feat(frontend): Shiki 白名单语言高亮并支持 aardio
显式加载常用语言;修复 pretty-code 去 shiki class 导致不上色,以及 aardio 循环别名。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1891,17 +1891,34 @@ a.j13-mention-token:hover {
|
|||||||
background: var(--accent-soft);
|
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,
|
||||||
.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));
|
color: var(--shiki-light, var(--ink));
|
||||||
background-color: transparent !important;
|
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;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
html.dark .prose-content .shiki,
|
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;
|
color: var(--shiki-dark, var(--ink)) !important;
|
||||||
font-style: var(--shiki-dark-font-style) !important;
|
font-style: var(--shiki-dark-font-style) !important;
|
||||||
font-weight: var(--shiki-dark-font-weight) !important;
|
font-weight: var(--shiki-dark-font-weight) !important;
|
||||||
|
|||||||
@@ -136,18 +136,11 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { codeToHtml } = await import("shiki");
|
const { siteCodeToHtml } = await import("./shikiHighlighter");
|
||||||
const html = await codeToHtml(plainText.replace(/\n$/, ""), {
|
const html = await siteCodeToHtml(plainText.replace(/\n$/, ""), lang);
|
||||||
lang: lang === "text" ? "plaintext" : lang,
|
|
||||||
themes: {
|
|
||||||
light: "github-light",
|
|
||||||
dark: "github-dark-dimmed",
|
|
||||||
},
|
|
||||||
defaultColor: false,
|
|
||||||
});
|
|
||||||
if (!cancelled) setClientHtml(html);
|
if (!cancelled) setClientHtml(html);
|
||||||
} catch {
|
} catch {
|
||||||
// 未知语言:保持纯文本
|
// 未知语言 / 加载失败:保持纯文本
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
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 (
|
return (
|
||||||
<div className="md-code-block" ref={rootRef}>
|
<div className="md-code-block" ref={rootRef}>
|
||||||
|
|||||||
117
frontend/components/markdown/aardioGrammar.ts
Normal file
117
frontend/components/markdown/aardioGrammar.ts
Normal file
@@ -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:
|
||||||
|
"\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,24 +6,45 @@ import { unified } from "unified";
|
|||||||
import remarkParse from "remark-parse";
|
import remarkParse from "remark-parse";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkRehype from "remark-rehype";
|
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 } from "hast";
|
||||||
|
import type { Root as MdastRoot } from "mdast";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
import { mergeMarkdownComponents } from "./mergeComponents";
|
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: {
|
theme: {
|
||||||
light: "github-light",
|
light: SHIKI_THEMES.light,
|
||||||
dark: "github-dark-dimmed",
|
dark: SHIKI_THEMES.dark,
|
||||||
},
|
},
|
||||||
keepBackground: false,
|
keepBackground: false,
|
||||||
defaultLang: "plaintext",
|
defaultLang: "plaintext",
|
||||||
|
// HighlighterCore 与 pretty-code 期望的 Highlighter 在运行时兼容
|
||||||
|
getHighlighter: getPrettyCodeHighlighter as unknown as PrettyCodeOptions["getHighlighter"],
|
||||||
// 空行在 display:grid 下会塌缩成 0 高,补一个空格撑住行高
|
// 空行在 display:grid 下会塌缩成 0 高,补一个空格撑住行高
|
||||||
onVisitLine(element: { children: unknown[] }) {
|
onVisitLine(element) {
|
||||||
if (element.children.length === 0) {
|
if (element.children.length === 0) {
|
||||||
element.children = [{ type: "text", value: " " }];
|
element.children = [{ type: "text", value: " " }];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */
|
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */
|
||||||
export async function renderMarkdownToReact(
|
export async function renderMarkdownToReact(
|
||||||
@@ -33,6 +54,7 @@ export async function renderMarkdownToReact(
|
|||||||
const processor = unified()
|
const processor = unified()
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
|
.use(remarkNormalizeCodeLang)
|
||||||
.use(remarkRehype)
|
.use(remarkRehype)
|
||||||
.use(rehypePrettyCode, { ...prettyCodeOptions });
|
.use(rehypePrettyCode, { ...prettyCodeOptions });
|
||||||
|
|
||||||
|
|||||||
116
frontend/components/markdown/shikiHighlighter.ts
Normal file
116
frontend/components/markdown/shikiHighlighter.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string>([...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<HighlighterCore> | null = null;
|
||||||
|
|
||||||
|
/** 站点单例 highlighter:仅白名单语言 + aardio */
|
||||||
|
export function getSiteHighlighter(): Promise<HighlighterCore> {
|
||||||
|
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<string> {
|
||||||
|
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<HighlighterCore> {
|
||||||
|
return getSiteHighlighter();
|
||||||
|
}
|
||||||
3
frontend/package-lock.json
generated
3
frontend/package-lock.json
generated
@@ -20,7 +20,8 @@
|
|||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.2",
|
||||||
"shiki": "^4.4.3",
|
"shiki": "^4.4.3",
|
||||||
"unified": "^11.0.5"
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.2",
|
||||||
"shiki": "^4.4.3",
|
"shiki": "^4.4.3",
|
||||||
"unified": "^11.0.5"
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user