206 lines
6.8 KiB
JavaScript
206 lines
6.8 KiB
JavaScript
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 <prepare|render> <input.md> <work-dir> [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>${code}</code>`)
|
|
return `\u0000${tokens.length - 1}\u0000`
|
|
})
|
|
value = value.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
value = value.replace(/\[(.+?)\]\((https?:[^)]+)\)/g, '<a href="$2">$1</a>')
|
|
|
|
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(`<div class="mermaid-diagram">${svg}</div>`)
|
|
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(`<pre><code class="language-${escapeHtml(language)}">${escapeHtml(code.join('\n'))}</code></pre>`)
|
|
continue
|
|
}
|
|
|
|
const heading = line.match(/^(#{1,6})\s+(.+)$/)
|
|
if (heading) {
|
|
const level = heading[1].length
|
|
output.push(`<h${level}>${inline(heading[2])}</h${level}>`)
|
|
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(`<blockquote>${inline(quote.join('<br>'))}</blockquote>`)
|
|
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(`<table><thead><tr>${header.map((cell) => `<th>${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table>`)
|
|
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
|
|
? `<span class="checkbox">${task[1].trim() ? '☑' : '☐'}</span> ${inline(task[2])}`
|
|
: inline(match[1])
|
|
items.push(`<li>${content}</li>`)
|
|
i += 1
|
|
}
|
|
output.push(`<${tag}>${items.join('')}</${tag}>`)
|
|
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(`<p>${inline(paragraph.join(' '))}</p>`)
|
|
}
|
|
|
|
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 = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Markdown PDF</title>
|
|
<style>
|
|
@page { size: A4; margin: 18mm 15mm; }
|
|
* { box-sizing: border-box; }
|
|
body { color: #1f2937; font: 10.5pt/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; }
|
|
h1 { margin: 0 0 8px; color: #0f4c81; font-size: 24pt; }
|
|
h2 { margin-top: 26px; border-bottom: 1px solid #dbe4ef; padding-bottom: 5px; color: #123b62; font-size: 16pt; }
|
|
h3 { margin-top: 18px; color: #174f84; font-size: 12pt; }
|
|
p, li, table, .mermaid-diagram { break-inside: avoid; }
|
|
table { width: 100%; margin: 12px 0; border-collapse: collapse; font-size: 9.5pt; }
|
|
th { background: #eaf3fb; color: #123b62; font-weight: 600; }
|
|
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; text-align: left; vertical-align: top; }
|
|
code { border-radius: 3px; background: #f1f5f9; padding: 1px 4px; color: #a33d10; font-family: "SFMono-Regular", Consolas, monospace; }
|
|
pre { overflow-wrap: anywhere; border: 1px solid #dbe4ef; border-radius: 4px; background: #f8fafc; padding: 10px; white-space: pre-wrap; font-size: 8.8pt; }
|
|
pre code { background: transparent; padding: 0; color: #1f2937; }
|
|
blockquote { margin: 10px 0; border-left: 3px solid #67a6d8; padding-left: 12px; color: #475569; }
|
|
.mermaid-diagram { margin: 14px 0; text-align: center; }
|
|
.mermaid-diagram svg { max-width: 100%; height: auto; }
|
|
.checkbox { color: #0f4c81; }
|
|
a { color: #0f4c81; }
|
|
</style>
|
|
</head>
|
|
<body>${body}</body>
|
|
</html>`
|
|
|
|
await writeFile(outputPath, html)
|