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