dorfteich/packages/shared/src/editor-schema/schema.ts
Claude Fable 5 e164370691 #154: Aufgabenübersicht als Kern-Block (Seite + Unterseiten)
Neuer Block-Atom task_overview (Markdown-Fence dorfteich-tasks,
HTML-Placeholder). Shared extractTaskRows liest Task-Zeilen mit Text,
Mentions (#150) und Start-/Zieldaten (#152); TasksService sammelt zur
Lesezeit den Teilbaum (rekursiv via collectSubtreeIds, canAccessPage-
Filter je Quellseite) aus Basis-State + page_updates-Log — KEINE
abgeleitete Tabelle nötig (Teilbäume sind klein, kein Drift). Neuer
auth-Endpoint GET /read/:pond/:slug/tasks; die öffentliche Ansicht
expandiert den Placeholder serverseitig zur statischen Tabelle
(Instanz-Sprache). NodeView mit Live-Tabelle und Rückschreib-Checkboxen
(optimistisch, Override bis der debounced Collab-Persist nachzieht);
Einfügen über die Block-Auswahl (eingebauter Eintrag). Unit- + DB-Tests,
neuer CI-Pack tasks.spec (voller Loop inkl. Rückschreiben end-to-end),
User-Guide-Doku en+de.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 01:20:30 +02:00

449 lines
15 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`, `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-<pluginId>-<styleId>`. 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<string, string> = {
'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<string, string> = { '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<string, string> = { '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<string, string> = {
'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;
}
}