feat(docs): export database documentation as standalone HTML
This commit is contained in:
parent
efd0c381ad
commit
63498da121
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/**'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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://`. */
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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}`">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "#/";
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "ドキュメント化できないテーブルがあります",
|
||||
|
|
|
|||
|
|
@ -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: "문서화할 수 없는 테이블이 있습니다",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "有一张表无法生成文档",
|
||||
|
|
|
|||
|
|
@ -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: "有一張資料表無法產生文件",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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]" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue