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
199 lines
8.2 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
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);
|
|
}
|