diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index efe4be1..9f764a4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -68,3 +68,93 @@ hey -c 100 -n 100000 http://localhost:8080/ ## CI 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/.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_ -- --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_ -- --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 = ''; + ``` + +3. **清 SSR 缓存**:`IncrementalRenderer` 会把渲染结果持久化到 `static/` 目录(如 `static/post//index/*.html`)。删除后重启服务器才会重新渲染。 + diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index c34d938..f87deee 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -41,6 +41,32 @@ pub fn PostsPage(page: i32) -> Element { let mut rebuilding = use_signal(|| false); let mut rebuild_result = use_signal(|| Option::::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 直接结束加载。 use_effect(move || { 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" }, disabled: rebuilding(), - onclick: move |_| { - 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); - }); - }, + onclick: move |_| do_rebuild(false), 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", "重建 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 { 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 {},