Compare commits

...

10 Commits

Author SHA1 Message Date
xfy
f613460742 fix(docker): 修复 DX 下载 case 分支行延续导致的解析失败
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
Dockerfile 的 RUN 指令只在行尾有反斜杠时延续到下一行。
DX 下载步骤的 amd64/arm64 分支把 DX_TRIPLET 和 DX_SHA256 拆成两行,
首行缺少续行符,Docker 把次行 DX_SHA256=... 当成新的顶层指令,
报错 unknown instruction: DX_SHA256=...。

改成与同文件其它 case 块(Node/Tailwind/musl)一致的写法:
每个分支单行、以 ;; 收尾。SHA256 值已对照 GitHub 官方 .sha256 旁挂文件核对一致。
2026-07-16 15:15:12 +08:00
xfy
438a5e1046 fix(ssr): 文章写入后物理删除 SSR 磁盘缓存,根治'重建后内容不更新'
Dioxus 0.7 增量渲染器把每路由 SSR 结果落盘到 static/<route>/index/<hash>.html,
只暴露 invalidate_after(ttl) 一个失效手段,无按路由失效的公开 API。
之前 bump_global_generation 在 Dioxus 0.7 不生效(ssr_cache.rs 注释已承认),
导致文章重建/编辑后,即便 DB content_html 已是新内容,SSR 缓存仍返回旧页面,
最长滞后 SSR_CACHE_SECS(默认 3600s)。本次 katex/mermaid 渲染不生效即因此。

修复:在 ssr_cache.rs 新增物理删除缓存的函数:
- invalidate_ssr_route(route): 删 static/<route>/ (含路径穿越防护)
- invalidate_ssr_all_public(): 删 static/ 下除 .well-known/admin 外全部
- invalidate_ssr_home(): 删 static/index/ (细粒度,allow dead_code)

所有写路径(create/update/rebuild/delete/trash)在事务提交后调用:
- 单篇操作失效 /post/{slug} + 全量公开页(列表项变化影响首页/归档/标签)
- 批量操作直接全量失效

世代号保留作 X-SSR-Generation 可观测性,实际失效靠删文件。
顺带修 katex.rs 的 clippy(field_reassign_with_default)+ cmp_owned。

556 Rust 测试 + clippy -D warnings + 双 target 编译通过。
2026-07-16 15:11:34 +08:00
xfy
09ca480842 chore(docker): 移除 gh-proxy,改用 GitHub 直连下载 dx 与 Tailwind
Dioxus CLI 与 Tailwind CSS 独立二进制此前经 gh-proxy.com 镜像下载,
现移除 GH_PROXY 前缀及相关注释,直接从 GitHub Releases 拉取。
2026-07-16 14:51:21 +08:00
xfy
78a8d188d1 fix(admin): 重建结果消息改绝对定位,避免撑高容器顶起按钮
RebuildCacheBar 原用 flex-col 把「已重建 x 篇文章」消息排在按钮下方,
参与文档流。其祖先 header 为 md:items-end(底边固定),消息出现时
新增高度全部转化为按钮上移,「重建内容/重建全部」被顶起、「发布文章」
相对下沉,三者错位。

改为外层 relative + 消息 absolute top-full 脱离文档流:出现/消失都
不影响按钮行高度,布局稳定。双 target 编译通过。
2026-07-16 14:36:33 +08:00
xfy
f39cb35a31 docs: 补充数学公式与流程图架构说明
AGENTS.md 新增 'Math & Diagrams' 章节详述两条渲染路径:
- KaTeX 服务端渲染:katex-rs + pulldown-cmark ENABLE_MATH + sanitizer span
  白名单 + 自托管 CSS/字体(make katex-css)
- mermaid 客户端懒加载:IntersectionObserver + 动态 import 独立 bundle

同步更新:Server-only deps 加 katex-rs、Frontend Lib 表加 mermaid-renderer 行、
Build Artifacts 加 public/{katex,mermaid}、markdown.rs 描述。
2026-07-16 14:24:38 +08:00
xfy
70edc5e46c feat(frontend): mermaid 流程图懒加载渲染
mermaid 无官方 SSR 支持,采用客户端 IntersectionObserver 懒加载:服务端
markdown.rs 只产出普通 <pre><code class="language-mermaid"> 代码块,
前端在块进视口时动态 import 独立 bundle(~1MB)渲染成 SVG。

新增 libs/mermaid-renderer/:mermaid 11.16 IIFE bundle,输出 public/mermaid/
yggdrasil-core/mermaid.ts:扫描 language-mermaid 块,视口可见时动态 import
  /mermaid/mermaid.js(单例缓存),mermaid.initialize 适配 light/dark 主题,
  securityLevel=strict;渲染失败加 .mermaid-error class 保留源码;幂等守卫
post-content.ts:initCopyButtons 跳过 language-mermaid 块(图无需 copy)
post_content.rs:use_effect 调 __initMermaid('.post-content', theme),
  读 use_resolved_theme() 传主题并建立订阅

新增 6 个 mermaid 单测(扫描/主题/幂等/非mermaid/未命中/错误回退)。
550 Rust + 28 前端测试全过,双 target 编译通过。
2026-07-16 14:22:43 +08:00
xfy
218533402b feat(css): 自托管 KaTeX CSS 与 woff2 字体
服务端 katex-rs 只输出 HTML span,字体/排版靠配套 CSS。从 npm 包 katex
0.16.x 的 dist/ 拷贝 katex.min.css + woff2 字体(省去 woff/ttf)到 public/katex/,
CSS 用相对 url(fonts/) 引用故两者须同级。

- libs/package.json: katex ^0.16.22 作 workspace 根 devDependency
- Makefile: 新增 katex-css target,接入 build/build-linux/dev;clean 清 public/katex
- Dioxus.toml: [web.resource]/[web.resource.dev] style 列表注册 /katex/katex.min.css
- .gitignore: public/katex 作构建产物忽略(同 highlight.css 等)

make katex-css 验证通过:23KB CSS + 20 个 woff2 字体就位。
2026-07-16 14:15:20 +08:00
xfy
84f6223c68 feat(markdown): 评论支持数学公式渲染
- comments/markdown.rs: 开启 ENABLE_MATH,InlineMath/DisplayMath 事件
  渲染成 katex HTML 注入事件流(评论不用块级 <p> 包裹,保持紧凑)
- sanitizer.rs: 评论路径 span 白名单加 style(KaTeX 内联定位 style 需保留,
  与文章正文 sanitizer.rs:382 对齐)
- 新增 3 个评论公式测试(内联/块级/style 保留)

双 target 编译通过,24 个评论测试 + 36 个 sanitizer 测试全过。
2026-07-16 14:12:20 +08:00
xfy
95fe9d862d feat(markdown): 文章支持数学公式 SSR 渲染
render_markdown_enhanced 事件循环新增 InlineMath/DisplayMath 分支:
- $...$ 内联公式调 katex::render_inline 注入 span
- $$...$$ 块级公式用 <p class="math-display"> 包裹 katex 输出
- 标题内公式按内联渲染,不产生块级 <p>

pulldown-cmark 的 Options::all() 已含 ENABLE_MATH,无需改 options。
新增 5 个端到端测试(内联/块级/标题内/坏公式不破坏全文)。
双 target 编译通过,16 个 markdown 测试全过。
2026-07-16 14:11:05 +08:00
xfy
891a576dc0 feat(deps): 引入 katex-rs 服务端数学公式渲染
纯 Rust 的 katex-rs 0.2.4 重实现 KaTeX,把 TeX 公式渲染成 HTML span,
供后续 pulldown-cmark 的 InlineMath/DisplayMath 事件调用。

- Cargo.toml: katex-rs 作为 optional dep 经 server feature 启用
- src/api/katex.rs: thread_local 缓存 KatexContext/Settings(因含 RefCell
  宏表非 Sync,不能放全局 static);OutputFormat::Html 避开 MathML 标签
  使 sanitizer 无需开白名单;throw_on_error=false 防坏公式中断全文
- src/api/mod.rs: #[cfg(feature="server")] 注册 katex 模块

双 target 编译通过(server + wasm32),4 个单测通过。
2026-07-16 14:09:14 +08:00
30 changed files with 1738 additions and 63 deletions

2
.gitignore vendored
View File

@ -8,11 +8,13 @@
others/ others/
public/style.css public/style.css
public/highlight.css public/highlight.css
public/katex
public/tiptap public/tiptap
public/codemirror public/codemirror
public/lightbox public/lightbox
public/yggdrasil-core public/yggdrasil-core
public/xterm public/xterm
public/mermaid
public/doc public/doc
/static /static
.env .env

View File

@ -125,7 +125,7 @@ Dioxus 0.7 fullstack project with **two independent gates** — the most common
**Server-only helpers**: `src/auth/password.rs`, `src/auth/session.rs`, `src/api/auth.rs`, `src/api/comments/helpers.rs`, and several model helper methods are gated with `#[cfg(feature = "server")]` because they are only called from server function bodies, which are stripped in WASM builds. **Server-only helpers**: `src/auth/password.rs`, `src/auth/session.rs`, `src/api/auth.rs`, `src/api/comments/helpers.rs`, and several model helper methods are gated with `#[cfg(feature = "server")]` because they are only called from server function bodies, which are stripped in WASM builds.
**Server-only dependencies**: Crates that are only used behind `#[cfg(feature = "server")]` (e.g., `argon2`, `uuid`, `regex`, `pulldown-cmark`, `rand`, `http`, `sha2`, `hex`, `sysinfo`, `sqlparser`, `dashmap`, plus the rest of the server stack) are declared as `optional = true` in `Cargo.toml` and enabled only through the `server` feature. They are not compiled into the WASM frontend. The `generate_highlight_css` binary is `required-features = ["server"]` (uses `syntect`). **Server-only dependencies**: Crates that are only used behind `#[cfg(feature = "server")]` (e.g., `argon2`, `uuid`, `regex`, `pulldown-cmark`, `katex-rs`, `rand`, `http`, `sha2`, `hex`, `sysinfo`, `sqlparser`, `dashmap`, plus the rest of the server stack) are declared as `optional = true` in `Cargo.toml` and enabled only through the `server` feature. They are not compiled into the WASM frontend. The `generate_highlight_css` binary is `required-features = ["server"]` (uses `syntect`).
**Shared types that compile on both targets**: bridges like `src/tiptap_bridge.rs` and `src/codemirror_bridge.rs` keep shared structs (e.g. `UploadsInFlight`, `SqlSchema`/`SqlTable`) outside cfg gates, while wasm-bindgen externs + `EditorHandle` live in inner `#[cfg(target_arch = "wasm32")] mod wasm`. Similarly `src/sysinfo_sampler.rs` exposes `SystemSnapshot` on both targets but gates the actual sampler + `RwLock` behind `server`. **Shared types that compile on both targets**: bridges like `src/tiptap_bridge.rs` and `src/codemirror_bridge.rs` keep shared structs (e.g. `UploadsInFlight`, `SqlSchema`/`SqlTable`) outside cfg gates, while wasm-bindgen externs + `EditorHandle` live in inner `#[cfg(target_arch = "wasm32")] mod wasm`. Similarly `src/sysinfo_sampler.rs` exposes `SystemSnapshot` on both targets but gates the actual sampler + `RwLock` behind `server`.
@ -148,7 +148,7 @@ src/api/ — server functions + Axum handlers
auth.rs — login, register, session validation auth.rs — login, register, session validation
comments/ — comment CRUD + approval/spam/trash server functions comments/ — comment CRUD + approval/spam/trash server functions
database/ — /admin/system backend (status/system_status/sql_console/schema/export/backup/tasks) database/ — /admin/system backend (status/system_status/sql_console/schema/export/backup/tasks)
markdown.rs — Markdown→HTML rendering (pulldown-cmark + ammonia sanitization) markdown.rs — Markdown→HTML rendering (pulldown-cmark + ammonia sanitization + KaTeX math)
image.rs — image serving with processing pipeline + disk+memory cache image.rs — image serving with processing pipeline + disk+memory cache
upload.rs — image upload, auto-converts to WebP upload.rs — image upload, auto-converts to WebP
rate_limit.rs — governor-based rate limiting (6 tiers: strict/upload/image/comment/unknown/code_exec) rate_limit.rs — governor-based rate limiting (6 tiers: strict/upload/image/comment/unknown/code_exec)
@ -166,7 +166,7 @@ src/hooks/ — shared Dioxus hooks
src/models/ — Post, User, Tag data models src/models/ — Post, User, Tag data models
src/pages/ — route page components (frontend + admin) src/pages/ — route page components (frontend + admin)
src/router.rs — Dioxus Router route definitions src/router.rs — Dioxus Router route definitions
src/ssr_cache.rs — SSR generation invalidation state (server feature only) src/ssr_cache.rs — SSR disk-cache invalidation: physically deletes `static/<route>/` on post writes (Dioxus 0.7 has no route-level invalidation API)
src/sysinfo_sampler.rs — host metrics snapshot; SystemSnapshot on both targets, sampler+RwLock server-only src/sysinfo_sampler.rs — host metrics snapshot; SystemSnapshot on both targets, sampler+RwLock server-only
src/tasks/ — background tokio tasks (session cleanup) src/tasks/ — background tokio tasks (session cleanup)
src/theme.rs — light/dark theme with SSR cookie + WASM localStorage src/theme.rs — light/dark theme with SSR cookie + WASM localStorage
@ -192,15 +192,16 @@ Readers can execute fenced code blocks in isolated Docker containers; authors ge
## Frontend Lib Subprojects ## Frontend Lib Subprojects
Five Vite-built IIFE libraries under `libs/` (plus `libs/shared/`, an internal source-shared package with no `public/` artifact — see below), managed as a **pnpm workspace** (single `libs/pnpm-lock.yaml`, shared `libs/tsconfig.base.json` + `libs/biome.json`, hoisted devDeps). Built artifacts go to `public/<name>/`**do not edit `public/<name>/` files; they are build artifacts**. Each `build` script is `tsc --noEmit && vite build` (type-check before bundle). Output is IIFE because Dioxus `[web.resource] script` injects bare `<script src>` without `type="module"` support. Registered globally in `Dioxus.toml` `script`/`style` arrays. Six Vite-built IIFE libraries under `libs/` (plus `libs/shared/`, an internal source-shared package with no `public/` artifact — see below), managed as a **pnpm workspace** (single `libs/pnpm-lock.yaml`, shared `libs/tsconfig.base.json` + `libs/biome.json`, hoisted devDeps). Built artifacts go to `public/<name>/`**do not edit `public/<name>/` files; they are build artifacts**. Each `build` script is `tsc --noEmit && vite build` (type-check before bundle). Output is IIFE because Dioxus `[web.resource] script` injects bare `<script src>` without `type="module"` support. Registered globally in `Dioxus.toml` `script`/`style` arrays (except `mermaid-renderer`, which is dynamically imported — not in `Dioxus.toml`).
| Lib | Output dir | Exposes | Wiring | | Lib | Output dir | Exposes | Wiring |
| ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `libs/tiptap-editor/` | `public/tiptap/` (`editor.js`/`.css`/`.map`) | `window.TiptapEditor` | wasm-bindgen via `src/tiptap_bridge.rs` — injects `Closure` callbacks (`onUpdate`/`onReady`/`onUploadEvent`/`onImageUpload`) into `TiptapEditor.create`, holds instance + closures in `EditorHandle` (Drop → `destroy()`). No `js_sys::eval`, no `window` globals, no polling. | | `libs/tiptap-editor/` | `public/tiptap/` (`editor.js`/`.css`/`.map`) | `window.TiptapEditor` | wasm-bindgen via `src/tiptap_bridge.rs` — injects `Closure` callbacks (`onUpdate`/`onReady`/`onUploadEvent`/`onImageUpload`) into `TiptapEditor.create`, holds instance + closures in `EditorHandle` (Drop → `destroy()`). No `js_sys::eval`, no `window` globals, no polling. |
| `libs/codemirror-editor/` | `public/codemirror/` (`editor.js`/`.map`, **no CSS**) | `window.CodeMirrorEditor` (object literal `{ create }`) + `window.EditorOptions` (class, survives TS erasure) | `src/codemirror_bridge.rs` mirrors tiptap — `get_module()` uses `Reflect::get` + `unchecked_into` (object literal, NOT a constructor extern). Themes are JS `Extension`s from `@catppuccin/codemirror` (Latte/Mocha), hot-swapped via `Compartment.reconfigure`. | | `libs/codemirror-editor/` | `public/codemirror/` (`editor.js`/`.map`, **no CSS**) | `window.CodeMirrorEditor` (object literal `{ create }`) + `window.EditorOptions` (class, survives TS erasure) | `src/codemirror_bridge.rs` mirrors tiptap — `get_module()` uses `Reflect::get` + `unchecked_into` (object literal, NOT a constructor extern). Themes are JS `Extension`s from `@catppuccin/codemirror` (Latte/Mocha), hot-swapped via `Compartment.reconfigure`. |
| `libs/lightbox/` | `public/lightbox/` (`lightbox.js`/`.css`/`.map`) | self-initializing IIFE | **Not** wasm-bindgen. `src/components/post/post_content.rs` sets `window.__lightboxSelectors` before load; IIFE tail reads it and self-initializes. Direct fallback call if already loaded. | | `libs/lightbox/` | `public/lightbox/` (`lightbox.js`/`.css`/`.map`) | self-initializing IIFE | **Not** wasm-bindgen. `src/components/post/post_content.rs` sets `window.__lightboxSelectors` before load; IIFE tail reads it and self-initializes. Direct fallback call if already loaded. |
| `libs/yggdrasil-core/` | `public/yggdrasil-core/` (`yggdrasil-core.js`/`.css`/`.map`) | `window.__initPostContent`, `window.__startThemeTransition` | Designated home for all new core JS — add here, not to `public/js/`. Rust calls entry points via `js_sys::Reflect::get` + `Function::apply` (no `js_sys::eval`), silently no-oping if undefined. Theme reveal uses View Transitions API (`startViewTransition` + `@keyframes tt-expand` `clip-path` expand); falls back to instant switch when VT / `prefers-reduced-motion`. | | `libs/yggdrasil-core/` | `public/yggdrasil-core/` (`yggdrasil-core.js`/`.css`/`.map`) | `window.__initPostContent`, `window.__initMermaid`, `window.__startThemeTransition` | Designated home for all new core JS — add here, not to `public/js/`. Rust calls entry points via `js_sys::Reflect::get` + `Function::apply` (no `js_sys::eval`), silently no-oping if undefined. Theme reveal uses View Transitions API (`startViewTransition` + `@keyframes tt-expand` `clip-path` expand); falls back to instant switch when VT / `prefers-reduced-motion`. `mermaid.ts` scans `language-mermaid` blocks, dynamic-imports the mermaid bundle on viewport intersection. |
| `libs/xterm-terminal/` | `public/xterm/` (`terminal.js`/`.css`/`.map`) | `window.XtermTerminal` (object literal `{ create }`) | `src/xterm_bridge.rs` mirrors codemirror — `get_module()` uses `Reflect::get` + `unchecked_into` (object literal, NOT a constructor extern). `TerminalHandle` holds instance + `onReady` closure, Drop → `destroy()`. **Output-only** (no stdin); `write_stdout`/`write_stderr`/`write_all` stream the Code Runner's stdout/stderr into the terminal. `@xterm/xterm` ^6 + `@xterm/addon-fit`; theme hot-swap via `THEME_CHANGE_EVENT`. | | `libs/xterm-terminal/` | `public/xterm/` (`terminal.js`/`.css`/`.map`) | `window.XtermTerminal` (object literal `{ create }`) | `src/xterm_bridge.rs` mirrors codemirror — `get_module()` uses `Reflect::get` + `unchecked_into` (object literal, NOT a constructor extern). `TerminalHandle` holds instance + `onReady` closure, Drop → `destroy()`. **Output-only** (no stdin); `write_stdout`/`write_stderr`/`write_all` stream the Code Runner's stdout/stderr into the terminal. `@xterm/xterm` ^6 + `@xterm/addon-fit`; theme hot-swap via `THEME_CHANGE_EVENT`. |
| `libs/mermaid-renderer/` | `public/mermaid/` (`mermaid.js`/`.map`, ~3.4MB / gzip ~900KB) | default export (mermaid 11 API) | **Not** registered in `Dioxus.toml` (not globally injected). Dynamically imported by `yggdrasil-core/mermaid.ts` via `import('/mermaid/mermaid.js')` only when a `language-mermaid` block enters the viewport. IIFE wraps `mermaid ^11.16`; `securityLevel: 'strict'`; theme passed at init. No CSS (mermaid ships inline SVG styles). |
Run a single lib's tests: `cd libs && pnpm --filter @yggdrasil/<name> test` (Vitest + happy-dom). Watch mode: append `-- test:watch`. Run a single lib's tests: `cd libs && pnpm --filter @yggdrasil/<name> test` (Vitest + happy-dom). Watch mode: append `-- test:watch`.
@ -220,6 +221,25 @@ Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服
- `src/highlight.rs` uses syntect at runtime for code block highlighting - `src/highlight.rs` uses syntect at runtime for code block highlighting
- All gated behind `#[cfg(feature = "server")]` - All gated behind `#[cfg(feature = "server")]`
## Math & Diagrams (数学公式与流程图)
两种富内容,两条不同的渲染路径:
**数学公式(KaTeX 服务端渲染)**:
- `src/api/katex.rs`(server-only)用纯 Rust 的 `katex-rs = "0.2"` crate 把 TeX 渲染成 HTML span。库名是 `katex`(crate 名 `katex-rs`),核心 API `katex::render_to_string(&ctx, expr, &settings)``KatexContext`/`Settings` 内含 `RefCell<HashMap>` 宏表非 `Sync`,故用 `thread_local!` 缓存而非全局 static。
- `OutputFormat::Html`:只产出视觉层 `<span class="katex">…</span>`,不含 MathML 语义层(`<math>` 等)。这样 sanitizer 无需为 MathML 标签开白名单,XSS 面最小。`throw_on_error=false`:坏公式渲染成红色错误 span 而非中断全文。
- pulldown-cmark 的 `Options::all()`(含 `ENABLE_MATH`)解析 `$...$``InlineMath``$$...$$``DisplayMath` 事件(0.11+ 支持)。`src/api/markdown.rs` 事件循环有 `InlineMath`/`DisplayMath` 分支调 katex;评论路径 `comments/markdown.rs` 同样开了 `ENABLE_MATH`
- sanitizer:文章正文 `span` 已允许 `class`+`style`;评论路径 `span` 白名单也加了 `style`(KaTeX 内联定位 style 需保留)。**MathML 标签不放行**(OutputFormat::Html 不输出)。
- **配套 CSS/字体**:`katex-rs` 不打包 CSS。`make katex-css` 从 npm 包 `katex`(libs/ workspace 根 devDependency)的 dist/ 拷 `katex.min.css` + woff2 字体到 `public/katex/`(fonts/ 须与 CSS 同级,因 CSS 用相对 `url(fonts/...)`)。Dioxus.toml 两段 style 列表注册 `/katex/katex.min.css`。无 CSS 则只见裸 span、无数学字体排版。
**流程图(mermaid 客户端懒加载)**:
- mermaid 无官方 SSR 支持(社区方案靠 Puppeteer,太重)。采用客户端 IntersectionObserver 懒加载。
- 服务端:mermaid 代码块就是普通 `<pre><code class="language-mermaid">`,`markdown.rs` 无特殊处理(syntect 对 mermaid 无语法定义,回退纯文本)。`pre``class` 在 sanitizer 通用白名单。
- 前端:`libs/mermaid-renderer/` 是独立 IIFE bundle(mermaid 11.16,~1MB,gzip ~900KB),输出 `public/mermaid/mermaid.js``libs/yggdrasil-core/src/mermaid.ts` 扫描 `pre > code.language-mermaid`,IntersectionObserver 视口可见(rootMargin 200px)时动态 `import('/mermaid/mermaid.js')`(单例缓存),`mermaid.render` 产 SVG 注入父 `pre`。主题经 `__initMermaid(selector, theme)` 传入(light/dark),`securityLevel: 'strict'`。渲染失败加 `.mermaid-error` class 保留源码。幂等守卫 `dataset.mermaidRendered`
- 动态 import 的绝对路径 `/mermaid/mermaid.js` 无类型声明,tsc 会报 TS2307,故用变量间接构造 import 字面量 + `@vite-ignore`
- `post_content.rs` 的 use_effect 调 `__initMermaid('.post-content', theme)`,读 `use_resolved_theme()` 传主题。`initCopyButtons`(`post-content.ts`)跳过 `language-mermaid` 块(图无需 copy 按钮)。mermaid 渲染的 SVG 是客户端 `innerHTML` 注入,不经过服务端 sanitizer(与 CodeRunner 模式一致)。
- Dioxus.toml 的 script 列表**不加** mermaid(它是动态 import 的独立 bundle,非全局注入);public/mermaid 由 dx build 作静态资源拷贝。
## Auth & Session ## Auth & Session
- **Registration**: first user becomes `admin`; subsequent registrations rejected with `"Registration is closed"` - **Registration**: first user becomes `admin`; subsequent registrations rejected with `"Registration is closed"`
@ -231,7 +251,7 @@ Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服
- **Post/tag caches** (`src/cache.rs`): moka future-based, TTL varies by data type (60s600s). Invalidated on writes. - **Post/tag caches** (`src/cache.rs`): moka future-based, TTL varies by data type (60s600s). Invalidated on writes.
- **Image processing cache** (`src/api/image.rs`): two-tier — in-memory moka cache + disk cache in `uploads/.cache/`. Keyed by path + query params. - **Image processing cache** (`src/api/image.rs`): two-tier — in-memory moka cache + disk cache in `uploads/.cache/`. Keyed by path + query params.
- **SSR cache** (`IncrementalRendererConfig` in `src/main.rs`): default TTL `SSR_CACHE_SECS` (3600s prod, 0 in `make dev`); invalidation generation tracked in `src/ssr_cache.rs`. - **SSR cache** (`IncrementalRendererConfig` in `src/main.rs`): Dioxus 0.7 增量渲染器把每路由结果落盘到 `static/<route>/index/<hash>.html`,以 `path_and_query()` 作 key只暴露 `invalidate_after(ttl)` 一个失效手段。`src/ssr_cache.rs``invalidate_ssr_route(route)` / `invalidate_ssr_all_public()` 通过**物理删除缓存目录**绕过这个限制——所有写路径create/update/rebuild/delete/trash在事务提交后调用删除对应路由或全量公开页的缓存目录下次请求触发重渲染。全局世代号保留作 `X-SSR-Generation` 可观测性但实际失效靠删文件。TTL `SSR_CACHE_SECS`(默认 3600s prod0 in `make dev`)仍作兜底。
## Testing ## Testing
@ -270,6 +290,8 @@ Most Rust tests use `#[cfg(all(test, feature = "server"))]` — they only run wh
- `public/style.css` — Tailwind output - `public/style.css` — Tailwind output
- `public/highlight.css` — generated by `generate_highlight_css` binary - `public/highlight.css` — generated by `generate_highlight_css` binary
- `public/{tiptap,codemirror,lightbox,yggdrasil-core}/` — Vite build outputs - `public/{tiptap,codemirror,lightbox,yggdrasil-core}/` — Vite build outputs
- `public/mermaid/` — mermaid IIFE bundle (from `libs/mermaid-renderer/`)
- `public/katex/` — KaTeX CSS + woff2 fonts (from npm `katex` dist via `make katex-css`)
- `public/doc/` — cargo doc output (copied by `make doc`) - `public/doc/` — cargo doc output (copied by `make doc`)
- `/dist`, `/.dioxus`, `/target`, `/static` - `/dist`, `/.dioxus`, `/target`, `/static`
- `libs/node_modules/` + `libs/*/node_modules/` — pnpm workspace store + symlinks - `libs/node_modules/` + `libs/*/node_modules/` — pnpm workspace store + symlinks

97
Cargo.lock generated
View File

@ -398,6 +398,31 @@ dependencies = [
"serde_repr", "serde_repr",
] ]
[[package]]
name = "bon"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "3.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
dependencies = [
"darling",
"ident_case",
"prettyplease",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "8.0.4" version = "8.0.4"
@ -895,6 +920,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim",
"syn", "syn",
] ]
@ -2155,6 +2181,12 @@ dependencies = [
"http", "http",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.2" version = "0.5.2"
@ -2585,6 +2617,24 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "katex-rs"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5382fea1e8edf972c23050cdfec2d12beca242e6d15e32310e5d1543a51d103"
dependencies = [
"bon",
"phf",
"phf_codegen",
"rapidhash",
"serde",
"serde_json",
"strum",
"strum_macros",
"thiserror 2.0.18",
"unicode-normalization",
]
[[package]] [[package]]
name = "keyboard-types" name = "keyboard-types"
version = "0.7.0" version = "0.7.0"
@ -3394,6 +3444,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -3661,6 +3721,15 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "rapidhash"
version = "4.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "raw-cpuid" name = "raw-cpuid"
version = "11.6.0" version = "11.6.0"
@ -4246,6 +4315,33 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "subsecond" name = "subsecond"
version = "0.7.9" version = "0.7.9"
@ -5512,6 +5608,7 @@ dependencies = [
"http", "http",
"image", "image",
"js-sys", "js-sys",
"katex-rs",
"lol_html", "lol_html",
"md-5 0.10.6", "md-5 0.10.6",
"mimalloc", "mimalloc",

View File

@ -58,6 +58,11 @@ tokio-stream = { version = "0.1", features = ["sync"], optional = true }
# 且对全静态 musl 链接友好(生产 Docker 镜像即 musl 目标)。 # 且对全静态 musl 链接友好(生产 Docker 镜像即 musl 目标)。
# server-only经 server feature 启用WASM 前端构建不编译(见 main.rs cfg 门控)。 # server-only经 server feature 启用WASM 前端构建不编译(见 main.rs cfg 门控)。
mimalloc = { version = "0.1", optional = true } mimalloc = { version = "0.1", optional = true }
# KaTeX 服务端数学公式渲染:纯 Rust 重实现(无 JS 运行时),渲染 $...$ / $$...$$
# 为 HTML span。库名是 katexcrate 名 katex-rs代码用 `use katex::...`。
# 默认 features 为空即原生服务端编译wasm feature 仅前端构建才需,这里不开。
# 配套前端需引入 katex.min.css + 字体(见 public/katex/、Makefile katex-css
katex-rs = { version = "0.2", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] } web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] }
@ -119,4 +124,5 @@ server = [
"dep:bollard", "dep:bollard",
"dep:tokio-stream", "dep:tokio-stream",
"dep:mimalloc", "dep:mimalloc",
"dep:katex-rs",
] ]

View File

@ -10,9 +10,9 @@ title = "Yggdrasil - Dioxus SSR"
watch_path = ["src", "Cargo.toml"] watch_path = ["src", "Cargo.toml"]
[web.resource] [web.resource]
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css", "/xterm/terminal.css"] style = ["/style.css", "/highlight.css", "/katex/katex.min.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css", "/xterm/terminal.css"]
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js", "/xterm/terminal.js"] script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js", "/xterm/terminal.js"]
[web.resource.dev] [web.resource.dev]
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css", "/xterm/terminal.css"] style = ["/style.css", "/highlight.css", "/katex/katex.min.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css", "/xterm/terminal.css"]
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js", "/xterm/terminal.js"] script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js", "/xterm/terminal.js"]

View File

@ -11,7 +11,6 @@ FROM rust:1.96-bookworm AS builder
# - Debian apt -> TUNA (Tsinghua) # - Debian apt -> TUNA (Tsinghua)
# - Rust + crates.io -> rsproxy (ByteDance) # - Rust + crates.io -> rsproxy (ByteDance)
# - Node.js + npm/pnpm -> npmmirror (Alibaba) # - Node.js + npm/pnpm -> npmmirror (Alibaba)
# - Tailwind standalone -> gh-proxy.com (China GitHub-release CDN)
ARG DEBIAN_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian ARG DEBIAN_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian
ARG DEBIAN_SECURITY_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian-security ARG DEBIAN_SECURITY_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian-security
ARG NODE_MIRROR=https://registry.npmmirror.com/-/binary/node ARG NODE_MIRROR=https://registry.npmmirror.com/-/binary/node
@ -77,17 +76,31 @@ RUN mkdir -p /usr/local/cargo \
RUN rustup target add wasm32-unknown-unknown \ RUN rustup target add wasm32-unknown-unknown \
x86_64-unknown-linux-musl aarch64-unknown-linux-musl x86_64-unknown-linux-musl aarch64-unknown-linux-musl
# Install the Dioxus CLI (must match the dioxus crate version). # Install the Dioxus CLI from the official prebuilt binary (GitHub Releases),
RUN cargo install dioxus-cli --version 0.7.9 --locked # NOT `cargo install` (which compiles dx-cli's huge dep tree from source — the
# slowest single Docker step). The release tag v0.7.9 matches the crate version
# we previously pinned. dx runs only in this builder stage (to emit the WASM
# client bundle); it never enters the static-musl runtime image, so the glibc
# (linux-gnu) linking of the prebuilt binary is fine here. Each buildx platform
# leg downloads only its native arch; the sha256 pins the exact artifact
# (supply-chain integrity, verified against the release's .sha256 sidecar).
ARG DX_VERSION=0.7.9
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) DX_TRIPLET=x86_64-unknown-linux-gnu DX_SHA256=3b132551b480bc96f938f9f0d37936ee1190f994977539dcc347eaf38540d005 ;; \
arm64) DX_TRIPLET=aarch64-unknown-linux-gnu DX_SHA256=8cf14db0b11b43b31dd6d39e71b00e567f2fccfde85ae3a8f7ef0f8745e5ccfb ;; \
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
esac \
&& DX_URL="https://github.com/DioxusLabs/dioxus/releases/download/v${DX_VERSION}/dx-${DX_TRIPLET}.tar.gz" \
&& curl -fsSL "${DX_URL}" -o /tmp/dx.tar.gz \
&& echo "${DX_SHA256} /tmp/dx.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/dx.tar.gz -C /usr/local/bin \
&& rm /tmp/dx.tar.gz \
&& dx --version
# --- Tailwind CSS v4: the standalone binary is distributed via GitHub # --- Tailwind CSS v4: the standalone binary is distributed via GitHub
# Releases (~106 MB). GitHub's release CDN is slow/unreliable from inside the # Releases (~106 MB). ---
# container, and the host proxy (host.docker.internal:10808) throttles large
# files to ~16 KB/s. Route the download through a China GitHub proxy CDN
# instead — gh-proxy.com served the full file at ~430 KB/s in testing.
# Falls back to the direct GitHub URL if GH_PROXY is set empty. ---
ARG TAILWIND_VERSION=4.3.1 ARG TAILWIND_VERSION=4.3.1
ARG GH_PROXY=https://gh-proxy.com
RUN ARCH="$(dpkg --print-architecture)" \ RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \ && case "$ARCH" in \
amd64) TW_ARCH=x64 ;; \ amd64) TW_ARCH=x64 ;; \
@ -95,7 +108,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \ *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
esac \ esac \
&& GH_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/v${TAILWIND_VERSION}/tailwindcss-linux-${TW_ARCH}" \ && GH_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/v${TAILWIND_VERSION}/tailwindcss-linux-${TW_ARCH}" \
&& curl -fsSL -o /usr/local/bin/tailwindcss "${GH_PROXY:+${GH_PROXY}/}${GH_URL}" \ && curl -fsSL -o /usr/local/bin/tailwindcss "${GH_URL}" \
&& chmod +x /usr/local/bin/tailwindcss && chmod +x /usr/local/bin/tailwindcss
WORKDIR /build WORKDIR /build

View File

@ -1,9 +1,10 @@
.PHONY: dev build build-linux build-freebsd freebsd-sysroot docker docker-amd64 docker-apple docker-multiarch css css-watch clean build-libs build-editor build-codemirror build-lightbox build-core build-xterm highlight-css test doc doc-open start lint fix restore-webp .PHONY: dev build build-linux build-freebsd freebsd-sysroot docker docker-amd64 docker-apple docker-multiarch css css-watch clean build-libs build-editor build-codemirror build-lightbox build-core build-xterm highlight-css katex-css test doc doc-open start lint fix restore-webp
build: build:
@cd libs && pnpm install --frozen-lockfile @cd libs && pnpm install --frozen-lockfile
@$(MAKE) build-libs @$(MAKE) build-libs
@$(MAKE) highlight-css @$(MAKE) highlight-css
@$(MAKE) katex-css
@tailwindcss -i input.css -o public/style.css --minify @tailwindcss -i input.css -o public/style.css --minify
@$(MAKE) doc @$(MAKE) doc
@dx build --release --debug-symbols=false @dx build --release --debug-symbols=false
@ -13,6 +14,7 @@ build-linux:
@cd libs && pnpm install --frozen-lockfile @cd libs && pnpm install --frozen-lockfile
@$(MAKE) build-libs @$(MAKE) build-libs
@$(MAKE) highlight-css @$(MAKE) highlight-css
@$(MAKE) katex-css
@tailwindcss -i input.css -o public/style.css --minify @tailwindcss -i input.css -o public/style.css --minify
@dx build @client --release --debug-symbols=false --wasm-js-cfg false @dx build @client --release --debug-symbols=false --wasm-js-cfg false
@dx build @server --release --debug-symbols=false --target x86_64-unknown-linux-musl --wasm-js-cfg false --features server @dx build @server --release --debug-symbols=false --target x86_64-unknown-linux-musl --wasm-js-cfg false --features server
@ -82,6 +84,17 @@ restore-webp:
highlight-css: highlight-css:
@cargo run --bin generate_highlight_css @cargo run --bin generate_highlight_css
# 把 npm 包 katex 的 dist/ 拷贝到 public/katex/(服务端 katex-rs 不打包 CSS
# KaTeX 的 katex.min.css 用相对 URL 引 fonts/,故 fonts/ 必须与 CSS 同级。
# 只拷 woff2现代浏览器全支持省去 woff/ttf ~70% 字体体积)。
# katex 作为 libs/ workspace 根 devDependencypnpm install 后在 libs/node_modules/katex/。
katex-css:
@echo "Copying KaTeX CSS + woff2 fonts to public/katex/..."
@mkdir -p public/katex/fonts
@cp libs/node_modules/katex/dist/katex.min.css public/katex/katex.min.css
@cp libs/node_modules/katex/dist/fonts/*.woff2 public/katex/fonts/
@echo "KaTeX CSS ready at public/katex/"
# 并行构建全部 libs/ 子项目pnpm -r 拓扑顺序,无相互依赖则并发)。 # 并行构建全部 libs/ 子项目pnpm -r 拓扑顺序,无相互依赖则并发)。
# 依赖安装由调用方负责build/build-linux 用 pnpm install --frozen-lockfile # 依赖安装由调用方负责build/build-linux 用 pnpm install --frozen-lockfile
# dev 假设 node_modules 已存在)。 # dev 假设 node_modules 已存在)。
@ -94,8 +107,9 @@ build-codemirror: ; @cd libs && pnpm --filter @yggdrasil/codemirror-editor run b
build-lightbox: ; @cd libs && pnpm --filter @yggdrasil/lightbox run build build-lightbox: ; @cd libs && pnpm --filter @yggdrasil/lightbox run build
build-core: ; @cd libs && pnpm --filter @yggdrasil/core run build build-core: ; @cd libs && pnpm --filter @yggdrasil/core run build
build-xterm: ; @cd libs && pnpm --filter @yggdrasil/xterm-terminal run build build-xterm: ; @cd libs && pnpm --filter @yggdrasil/xterm-terminal run build
build-mermaid: ; @cd libs && pnpm --filter @yggdrasil/mermaid-renderer run build
dev: build-libs highlight-css dev: build-libs highlight-css katex-css
@echo "Cleaning static/..." @echo "Cleaning static/..."
@rm -rf static/ @rm -rf static/
@echo "Building CSS..." @echo "Building CSS..."
@ -188,6 +202,8 @@ docker-multiarch:
clean: clean:
@cargo clean @cargo clean
@rm -f public/style.css public/highlight.css @rm -f public/style.css public/highlight.css
@rm -rf public/katex
@rm -rf public/mermaid
@rm -rf public/doc @rm -rf public/doc
@rm -rf uploads/.cache @rm -rf uploads/.cache
@rm -rf libs/node_modules libs/*/node_modules @rm -rf libs/node_modules libs/*/node_modules

View File

@ -0,0 +1,14 @@
{
"name": "@yggdrasil/mermaid-renderer",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit && vite build",
"dev": "vite",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"mermaid": "^11.16.0"
}
}

View File

@ -0,0 +1,5 @@
// 把 mermaid 默认导出包成 IIFE 全局 bundle。
// yggdrasil-core 的 mermaid.ts 通过动态 import('/mermaid/mermaid.js') 拿到 .default。
import mermaid from 'mermaid';
export default mermaid;

View File

@ -0,0 +1,4 @@
{
"extends": "../tsconfig.base.json",
"include": ["src"]
}

View File

@ -0,0 +1,27 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
// mermaid 独立 IIFE bundle~1MB不打进 yggdrasil-core避免拖累每篇文章首屏
// 由 yggdrasil-core 的 mermaid.ts 在 IntersectionObserver 视口可见时动态 import。
// 输出到 public/mermaid/mermaid.jsdx build 会作为静态资源拷贝。
export default defineConfig({
build: {
outDir: resolve(__dirname, '../../public/mermaid'),
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MermaidRenderer',
fileName: () => 'mermaid.js',
formats: ['iife'],
},
// mermaid 全量打进单文件(含其依赖 cytoscape/dagre-d3 等)。
rolldownOptions: {
output: {
inlineDynamicImports: true,
},
},
cssCodeSplit: false,
minify: true,
sourcemap: true,
},
});

View File

@ -12,6 +12,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.3", "@biomejs/biome": "^2.5.3",
"happy-dom": "^20.10.6", "happy-dom": "^20.10.6",
"katex": "^0.16.22",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.1.3", "vite": "^8.1.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"

823
libs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,15 @@
import { initAnchorClick } from './anchor-click'; import { initAnchorClick } from './anchor-click';
import { scrollToHash } from './hash-scroll'; import { scrollToHash } from './hash-scroll';
import { initMermaid } from './mermaid';
import { initPostContent } from './post-content'; import { initPostContent } from './post-content';
import { applyResolvedTheme, startThemeTransition } from './theme-transition'; import { applyResolvedTheme, startThemeTransition } from './theme-transition';
import type { ThemeName } from '@yggdrasil/shared';
import './style.css'; import './style.css';
declare global { declare global {
interface Window { interface Window {
__initPostContent: (selector: string) => void; __initPostContent: (selector: string) => void;
__initMermaid: (selector: string, theme: ThemeName) => void;
__initAnchorClick: () => void; __initAnchorClick: () => void;
__scrollToHash: () => void; __scrollToHash: () => void;
__startThemeTransition: (x: number, y: number) => void; __startThemeTransition: (x: number, y: number) => void;
@ -15,6 +18,7 @@ declare global {
} }
window.__initPostContent = initPostContent; window.__initPostContent = initPostContent;
window.__initMermaid = initMermaid;
window.__initAnchorClick = initAnchorClick; window.__initAnchorClick = initAnchorClick;
window.__scrollToHash = scrollToHash; window.__scrollToHash = scrollToHash;
window.__startThemeTransition = startThemeTransition; window.__startThemeTransition = startThemeTransition;

View File

@ -0,0 +1,120 @@
/**
* mermaid 退
* window.__initMermaid mock IntersectionObserver mermaid bundle
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index';
import { _resetMermaidLoader } from './mermaid';
// mock IntersectionObserverobserve 时立即异步触发 isIntersecting 回调,模拟块进视口。
const disconnect = vi.fn();
const observe = vi.fn();
let intersectCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: (entries: { isIntersecting: boolean }[]) => void) {
intersectCallback = cb;
}
observe() {
observe();
// 立即触发可见,模拟块已在视口内。
if (intersectCallback) intersectCallback([{ isIntersecting: true }]);
}
disconnect() {
disconnect();
}
},
);
describe('initMermaid', () => {
const mockRender = vi.fn().mockResolvedValue({ svg: '<svg>diagram</svg>' });
const mockInitialize = vi.fn();
beforeEach(() => {
document.body.innerHTML = '';
mockRender.mockResolvedValue({ svg: '<svg>diagram</svg>' });
mockRender.mockClear();
mockInitialize.mockClear();
observe.mockClear();
disconnect.mockClear();
// 注入 mock mermaid bundle 加载函数
_resetMermaidLoader(async () => ({ initialize: mockInitialize, render: mockRender }));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('扫描 language-mermaid 块并渲染成 SVG', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.innerHTML).toContain('<svg>diagram</svg>');
});
});
it('用 dark 主题初始化 mermaid', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockInitialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'dark' }));
});
});
it('幂等:重复调用不重复渲染已处理的块', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
mockRender.mockClear();
// 第二次调用(模拟上下篇切换):不应再次 render
window.__initMermaid('.post-content', 'light');
await new Promise((r) => setTimeout(r, 50));
expect(mockRender).not.toHaveBeenCalled();
});
it('非 mermaid 代码块不受影响', () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-rust">fn main() {}</code></pre>';
document.body.appendChild(root);
expect(() => window.__initMermaid('.post-content', 'light')).not.toThrow();
// 不应尝试渲染 rust 块
expect(observe).not.toHaveBeenCalled();
});
it('selector 未命中时不报错', () => {
expect(() => window.__initMermaid('.not-exist', 'light')).not.toThrow();
});
it('渲染失败时加 mermaid-error class', async () => {
mockRender.mockRejectedValueOnce(new Error('syntax error'));
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">bad syntax</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.classList.contains('mermaid-error')).toBe(true);
});
});
});

View File

@ -0,0 +1,116 @@
/**
* Mermaid
*
* `pre > code.language-mermaid` import bundle
* `public/mermaid/mermaid.js`~1MB
* mermaid SVG <pre>
*
* post-content.tsquerySelectorAll + + DOM
* mermaid SSR markdown.rs
* `language-mermaid` class data
*/
import type { ThemeName } from '@yggdrasil/shared';
type MermaidApi = {
initialize: (config: Record<string, unknown>) => void;
render: (id: string, text: string) => Promise<{ svg: string }>;
};
let mermaidPromise: Promise<MermaidApi> | null = null;
/**
* mermaid bundle 便
*
* '/mermaid/mermaid.js' importVite
* @vite-ignore import
* tsc TS2307bundle default export mermaid API
* `loadMermaidBundle` mock
*/
export let loadMermaidBundle: () => Promise<MermaidApi> = async () => {
const url = '/mermaid/mermaid.js';
const mod = (await import(/* @vite-ignore */ url)) as { default: MermaidApi };
return mod.default;
};
/** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */
export function _resetMermaidLoader(loader?: () => Promise<MermaidApi>): void {
mermaidPromise = null;
if (loader) loadMermaidBundle = loader;
}
/**
* mermaid bundle
*/
function loadMermaid(): Promise<MermaidApi> {
if (!mermaidPromise) {
mermaidPromise = loadMermaidBundle().catch((err) => {
mermaidPromise = null;
throw err;
});
}
return mermaidPromise;
}
/**
* mermaid <pre> IntersectionObserver
*
* IntersectionObserverSSR /
* rootMargin 200px
*/
function observeBlock(pre: HTMLPreElement, render: () => Promise<void>): void {
if (typeof IntersectionObserver === 'undefined') {
void render();
return;
}
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
io.disconnect();
void render();
}
},
{ rootMargin: '200px' },
);
io.observe(pre);
}
/**
* mermaid
*
* @param selector '.post-content'
* @param theme mermaid
*/
export function initMermaid(selector: string, theme: ThemeName): void {
const root = document.querySelector(selector);
if (!root) return;
const blocks = root.querySelectorAll<HTMLPreElement>('pre > code.language-mermaid');
if (blocks.length === 0) return;
blocks.forEach((code, i) => {
const pre = code.parentElement as HTMLPreElement | null;
if (!pre) return;
if (pre.dataset.mermaidRendered) return; // 幂等:上下篇切换重复调用不重渲染
const source = code.textContent || '';
observeBlock(pre, async () => {
try {
const mermaid = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
theme: theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
});
const { svg } = await mermaid.render(`mermaid-svg-${i}`, source);
// 替换整个 <pre> 内容为 SVG丢弃 copy 按钮(图不需要复制源码)。
pre.innerHTML = svg;
pre.dataset.mermaidRendered = 'true';
} catch (err) {
// 渲染失败(语法错误 / bundle 加载失败):保留原始源码,加错误标记 class
// 便于用户发现是 mermaid 源写错了。不破坏页面其余内容。
console.error('mermaid render failed:', err);
pre.classList.add('mermaid-error');
}
});
});
}

View File

@ -11,6 +11,8 @@ function initCopyButtons(root: Element): void {
const pre = code.parentElement; const pre = code.parentElement;
if (!pre) return; if (!pre) return;
if (pre.querySelector('.copy-code')) return; if (pre.querySelector('.copy-code')) return;
// mermaid 代码块由 mermaid.ts 渲染成 SVG不注入 copy 按钮(图无需复制源码)。
if (code.classList.contains('language-mermaid')) return;
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'copy-code'; btn.className = 'copy-code';

View File

@ -22,7 +22,7 @@ pub fn clean_comment_html(input: &str) -> String {
pub fn render_comment_markdown(md: &str) -> String { pub fn render_comment_markdown(md: &str) -> String {
use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd}; use pulldown_cmark::{CodeBlockKind, Event, Options, Tag, TagEnd};
let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_MATH;
let parser = pulldown_cmark::Parser::new_ext(md, opts); let parser = pulldown_cmark::Parser::new_ext(md, opts);
let mut events: Vec<Event> = Vec::new(); let mut events: Vec<Event> = Vec::new();
@ -33,6 +33,17 @@ pub fn render_comment_markdown(md: &str) -> String {
// 逐事件处理 Markdown AST转换标题并收集代码块内容。 // 逐事件处理 Markdown AST转换标题并收集代码块内容。
for event in parser { for event in parser {
match event { match event {
Event::InlineMath(tex) => {
// 内联公式直接渲染成 HTML 注入事件流(评论不使用块级段落包裹)。
let html = crate::api::katex::render_inline(&tex);
events.push(Event::Html(html.into()));
}
Event::DisplayMath(tex) => {
// 评论里的块级公式KaTeX 输出本身已含 .katex-display 居中样式,
// 无需额外 <p>(评论渲染较紧凑,避免引入多余段间距)。
let html = crate::api::katex::render_display(&tex);
events.push(Event::Html(html.into()));
}
Event::Start(Tag::Heading { .. }) => { Event::Start(Tag::Heading { .. }) => {
// 评论中不保留标题层级,统一加粗。 // 评论中不保留标题层级,统一加粗。
events.push(Event::Start(Tag::Strong)); events.push(Event::Start(Tag::Strong));
@ -233,4 +244,40 @@ mod tests {
assert!(result.contains("<code>foo()</code>")); assert!(result.contains("<code>foo()</code>"));
assert!(!result.contains("<h2>")); assert!(!result.contains("<h2>"));
} }
#[test]
fn render_comment_inline_math() {
// 评论里的 $...$ 内联公式ENABLE_MATH 解析 → katex 渲染 → sanitizer 放行 span。
let result = render_comment_markdown("方程 $a^2 + b^2 = c^2$ 是勾股定理");
assert!(
result.contains("katex"),
"评论内联公式应渲染为 katex span, got: {}",
result
);
assert!(result.contains("方程"));
assert!(result.contains("勾股定理"));
}
#[test]
fn render_comment_display_math() {
// 评论里的 $$...$$ 块级公式。
let result = render_comment_markdown("$$\\int_0^1 x\\,dx$$");
assert!(
result.contains("katex-display"),
"评论块级公式应含 katex-display, got: {}",
result
);
}
#[test]
fn render_comment_math_span_style_preserved() {
// KaTeX 内联 style垂直对齐必须经 clean_comment_html 保留,
// 否则公式在评论里排版错位。验证 span 的 style 属性未被剥离。
let result = render_comment_markdown("$x^2$");
assert!(
result.contains("style=\""),
"评论 katex span 的 style 应保留, got: {}",
result
);
}
} }

117
src/api/katex.rs Normal file
View File

@ -0,0 +1,117 @@
//! KaTeX 服务端数学公式渲染。
//!
//! 用纯 Rust 的 [`katex`](https://crates.io/crates/katex-rs) crate 把 TeX 公式
//! 渲染成 HTML span供 pulldown-cmark 的 `InlineMath` / `DisplayMath` 事件调用。
//! 仅在 `feature = "server"` 时编译——前端 WASM 不参与公式渲染SSR 即终态)。
//!
//! 渲染策略:
//! - `OutputFormat::Html`:只产出视觉层 `<span class="katex">…</span>`,不含 MathML
//! 语义层(`<math>` 等)。这样 sanitizer 无需为 MathML 标签开白名单XSS 面最小。
//! 屏幕阅读器等无障碍场景的语义损失可接受(本站数学公式占比低)。
//! - `throw_on_error = false`:坏公式渲染成红色错误 span 而非中断整篇文章。
//!
//! 配套资源:前端必须加载 `public/katex/katex.min.css` + `fonts/`(见 Makefile
//! `katex-css`),否则只有裸 span、无数学字体排版。crate 本身不打包 CSS。
#![cfg(feature = "server")]
use katex::{KatexContext, OutputFormat, Settings};
/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`。
fn inline_settings() -> Settings {
Settings {
output: OutputFormat::Html,
display_mode: false,
throw_on_error: false,
..Settings::default()
}
}
/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行)。
fn display_settings() -> Settings {
Settings {
output: OutputFormat::Html,
display_mode: true,
throw_on_error: false,
..Settings::default()
}
}
thread_local! {
/// KaTeX 上下文:含全部内置符号 / 宏表应在多次渲染间复用README 建议)。
/// 用 thread_local 而非全局 static`KatexContext` 内含 `RefCell<HashMap>`
/// 宏表,非 `Sync`,不能放 `LazyLock`。tokio 多线程 runtime 下每线程各持一份。
static KATEX_CTX: KatexContext = KatexContext::default();
/// 每线程缓存的渲染配置,避免每次渲染都重建宏表 HashMap。
/// `Settings` 同样因 `RefCell` 宏表非 `Sync`。
static INLINE_SETTINGS: Settings = inline_settings();
static DISPLAY_SETTINGS: Settings = display_settings();
}
/// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
///
/// 渲染失败(坏 TeX时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。
pub fn render_inline(tex: &str) -> String {
KATEX_CTX.with(|ctx| {
INLINE_SETTINGS.with(|settings| {
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
})
})
}
/// 渲染块级公式 `$$...$$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
///
/// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹
/// (如 `<p class="math-display">`),这里只产出 KaTeX 的 span 串。
pub fn render_display(tex: &str) -> String {
KATEX_CTX.with(|ctx| {
DISPLAY_SETTINGS.with(|settings| {
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_inline_produces_katex_span() {
let html = render_inline("E = mc^2");
assert!(
html.contains("katex"),
"内联公式应产出含 katex class 的 span, got: {html}"
);
}
#[test]
fn render_display_produces_katex_display() {
let html = render_display("\\frac{a}{b}");
assert!(
html.contains("katex-display"),
"块级公式应产出含 katex-display class 的结构, got: {html}"
);
}
#[test]
fn render_bad_tex_does_not_panic() {
// throw_on_error=false坏 TeX 不应 panic、不应返回 Err。
// KaTeX 可能渲染成红色错误 span也可能把未知宏当字面文本处理。
// 关键契约:返回非空字符串、不中断调用方。
let html = render_inline("\\thisisnotarealmacroxyz{");
assert!(!html.is_empty(), "坏公式应返回非空 HTML, got empty");
}
#[test]
fn render_inline_does_not_emit_math_tag() {
// OutputFormat::Html 不应产出 <math> 标签(那是 HtmlAndMathml / Mathml 模式)。
let html = render_inline("a^2 + b^2 = c^2");
assert!(
!html.contains("<math"),
"Html 输出不应含 <math> 标签, got: {html}"
);
}
}

View File

@ -211,6 +211,26 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
html.push_str("</code></pre>"); html.push_str("</code></pre>");
in_codeblock = false; in_codeblock = false;
} }
Event::InlineMath(tex) => {
// 先刷出累积的普通事件,再注入 KaTeX 渲染结果。
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
// 标题内的公式按内联渲染(标题不应用块级公式)。
html.push_str(&crate::api::katex::render_inline(&tex));
}
Event::DisplayMath(tex) => {
if !non_heading_events.is_empty() {
pulldown_cmark::html::push_html(&mut html, non_heading_events.into_iter());
non_heading_events = Vec::new();
}
// 块级公式独占一行:用 <p class="math-display"> 包裹 KaTeX 输出。
// KaTeX 自身已产出 .katex-display外层 <p> 负责段间距与居中容器。
html.push_str("<p class=\"math-display\">");
html.push_str(&crate::api::katex::render_display(&tex));
html.push_str("</p>");
}
_ => { _ => {
if in_heading { if in_heading {
// 标题内部只保留文本与行内代码,避免嵌套块级元素。 // 标题内部只保留文本与行内代码,避免嵌套块级元素。
@ -823,4 +843,68 @@ console.log(1)
assert!(result.html.contains("未完成")); assert!(result.html.contains("未完成"));
assert!(result.html.contains("已完成")); assert!(result.html.contains("已完成"));
} }
#[test]
fn render_markdown_inline_math() {
// $...$ 内联公式pulldown-cmark (ENABLE_MATH) 解析 → katex 渲染成 span。
// sanitizer 放行 span 的 class/styleKaTeX 输出应原样保留。
let result = render_markdown_enhanced("公式 $E = mc^2$ 很重要");
assert!(
result.html.contains("katex"),
"内联公式应渲染为 katex span, got: {}",
result.html
);
// 前后文本保留
assert!(result.html.contains("公式"));
assert!(result.html.contains("很重要"));
}
#[test]
fn render_markdown_display_math() {
// $$...$$ 块级公式:应产出 <p class="math-display"> + katex-display。
let result = render_markdown_enhanced("$$\\frac{a}{b}$$");
assert!(
result.html.contains("math-display"),
"块级公式应用 math-display 段落包裹, got: {}",
result.html
);
assert!(
result.html.contains("katex-display"),
"KaTeX 块级输出应含 katex-display, got: {}",
result.html
);
}
#[test]
fn render_markdown_inline_math_in_heading() {
// 标题内的 $...$ 按内联公式渲染(不应崩,也不应产生块级 <p>)。
let result = render_markdown_enhanced("## 勾股 $a^2 + b^2 = c^2$ 定理");
assert!(
result.html.contains("katex"),
"标题内联公式应渲染, got: {}",
result.html
);
// 标题里不应出现块级公式的 <p class="math-display">
assert!(
!result.html.contains("math-display"),
"标题内不应有块级公式包裹, got: {}",
result.html
);
}
#[test]
fn render_markdown_bad_math_does_not_break() {
// 坏 TeX 不应中断整篇渲染throw_on_error=false 回退到错误 span。
let result = render_markdown_enhanced("正常文本 $\\undefinedmacro{$ 后续文本");
assert!(
result.html.contains("正常文本"),
"坏公式不应破坏前文, got: {}",
result.html
);
assert!(
result.html.contains("后续文本"),
"坏公式不应破坏后文, got: {}",
result.html
);
}
} }

View File

@ -20,6 +20,9 @@ pub mod error;
pub mod health; pub mod health;
/// 图片服务的 Axum 处理器。 /// 图片服务的 Axum 处理器。
pub mod image; pub mod image;
/// KaTeX 服务端数学公式渲染server-only
#[cfg(feature = "server")]
pub mod katex;
/// Markdown 渲染与 HTML 清理。 /// Markdown 渲染与 HTML 清理。
pub mod markdown; pub mod markdown;
/// 文章 CRUD 相关接口。 /// 文章 CRUD 相关接口。

View File

@ -138,7 +138,8 @@ pub async fn create_post(
// 失效该文章涉及的所有标签下文章列表缓存。 // 失效该文章涉及的所有标签下文章列表缓存。
crate::cache::invalidate_tag_posts_for(&tags_cleaned).await; crate::cache::invalidate_tag_posts_for(&tags_cleaned).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // SSR新文章会出现在首页/分页/归档/标签等所有列表页,全量失效。
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok( Ok(CreatePostResponse::ok(

View File

@ -71,7 +71,9 @@ pub async fn delete_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErr
crate::cache::invalidate_post_by_slug(&slug).await; crate::cache::invalidate_post_by_slug(&slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await; crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // SSR删除影响详情页变 404与所有列表页。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok( Ok(CreatePostResponse::ok(

View File

@ -130,7 +130,8 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
if rebuilt > 0 { if rebuilt > 0 {
crate::cache::invalidate_all_post_caches(); crate::cache::invalidate_all_post_caches();
crate::cache::invalidate_search_results(); crate::cache::invalidate_search_results();
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // 批量重建影响所有公开页(列表/标签/归档/各文章),全量失效 SSR 磁盘缓存。
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
} }
@ -219,7 +220,9 @@ pub async fn rebuild_post_content_html(post_id: i32) -> Result<CreatePostRespons
crate::cache::invalidate_post_lists(); crate::cache::invalidate_post_lists();
crate::cache::invalidate_search_results(); crate::cache::invalidate_search_results();
crate::cache::invalidate_post_by_slug(&slug).await; crate::cache::invalidate_post_by_slug(&slug).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // SSR单篇详情页 + 列表页(首页/分页/归档/标签都含列表项摘要)。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok( Ok(CreatePostResponse::ok(

View File

@ -82,7 +82,10 @@ pub async fn restore_post(post_id: i32) -> Result<CreatePostResponse, ServerFnEr
crate::cache::invalidate_post_by_slug(&new_slug).await; crate::cache::invalidate_post_by_slug(&new_slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await; crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // SSR回收站操作影响详情页与所有列表页含归档
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{current_slug}"));
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{new_slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok( Ok(CreatePostResponse::ok(
@ -153,7 +156,9 @@ pub async fn purge_post(post_id: i32) -> Result<CreatePostResponse, ServerFnErro
crate::cache::invalidate_post_by_slug(&slug).await; crate::cache::invalidate_post_by_slug(&slug).await;
crate::cache::invalidate_tag_posts_for(&tags).await; crate::cache::invalidate_tag_posts_for(&tags).await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // SSR彻底删除影响详情页与所有列表页。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok( Ok(CreatePostResponse::ok(
@ -245,13 +250,14 @@ pub async fn batch_restore_posts(post_ids: Vec<i32>) -> Result<CreatePostRespons
crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::<Vec<_>>()) crate::cache::invalidate_tag_posts_for(&affected_tags.into_iter().collect::<Vec<_>>())
.await; .await;
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // SSR批量恢复影响多篇文章的列表项全量失效。
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
} else { } else {
// 影响集过大时回退到全量失效,避免大量串行缓存操作。 // 影响集过大时回退到全量失效,避免大量串行缓存操作。
crate::cache::invalidate_all_post_caches(); crate::cache::invalidate_all_post_caches();
crate::cache::invalidate_search_results(); crate::cache::invalidate_search_results();
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
} }

View File

@ -202,10 +202,13 @@ pub async fn update_post(
if let Some(ref old) = old_slug { if let Some(ref old) = old_slug {
if old != &final_slug { if old != &final_slug {
crate::cache::invalidate_post_by_slug(old).await; crate::cache::invalidate_post_by_slug(old).await;
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{old}"));
} }
} }
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。 // SSR内容/标签/摘要变化影响详情页与所有列表页。
crate::ssr_cache::invalidate_ssr_route(&format!("/post/{final_slug}"));
crate::ssr_cache::invalidate_ssr_all_public();
crate::ssr_cache::bump_global_generation(); crate::ssr_cache::bump_global_generation();
Ok(CreatePostResponse::ok( Ok(CreatePostResponse::ok(

View File

@ -410,7 +410,9 @@ pub fn clean_comment_html(input: &str) -> String {
], ],
extra_tag_attrs: vec![ extra_tag_attrs: vec![
("a", vec!["class", "aria-hidden", "aria-label"]), ("a", vec!["class", "aria-hidden", "aria-label"]),
("span", vec!["class"]), // span 的 styleKaTeX 服务端渲染产出的内联 style元素垂直对齐/定位)
// 需保留否则公式排版错位。与文章正文路径sanitizer.rs:382对齐。
("span", vec!["class", "style"]),
], ],
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES, allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
allow_data_uri: false, allow_data_uri: false,

View File

@ -136,6 +136,13 @@ pub fn PostContent(content_html: String) -> Element {
// 每次渲染重新解析开销可控。 // 每次渲染重新解析开销可控。
let fragments = split_content_fragments(&content_html); let fragments = split_content_fragments(&content_html);
// mermaid 流程图主题需随当前生效主题light/dark切换。读 use_resolved_theme()
// 建立订阅:主题变化时下方 use_effect 重跑,重新调用 __initMermaid幂等
// 已渲染的块由 dataset.mermaidRendered 守卫跳过;主题切换暂不强制重渲染图,
// 后续如需可在 mermaid.ts 监听 THEME_CHANGE_EVENT
#[cfg(target_arch = "wasm32")]
let resolved_theme = crate::theme::use_resolved_theme();
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use_effect(move || { use_effect(move || {
let window = web_sys::window().unwrap(); let window = web_sys::window().unwrap();
@ -144,6 +151,16 @@ pub fn PostContent(content_html: String) -> Element {
// (与旧 eval 中的 if 守卫语义一致)。 // (与旧 eval 中的 if 守卫语义一致)。
invoke_optional_global(&window, "__initPostContent", &[".post-content".into()]); invoke_optional_global(&window, "__initPostContent", &[".post-content".into()]);
// mermaid 流程图懒加载渲染:扫描 .post-content 下的 language-mermaid 代码块,
// IntersectionObserver 视口可见时动态 import /mermaid/mermaid.js 渲染成 SVG。
// 读取 resolved_theme() 既是为了传主题,也建立订阅让主题切换重跑此 effect。
let theme_str: String = if resolved_theme() == crate::theme::ResolvedTheme::Dark {
"dark".into()
} else {
"light".into()
};
invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]);
// lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。 // lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。
// 双保险契约:先设配置,若 lightbox.js 已加载则立即调用; // 双保险契约:先设配置,若 lightbox.js 已加载则立即调用;
// 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。 // 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。

View File

@ -295,9 +295,10 @@ fn RebuildCacheBar() -> Element {
}; };
rsx! { rsx! {
// 竖向布局:按钮行 + 结果消息行(右对齐,消息行不撑高按钮行)。 // 消息绝对定位到按钮行下方,脱离文档流:出现/消失都不撑高祖先容器,
// 避免 header 的 md:items-end 把固定底边转化为按钮上移("按钮被顶上去" bug
// 自持 rebuilding / rebuild_result state与父组件零耦合。 // 自持 rebuilding / rebuild_result state与父组件零耦合。
div { class: "flex flex-col items-end gap-1", div { class: "relative flex items-center gap-3",
div { class: "flex items-center gap-3", div { class: "flex items-center gap-3",
Tooltip { Tooltip {
tip: "重建 content_html 为空的文章渲染缓存".to_string(), tip: "重建 content_html 为空的文章渲染缓存".to_string(),
@ -332,9 +333,9 @@ fn RebuildCacheBar() -> Element {
} }
} }
} }
// 重建结果消息:独立成行,右对齐,与按钮行同属本组件 // 重建结果消息:绝对定位到按钮行正下方,脱离文档流,不影响布局高度
if let Some(msg) = rebuild_result() { if let Some(msg) = rebuild_result() {
div { class: "text-xs text-paper-secondary whitespace-pre-line", "{msg}" } div { class: "absolute top-full right-0 mt-1 text-xs text-paper-secondary whitespace-pre-line", "{msg}" }
} }
} }
} }

View File

@ -1,29 +1,19 @@
//! SSR 增量渲染缓存失效的未来就绪基础设施 //! SSR 增量渲染缓存失效
//! //!
//! 本模块维护一个全局单调递增的世代号generation。文章写入成功后调用方会 //! Dioxus 0.7 增量渲染器把每个路由的 SSR 结果落盘到 `static/<route>/index/<hash>.html`
//! 使其递增,从而**标记** SSR 渲染结果已过期。然而: //! 以请求 `path_and_query()` 作 key。它只暴露 `invalidate_after(ttl)` 一个失效手段,
//! **没有按路由失效的公开 API**。本模块通过**物理删除缓存目录**绕过这个限制:
//! 写路径create/update/rebuild/delete调用 [`invalidate_ssr_route`] 删掉对应路由的
//! 缓存目录,下次请求触发重渲染。
//! //!
//! **Dioxus 0.7 的增量渲染器使用请求 URI 的 `path_and_query()` 作为内部缓存键, //! 全局世代号(`bump_global_generation`)保留作可观测性 + 未来就绪钩子,但不依赖它
//! 且没有暴露公开 API 供外部代码自定义缓存键或按路由失效已渲染页面。** //! 实际失效缓存。
//! 因此,当前世代号并**不会**实际使 Dioxus 的 SSR 缓存失效;它只是为未来 API
//! 准备好状态,并在请求/响应中提供可观测性。
//!
//! 在 Dioxus 提供以下任一能力之前,有效的 SSR 缓存失效手段仍是调低
//! `SSR_CACHE_SECS` 这一兜底 TTL
//! - 自定义增量渲染缓存键的回调;或
//! - 从 server function 内部按路由失效缓存的公开 API。
//!
//! 当前实现:
//! - `bump_global_generation()` / `current_global_generation()`:全局世代号。
//! - `SsrGeneration`:注入到请求扩展中的类型;未来 Dioxus 支持读取扩展生成
//! 缓存键时可直接使用。
//! - `src/main.rs` 的中间件把当前世代号附加到 `X-SSR-Generation` 响应头
//! (仅 GET 请求),便于调试与监控。
//! //!
//! 仅在启用 `server` feature 时编译。 //! 仅在启用 `server` feature 时编译。
#![cfg(feature = "server")] #![cfg(feature = "server")]
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::LazyLock; use std::sync::LazyLock;
@ -40,21 +30,94 @@ static GLOBAL_GENERATION: LazyLock<AtomicU64> = LazyLock::new(AtomicU64::default
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SsrGeneration(pub u64); pub struct SsrGeneration(pub u64);
/// Dioxus 增量渲染器缓存落盘根目录(相对 CWD
///
/// 路由 `/post/foo` 的缓存为 `static/post/foo/index/<hash>.html`。
/// 由 `IncrementalRendererConfig::default()` 决定,无公开 API 可读,故硬编码。
const SSR_CACHE_ROOT: &str = "static";
/// 计算某路由的 SSR 缓存目录路径。
///
/// route 形如 `/post/markdown-syntax-test`(前导 `/` 可有可无)。返回
/// `static/post/markdown-syntax-test`(不含 `index/`,删整目录更彻底)。
/// 对路径段做安全清洗:禁止 `..` / 空段,避免越出 `static/` 根。
fn route_cache_dir(route: &str) -> Option<PathBuf> {
let mut path = PathBuf::from(SSR_CACHE_ROOT);
for seg in route.trim_start_matches('/').split('/') {
if seg.is_empty() || seg == ".." || seg == "." {
continue;
}
path.push(seg);
}
if path.as_path() == std::path::Path::new(SSR_CACHE_ROOT) {
None // 根路由("/")单独由 invalidate_ssr_home 处理
} else {
Some(path)
}
}
/// 失效单一路由的 SSR 磁盘缓存。
///
/// 删除 `static/<route>/` 目录(含 `index/<hash>.html`)。文件不存在时静默。
/// **IO 在当前线程同步执行**——删除一个空目录是纳秒级操作,不值得 spawn_blocking
/// 调用方已在事务提交后调用,无阻塞风险。
pub fn invalidate_ssr_route(route: &str) {
let Some(dir) = route_cache_dir(route) else {
return;
};
match std::fs::remove_dir_all(&dir) {
Ok(()) => tracing::debug!(route = route, dir = %dir.display(), "SSR 路由缓存已删除"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(route = route, error = %e, "删除 SSR 路由缓存失败"),
}
}
/// 失效首页 SSR 缓存(路由 `/`,落盘在 `static/index/`)。
///
/// 通常 [`invalidate_ssr_all_public`] 已覆盖首页;本函数留作只需刷新首页的细粒度场景。
#[allow(dead_code)]
pub fn invalidate_ssr_home() {
let dir = PathBuf::from(SSR_CACHE_ROOT).join("index");
match std::fs::remove_dir_all(&dir) {
Ok(()) => tracing::debug!("SSR 首页缓存已删除"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(error = %e, "删除 SSR 首页缓存失败"),
}
}
/// 失效所有公开页 SSR 缓存(删除 `static/` 下除 `.well-known`、`admin` 外的全部)。
///
/// 用于批量重建等影响面广的写入。保留 `.well-known`(浏览器/PWA 元数据,
/// 与内容无关)和 `admin`(管理后台,写入者自己的视角无需刷新)。
pub fn invalidate_ssr_all_public() {
let root = PathBuf::from(SSR_CACHE_ROOT);
let Ok(entries) = std::fs::read_dir(&root) else {
return; // 目录不存在(首次启动或已被清)
};
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name == ".well-known" || name == "admin" {
continue;
}
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(entry = %name, error = %e, "删除 SSR 缓存条目失败");
}
}
}
tracing::debug!("已失效全部公开页 SSR 缓存");
}
/// 原子递增并返回新的全局世代号。 /// 原子递增并返回新的全局世代号。
/// ///
/// 任何文章写入操作都会调用本函数,标记 SSR 渲染结果已过期。当前 Dioxus 0.7 /// 仅作可观测性用途(注入 `X-SSR-Generation` 响应头)。实际 SSR 缓存失效由
/// 不读取此世代号(详见模块文档),实际失效仍依赖 `SSR_CACHE_SECS` 兜底 TTL。 /// [`invalidate_ssr_route`] / [`invalidate_ssr_home`] 物理删文件完成。
/// 此处打一条 info 日志,既证明写入路径已执行,也提醒部署者此处存在滞后——
/// 避免"我改了文章怎么没变"被误判成 DB/发布链路问题。
pub fn bump_global_generation() -> u64 { pub fn bump_global_generation() -> u64 {
let new = GLOBAL_GENERATION let new = GLOBAL_GENERATION
.fetch_add(1, Ordering::SeqCst) .fetch_add(1, Ordering::SeqCst)
.wrapping_add(1); .wrapping_add(1);
tracing::info!( tracing::debug!(new_generation = new, "SSR 全局世代号已递增");
new_generation = new,
"SSR 世代号已递增写入路径触发。注意Dioxus 0.7 不读取此值,\
SSR SSR_CACHE_SECS "
);
new new
} }
@ -80,4 +143,58 @@ mod tests {
assert!(g2 > g1); assert!(g2 > g1);
assert_eq!(current, g2); assert_eq!(current, g2);
} }
#[test]
fn route_cache_dir_rejects_traversal() {
// 路径穿越尝试不应越出 static/ 根。
let p = route_cache_dir("/post/../../../etc/passwd").unwrap();
let segs: Vec<_> = p.components().collect();
// .. 被过滤,只剩 static/post/etc/passwd
assert!(p.starts_with("static"));
assert!(!segs.iter().any(|c| c.as_os_str() == ".."));
}
#[test]
fn route_cache_dir_normalizes_leading_slash() {
let a = route_cache_dir("/post/foo").unwrap();
let b = route_cache_dir("post/foo").unwrap();
assert_eq!(a, b);
assert!(a.ends_with("post/foo"));
}
#[test]
fn route_cache_dir_none_for_root() {
// 根路由 "/" 无目录段,由 invalidate_ssr_home 单独处理。
assert!(route_cache_dir("/").is_none());
assert!(route_cache_dir("").is_none());
}
#[test]
fn invalidate_ssr_route_deletes_dir() {
// 造一个假的缓存目录static/__test_route/index/fake.html
let dir = route_cache_dir("/__test_route_xyz").unwrap();
std::fs::create_dir_all(dir.join("index")).unwrap();
std::fs::write(dir.join("index").join("fake.html"), "stale").unwrap();
assert!(dir.exists());
invalidate_ssr_route("/__test_route_xyz");
assert!(!dir.exists(), "删除后目录不应存在");
}
#[test]
fn invalidate_ssr_route_missing_is_noop() {
// 不存在的路由删除应静默成功NotFound 不报错)。
invalidate_ssr_route("/__never_exists_xyz_123");
}
#[test]
fn invalidate_ssr_all_public_is_safe_on_missing_root() {
// static/ 根不存在时不应 panic首次启动或已清空场景
// 不创建真实 static/ 目录,直接调用验证 NotFound 静默。
// (若真实环境已有 static/本测试不会误删——read_dir 对每个条目单独删,
// 这里仅验证根缺失分支。)
invalidate_ssr_all_public();
invalidate_ssr_home();
}
} }