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