import { Mark, Node } from 'prosemirror-model'; import { isAllowedLinkHref } from './schema'; /** * Hand-rolled ProseMirror → HTML renderer (security.md §Content): every * text node is escaped and link protocols are allowlisted. No DOM is * touched — this runs in the API/collab server as much as in the browser. */ function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } const MARK_TAGS: Record = { bold: 'strong', italic: 'em', code: 'code', strikethrough: 's', }; function tagNameFor(mark: Mark): string { return mark.type.name === 'link' ? 'a' : (MARK_TAGS[mark.type.name] ?? mark.type.name); } function openTagFor(mark: Mark): string { if (mark.type.name === 'link') { const href = mark.attrs.href as string; const safeHref = isAllowedLinkHref(href) ? href : '#'; return ``; } return `<${tagNameFor(mark)}>`; } function renderMarks(marks: readonly Mark[], inner: string): string { const open = marks.map(openTagFor).join(''); const close = [...marks] .reverse() .map((mark) => ``) .join(''); return open + inner + close; } function renderInline(node: Node): string { let out = ''; node.forEach((child) => { if (child.isText) { out += renderMarks(child.marks, escapeHtml(child.text ?? '')); } else if (child.type.name === 'hard_break') { out += '
'; } else if (child.type.name === 'image') { const alt = escapeHtml((child.attrs.alt as string) ?? ''); const width = child.attrs.width as number | null; const widthAttr = width ? ` width="${width}"` : ''; out += `${alt}`; } else if (child.type.name === 'wikilink') { // The slug and shown text; live title resolution happens in the editor // where the pond's pages are known (issue #46). The href is the target // slug *relative* to the current page URL, so this static HTML links // correctly from both the public view (`/public//`) and the // in-app version-history preview (`/p//`) without the // renderer needing the pond context. Slugs are `[a-z0-9-]`, safe as a // bare path segment. const slug = escapeHtml(child.attrs.targetSlug as string); const display = child.attrs.displayText as string | null; const text = escapeHtml(display ?? (child.attrs.targetSlug as string)); const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : ''; out += `
${text}`; } else if (child.type.name === 'date_marker') { // A date marker (issue #152): static HTML shows the unambiguous ISO // date; locale-aware formatting happens where the viewer is known. const kind = escapeHtml(child.attrs.kind as string); const date = escapeHtml(child.attrs.date as string); out += `${kind === 'due' ? '»' : '«'} ${date}`; } else if (child.type.name === 'mention') { // A user mention (issue #150): static HTML shows the @username; the // stable user id rides along for consumers that can resolve it. const username = escapeHtml(child.attrs.username as string); const userId = child.attrs.userId ? escapeHtml(child.attrs.userId as string) : ''; const idAttr = userId ? ` data-mention-user-id="${userId}"` : ''; out += `@${username}`; } }); return out; } function renderListItems(node: Node): string { let out = ''; node.forEach((item) => { if (item.type.name === 'task_item') { const checked = item.attrs.checked === true; const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : ''; // aria-label: the disabled checkbox needs a name (#169, WCAG 4.1.2); // the item text doubles as its label in the static rendering. out += `
  • ${renderBlocks(item)}
  • `; } else { out += `
  • ${renderBlocks(item)}
  • `; } }); return out; } function renderTable(node: Node): string { let out = ''; node.forEach((row) => { out += ''; row.forEach((cell) => { const tag = cell.type.name === 'table_header' ? 'th' : 'td'; out += `<${tag}>${renderBlocks(cell)}`; }); out += ''; }); return `${out}
    `; } function renderBlock(node: Node): string { switch (node.type.name) { case 'paragraph': return `

    ${renderInline(node)}

    `; case 'heading': { const level = node.attrs.level as number; return `${renderInline(node)}`; } case 'blockquote': return `
    ${renderBlocks(node)}
    `; case 'section': { // Section-style container (issue #75): scoped class so the plugin's // sanitized CSS (`.dt-style--`) applies; when the // plugin is gone the class matches nothing and the content stays plain. const pluginId = escapeHtml(node.attrs.pluginId as string); const styleId = escapeHtml(node.attrs.styleId as string); return `
    ${renderBlocks(node)}
    `; } case 'plugin_block': { // A plugin-owned block (#76). The static HTML carries the full state in // data attributes (same shape as the schema's toDOM, so editor // copy/paste round-trips) and a neutral `[plugin/type]` label; the SPA // renders it live through the plugin's sandbox, and office/PDF // renditions replace it with the manifest fallback (#79). const pluginId = escapeHtml(node.attrs.pluginId as string); const blockType = escapeHtml(node.attrs.blockType as string); const data = escapeHtml(JSON.stringify(node.attrs.data ?? {})); return ( `
    [${pluginId}/${blockType}]
    ` ); } case 'task_overview': // The task overview (issue #154): a placeholder the permission-aware // renderers (public view, exports) replace with the static table. return '
    [tasks]
    '; case 'transclusion': { // A page embed (#135). The static HTML is a placeholder carrying the // target slug; the read view / public renderer expands it server-side to // the target page's rendered HTML (permission-checked, recursion limited). // Left un-expanded (here) it degrades to a labelled block. The `bare` // flag (#146) rides along so the expansion can drop frame + title. const slug = escapeHtml(node.attrs.targetSlug as string); const display = node.attrs.displayText ? escapeHtml(node.attrs.displayText as string) : slug; const bare = node.attrs.bare ? ' data-transclusion-bare="1"' : ''; return `
    ${display}
    `; } case 'code_block': return `
    ${escapeHtml(node.textContent)}
    `; case 'horizontal_rule': return '
    '; case 'bullet_list': return `
      ${renderListItems(node)}
    `; case 'ordered_list': { const order = node.attrs.order as number; const startAttr = order !== 1 ? ` start="${order}"` : ''; return `${renderListItems(node)}`; } case 'task_list': return `
      ${renderListItems(node)}
    `; case 'table': return renderTable(node); default: return renderBlocks(node); } } function renderBlocks(node: Node): string { let out = ''; node.forEach((child) => { out += renderBlock(child); }); return out; } export function docToHtml(doc: Node): string { return renderBlocks(doc); }