import { createHighlighterCore, type HighlighterCore } from "shiki/core"; import { createOnigurumaEngine } from "shiki/engine/oniguruma"; import type { LanguageRegistration } from "shiki"; import { aardioGrammar } from "./aardioGrammar"; /** 站点 Markdown 代码高亮:显式白名单(非全量) */ export const SHIKI_LANG_IDS = [ "go", "python", "r", "markdown", "javascript", "typescript", "tsx", "jsx", "json", "jsonc", "yaml", "toml", "ini", "bash", "sql", "html", "css", "scss", "diff", "rust", "ruby", "powershell", "nginx", "dockerfile", "kotlin", "java", "c", "cpp", "csharp", "php", "swift", "dart", "lua", "vue", "graphql", "xml", "regex", "makefile", "prisma", "plaintext", ] as const; export type SiteShikiLang = (typeof SHIKI_LANG_IDS)[number] | "aardio"; export const SHIKI_LANG_ALIAS: Record = { py: "python", md: "markdown", js: "javascript", ts: "typescript", sh: "bash", shell: "bash", zsh: "bash", yml: "yaml", text: "plaintext", txt: "plaintext", golang: "go", rb: "ruby", ps1: "powershell", pwsh: "powershell", "c++": "cpp", cs: "csharp", "c#": "csharp", regexp: "regex", docker: "dockerfile", }; /** * 白名单 id → @shikijs/langs grammar 内部注册名。 * 部分语言的 dist 文件只是别名重导出(如 bash.mjs = shellscript.mjs), * HighlighterCore 按内部名注册,codeToHtml(lang:"bash") 会静默降级 plaintext。 */ const INTERNAL_LANG_ALIAS: Record = { bash: "shellscript", sh: "shellscript", shell: "shellscript", zsh: "shellscript", dockerfile: "docker", regex: "regexp", makefile: "make", }; /** * createHighlighterCore 的 langAlias:输入别名 + 白名单 id 都要能落到 grammar 内部名。 * 注意 core 会校验别名链无环,且 docker/regexp 本身就是内部注册名, * 不能再作为 alias 键(会与 dockerfile→docker、regex→regexp 互指成环)。 */ function buildCoreLangAlias(): Record { const merged = { ...SHIKI_LANG_ALIAS, ...INTERNAL_LANG_ALIAS }; delete merged["docker"]; delete merged["regexp"]; return merged; } export const SHIKI_THEMES = { light: "github-light", dark: "github-dark-dimmed", } as const; const ALLOWED = new Set([...SHIKI_LANG_IDS, "aardio"]); /** 未知语言降级为 plaintext;别名归一到白名单 id */ export function resolveShikiLang(raw?: string | null): SiteShikiLang { const id = (raw || "").trim().toLowerCase(); if (!id) return "plaintext"; const mapped = SHIKI_LANG_ALIAS[id] || id; if (ALLOWED.has(mapped)) return mapped as SiteShikiLang; return "plaintext"; } let highlighterPromise: Promise | null = null; /** 站点单例 highlighter:仅白名单语言 + aardio */ export function getSiteHighlighter(): Promise { if (!highlighterPromise) { highlighterPromise = createHighlighterCore({ themes: [ import("@shikijs/themes/github-light"), import("@shikijs/themes/github-dark-dimmed"), ], langs: [ import("@shikijs/langs/go"), import("@shikijs/langs/python"), import("@shikijs/langs/r"), import("@shikijs/langs/markdown"), import("@shikijs/langs/javascript"), import("@shikijs/langs/typescript"), import("@shikijs/langs/tsx"), import("@shikijs/langs/jsx"), import("@shikijs/langs/json"), import("@shikijs/langs/yaml"), import("@shikijs/langs/toml"), import("@shikijs/langs/bash"), import("@shikijs/langs/sql"), import("@shikijs/langs/html"), import("@shikijs/langs/css"), import("@shikijs/langs/scss"), import("@shikijs/langs/diff"), import("@shikijs/langs/rust"), import("@shikijs/langs/ruby"), import("@shikijs/langs/powershell"), import("@shikijs/langs/nginx"), import("@shikijs/langs/dockerfile"), import("@shikijs/langs/kotlin"), import("@shikijs/langs/java"), import("@shikijs/langs/c"), import("@shikijs/langs/cpp"), import("@shikijs/langs/csharp"), import("@shikijs/langs/php"), import("@shikijs/langs/swift"), import("@shikijs/langs/dart"), import("@shikijs/langs/lua"), import("@shikijs/langs/vue"), import("@shikijs/langs/graphql"), import("@shikijs/langs/xml"), import("@shikijs/langs/regex"), import("@shikijs/langs/makefile"), import("@shikijs/langs/prisma"), // plaintext / text / txt 为 Shiki 特殊语言,无需 grammar aardioGrammar, ], langAlias: buildCoreLangAlias(), engine: createOnigurumaEngine(import("shiki/wasm")), }); } return highlighterPromise; } /** 客户端/工具:按白名单高亮为双主题 HTML */ export async function siteCodeToHtml(code: string, langRaw?: string | null): Promise { const highlighter = await getSiteHighlighter(); const lang = resolveShikiLang(langRaw); return highlighter.codeToHtml(code, { lang, themes: { light: SHIKI_THEMES.light, dark: SHIKI_THEMES.dark, }, defaultColor: false, }); } /** * rehype-pretty-code 会对每个 fence 调用 loadLanguage(langId: string)。 * HighlighterCore 不能把裸 string 当 grammar(会 TypeError 并可能搞坏注册表), * 这里包装:已装载的语言直接跳过。 */ export async function getPrettyCodeHighlighter(): Promise { const core = await getSiteHighlighter(); const loadLanguage = async ( ...langs: (string | LanguageRegistration | Promise)[] ) => { const pending: (LanguageRegistration | Promise)[] = []; const loaded = new Set(core.getLoadedLanguages()); for (const lang of langs.flat()) { if (typeof lang === "string") { const id = resolveShikiLang(lang); if (loaded.has(lang) || loaded.has(id) || id === "plaintext") continue; continue; } pending.push(lang); } if (pending.length > 0) { await core.loadLanguage(...pending); } }; return new Proxy(core, { get(target, prop, receiver) { if (prop === "loadLanguage") return loadLanguage; const value = Reflect.get(target, prop, receiver); return typeof value === "function" ? value.bind(target) : value; }, }) as HighlighterCore; }