fix(frontend): 修复 aardio 注释着色、代码行距与折叠 hydration

注释不再被运算符拆开;去掉 grid 行 min-height;挂载后再折叠以避免 SSR/CSR 不一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 06:25:25 +08:00
parent 8d5e00a112
commit 2dd9cb82b3
4 changed files with 90 additions and 25 deletions

View File

@@ -96,27 +96,53 @@ function readProp(
return typeof v === "string" ? v : undefined;
}
/** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端仅在未 SSR 高亮时补色 */
/** 子树里是否已有 Shiki 双主题 token(--shiki-light),用于判断 SSR 是否真正着色成功 */
function hasShikiTokens(node: ReactNode): boolean {
if (node == null || typeof node === "boolean") return false;
if (Array.isArray(node)) return node.some(hasShikiTokens);
if (!isReactElement(node)) return false;
const props = node.props as Record<string, unknown>;
const style = props.style;
if (typeof style === "string" && style.includes("--shiki-light")) return true;
if (
style &&
typeof style === "object" &&
("--shiki-light" in (style as object) ||
Object.values(style as Record<string, unknown>).some(
(v) => typeof v === "string" && String(v).includes("#")
))
) {
// React 可能把 CSS 变量写成 style={{ ['--shiki-light']: '#xxx' }}
const rec = style as Record<string, unknown>;
if ("--shiki-light" in rec || "color" in rec) {
// 有 --shiki-light 即视为已着色;仅有 color 不够(可能是别的)
if ("--shiki-light" in rec) return true;
}
for (const k of Object.keys(rec)) {
if (k.includes("shiki")) return true;
}
}
return hasShikiTokens(props.children as ReactNode);
}
/** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端仅在未真正着色时补色 */
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const restMap = rest as Record<string, unknown>;
const dataLanguage = readProp(restMap, "data-language");
const dataTheme = readProp(restMap, "data-theme");
const plainText = useMemo(() => extractText(children), [children]);
const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
// rehype-pretty-code 会去掉 pre 上的 shiki class,但会保留 data-theme / data-language
const serverHighlighted =
(typeof className === "string" && className.includes("shiki")) ||
!!dataTheme ||
!!dataLanguage;
// 以是否存在 --shiki-light token 为准;勿仅凭 data-language(失败回退 plaintext 时仍带该属性)
const serverHighlighted = useMemo(() => hasShikiTokens(children), [children]);
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const [lineCount, setLineCount] = useState(approxLines);
/** 挂载后再启用折叠,避免 SSR/CSR 行数统计不一致导致 hydration mismatch */
const [foldReady, setFoldReady] = useState(false);
const [lineCount, setLineCount] = useState(0);
const [language, setLanguage] = useState(() =>
readLanguage(null, className, dataLanguage)
);
@@ -127,9 +153,10 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
const pre = host?.querySelector("pre") ?? null;
setLineCount(countLines(pre, plainText));
setLanguage(readLanguage(pre, className, dataLanguage));
setFoldReady(true);
}, [plainText, className, dataLanguage, clientHtml]);
// 仅客户端预览(无 SSR 高亮)时动态补双主题;切勿覆盖服务端已渲染内容
// SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
useEffect(() => {
if (serverHighlighted || !plainText.trim()) return;
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
@@ -149,7 +176,7 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
}, [plainText, className, dataLanguage, serverHighlighted]);
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
const canCollapse = effectiveLines > COLLAPSE_LINE_THRESHOLD;
const canCollapse = foldReady && effectiveLines > COLLAPSE_LINE_THRESHOLD;
const collapsed = canCollapse && !expanded;
const onCopy = async () => {

View File

@@ -1,13 +1,13 @@
import type { LanguageRegistration } from "shiki";
/**
* aardio TextMate grammar(对齐官方 Prism / 用户提供的 highlight.js 规则):
* 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`
// 勿把 name 自身再写进 aliases,否则 Shiki 报 Circular alias
patterns: [
{ include: "#block-comment" },
{ include: "#line-comment" },
@@ -25,9 +25,9 @@ export const aardioGrammar: LanguageRegistration = {
repository: {
"block-comment": {
name: "comment.block.aardio",
begin: "/\\*+",
end: "\\*+/",
patterns: [{ include: "self" }],
// 用明确的 /* ... */,不要 include self(会在注释内再次匹配运算符)
begin: "/\\*",
end: "\\*/",
},
"line-comment": {
name: "comment.line.double-slash.aardio",
@@ -76,17 +76,16 @@ export const aardioGrammar: LanguageRegistration = {
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",
"\\b(?:begin|end|if|lambda|\\u03BB|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*\\()",
"\\b(?!(?:begin|end|if|lambda|\\u03BB|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)[_$A-Za-z][_$A-Za-z0-9]*(?=\\s*\\()",
},
numbers: {
patterns: [
@@ -110,8 +109,9 @@ export const aardioGrammar: LanguageRegistration = {
},
operators: {
name: "keyword.operator.aardio",
// / 与 * 用前瞻避开注释分隔符 /* */,避免把注释拆成运算符
match:
"\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]",
"\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|\\+=|-=|\\*=|/=|%=|&=|\\|=|\\^=|[=!<>]=?|[+\\-~%?:]|\\^|&|\\||/(?!\\*)|\\*(?!/)",
},
},
};

View File

@@ -1,5 +1,6 @@
import { createHighlighterCore, type HighlighterCore } from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
import type { LanguageRegistration } from "shiki";
import { aardioGrammar } from "./aardioGrammar";
/** 站点 Markdown 代码高亮:显式白名单(非全量) */
@@ -108,9 +109,36 @@ export async function siteCodeToHtml(code: string, langRaw?: string | null): Pro
}
/**
* 供 rehype-pretty-code 的 getHighlighter:
* 忽略其默认 langs:['plaintext'],始终返回站点白名单单例。
* rehype-pretty-code 会对每个 fence 调用 loadLanguage(langId: string)。
* HighlighterCore 不能把裸 string 当 grammar(会 TypeError 并可能搞坏注册表),
* 这里包装:已装载的语言直接跳过。
*/
export async function getPrettyCodeHighlighter(): Promise<HighlighterCore> {
return getSiteHighlighter();
const core = await getSiteHighlighter();
const loadLanguage = async (
...langs: (string | LanguageRegistration | Promise<LanguageRegistration>)[]
) => {
const pending: (LanguageRegistration | Promise<LanguageRegistration>)[] = [];
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;
}