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, ref, toValue } from "vue" import { data } from "@/data/data" import { DataType } from "@/data/DataType.enum" import type { TikzCache } from "@/data/models/TikzCache" import alloyGrammar from "@/utils/alloy.tmLanguage.json" import { decodeBase64ToUTF8, encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8" import { html5Media } from "@/utils/markdown/markdown-html5-media" import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons" import { renderFallback } from "@/utils/markdown/renderFallback" const TIKZ_BUNDLE_URL = "https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js" const TIKZ_STYLES_URL = "https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/styles.css" const TIKZ_RENDER_TIMEOUT_MS = 30000 const markdownItMermaidExtractor = (md: MarkdownIt) => { const defaultFence = md.renderer.rules.fence || function ( tokens: Array, index: number, options: Options, _: unknown, self: Renderer ) { return self.renderToken(tokens, index, options) } md.renderer.rules.fence = function ( tokens: Array, index: number, options: Options, env: unknown, self: Renderer ) { const token = tokens[index] if (token.info.trim() === "mermaid") { const content = token.content.trim() return `
\n${md.utils.escapeHtml(content)}\n
\n` } return defaultFence(tokens, index, options, env, self) } } const markdownItTikzExtractor = (md: MarkdownIt) => { const defaultFence = md.renderer.rules.fence || function ( tokens: Array, index: number, options: Options, _: unknown, self: Renderer ) { return self.renderToken(tokens, index, options) } md.renderer.rules.fence = function ( tokens: Array, index: number, options: Options, env: unknown, self: Renderer ) { const token = tokens[index] if (token.info.trim() === "tikz") { const encoded = encodeUTF8ToBase64(token.content) return `
Rendering TikZ…
\n` } return defaultFence(tokens, index, options, env, self) } } const markdownItMacroplanExtractor = (md: MarkdownIt) => { const defaultFence = md.renderer.rules.fence || function ( tokens: Array, index: number, options: Options, _: unknown, self: Renderer ) { return self.renderToken(tokens, index, options) } md.renderer.rules.fence = function ( tokens: Array, index: number, options: Options, env: unknown, self: Renderer ) { const token = tokens[index] if (token.info.trim() === "macroplan") { const encoded = encodeUTF8ToBase64(token.content) return `
Rendering macroplan…
\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(markdownItTikzExtractor) .use(markdownItMacroplanExtractor) .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 '
\n' }, closeRender: () => "
\n", tabOpenRender: (data: MarkdownItTabData) => { const isChecked = data.isActive || (!currentTabActiveSet && data.index === 0) const checked = isChecked ? " checked" : "" const title = data.title.replace(/"/g, """) return `\n
\n` }, tabCloseRender: () => "
\n" }) .use(markdownItAnchor, { slugify: (s: string) => slugger.slug(s) }) let shikijiPromise: Promise | null = null const shikijiReady = ref(false) export const useShikiji = (): Promise => { if (!shikijiPromise) { shikijiPromise = Shikiji({ themes: { light: "vitesse-light", dark: "vitesse-black" }, langs: [ "bash", "javascript", "typescript", "tsx", "markdown", "mermaid", "html", "css", "json", "rust", { ...alloyGrammar, name: "alloy", aliases: ["als"] } as unknown as LanguageRegistration ] }).then((plugin) => { md.use(plugin) shikijiReady.value = true }) } return shikijiPromise } let mermaidInitialized = false export const runMermaid = (querySelector: string): Promise => { if (!mermaidInitialized) { mermaidInitialized = true mermaid.initialize({ theme: "dark", startOnLoad: false, flowchart: { curve: "natural" } }) } return mermaid.run({ querySelector }) } let tikzBundlePromise: Promise | null = null let tikzStylesPromise: Promise | null = null let domPurifyPromise: Promise | null = null const ensureTikzBundle = (): Promise => { if (tikzBundlePromise) return tikzBundlePromise tikzBundlePromise = new Promise((resolve, reject) => { const existing = document.getElementById("tikzjax-bundle") if (existing) { resolve() return } const script = document.createElement("script") script.id = "tikzjax-bundle" script.src = TIKZ_BUNDLE_URL script.async = true script.crossOrigin = "anonymous" script.addEventListener("load", () => resolve()) script.addEventListener("error", () => { tikzBundlePromise = null reject(new Error("Failed to load TikZ engine")) }) document.head.appendChild(script) }) return tikzBundlePromise } const ensureTikzStyles = (): Promise => { if (tikzStylesPromise) return tikzStylesPromise tikzStylesPromise = new Promise((resolve, reject) => { const existing = document.getElementById("tikzjax-styles") if (existing) { resolve() return } const link = document.createElement("link") link.id = "tikzjax-styles" link.rel = "stylesheet" link.href = TIKZ_STYLES_URL link.crossOrigin = "anonymous" link.addEventListener("load", () => resolve()) link.addEventListener("error", () => { tikzStylesPromise = null reject(new Error("Failed to load TikZ font styles")) }) document.head.appendChild(link) }) return tikzStylesPromise } const ensureDomPurify = (): Promise => { if (!domPurifyPromise) domPurifyPromise = import("dompurify") return domPurifyPromise } const sha256Hex = async (text: string): Promise => { const buf = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(text) ) return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join("") } const sanitizeSvg = async (svg: string): Promise => { const DOMPurify = (await ensureDomPurify()).default return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) } const tidyTikzSource = (s: string): string => s .replaceAll(" ", "") .split("\n") .map((line) => line.trim()) .filter((line) => line) .join("\n") const renderTikzError = (err: unknown, source: string): string => { const msg = err instanceof Error ? err.message : String(err) return ( `
TikZ error` + `
${md.utils.escapeHtml(msg)}
` + `
source` + `
${md.utils.escapeHtml(source)}
` ) } const renderOneTikzBlock = ( el: HTMLElement, source: string ): Promise => { return new Promise((resolve, reject) => { let settled = false const timer = window.setTimeout(() => { if (settled) return settled = true el.removeEventListener("tikzjax-load-finished", handler) reject(new Error("TikZ render timed out")) }, TIKZ_RENDER_TIMEOUT_MS) const handler = (e: Event) => { if (settled) return settled = true window.clearTimeout(timer) el.removeEventListener("tikzjax-load-finished", handler) const target = e.target as Element | null if (!target) { reject(new Error("TikZ produced no output")) return } resolve(target.outerHTML) } el.addEventListener("tikzjax-load-finished", handler) while (el.firstChild) el.removeChild(el.firstChild) const script = document.createElement("script") script.type = "text/tikz" script.textContent = source el.appendChild(script) }) } export const runTikz = async (querySelector: string): Promise => { const elements = Array.from( document.querySelectorAll(querySelector) ) if (elements.length === 0) return void ensureTikzStyles().catch(() => undefined) await Promise.all( elements.map(async (el) => { if (el.dataset.tikzRendered) return el.dataset.tikzRendered = "pending" const encoded = el.dataset.tikzSource if (!encoded) { el.dataset.tikzRendered = "error" return } const source = tidyTikzSource(decodeBase64ToUTF8(encoded)) let hash: string try { hash = await sha256Hex(source) } catch (err) { el.innerHTML = renderTikzError(err, source) el.dataset.tikzRendered = "error" return } const cacheId = `${DataType.TikzCache}-${hash}` const cached = await data.get(cacheId) if (cached?.svg) { el.innerHTML = cached.svg el.dataset.tikzRendered = "true" return } try { await ensureTikzBundle() const rawSvg = await renderOneTikzBlock(el, source) const sanitized = await sanitizeSvg(rawSvg) el.innerHTML = sanitized el.dataset.tikzRendered = "true" void data.add({ _id: cacheId, $type: DataType.TikzCache, svg: sanitized } as TikzCache) } catch (err) { el.innerHTML = renderTikzError(err, source) el.dataset.tikzRendered = "error" } }) ) } const rules: RenderRuleRecord = { table_open: () => '
', table_close: () => "
" } 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) => { // Track Shikiji readiness so reactive consumers (e.g. the `content` // computed in useFile) re-render once `useShikiji()` mutates the // singleton `md` — otherwise the first render on page load stays // unhighlighted because nothing else invalidates the computed. if (content.includes("```")) void shikijiReady.value slugger.reset() try { return env ? md.render(content, env) : md.render(content) } catch (error) { // Never let a plugin failure blank the note or dump a raw error — // degrade to the note's raw text. console.error("markdown render failed", error) return renderFallback(content) } } export const renderCodeFile = async ({ rawContent, lang, filename }: { rawContent: string lang: string | null filename?: string }): Promise => { await useShikiji() const heading = filename ? `# ${filename}\n\n` : "" // Drop the trailing newline so the final empty line isn't numbered. const lines = rawContent.replace(/\n$/, "").split("\n") // Sized gutter so line numbers stay right-aligned past 3 digits. const wrapper = `
` if (lang !== null) { const rendered = renderMarkdown( `${heading}\`\`\`\`${lang}\n${lines.join("\n")}\n\`\`\`\`` ) // Shikiji always appends an empty line span; drop it so it isn't numbered. return `${wrapper}${rendered.replace('\n', "")}
` } // Mirror Shikiji's per-line spans so the line-number CSS applies uniformly. const code = lines .map((line) => `${md.utils.escapeHtml(line)}`) .join("\n") return `${wrapper}${renderMarkdown(heading)}
${code}
` } export const markdownBuilder = (defaultPrefix?: Ref | 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 } }