feat(markdown): Migrate to comark for markdown rendering
This commit is contained in:
+2
-1
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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('$', '$'));
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user