70 KiB
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 <script setup lang="ts">, Vite 8, Tailwind v4, vitest 4 + happy-dom 20, base64 0.22 and sha2 0.10 (both already dbx-core dependencies).
Global Constraints
- No new dependencies.
base64 0.22andsha2 0.10are already incrates/dbx-core/Cargo.toml(lines 61 and 67).happy-dom ^20.10.6is already a devDependency. Adding anything else requires explicit approval. apps/desktop/src/docs/**/*.vuemust make zero backend calls. Enforced byapps/desktop/src/docs/__tests__/componentContract.spec.ts, whose forbidden list is["@/lib/backend", "@tauri-apps", "invoke(", "useConnectionStore", "useQueryStore", "useSettingsStore", "fetch(", "axios", "vue-i18n", "useI18n("]. Extend it; never weaken it.- Every component uses
<script setup lang="ts">. Asserted by the same contract test. - No colour computed in a component.
oklch(and 6-digit hex literals are banned insrc/docs/**; hues go throughgroupStyle(hue)fromapps/desktop/src/docs/groupColor.ts. v-htmlmay only ever be bound torenderNoteoutput. Both quote styles are checked;innerHTMLis banned outright.- All 8 locales.
en,es,it,ja,ko,pt-BR,zh-CN,zh-TWinapps/desktop/src/i18n/locales/docs/. Parity is enforced byapps/desktop/src/i18n/__tests__/docsNamespaceParity.spec.ts. - Tests live at
apps/desktop/src/**/*.spec.ts(vitestincludeatvitest.config.ts:14). Run one withpnpm vitest run <path> -t "<test name>". - Rust docs tests run with
cargo test -p dbx-core --lib docs. - Every new test gets the deliberate-break treatment: break the behaviour, run the named test, confirm it fails, restore, and report the exact failure message in the task report. Parts 3a and 3b caught four defects this way that no amount of reading found.
- Commit after every task. Conventional Commits. End every commit message with:
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH
File Structure
Created
| Path | Responsibility |
|---|---|
apps/desktop/src/docs/docsRoute.ts |
Pure parse/format between a hash string and DocsRoute |
apps/desktop/src/docs/diagramGeometry.ts |
Pure edge clipping — line from card centre to card border |
apps/desktop/src/docs/components/SchemaDiagram.vue |
Minimal read-only SVG ER renderer |
apps/desktop/src/docs-export/main.ts |
Export entry: decode payload, mount ExportApp |
apps/desktop/src/docs-export/ExportApp.vue |
Export shell: hash sync, language switcher, readonly, diagram="inline" |
apps/desktop/src/docs-export/exportTranslate.ts |
Build a Translate from a bundled locale, English fallback |
apps/desktop/src/docs-export/export.css |
Tailwind entry with @source narrowed to ../docs/** |
apps/desktop/src/styles/tokens.css |
Design tokens extracted from globals.css |
apps/desktop/vite.docs-export.config.ts |
Single-file bundle config, font inlining, manifest emission |
crates/dbx-core/src/docs/export.rs |
to_standalone_html + the staleness guard test |
src-tauri/src/commands/docs.rs (modify) |
docs_export_html command |
Modified
| Path | Change |
|---|---|
apps/desktop/src/docs/DocsApp.vue |
Optional v-model:route, diagram prop, translate its own literals |
apps/desktop/src/docs/components/{DocsSidebar,DocsSearch,TablePage,ColumnTable,RelationshipList}.vue |
Accept and use translate |
apps/desktop/src/i18n/locales/docs/*.ts (×8) |
~18 new keys |
apps/desktop/src/docs/__tests__/componentContract.spec.ts |
Add SchemaDiagram.vue; add the hardcoded-literal guard |
apps/desktop/src/styles/globals.css |
Token blocks replaced by @import "./tokens.css" |
crates/dbx-core/src/docs/mod.rs |
pub mod export; + re-export |
crates/dbx-cli/src/main.rs |
--lang flag, run_docs, usage line |
apps/desktop/src/lib/backend/{api,tauri,http}.ts |
exportDocsHtml |
apps/desktop/src/components/docs/DatabaseDocsDialog.vue |
Export HTML button |
crates/dbx-web/src/{main.rs,routes/docs.rs} |
/docs/export route |
package.json |
build:docs-export script |
Task 1: Internationalise the viewer
The docs namespace has 40 keys across 8 locales and a passing parity test, but most of the viewer renders English literals instead of calling them. groupBySchema: "Schemas" and groupByTableGroup: "Table Groups" exist specifically for DocsSidebar.vue, which hardcodes both words.
This is a prerequisite: a language switcher over a viewer that is two-thirds hardcoded English would be worse than none. It is also a live defect in the open PR — a Chinese user opening the docs dialog today sees mostly English chrome.
Files:
- Modify:
apps/desktop/src/docs/DocsApp.vue - Modify:
apps/desktop/src/docs/components/DocsSidebar.vue - Modify:
apps/desktop/src/docs/components/DocsSearch.vue - Modify:
apps/desktop/src/docs/components/TablePage.vue - Modify:
apps/desktop/src/docs/components/ColumnTable.vue - Modify:
apps/desktop/src/docs/components/RelationshipList.vue - Modify:
apps/desktop/src/i18n/locales/docs/{en,es,it,ja,ko,pt-BR,zh-CN,zh-TW}.ts - Test:
apps/desktop/src/docs/__tests__/componentContract.spec.ts
Interfaces:
- Consumes:
Translatefromapps/desktop/src/docs/docsWarnings.ts— exactlyexport type Translate = (key: string, params?: Record<string, string | number>) => string; - Produces: every component under
src/docs/accepts a requiredtranslate: Translateprop. Later tasks rely onDocsSidebarandSchemaDiagramhaving it.
Keys already present — wire these, do not re-add: groupBySchema, groupByTableGroup, search, columns, indexes, references, referencedBy, noGroup, noSchema.
Leave the existing search value ("Search tables, columns, groups…") unchanged even though search now also covers enums. Rewording one clause across 8 locales invites translation drift for negligible gain, and results are already labelled by kind.
- Step 1: Write the failing guard test
Append to apps/desktop/src/docs/__tests__/componentContract.spec.ts, inside the existing describe:
it("renders no English literal that already has a key", async () => {
// Derived from the namespace, NOT an enumerated list of strings. A key
// added tomorrow is covered tomorrow. This is the guard Part 3b lacked:
// docsNamespaceParity compares locale files to EACH OTHER, so a key that
// no component ever calls passes every existing test.
const en = (await import("../../i18n/locales/docs/en")).default as Record<string, unknown>;
const literals: string[] = [];
const walk = (node: unknown) => {
if (typeof node === "string") {
// Skip short strings (false positives on words like "LOCAL") and any
// string carrying a placeholder, which cannot appear verbatim anyway.
if (node.length >= 4 && !node.includes("{")) literals.push(node);
return;
}
if (node && typeof node === "object") Object.values(node).forEach(walk);
};
walk(en);
expect(literals.length).toBeGreaterThan(10);
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
for (const file of files) {
const source = readFileSync(file, "utf8");
for (const literal of literals) {
expect(source.includes(`>${literal}<`), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
expect(source.includes(`="${literal}"`), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
}
}
});
- Step 2: Run it and watch it fail
Run: pnpm vitest run apps/desktop/src/docs/__tests__/componentContract.spec.ts -t "renders no English literal"
Expected: FAIL. DocsSidebar.vue hardcodes "Schemas" — call translate() instead: expected true to be false (or another of the six components; any one failing is the correct starting state).
- Step 3: Add the new keys to
en.ts
Insert into apps/desktop/src/i18n/locales/docs/en.ts before the warnings: block:
overview: "Overview",
groupBy: "Group by",
searchLabel: "Search",
groups: "Groups",
relationships: "Relationships",
columnHeader: "Column",
typeHeader: "Type",
settingsHeader: "Settings",
noteHeader: "Note",
nameHeader: "Name",
definitionHeader: "Definition",
noOutgoingRelationships: "This table references no other table.",
noIncomingRelationships: "No table references this one.",
diagram: "Diagram",
language: "Language",
exportHtml: "Export HTML…",
exporting: "Exporting…",
exportFailed: "Could not export: {error}",
- Step 4: Translate the 18 new keys into the other 7 locales
Add the same 18 keys to es.ts, it.ts, ja.ts, ko.ts, pt-BR.ts, zh-CN.ts, zh-TW.ts, matching each file's existing tone and terminology.
{error} must survive verbatim in every locale. The Part 3b review caught that no automated test verifies placeholder preservation — parity compares key names, so a translated or renamed {error} would render literal braces to users and pass every test. Check each one by eye.
- Step 5: Wire
DocsSidebar.vue
Add to its defineProps: translate: Translate; with import type { Translate } from "../docsWarnings";. Then replace the four literals:
>Overview< → >{{ translate("docs.overview") }}<
>Group by< → >{{ translate("docs.groupBy") }}<
>Schemas< → >{{ translate("docs.groupBySchema") }}<
>Table Groups< → >{{ translate("docs.groupByTableGroup") }}<
- Step 6: Wire the remaining five components
DocsSearch.vue — add the translate prop; >Search< becomes {{ translate("docs.searchLabel") }}, and placeholder="Search tables, columns, groups, enums…" becomes :placeholder='translate("docs.search")'.
TablePage.vue — already receives translate and passes it down; now use it for its own headings: Columns → docs.columns, Indexes → docs.indexes, Relationships → docs.relationships, Name → docs.nameHeader, Settings → docs.settingsHeader, Definition → docs.definitionHeader.
ColumnTable.vue — headers Column → docs.columnHeader, Type → docs.typeHeader, Settings → docs.settingsHeader, Note → docs.noteHeader.
RelationshipList.vue — add the translate prop and thread it from TablePage.vue:
>References ({{ outgoing.length }})< → >{{ translate("docs.references") }} ({{ outgoing.length }})<
>Referenced by ({{ incoming.length }})< → >{{ translate("docs.referencedBy") }} ({{ incoming.length }})<
>This table references no other table.< → >{{ translate("docs.noOutgoingRelationships") }}<
>No table references this one.< → >{{ translate("docs.noIncomingRelationships") }}<
DocsApp.vue — >Groups< becomes {{ translate("docs.groups") }}.
- Step 7: Run the guard, the parity test and typecheck
Run: pnpm vitest run apps/desktop/src/docs apps/desktop/src/i18n
Expected: PASS, with the new guard among them.
Run: pnpm typecheck
Expected: clean. A missing translate prop on a threaded component fails here.
- Step 8: Commit
git add apps/desktop/src/docs apps/desktop/src/i18n
git commit -m "$(cat <<'EOF'
fix(docs): translate the viewer's own chrome
The docs namespace has carried 40 keys across 8 locales since Part 3b, with
a parity test that passes — but most components rendered English literals
instead of calling them. `groupBySchema: "Schemas"` existed specifically for
DocsSidebar, which hardcoded the word.
Parity compares locale files to each other, so a key no component ever calls
is invisible to it. The new guard derives its forbidden literals FROM the
English namespace, so a key added later is covered without anyone
remembering to register it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH
EOF
)"
Task 2: Extract the design tokens
The export's Tailwind entry cannot import globals.css: line 40 is @source "../**/*.{vue,ts,tsx,js,jsx,html}", which scans the whole application, and @source is additive with no way to un-source. But the viewer's utilities still resolve against tokens defined in globals.css from line 131.
Files:
- Create:
apps/desktop/src/styles/tokens.css - Modify:
apps/desktop/src/styles/globals.css - Test:
apps/desktop/src/docs/__tests__/tokens.spec.ts
Interfaces:
- Produces:
apps/desktop/src/styles/tokens.css, importable by bothglobals.cssand (Task 5)export.css.
This edits a 76 KB stylesheet the whole application depends on. The move is mechanical, but cascade order between :root and .dark must be preserved exactly — .dark must still be declared after :root.
- Step 1: Write the failing test
Create apps/desktop/src/docs/__tests__/tokens.spec.ts:
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const stylesDir = path.resolve(__dirname, "../../styles");
/** Every `--name:` custom property declared in a stylesheet. */
function declaredTokens(file: string): Set<string> {
const source = readFileSync(path.join(stylesDir, file), "utf8");
return new Set(Array.from(source.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm), (match) => match[1]));
}
describe("design tokens", () => {
it("defines the tokens the docs viewer resolves against", () => {
// These are the utilities used in src/docs/**: bg-background,
// text-foreground, text-muted-foreground, bg-muted, border-border,
// focus:border-ring. If a token stops being declared here the export
// renders unstyled while the app, which has globals.css, looks fine —
// so this must be checked against tokens.css, not globals.css.
const tokens = declaredTokens("tokens.css");
for (const token of ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--ring"]) {
expect(tokens.has(token), `tokens.css must declare ${token}`).toBe(true);
}
});
it("keeps globals.css importing the extracted tokens", () => {
const globals = readFileSync(path.join(stylesDir, "globals.css"), "utf8");
expect(globals.includes('@import "./tokens.css"')).toBe(true);
});
it("declares the dark overrides after the light ones", () => {
// Custom properties resolve at use time, but override order still
// decides which block wins. A tokens.css with .dark BEFORE :root leaves
// dark mode rendering light values.
const source = readFileSync(path.join(stylesDir, "tokens.css"), "utf8");
const root = source.search(/^:root\s*\{/m);
const dark = source.search(/^\.dark\s*\{/m);
expect(root, "tokens.css must declare :root").toBeGreaterThan(-1);
expect(dark, "tokens.css must declare .dark").toBeGreaterThan(-1);
expect(dark).toBeGreaterThan(root);
});
});
- Step 2: Run it and watch it fail
Run: pnpm vitest run apps/desktop/src/docs/__tests__/tokens.spec.ts
Expected: FAIL — ENOENT: no such file or directory, open '.../styles/tokens.css'.
- Step 3: Move the token blocks
Read apps/desktop/src/styles/globals.css and find the :root { … } block starting at line 131 and its corresponding .dark { … } block. Cut both verbatim into a new apps/desktop/src/styles/tokens.css with this header:
/*
* Design tokens, shared by the application shell and the standalone
* documentation export.
*
* These live apart from globals.css because the export's Tailwind entry
* cannot import globals.css: its `@source` scans the whole application and
* `@source` is additive, so the export would emit every utility in DBX. The
* export still needs these tokens, and duplicating them is how the values
* drift.
*
* Order matters: `.dark` must stay declared after `:root`.
*/
Do not reformat, reorder or "tidy" the declarations while moving them — a diff that is a pure move is reviewable; one that also reformats is not.
- Step 4: Import from globals.css
At the point in globals.css where the :root block used to begin, put:
@import "./tokens.css";
Leave @theme inline (line 71) and the @import "tailwindcss" / @source lines exactly where they are.
- Step 5: Verify the application is unchanged
Run: pnpm vitest run apps/desktop/src/docs/__tests__/tokens.spec.ts
Expected: PASS (all three).
Run: pnpm build
Expected: succeeds. Then confirm the built CSS still carries the tokens:
grep -c -- "--muted-foreground" dist/assets/*.css
Expected: at least 1. A zero here means the @import did not resolve and the whole application would render unstyled.
- Step 6: Deliberate break
Reorder tokens.css so .dark precedes :root. Run the test; confirm it fails with expected <n> to be greater than <m>. Restore. Report the message.
- Step 7: Commit
git add apps/desktop/src/styles apps/desktop/src/docs/__tests__/tokens.spec.ts
git commit -m "$(cat <<'EOF'
refactor(styles): extract design tokens for reuse by the export
The standalone export's Tailwind entry cannot import globals.css — its
`@source` scans the whole application and `@source` is additive, so the
export would emit every utility in DBX rather than the ~40 the viewer uses.
The tokens move verbatim; globals.css imports them. Copying the values into
the export instead is exactly the duplication that has produced this
feature's recurring defects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH
EOF
)"
Task 3: DocsRoute and optional v-model:route
Files:
- Create:
apps/desktop/src/docs/docsRoute.ts - Create:
apps/desktop/src/docs/__tests__/docsRoute.spec.ts - Modify:
apps/desktop/src/docs/DocsApp.vue
Interfaces:
- Produces:
export type DocsRoute,export function parseDocsHash(hash: string, snapshot: SchemaSnapshot, allowDiagram: boolean): DocsRoute,export function formatDocsHash(route: DocsRoute): string. Task 5'sExportApp.vueconsumes both. - Produces:
DocsAppaccepts optionalroute?: DocsRouteand emitsupdate:route.
DocsApp must stay uncontrolled when route is absent — DBX has no router (vue-router is not a dependency), so a DocsApp that always wrote to the URL would hijack the host application's address bar.
- Step 1: Write the failing test
Create apps/desktop/src/docs/__tests__/docsRoute.spec.ts:
import { describe, expect, it } from "vitest";
import { formatDocsHash, parseDocsHash } from "../docsRoute";
import type { SchemaSnapshot } from "../types";
// `formatVersion` is top-level on SchemaSnapshot, not inside `project`.
const snapshot = {
formatVersion: 1,
project: { name: "shop", databaseType: "postgres", database: "shop", schemas: ["public"], generatedAt: "2026-08-06T00:00:00Z", note: null },
tables: [{ schema: "public", name: "orders", columns: [], indexes: [] }, { schema: null, name: "a/b", columns: [], indexes: [] }],
enums: [{ schema: "public", name: "order_status", values: ["pending"] }],
relationships: [],
groups: [],
warnings: [],
} as unknown as SchemaSnapshot;
describe("parseDocsHash", () => {
it("reads a table route", () => {
expect(parseDocsHash("#/table/public.orders", snapshot, true)).toEqual({ kind: "table", key: "public.orders" });
});
it("decodes an identifier containing a slash", () => {
// A table named `a/b` must survive the round trip; an undecoded hash
// would split into a bogus segment and silently fall back to the index.
expect(parseDocsHash("#/table/a%2Fb", snapshot, true)).toEqual({ kind: "table", key: "a/b" });
});
it("reads an enum route", () => {
expect(parseDocsHash("#/enum/order_status", snapshot, true)).toEqual({ kind: "enum", name: "order_status" });
});
it("falls back to the index for a table that is not in the snapshot", () => {
// A deep link into a since-dropped table is the EXPECTED case for a file
// someone saved months ago. It must never render a blank page.
expect(parseDocsHash("#/table/public.gone", snapshot, true)).toEqual({ kind: "index" });
});
it("falls back to the index for junk, empty and bare hashes", () => {
for (const hash of ["", "#", "#/", "#/nonsense", "#/table", "#/table/", "not-a-hash"]) {
expect(parseDocsHash(hash, snapshot, true), hash).toEqual({ kind: "index" });
}
});
it("refuses the diagram route when the host has not enabled it", () => {
// The dialog passes diagram="external" and keeps its button to the full
// SchemaDiagramDialog. A hash must not render a view that host declined.
expect(parseDocsHash("#/diagram", snapshot, false)).toEqual({ kind: "index" });
expect(parseDocsHash("#/diagram", snapshot, true)).toEqual({ kind: "diagram" });
});
});
describe("formatDocsHash", () => {
it("round-trips every route kind", () => {
for (const route of [{ kind: "index" }, { kind: "table", key: "public.orders" }, { kind: "enum", name: "order_status" }, { kind: "diagram" }] as const) {
expect(parseDocsHash(formatDocsHash(route), snapshot, true)).toEqual(route);
}
});
it("encodes a slash in an identifier", () => {
expect(formatDocsHash({ kind: "table", key: "a/b" })).toBe("#/table/a%2Fb");
});
});
- Step 2: Run it and watch it fail
Run: pnpm vitest run apps/desktop/src/docs/__tests__/docsRoute.spec.ts
Expected: FAIL — Failed to resolve import "../docsRoute".
- Step 3: Implement
docsRoute.ts
import { qualifiedTableKey } from "./docsKeys";
import type { SchemaSnapshot } from "./types";
/**
* Where the viewer is pointing.
*
* This exists so the standalone export can drive navigation from
* `location.hash` without DocsApp itself touching the URL: DBX has no
* router, and a viewer that wrote to the address bar would hijack the host
* application's.
*/
export type DocsRoute = { kind: "index" } | { kind: "table"; key: string } | { kind: "enum"; name: string } | { kind: "diagram" };
const INDEX: DocsRoute = { kind: "index" };
/**
* Resolve a hash against a snapshot.
*
* Anything unrecognised — junk, a table that no longer exists, the diagram
* route on a host that did not enable it — resolves to the index. A saved
* file whose schema has since changed is the expected case, not an exotic
* one, and must never render blank.
*/
export function parseDocsHash(hash: string, snapshot: SchemaSnapshot, allowDiagram: boolean): DocsRoute {
if (!hash.startsWith("#")) return INDEX;
const segments = hash.slice(1).replace(/^\//, "").split("/");
const [kind, ...rest] = segments;
const identifier = rest.join("/");
if (kind === "diagram" && identifier === "") return allowDiagram ? { kind: "diagram" } : INDEX;
if (identifier === "") return INDEX;
let decoded: string;
try {
decoded = decodeURIComponent(identifier);
} catch {
// A malformed percent-escape throws rather than returning null.
return INDEX;
}
if (kind === "table") {
return snapshot.tables.some((table) => qualifiedTableKey(table) === decoded) ? { kind: "table", key: decoded } : INDEX;
}
if (kind === "enum") {
return (snapshot.enums ?? []).some((value) => value.name === decoded) ? { kind: "enum", name: decoded } : INDEX;
}
return INDEX;
}
export function formatDocsHash(route: DocsRoute): string {
switch (route.kind) {
case "table":
return `#/table/${encodeURIComponent(route.key)}`;
case "enum":
return `#/enum/${encodeURIComponent(route.name)}`;
case "diagram":
return "#/diagram";
default:
return "#/";
}
}
- Step 4: Run the test
Run: pnpm vitest run apps/desktop/src/docs/__tests__/docsRoute.spec.ts
Expected: PASS (8 tests).
- Step 5: Make
DocsAppoptionally controlled
In apps/desktop/src/docs/DocsApp.vue, add to defineProps:
/**
* When provided, navigation is controlled by the host and mirrored back
* through `update:route`. Absent — the dialog's case — DocsApp owns its
* own navigation exactly as before and never touches the URL.
*/
route?: DocsRoute;
/** `inline` renders SchemaDiagram; `external` leaves the host to offer its own. */
diagram?: "inline" | "external";
Add "update:route": [route: DocsRoute] to defineEmits. Replace the internal activeKey / activeEnumName reads with a computed that prefers props.route when present, and have open() / home() / openEnum() emit update:route in addition to setting internal state. Default diagram to "external".
- Step 6: Verify nothing regressed
Run: pnpm vitest run apps/desktop/src/docs && pnpm typecheck
Expected: PASS, clean.
- Step 7: Deliberate break
Change parseDocsHash to return { kind: "table", key: decoded } without the snapshot.tables.some(...) check. Run the test; confirm falls back to the index for a table that is not in the snapshot fails. Restore. Report the message.
- Step 8: Commit
git add apps/desktop/src/docs
git commit -m "$(cat <<'EOF'
feat(docs): add a route model the export can drive from the URL
DocsApp gains an OPTIONAL v-model:route. Absent, it owns navigation exactly
as before — which is what the in-app dialog needs, since DBX has no router
and a viewer that wrote to location.hash would hijack the host's address bar.
Parsing is total: junk, a dropped table, or the diagram route on a host that
did not enable it all resolve to the index. A deep link into a file saved
months ago is the expected case, not an exotic one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH
EOF
)"
Task 4: The minimal ER renderer
Files:
- Create:
apps/desktop/src/docs/diagramGeometry.ts - Create:
apps/desktop/src/docs/__tests__/diagramGeometry.spec.ts - Create:
apps/desktop/src/docs/components/SchemaDiagram.vue - Modify:
apps/desktop/src/docs/DocsApp.vue - Modify:
apps/desktop/src/docs/__tests__/componentContract.spec.ts
Interfaces:
- Consumes:
layoutDiagramTables(tables: Pick<DiagramTable, "name" | "columns">[], options?: DiagramLayoutOptions): Record<string, DiagramPosition>from@/lib/diagram/erDiagram— permitted by the contract test, which forbids only@/lib/backend; that module has exactly one import and it isimport type, so it bundles cleanly. - Produces:
export function clipToCard(from: Point, to: Point, half: Size): Pointandexport interface Point { x: number; y: number }.
Layout is reused. Edge routing is the genuinely new work — nothing in the codebase draws a line between two cards.
- Step 1: Write the failing geometry test
Create apps/desktop/src/docs/__tests__/diagramGeometry.spec.ts:
import { describe, expect, it } from "vitest";
import { clipToCard } from "../diagramGeometry";
describe("clipToCard", () => {
const half = { width: 100, height: 50 };
it("exits through the vertical edge for a horizontal run", () => {
// Centre (0,0) to (500,0): the line leaves through the right edge at
// x = +100, not through the top or bottom.
expect(clipToCard({ x: 0, y: 0 }, { x: 500, y: 0 }, half)).toEqual({ x: 100, y: 0 });
});
it("exits through the horizontal edge for a vertical run", () => {
expect(clipToCard({ x: 0, y: 0 }, { x: 0, y: 500 }, half)).toEqual({ x: 0, y: 50 });
});
it("picks the nearer edge on a diagonal", () => {
// Slope 1 against a 2:1 card: the vertical edge is reached first, so the
// result sits ON x = 100 with |y| < 50. Clipping to the wrong axis puts
// the endpoint outside the card and the line visibly overshoots.
const point = clipToCard({ x: 0, y: 0 }, { x: 500, y: 500 }, half);
expect(point.x).toBeCloseTo(50);
expect(point.y).toBeCloseTo(50);
});
it("returns the centre when both points coincide", () => {
// Two tables laid out at the same position would otherwise divide by zero
// and emit NaN into the SVG path, which renders nothing at all.
expect(clipToCard({ x: 7, y: 7 }, { x: 7, y: 7 }, half)).toEqual({ x: 7, y: 7 });
});
it("handles negative directions symmetrically", () => {
expect(clipToCard({ x: 0, y: 0 }, { x: -500, y: 0 }, half)).toEqual({ x: -100, y: 0 });
});
});
- Step 2: Run it and watch it fail
Run: pnpm vitest run apps/desktop/src/docs/__tests__/diagramGeometry.spec.ts
Expected: FAIL — Failed to resolve import "../diagramGeometry".
- Step 3: Implement
diagramGeometry.ts
export interface Point {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
/**
* Where a line from one card's centre towards another leaves the first card.
*
* Without this, edges terminate under the card and appear to sprout from a
* table's middle. Scaling both axes and taking the smaller factor picks
* whichever edge the ray actually reaches first.
*
* `half` is the card's HALF width and height, measured from its centre.
*/
export function clipToCard(from: Point, to: Point, half: Size): Point {
const dx = to.x - from.x;
const dy = to.y - from.y;
if (dx === 0 && dy === 0) {
// Coincident centres would divide by zero and put NaN in the path data,
// which renders as nothing rather than as an error.
return { x: from.x, y: from.y };
}
const scaleX = dx === 0 ? Number.POSITIVE_INFINITY : half.width / Math.abs(dx);
const scaleY = dy === 0 ? Number.POSITIVE_INFINITY : half.height / Math.abs(dy);
const scale = Math.min(scaleX, scaleY);
return { x: from.x + dx * scale, y: from.y + dy * scale };
}
- Step 4: Run the geometry test
Run: pnpm vitest run apps/desktop/src/docs/__tests__/diagramGeometry.spec.ts
Expected: PASS (5 tests).
- Step 5: Create
SchemaDiagram.vue
apps/desktop/src/docs/components/SchemaDiagram.vue — <script setup lang="ts">, props { snapshot: SchemaSnapshot; translate: Translate }, emits { select: [tableKey: string] }.
Compute positions with layoutDiagramTables(snapshot.tables.map((t) => ({ name: qualifiedTableKey(t), columns: t.columns }))). Render an <svg> sized to the layout extent inside a <div class="overflow-auto"> — scrolling is panning; there are no zoom controls by design.
For each table draw a <rect> plus its qualified name and up to eight column names. Apply groupStyle(hue) and style="background-color: var(--group-c)" for the group accent — never a computed colour, which the contract test bans. For each relationship draw a <line> whose endpoints come from clipToCard in both directions. A <rect> per table carries @click="emit('select', key)".
- Step 6: Register it in the contract test and render it in
DocsApp
In componentContract.spec.ts, add "SchemaDiagram.vue" to EXPECTED — keep the array alphabetically sorted, since the first test compares against a sorted list.
In DocsApp.vue, render <SchemaDiagram v-else-if="view === 'diagram'" … /> and add a Diagram entry to the sidebar only when diagram === "inline".
- Step 7: Run everything and typecheck
Run: pnpm vitest run apps/desktop/src/docs && pnpm typecheck
Expected: PASS, clean. The contract test must confirm SchemaDiagram.vue references none of the forbidden needles and computes no colour.
- Step 8: Deliberate break
In clipToCard, replace Math.min(scaleX, scaleY) with scaleX. Run the geometry test; confirm picks the nearer edge on a diagonal fails with expected 100 to be close to 50. Restore. Report the message.
- Step 9: Commit
git add apps/desktop/src/docs
git commit -m "$(cat <<'EOF'
feat(docs): add a minimal read-only ER renderer
Reuses layoutDiagramTables from lib/diagram/erDiagram, which is bundleable
as-is: 211 lines with exactly one import, and that import is `import type`.
Edge routing is the new part — nothing in the codebase drew a line between
two cards. clipToCard scales both axes and takes the smaller factor, so an
edge stops on whichever border the ray reaches first instead of vanishing
under the card.
Deliberately minimal: scroll is the pan, there is no zoom, and the host
chooses whether to show this at all. In-app the dialog keeps its button to
the full SchemaDiagramDialog, so no screen offers two ER views.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH
EOF
)"
Task 5: The export shell and the bundle
Files:
- Create:
apps/desktop/src/docs-export/{main.ts,ExportApp.vue,exportTranslate.ts,export.css} - Create:
apps/desktop/vite.docs-export.config.ts - Create:
apps/desktop/src/docs-export/__tests__/exportTranslate.spec.ts - Modify:
package.json
Interfaces:
-
Consumes:
parseDocsHash,formatDocsHash,DocsRoute(Task 3);DocsAppwithroute/diagramprops (Tasks 3–4);tokens.css(Task 2). -
Produces:
crates/dbx-core/assets/docs-export.js,docs-export.css,docs-export.manifest.json. Tasks 6 and 7 consume all three. -
Produces: the payload contract —
{ snapshot, annotations, lang }— which Task 6's Rust side must emit exactly. -
Step 1: Write the failing translate test
Create apps/desktop/src/docs-export/__tests__/exportTranslate.spec.ts:
import { describe, expect, it } from "vitest";
import { createExportTranslate, EXPORT_LOCALES } from "../exportTranslate";
describe("createExportTranslate", () => {
it("carries every locale the app ships", () => {
expect(Object.keys(EXPORT_LOCALES).sort()).toEqual(["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"]);
});
it("resolves a nested key under the docs prefix", () => {
expect(createExportTranslate("en")("docs.columns")).toBe("Columns");
expect(createExportTranslate("en")("docs.warnings.orphanedNotes.title")).toBe("Some notes no longer match anything");
});
it("substitutes placeholders", () => {
expect(createExportTranslate("en")("docs.shadowedComment", { comment: "hi" })).toBe("Database comment: hi");
});
it("falls back to English for an unknown locale", () => {
// A hand-edited payload, or a --lang that slipped through, must render
// English rather than raw keys in a file someone opens offline.
expect(createExportTranslate("kl" as never)("docs.columns")).toBe("Columns");
});
it("returns the key when nothing resolves", () => {
expect(createExportTranslate("en")("docs.nope.missing")).toBe("docs.nope.missing");
});
});
- Step 2: Run it and watch it fail
Run: pnpm vitest run apps/desktop/src/docs-export
Expected: FAIL — Failed to resolve import "../exportTranslate".
- Step 3: Implement
exportTranslate.ts
import en from "@/i18n/locales/docs/en";
import es from "@/i18n/locales/docs/es";
import it from "@/i18n/locales/docs/it";
import ja from "@/i18n/locales/docs/ja";
import ko from "@/i18n/locales/docs/ko";
import ptBR from "@/i18n/locales/docs/pt-BR";
import zhCN from "@/i18n/locales/docs/zh-CN";
import zhTW from "@/i18n/locales/docs/zh-TW";
import type { Translate } from "@/docs/docsWarnings";
export const EXPORT_LOCALES = { en, es, it, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW } as const;
export type ExportLocale = keyof typeof EXPORT_LOCALES;
function lookup(source: unknown, key: string): string | null {
const value = key.split(".").reduce<unknown>((node, part) => (node && typeof node === "object" ? (node as Record<string, unknown>)[part] : undefined), source);
return typeof value === "string" ? value : null;
}
/**
* Build a `Translate` over a bundled namespace.
*
* English is the fallback rather than the raw key. The parity test guarantees
* all 8 namespaces agree, so this should never fire — it exists so an
* artefact opened offline degrades to English instead of showing
* `docs.columns` to a reader. This is not the Part 3b hazard where a fallback
* masked drift: there the fallback replaced the guard, here the guard runs in
* CI and this is only a runtime backstop.
*/
export function createExportTranslate(lang: ExportLocale): Translate {
const primary = EXPORT_LOCALES[lang] ?? EXPORT_LOCALES.en;
return (key, params) => {
// Keys arrive prefixed with `docs.` because that is how the namespace is
// mounted in the app; the bundled modules are the namespace itself.
const bare = key.startsWith("docs.") ? key.slice("docs.".length) : key;
const template = lookup(primary, bare) ?? lookup(EXPORT_LOCALES.en, bare);
if (template === null) return key;
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (match, name: string) => (name in params ? String(params[name]) : match));
};
}
- Step 4: Run the translate test
Run: pnpm vitest run apps/desktop/src/docs-export
Expected: PASS (5 tests).
- Step 5: Write
export.css
@import "tailwindcss";
/*
* Narrowed deliberately. globals.css sources the whole application, and
* `@source` is additive with no way to un-source, so importing it would emit
* every utility in DBX rather than the ~40 the viewer uses.
*/
@source "../docs/**/*.vue";
@source "../docs-export/**/*.vue";
@import "../styles/tokens.css";
No @font-face here — the Vite plugin in Step 6 prepends it with the font already inlined, so no rule can reference a path that fails under file://.
- Step 6: Write
vite.docs-export.config.ts
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
const repoRoot = path.resolve(__dirname, "../..");
const assetsDir = path.join(repoRoot, "crates/dbx-core/assets");
const fontPath = path.join(__dirname, "public/fonts/geist-latin-wght-normal.woff2");
function sha256(buffer: Buffer | string): string {
return createHash("sha256").update(buffer).digest("hex");
}
/**
* Inline the font and write the staleness manifest.
*
* The manifest is derived from Rollup's ACTUAL module graph, never from a
* hand-written glob. SchemaDiagram.vue imports erDiagram.ts from outside
* src/docs/, so a glob of that directory would miss it and the guard would
* pass while the artefact was stale — the same shape as the three guards
* this feature has already had to widen after the fact.
*
* New files are covered for free: a module can only enter the bundle by being
* imported, which means editing an existing file, which changes that file's
* hash. The one hole would be `import.meta.glob`, which the viewer does not
* use.
*/
function exportBundlePlugin() {
return {
name: "dbx-docs-export",
generateBundle(_options: unknown, bundle: Record<string, { type: string; source?: string | Uint8Array; code?: string; modules?: Record<string, unknown> }>) {
const font = readFileSync(fontPath).toString("base64");
const fontFace = `@font-face{font-family:"Geist Variable";font-style:normal;font-display:swap;font-weight:100 900;src:url("data:font/woff2;base64,${font}") format("woff2-variations")}\n`;
const sources: Record<string, string> = {};
const deps: Record<string, string> = {};
for (const chunk of Object.values(bundle)) {
for (const id of Object.keys(chunk.modules ?? {})) {
if (!path.isAbsolute(id)) continue;
const nodeModules = id.indexOf("/node_modules/");
if (nodeModules !== -1) {
const after = id.slice(nodeModules + "/node_modules/".length);
const name = after.startsWith("@") ? after.split("/").slice(0, 2).join("/") : after.split("/")[0];
if (!(name in deps)) {
const pkg = JSON.parse(readFileSync(path.join(id.slice(0, nodeModules), "node_modules", name, "package.json"), "utf8"));
deps[name] = pkg.version;
}
continue;
}
if (!id.startsWith(repoRoot)) continue;
const relative = path.relative(repoRoot, id);
sources[relative] = sha256(readFileSync(id));
}
}
sources[path.relative(repoRoot, fontPath)] = sha256(readFileSync(fontPath));
for (const [name, chunk] of Object.entries(bundle)) {
if (name.endsWith(".css") && typeof chunk.source === "string") chunk.source = fontFace + chunk.source;
}
writeFileSync(
path.join(assetsDir, "docs-export.manifest.json"),
`${JSON.stringify({ sources: Object.fromEntries(Object.entries(sources).sort()), deps: Object.fromEntries(Object.entries(deps).sort()) }, null, 2)}\n`,
);
},
};
}
export default defineConfig({
plugins: [vue(), tailwindcss(), exportBundlePlugin()],
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
build: {
outDir: assetsDir,
emptyOutDir: false,
cssCodeSplit: false,
// One JS file. Without this the export would need to load chunks over the
// network, which is precisely what a file:// artefact cannot do.
rollupOptions: {
input: path.resolve(__dirname, "src/docs-export/main.ts"),
output: { inlineDynamicImports: true, entryFileNames: "docs-export.js", assetFileNames: "docs-export.[ext]" },
},
},
});
- Step 7: Write
main.tsandExportApp.vue
main.ts reads the base64 payload from <script type="application/dbx-snapshot">, decodes it, and mounts:
import { createApp } from "vue";
import ExportApp from "./ExportApp.vue";
import "./export.css";
const node = document.querySelector("script[type='application/dbx-snapshot']");
// `atob` yields one byte per character; the payload is UTF-8, so it must be
// widened before decoding or every non-ASCII table name and note is mangled.
const binary = atob((node?.textContent ?? "").trim());
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
const payload = JSON.parse(new TextDecoder().decode(bytes));
createApp(ExportApp, { payload }).mount("#app");
ExportApp.vue holds a route ref initialised from parseDocsHash(location.hash, payload.snapshot, true), listens for hashchange, writes location.hash = formatDocsHash(route) on update:route, renders a <select> of EXPORT_LOCALES bound to a lang ref, and renders:
<DocsApp :snapshot="payload.snapshot" :annotations="payload.annotations" :readonly="true"
:translate="translate" :route="route" diagram="inline" @update:route="route = $event" />
Language is not persisted: under file:// every document shares one opaque origin, so localStorage would leak one export's preference into an unrelated one.
- Step 8: Add the build script
In the root package.json scripts, after "build":
"build:docs-export": "vite build --config apps/desktop/vite.docs-export.config.ts",
- Step 9: Build and inspect
Run: pnpm build:docs-export
Expected: succeeds, writing three files. Verify:
ls -la crates/dbx-core/assets/docs-export.*
grep -c "url(/" crates/dbx-core/assets/docs-export.css
Expected: three files; 0 external URL references.
- Step 10: Commit
git add apps/desktop/src/docs-export apps/desktop/vite.docs-export.config.ts package.json crates/dbx-core/assets
git commit -m "$(cat <<'EOF'
feat(docs): bundle the viewer as a single-file export
One JS and one CSS, with the font inlined as a data URI, built from the
same components the app renders — which is what the purity contract on
src/docs/ has been protecting since Part 3a.
The staleness manifest is derived from Rollup's module graph rather than a
glob. SchemaDiagram.vue imports erDiagram.ts from outside src/docs/, so a
glob of that directory would miss it and pass while the artefact was stale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH
EOF
)"
Task 6: to_standalone_html
Files:
- Create:
crates/dbx-core/src/docs/export.rs - Modify:
crates/dbx-core/src/docs/mod.rs
Interfaces:
-
Consumes:
crates/dbx-core/assets/docs-export.{js,css}(Task 5);SchemaSnapshotandAnnotationFilefromdocs::snapshot/docs::annotations. -
Produces:
pub fn to_standalone_html(snapshot: &SchemaSnapshot, annotations: &AnnotationFile, lang: &str) -> Result<String, String>andpub const EXPORT_LANGUAGES: [&str; 8]. Tasks 8 and 9 both call it. -
Step 1: Write the failing tests
Create crates/dbx-core/src/docs/export.rs with only its test module, so the tests fail to compile against a missing function:
#[cfg(test)]
mod tests {
use super::*;
use crate::docs::annotations::AnnotationFile;
use crate::docs::snapshot::SchemaSnapshot;
/// `SchemaSnapshot` is `#[serde(rename_all = "camelCase")]` with
/// `format_version` at the TOP level — not inside `project` — and
/// `ProjectMeta` requires `name`, `databaseType`, `schemas` and
/// `generatedAt`. `AnnotationFile` does NOT derive `Default`, and its
/// `format_version` must be 1, so it is built explicitly.
fn fixture() -> (SchemaSnapshot, AnnotationFile) {
let snapshot: SchemaSnapshot = serde_json::from_str(
r#"{"formatVersion":1,"project":{"name":"shop","databaseType":"postgres","database":"shop","schemas":["public"],"generatedAt":"2026-08-06T00:00:00Z","note":null},"tables":[],"enums":[],"relationships":[],"groups":[],"warnings":[]}"#,
)
.expect("fixture snapshot");
let annotations = AnnotationFile {
format_version: 1,
project: None,
groups: Vec::new(),
tables: std::collections::BTreeMap::new(),
};
(snapshot, annotations)
}
#[test]
fn a_note_containing_a_closing_script_tag_survives() {
// THE reason the payload is base64. A note discussing HTML is
// entirely plausible in a schema document, and inlined as text it
// would terminate the script element early and inject the rest of
// the payload as markup.
let (snapshot, mut annotations) = fixture();
annotations.project = Some(crate::docs::annotations::ProjectAnnotation {
name: None,
note: Some("</script><img src=x onerror=alert(1)>".into()),
});
let html = to_standalone_html(&snapshot, &annotations, "en").expect("export");
assert!(!html.contains("<img src=x"), "the payload leaked into markup");
assert_eq!(html.matches("</script>").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("</script>").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:
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<String, String> {
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!(
"<!doctype html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>{title}</title>\n<style>{EXPORT_CSS}</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script type=\"application/dbx-snapshot\">{encoded}</script>\n<script>{EXPORT_JS}</script>\n</body>\n</html>\n"
))
}
fn html_escape(value: &str) -> String {
value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
Add to crates/dbx-core/src/docs/mod.rs:
pub mod export;
and to the re-export list:
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
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 `</script>` — 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) <noreply@anthropic.com>
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:
/// 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
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) <noreply@anthropic.com>
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_htmlandEXPORT_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):
#[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 <connection>"), "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<String>, to struct Flags, and beside the "--notes" arm (line 573):
"--lang" => flags.lang = Some(option_value(argv, &mut index, "--lang")?),
- Step 4: Add the verb
Beside the dbml dispatch (line 245):
if args.first().is_some_and(|arg| arg == "docs") {
return run_docs(backend, &flags).await;
}
And the function, mirroring run_dbml:
async fn run_docs(backend: &dyn DbxBackend, flags: &Flags) -> Result<String, CliError> {
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 <connection> [--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:
cargo run -p dbx-cli -- docs <connection> --database <db> --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
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) <noreply@anthropic.com>
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); thedocs.exportHtml/docs.exporting/docs.exportFailedkeys (Task 1). - Produces:
exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise<void>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:
#[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):
.route("/docs/export", post(routes::docs::export_html))
- Step 3: Add both transports
apps/desktop/src/lib/backend/tauri.ts, beside collectDocsSnapshot (line 1626):
export async function exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise<void> {
return invoke("docs_export_html", { filePath, snapshot, annotations, lang });
}
http.ts gets the same signature posting to /docs/export. api.ts, after line 185:
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:
async function exportHtml(): Promise<void> {
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:
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
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) <noreply@anthropic.com>
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
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
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH
EOF
)"
Final verification
make check— format, lint, typecheck, testsmake cargo-check-fastcargo test -p dbx-core --lib docspnpm vitest run apps/desktop/src/docs apps/desktop/src/docs-export apps/desktop/src/i18npnpm 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 docsusage
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.