fix(frontend): 修复 aardio 注释着色、代码行距与折叠 hydration
注释不再被运算符拆开;去掉 grid 行 min-height;挂载后再折叠以避免 SSR/CSR 不一致。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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:
|
||||
"\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]",
|
||||
"\\.\\.|--|\\+\\+|\\*\\*=?|&&=?|\\|\\|=?|[!=]==|<<=?|>>>?=?|\\+=|-=|\\*=|/=|%=|&=|\\|=|\\^=|[=!<>]=?|[+\\-~%?:]|\\^|&|\\||/(?!\\*)|\\*(?!/)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user