dorfteich/packages/shared/src/editor-schema/markdown.ts
Claude Fable 5 7471fc70f7 #150: @-Mentions — Inline-Node, instanzweite User-Suche, Autocomplete
Neuer Inline-Atom mention {userId, username}: Markdown-Regel @username
(E-Mail-sicher über Wortgrenzen), Serializer, HTML-Span dt-mention,
Plain-Text für die Suche, Extraktor extractMentionUserIds. Neue
Endpoints GET /users/search (auth, min. 2 Zeichen, Limit 10,
Rate-Limit) und GET /users/brief (Batch-Auflösung für live
Anzeigenamen; gelöschte Nutzer → toter Chip). Editor: MentionView mit
Live-displayName, MentionAutocomplete (Klon des Wikilink-Musters),
Chip-CSS. 5 Unit-Tests.

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

578 lines
20 KiB
TypeScript

import MarkdownIt from 'markdown-it';
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs';
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs';
import Token from 'markdown-it/lib/token.mjs';
import { Mark, Node } from 'prosemirror-model';
import { MarkdownParser, MarkdownSerializer, MarkdownSerializerState } from 'prosemirror-markdown';
import { editorSchema } from './schema';
/**
* markdown-it's default preset already tokenizes GFM tables and
* strikethrough; task lists are GFM-only and not tokenized by markdown-it
* itself, so {@link transformTokens} rewrites the raw token stream before
* `prosemirror-markdown` turns it into a document (ADR 0004).
*/
function findMatchingClose(tokens: Token[], openIndex: number): number {
let depth = 1;
for (let i = openIndex + 1; i < tokens.length; i += 1) {
const type = tokens[i]?.type ?? '';
if (type.endsWith('_open')) depth += 1;
else if (type.endsWith('_close')) depth -= 1;
if (depth === 0) return i;
}
throw new Error(`Unbalanced markdown-it token stream at index ${openIndex}`);
}
/** Indices of `type` tokens directly inside `[start, end)`, not in a nested container. */
function directChildOpens(tokens: Token[], start: number, end: number, type: string): number[] {
const result: number[] = [];
let depth = 0;
for (let i = start; i < end; i += 1) {
const tok = tokens[i];
if (!tok) continue;
if (depth === 0 && tok.type === type) result.push(i);
if (tok.type.endsWith('_open')) depth += 1;
else if (tok.type.endsWith('_close')) depth -= 1;
}
return result;
}
const TASK_MARKER = /^\[([ xX])\]\s+/;
/**
* Reads (and strips) the `[ ] `/`[x] ` prefix from a list item's first
* paragraph. Returns `null` when the item has no checkbox marker, which
* also signals "this list is not a task list" to the caller.
*/
function taskCheckedFor(tokens: Token[], itemOpen: number): boolean | null {
const paragraphOpen = tokens[itemOpen + 1];
if (paragraphOpen?.type !== 'paragraph_open') return null;
const inline = tokens[itemOpen + 2];
if (inline?.type !== 'inline') return null;
const firstChild = inline.children?.[0];
if (!firstChild || firstChild.type !== 'text') return null;
const match = TASK_MARKER.exec(firstChild.content);
if (!match) return null;
firstChild.content = firstChild.content.slice(match[0].length);
return (match[1] ?? '').toLowerCase() === 'x';
}
function retype(token: Token, type: string): Token {
const clone = new Token(type, token.tag, token.nesting);
Object.assign(clone, token, { type });
return clone;
}
/** Info string of a plugin-block fence: `dorfteich-plugin <pluginId>/<blockType>`. */
const PLUGIN_BLOCK_INFO = /^dorfteich-plugin\s+([a-z0-9-]+)\/([a-z0-9-]+)\s*$/;
/** Parses a plugin block's fence body (its data JSON); anything unparsable
* degrades to empty data — the block itself (plugin + type) survives. */
function pluginBlockData(body: string): unknown {
try {
const parsed: unknown = JSON.parse(body);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
/**
* Rewrites the markdown-it token stream so it matches the editor schema:
* - a bullet list where every item carries a `[ ]`/`[x]` marker becomes a
* `task_list` of `task_item`s (mixed lists are left as plain bullet
* lists — GFM does not define their meaning either);
* - table cell content (a bare `inline` token in markdown-it) is wrapped
* in a synthetic paragraph, matching `table_cell`/`table_header`'s
* `block+` content;
* - a fence whose info string is `dorfteich-plugin <pluginId>/<blockType>`
* becomes a `plugin_block` token, its body carrying the data JSON (#76).
*/
function transformTokens(tokens: Token[]): Token[] {
const out: Token[] = [];
let i = 0;
while (i < tokens.length) {
const tok = tokens[i];
if (!tok) {
i += 1;
continue;
}
if (tok.type === 'bullet_list_open') {
const close = findMatchingClose(tokens, i);
const itemOpens = directChildOpens(tokens, i + 1, close, 'list_item_open');
const checks = itemOpens.map((idx) => taskCheckedFor(tokens, idx));
const isTaskList = checks.length > 0 && checks.every((c) => c !== null);
if (isTaskList) {
out.push(retype(tok, 'task_list_open'));
let itemPos = 0;
for (let j = i + 1; j < close; j += 1) {
const t = tokens[j];
if (!t) continue;
if (t.type === 'list_item_open') {
const retyped = retype(t, 'task_item_open');
retyped.attrSet('checked', String(checks[itemPos] === true));
itemPos += 1;
out.push(retyped);
} else if (t.type === 'list_item_close') {
out.push(retype(t, 'task_item_close'));
} else {
out.push(t);
}
}
const closeTok = tokens[close];
if (closeTok) out.push(retype(closeTok, 'task_list_close'));
i = close + 1;
continue;
}
}
if (tok.type === 'fence') {
const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim());
if (info) {
const block = retype(tok, 'plugin_block');
block.attrSet('pluginId', info[1]!);
block.attrSet('blockType', info[2]!);
out.push(block);
i += 1;
continue;
}
}
if (tok.type === 'th_open' || tok.type === 'td_open') {
const close = findMatchingClose(tokens, i);
out.push(tok);
out.push(Object.assign(new Token('paragraph_open', 'p', 1), { hidden: true }));
for (let j = i + 1; j < close; j += 1) {
const t = tokens[j];
if (t) out.push(t);
}
out.push(Object.assign(new Token('paragraph_close', 'p', -1), { hidden: true }));
const closeTok = tokens[close];
if (closeTok) out.push(closeTok);
i = close + 1;
continue;
}
out.push(tok);
i += 1;
}
return out;
}
/** markdown-it inline rule for `[[slug]]` / `[[slug|display]]` (issue #46). */
function wikilinkRule(state: StateInline, silent: boolean): boolean {
const start = state.pos;
// Two opening brackets.
if (state.src.charCodeAt(start) !== 0x5b || state.src.charCodeAt(start + 1) !== 0x5b) {
return false;
}
const close = state.src.indexOf(']]', start + 2);
if (close < 0) return false;
const inner = state.src.slice(start + 2, close);
// No nesting or line breaks inside a wikilink.
if (inner.includes('[') || inner.includes(']') || inner.includes('\n')) return false;
const pipe = inner.indexOf('|');
const slug = (pipe >= 0 ? inner.slice(0, pipe) : inner).trim();
const display = pipe >= 0 ? inner.slice(pipe + 1).trim() : '';
if (!slug) return false;
if (!silent) {
const token = state.push('wikilink', '', 0);
token.attrs = display
? [
['target', slug],
['display', display],
]
: [['target', slug]];
}
state.pos = close + 2;
return true;
}
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
/** markdown-it inline rule for `@username` mentions (issue #150). Only fires
* at a word boundary — `stefan@example.org` stays plain text. The parser is
* database-less, so the user id stays empty (purely visual) until the editor
* or a resolver fills it in. */
function mentionRule(state: StateInline, silent: boolean): boolean {
const start = state.pos;
if (state.src.charCodeAt(start) !== 0x40 /* @ */) return false;
const before = start > 0 ? state.src[start - 1]! : '';
if (/[\w@.-]/.test(before)) return false;
const match = MENTION_NAME.exec(state.src.slice(start + 1));
if (!match || match[0].length < 3) return false;
const username = match[0];
// Trailing user-ish characters would make this an address, not a mention.
const after = state.src[start + 1 + username.length] ?? '';
if (after === '@') return false;
if (!silent) {
const token = state.push('mention', '', 0);
token.attrs = [['username', username]];
}
state.pos = start + 1 + username.length;
return true;
}
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page
* (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */
const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
/**
* Block rule for page embeds (issue #135). A line consisting solely of
* `![[slug]]` becomes a `transclusion` block node; anything else (including
* `![[x]]` mid-paragraph) is left untouched. Registered before `paragraph` so
* the lone-embed line is not swallowed as ordinary text. A `$` prefix marks
* the embed as `bare` (frameless/titleless, issue #146).
*/
function transclusionRule(
state: StateBlock,
startLine: number,
_endLine: number,
silent: boolean,
): boolean {
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
const max = state.eMarks[startLine]!;
const match = TRANSCLUSION_LINE.exec(state.src.slice(start, max));
if (!match) return false;
if (silent) return true;
const token = state.push('transclusion', '', 0);
token.attrSet('target', match[2]!.trim());
const display = match[3]?.trim();
if (display) token.attrSet('display', display);
if (match[1] === '$') token.attrSet('bare', '1');
token.map = [startLine, startLine + 1];
state.line = startLine + 1;
return true;
}
/** Opening fence of a section-style container: `::: {data-section-style="p/s"}`. */
const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/;
const SECTION_CLOSE = /^:::+\s*$/;
/**
* Block rule for section-style containers (issue #75). Recognises a Pandoc-style
* fenced div opened by `::: {data-section-style="<pluginId>/<styleId>"}` and
* closed by a bare `:::`, tokenising the body as normal block content in
* between. Registered before `fence` so the `:::` marker is not mistaken for a
* code fence.
*/
function sectionRule(
state: StateBlock,
startLine: number,
endLine: number,
silent: boolean,
): boolean {
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
const max = state.eMarks[startLine]!;
const open = SECTION_OPEN.exec(state.src.slice(start, max));
if (!open) return false;
if (silent) return true;
// Find the matching closing fence, honouring nested sections.
let depth = 1;
let nextLine = startLine;
for (nextLine = startLine + 1; nextLine < endLine; nextLine += 1) {
const lineStart = state.bMarks[nextLine]! + state.tShift[nextLine]!;
const lineMax = state.eMarks[nextLine]!;
const text = state.src.slice(lineStart, lineMax);
if (SECTION_OPEN.test(text)) depth += 1;
else if (SECTION_CLOSE.test(text)) {
depth -= 1;
if (depth === 0) break;
}
}
const openToken = state.push('section_open', 'div', 1);
openToken.attrSet('pluginId', open[1]!);
openToken.attrSet('styleId', open[2]!);
openToken.map = [startLine, nextLine];
const oldLineMax = state.lineMax;
state.lineMax = nextLine;
state.md.block.tokenize(state, startLine + 1, nextLine);
state.lineMax = oldLineMax;
state.push('section_close', 'div', -1);
state.line = nextLine + 1;
return true;
}
function createTokenizer(): MarkdownIt {
const md = new MarkdownIt('default', { html: false });
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
// `@username` mentions (issue #150).
md.inline.ruler.before('link', 'mention', mentionRule);
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
// as plain text (issue #135).
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
// Run before `fence` so `:::` is not read as a code fence.
md.block.ruler.before('fence', 'section', sectionRule);
const rawParse = md.parse.bind(md);
md.parse = (src, env) => transformTokens(rawParse(src, env));
return md;
}
const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
blockquote: { block: 'blockquote' },
section: {
block: 'section',
getAttrs: (tok) => ({
pluginId: tok.attrGet('pluginId') ?? '',
styleId: tok.attrGet('styleId') ?? '',
}),
},
plugin_block: {
node: 'plugin_block',
getAttrs: (tok) => ({
pluginId: tok.attrGet('pluginId') ?? '',
blockType: tok.attrGet('blockType') ?? '',
data: pluginBlockData(tok.content.trim()),
}),
},
paragraph: { block: 'paragraph' },
list_item: { block: 'list_item' },
task_item: {
block: 'task_item',
getAttrs: (tok) => ({ checked: tok.attrGet('checked') === 'true' }),
},
bullet_list: { block: 'bullet_list' },
task_list: { block: 'task_list' },
ordered_list: {
block: 'ordered_list',
getAttrs: (tok) => ({ order: Number(tok.attrGet('start')) || 1 }),
},
heading: {
block: 'heading',
// The schema only defines levels 1-4; deeper markdown headings clamp
// down rather than fail the parse.
getAttrs: (tok) => ({ level: Math.min(4, Number(tok.tag.slice(1)) || 1) }),
},
code_block: { block: 'code_block', noCloseToken: true },
fence: { block: 'code_block', noCloseToken: true },
hr: { node: 'horizontal_rule' },
image: {
node: 'image',
// The "src" slot carries the opaque fileId; the API's markdown import/
// export endpoint (#30) is responsible for resolving it to a servable
// URL and back — that boundary is not this package's concern.
getAttrs: (tok) => ({
fileId: tok.attrGet('src') ?? '',
alt: tok.children?.[0]?.content ?? '',
width: null,
}),
},
hardbreak: { node: 'hard_break' },
wikilink: {
node: 'wikilink',
getAttrs: (tok) => ({
targetSlug: tok.attrGet('target') ?? '',
displayText: tok.attrGet('display') || null,
}),
},
mention: {
node: 'mention',
getAttrs: (tok) => ({
username: tok.attrGet('username') ?? '',
// A Markdown import cannot resolve users — the mention stays visual.
userId: tok.attrGet('userId') ?? '',
}),
},
transclusion: {
node: 'transclusion',
getAttrs: (tok) => ({
targetSlug: tok.attrGet('target') ?? '',
displayText: tok.attrGet('display') || null,
bare: tok.attrGet('bare') === '1',
}),
},
em: { mark: 'italic' },
strong: { mark: 'bold' },
s: { mark: 'strikethrough' },
link: { mark: 'link', getAttrs: (tok) => ({ href: tok.attrGet('href') ?? '' }) },
code_inline: { mark: 'code', noCloseToken: true },
table: { block: 'table' },
thead: { ignore: true },
tbody: { ignore: true },
tr: { block: 'table_row' },
th: { block: 'table_header' },
td: { block: 'table_cell' },
});
/** Parses Markdown into a document of {@link editorSchema}. */
export function markdownToDoc(markdown: string): Node {
return markdownParser.parse(markdown);
}
function renderTable(state: MarkdownSerializerState, node: Node): void {
const rows: string[][] = [];
node.forEach((row) => {
const cells: string[] = [];
row.forEach((cell) => {
cells.push(cell.textContent.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim());
});
rows.push(cells);
});
const headerRow = rows[0];
if (!headerRow) {
state.closeBlock(node);
return;
}
const lines = [
`| ${headerRow.join(' | ')} |`,
`| ${headerRow.map(() => '---').join(' | ')} |`,
...rows.slice(1).map((row) => `| ${row.join(' | ')} |`),
];
state.write(lines.join('\n'));
state.closeBlock(node);
}
/** Serializes a document of {@link editorSchema} back to Markdown. */
const markdownSerializer = new MarkdownSerializer(
{
blockquote(state, node) {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
},
section(state, node) {
// Pandoc-style fenced div with an unambiguous data attribute (the `/`
// separates the two slugs cleanly, unlike the hyphenated CSS class).
const pluginId = node.attrs.pluginId as string;
const styleId = node.attrs.styleId as string;
state.write(`::: {data-section-style="${pluginId}/${styleId}"}\n`);
state.renderContent(node);
state.write(':::');
state.closeBlock(node);
},
code_block(state, node) {
const fence = fenceFor(node.textContent);
state.write(`${fence}\n`);
state.text(node.textContent, false);
state.write('\n');
state.write(fence);
state.closeBlock(node);
},
plugin_block(state, node) {
// A fence with a reserved info string; the body is the block's data as
// compact JSON (#76). Office/PDF renditions replace this with the
// manifest fallback (#79) — the markdown form is the lossless one.
const pluginId = node.attrs.pluginId as string;
const blockType = node.attrs.blockType as string;
const payload = JSON.stringify(node.attrs.data ?? {});
const fence = fenceFor(payload);
state.write(`${fence}dorfteich-plugin ${pluginId}/${blockType}\n`);
state.text(payload, false);
state.write('\n');
state.write(fence);
state.closeBlock(node);
},
heading(state, node) {
state.write(`${state.repeat('#', node.attrs.level as number)} `);
state.renderInline(node, false);
state.closeBlock(node);
},
horizontal_rule(state, node) {
state.write('---');
state.closeBlock(node);
},
bullet_list(state, node) {
state.renderList(node, ' ', () => '- ');
},
task_list(state, node) {
state.renderList(node, ' ', (i) => `- [${node.child(i).attrs.checked ? 'x' : ' '}] `);
},
ordered_list(state, node) {
const start = (node.attrs.order as number) || 1;
const maxWidth = String(start + node.childCount - 1).length;
const space = state.repeat(' ', maxWidth + 2);
state.renderList(node, space, (i) => {
const numeral = String(start + i);
return state.repeat(' ', maxWidth - numeral.length) + numeral + '. ';
});
},
list_item(state, node) {
state.renderContent(node);
},
task_item(state, node) {
state.renderContent(node);
},
paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
image(state, node) {
const alt = state.esc((node.attrs.alt as string) || '');
const fileId = (node.attrs.fileId as string).replace(/[()]/g, '\\$&');
state.write(`![${alt}](${fileId})`);
},
wikilink(state, node) {
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
},
mention(state, node) {
state.write(`@${node.attrs.username as string}`);
},
transclusion(state, node) {
const slug = node.attrs.targetSlug as string;
const display = node.attrs.displayText as string | null;
const prefix = node.attrs.bare ? '$' : '!';
state.write(display ? `${prefix}[[${slug}|${display}]]` : `${prefix}[[${slug}]]`);
state.closeBlock(node);
},
hard_break(state, node, parent, index) {
for (let i = index + 1; i < parent.childCount; i += 1) {
if (parent.child(i).type !== node.type) {
state.write('\\\n');
return;
}
}
},
text(state, node) {
state.text(node.text ?? '');
},
table: renderTable,
},
{
italic: { open: '*', close: '*', mixable: true, expelEnclosingWhitespace: true },
bold: { open: '**', close: '**', mixable: true, expelEnclosingWhitespace: true },
strikethrough: { open: '~~', close: '~~', mixable: true, expelEnclosingWhitespace: true },
link: {
open: '[',
close: (_state, mark: Mark) => `](${(mark.attrs.href as string).replace(/[()]/g, '\\$&')})`,
mixable: true,
},
code: {
open: (_state, _mark, parent, index) => backticksFor(parent.child(index), -1),
close: (_state, _mark, parent, index) => backticksFor(parent.child(index - 1), 1),
escape: false,
},
},
);
/** A fence long enough that `body` cannot terminate it early. */
function fenceFor(body: string): string {
const backticks = body.match(/`{3,}/gm);
return backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```';
}
function backticksFor(node: Node, side: -1 | 1): string {
const text = node.isText ? (node.text ?? '') : '';
const matches = text.match(/`+/g);
const len = matches ? Math.max(...matches.map((m) => m.length)) : 0;
let result = len > 0 && side > 0 ? ' `' : '`';
result += '`'.repeat(len);
if (len > 0 && side < 0) result += ' ';
return result;
}
export function docToMarkdown(doc: Node): string {
// The schema has no `tight`/`loose` list attribute (out of scope for v1)
// — every list renders without blank lines between items.
return markdownSerializer.serialize(doc, { tightLists: true });
}