dorfteich/packages/shared/src/editor-schema/html.ts
Claude Fable 5 58c19abfdd #169: Nicht-Text-Inhalte — Task-Checkboxen, Wissensgraph
Task-Checkboxen tragen in beiden Renderpfaden einen Namen: docToHtml
setzt aria-label aus dem Aufgabentext, die Editor-NodeView ebenso. Die
NodeView rendert ihr Host-Element jetzt selbst als li (ReactNodeView-
Renderer as/attrs) — TipTaps zusätzliches div-Host-Element zwischen ul
und li brach die Listensemantik; der Wrapper flacht per display:contents
ab, die #137-Pixel-Abstimmung bleibt erhalten (Selektor auf die neue
Tiefe nachgeführt, Ausrichtung nachgemessen: 1px-Versatz unverändert).
Der Wissensgraph-SVG bekommt ein beschreibendes aria-label inklusive
Verweis auf die Backlinks als gleichwertige Listenform. Der
Bild-Alt-Editor existierte bereits (Bild-Controls bei Auswahl) — kein
Änderungsbedarf. Hinweis: gecachte Seiten übernehmen das
Checkbox-Label wie bei jeder docToHtml-Änderung erst mit dem nächsten
Persist ihrer Inhalte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:35:16 +02:00

199 lines
8.2 KiB
TypeScript

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const MARK_TAGS: Record<string, string> = {
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 `<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer">`;
}
return `<${tagNameFor(mark)}>`;
}
function renderMarks(marks: readonly Mark[], inner: string): string {
const open = marks.map(openTagFor).join('');
const close = [...marks]
.reverse()
.map((mark) => `</${tagNameFor(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 += '<br>';
} 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 += `<img data-file-id="${escapeHtml(child.attrs.fileId as string)}" alt="${alt}"${widthAttr}>`;
} 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/<pond>/<slug>`) and the
// in-app version-history preview (`/p/<pond>/<slug>`) 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 += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
} 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 +=
`<span class="dt-date dt-date--${kind}" data-date-marker="${kind}"` +
` data-date="${date}">${kind === 'due' ? '»' : '«'} ${date}</span>`;
} 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 += `<span class="dt-mention" data-mention="${username}"${idAttr}>@${username}</span>`;
}
});
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 += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''} aria-label="${escapeHtml(item.textContent)}">${renderBlocks(item)}</li>`;
} else {
out += `<li>${renderBlocks(item)}</li>`;
}
});
return out;
}
function renderTable(node: Node): string {
let out = '<table>';
node.forEach((row) => {
out += '<tr>';
row.forEach((cell) => {
const tag = cell.type.name === 'table_header' ? 'th' : 'td';
out += `<${tag}>${renderBlocks(cell)}</${tag}>`;
});
out += '</tr>';
});
return `${out}</table>`;
}
function renderBlock(node: Node): string {
switch (node.type.name) {
case 'paragraph':
return `<p>${renderInline(node)}</p>`;
case 'heading': {
const level = node.attrs.level as number;
return `<h${level}>${renderInline(node)}</h${level}>`;
}
case 'blockquote':
return `<blockquote>${renderBlocks(node)}</blockquote>`;
case 'section': {
// Section-style container (issue #75): scoped class so the plugin's
// sanitized CSS (`.dt-style-<pluginId>-<styleId>`) 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 `<div class="dt-section dt-style-${pluginId}-${styleId}">${renderBlocks(node)}</div>`;
}
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 (
`<div class="dt-plugin-block" data-plugin-block="${pluginId}/${blockType}"` +
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
);
}
case 'task_overview':
// The task overview (issue #154): a placeholder the permission-aware
// renderers (public view, exports) replace with the static table.
return '<div class="dt-task-overview" data-task-overview="1">[tasks]</div>';
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 `<div class="dt-transclusion" data-transclusion="${slug}"${bare}>${display}</div>`;
}
case 'code_block':
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
case 'horizontal_rule':
return '<hr>';
case 'bullet_list':
return `<ul>${renderListItems(node)}</ul>`;
case 'ordered_list': {
const order = node.attrs.order as number;
const startAttr = order !== 1 ? ` start="${order}"` : '';
return `<ol${startAttr}>${renderListItems(node)}</ol>`;
}
case 'task_list':
return `<ul data-type="task_list">${renderListItems(node)}</ul>`;
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);
}