import { readFile, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' const [mode, inputPath, workDir, outputPath] = process.argv.slice(2) if (!['prepare', 'render'].includes(mode) || !inputPath || !workDir) { console.error('Usage: render-markdown-pdf.mjs [output.html]') process.exit(1) } const markdown = await readFile(inputPath, 'utf8') function escapeHtml(value) { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } function inline(text) { const tokens = [] let value = escapeHtml(text) value = value.replace(/`([^`]+)`/g, (_, code) => { tokens.push(`${code}`) return `\u0000${tokens.length - 1}\u0000` }) value = value.replace(/\*\*(.+?)\*\*/g, '$1') value = value.replace(/\[(.+?)\]\((https?:[^)]+)\)/g, '$1') return value.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)]) } function isTableDivider(line) { return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line) } function cells(line) { return line .trim() .replace(/^\|/, '') .replace(/\|$/, '') .split('|') .map((cell) => inline(cell.trim())) } function renderMarkdown(source, diagrams) { const diagramPattern = /```mermaid\n[\s\S]*?\n```/g let index = 0 const prepared = source.replace(diagramPattern, () => `@@MERMAID_${index++}@@`) const lines = prepared.replace(/\r\n/g, '\n').split('\n') const output = [] for (let i = 0; i < lines.length;) { const line = lines[i] if (!line.trim()) { i += 1 continue } const diagram = line.match(/^@@MERMAID_(\d+)@@$/) if (diagram) { const svg = diagrams[Number(diagram[1])] || '' output.push(`
${svg}
`) i += 1 continue } const fence = line.match(/^```([^`]*)$/) if (fence) { const language = fence[1].trim() const code = [] i += 1 while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]) i += 1 } if (i < lines.length) i += 1 output.push(`
${escapeHtml(code.join('\n'))}
`) continue } const heading = line.match(/^(#{1,6})\s+(.+)$/) if (heading) { const level = heading[1].length output.push(`${inline(heading[2])}`) i += 1 continue } if (line.startsWith('> ')) { const quote = [] while (i < lines.length && lines[i].startsWith('> ')) { quote.push(lines[i].slice(2)) i += 1 } output.push(`
${inline(quote.join('
'))}
`) continue } if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1])) { const header = cells(line) const rows = [] i += 2 while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { rows.push(cells(lines[i])) i += 1 } output.push(`${header.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${cell}
${cell}
`) continue } const unordered = line.match(/^[-*]\s+(.*)$/) const ordered = line.match(/^\d+\.\s+(.*)$/) if (unordered || ordered) { const tag = ordered ? 'ol' : 'ul' const items = [] while (i < lines.length) { const match = tag === 'ol' ? lines[i].match(/^\d+\.\s+(.*)$/) : lines[i].match(/^[-*]\s+(.*)$/) if (!match) break const task = match[1].match(/^\[([ xX])\]\s+(.*)$/) const content = task ? `${task[1].trim() ? '☑' : '☐'} ${inline(task[2])}` : inline(match[1]) items.push(`
  • ${content}
  • `) i += 1 } output.push(`<${tag}>${items.join('')}`) continue } const paragraph = [line.trim()] i += 1 while ( i < lines.length && lines[i].trim() && !/^(#{1,6})\s+/.test(lines[i]) && !/^```/.test(lines[i]) && !/^@@MERMAID_\d+@@$/.test(lines[i]) && !/^[-*]\s+/.test(lines[i]) && !/^\d+\.\s+/.test(lines[i]) && !lines[i].startsWith('> ') && !(lines[i].includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1])) ) { paragraph.push(lines[i].trim()) i += 1 } output.push(`

    ${inline(paragraph.join(' '))}

    `) } return output.join('\n') } if (mode === 'prepare') { const diagrams = [...markdown.matchAll(/```mermaid\n([\s\S]*?)\n```/g)].map((match) => match[1]) await Promise.all(diagrams.map((diagram, index) => writeFile(path.join(workDir, `diagram-${index}.mmd`), diagram))) process.exit(0) } if (!outputPath) { console.error('An HTML output path is required for render mode.') process.exit(1) } const svgFiles = (await readdir(workDir)) .filter((file) => /^diagram-\d+\.svg$/.test(file)) .sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])) const diagrams = await Promise.all(svgFiles.map((file) => readFile(path.join(workDir, file), 'utf8'))) const body = renderMarkdown(markdown, diagrams) const html = ` Markdown PDF ${body} ` await writeFile(outputPath, html)