feat(docs): export database documentation as standalone HTML

This commit is contained in:
Fernando Possebon 2026-08-08 00:04:16 -03:00 committed by GitHub
parent efd0c381ad
commit 63498da121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 4374 additions and 237 deletions

20
.gitattributes vendored
View File

@ -1,6 +1,14 @@
# Keep formatter-sensitive source files consistent across platforms.
# Keep formatter-sensitive source files consistent across platforms. The three
# apps/desktop rules below the .vue line exist for
# docs_export_bundle_is_current, which hashes raw bytes: every extension it
# manifests (.css, tsconfig.json, and the one .ts outside src/ —
# vite.docs-export.config.ts) needs an explicit eol=lf, or a CRLF checkout
# fails the guard for files the contributor never touched.
apps/desktop/src/**/*.ts text eol=lf
apps/desktop/src/**/*.vue text eol=lf
apps/desktop/**/*.css text eol=lf
apps/desktop/tsconfig.json text eol=lf
apps/desktop/*.ts text eol=lf
src-tauri/windows/nsis/**/*.nsi text eol=lf
# Keep tests available for review and CI without counting them as shipped code
@ -12,3 +20,13 @@ apps/desktop/src/**/*.test.ts linguist-vendored
packages/**/tests/** linguist-vendored
packages/**/*.spec.ts linguist-vendored
packages/**/*.test.ts linguist-vendored
# Build output, committed because the Rust crate embeds it. Minified and large,
# so it would drown every diff and review it appears in — `-diff` makes git
# report "Binary files differ" instead. The manifest stays diffable on purpose:
# it is how a reviewer sees which sources moved. `-text` disables any
# eol conversion on checkout: docs_export_bundle_is_current hashes these two
# files' raw bytes, and an autocrlf checkout would otherwise change those
# bytes and fail the guard for a contributor who touched neither file.
crates/dbx-core/assets/docs-export.js -text -diff linguist-generated
crates/dbx-core/assets/docs-export.css -text -diff linguist-generated

View File

@ -21,6 +21,12 @@ jobs:
env:
# The workspace intentionally contains platform-specific CLI/MCP packages for every release target.
NPM_CONFIG_LOGLEVEL: error
# Same two-layer cache as rust-test/rust-fmt-clippy: sccache cannot
# reuse Cargo incremental artifacts, so avoid generating them in CI.
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
- uses: actions/checkout@v5
@ -38,9 +44,98 @@ jobs:
- name: Install frontend dependencies
run: pnpm --filter dbx... install --frozen-lockfile
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
with:
version: "v0.10.0"
- name: Configure S3 sccache
if: env.SCCACHE_GHA_ENABLED != 'true'
shell: bash
env:
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
run: |
{
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
echo "SCCACHE_REGION=${CACHE_REGION}"
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
echo "SCCACHE_S3_USE_SSL=true"
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
} >> "$GITHUB_ENV"
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./ -> target"
# Own key: this job builds one crate with --no-default-features,
# a different fingerprint than either Rust job's feature set, so
# sharing their key would only ever miss and waste cache space.
shared-key: ci-frontend-docs-export-smoke-x86_64-unknown-linux-gnu
# Preserve completed dependency builds when a later step fails.
cache-on-failure: true
# PR caches are large and branch-scoped; restore them from main without saving per-PR copies.
save-if: ${{ github.ref == 'refs/heads/main' }}
# exportSmoke.spec.ts shells out to this example to build a real
# to_standalone_html fixture. Built here, before `pnpm check`, so the
# test's own `cargo run` (same flags — see the spec's comment) is a
# cache hit rather than a fresh compile of dbx-core inside a vitest
# hook. `--no-default-features`: the docs module needs none of
# duckdb-sidecar, mq-admin, sqlite-sqlcipher or system-fonts, so this
# avoids requiring any system dev packages this job doesn't already
# install.
- name: Build the docs export smoke fixture example
run: cargo build -p dbx-core --locked --no-default-features --example docs_export_smoke
# Guards that the committed bundle (crates/dbx-core/assets/docs-export.*)
# matches the sources under apps/desktop/ it was built from. This job is
# what runs on a frontend-only change, so the guard has to run here too —
# rust-test is gated on crates/**/src-tauri/**/Cargo.*, none of which a
# frontend-only PR touches. Same `--no-default-features` fingerprint as
# the prebuild step above, so this is a cache hit, not a fresh compile.
- name: Guard the committed docs export bundle
run: cargo test -p dbx-core --locked --no-default-features --lib docs::export
- name: Show sccache stats
if: always()
continue-on-error: true
run: ${SCCACHE_PATH} --show-stats
- name: Frontend check
run: pnpm check
# Advisory, not a guard: the manifest guard above hashes INPUTS, so it
# cannot see a hand-edited artefact (no source hash moves) or a
# toolchain bump (vite, tailwindcss and @vitejs/plugin-vue are
# plugins, not modules — they never enter the graph). Rebuilding and
# diffing checks the relationship between inputs and output instead,
# closing both gaps. Must run last: it overwrites the committed
# bundle, and both the guard above and `pnpm check` above (whose
# exportSmoke.spec.ts executes the *committed* bundle) need it
# untouched to be testing what is actually checked in.
- name: Rebuild the docs export bundle
run: pnpm build:docs-export
# continue-on-error for one cycle: every reproducibility observation
# so far was same-platform, and this job is ubuntu-22.04 x86_64 like
# every contributor's toolchain is lockfile-pinned to expect. If a
# byte ever differs cross-platform, surface it in the job summary
# rather than reddening every PR at once on a repo we contribute to,
# not maintain.
- name: Report any docs export bundle drift
run: git diff --exit-code -- crates/dbx-core/assets/
continue-on-error: true
github-scripts:
needs: changes
if: needs.changes.outputs.github_scripts == 'true'
@ -476,6 +571,8 @@ jobs:
- 'package.json'
- '.oxfmtrc.json'
- 'scripts/run-check.mjs'
- 'crates/dbx-core/src/docs/**'
- 'crates/dbx-core/assets/docs-export.*'
- '.github/workflows/ci.yml'
packages:
- 'packages/cli/**'

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Network } from "@lucide/vue";
import { Download, Network } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DocsApp from "@/docs/DocsApp.vue";
@ -9,9 +9,14 @@ import { emptyAnnotations, removeGroup, setColumnNote, setProjectNote, setTableG
import type { AnnotationFile, DocsEdit, SchemaSnapshot } from "@/docs/types";
import type { Translate } from "@/docs/docsWarnings";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useConnectionStore } from "@/stores/connectionStore";
import { createAutosave } from "./docsAutosave";
// The languages `to_standalone_html` accepts. Kept in sync manually this
// component lives outside `src/docs/` and cannot import from dbx-core.
const EXPORT_LANGUAGES = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"];
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
@ -23,7 +28,7 @@ const open = defineModel<boolean>("open", { default: false });
// This component lives OUTSIDE src/docs/, so it may and must use useI18n():
// it is what supplies the `translate` prop that the viewer components need,
// since they are banned from importing vue-i18n themselves.
const { t } = useI18n();
const { t, locale } = useI18n();
// `Translate` is one narrow signature; vue-i18n's `t` is heavily overloaded and
// does not assign to it directly, so bridge it explicitly.
@ -70,6 +75,9 @@ const statusLabel = computed(() => {
const canOpenDiagram = computed(() => (props.prefillConnectionId ?? "") !== "" && (props.prefillDatabase ?? "") !== "");
const exporting = ref(false);
const exportError = ref<string | null>(null);
async function load(): Promise<void> {
const connectionId = props.prefillConnectionId;
const database = props.prefillDatabase;
@ -153,6 +161,32 @@ function openDiagram(): void {
connectionStore.diagramSource = { connectionId, database, schema: props.prefillSchema };
}
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;
}
// `to_standalone_html` rejects any language outside its fixed list; the
// app's locale is otherwise a superset risk, so fall back rather than
// surface that rejection to the user.
const lang = EXPORT_LANGUAGES.includes(locale.value) ? locale.value : "en";
await api.exportDocsHtml(outputPath, snapshot.value, annotations.value, lang);
} catch (error) {
// Never swallowed: a failed export that reports success is the worst
// outcome here, exactly as with a failed autosave.
exportError.value = t("docs.exportFailed", { error: String(error) });
} finally {
exporting.value = false;
}
}
watch(
open,
(isOpen, wasOpen) => {
@ -179,10 +213,15 @@ watch(
<span v-if="statusLabel" class="text-xs font-normal" :class="status.state === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ statusLabel }}
</span>
<span v-if="exportError" class="text-xs font-normal text-destructive">{{ exportError }}</span>
<Button v-if="canOpenDiagram" variant="outline" size="sm" class="ml-auto" @click="openDiagram()">
<Network class="w-4 h-4" />
{{ t("docs.openDiagram") }}
</Button>
<Button v-if="snapshot" variant="outline" size="sm" :disabled="exporting" @click="exportHtml()">
<Download class="w-4 h-4" />
{{ exporting ? t("docs.exporting") : t("docs.exportHtml") }}
</Button>
</DialogTitle>
</DialogHeader>

View File

@ -1,8 +1,9 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { readCascadeCss } from "../../../styles/__tests__/cascadeCss";
const nodeTreeSource = readFileSync(new URL("../ExplainPlanNodeTree.vue", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../../../styles/globals.css", import.meta.url), "utf8");
const globalStyles = readCascadeCss();
type Rgb = [number, number, number];

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import DocsApp from "@/docs/DocsApp.vue";
import { formatDocsHash, parseDocsHash } from "@/docs/docsRoute";
import type { DocsRoute } from "@/docs/docsRoute";
import { LOCALE_OPTIONS } from "@/lib/app/localeOptions";
import type { ExportPayload } from "./exportPayload";
import { createExportTranslate, EXPORT_LOCALES } from "./exportTranslate";
import type { ExportLocale } from "./exportTranslate";
const props = defineProps<{ payload: ExportPayload }>();
/**
* The reader is not the exporter, so both controls below are theirs to change
* and neither is persisted: under `file://` every document shares one opaque
* origin, so `localStorage` would leak one export's preference into an
* unrelated one.
*/
const lang = ref<ExportLocale>(props.payload.lang in EXPORT_LOCALES ? props.payload.lang : "en");
const translate = computed(() => createExportTranslate(lang.value));
// Derived from the app's own list so the endonyms cannot drift, filtered to
// what this bundle can actually render.
const languages = LOCALE_OPTIONS.filter((option) => option.value in EXPORT_LOCALES);
/**
* Nothing applies `.dark` in a standalone file: DBX's theme system is not
* here. `prefers-color-scheme` is the only signal the document has on load,
* and the select overrides it from then on.
*/
const theme = ref<"light" | "dark">(typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light");
watch(
theme,
(value) => {
// The same two writes the app makes (composables/useTheme.ts): the class
// drives the tokens and the `dark:` variant, `color-scheme` drives the
// form controls and scrollbars the page does not style itself.
const root = document.documentElement;
root.classList.toggle("dark", value === "dark");
root.style.colorScheme = value;
},
{ immediate: true },
);
// export.rs sets `<html lang>` correctly at export time, but that is a
// snapshot of the moment the file was generated nothing here kept it in
// sync with the reader's own choice. Without this, switching languages left
// screen readers and hyphenation reading the export-time locale forever.
watch(
lang,
(value) => {
document.documentElement.lang = value;
},
{ immediate: true },
);
const route = ref<DocsRoute>(parseDocsHash(location.hash, props.payload.snapshot, true));
// The URL is the source of truth, so Back and Forward work and a link to a
// table survives being copied out of the address bar.
function readHash(): void {
route.value = parseDocsHash(location.hash, props.payload.snapshot, true);
}
function navigate(next: DocsRoute): void {
route.value = next;
const hash = formatDocsHash(next);
if (location.hash !== hash) location.hash = hash;
}
onMounted(() => window.addEventListener("hashchange", readHash));
onBeforeUnmount(() => window.removeEventListener("hashchange", readHash));
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background text-foreground">
<div class="flex shrink-0 flex-wrap items-center justify-end gap-4 border-b border-border px-4 py-2">
<label class="flex items-center gap-2 text-xs text-muted-foreground">
{{ translate("docs.theme") }}
<select v-model="theme" class="rounded border border-border bg-background px-2 py-1 text-xs text-foreground">
<option value="light">{{ translate("docs.themeLight") }}</option>
<option value="dark">{{ translate("docs.themeDark") }}</option>
</select>
</label>
<label class="flex items-center gap-2 text-xs text-muted-foreground">
{{ translate("docs.language") }}
<select v-model="lang" class="rounded border border-border bg-background px-2 py-1 text-xs text-foreground">
<option v-for="option in languages" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</label>
</div>
<DocsApp class="min-h-0 flex-1" :snapshot="payload.snapshot" :annotations="payload.annotations" :readonly="true" :translate="translate" :route="route" diagram="inline" @update:route="navigate" />
</div>
</template>

View File

@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import { readPayload } from "../exportPayload";
/** Write the document Task 6's Rust side emits: base64 of UTF-8 JSON. */
function embed(payload: unknown): void {
document.body.innerHTML = `<div id="app"></div>`;
const node = document.createElement("script");
node.type = "application/dbx-snapshot";
node.textContent = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
document.body.appendChild(node);
}
const snapshot = { formatVersion: 1, project: { name: "顧客データベース", databaseType: "postgres" }, tables: [{ schema: "public", name: "顧客", columns: [{ name: "名前", note: "Café — naïve ★" }] }] };
afterEach(() => {
document.body.innerHTML = "";
});
describe("readPayload", () => {
it("round-trips non-ASCII through base64", () => {
// The regression this exists to catch: `JSON.parse(atob(text))` mounts and
// renders, so every test downstream of it passes — while a Japanese table
// name arrives as mojibake in a file someone opens offline with no way to
// report it. atob yields one byte per character; the payload is UTF-8.
embed({ snapshot, annotations: { formatVersion: 1 }, lang: "ja" });
const payload = readPayload();
expect(payload.snapshot.project.name).toBe("顧客データベース");
expect(payload.snapshot.tables[0].name).toBe("顧客");
expect(payload.snapshot.tables[0].columns[0].note).toBe("Café — naïve ★");
expect(payload.lang).toBe("ja");
});
it("carries the annotations layer through unchanged", () => {
// Task 6 must emit this key even when empty — DocsApp reads
// `annotations.groups` and a missing object throws before anything renders.
embed({ snapshot, annotations: { formatVersion: 1, groups: [{ id: "a", name: "Grupo", hue: 210 }] }, lang: "pt-BR" });
expect(readPayload().annotations.groups?.[0].name).toBe("Grupo");
});
it("names the missing element rather than throwing something opaque", () => {
document.body.innerHTML = `<div id="app"></div>`;
expect(() => readPayload()).toThrow(/application\/dbx-snapshot/);
});
it("throws on a payload that is not base64", () => {
document.body.innerHTML = `<div id="app"></div><script type="application/dbx-snapshot">not base64 at all!</script>`;
expect(() => readPayload()).toThrow();
// The two failures must stay distinguishable: a document with no payload
// and a document with a damaged one are different problems for whoever
// produced the file. The decoder's own wording is not asserted — Chrome,
// Firefox and Safari each phrase it differently — but it must not be
// mistaken for the missing-element case. `main.spec.ts` covers what the
// reader actually sees for both.
expect(() => readPayload()).not.toThrow(/application\/dbx-snapshot/);
});
});

View File

@ -0,0 +1,53 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
// @vitest-environment happy-dom
// Not under the workspace's `target/`: this repo's global `~/.cargo/config.toml`
// can redirect cargo's target-dir elsewhere (to dedupe build artefacts across
// worktrees), so a path assuming `<repo>/target/` is not portable. `os.tmpdir()`
// has no such dependency on cargo configuration.
const htmlPath = path.join(os.tmpdir(), "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.
//
// `--no-default-features`: the docs module doesn't touch duckdb-sidecar,
// mq-admin, sqlite-sqlcipher or system-fonts, and matching that flag here
// to the CI workflow's prebuild step (same cargo invocation, same
// fingerprint) is what makes this a cache hit instead of a fresh compile
// of dbx-core inside the hook. If this flag ever drifts from the
// workflow's build step, the hook goes back to compiling from scratch
// and risks the 120s timeout below.
execFileSync("cargo", ["run", "-p", "dbx-core", "--no-default-features", "--example", "docs_export_smoke", "--", htmlPath], { stdio: "inherit" });
// The CI workflow prebuilds this example in its own step so this hook
// only pays for a freshness check and a run (well under a second warm,
// measured locally). 120s covers a slow/cold worst case without making
// an unrelated hook's timeout wait this long by raising it globally.
}, 120_000);
it("mounts and renders a table name", async () => {
// The route is read from `location.hash` at mount time (ExportApp.vue),
// so landing directly on the table page is what makes the assertion
// below check rendered CONTENT rather than merely an element's presence:
// the index page lists "orders" and "public" as separate text nodes
// (a schema header, then a bare table name), never the joined string
// "public.orders" that only the table page's heading produces.
location.hash = "#/table/public.orders";
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));
const rendered = document.querySelector("#app")?.textContent ?? "";
expect(rendered).toContain("public.orders");
// Column data, not just the heading — proves the fixture's real
// structure (built by the Rust example, not this test) reached the DOM.
expect(rendered).toContain("customer_id");
});
});

View File

@ -0,0 +1,27 @@
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");
});
});

View File

@ -0,0 +1,40 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mounting is `main.ts`'s module-level side effect, so importing it IS the
// test. The module cache would return the first import's result for the
// second, running nothing — hence resetModules between cases.
beforeEach(() => {
vi.resetModules();
document.body.innerHTML = `<div id="app"></div>`;
});
describe("the export entry point", () => {
it("says so when the document carries no payload at all", async () => {
await import("../main");
expect(document.querySelector("#app")?.textContent).toContain("could not be read");
expect(document.querySelector("#app")?.textContent).toContain("application/dbx-snapshot");
});
it("says so when the payload is present but not decodable", async () => {
// A truncated file, or one an editor has line-wrapped and mangled. This
// failure arrives as a DOMException from `atob` rather than the Error
// thrown above, so it is a genuinely different path through the catch —
// and the reader must still get a sentence instead of a white screen.
const node = document.createElement("script");
node.type = "application/dbx-snapshot";
node.textContent = "not base64 at all!";
document.body.appendChild(node);
await import("../main");
const rendered = document.querySelector("#app")?.textContent ?? "";
expect(rendered).toContain("could not be read");
// Deliberately not asserting the decoder's own wording: Chrome, Firefox and
// Safari each phrase the atob failure differently, and pinning one of them
// would make this test a statement about the runtime rather than about the
// export. What must hold is that the message is ours and is not the
// missing-element one.
expect(rendered).not.toContain("no <script");
expect(rendered.length).toBeGreaterThan("This documentation file could not be read: ".length);
});
});

View File

@ -0,0 +1,37 @@
/*
* The standalone export's Tailwind entry point.
*
* `source(none)` turns OFF automatic content detection, which otherwise scans
* the whole repository from the project root and would emit every utility in
* DBX the same reason this file cannot simply import globals.css. `@source`
* is additive with no way to un-source, so the narrowing has to happen here,
* at the import.
*/
@import "tailwindcss" source(none);
@source "../docs/**/*.vue";
@source "../docs-export/**/*.vue";
/*
* Carries the raw `--background`/`--border` properties AND the `@theme inline`
* block that maps them onto `--color-*`. Without that block Tailwind defines
* every custom property and generates none of `bg-background`,
* `border-border`, `fill-card` a build that succeeds while emitting an
* unstyled page.
*/
@import "../styles/tokens.css";
/*
* The export owns its own page box. Task 6 emits the surrounding HTML from
* Rust, so nothing outside this bundle can be relied on to size the document:
* DocsApp is `h-full` and would collapse to zero height against an unsized
* ancestor.
*/
html,
body,
#app {
height: 100%;
}
/* No `@font-face` here the Vite plugin prepends one with the font already
* inlined as a data URI, so no rule can reference a path that fails under
* `file://`. */

View File

@ -0,0 +1,37 @@
import type { AnnotationFile, SchemaSnapshot } from "@/docs/types";
import type { ExportLocale } from "./exportTranslate";
/**
* The contract between the exporter and this bundle.
*
* Task 6's Rust side serialises exactly this object as JSON, encodes it UTF-8
* then base64, and writes it as the text of
* `<script type="application/dbx-snapshot">`. Nothing else in the emitted
* document is read by the bundle.
*
* `lang` picks the starting locale only. The reader can change it the
* person who exported the file is rarely the person who opens it.
*/
export interface ExportPayload {
snapshot: SchemaSnapshot;
annotations: AnnotationFile;
lang: ExportLocale;
}
/**
* Read the snapshot the exporter embedded in the document.
*
* This lives here rather than in `main.ts` so a spec can call it: importing
* `main.ts` mounts the application as a side effect. It is the whole of the
* contract with Task 6's Rust side, and the only thing in the emitted document
* the bundle reads.
*/
export function readPayload(): ExportPayload {
const node = document.querySelector("script[type='application/dbx-snapshot']");
if (node === null) throw new Error("no <script type='application/dbx-snapshot'> in this document");
// `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));
return JSON.parse(new TextDecoder().decode(bytes)) as ExportPayload;
}

View File

@ -0,0 +1,41 @@
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));
};
}

View File

@ -0,0 +1,16 @@
import { createApp } from "vue";
import ExportApp from "./ExportApp.vue";
import { readPayload } from "./exportPayload";
import "./export.css";
try {
createApp(ExportApp, { payload: readPayload() }).mount("#app");
} catch (error) {
// A blank page would be the reader's only clue that the file is damaged.
// English is not a choice here: `lang` lives inside the payload that just
// failed to parse, so there is no locale to translate into.
const message = document.createElement("p");
message.style.cssText = "margin:2rem;font-family:system-ui,sans-serif";
message.textContent = `This documentation file could not be read: ${error instanceof Error ? error.message : String(error)}`;
document.querySelector("#app")?.replaceChildren(message);
}

View File

@ -5,30 +5,44 @@ import DocsSidebar from "./components/DocsSidebar.vue";
import EnumPage from "./components/EnumPage.vue";
import GroupEditor from "./components/GroupEditor.vue";
import NoteEditor from "./components/NoteEditor.vue";
import SchemaDiagram from "./components/SchemaDiagram.vue";
import TablePage from "./components/TablePage.vue";
import WarningBanner from "./components/WarningBanner.vue";
import WikiIndex from "./components/WikiIndex.vue";
import "./docs.css";
import type { Translate } from "./docsWarnings";
import { qualifiedTableKey } from "./docsKeys";
import type { DocsRoute } from "./docsRoute";
import { groupBySchema, groupByTableGroup } from "./docsIndex";
import type { AnnotationFile, DocsEdit, GroupAnnotation, SchemaSnapshot } from "./types";
const props = defineProps<{
snapshot: SchemaSnapshot;
/**
* The local notes layer. `snapshot` already carries notes merged for display,
* so this is here for what the merge erases: `groups` holds the editable
* `GroupAnnotation` records, while `snapshot.groups` holds resolved
* `TableGroup`s that GroupEditor and GroupPicker cannot write back to.
*/
annotations: AnnotationFile;
readonly?: boolean;
translate: Translate;
}>();
const props = withDefaults(
defineProps<{
snapshot: SchemaSnapshot;
/**
* The local notes layer. `snapshot` already carries notes merged for display,
* so this is here for what the merge erases: `groups` holds the editable
* `GroupAnnotation` records, while `snapshot.groups` holds resolved
* `TableGroup`s that GroupEditor and GroupPicker cannot write back to.
*/
annotations: AnnotationFile;
readonly?: boolean;
translate: Translate;
/**
* 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";
}>(),
{ diagram: "external" },
);
const emit = defineEmits<{
edit: [edit: DocsEdit];
"update:route": [route: DocsRoute];
}>();
/** `readonly` is the one optional prop; absent means editing is allowed. */
@ -39,8 +53,26 @@ const annotationGroups = computed<GroupAnnotation[]>(() => props.annotations.gro
// Grouping is computed once here and handed to both the sidebar and the index,
// so the two can never disagree about what the sections are.
const mode = ref<"schema" | "group">(props.snapshot.groups.length > 0 ? "group" : "schema");
const activeKey = ref<string | null>(null);
const activeEnumName = ref<string | null>(null);
// Owned only when `route` is absent see the prop doc above. `effectiveRoute`
// prefers `props.route`, so once the host controls navigation, a click here
// still updates these (harmless) but the DISPLAYED view tracks the prop, not
// this state. That is what keeps the two from disagreeing if the host
// declines to apply an `update:route`.
const internalKey = ref<string | null>(null);
const internalEnumName = ref<string | null>(null);
const internalDiagram = ref(false);
const effectiveRoute = computed<DocsRoute>(() => {
if (props.route) return props.route;
if (internalDiagram.value) return { kind: "diagram" };
if (internalEnumName.value !== null) return { kind: "enum", name: internalEnumName.value };
if (internalKey.value !== null) return { kind: "table", key: internalKey.value };
return { kind: "index" };
});
const activeKey = computed(() => (effectiveRoute.value.kind === "table" ? effectiveRoute.value.key : null));
const activeEnumName = computed(() => (effectiveRoute.value.kind === "enum" ? effectiveRoute.value.name : null));
const sections = computed(() => (mode.value === "schema" ? groupBySchema(props.snapshot) : groupByTableGroup(props.snapshot)));
@ -53,7 +85,10 @@ const activeTable = computed(() => props.snapshot.tables.find((table) => qualifi
*/
const activeEnum = computed(() => (activeEnumName.value === null ? null : (props.snapshot.enums.find((value) => value.name === activeEnumName.value) ?? null)));
const view = computed<"index" | "table" | "enum">(() => {
const view = computed<"index" | "table" | "enum" | "diagram">(() => {
if (effectiveRoute.value.kind === "diagram") {
return "diagram";
}
if (activeEnum.value !== null) {
return "enum";
}
@ -72,21 +107,34 @@ function open(key: string): void {
// A key naming no table leaves the reader where they are rather than
// dropping them on a blank page.
if (props.snapshot.tables.some((table) => qualifiedTableKey(table) === key)) {
activeEnumName.value = null;
activeKey.value = key;
internalEnumName.value = null;
internalDiagram.value = false;
internalKey.value = key;
emit("update:route", { kind: "table", key });
}
}
function openEnum(name: string): void {
if (props.snapshot.enums.some((value) => value.name === name)) {
activeKey.value = null;
activeEnumName.value = name;
internalKey.value = null;
internalDiagram.value = false;
internalEnumName.value = name;
emit("update:route", { kind: "enum", name });
}
}
function openDiagram(): void {
internalKey.value = null;
internalEnumName.value = null;
internalDiagram.value = true;
emit("update:route", { kind: "diagram" });
}
function home(): void {
activeKey.value = null;
activeEnumName.value = null;
internalKey.value = null;
internalEnumName.value = null;
internalDiagram.value = false;
emit("update:route", { kind: "index" });
}
/**
@ -109,7 +157,18 @@ function createGroupFor(tableKey: string): void {
<template>
<div class="flex h-full min-h-0 bg-background text-foreground">
<DocsSidebar :sections="sections" :mode="mode" :active-key="activeKey" @update:mode="mode = $event" @select="open" @home="home()" />
<div class="flex h-full min-h-0 w-64 shrink-0 flex-col">
<DocsSidebar class="min-h-0 flex-1" :sections="sections" :mode="mode" :active-key="activeKey" :translate="translate" @update:mode="mode = $event" @select="open" @home="home()" />
<button
v-if="diagram === 'inline'"
type="button"
class="shrink-0 border-t border-r border-border bg-background px-3 py-2 text-left text-xs font-medium transition-colors"
:class="view === 'diagram' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'"
@click="openDiagram()"
>
{{ translate("docs.diagram") }}
</button>
</div>
<main class="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<header class="flex flex-wrap items-start justify-between gap-3">
@ -119,17 +178,17 @@ function createGroupFor(tableKey: string): void {
{{ snapshot.project.databaseType }}<template v-if="snapshot.project.database"> · {{ snapshot.project.database }}</template> · {{ snapshot.tables.length }} tables · generated {{ snapshot.project.generatedAt }}
</p>
</div>
<DocsSearch :snapshot="snapshot" @select="open" @select-enum="openEnum" />
<DocsSearch :snapshot="snapshot" :translate="translate" @select="open" @select-enum="openEnum" />
</header>
<WarningBanner :warnings="snapshot.warnings" :translate="translate" />
<div v-if="view === 'index'" class="flex flex-col gap-4">
<NoteEditor :model-value="snapshot.project.note ?? ''" :readonly="isReadonly" :translate="translate" @update:model-value="emit('edit', { kind: 'projectNote', note: $event })" />
<WikiIndex :sections="sections" @select="open" />
<WikiIndex :sections="sections" :translate="translate" @select="open" />
<section v-if="!isReadonly && annotationGroups.length > 0" class="flex flex-col gap-2">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Groups</h2>
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.groups") }}</h2>
<GroupEditor v-for="group in annotationGroups" :key="group.id" :group="group" :translate="translate" @update:group="emit('edit', { kind: 'upsertGroup', group: $event })" @delete="emit('edit', { kind: 'removeGroup', groupId: $event })" />
</section>
</div>
@ -148,6 +207,8 @@ function createGroupFor(tableKey: string): void {
/>
<EnumPage v-else-if="activeEnum" :enum-type="activeEnum" :snapshot="snapshot" :translate="translate" @select="open" />
<SchemaDiagram v-else-if="view === 'diagram'" :snapshot="snapshot" @select="open" />
</main>
</div>
</template>

View File

@ -2,17 +2,18 @@ import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "vue/compiler-sfc";
import ts from "typescript";
const docsRoot = path.resolve(__dirname, "..");
function vueFiles(): string[] {
function filesWithExtension(extension: string): string[] {
const found: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "__tests__" && entry.name !== "fixtures") {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".vue")) {
} else if (entry.isFile() && entry.name.endsWith(extension)) {
found.push(full);
}
}
@ -21,12 +22,122 @@ function vueFiles(): string[] {
return found;
}
function vueFiles(): string[] {
return filesWithExtension(".vue");
}
// Every pure module beside the components — docsIndex.ts, docsSearch.ts,
// docsWarnings.ts, and so on. "(no group)" lived in one of these
// (docsIndex.ts) and was invisible to the guard below until this existed:
// that guard only ever walked .vue files, so a literal that never touches a
// template — only a data field a template later reads — was outside its
// reach entirely.
function tsFiles(): string[] {
return filesWithExtension(".ts");
}
/**
* Every namespace-key VALUE currently spelled out in en.ts, long enough and
* plain enough to check for. Shared by both the .vue and .ts scans below so
* there is exactly one definition of "what counts as a hardcoded literal"
* not two lists that can drift out of step with each other.
*/
async function namespaceLiterals(): Promise<string[]> {
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);
return literals;
}
function scriptOf(file: string): string {
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
return `${descriptor.script?.content ?? ""}\n${descriptor.scriptSetup?.content ?? ""}`;
}
const EXPECTED = ["ColumnTable.vue", "DocsApp.vue", "DocsSearch.vue", "DocsSidebar.vue", "EnumPage.vue", "GroupEditor.vue", "GroupPicker.vue", "NoteEditor.vue", "RelationshipList.vue", "TablePage.vue", "WarningBanner.vue", "WikiIndex.vue"];
/**
* Splits a component's template into the two channels that can actually
* reach the reader as text: static text between tags, and the JS
* expressions inside `{{ }}` interpolations. Attribute values (`:class="…"`,
* `:title="…"`) are deliberately excluded see the guard below for why.
*
* The two channels get different treatment below: `staticText` is scanned
* with plain substring matching, because it can't contain anything but
* literal characters. `interpolations` holds JS, where a bare identifier
* (`table.viewDefinition`, `snapshot.project.databaseType`) can innocently
* contain a locale word as a substring only a *quoted* occurrence
* (`"literal"` or `'literal'`) inside an expression counts as hardcoded copy.
*/
function domTextOf(file: string): { staticText: string; interpolations: string[] } {
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
const template = descriptor.template?.content ?? "";
// Comments are prose, not display text, and can otherwise trip a
// substring match by coincidence (a comment mentioning "the note" would
// false-positive on the `noteHeader` key).
const withoutComments = template.replace(/<!--[\s\S]*?-->/g, "");
const interpolations: string[] = [];
const withoutInterpolations = withoutComments.replace(/{{([\s\S]*?)}}/g, (_match, expr) => {
interpolations.push(expr);
return "";
});
// Whatever sits between a tag's closing `>` and the next `<` is DOM text;
// whatever sits before that `>`, inside the tag itself, is an attribute
// and mostly doesn't render as visible text — so this intentionally
// doesn't look there on purpose. "Mostly" because the split is a regex,
// not a parser: an attribute value containing a bare `>` (a comparison
// like `v-if="count > 0"`) ends the match early and leaks the tail of
// that attribute into a segment. That leak runs in the safe direction —
// it can only add text to scan, never drop real display text — so the
// risk is a false positive below, never a missed literal.
const segments: string[] = [];
for (const match of withoutInterpolations.matchAll(/>([^<]*)</g)) {
segments.push(match[1]);
}
return { staticText: segments.join(" "), interpolations };
}
/**
* Every string literal in a .ts file that could become a runtime VALUE
* and, from there, reach a template as display text, exactly how
* docsIndex.ts's `label: "(no group)"` did. Excludes string literals in TYPE
* position, e.g. `type NoteSource = "DATABASE" | "LOCAL" | "NONE"`: those
* disappear at compile time and can never be displayed, so flagging them
* would be the .ts equivalent of the `=== 'LOCAL'` false positive from the
* last round. `ts.isLiteralTypeNode` is TypeScript's own distinction
* between the two, not a hand-picked exception the same check the
* compiler itself uses to know it is looking at a type, not a value.
*
* Unlike domTextOf's split of display-vs-comparison text, this collects
* every value-position literal with no such distinction a literal used
* only in a comparison (`type === "LOCAL"`) is indistinguishable here from
* one that reaches a template as display text. Accepted as this scan's
* blind spot rather than chased, for the same reason: the risk is a false
* positive, not a missed literal.
*/
function valueStringLiteralsOf(file: string): string[] {
const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
const literals: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isStringLiteral(node) && !ts.isLiteralTypeNode(node.parent)) {
literals.push(node.text);
}
ts.forEachChild(node, visit);
};
visit(source);
return literals;
}
const EXPECTED = ["ColumnTable.vue", "DocsApp.vue", "DocsSearch.vue", "DocsSidebar.vue", "EnumPage.vue", "GroupEditor.vue", "GroupPicker.vue", "NoteEditor.vue", "RelationshipList.vue", "SchemaDiagram.vue", "TablePage.vue", "WarningBanner.vue", "WikiIndex.vue"];
describe("docs viewer component contract", () => {
it("finds every expected component", () => {
@ -154,4 +265,65 @@ describe("docs viewer component contract", () => {
expect(block, `${selector} must define --group-tint without oklch`).toContain("--group-tint: hsl(");
}
});
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 literals = await namespaceLiterals();
expect(literals.length).toBeGreaterThan(10);
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
for (const file of files) {
// Deliberately scoped to what reaches the DOM as text — see domTextOf
// — rather than the whole file. Two earlier, narrower shapes of this
// same guard both ran green over a real defect: matching only
// `>literal<` misses a literal buried in a JS fallback
// (`section.label || "(no schema)"`, DocsSidebar.vue/WikiIndex.vue);
// matching any quoted occurrence anywhere in the template misses
// nothing display-related but flags plenty that isn't — a bare
// identifier like `table.viewDefinition` or
// `snapshot.project.databaseType` shares a substring with a locale
// word purely by accident, and an attribute-only comparison like
// `noteOf(column)?.source === 'LOCAL'` tests data, not copy.
const { staticText, interpolations } = domTextOf(file);
for (const literal of literals) {
// Static text, e.g. `>Overview<` or `>⬤ LOCAL<` — anything literally
// written between tags is definitionally display text, so a plain
// substring match is safe here.
expect(staticText.includes(literal), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
// A JS expression inside a `{{ }}` mustache, e.g.
// `section.label || "(no schema)"`. Only a *quoted* occurrence
// counts — an unquoted one is a property/variable name, not copy.
expect(
interpolations.some((expr) => expr.includes(`"${literal}"`) || expr.includes(`'${literal}'`)),
`${path.basename(file)} hardcodes "${literal}" in a template expression — call translate() instead`,
).toBe(false);
}
}
// Known gap, accepted rather than chased: a literal passed to an
// attribute binding — `:title="'Hardcoded text'"` — can still reach the
// reader (as a tooltip, an aria-label, …) and this guard does not see
// it, because attribute values are excluded by design to avoid the
// data-comparison false positives above. If a future defect turns out
// to live in an attribute, that's this guard's known blind spot, not a
// regression in it.
// The pure modules beside the components — docsIndex.ts's
// `label: "(no group)"` was exactly this: a literal that never touches a
// template directly, only a data field a template reads later. A .vue-only
// scan is structurally blind to it no matter how the .vue scan itself is
// shaped, so this is a second, independent walk over a different file
// extension rather than a wider regex over the same one.
const tsSourceFiles = tsFiles();
expect(tsSourceFiles.length).toBeGreaterThan(0);
for (const file of tsSourceFiles) {
const valueLiterals = valueStringLiteralsOf(file);
for (const literal of literals) {
expect(valueLiterals.includes(literal), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
}
}
});
});

View File

@ -0,0 +1,35 @@
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 });
});
});

View File

@ -45,6 +45,14 @@ describe("groupBySchema", () => {
expect(sections).toHaveLength(1);
expect(sections[0].tables[0].name).toBe("orders");
});
it("tags every section with the schema fallback key", () => {
// The render sites fall back to `translate(section.fallbackKey)` when
// `label` is empty — schema sections must carry docs.noSchema, not the
// sibling docs.noGroup key groupByTableGroup uses.
const sections = groupBySchema(snapshot([table("public", "orders")]));
expect(sections[0].fallbackKey).toBe("docs.noSchema");
});
});
describe("groupByTableGroup", () => {
@ -62,11 +70,18 @@ describe("groupByTableGroup", () => {
expect(sections[0].note).toBe("Checkout.");
});
it("collects ungrouped tables into a trailing (no group) section", () => {
it("collects ungrouped tables into a trailing, unlabelled section", () => {
const sections = groupByTableGroup(snapshot([table("core", "orders", "order-mgmt"), table("core", "users", null)], groups));
const last = sections[sections.length - 1];
expect(last.key).toBe("");
// Empty, not a hardcoded "(no group)": the render sites translate this via
// `translate(section.fallbackKey)` when `label` is falsy. A non-empty
// English literal here would bypass that fallback and render untranslated
// in every non-English locale — this guarded a real defect, not a
// hypothetical one.
expect(last.label).toBe("");
expect(last.fallbackKey).toBe("docs.noGroup");
expect(last.hue).toBeNull();
expect(last.tables.map((t) => t.name)).toEqual(["users"]);
});

View File

@ -0,0 +1,64 @@
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");
});
});

View File

@ -0,0 +1,42 @@
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);
});
});

View File

@ -68,10 +68,10 @@ function shadowedTitle(column: ColumnInfo): string | undefined {
<table class="w-full text-xs">
<thead>
<tr class="bg-muted/30">
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Column</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Type</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Settings</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Note</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.columnHeader") }}</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.typeHeader") }}</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.settingsHeader") }}</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.noteHeader") }}</th>
</tr>
</thead>
<tbody>
@ -87,7 +87,7 @@ function shadowedTitle(column: ColumnInfo): string | undefined {
</td>
<td class="px-2 py-1.5 text-muted-foreground">
<div class="flex items-start gap-1">
<span v-if="noteOf(column)?.source === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium" :title="shadowedTitle(column)"> LOCAL</span>
<span v-if="noteOf(column)?.source === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium" :title="shadowedTitle(column)"> {{ translate("docs.localNote") }}</span>
<!-- Merged note, for the same reason as TablePage: NoteEditor
renders and edits one value, and the local layer alone would
hide notes that came from the database. -->

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { searchDocs, type SearchHit } from "../docsSearch";
import type { Translate } from "../docsWarnings";
import type { SchemaSnapshot } from "../types";
const props = defineProps<{
snapshot: SchemaSnapshot;
translate: Translate;
}>();
const emit = defineEmits<{
@ -71,13 +73,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
<template>
<div>
<button type="button" class="flex items-center gap-2 rounded border border-border bg-background px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/40" @click="show()">
<span>Search</span>
<span>{{ translate("docs.searchLabel") }}</span>
<span class="rounded bg-muted/50 px-1 py-0.5 text-[10px]">K</span>
</button>
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24" @click.self="open = false">
<div class="w-full max-w-lg overflow-hidden rounded-md border border-border bg-background shadow-lg">
<input ref="input" v-model="query" type="text" placeholder="Search tables, columns, groups, enums…" class="w-full border-b border-border bg-transparent px-3 py-2 text-sm text-foreground outline-none" />
<input ref="input" v-model="query" type="text" :placeholder="translate('docs.search')" class="w-full border-b border-border bg-transparent px-3 py-2 text-sm text-foreground outline-none" />
<ul class="max-h-80 overflow-y-auto">
<li v-if="query.trim() !== '' && hits.length === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">Nothing matches {{ query }}.</li>
<li v-for="(hit, position) in hits" :key="`${hit.kind}-${hit.context}-${hit.label}-${position}`">

View File

@ -2,12 +2,14 @@
import type { IndexSection } from "../docsIndex";
import { qualifiedTableKey } from "../docsKeys";
import { groupStyle } from "../groupColor";
import type { Translate } from "../docsWarnings";
defineProps<{
sections: IndexSection[];
mode: "schema" | "group";
/** Qualified name of the table currently open, or null on the index. */
activeKey: string | null;
translate: Translate;
}>();
const emit = defineEmits<{
@ -19,13 +21,13 @@ const emit = defineEmits<{
<template>
<nav class="flex w-64 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border bg-background p-3">
<button type="button" class="rounded px-2 py-1 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/40" @click="emit('home')">Overview</button>
<button type="button" class="rounded px-2 py-1 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/40" @click="emit('home')">{{ translate("docs.overview") }}</button>
<div class="flex flex-col gap-1">
<span class="px-2 text-[10px] uppercase tracking-wide text-muted-foreground">Group by</span>
<span class="px-2 text-[10px] uppercase tracking-wide text-muted-foreground">{{ translate("docs.groupBy") }}</span>
<div class="flex rounded border border-border p-0.5">
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'schema' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'schema')">Schemas</button>
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'group' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'group')">Table Groups</button>
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'schema' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'schema')">{{ translate("docs.groupBySchema") }}</button>
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'group' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'group')">{{ translate("docs.groupByTableGroup") }}</button>
</div>
</div>
@ -33,7 +35,7 @@ const emit = defineEmits<{
<div class="flex items-center gap-1.5 px-2 py-1" :class="{ 'docs-group': section.hue !== null }" :style="groupStyle(section.hue)">
<span v-if="section.hue !== null" class="h-2 w-2 shrink-0 rounded-full" style="background-color: var(--group-c)"></span>
<span class="truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{{ section.label || "(no schema)" }}
{{ section.label || translate(section.fallbackKey) }}
</span>
</div>
<button

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { qualifiedTableKey } from "../docsKeys";
import type { Translate } from "../docsWarnings";
import type { FieldRef, Relationship } from "../types";
const props = defineProps<{
@ -8,6 +9,7 @@ const props = defineProps<{
relationships: Relationship[];
schema: string | null;
table: string;
translate: Translate;
}>();
const emit = defineEmits<{
@ -50,8 +52,8 @@ const incoming = computed(() => props.relationships.filter((relationship) => isC
<template>
<div class="grid gap-4 sm:grid-cols-2">
<section>
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">References ({{ outgoing.length }})</h4>
<p v-if="outgoing.length === 0" class="text-xs text-muted-foreground">This table references no other table.</p>
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">{{ translate("docs.references") }} ({{ outgoing.length }})</h4>
<p v-if="outgoing.length === 0" class="text-xs text-muted-foreground">{{ translate("docs.noOutgoingRelationships") }}</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="relationship in outgoing" :key="relationship.id" class="text-xs">
<button type="button" class="w-full rounded border border-border bg-background px-2 py-1.5 text-left transition-colors hover:bg-muted/40" @click="emit('select', keyOf(relationship.to))">
@ -67,8 +69,8 @@ const incoming = computed(() => props.relationships.filter((relationship) => isC
</section>
<section>
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">Referenced by ({{ incoming.length }})</h4>
<p v-if="incoming.length === 0" class="text-xs text-muted-foreground">No table references this one.</p>
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">{{ translate("docs.referencedBy") }} ({{ incoming.length }})</h4>
<p v-if="incoming.length === 0" class="text-xs text-muted-foreground">{{ translate("docs.noIncomingRelationships") }}</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="relationship in incoming" :key="relationship.id" class="text-xs">
<button type="button" class="w-full rounded border border-border bg-background px-2 py-1.5 text-left transition-colors hover:bg-muted/40" @click="emit('select', keyOf(relationship.from))">

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from "vue";
import { layoutDiagramTables } from "@/lib/diagram/erDiagram";
import { clipToCard } from "../diagramGeometry";
import type { Point } from "../diagramGeometry";
import { qualifiedTableKey } from "../docsKeys";
import { groupStyle } from "../groupColor";
import type { SchemaSnapshot } from "../types";
const props = defineProps<{
snapshot: SchemaSnapshot;
}>();
const emit = defineEmits<{
select: [tableKey: string];
}>();
// Mirrors layoutDiagramTables' own defaults (lib/diagram/erDiagram.ts) so the
// card geometry drawn here matches the slot the layout actually left for it.
const CARD_WIDTH = 260;
const CARD_HEIGHT = 220;
const MARGIN = 40;
const MAX_VISIBLE_COLUMNS = 8;
const HALF = { width: CARD_WIDTH / 2, height: CARD_HEIGHT / 2 };
const positions = computed(() => layoutDiagramTables(props.snapshot.tables.map((table) => ({ name: qualifiedTableKey(table), columns: table.columns }))));
const groupsById = computed(() => new Map(props.snapshot.groups.map((group) => [group.id, group])));
interface DiagramCard {
key: string;
x: number;
y: number;
hue: number | null;
columns: string[];
}
const cards = computed<DiagramCard[]>(() =>
props.snapshot.tables.map((table) => {
const key = qualifiedTableKey(table);
const position = positions.value[key] ?? { x: MARGIN, y: MARGIN };
const group = table.groupId ? (groupsById.value.get(table.groupId) ?? null) : null;
return {
key,
x: position.x,
y: position.y,
hue: group?.hue ?? null,
columns: table.columns.slice(0, MAX_VISIBLE_COLUMNS).map((column) => column.name),
};
}),
);
function centreOf(key: string): Point {
const position = positions.value[key] ?? { x: MARGIN, y: MARGIN };
return { x: position.x + CARD_WIDTH / 2, y: position.y + CARD_HEIGHT / 2 };
}
interface DiagramEdge {
id: string;
from: Point;
to: Point;
}
const edges = computed<DiagramEdge[]>(() => {
const known = new Set(Object.keys(positions.value));
return props.snapshot.relationships
.map((relationship) => ({
relationship,
fromKey: qualifiedTableKey({ schema: relationship.from.schema, name: relationship.from.table }),
toKey: qualifiedTableKey({ schema: relationship.to.schema, name: relationship.to.table }),
}))
.filter(({ fromKey, toKey }) => known.has(fromKey) && known.has(toKey))
.map(({ relationship, fromKey, toKey }) => {
const fromCentre = centreOf(fromKey);
const toCentre = centreOf(toKey);
return {
id: relationship.id,
from: clipToCard(fromCentre, toCentre, HALF),
to: clipToCard(toCentre, fromCentre, HALF),
};
});
});
// Empty-diagram guard: Math.max(0, ...[]) is 0 rather than -Infinity, so a
// snapshot with no tables still produces a valid (if empty) canvas.
const svgWidth = computed(() => Math.max(0, ...cards.value.map((card) => card.x + CARD_WIDTH)) + MARGIN);
const svgHeight = computed(() => Math.max(0, ...cards.value.map((card) => card.y + CARD_HEIGHT)) + MARGIN);
/**
* `background-color` has no effect on SVG shapes they paint through `fill`
* so the group accent is the card's fill rather than a CSS background, tied
* to the same `--group-tint`/`--h` tokens `groupStyle` drives everywhere else
* in the viewer.
*/
function cardFillStyle(hue: number | null): Record<string, string> {
return hue === null ? {} : { ...groupStyle(hue), fill: "var(--group-tint)" };
}
</script>
<template>
<div class="overflow-auto">
<svg :width="svgWidth" :height="svgHeight" :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="block">
<line v-for="edge in edges" :key="edge.id" :x1="edge.from.x" :y1="edge.from.y" :x2="edge.to.x" :y2="edge.to.y" class="stroke-border" stroke-width="1.5" />
<g v-for="card in cards" :key="card.key">
<rect :x="card.x" :y="card.y" :width="CARD_WIDTH" :height="CARD_HEIGHT" rx="6" class="cursor-pointer stroke-border" :class="card.hue === null ? 'fill-card' : 'docs-group'" :style="cardFillStyle(card.hue)" stroke-width="1" @click="emit('select', card.key)" />
<text :x="card.x + 10" :y="card.y + 22" class="pointer-events-none fill-foreground font-mono text-[11px] font-semibold">{{ card.key }}</text>
<text v-for="(column, index) in card.columns" :key="column" :x="card.x + 10" :y="card.y + 42 + index * 18" class="pointer-events-none fill-muted-foreground font-mono text-[10px]">
{{ column }}
</text>
</g>
</svg>
</div>
</template>

View File

@ -56,7 +56,7 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
one shadows that comment, which is what noteSource and shadowedNote
below exist to disclose. -->
<div class="flex items-start gap-2">
<span v-if="table.noteSource === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium text-muted-foreground" :title="shadowedTitle"> LOCAL</span>
<span v-if="table.noteSource === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium text-muted-foreground" :title="shadowedTitle"> {{ translate("docs.localNote") }}</span>
<NoteEditor class="min-w-0 flex-1" :model-value="table.note ?? ''" :readonly="readonly" :translate="translate" @update:model-value="emit('edit', { kind: 'tableNote', tableKey: qualified, note: $event })" />
</div>
@ -64,19 +64,19 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
</header>
<section>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Columns</h3>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.columns") }}</h3>
<ColumnTable :columns="table.columns" :column-notes="table.columnNotes" :table-key="qualified" :readonly="readonly" :translate="translate" @edit="emit('edit', $event)" />
</section>
<section v-if="table.indexes.length > 0">
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Indexes</h3>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.indexes") }}</h3>
<div class="overflow-hidden rounded-md border border-border">
<table class="w-full text-xs">
<thead>
<tr class="bg-muted/30">
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Name</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Columns</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Settings</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.nameHeader") }}</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.columns") }}</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.settingsHeader") }}</th>
</tr>
</thead>
<tbody>
@ -100,12 +100,12 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
</section>
<section>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h3>
<RelationshipList :relationships="relationships" :schema="table.schema" :table="table.name" @select="emit('select', $event)" />
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.relationships") }}</h3>
<RelationshipList :relationships="relationships" :schema="table.schema" :table="table.name" :translate="translate" @select="emit('select', $event)" />
</section>
<section v-if="table.viewDefinition">
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Definition</h3>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.definitionHeader") }}</h3>
<pre class="overflow-x-auto rounded-md border border-border bg-muted/20 p-2 font-mono text-xs">{{ table.viewDefinition }}</pre>
</section>
</article>

View File

@ -3,9 +3,11 @@ import type { IndexSection } from "../docsIndex";
import { qualifiedTableKey } from "../docsKeys";
import { groupStyle } from "../groupColor";
import { renderNote } from "../renderNote";
import type { Translate } from "../docsWarnings";
defineProps<{
sections: IndexSection[];
translate: Translate;
}>();
const emit = defineEmits<{
@ -18,7 +20,7 @@ const emit = defineEmits<{
<section v-for="section in sections" :key="section.key" class="flex flex-col gap-2">
<div class="flex flex-col gap-1 border-l-2 pl-3" :class="{ 'docs-group': section.hue !== null }" style="border-color: var(--group-c, var(--border))" :style="groupStyle(section.hue)">
<div class="flex items-baseline gap-2">
<h2 class="text-sm font-semibold text-foreground">{{ section.label || "(no schema)" }}</h2>
<h2 class="text-sm font-semibold text-foreground">{{ section.label || translate(section.fallbackKey) }}</h2>
<span class="text-xs text-muted-foreground">{{ section.tables.length }} tables</span>
</div>
<div v-if="section.note" class="text-xs text-muted-foreground" v-html="renderNote(section.note)"></div>

View File

@ -0,0 +1,32 @@
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 };
}

View File

@ -5,6 +5,21 @@ export interface IndexSection {
/** Schema name, group id, or "" for the ungrouped bucket. */
key: string;
label: string;
/**
* Locale key to show in place of `label` when it's empty "docs.noSchema"
* from groupBySchema, "docs.noGroup" from groupByTableGroup. This module
* stays translator-free (it is pure, tested without Vue, and every other
* pure module in src/docs/ follows the same rule), so it hands back a KEY
* rather than calling translate() itself; the render site decides how.
*
* Carrying the key on the section rather than each render site inferring
* it from a `mode` prop means a fallback can never silently pick the
* wrong word for the section it labels: WikiIndex.vue doesn't even receive
* `mode`, so a mode-based guess there would either require threading a prop
* that has no other use, or duplicate this same schema/group decision at a
* second call site to keep in sync with this one.
*/
fallbackKey: "docs.noSchema" | "docs.noGroup";
/** Group hue, or null for schema sections and the ungrouped bucket. */
hue: number | null;
note: string | null;
@ -32,6 +47,7 @@ export function groupBySchema(snapshot: SchemaSnapshot): IndexSection[] {
.map(([key, tables]) => ({
key,
label: key,
fallbackKey: "docs.noSchema" as const,
hue: null,
note: null,
tables: [...tables].sort(byName),
@ -52,6 +68,7 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
sections.push({
key: group.id,
label: group.name,
fallbackKey: "docs.noGroup",
hue: group.hue,
note: group.note,
tables,
@ -63,7 +80,10 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
const ungrouped = snapshot.tables.filter((table) => table.groupId === null || !known.has(table.groupId)).sort(byName);
if (ungrouped.length > 0) {
sections.push({ key: "", label: "(no group)", hue: null, note: null, tables: ungrouped });
// Empty, not "(no group)": the render sites already fall back to a
// translated label when `label` is empty (see IndexSection.fallbackKey).
// A non-empty English literal here would bypass that fallback entirely.
sections.push({ key: "", label: "", fallbackKey: "docs.noGroup", hue: null, note: null, tables: ungrouped });
}
return sections;

View File

@ -0,0 +1,61 @@
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 "#/";
}
}

View File

@ -23,6 +23,27 @@ export default {
saving: "Saving…",
saved: "Saved",
saveFailed: "Could not save notes: {error}",
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",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
exportHtml: "Export HTML…",
exporting: "Exporting…",
exportFailed: "Could not export: {error}",
warnings: {
tableSkipped: {
title: "A table could not be documented",

View File

@ -23,6 +23,27 @@ export default {
saving: "Guardando…",
saved: "Guardado",
saveFailed: "No se pudieron guardar las notas: {error}",
overview: "Información general",
groupBy: "Agrupar por",
searchLabel: "Buscar",
groups: "Grupos",
relationships: "Relaciones",
columnHeader: "Columna",
typeHeader: "Tipo",
settingsHeader: "Configuración",
noteHeader: "Nota",
nameHeader: "Nombre",
definitionHeader: "Definición",
noOutgoingRelationships: "Esta tabla no referencia ninguna otra tabla.",
noIncomingRelationships: "Ninguna tabla referencia esta.",
diagram: "Diagrama",
language: "Idioma",
theme: "Tema",
themeLight: "Claro",
themeDark: "Oscuro",
exportHtml: "Exportar HTML…",
exporting: "Exportando…",
exportFailed: "No se pudo exportar: {error}",
warnings: {
tableSkipped: {
title: "No se pudo documentar una tabla",

View File

@ -23,6 +23,27 @@ export default {
saving: "Salvataggio…",
saved: "Salvato",
saveFailed: "Impossibile salvare le note: {error}",
overview: "Panoramica",
groupBy: "Raggruppa per",
searchLabel: "Cerca",
groups: "Gruppi",
relationships: "Relazioni",
columnHeader: "Colonna",
typeHeader: "Tipo",
settingsHeader: "Impostazioni",
noteHeader: "Nota",
nameHeader: "Nome",
definitionHeader: "Definizione",
noOutgoingRelationships: "Questa tabella non fa riferimento a nessun'altra tabella.",
noIncomingRelationships: "Nessuna tabella fa riferimento a questa.",
diagram: "Diagramma",
language: "Lingua",
theme: "Tema",
themeLight: "Chiaro",
themeDark: "Scuro",
exportHtml: "Esporta HTML…",
exporting: "Esportazione…",
exportFailed: "Impossibile esportare: {error}",
warnings: {
tableSkipped: {
title: "Impossibile documentare una tabella",

View File

@ -23,6 +23,27 @@ export default {
saving: "保存中…",
saved: "保存済み",
saveFailed: "メモを保存できませんでした: {error}",
overview: "概要",
groupBy: "グループ化",
searchLabel: "検索",
groups: "グループ",
relationships: "リレーションシップ",
columnHeader: "カラム",
typeHeader: "型",
settingsHeader: "設定",
noteHeader: "メモ",
nameHeader: "名前",
definitionHeader: "定義",
noOutgoingRelationships: "このテーブルは他のテーブルを参照していません。",
noIncomingRelationships: "このテーブルを参照しているテーブルはありません。",
diagram: "図",
language: "言語",
theme: "テーマ",
themeLight: "ライト",
themeDark: "ダーク",
exportHtml: "HTMLをエクスポート…",
exporting: "エクスポート中…",
exportFailed: "エクスポートできませんでした: {error}",
warnings: {
tableSkipped: {
title: "ドキュメント化できないテーブルがあります",

View File

@ -23,6 +23,27 @@ export default {
saving: "저장 중…",
saved: "저장됨",
saveFailed: "메모를 저장할 수 없습니다: {error}",
overview: "개요",
groupBy: "그룹화 기준",
searchLabel: "검색",
groups: "그룹",
relationships: "관계",
columnHeader: "컬럼",
typeHeader: "유형",
settingsHeader: "설정",
noteHeader: "메모",
nameHeader: "이름",
definitionHeader: "정의",
noOutgoingRelationships: "이 테이블은 다른 테이블을 참조하지 않습니다.",
noIncomingRelationships: "이 테이블을 참조하는 테이블이 없습니다.",
diagram: "다이어그램",
language: "언어",
theme: "테마",
themeLight: "라이트",
themeDark: "다크",
exportHtml: "HTML 내보내기…",
exporting: "내보내는 중…",
exportFailed: "내보낼 수 없습니다: {error}",
warnings: {
tableSkipped: {
title: "문서화할 수 없는 테이블이 있습니다",

View File

@ -23,6 +23,27 @@ export default {
saving: "Salvando…",
saved: "Salvo",
saveFailed: "Não foi possível salvar as notas: {error}",
overview: "Visão geral",
groupBy: "Agrupar por",
searchLabel: "Buscar",
groups: "Grupos",
relationships: "Relacionamentos",
columnHeader: "Coluna",
typeHeader: "Tipo",
settingsHeader: "Configurações",
noteHeader: "Nota",
nameHeader: "Nome",
definitionHeader: "Definição",
noOutgoingRelationships: "Esta tabela não referencia nenhuma outra tabela.",
noIncomingRelationships: "Nenhuma tabela referencia esta.",
diagram: "Diagrama",
language: "Idioma",
theme: "Tema",
themeLight: "Claro",
themeDark: "Escuro",
exportHtml: "Exportar HTML…",
exporting: "Exportando…",
exportFailed: "Não foi possível exportar: {error}",
warnings: {
tableSkipped: {
title: "Uma tabela não pôde ser documentada",

View File

@ -23,6 +23,27 @@ export default {
saving: "保存中…",
saved: "已保存",
saveFailed: "无法保存备注: {error}",
overview: "概览",
groupBy: "分组方式",
searchLabel: "搜索",
groups: "分组",
relationships: "关系",
columnHeader: "列",
typeHeader: "类型",
settingsHeader: "设置",
noteHeader: "备注",
nameHeader: "名称",
definitionHeader: "定义",
noOutgoingRelationships: "此表未引用任何其他表。",
noIncomingRelationships: "没有表引用此表。",
diagram: "图",
language: "语言",
theme: "主题",
themeLight: "浅色",
themeDark: "深色",
exportHtml: "导出 HTML…",
exporting: "导出中…",
exportFailed: "无法导出: {error}",
warnings: {
tableSkipped: {
title: "有一张表无法生成文档",

View File

@ -23,6 +23,27 @@ export default {
saving: "儲存中…",
saved: "已儲存",
saveFailed: "無法儲存備註: {error}",
overview: "總覽",
groupBy: "分組方式",
searchLabel: "搜尋",
groups: "分組",
relationships: "關聯",
columnHeader: "欄位",
typeHeader: "類型",
settingsHeader: "設定",
noteHeader: "備註",
nameHeader: "名稱",
definitionHeader: "定義",
noOutgoingRelationships: "此資料表未參照任何其他資料表。",
noIncomingRelationships: "沒有資料表參照此資料表。",
diagram: "圖表",
language: "語言",
theme: "主題",
themeLight: "淺色",
themeDark: "深色",
exportHtml: "匯出 HTML…",
exporting: "匯出中…",
exportFailed: "無法匯出: {error}",
warnings: {
tableSkipped: {
title: "有一張資料表無法產生文件",

View File

@ -184,6 +184,7 @@ export const collectDocsSnapshot = forward("collectDocsSnapshot");
export const loadDocsAnnotations = forward("loadDocsAnnotations");
export const applyDocsAnnotations = forward("applyDocsAnnotations");
export const saveDocsAnnotations = forward("saveDocsAnnotations");
export const exportDocsHtml = forward("exportDocsHtml");
// Query
export const executeQuery = forward("executeQuery");

View File

@ -860,6 +860,21 @@ export async function saveDocsAnnotations(connectionId: string, annotations: Ann
return post("/api/docs/annotations/save", { connectionId, annotations });
}
export async function exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise<void> {
const result = await post<{ content: string }>("/api/docs/export", { snapshot, annotations, lang });
// No `downloadTextFile` here: it prepends a BOM, which the Tauri command's
// `std::fs::write(&file_path, html)` does not. The two callers must produce
// byte-identical output for the same inputs.
const fileName = filePath.split(/[\\/]/).pop() || "docs.html";
const blob = new Blob([result.content], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------

View File

@ -1681,6 +1681,10 @@ export async function saveDocsAnnotations(connectionId: string, annotations: Ann
return invoke("docs_save_annotations", { connectionId, annotations });
}
export async function exportDocsHtml(filePath: string, snapshot: SchemaSnapshot, annotations: AnnotationFile, lang: string): Promise<void> {
return invoke("docs_export_html", { filePath, snapshot, annotations, lang });
}
export async function saveConnections(configs: ConnectionConfig[]): Promise<void> {
return invoke("save_connections", { configs });
}

View File

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
const IMPORT_STATEMENT = '@import "./tokens.css";';
/**
* Reads the desktop stylesheet the way the browser assembles it: globals.css
* with its `@import "./tokens.css"` replaced, in place, by the imported file.
*
* Design tokens live in tokens.css so the standalone documentation export can
* reuse them without pulling in the app shell. Assertions about declaration
* order are assertions about the cascade, so a spec that reads only globals.css
* sees half the stylesheet and draws the wrong conclusion.
*/
export function readCascadeCss(): string {
const globals = readFileSync(new URL("../globals.css", import.meta.url), "utf8");
const tokens = readFileSync(new URL("../tokens.css", import.meta.url), "utf8");
if (!globals.includes(IMPORT_STATEMENT)) {
// Failing loudly beats returning globals.css alone: a silent half-stylesheet
// would resurface as an unrelated-looking assertion failure somewhere else.
throw new Error(`globals.css no longer pulls in tokens.css with \`${IMPORT_STATEMENT}\``);
}
return globals.replace(IMPORT_STATEMENT, tokens);
}

View File

@ -1,7 +1,8 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { readCascadeCss } from "./cascadeCss";
const globalsCss = readFileSync(new URL("../globals.css", import.meta.url), "utf8");
const globalsCss = readCascadeCss();
const dialogContentSource = readFileSync(new URL("../../components/ui/dialog/DialogContent.vue", import.meta.url), "utf8");
const dialogScrollContentSource = readFileSync(new URL("../../components/ui/dialog/DialogScrollContent.vue", import.meta.url), "utf8");
const dialogOverlaySource = readFileSync(new URL("../../components/ui/dialog/DialogOverlay.vue", import.meta.url), "utf8");

View File

@ -66,128 +66,15 @@
}
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
/* Aliases for legacy MQ panel styles (used across components/mq/*). */
--color-error: var(--destructive);
--color-error-bg: color-mix(in srgb, var(--destructive) 10%, transparent);
--color-error-alpha: var(--color-error-bg);
--color-success: var(--success);
--color-success-bg: var(--success-bg);
--color-success-alpha: var(--success-bg);
--color-warning: var(--warning);
--color-warning-bg: var(--warning-bg);
--color-warning-alpha: var(--warning-bg);
--color-info: var(--info);
--color-info-bg: var(--info-bg);
--color-info-alpha: var(--info-bg);
--color-hover: var(--accent);
--color-background-secondary: var(--muted);
--color-text: var(--foreground);
--color-text-secondary: var(--muted-foreground);
--color-text-tertiary: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
--color-border-light: color-mix(in srgb, var(--border) 60%, transparent);
--color-primary-alpha: color-mix(in srgb, var(--primary) 12%, transparent);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: var(--dbx-radius-sm);
--radius-md: var(--dbx-radius-md);
--radius-lg: var(--dbx-radius-lg);
--radius-xl: var(--dbx-radius-xl);
}
:root {
--background: rgb(255 255 255);
--foreground: rgb(10 10 10);
--card: rgb(255 255 255);
--card-foreground: rgb(10 10 10);
--popover: rgb(255 255 255);
--popover-foreground: rgb(10 10 10);
--primary: rgb(23 23 23);
--primary-foreground: rgb(250 250 250);
--secondary: rgb(245 245 245);
--secondary-foreground: rgb(23 23 23);
--muted: rgb(245 245 245);
--muted-foreground: rgb(115 115 115);
--accent: rgb(245 245 245);
--accent-foreground: rgb(23 23 23);
--destructive: rgb(231 0 11);
--destructive-rgb: 231, 0, 11;
--border: rgb(229 229 229);
--input: rgb(229 229 229);
--ring: rgb(161 161 161);
--chart-1: rgb(212 212 212);
--chart-2: rgb(115 115 115);
--chart-3: rgb(82 82 82);
--chart-4: rgb(64 64 64);
--chart-5: rgb(38 38 38);
--radius: var(--dbx-radius-md);
--dbx-radius-default: 4px;
--dbx-radius-sm: 4px;
--dbx-radius-md: 4px;
--dbx-radius-lg: 6px;
--dbx-radius-xl: 6px;
--dbx-radius-fixed-4: 4px;
--dbx-radius-fixed-5: 5px;
--dbx-radius-fixed-6: 6px;
--sidebar: rgb(250 250 250);
--sidebar-foreground: rgb(10 10 10);
--sidebar-primary: rgb(23 23 23);
--sidebar-primary-foreground: rgb(250 250 250);
--sidebar-accent: rgb(245 245 245);
--sidebar-accent-foreground: rgb(23 23 23);
--sidebar-border: rgb(229 229 229);
--sidebar-ring: rgb(161 161 161);
--success: rgb(22 163 74);
--success-foreground: rgb(240 253 244);
--success-bg: color-mix(in srgb, var(--success) 12%, transparent);
--warning: rgb(217 119 6);
--warning-foreground: rgb(255 251 235);
--warning-bg: color-mix(in srgb, var(--warning) 14%, transparent);
--info: rgb(37 99 235);
--info-foreground: rgb(239 246 255);
--info-bg: color-mix(in srgb, var(--info) 12%, transparent);
--dbx-chrome: rgb(245 245 245);
--dbx-chrome-muted: rgb(240 240 240);
--dbx-content: rgb(255 255 255);
--dbx-editor-toolbar: rgb(250 250 250);
--dbx-gutter: rgb(243 243 243);
--dbx-sidebar-header: rgb(250 250 250);
--dbx-window-top-border: rgb(0 0 0 / 0.35);
--dbx-viewport-height: 100vh;
}
/*
* `@custom-variant dark` and the `@theme inline` block that maps the raw
* tokens onto Tailwind's `--color-*` namespace both live in tokens.css now:
* they are plumbing for those tokens, and the standalone documentation export
* imports tokens.css without this file. Utilities like `bg-background` come
* from that block, so an entry point with the raw properties and no `@theme`
* emits none of them.
*/
@import "./tokens.css";
:root[data-corner-style="large"] {
--dbx-radius-default: 6px;
@ -293,57 +180,6 @@
}
}
.dark {
--background: rgb(19 20 22);
--foreground: rgb(215 215 219);
--card: rgb(27 27 30);
--card-foreground: rgb(215 215 219);
--popover: rgb(30 30 32);
--popover-foreground: rgb(221 221 226);
--primary: rgb(208 208 214);
--primary-foreground: rgb(19 20 22);
--secondary: rgb(42 42 45);
--secondary-foreground: rgb(215 215 219);
--muted: rgb(42 42 45);
--muted-foreground: rgb(151 152 157);
--accent: rgb(46 47 51);
--accent-foreground: rgb(221 221 226);
--destructive: rgb(243 98 95);
--destructive-rgb: 243, 98, 95;
--border: rgb(110 110 114 / 0.28);
--input: rgb(110 110 114 / 0.34);
--ring: rgb(133 134 139);
--chart-1: rgb(212 212 212);
--chart-2: rgb(115 115 115);
--chart-3: rgb(82 82 82);
--chart-4: rgb(64 64 64);
--chart-5: rgb(38 38 38);
--sidebar: rgb(25 25 28);
--sidebar-foreground: rgb(208 208 213);
--sidebar-primary: rgb(208 208 214);
--sidebar-primary-foreground: rgb(19 20 22);
--sidebar-accent: rgb(44 44 48);
--sidebar-accent-foreground: rgb(221 221 226);
--sidebar-border: rgb(110 110 114 / 0.28);
--sidebar-ring: rgb(133 134 139);
--success: rgb(74 222 128);
--success-foreground: rgb(20 30 24);
--success-bg: color-mix(in srgb, var(--success) 16%, transparent);
--warning: rgb(251 191 36);
--warning-foreground: rgb(40 32 12);
--warning-bg: color-mix(in srgb, var(--warning) 16%, transparent);
--info: rgb(96 165 250);
--info-foreground: rgb(18 28 46);
--info-bg: color-mix(in srgb, var(--info) 16%, transparent);
--dbx-chrome: rgb(27 27 30);
--dbx-chrome-muted: rgb(32 32 36);
--dbx-content: rgb(19 20 22);
--dbx-editor-toolbar: rgb(25 25 28);
--dbx-gutter: rgb(23 23 25);
--dbx-sidebar-header: rgb(25 25 28);
--dbx-window-top-border: rgb(255 255 255 / 0.18);
}
html.theme-soft {
--background: rgb(250 251 253);
--foreground: rgb(40 44 52);

View File

@ -0,0 +1,210 @@
/*
* 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`.
*
* Scope: this file carries only the base light (`:root`) and dark (`.dark`)
* token sets, in rgb. It deliberately does NOT carry the alternate
* installable app themes (`html.theme-soft`, `.theme-graphite`, etc., still
* in globals.css), the `@supports (color: oklch(...))` progressive
* enhancement that re-declares these same tokens in oklch for wide-gamut
* displays (also still in globals.css, after the tokens.css import), or the
* `:root[data-corner-style]` radius variants. None of those apply to a
* static file:// export with no theme picker and no live preference to
* express pulling them in here would just be duplication with nothing to
* show for it.
*
* The `@theme inline` block below is part of the same plumbing and has to
* travel with the raw properties. Tailwind generates `bg-background`,
* `border-border` and `fill-card` from the `--color-*` entries here, NOT from
* the `--background`/`--border` properties themselves an entry point that
* imports only the raw tokens defines every custom property and emits none of
* the utilities that read them, which renders as a completely unstyled page
* while every build and test stays green.
*
* `@custom-variant dark` travels with it for the same reason: Tailwind's stock
* `dark:` variant keys off `prefers-color-scheme`, so without this the app's
* (and the export's) `.dark` class would flip the token values while every
* `dark:` utility kept following the OS.
*/
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
/* Aliases for legacy MQ panel styles (used across components/mq/*). */
--color-error: var(--destructive);
--color-error-bg: color-mix(in srgb, var(--destructive) 10%, transparent);
--color-error-alpha: var(--color-error-bg);
--color-success: var(--success);
--color-success-bg: var(--success-bg);
--color-success-alpha: var(--success-bg);
--color-warning: var(--warning);
--color-warning-bg: var(--warning-bg);
--color-warning-alpha: var(--warning-bg);
--color-info: var(--info);
--color-info-bg: var(--info-bg);
--color-info-alpha: var(--info-bg);
--color-hover: var(--accent);
--color-background-secondary: var(--muted);
--color-text: var(--foreground);
--color-text-secondary: var(--muted-foreground);
--color-text-tertiary: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
--color-border-light: color-mix(in srgb, var(--border) 60%, transparent);
--color-primary-alpha: color-mix(in srgb, var(--primary) 12%, transparent);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: var(--dbx-radius-sm);
--radius-md: var(--dbx-radius-md);
--radius-lg: var(--dbx-radius-lg);
--radius-xl: var(--dbx-radius-xl);
}
:root {
--background: rgb(255 255 255);
--foreground: rgb(10 10 10);
--card: rgb(255 255 255);
--card-foreground: rgb(10 10 10);
--popover: rgb(255 255 255);
--popover-foreground: rgb(10 10 10);
--primary: rgb(23 23 23);
--primary-foreground: rgb(250 250 250);
--secondary: rgb(245 245 245);
--secondary-foreground: rgb(23 23 23);
--muted: rgb(245 245 245);
--muted-foreground: rgb(115 115 115);
--accent: rgb(245 245 245);
--accent-foreground: rgb(23 23 23);
--destructive: rgb(231 0 11);
--destructive-rgb: 231, 0, 11;
--border: rgb(229 229 229);
--input: rgb(229 229 229);
--ring: rgb(161 161 161);
--chart-1: rgb(212 212 212);
--chart-2: rgb(115 115 115);
--chart-3: rgb(82 82 82);
--chart-4: rgb(64 64 64);
--chart-5: rgb(38 38 38);
--radius: var(--dbx-radius-md);
--dbx-radius-default: 4px;
--dbx-radius-sm: 4px;
--dbx-radius-md: 4px;
--dbx-radius-lg: 6px;
--dbx-radius-xl: 6px;
--dbx-radius-fixed-4: 4px;
--dbx-radius-fixed-5: 5px;
--dbx-radius-fixed-6: 6px;
--sidebar: rgb(250 250 250);
--sidebar-foreground: rgb(10 10 10);
--sidebar-primary: rgb(23 23 23);
--sidebar-primary-foreground: rgb(250 250 250);
--sidebar-accent: rgb(245 245 245);
--sidebar-accent-foreground: rgb(23 23 23);
--sidebar-border: rgb(229 229 229);
--sidebar-ring: rgb(161 161 161);
--success: rgb(22 163 74);
--success-foreground: rgb(240 253 244);
--success-bg: color-mix(in srgb, var(--success) 12%, transparent);
--warning: rgb(217 119 6);
--warning-foreground: rgb(255 251 235);
--warning-bg: color-mix(in srgb, var(--warning) 14%, transparent);
--info: rgb(37 99 235);
--info-foreground: rgb(239 246 255);
--info-bg: color-mix(in srgb, var(--info) 12%, transparent);
--dbx-chrome: rgb(245 245 245);
--dbx-chrome-muted: rgb(240 240 240);
--dbx-content: rgb(255 255 255);
--dbx-editor-toolbar: rgb(250 250 250);
--dbx-gutter: rgb(243 243 243);
--dbx-sidebar-header: rgb(250 250 250);
--dbx-window-top-border: rgb(0 0 0 / 0.35);
--dbx-viewport-height: 100vh;
}
.dark {
--background: rgb(19 20 22);
--foreground: rgb(215 215 219);
--card: rgb(27 27 30);
--card-foreground: rgb(215 215 219);
--popover: rgb(30 30 32);
--popover-foreground: rgb(221 221 226);
--primary: rgb(208 208 214);
--primary-foreground: rgb(19 20 22);
--secondary: rgb(42 42 45);
--secondary-foreground: rgb(215 215 219);
--muted: rgb(42 42 45);
--muted-foreground: rgb(151 152 157);
--accent: rgb(46 47 51);
--accent-foreground: rgb(221 221 226);
--destructive: rgb(243 98 95);
--destructive-rgb: 243, 98, 95;
--border: rgb(110 110 114 / 0.28);
--input: rgb(110 110 114 / 0.34);
--ring: rgb(133 134 139);
--chart-1: rgb(212 212 212);
--chart-2: rgb(115 115 115);
--chart-3: rgb(82 82 82);
--chart-4: rgb(64 64 64);
--chart-5: rgb(38 38 38);
--sidebar: rgb(25 25 28);
--sidebar-foreground: rgb(208 208 213);
--sidebar-primary: rgb(208 208 214);
--sidebar-primary-foreground: rgb(19 20 22);
--sidebar-accent: rgb(44 44 48);
--sidebar-accent-foreground: rgb(221 221 226);
--sidebar-border: rgb(110 110 114 / 0.28);
--sidebar-ring: rgb(133 134 139);
--success: rgb(74 222 128);
--success-foreground: rgb(20 30 24);
--success-bg: color-mix(in srgb, var(--success) 16%, transparent);
--warning: rgb(251 191 36);
--warning-foreground: rgb(40 32 12);
--warning-bg: color-mix(in srgb, var(--warning) 16%, transparent);
--info: rgb(96 165 250);
--info-foreground: rgb(18 28 46);
--info-bg: color-mix(in srgb, var(--info) 16%, transparent);
--dbx-chrome: rgb(27 27 30);
--dbx-chrome-muted: rgb(32 32 36);
--dbx-content: rgb(19 20 22);
--dbx-editor-toolbar: rgb(25 25 28);
--dbx-gutter: rgb(23 23 25);
--dbx-sidebar-header: rgb(25 25 28);
--dbx-window-top-border: rgb(255 255 255 / 0.18);
}

View File

@ -0,0 +1,160 @@
import { createHash } from "node:crypto";
import { readFileSync, statSync, 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");
}
function isFile(file: string): boolean {
try {
return statSync(file).isFile();
} catch {
return false;
}
}
interface BundleChunk {
type: string;
source?: string | Uint8Array;
code?: string;
modules?: Record<string, unknown>;
}
/**
* Every `@import` a stylesheet actually makes, followed to the files on disk.
*
* Tailwind resolves CSS `@import` inside its own plugin, so tokens.css never
* becomes a Rollup module and the module graph alone cannot see it. This
* follows the same edges the build follows, from the same bytes a new
* `@import` is picked up because it is read out of the file, not matched
* against a list. Bare specifiers (`tailwindcss`) resolve inside a package and
* are left to `deps`.
*/
function cssImportsOf(file: string, seen: Set<string>): void {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(/@import\s+(?:url\()?["']([^"']+)["']/g)) {
const specifier = match[1];
if (!specifier.startsWith(".") && !specifier.startsWith("/")) continue;
const resolved = path.resolve(path.dirname(file), specifier);
if (seen.has(resolved) || !isFile(resolved)) continue;
seen.add(resolved);
cssImportsOf(resolved, seen);
}
}
/**
* Inline the font and write the staleness manifest.
*
* The manifest is derived from Rollup's ACTUAL module graph plus the CSS
* `@import` graph the module graph cannot see 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",
// Vite's own `vite:css-post` creates the stylesheet asset in its
// generateBundle, and it runs after normal user plugins. Without `post`
// this hook fires while the CSS does not exist yet and the `@font-face`
// silently never lands.
enforce: "post" as const,
generateBundle(_options: unknown, bundle: Record<string, BundleChunk>) {
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> = {};
const record = (rawId: string): void => {
// Vue splits an SFC into `File.vue?vue&type=style&…` sub-requests and
// Vite tags CSS the same way. The file on disk is the part before the
// query; without stripping it every SFC would be missing from the
// manifest, which is the failure this whole derivation exists to avoid.
const id = rawId.split("?")[0];
if (!path.isAbsolute(id)) return;
// `lastIndexOf`: under pnpm a real path is
// `<root>/node_modules/.pnpm/marked@18.0.4/node_modules/marked/…`, and
// the FIRST occurrence yields the package name `.pnpm`.
const nodeModules = id.lastIndexOf("/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) return;
const manifest = path.join(id.slice(0, nodeModules), "node_modules", name, "package.json");
if (!isFile(manifest)) return;
deps[name] = JSON.parse(readFileSync(manifest, "utf8")).version;
return;
}
if (!id.startsWith(repoRoot) || !isFile(id)) return;
sources[path.relative(repoRoot, id)] = sha256(readFileSync(id));
};
const stylesheets = new Set<string>();
for (const chunk of Object.values(bundle)) {
for (const id of Object.keys(chunk.modules ?? {})) {
record(id);
const file = id.split("?")[0];
if (file.endsWith(".css") && isFile(file)) cssImportsOf(file, stylesheets);
}
}
for (const file of stylesheets) record(file);
record(fontPath);
// This file, and the tsconfig esbuild reads `target` out of. Neither is a
// module, and both decide emitted bytes: the @font-face template below,
// `format: "iife"`, the `@source` narrowing. Without them someone can
// change how the bundle is built, not rebuild, and leave the staleness
// guard green over artefacts that no longer match the tree.
record(__filename);
record(path.join(__dirname, "tsconfig.json"));
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({
root: __dirname,
// The app's public/ holds the font files this build inlines. Left on, Vite
// would copy all of them into crates/dbx-core/assets beside the bundle.
publicDir: false,
plugins: [vue(), tailwindcss(), exportBundlePlugin()],
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
build: {
outDir: assetsDir,
emptyOutDir: false,
cssCodeSplit: false,
rollupOptions: {
input: path.resolve(__dirname, "src/docs-export/main.ts"),
// `iife`, not the default `es`, for two reasons: Task 6 inlines this into
// a document opened over file://, where a module script is subject to
// CORS-flavoured rules no plain <script> has to satisfy — and an iife is
// one file by construction, where an es bundle could split into chunks
// the export would have to load over a network it does not have.
output: { format: "iife", entryFileNames: "docs-export.js", assetFileNames: "docs-export.[ext]" },
},
},
});

View File

@ -94,6 +94,7 @@ struct Flags {
file: Option<PathBuf>,
out: Option<PathBuf>,
notes: Option<PathBuf>,
lang: Option<String>,
allow_writes: bool,
allow_dangerous: bool,
help: bool,
@ -245,6 +246,9 @@ async fn run_with_backend(backend: &dyn DbxBackend, flags: Flags) -> Result<Stri
if args.first().is_some_and(|arg| arg == "dbml") {
return run_dbml(backend, &flags).await;
}
if args.first().is_some_and(|arg| arg == "docs") {
return run_docs(backend, &flags).await;
}
if args.first().is_some_and(|arg| arg == "open") {
ensure_arg_count(args, 3, "dbx open")?;
let connection = required(args.get(1), "Connection name is required.")?;
@ -498,6 +502,61 @@ async fn run_dbml(backend: &dyn DbxBackend, flags: &Flags) -> Result<String, Cli
}
}
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),
}
}
async fn find_connection(backend: &dyn DbxBackend, name: &str) -> Result<ConnectionConfig, CliError> {
backend
.load_connections()
@ -525,6 +584,7 @@ fn parse_flags(argv: &[String]) -> Result<Flags, CliError> {
file: None,
out: None,
notes: None,
lang: None,
allow_writes: false,
allow_dangerous: false,
help: false,
@ -571,6 +631,7 @@ fn parse_flags(argv: &[String]) -> Result<Flags, CliError> {
"--file" => flags.file = Some(PathBuf::from(option_value(argv, &mut index, "--file")?)),
"--out" => flags.out = Some(PathBuf::from(option_value(argv, &mut index, "--out")?)),
"--notes" => flags.notes = Some(PathBuf::from(option_value(argv, &mut index, "--notes")?)),
"--lang" => flags.lang = Some(option_value(argv, &mut index, "--lang")?),
"--allow-writes" => flags.allow_writes = true,
"--allow-dangerous-sql" => flags.allow_dangerous = true,
value if value.starts_with('-') => {
@ -914,7 +975,7 @@ fn csv_cell(value: &str) -> String {
}
fn usage() -> &'static str {
"Usage:\n dbx doctor [--json]\n dbx capabilities [--json]\n dbx connections list [--json]\n dbx schema list <connection> [--schema name] [--json]\n dbx schema describe <connection> <table> [--schema name] [--json]\n dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]\n dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]\n dbx dbml <connection> [--out path] [--notes path] [--schema name] [--database name] [--tables a,b]\n dbx open <connection> <table> [--schema name] [--database name] [--json]"
"Usage:\n dbx doctor [--json]\n dbx capabilities [--json]\n dbx connections list [--json]\n dbx schema list <connection> [--schema name] [--json]\n dbx schema describe <connection> <table> [--schema name] [--json]\n dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]\n dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]\n dbx dbml <connection> [--out path] [--notes path] [--schema name] [--database name] [--tables a,b]\n dbx docs <connection> [--out path] [--notes path] [--lang code] [--schema name] [--database name] [--tables a,b]\n dbx open <connection> <table> [--schema name] [--database name] [--json]"
}
#[cfg(test)]
@ -1251,4 +1312,21 @@ mod tests {
fn dbml_appears_in_the_usage_text() {
assert!(usage().contains("dbx dbml <connection>"), "got: {}", usage());
}
#[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());
}
}

3
crates/dbx-core/assets/docs-export.css generated Normal file

File diff suppressed because one or more lines are too long

64
crates/dbx-core/assets/docs-export.js generated Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,52 @@
{
"sources": {
"apps/desktop/public/fonts/geist-latin-wght-normal.woff2": "19f9c92546aa300c312235e3125af1b81394d8db9a4bc4a425cd5b641d2d54e1",
"apps/desktop/src/docs-export/ExportApp.vue": "499ba5beb89cc411ef32f92d9aaff356a43663f5f455a0a168bb3ade86dd143c",
"apps/desktop/src/docs-export/export.css": "c96e413fb305e7b06932248d6f03e68ad05928954dc89bac075a79a6d4cf4a9d",
"apps/desktop/src/docs-export/exportPayload.ts": "d2355903843aa8a2e37b83827de9c4df2187d6df92fd514e3ab2fd89390e4b6c",
"apps/desktop/src/docs-export/exportTranslate.ts": "2e01bf9ad20fb38f3dfab96769ed87c44a24e5eb8d0aa352f4b398606fb77e3b",
"apps/desktop/src/docs-export/main.ts": "08c541f19fa6e368ea42fd096320da4312624f1961cd2b59387aa618288fc397",
"apps/desktop/src/docs/DocsApp.vue": "60fa8c3f434efb39622688f8ad65dcad77eedd99bde9ffb9103d711519aa29d3",
"apps/desktop/src/docs/components/ColumnTable.vue": "a3e40f27e2d20f46c16b8b5b4f5cb83037ebb35ee667563a351dbe0a41e2f745",
"apps/desktop/src/docs/components/DocsSearch.vue": "163d585dc3400b30d7ff37b4efec7ce646933699131e11e4120979b2d3286c78",
"apps/desktop/src/docs/components/DocsSidebar.vue": "cf4a394b9b1ceff18e463c3ba5366921b61033d6ccf530373a9b300b23f0baf8",
"apps/desktop/src/docs/components/EnumPage.vue": "e570c3b0f322816646025b185909cf4a46e85cc87ff153706d9080f5e3c36f84",
"apps/desktop/src/docs/components/GroupEditor.vue": "57c241281d4c3cf90aa60b5544b72955177e48bbe80bdd78d5b7f417fb635e18",
"apps/desktop/src/docs/components/GroupPicker.vue": "2bf6066c3574935402b0acc9f50a6580b1fb256b94bac960bafb183fd77a0c98",
"apps/desktop/src/docs/components/NoteEditor.vue": "ab905e106734bd27e46bfb306c168d624e3f0eba9bd878975f738c69eb50d394",
"apps/desktop/src/docs/components/RelationshipList.vue": "bc9d074aa942c878405f19dd49cea8f7aa492bbcde7fffa0c93e2a51dad0abf7",
"apps/desktop/src/docs/components/SchemaDiagram.vue": "5942d6d86bf56efeabcd9e0aee2179515778a067760d4bb7a71e09b694deefef",
"apps/desktop/src/docs/components/TablePage.vue": "48b3fb848c2bede191b5ff04379c24b5088b49ea53e7b7be853ba904ad6cd371",
"apps/desktop/src/docs/components/WarningBanner.vue": "5b9ea68de1c7221cee9c5a5af6488316902fcc5c3a2bbce5d69e0ab767c1b059",
"apps/desktop/src/docs/components/WikiIndex.vue": "eea6724bc705b98969eceb56852928cede06ca8b32c0b125f425eec2c57bd7e6",
"apps/desktop/src/docs/diagramGeometry.ts": "839ba0fb02560bfcd4cb686568ec664de1c041a6c364b8c7b27438ce884f9dbf",
"apps/desktop/src/docs/docs.css": "7a2b1f5344b453c7d3673eff7027f7226d0011eb714561e33d1a3e19bc3431c9",
"apps/desktop/src/docs/docsIndex.ts": "7e993b2039761c69dca2a1eb440eae1d24ac0e8d67802091221f52aaf73a5d72",
"apps/desktop/src/docs/docsKeys.ts": "f98396155fd83b595080a9311092b86cd50d637f01b0986abe17d56568bdbb24",
"apps/desktop/src/docs/docsRoute.ts": "bd7a97b31bef4825e518596937c302a8792ea45e2e31c5b7a1066390aa4de5d2",
"apps/desktop/src/docs/docsSearch.ts": "2ca70bb8c31510823058b62402999d158592844a6f57205b5a73019d320ef84f",
"apps/desktop/src/docs/docsWarnings.ts": "0378b27c0c7c841fc9703f4522e13944ee1116db2fbda823f9cd7a8e8fdd3134",
"apps/desktop/src/docs/groupColor.ts": "a6cee9c5af8e531eaf15486678307b8cad41e75b9ec688ebae6f33c8a0da6a73",
"apps/desktop/src/docs/renderNote.ts": "aa2f3ef12deb2aa399de13f6f0d0d747595c384a50f501ae0d7c4a1182e63e1c",
"apps/desktop/src/i18n/locales/docs/en.ts": "caacc3011fdf80e1810eb3fb8f48f658825a1312809cb9444f610d2db25407e7",
"apps/desktop/src/i18n/locales/docs/es.ts": "950eeee11572cea85d4d226e0646484876fe62d8a9e7d641c3fb3704ec714258",
"apps/desktop/src/i18n/locales/docs/it.ts": "878fb388813e994a4f9dafba0847d3cdaf06d7e9d1ecd18625c9ee772c149128",
"apps/desktop/src/i18n/locales/docs/ja.ts": "ecaeb17c683b811bc361deaafa421749e64677a804d6f77ab08c1ea6554a6c25",
"apps/desktop/src/i18n/locales/docs/ko.ts": "271d21eb054b6731d108621eae216c69b49dada868774239b085e399dcc0ecf3",
"apps/desktop/src/i18n/locales/docs/pt-BR.ts": "f92c0b95b1b807c60a5136677808ed5014d9061dffd970aa5866eb1c82300dc5",
"apps/desktop/src/i18n/locales/docs/zh-CN.ts": "2092c39277f2a6774405124d1f167bbcbc5f633d8f82bbd2b28ff5e12d78f3f7",
"apps/desktop/src/i18n/locales/docs/zh-TW.ts": "691cf502248dbd09358c6c276990945013b26c52fb682bcd56b26f378daea29c",
"apps/desktop/src/lib/app/localeOptions.ts": "c269b1ca1d5c71dc27dd2ab5422a902c848722acabf0dbd205275624fc9482e6",
"apps/desktop/src/lib/diagram/erDiagram.ts": "1b9522b41cd2ee15ef04dc11e0f855459287fca2d5e0f551263dfc6f8de5764e",
"apps/desktop/src/styles/tokens.css": "5f050d28e3e278c259929e55ffc7bad63e5c3e8340ab6c04c018cbfd941c2b44",
"apps/desktop/tsconfig.json": "5ad521941835ad8529e9b81ba79c11650e5f020f0e376b7a9f17e22a9306fa76",
"apps/desktop/vite.docs-export.config.ts": "ba99d16885a572aaccaab46fcd61a5c4ac0c3bea572ea92582fca64118f83d86"
},
"deps": {
"@vue/reactivity": "3.5.35",
"@vue/runtime-core": "3.5.35",
"@vue/runtime-dom": "3.5.35",
"@vue/shared": "3.5.35",
"marked": "18.0.4"
}
}

View File

@ -0,0 +1,89 @@
//! Builds a small two-table snapshot and writes the real
//! `to_standalone_html` output to `argv[1]`.
//!
//! Exists so `exportSmoke.spec.ts` can execute genuine export output in
//! happy-dom rather than a hand-built approximation of it.
use std::collections::BTreeMap;
use std::env;
use std::fs;
use dbx_core::docs::annotations::AnnotationFile;
use dbx_core::docs::{
to_standalone_html, Cardinality, DocTable, FieldRef, ProjectMeta, Relationship, SchemaSnapshot, TableKind,
};
use dbx_core::types::ColumnInfo;
fn main() {
let out_path = env::args().nth(1).expect("usage: docs_export_smoke <output-path>");
let orders = DocTable {
schema: Some("public".into()),
name: "orders".into(),
kind: TableKind::Table,
columns: vec![
ColumnInfo { name: "id".into(), data_type: "bigint".into(), is_primary_key: true, ..Default::default() },
ColumnInfo { name: "customer_id".into(), data_type: "bigint".into(), ..Default::default() },
],
indexes: vec![],
foreign_keys: vec![],
group_id: None,
note: Some("Checkout rows.".into()),
note_source: dbx_core::docs::NoteSource::Database,
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: Some(2_400_000),
view_definition: None,
};
let customers = DocTable {
schema: Some("public".into()),
name: "customers".into(),
kind: TableKind::Table,
columns: vec![ColumnInfo {
name: "id".into(),
data_type: "bigint".into(),
is_primary_key: true,
..Default::default()
}],
indexes: vec![],
foreign_keys: vec![],
group_id: None,
note: None,
note_source: dbx_core::docs::NoteSource::None,
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: Some(50_000),
view_definition: None,
};
let snapshot = SchemaSnapshot {
format_version: 1,
project: ProjectMeta {
name: "shop".into(),
database_type: "postgres".into(),
database: Some("shop".into()),
schemas: vec!["public".into()],
generated_at: "2026-08-06T00:00:00Z".into(),
note: None,
},
tables: vec![orders, customers],
relationships: vec![Relationship {
id: "orders.customer_id->customers.id".into(),
name: None,
from: FieldRef { schema: Some("public".into()), table: "orders".into(), column: "customer_id".into() },
to: FieldRef { schema: Some("public".into()), table: "customers".into(), column: "id".into() },
cardinality: Cardinality::ManyToOne,
on_update: None,
on_delete: None,
}],
groups: vec![],
enums: vec![],
warnings: vec![],
};
let annotations = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
let html = to_standalone_html(&snapshot, &annotations, "en").expect("export");
fs::write(&out_path, html).expect("write output file");
}

View File

@ -0,0 +1,277 @@
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
#[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 the_shell_and_stylesheet_reference_no_external_resources() {
// This test's reach stops at the hand-authored shell and the
// bundled CSS's `url(...)` references — it does not scan EXPORT_JS
// for network calls. A substring scan of the bundle can't prove
// that: it legitimately contains the literal `http://` four times
// (three SVG/MathML/xlink XML namespace URIs passed to
// `createElementNS`, one inside the markdown autolinker building an
// href for `www.`-prefixed text), none of which are fetched. The
// bundle's purity is covered on the viewer side instead:
// `componentContract.spec.ts` forbids `fetch(`, `axios` and
// `invoke(` in every viewer source, and the manifest guard ties the
// committed bundle to those sources.
let (snapshot, annotations) = fixture();
let html = to_standalone_html(&snapshot, &annotations, "en").expect("export");
// 1. Every `url(...)` in the emitted stylesheet must be a `data:`
// URI — an allowlist of the one legitimate scheme, not a
// blocklist of bad ones, so a new absolute font or image url()
// fails without anyone having to extend a list. The match is
// case-insensitive because CSS's `url()` function name is
// case-insensitive per spec (`URL(...)` is legal); an ASCII-only
// bundle makes a byte-wise check safe here.
fn find_url_ci(s: &str, from: usize) -> Option<usize> {
let bytes = s.as_bytes();
(from..bytes.len().saturating_sub(3)).find(|&i| bytes[i..i + 4].eq_ignore_ascii_case(b"url("))
}
let style_start = html.find("<style>").expect("style element") + "<style>".len();
let style_end = html.find("</style>").expect("style element closes");
let style = &html[style_start..style_end];
let mut cursor = 0;
let mut url_count = 0;
while let Some(pos) = find_url_ci(style, cursor) {
let rest = &style[pos + "url(".len()..];
let end = rest.find(')').expect("unterminated url(");
let value = rest[..end].trim().trim_matches('\'').trim_matches('"');
assert!(value.starts_with("data:"), "stylesheet references a non-data url(): {value}");
url_count += 1;
cursor = pos + "url(".len() + end + 1;
}
// A scan that finds nothing hasn't verified anything — today the
// font is inlined via exactly one `url()`, so zero would mean the
// build stopped inlining it, not that there is nothing left to check.
assert!(url_count >= 1, "found no url(...) in the stylesheet — the scan above verified nothing");
// 2. The hand-authored shell — the document with the bundle's own
// CSS, JS, and the base64 payload removed — must contain no
// `src=` or `href=` at all. Checked against the shell alone so
// neither the bundle's contents nor the payload can influence
// the result.
let payload_start = html.find("application/dbx-snapshot").expect("payload element");
let payload_body = &html[payload_start..];
let encoded = payload_body[payload_body.find('>').unwrap() + 1..payload_body.find("</script>").unwrap()].trim();
let shell = html.replace(EXPORT_CSS, "").replace(EXPORT_JS, "").replace(encoded, "");
assert!(!shell.contains("src="), "the shell references an external src");
assert!(!shell.contains("href="), "the shell references an external href");
}
#[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}");
}
/// 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(", ")
);
}
/// `EXPORT_JS` is interpolated into `<script>{EXPORT_JS}</script>` raw —
/// unlike the base64 payload, nothing escapes it. That is safe against a
/// literal `</script>` (asserted above), but the HTML tokenizer has two
/// more states that can hide one: inside a `<script>` element, a literal
/// `<!--` switches it to script-data-escaped, and a `<script` seen while
/// in that state switches it again to script-data-double-escaped — where
/// `</script>` no longer closes the element. Both sequences exist in
/// EXPORT_JS today (third-party minified output) and are safe only
/// because every `<!--` is closed by a `-->` before the next `<script`.
/// This pins that ordering so a dependency bump can't silently trade it
/// away and swallow the real closing tag, leaving the reader a blank
/// page. Deliberately not a full tokenizer: it only checks the ordering
/// the double-escape trap actually depends on.
#[test]
fn embedded_export_js_closes_every_comment_before_the_next_script_tag() {
let lower = EXPORT_JS.to_ascii_lowercase();
let mut pos = 0;
while let Some(open_rel) = lower[pos..].find("<!--") {
let open = pos + open_rel;
let close = lower[open..].find("-->").map(|offset| open + offset);
let next_script = lower[open..].find("<script").map(|offset| open + offset);
match (close, next_script) {
(Some(close), Some(script)) => assert!(
close < script,
"an unmatched <!-- at byte {open} precedes a <script at byte {script} \
before its --> closes this would trap the browser in \
script-data-double-escaped state and swallow our own closing </script>"
),
(None, Some(script)) => panic!(
"an unclosed <!-- at byte {open} precedes a <script at byte {script} \
with no matching --> anywhere after it"
),
(Some(_), None) | (None, None) => {}
}
pos = open + "<!--".len();
}
}
/// Tailwind v4 generates utility classes like `bg-background` from the
/// `--color-*` entries inside an `@theme` block (in `tokens.css`), not
/// from raw custom properties. If that block were lost, the build would
/// still succeed and emit a stylesheet — just one with no utilities in
/// it — and the export would render completely unstyled while every
/// other test here passed. Nothing else in this file guards it.
#[test]
fn the_stylesheet_carries_resolved_utilities() {
assert!(
EXPORT_CSS.contains(".bg-background{background-color:var("),
"the stylesheet is missing a resolved `.bg-background` utility — \
`@theme` in tokens.css may have been lost, or the token it \
resolves through is gone"
);
// `@custom-variant dark` — its failure is invisible in light mode,
// where the export would look correct right up until dark mode
// shipped unstyled.
assert!(
EXPORT_CSS.contains(":is(.dark *)"),
"the stylesheet has no `:is(.dark *)` selector — the dark variant \
may have been lost; this would ship undetected because light \
mode looks fine"
);
}
}

View File

@ -2,6 +2,7 @@ pub mod annotations;
pub mod collector;
pub mod color;
pub mod dbml;
pub mod export;
pub mod keys;
pub mod relations;
pub mod snapshot;
@ -9,6 +10,7 @@ pub mod snapshot;
pub use collector::{collect_snapshot, CollectOptions, CollectProgress};
pub use color::hue_to_hex;
pub use dbml::{to_dbml, DbmlOutput};
pub use export::{to_standalone_html, EXPORT_LANGUAGES};
pub use keys::{column_key, fold_identifier, table_key};
pub use relations::build_relationships;
pub use snapshot::*;

View File

@ -381,6 +381,7 @@ async fn main() {
.route("/docs/annotations/load", post(routes::docs::load_annotations))
.route("/docs/annotations/apply", post(routes::docs::apply_annotations))
.route("/docs/annotations/save", post(routes::docs::save_annotations))
.route("/docs/export", post(routes::docs::export_html))
.route("/dialect/data-types", get(routes::dialect::list_data_types))
.route("/schema-diff/prepare", post(routes::schema_diff::prepare_schema_diff))
.route("/schema-diff/generate-sync-sql", post(routes::schema_diff::generate_schema_sync_sql))

View File

@ -5,7 +5,7 @@ use axum::extract::State;
use axum::Json;
use dbx_core::docs::{CollectOptions, SchemaSnapshot};
use dbx_core::models::connection::ConnectionConfig;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::state::WebState;
@ -112,3 +112,26 @@ pub async fn save_annotations(
dbx_core::docs::annotations::save_annotations(&path, &request.annotations).map_err(AppError::from)?;
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocsExportRequest {
pub snapshot: SchemaSnapshot,
pub annotations: dbx_core::docs::annotations::AnnotationFile,
pub lang: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocsExportResponse {
pub content: String,
}
/// Returns the rendered HTML as a string rather than writing a file: the
/// browser has no filesystem to write to, so `http.ts` downloads this content
/// as a blob instead of the Tauri command's `std::fs::write`.
pub async fn export_html(Json(request): Json<DocsExportRequest>) -> Result<Json<DocsExportResponse>, AppError> {
let content = dbx_core::docs::to_standalone_html(&request.snapshot, &request.annotations, &request.lang)
.map_err(AppError::from)?;
Ok(Json(DocsExportResponse { content }))
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,302 @@
# Standalone documentation export — design
**Part 3c of the database-documentation feature.** Parts 1, 2, 3a and 3b are complete on
`feature/docs-snapshot-dbml` and open upstream as PR #5559: `SchemaSnapshot` collection with a DBML
serializer and a `dbx dbml` verb, a file-backed annotation store, a pure Vue viewer, and that
viewer mounted in DBX with autosaved editing.
This part makes the documentation leave the application: one self-contained HTML file, produced by
a `dbx docs` verb and by a button in the app, that opens from `file://` on a machine with no DBX
installed.
## Goal
A user runs `dbx docs prod --out schema.html` in CI, or clicks **Export HTML** in the docs dialog,
and gets a single file they can commit, attach to a ticket, or email to a colleague. It opens with
no network, no server and no install. Deep links into it survive a reload, and the reader chooses
their own language.
## Scope
**In scope**
- A standalone Vite bundle of the existing viewer, built to one JS and one CSS
- `crates/dbx-core/src/docs/export.rs``to_standalone_html(snapshot, annotations, lang)`
- The `dbx docs` CLI verb, mirroring `dbx dbml`'s flags
- A Tauri command and an **Export HTML** button in `DatabaseDocsDialog.vue`
- Hash routing in the export shell, so `schema.html#/table/public.orders` is a link
- A language switcher over all 8 bundled `docs` namespaces
- `SchemaDiagram.vue` — a minimal read-only ER renderer
- A staleness guard so the committed bundle cannot drift from its sources
**Out of scope**
- Editing in the export. It is read-only; `readonly` exists on `DocsApp` for exactly this.
- Zoom, pan controls, drag, custom relationships or join-SQL in the diagram — that is what the
full `SchemaDiagramDialog` is for.
- The `latin-ext` font subset. The latin subset is inlined; `latin-ext` doubles the font cost to
serve characters most schemas never use, and the fallback is graceful.
- Persisting the reader's language choice. See "Language" below.
## Decisions taken before design
| Question | Decision |
|---|---|
| How does the built bundle reach the Rust binary? | Committed build artefact in `crates/dbx-core/assets/`, embedded with `include_str!`, with a staleness guard |
| Where does the ER renderer live? | `src/docs/`, with the host choosing the affordance |
| What language is the export? | All 8 namespaces bundled, switchable by the reader |
| Is there an in-app export button? | Yes — same Rust function, two callers |
| How is a stale bundle detected? | Content hash against a manifest, derived from the build |
| How is the payload embedded? | base64 |
## Architecture
### Three pieces, each with one job
```
apps/desktop/src/docs-export/ the export shell (browser)
main.ts mounts ExportApp, decodes the payload
ExportApp.vue hash routing, language switcher, readonly=true, diagram="inline"
export.css @source narrowed to ../docs/** + inlined @font-face
pnpm build:docs-export vite.docs-export.config.ts
→ one JS, one CSS, font as data URI, no code splitting
→ crates/dbx-core/assets/docs-export.{js,css,manifest.json}
crates/dbx-core/src/docs/export.rs to_standalone_html(snapshot, annotations, lang)
→ include_str! the two assets, base64 the payload, emit one HTML file
```
### Why the bundle is committed rather than built by cargo
Rust cannot run Vite, and `dbx docs` must work from a plain `cargo install` on a machine with no
Node. A `build.rs` that shells out to pnpm would break exactly that case, and neither existing
`build.rs` invokes an external toolchain.
There is partial precedent: `crates/dbx-core/src/ai_pi_agent_cli.rs:24` embeds
`include_str!("../assets/pi-mcp-bridge.mjs")`, and `crates/dbx-core/assets/` already carries a
54.8 KB driver manifest. The caveat is honest and belongs in the PR: `pi-mcp-bridge.mjs` is
hand-written, so the precedent covers committing a *source* asset, not a *build artefact*. The
staleness guard is what makes the difference acceptable.
### Data flow
`dbx docs` collects a snapshot exactly as `dbx dbml` does — same collector, same `--schema`,
`--database`, `--tables`, `--notes` flags, same warnings on stderr through the `Display` impl. It
then calls `to_standalone_html`. The Tauri command calls the identical function.
The snapshot is passed **with annotations already applied**, because `apply_annotations` is Rust
and the export has no Rust at runtime. The raw `AnnotationFile` also travels, for the same reason
the dialog needs it: `snapshot.groups` holds resolved `TableGroup`s, while `annotations.groups`
holds the `GroupAnnotation` records that carry each group's hue.
### The payload is base64
The exported page contains user-authored Markdown. A note containing the literal text `</script>`
— entirely plausible in a schema document that discusses HTML — would terminate the script element
early and inject the remainder as markup.
base64 removes the problem structurally rather than procedurally: the alphabet is `A-Za-z0-9+/=`,
so no escaping rule exists to forget, and the property is auditable by inspection instead of by
reviewing every serialization path. It costs +33% on the JSON only.
The alternatives were considered and rejected. A `<script type="application/json">` in which every
`<` is rewritten as its JSON unicode escape (backslash, `u`, `003c`) is what SSR frameworks do and
is sound, but its correctness depends on that rewrite being applied on every path. A JS string literal needs four independent escaping rules —
quotes, backslashes, `</script>`, and U+2028/U+2029, which `JSON.stringify` emits raw because they
are valid JSON but which were line terminators inside JS string literals until ES2019.
The cost of base64 is that the embedded schema is no longer readable in a text editor. That is
accepted.
### Styling: `globals.css` cannot be reused
Two facts decide this.
`apps/desktop/src/styles/globals.css:40` is `@source "../**/*.{vue,ts,tsx,js,jsx,html}"`. Tailwind
v4 scans the entire application, so importing `globals.css` into the export would emit every
utility used anywhere in DBX. The 76 KB of source is not the cost; the generated utility surface
is. `@source` is additive — there is no way to un-source it.
Its four `@font-face` blocks use absolute `/fonts/…` URLs, which under `file://` resolve against
the filesystem root and fail silently to a system font.
So `export.css` is its own entry: `@source` narrowed to `../docs/**`, and `@font-face` redeclared
with the woff2 inlined as a data URI (~38 KB base64).
### One shared-file change: `tokens.css`
The viewer's utilities resolve against tokens — `--background`, `--foreground`,
`--muted-foreground`, `--border`, `--ring` — defined in `globals.css` from line 131, interleaved
with roughly 2300 lines of application-specific rules. A narrowed `@source` still needs those
tokens.
The token blocks move to `apps/desktop/src/styles/tokens.css`, imported by **both** `globals.css`
and `export.css`. One definition, no drift. Copying the values into the export instead is exactly
the duplication that produced this feature's recurring defects.
**This is a real risk and the plan must treat it as one.** It edits a 76 KB stylesheet the whole
application depends on, on an already-large branch. The move is mechanical — a contiguous block
plus one `@import` — but the cascade order between `:root` and `.dark` must be preserved exactly.
A test asserts every custom property defined at `:root` before the extraction still resolves after
it.
## The export shell
### Hash routing stays out of the shared tree
`DocsApp` owns navigation internally today: `activeKey`, `activeEnumName`, and a computed
`view: "index" | "table" | "enum"`. It gains an **optional** `v-model:route`:
```ts
type DocsRoute =
| { kind: "index" }
| { kind: "table"; key: string }
| { kind: "enum"; name: string }
| { kind: "diagram" };
```
Absent, `DocsApp` behaves exactly as it does today and the dialog is untouched. Present, the shell
controls navigation and mirrors `location.hash`.
`{ kind: "diagram" }` is only reachable when `diagram="inline"`. Under `diagram="external"` the
route resolves to the index, so a hash of `#/diagram` in the dialog's context cannot render a view
that host has deliberately not enabled.
This separation is not stylistic. DBX has **no router at all**`vue-router` is not a dependency,
and the only `location.hash` reference in the frontend is a debug logger at
`lib/backend/debugLog.ts:214`. A `DocsApp` that wrote to the URL would hijack the host
application's address bar.
Hash grammar, with `encodeURIComponent` on the identifier so a table named `a/b` round-trips:
```
#/ index
#/table/public.orders table page
#/enum/order_status enum page
#/diagram ER diagram
```
Hash routing rather than `pushState` because the target is `file://`, where `pushState` is
unusable but a fragment survives a reload and makes `schema.html#/table/public.orders` a link
worth pasting.
**Parsing is defensive by contract.** An unparseable hash, or one naming a table or enum absent
from the payload, resolves to the index. A stale deep link into a since-dropped table is the
expected case, not an exotic one, and must never produce a blank page.
### Language
All 8 `docs` namespaces are bundled. They total 15,197 bytes of source across
`apps/desktop/src/i18n/locales/docs/{en,es,it,ja,ko,pt-BR,zh-CN,zh-TW}.ts` — roughly 3 KB gzipped,
negligible against the bundle. The payload carries an initial `lang` from `--lang` or the app's
current locale; the switcher rebuilds `translate` from the bundled namespace.
**No persistence.** Under `file://` every document shares one opaque origin, so `localStorage`
would leak one export's preference into an unrelated one. A single-session document is the honest
model.
**Missing keys fall back to English.** The parity test guarantees all 8 namespaces agree, so this
should never fire; it exists so an artefact opened offline degrades to English rather than
rendering a raw key. This does not repeat the Part 3b hazard where a fallback masked drift — there
the fallback replaced the guard, here the guard runs in CI and the fallback is a runtime backstop.
The switcher is worth having because **the reader is usually not the exporter**: the file gets
sent to someone else, and upstream's largest user base does not read English.
### The ER renderer
`SchemaDiagram.vue` lives in `src/docs/` and is added to the contract test's expected component
list. It imports `layoutDiagramTables` from `@/lib/diagram/erDiagram` — permitted, since only
`@/lib/backend` is forbidden, and that module has exactly one import which is `import type`,
making it fully bundleable.
So the grid layout is reused, not reinvented. What is genuinely new is **edge routing**, which
exists nowhere in the codebase today: a straight line between two cards' centres, clipped at each
card's border so it terminates on the edge rather than under the box. Crossings are tolerated.
Deliberately minimal: SVG at natural size inside an `overflow: auto` container, because scrolling
*is* panning. No zoom, no drag, no custom relationships, no join-SQL. Group colours come from
`groupStyle(hue)` so the diagram agrees with the wiki, and clicking a table navigates to its page —
which under hash routing makes the diagram a usable table of contents.
The host chooses the affordance. `DatabaseDocsDialog` passes `diagram="external"` and keeps its
existing button to the full `SchemaDiagramDialog`; the export passes `diagram="inline"`. No screen
shows both, and neither host gets a worse diagram than it could have.
## The staleness guard
`pnpm build:docs-export` emits `docs-export.js`, `docs-export.css` and `docs-export.manifest.json`
into `crates/dbx-core/assets/`.
**The manifest is derived from Rollup's module graph, not from a hand-written glob.** This is the
single most important detail in this section. `SchemaDiagram.vue` imports `erDiagram.ts`, which
lives outside `src/docs/` — a glob of `src/docs/**` would miss it and the guard would pass while
the artefact was stale. That is the same defect shape that has now bitten this feature three times:
`vue-i18n` absent from the contract test's forbidden list, `DocEnum` unpinned by an enum-free
fixture, `.docs-ground-light` absent from the CSS selector list. **A guard that enumerates its
targets silently excludes everything added later.**
So the build script writes the manifest from every repo file that actually entered the bundle,
plus the resolved versions of the node_modules packages it pulled in.
This closes the loop on new files, and the reason should be stated in the plan rather than left
implied: a new module can only enter the bundle by being imported, which means editing an existing
file, which changes that file's hash. The one hole is `import.meta.glob`, which would add modules
without editing an importer — the viewer does not use it, and the plan says so explicitly.
The Rust test recomputes the hashes and fails with the regeneration command. It must skip when the
crate is consumed from a published package where `apps/desktop/` does not exist — and that skip is
itself a hazard, since a vacuous skip in CI would silently disable the guard. The skip therefore
keys off a repository-only marker (`pnpm-workspace.yaml`): marker present, the test runs; marker
absent, the crate is genuinely packaged and there is nothing to check.
## Error handling
- `--out` omitted writes to stdout, matching `dbx dbml`. A 400 KB HTML in a terminal is unpleasant,
but consistency and `dbx docs prod | gzip > docs.html.gz` are worth more than a special case.
- `--lang xx` fails with the list of valid locales rather than falling back. A typo should be loud,
the same reasoning that made `--notes` an explicit flag in Part 2.
- A missing or malformed notes file behaves exactly as `dbx dbml` does, through the existing
`require_notes_file`.
## Testing
| Layer | Approach |
|---|---|
| `to_standalone_html` | Rust: base64 round-trip; a note containing `</script>` survives intact |
| Self-containedness | Rust: the emitted HTML contains no `http://`, `https://`, `url(/`, or external `src`/`href` |
| Route parsing | TS: pure parse/format — unknown table resolves to index, encoded identifiers, empty hash |
| Language | TS: the translate factory picks the right namespace; unknown lang falls back to English |
| Edge clipping | TS: the line-to-card-border intersection is a pure function |
| Contract | `SchemaDiagram.vue` added to the expected component list |
| Token extraction | Every custom property defined at `:root` before the move still resolves after it |
| Staleness | The manifest test, verified by touching a source file and watching it fail |
**Every new test gets the deliberate-break treatment**: break the behaviour, watch the named test
fail, restore, report the failure message. Across Parts 3a and 3b this caught a vacuous ranking
test, a CSS ordering bug that let a whole theme's colours be deleted silently, a capability warning
that contradicted its own data, and an atomicity test that was vacuous because of the fix
instruction I had written myself.
### The test worth attempting but not promising
A `happy-dom` smoke test that loads the generated HTML, executes the inline bundle, and asserts a
known table name reaches the DOM is the only test that proves the artefact *works* rather than
merely looks right. `happy-dom ^20.10.6` is already a dependency; Playwright and Puppeteer are not.
happy-dom can execute scripts, but running a full Vue bundle in it is not guaranteed. If it works
it is the most valuable test in this part. If it does not, the plan says so plainly and falls back
to the structural self-containedness test plus opening the file in a real browser over `file://`
the method already used to verify the viewer for PR #5559's screenshots.
## Success criteria
- `dbx docs prod --out schema.html` produces one file that opens from `file://` with no network, no
server and no DBX installed.
- `schema.html#/table/public.orders` survives a reload and can be pasted to a colleague.
- A reader switches language without re-exporting.
- The in-app button and the CLI produce byte-identical output for the same inputs, because they
call the same function.
- The committed bundle cannot drift from its sources without a test failing.
- `apps/desktop/src/docs/**/*.vue` still makes zero backend calls, enforced by the contract test.

View File

@ -25,6 +25,7 @@
"build:knowledge-base": "node scripts/build-knowledge-base.mjs",
"typecheck": "vue-tsc --noEmit --project apps/desktop/tsconfig.json",
"build": "vite build --config apps/desktop/vite.config.ts",
"build:docs-export": "vite build --config apps/desktop/vite.docs-export.config.ts",
"build:checked": "pnpm typecheck && pnpm build",
"check": "node scripts/run-check.mjs",
"db:env": "node scripts/database-env.mjs",

View File

@ -8,7 +8,7 @@ use dbx_core::connection::AppState;
use dbx_core::docs::annotations::{
apply_annotations, load_annotations, resolve_notes_path, save_annotations, AnnotationFile,
};
use dbx_core::docs::{collect_snapshot, CollectOptions, SchemaSnapshot};
use dbx_core::docs::{collect_snapshot, to_standalone_html, CollectOptions, SchemaSnapshot};
use dbx_core::models::connection::ConnectionConfig;
use tauri::State;
@ -81,3 +81,14 @@ pub async fn docs_save_annotations(
let path = notes_path_of(&state, &connection_id).await?;
save_annotations(&path, &annotations)
}
#[tauri::command]
pub async fn docs_export_html(
file_path: String,
snapshot: SchemaSnapshot,
annotations: AnnotationFile,
lang: String,
) -> Result<(), String> {
let html = to_standalone_html(&snapshot, &annotations, &lang)?;
std::fs::write(&file_path, html).map_err(|error| format!("Failed to write {file_path}: {error}"))
}

View File

@ -1873,6 +1873,7 @@ pub fn run() {
commands::docs::docs_load_annotations,
commands::docs::docs_apply_annotations,
commands::docs::docs_save_annotations,
commands::docs::docs_export_html,
commands::document_cmd::document_list_databases,
commands::document_cmd::document_list_collections,
commands::document_cmd::document_find_documents,