Files
veridian/app/utils/markdown.ts
T

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('$', '&#36;'));
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;
};