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
449 lines
15 KiB
TypeScript
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;
|
|
}
|
|
}
|