From 056db1ce085881a94f554a5710e8337ae2bb1e31 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 28 Jun 2026 16:55:23 +0200 Subject: [PATCH] audit: cycle 0001 (walking skeleton) close Architect drift review (0060fea..HEAD) against the five domain invariants and the cycle spec. What holds: read-only Gitea access (the Fetcher exposes no write path); whitelist + traversal refusal (config::resolve, e2e-pinned); the single docs-root join shared by serve and check, so check validates exactly what serve fetches. Drift resolved in iter 0001.tidy (preceding commit): the missing registry.toml, the cache-before-fetch data-flow, and the unescaped registry slug/page. Ratified (carry-on, not fixed this cycle, each tracked as a follow-up issue): - The render directive-expansion seam is conceptual (a comment), not an AST walk step. For the walking skeleton (code + table are native Markdown) a no-op seam needs no code; it materialises when code-include lands. - GiteaClient's 404->NotFound status mapping is covered by the gated live smoke's happy path, not a unit test of the 404 branch. No regression scripts are configured (CLAUDE.md project facts), so the architect is the sole drift gate; its What holds is non-empty (review confirmed). Retires the cycle's spec and plan per the lifecycle convention. closes #1 --- docs/plans/0001-walking-skeleton.md | 1117 --------------------------- docs/specs/0001-walking-skeleton.md | 258 ------- 2 files changed, 1375 deletions(-) delete mode 100644 docs/plans/0001-walking-skeleton.md delete mode 100644 docs/specs/0001-walking-skeleton.md diff --git a/docs/plans/0001-walking-skeleton.md b/docs/plans/0001-walking-skeleton.md deleted file mode 100644 index 73e468f..0000000 --- a/docs/plans/0001-walking-skeleton.md +++ /dev/null @@ -1,1117 +0,0 @@ -# Walking Skeleton — Implementation Plan - -> **Parent spec:** `docs/specs/0001-walking-skeleton.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to -> run this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** A `docsite serve` HTTP server plus a `docsite check` subcommand -that render aura + data-server docs live from Gitea `main`, dark-themed, -navigated from a DocSite-owned whitelist registry, with `code` + `table` -directives. - -**Architecture:** Single crate with a lib (`src/lib.rs`, modules below) and -a thin bin (`src/main.rs`, subcommand dispatch). Source is fetched read-only -through a `Fetcher` trait — `GiteaClient` (blocking reqwest) in production, -`MockFetcher` in tests — so tests run offline. Render is comrak (GFM) + -syntect; the themed shell wraps it; an in-memory `(repo,path,sha)` cache -keys on the source revision. `docsite check` and a boot-time pass validate -every nav reference against the live sources. - -**Tech Stack:** Rust 2021, axum + tokio (server), reqwest blocking (Gitea), -comrak + syntect (render), serde + toml (config). lib+bin split; theme.css -embedded via `include_str!`. - -**Derived layout decisions (plan-recon open questions):** (1) lib+bin split -— required for `tests/` integration tests; (2) theme.css embedded via -`include_str!` — self-contained binary; (3) dependency versions via -`cargo add` — current compatible versions, not pinned by guess. - -**Module registration (ordering contract):** `src/lib.rs` starts with NO -module declarations. Each component task below FIRST appends its own -`pub mod ;` line to `src/lib.rs`, so the library compiles after every -task — a per-task `cargo test --lib ::` gate would otherwise fail on a -lib that declares a not-yet-created module. `src/main.rs` stays a standalone -dispatch stub until Task 9 wires it to `server::run` / `check::run_cli`. Task -order respects the dependency graph: config, gitea, render, theme, cache -(independent or config-only), then check (config+gitea), then server (all). - ---- - -### Task 1: Scaffold — dependencies + lib/bin split - -**Files:** -- Modify: `Cargo.toml:7-9` (populate `[dependencies]`) -- Create: `src/lib.rs` -- Modify: `src/main.rs:9-11` (subcommand dispatch stub) - -- [ ] **Step 1: Add dependencies** - -Run: -``` -cargo add axum tokio --features tokio/rt-multi-thread,tokio/macros,tokio/net -cargo add reqwest --no-default-features --features blocking,rustls-tls -cargo add comrak syntect serde --features serde/derive -cargo add toml -``` -Expected: each prints `Adding v to dependencies`; `cargo` resolves -without error. (LAN Gitea is plain HTTP, but reqwest still needs a TLS -backend to build; rustls avoids a system-openssl dependency.) - -- [ ] **Step 2: Create the lib crate root** - -Create `src/lib.rs` (no module declarations yet — each component task appends -its own `pub mod` line, per the ordering contract above): -```rust -//! DocSite library crate — the render/serve/check machinery behind the -//! `docsite` binary. Split out as a lib so the integration tests under -//! `tests/` can drive it directly with an injected `Fetcher`. Module -//! declarations are appended by the tasks that create each module, so the -//! lib compiles after every task. -``` - -- [ ] **Step 3: Replace the main stub with subcommand dispatch** - -Replace `src/main.rs` entirely: -```rust -//! `docsite` — a single-source-of-truth LAN documentation server. -//! Subcommands: `serve` (run the HTTP server) and `check` (validate every -//! configured nav reference against the live Gitea sources, exit-coded). - -use std::process::ExitCode; - -fn main() -> ExitCode { - let args: Vec = std::env::args().collect(); - match args.get(1).map(String::as_str) { - // server::run / check::run_cli are wired in Task 9, once those modules - // exist; until then the dispatch shape compiles as a standalone stub. - Some("serve") => { eprintln!("docsite: serve not yet wired"); ExitCode::from(70) } - Some("check") => { eprintln!("docsite: check not yet wired"); ExitCode::from(70) } - _ => { - eprintln!("usage: docsite "); - ExitCode::from(2) - } - } -} -``` - -- [ ] **Step 4: Build gate** - -Run: `cargo build` -Expected: PASS — the scaffold compiles: the lib has no modules yet and main's -dispatch is a standalone stub referencing nothing unbuilt. The real -serve/check wiring lands in Task 9. - ---- - -### Task 2: config — registry model, parse, lookup, whitelist - -**Files:** -- Create: `src/config/mod.rs` -- Create: `src/config/registry.rs` -- Test: in-module `#[cfg(test)]` in `src/config/mod.rs` - -- [ ] **Step 1: Write the serde model** - -Create `src/config/registry.rs`: -```rust -//! The DocSite-owned `registry.toml` model: the publish whitelist + nav. -//! NO `deny_unknown_fields` — unknown keys are tolerated so the schema can -//! grow (forward-compatible). - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct Registry { - pub bind: String, - pub gitea: String, - #[serde(default)] - pub project: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct Project { - pub slug: String, - pub repo: String, - pub branch: String, - pub docs: String, - #[serde(default)] - pub section: Vec
, -} - -#[derive(Debug, Deserialize)] -pub struct Section { - pub title: String, - pub pages: Vec, -} - -/// A nav page resolved to its source location: the project plus the page -/// path relative to the project's `docs` root. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PageRef { - pub slug: String, - pub rel: String, -} -``` - -- [ ] **Step 2: Write the failing test for load + lookup + whitelist** - -Create `src/config/mod.rs`: -```rust -pub mod registry; -pub use registry::{PageRef, Project, Registry, Section}; - -use std::path::Path; - -/// Parse a `registry.toml` from disk. -pub fn load(path: impl AsRef) -> Result { - let text = std::fs::read_to_string(path).map_err(|e| format!("registry: {e}"))?; - toml::from_str(&text).map_err(|e| format!("registry parse: {e}")) -} - -impl Registry { - pub fn project(&self, slug: &str) -> Option<&Project> { - self.project.iter().find(|p| p.slug == slug) - } - - /// Resolve a served url path (`/`) to a `PageRef`, - /// IFF that page is listed in some section of the project (whitelist). - /// `url_page` is the path after the slug, without a trailing `.md` - /// (e.g. `design/INDEX`). A `..` component is refused (traversal). - pub fn resolve(&self, slug: &str, url_page: &str) -> Option { - if url_page.split('/').any(|c| c == ".." || c.is_empty()) { - return None; - } - let proj = self.project(slug)?; - let want = format!("{url_page}.md"); - let listed = proj - .section - .iter() - .flat_map(|s| &s.pages) - .any(|p| p == &want); - listed.then(|| PageRef { slug: slug.to_string(), rel: want }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Registry { - toml::from_str( - r#" -bind = "0.0.0.0:8080" -gitea = "http://gitea.test" -[[project]] -slug = "aura" -repo = "Brummel/aura" -branch = "main" -docs = "docs" -[[project.section]] -title = "Design" -pages = ["design/INDEX.md"] -"#, - ) - .expect("sample registry parses") - } - - #[test] - fn resolves_a_listed_page() { - let r = sample(); - assert_eq!( - r.resolve("aura", "design/INDEX"), - Some(PageRef { slug: "aura".into(), rel: "design/INDEX.md".into() }) - ); - } - - #[test] - fn refuses_an_unlisted_page() { - // glossary is not in any section -> not reachable (whitelist) - assert_eq!(sample().resolve("aura", "glossary"), None); - } - - #[test] - fn refuses_traversal() { - assert_eq!(sample().resolve("aura", "../secret"), None); - } - - #[test] - fn refuses_unknown_slug() { - assert_eq!(sample().resolve("nope", "design/INDEX"), None); - } - - #[test] - fn tolerates_unknown_keys() { - // a future key must not break parsing (forward-compatible) - let r: Result = - toml::from_str("bind=\"x\"\ngitea=\"y\"\nfuture_key=\"z\"\n"); - assert!(r.is_ok(), "unknown top-level key must be tolerated"); - } -} -``` - -- [ ] **Step 3: Run the tests to verify they pass** - -Run: `cargo test --lib config::` -Expected: PASS — 5 tests in `config::tests`. - ---- - -### Task 3: gitea — Fetcher trait, GiteaError, client + mock - -**Files:** -- Create: `src/gitea/mod.rs` -- Create: `src/gitea/client.rs` -- Test: in-module `#[cfg(test)]` in `src/gitea/mod.rs` - -- [ ] **Step 1: Write the trait, error, and mock** - -Create `src/gitea/mod.rs`: -```rust -pub mod client; -pub use client::GiteaClient; - -use std::collections::HashMap; - -/// A read-only source fetcher. Injected so tests run offline. SYNC by design -/// — the production impl uses blocking reqwest; axum handlers call it inside -/// `spawn_blocking`. -pub trait Fetcher: Send + Sync { - /// The current commit SHA of `branch` in `repo` (the cache key source). - fn branch_sha(&self, repo: &str, branch: &str) -> Result; - /// The raw bytes of `path` at `branch` in `repo`. - fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result; -} - -/// `NotFound` is the broken-reference signal — distinct from a transport -/// failure, so a renamed source and an unreachable Gitea render differently. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum GiteaError { - NotFound { repo: String, path: String }, - Transport(String), -} - -/// In-memory fetcher for tests: `(repo, path)` -> body, plus a branch SHA. -pub struct MockFetcher { - pub sha: String, - pub files: HashMap<(String, String), String>, -} - -impl MockFetcher { - pub fn new(sha: &str) -> Self { - Self { sha: sha.to_string(), files: HashMap::new() } - } - pub fn with(mut self, repo: &str, path: &str, body: &str) -> Self { - self.files.insert((repo.to_string(), path.to_string()), body.to_string()); - self - } -} - -impl Fetcher for MockFetcher { - fn branch_sha(&self, _repo: &str, _branch: &str) -> Result { - Ok(self.sha.clone()) - } - fn fetch_raw(&self, repo: &str, _branch: &str, path: &str) -> Result { - self.files - .get(&(repo.to_string(), path.to_string())) - .cloned() - .ok_or_else(|| GiteaError::NotFound { repo: repo.to_string(), path: path.to_string() }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn mock() -> MockFetcher { - MockFetcher::new("a517899").with("Brummel/aura", "docs/glossary.md", "# Glossary\n") - } - - #[test] - fn fetch_hit_returns_body() { - assert_eq!(mock().fetch_raw("Brummel/aura", "main", "docs/glossary.md").unwrap(), "# Glossary\n"); - } - - #[test] - fn fetch_miss_is_notfound() { - let err = mock().fetch_raw("Brummel/aura", "main", "docs/gone.md").unwrap_err(); - assert!(matches!(err, GiteaError::NotFound { .. }), "miss must be NotFound, got {err:?}"); - } - - #[test] - fn branch_sha_returns_pinned() { - assert_eq!(mock().branch_sha("Brummel/aura", "main").unwrap(), "a517899"); - } -} -``` - -- [ ] **Step 2: Run mock tests** - -Run: `cargo test --lib gitea::` -Expected: PASS — 3 tests. - -- [ ] **Step 3: Write the production GiteaClient** - -Create `src/gitea/client.rs`: -```rust -//! Read-only Gitea source client (blocking reqwest). Resolves a branch SHA -//! via the API and fetches raw file bytes via the raw endpoint. Token from -//! `$DOCSITE_GITEA_TOKEN` for private repos. - -use super::{Fetcher, GiteaError}; - -pub struct GiteaClient { - base: String, - token: Option, - http: reqwest::blocking::Client, -} - -impl GiteaClient { - pub fn new(base: &str) -> Self { - Self { - base: base.trim_end_matches('/').to_string(), - token: std::env::var("DOCSITE_GITEA_TOKEN").ok(), - http: reqwest::blocking::Client::new(), - } - } - - fn get(&self, url: &str) -> Result { - let mut req = self.http.get(url); - if let Some(t) = &self.token { - req = req.header("Authorization", format!("token {t}")); - } - req.send().map_err(|e| GiteaError::Transport(e.to_string())) - } -} - -impl Fetcher for GiteaClient { - fn branch_sha(&self, repo: &str, branch: &str) -> Result { - // GET {base}/api/v1/repos/{repo}/branches/{branch} -> { "commit": { "id": "" } } - let url = format!("{}/api/v1/repos/{repo}/branches/{branch}", self.base); - let resp = self.get(&url)?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err(GiteaError::NotFound { repo: repo.into(), path: format!("branch:{branch}") }); - } - let body = resp.text().map_err(|e| GiteaError::Transport(e.to_string()))?; - // minimal extraction without a json dep on the model: the API returns - // {"name":...,"commit":{"id":"",...}} - let key = "\"id\":\""; - let start = body.find(key).ok_or_else(|| GiteaError::Transport("no commit id".into()))? + key.len(); - let end = body[start..].find('"').ok_or_else(|| GiteaError::Transport("unterminated id".into()))?; - Ok(body[start..start + end].to_string()) - } - - fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result { - // GET {base}/{repo}/raw/branch/{branch}/{path} - let url = format!("{}/{repo}/raw/branch/{branch}/{path}", self.base); - let resp = self.get(&url)?; - if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err(GiteaError::NotFound { repo: repo.into(), path: path.into() }); - } - if !resp.status().is_success() { - return Err(GiteaError::Transport(format!("status {}", resp.status()))); - } - resp.text().map_err(|e| GiteaError::Transport(e.to_string())) - } -} -``` - -- [ ] **Step 4: Build gate** - -Run: `cargo build` -Expected: PASS — the client compiles (no test asserts the live endpoints; -those are exercised by the gated live smoke in Task 9). - ---- - -### Task 4: render — comrak (GFM) + syntect - -**Files:** -- Create: `src/render/mod.rs` -- Test: in-module `#[cfg(test)]` - -- [ ] **Step 1: Write the failing render test** - -Create `src/render/mod.rs`: -```rust -//! Markdown -> HTML render: comrak (GitHub-flavored, tables on) with syntect -//! highlighting of code fences (dark theme). `render_doc` is pure. A no-op -//! directive-expansion seam is reserved here (invariant 4) for `code-include` -//! in a later iteration; `code` and `table` are native Markdown. - -use comrak::plugins::syntect::SyntectAdapter; -use comrak::{markdown_to_html_with_plugins, Options, Plugins}; - -/// Render GFM markdown to an HTML fragment (no / wrapper). -pub fn render_doc(markdown: &str) -> String { - let mut options = Options::default(); - options.extension.table = true; - options.extension.strikethrough = true; - options.extension.autolink = true; - - let adapter = SyntectAdapter::new(Some("base16-ocean.dark")); - let mut plugins = Plugins::default(); - plugins.render.codefence_syntax_highlighter = Some(&adapter); - - markdown_to_html_with_plugins(markdown, &options, &plugins) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn renders_a_table() { - let html = render_doc("| a | b |\n|---|---|\n| 1 | 2 |\n"); - assert!(html.contains(""), "GFM table not rendered: {html}"); - assert!(html.contains(""), "table cell missing: {html}"); - } - - #[test] - fn highlights_a_code_fence() { - let html = render_doc("```rust\nfn main() {}\n```\n"); - // syntect emits inline-styled spans inside a
-        assert!(html.contains(" for the fence: {html}");
-        assert!(html.contains("style=\"color"), "syntect did not style the fence: {html}");
-    }
-
-    #[test]
-    fn unknown_language_falls_back_without_panic() {
-        let html = render_doc("```nosuchlang\nplain text\n```\n");
-        assert!(html.contains(": {html}");
-    }
-}
-```
-
-- [ ] **Step 2: Run render tests**
-
-Run: `cargo test --lib render::`
-Expected: PASS — 3 tests. (If the syntect adapter constructor signature
-differs in the resolved comrak version, adjust the `SyntectAdapter::new`
-call to the resolved API; the assertion contract — `
1
`, styled `
`,
-no panic on unknown lang — is fixed.)
-
----
-
-### Task 5: theme — shell + embedded stylesheet + page_html
-
-**Files:**
-- Create: `src/theme/mod.rs`
-- Create: `assets/theme.css`
-- Test: in-module `#[cfg(test)]`
-
-- [ ] **Step 1: Write the dark stylesheet**
-
-Create `assets/theme.css`:
-```css
-:root {
-  --bg: #16161a; --fg: #cdd6f4; --dim: #6c7086; --link: #89b4fa;
-  --panel: #1e1e2e; --border: #313244; --accent: #f5e0dc;
-}
-html, body { margin: 0; background: var(--bg); color: var(--fg);
-  font-family: system-ui, sans-serif; line-height: 1.6; }
-header.crumb { padding: 10px 16px; border-bottom: 1px solid var(--border);
-  font-size: 13px; color: var(--dim); }
-header.crumb a { color: var(--link); text-decoration: none; }
-nav { padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 14px; }
-nav .sec { color: var(--dim); margin: 8px 0 2px; text-transform: uppercase; font-size: 12px; }
-nav a { display: block; color: var(--link); text-decoration: none; padding: 1px 0; }
-main.doc { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
-main.doc code, main.doc pre { font-family: ui-monospace, monospace; }
-main.doc pre { background: var(--panel); border: 1px solid var(--border);
-  border-radius: 6px; padding: 12px; overflow-x: auto; }
-main.doc table { border-collapse: collapse; margin: 12px 0; }
-main.doc th, main.doc td { border: 1px solid var(--border); padding: 6px 10px; }
-main.doc a { color: var(--link); }
-.docsite-error { background: #3a1e26; border: 1px solid #f38ba8; color: #f38ba8;
-  border-radius: 6px; padding: 12px; margin: 12px 0; font-family: ui-monospace, monospace; }
-```
-
-- [ ] **Step 2: Write the shell + nav builder with a failing test**
-
-Create `src/theme/mod.rs`:
-```rust
-//! The themed HTML shell: header breadcrumb, nav built from the registry,
-//! and the rendered doc body. The stylesheet is embedded (served at
-//! /assets/theme.css by the server module) so the binary is self-contained.
-
-use crate::config::{Project, Registry};
-
-pub const THEME_CSS: &str = include_str!("../../assets/theme.css");
-
-/// Build the nav `
"), "table not rendered"); - assert!(body.contains(" visible error block - let mut s = state(); - let r: Registry = toml::from_str( - "bind=\"x\"\ngitea=\"y\"\n[[project]]\nslug=\"aura\"\nrepo=\"Brummel/aura\"\nbranch=\"main\"\ndocs=\"docs\"\n[[project.section]]\ntitle=\"D\"\npages=[\"gone.md\"]\n", - ).unwrap(); - s.reg = Arc::new(r); - let (status, body) = get(router(s), "/aura/gone").await; - assert_eq!(status, StatusCode::OK, "broken ref is content, not a server fault"); - assert!(body.contains("docsite-error"), "no fail-loud error block: {body}"); - assert!(body.contains("broken reference"), "error block text missing"); -} -``` - -- [ ] **Step 3: Add the test-only dev-dependency** - -Run: `cargo add --dev tower --features util` -Expected: `tower` dev-dep added (for `tower::ServiceExt::oneshot`). The -`#[tokio::test]` macro is already covered by the regular `tokio` dependency -(it carries `macros` + `rt-multi-thread`). - -- [ ] **Step 4: Run the e2e suite** - -Run: `cargo test --test e2e` -Expected: PASS — 4 tests (render, 404, traversal, fail-loud block). - ---- - -### Task 9: main wiring + gated live smoke - -**Files:** -- Modify: `src/main.rs` (replace the stub arms with the real entries) -- Test: `tests/live_smoke.rs` - -- [ ] **Step 1: Wire main.rs to the real entries** - -Replace the two stub arms in `src/main.rs`'s match with: -```rust - Some("serve") => docsite::server::run("registry.toml"), - Some("check") => docsite::check::run_cli("registry.toml"), -``` -Both modules now exist (Task 8 created `server`, Task 7 created `check`), and -both return `std::process::ExitCode`, matching `main`'s return type. - -- [ ] **Step 2: Full build + whole suite gate** - -Run: `cargo build && cargo test` -Expected: PASS — `docsite serve` / `docsite check` dispatch compiles; all lib -+ e2e tests green. - -- [ ] **Step 3: Write the gated live smoke test** - -Create `tests/live_smoke.rs`: -```rust -//! Gated smoke test against the real Gitea. Skips cleanly when Gitea is -//! unreachable (the project's skip-on-no-dependency convention) so it never -//! fails on an offline machine. - -use docsite::gitea::{Fetcher, GiteaClient}; - -#[test] -fn aura_glossary_is_fetchable_from_live_gitea() { - let client = GiteaClient::new("http://192.168.178.103:3000"); - match client.fetch_raw("Brummel/aura", "main", "docs/glossary.md") { - Ok(body) => assert!(!body.is_empty(), "live glossary should be non-empty"), - Err(e) => { - eprintln!("skip: live Gitea unreachable or auth-gated: {e:?}"); - } - } -} -``` - -- [ ] **Step 4: Run the live smoke (non-fatal)** - -Run: `cargo test --test live_smoke -- --nocapture` -Expected: PASS — either the assertion holds (Gitea reachable) or it prints -`skip:` and passes (unreachable/auth-gated). Never fails the suite. - -- [ ] **Step 5: Manual serve sanity (optional, recorded not gated)** - -Run: `DOCSITE_GITEA_TOKEN= cargo run -- serve` then -`curl -s localhost:8080/aura/design/INDEX | head -3` -Expected: the themed HTML shell for the aura design ledger. (A manual check -the orchestrator records; not part of `cargo test`.) diff --git a/docs/specs/0001-walking-skeleton.md b/docs/specs/0001-walking-skeleton.md deleted file mode 100644 index 8c987b1..0000000 --- a/docs/specs/0001-walking-skeleton.md +++ /dev/null @@ -1,258 +0,0 @@ -# Walking Skeleton — Design Spec - -**Date:** 2026-06-28 -**Status:** Draft — awaiting sign-off (under `/boss`: the Step-5 grounding-check PASS) -**Authors:** orchestrator + Claude - -## Goal - -The first runnable DocSite server: render the published docs of `aura` and -`data-server` live from their canonical Gitea repositories (pushed `main`), -wrapped in one consistent dark theme, navigated from a DocSite-owned -whitelist. This is the minimal end-to-end proof of the single-source -live-render pipeline — content stays once on the Gitea host, DocSite holds -only a revision-keyed cache, and it never writes to a source repo. - -Two render directives are in scope: `code` (syntax-highlighted fences) and -`table` (native Markdown). `code-include`, `mermaid`, and chart/graph -embedding are explicitly out of scope (later iterations; embedding is gated -on `Brummel/Aura#150`). - -## Architecture - -A single Rust binary `docsite` with two subcommands: - -- `docsite serve` — runs the axum HTTP server. -- `docsite check` — validates every configured navigation reference against - the current Gitea sources and exits non-zero if any is broken. - -Layers: - -- **Config** (`registry.toml`, DocSite-owned) — lists the published - projects, each with its Gitea coordinates (`repo`, `branch`, `docs`) and - an ordered set of nav sections (the publish whitelist). -- **Gitea source client** — read-only. Resolves a branch's current commit - SHA, then fetches a file's raw bytes at that revision. Authenticated with - a read-only token from config/env for private repos. -- **Render** — comrak (GitHub-flavored Markdown, tables on) → syntect - highlighting of code fences (dark theme) → HTML body. -- **Theme** — one HTML shell (header breadcrumb · nav from registry · - content) plus a static stylesheet served at `/assets/theme.css`. -- **Cache** — keyed on `(repo, path, commit_sha)`; a rendered page is a - pure function of the source bytes at a revision, so a cache hit is - byte-identical and revision-exact. - -The render pipeline exposes a directive-expansion seam (an AST walk step) -that is a no-op in this iteration — `code` and `table` are native Markdown -— so the closed-vocabulary handler registry (invariant 4) attaches here -when `code-include` lands, without reshaping the pipeline. - -## Concrete code shapes - -### The user-facing artifact: `registry.toml` - -This is the configuration the maintainer (the AI) writes; it *is* the -acceptance evidence — adding a project to DocSite is editing this file, no -code change. - -```toml -# DocSite/registry.toml -bind = "0.0.0.0:8080" -gitea = "http://192.168.178.103:3000" # Gitea base URL -# read-only token resolved from $DOCSITE_GITEA_TOKEN (never stored here) - -[[project]] -slug = "aura" -repo = "Brummel/aura" -branch = "main" -docs = "docs" # doc-root within the repo - - [[project.section]] - title = "Concepts" - pages = ["glossary.md"] - - [[project.section]] - title = "Design & Architecture" - pages = ["design/INDEX.md"] # project-layout.md is obsolete — omitted - -[[project]] -slug = "data-server" -repo = "Brummel/data-server" -branch = "main" -docs = "." # README at repo root - - [[project.section]] - title = "Reference" - pages = ["README.md"] -``` - -### CLI invocations - -```console -$ DOCSITE_GITEA_TOKEN= docsite serve -docsite: serving 2 projects on 0.0.0.0:8080 -docsite: validated 3 nav references against Gitea main - -$ docsite check -aura/glossary.md ok (main @ a1b2c3d) -aura/design/INDEX.md ok (main @ a1b2c3d) -data-server/README.md ok (main @ e4f5a6b) -3 references ok, 0 broken -$ echo $? -0 -``` - -A broken reference (a source file renamed/removed upstream) is the -fail-loud case: - -```console -$ docsite check -aura/glossary.md ok (main @ a1b2c3d) -aura/design/INDEX.md BROKEN — not found at Brummel/aura main:docs/design/INDEX.md -data-server/README.md ok (main @ e4f5a6b) -2 references ok, 1 broken -$ echo $? -1 -``` - -### Request → response (the served page) - -```console -$ curl -s http://192.168.178.103:8080/aura/design/INDEX | head - - -… - -
docsite / aura / Design Ledger
- -
…rendered Markdown: prose, syntect-highlighted code, tables…
-``` - -### Implementation shapes (secondary) - -The config model and the core functions — shapes only; exact bytes are the -planner's: - -```rust -// config/registry.rs — serde, NO deny_unknown_fields (forward-compatible) -struct Registry { bind: String, gitea: String, project: Vec } -struct Project { slug: String, repo: String, branch: String, docs: String, - section: Vec
} -struct Section { title: String, pages: Vec } - -// A page reference resolved to its Gitea location. -struct PageRef { slug: String, rel: String } // rel is relative to project.docs - -// gitea/client.rs — read-only -fn branch_sha(repo: &str, branch: &str) -> Result; -fn fetch_raw(repo: &str, branch: &str, path: &str) -> Result; -// GiteaError::NotFound is the broken-reference signal (distinct from transport errors) - -// render/mod.rs -fn render_doc(markdown: &str) -> String; // comrak(GFM) + syntect; pure -fn page_html(reg: &Registry, page: &PageRef, body: &str) -> String; // theme shell + nav - -// check.rs — the reference validator behind `docsite check` -fn check_all(reg: &Registry) -> CheckReport; // exit code = (broken == 0) ? 0 : 1 -``` - -## Components - -- **`config`** — load + parse `registry.toml`; build the slug→project and - (slug, url-path)→PageRef lookup. A url path not present in any section is - not a `PageRef` — it cannot be served (whitelist). -- **`gitea`** — `branch_sha` (one call per repo per request cycle, itself - briefly cacheable) and `fetch_raw` at that revision; read-only token in - the `Authorization` header. `NotFound` is modelled distinctly from a - transport failure so a broken reference and an unreachable Gitea render - differently. -- **`render`** — `render_doc` (pure: comrak GFM with tables, syntect dark - theme on fences, unknown language → plain fenced fallback) and - `page_html` (wrap in the theme shell, inject nav from the registry). -- **`theme`** — the shell template + `theme.css` (dark background, - Catppuccin accents, prose in a reading face, code/tables monospace), - served statically. -- **`server`** — axum router: `GET /` (project index from registry), - `GET /:slug/*page`, `GET /assets/theme.css`. -- **`cache`** — `(repo, path, sha)` → rendered HTML; invalidated implicitly - by a new `sha`. -- **`check`** — `check_all` drives `docsite check` and the startup - validation pass. - -## Data flow - -``` -GET /aura/design/INDEX - -> config: slug=aura -> project; url-path "design/INDEX" in a section? - no -> 404 (whitelist) - yes -> PageRef{ slug:"aura", rel:"design/INDEX.md" } - -> gitea.branch_sha("Brummel/aura","main") -> sha - -> cache.get((repo, "docs/design/INDEX.md", sha)) - hit -> serve - miss -> gitea.fetch_raw(repo,"main","docs/design/INDEX.md") - NotFound -> visible error block (fail-loud), log - ok -> render_doc -> page_html(nav) -> cache.put -> serve -GET /assets/theme.css -> static -GET / -> project index from registry -``` - -Startup (`serve`) runs `check_all` once and logs any broken reference -without refusing to boot (a single broken page must not down the whole -site); `docsite check` is the same validation as a standalone exit-coded -command for a hook/cron. - -## Error handling - -- Unknown slug, or a url-path absent from every section → **404** - (whitelist; `docs/specs`/`docs/plans`/postmortems are unreachable by - construction). -- A page path that escapes the project's `docs` root (`..`, absolute) → - **refused** before any fetch. -- `fetch_raw` → `NotFound` (source renamed/removed) → a **visible - in-page error block** naming the missing `repo main:path`, never silent - emptiness; logged. The page returns 200 with the error block (the broken - reference is content, not a server fault) — but `docsite check` reports - it as broken with a non-zero exit. -- Gitea unreachable / transport error → **502** with a clear message - (distinct from `NotFound`). -- syntect unknown language → **plain fenced fallback**, no failure. -- `registry.toml` malformed → refuse to start with a precise parse error. - -## Testing strategy - -- **Hermetic by default.** The Gitea client is fed by an injected fetcher - (a trait or fn pointer) so tests run against in-repo fixtures, not the - live Gitea or the foreign repos — tests stay deterministic and offline. -- **E2E render:** a fixture project + fixture Markdown (prose + a code - fence + a table) → assert the served HTML carries the theme shell, the - nav from the registry, syntect-classed code, and table markup. -- **Whitelist negative:** a path not in any section (e.g. a `docs/specs/…` - name) → 404; never leaked. -- **Traversal:** a `..`-bearing path → refused (no fetch attempted). -- **Reference check:** `check_all` over a registry whose fixture fetcher - reports one `NotFound` → report lists it broken, exit code 1; all-ok → - exit 0. -- **Determinism / cache:** same `(repo,path,sha)` → byte-identical output; - a changed `sha` → re-render. -- **Gated live smoke:** one test hitting the real Gitea for - `aura/glossary.md`, skipped cleanly when Gitea is unreachable (the - project's skip-on-no-dependency convention). - -## Acceptance criteria - -Per DocSite's default feature-acceptance criterion (solves a real -user-facing problem; contradicts no stated design commitment): the -maintainer publishes a project's docs by editing `registry.toml` alone -(shown above) — no code change — and the served pages honor all five domain -invariants. Concretely: - -- [ ] `docsite serve` renders `aura/glossary`, `aura/design/INDEX`, and - `data-server/README` live from Gitea `main`, each in the theme shell with - nav from `registry.toml`. -- [ ] An unlisted path (a `docs/specs` file) → 404; never served. -- [ ] A `..`-bearing path → refused. -- [ ] `docsite check` validates the configured nav references against the - current Gitea sources, reporting broken ones with a non-zero exit. -- [ ] Same source revision → byte-identical rendered output. -- [ ] DocSite performs no write against any source repo (read-only Gitea - access only).