Files
remanso/src/hooks/useMarkdown.hook.ts
Julien Calixte e715fb02d3 fix(markdown): cache Shikiji init promise to avoid race on parallel callers
The boolean guard flipped synchronously before the async plugin load
resolved, so concurrent callers (e.g. multiple stacked non-markdown
notes mounting on reload) returned early and rendered before
markdown-it-shikiji was attached to the shared md instance. Cache the
in-flight promise instead so all callers await the same resolution.
2026-04-30 11:03:29 +02:00

202 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MarkdownItTabData, MarkdownItTabInfo } from "@mdit/plugin-tab"
import { tab } from "@mdit/plugin-tab"
import markdownItKatex from "@vscode/markdown-it-katex"
import GithubSlugger from "github-slugger"
import MarkdownIt, { Options } from "markdown-it"
import Renderer, { type RenderRuleRecord } from "markdown-it/lib/renderer.mjs"
import type Token from "markdown-it/lib/token.mjs"
import markdownItAnchor from "markdown-it-anchor"
import blockEmbedPlugin from "markdown-it-block-embed"
import markdownItCheckbox from "markdown-it-checkbox"
import MarkdownItGitHubAlerts from "markdown-it-github-alerts"
import markdownItIframe from "markdown-it-iframe"
import Shikiji from "markdown-it-shikiji"
import mermaid from "mermaid"
import type { LanguageRegistration } from "shikiji-core"
import { Ref, toValue } from "vue"
import alloyGrammar from "@/utils/alloy.tmLanguage.json"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
import { html5Media } from "@/utils/markdown/markdown-html5-media"
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
const markdownItMermaidExtractor = (md: MarkdownIt) => {
const defaultFence =
md.renderer.rules.fence ||
function (
tokens: Array<Token>,
index: number,
options: Options,
_: unknown,
self: Renderer
) {
return self.renderToken(tokens, index, options)
}
md.renderer.rules.fence = function (
tokens: Array<Token>,
index: number,
options: Options,
env: unknown,
self: Renderer
) {
const token = tokens[index]
if (token.info.trim() === "mermaid") {
const content = token.content.trim()
return `<pre class="mermaid">\n${md.utils.escapeHtml(content)}\n</pre>\n`
}
return defaultFence(tokens, index, options, env, self)
}
}
const slugger = new GithubSlugger()
let tabGroupCounter = 0
let currentTabGroup = 0
let currentTabActiveSet = false
const md = new MarkdownIt({
typographer: true,
quotes: ["«\xA0", "\xA0»", "\xA0", "\xA0"]
})
.use(markdownItMermaidExtractor)
.use(html5Media)
.use(blockEmbedPlugin, {
youtube: {
width: "100%",
height: 300
}
})
.use(markdownItCheckbox)
.use(markdownItKatex)
.use(markdownItIframe, {
width: "100%"
})
.use(MarkdownItGitHubAlerts)
.use(markdownItTablerIcons)
.use(tab, {
name: "tabs",
openRender: (info: MarkdownItTabInfo) => {
currentTabGroup = ++tabGroupCounter
currentTabActiveSet = info.active >= 0
return '<div class="tabs tabs-box">\n'
},
closeRender: () => "</div>\n",
tabOpenRender: (data: MarkdownItTabData) => {
const isChecked =
data.isActive || (!currentTabActiveSet && data.index === 0)
const checked = isChecked ? " checked" : ""
const title = data.title.replace(/"/g, "&quot;")
return `<input type="radio" name="md-tabs-${currentTabGroup}" class="tab" aria-label="${title}"${checked}>\n<div class="tab-content bg-base-100 border-base-300 rounded-box p-2">\n`
},
tabCloseRender: () => "</div>\n"
})
.use(markdownItAnchor, {
slugify: (s: string) => slugger.slug(s)
})
let shikijiPromise: Promise<void> | null = null
export const useShikiji = (): Promise<void> => {
if (!shikijiPromise) {
shikijiPromise = Shikiji({
themes: {
light: "vitesse-light",
dark: "vitesse-black"
},
langs: [
"bash",
"javascript",
"typescript",
"markdown",
"mermaid",
"html",
"css",
"json",
{
...alloyGrammar,
name: "alloy",
aliases: ["als"]
} as unknown as LanguageRegistration
]
}).then((plugin) => {
md.use(plugin)
})
}
return shikijiPromise
}
let mermaidInitialized = false
export const runMermaid = (querySelector: string) => {
if (!mermaidInitialized) {
mermaidInitialized = true
mermaid.initialize({
theme: "dark",
startOnLoad: false,
flowchart: { curve: "natural" }
})
}
mermaid.run({
querySelector
})
}
const rules: RenderRuleRecord = {
table_open: () =>
'<div class="overflow-x-auto"><table class="table table-zebra">',
table_close: () => "</table></div>"
}
md.renderer.rules = { ...md.renderer.rules, ...rules }
const stripFrontmatter = (content: string): string => {
const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
return match ? content.slice(match[0].length) : content
}
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
slugger.reset()
return env ? md.render(content, env) : md.render(content)
}
export const renderCodeFile = async ({
rawContent,
lang,
filename
}: {
rawContent: string
lang: string | null
filename?: string
}): Promise<string> => {
await useShikiji()
const heading = filename ? `# ${filename}\n\n` : ""
if (lang !== null) {
return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
}
return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
}
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
const getRawContent = (content: string) => decodeBase64ToUTF8(content)
const renderFromUTF8 = (content: string, prefix?: string) => {
return content
? renderMarkdown(stripFrontmatter(content), {
docId: defaultPrefix ? toValue(defaultPrefix) : (prefix ?? "")
})
: ""
}
return {
toHTML: (content: string) =>
content ? renderMarkdown(stripFrontmatter(content)) : "",
render: (content: string, prefix?: string) =>
renderFromUTF8(decodeBase64ToUTF8(content), prefix),
renderFromUTF8,
getRawContent
}
}