docs(development): 补充代码高亮开发文档
- DEVELOPMENT.md 增加代码高亮系统的详细开发指南, 涵盖添加/修复语法、刷新文章、调试流程等 - admin/posts.rs 重构重建逻辑:提取 do_rebuild 闭包消除重复, 新增「重建全部」按钮用于批量刷新已有文章渲染缓存
This commit is contained in:
parent
e8be1967db
commit
a9f0e8d16b
@ -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`)。删除后重启服务器才会重新渲染。
|
||||||
|
|
||||||
|
|||||||
@ -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 {},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user