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 /`. */ 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 /` * 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' && tok.info.trim() === 'dorfteich-tasks') { // The task overview block (issue #154) — an empty fence marker. out.push(retype(tok, 'task_overview')); i += 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; } /** `>>`/`<<` + ISO or `dd.mm.yyyy` date (issue #152). */ const DATE_MARKER = /^([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))/; /** Canonical `YYYY-MM-DD` from the regex groups, or null for a non-date. */ function isoDateOf(match: RegExpExecArray): string | null { const [year, month, day] = match[2] ? [Number(match[2]), Number(match[3]), Number(match[4])] : [Number(match[7]), Number(match[6]), Number(match[5])]; const date = new Date(Date.UTC(year, month - 1, day)); const valid = date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; if (!valid) return null; const pad = (value: number): string => String(value).padStart(2, '0'); return `${year}-${pad(month)}-${pad(day)}`; } /** markdown-it inline rule for date markers (issue #152): `>>` = due date, * `<<` = start date; ISO is canonical, `dd.mm.yyyy` is accepted as input * lenience. An invalid calendar date stays plain text. */ function dateMarkerRule(state: StateInline, silent: boolean): boolean { const match = DATE_MARKER.exec(state.src.slice(state.pos)); if (!match) return false; const iso = isoDateOf(match); if (!iso) return false; if (!silent) { const token = state.push('date_marker', '', 0); token.attrs = [ ['kind', match[1] === '>' ? 'due' : 'start'], ['date', iso], ]; } state.pos += match[0].length; return true; } /** Block guard (issue #152): a line starting with `>>2026-…` is a paragraph * with a due-date marker, not a nested blockquote — registered before * `blockquote` so markdown-it never sees the `>` as a quote. */ function dateLineRule( state: StateBlock, startLine: number, endLine: number, silent: boolean, ): boolean { const start = state.bMarks[startLine]! + state.tShift[startLine]!; const max = state.eMarks[startLine]!; const match = DATE_MARKER.exec(state.src.slice(start, max)); if (!match || !isoDateOf(match)) return false; if (silent) return true; // Consume the paragraph like markdown-it's own paragraph rule would. const terminatorRules = state.md.block.ruler.getRules('paragraph'); let nextLine = startLine + 1; for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine += 1) { if (state.sCount[nextLine]! - state.blkIndent > 3) continue; if (state.sCount[nextLine]! < 0) continue; let terminate = false; for (const rule of terminatorRules) { if (rule(state, nextLine, endLine, true)) { terminate = true; break; } } if (terminate) break; } const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); const open = state.push('paragraph_open', 'p', 1); open.map = [startLine, nextLine]; const inline = state.push('inline', '', 0); inline.content = content; inline.map = [startLine, nextLine]; inline.children = []; state.push('paragraph_close', 'p', -1); state.line = nextLine; 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="/"}` 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); // `>>`/`<<` date markers (issue #152) … md.inline.ruler.before('link', 'date_marker', dateMarkerRule); // … and the guard that keeps a line-leading `>>date` out of blockquote. md.block.ruler.before('blockquote', 'date_line', dateLineRule); // 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()), }), }, task_overview: { node: 'task_overview' }, 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') ?? '', }), }, date_marker: { node: 'date_marker', getAttrs: (tok) => ({ kind: tok.attrGet('kind') ?? 'due', date: tok.attrGet('date') ?? '', }), }, 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()); // GFM has no cell spans: pad a colspan with empty cells so every row // keeps the table's column count (rowspan stays lossy — the covered // rows are simply shorter; issue #337). for (let extra = 1; extra < (cell.attrs.colspan as number); extra += 1) cells.push(''); }); 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); }, task_overview(state, node) { // An empty marker fence (issue #154) — the overview has no payload. state.write('```dorfteich-tasks\n```'); 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}`); }, date_marker(state, node) { const prefix = node.attrs.kind === 'due' ? '>>' : '<<'; state.write(`${prefix}${node.attrs.date 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 }); }