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` and `plugin_block` are reserved for later stories * (wikilinks, plugin-defined block types) — 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], }, code_block: { group: 'block', content: 'text*', marks: '', code: true, defining: true, whitespace: 'pre', parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }], toDOM: () => ['pre', ['code', 0]], }, 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*', attrs: { checked: { default: false, validate: 'boolean' } }, parseDOM: [{ tag: 'li[data-type="task_item"]' }], toDOM: (node) => [ 'li', { 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) }, 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]' }], toDOM: (node) => [ 'img', { 'data-file-id': node.attrs.fileId as string, alt: node.attrs.alt as string, width: node.attrs.width as number | null, }, ], }, ...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]' }], toDOM: (mark) => ['a', { href: mark.attrs.href as string }, 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; } }