feat(markdown): Migrate to comark for markdown rendering

This commit is contained in:
Zoe
2026-08-11 03:18:28 -05:00
parent c9e48687ef
commit 4b99921023
10 changed files with 441 additions and 337 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '~/assets/css/reset.css';
import '~/assets/css/base.css';
import 'katex/dist/katex.min.css';
const { user } = useAuth();
const { accent, neutral, hinting, refresh: refreshSettings } = await useUserSettings();
@@ -24,7 +25,7 @@ watchEffect(() => {
});
if (import.meta.client) {
// force shiki into browser rendering only
// Force Shiki into browser rendering only.
window.sessionStorage.setItem('mdc-shiki-highlighter', 'browser');
}
</script>
+2
View File
@@ -81,6 +81,7 @@
--color-border: color-mix(in srgb, rgba(255, 255, 255, 0.1), var(--color-accent) var(--accent-hinting));
--color-border-active: color-mix(in srgb, rgba(255, 255, 255, 0.16), var(--color-accent) var(--accent-hinting));
}
:root.light {
@@ -107,6 +108,7 @@
--color-border: color-mix(in srgb, rgba(0, 0, 0, 0.08), var(--color-accent) var(--accent-hinting));
--color-border-active: color-mix(in srgb, rgba(0, 0, 0, 0.14), var(--color-accent) var(--accent-hinting));
}
/* :root.dark {
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { renderMath } from 'comark/plugins/math';
const props = defineProps<{
content: string;
display: boolean;
}>();
const html = computed(() => renderMath(props.content, props.display, { throwOnError: false }));
</script>
<template>
<span v-if="!display" class="math-inline" v-html="html"></span>
<div v-else class="math-block" v-html="html"></div>
</template>
+66 -81
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { h, Text, computed } from 'vue';
import type { MarkdownAstNode, MarkdownElementNode } from '~/utils/markdown';
import { createMarkdownParser, getNodeAttributes, getNodeChildren, getNodeTag, splitParagraphBreaks, textContent, toVueAttributes } from '~/utils/markdown';
import { createTextVNode, h, ref, watch } from 'vue';
import MarkdownMath from './Math.vue';
import MarkdownShikiHighlight from './ShikiHighlight.vue';
const props = defineProps<{
@@ -8,98 +11,80 @@ const props = defineProps<{
id: string;
}>();
const { $remark } = useNuxtApp();
const parser = createMarkdownParser();
const parse = async (content: string, finished: boolean) => {
const tree = await parser(content, { streaming: !finished });
return splitParagraphBreaks(tree);
};
// this function effectively removes lazy concetenation from the markdown
// normally in markdown, if you have two lines that are separated by only
// one new line. E.g.:
//
// This is some markdown text but I split
// it into two lines so it's easier to read in the editor
//
// they are *usually* concatenated into one line, however,
// since we are a web editor, with line wrapping, that behavior
// in undesirable, so pre preserve newlines by just maiking
// every new line two newlines (as long as we arent in a codeblock)
// function preprocessMarkdown(doc: string) {
// const lines = doc.split('\n');
// let inCode = false;
// let result: string[] = [];
const parsed = ref(await parse(props.content, props.finished));
let parseVersion = 0;
// for (let i = 0; i < lines.length; i++) {
// const fullLine = lines[i]!;
// const match = fullLine.match(/^( {0,3})(.*)/);
// const content = match?.[2] || '';
// if (content.startsWith('```')) {
// if (!inCode) {
// inCode = true;
// } else {
// inCode = false;
// }
// result.push(fullLine);
// continue;
// }
// if (inCode) {
// result.push(fullLine);
// } else {
// if (content.trim().length === 0) {
// result.push('');
// } else {
// if (result.length > 0 && result[result.length - 1] !== '') {
// result.push('');
// }
// result.push(fullLine);
// }
// }
// }
// return result.join('\n');
// }
const ast = computed(() => {
const mdast = $remark.parse(props.content);
return $remark.runSync(mdast);
});
const renderNode = (node: any, index: number): any => {
if (node.type === 'text' || node.type === 'raw') return h(Text, node.value);
if (node.type === 'element') {
if (node.tagName === 'code') {
const isBlock = node.position?.start.line !== node.position?.end.line;
if (isBlock && node.children?.[0]?.type === 'text') {
return h(MarkdownShikiHighlight, {
key: `code-${index}`,
code: node.children[0].value,
language: node.properties?.className?.[0]?.replace('language-', '') || 'text'
});
}
watch(
[() => props.content, () => props.finished],
async ([content, finished]) => {
const version = ++parseVersion;
const tree = await parse(content, finished);
if (version === parseVersion) {
parsed.value = tree;
}
},
);
const children = node.children?.map((child: any, i: number) => renderNode(child, i)) || [];
return h(
node.tagName,
{ ...node.properties, key: `${node.tagName}-${index}` },
children
);
const renderNode = (node: MarkdownAstNode, path: string): any => {
if (typeof node === 'string') {
return createTextVNode(node);
}
return null;
if (!Array.isArray(node)) {
return null;
}
if (node[0] === null) {
return null;
}
const tag = getNodeTag(node);
const attributes = getNodeAttributes(node);
if (tag === 'pre') {
const language = typeof attributes.language === 'string' ? attributes.language : 'text';
return h(MarkdownShikiHighlight, {
key: path,
code: textContent(node),
language,
});
}
if (tag === 'math') {
return h(MarkdownMath, {
key: path,
content: typeof attributes.content === 'string' ? attributes.content : textContent(node),
display: typeof attributes.class === 'string' && attributes.class.includes('block'),
});
}
const children = getNodeChildren(node)
.map((child, index) => renderNode(child, `${path}.${index}`))
.filter((child) => child !== null);
return h(
tag,
{ ...toVueAttributes(attributes), key: path },
children,
);
};
const render = () => {
const children = ast.value?.children?.flatMap(renderNode) || [];
const children = parsed.value.nodes
.map((node, index) => renderNode(node, `${props.id}.${index}`))
.filter((child) => child !== null);
return h('div', { class: 'prose-wrapper' }, [
h('article', { class: 'markdown-body' }, children)
h('article', { class: 'markdown-body' }, children),
]);
}
};
</script>
<template>
<render />
</template>
</template>
-65
View File
@@ -1,65 +0,0 @@
import { unified } from 'unified';
import { visit } from 'unist-util-visit';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import remarkBreaks from 'remark-breaks';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
function remarkSplitBlocks() {
return (tree: any) => {
visit(tree, 'paragraph', (node, index, parent) => {
if (parent == null || index == null) {
return;
}
const breakIndex = node.children.findIndex((child: any) => child.type === 'break');
if (breakIndex === -1) {
return;
}
const beforeBreak = node.children.slice(0, breakIndex);
const afterBreak = node.children.slice(breakIndex + 1);
// Keep content before the break in the current paragraph.
node.children = beforeBreak;
// Insert content after the break as the next sibling of this
// paragraph in its actual parent (root, listItem, blockquote, etc.).
// Previously this only spliced into tree.children and dropped
// nested content when the parent wasn't a direct root child.
if (afterBreak.length > 0) {
const newParagraph = {
type: 'paragraph',
children: afterBreak,
};
parent.children.splice(index + 1, 0, newParagraph);
// Continue at the newly inserted paragraph so further
// hard breaks inside it are split as well.
return index + 1;
}
});
};
}
export default defineNuxtPlugin((nuxtApp) => {
const remark =
unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkBreaks)
.use(remarkSplitBlocks)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex, { output: 'mathml' });
return {
provide: {
remark,
}
}
})
+266
View File
@@ -0,0 +1,266 @@
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;
};