diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 0d36c7e..92c6373 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -1891,17 +1891,34 @@ a.j13-mention-token:hover {
background: var(--accent-soft);
}
-/* Shiki 双主题:跟随 html.dark;变量缺省时回退到正文色,避免整块「隐形」 */
+/* Shiki 双主题:跟随 html.dark
+ 注意:rehype-pretty-code 会去掉 pre 上的 .shiki class,
+ 因此同时用 data-theme / 代码块容器命中,才能让 --shiki-* 变量真正上色 */
.prose-content .shiki,
-.prose-content .shiki span {
+.prose-content .shiki span,
+.prose-content .md-code-block pre,
+.prose-content .md-code-block pre span,
+.prose-content .md-code-shiki-host .shiki,
+.prose-content .md-code-shiki-host .shiki span,
+.prose-content pre[data-theme],
+.prose-content pre[data-theme] span {
color: var(--shiki-light, var(--ink));
background-color: transparent !important;
}
-.prose-content .shiki {
+.prose-content .shiki,
+.prose-content .md-code-block pre,
+.prose-content .md-code-shiki-host .shiki,
+.prose-content pre[data-theme] {
background: transparent !important;
}
html.dark .prose-content .shiki,
-html.dark .prose-content .shiki span {
+html.dark .prose-content .shiki span,
+html.dark .prose-content .md-code-block pre,
+html.dark .prose-content .md-code-block pre span,
+html.dark .prose-content .md-code-shiki-host .shiki,
+html.dark .prose-content .md-code-shiki-host .shiki span,
+html.dark .prose-content pre[data-theme],
+html.dark .prose-content pre[data-theme] span {
color: var(--shiki-dark, var(--ink)) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
diff --git a/frontend/components/markdown/MdCodeBlock.tsx b/frontend/components/markdown/MdCodeBlock.tsx
index 93429d3..927470c 100644
--- a/frontend/components/markdown/MdCodeBlock.tsx
+++ b/frontend/components/markdown/MdCodeBlock.tsx
@@ -136,18 +136,11 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
let cancelled = false;
(async () => {
try {
- const { codeToHtml } = await import("shiki");
- const html = await codeToHtml(plainText.replace(/\n$/, ""), {
- lang: lang === "text" ? "plaintext" : lang,
- themes: {
- light: "github-light",
- dark: "github-dark-dimmed",
- },
- defaultColor: false,
- });
+ const { siteCodeToHtml } = await import("./shikiHighlighter");
+ const html = await siteCodeToHtml(plainText.replace(/\n$/, ""), lang);
if (!cancelled) setClientHtml(html);
} catch {
- // 未知语言:保持纯文本
+ // 未知语言 / 加载失败:保持纯文本
}
})();
return () => {
@@ -185,7 +178,7 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
}
};
- const preClass = ["md-code-pre", className].filter(Boolean).join(" ");
+ const preClass = ["md-code-pre", "shiki", className].filter(Boolean).join(" ");
return (
diff --git a/frontend/components/markdown/aardioGrammar.ts b/frontend/components/markdown/aardioGrammar.ts
new file mode 100644
index 0000000..53d84e0
--- /dev/null
+++ b/frontend/components/markdown/aardioGrammar.ts
@@ -0,0 +1,117 @@
+import type { LanguageRegistration } from "shiki";
+
+/**
+ * aardio TextMate grammar(对齐官方 Prism / 用户提供的 highlight.js 规则):
+ * https://www.aardio.com/zh-cn/docs/js/prism.js
+ */
+export const aardioGrammar: LanguageRegistration = {
+ name: "aardio",
+ scopeName: "source.aardio",
+ // 勿把 name 自身再写进 aliases,否则 Shiki 报 Circular alias `aardio -> aardio`
+ patterns: [
+ { include: "#block-comment" },
+ { include: "#line-comment" },
+ { include: "#string-double" },
+ { include: "#string-backtick" },
+ { include: "#string-single" },
+ { include: "#constant-underscore" },
+ { include: "#class-decl" },
+ { include: "#keywords" },
+ { include: "#literals" },
+ { include: "#function-call" },
+ { include: "#numbers" },
+ { include: "#operators" },
+ ],
+ repository: {
+ "block-comment": {
+ name: "comment.block.aardio",
+ begin: "/\\*+",
+ end: "\\*+/",
+ patterns: [{ include: "self" }],
+ },
+ "line-comment": {
+ name: "comment.line.double-slash.aardio",
+ match: "//.*$",
+ },
+ "string-double": {
+ name: "string.quoted.double.aardio",
+ begin: '"',
+ end: '"',
+ patterns: [{ name: "constant.character.escape.aardio", match: '""' }],
+ },
+ "string-backtick": {
+ name: "string.quoted.other.aardio",
+ begin: "`",
+ end: "`",
+ patterns: [{ name: "constant.character.escape.aardio", match: "``" }],
+ },
+ "string-single": {
+ name: "string.quoted.single.aardio",
+ begin: "'",
+ end: "'",
+ patterns: [
+ {
+ name: "constant.character.escape.aardio",
+ match: "\\\\.",
+ },
+ ],
+ },
+ "constant-underscore": {
+ name: "constant.other.aardio",
+ match: "\\b_[A-Za-z]\\w*\\b",
+ },
+ "class-decl": {
+ begin: "\\b(class)\\b",
+ beginCaptures: {
+ "1": { name: "keyword.control.aardio" },
+ },
+ end: "(?=[{;])",
+ patterns: [
+ {
+ name: "entity.name.type.class.aardio",
+ match: "[\\w.\\\\]+",
+ },
+ ],
+ },
+ keywords: {
+ name: "keyword.control.aardio",
+ match:
+ "\\b(?:begin|end|if|lambda|λ|else|elseif|class|function|return|while|do|namespace|select|case|catch|try|for|in|this|global|self|owner|var|def|and|not|or|break|continue|import|with|ctor|eval|type|assert|assert2|assertf|error|rget|callex|errput|loadcode|dumpcode|collectgarbage|call|invoke|tostring|topointer|tonumber|sleep|execute|setlocale|setprivilege|loadcodex|reduce|switch)\\b",
+ },
+ literals: {
+ name: "constant.language.aardio",
+ match: "\\b(?:true|false|null)\\b",
+ },
+ "function-call": {
+ // 排除关键字,避免 if( 被当成函数
+ name: "entity.name.function.aardio",
+ match:
+ "\\b(?!(?:begin|end|if|lambda|λ|else|elseif|class|function|return|while|do|namespace|select|case|catch|try|for|in|this|global|self|owner|var|def|null|and|not|or|break|continue|import|with|ctor|eval|type|assert|assert2|assertf|error|rget|callex|errput|loadcode|dumpcode|collectgarbage|call|invoke|tostring|topointer|tonumber|sleep|execute|setlocale|setprivilege|loadcodex|reduce|switch|true|false)\\b)[_$[:alpha:]][_$[:alnum:]]*(?=\\s*\\()",
+ },
+ numbers: {
+ patterns: [
+ {
+ name: "constant.numeric.aardio",
+ match: "\\b0x[a-fA-F_\\d]+(?:\\.[a-fA-F_\\d]*)?(?:p[+-]?\\d[_\\d]*)?\\b",
+ },
+ {
+ name: "constant.numeric.aardio",
+ match: "\\b\\d+#[\\d_]+\\b",
+ },
+ {
+ name: "constant.numeric.aardio",
+ match: "\\b\\d[_\\d]*(?:\\.(?![\\w.])|\\.[_\\d]*)?(?:e[+-]?\\d[_\\d]*)?\\b",
+ },
+ {
+ name: "constant.numeric.aardio",
+ match: "\\.\\d[_\\d]*(?:e[+-]?\\d[_\\d]*)?\\b",
+ },
+ ],
+ },
+ operators: {
+ name: "keyword.operator.aardio",
+ match:
+ "\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]",
+ },
+ },
+};
diff --git a/frontend/components/markdown/renderMarkdown.tsx b/frontend/components/markdown/renderMarkdown.tsx
index 16b7fb4..4bb19bf 100644
--- a/frontend/components/markdown/renderMarkdown.tsx
+++ b/frontend/components/markdown/renderMarkdown.tsx
@@ -6,24 +6,45 @@ import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
-import rehypePrettyCode from "rehype-pretty-code";
+import rehypePrettyCode, { type Options as PrettyCodeOptions } from "rehype-pretty-code";
import type { Root } from "hast";
+import type { Root as MdastRoot } from "mdast";
+import { visit } from "unist-util-visit";
import { mergeMarkdownComponents } from "./mergeComponents";
+import {
+ getPrettyCodeHighlighter,
+ resolveShikiLang,
+ SHIKI_THEMES,
+} from "./shikiHighlighter";
-export const prettyCodeOptions = {
+/**
+ * 将代码围栏语言归一到白名单(未知 → plaintext),
+ * 避免 rehype-pretty-code 对未装载语言 loadLanguage 失败。
+ */
+function remarkNormalizeCodeLang() {
+ return (tree: MdastRoot) => {
+ visit(tree, "code", (node) => {
+ node.lang = resolveShikiLang(node.lang);
+ });
+ };
+}
+
+export const prettyCodeOptions: PrettyCodeOptions = {
theme: {
- light: "github-light",
- dark: "github-dark-dimmed",
+ light: SHIKI_THEMES.light,
+ dark: SHIKI_THEMES.dark,
},
keepBackground: false,
defaultLang: "plaintext",
+ // HighlighterCore 与 pretty-code 期望的 Highlighter 在运行时兼容
+ getHighlighter: getPrettyCodeHighlighter as unknown as PrettyCodeOptions["getHighlighter"],
// 空行在 display:grid 下会塌缩成 0 高,补一个空格撑住行高
- onVisitLine(element: { children: unknown[] }) {
+ onVisitLine(element) {
if (element.children.length === 0) {
element.children = [{ type: "text", value: " " }];
}
},
-} as const;
+};
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */
export async function renderMarkdownToReact(
@@ -33,6 +54,7 @@ export async function renderMarkdownToReact(
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
+ .use(remarkNormalizeCodeLang)
.use(remarkRehype)
.use(rehypePrettyCode, { ...prettyCodeOptions });
diff --git a/frontend/components/markdown/shikiHighlighter.ts b/frontend/components/markdown/shikiHighlighter.ts
new file mode 100644
index 0000000..aecaf5f
--- /dev/null
+++ b/frontend/components/markdown/shikiHighlighter.ts
@@ -0,0 +1,116 @@
+import { createHighlighterCore, type HighlighterCore } from "shiki/core";
+import { createOnigurumaEngine } from "shiki/engine/oniguruma";
+import { aardioGrammar } from "./aardioGrammar";
+
+/** 站点 Markdown 代码高亮:显式白名单(非全量) */
+export const SHIKI_LANG_IDS = [
+ "go",
+ "python",
+ "r",
+ "markdown",
+ "javascript",
+ "typescript",
+ "tsx",
+ "jsx",
+ "json",
+ "yaml",
+ "toml",
+ "bash",
+ "sql",
+ "html",
+ "css",
+ "scss",
+ "diff",
+ "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",
+};
+
+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"),
+ // plaintext / text / txt 为 Shiki 特殊语言,无需 grammar
+ aardioGrammar,
+ ],
+ langAlias: SHIKI_LANG_ALIAS,
+ 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 的 getHighlighter:
+ * 忽略其默认 langs:['plaintext'],始终返回站点白名单单例。
+ */
+export async function getPrettyCodeHighlighter(): Promise {
+ return getSiteHighlighter();
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f9149d6..15f2507 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -20,7 +20,8 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^4.4.3",
- "unified": "^11.0.5"
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
diff --git a/frontend/package.json b/frontend/package.json
index ba0e035..1a6171d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,7 +21,8 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^4.4.3",
- "unified": "^11.0.5"
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",