优化 Mermaid 渲染
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
dist/
|
dist/
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
# generated font stacks and KaTeX @font-face rules (scripts/generate-fonts.mjs)
|
||||||
|
src/styles/fonts.generated.css
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install
|
npm install
|
||||||
|
npm run setup:mermaid
|
||||||
npm run dev -- --background
|
npm run dev -- --background
|
||||||
npm run astro -- dev status
|
npm run astro -- dev status
|
||||||
npm run astro -- dev logs
|
npm run astro -- dev logs
|
||||||
@@ -14,8 +15,16 @@ npm run astro -- dev stop
|
|||||||
|
|
||||||
中文首页:`http://localhost:4321/zh-cn/`,英文首页:`/en/`。根路径跳转到中文首页。
|
中文首页:`http://localhost:4321/zh-cn/`,英文首页:`/en/`。根路径跳转到中文首页。
|
||||||
|
|
||||||
|
Mermaid 在构建时通过 Playwright Chromium 渲染,首次安装及 Playwright 升级后需执行 `npm run setup:mermaid`。Linux CI 使用 `npx playwright install --with-deps chromium`,并安装中文无衬线字体(如 Debian/Ubuntu 的 `fonts-noto-cjk`)。构建环境须安装开发依赖;发布时只需上传 `dist/`,无需 Chromium 或 Node.js 服务。
|
||||||
|
|
||||||
`npm run build` 生成 `dist/`,`npm run preview` 验证生产版本和 Pagefind 搜索。正式部署时,在 `astro.config.mjs` 设置实际 `site` 域名,以生成 sitemap。
|
`npm run build` 生成 `dist/`,`npm run preview` 验证生产版本和 Pagefind 搜索。正式部署时,在 `astro.config.mjs` 设置实际 `site` 域名,以生成 sitemap。
|
||||||
|
|
||||||
|
构建会自动生成字体 CSS,并执行 `check:fonts` 和 `check:site`:检查字体引用、站内导航链接与锚点、页面直接引用的资源和公式错误,不检查外站可达性或 canonical/hreflang 等 SEO 元数据。`check:site` 中的站点域名应与 `astro.config.mjs` 保持一致。生成文件不提交;`scripts/` 中的脚本需要随项目一起提交。
|
||||||
|
|
||||||
|
`check:site` 同时检查 Mermaid 深浅两套静态 SVG 和页面 ID 冲突。修改图表处理逻辑后,运行 `npm run check:mermaid`,验证多图、重复时序图的箭头引用和语法错误定位。
|
||||||
|
|
||||||
|
当前仅精确过滤 Astro 7.3 生成的 `use astro:head-inject` 指令警告,其他构建警告保留。Mermaid 渲染器仅用于构建,不再进入浏览器端资源包。
|
||||||
|
|
||||||
## 编写与维护
|
## 编写与维护
|
||||||
|
|
||||||
文档支持 `.md` 与 `.mdx`:普通文章可以直接使用 Markdown;需要组件时使用 MDX,在 frontmatter 后导入 Starlight 或项目内的 Astro 组件。可按内容使用卡片、步骤、标签页等任意组件,使用其他前端框架的组件前需安装对应的 Astro 集成。提示信息也可直接使用 `:::note` / `:::tip` / `:::caution` / `:::danger` 语法。
|
文档支持 `.md` 与 `.mdx`:普通文章可以直接使用 Markdown;需要组件时使用 MDX,在 frontmatter 后导入 Starlight 或项目内的 Astro 组件。可按内容使用卡片、步骤、标签页等任意组件,使用其他前端框架的组件前需安装对应的 Astro 集成。提示信息也可直接使用 `:::note` / `:::tip` / `:::caution` / `:::danger` 语法。
|
||||||
@@ -34,17 +43,20 @@ npm run astro -- dev stop
|
|||||||
|
|
||||||
## 公式与 Mermaid
|
## 公式与 Mermaid
|
||||||
|
|
||||||
- 行内公式使用 `$…$`,独立公式使用 `$$…$$`(分行包裹)。`remark-math` 和 `rehype-katex` 在构建时渲染,KaTeX 的样式和字体随站点打包。
|
- 行内公式使用 `$…$`,独立公式使用 `$$…$$`(分行包裹)。`remark-math` 和 `rehype-katex` 在构建时渲染;字体脚本生成本地样式,字体文件按安装的 KaTeX 版本从 cdnjs 加载,不打包到站点。CDN 不可达时,本地回退字体不能保证复杂公式排版一致。
|
||||||
- 以上公式写法和 Mermaid 均支持 `.md` 与 `.mdx`,不需要逐页导入组件。
|
- 以上公式写法和 Mermaid 均支持 `.md` 与 `.mdx`,不需要逐页导入组件。
|
||||||
- 图表使用标准的 Mermaid 围栏代码块。`src/plugins/remark-mermaid.mjs` 保留源码,`src/scripts/mermaid.ts` 在有图表的页面按需加载 Mermaid 并渲染 SVG,随深浅主题切换重新绘制。宽图默认适应宽度,点击“放大查看”后可滚动阅读,无 JavaScript 或图表错误时仍可阅读源码。
|
- 图表使用标准的 Mermaid 围栏代码块。`src/plugins/remark-mermaid.mjs` 在代码高亮前识别图表,`src/plugins/rehype-mermaid.mjs` 调用 `rehype-mermaid` 的 `inline-svg` 策略,在构建时生成深浅两套 SVG 并嵌入 HTML。CSS 跟随 Starlight 的 `data-theme` 切换,打印使用浅色版,无需重新渲染;没有 JavaScript 也能看到图表。`src/scripts/mermaid.ts` 仅提供放大和滚动查看功能。图表语法错误会导致构建失败,并报告源文件位置。
|
||||||
|
- 图表沿用插件依赖的 Mermaid 版本(当前为 11.x),不再单独安装浏览器端 Mermaid 12。渲染使用本地无衬线字体,构建与阅读设备的字体差异仍可能影响字形;复杂中文图表应在 CI 构建后检查。内联 SVG 保留文本与矢量清晰度,但深浅两份会增加对应页面的 HTML 体积。
|
||||||
|
- 主题配置作为插件选项传入,修改配色会自动使 Astro 内容缓存失效。若修改渲染插件逻辑或升级渲染依赖后仍看到旧图,运行 `npm run astro -- sync --force` 后重新构建。
|
||||||
|
- MDX 集成保留静态优化,但将 `style` 排除,避免 SVG 内部样式被输出为无效的 `set:html` 属性。该集成需排在 Starlight 之后,以保留代码高亮的处理顺序。
|
||||||
- 除数学公式外,正文、导航、代码块、表单和图表文字均为无衬线字体。KaTeX 保留数学字体。
|
- 除数学公式外,正文、导航、代码块、表单和图表文字均为无衬线字体。KaTeX 保留数学字体。
|
||||||
- `Head.astro` 仅增加图表渲染脚本,文档框架沿用 Starlight 默认实现。页眉 Logo 通过 Starlight 配置加载;搜索、导航、主题和语言切换保留原有行为。
|
- `Head.astro` 增加图表放大脚本和可选的远程正文字体,文档框架沿用 Starlight 默认实现。页眉 Logo 通过 Starlight 配置随主题切换:浅色使用 `public/simba/logo-light.svg`(深灰主体),深色使用原版 `public/simba/logo.svg`(白色主体),两版保留红色标记;搜索、导航、主题和语言切换保留原有行为。
|
||||||
|
|
||||||
写法和预览见 `src/content/docs/zh-cn/guides/contributing.mdx`。
|
写法和预览见 `src/content/docs/zh-cn/guides/contributing.mdx`。
|
||||||
|
|
||||||
## 校内赛规则
|
## 校内赛规则
|
||||||
|
|
||||||
手册按概览、赛前考核、开源审查、对抗赛、制作规范、裁判系统、待确认事项拆为七章。比赛流程使用原始附件的 Mermaid 源码,不另写 Astro 流程组件;数学表达使用原有美元符号公式语法。
|
手册分为概览、赛前准备(含开源审查)、对抗赛、制作规范、裁判系统五章。比赛流程使用 Mermaid 源码;数学表达使用美元符号公式语法。
|
||||||
|
|
||||||
- [Simba 主站](https://simba-robotics.com)
|
- [Simba 主站](https://simba-robotics.com)
|
||||||
- [文档仓库](https://git.simba-robotics.com/PRAM/simba_docs)
|
- [文档仓库](https://git.simba-robotics.com/PRAM/simba_docs)
|
||||||
|
|||||||
+40
-4
@@ -1,21 +1,42 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import starlight from '@astrojs/starlight';
|
import starlight from '@astrojs/starlight';
|
||||||
|
import mdx from '@astrojs/mdx';
|
||||||
import { unified } from '@astrojs/markdown-remark';
|
import { unified } from '@astrojs/markdown-remark';
|
||||||
import remarkMath from 'remark-math';
|
import remarkMath from 'remark-math';
|
||||||
import rehypeKatex from 'rehype-katex';
|
import rehypeKatex from 'rehype-katex';
|
||||||
import remarkMermaid from './src/plugins/remark-mermaid.mjs';
|
import remarkMermaid from './src/plugins/remark-mermaid.mjs';
|
||||||
|
import rehypeStaticMermaid, { mermaidThemes } from './src/plugins/rehype-mermaid.mjs';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
site: 'https://docs.simba-robotics.com',
|
||||||
redirects: { '/': '/zh-cn/' },
|
redirects: { '/': '/zh-cn/' },
|
||||||
markdown: {
|
markdown: {
|
||||||
processor: unified({
|
processor: unified({
|
||||||
remarkPlugins: [remarkMath, remarkMermaid],
|
remarkPlugins: [remarkMath, remarkMermaid],
|
||||||
rehypePlugins: [rehypeKatex],
|
rehypePlugins: [rehypeKatex, [rehypeStaticMermaid, { themes: mermaidThemes }]],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
rolldownOptions: {
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
// Astro 7.3 identifies propagated CSS by the virtual module's query.
|
||||||
|
// Rolldown does not understand Astro's generated directive; keep the
|
||||||
|
// module and its assets intact and ignore only this specific warning.
|
||||||
|
if (
|
||||||
|
warning.code === 'MODULE_LEVEL_DIRECTIVE' &&
|
||||||
|
warning.message.includes('"use astro:head-inject"') &&
|
||||||
|
warning.message.includes('.mdx?astroPropagatedAssets')
|
||||||
|
) return;
|
||||||
|
warn(warning);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
integrations: [starlight({
|
integrations: [starlight({
|
||||||
title: 'ΣDOCS',
|
title: 'ΣDOCS',
|
||||||
|
disable404Route: true,
|
||||||
description: 'Simba Robotics / 黑龙江大学 602 创新实验室的工程知识库与校内赛手册。',
|
description: 'Simba Robotics / 黑龙江大学 602 创新实验室的工程知识库与校内赛手册。',
|
||||||
defaultLocale: 'zh-cn',
|
defaultLocale: 'zh-cn',
|
||||||
locales: {
|
locales: {
|
||||||
@@ -23,8 +44,19 @@ export default defineConfig({
|
|||||||
en: { label: 'English', lang: 'en' },
|
en: { label: 'English', lang: 'en' },
|
||||||
},
|
},
|
||||||
favicon: '/simba/logo.svg',
|
favicon: '/simba/logo.svg',
|
||||||
logo: { src: './public/simba/logo.svg', alt: 'Simba Robotics' },
|
logo: {
|
||||||
customCss: ['katex/dist/katex.min.css', './src/styles/custom.css'],
|
light: './public/simba/logo-light.svg',
|
||||||
|
dark: './public/simba/logo.svg',
|
||||||
|
alt: 'Simba Robotics',
|
||||||
|
},
|
||||||
|
/*
|
||||||
|
* `fonts.generated.css` carries the font stacks and the KaTeX faces. It is
|
||||||
|
* produced by `scripts/generate-fonts.mjs` at predev/prebuild, and it must
|
||||||
|
* stay last so its stack wins over Starlight's defaults. Note that KaTeX is
|
||||||
|
* no longer imported from node_modules: doing so would make the bundler copy
|
||||||
|
* the ~1 MB `KaTeX_*.woff2` set into `dist/_astro`.
|
||||||
|
*/
|
||||||
|
customCss: ['./src/styles/custom.css', './src/styles/fonts.generated.css'],
|
||||||
components: { Head: './src/components/Head.astro' },
|
components: { Head: './src/components/Head.astro' },
|
||||||
expressiveCode: {
|
expressiveCode: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
@@ -46,5 +78,9 @@ export default defineConfig({
|
|||||||
{ label: '视觉算法 · VSAG', translations: { en: 'Vision & Algorithms' }, items: [{ autogenerate: { directory: 'vsag' } }] },
|
{ label: '视觉算法 · VSAG', translations: { en: 'Vision & Algorithms' }, items: [{ autogenerate: { directory: 'vsag' } }] },
|
||||||
{ label: '宣传筹划 · PRAM', translations: { en: 'Operations & Media' }, items: [{ autogenerate: { directory: 'pram' } }] },
|
{ label: '宣传筹划 · PRAM', translations: { en: 'Operations & Media' }, items: [{ autogenerate: { directory: 'pram' } }] },
|
||||||
],
|
],
|
||||||
})],
|
}),
|
||||||
|
// Keep SVG styles executable as style elements, instead of serializing
|
||||||
|
// Astro's set:html directive into inert attributes during MDX optimization.
|
||||||
|
mdx({ optimize: { ignoreElementNames: ['style'] } }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+1611
-870
File diff suppressed because it is too large
Load Diff
+22
-5
@@ -3,20 +3,37 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"fonts": "node scripts/generate-fonts.mjs",
|
||||||
|
"check:fonts": "node scripts/generate-fonts.mjs --check",
|
||||||
|
"check:site": "node scripts/check-build.mjs",
|
||||||
|
"check:mermaid": "node --test scripts/mermaid.test.mjs",
|
||||||
|
"setup:mermaid": "playwright install chromium",
|
||||||
|
"predev": "npm run fonts",
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
|
"prestart": "npm run fonts",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro build",
|
"prebuild": "npm run fonts",
|
||||||
|
"build": "astro build && npm run check:fonts && npm run check:site",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/markdown-remark": "7.2.0",
|
"@astrojs/markdown-remark": "^7.3.1",
|
||||||
"@astrojs/starlight": "^0.41.1",
|
"@astrojs/mdx": "^8.0.2",
|
||||||
"astro": "^7.0.2",
|
"@astrojs/starlight": "^0.42.3",
|
||||||
|
"astro": "^7.3.5",
|
||||||
"katex": "^0.16.47",
|
"katex": "^0.16.47",
|
||||||
"mermaid": "^12.0.0",
|
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright": "^1.63.0",
|
||||||
|
"rehype-mermaid": "^3.0.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"sharp@0.35.4": true,
|
||||||
|
"sharp@0.34.5": true,
|
||||||
|
"esbuild@0.28.2": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
- 所有选手应为黑龙江大学本科生
|
- 所有选手应为黑龙江大学本科生
|
||||||
- 以团队形式参赛,每队人数严格限制为 3 人
|
- 以团队形式参赛,每队人数严格限制为 3 人
|
||||||
- 每人限参加一支队伍
|
- 每人限参加一支队伍
|
||||||
- 每队 50 元报名费
|
|
||||||
|
|
||||||
鼓励各位同学跨学院、跨专业组队,以实现技术互补。
|
鼓励各位同学跨学院、跨专业组队,以实现技术互补。
|
||||||
|
|
||||||
@@ -248,7 +247,6 @@ flowchart TD
|
|||||||
#### 奖励
|
#### 奖励
|
||||||
|
|
||||||
1. 冠、亚、季军奖杯
|
1. 冠、亚、季军奖杯
|
||||||
2. 一/二/三等奖对应档奖金
|
|
||||||
2. 校级竞赛一/二/三等奖证书(加盖黑大公章),可申请学分/综测加分
|
2. 校级竞赛一/二/三等奖证书(加盖黑大公章),可申请学分/综测加分
|
||||||
3. 八强队伍获得 Simba 战队直通面试机会 / 明信片套装 / 3D 打印 NFC 钥匙扣
|
3. 八强队伍获得 Simba 战队直通面试机会 / 明信片套装 / 3D 打印 NFC 钥匙扣
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
viewBox="0 0 270.93333 270.93333"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||||
|
sodipodi:docname="logo-light.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#000000"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="0.5"
|
||||||
|
inkscape:cx="354"
|
||||||
|
inkscape:cy="766"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1476"
|
||||||
|
inkscape:window-x="-12"
|
||||||
|
inkscape:window-y="-12"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="图层 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<path
|
||||||
|
d="m 120.18387,65.065937 -8.00358,13.862771 h 27.77765 l 0.0541,0.09409 30.76276,53.283672 -35.41234,61.33599 -0.008,0.0143 -7.99546,13.84855 -0.008,0.0143 h 16.00718 44.01701 l -5.80572,10.05592 h -66.94744 l -0.007,0.0105 h -6.48395 l 5.81384,-10.07013 8.00358,-13.86278 28.83631,-49.94524 8.0036,-13.86276 H 142.78056 114.57375 107.01421 83.392663 67.385498 l -8.004262,13.86276 -42.562363,73.72043 -8.0035837,13.86278 H 24.822458 84.211033 l -0.0914,0.15771 h 16.007157 11.36098 l 0.007,-0.0105 h 62.07111 16.00784 l 0.0105,-0.0175 h 41.16526 15.05748 16.00718 l -8.00358,-13.86278 -5.81181,-10.06608 -8.00358,-13.86276 h -16.00718 -12.5848 -16.00784 l -0.0156,0.0278 h -1.6347 -0.80551 -12.33705 l 27.39928,-47.45699 8.00358,-13.86276 -5.11392,-8.85783 -11.76441,-20.37651 h 31.64205 l 5.06519,-8.773893 2.9384,-5.088885 h -15.13465 -48.55152 l 8.00358,13.862778 h 0.0297 l 16.87765,29.23434 -35.40286,61.31975 h -13.23258 l 27.41688,-47.48812 8.00358,-13.8621 -8.00358,-13.86276 -22.75918,-39.420912 h 60.26447 l -8.00332,-13.862894 h -32.42792 v -0.09409 z M 75.389113,143.70806 h 23.62222 7.558857 28.2075 l -28.83633,49.94523 -8.003587,13.86278 -5.11257,8.85512 -5.1119,-8.85512 -8.00427,-13.86278 H 63.701872 46.553462 l 8.574205,-14.85103 8.003585,-13.86277 z"
|
||||||
|
style="fill:#303030;stroke-width:0.904925;paint-order:stroke fill markers"
|
||||||
|
id="path24" />
|
||||||
|
<path
|
||||||
|
d="m 134.72691,41.313757 -8.00358,13.862771 h 62.89352 16.00718 31.56419 l -8.00358,-13.862771 h -31.5642 -16.00717 z"
|
||||||
|
style="fill:#303030;stroke-width:1.17159;paint-order:stroke fill markers"
|
||||||
|
id="path26" />
|
||||||
|
<path
|
||||||
|
d="M 243.26673,60.098895 A 11.644196,11.644196 0 0 0 231.62281,71.742811 11.644196,11.644196 0 0 0 243.26673,83.386727 11.644196,11.644196 0 0 0 254.91131,71.742811 11.644196,11.644196 0 0 0 243.26673,60.098895 Z"
|
||||||
|
style="fill:#ff0000;stroke-width:0.513476;paint-order:stroke fill markers"
|
||||||
|
id="path25" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
// Check generated HTML, so Markdown links and MDX components are both covered.
|
||||||
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative, sep } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('../dist/', import.meta.url));
|
||||||
|
if (!existsSync(root)) throw new Error('Missing dist/. Run npm run build first.');
|
||||||
|
const origin = 'https://docs.simba-robotics.com';
|
||||||
|
const files = [];
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(path);
|
||||||
|
else files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root);
|
||||||
|
const pages = new Map(files.filter((path) => path.endsWith('.html'))
|
||||||
|
.map((path) => [path, readFileSync(path, 'utf8')]));
|
||||||
|
const errors = [];
|
||||||
|
let checked = 0;
|
||||||
|
let diagrams = 0;
|
||||||
|
|
||||||
|
for (const [path, html] of pages) {
|
||||||
|
const page = relative(root, path).split(sep).join('/');
|
||||||
|
const base = new URL(page.replace(/index\.html$/, ''), origin);
|
||||||
|
if (html.includes('class="katex-error"')) errors.push(`${page}: invalid math`);
|
||||||
|
if (/<style\b[^>]*\sset:html=/.test(html)) errors.push(`${page}: MDX emitted inert SVG styles`);
|
||||||
|
const diagramCount = [...html.matchAll(/class="mermaid-diagram"/g)].length;
|
||||||
|
if (diagramCount) {
|
||||||
|
diagrams += diagramCount;
|
||||||
|
const staticCount = [...html.matchAll(/data-rendered="static"/g)].length;
|
||||||
|
const lightCount = [...html.matchAll(/class="mermaid-variant mermaid-light"/g)].length;
|
||||||
|
const darkCount = [...html.matchAll(/class="mermaid-variant mermaid-dark"/g)].length;
|
||||||
|
const svgCount = [...html.matchAll(/<svg\b[^>]*\bid="mermaid-[^"]+"/g)].length;
|
||||||
|
if ([staticCount, lightCount, darkCount].some((count) => count !== diagramCount) || svgCount !== diagramCount * 2) {
|
||||||
|
errors.push(`${page}: Mermaid must contain both pre-rendered SVG themes`);
|
||||||
|
}
|
||||||
|
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||||
|
if (ids.length !== new Set(ids).size) errors.push(`${page}: duplicate IDs may break inline SVG references`);
|
||||||
|
}
|
||||||
|
// Astro emits quoted, escaped attributes. Code examples escape '<', so they
|
||||||
|
// are not mistaken for real links here. External URLs are not fetched.
|
||||||
|
for (const tag of html.matchAll(/<(?:a|link|script|img)\b[^>]*>/g)) {
|
||||||
|
// Canonical/hreflang are SEO metadata, not navigation or loaded assets.
|
||||||
|
if (/^<link\b/.test(tag[0]) && /\srel="(?:canonical|alternate)"/.test(tag[0])) continue;
|
||||||
|
for (const attribute of tag[0].matchAll(/\s(?:href|src)="([^"]*)"/g)) {
|
||||||
|
const ref = attribute[1].replaceAll('&', '&');
|
||||||
|
const url = new URL(ref, base);
|
||||||
|
if (url.origin !== base.origin) continue;
|
||||||
|
let target = join(root, decodeURIComponent(url.pathname));
|
||||||
|
if (existsSync(target) && statSync(target).isDirectory()) target = join(target, 'index.html');
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
errors.push(`${page}: missing ${ref}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
checked++;
|
||||||
|
if (url.hash && pages.has(target)) {
|
||||||
|
const id = decodeURIComponent(url.hash.slice(1));
|
||||||
|
if (!pages.get(target).includes(`id="${id}"`)) errors.push(`${page}: missing anchor ${ref}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(join(root, '404.html'))) errors.push('Missing 404.html');
|
||||||
|
for (const path of files) {
|
||||||
|
if (/\.(?:woff2?|ttf|otf|eot)$/i.test(path)) errors.push(`Unexpected bundled font: ${relative(root, path)}`);
|
||||||
|
}
|
||||||
|
if (errors.length) {
|
||||||
|
console.error(errors.join('\n'));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`Checked ${pages.size} HTML pages, ${checked} local links/assets, and ${diagrams} static Mermaid diagrams; no broken anchors, math errors, or bundled fonts.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Generate `src/styles/fonts.generated.css`.
|
||||||
|
*
|
||||||
|
* Policy: ship no font binaries, and never let a font request block rendering.
|
||||||
|
*
|
||||||
|
* 1. Site typography — `Noto Sans SC` from the Google Fonts CN API, loaded
|
||||||
|
* through a non-blocking `<link>` in `Head.astro`, followed by a long local
|
||||||
|
* fallback chain so the page is fully readable even when Google is
|
||||||
|
* unreachable.
|
||||||
|
*
|
||||||
|
* 2. KaTeX — a math font cannot be faked with a UI font: KaTeX positions every
|
||||||
|
* glyph against `KaTeX_Main`/`KaTeX_Math` metrics and needs the `KaTeX_Size*`
|
||||||
|
* faces for large delimiters. So the faces come from a CDN (cdnjs, which is
|
||||||
|
* reachable from mainland China, unlike jsDelivr) and KaTeX's own
|
||||||
|
* `@font-face` blocks — the ~1 MB of `fonts/KaTeX_*.woff2` this repo used to
|
||||||
|
* copy into `dist/` — are replaced by CDN-pinned ones. Every element-level
|
||||||
|
* `font-family: KaTeX_*` declaration keeps its matching KaTeX face first,
|
||||||
|
* followed by local fallbacks. If the CDN is blocked, the local faces keep
|
||||||
|
* text visible but cannot guarantee KaTeX's glyph metrics or delimiters.
|
||||||
|
*
|
||||||
|
* The generator proves its own contract: every `url()` it emits must be an
|
||||||
|
* absolute HTTPS CDN URL, and no relative font reference may survive, so the
|
||||||
|
* bundler can never pick up a font file by accident.
|
||||||
|
*
|
||||||
|
* Output is deterministic, so it is generated at predev/prebuild instead of
|
||||||
|
* being committed. `--check` verifies without writing.
|
||||||
|
*/
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const KATEX_DIR = new URL('../node_modules/katex/', import.meta.url);
|
||||||
|
const KATEX_PKG = fileURLToPath(new URL('package.json', KATEX_DIR));
|
||||||
|
const KATEX_CSS = fileURLToPath(new URL('dist/katex.css', KATEX_DIR));
|
||||||
|
const OUT_FILE = fileURLToPath(new URL('../src/styles/fonts.generated.css', import.meta.url));
|
||||||
|
|
||||||
|
/** cdnjs mirror. Swap for another host if this one is not reachable for you. */
|
||||||
|
const KATEX_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/KaTeX';
|
||||||
|
|
||||||
|
/** Site webfont family requested from the Google Fonts CN API in `Head.astro`. */
|
||||||
|
export const SANS_FAMILY = 'Noto Sans SC';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heading webfont for the site title in the top-left corner. The title contains
|
||||||
|
* a Greek `Σ`, so the face must cover Greek as well as Latin: Manrope is a
|
||||||
|
* variable font (200..800) with a greek subset, and Archivo Black (weight 400)
|
||||||
|
* is requested as a separate heavy fallback.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_FAMILY = 'Manrope';
|
||||||
|
|
||||||
|
const SYSTEM_SANS = [
|
||||||
|
'system-ui',
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'Segoe UI',
|
||||||
|
'Roboto',
|
||||||
|
'Helvetica Neue',
|
||||||
|
'Arial',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CJK_SANS = [
|
||||||
|
'PingFang SC',
|
||||||
|
'Hiragino Sans GB',
|
||||||
|
'Microsoft YaHei',
|
||||||
|
'Noto Sans CJK SC',
|
||||||
|
'Source Han Sans SC',
|
||||||
|
'WenQuanYi Micro Hei',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Quote only the families that need it, so the output stays readable. */
|
||||||
|
const fontStack = (families) =>
|
||||||
|
families.map((family) => (/[\s"]/.test(family) ? `"${family}"` : family)).join(', ');
|
||||||
|
|
||||||
|
/** Site stack: the webfont first, then every plausible local fallback. */
|
||||||
|
const SANS_STACK = fontStack([SANS_FAMILY, ...SYSTEM_SANS, ...CJK_SANS, 'sans-serif']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display stack for the site title. Only Latin + Greek are covered by the
|
||||||
|
* webfonts, so the site's CJK fallbacks follow for any Chinese in the title.
|
||||||
|
* `Archivo Black` covers Greek and is a genuine display cut, so it is a real
|
||||||
|
* fallback rather than a placeholder.
|
||||||
|
*/
|
||||||
|
const DISPLAY_STACK = fontStack([
|
||||||
|
DISPLAY_FAMILY,
|
||||||
|
'Archivo Black',
|
||||||
|
...SYSTEM_SANS,
|
||||||
|
...CJK_SANS,
|
||||||
|
'sans-serif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Math stack. Locally installed TeX faces come first, then the platform UI
|
||||||
|
* faces for Latin/Greek symbols, then CJK faces for any Chinese inside math.
|
||||||
|
*/
|
||||||
|
const MATH_STACK = fontStack([
|
||||||
|
'STIX Two Math',
|
||||||
|
'Latin Modern Math',
|
||||||
|
'Cambria Math',
|
||||||
|
...SYSTEM_SANS,
|
||||||
|
...CJK_SANS,
|
||||||
|
'sans-serif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXPECTED_FAMILIES = [
|
||||||
|
'KaTeX_AMS',
|
||||||
|
'KaTeX_Caligraphic',
|
||||||
|
'KaTeX_Fraktur',
|
||||||
|
'KaTeX_Main',
|
||||||
|
'KaTeX_Math',
|
||||||
|
'KaTeX_SansSerif',
|
||||||
|
'KaTeX_Script',
|
||||||
|
'KaTeX_Size1',
|
||||||
|
'KaTeX_Size2',
|
||||||
|
'KaTeX_Size3',
|
||||||
|
'KaTeX_Size4',
|
||||||
|
'KaTeX_Typewriter',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Read a single `prop: value` declaration out of a rule body. */
|
||||||
|
function readProp(body, prop) {
|
||||||
|
const match = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;}]+)`, 'i').exec(body);
|
||||||
|
return match ? match[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect every `@font-face` as `{ family, style, weight, src }`. */
|
||||||
|
function findFontFaces(css) {
|
||||||
|
const faces = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const start = css.indexOf('@font-face', cursor);
|
||||||
|
if (start === -1) break;
|
||||||
|
|
||||||
|
const open = css.indexOf('{', start);
|
||||||
|
const close = css.indexOf('}', open);
|
||||||
|
if (open === -1 || close === -1) break;
|
||||||
|
|
||||||
|
const body = css.slice(open + 1, close);
|
||||||
|
const family = readProp(body, 'font-family')?.replace(/^['"]|['"]$/g, '');
|
||||||
|
if (family) {
|
||||||
|
faces.push({
|
||||||
|
family,
|
||||||
|
style: readProp(body, 'font-style') ?? 'normal',
|
||||||
|
weight: readProp(body, 'font-weight') ?? 'normal',
|
||||||
|
src: readProp(body, 'src') ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a face's `src` into absolute CDN URLs, keeping only the formats asked
|
||||||
|
* for. Upstream ships woff2 + woff + ttf; woff2 is universally supported for
|
||||||
|
* the browsers this site targets, so shipping one format is enough.
|
||||||
|
*/
|
||||||
|
function cdnSources(face, version, formats = ['woff2']) {
|
||||||
|
const files = [...face.src.matchAll(/url\(\s*([^)\s"']+)\s*\)/gi)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter((file) => formats.some((format) => file.endsWith(`.${format}`)));
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
throw new Error(`No ${formats.join('/')} source found for ${face.family} (${face.style} ${face.weight})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
.map((file) => {
|
||||||
|
const name = file.replace(/^.*\//, '');
|
||||||
|
const format = name.slice(name.lastIndexOf('.') + 1);
|
||||||
|
const remote = `${KATEX_CDN}/${version}/fonts/${name}`;
|
||||||
|
return `url(${remote}) format("${format}")`;
|
||||||
|
})
|
||||||
|
.join(',\n ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = JSON.parse(await readFile(KATEX_PKG, 'utf8')).version;
|
||||||
|
const source = await readFile(KATEX_CSS, 'utf8');
|
||||||
|
const faces = findFontFaces(source);
|
||||||
|
const families = [...new Set(faces.map((face) => face.family))];
|
||||||
|
|
||||||
|
const added = families.filter((family) => !EXPECTED_FAMILIES.includes(family));
|
||||||
|
const dropped = EXPECTED_FAMILIES.filter((family) => !families.includes(family));
|
||||||
|
if (added.length || dropped.length) {
|
||||||
|
throw new Error(
|
||||||
|
`KaTeX ${version} declares unexpected families (added: ${added.join(', ') || 'none'}; ` +
|
||||||
|
`missing: ${dropped.join(', ') || 'none'}). Update EXPECTED_FAMILIES in ` +
|
||||||
|
'scripts/generate-fonts.mjs and re-check the fallback chain.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontFaces = EXPECTED_FAMILIES.flatMap((family) =>
|
||||||
|
faces
|
||||||
|
.filter((face) => face.family === family)
|
||||||
|
.map(
|
||||||
|
(face) => `@font-face {
|
||||||
|
font-family: ${family};
|
||||||
|
src:
|
||||||
|
${cdnSources(face, version)};
|
||||||
|
font-style: ${face.style};
|
||||||
|
font-weight: ${face.weight};
|
||||||
|
font-display: swap;
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
).join('\n\n');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append local fallbacks while preserving the exact KaTeX family, handling both
|
||||||
|
* `font-family: KaTeX_Main, Times New Roman, serif` and the `font:` shorthand
|
||||||
|
* (`.katex { font: normal 1.21em KaTeX_Main, ... }`), which is easy to miss
|
||||||
|
* because it has no `font-family` token.
|
||||||
|
*/
|
||||||
|
function addMathFallbacks(rule) {
|
||||||
|
return rule
|
||||||
|
.replace(/(^|[\s;{])(font-family\s*:\s*)([^;}]+)/gm, (match, lead, prop, value) =>
|
||||||
|
value.includes('KaTeX_') ? `${lead}${prop}${value.match(/KaTeX_[A-Za-z0-9]+/)[0]}, ${MATH_STACK}` : match,
|
||||||
|
)
|
||||||
|
.replace(/(^|[\s;{])(font\s*:\s*)([^;}]+)/gm, (match, lead, prop, value) => {
|
||||||
|
if (!value.includes('KaTeX_')) return match;
|
||||||
|
// Keep the position of the family list: `font: normal 1em <families>`.
|
||||||
|
const [head, ...rest] = value.split(/\s+(?=KaTeX_)/);
|
||||||
|
const family = value.match(/KaTeX_[A-Za-z0-9]+/)[0];
|
||||||
|
return rest.length ? `${lead}${prop}${head} ${family}, ${MATH_STACK}` : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// KaTeX's layout/sizing rules are version-locked to its HTML output, so they are
|
||||||
|
// kept verbatim. Dropping the `@font-face` blocks leaves whitespace behind, and
|
||||||
|
// only fallback font lists change inside the rules themselves.
|
||||||
|
const layout = addMathFallbacks(source.replace(/@font-face\s*\{[^}]*\}/g, ''))
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Declaring @font-face is not sufficient: layout rules must actually use it.
|
||||||
|
for (const family of EXPECTED_FAMILIES) {
|
||||||
|
if (!layout.includes(family + ',')) {
|
||||||
|
throw new Error(`KaTeX layout lost its ${family} font reference.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = `/*
|
||||||
|
* GENERATED FILE - do not edit. Run \`node scripts/generate-fonts.mjs\`.
|
||||||
|
* KaTeX fonts are pinned to cdnjs ${version}; no font file is bundled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Latin first, then Simplified Chinese, then the hosting platform's UI and
|
||||||
|
CJK faces. The webfonts are optional: every entry after them is local. */
|
||||||
|
--sl-font: ${SANS_STACK};
|
||||||
|
--sl-font-mono: ${SANS_STACK};
|
||||||
|
--sl-font-system-mono: ${SANS_STACK};
|
||||||
|
|
||||||
|
/* Heading face for the site title; see .site-title in custom.css. */
|
||||||
|
--sl-font-display: ${DISPLAY_STACK};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* KaTeX math faces, loaded from the CDN and never bundled. \`font-display: swap\`
|
||||||
|
* means a blocked CDN shows the local stack immediately instead of blank math.
|
||||||
|
* Local fallback metrics differ, so complex formulas require the remote faces.
|
||||||
|
*/
|
||||||
|
${fontFaces}
|
||||||
|
|
||||||
|
/* --- katex/dist/katex.css layout, preserving KaTeX faces with local fallbacks --- */
|
||||||
|
|
||||||
|
${layout}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Contract: no relative font reference may survive, so the bundler has nothing
|
||||||
|
// to copy into dist/_astro and no page can request a font from its own origin.
|
||||||
|
const urls = [...css.matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((match) => match[1]);
|
||||||
|
const relative = urls.filter((url) => !url.startsWith('https://'));
|
||||||
|
if (relative.length) {
|
||||||
|
throw new Error(`Generated CSS must only reference absolute CDN fonts, found: ${relative.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (!urls.length) {
|
||||||
|
throw new Error('Generated CSS references no KaTeX fonts; check the cdnSources mapping.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = createHash('sha256').update(css).digest('hex').slice(0, 16);
|
||||||
|
const output = `/* sha256:${digest} */\n${css}`;
|
||||||
|
const summary = `KaTeX ${version} pinned to cdnjs, ${urls.length} remote faces, 0 bundled files`;
|
||||||
|
|
||||||
|
const current = await readFile(OUT_FILE, 'utf8').catch(() => null);
|
||||||
|
|
||||||
|
if (process.argv.includes('--check')) {
|
||||||
|
if (current !== output) {
|
||||||
|
console.error(`fonts.generated.css is stale (${summary}). Run: node scripts/generate-fonts.mjs`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`fonts.generated.css is up to date (${summary}).`);
|
||||||
|
} else if (current === output) {
|
||||||
|
console.log(`fonts.generated.css unchanged (${summary}).`);
|
||||||
|
} else {
|
||||||
|
await writeFile(OUT_FILE, output, 'utf8');
|
||||||
|
console.log(`Wrote src/styles/fonts.generated.css (${summary}).`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
||||||
|
import remarkMermaid from '../src/plugins/remark-mermaid.mjs';
|
||||||
|
import rehypeStaticMermaid from '../src/plugins/rehype-mermaid.mjs';
|
||||||
|
|
||||||
|
const renderer = await createMarkdownProcessor({
|
||||||
|
remarkPlugins: [remarkMermaid],
|
||||||
|
rehypePlugins: [rehypeStaticMermaid],
|
||||||
|
});
|
||||||
|
const fence = (diagram) => `\`\`\`mermaid\n${diagram}\n\`\`\``;
|
||||||
|
|
||||||
|
test('multiple diagrams and both themes have distinct IDs and working arrow references', async () => {
|
||||||
|
const sequence = fence('sequenceDiagram\nAlice->>Bob: Hello');
|
||||||
|
const source = [fence('flowchart LR\nA[中文标签] --> B[完成]'), sequence, sequence].join('\n\n');
|
||||||
|
const { code } = await renderer.render(source);
|
||||||
|
assert.equal([...code.matchAll(/data-rendered="static"/g)].length, 3);
|
||||||
|
assert.equal([...code.matchAll(/<svg\b/g)].length, 6);
|
||||||
|
const ids = [...code.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||||||
|
assert.equal(ids.length, new Set(ids).size);
|
||||||
|
const markers = [...code.matchAll(/marker-(?:start|end)="url\(#([^)]*)\)"/g)];
|
||||||
|
assert(markers.length > 0);
|
||||||
|
for (const match of markers) assert(ids.includes(match[1]), `Missing arrow marker: ${match[1]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid diagram rejects compilation with source position', async (t) => {
|
||||||
|
// Astro logs the expected parse failure before throwing it.
|
||||||
|
t.mock.method(console, 'error', () => {});
|
||||||
|
await assert.rejects(
|
||||||
|
renderer.render(fence('flowchart LR\nA[Unclosed'), {
|
||||||
|
fileURL: new URL('./invalid-mermaid.md', import.meta.url),
|
||||||
|
}),
|
||||||
|
(error) => error.fatal === true && error.ruleId === 'rehype-mermaid'
|
||||||
|
&& error.line === 1 && error.file.endsWith('invalid-mermaid.md'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,8 +1,54 @@
|
|||||||
---
|
---
|
||||||
import DefaultHead from '@astrojs/starlight/components/Head.astro';
|
import DefaultHead from '@astrojs/starlight/components/Head.astro';
|
||||||
|
|
||||||
|
// A 404 is not a translated document. Starlight would otherwise advertise
|
||||||
|
// nonexistent /zh-cn/404/ and /en/404/ alternates for this custom page.
|
||||||
|
if (Astro.locals.starlightRoute.id === '404' || Astro.response.status === 404) {
|
||||||
|
Astro.locals.starlightRoute.head = Astro.locals.starlightRoute.head.filter(
|
||||||
|
({ tag, attrs }) => tag !== 'link' || !['canonical', 'alternate'].includes(String(attrs?.rel)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Fonts CN API, loaded without blocking first paint.
|
||||||
|
*
|
||||||
|
* `font-display=optional` is deliberate: if a webfont is not cached the page
|
||||||
|
* renders immediately with the local fallback stacks in `fonts.generated.css`
|
||||||
|
* and never swaps, so there is no reflow. A slow or blocked fonts.googleapis.cn
|
||||||
|
* therefore costs nothing but the webfont itself.
|
||||||
|
*
|
||||||
|
* `Manrope` is the heading face behind `--sl-font-display` for the site title in
|
||||||
|
* the top-left corner. A variable font, so one request spans 600..800.
|
||||||
|
* `Archivo Black` is the heavy fallback; the title's Greek `Σ` needs a face with
|
||||||
|
* a greek subset, which is why these two were chosen.
|
||||||
|
*/
|
||||||
|
const WEBFONT_CSS =
|
||||||
|
'https://fonts.googleapis.cn/css2?family=Archivo+Black&family=Manrope:wght@600..800&family=Noto+Sans+SC:wght@400..700&display=optional';
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultHead />
|
<DefaultHead />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Warm the connection while the HTML is still parsing. `crossorigin` is required
|
||||||
|
for the font file host because fonts are fetched in CORS mode.
|
||||||
|
-->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.cn" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.cn" crossorigin />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Non-blocking stylesheet load: `media=print` keeps it off the critical path,
|
||||||
|
and the `onload` handler promotes it to `all`. The inline `onerror` prevents
|
||||||
|
it from staying in print mode if the request fails. Without JS the webfont is
|
||||||
|
skipped entirely and the local stack is used.
|
||||||
|
-->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href={WEBFONT_CSS}
|
||||||
|
media="print"
|
||||||
|
onload="this.media='all'"
|
||||||
|
onerror="this.media='all'"
|
||||||
|
/>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import '../scripts/mermaid';
|
import '../scripts/mermaid';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineCollection } from 'astro:content';
|
import { defineCollection } from 'astro:content';
|
||||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders';
|
||||||
import { docsSchema } from '@astrojs/starlight/schema';
|
import { docsSchema, i18nSchema } from '@astrojs/starlight/schema';
|
||||||
|
|
||||||
export const collections = {
|
export const collections = {
|
||||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||||
|
i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { CardGrid, LinkCard, Steps } from '@astrojs/starlight/components';
|
|||||||
正式规则调整以组委会后续发布为准。
|
正式规则调整以组委会后续发布为准。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
当前版本号:`Rev_0.0.2`
|
当前版本号:`Rev_0.0.3`
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -32,7 +32,6 @@ import { CardGrid, LinkCard, Steps } from '@astrojs/starlight/components';
|
|||||||
| 参赛对象 | 黑龙江大学本科生 |
|
| 参赛对象 | 黑龙江大学本科生 |
|
||||||
| 组队人数 | **严格限定 3 人** |
|
| 组队人数 | **严格限定 3 人** |
|
||||||
| 队伍限制 | 每人仅可参加一支队伍 |
|
| 队伍限制 | 每人仅可参加一支队伍 |
|
||||||
| 报名费 | **50 元 / 队** |
|
|
||||||
| 赛事周期 | **2026 年 9 月 23 日—11 月 1 日** |
|
| 赛事周期 | **2026 年 9 月 23 日—11 月 1 日** |
|
||||||
|
|
||||||
:::tip[鼓励跨学院、跨专业组队]
|
:::tip[鼓励跨学院、跨专业组队]
|
||||||
|
|||||||
@@ -145,6 +145,5 @@ A 类作品最高获得一等奖,B 类最高获得二等奖,C 类最高获
|
|||||||
## 奖励
|
## 奖励
|
||||||
|
|
||||||
1. 冠、亚、季军奖杯。
|
1. 冠、亚、季军奖杯。
|
||||||
2. 一、二、三等奖对应档奖金。
|
|
||||||
3. 校级竞赛一、二、三等奖证书(加盖黑大公章),可申请学分或综测加分。
|
3. 校级竞赛一、二、三等奖证书(加盖黑大公章),可申请学分或综测加分。
|
||||||
4. 八强队伍获得 **Simba 战队直通面试机会、明信片套装、3D 打印 NFC 钥匙扣**。
|
4. 八强队伍获得 **Simba 战队直通面试机会、明信片套装、3D 打印 NFC 钥匙扣**。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Tabs, TabItem } from '@astrojs/starlight/components';
|
|||||||
|
|
||||||
## 报名
|
## 报名
|
||||||
|
|
||||||
报名时间为 **2026 年 9 月 23 日至 10 月 9 日**。每队 3 人,每人仅可参加一队,报名费为 50 元 / 队。
|
报名时间为 **2026 年 9 月 23 日至 10 月 9 日**。每队 3 人,每人仅可参加一队。
|
||||||
|
|
||||||
1. **联系队友并确认资格。** 所有选手均须为黑龙江大学本科生。
|
1. **联系队友并确认资格。** 所有选手均须为黑龙江大学本科生。
|
||||||
2. **加入赛事官方 QQ 群。** 后续通知将第一时间在群内发布。
|
2. **加入赛事官方 QQ 群。** 后续通知将第一时间在群内发布。
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ sidebar:
|
|||||||
| 供电 | 通过杜邦线提供 **5 V ± 1 V** |
|
| 供电 | 通过杜邦线提供 **5 V ± 1 V** |
|
||||||
| 通信 | 一路 **UART**,可向主控发送键盘、鼠标控制指令 |
|
| 通信 | 一路 **UART**,可向主控发送键盘、鼠标控制指令 |
|
||||||
|
|
||||||
装甲板安装时的角度、高度和刚性要求见[装甲模块安装规范](/zh-cn/competition/robot-spec/#装甲模块)。
|
装甲板安装时的角度、高度和刚性要求见[装甲模块安装规范](/zh-cn/competition/robot-spec/#装甲模块安装)。
|
||||||
|
|
||||||
## 图传模块
|
## 图传模块
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ $$
|
|||||||
v = \frac{\Delta x}{\Delta t}
|
v = \frac{\Delta x}{\Delta t}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
正文、导航、代码与图表标签统一采用无衬线字体,数学公式保留 KaTeX 数学字体。
|
正文、导航、代码与图表标签统一使用无衬线字体栈,数学公式使用 KaTeX 自身的数学字体。
|
||||||
|
|
||||||
## Mermaid 流程图
|
## Mermaid 流程图
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ flowchart LR
|
|||||||
B --> C[核对结果]
|
B --> C[核对结果]
|
||||||
```
|
```
|
||||||
|
|
||||||
流程图随 Starlight 深浅主题切换。宽图默认适应正文宽度,可点击“放大查看”后滚动阅读细节;没有 JavaScript 时保留可读源码。[比赛赛程](/zh-cn/competition/matches/#晋级流程)直接使用原始附件中的 Mermaid 定义。
|
流程图在构建时生成静态 SVG,随 Starlight 深浅主题切换,无需浏览器重新渲染。宽图默认适应正文宽度,可点击“放大查看”后滚动阅读细节;没有 JavaScript 时仍能看到图表。图表语法错误会在构建时提示,请修正后再发布。[比赛赛程](/zh-cn/competition/matches/#晋级流程)直接使用原始附件中的 Mermaid 定义。
|
||||||
|
|
||||||
## 提示栏
|
## 提示栏
|
||||||
|
|
||||||
@@ -154,6 +154,21 @@ flowchart LR
|
|||||||
用于已确认的禁止事项。例如校内赛图传模块严禁使用 5 V 输入。
|
用于已确认的禁止事项。例如校内赛图传模块严禁使用 5 V 输入。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## 字体与静态资源
|
||||||
|
|
||||||
|
本站不分发字体文件,也不让字体请求阻塞首屏:
|
||||||
|
|
||||||
|
- **正文**使用思源黑体 `Noto Sans SC`,通过 Google Fonts 中国镜像 `fonts.googleapis.cn` 异步加载,并以 `font-display=optional` 声明。加载失败、被拦截或超时都不会留白,浏览器直接使用本地回退链(`system-ui` → `PingFang SC` / `Microsoft YaHei` → `sans-serif`)。
|
||||||
|
- **左上角标题**(`ΣDOCS`)使用粗标题字体 `Manrope` 800,回退到 `Archivo Black`。标题含希腊字母 `Σ`,因此只选用带 greek 子集的字体族;字体族由 `--sl-font-display` 提供,样式在 `custom.css` 的 `.site-title` 中。
|
||||||
|
- **数学公式**使用 KaTeX 的数学字体,按安装版本固定引用 cdnjs。每条公式样式优先使用对应的 `KaTeX_Main` / `KaTeX_Math` / `KaTeX_Size*` 字体,之后才是本地回退字体。CDN 不可达时仍会显示文字,但替代字体的字形度量不同,复杂公式和大括号可能错位;离线环境不能保证数学排版一致。
|
||||||
|
- `src/styles/fonts.generated.css` 由 `scripts/generate-fonts.mjs` 在 `npm run dev` / `npm run build` 前自动生成,**不要手工修改**。脚本会校验生成结果中不含任何相对字体路径,因此打包产物不会包含字体文件。
|
||||||
|
|
||||||
|
修改字体策略时,请同时调整 `scripts/generate-fonts.mjs` 中的字体栈与 `src/components/Head.astro` 中的加载地址,并运行 `npm run check:fonts` 确认生成结果一致。`public/` 与仓库中不得新增 `.woff2` / `.woff` / `.ttf` 等字体资源。
|
||||||
|
|
||||||
|
### 站点标志与标题尺寸
|
||||||
|
|
||||||
|
顶栏是固定高度的,内部元素超高会被直接裁掉,因此标志尺寸受 `--sl-nav-height - 2 * --sl-nav-pad-y` 约束。`custom.css` 把该高度上调 0.25rem 并给出 `--sl-logo-size`(手机 2.25rem / 桌面 2.625rem)。如果需要更大的标志,必须同时上调 `--sl-nav-height`,否则会被裁切;该变量也会影响固定侧栏与正文的偏移。
|
||||||
|
|
||||||
## 发布前检查
|
## 发布前检查
|
||||||
|
|
||||||
1. **核实内容。** 区分正式规则、经验建议、示例与待确认项。
|
1. **核实内容。** 区分正式规则、经验建议、示例与待确认项。
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"search.label": "Search"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"search.label": "搜索",
|
||||||
|
"expressiveCode.copyButtonTooltip": "复制代码",
|
||||||
|
"expressiveCode.copyButtonCopied": "已复制!",
|
||||||
|
"pagefind.clear_search": "清除",
|
||||||
|
"pagefind.load_more": "加载更多结果",
|
||||||
|
"pagefind.search_label": "搜索文档"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<StarlightPage
|
||||||
|
lang="zh-CN"
|
||||||
|
frontmatter={{
|
||||||
|
title: '404 · 页面不存在',
|
||||||
|
description: '请求的文档不存在或已移动。Page not found.',
|
||||||
|
editUrl: false,
|
||||||
|
pagefind: false,
|
||||||
|
head: [{ tag: 'meta', attrs: { name: 'robots', content: 'noindex' } }],
|
||||||
|
tableOfContents: false,
|
||||||
|
prev: false,
|
||||||
|
next: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>文档可能已移动,或链接地址有误。请使用顶部搜索查找内容,也可以<a href="/zh-cn/">返回中文首页</a>。</p>
|
||||||
|
<p>The page may have moved, or the link may be incorrect. Use the search bar or <a href="/en/">return to the English home page</a>.</p>
|
||||||
|
</StarlightPage>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import rehypeMermaid from 'rehype-mermaid';
|
||||||
|
|
||||||
|
// Use installed sans-serif fonts when measuring labels. Linux CI needs a CJK
|
||||||
|
// font (e.g. fonts-noto-cjk); rendering must not depend on a remote font CDN.
|
||||||
|
const fontFamily = 'Arial, "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", sans-serif';
|
||||||
|
function theme(dark) {
|
||||||
|
return {
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: 'strict',
|
||||||
|
theme: 'base',
|
||||||
|
fontFamily,
|
||||||
|
htmlLabels: false,
|
||||||
|
flowchart: { htmlLabels: false, useMaxWidth: false },
|
||||||
|
themeVariables: {
|
||||||
|
fontFamily,
|
||||||
|
darkMode: dark,
|
||||||
|
background: dark ? '#1a1a1a' : '#ffffff',
|
||||||
|
primaryColor: dark ? '#401820' : '#ffeaed',
|
||||||
|
primaryTextColor: dark ? '#eeeeee' : '#292929',
|
||||||
|
primaryBorderColor: dark ? '#d65a70' : '#c21932',
|
||||||
|
lineColor: dark ? '#c4c4c4' : '#5c5c5c',
|
||||||
|
secondaryColor: dark ? '#303030' : '#f4f4f4',
|
||||||
|
tertiaryColor: dark ? '#242424' : '#fafafa',
|
||||||
|
clusterBkg: dark ? '#242424' : '#fafafa',
|
||||||
|
clusterBorder: dark ? '#5c5c5c' : '#b8b8b8',
|
||||||
|
edgeLabelBackground: dark ? '#242424' : '#ffffff',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function element(tagName, properties, children) {
|
||||||
|
return { type: 'element', tagName, properties, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose config as plugin options so Astro's content cache sees theme changes.
|
||||||
|
export const mermaidThemes = { light: theme(false), dark: theme(true) };
|
||||||
|
|
||||||
|
// Some diagram types use fixed IDs (e.g. sequence arrows). Mermaid's root
|
||||||
|
// prefix alone does not namespace these, so rewrite their local references too.
|
||||||
|
function namespaceSvg(svg) {
|
||||||
|
const ids = new Map();
|
||||||
|
const walk = (node, fn) => {
|
||||||
|
fn(node);
|
||||||
|
node.children?.forEach((child) => walk(child, fn));
|
||||||
|
};
|
||||||
|
walk(svg, (node) => {
|
||||||
|
if (node !== svg && node.properties?.id) {
|
||||||
|
const id = String(node.properties.id);
|
||||||
|
ids.set(id, `${svg.properties.id}--${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const rewriteUrls = (value) => value.replace(/url\(\s*(['"]?)#([^\s)'"<>]+)\1\s*\)/g,
|
||||||
|
(match, quote, id) => ids.has(id) ? `url(#${ids.get(id)})` : match);
|
||||||
|
walk(svg, (node) => {
|
||||||
|
if (node.type !== 'element') return;
|
||||||
|
for (const [key, value] of Object.entries(node.properties)) {
|
||||||
|
if (key === 'id') node.properties[key] = ids.get(String(value)) ?? value;
|
||||||
|
else if ((key === 'href' || key === 'xLinkHref') && String(value).startsWith('#')) {
|
||||||
|
node.properties[key] = ids.has(String(value).slice(1)) ? `#${ids.get(String(value).slice(1))}` : value;
|
||||||
|
} else if (key === 'ariaLabelledBy' || key === 'ariaDescribedBy') {
|
||||||
|
const tokens = Array.isArray(value) ? value : String(value).split(/\s+/);
|
||||||
|
node.properties[key] = tokens.map((id) => ids.get(id) ?? id);
|
||||||
|
} else if (typeof value === 'string') node.properties[key] = rewriteUrls(value);
|
||||||
|
}
|
||||||
|
if (node.tagName === 'style') {
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.type === 'text') child.value = rewriteUrls(child.value)
|
||||||
|
.replace(/#([\w-]+)/g, (match, id) => ids.has(id) ? `#${ids.get(id)}` : match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compile both themes to inline SVG; CSS follows Starlight's manual theme. */
|
||||||
|
export default function rehypeStaticMermaid({ themes = mermaidThemes } = {}) {
|
||||||
|
return async (tree, file) => {
|
||||||
|
const diagrams = [];
|
||||||
|
function visit(node) {
|
||||||
|
if (node.type === 'element' && node.properties?.className?.includes('mermaid-diagram')) {
|
||||||
|
diagrams.push(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.children?.forEach(visit);
|
||||||
|
}
|
||||||
|
visit(tree);
|
||||||
|
if (!diagrams.length) return;
|
||||||
|
|
||||||
|
// Prefix SVG IDs to prevent collisions across themes and documents.
|
||||||
|
const prefix = createHash('sha256').update(String(file.path ?? '') + JSON.stringify(diagrams)).digest('hex').slice(0, 12);
|
||||||
|
const rendered = await Promise.all(['light', 'dark'].map(async (mode) => {
|
||||||
|
const root = { type: 'root', children: diagrams.map((node) => structuredClone(node.children[0])) };
|
||||||
|
await rehypeMermaid({
|
||||||
|
strategy: 'inline-svg',
|
||||||
|
prefix: `mermaid-${prefix}-${mode}`,
|
||||||
|
mermaidConfig: themes[mode],
|
||||||
|
})(root, file);
|
||||||
|
return root.children;
|
||||||
|
}));
|
||||||
|
|
||||||
|
diagrams.forEach((diagram, index) => {
|
||||||
|
const variants = ['light', 'dark'].map((mode, modeIndex) => {
|
||||||
|
const svg = rendered[modeIndex][index];
|
||||||
|
namespaceSvg(svg);
|
||||||
|
// Keep the measured font instead of swapping to an optional webfont.
|
||||||
|
const viewBox = String(svg.properties.viewBox).trim().split(/[\s,]+/).map(Number);
|
||||||
|
return element('div', {
|
||||||
|
className: ['mermaid-variant', `mermaid-${mode}`],
|
||||||
|
style: `--mermaid-width: ${viewBox[2]}px`,
|
||||||
|
}, [svg]);
|
||||||
|
});
|
||||||
|
diagram.properties['data-rendered'] = 'static';
|
||||||
|
diagram.children = [element('div', {
|
||||||
|
className: ['mermaid-canvas'],
|
||||||
|
tabIndex: 0,
|
||||||
|
}, variants)];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Render standard fenced Mermaid blocks without requiring MDX components. */
|
/** Reserve Mermaid fences before code highlighting, in both Markdown and MDX. */
|
||||||
export default function remarkMermaid() {
|
export default function remarkMermaid() {
|
||||||
return (tree) => {
|
return (tree) => {
|
||||||
const visit = (parent) => {
|
const visit = (parent) => {
|
||||||
@@ -6,9 +6,10 @@ export default function remarkMermaid() {
|
|||||||
parent.children = parent.children.map((node) => {
|
parent.children = parent.children.map((node) => {
|
||||||
if (node.type === 'code' && node.lang === 'mermaid') {
|
if (node.type === 'code' && node.lang === 'mermaid') {
|
||||||
// HAST data works in both Markdown and MDX, without injecting raw HTML.
|
// HAST data works in both Markdown and MDX, without injecting raw HTML.
|
||||||
// Text nodes safely preserve the source as the no-JavaScript fallback.
|
// rehype-mermaid renders this source during compilation.
|
||||||
return {
|
return {
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
|
position: node.position,
|
||||||
children: [],
|
children: [],
|
||||||
data: {
|
data: {
|
||||||
hName: 'div',
|
hName: 'div',
|
||||||
@@ -16,7 +17,8 @@ export default function remarkMermaid() {
|
|||||||
hChildren: [{
|
hChildren: [{
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'pre',
|
tagName: 'pre',
|
||||||
properties: { className: ['mermaid-source'] },
|
properties: { className: ['mermaid'] },
|
||||||
|
position: node.position,
|
||||||
children: [{ type: 'text', value: node.value }],
|
children: [{ type: 'text', value: node.value }],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
|||||||
+35
-109
@@ -1,113 +1,39 @@
|
|||||||
// Only diagram pages load Mermaid. Authors use ordinary fenced code blocks.
|
// Diagrams are already rendered by rehype-mermaid. Only the zoom button needs JS.
|
||||||
const sources = new WeakMap<HTMLElement, string>();
|
function setupDiagramZoom() {
|
||||||
let running = false;
|
const chinese = document.documentElement.lang.startsWith('zh');
|
||||||
let pending = false;
|
for (const diagram of document.querySelectorAll<HTMLElement>('.mermaid-diagram')) {
|
||||||
let sequence = 0;
|
if (diagram.querySelector('.mermaid-zoom')) continue;
|
||||||
|
const canvas = diagram.querySelector<HTMLElement>('.mermaid-canvas');
|
||||||
async function renderDiagrams() {
|
if (!canvas) continue;
|
||||||
if (running) {
|
canvas.setAttribute('role', 'region');
|
||||||
pending = true;
|
canvas.setAttribute('aria-label', chinese ? 'Mermaid 图表,可滚动查看' : 'Mermaid diagram, scroll to explore');
|
||||||
return;
|
const zoom = document.createElement('button');
|
||||||
}
|
zoom.type = 'button';
|
||||||
const diagrams = [...document.querySelectorAll<HTMLElement>('.mermaid-diagram')];
|
zoom.className = 'mermaid-zoom';
|
||||||
if (!diagrams.length) return;
|
const updateButton = () => {
|
||||||
running = true;
|
const expanded = diagram.dataset.zoomed === 'true';
|
||||||
try {
|
zoom.textContent = chinese ? (expanded ? '适应宽度' : '放大查看') : (expanded ? 'Fit to width' : 'Enlarge diagram');
|
||||||
const { default: mermaid } = await import('mermaid');
|
zoom.setAttribute('aria-pressed', String(expanded));
|
||||||
const dark = document.documentElement.dataset.theme === 'dark';
|
};
|
||||||
mermaid.initialize({
|
updateButton();
|
||||||
startOnLoad: false,
|
zoom.addEventListener('click', () => {
|
||||||
securityLevel: 'strict',
|
diagram.dataset.zoomed = String(diagram.dataset.zoomed !== 'true');
|
||||||
theme: 'base',
|
updateButton();
|
||||||
themeVariables: {
|
canvas.scrollTop = 0;
|
||||||
darkMode: dark,
|
canvas.scrollLeft = 0;
|
||||||
background: dark ? '#1a1a1a' : '#ffffff',
|
if (diagram.dataset.zoomed === 'true') {
|
||||||
primaryColor: dark ? '#401820' : '#ffeaed',
|
const mode = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
|
||||||
primaryTextColor: dark ? '#eeeeee' : '#292929',
|
const firstNode = [...canvas.querySelectorAll(`.mermaid-${mode} .node`)]
|
||||||
primaryBorderColor: dark ? '#d65a70' : '#c21932',
|
.map((node) => node.getBoundingClientRect())
|
||||||
lineColor: dark ? '#c4c4c4' : '#5c5c5c',
|
.sort((a, b) => a.top - b.top || a.left - b.left)[0];
|
||||||
secondaryColor: dark ? '#303030' : '#f4f4f4',
|
canvas.scrollLeft = firstNode
|
||||||
tertiaryColor: dark ? '#242424' : '#fafafa',
|
? firstNode.left + firstNode.width / 2 - canvas.getBoundingClientRect().left - canvas.clientWidth / 2
|
||||||
clusterBkg: dark ? '#242424' : '#fafafa',
|
: (canvas.scrollWidth - canvas.clientWidth) / 2;
|
||||||
clusterBorder: dark ? '#5c5c5c' : '#b8b8b8',
|
|
||||||
edgeLabelBackground: dark ? '#242424' : '#ffffff',
|
|
||||||
},
|
|
||||||
fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--sl-font').trim(),
|
|
||||||
flowchart: { htmlLabels: false, useMaxWidth: false },
|
|
||||||
suppressErrorRendering: true,
|
|
||||||
});
|
|
||||||
for (const diagram of diagrams) {
|
|
||||||
if (!diagram.isConnected) continue;
|
|
||||||
const source = sources.get(diagram) ?? diagram.querySelector('.mermaid-source')?.textContent;
|
|
||||||
if (!source) continue;
|
|
||||||
sources.set(diagram, source);
|
|
||||||
try {
|
|
||||||
const { svg } = await mermaid.render(`mermaid-diagram-${++sequence}`, source);
|
|
||||||
if (!diagram.isConnected) continue;
|
|
||||||
const previousCanvas = diagram.querySelector('.mermaid-canvas');
|
|
||||||
const position = { left: previousCanvas?.scrollLeft ?? 0, top: previousCanvas?.scrollTop ?? 0 };
|
|
||||||
const chinese = document.documentElement.lang.startsWith('zh');
|
|
||||||
const canvas = document.createElement('div');
|
|
||||||
canvas.className = 'mermaid-canvas';
|
|
||||||
canvas.innerHTML = svg;
|
|
||||||
canvas.setAttribute('role', 'region');
|
|
||||||
canvas.setAttribute('aria-label', chinese ? 'Mermaid 流程图,可滚动查看' : 'Mermaid diagram, scroll to explore');
|
|
||||||
canvas.tabIndex = 0;
|
|
||||||
const width = canvas.querySelector('svg')?.viewBox.baseVal.width;
|
|
||||||
if (width) canvas.style.setProperty('--mermaid-width', `${width}px`);
|
|
||||||
|
|
||||||
const zoom = document.createElement('button');
|
|
||||||
zoom.type = 'button';
|
|
||||||
zoom.className = 'mermaid-zoom';
|
|
||||||
const updateButton = () => {
|
|
||||||
const expanded = diagram.dataset.zoomed === 'true';
|
|
||||||
zoom.textContent = chinese ? (expanded ? '适应宽度' : '放大查看') : (expanded ? 'Fit to width' : 'Enlarge diagram');
|
|
||||||
zoom.setAttribute('aria-pressed', String(expanded));
|
|
||||||
};
|
|
||||||
updateButton();
|
|
||||||
zoom.addEventListener('click', () => {
|
|
||||||
diagram.dataset.zoomed = String(diagram.dataset.zoomed !== 'true');
|
|
||||||
updateButton();
|
|
||||||
canvas.scrollTop = 0;
|
|
||||||
canvas.scrollLeft = 0;
|
|
||||||
if (diagram.dataset.zoomed === 'true') {
|
|
||||||
// Start at the topmost node, even when a wide bracket is asymmetric.
|
|
||||||
const firstNode = [...canvas.querySelectorAll('.node')]
|
|
||||||
.map((node) => node.getBoundingClientRect())
|
|
||||||
.sort((a, b) => a.top - b.top || a.left - b.left)[0];
|
|
||||||
canvas.scrollLeft = firstNode
|
|
||||||
? firstNode.left + firstNode.width / 2 - canvas.getBoundingClientRect().left - canvas.clientWidth / 2
|
|
||||||
: (canvas.scrollWidth - canvas.clientWidth) / 2;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
diagram.replaceChildren(zoom, canvas);
|
|
||||||
canvas.scrollLeft = position.left;
|
|
||||||
canvas.scrollTop = position.top;
|
|
||||||
diagram.dataset.rendered = 'true';
|
|
||||||
} catch (error) {
|
|
||||||
// Preserve the original text if a contributor's diagram cannot be parsed.
|
|
||||||
const fallback = document.createElement('pre');
|
|
||||||
fallback.className = 'mermaid-source';
|
|
||||||
fallback.textContent = source;
|
|
||||||
diagram.replaceChildren(fallback);
|
|
||||||
diagram.dataset.rendered = 'error';
|
|
||||||
console.error('Unable to render Mermaid diagram:', error);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
} catch (error) {
|
diagram.prepend(zoom);
|
||||||
console.error('Unable to load Mermaid:', error);
|
|
||||||
} finally {
|
|
||||||
running = false;
|
|
||||||
if (pending) {
|
|
||||||
pending = false;
|
|
||||||
void renderDiagrams();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void renderDiagrams();
|
setupDiagramZoom();
|
||||||
document.addEventListener('astro:page-load', renderDiagrams);
|
document.addEventListener('astro:page-load', setupDiagramZoom);
|
||||||
new MutationObserver(() => void renderDiagrams()).observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['data-theme'],
|
|
||||||
});
|
|
||||||
|
|||||||
+54
-8
@@ -1,7 +1,9 @@
|
|||||||
:root {
|
:root {
|
||||||
--sl-font: 'Segoe UI', 'Microsoft YaHei', 'Noto Sans', Arial, sans-serif;
|
/*
|
||||||
--sl-font-mono: var(--sl-font);
|
* `--sl-font`, `--sl-font-mono` and every KaTeX font family are set in
|
||||||
--sl-font-system-mono: var(--sl-font);
|
* `fonts.generated.css`, which is generated by `scripts/generate-fonts.mjs`
|
||||||
|
* and loaded after this file. Nothing here may point at a font binary.
|
||||||
|
*/
|
||||||
--sl-color-accent-low: #401820;
|
--sl-color-accent-low: #401820;
|
||||||
--sl-color-accent: #d91c3a;
|
--sl-color-accent: #d91c3a;
|
||||||
--sl-color-accent-high: #ffb3bd;
|
--sl-color-accent-high: #ffb3bd;
|
||||||
@@ -27,14 +29,52 @@
|
|||||||
--sl-color-accent-high: #891328;
|
--sl-color-accent-high: #891328;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Includes navigation, form controls, code, and diagram labels. KaTeX keeps its math fonts. */
|
/*
|
||||||
|
* Body text and code inherit the same stack. Mermaid uses its build-time sans
|
||||||
|
* stack so optional webfonts cannot change label widths. Math keeps its own
|
||||||
|
* families from `fonts.generated.css`, which are resolved against local TeX
|
||||||
|
* faces when the CDN is unavailable.
|
||||||
|
*/
|
||||||
body, button, input, select, textarea, code, pre, kbd, samp {
|
body, button, input, select, textarea, code, pre, kbd, samp {
|
||||||
font-family: var(--sl-font);
|
font-family: var(--sl-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Site title ("ΣDOCS"): a heavy display face instead of the inherited body
|
||||||
|
* stack. `--sl-font-display` starts with Google's Manrope at weight 800 and
|
||||||
|
* falls back to Archivo Black, then locally installed faces, so a blocked
|
||||||
|
* webfont still renders a bold title.
|
||||||
|
*/
|
||||||
|
.site-title {
|
||||||
|
font-family: var(--sl-font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Logo. The upstream rule sizes the image to exactly the nav's content box
|
||||||
|
* (`nav-height - 2 * nav-pad-y`), so the square mark is capped at 2rem on phones
|
||||||
|
* and 2.5rem on desktop, and grows only when the nav grows. Bumping the nav by
|
||||||
|
* 0.25rem takes it to 36px (+13%) on phones and 42px (+5%) on desktop.
|
||||||
|
*
|
||||||
|
* The extra nav height also shifts the fixed sidebar and the content offset,
|
||||||
|
* which is the visible cost of the larger mark.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--sl-nav-height: 3.75rem;
|
||||||
|
--sl-logo-size: 2.25rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 50em) {
|
||||||
|
:root {
|
||||||
|
--sl-nav-height: 4.25rem;
|
||||||
|
--sl-logo-size: 2.625rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.site-title img {
|
.site-title img {
|
||||||
padding: .25rem;
|
height: var(--sl-logo-size);
|
||||||
|
flex: none;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
background: #222222;
|
|
||||||
}
|
}
|
||||||
:root[data-theme='dark'] .sidebar-pane a[aria-current='page'] {
|
:root[data-theme='dark'] .sidebar-pane a[aria-current='page'] {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -50,14 +90,20 @@ body, button, input, select, textarea, code, pre, kbd, samp {
|
|||||||
}
|
}
|
||||||
.mermaid-canvas { max-height: 75vh; overflow: auto; margin-top: .75rem; }
|
.mermaid-canvas { max-height: 75vh; overflow: auto; margin-top: .75rem; }
|
||||||
.mermaid-diagram svg { display: block; width: 100%; height: auto; margin-inline: auto; }
|
.mermaid-diagram svg { display: block; width: 100%; height: auto; margin-inline: auto; }
|
||||||
|
/* HTML labels, if enabled per diagram, must not inherit article spacing. */
|
||||||
|
.mermaid-diagram svg p { margin: 0; }
|
||||||
|
.mermaid-variant { margin: 0; }
|
||||||
|
.mermaid-dark { display: none; }
|
||||||
|
:root[data-theme='dark'] .mermaid-light { display: none; }
|
||||||
|
:root[data-theme='dark'] .mermaid-dark { display: block; }
|
||||||
.mermaid-diagram[data-zoomed='true'] svg { width: var(--mermaid-width); max-width: none; }
|
.mermaid-diagram[data-zoomed='true'] svg { width: var(--mermaid-width); max-width: none; }
|
||||||
.mermaid-zoom { padding: .35rem .75rem; border: 1px solid var(--sl-color-gray-5); border-radius: .25rem; color: var(--sl-color-text); background: var(--sl-color-bg); cursor: pointer; }
|
.mermaid-zoom { padding: .35rem .75rem; border: 1px solid var(--sl-color-gray-5); border-radius: .25rem; color: var(--sl-color-text); background: var(--sl-color-bg); cursor: pointer; }
|
||||||
.mermaid-zoom:hover { border-color: var(--sl-color-text-accent); }
|
.mermaid-zoom:hover { border-color: var(--sl-color-text-accent); }
|
||||||
.mermaid-diagram svg :is(text, tspan, foreignObject, span, p) { font-family: var(--sl-font) !important; }
|
|
||||||
.mermaid-source { white-space: pre; }
|
|
||||||
@media print {
|
@media print {
|
||||||
.mermaid-diagram { overflow: visible; padding: 0; border: 0; }
|
.mermaid-diagram { overflow: visible; padding: 0; border: 0; }
|
||||||
.mermaid-zoom { display: none; }
|
.mermaid-zoom { display: none; }
|
||||||
.mermaid-canvas { max-height: none; overflow: visible; }
|
.mermaid-canvas { max-height: none; overflow: visible; }
|
||||||
|
:root[data-theme] .mermaid-light { display: block; }
|
||||||
|
:root[data-theme] .mermaid-dark { display: none; }
|
||||||
.mermaid-diagram svg, .mermaid-diagram[data-zoomed='true'] svg { width: 100%; height: auto; }
|
.mermaid-diagram svg, .mermaid-diagram[data-zoomed='true'] svg { width: 100%; height: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user