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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 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));