${opts.icon ? `${esc(opts.icon)} ` : ""}${esc(opts.title)}
${props.length ? `- ${props.map(([k, v]) => `
- ${esc(k)}
- ${fmt(v)} `).join("")}
/** ProseMirror JSON → print-ready HTML (used by ?format=pdf). KaTeX rendered server-side; CSS + fonts inlined (CSP: font-src data:). */
import fs from "node:fs";
import path from "node:path";
import katex from "katex";
import type { PMNode } from "@/lib/editor/text";
import type { Resolvers } from "./markdown";
const esc = (s: unknown) => String(s ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
const safeHref = (h: unknown) => { const s = String(h ?? ""); return /^(https?:\/\/|mailto:|\/p\/|\/api\/files\/|\.{1,2}\/|#)/i.test(s) ? s : "#"; };
const tex = (latex: string, display: boolean) => { try { return katex.renderToString(latex, { displayMode: display, throwOnError: false, output: "htmlAndMathml" }); } catch { return `${esc(latex)}`; } };
export function docToHtml(doc: PMNode | null | undefined, r: Resolvers = {}): { html: string; hasMath: boolean } {
let hasMath = false;
const inline = (n: PMNode): string => {
if (n.type === "text") { let t = esc(n.text); for (const m of n.marks ?? []) { if (m.type === "bold") t = `${t}`; else if (m.type === "italic") t = `${t}`; else if (m.type === "code") t = `${t}`; else if (m.type === "strike") t = `${t}`; else if (m.type === "underline") t = `${t}`; else if (m.type === "highlight") t = `${t}`; else if (m.type === "subscript") t = `${t}`; else if (m.type === "superscript") t = `${t}`; else if (m.type === "link") { const pm = String(m.attrs?.href ?? "").match(/^\/p\/([0-9a-f-]{36})/); const href = pm && r.mention ? r.mention(pm[1], n.text ?? "") ?? m.attrs?.href : (r.file?.(String(m.attrs?.href ?? "")) ?? m.attrs?.href); t = `${t}`; } } return t; }
if (n.type === "hardBreak") return "
";
if (n.type === "mention") { const label = esc(n.attrs?.label ?? n.attrs?.id); const href = r.mention?.(String(n.attrs?.id ?? ""), String(n.attrs?.label ?? "")); return href ? `@${label}` : `@${label}`; }
if (n.type === "inlineMath" || n.type === "math") { hasMath = true; return tex(String(n.attrs?.latex ?? ""), false); }
if (n.type === "footnoteRef") return `${esc(n.attrs?.id)}`;
return (n.content ?? []).map(inline).join("");
};
const blocks = (ns: PMNode[] | undefined) => (ns ?? []).map(block).join("\n");
const block = (n: PMNode): string => {
switch (n.type) {
case "paragraph": return `
${inline(n)}
`; case "heading": { const l = Math.min(6, Math.max(1, Number(n.attrs?.level ?? 1))); return `${blocks(n.content)}`; case "codeBlock": return `
${esc((n.content ?? []).map((c) => c.text ?? "").join(""))}`;
case "bulletList": return `📎 ${esc(n.attrs?.name ?? "file")}${n.attrs?.size ? ` (${Math.round(Number(n.attrs.size) / 1024)} KB)` : ""}
`; } case "youtube": return ``; case "blockMath": hasMath = true; return `${esc(n.attrs?.id)} ${(n.content ?? []).map(inline).join(" ")}
`; default: return n.content ? blocks(n.content) : n.text ? `${esc(n.text)}
` : ""; } }; const html = blocks(doc?.content); return { html, hasMath }; } let katexCssCache: string | null = null; /** katex.min.css with the woff2 fonts inlined as data: URIs (works under the app CSP, no external requests). */ function katexCss(): string { if (katexCssCache) return katexCssCache; try { const dir = path.join(process.cwd(), "node_modules", "katex", "dist"); let css = fs.readFileSync(path.join(dir, "katex.min.css"), "utf8"); css = css.replace(/url\(fonts\/([^)]+?\.woff2)\)\s*format\("woff2"\),?/g, (_m, f) => { try { const b = fs.readFileSync(path.join(dir, "fonts", f)); return `url(data:font/woff2;base64,${b.toString("base64")}) format("woff2"),`; } catch { return ""; } }); css = css.replace(/,?\s*url\(fonts\/[^)]+?\.(woff|ttf)\)\s*format\("(woff|truetype)"\)/g, ""); katexCssCache = css; } catch { katexCssCache = ""; } return katexCssCache; } const PRINT_CSS = ` :root{--fg:#111;--muted:#666;--line:#ddd;--sunk:#f5f5f4;--accent:#2563eb} *{box-sizing:border-box}html{-webkit-print-color-adjust:exact;print-color-adjust:exact} body{margin:0;background:#fff;color:var(--fg);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif} main{max-width:760px;margin:0 auto;padding:40px 28px 60px} header.doc h1{font-size:30px;line-height:1.2;margin:0 0 6px;letter-spacing:-.01em} header.doc .meta{color:var(--muted);font-size:12.5px;display:flex;flex-wrap:wrap;gap:6px 14px;margin-bottom:8px} header.doc .props{display:grid;grid-template-columns:max-content 1fr;gap:3px 14px;font-size:13px;margin:10px 0 0;padding:10px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)} header.doc .props dt{color:var(--muted)}header.doc .props dd{margin:0} h1,h2,h3,h4{line-height:1.25;margin:1.5em 0 .5em;page-break-after:avoid}h1{font-size:24px}h2{font-size:20px}h3{font-size:17px}h4{font-size:15px} p{margin:.6em 0}a{color:var(--accent);text-decoration:none}a.mention{background:var(--sunk);padding:0 4px;border-radius:4px;color:inherit} code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--sunk);padding:1px 4px;border-radius:4px} pre{background:var(--sunk);border:1px solid var(--line);border-radius:8px;padding:12px 14px;overflow-x:auto;page-break-inside:avoid}pre code{background:none;padding:0} blockquote{margin:1em 0;padding:2px 16px;border-left:3px solid var(--line);color:#333} table{border-collapse:collapse;width:100%;margin:1em 0;font-size:13.5px;page-break-inside:avoid}th,td{border:1px solid var(--line);padding:6px 9px;vertical-align:top;text-align:left}th{background:var(--sunk)}td p,th p{margin:0} ul,ol{padding-left:1.5em}li>p{margin:.2em 0}ul.tasks{list-style:none;padding-left:.2em}ul.tasks li{display:flex;gap:8px;align-items:flex-start}ul.tasks li.done{color:var(--muted);text-decoration:line-through}ul.tasks input{margin-top:6px} figure{margin:1.2em 0;text-align:center;page-break-inside:avoid}figure img{max-width:100%;height:auto;border-radius:6px}figcaption{color:var(--muted);font-size:12.5px;margin-top:4px} hr{border:0;border-top:1px solid var(--line);margin:2em 0}mark{background:#fef08a} .callout{display:flex;gap:10px;padding:12px 14px;border-radius:8px;background:var(--sunk);border:1px solid var(--line);margin:1em 0;page-break-inside:avoid}.callout-warning{background:#fef3c7}.callout-danger{background:#fee2e2}.callout-info{background:#dbeafe}.callout>div>p:first-child{margin-top:0}.callout>div>p:last-child{margin-bottom:0} .ai-block{border-left:3px solid #8b5cf6;padding-left:12px;margin:1em 0}.math-block{margin:1em 0;overflow-x:auto} .file{background:var(--sunk);border:1px solid var(--line);border-radius:6px;padding:6px 10px} details{border:1px solid var(--line);border-radius:6px;padding:6px 12px;margin:1em 0}summary{font-weight:600;cursor:pointer} .footnote{font-size:13px;color:#333}footer{margin-top:40px;padding-top:10px;border-top:1px solid var(--line);color:var(--muted);font-size:12px} .toolbar{position:fixed;top:12px;right:12px;display:flex;gap:8px}.toolbar button{font:13px/1 inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);background:#fff;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.08)} @page{margin:18mm 16mm} @media print{.toolbar{display:none}main{max-width:none;padding:0}a{color:inherit}a[href^="http"]:after{content:" (" attr(href) ")";font-size:85%;color:var(--muted)}} `; export function printHtml(opts: { title: string; icon?: string | null; meta: string[]; props: Record