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
578 lines
20 KiB
TypeScript
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(``);
|
|
},
|
|
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 });
|
|
}
|