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