267 lines
7.6 KiB
TypeScript
267 lines
7.6 KiB
TypeScript
import type { MarkdownDocument, Node } from 'comark';
|
|
import { createSerializedMarkdownParser as createComarkParser } from 'comark';
|
|
import breaks from 'comark/plugins/breaks';
|
|
import footnotes from 'comark/plugins/footnotes';
|
|
import math from 'comark/plugins/math';
|
|
|
|
export type MarkdownTree = MarkdownDocument;
|
|
export type MarkdownAstNode = Node;
|
|
export type MarkdownElementNode = Exclude<MarkdownAstNode, string | [null, ...unknown[]]>;
|
|
|
|
const footnoteSyntax = {
|
|
name: 'footnote-syntax',
|
|
markdownItPlugins: [
|
|
(markdown: any) => markdown.inline.ruler.before('link', 'footnote_inline', (state: any, silent: boolean) => {
|
|
const start = state.pos;
|
|
if (!state.src.startsWith('[^', start)) {
|
|
return false;
|
|
}
|
|
|
|
const end = state.src.indexOf(']', start + 2);
|
|
const label = end === -1 ? '' : state.src.slice(start + 2, end);
|
|
if (end === -1 || label.length === 0 || /\s/.test(label) || silent) {
|
|
return false;
|
|
}
|
|
|
|
state.push('mdc_inline_span', 'span', 1);
|
|
state.push('text', '', 0).content = `^${label}`;
|
|
state.push('mdc_inline_span', 'span', -1);
|
|
state.pos = end + 1;
|
|
return true;
|
|
}),
|
|
],
|
|
};
|
|
|
|
const parserPlugins = [
|
|
breaks(),
|
|
footnoteSyntax,
|
|
footnotes(),
|
|
math(),
|
|
] as const;
|
|
|
|
const createConfiguredParser = (autoClose: boolean) => createComarkParser({
|
|
registerDefaultPlugins: false,
|
|
autoClose,
|
|
plugins: parserPlugins,
|
|
});
|
|
|
|
const protectDollarRunsInLine = (line: string): string => {
|
|
let result = '';
|
|
let segment = '';
|
|
let codeDelimiterLength = 0;
|
|
|
|
const flushSegment = () => {
|
|
result += segment.replace(/\${3,}/g, (run) => run.replaceAll('$', '$'));
|
|
segment = '';
|
|
};
|
|
|
|
for (let index = 0; index < line.length;) {
|
|
let delimiterLength = 0;
|
|
if (line[index] === '`') {
|
|
while (line[index + delimiterLength] === '`') {
|
|
delimiterLength += 1;
|
|
}
|
|
}
|
|
|
|
if (codeDelimiterLength !== 0) {
|
|
result += line.slice(index, index + (delimiterLength || 1));
|
|
if (delimiterLength === codeDelimiterLength) {
|
|
codeDelimiterLength = 0;
|
|
}
|
|
index += delimiterLength || 1;
|
|
continue;
|
|
}
|
|
|
|
if (delimiterLength === 0) {
|
|
segment += line[index];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
flushSegment();
|
|
result += line.slice(index, index + delimiterLength);
|
|
codeDelimiterLength = delimiterLength;
|
|
index += delimiterLength;
|
|
}
|
|
|
|
flushSegment();
|
|
return result;
|
|
};
|
|
|
|
const protectDollarRuns = (content: string): string => {
|
|
const lines = content.split('\n');
|
|
let inFence = false;
|
|
|
|
return lines.map((line) => {
|
|
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
if (fence !== null) {
|
|
inFence = !inFence;
|
|
return line;
|
|
}
|
|
|
|
if (inFence) {
|
|
return line;
|
|
}
|
|
|
|
return protectDollarRunsInLine(line);
|
|
}).join('\n');
|
|
};
|
|
|
|
export const createMarkdownParser = () => {
|
|
const streamingParser = createConfiguredParser(true);
|
|
const finalParser = createConfiguredParser(false);
|
|
|
|
return (content: string, options: { streaming?: boolean } = {}) => {
|
|
const protectedContent = protectDollarRuns(content);
|
|
if (options.streaming === true) {
|
|
return streamingParser(protectedContent, { streaming: true });
|
|
}
|
|
|
|
return finalParser(protectedContent, { streaming: false });
|
|
};
|
|
};
|
|
|
|
export const getNodeTag = (node: MarkdownAstNode): string | null => {
|
|
if (!Array.isArray(node) || node[0] === null) {
|
|
return null;
|
|
}
|
|
|
|
return node[0];
|
|
};
|
|
|
|
export const getNodeAttributes = (node: MarkdownAstNode): Record<string, unknown> => {
|
|
if (!Array.isArray(node) || node[0] === null || typeof node[1] !== 'object' || node[1] === null) {
|
|
return {};
|
|
}
|
|
|
|
return node[1] as Record<string, unknown>;
|
|
};
|
|
|
|
export const getNodeChildren = (node: MarkdownAstNode): MarkdownAstNode[] => {
|
|
if (!Array.isArray(node) || node[0] === null) {
|
|
return [];
|
|
}
|
|
|
|
return node.slice(2) as MarkdownAstNode[];
|
|
};
|
|
|
|
export const textContent = (node: MarkdownAstNode): string => {
|
|
if (typeof node === 'string') {
|
|
return node;
|
|
}
|
|
|
|
if (!Array.isArray(node) || node[0] === null) {
|
|
return '';
|
|
}
|
|
|
|
return getNodeChildren(node).map(textContent).join('');
|
|
};
|
|
|
|
const cloneNode = (node: MarkdownAstNode): MarkdownAstNode => {
|
|
if (typeof node === 'string') {
|
|
return node;
|
|
}
|
|
|
|
if (!Array.isArray(node)) {
|
|
return node;
|
|
}
|
|
|
|
if (node[0] === null) {
|
|
return [null, { ...node[1] }, node[2]];
|
|
}
|
|
|
|
return [
|
|
node[0],
|
|
{ ...node[1] },
|
|
...getNodeChildren(node).map(cloneNode),
|
|
] as MarkdownElementNode;
|
|
};
|
|
|
|
const splitParagraph = (node: MarkdownElementNode): MarkdownElementNode[] => {
|
|
const attributes = { ...node[1] };
|
|
const children = getNodeChildren(node);
|
|
const paragraphs: MarkdownElementNode[] = [];
|
|
let current: MarkdownAstNode[] = [];
|
|
|
|
for (const child of children) {
|
|
if (getNodeTag(child) === 'br') {
|
|
paragraphs.push(['p', { ...attributes }, ...current] as MarkdownElementNode);
|
|
current = [];
|
|
continue;
|
|
}
|
|
|
|
current.push(child);
|
|
}
|
|
|
|
if (current.length > 0 || paragraphs.length === 0) {
|
|
paragraphs.push(['p', { ...attributes }, ...current] as MarkdownElementNode);
|
|
}
|
|
|
|
return paragraphs;
|
|
};
|
|
|
|
const transformChildren = (nodes: MarkdownAstNode[]): MarkdownAstNode[] => {
|
|
const result: MarkdownAstNode[] = [];
|
|
|
|
for (const originalNode of nodes) {
|
|
const node = cloneNode(originalNode);
|
|
if (typeof node === 'string' || !Array.isArray(node) || node[0] === null) {
|
|
result.push(node);
|
|
continue;
|
|
}
|
|
|
|
const tag = node[0];
|
|
const transformedChildren = transformChildren(getNodeChildren(node));
|
|
let transformedNode = [tag, { ...node[1] }, ...transformedChildren] as MarkdownElementNode;
|
|
|
|
if (tag === 'p') {
|
|
result.push(...splitParagraph(transformedNode));
|
|
continue;
|
|
}
|
|
|
|
if ((tag === 'li' || tag === 'blockquote') && transformedChildren.some((child) => getNodeTag(child) === 'br')) {
|
|
const segments: MarkdownAstNode[][] = [[]];
|
|
for (const child of transformedChildren) {
|
|
if (getNodeTag(child) === 'br') {
|
|
segments.push([]);
|
|
} else {
|
|
segments[segments.length - 1]?.push(child);
|
|
}
|
|
}
|
|
|
|
transformedNode = [
|
|
tag,
|
|
{ ...node[1] },
|
|
...segments
|
|
.filter((segment) => segment.length > 0)
|
|
.map((segment) => ['p', {}, ...segment] as MarkdownElementNode),
|
|
] as MarkdownElementNode;
|
|
}
|
|
|
|
result.push(transformedNode);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Comark's breaks plugin emits `br` nodes. Promote each hard break to a
|
|
* sibling paragraph to keep the chat renderer's established layout.
|
|
*/
|
|
export const splitParagraphBreaks = (tree: MarkdownTree): MarkdownTree => ({
|
|
...tree,
|
|
nodes: transformChildren(tree.nodes),
|
|
});
|
|
|
|
export const toVueAttributes = (attributes: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {};
|
|
|
|
for (const [key, value] of Object.entries(attributes)) {
|
|
if (key !== '$') {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|