yggdrasil/AGENTS.md
xfy eaabb87108
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
docs(code-runner): AGENTS.md 补充别名归一化与 bun 镜像说明
Code Runner API 层描述补充 normalize_lang 别名归一化机制:
- parse_fence_info 在 markdown 渲染期把别名(js→node, ts→bun 等)换成 canonical
- start_exec/start_exec_stream 在 LANGUAGES.get 前显式 normalize_lang

Runner images 段落补 yggdrasil-runner-bun,记录三个踩坑点:
- bun 不在 Alpine 3.18 仓库,用 bun.sh 脚本(自动选 musl 变体)
- bun 的 musl 二进制是 C++,需 libstdc++ + libgcc 运行时
- BUN_INSTALL 必须 export,否则 curl|bash 管道右侧读不到
2026-07-21 15:47:39 +08:00

41 KiB
Raw Permalink Blame History

AGENTS.md

Workflow

  • 每完成一个功能点立即提交。Agent 自主判断提交时机——当一个逻辑完整的改动通过验证(编译通过 / 测试通过)后,无需等待用户指令,直接 git add + git commit
  • 提交粒度按"功能点"而非"文件":相关联的多文件改动合并为一个提交,不相关的改动拆成多个提交。
  • 提交信息遵循现有风格:type(scope): 简述,正文(可选)说明动机与关键改动。常见 type:feat / fix / docs / refactor / chore / perf
  • 只在用户明确要求时才 git push。提交到本地即可,不主动推送。

JavaScript 库

在 libs 目录下都是 JavaScript 库,他们的包管理器都是 pnpm。

Development Commands

make dev           # 增量构建全部 libs (pnpm -r run build) + tailwindcss watch + dx serve (needs PostgreSQL, SSR_CACHE_SECS=0)
make build         # pnpm install → build-libs → highlight-css → tailwindcss → doc → dx build --release → restore-webp
make build-linux   # 客户端 + 服务端分离构建,target x86_64-unknown-linux-musl
make build-freebsd # cross-compile FreeBSD x86_64 server binary (clang + lld + sysroot, via cargo, not dx)
make freebsd-sysroot # download/extract FreeBSD base.txz → .freebsd-sysroot/ (idempotent)
make css           # one-shot Tailwind build
make css-watch     # Tailwind watch mode
make test          # cargo test + pnpm -r run test (全部 libs: tiptap-editor / lightbox / yggdrasil-core / codemirror-editor / xterm-terminal)
make doc           # cargo doc (ayu 主题) → 拷贝到 public/doc/,随 build 发布
make doc-open      # 同 doc生成后自动用浏览器打开本地预览不拷贝
make lint          # Biome check (libs) + cargo clippy (严格模式,warning 即失败)
make fix           # biome format --write (libs) + cargo fix --allow-dirty
make clean         # cargo clean + rm public/{style.css,highlight.css,doc} + rm -rf uploads/.cache + rm -rf libs/node_modules libs/*/node_modules

Build order matters: make build runs pnpm install --frozen-lockfile (in libs/) → build-libs (pnpm -r run build, all libs in parallel) → highlight-css (cargo run --bin generate_highlight_css) → tailwindcss --minifydocdx build --releaserestore-webp. Do not run dx build --release alone.

restore-webp workaround: dx build 0.7.9 re-encodes public/*.webp into VP8L lossless stills (drops animation frames, 7-8× larger), contradicting the "verbatim copy" promise. restore-webp overwrites .webp in target/dx/**/web/public/ from source public/. SVG/ICO are unaffected. Remove once upstream fixes it.

JS workspace lives under libs/: libs/ is a pnpm workspace — root libs/package.json hoists the 4 shared devDeps (happy-dom/typescript/vite/vitest) + Biome; libs/pnpm-workspace.yaml lists libs/* packages; libs/pnpm-lock.yaml is the single lockfile. First-time setup: cd libs && pnpm install. All Makefile recipes cd libs && pnpm .... Build a single lib: make build-editor (≈ cd libs && pnpm --filter @yggdrasil/tiptap-editor run build). There is also @yggdrasil/shared (libs/shared/) — a non-IIFE internal source-shared package (exports ThemeName/THEME_CHANGE_EVENT/prefersReducedMotion, main points at src/index.ts with no Vite build) consumed via workspace:* by codemirror-editor/lightbox/xterm-terminal/yggdrasil-core and inlined into each lib's IIFE bundle at build time; it has no public/ artifact.

Prerequisites

  • Rust 1.95+ with wasm32-unknown-unknown target
  • dx CLI (cargo install dioxus-cli)
  • tailwindcss CLI v4 — install via npm install -g @tailwindcss/cli (v4 splits the CLI into its own package; the tailwindcss core package has no bin), or use the standalone binary
  • pnpm 11+ (libs/ is a pnpm workspace; vendored as a devDependency via corepack, but a global install is convenient)
  • PostgreSQL running locally

Biome (linter + formatter for libs/) is a devDependency in libs/package.json — no separate install. make lint runs biome check + cargo clippy; make fix runs biome format --write + cargo fix. Config in libs/biome.json.

Environment

Create .env (not committed):

DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil
RUST_LOG=info

Migrations run automatically at startup — there is no migrate.sh. On boot src/main.rs calls db::migrate::run_on_conn, which applies migrations/*.sql in order (tracked in the MIGRATIONS array in src/db/migrate.rs). Before the pool is touched, db::pool.rs::ensure_database_exists connects to the postgres maintenance DB and CREATEs the target DB if missing (zero manual setup). The migration step is serialized across instances via an advisory lock and waits up to MIGRATE_STARTUP_TIMEOUT_SECS for PostgreSQL.

Adding a migration: create migrations/NNN_name.sql, then add a (version, include_str!("./../migrations/NNN_name.sql")) row to the MIGRATIONS array in src/db/migrate.rs. A compile-time test asserts every .sql file on disk has a matching row (and vice versa), so forgetting the row fails the build.

Optional tuning via env vars (all have sane defaults):

WEBP_QUALITY=85.0           # 0.0100.0, clamped
WEBP_METHOD=2               # 06, clamped
MAX_IMAGE_DIMENSION=8192     # max single side in px, min 512, no upper limit
MAX_IMAGE_PIXELS=50000000   # max total pixels (~7k×7k), min 1M, no upper limit
RATE_LIMIT_STRICT_PER_SEC=1
RATE_LIMIT_STRICT_BURST=5
RATE_LIMIT_UPLOAD_PER_SEC=2
RATE_LIMIT_UPLOAD_BURST=15
RATE_LIMIT_IMAGE_PER_SEC=10
RATE_LIMIT_IMAGE_BURST=50
RATE_LIMIT_COMMENT_PER_SEC=1          # comment posting
RATE_LIMIT_COMMENT_BURST=5
RATE_LIMIT_UNKNOWN_PER_SEC=30         # fallback bucket when real client IP can't be determined
RATE_LIMIT_UNKNOWN_BURST=100
RATE_LIMIT_CODE_EXEC_PER_SEC=1        # code runner per-IP burst (governor: integer only, no decimals)
RATE_LIMIT_CODE_EXEC_BURST=3
RATE_LIMIT_CODE_EXEC_DAILY=50          # code runner per-IP daily cap
DB_POOL_SIZE=20             # database connection pool size
MIGRATE_STARTUP_TIMEOUT_SECS=30  # how long startup waits for PostgreSQL before giving up
STATEMENT_TIMEOUT_SECS=30   # per-query timeout; slow queries are canceled to protect the pool
SSR_CACHE_SECS=3600         # incremental SSR cache TTL (set 0 in dev)
SYSINFO_SAMPLE_SECS=0.5     # sysinfo sampling interval in seconds, supports decimals

Code Runner tuning (all optional, sane defaults):

CODE_RUNNER_ALLOW_NETWORK=false        # global network switch; AND-ed with per-language allow_network
CODE_RUNNER_MAX_CONCURRENT=4           # tokio Semaphore for in-flight containers
CODE_RUNNER_MAX_CPU_CORES=2.0          # upper clamp for cpu_cores (lower bound 0.1)
CODE_RUNNER_MAX_MEMORY_MB=1024         # upper clamp for memory_mb (lower bound 16)
CODE_RUNNER_MAX_TIMEOUT_SECS=30        # upper clamp for timeout_secs (lower bound 1)
CODE_RUNNER_MAX_OUTPUT_BYTES=1048576   # hard cap on stdout+stderr captured
CODE_RUNNER_MAX_SOURCE_BYTES=65536     # max source size accepted by StartExec
CODE_RUNNER_QUEUE_TIMEOUT_SECS=30      # how long a task waits for a container slot before failing
CODE_RUNNER_TASK_TTL_SECS=300          # DashMap task entry lifetime (gc_old_tasks)
CODE_RUNNER_LANGUAGES=                 # unset = all registered langs; set a comma-list to narrow (e.g. python,node)
DOCKER_SOCKET_PATH=/var/run/docker.sock

Session / security tuning:

COOKIE_SECURE=false         # set true/1/yes to add Secure flag to session cookie
TRUSTED_PROXY_COUNT=0       # number of reverse proxies in front of the app; used to extract real client IP from X-Forwarded-For
APP_BASE_URL=               # e.g. https://your-domain.example — trusted origin for CSRF checks on write requests; unset falls back to Host header + X-Forwarded-Proto
EXPOSE_VERSION_HEADERS=true # 附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git 响应头;设 0/false/no 关闭(不向外部暴露版本/commit 时)

Architecture: Conditional Compilation

Dioxus 0.7 fullstack project with two independent gates — the most common source of compilation errors.

Gate Applies to Used for
#[cfg(feature = "server")] Server binary only DB, env loading, background tasks, server function bodies, highlight, WebP, caching, sysinfo
#[cfg(target_arch = "wasm32")] WASM frontend only localStorage, DOM APIs, web_sys calls, theme detection

Critical: Both default features (web + server) are enabled in Cargo.toml. The dx CLI handles feature selection during builds.

Stub pattern: src/db/mod.rs provides a DummyPool when server feature is disabled — do not remove.

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, 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.

Global allocator (mimalloc): src/main.rs registers #[global_allocator] static GLOBAL: mimalloc::MiMalloc gated with #[cfg(all(feature = "server", not(target_arch = "wasm32")))]. Both gates are load-bearing: mimalloc is optional = true enabled via the server feature, and the mimalloc_rust crate does not compile on wasm32 (Issue #76), so the WASM frontend must fall back to the default allocator. Do not delete either cfg — removing the not(wasm32) gate breaks the frontend bundle, removing the server gate pulls mimalloc into the WASM target. Chosen over jemalloc because the production server is a fully-static musl binary (Dockerfile), and jemalloc has known musl build pitfalls (pthread_getname_np for musl <1.2.3, unprefixed-malloc symbol conflicts); mimalloc links cleanly into musl/glibc/FreeBSD.

Dual API Architecture

The server exposes two distinct API patterns:

  1. Dioxus server functions (#[server(Name, "/api")] in src/api/) — auto-routed, callable from both client and server Rust. Spread across src/api/auth.rs, src/api/posts/, src/api/comments/, src/api/settings.rs, and src/api/database/.

  2. Axum routes (registered in src/main.rs) — manual axum::Router for endpoints that don't fit the server-function model:

    • POST /api/upload — image upload (multipart, auth-required, rate-limited)
    • GET /uploads/{*path} — image serving with on-the-fly resize/rotate/convert (query params: w, h, thumb, rotate, format, quality)

Server Module Structure

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 + 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)
  settings.rs     — site settings server functions (trash retention, etc.)
  slug.rs         — URL slug generation
  posts/          — CRUD server functions for blog posts
  code_runner/    — runnable code-block server functions + data structures (see Code Runner section)
src/auth/         — password hashing (Argon2) + session token management
src/bin/          — generate_highlight_css (build-time CSS generation)
src/cache.rs      — moka future-based caches for posts, tags, stats + cache_stats()
src/components/   — Dioxus UI components
src/context.rs    — shared Dioxus context/state
src/db/           — PostgreSQL pool (deadpool-postgres, LazyLock global) + migrate.rs + pool.rs (ensure_database_exists)
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 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
src/webp.rs       — zenwebp encode/decode (image crate has no WebP)
src/tiptap_bridge.rs    — wasm-bindgen bindings for Tiptap editor
src/codemirror_bridge.rs — wasm-bindgen bindings for CodeMirror editor (mirrors tiptap_bridge)
src/infra/        — Docker execution layer + runner config (server-only); see Code Runner section

Code Runner (```lang runnable code blocks)

Readers can execute fenced code blocks in isolated Docker containers; authors get a /admin/runner trial sandbox. Three-layer architecture, all Docker interaction gated behind #[cfg(feature = "server")]:

  • Execution layer (src/infra/docker.rs, server-only): bollard client over Unix socket (DOCKER_CLIENT LazyLock). run_in_container creates a read-only-rootfs container (tmpfs /code,/tmp,/run; cpu/memory/pids/ulimits cap; cap_drop=ALL; no-new-privileges; non-root 1000:1000; network_mode none/bridge), injects source via stdin, waits with timeout, captures stdout/stderr (truncated to output_bytes), inspects OOM, force-removes via ContainerGuard Drop. src/infra/runner_config.rs (RUNNER_CONFIG) reads all CODE_RUNNER_* env vars; clamp_limits AND-merges request overrides × language allow_network × global switch.
  • API layer (src/api/code_runner/): shared ExecRequest/ExecResult/ExecStatus/ExecTask structs (compile on both targets); progress.rs = DashMap task registry + gc_old_tasks; languages.rs = LANGUAGES registry + parse_fence_info + normalize_lang (alias normalization: js/javascriptnode, rsrust, ts/typescriptbun — done in parse_fence_info so data-lang/ExecRequest.language/LANGUAGES.get only ever see canonical keys python/node/go/rust/bun); execute.rs = StartExec/GetExecResult server functions (double rate-limit → whitelist → size check → enqueue → tokio::spawn + RUNNER_SEMAPHORE concurrency cap → clamp → run_in_container). start_exec/start_exec_stream call normalize_lang(&req.language) before LANGUAGES.get so aliases survive the wire. System errors are sanitized to 「系统暂时不可用」 for anonymous callers; full errors in server logs only.
  • Markdown/render layer: a fenced block ```python runnable {...overrides} renders to <pre data-runnable="true" data-lang="python" data-overrides="..." data-source="..."> (sanitizer whitelists these 4 attrs on <pre>). PostContent (src/components/post/post_content.rs) splits content_html into Html/Runnable fragments so each runnable block renders as a real <CodeRunner> vdom element (no manual DOM mutation → no hydration conflict). CodeRunner component polls GetExecResult via WASM-friendly sleep_ms. Output is rendered into an xterm.js terminal instance (src/xterm_bridge.rs + libs/xterm-terminal, mirroring the codemirror bridge pattern) — stdout/stderr streamed via SSE, with full-buffer writeAll as a polling fallback.

Critical WASM-visibility rule: code_runner/execute.rs is not cfg-gated (server functions must be visible to the client), but every server-only use/static inside it is individually #[cfg(feature = "server")]. The shared use of ExecRequest/ExecTask (used in signatures) stays ungated. code_runner/languages.rs and code_runner/progress.rs (pure server helpers) are wholly gated. Mirrors the posts/ module convention.

Governor 0.8 caveat: Quota::per_day does not exist; CODE_EXEC_DAILY_LIMITER uses Quota::with_period(24h).allow_burst(daily). RATE_LIMIT_CODE_EXEC_PER_SEC must be an integer (governor's per_second takes NonZeroU32; decimals in .env fall back to the default 1).

Runner images (docker/): build-runners.sh builds yggdrasil-runner-baseyggdrasil-runner-pythonyggdrasil-runner-nodeyggdrasil-runner-goyggdrasil-runner-rustyggdrasil-runner-bun; tags must match LANGUAGES image fields. Python image symlinks pythonpython3 to match run_cmd. Go image redirects GOCACHE/GOTMPDIR/GOPATH to /tmp (read-only rootfs makes $HOME/.cache unwritable). Rust image ships a /usr/local/bin/run-rust.sh wrapper because rustc compile + run is two steps and docker.rs injects run_cmd via execexec A && B would replace the shell and never reach B, so the two steps are encapsulated in the wrapper. Bun image uses the official bun.sh/install script (bun isn't in Alpine 3.18 repos; the script auto-detects /etc/alpine-release and picks the musl variant) and must apk add libstdc++ libgcc — bun's musl binary is C++ and needs the C++ runtime that musl libc alone doesn't provide; BUN_INSTALL=/opt/bun is export-ed so the pipe curl | bash reads it on both sides (a bare VAR=x curl | bash only sets it for curl, making bash fall back to $HOME/.bun). runner.toml files are image self-descriptions only — runtime config is the Rust LANGUAGES registry + CODE_RUNNER_* env, not parsed from toml.

Frontend Lib Subprojects

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 Extensions 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.__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.

Database Management (/admin/system)

Admin area at /admin/system (menu "系统") with 5 tabs: 数据库状态 / 服务器状态 / SQL 控制台 / 数据导出 / 备份恢复. All gated by get_current_admin_user (admin-only). Backend in src/api/database/ (status/system_status/sql_console/schema/export/backup/tasks), page in src/pages/admin/system.rs.

  • SQL 控制台 is full read-write with 4 guards: (1) sqlparser AST gates — DROP DATABASE/DROP SCHEMA/CREATE DATABASE absolutely forbidden (string pre-check); DROP/TRUNCATE/ALTER require a confirm_dangerous checkbox; (2) UPDATE/DELETE without WHERE rejected; (3) STATEMENT_TIMEOUT_SECS query timeout (pool-level GUC); (4) frontend write-confirm dialog. Multi-statement disabled by default. Results capped at 500 rows.
  • 备份恢复 uses dashmap task-progress table; create_backup/restore_backup return a task_id immediately and poll get_task_progress. Backup prefers pg_dump (--clean --if-exists so the dump script includes DROP ... IF EXISTS → restore is idempotent: it drops+recreates existing objects instead of failing on "relation already exists" / duplicate-key during COPY), falls back to per-table COPY TO STDOUT (data only) when pg_dump is unavailable. Restore runs psql -v ON_ERROR_STOP=1 -f (without ON_ERROR_STOP, psql returns exit 0 even when every statement errors → silent false-success with zero data change); on success it flushes all post caches + bumps SSR generation. Backup files carry a -- YGGDRASIL BACKUP v1 signature header; restore rejects non-system files. backups/ is gitignored and served only via GET /api/database/backups/{filename} (admin-gated, path-allowlist). Pre-fix backup files (no DROP) will fail-restore correctly under ON_ERROR_STOP — regenerate the backup to actually restore.
  • 服务器状态 uses sysinfo (optional, server feature) with a background sampler (SYSINFO_SAMPLE_SECS, default 0.5s) writing to a RwLock<SystemSnapshot>; server functions read the snapshot (zero sampling cost), so frontend can poll high-frequency. src/cache.rs exposes moka hit-rate via AtomicU64 hit/miss counters per cache + cache_stats().

Syntax Highlighting Pipeline

  • themes/ contains Catppuccin Latte (light) and Mocha (dark) .tmTheme files
  • syntaxes/ has custom Sublime syntax definitions (Kotlin, Swift)
  • src/bin/generate_highlight_css.rs generates public/highlight.css with class-based rules scoped under .md-content pre code, with .dark prefix for dark mode
  • 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 无语法定义,回退纯文本)。preclass 在 sanitizer 通用白名单。
  • 前端:libs/mermaid-renderer/ 是独立 IIFE bundle(mermaid 11.16,~1MB,gzip ~900KB),输出 public/mermaid/mermaid.jslibs/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"
  • Login: sets an HttpOnly cookie via FullstackContext::add_response_header
  • Session validation: get_current_user reads session cookie, queries sessions + users tables
  • Background cleanup: tasks::session_cleanup::run_cleanup() deletes expired sessions every hour

Caching

  • 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.
  • 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.rsinvalidate_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

make test # cargo test (Rust) + pnpm -r run test in all libs
make lint # Biome check (libs) + cargo clippy --all-targets --all-features -- -D warnings
dx check  # Dioxus type-check (catches component/Router issues)

Must verify both targets: This is a fullstack project — the server binary and the WASM frontend are two separate compilation targets with different features (--all-features enables web+server; the WASM bundle uses --no-default-features --features web). A change that compiles on one target can fail on the other. cargo build --all-features alone is NOT sufficient — it only builds the server binary. Before considering work done, compile the WASM target too:

cargo build --all-features                                        # server target (native)
cargo build --target wasm32-unknown-unknown --no-default-features --features web  # WASM frontend

dx check does Dioxus-level type-checking but does NOT run a full cargo build for the WASM target, so it can miss borrow-checker / move / lifetime errors that only surface in the frontend bundle. dx serve / make dev do the real WASM compile — run them (or the explicit cargo build --target wasm32-... above) before declaring a task complete.

Common target-mismatch traps:

  • #[cfg(target_arch = "wasm32")] code invisible to server build → web_sys / js_sys references only resolve on WASM.
  • let mut x = use_signal(...) flagged as unused_mut on server (where the .set() calls live inside stripped wasm blocks) but required on WASM. Use #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] on the function/item rather than deleting mut.
  • use imports placed inside a #[cfg(target_arch = "wasm32")] {} block are invisible to the server build; imports at module top level are visible to both. When an import is only needed on one target, gate the use line itself.

Most Rust tests use #[cfg(all(test, feature = "server"))] — they only run when the server feature is active (which is the default). No integration tests requiring a database connection; the migration .sqlMIGRATIONS-array parity is enforced by a compile-time test. The 4 libs/ subprojects run their own vitest suites (Vitest + happy-dom).

Image Processing Constraints

  • The image crate is configured without WebP support (default-features = false, features = ["jpeg", "png", "gif"]). Do not add WebP to the image crate features.
  • All WebP encode/decode goes through zenwebp via src/webp.rs.
  • Upload pipeline auto-converts non-GIF/non-WebP images to WebP, keeping original format if WebP is larger.
  • Image serving supports on-the-fly resize (w, h), thumbnail (thumb=WxH), rotation (90/180/270), and format conversion.

Build Artifacts (gitignored)

  • 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
  • uploads/.cache/ — image processing disk cache
  • backups/ — admin DB backup files
  • .freebsd-sysroot/ — FreeBSD cross-compile sysroot (machine-local)

Notes

  • rand is optional and only enabled by the server feature; it is not compiled into the WASM frontend.
  • #[allow(unused_mut, unused_variables)] on Write component is intentional — mut signals are used in #[cfg(target_arch = "wasm32")] blocks stripped in server builds.
  • Release profile: panic = "abort" (drops WASM unwind metadata; server errors go through Result + ?, process crashes restarted by systemd/k8s).

Pitfall Log (踩坑记录)

Recurring traps that have cost real debugging time. Read before touching the relevant area; add new entries when you hit a non-obvious failure.

Custom hooks that own resources (use_hook + use_effect + use_drop)

When writing a hook that registers a side effect (e.g. src/hooks/event_listener.rs), the use_effect callback is typed FnMut and may run more than once. But the things you move into it are often FnOnce (an init/acquire closure) or need to be moved onward into a Closure::wrap event handler. Naively move ||-ing them in triggers E0507 cannot move out of captured variable in an FnMut closure and E0310 the parameter type may not live long enough.

Fix pattern (used in event_listener.rs): wrap the FnOnce/FnMut args in Option, take() them on the first effect run, and add 'static bounds to the relevant generic params (A: FnOnce() -> Option<T> + 'static). This consumes each captured value exactly once without violating FnMut.

Also: use_hook / use_effect / use_drop come from dioxus::prelude. If you scope an import to a #[cfg(target_arch = "wasm32")] block, the use dioxus::prelude::*; must live inside that block too — the server (no-wasm) variant of the hook is a no-op and must not reference them.

Single-target verification is a lie

The single most repeated mistake: running only cargo build --all-features (server) and shipping, then dx serve blowing up on the WASM bundle. The WASM target compiles with different features and surfaces different borrow/move errors. Always compile both targets (see Testing section) before marking work done. dx check is a fast Dioxus type-check, not a substitute for cargo build --target wasm32-unknown-unknown.

mut bindings needed only on WASM

let mut x = use_signal(...) produces unused_mut on the server build when every .set() lives inside a #[cfg(target_arch = "wasm32")] block. Don't delete mut — it's required on the WASM build. Use #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] on the enclosing fn/struct. This is the project's established convention (see Write component, use_paginated, etc.).

Placing util functions: check the feature gate of the target module

src/utils/text.rs is #[cfg(feature = "server")]-gated and pulls in regex (an optional, server-only dep). A frontend-reachable function (e.g. escape_html, called from a #[component]) cannot move there — the WASM build would fail on the missing regex crate. Use an ungated module (src/utils/html.rs, src/utils/time.rs) for code that must compile on both targets. Verify the destination module's cfg before moving anything into it.

Reactive hooks don't track plain props (use_server_future / use_resource / use_memo)

Dioxus 0.7's reactive hooks (use_server_future, use_resource, use_memo) only re-run/recompute when a signal read inside their closure changes. They track dependencies via a ReactiveContext that subscribes to signals during the closure's execution. A plain component prop (String, i32, any non-signal) is a frozen snapshot the moment it's moved into the closure — reading it establishes no subscription. So when the prop later changes (e.g. a route param updates), the hook does not re-run. The component re-renders (props are unequal), but the hook's task/memo keeps its first-run value forever.

This bites specifically on same-route-variant navigation: /post/a → /post/b (both Route::PostDetail) reuses the component instance and only updates props, so the frozen prop never refreshes the hook. Cross-variant navigation (/ → /page/2, i.e. Route::Home → Route::HomePage) mounts a fresh component and runs the hook for the first time, so it looks fine and hides the bug.

This cost three rounds of debugging (commits 79978aa, 1c56fd8, f86cb48):

  1. use_server_future not re-fetching (src/pages/post_detail.rs, home.rs, tags.rs): use_server_future(move || get_post_by_slug(slug.clone())) — clicking prev/next changed the URL but the article content stayed on the old post (only a full browser refresh worked). Fix: read the slug inside the closure via dioxus::router::router().current::<Route>()current() calls subscribe_to_current_context(), registering the subscription in the hook's ReactiveContext, so route changes re-run the future.

  2. use_memo caching stale derived data (src/components/post/post_content.rs): use_memo(move || split_content_fragments(&content_html)) — after the route fix made the title update, the body still showed the old article. The memo permanently cached the first parse because content_html is a plain String prop. Fix: drop the memo, call the pure split_content_fragments(&content_html) directly in the render body (render purity is preserved — it's a pure function call).

Decision rule:

  • If the value a hook depends on is a signal (from use_signal, a Resource/Memo, router.current(), context), the hook re-runs correctly — no action needed.
  • If it's a plain prop that can change while the component instance is reused (route params, parent-passed String/i32), the hook will silently keep stale data. Either read a signal source inside the closure, or drop the hook and recompute inline.
  • For one-off derived values from props, prefer direct inline computation in the render body (it's pure) over use_memo — the memo buys nothing for prop-derived data since it won't recompute anyway, and it introduces this exact staleness bug.

Suspect areas still open: CommentSection (src/components/comments/section.rs:86) reads post_id (plain prop) inside use_resource — currently safe only because its parent article uses key: "{post.slug}" and remounts it on every article switch. If that key is ever removed/changed, comments would stop refreshing on navigation.