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",