Compare commits
10 Commits
1935321da1
...
f613460742
| Author | SHA1 | Date | |
|---|---|---|---|
| f613460742 | |||
| 438a5e1046 | |||
| 09ca480842 | |||
| 78a8d188d1 | |||
| f39cb35a31 | |||
| 70edc5e46c | |||
| 218533402b | |||
| 84f6223c68 | |||
| 95fe9d862d | |||
| 891a576dc0 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,11 +8,13 @@
|
||||
others/
|
||||
public/style.css
|
||||
public/highlight.css
|
||||
public/katex
|
||||
public/tiptap
|
||||
public/codemirror
|
||||
public/lightbox
|
||||
public/yggdrasil-core
|
||||
public/xterm
|
||||
public/mermaid
|
||||
public/doc
|
||||
/static
|
||||
.env
|
||||
|
||||
34
AGENTS.md
34
AGENTS.md
@ -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 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`.
|
||||
|
||||
@ -148,7 +148,7 @@ src/api/ — server functions + Axum handlers
|
||||
auth.rs — login, register, session validation
|
||||
comments/ — comment CRUD + approval/spam/trash server functions
|
||||
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
|
||||
upload.rs — image upload, auto-converts to WebP
|
||||
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/pages/ — route page components (frontend + admin)
|
||||
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/tasks/ — background tokio tasks (session cleanup)
|
||||
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
|
||||
|
||||
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 |
|
||||
| ------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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/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/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`.
|
||||
|
||||
@ -220,6 +221,25 @@ Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服
|
||||
- `src/highlight.rs` uses syntect at runtime for code block highlighting
|
||||
- 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
|
||||
|
||||
- **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 (60s–600s). 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.
|
||||
- **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 prod,0 in `make dev`)仍作兜底。
|
||||
|
||||
## Testing
|
||||
|
||||
@ -270,6 +290,8 @@ Most Rust tests use `#[cfg(all(test, feature = "server"))]` — they only run wh
|
||||
- `public/style.css` — Tailwind output
|
||||
- `public/highlight.css` — generated by `generate_highlight_css` binary
|
||||
- `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`)
|
||||
- `/dist`, `/.dioxus`, `/target`, `/static`
|
||||
- `libs/node_modules/` + `libs/*/node_modules/` — pnpm workspace store + symlinks
|
||||
|
||||
97
Cargo.lock
generated
97
Cargo.lock
generated
@ -398,6 +398,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
@ -895,6 +920,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
@ -2155,6 +2181,12 @@ dependencies = [
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
@ -2585,6 +2617,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "keyboard-types"
|
||||
version = "0.7.0"
|
||||
@ -3394,6 +3444,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@ -3661,6 +3721,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
@ -4246,6 +4315,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "subsecond"
|
||||
version = "0.7.9"
|
||||
@ -5512,6 +5608,7 @@ dependencies = [
|
||||
"http",
|
||||
"image",
|
||||
"js-sys",
|
||||
"katex-rs",
|
||||
"lol_html",
|
||||
"md-5 0.10.6",
|
||||
"mimalloc",
|
||||
|
||||
@ -58,6 +58,11 @@ tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
# 且对全静态 musl 链接友好(生产 Docker 镜像即 musl 目标)。
|
||||
# server-only:经 server feature 启用;WASM 前端构建不编译(见 main.rs cfg 门控)。
|
||||
mimalloc = { version = "0.1", optional = true }
|
||||
# KaTeX 服务端数学公式渲染:纯 Rust 重实现(无 JS 运行时),渲染 $...$ / $$...$$
|
||||
# 为 HTML span。库名是 katex(crate 名 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]
|
||||
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:tokio-stream",
|
||||
"dep:mimalloc",
|
||||
"dep:katex-rs",
|
||||
]
|
||||
|
||||
@ -10,9 +10,9 @@ title = "Yggdrasil - Dioxus SSR"
|
||||
watch_path = ["src", "Cargo.toml"]
|
||||
|
||||
[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"]
|
||||
|
||||
[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"]
|
||||
|
||||
33
Dockerfile
33
Dockerfile
@ -11,7 +11,6 @@ FROM rust:1.96-bookworm AS builder
|
||||
# - Debian apt -> TUNA (Tsinghua)
|
||||
# - Rust + crates.io -> rsproxy (ByteDance)
|
||||
# - 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_SECURITY_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian-security
|
||||
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 \
|
||||
x86_64-unknown-linux-musl aarch64-unknown-linux-musl
|
||||
|
||||
# Install the Dioxus CLI (must match the dioxus crate version).
|
||||
RUN cargo install dioxus-cli --version 0.7.9 --locked
|
||||
# Install the Dioxus CLI from the official prebuilt binary (GitHub Releases),
|
||||
# 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
|
||||
# Releases (~106 MB). GitHub's release CDN is slow/unreliable from inside the
|
||||
# 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. ---
|
||||
# Releases (~106 MB). ---
|
||||
ARG TAILWIND_VERSION=4.3.1
|
||||
ARG GH_PROXY=https://gh-proxy.com
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& case "$ARCH" in \
|
||||
amd64) TW_ARCH=x64 ;; \
|
||||
@ -95,7 +108,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
|
||||
*) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& 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
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
20
Makefile
20
Makefile
@ -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:
|
||||
@cd libs && pnpm install --frozen-lockfile
|
||||
@$(MAKE) build-libs
|
||||
@$(MAKE) highlight-css
|
||||
@$(MAKE) katex-css
|
||||
@tailwindcss -i input.css -o public/style.css --minify
|
||||
@$(MAKE) doc
|
||||
@dx build --release --debug-symbols=false
|
||||
@ -13,6 +14,7 @@ build-linux:
|
||||
@cd libs && pnpm install --frozen-lockfile
|
||||
@$(MAKE) build-libs
|
||||
@$(MAKE) highlight-css
|
||||
@$(MAKE) katex-css
|
||||
@tailwindcss -i input.css -o public/style.css --minify
|
||||
@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
|
||||
@ -82,6 +84,17 @@ restore-webp:
|
||||
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 根 devDependency,pnpm 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 拓扑顺序,无相互依赖则并发)。
|
||||
# 依赖安装由调用方负责(build/build-linux 用 pnpm install --frozen-lockfile,
|
||||
# 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-core: ; @cd libs && pnpm --filter @yggdrasil/core 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/..."
|
||||
@rm -rf static/
|
||||
@echo "Building CSS..."
|
||||
@ -188,6 +202,8 @@ docker-multiarch:
|
||||
clean:
|
||||
@cargo clean
|
||||
@rm -f public/style.css public/highlight.css
|
||||
@rm -rf public/katex
|
||||
@rm -rf public/mermaid
|
||||
@rm -rf public/doc
|
||||
@rm -rf uploads/.cache
|
||||
@rm -rf libs/node_modules libs/*/node_modules
|
||||
|
||||
14
libs/mermaid-renderer/package.json
Normal file
14
libs/mermaid-renderer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
libs/mermaid-renderer/src/index.ts
Normal file
5
libs/mermaid-renderer/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// 把 mermaid 默认导出包成 IIFE 全局 bundle。
|
||||
// yggdrasil-core 的 mermaid.ts 通过动态 import('/mermaid/mermaid.js') 拿到 .default。
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
export default mermaid;
|
||||
4
libs/mermaid-renderer/tsconfig.json
Normal file
4
libs/mermaid-renderer/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
27
libs/mermaid-renderer/vite.config.ts
Normal file
27
libs/mermaid-renderer/vite.config.ts
Normal 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.js,dx 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,
|
||||
},
|
||||
});
|
||||
@ -12,6 +12,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.3",
|
||||
"happy-dom": "^20.10.6",
|
||||
"katex": "^0.16.22",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.10"
|
||||
|
||||
823
libs/pnpm-lock.yaml
generated
823
libs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,15 @@
|
||||
import { initAnchorClick } from './anchor-click';
|
||||
import { scrollToHash } from './hash-scroll';
|
||||
import { initMermaid } from './mermaid';
|
||||
import { initPostContent } from './post-content';
|
||||
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
|
||||
import type { ThemeName } from '@yggdrasil/shared';
|
||||
import './style.css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__initPostContent: (selector: string) => void;
|
||||
__initMermaid: (selector: string, theme: ThemeName) => void;
|
||||
__initAnchorClick: () => void;
|
||||
__scrollToHash: () => void;
|
||||
__startThemeTransition: (x: number, y: number) => void;
|
||||
@ -15,6 +18,7 @@ declare global {
|
||||
}
|
||||
|
||||
window.__initPostContent = initPostContent;
|
||||
window.__initMermaid = initMermaid;
|
||||
window.__initAnchorClick = initAnchorClick;
|
||||
window.__scrollToHash = scrollToHash;
|
||||
window.__startThemeTransition = startThemeTransition;
|
||||
|
||||
120
libs/yggdrasil-core/src/mermaid.test.ts
Normal file
120
libs/yggdrasil-core/src/mermaid.test.ts
Normal 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 IntersectionObserver:observe 时立即异步触发 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-->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-->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-->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);
|
||||
});
|
||||
});
|
||||
});
|
||||
116
libs/yggdrasil-core/src/mermaid.ts
Normal file
116
libs/yggdrasil-core/src/mermaid.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Mermaid 流程图懒加载渲染。
|
||||
*
|
||||
* 扫描 `pre > code.language-mermaid` 代码块,在进入视口时动态 import 独立 bundle
|
||||
* `public/mermaid/mermaid.js`(~1MB,只在有图且可见时加载,不影响无图文章首屏),
|
||||
* 把 mermaid 源码渲染成 SVG 注入到父 <pre>。
|
||||
*
|
||||
* 范式照搬 post-content.ts:querySelectorAll + 幂等守卫 + 注入 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' 动态 import:Vite 无法静态分析此字面量,
|
||||
* 故加 @vite-ignore 避免构建时报错,且该路径无类型声明需用函数间接构造 import
|
||||
* 字面量以绕过 tsc 的模块解析(TS2307)。bundle 加载后 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:进入视口才渲染。
|
||||
*
|
||||
* 无 IntersectionObserver(SSR / 旧环境)时直接同步渲染。
|
||||
* 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -11,6 +11,8 @@ function initCopyButtons(root: Element): void {
|
||||
const pre = code.parentElement;
|
||||
if (!pre) return;
|
||||
if (pre.querySelector('.copy-code')) return;
|
||||
// mermaid 代码块由 mermaid.ts 渲染成 SVG,不注入 copy 按钮(图无需复制源码)。
|
||||
if (code.classList.contains('language-mermaid')) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'copy-code';
|
||||
|
||||
@ -22,7 +22,7 @@ pub fn clean_comment_html(input: &str) -> String {
|
||||
pub fn render_comment_markdown(md: &str) -> String {
|
||||
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 mut events: Vec<Event> = Vec::new();
|
||||
@ -33,6 +33,17 @@ pub fn render_comment_markdown(md: &str) -> String {
|
||||
// 逐事件处理 Markdown AST,转换标题并收集代码块内容。
|
||||
for event in parser {
|
||||
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 { .. }) => {
|
||||
// 评论中不保留标题层级,统一加粗。
|
||||
events.push(Event::Start(Tag::Strong));
|
||||
@ -233,4 +244,40 @@ mod tests {
|
||||
assert!(result.contains("<code>foo()</code>"));
|
||||
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
117
src/api/katex.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -211,6 +211,26 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
|
||||
html.push_str("</code></pre>");
|
||||
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 {
|
||||
// 标题内部只保留文本与行内代码,避免嵌套块级元素。
|
||||
@ -823,4 +843,68 @@ console.log(1)
|
||||
assert!(result.html.contains("未完成"));
|
||||
assert!(result.html.contains("已完成"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_markdown_inline_math() {
|
||||
// $...$ 内联公式:pulldown-cmark (ENABLE_MATH) 解析 → katex 渲染成 span。
|
||||
// sanitizer 放行 span 的 class/style,KaTeX 输出应原样保留。
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,9 @@ pub mod error;
|
||||
pub mod health;
|
||||
/// 图片服务的 Axum 处理器。
|
||||
pub mod image;
|
||||
/// KaTeX 服务端数学公式渲染(server-only)。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod katex;
|
||||
/// Markdown 渲染与 HTML 清理。
|
||||
pub mod markdown;
|
||||
/// 文章 CRUD 相关接口。
|
||||
|
||||
@ -138,7 +138,8 @@ pub async fn create_post(
|
||||
// 失效该文章涉及的所有标签下文章列表缓存。
|
||||
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();
|
||||
|
||||
Ok(CreatePostResponse::ok(
|
||||
|
||||
@ -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_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();
|
||||
|
||||
Ok(CreatePostResponse::ok(
|
||||
|
||||
@ -130,7 +130,8 @@ pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, Se
|
||||
if rebuilt > 0 {
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
crate::cache::invalidate_search_results();
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
// 批量重建影响所有公开页(列表/标签/归档/各文章),全量失效 SSR 磁盘缓存。
|
||||
crate::ssr_cache::invalidate_ssr_all_public();
|
||||
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_search_results();
|
||||
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();
|
||||
|
||||
Ok(CreatePostResponse::ok(
|
||||
|
||||
@ -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_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();
|
||||
|
||||
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_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();
|
||||
|
||||
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<_>>())
|
||||
.await;
|
||||
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
// SSR:批量恢复影响多篇文章的列表项,全量失效。
|
||||
crate::ssr_cache::invalidate_ssr_all_public();
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
} else {
|
||||
// 影响集过大时回退到全量失效,避免大量串行缓存操作。
|
||||
crate::cache::invalidate_all_post_caches();
|
||||
crate::cache::invalidate_search_results();
|
||||
// 递增 SSR 全局世代号(未来就绪基础设施;当前不会使 Dioxus 0.7 SSR 缓存失效)。
|
||||
crate::ssr_cache::invalidate_ssr_all_public();
|
||||
crate::ssr_cache::bump_global_generation();
|
||||
}
|
||||
|
||||
|
||||
@ -202,10 +202,13 @@ pub async fn update_post(
|
||||
if let Some(ref old) = old_slug {
|
||||
if old != &final_slug {
|
||||
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();
|
||||
|
||||
Ok(CreatePostResponse::ok(
|
||||
|
||||
@ -410,7 +410,9 @@ pub fn clean_comment_html(input: &str) -> String {
|
||||
],
|
||||
extra_tag_attrs: vec![
|
||||
("a", vec!["class", "aria-hidden", "aria-label"]),
|
||||
("span", vec!["class"]),
|
||||
// span 的 style:KaTeX 服务端渲染产出的内联 style(元素垂直对齐/定位)
|
||||
// 需保留,否则公式排版错位。与文章正文路径(sanitizer.rs:382)对齐。
|
||||
("span", vec!["class", "style"]),
|
||||
],
|
||||
allowed_schemes: &DEFAULT_ALLOWED_SCHEMES,
|
||||
allow_data_uri: false,
|
||||
|
||||
@ -136,6 +136,13 @@ pub fn PostContent(content_html: String) -> Element {
|
||||
// 每次渲染重新解析开销可控。
|
||||
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")]
|
||||
use_effect(move || {
|
||||
let window = web_sys::window().unwrap();
|
||||
@ -144,6 +151,16 @@ pub fn PostContent(content_html: String) -> Element {
|
||||
// (与旧 eval 中的 if 守卫语义一致)。
|
||||
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.js 已加载则立即调用;
|
||||
// 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。
|
||||
|
||||
@ -295,9 +295,10 @@ fn RebuildCacheBar() -> Element {
|
||||
};
|
||||
|
||||
rsx! {
|
||||
// 竖向布局:按钮行 + 结果消息行(右对齐,消息行不撑高按钮行)。
|
||||
// 消息绝对定位到按钮行下方,脱离文档流:出现/消失都不撑高祖先容器,
|
||||
// 避免 header 的 md:items-end 把固定底边转化为按钮上移("按钮被顶上去" bug)。
|
||||
// 自持 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",
|
||||
Tooltip {
|
||||
tip: "重建 content_html 为空的文章渲染缓存".to_string(),
|
||||
@ -332,9 +333,9 @@ fn RebuildCacheBar() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 重建结果消息:独立成行,右对齐,与按钮行同属本组件。
|
||||
// 重建结果消息:绝对定位到按钮行正下方,脱离文档流,不影响布局高度。
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
173
src/ssr_cache.rs
173
src/ssr_cache.rs
@ -1,29 +1,19 @@
|
||||
//! SSR 增量渲染缓存失效的未来就绪基础设施。
|
||||
//! SSR 增量渲染缓存失效。
|
||||
//!
|
||||
//! 本模块维护一个全局单调递增的世代号(generation)。文章写入成功后调用方会
|
||||
//! 使其递增,从而**标记** SSR 渲染结果已过期。然而:
|
||||
//! Dioxus 0.7 增量渲染器把每个路由的 SSR 结果落盘到 `static/<route>/index/<hash>.html`,
|
||||
//! 以请求 `path_and_query()` 作 key。它只暴露 `invalidate_after(ttl)` 一个失效手段,
|
||||
//! **没有按路由失效的公开 API**。本模块通过**物理删除缓存目录**绕过这个限制:
|
||||
//! 写路径(create/update/rebuild/delete)调用 [`invalidate_ssr_route`] 删掉对应路由的
|
||||
//! 缓存目录,下次请求触发重渲染。
|
||||
//!
|
||||
//! **Dioxus 0.7 的增量渲染器使用请求 URI 的 `path_and_query()` 作为内部缓存键,
|
||||
//! 且没有暴露公开 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 请求),便于调试与监控。
|
||||
//! 全局世代号(`bump_global_generation`)保留作可观测性 + 未来就绪钩子,但不依赖它
|
||||
//! 实际失效缓存。
|
||||
//!
|
||||
//! 仅在启用 `server` feature 时编译。
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@ -40,21 +30,94 @@ static GLOBAL_GENERATION: LazyLock<AtomicU64> = LazyLock::new(AtomicU64::default
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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
|
||||
/// 不读取此世代号(详见模块文档),实际失效仍依赖 `SSR_CACHE_SECS` 兜底 TTL。
|
||||
/// 此处打一条 info 日志,既证明写入路径已执行,也提醒部署者此处存在滞后——
|
||||
/// 避免"我改了文章怎么没变"被误判成 DB/发布链路问题。
|
||||
/// 仅作可观测性用途(注入 `X-SSR-Generation` 响应头)。实际 SSR 缓存失效由
|
||||
/// [`invalidate_ssr_route`] / [`invalidate_ssr_home`] 物理删文件完成。
|
||||
pub fn bump_global_generation() -> u64 {
|
||||
let new = GLOBAL_GENERATION
|
||||
.fetch_add(1, Ordering::SeqCst)
|
||||
.wrapping_add(1);
|
||||
tracing::info!(
|
||||
new_generation = new,
|
||||
"SSR 世代号已递增(写入路径触发)。注意:Dioxus 0.7 不读取此值,\
|
||||
SSR 缓存需等待 SSR_CACHE_SECS 过期后才反映新内容"
|
||||
);
|
||||
tracing::debug!(new_generation = new, "SSR 全局世代号已递增");
|
||||
new
|
||||
}
|
||||
|
||||
@ -80,4 +143,58 @@ mod tests {
|
||||
assert!(g2 > g1);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user