Compare commits
3 Commits
f7e4d2f5ea
...
2b75ac4d28
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b75ac4d28 | |||
| c693d82bab | |||
| 68e290a9fe |
499
AGENTS.md
499
AGENTS.md
@ -1,4 +1,153 @@
|
|||||||
# AGENTS.md
|
# Repository Guidelines
|
||||||
|
|
||||||
|
Yggdrasil is a fullstack blog/CMS built with **Dioxus 0.7**. A single Rust crate (`yggdrasil`, Cargo edition 2021) compiles to **two targets from one codebase**: a WASM frontend (feature `web`) and a native Axum server (feature `server`). Stack: PostgreSQL via tokio-postgres + deadpool, Tailwind CSS v4, Argon2 passwords, moka cache, syntect code highlighting, katex-rs math, mimalloc allocator. The blog also runs user-submitted code in isolated Docker containers.
|
||||||
|
|
||||||
|
## Architecture & Data Flow
|
||||||
|
|
||||||
|
**The `server` feature gate is the central organizing principle.** Nearly every module gates real DB/IO/Axum logic under `#[cfg(feature = "server")]` and provides a compiling stub under `#[cfg(not(feature = "server"))]` for the WASM build. `default = ["web", "server"]` (fullstack); override to build one target only (see Development Commands).
|
||||||
|
|
||||||
|
- **Two endpoint kinds**: (1) Dioxus server functions `#[server(Name, "/api")]` (auth, posts, comments, settings, database, code_runner); (2) manual Axum routers merged into the app router in `src/main.rs` (upload, image serving, health, SSE stream).
|
||||||
|
- **Boot** (`src/main.rs`, server branch): dotenvy → tracing → build_info → hard-check `DATABASE_URL` → `validate_database_url()` → CSRF warn → a *throwaway* multi-thread tokio runtime runs `ensure_database()` + migrations + port pre-probe, then is dropped → `dioxus::server::serve` returns the Axum router with background tasks (session cleanup, post purge, image-cache cleanup, IP purge, sysinfo sampler). mimalloc is the global allocator under `cfg(all(feature="server", not(wasm32)))`.
|
||||||
|
- **Read flow** (e.g. `GET /post/:slug`): middleware stack `[ssr_generation → add_cache_control → csrf → optional compression → 30s Timeout → admin_guard]` → Dioxus IncrementalRenderer checks persisted `static/<route>/index/<hash>.html` (TTL `SSR_CACHE_SECS`, default 3600s). HIT → serve cached HTML. MISS → SSR renders `PostDetail` → `use_server_future(get_post_by_slug(slug))` → Dioxus deserializes to a server fn call → `cache::get_post_by_slug` (moka, 600s TTL) → miss → `get_conn()` from deadpool pool → query → cache set → return.
|
||||||
|
- **Write flow** (e.g. create post): admin client → POST `/api/CreatePost` → CSRF validates Origin → `get_current_admin_user()` → validate → `spawn_blocking(render_markdown_enhanced)` → BEGIN TXN → INSERT post + `sync_tags` → COMMIT → invalidate matching moka caches **and** `ssr_cache::invalidate_ssr_*` (physical dir deletion) → return.
|
||||||
|
- **Auth**: cookie-session (HttpOnly, SameSite=Lax, optional Secure), Argon2 hashing in `spawn_blocking`, moka-cached sessions re-checked against `users.session_generation` on every hit. First registered user becomes admin (atomic `INSERT ... ON CONFLICT`).
|
||||||
|
- **Code execution**: ` ```lang runnable ``` ` blocks and `/admin/runner` run code in Docker containers (bollard, `src/infra/docker.rs`) — read-only rootfs + tmpfs `/code`, UID 1000, resource/cap-limited, `ContainerGuard` cleanup. SSE streams output at `GET /api/exec/stream`.
|
||||||
|
|
||||||
|
## Key Directories
|
||||||
|
|
||||||
|
- `src/` — Rust source (single crate). `main.rs` (entry), `router.rs` (Dioxus routes), `middleware.rs` (axum layers: compression, cache-control, admin_guard), `cache.rs` (moka, many domain caches with distinct TTLs), `ssr_cache.rs` (physical SSR cache invalidation), `theme.rs` (light/dark/system), `highlight.rs` (syntect), `context.rs` (`UserContext` global login state).
|
||||||
|
- `src/api/` — endpoints: `auth.rs`, `posts/` (create/update/delete/trash/list/read/search/stats/tags/rebuild/helpers/types), `comments/`, `settings/`, `database/` (admin: console/export/backup), `code_runner/`, `upload.rs`, `image.rs`, `health.rs`, `sse`. Cross-cutting: `error.rs` (`AppError`), `csrf.rs`, `rate_limit.rs`, `sanitizer.rs`, `slug.rs`, `markdown.rs` (`render_markdown_enhanced`), `katex.rs`.
|
||||||
|
- `src/db/` — `pool.rs` (`DB_POOL: LazyLock<deadpool>`, `get_conn()` runtime fast-fail, `get_conn_for_startup()` retry), `migrate.rs` (`MIGRATIONS` array + runner), `retry.rs`, `mod.rs` (`format_with_sources`, `DummyPool` stub).
|
||||||
|
- `src/models/` — `post.rs`, `user.rs`, `comment.rs`, `settings.rs` (serde DTOs shared across SSR/cache/API).
|
||||||
|
- `src/auth/` — `password.rs` (Argon2), `session.rs` (UUID token, SHA-256 `hash_token`, cookie build/parse).
|
||||||
|
- `src/components/` — Dioxus components (layouts, header/nav/footer, post/comments/code_runner/skeletons/forms/ui atoms).
|
||||||
|
- `src/pages/` — route components. **`post_detail.rs` header docs are the canonical guide for `use_server_future` + route-subscription gotchas — read before editing pages.**
|
||||||
|
- `src/tasks/` — server-only background loops spawned in `serve()`.
|
||||||
|
- `src/infra/` — `docker.rs` (bollard), `runner_config.rs`.
|
||||||
|
- `src/hooks/`, `src/utils/` — query/event hooks; text/time/html helpers.
|
||||||
|
- `src/bin/generate_highlight_css.rs` — build-tool binary (regenerates `public/highlight.css` from syntect themes; `required-features = ["server"]`).
|
||||||
|
- `src/*_bridge.rs` — wasm-bindgen bridges for JS editors/terminal (`tiptap_bridge`, `codemirror_bridge`, `xterm_bridge`).
|
||||||
|
- `libs/` — pnpm JS workspace, packages named `@yggdrasil/*`. Each builds to a self-contained IIFE bundle written **directly into `public/<dir>/`** and consumed by the Rust side via window globals (`js_sys::Reflect::get` on object-literal modules, or global `__init*` functions via a typed `invoke_optional_global` helper).
|
||||||
|
- `tiptap-editor` → `public/tiptap/` (rich-text Markdown editor), `codemirror-editor` → `public/codemirror/` (code-runner source editor), `lightbox` → `public/lightbox/`, `xterm-terminal` → `public/xterm/`, `yggdrasil-core` → `public/yggdrasil-core/`, `mermaid-renderer` (dynamically script-injected by yggdrasil-core on viewport visibility), `shared` (cross-lib constants: `ThemeName`, `THEME_CHANGE_EVENT` — inlined into each IIFE, not bundled).
|
||||||
|
- `migrations/` — 14 numbered SQL files (`NNN_desc.sql`); each must also be registered in the `MIGRATIONS` array in `src/db/migrate.rs` (enforced by a compile-test).
|
||||||
|
- `syntaxes/` — `.sublime-syntax` definitions (JSX/Kotlin/Swift/TSX/TypeScript/Vue/Zig); embedded via `include_str!` at compile time.
|
||||||
|
- `themes/` — Catppuccin Latte (light) / Mocha (dark) `.tmTheme` for syntect.
|
||||||
|
- `docker/` — `Dockerfile` (app), `build-runners.sh` + `runner-base/` + `runner-{python,node,go,rust,bun}/` (sandbox images).
|
||||||
|
- `docs/` — `DEPLOYMENT.md`, `test-markdown.md` (rendering test fixture). `DEVELOPMENT.md` (perf benchmarking + highlighting guide). `CHANGELOG.md` (Keep a Changelog v1.1.0, SemVer; current `0.5.0`).
|
||||||
|
- `scripts/` — `migrate.sh` (manual DB migration runner; companion to the built-in startup migrator), `xun.fish` (full-deploy pipeline to the `xun` server — build all images, scp, rolling-restart `app` only, verify).
|
||||||
|
- `static/` — Dioxus IncrementalRenderer persists SSR HTML here at runtime (gitignored output, not source).
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
Prerequisites: Rust 1.95+, `wasm32-unknown-unknown` target, `dx` CLI (v0.7.9), `tailwindcss` CLI v4, PostgreSQL, Node 20+ / pnpm.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev server (builds libs + highlight.css + katex.css first, assumes node_modules present)
|
||||||
|
make dev
|
||||||
|
|
||||||
|
# Full release build (client WASM + native server)
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Linux cross-build (musl static binary)
|
||||||
|
make build-linux
|
||||||
|
|
||||||
|
# CSS
|
||||||
|
make css # input.css -> public/style.css (one-shot)
|
||||||
|
make css-watch # with --watch
|
||||||
|
|
||||||
|
# Lint (JS Biome + Rust clippy, no writes)
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Auto-fix (Biome -> cargo fix -> cargo fmt -> dx fmt)
|
||||||
|
make fix
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
make test # == cargo test --features server
|
||||||
|
cargo test --features server highlight_code_swift -- --nocapture # single highlight test w/ output
|
||||||
|
|
||||||
|
# Docs (rustdoc, --no-deps --document-private-items, ayu theme) -> public/doc/
|
||||||
|
make doc
|
||||||
|
make doc-open
|
||||||
|
|
||||||
|
# Build JS libs
|
||||||
|
make build-libs
|
||||||
|
make build-editor # pnpm --filter @yggdrasil/tiptap-editor run build (also codemirror/lightbox/core/xterm/mermaid)
|
||||||
|
|
||||||
|
# Regenerate highlight.css (only when adding new syntect scope types)
|
||||||
|
cargo run --features server --bin generate_highlight_css
|
||||||
|
|
||||||
|
# WASM-only build (e.g. to check the web target)
|
||||||
|
cargo build --no-default-features --features web
|
||||||
|
# Server-only build (as the Dockerfile does)
|
||||||
|
cargo build --no-default-features --features server
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil ./scripts/migrate.sh # manual; also auto-runs on server startup
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
make docker # native arch, load into local daemon
|
||||||
|
make docker-amd64 # x86_64 via QEMU (Apple Silicon)
|
||||||
|
make docker-apple # x86_64 via Apple Container CLI (macOS 26+)
|
||||||
|
make docker-multiarch IMAGE=ghcr.io/owner/yggdrasil:latest # amd64+arm64, push to registry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Configuration
|
||||||
|
|
||||||
|
**Feature model** (`Cargo.toml`): `default = ["web", "server"]`. `web` = `dioxus/web` (WASM); `server` = all native deps (tokio, axum, tokio-postgres, deadpool, argon2, moka, syntect, katex-rs, mimalloc, governor, bollard, …). Most deps are `optional = true` and gated behind the `server` feature list. Release profile: `opt-level=3`, `lto="thin"`, `codegen-units=1`, `strip=symbols`, **`panic="abort"`** (shed WASM unwind metadata; server relies on systemd/k8s restart — design around `Result + ?`, never panic-driven control flow).
|
||||||
|
|
||||||
|
**`.cargo/config.toml`**: sets `--cfg getrandom_backend="wasm_js"` for `wasm32-unknown-unknown` only (workaround for a Dioxus 0.7.9 cfg leak into the server build). musl/freebsd linker blocks are commented templates.
|
||||||
|
|
||||||
|
**`build.rs`**: injects `YGG_BUILD_GIT_DESCRIBE/HASH/COMMIT_DATE` + rustc version + build time via `cargo:rustc-env` (read by `src/build_info.rs` through `env!`). 3-tier fallback: env var → local `git` → `"unknown"`. `rerun-if-changed=.git/HEAD` + `.git/index`. std-only (no build-deps).
|
||||||
|
|
||||||
|
**Self-contained binary**: migrations (`src/db/migrate.rs` `include_str!`), custom syntaxes (`src/highlight.rs` `include_str!`), and `public/highlight.css` (pre-generated at build time) are embedded — the runtime `scratch` image needs only the binary + `public/` + `uploads/`.
|
||||||
|
|
||||||
|
**Key env vars** (see `.env.example` for the full ~45-var reference; no mailer, no `LISTEN_ADDR` — uses `IP`/`PORT`, no `UPLOAD` dir env, no session-lifetime/ADMIN env):
|
||||||
|
|
||||||
|
| Category | Var | Purpose (defaults) |
|
||||||
|
|---|---|---|
|
||||||
|
| Database | `DATABASE_URL` | PostgreSQL connection string (required) |
|
||||||
|
| Database | `DB_POOL_SIZE` | deadpool pool size (20) |
|
||||||
|
| Database | `STATEMENT_TIMEOUT_SECS` | per-query SQL timeout (30) |
|
||||||
|
| Database | `MIGRATE_STARTUP_TIMEOUT_SECS` | startup DB-connect retry window (30) |
|
||||||
|
| Server | `RUST_LOG` | tracing filter (`info`) |
|
||||||
|
| Server | `IP` / `PORT` | bind address (set in Dockerfile `0.0.0.0:3000`) |
|
||||||
|
| Server | `DIOXUS_PUBLIC_PATH` | public assets path (Dockerfile `/app/public`) |
|
||||||
|
| Perf | `SSR_CACHE_SECS` | SSR page cache TTL (3600) |
|
||||||
|
| Perf | `COMPRESSION_ALGORITHMS` | response compression — gzip/brotli/deflate/zstd/`all`/`off` (**off**) |
|
||||||
|
| Perf | `TOKIO_WORKER_THREADS` | tokio workers (read by runtime, not app code) |
|
||||||
|
| Perf | `SYSINFO_SAMPLE_SECS` | `/admin/system` metric interval (0.5) |
|
||||||
|
| Security | `APP_BASE_URL` | CSRF trusted origin (prod strongly recommended; else Host-header fallback) |
|
||||||
|
| Security | `COOKIE_SECURE` | add `Secure` to session cookie (false) |
|
||||||
|
| Security | `TRUSTED_PROXY_COUNT` | reverse-proxy hop count for real-IP from XFF (0) |
|
||||||
|
| Security | `EXPOSE_VERSION_HEADERS` | attach Server/X-Yggdrasil-Version/Git headers (true) |
|
||||||
|
| Security | `MAX_SESSIONS_PER_USER` | concurrent-session cap w/ LRU evict (5) |
|
||||||
|
| Images | `WEBP_QUALITY` / `WEBP_METHOD` | WebP encode quality (85) / method (2) |
|
||||||
|
| Images | `MAX_IMAGE_DIMENSION` / `MAX_IMAGE_PIXELS` | max edge px (8192) / total pixels (50M) |
|
||||||
|
| Images | `IMAGE_DISK_CACHE_MAX_MB` / `_MAX_AGE_HOURS` | `uploads/.cache` cap (1024) / retention (168 = 7d) |
|
||||||
|
| Rate limit | `RATE_LIMIT_{STRICT,UPLOAD,IMAGE,COMMENT,CODE_EXEC,UNKNOWN}_PER_SEC/_BURST` | governor buckets keyed by client IP |
|
||||||
|
| Runners | `CODE_RUNNER_ALLOW_NETWORK` / `_MAX_CONCURRENT` / `_MAX_CPU_CORES` / `_MAX_MEMORY_MB` / `_MAX_TIMEOUT_SECS` / `_MAX_OUTPUT_BYTES` / `_MAX_SOURCE_BYTES` | sandbox limits |
|
||||||
|
| Runners | `CODE_RUNNER_LANGUAGES` | optional allow-list (default: all registered) |
|
||||||
|
| Runners | `DOCKER_SOCKET_PATH` | docker.sock for bollard (`/var/run/docker.sock`) |
|
||||||
|
|
||||||
|
**Production deployment** (see `docs/DEPLOYMENT.md`): the app does NOT do TLS — a reverse proxy (nginx/Caddy) is mandatory. **MUST set** `APP_BASE_URL`, `COOKIE_SECURE=true`, `TRUSTED_PROXY_COUNT` (exact proxy hop count — a wrong value lets attackers spoof XFF to bypass rate limits or makes all users share one proxy-IP bucket). nginx: `client_max_body_size 12m` (app hard-limits 10 MiB), `proxy_read/send_timeout 360s` (image transcoding up to 300s). Bind `127.0.0.1:3000:3000`, not `0.0.0.0`. Health: `/healthz` (liveness), `/readyz` (readiness, `SELECT 1`).
|
||||||
|
|
||||||
|
## Code Conventions & Common Patterns
|
||||||
|
|
||||||
|
1. **Dual-target gating.** Any code touching DB/IO/Axum must gate impl under `#[cfg(feature = "server")]` and provide a compiling `#[cfg(not(feature = "server"))]` stub. Never put server-only deps in code reachable by the web build. The `DummyPool` stub in `src/db/mod.rs` exists for this — do not delete it.
|
||||||
|
2. **Server functions.** `#[server(FnName, "/api")] pub async fn name(args...) -> Result<T, ServerFnError>`. Args/return serde-serializable. Re-export from the module's `mod.rs` (`pub use create::create_post;`).
|
||||||
|
3. **Error handling.** Never `?` a raw DB error into `ServerFnError`. Use `AppError` constructors (`db_conn`/`query`/`tx`) which log the full chain via `db::format_with_sources` but expose a generic message — they never leak SQL. Map domain failures via `AppError::Unauthorized/Forbidden/NotFound/BadRequest/Internal` then `.into()`. Validation/business rejections return `Ok(Response{success:false,...})`, **not** `Err`.
|
||||||
|
4. **Component purity (Dioxus 0.7).** `#[component]` bodies and `rsx!` must be **pure** — no `signal.set`, `spawn`, DOM calls, or side effects in the render body. Derive data inline or via signals; do effects in `use_effect`. Don't store derivable data in `use_signal`. (See the `dioxus-render-purity` skill.)
|
||||||
|
5. **Async data in pages.** Use `use_server_future(move || { ... })?`. To re-run on route-param change you MUST read the router state **inside the closure** via `router().current::<Route>()` (it subscribes via `ReactiveContext`) — a moved `String` prop is a frozen snapshot that won't re-trigger. To force a child remount on identity change (e.g. slug), wrap it in a single-element `for x in std::iter::once(...) { Comp { key: "{x}" } }` — a bare `key` on a non-list element is ignored by Dioxus's diff. See `src/pages/post_detail.rs` header docs.
|
||||||
|
6. **Auth guard.** Every admin server fn starts with `let user = get_current_admin_user().await?;`. The SSR `admin_guard` middleware is a fast-path 302 (fail-OPEN on DB error); the client `AdminLayout` is the backstop. Don't rely solely on the middleware for security decisions in server fns.
|
||||||
|
7. **CPU-bound work** (Argon2, syntect/markdown render) MUST go in `tokio::task::spawn_blocking` — never on the async worker.
|
||||||
|
8. **Caching.** Read-through on reads (`cache::get` → miss → db → `cache::set`); on writes call the matching `cache::invalidate_*` **and** `ssr_cache::invalidate_ssr_*` before returning. Use the `CacheKey` enum; don't hand-roll keys. Note: `ssr_cache::GLOBAL_GENERATION` / `X-SSR-Generation` is **observability only** — real SSR freshness is physical dir deletion + `SSR_CACHE_SECS` TTL.
|
||||||
|
9. **Migrations.** Create `migrations/NNN_desc.sql` **and** append `("NNN", include_str!("../../migrations/NNN_desc.sql"))` to the `MIGRATIONS` array in `src/db/migrate.rs` (a compile-test guards file/array parity). Each migration runs in its own transaction; write them idempotent-safe.
|
||||||
|
10. **DB connections.** Runtime path = `get_conn()` (fast-fail — do NOT retry pool-full `Timeout`, to avoid avalanche); startup path = `get_conn_for_startup()`. `statement_timeout` is injected globally via libpq options — don't add per-query timeouts.
|
||||||
|
11. **Markdown/HTML rendering** (`render_markdown_enhanced`): pulldown-cmark + syntect classed highlighting + TOC + heading anchors, CPU-bound → `spawn_blocking`. **Article HTML is rendered once at save time and stored in `posts.content_html`** — modifying syntaxes does not auto-refresh existing posts; rebuild via the `/admin/posts` "rebuild all" button (`rebuild_content_html`, batch size 500).
|
||||||
|
12. **Code highlighting** (`src/highlight.rs`): syntect `ClassedHTMLGenerator` emits CSS classes paired with `public/highlight.css`. To add/fix a language: edit `syntaxes/<Lang>.sublime-syntax` (the `expression` context's `include` order matters — multi-token rules before single-token ones), validate the YAML, add a test asserting CSS classes, run `cargo test --features server highlight_code_<lang> -- --nocapture`, and regenerate `public/highlight.css` only if a new scope type was added (`cargo run --features server --bin generate_highlight_css`). See `DEVELOPMENT.md` for the full guide.
|
||||||
|
13. **WebP**: the `image` crate's `"webp"` feature is **intentionally excluded** — all WebP encode/decode goes through zenwebp (`src/webp.rs`). Do NOT add it.
|
||||||
|
14. **JS libs** (`libs/`): pnpm workspace, TypeScript strict (target ES2020, `verbatimModuleSyntax` ⟹ use `import type`), Biome formatter (2-space, single quotes, semicolons, `trailingCommas: all`, line width 100), Vite 8 IIFE bundles written into `../../public/<dir>/`. `@yggdrasil/shared` is inlined into each IIFE — IIFEs cannot import each other at runtime. Use `make build-libs` or `make build-<name>` (`pnpm --filter`).
|
||||||
|
15. **Heavy `//!` module docs explain WHY.** Read a module's top doc comment before editing it. User-facing strings are predominantly Chinese.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@ -7,343 +156,17 @@
|
|||||||
- 提交信息遵循现有风格:`type(scope): 简述`,正文(可选)说明动机与关键改动。常见 type:`feat` / `fix` / `docs` / `refactor` / `chore` / `perf`。
|
- 提交信息遵循现有风格:`type(scope): 简述`,正文(可选)说明动机与关键改动。常见 type:`feat` / `fix` / `docs` / `refactor` / `chore` / `perf`。
|
||||||
- 只在用户明确要求时才 `git push`。提交到本地即可,不主动推送。
|
- 只在用户明确要求时才 `git push`。提交到本地即可,不主动推送。
|
||||||
|
|
||||||
## JavaScript 库
|
## Testing & QA
|
||||||
|
|
||||||
在 libs 目录下都是 JavaScript 库,他们的包管理器都是 pnpm。
|
- **Layout**: mostly inline `#[cfg(test)] mod tests` unit tests across `src/` (~40 modules) plus exactly one integration file `tests/post_detail_slug_rerun.rs` (a source-string guard asserting a Dioxus render-purity antipattern is absent).
|
||||||
|
- **Philosophy**: pure-function unit tests deliberately decoupled from DB/FS/cache. Inject dependencies (closures, temp dirs) instead of touching live state. **No test connects to live Postgres** — there is no test DB harness.
|
||||||
|
- **Feature gating**: server-touching tests use `#[cfg(all(test, feature = "server"))]`; pure-logic tests use plain `#[cfg(test)]`.
|
||||||
|
- **`serial_test`** serializes tests that mutate *process-global* state (moka cache singletons in `cache.rs`, env vars in `csrf`/`rate_limit`, in-process task maps in `progress.rs`, Docker in `infra/docker.rs`) — never DB rows.
|
||||||
|
- **Docker tests** auto-skip when the daemon is unavailable (`require_docker().await` → `None` → `eprintln!("skip: Docker daemon 不可用")`).
|
||||||
|
- **Run**: `make test` (== `cargo test --features server`; default features already enable `server`). For a single highlight test with visible output: `cargo test --features server highlight_code_<lang> -- --nocapture`.
|
||||||
|
- **Highlighting tests** (`src/highlight.rs`, ~30 tests) are the canonical example of the test philosophy; include compile-consistency tests (`custom_syntax_list_matches_directory`, `migrations_match_files_on_disk`) that keep embedded arrays in sync with their on-disk directories.
|
||||||
|
- **Coverage**: no formal coverage target; tests defend invariants and guard footguns (migration/syntax array parity, render-purity anti-patterns).
|
||||||
|
|
||||||
## Development Commands
|
## CI
|
||||||
|
|
||||||
```bash
|
Gitea Actions (`.gitea/workflows/ci.yaml`): a `check` job (lint/typecheck) and a `build` job. Docker images are **not** pushed by CI — deployment is manual via `scripts/xun.fish` (build → scp → rolling-restart `app` only → verify `/healthz` + `/readyz`). CI dashboard: https://git.rua.plus/api/v1/repos/xfy/yggdrasil/actions/tasks.
|
||||||
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 --minify` → `doc` → `dx build --release` → `restore-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 `CREATE`s 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.0–100.0, clamped
|
|
||||||
WEBP_METHOD=2 # 0–6, 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`/`javascript`→`node`, `rs`→`rust`, `ts`/`typescript`→`bun` — 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-base` → `yggdrasil-runner-python` → `yggdrasil-runner-node` → `yggdrasil-runner-go` → `yggdrasil-runner-rust` → `yggdrasil-runner-bun`; tags must match `LANGUAGES` image fields. Python image symlinks `python`→`python3` 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 `exec` — `exec 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 `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.__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 无语法定义,回退纯文本)。`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"`
|
|
||||||
- **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 (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`): 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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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 `.sql`↔`MIGRATIONS`-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 `move`d 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.
|
|
||||||
|
|||||||
75
libs/tiptap-editor/src/__tests__/footnote.test.ts
Normal file
75
libs/tiptap-editor/src/__tests__/footnote.test.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import { Markdown } from '@tiptap/markdown';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 脚注语法回归测试。
|
||||||
|
*
|
||||||
|
* @tiptap/markdown 的 escapeMarkdownSyntax 将 `[`→`\[`、`]`→`\]`,
|
||||||
|
* 导致 `[^1]` 被序列化为 `\[^1\]`,pulldown-cmark 将其当作字面文本。
|
||||||
|
* TiptapEditorInstance.unescapeFootnoteSyntax 在序列化后做后处理还原。
|
||||||
|
*
|
||||||
|
* 此测试直接验证 escapeMarkdownSyntax 的行为以及 unescape 正则的修复效果。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UNESCAPE_RE = /\\\[\^([^\n\\]*?)\\\]/g;
|
||||||
|
const unescape = (md: string) => md.replace(UNESCAPE_RE, '[^$1]');
|
||||||
|
|
||||||
|
function makeEditor() {
|
||||||
|
return new Editor({
|
||||||
|
element: document.body,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
|
||||||
|
Markdown,
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('脚注语法 - escapeMarkdownSyntax 破坏与修复', () => {
|
||||||
|
let editor: Editor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
editor = makeEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapeMarkdownSyntax 确实会把 [^1] 转义为 \\[^1\\]', () => {
|
||||||
|
editor.commands.setContent('引用[^1]', { contentType: 'markdown' });
|
||||||
|
const raw = editor.getMarkdown();
|
||||||
|
// 未修复时 raw 为 `\[^1\]`——这就是脚注失效的直接原因
|
||||||
|
expect(raw).toContain('\\[^1\\]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescape 正则还原脚注引用 \\[^1\\] → [^1]', () => {
|
||||||
|
expect(unescape('\\[^1\\]')).toBe('[^1]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescape 正则还原含空格的脚注 label', () => {
|
||||||
|
expect(unescape('\\[^my note\\]')).toBe('[^my note]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescape 正则还原脚注定义 \\[^1\\]: → [^1]:', () => {
|
||||||
|
expect(unescape('\\[^1\\]: 定义内容')).toBe('[^1]: 定义内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescape 正则不影响普通链接 [text](url)', () => {
|
||||||
|
const md = '\\[text\\](https://example.com)';
|
||||||
|
// 普通链接的 \[text\] 不以 ^ 开头,不会被误改
|
||||||
|
expect(unescape(md)).toBe(md);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescape 正则一次处理多个脚注引用', () => {
|
||||||
|
const md = '引用了\\[^1\\]和\\[^pc\\]和\\[^2\\]';
|
||||||
|
expect(unescape(md)).toBe('引用了[^1]和[^pc]和[^2]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('完整往返:unescape(getMarkdown()) 保留脚注引用语法', () => {
|
||||||
|
editor.commands.setContent('正文[^1]', { contentType: 'markdown' });
|
||||||
|
const fixed = unescape(editor.getMarkdown());
|
||||||
|
expect(fixed).toContain('[^1]');
|
||||||
|
expect(fixed).not.toContain('\\[^1\\]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -162,7 +162,7 @@ class TiptapEditorInstance {
|
|||||||
autofocus: false,
|
autofocus: false,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
if (this.options.onUpdate) {
|
if (this.options.onUpdate) {
|
||||||
this.options.onUpdate(editor.getMarkdown());
|
this.options.onUpdate(TiptapEditorInstance.unescapeFootnoteSyntax(editor.getMarkdown()));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFocus: () => {
|
onFocus: () => {
|
||||||
@ -205,7 +205,22 @@ class TiptapEditorInstance {
|
|||||||
if (this.isSourceMode && this.sourceTextarea) {
|
if (this.isSourceMode && this.sourceTextarea) {
|
||||||
return this.sourceTextarea.value;
|
return this.sourceTextarea.value;
|
||||||
}
|
}
|
||||||
return this.editor?.getMarkdown() || '';
|
return TiptapEditorInstance.unescapeFootnoteSyntax(this.editor?.getMarkdown() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修复 @tiptap/markdown 的 escapeMarkdownSyntax 对脚注语法的破坏。
|
||||||
|
*
|
||||||
|
* escapeMarkdownSyntax(MarkdownManager.ts:1112)将 `[` → `\[`、`]` → `\]`,
|
||||||
|
* 导致 `[^id]` 被转义为 `\[^id\]`,pulldown-cmark 将其视为字面文本而非脚注引用/定义。
|
||||||
|
* 这是与数学公式节点同类的问题:LaTeX 通过 atom Node + renderMarkdown 绕过转义,
|
||||||
|
* 但脚注是普通文本节点,无法走相同路径。
|
||||||
|
*
|
||||||
|
* 此函数在序列化后对完整的 Markdown 做后处理:把 `\[^label\]` 还原为 `[^label]`。
|
||||||
|
* 仅匹配 `\[` + `^` 开头的模式(脚注专属),不会影响普通 `[text](url)` 链接语法。
|
||||||
|
*/
|
||||||
|
private static unescapeFootnoteSyntax(md: string): string {
|
||||||
|
return md.replace(/\\\[\^([^\n\\]*?)\\\]/g, '[^$1]');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -219,7 +234,7 @@ class TiptapEditorInstance {
|
|||||||
const proseMirrorDom = this.editor.view.dom;
|
const proseMirrorDom = this.editor.view.dom;
|
||||||
if (!this.isSourceMode) {
|
if (!this.isSourceMode) {
|
||||||
// 富文本 → 源码:导出当前 Markdown 到 textarea
|
// 富文本 → 源码:导出当前 Markdown 到 textarea
|
||||||
this.sourceTextarea.value = this.editor.getMarkdown();
|
this.sourceTextarea.value = TiptapEditorInstance.unescapeFootnoteSyntax(this.editor.getMarkdown());
|
||||||
// 必须在 display:'none' 之前读取滚动比例——隐藏后 scrollTop 会被浏览器归零
|
// 必须在 display:'none' 之前读取滚动比例——隐藏后 scrollTop 会被浏览器归零
|
||||||
const pmRatio = this.getScrollRatio(proseMirrorDom);
|
const pmRatio = this.getScrollRatio(proseMirrorDom);
|
||||||
proseMirrorDom.style.display = 'none';
|
proseMirrorDom.style.display = 'none';
|
||||||
|
|||||||
@ -79,7 +79,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
|||||||
// 无需 mut:本组件只读取(L254)与传递(L452),写入都在 CoverUploader 内部,
|
// 无需 mut:本组件只读取(L254)与传递(L452),写入都在 CoverUploader 内部,
|
||||||
// 而 Dioxus Signal 是 Copy 类型,.set() 不要求 mut 绑定。
|
// 而 Dioxus Signal 是 Copy 类型,.set() 不要求 mut 绑定。
|
||||||
let cover_uploading = use_signal(|| false);
|
let cover_uploading = use_signal(|| false);
|
||||||
let mut status = use_signal(|| "draft".to_string());
|
let mut status = use_signal(|| "published".to_string());
|
||||||
let mut content = use_signal(|| "".to_string());
|
let mut content = use_signal(|| "".to_string());
|
||||||
// 页面与编辑器加载、保存、错误、成功等状态。
|
// 页面与编辑器加载、保存、错误、成功等状态。
|
||||||
let mut loading = use_signal(|| true);
|
let mut loading = use_signal(|| true);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user