# Standalone Documentation Export Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Produce one self-contained HTML file — from a `dbx docs` CLI verb and from a button in the app — that renders a database's documentation from `file://` on a machine with no DBX installed. **Architecture:** The existing pure viewer under `apps/desktop/src/docs/` is bundled by a dedicated Vite entry into one JS and one CSS file, committed into `crates/dbx-core/assets/` and embedded with `include_str!`. A Rust function `to_standalone_html` base64-encodes the snapshot payload and emits the HTML; the CLI and a Tauri command both call it. A build-derived manifest makes the committed artefact unable to drift from its sources without a test failing. **Tech Stack:** Rust (dbx-core, dbx-cli, Tauri), Vue 3.5 `".into()), }); let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); assert!(!html.contains("").count(), 2, "exactly the two real script elements"); } #[test] fn the_payload_round_trips() { let (snapshot, annotations) = fixture(); let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); let start = html.find("application/dbx-snapshot").expect("payload element"); let body = &html[start..]; let encoded = body[body.find('>').unwrap() + 1..body.find("").unwrap()].trim(); let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).expect("valid base64"); let value: serde_json::Value = serde_json::from_slice(&decoded).expect("valid json"); assert_eq!(value["snapshot"]["project"]["name"], "shop"); assert_eq!(value["lang"], "en"); } #[test] fn nothing_is_loaded_from_the_network() { // The whole point of the artefact. A single absolute URL left in the // CSS means the file renders unstyled on a machine with no network, // and nothing else would catch it. let (snapshot, annotations) = fixture(); let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); for needle in ["http://", "https://", "url(/", "src=\"/", "href=\"/"] { assert!(!html.contains(needle), "export references {needle}"); } } #[test] fn an_unknown_language_is_rejected() { let (snapshot, annotations) = fixture(); let error = to_standalone_html(&snapshot, &annotations, "kl").expect_err("should reject"); assert!(error.contains("kl"), "got: {error}"); assert!(error.contains("en"), "the error must list the valid locales, got: {error}"); } } ``` - [ ] **Step 2: Run and watch it fail** Run: `cargo test -p dbx-core --lib docs::export` Expected: FAIL to compile — `cannot find function to_standalone_html in this scope`. - [ ] **Step 3: Implement `to_standalone_html`** Above the test module in the same file: ```rust use base64::Engine as _; use crate::docs::annotations::AnnotationFile; use crate::docs::snapshot::SchemaSnapshot; /// The viewer bundle, built by `pnpm build:docs-export` and committed. /// /// Committed rather than built by cargo because Rust cannot run Vite and /// `dbx docs` must work from a plain `cargo install` on a machine with no /// Node. `docs_export_bundle_is_current` is what keeps it honest. const EXPORT_JS: &str = include_str!("../../assets/docs-export.js"); const EXPORT_CSS: &str = include_str!("../../assets/docs-export.css"); pub const EXPORT_LANGUAGES: [&str; 8] = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"]; /// Render a snapshot as one self-contained HTML file. /// /// `snapshot` must already have annotations applied — `apply_annotations` is /// Rust and the export has no Rust at runtime. `annotations` travels too, /// because the merge erases what the viewer needs to colour groups: /// `snapshot.groups` holds resolved `TableGroup`s, `annotations.groups` holds /// the hue. pub fn to_standalone_html(snapshot: &SchemaSnapshot, annotations: &AnnotationFile, lang: &str) -> Result { if !EXPORT_LANGUAGES.contains(&lang) { return Err(format!("Unknown language \"{lang}\". Valid values: {}.", EXPORT_LANGUAGES.join(", "))); } let payload = serde_json::json!({ "snapshot": snapshot, "annotations": annotations, "lang": lang }); let json = serde_json::to_vec(&payload).map_err(|error| format!("Failed to serialise the documentation payload: {error}"))?; // base64 rather than escaped JSON: the alphabet cannot contain `<`, so no // escaping rule exists to forget. The alternative's correctness depends // on every serialisation path applying the escape. let encoded = base64::engine::general_purpose::STANDARD.encode(&json); let title = html_escape(&snapshot.project.name); Ok(format!( "\n\n\n\n\n{title}\n\n\n\n
\n\n\n\n\n" )) } fn html_escape(value: &str) -> String { value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } ``` Add to `crates/dbx-core/src/docs/mod.rs`: ```rust pub mod export; ``` and to the re-export list: ```rust pub use export::{to_standalone_html, EXPORT_LANGUAGES}; ``` - [ ] **Step 4: Run the tests** Run: `cargo test -p dbx-core --lib docs::export` Expected: PASS (4 tests). - [ ] **Step 5: Deliberate break** Replace the base64 encode with `String::from_utf8(json).unwrap()`. Run the tests; confirm `a_note_containing_a_closing_script_tag_survives` fails with `the payload leaked into markup`. Restore. Report the message. - [ ] **Step 6: Commit** ```bash git add crates/dbx-core/src/docs git commit -m "$(cat <<'EOF' feat(docs): render a snapshot as one self-contained HTML file The payload is base64 rather than escaped JSON. A note containing the literal `` — plausible in any schema document that discusses HTML — would otherwise terminate the script element early and inject the remainder as markup. base64's alphabet cannot contain `<`, so the property holds by construction rather than by remembering to escape on every path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH EOF )" ``` --- ## Task 7: The staleness guard **Files:** - Modify: `crates/dbx-core/src/docs/export.rs` **Interfaces:** - Consumes: `crates/dbx-core/assets/docs-export.manifest.json` (Task 5). - [ ] **Step 1: Write the test** Append to the `tests` module in `export.rs`: ```rust /// The committed bundle must match the sources it was built from. /// /// Skips only when the crate is consumed from a published package, where /// `apps/desktop/` does not exist. That skip is itself a hazard — a /// vacuous skip in CI would silently disable this guard — so it keys off /// a repository-only marker rather than off the absence of the sources. #[test] fn docs_export_bundle_is_current() { use sha2::{Digest, Sha256}; let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("workspace root"); if !workspace.join("pnpm-workspace.yaml").exists() { return; // packaged crate: the sources are genuinely absent } let manifest: serde_json::Value = serde_json::from_str(include_str!("../../assets/docs-export.manifest.json")).expect("manifest is valid JSON"); let sources = manifest["sources"].as_object().expect("manifest.sources"); assert!(sources.len() > 5, "manifest lists only {} sources — the build emitted an empty graph", sources.len()); let mut stale = Vec::new(); for (relative, expected) in sources { let path = workspace.join(relative); let Ok(bytes) = std::fs::read(&path) else { stale.push(format!("{relative} (missing)")); continue; }; let actual = format!("{:x}", Sha256::digest(&bytes)); if actual != expected.as_str().unwrap_or_default() { stale.push(relative.clone()); } } assert!(stale.is_empty(), "the committed docs export bundle is stale.\nChanged: {}\nRun: pnpm build:docs-export", stale.join(", ")); } ``` - [ ] **Step 2: Run it** Run: `cargo test -p dbx-core --lib docs::export::tests::docs_export_bundle_is_current` Expected: PASS — the bundle was built in Task 5 and nothing has changed since. - [ ] **Step 3: Deliberate break** Append a blank line to `apps/desktop/src/docs/DocsApp.vue`. Run the test. Confirm it fails with `the committed docs export bundle is stale. Changed: apps/desktop/src/docs/DocsApp.vue`. Revert the edit and re-run to confirm it passes. Report the message. - [ ] **Step 4: Verify the guard reaches outside `src/docs/`** This is the whole reason the manifest is derived rather than globbed. Append a blank line to `apps/desktop/src/lib/diagram/erDiagram.ts` — a file a `src/docs/**` glob would have missed entirely. Run the test; confirm it fails naming that file. Revert. Report the message. - [ ] **Step 5: Commit** ```bash git add crates/dbx-core/src/docs/export.rs git commit -m "$(cat <<'EOF' test(docs): fail when the committed export bundle is stale Verified to bite on a change to erDiagram.ts, which lives outside src/docs/ and which a directory glob would have missed — the failure mode this guard exists to prevent. The packaged-crate skip keys off pnpm-workspace.yaml rather than off the absence of the sources, so a CI checkout cannot silently skip it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH EOF )" ``` --- ## Task 8: The `dbx docs` verb **Files:** - Modify: `crates/dbx-cli/src/main.rs` **Interfaces:** - Consumes: `dbx_core::docs::to_standalone_html` and `EXPORT_LANGUAGES` (Task 6). `run_docs` mirrors `run_dbml` (line 461) exactly, with one difference: `run_dbml` scopes `annotations` inside its `if let`, but the export needs the `AnnotationFile` **after** applying it, so it must be hoisted. - [ ] **Step 1: Write the failing flag tests** Add beside the existing `dbml` flag tests (around line 1148): ```rust #[test] fn parses_the_lang_flag() { let flags = parse_flags(&args(&["docs", "local", "--lang", "zh-CN"])).expect("parse"); assert_eq!(flags.args, args(&["docs", "local"])); assert_eq!(flags.lang.as_deref(), Some("zh-CN")); } #[test] fn lang_requires_a_value() { parse_flags(&args(&["docs", "local", "--lang"])).expect_err("should fail"); } #[test] fn docs_appears_in_the_usage_text() { assert!(usage().contains("dbx docs "), "got: {}", usage()); } ``` - [ ] **Step 2: Run and watch them fail** Run: `cargo test -p dbx-cli parses_the_lang_flag` Expected: FAIL to compile — `no field 'lang' on type 'Flags'`. - [ ] **Step 3: Add the flag** Add `lang: Option,` to `struct Flags`, and beside the `"--notes"` arm (line 573): ```rust "--lang" => flags.lang = Some(option_value(argv, &mut index, "--lang")?), ``` - [ ] **Step 4: Add the verb** Beside the `dbml` dispatch (line 245): ```rust if args.first().is_some_and(|arg| arg == "docs") { return run_docs(backend, &flags).await; } ``` And the function, mirroring `run_dbml`: ```rust async fn run_docs(backend: &dyn DbxBackend, flags: &Flags) -> Result { let args = &flags.args; let connection_name = required(args.get(1), "Connection name is required.")?; let connection = find_connection(backend, connection_name).await?; let database = selected_database(&connection, flags.database.as_deref()); let options = DocsSnapshotOptions { schemas: flags.schema.clone().into_iter().collect(), tables: flags.tables.clone(), project_name: Some(connection.name.clone()), }; let mut snapshot = backend.collect_docs_snapshot(&connection, &database, options).await.map_err(command_error)?; // Unlike run_dbml, the AnnotationFile is needed AFTER it has been applied: // the merge resolves groups into TableGroups and drops the hue the viewer // colours with, so the raw file has to travel too. // // AnnotationFile does not derive Default and `format_version` must be 1, // so the empty value is constructed explicitly rather than defaulted. let mut annotations = dbx_core::docs::annotations::AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: std::collections::BTreeMap::new(), }; if let Some(path) = flags.notes.as_ref() { require_notes_file(path)?; if let Some(loaded) = dbx_core::docs::annotations::load_annotations(path) .map_err(|error| CliError::new("NOTES_INVALID", error))? { dbx_core::docs::annotations::apply_annotations(&mut snapshot, &loaded, connection.db_type); annotations = loaded; } } for warning in &snapshot.warnings { eprintln!("warning: {warning}"); } let lang = flags.lang.as_deref().unwrap_or("en"); let html = dbx_core::docs::to_standalone_html(&snapshot, &annotations, lang).map_err(|error| CliError::new("EXPORT_FAILED", error))?; match flags.out.as_ref() { Some(path) => { std::fs::write(path, &html) .map_err(|error| CliError::new("WRITE_FAILED", format!("Failed to write {}: {error}", path.display())))?; Ok(format!("Wrote {} bytes to {}", html.len(), path.display())) } None => Ok(html), } } ``` - [ ] **Step 5: Add the usage line** In `usage()`, after the `dbx dbml` line: ``` \n dbx docs [--out path] [--notes path] [--lang code] [--schema name] [--database name] [--tables a,b] ``` - [ ] **Step 6: Run the tests and try it** Run: `cargo test -p dbx-cli` Expected: PASS. Run against a live connection: ```bash cargo run -p dbx-cli -- docs --database --schema public --out /tmp/schema.html ``` Expected: `Wrote N bytes to /tmp/schema.html`. Then open it in a browser over `file://` and confirm the index renders, a table page opens, the hash updates, a reload keeps the page, and the language switcher works. - [ ] **Step 7: Commit** ```bash git add crates/dbx-cli/src/main.rs git commit -m "$(cat <<'EOF' feat(cli): add the dbx docs verb Mirrors dbx dbml's flags and collector, plus --lang. An unknown language fails with the list of valid values rather than falling back — the same reasoning that made --notes explicit in Part 2. Unlike run_dbml, the AnnotationFile is kept after being applied: the merge resolves groups into TableGroups and drops the hue the viewer colours with. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH EOF )" ``` --- ## Task 9: The in-app export button **Files:** - Modify: `src-tauri/src/commands/docs.rs`, `src-tauri/src/lib.rs` - Modify: `crates/dbx-web/src/routes/docs.rs`, `crates/dbx-web/src/main.rs` - Modify: `apps/desktop/src/lib/backend/{api,tauri,http}.ts` - Modify: `apps/desktop/src/components/docs/DatabaseDocsDialog.vue` **Interfaces:** - Consumes: `to_standalone_html` (Task 6); the `docs.exportHtml` / `docs.exporting` / `docs.exportFailed` keys (Task 1). - Produces: `exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise` on both transports. **Rust writes the file; the frontend supplies the path.** This mirrors `exportQueryResultCsv(filePath, columns, rows)` at `apps/desktop/src/lib/backend/tauri.ts:3807`, and avoids returning a ~400 KB string across IPC only to write it back out. **Every backend operation exists twice** — once in `tauri.ts`, once in `http.ts`. `api.ts` types the backend as `typeof TauriModule` and resolves either at runtime, so a signature that differs between them compiles on both sides and fails only when a user clicks the button. Add both. - [ ] **Step 1: Add the Tauri command** In `src-tauri/src/commands/docs.rs`, following the shape at line 34: ```rust #[tauri::command] pub async fn docs_export_html( file_path: String, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: String, ) -> Result<(), String> { let html = dbx_core::docs::to_standalone_html(&snapshot, &annotations, &lang)?; std::fs::write(&file_path, html).map_err(|error| format!("Failed to write {file_path}: {error}")) } ``` Tauri converts argument names between camelCase and snake_case **by name**: the frontend sends `filePath` and this receives `file_path`. A mismatch compiles cleanly on both sides and fails only at runtime, so keep the names aligned. Register it in `src-tauri/src/lib.rs` beside the other four (lines 1742–1745): `commands::docs::docs_export_html,`. - [ ] **Step 2: Add the web route** In `crates/dbx-web/src/routes/docs.rs` add a handler with a `#[serde(rename_all = "camelCase")]` request struct carrying `snapshot`, `annotations`, `lang`, returning the HTML string. Register it in `crates/dbx-web/src/main.rs` beside the existing docs routes (around line 377): ```rust .route("/docs/export", post(routes::docs::export_html)) ``` - [ ] **Step 3: Add both transports** `apps/desktop/src/lib/backend/tauri.ts`, beside `collectDocsSnapshot` (line 1626): ```ts export async function exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise { return invoke("docs_export_html", { filePath, snapshot, annotations, lang }); } ``` `http.ts` gets the same signature posting to `/docs/export`. `api.ts`, after line 185: ```ts export const exportDocsHtml = forward("exportDocsHtml"); ``` - [ ] **Step 4: Add the button** In `DatabaseDocsDialog.vue`, beside the existing diagram button (line ~182), add an Export button. Follow the established export pattern from `apps/desktop/src/composables/useDataGridExport.ts:649-662` — a default filename, replaced by a real path from the Tauri save dialog when running in Tauri, then one call that is identical in both runtimes: ```ts async function exportHtml(): Promise { if (!snapshot.value) return; exporting.value = true; exportError.value = null; try { let outputPath = `${snapshot.value.project.name}-docs.html`; if (isTauriRuntime()) { const { save } = await import("@tauri-apps/plugin-dialog"); const chosen = await save({ defaultPath: outputPath, filters: [{ name: "HTML", extensions: ["html"] }] }); if (!chosen) return; // the user cancelled; not an error outputPath = chosen as string; } await api.exportDocsHtml(outputPath, snapshot.value, annotations.value, locale.value); } catch (error) { // Never swallowed: a failed export that reports success is the worst // outcome here, exactly as with a failed autosave. exportError.value = translate("docs.exportFailed", { error: String(error) }); } finally { exporting.value = false; } } ``` `locale.value` comes from `useI18n()`, which this component already imports — it lives outside `src/docs/` and so is permitted to. If the app's locale is not one of the eight, `to_standalone_html` rejects it; map it to `"en"` before calling rather than surfacing that error. Show `docs.exporting` on the button while in flight and `docs.exportFailed` on failure. - [ ] **Step 5: Verify** Run: `cargo check --workspace && pnpm typecheck` Expected: clean. Then run the app (`pnpm dev:web` with `DBX_DISABLE_PASSWORD=1` plus the backend) and click Export. Confirm the saved file opens over `file://` and is **byte-identical** to the CLI's output for the same inputs: ```bash cmp /tmp/from-cli.html /tmp/from-app.html && echo "identical" ``` Expected: `identical`. They call the same function; any difference means one caller is passing something the other is not. - [ ] **Step 6: Commit** ```bash git add src-tauri crates/dbx-web apps/desktop/src git commit -m "$(cat <<'EOF' feat(docs): export documentation as HTML from the app The button and `dbx docs` call the same Rust function, so the two cannot drift — verified by comparing their output byte-for-byte. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH EOF )" ``` --- ## Task 10: Prove the exported file actually runs Every test so far checks that the artefact *looks* right. None executes it. This task attempts the one that does. **Files:** - Create: `apps/desktop/src/docs-export/__tests__/exportSmoke.spec.ts` - [ ] **Step 1: Attempt the happy-dom smoke test** ```ts import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; // @vitest-environment happy-dom const htmlPath = path.resolve(__dirname, "../../../../../target/docs-export-smoke.html"); describe("the exported file", () => { beforeAll(() => { // Generated by a small Rust example so the test exercises the REAL // to_standalone_html output rather than a hand-built approximation. execFileSync("cargo", ["run", "-p", "dbx-core", "--example", "docs_export_smoke", "--", htmlPath], { stdio: "inherit" }); }); it("mounts and renders a table name", async () => { document.documentElement.innerHTML = readFileSync(htmlPath, "utf8"); for (const script of Array.from(document.querySelectorAll("script"))) { if (script.getAttribute("type") === null) new Function(script.textContent ?? "")(); } await new Promise((resolve) => setTimeout(resolve, 100)); expect(document.querySelector("#app")?.textContent ?? "").toContain("public.orders"); }); }); ``` This needs a `crates/dbx-core/examples/docs_export_smoke.rs` that builds a two-table snapshot, calls `to_standalone_html`, and writes it to `argv[1]`. - [ ] **Step 2: Run it** Run: `pnpm vitest run apps/desktop/src/docs-export/__tests__/exportSmoke.spec.ts` - [ ] **Step 3: Decide honestly** If it passes, keep it — it is the most valuable test in this part. **If happy-dom cannot execute the Vue bundle, delete the test rather than weakening it into something that passes without proving anything.** Record in the task report exactly what failed and with what message, and note that the artefact is instead covered by `nothing_is_loaded_from_the_network` plus manual `file://` verification. A test that mounts nothing but asserts `true` is worse than no test, because it reads as coverage. - [ ] **Step 4: Manual verification either way** Open the generated file in a real browser over `file://` with the network disabled. Confirm: the index renders with the correct font; a table page opens; the hash updates; a reload lands on the same page; a deep link to a removed table falls back to the index; the language switcher changes the chrome; the diagram renders and clicking a table navigates. - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/docs-export crates/dbx-core/examples git commit -m "$(cat <<'EOF' test(docs): execute the exported file rather than only inspecting it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH EOF )" ``` --- ## Final verification - [ ] `make check` — format, lint, typecheck, tests - [ ] `make cargo-check-fast` - [ ] `cargo test -p dbx-core --lib docs` - [ ] `pnpm vitest run apps/desktop/src/docs apps/desktop/src/docs-export apps/desktop/src/i18n` - [ ] `pnpm build:docs-export && git diff --exit-code crates/dbx-core/assets/` — confirms the committed bundle is what the current sources produce - [ ] Update PR #5559's body with the export screenshots and the `dbx docs` usage Note: `queryStore.test.ts > result cache eviction keeps recently accessed inactive tabs` is a known wall-clock-timeout flake under full-suite load. It passes in isolation and on re-run, and is untouched by this work. Re-run before investigating.