code icon Code

Render Letter HTML

Generate a beautifully typeset single-file HTML page from content

Source Code

import fs from "fs";
import { marked } from "marked";

const [
  title,
  contentPath,
  style = "modern",
  author,
  date,
  imageUrl,
  outputPath,
] = process.argv.slice(2);

if (!title || !contentPath || !outputPath) {
  console.error("Error: title, contentPath, and outputPath are required");
  process.exit(1);
}

if (!fs.existsSync(contentPath)) {
  console.error(`Error: Content file not found: ${contentPath}`);
  process.exit(1);
}

const content = fs.readFileSync(contentPath, "utf-8");

// Convert markdown to HTML
const contentHtml = marked.parse(content);

// Extract first paragraph for meta description
const firstParagraph = content
  .split("\n\n")[0]
  .replace(/[#*_`\[\]]/g, "")
  .slice(0, 160);

// Typography styles
const styles = {
  modern: {
    name: "Modern",
    fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
    headingFont: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
    fontSize: "clamp(1.05rem, 1vw + 0.9rem, 1.2rem)",
    lineHeight: "1.7",
    maxWidth: "720px",
    background: "#ffffff",
    backgroundDark: "#0f0f0f",
    text: "#1a1a1a",
    textDark: "#e5e5e5",
    textMuted: "#666666",
    textMutedDark: "#999999",
    accent: "#2563eb",
    headingWeight: "700",
    paragraphSpacing: "1.5em",
  },
  classic: {
    name: "Classic",
    fontFamily: "Georgia, 'Times New Roman', serif",
    headingFont: "Georgia, 'Times New Roman', serif",
    fontSize: "clamp(1.1rem, 1vw + 0.95rem, 1.25rem)",
    lineHeight: "1.85",
    maxWidth: "680px",
    background: "#faf8f5",
    backgroundDark: "#1a1816",
    text: "#2c2825",
    textDark: "#e8e4df",
    textMuted: "#6b6560",
    textMutedDark: "#9a958f",
    accent: "#8b4513",
    headingWeight: "400",
    paragraphSpacing: "1.6em",
  },
  serif: {
    name: "Serif",
    fontFamily: "Palatino, 'Palatino Linotype', Georgia, serif",
    headingFont: "Palatino, 'Palatino Linotype', Georgia, serif",
    fontSize: "clamp(1.08rem, 1vw + 0.92rem, 1.22rem)",
    lineHeight: "1.8",
    maxWidth: "700px",
    background: "#fcfcfa",
    backgroundDark: "#141413",
    text: "#1f1f1f",
    textDark: "#ececea",
    textMuted: "#5a5a58",
    textMutedDark: "#a5a5a3",
    accent: "#4a5568",
    headingWeight: "600",
    paragraphSpacing: "1.55em",
  },
};

const s = styles[style] || styles.modern;

// Generate HTML
const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${escapeHtml(title)}</title>
  <meta name="description" content="${escapeHtml(firstParagraph)}">
  ${imageUrl ? `<meta property="og:image" content="${escapeHtml(imageUrl)}">` : ""}
  <meta property="og:title" content="${escapeHtml(title)}">
  <meta property="og:description" content="${escapeHtml(firstParagraph)}">
  <meta property="og:type" content="article">
  ${author ? `<meta name="author" content="${escapeHtml(author)}">` : ""}
  <style>
    *, *::before, *::after {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    :root {
      --font-body: ${s.fontFamily};
      --font-heading: ${s.headingFont};
      --font-size: ${s.fontSize};
      --line-height: ${s.lineHeight};
      --max-width: ${s.maxWidth};
      --bg: ${s.background};
      --text: ${s.text};
      --text-muted: ${s.textMuted};
      --accent: ${s.accent};
      --heading-weight: ${s.headingWeight};
      --paragraph-spacing: ${s.paragraphSpacing};
    }

    @media (prefers-color-scheme: dark) {
      :root {
        --bg: ${s.backgroundDark};
        --text: ${s.textDark};
        --text-muted: ${s.textMutedDark};
      }
    }

    html {
      font-size: 16px;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
    }

    body {
      font-family: var(--font-body);
      font-size: var(--font-size);
      line-height: var(--line-height);
      color: var(--text);
      background: var(--bg);
      min-height: 100vh;
      padding: 0;
    }

    .container {
      max-width: var(--max-width);
      margin: 0 auto;
      padding: clamp(2rem, 8vw, 5rem) clamp(1.25rem, 5vw, 2rem);
    }

    /* Header image */
    .header-image {
      width: 100%;
      max-height: 400px;
      object-fit: cover;
      margin-bottom: 2.5rem;
      border-radius: 4px;
    }

    /* Title and meta */
    header {
      margin-bottom: 3rem;
      text-align: center;
    }

    h1 {
      font-family: var(--font-heading);
      font-size: clamp(2rem, 5vw, 3rem);
      font-weight: var(--heading-weight);
      line-height: 1.2;
      margin-bottom: 1rem;
      letter-spacing: -0.02em;
    }

    .meta {
      color: var(--text-muted);
      font-size: 0.95em;
    }

    .meta span + span::before {
      content: "·";
      margin: 0 0.5em;
    }

    /* Content */
    .content {
      text-align: left;
    }

    .content p {
      margin-bottom: var(--paragraph-spacing);
      text-align: justify;
      hyphens: auto;
      -webkit-hyphens: auto;
    }

    .content h2 {
      font-family: var(--font-heading);
      font-size: 1.5em;
      font-weight: var(--heading-weight);
      margin: 2.5rem 0 1rem;
      letter-spacing: -0.01em;
    }

    .content h3 {
      font-family: var(--font-heading);
      font-size: 1.25em;
      font-weight: var(--heading-weight);
      margin: 2rem 0 0.75rem;
    }

    .content ul, .content ol {
      margin-bottom: var(--paragraph-spacing);
      padding-left: 1.5em;
    }

    .content li {
      margin-bottom: 0.5em;
    }

    .content blockquote {
      border-left: 3px solid var(--accent);
      margin: 1.5rem 0;
      padding-left: 1.5rem;
      font-style: italic;
      color: var(--text-muted);
    }

    .content a {
      color: var(--accent);
      text-decoration: underline;
      text-underline-offset: 2px;
    }

    .content a:hover {
      text-decoration-thickness: 2px;
    }

    .content code {
      font-family: ui-monospace, 'SF Mono', Monaco, monospace;
      font-size: 0.9em;
      background: rgba(128, 128, 128, 0.1);
      padding: 0.15em 0.4em;
      border-radius: 4px;
    }

    .content pre {
      background: rgba(128, 128, 128, 0.1);
      padding: 1rem 1.25rem;
      border-radius: 6px;
      overflow-x: auto;
      margin-bottom: var(--paragraph-spacing);
    }

    .content pre code {
      background: none;
      padding: 0;
    }

    .content hr {
      border: none;
      border-top: 1px solid var(--text-muted);
      opacity: 0.3;
      margin: 2.5rem 0;
    }

    .content img {
      max-width: 100%;
      height: auto;
      border-radius: 4px;
      margin: 1.5rem 0;
    }

    /* First paragraph drop cap for classic style */
    ${style === "classic" ? `
    .content > p:first-of-type::first-letter {
      float: left;
      font-size: 3.5em;
      line-height: 0.8;
      padding-right: 0.1em;
      font-weight: 400;
    }
    ` : ""}

    /* Print styles */
    @media print {
      body {
        background: white;
        color: black;
      }
      .container {
        padding: 1rem;
        max-width: 100%;
      }
      .header-image {
        max-height: 200px;
      }
    }

    /* Responsive adjustments */
    @media (max-width: 600px) {
      .content p {
        text-align: left;
        hyphens: none;
      }
      h1 {
        text-align: left;
      }
      header {
        text-align: left;
      }
    }
  </style>
</head>
<body>
  <article class="container">
    ${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="header-image">` : ""}
    <header>
      <h1>${escapeHtml(title)}</h1>
      ${(author || date) ? `
      <div class="meta">
        ${author ? `<span>${escapeHtml(author)}</span>` : ""}
        ${date ? `<span>${escapeHtml(date)}</span>` : ""}
      </div>
      ` : ""}
    </header>
    <div class="content">
      ${contentHtml}
    </div>
  </article>
</body>
</html>`;

function escapeHtml(text) {
  if (!text) return "";
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

// Ensure output directory exists
const outputDir = outputPath.substring(0, outputPath.lastIndexOf("/"));
if (outputDir && !fs.existsSync(outputDir)) {
  fs.mkdirSync(outputDir, { recursive: true });
}

// Write the file
fs.writeFileSync(outputPath, html, "utf-8");

const fileSize = Buffer.byteLength(html, "utf-8");
console.log(`Rendered letter with "${s.name}" style`);
console.log(`  Title: ${title}`);
console.log(`  Style: ${style}`);
console.log(`  Size: ${(fileSize / 1024).toFixed(1)} KB`);
console.log(`  Output: ${outputPath}`);
if (author) console.log(`  Author: ${author}`);
if (date) console.log(`  Date: ${date}`);
if (imageUrl) console.log(`  Header image: ${imageUrl}`);

console.log("\n--- RESULT ---");
console.log(JSON.stringify({
  success: true,
  outputPath,
  title,
  style,
  size: fileSize,
  hasImage: !!imageUrl,
}, null, 2));