From 2dd9cb82b3c155e7263e626f202e648138cc946e Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 16 Sep 2026 06:25:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=20aardio=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E7=9D=80=E8=89=B2=E3=80=81=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=A1=8C=E8=B7=9D=E4=B8=8E=E6=8A=98=E5=8F=A0=20hydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注释不再被运算符拆开;去掉 grid 行 min-height;挂载后再折叠以避免 SSR/CSR 不一致。 Co-authored-by: Cursor --- frontend/app/globals.css | 14 +++++- frontend/components/markdown/MdCodeBlock.tsx | 49 ++++++++++++++----- frontend/components/markdown/aardioGrammar.ts | 18 +++---- .../components/markdown/shikiHighlighter.ts | 34 +++++++++++-- 4 files changed, 90 insertions(+), 25 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 92c6373..5412734 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1725,13 +1725,23 @@ a.j13-mention-token:hover { color: inherit; 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 .md-code-shiki-host code > .line, .prose-content .md-code-shiki-host code > [data-line] { - min-height: 1.65em; 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 { border-left: 3px solid var(--accent); background: var(--accent-soft); diff --git a/frontend/components/markdown/MdCodeBlock.tsx b/frontend/components/markdown/MdCodeBlock.tsx index 927470c..4298284 100644 --- a/frontend/components/markdown/MdCodeBlock.tsx +++ b/frontend/components/markdown/MdCodeBlock.tsx @@ -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; + 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).some( + (v) => typeof v === "string" && String(v).includes("#") + )) + ) { + // React 可能把 CSS 变量写成 style={{ ['--shiki-light']: '#xxx' }} + const rec = style as Record; + 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(null); const hostRef = useRef(null); const restMap = rest as Record; 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 () => { diff --git a/frontend/components/markdown/aardioGrammar.ts b/frontend/components/markdown/aardioGrammar.ts index 53d84e0..c7cf476 100644 --- a/frontend/components/markdown/aardioGrammar.ts +++ b/frontend/components/markdown/aardioGrammar.ts @@ -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: - "\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]", + "\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|\\+=|-=|\\*=|/=|%=|&=|\\|=|\\^=|[=!<>]=?|[+\\-~%?:]|\\^|&|\\||/(?!\\*)|\\*(?!/)", }, }, }; diff --git a/frontend/components/markdown/shikiHighlighter.ts b/frontend/components/markdown/shikiHighlighter.ts index aecaf5f..ba76eda 100644 --- a/frontend/components/markdown/shikiHighlighter.ts +++ b/frontend/components/markdown/shikiHighlighter.ts @@ -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 { - return getSiteHighlighter(); + const core = await getSiteHighlighter(); + + const loadLanguage = async ( + ...langs: (string | LanguageRegistration | Promise)[] + ) => { + const pending: (LanguageRegistration | Promise)[] = []; + 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; }