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
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.
174 lines
4.5 KiB
TypeScript
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;
|
|
}
|
|
}
|