docs(development): 补充代码高亮开发文档

- DEVELOPMENT.md 增加代码高亮系统的详细开发指南,
  涵盖添加/修复语法、刷新文章、调试流程等
- admin/posts.rs 重构重建逻辑:提取 do_rebuild 闭包消除重复,
  新增「重建全部」按钮用于批量刷新已有文章渲染缓存
This commit is contained in:
xfy 2026-06-16 15:19:45 +08:00
parent e8be1967db
commit a9f0e8d16b
2 changed files with 132 additions and 26 deletions

View File

@ -68,3 +68,93 @@ hey -c 100 -n 100000 http://localhost:8080/
## CI ## CI
https://git.rua.plus/api/v1/repos/xfy/yggdrasil/actions/tasks https://git.rua.plus/api/v1/repos/xfy/yggdrasil/actions/tasks
## 代码高亮Syntax Highlighting
代码高亮基于 [syntect](https://docs.rs/syntect),将代码块渲染成带 CSS class 的 HTML配合 `public/highlight.css` 的主题规则着色。涉及四个部分:
| 文件 | 作用 |
| ---- | ---- |
| `syntaxes/*.sublime-syntax` | 各语言的语法定义Sublime Text 格式) |
| `themes/*.tmTheme` | Catppuccin Latte/ Mocha配色主题 |
| `src/highlight.rs` | 运行时高亮入口,加载语法集并渲染 HTML |
| `src/bin/generate_highlight_css.rs` | 构建期从主题生成 `public/highlight.css` |
### 渲染时机(关键)
**文章 HTML 在保存时渲染一次,固化进数据库的 `posts.content_html` 字段,读取时不再重新渲染。** `highlight_code` 只在 `render_markdown_enhanced` 内被调用,而后者只在文章创建/更新(`src/api/posts/create.rs``update.rs`)时触发。
这意味着:**修改语法定义后,已存在的文章不会自动刷新**,必须手动重建(见下文「刷新已有文章」)。
### 添加 / 修复某个语言的高亮
1. **编辑语法定义**:修改 `syntaxes/<Lang>.sublime-syntax`(参考同目录 `Kotlin.sublime-syntax` 的完整写法)。核心是 `expression` 上下文——它必须 `include` 所有需要识别的元素:
```yaml
expression:
- include: whitespace
- include: comments
- include: string-literal
- include: declaration-keywords # 关键字
- include: types # 类型
- include: function-declaration # 函数声明(须在 declaration-keywords 之前)
- include: function-calls
- include: types-and-identifiers
```
> **include 顺序很重要**`declaration-keywords` 的裸关键字匹配会吃掉 `func name` 中的 `func`,导致后续 `function-declaration` 的多 token 匹配失败。让多 token 的规则(如 `func\s+name`)排在单 token 规则之前。
2. **验证 YAML 合法**syntect 加载失败只会 `warn`,不会 panic容易静默丢语法
```bash
python3 -c "import yaml; yaml.safe_load(open('syntaxes/Swift.sublime-syntax')); print('OK')"
```
3. **加回归测试**:在 `src/highlight.rs``tests` 模块里加测试,断言关键字/类型/函数等产出对应的 CSS class
```rust
#[test]
fn highlight_code_swift_keyword_and_func() {
let result = highlight_code("func greet() {}", Some("swift"));
assert!(result.contains("keyword"));
assert!(result.contains("name function") || result.contains("variable function"));
}
```
4. **运行测试**
```bash
cargo test --features server highlight_code_<lang> -- --nocapture
```
`--nocapture` 会打印 HTML 输出,方便人眼检查每个 token 的 class 是否正确。
5. **重新生成 highlight.css**(如果新增了 scope 类型才需要,已有 scope 的颜色规则会自动覆盖):
```bash
cargo run --features server --bin generate_highlight_css
```
### 刷新已有文章
修改语法后,用 `/admin/posts` 页面的按钮重建文章 HTML
- **重建内容**:仅重建 `content_html` 为空的文章
- **重建全部**:重建所有文章(含已有内容)—— 语法/渲染逻辑升级后用这个
底层调用 `rebuild_content_html(rebuild_all: bool)` server function`src/api/posts/rebuild.rs`),单批上限 500 篇,渲染异常会被捕获汇总,不会因单篇失败中断整批。
### 调试「高亮不生效」
排查顺序(从快到慢):
1. **先跑测试**`cargo test --features server highlight_code_<lang> -- --nocapture`。测试直接调用 `highlight_code`,绕过 DB 和缓存,能立刻判断是语法定义问题还是运行时问题。
2. **查 DB**:测试通过但页面仍不对,查数据库里该文章的 `content_html` 是否含期望的 class——多数情况是旧 HTML 固化在 DB需要重建
```sql
SELECT (LENGTH(content_html) - LENGTH(REPLACE(content_html, 'keyword', ''))) / LENGTH('keyword')
FROM posts WHERE slug = '<slug>';
```
3. **清 SSR 缓存**`IncrementalRenderer` 会把渲染结果持久化到 `static/` 目录(如 `static/post/<slug>/index/*.html`)。删除后重启服务器才会重新渲染。

View File

@ -41,6 +41,32 @@ pub fn PostsPage(page: i32) -> Element {
let mut rebuilding = use_signal(|| false); let mut rebuilding = use_signal(|| false);
let mut rebuild_result = use_signal(|| Option::<String>::None); let mut rebuild_result = use_signal(|| Option::<String>::None);
// 重建文章渲染缓存rebuild_all 为 false 时仅重建 content_html 为空的文章,
// 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。
let mut do_rebuild = move |rebuild_all: bool| {
rebuilding.set(true);
rebuild_result.set(None);
spawn(async move {
match rebuild_content_html(rebuild_all).await {
Ok(RebuildResult { rebuilt, failed, errors }) => {
if failed > 0 {
let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed}");
if let Some(first) = errors.first() {
msg.push_str(&format!("\n{first}"));
}
rebuild_result.set(Some(msg));
} else {
rebuild_result.set(Some(format!("已重建 {rebuilt} 篇文章")));
}
}
Err(e) => {
rebuild_result.set(Some(format!("失败: {e}")));
}
}
rebuilding.set(false);
});
};
// 页码变化时加载分页数据WASM 前端请求接口SSR 直接结束加载。 // 页码变化时加载分页数据WASM 前端请求接口SSR 直接结束加载。
use_effect(move || { use_effect(move || {
let _ = current_page; let _ = current_page;
@ -87,38 +113,28 @@ pub fn PostsPage(page: i32) -> Element {
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all" "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
}, },
disabled: rebuilding(), disabled: rebuilding(),
onclick: move |_| { onclick: move |_| do_rebuild(false),
rebuilding.set(true);
rebuild_result.set(None);
spawn(async move {
match rebuild_content_html(false).await {
Ok(RebuildResult { rebuilt, failed, errors }) => {
if failed > 0 {
let mut msg = format!(
"已重建 {rebuilt} 篇,失败 {failed} 篇"
);
if let Some(first) = errors.first() {
msg.push_str(&format!("\n{first}"));
}
rebuild_result.set(Some(msg));
} else {
rebuild_result
.set(Some(format!("已重建 {rebuilt} 篇文章")));
}
}
Err(e) => {
rebuild_result.set(Some(format!("失败: {e}")));
}
}
rebuilding.set(false);
});
},
if rebuilding() { "重建中..." } else { "重建内容" } if rebuilding() { "重建中..." } else { "重建内容" }
} }
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50", div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
"重建 content_html 为空的文章渲染缓存" "重建 content_html 为空的文章渲染缓存"
} }
} }
div { class: "group relative",
button {
class: if rebuilding() {
"px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] border border-gray-300 dark:border-[#444]"
} else {
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
},
disabled: rebuilding(),
onclick: move |_| do_rebuild(true),
if rebuilding() { "重建中..." } else { "重建全部" }
}
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
"重建所有文章的渲染缓存(含已有内容)"
}
}
Link { Link {
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer", class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
to: Route::Write {}, to: Route::Write {},