docs: harden the shared markdown parser

This commit is contained in:
Benjamin Canac
2026-09-11 16:30:03 +02:00
parent 038857264d
commit 57dc361712
3 changed files with 54 additions and 28 deletions
+12 -9
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { MarkdownDocument } from '@comark/vue'
import { parseMarkdown } from '../../utils/markdown'
import { parseMarkdownDoc } from '../../utils/markdown'
import type { MarkdownDoc } from '../../utils/markdown'
/**
@@ -21,7 +21,7 @@ async function resolve(value: string | MarkdownDoc | undefined) {
return null
}
const doc = typeof value === 'string' ? await parseMarkdown(value) : value
const doc = typeof value === 'string' ? await parseMarkdownDoc(value) : value
if (!props.unwrap) {
return doc
}
@@ -32,19 +32,22 @@ async function resolve(value: string | MarkdownDoc | undefined) {
return { ...doc, nodes } as MarkdownDoc
}
// awaited so the server renders it; the watch covers a value that changes after
const doc = shallowRef<MarkdownDoc | null>(await resolve(props.value))
const doc = shallowRef<MarkdownDoc | null>(null)
// the last change wins: a parse for a value that has moved on is dropped,
// and a cached document resolves ahead of one still being parsed
// the last change wins: a parse for a value that has moved on is dropped
let version = 0
watch(() => props.value, async (value) => {
async function update() {
const current = ++version
const next = await resolve(value)
const next = await resolve(props.value)
if (current === version) {
doc.value = next
}
})
}
watch(() => [props.value, props.unwrap], update)
// awaited so the server renders it
await update()
</script>
<template>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import { parseMarkdown } from '../../../utils/markdown'
import { parseMarkdownDoc } from '../../../utils/markdown'
import type { MarkdownDoc } from '../../../utils/markdown'
/**
@@ -43,7 +43,7 @@ const { data: ast } = await useAsyncData(`release-${release?.tag ?? 'unavailable
const { markdown } = await $fetch(`/api/github/releases/${release.tag}`)
return await parseMarkdown(linkMentions(markdown))
return await parseMarkdownDoc(linkMentions(markdown))
})
const notes = computed(() => ast.value as MarkdownDoc | null)
+40 -17
View File
@@ -1,12 +1,19 @@
import { createMarkdownParser, defineComarkPlugin } from '@comark/vue/parse'
import shiki, { getHighlighter } from '@comark/vue/plugins/shiki'
import type { ShikiOptions } from '@comark/vue/plugins/shiki'
import { codeToTokens, getTokenStyleObject, stringifyTokenStyle } from 'shiki/core'
import type { ComarkParsePostState } from '@comark/vue'
import toc from '@comark/vue/plugins/toc'
import emoji from '@comark/vue/plugins/emoji'
import bash from 'shiki/dist/langs/bash.mjs'
import css from 'shiki/dist/langs/css.mjs'
import diff from 'shiki/dist/langs/diff.mjs'
import html from 'shiki/dist/langs/html.mjs'
import javascript from 'shiki/dist/langs/javascript.mjs'
import json from 'shiki/dist/langs/json.mjs'
import typescript from 'shiki/dist/langs/typescript.mjs'
import vue from 'shiki/dist/langs/vue.mjs'
import yaml from 'shiki/dist/langs/yaml.mjs'
import { shikiTransformers } from './shiki'
/** A rendered element as Comark writes it: its tag, its props, then its children. */
@@ -15,6 +22,19 @@ type MarkdownNode = string | MarkdownElement
const THEMES = { light: 'material-theme-lighter', dark: 'material-theme-palenight' } as const
/**
* The highlighter is a singleton, built by whichever of the two plugins reaches
* it first, so both pass the same options: a document holding only inline code
* never enters the shiki plugin and would otherwise build it with the defaults.
* Those defaults also carry tsx, svelte and astro that nothing here writes, so
* the grammars are listed instead, html and the scripts before vue embeds them.
*/
const SHIKI_OPTIONS: ShikiOptions = {
registerDefaultLanguages: false,
languages: [...html, ...css, ...javascript, ...typescript, ...vue, ...bash, ...json, ...yaml, ...diff],
transformers: shikiTransformers()
}
/**
* The pseudo languages the docs write inline, as `@nuxtjs/mdc` resolved them:
* a bare type expression and a template fragment, each highlighted in the
@@ -55,22 +75,25 @@ const inlineShiki = defineComarkPlugin(() => ({
if (!pending.length) return
// the instance the shiki plugin built, which runs before this one
const highlighter = await getHighlighter()
const highlighter = await getHighlighter(SHIKI_OPTIONS)
for (const [node, lang, code] of pending) {
const context = CONTEXTS[lang]
try {
const { tokens } = codeToTokens(highlighter, code, { themes: THEMES, ...context ?? { lang } })
// built aside so a throw halfway through leaves the node untouched
const children: MarkdownNode[] = []
for (const [index, line] of tokens.entries()) {
if (index) children.push('\n')
for (const token of line) {
const style = stringifyTokenStyle(token.htmlStyle || getTokenStyleObject(token))
children.push(style ? ['span', { style }, token.content] : token.content)
}
}
const props = node[1]
props.class = [props.class, 'shiki'].filter(Boolean).join(' ')
node.length = 2
for (const [index, line] of tokens.entries()) {
if (index) node.push('\n')
for (const token of line) {
const style = stringifyTokenStyle(token.htmlStyle || getTokenStyleObject(token))
node.push(style ? ['span', { style }, token.content] : token.content)
}
}
node.push(...children)
} catch {
// an unknown language stays as it was written
}
@@ -84,11 +107,7 @@ const inlineShiki = defineComarkPlugin(() => ({
* through here, so the site holds one parser and one highlighter.
*/
const markdownPlugins = [
shiki({
// on top of the plugin's defaults, which the array is appended to
languages: [...css, ...diff, ...html],
transformers: shikiTransformers()
}),
shiki(SHIKI_OPTIONS),
// the release notes carry their own table of contents
toc({ depth: 3, searchDepth: 3 }),
// GitHub writes its release headings with shortcodes, and `:sparkles:` reads
@@ -116,13 +135,14 @@ export type MarkdownDoc = Awaited<ReturnType<typeof parse>>
* Documents by source. Every runtime caller comes through here, and the same
* string is rendered over and over (a prop description repeated across pages,
* a type in two tables), so the parse happens once. The promise is cached, so
* two callers racing on one string share the work, and the whole thing is
* dropped once it has seen enough: this module outlives a request on the server.
* two callers racing on one string share the work, and the oldest entry goes
* once the map is full: this module outlives a request on the server.
*/
const DOCUMENT_LIMIT = 500
const documents = new Map<string, Promise<MarkdownDoc>>()
export function parseMarkdown(markdown: string): Promise<MarkdownDoc> {
// not `parseMarkdown`, which `@nuxtjs/mdc` already auto-imports under that name
export function parseMarkdownDoc(markdown: string): Promise<MarkdownDoc> {
const cached = documents.get(markdown)
if (cached) {
return cached
@@ -131,10 +151,13 @@ export function parseMarkdown(markdown: string): Promise<MarkdownDoc> {
const doc = parse(markdown)
if (documents.size >= DOCUMENT_LIMIT) {
documents.clear()
documents.delete(documents.keys().next().value!)
}
documents.set(markdown, doc)
// a transient failure would otherwise replay for every later caller
doc.catch(() => documents.delete(markdown))
return doc
}