import { matchInlineMathAt } from "./inline-math.js"; import { KATEX_STYLESHEET_URL, renderMathToHtml } from "./math-rendering.js"; export type DocumentExportFormat = "pdf" | "markdown" | "html"; export interface DocumentExportInput { id: string; title?: string | null; content?: string | null; updatedAt?: string | null; format: DocumentExportFormat; } export interface DocumentExportPayload { id: string; title: string; format: DocumentExportFormat; filename: string; mimeType: string; content: string; print: boolean; } const EXTENSION_BY_FORMAT: Record = { pdf: "pdf", markdown: "md", html: "html", }; const MIME_BY_FORMAT: Record = { pdf: "text/html;charset=utf-8", markdown: "text/markdown;charset=utf-8", html: "text/html;charset=utf-8", }; function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function safeExportUrl(value: string, kind: "link" | "image"): string { const normalized = value.replace(/&/g, "&").trim(); const lower = normalized.toLowerCase(); if ( lower.startsWith("#") || lower.startsWith("/") || lower.startsWith("./") || lower.startsWith("../") ) { return value; } if (kind === "image" && lower.startsWith("data:image/")) return value; try { const url = new URL(normalized); const allowed = kind === "image" ? url.protocol === "http:" || url.protocol === "https:" : ["http:", "https:", "mailto:", "tel:"].includes(url.protocol); return allowed ? value : "#"; } catch { return "#"; } } function normalizeTitle(title: string | null | undefined): string { const normalized = (title ?? "").replace(/\s+/g, " ").trim(); return normalized || "Untitled"; } export function exportFilename( title: string | null | undefined, format: DocumentExportFormat, ): string { const base = normalizeTitle(title) .toLowerCase() .replace(/['"]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80); return `${base || "untitled"}.${EXTENSION_BY_FORMAT[format]}`; } export function markdownWithTitle( title: string | null | undefined, content: string | null | undefined, ): string { const safeTitle = normalizeTitle(title); const body = (content ?? "").trim(); const firstHeading = body.match(/^#\s+(.+?)(?:\n|$)/); if (firstHeading?.[1]?.trim().toLowerCase() === safeTitle.toLowerCase()) { return `${body}\n`; } return `${`# ${safeTitle}`}${body ? `\n\n${body}` : ""}\n`; } interface InlineExportToken { marker: string; html: string; source: string; } function restoreTokenSources( text: string, tokens: InlineExportToken[], ): string { return tokens.reduce( (restored, token) => restored.split(token.marker).join(escapeHtml(token.source)), text, ); } function inlineMarkdownSegmentToHtml( text: string, tokens: InlineExportToken[], ): string { return escapeHtml(text) .replace( /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_m, alt, src) => { return `${restoreTokenSources(alt, tokens)}`; }, ) .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, href) => { return `${label}`; }) .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/__([^_]+)__/g, "$1") .replace(/~~([^~]+)~~/g, "$1") .replace(/(^|[^*])\*([^*]+)\*/g, "$1$2") .replace(/(^|[^_])_([^_]+)_/g, "$1$2"); } function isEscapedDelimiter(text: string, index: number): boolean { let backslashes = 0; for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor--) { backslashes++; } return backslashes % 2 === 1; } function mathErrorHtml( source: string, error: string, displayMode: boolean, ): string { const className = displayMode ? "math-error math-error-block" : "math-error math-error-inline"; const tagName = displayMode ? "pre" : "code"; return `<${tagName} class="${className}" title="${escapeHtml(error)}">${escapeHtml(source)}`; } function inlineMarkdownToHtml(text: string): string { const tokens: InlineExportToken[] = []; let markerStart = "\uE000agent-native-inline-"; while (text.includes(markerStart)) markerStart += "-"; const protectedText: string[] = []; let cursor = 0; while (cursor < text.length) { if (text.startsWith("$`", cursor) && isEscapedDelimiter(text, cursor)) { const close = text.indexOf("`$", cursor + 2); if (close !== -1) { protectedText.push(text.slice(cursor, close + 2)); cursor = close + 2; continue; } } if (text[cursor] === "$") { const inlineMath = matchInlineMathAt(text, cursor); if (inlineMath) { const { latex } = inlineMath; const source = text.slice(cursor, inlineMath.to); const math = renderMathToHtml(latex, false); const marker = `${markerStart}${tokens.length}\uE001`; tokens.push({ marker, source, html: math.ok ? `${math.html}` : mathErrorHtml(source, math.error, false), }); protectedText.push(marker); cursor = inlineMath.to; continue; } } if (text[cursor] === "`" && !isEscapedDelimiter(text, cursor)) { let delimiterLength = 1; while (text[cursor + delimiterLength] === "`") delimiterLength++; const delimiter = "`".repeat(delimiterLength); let close = text.indexOf(delimiter, cursor + delimiterLength); while ( close !== -1 && (text[close - 1] === "`" || text[close + delimiterLength] === "`") ) { close = text.indexOf(delimiter, close + delimiterLength); } if (close !== -1) { const source = text.slice(cursor, close + delimiterLength); let code = text .slice(cursor + delimiterLength, close) .replace(/\r?\n/g, " "); if ( code.startsWith(" ") && code.endsWith(" ") && code.trim().length > 0 ) { code = code.slice(1, -1); } const marker = `${markerStart}${tokens.length}\uE001`; tokens.push({ marker, source, html: `${escapeHtml(code)}`, }); protectedText.push(marker); cursor = close + delimiterLength; continue; } } protectedText.push(text[cursor]); cursor++; } return tokens.reduce( (html, token) => html.split(token.marker).join(token.html), inlineMarkdownSegmentToHtml(protectedText.join(""), tokens), ); } function listItemsToHtml(items: string[][], ordered: boolean): string { const renderedItems = items .map((lines) => { const task = lines[0].match(/^\[( |x|X)\]\s+(.*)$/); if (task) { const checked = task[1].toLowerCase() === "x"; return `
  • ${inlineMarkdownToHtml(task[2])}${ lines.length > 1 ? markdownToHtml(lines.slice(1).join("\n")) : "" }
  • `; } return lines.length === 1 ? `
  • ${inlineMarkdownToHtml(lines[0])}
  • ` : `
  • ${markdownToHtml(lines.join("\n"))}
  • `; }) .join("\n"); return ordered ? `
      \n${renderedItems}\n
    ` : ``; } interface ListMarkerMatch { baseIndent: number; contentIndent: number; content: string; } function indentationWidth(value: string): number { let width = 0; for (const character of value) { width += character === "\t" ? 4 - (width % 4) : 1; } return width; } function matchListMarker( line: string, ordered: boolean, ): ListMarkerMatch | null { const match = line.match( ordered ? /^([ \t]*)(?:\d+[.)])([ \t]+)(.*)$/ : /^([ \t]*)(?:[-*+])([ \t]+)(.*)$/, ); if (!match) return null; const contentStart = match[0].length - match[3].length; return { baseIndent: indentationWidth(match[1]), contentIndent: indentationWidth(line.slice(0, contentStart)), content: match[3], }; } function leadingIndentWidth(line: string): number { return indentationWidth(line.match(/^[ \t]*/)?.[0] ?? ""); } function stripLeadingIndent(line: string, targetWidth: number): string { let width = 0; let index = 0; while (index < line.length && width < targetWidth) { const character = line[index]; if (character !== " " && character !== "\t") break; width += character === "\t" ? 4 - (width % 4) : 1; index++; } return line.slice(index); } function collectListItems( lines: string[], start: number, ordered: boolean, ): { items: string[][]; nextIndex: number } { const firstMarker = matchListMarker(lines[start], ordered); if (!firstMarker) return { items: [], nextIndex: start }; const baseIndent = firstMarker.baseIndent; const items: string[][] = []; let index = start; while (index < lines.length) { const itemMarker = matchListMarker(lines[index], ordered); if (!itemMarker || itemMarker.baseIndent !== baseIndent) break; const item = [itemMarker.content]; index++; while (index < lines.length) { const nextMarker = matchListMarker(lines[index], ordered); if (nextMarker?.baseIndent === baseIndent) break; if (leadingIndentWidth(lines[index]) >= itemMarker.contentIndent) { item.push(stripLeadingIndent(lines[index], itemMarker.contentIndent)); index++; continue; } if (!lines[index].trim()) { let nextContent = index + 1; while (nextContent < lines.length && !lines[nextContent].trim()) { nextContent++; } if ( nextContent < lines.length && leadingIndentWidth(lines[nextContent]) >= itemMarker.contentIndent ) { item.push(""); index++; continue; } } break; } items.push(item); } return { items, nextIndex: index }; } function isEmptyBlockLine(trimmed: string): boolean { return /^]*\/>$/.test(trimmed); } function markdownToHtml(markdown: string): string { const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); const blocks: string[] = []; let index = 0; while (index < lines.length) { const line = lines[index]; const trimmed = line.trim(); if (!trimmed) { index++; continue; } if (isEmptyBlockLine(trimmed)) { blocks.push("

     

    "); index++; continue; } if (trimmed === "$$") { const source = [line]; const latex: string[] = []; index++; while (index < lines.length && lines[index].trim() !== "$$") { source.push(lines[index]); latex.push(lines[index]); index++; } const closed = index < lines.length; if (closed) { source.push(lines[index]); index++; } if (!closed) { blocks.push( mathErrorHtml( source.join("\n"), "This block equation is missing its closing delimiter.", true, ), ); continue; } const math = renderMathToHtml(latex.join("\n"), true); blocks.push( math.ok ? `
    ${math.html}
    ` : mathErrorHtml(source.join("\n"), math.error, true), ); continue; } const codeFence = trimmed.match(/^```(\w+)?/); if (codeFence) { const code: string[] = []; index++; while (index < lines.length && !lines[index].trim().startsWith("```")) { code.push(lines[index]); index++; } index++; const language = codeFence[1] ? ` class="language-${escapeHtml(codeFence[1])}"` : ""; blocks.push( `
    ${escapeHtml(code.join("\n"))}
    `, ); continue; } const heading = trimmed.match(/^(#{1,6})\s+(.+)$/); if (heading) { const level = heading[1].length; blocks.push(`${inlineMarkdownToHtml(heading[2])}`); index++; continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { blocks.push("
    "); index++; continue; } if (/^>\s?/.test(trimmed)) { const quote: string[] = []; while (index < lines.length && /^>\s?/.test(lines[index].trim())) { quote.push(lines[index].trim().replace(/^>\s?/, "")); index++; } blocks.push( `
    ${markdownToHtml(quote.join("\n"))}
    `, ); continue; } if (/^\s*[-*+]\s+/.test(line)) { const list = collectListItems(lines, index, false); index = list.nextIndex; blocks.push(listItemsToHtml(list.items, false)); continue; } if (/^\s*\d+[.)]\s+/.test(line)) { const list = collectListItems(lines, index, true); index = list.nextIndex; blocks.push(listItemsToHtml(list.items, true)); continue; } const paragraph: string[] = [line]; index++; while ( index < lines.length && lines[index].trim() && !isEmptyBlockLine(lines[index].trim()) && lines[index].trim() !== "$$" && !/^(#{1,6})\s+/.test(lines[index].trim()) && !/^```/.test(lines[index].trim()) && !/^>\s?/.test(lines[index].trim()) && !/^\s*[-*+]\s+/.test(lines[index]) && !/^\s*\d+[.)]\s+/.test(lines[index]) ) { paragraph.push(lines[index]); index++; } blocks.push(`

    ${inlineMarkdownToHtml(paragraph.join("\n"))}

    `); } return blocks.join("\n\n"); } function buildHtmlDocument(input: { title: string; content: string; updatedAt?: string | null; print: boolean; }): string { const body = markdownToHtml(input.content); const updated = input.updatedAt ? `

    Updated ${escapeHtml( new Date(input.updatedAt).toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }), )}

    ` : ""; return ` ${escapeHtml(input.title)}
    ${updated}

    ${escapeHtml(input.title)}

    ${body}
    `; } export function buildDocumentExport( input: DocumentExportInput, ): DocumentExportPayload { const title = normalizeTitle(input.title); const filename = exportFilename(title, input.format); const markdown = markdownWithTitle(title, input.content); const isHtmlLike = input.format === "html" || input.format === "pdf"; const content = isHtmlLike ? buildHtmlDocument({ title, content: input.content ?? "", updatedAt: input.updatedAt, print: input.format === "pdf", }) : markdown; return { id: input.id, title, format: input.format, filename, mimeType: MIME_BY_FORMAT[input.format], content, print: input.format === "pdf", }; }