// src/lib/pdf.ts import fs from "node:fs"; import path from "node:path"; import GithubSlugger from "github-slugger"; import { marked } from "marked"; import markedAlert from "marked-alert"; import markedKatex from "marked-katex-extension"; import puppeteer from "puppeteer"; import type { ThemeKey } from "@/lib/themes"; import { buildThemeCss, getHighlightThemeForDocumentTheme, getMermaidThemeForDocumentTheme, getWrapperClass, } from "@/lib/themes"; import { alertCss } from "./alert-css"; import { katexCss } from "./katex-css"; import { loadHljsCss } from "./highlightCss"; import { markdownBaseCss } from "./markdown-base.css"; const DEFAULT_TITLE = "Binderly"; export type RenderMarkdownToPdfOptions = { markdown: string; theme: ThemeKey; customCss?: string; metadata?: { title?: string; author?: string; }; roundedCorners?: boolean; showToc?: boolean; }; // Resolve mermaid's UMD bundle from disk at runtime. We use process.cwd() rather than // import.meta.url / createRequire because Next.js bundles this file into .next/server/... // and rewrites import.meta.url, which previously caused EBADF on production builds. let mermaidScriptPathCache: string | null | undefined; function findMermaidScriptPath(): string | null { if (mermaidScriptPathCache !== undefined) return mermaidScriptPathCache; const candidates = [ path.join(process.cwd(), "node_modules", "mermaid", "dist", "mermaid.min.js"), path.join(process.cwd(), "..", "node_modules", "mermaid", "dist", "mermaid.min.js"), ]; for (const candidate of candidates) { try { if (fs.statSync(candidate).isFile()) { mermaidScriptPathCache = candidate; return candidate; } } catch { // not found at this path, try next } } mermaidScriptPathCache = null; return null; } // Whether the current render targets a Primer (`.markdown-body`) theme. // Read by the marked code renderer to decide whether to emit our custom // language-label/copy wrapper or stay native (matching GitHub's own rendering). let currentRenderIsPrimer = false; let markedConfigured = false; function configureMarkedOnce() { if (markedConfigured) return; marked.setOptions({ breaks: true, gfm: true }); marked.use(markedAlert()); marked.use(markedKatex({ throwOnError: false, nonStandard: true })); marked.use({ renderer: { code({ text, lang }) { const language = (lang ?? "").trim(); if (language === "mermaid") { // Use a
 so mermaid.run() can find it.
          return `
${escapeHtml(text)}
\n`; } const escaped = escapeHtml(text); const langClass = language ? ` class="language-${language}"` : ""; if (currentRenderIsPrimer) { // Match GitHub: native
 with no header chrome.
          return `
${escaped}
\n`; } const langLabel = language || "text"; return `
${escapeHtml(langLabel)}
${escaped}
\n`; }, }, }); markedConfigured = true; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function addHeadingIds(html: string): { html: string; toc: Array<{ level: number; text: string; slug: string }>; } { const slugger = new GithubSlugger(); const toc: Array<{ level: number; text: string; slug: string }> = []; const updated = html.replace( /([\s\S]*?)<\/h\1>/g, (_match, levelStr: string, inner: string) => { const level = Number(levelStr); const textOnly = inner.replace(/<[^>]+>/g, "").trim(); if (!textOnly) return _match; const slug = slugger.slug(textOnly); toc.push({ level, text: textOnly, slug }); return `${inner}`; }, ); return { html: updated, toc }; } function buildTocHtml( toc: Array<{ level: number; text: string; slug: string }>, ): string { if (toc.length === 0) return ""; const items = toc .map( (h) => `
  • ${escapeHtml(h.text)}
  • `, ) .join(""); return ``; } const pdfTocCss = ` .pdf-toc { margin: 0 0 32px; padding: 16px 20px; border: 1px solid var(--md-border, #d0d7de); border-radius: 6px; break-inside: avoid; page-break-inside: avoid; } .pdf-toc h2 { margin: 0 0 8px !important; padding: 0 !important; border: 0 !important; font-size: 14px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--md-muted, #656d76); } .pdf-toc ul { list-style: none; margin: 0; padding: 0; } .pdf-toc li { margin: 2px 0; } .pdf-toc a { text-decoration: none; } `; const pdfCodeBlockCss = ` .code-block-wrapper { margin: 12px 0; border: 1px solid var(--md-border, #d0d7de); border-radius: 6px; overflow: hidden; break-inside: avoid; page-break-inside: avoid; } .code-block-header { padding: 6px 12px; border-bottom: 1px solid var(--md-border, #d0d7de); background: rgba(0, 0, 0, 0.04); font-size: 12px; line-height: 1; } .code-block-lang { text-transform: lowercase; letter-spacing: 0.04em; color: var(--md-muted, #656d76); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-weight: 500; } .code-block-pre { margin: 0 !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; padding: 12px 16px !important; overflow-x: auto; } `; const pdfPageBreakCss = ` .code-block-wrapper, pre, table, .markdown-alert, .mermaid, .mermaid-diagram, figure, img { break-inside: avoid; page-break-inside: avoid; } h1, h2, h3 { break-after: avoid; page-break-after: avoid; } .mermaid svg, .mermaid-diagram svg { max-width: 100%; height: auto; } `; export async function renderMarkdownToPdf({ markdown, theme, customCss, metadata, roundedCorners = false, showToc = true, }: RenderMarkdownToPdfOptions): Promise { configureMarkedOnce(); const wrapper = getWrapperClass(theme); const isPrimer = wrapper === "markdown-body"; // Tell the (global) marked code renderer which mode to emit for this parse. currentRenderIsPrimer = isPrimer; const rawHtml = await marked.parse(markdown); const { html: htmlWithIds, toc } = addHeadingIds(String(rawHtml)); const tocHtml = showToc ? buildTocHtml(toc) : ""; const htmlContent = `${tocHtml}${htmlWithIds}`; const highlightTheme = getHighlightThemeForDocumentTheme(theme); const hljsCss = await loadHljsCss(highlightTheme); const themeCss = buildThemeCss(theme, customCss); // Layout overrides per mode (rounded card vs full page), // adapted for the active wrapper class. const wrapperSelector = `.${wrapper}`; const pdfOverrides = roundedCorners ? ` @page { margin: 0; } body { margin: 0; padding: 15mm; background-color: ${isPrimer ? "transparent" : "white"}; min-height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; } ${wrapperSelector} { margin: 0 auto !important; width: 100%; max-width: 768px !important; border: 1px solid var(--md-border, #d0d7de) !important; border-radius: 24px !important; } ${wrapperSelector} table tr { background-color: transparent !important; } ` : ` @page { margin: 0; } body { margin: 0; padding: 0; } ${wrapperSelector} { max-width: none !important; margin: 0 !important; border: none !important; border-radius: 0 !important; padding: 20mm 16mm 22mm 16mm !important; width: 100% !important; min-height: 100vh; box-sizing: border-box; } ${wrapperSelector} table tr { background-color: transparent !important; } `; // Primer themes ship comprehensive CSS — skip markdown-base.css to avoid global-selector conflicts. const cssChunks = isPrimer ? [ hljsCss, themeCss, alertCss, katexCss, pdfTocCss, pdfCodeBlockCss, pdfPageBreakCss, pdfOverrides, ] : [ markdownBaseCss, hljsCss, themeCss, alertCss, katexCss, pdfTocCss, pdfCodeBlockCss, pdfPageBreakCss, pdfOverrides, ]; const combinedCss = cssChunks.join("\n"); const usesMermaid = /```mermaid/.test(markdown); const mermaidPath = usesMermaid ? findMermaidScriptPath() : null; if (usesMermaid && !mermaidPath) { console.warn( "[pdf] Markdown contains mermaid blocks but mermaid.min.js was not found at /node_modules/mermaid/dist/mermaid.min.js — diagrams will render as raw text.", ); } const html = ` ${escapeHtml(metadata?.title ?? DEFAULT_TITLE)}
    ${htmlContent}
    `; const browser = await puppeteer.launch({ headless: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-accelerated-2d-canvas", "--no-first-run", "--no-zygote", "--disable-gpu", ], }); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: "networkidle0" }); if (usesMermaid && mermaidPath) { // Inject mermaid via Puppeteer's addScriptTag — this avoids reading and // inlining the (~3MB) UMD ourselves, and avoids Next bundler issues with // import.meta.url / createRequire that previously caused EBADF in prod. await page.addScriptTag({ path: mermaidPath }); const mermaidTheme = getMermaidThemeForDocumentTheme(theme); await page.evaluate(async (themeName: string) => { const m = ( window as unknown as { mermaid: { initialize: (opts: unknown) => void; run: (opts?: unknown) => Promise; }; } ).mermaid; m.initialize({ startOnLoad: false, theme: themeName, securityLevel: "loose", }); await m.run({ querySelector: "pre.mermaid" }); }, mermaidTheme); // Give layout one tick so SVGs settle before printing. await page.evaluate( () => new Promise((r) => requestAnimationFrame(() => r())), ); } const pdfBuffer = await page.pdf({ format: "A4", printBackground: true, displayHeaderFooter: true, headerTemplate: "", footerTemplate: `
    /
    `, margin: { top: 0, right: 0, bottom: 14, left: 0, }, }); await page.close(); return Buffer.from(pdfBuffer); } finally { await browser.close(); } }