dorfteich/packages/shared/src/editor-schema/schema.ts
Claude Sonnet 5 b0d9a00c18
All checks were successful
CD / Build and push images (push) Successful in 1m50s
CI / Lint, typecheck, test (push) Successful in 1m23s
CI / Auth e2e pack (push) Successful in 1m42s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m4s
CD / Promote to Int (push) Successful in 10s
Fix Prettier formatting in editor-schema (#24)
The #24 commit passed ESLint but not the repo's Prettier check (pnpm
lint runs both) — CI caught it after the push. Formatting only, no
behavior change.
2026-07-05 22:22:37 +02:00

174 lines
4.5 KiB
TypeScript

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;
}
}