diff --git a/.gitattributes b/.gitattributes index 4939cbd20..3555aba67 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b8b5c7f7..aa761698e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/**' diff --git a/apps/desktop/src/components/docs/DatabaseDocsDialog.vue b/apps/desktop/src/components/docs/DatabaseDocsDialog.vue index 1ae261697..136f20ce2 100644 --- a/apps/desktop/src/components/docs/DatabaseDocsDialog.vue +++ b/apps/desktop/src/components/docs/DatabaseDocsDialog.vue @@ -1,7 +1,7 @@ + + diff --git a/apps/desktop/src/docs-export/__tests__/exportPayload.spec.ts b/apps/desktop/src/docs-export/__tests__/exportPayload.spec.ts new file mode 100644 index 000000000..96d647f14 --- /dev/null +++ b/apps/desktop/src/docs-export/__tests__/exportPayload.spec.ts @@ -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 = `
`; + 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 = `
`; + expect(() => readPayload()).toThrow(/application\/dbx-snapshot/); + }); + + it("throws on a payload that is not base64", () => { + document.body.innerHTML = `
`; + 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/); + }); +}); diff --git a/apps/desktop/src/docs-export/__tests__/exportSmoke.spec.ts b/apps/desktop/src/docs-export/__tests__/exportSmoke.spec.ts new file mode 100644 index 000000000..2806674ee --- /dev/null +++ b/apps/desktop/src/docs-export/__tests__/exportSmoke.spec.ts @@ -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 `/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"); + }); +}); diff --git a/apps/desktop/src/docs-export/__tests__/exportTranslate.spec.ts b/apps/desktop/src/docs-export/__tests__/exportTranslate.spec.ts new file mode 100644 index 000000000..a4682f1c7 --- /dev/null +++ b/apps/desktop/src/docs-export/__tests__/exportTranslate.spec.ts @@ -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"); + }); +}); diff --git a/apps/desktop/src/docs-export/__tests__/main.spec.ts b/apps/desktop/src/docs-export/__tests__/main.spec.ts new file mode 100644 index 000000000..623b3d824 --- /dev/null +++ b/apps/desktop/src/docs-export/__tests__/main.spec.ts @@ -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 = `
`; +}); + +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 `. 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 + + diff --git a/apps/desktop/src/docs/components/TablePage.vue b/apps/desktop/src/docs/components/TablePage.vue index 6e03cda73..92d844bf3 100644 --- a/apps/desktop/src/docs/components/TablePage.vue +++ b/apps/desktop/src/docs/components/TablePage.vue @@ -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. -->
- ⬤ LOCAL + ⬤ {{ translate("docs.localNote") }}
@@ -64,19 +64,19 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
-

Columns

+

{{ translate("docs.columns") }}

-

Indexes

+

{{ translate("docs.indexes") }}

- - - + + + @@ -100,12 +100,12 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
-

Relationships

- +

{{ translate("docs.relationships") }}

+
-

Definition

+

{{ translate("docs.definitionHeader") }}

{{ table.viewDefinition }}
diff --git a/apps/desktop/src/docs/components/WikiIndex.vue b/apps/desktop/src/docs/components/WikiIndex.vue index 9a9942d62..e2a58f500 100644 --- a/apps/desktop/src/docs/components/WikiIndex.vue +++ b/apps/desktop/src/docs/components/WikiIndex.vue @@ -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.label || "(no schema)" }}

+

{{ section.label || translate(section.fallbackKey) }}

{{ section.tables.length }} tables
diff --git a/apps/desktop/src/docs/diagramGeometry.ts b/apps/desktop/src/docs/diagramGeometry.ts new file mode 100644 index 000000000..5a46afef8 --- /dev/null +++ b/apps/desktop/src/docs/diagramGeometry.ts @@ -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 }; +} diff --git a/apps/desktop/src/docs/docsIndex.ts b/apps/desktop/src/docs/docsIndex.ts index 66579856f..faf3ac27f 100644 --- a/apps/desktop/src/docs/docsIndex.ts +++ b/apps/desktop/src/docs/docsIndex.ts @@ -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; diff --git a/apps/desktop/src/docs/docsRoute.ts b/apps/desktop/src/docs/docsRoute.ts new file mode 100644 index 000000000..d258b9993 --- /dev/null +++ b/apps/desktop/src/docs/docsRoute.ts @@ -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 "#/"; + } +} diff --git a/apps/desktop/src/i18n/locales/docs/en.ts b/apps/desktop/src/i18n/locales/docs/en.ts index f0f72d634..1ec86e9bd 100644 --- a/apps/desktop/src/i18n/locales/docs/en.ts +++ b/apps/desktop/src/i18n/locales/docs/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/docs/es.ts b/apps/desktop/src/i18n/locales/docs/es.ts index eed9b3f0d..361af96d6 100644 --- a/apps/desktop/src/i18n/locales/docs/es.ts +++ b/apps/desktop/src/i18n/locales/docs/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/docs/it.ts b/apps/desktop/src/i18n/locales/docs/it.ts index c78a5e9fe..b154d9a69 100644 --- a/apps/desktop/src/i18n/locales/docs/it.ts +++ b/apps/desktop/src/i18n/locales/docs/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/docs/ja.ts b/apps/desktop/src/i18n/locales/docs/ja.ts index b8267a63a..59337e46c 100644 --- a/apps/desktop/src/i18n/locales/docs/ja.ts +++ b/apps/desktop/src/i18n/locales/docs/ja.ts @@ -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: "ドキュメント化できないテーブルがあります", diff --git a/apps/desktop/src/i18n/locales/docs/ko.ts b/apps/desktop/src/i18n/locales/docs/ko.ts index da6652376..3e4897fb9 100644 --- a/apps/desktop/src/i18n/locales/docs/ko.ts +++ b/apps/desktop/src/i18n/locales/docs/ko.ts @@ -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: "문서화할 수 없는 테이블이 있습니다", diff --git a/apps/desktop/src/i18n/locales/docs/pt-BR.ts b/apps/desktop/src/i18n/locales/docs/pt-BR.ts index 276d90708..3225bb83e 100644 --- a/apps/desktop/src/i18n/locales/docs/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/docs/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/docs/zh-CN.ts b/apps/desktop/src/i18n/locales/docs/zh-CN.ts index c968bc8ac..df85b0f7b 100644 --- a/apps/desktop/src/i18n/locales/docs/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/docs/zh-CN.ts @@ -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: "有一张表无法生成文档", diff --git a/apps/desktop/src/i18n/locales/docs/zh-TW.ts b/apps/desktop/src/i18n/locales/docs/zh-TW.ts index 48a53fd04..62ed4a756 100644 --- a/apps/desktop/src/i18n/locales/docs/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/docs/zh-TW.ts @@ -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: "有一張資料表無法產生文件", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 152502cfa..bb2f13a73 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -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"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index dac91dc84..dfa586a2b 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -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 { + 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 // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index f757116e8..45f7daef3 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -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 { + return invoke("docs_export_html", { filePath, snapshot, annotations, lang }); +} + export async function saveConnections(configs: ConnectionConfig[]): Promise { return invoke("save_connections", { configs }); } diff --git a/apps/desktop/src/styles/__tests__/cascadeCss.ts b/apps/desktop/src/styles/__tests__/cascadeCss.ts new file mode 100644 index 000000000..0b8d4e7fc --- /dev/null +++ b/apps/desktop/src/styles/__tests__/cascadeCss.ts @@ -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); +} diff --git a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts index 007cf6f6b..41b02049b 100644 --- a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts +++ b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts @@ -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"); diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index 5b710c08e..8988ce883 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -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); diff --git a/apps/desktop/src/styles/tokens.css b/apps/desktop/src/styles/tokens.css new file mode 100644 index 000000000..64d7c055f --- /dev/null +++ b/apps/desktop/src/styles/tokens.css @@ -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); +} diff --git a/apps/desktop/vite.docs-export.config.ts b/apps/desktop/vite.docs-export.config.ts new file mode 100644 index 000000000..5c173d1be --- /dev/null +++ b/apps/desktop/vite.docs-export.config.ts @@ -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; +} + +/** + * 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): 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) { + 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 = {}; + const deps: Record = {}; + + 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 + // `/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(); + 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 \n\n\n\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("".into()), + }); + let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); + + assert!(!html.contains("").count(), 2, "exactly the two real script elements"); + } + + #[test] + fn the_payload_round_trips() { + let (snapshot, annotations) = fixture(); + let html = to_standalone_html(&snapshot, &annotations, "en").expect("export"); + let start = html.find("application/dbx-snapshot").expect("payload element"); + let body = &html[start..]; + let encoded = body[body.find('>').unwrap() + 1..body.find("").unwrap()].trim(); + let decoded = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).expect("valid base64"); + let value: serde_json::Value = serde_json::from_slice(&decoded).expect("valid json"); + assert_eq!(value["snapshot"]["project"]["name"], "shop"); + assert_eq!(value["lang"], "en"); + } + + #[test] + fn 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 { + 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("").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("").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 `` raw — + /// unlike the base64 payload, nothing escapes it. That is safe against a + /// literal `` (asserted above), but the HTML tokenizer has two + /// more states that can hide one: inside a `` no longer closes the element. Both sequences exist in + /// EXPORT_JS today (third-party minified output) and are safe only + /// because every `` before the next `").map(|offset| open + offset); + let next_script = lower[open..].find(" assert!( + close < script, + "an unmatched closes — this would trap the browser in \ + script-data-double-escaped state and swallow our own closing " + ), + (None, Some(script)) => panic!( + "an unclosed anywhere after it" + ), + (Some(_), None) | (None, None) => {} + } + pos = open + "
NameColumnsSettings{{ translate("docs.nameHeader") }}{{ translate("docs.columns") }}{{ translate("docs.settingsHeader") }}