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

@@ -1725,13 +1725,23 @@ a.j13-mention-token:hover {
color: inherit; color: inherit;
min-width: 100%; min-width: 100%;
} }
/* rehype-pretty-code grid 行:保证每行有高度,避免折叠/展开后大片空白 */ /* grid 代码行:勿设 min-height,否则会把行距撑得很大并在底部留白 */
.prose-content pre code[style*="grid"] {
display: grid;
width: max-content;
min-width: 100%;
}
.prose-content pre code[style*="grid"] > [data-line], .prose-content pre code[style*="grid"] > [data-line],
.prose-content .md-code-shiki-host code > .line, .prose-content .md-code-shiki-host code > .line,
.prose-content .md-code-shiki-host code > [data-line] { .prose-content .md-code-shiki-host code > [data-line] {
min-height: 1.65em;
line-height: 1.65; line-height: 1.65;
} }
/* 空行仍占一行高度(pretty-code onVisitLine 会塞空格) */
.prose-content pre code[style*="grid"] > [data-line]:empty::after,
.prose-content .md-code-shiki-host code > .line:empty::after {
content: " ";
white-space: pre;
}
.prose-content blockquote { .prose-content blockquote {
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
background: var(--accent-soft); background: var(--accent-soft);

View File

@@ -96,27 +96,53 @@ function readProp(
return typeof v === "string" ? v : undefined; 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) { export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const restMap = rest as Record<string, unknown>; const restMap = rest as Record<string, unknown>;
const dataLanguage = readProp(restMap, "data-language"); const dataLanguage = readProp(restMap, "data-language");
const dataTheme = readProp(restMap, "data-theme");
const plainText = useMemo(() => extractText(children), [children]); const plainText = useMemo(() => extractText(children), [children]);
const approxLines = useMemo(() => countLines(null, plainText), [plainText]); const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
// 以是否存在 --shiki-light token 为准;勿仅凭 data-language(失败回退 plaintext 时仍带该属性)
// rehype-pretty-code 会去掉 pre 上的 shiki class,但会保留 data-theme / data-language const serverHighlighted = useMemo(() => hasShikiTokens(children), [children]);
const serverHighlighted =
(typeof className === "string" && className.includes("shiki")) ||
!!dataTheme ||
!!dataLanguage;
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = 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(() => const [language, setLanguage] = useState(() =>
readLanguage(null, className, dataLanguage) readLanguage(null, className, dataLanguage)
); );
@@ -127,9 +153,10 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
const pre = host?.querySelector("pre") ?? null; const pre = host?.querySelector("pre") ?? null;
setLineCount(countLines(pre, plainText)); setLineCount(countLines(pre, plainText));
setLanguage(readLanguage(pre, className, dataLanguage)); setLanguage(readLanguage(pre, className, dataLanguage));
setFoldReady(true);
}, [plainText, className, dataLanguage, clientHtml]); }, [plainText, className, dataLanguage, clientHtml]);
// 仅客户端预览(无 SSR 高亮)时动态补双主题;切勿覆盖服务端已渲染内容 // SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
useEffect(() => { useEffect(() => {
if (serverHighlighted || !plainText.trim()) return; if (serverHighlighted || !plainText.trim()) return;
const lang = readLanguage(null, className, dataLanguage) || "plaintext"; const lang = readLanguage(null, className, dataLanguage) || "plaintext";
@@ -149,7 +176,7 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
}, [plainText, className, dataLanguage, serverHighlighted]); }, [plainText, className, dataLanguage, serverHighlighted]);
const effectiveLines = lineCount > 0 ? lineCount : approxLines; const effectiveLines = lineCount > 0 ? lineCount : approxLines;
const canCollapse = effectiveLines > COLLAPSE_LINE_THRESHOLD; const canCollapse = foldReady && effectiveLines > COLLAPSE_LINE_THRESHOLD;
const collapsed = canCollapse && !expanded; const collapsed = canCollapse && !expanded;
const onCopy = async () => { const onCopy = async () => {

View File

@@ -1,13 +1,13 @@
import type { LanguageRegistration } from "shiki"; 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 * https://www.aardio.com/zh-cn/docs/js/prism.js
*/ */
export const aardioGrammar: LanguageRegistration = { export const aardioGrammar: LanguageRegistration = {
name: "aardio", name: "aardio",
scopeName: "source.aardio", scopeName: "source.aardio",
// 勿把 name 自身再写进 aliases,否则 Shiki 报 Circular alias `aardio -> aardio` // 勿把 name 自身再写进 aliases,否则 Shiki 报 Circular alias
patterns: [ patterns: [
{ include: "#block-comment" }, { include: "#block-comment" },
{ include: "#line-comment" }, { include: "#line-comment" },
@@ -25,9 +25,9 @@ export const aardioGrammar: LanguageRegistration = {
repository: { repository: {
"block-comment": { "block-comment": {
name: "comment.block.aardio", name: "comment.block.aardio",
begin: "/\\*+", // 用明确的 /* ... */,不要 include self(会在注释内再次匹配运算符)
end: "\\*+/", begin: "/\\*",
patterns: [{ include: "self" }], end: "\\*/",
}, },
"line-comment": { "line-comment": {
name: "comment.line.double-slash.aardio", name: "comment.line.double-slash.aardio",
@@ -76,17 +76,16 @@ export const aardioGrammar: LanguageRegistration = {
keywords: { keywords: {
name: "keyword.control.aardio", name: "keyword.control.aardio",
match: 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: { literals: {
name: "constant.language.aardio", name: "constant.language.aardio",
match: "\\b(?:true|false|null)\\b", match: "\\b(?:true|false|null)\\b",
}, },
"function-call": { "function-call": {
// 排除关键字,避免 if( 被当成函数
name: "entity.name.function.aardio", name: "entity.name.function.aardio",
match: 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: { numbers: {
patterns: [ patterns: [
@@ -110,8 +109,9 @@ export const aardioGrammar: LanguageRegistration = {
}, },
operators: { operators: {
name: "keyword.operator.aardio", name: "keyword.operator.aardio",
// / 与 * 用前瞻避开注释分隔符 /* */,避免把注释拆成运算符
match: match:
"\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]", "\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|\\+=|-=|\\*=|/=|%=|&=|\\|=|\\^=|[=!<>]=?|[+\\-~%?:]|\\^|&|\\||/(?!\\*)|\\*(?!/)",
}, },
}, },
}; };

View File

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