dorfteich/packages/shared/src/editor-schema/markdown.ts
Claude Sonnet 5 b89aa6bed0
Some checks failed
CD / Build and push images (push) Successful in 1m50s
CI / Lint, typecheck, test (push) Failing after 52s
CI / Auth e2e pack (push) Successful in 1m47s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m5s
CD / Promote to Int (push) Successful in 10s
Add editor document schema in packages/shared (#24)
ProseMirror schema (headings 1-4, lists incl. task lists, blockquote,
code block, tables via prosemirror-tables, images, hard breaks; bold/
italic/code/strikethrough/link marks) plus docToMarkdown, markdownToDoc,
docToPlainText, docToHtml, and extractOutline built on it. Markdown
parsing extends markdown-it's default preset with a token-stream
transform for GFM task lists and table-cell paragraph wrapping.
docToHtml hand-rolls escaping and link-protocol allowlisting with zero
DOM dependencies, so it runs in the API/collab server as well as the
browser.

Node names `wikilink` and `plugin_block` are reserved for later stories.

Closes #24
2026-07-05 21:59:12 +02:00

316 lines
11 KiB
TypeScript

import MarkdownIt from 'markdown-it';
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;
}
/**
* 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.
*/
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 === '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;
}
function createTokenizer(): MarkdownIt {
const md = new MarkdownIt('default', { html: false });
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' },
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' },
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));
},
code_block(state, node) {
const backticks = node.textContent.match(/`{3,}/gm);
const fence = backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```';
state.write(`${fence}\n`);
state.text(node.textContent, 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})`);
},
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,
},
},
);
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 });
}