#!/usr/bin/env npx tsx import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import frontMatter from "front-matter"; import hljs from "highlight.js/lib/core"; import { marked, type RendererObject, type Tokens } from "marked"; import readingTime, { type ReadTimeResults } from "reading-time"; import { markedAlert, markedFootnotes, markedInfographic, markedMarkup, markedPlantUML, markedRuby, markedSlider, markedToc, MDKatex, } from "./extensions/index.js"; import { COMMON_LANGUAGES, highlightAndFormatCode, } from "./utils/languages.js"; type ThemeName = "default" | "grace" | "simple"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const THEME_DIR = path.resolve(SCRIPT_DIR, "themes"); const THEME_NAMES: ThemeName[] = ["default", "grace", "simple"]; const DEFAULT_STYLE = { primaryColor: "#0F4C81", fontFamily: "-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif", fontSize: "16px", foreground: "0 0% 3.9%", blockquoteBackground: "#f7f7f7", }; Object.entries(COMMON_LANGUAGES).forEach(([name, lang]) => { hljs.registerLanguage(name, lang); }); export { hljs }; marked.setOptions({ breaks: true, }); marked.use(markedSlider()); interface IOpts { legend?: string; citeStatus?: boolean; countStatus?: boolean; isMacCodeBlock?: boolean; isShowLineNumber?: boolean; themeMode?: "light" | "dark"; } interface RendererAPI { reset: (newOpts: Partial) => void; setOptions: (newOpts: Partial) => void; getOpts: () => IOpts; parseFrontMatterAndContent: (markdown: string) => { yamlData: Record; markdownContent: string; readingTime: ReadTimeResults; }; buildReadingTime: (reading: ReadTimeResults) => string; buildFootnotes: () => string; buildAddition: () => string; createContainer: (html: string) => string; } interface ParseResult { yamlData: Record; markdownContent: string; readingTime: ReadTimeResults; } function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/`/g, "`"); } function buildAddition(): string { return ` `; } function buildFootnoteArray(footnotes: [number, string, string][]): string { return footnotes .map(([index, title, link]) => link === title ? `[${index}]: ${title}
` : `[${index}] ${title}: ${link}
` ) .join("\n"); } function transform(legend: string, text: string | null, title: string | null): string { const options = legend.split("-"); for (const option of options) { if (option === "alt" && text) { return text; } if (option === "title" && title) { return title; } } return ""; } const macCodeSvg = ` `.trim(); function parseFrontMatterAndContent(markdownText: string): ParseResult { try { const parsed = frontMatter(markdownText); const yamlData = parsed.attributes; const markdownContent = parsed.body; const readingTimeResult = readingTime(markdownContent); return { yamlData: yamlData as Record, markdownContent, readingTime: readingTimeResult, }; } catch (error) { console.error("Error parsing front-matter:", error); return { yamlData: {}, markdownContent: markdownText, readingTime: readingTime(markdownText), }; } } export function initRenderer(opts: IOpts = {}): RendererAPI { const footnotes: [number, string, string][] = []; let footnoteIndex = 0; let codeIndex = 0; const listOrderedStack: boolean[] = []; const listCounters: number[] = []; const isBrowser = typeof window !== "undefined"; function getOpts(): IOpts { return opts; } function styledContent(styleLabel: string, content: string, tagName?: string): string { const tag = tagName ?? styleLabel; const className = `${styleLabel.replace(/_/g, "-")}`; const headingAttr = /^h\d$/.test(tag) ? " data-heading=\"true\"" : ""; return `<${tag} class="${className}"${headingAttr}>${content}`; } function addFootnote(title: string, link: string): number { const existingFootnote = footnotes.find(([, , existingLink]) => existingLink === link); if (existingFootnote) { return existingFootnote[0]; } footnotes.push([++footnoteIndex, title, link]); return footnoteIndex; } function reset(newOpts: Partial): void { footnotes.length = 0; footnoteIndex = 0; setOptions(newOpts); } function setOptions(newOpts: Partial): void { opts = { ...opts, ...newOpts }; marked.use(markedAlert()); if (isBrowser) { marked.use(MDKatex({ nonStandard: true }, true)); } marked.use(markedMarkup()); marked.use(markedInfographic({ themeMode: opts.themeMode })); } function buildReadingTime(readingTimeResult: ReadTimeResults): string { if (!opts.countStatus) { return ""; } if (!readingTimeResult.words) { return ""; } return `

字数 ${readingTimeResult?.words},阅读大约需 ${Math.ceil(readingTimeResult?.minutes)} 分钟

`; } const buildFootnotes = () => { if (!footnotes.length) { return ""; } return ( styledContent("h4", "引用链接") + styledContent("footnotes", buildFootnoteArray(footnotes), "p") ); }; const renderer: RendererObject = { heading({ tokens, depth }: Tokens.Heading) { const text = this.parser.parseInline(tokens); const tag = `h${depth}`; return styledContent(tag, text); }, paragraph({ tokens }: Tokens.Paragraph): string { const text = this.parser.parseInline(tokens); const isFigureImage = text.includes(" { const windowRef = typeof window !== "undefined" ? (window as any) : undefined; if (windowRef && windowRef.mermaid) { const mermaid = windowRef.mermaid; await mermaid.run(); } else { const mermaid = await import("mermaid"); await mermaid.default.run(); } }, 0) as any as number; } return `
${text}
`; } const langText = lang.split(" ")[0]; const isLanguageRegistered = hljs.getLanguage(langText); const language = isLanguageRegistered ? langText : "plaintext"; const highlighted = highlightAndFormatCode( text, language, hljs, !!opts.isShowLineNumber ); const span = `${macCodeSvg}`; let pendingAttr = ""; if (!isLanguageRegistered && langText !== "plaintext") { const escapedText = text.replace(/"/g, """); pendingAttr = ` data-language-pending="${langText}" data-raw-code="${escapedText}" data-show-line-number="${opts.isShowLineNumber}"`; } const code = `${highlighted}`; return `
${span}${code}
`; }, codespan({ text }: Tokens.Codespan): string { const escapedText = escapeHtml(text); return styledContent("codespan", escapedText, "code"); }, list({ ordered, items, start = 1 }: Tokens.List) { listOrderedStack.push(ordered); listCounters.push(Number(start)); const html = items.map((item) => this.listitem(item)).join(""); listOrderedStack.pop(); listCounters.pop(); return styledContent(ordered ? "ol" : "ul", html); }, listitem(token: Tokens.ListItem) { const ordered = listOrderedStack[listOrderedStack.length - 1]; const idx = listCounters[listCounters.length - 1]!; listCounters[listCounters.length - 1] = idx + 1; const prefix = ordered ? `${idx}. ` : "• "; let content: string; try { content = this.parser.parseInline(token.tokens); } catch { content = this.parser .parse(token.tokens) .replace(/^]*)?>([\s\S]*?)<\/p>/, "$1"); } return styledContent("listitem", `${prefix}${content}`, "li"); }, image({ href, title, text }: Tokens.Image): string { const newText = opts.legend ? transform(opts.legend, text, title) : ""; const subText = newText ? styledContent("figcaption", newText) : ""; const titleAttr = title ? ` title="${title}"` : ""; return `
${text}${subText}
`; }, link({ href, title, text, tokens }: Tokens.Link): string { const parsedText = this.parser.parseInline(tokens); if (/^https?:\/\/mp\.weixin\.qq\.com/.test(href)) { return `${parsedText}`; } if (href === text) { return parsedText; } if (opts.citeStatus) { const ref = addFootnote(title || text, href); return `${parsedText}[${ref}]`; } return `${parsedText}`; }, strong({ tokens }: Tokens.Strong): string { return styledContent("strong", this.parser.parseInline(tokens)); }, em({ tokens }: Tokens.Em): string { return styledContent("em", this.parser.parseInline(tokens)); }, table({ header, rows }: Tokens.Table): string { const headerRow = header .map((cell) => { const text = this.parser.parseInline(cell.tokens); return styledContent("th", text); }) .join(""); const body = rows .map((row) => { const rowContent = row.map((cell) => this.tablecell(cell)).join(""); return styledContent("tr", rowContent); }) .join(""); return `
${headerRow}${body}
`; }, tablecell(token: Tokens.TableCell): string { const text = this.parser.parseInline(token.tokens); return styledContent("td", text); }, hr(_: Tokens.Hr): string { return styledContent("hr", ""); }, }; marked.use({ renderer }); marked.use(markedMarkup()); marked.use(markedToc()); marked.use(markedSlider()); marked.use(markedAlert({})); if (isBrowser) { marked.use(MDKatex({ nonStandard: true }, true)); } marked.use(markedFootnotes()); marked.use( markedPlantUML({ inlineSvg: isBrowser, }) ); marked.use(markedInfographic()); marked.use(markedRuby()); return { buildAddition, buildFootnotes, setOptions, reset, parseFrontMatterAndContent, buildReadingTime, createContainer(content: string) { return styledContent("container", content, "section"); }, getOpts, }; } function printUsage(): void { console.error( [ "Usage:", " npx tsx src/md/render.ts [--theme ]", "", "Options:", ` --theme Theme name (${THEME_NAMES.join(", ")})`, ].join("\n") ); } function parseArgs(argv: string[]): CliOptions | null { let inputPath = ""; let theme: ThemeName = "default"; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith("--") && !inputPath) { inputPath = arg; continue; } if (arg === "--theme") { theme = (argv[i + 1] || "") as ThemeName; i += 1; continue; } if (arg.startsWith("--theme=")) { theme = arg.slice("--theme=".length) as ThemeName; continue; } if (arg === "--help" || arg === "-h") { return null; } console.error(`Unknown argument: ${arg}`); return null; } if (!inputPath) { return null; } if (!THEME_NAMES.includes(theme)) { console.error(`Unknown theme: ${theme}`); return null; } return { inputPath, theme, }; } interface CliOptions { inputPath: string; theme: ThemeName; } function renderMarkdown(raw: string, renderer: RendererAPI): { html: string; readingTime: ReadTimeResults; } { const { markdownContent, readingTime: readingTimeResult } = renderer.parseFrontMatterAndContent(raw); const html = marked.parse(markdownContent) as string; return { html, readingTime: readingTimeResult }; } function postProcessHtml( baseHtml: string, reading: ReadTimeResults, renderer: RendererAPI ): string { let html = baseHtml; html = renderer.buildReadingTime(reading) + html; html += renderer.buildFootnotes(); html += renderer.buildAddition(); html += ` `; html += ` `; return renderer.createContainer(html); } function formatTimestamp(date = new Date()): string { const pad = (value: number) => String(value).padStart(2, "0"); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad( date.getDate() )}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } function ensureMarkdownPath(inputPath: string): void { if (!inputPath.toLowerCase().endsWith(".md")) { throw new Error("Input file must end with .md"); } } function loadThemeCss(theme: ThemeName): { baseCss: string; themeCss: string; } { const basePath = path.join(THEME_DIR, "base.css"); const themePath = path.join(THEME_DIR, `${theme}.css`); if (!fs.existsSync(basePath)) { throw new Error(`Missing base CSS: ${basePath}`); } if (!fs.existsSync(themePath)) { throw new Error(`Missing theme CSS: ${themePath}`); } return { baseCss: fs.readFileSync(basePath, "utf-8"), themeCss: fs.readFileSync(themePath, "utf-8"), }; } function buildCss(baseCss: string, themeCss: string): string { const variables = ` :root { --md-primary-color: ${DEFAULT_STYLE.primaryColor}; --md-font-family: ${DEFAULT_STYLE.fontFamily}; --md-font-size: ${DEFAULT_STYLE.fontSize}; --foreground: ${DEFAULT_STYLE.foreground}; --blockquote-background: ${DEFAULT_STYLE.blockquoteBackground}; } body { margin: 0; padding: 24px; background: #ffffff; } #output { max-width: 860px; margin: 0 auto; } `.trim(); return [variables, baseCss, themeCss].join("\n\n"); } function buildHtmlDocument(title: string, css: string, html: string): string { return [ "", "", "", ' ', ' ', ` ${title}`, ` `, "", "", '
', html, "
", "", "", ].join("\n"); } function main(): void { const options = parseArgs(process.argv.slice(2)); if (!options) { printUsage(); process.exit(1); } const inputPath = path.resolve(process.cwd(), options.inputPath); ensureMarkdownPath(inputPath); if (!fs.existsSync(inputPath)) { console.error(`File not found: ${inputPath}`); process.exit(1); } const outputPath = path.resolve( process.cwd(), options.inputPath.replace(/\.md$/i, ".html") ); const { baseCss, themeCss } = loadThemeCss(options.theme); const css = buildCss(baseCss, themeCss); const markdown = fs.readFileSync(inputPath, "utf-8"); const renderer = initRenderer({}); const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown( markdown, renderer ); const content = postProcessHtml(baseHtml, readingTimeResult, renderer); const title = path.basename(outputPath, ".html"); const html = buildHtmlDocument(title, css, content); let backupPath = ""; if (fs.existsSync(outputPath)) { backupPath = `${outputPath}.bak-${formatTimestamp()}`; fs.renameSync(outputPath, backupPath); } fs.writeFileSync(outputPath, html, "utf-8"); if (backupPath) { console.log(`Backup created: ${backupPath}`); } console.log(`HTML written: ${outputPath}`); } main();