import { Schema } from 'prosemirror-model'; import { tableNodes } from 'prosemirror-tables'; /** * The one ProseMirror schema Dorfteich documents are written in (ADR 0004). * Editor (TipTap, #25), server-side derivation (this package), and plugin * validation (ADR 0008) all import this schema instead of defining their * own, so "valid document" means the same thing everywhere. * * Node names `wikilink`, `plugin_block`, and `transclusion` are reserved for * these features (wikilinks #46, plugin-defined block types #76, page embeds * #135) — do not repurpose them. */ export const editorSchema = new Schema({ nodes: { doc: { content: 'block+' }, paragraph: { group: 'block', content: 'inline*', parseDOM: [{ tag: 'p' }], toDOM: () => ['p', 0], }, heading: { group: 'block', content: 'inline*', defining: true, attrs: { level: { default: 1, validate: 'number' } }, parseDOM: [1, 2, 3, 4].map((level) => ({ tag: `h${level}`, attrs: { level } })), toDOM: (node) => [`h${node.attrs.level as number}`, 0], }, blockquote: { group: 'block', content: 'block+', parseDOM: [{ tag: 'blockquote' }], toDOM: () => ['blockquote', 0], }, // A styled container for section-style plugins (ADR 0008 kind // `section_style`, issue #75). It wraps block content and carries the // owning plugin + style ids; the applied CSS is the plugin's sanitized, // scoped stylesheet under `.dt-style--`. When the // plugin is disabled the class simply resolves to nothing, so the content // stays intact with neutral styling. section: { group: 'block', content: 'block+', defining: true, attrs: { pluginId: { default: '', validate: 'string' }, styleId: { default: '', validate: 'string' }, }, parseDOM: [ { tag: 'div[data-section-style]', getAttrs: (dom) => { const [pluginId = '', styleId = ''] = ( dom.getAttribute('data-section-style') ?? '' ).split('/'); return { pluginId, styleId }; }, }, ], toDOM: (node) => { const pluginId = node.attrs.pluginId as string; const styleId = node.attrs.styleId as string; return [ 'div', { 'data-section-style': `${pluginId}/${styleId}`, class: `dt-section dt-style-${pluginId}-${styleId}`, }, 0, ]; }, }, code_block: { group: 'block', content: 'text*', marks: '', code: true, defining: true, whitespace: 'pre', parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }], toDOM: () => ['pre', ['code', 0]], }, // A block owned by a code plugin (ADR 0008 extension point `block`, // issue #76): an atom carrying the owning plugin, its block type, and the // block's data as a JSON-serializable object. The editor renders it // through the plugin's sandboxed iframe; everything else (clipboard, // content cache, exports until #79) uses this DOM shape, whose data // attributes round-trip the full state — copy/paste never loses data. plugin_block: { group: 'block', atom: true, attrs: { pluginId: { validate: 'string' }, blockType: { validate: 'string' }, data: { default: {} }, }, parseDOM: [ { tag: 'div[data-plugin-block]', getAttrs: (dom) => { const [pluginId = '', blockType = ''] = ( dom.getAttribute('data-plugin-block') ?? '' ).split('/'); let data: unknown = {}; try { data = JSON.parse(dom.getAttribute('data-plugin-data') ?? '{}'); } catch { // A hand-edited attribute falls back to empty data; the node // itself (plugin + type) survives. } return { pluginId, blockType, data }; }, }, ], toDOM: (node) => [ 'div', { 'data-plugin-block': `${node.attrs.pluginId as string}/${node.attrs.blockType as string}`, 'data-plugin-data': JSON.stringify(node.attrs.data ?? {}), class: 'dt-plugin-block', }, ], }, horizontal_rule: { group: 'block', parseDOM: [{ tag: 'hr' }], toDOM: () => ['hr'], }, bullet_list: { group: 'block', content: 'list_item+', parseDOM: [{ tag: 'ul' }], toDOM: () => ['ul', 0], }, ordered_list: { group: 'block', content: 'list_item+', attrs: { order: { default: 1, validate: 'number' } }, parseDOM: [{ tag: 'ol' }], toDOM: (node) => node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0], }, list_item: { content: 'paragraph block*', parseDOM: [{ tag: 'li' }], toDOM: () => ['li', 0], }, task_list: { group: 'block', content: 'task_item+', parseDOM: [{ tag: 'ul[data-type="task_list"]' }], toDOM: () => ['ul', { 'data-type': 'task_list' }, 0], }, task_item: { content: 'paragraph block*', // `id` (issue #153): a stable per-line id (assigned lazily in the // editor) so the task overview (#154) can address a single checkbox for // display and server-side toggling. `default: null` keeps every // existing document valid; Markdown stays id-less by design. attrs: { checked: { default: false, validate: 'boolean' }, id: { default: null }, }, parseDOM: [ { tag: 'li[data-type="task_item"]', getAttrs: (dom) => ({ checked: dom.getAttribute('data-checked') === 'true', id: dom.getAttribute('data-task-id') || null, }), }, ], toDOM: (node) => { const attrs: Record = { 'data-type': 'task_item', 'data-checked': String(node.attrs.checked), }; if (node.attrs.id) attrs['data-task-id'] = node.attrs.id as string; return ['li', attrs, 0]; }, }, text: { group: 'inline' }, hard_break: { group: 'inline', inline: true, selectable: false, parseDOM: [{ tag: 'br' }], toDOM: () => ['br'], }, image: { group: 'inline', inline: true, atom: true, attrs: { fileId: { validate: 'string' }, alt: { default: '', validate: 'string' }, width: { default: null }, }, parseDOM: [ { tag: 'img[data-file-id]', // No implicit getAttrs: `fileId` has no default (required), so // without this ProseMirror's default attr matching (which only // applies static `rule.attrs`) would create a node missing it — // this is what makes internal copy/paste of an image node work. // (No explicit param type: this package has no DOM lib, and the // parameter type is inferred from the surrounding NodeSpec anyway.) getAttrs: (dom) => { const width = dom.getAttribute('width'); return { fileId: dom.getAttribute('data-file-id'), alt: dom.getAttribute('alt') ?? '', width: width ? Number(width) : null, }; }, }, ], toDOM: (node) => [ 'img', { 'data-file-id': node.attrs.fileId as string, alt: node.attrs.alt as string, width: node.attrs.width as number | null, }, ], }, // Obsidian-style `[[page link]]` (ADR 0004, issue #46). An inline atom // carrying the target page's `slug` and an optional explicit `displayText`; // when `displayText` is null the editor/renderer shows the target's current // title, so a rename updates every link. Live title resolution and phantom // (missing target) styling happen where the pond's pages are known — the // stored document only keeps slug + optional display text. wikilink: { group: 'inline', inline: true, atom: true, attrs: { targetSlug: { validate: 'string' }, displayText: { default: null }, }, parseDOM: [ { tag: 'a[data-wikilink]', getAttrs: (dom) => ({ targetSlug: dom.getAttribute('data-wikilink'), displayText: dom.getAttribute('data-display') || null, }), }, ], toDOM: (node) => { const slug = node.attrs.targetSlug as string; const display = node.attrs.displayText as string | null; const attrs: Record = { 'data-wikilink': slug, class: 'wikilink' }; if (display) attrs['data-display'] = display; return ['a', attrs, display ?? slug]; }, }, // Task overview block (issue #154): collects the task-list lines of the // current page and its subtree into a table (text, mentioned users, // start/due dates, source page) with write-back checkboxes. A block atom // without payload — the collection is computed where permissions are // known (server-side for the public view, the /read tasks endpoint for // the editor's read mode). task_overview: { group: 'block', atom: true, parseDOM: [{ tag: 'div[data-task-overview]' }], toDOM: () => ['div', { 'data-task-overview': '1', class: 'dt-task-overview' }, 'Tasks'], }, // `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline // atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as // a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in // the editor, where the viewer's language is known. date_marker: { group: 'inline', inline: true, atom: true, attrs: { kind: { validate: 'string' }, date: { validate: 'string' }, }, parseDOM: [ { tag: 'span[data-date-marker]', getAttrs: (dom) => ({ kind: dom.getAttribute('data-date-marker'), date: dom.getAttribute('data-date'), }), }, ], toDOM: (node) => { const kind = node.attrs.kind as string; const date = node.attrs.date as string; return [ 'span', { 'data-date-marker': kind, 'data-date': date, class: `dt-date dt-date--${kind}` }, `${kind === 'due' ? '»' : '«'} ${date}`, ]; }, }, // `@username` user mention (issue #150). An inline atom carrying the // mentioned user's stable id plus the username as serialization/display // fallback. Live display-name resolution happens in the editor where a // user lookup is available; a Markdown import cannot resolve users, so it // leaves `userId` empty — such a mention is purely visual. mention: { group: 'inline', inline: true, atom: true, attrs: { userId: { default: '' }, username: { validate: 'string' }, }, parseDOM: [ { tag: 'span[data-mention]', getAttrs: (dom) => ({ username: dom.getAttribute('data-mention'), userId: dom.getAttribute('data-mention-user-id') || '', }), }, ], toDOM: (node) => { const username = node.attrs.username as string; const userId = node.attrs.userId as string; const attrs: Record = { 'data-mention': username, class: 'dt-mention' }; if (userId) attrs['data-mention-user-id'] = userId; return ['span', attrs, `@${username}`]; }, }, // Obsidian-style page embed `![[slug]]` (issue #135). A block atom that // references another page by slug; the read view / public renderer expands // it to the target page's rendered HTML (permission-checked, recursion // limited), while the editor shows a placeholder card. Like `wikilink` it // stores slug + optional display text only — live resolution happens where // the pond's pages are known. The `$[[slug]]` variant (issue #146) sets // `bare`: the expansion drops the frame and title, so the embedded content // reads as part of the host page. transclusion: { group: 'block', atom: true, attrs: { targetSlug: { validate: 'string' }, displayText: { default: null }, bare: { default: false }, }, parseDOM: [ { tag: 'div[data-transclusion]', getAttrs: (dom) => ({ targetSlug: dom.getAttribute('data-transclusion'), displayText: dom.getAttribute('data-display') || null, bare: dom.getAttribute('data-transclusion-bare') === '1', }), }, ], toDOM: (node) => { const slug = node.attrs.targetSlug as string; const display = node.attrs.displayText as string | null; const attrs: Record = { 'data-transclusion': slug, class: 'dt-transclusion', }; if (display) attrs['data-display'] = display; if (node.attrs.bare) attrs['data-transclusion-bare'] = '1'; return ['div', attrs, display ?? slug]; }, }, ...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }), }, marks: { bold: { parseDOM: [{ tag: 'strong' }, { tag: 'b' }], toDOM: () => ['strong', 0], }, italic: { parseDOM: [{ tag: 'em' }, { tag: 'i' }], toDOM: () => ['em', 0], }, code: { parseDOM: [{ tag: 'code' }], toDOM: () => ['code', 0], }, strikethrough: { parseDOM: [{ tag: 's' }, { tag: 'del' }], toDOM: () => ['s', 0], }, link: { inclusive: false, attrs: { href: { validate: 'string' } }, parseDOM: [{ tag: 'a[href]' }], // `target=_blank` unconditionally: inside the editor (contentEditable) // clicking never navigates anyway, and it is what makes "open in new // tab" the default behavior in read mode without any extra UI there // (issue #29 acceptance criterion) — edit mode gets an explicit // bubble-menu action instead (LinkMenu.tsx), since a plain click there // only moves the cursor. toDOM: (mark) => [ 'a', { href: mark.attrs.href as string, target: '_blank', rel: 'noopener noreferrer' }, 0, ], }, }, }); /** Link protocols allowed in editor content (security.md §Content). */ export const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:'] as const; /** Rejects `javascript:`/`data:`/etc. hrefs; also rejects unparseable input. */ export function isAllowedLinkHref(href: string): boolean { try { // A base is only needed to resolve protocol-relative/relative inputs; // those never carry an executable scheme, so any base works here. const url = new URL(href, 'https://dorfteich.invalid'); return (ALLOWED_LINK_PROTOCOLS as readonly string[]).includes(url.protocol); } catch { return false; } }