feat(docs): export database documentation as standalone HTML

This commit is contained in:
Fernando Possebon authored and GitHub committed 2026-08-08 11:04:16 +08:00
1 parent efd0c381ad
commit 63498da121
59 files changed
+4374 -237

No files matched your search

+86 -25
View File
@@ -5,30 +5,44 @@ import DocsSidebar from "./components/DocsSidebar.vue";
import EnumPage from "./components/EnumPage.vue";
import GroupEditor from "./components/GroupEditor.vue";
import NoteEditor from "./components/NoteEditor.vue";
import SchemaDiagram from "./components/SchemaDiagram.vue";
import TablePage from "./components/TablePage.vue";
import WarningBanner from "./components/WarningBanner.vue";
import WikiIndex from "./components/WikiIndex.vue";
import "./docs.css";
import type { Translate } from "./docsWarnings";
import { qualifiedTableKey } from "./docsKeys";
import type { DocsRoute } from "./docsRoute";
import { groupBySchema, groupByTableGroup } from "./docsIndex";
import type { AnnotationFile, DocsEdit, GroupAnnotation, SchemaSnapshot } from "./types";
const props = defineProps<{
snapshot: SchemaSnapshot;
/**
* The local notes layer. `snapshot` already carries notes merged for display,
* so this is here for what the merge erases: `groups` holds the editable
* `GroupAnnotation` records, while `snapshot.groups` holds resolved
* `TableGroup`s that GroupEditor and GroupPicker cannot write back to.
*/
annotations: AnnotationFile;
readonly?: boolean;
translate: Translate;
}>();
const props = withDefaults(
defineProps<{
snapshot: SchemaSnapshot;
/**
* The local notes layer. `snapshot` already carries notes merged for display,
* so this is here for what the merge erases: `groups` holds the editable
* `GroupAnnotation` records, while `snapshot.groups` holds resolved
* `TableGroup`s that GroupEditor and GroupPicker cannot write back to.
*/
annotations: AnnotationFile;
readonly?: boolean;
translate: Translate;
/**
* When provided, navigation is controlled by the host and mirrored back
* through `update:route`. Absent — the dialog's case — DocsApp owns its
* own navigation exactly as before and never touches the URL.
*/
route?: DocsRoute;
/** `inline` renders SchemaDiagram; `external` leaves the host to offer its own. */
diagram?: "inline" | "external";
}>(),
{ diagram: "external" },
);
const emit = defineEmits<{
edit: [edit: DocsEdit];
"update:route": [route: DocsRoute];
}>();
/** `readonly` is the one optional prop; absent means editing is allowed. */
@@ -39,8 +53,26 @@ const annotationGroups = computed<GroupAnnotation[]>(() => props.annotations.gro
// Grouping is computed once here and handed to both the sidebar and the index,
// so the two can never disagree about what the sections are.
const mode = ref<"schema" | "group">(props.snapshot.groups.length > 0 ? "group" : "schema");
const activeKey = ref<string | null>(null);
const activeEnumName = ref<string | null>(null);
// Owned only when `route` is absent — see the prop doc above. `effectiveRoute`
// prefers `props.route`, so once the host controls navigation, a click here
// still updates these (harmless) but the DISPLAYED view tracks the prop, not
// this state. That is what keeps the two from disagreeing if the host
// declines to apply an `update:route`.
const internalKey = ref<string | null>(null);
const internalEnumName = ref<string | null>(null);
const internalDiagram = ref(false);
const effectiveRoute = computed<DocsRoute>(() => {
if (props.route) return props.route;
if (internalDiagram.value) return { kind: "diagram" };
if (internalEnumName.value !== null) return { kind: "enum", name: internalEnumName.value };
if (internalKey.value !== null) return { kind: "table", key: internalKey.value };
return { kind: "index" };
});
const activeKey = computed(() => (effectiveRoute.value.kind === "table" ? effectiveRoute.value.key : null));
const activeEnumName = computed(() => (effectiveRoute.value.kind === "enum" ? effectiveRoute.value.name : null));
const sections = computed(() => (mode.value === "schema" ? groupBySchema(props.snapshot) : groupByTableGroup(props.snapshot)));
@@ -53,7 +85,10 @@ const activeTable = computed(() => props.snapshot.tables.find((table) => qualifi
*/
const activeEnum = computed(() => (activeEnumName.value === null ? null : (props.snapshot.enums.find((value) => value.name === activeEnumName.value) ?? null)));
const view = computed<"index" | "table" | "enum">(() => {
const view = computed<"index" | "table" | "enum" | "diagram">(() => {
if (effectiveRoute.value.kind === "diagram") {
return "diagram";
}
if (activeEnum.value !== null) {
return "enum";
}
@@ -72,21 +107,34 @@ function open(key: string): void {
// A key naming no table leaves the reader where they are rather than
// dropping them on a blank page.
if (props.snapshot.tables.some((table) => qualifiedTableKey(table) === key)) {
activeEnumName.value = null;
activeKey.value = key;
internalEnumName.value = null;
internalDiagram.value = false;
internalKey.value = key;
emit("update:route", { kind: "table", key });
}
}
function openEnum(name: string): void {
if (props.snapshot.enums.some((value) => value.name === name)) {
activeKey.value = null;
activeEnumName.value = name;
internalKey.value = null;
internalDiagram.value = false;
internalEnumName.value = name;
emit("update:route", { kind: "enum", name });
}
}
function openDiagram(): void {
internalKey.value = null;
internalEnumName.value = null;
internalDiagram.value = true;
emit("update:route", { kind: "diagram" });
}
function home(): void {
activeKey.value = null;
activeEnumName.value = null;
internalKey.value = null;
internalEnumName.value = null;
internalDiagram.value = false;
emit("update:route", { kind: "index" });
}
/**
@@ -109,7 +157,18 @@ function createGroupFor(tableKey: string): void {
<template>
<div class="flex h-full min-h-0 bg-background text-foreground">
<DocsSidebar :sections="sections" :mode="mode" :active-key="activeKey" @update:mode="mode = $event" @select="open" @home="home()" />
<div class="flex h-full min-h-0 w-64 shrink-0 flex-col">
<DocsSidebar class="min-h-0 flex-1" :sections="sections" :mode="mode" :active-key="activeKey" :translate="translate" @update:mode="mode = $event" @select="open" @home="home()" />
<button
v-if="diagram === 'inline'"
type="button"
class="shrink-0 border-t border-r border-border bg-background px-3 py-2 text-left text-xs font-medium transition-colors"
:class="view === 'diagram' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'"
@click="openDiagram()"
>
{{ translate("docs.diagram") }}
</button>
</div>
<main class="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<header class="flex flex-wrap items-start justify-between gap-3">
@@ -119,17 +178,17 @@ function createGroupFor(tableKey: string): void {
{{ snapshot.project.databaseType }}<template v-if="snapshot.project.database"> · {{ snapshot.project.database }}</template> · {{ snapshot.tables.length }} tables · generated {{ snapshot.project.generatedAt }}
</p>
</div>
<DocsSearch :snapshot="snapshot" @select="open" @select-enum="openEnum" />
<DocsSearch :snapshot="snapshot" :translate="translate" @select="open" @select-enum="openEnum" />
</header>
<WarningBanner :warnings="snapshot.warnings" :translate="translate" />
<div v-if="view === 'index'" class="flex flex-col gap-4">
<NoteEditor :model-value="snapshot.project.note ?? ''" :readonly="isReadonly" :translate="translate" @update:model-value="emit('edit', { kind: 'projectNote', note: $event })" />
<WikiIndex :sections="sections" @select="open" />
<WikiIndex :sections="sections" :translate="translate" @select="open" />
<section v-if="!isReadonly && annotationGroups.length > 0" class="flex flex-col gap-2">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Groups</h2>
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.groups") }}</h2>
<GroupEditor v-for="group in annotationGroups" :key="group.id" :group="group" :translate="translate" @update:group="emit('edit', { kind: 'upsertGroup', group: $event })" @delete="emit('edit', { kind: 'removeGroup', groupId: $event })" />
</section>
</div>
@@ -148,6 +207,8 @@ function createGroupFor(tableKey: string): void {
/>
<EnumPage v-else-if="activeEnum" :enum-type="activeEnum" :snapshot="snapshot" :translate="translate" @select="open" />
<SchemaDiagram v-else-if="view === 'diagram'" :snapshot="snapshot" @select="open" />
</main>
</div>
</template>
+175 -3
View File
@@ -2,17 +2,18 @@ import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "vue/compiler-sfc";
import ts from "typescript";
const docsRoot = path.resolve(__dirname, "..");
function vueFiles(): string[] {
function filesWithExtension(extension: string): string[] {
const found: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "__tests__" && entry.name !== "fixtures") {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".vue")) {
} else if (entry.isFile() && entry.name.endsWith(extension)) {
found.push(full);
}
}
@@ -21,12 +22,122 @@ function vueFiles(): string[] {
return found;
}
function vueFiles(): string[] {
return filesWithExtension(".vue");
}
// Every pure module beside the components — docsIndex.ts, docsSearch.ts,
// docsWarnings.ts, and so on. "(no group)" lived in one of these
// (docsIndex.ts) and was invisible to the guard below until this existed:
// that guard only ever walked .vue files, so a literal that never touches a
// template — only a data field a template later reads — was outside its
// reach entirely.
function tsFiles(): string[] {
return filesWithExtension(".ts");
}
/**
* Every namespace-key VALUE currently spelled out in en.ts, long enough and
* plain enough to check for. Shared by both the .vue and .ts scans below so
* there is exactly one definition of "what counts as a hardcoded literal" —
* not two lists that can drift out of step with each other.
*/
async function namespaceLiterals(): Promise<string[]> {
const en = (await import("../../i18n/locales/docs/en")).default as Record<string, unknown>;
const literals: string[] = [];
const walk = (node: unknown) => {
if (typeof node === "string") {
// Skip short strings (false positives on words like "LOCAL") and any
// string carrying a placeholder, which cannot appear verbatim anyway.
if (node.length >= 4 && !node.includes("{")) literals.push(node);
return;
}
if (node && typeof node === "object") Object.values(node).forEach(walk);
};
walk(en);
return literals;
}
function scriptOf(file: string): string {
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
return `${descriptor.script?.content ?? ""}\n${descriptor.scriptSetup?.content ?? ""}`;
}
const EXPECTED = ["ColumnTable.vue", "DocsApp.vue", "DocsSearch.vue", "DocsSidebar.vue", "EnumPage.vue", "GroupEditor.vue", "GroupPicker.vue", "NoteEditor.vue", "RelationshipList.vue", "TablePage.vue", "WarningBanner.vue", "WikiIndex.vue"];
/**
* Splits a component's template into the two channels that can actually
* reach the reader as text: static text between tags, and the JS
* expressions inside `{{ }}` interpolations. Attribute values (`:class="…"`,
* `:title="…"`) are deliberately excluded — see the guard below for why.
*
* The two channels get different treatment below: `staticText` is scanned
* with plain substring matching, because it can't contain anything but
* literal characters. `interpolations` holds JS, where a bare identifier
* (`table.viewDefinition`, `snapshot.project.databaseType`) can innocently
* contain a locale word as a substring — only a *quoted* occurrence
* (`"literal"` or `'literal'`) inside an expression counts as hardcoded copy.
*/
function domTextOf(file: string): { staticText: string; interpolations: string[] } {
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
const template = descriptor.template?.content ?? "";
// Comments are prose, not display text, and can otherwise trip a
// substring match by coincidence (a comment mentioning "the note" would
// false-positive on the `noteHeader` key).
const withoutComments = template.replace(/<!--[\s\S]*?-->/g, "");
const interpolations: string[] = [];
const withoutInterpolations = withoutComments.replace(/{{([\s\S]*?)}}/g, (_match, expr) => {
interpolations.push(expr);
return "";
});
// Whatever sits between a tag's closing `>` and the next `<` is DOM text;
// whatever sits before that `>`, inside the tag itself, is an attribute
// and mostly doesn't render as visible text — so this intentionally
// doesn't look there on purpose. "Mostly" because the split is a regex,
// not a parser: an attribute value containing a bare `>` (a comparison
// like `v-if="count > 0"`) ends the match early and leaks the tail of
// that attribute into a segment. That leak runs in the safe direction —
// it can only add text to scan, never drop real display text — so the
// risk is a false positive below, never a missed literal.
const segments: string[] = [];
for (const match of withoutInterpolations.matchAll(/>([^<]*)</g)) {
segments.push(match[1]);
}
return { staticText: segments.join(" "), interpolations };
}
/**
* Every string literal in a .ts file that could become a runtime VALUE —
* and, from there, reach a template as display text, exactly how
* docsIndex.ts's `label: "(no group)"` did. Excludes string literals in TYPE
* position, e.g. `type NoteSource = "DATABASE" | "LOCAL" | "NONE"`: those
* disappear at compile time and can never be displayed, so flagging them
* would be the .ts equivalent of the `=== 'LOCAL'` false positive from the
* last round. `ts.isLiteralTypeNode` is TypeScript's own distinction
* between the two, not a hand-picked exception — the same check the
* compiler itself uses to know it is looking at a type, not a value.
*
* Unlike domTextOf's split of display-vs-comparison text, this collects
* every value-position literal with no such distinction — a literal used
* only in a comparison (`type === "LOCAL"`) is indistinguishable here from
* one that reaches a template as display text. Accepted as this scan's
* blind spot rather than chased, for the same reason: the risk is a false
* positive, not a missed literal.
*/
function valueStringLiteralsOf(file: string): string[] {
const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
const literals: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isStringLiteral(node) && !ts.isLiteralTypeNode(node.parent)) {
literals.push(node.text);
}
ts.forEachChild(node, visit);
};
visit(source);
return literals;
}
const EXPECTED = ["ColumnTable.vue", "DocsApp.vue", "DocsSearch.vue", "DocsSidebar.vue", "EnumPage.vue", "GroupEditor.vue", "GroupPicker.vue", "NoteEditor.vue", "RelationshipList.vue", "SchemaDiagram.vue", "TablePage.vue", "WarningBanner.vue", "WikiIndex.vue"];
describe("docs viewer component contract", () => {
it("finds every expected component", () => {
@@ -154,4 +265,65 @@ describe("docs viewer component contract", () => {
expect(block, `${selector} must define --group-tint without oklch`).toContain("--group-tint: hsl(");
}
});
it("renders no English literal that already has a key", async () => {
// Derived from the namespace, NOT an enumerated list of strings. A key
// added tomorrow is covered tomorrow. This is the guard Part 3b lacked:
// docsNamespaceParity compares locale files to EACH OTHER, so a key that
// no component ever calls passes every existing test.
const literals = await namespaceLiterals();
expect(literals.length).toBeGreaterThan(10);
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
for (const file of files) {
// Deliberately scoped to what reaches the DOM as text — see domTextOf
// — rather than the whole file. Two earlier, narrower shapes of this
// same guard both ran green over a real defect: matching only
// `>literal<` misses a literal buried in a JS fallback
// (`section.label || "(no schema)"`, DocsSidebar.vue/WikiIndex.vue);
// matching any quoted occurrence anywhere in the template misses
// nothing display-related but flags plenty that isn't — a bare
// identifier like `table.viewDefinition` or
// `snapshot.project.databaseType` shares a substring with a locale
// word purely by accident, and an attribute-only comparison like
// `noteOf(column)?.source === 'LOCAL'` tests data, not copy.
const { staticText, interpolations } = domTextOf(file);
for (const literal of literals) {
// Static text, e.g. `>Overview<` or `>⬤ LOCAL<` — anything literally
// written between tags is definitionally display text, so a plain
// substring match is safe here.
expect(staticText.includes(literal), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
// A JS expression inside a `{{ }}` mustache, e.g.
// `section.label || "(no schema)"`. Only a *quoted* occurrence
// counts — an unquoted one is a property/variable name, not copy.
expect(
interpolations.some((expr) => expr.includes(`"${literal}"`) || expr.includes(`'${literal}'`)),
`${path.basename(file)} hardcodes "${literal}" in a template expression — call translate() instead`,
).toBe(false);
}
}
// Known gap, accepted rather than chased: a literal passed to an
// attribute binding — `:title="'Hardcoded text'"` — can still reach the
// reader (as a tooltip, an aria-label, …) and this guard does not see
// it, because attribute values are excluded by design to avoid the
// data-comparison false positives above. If a future defect turns out
// to live in an attribute, that's this guard's known blind spot, not a
// regression in it.
// The pure modules beside the components — docsIndex.ts's
// `label: "(no group)"` was exactly this: a literal that never touches a
// template directly, only a data field a template reads later. A .vue-only
// scan is structurally blind to it no matter how the .vue scan itself is
// shaped, so this is a second, independent walk over a different file
// extension rather than a wider regex over the same one.
const tsSourceFiles = tsFiles();
expect(tsSourceFiles.length).toBeGreaterThan(0);
for (const file of tsSourceFiles) {
const valueLiterals = valueStringLiteralsOf(file);
for (const literal of literals) {
expect(valueLiterals.includes(literal), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
}
}
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { clipToCard } from "../diagramGeometry";
describe("clipToCard", () => {
const half = { width: 100, height: 50 };
it("exits through the vertical edge for a horizontal run", () => {
// Centre (0,0) to (500,0): the line leaves through the right edge at
// x = +100, not through the top or bottom.
expect(clipToCard({ x: 0, y: 0 }, { x: 500, y: 0 }, half)).toEqual({ x: 100, y: 0 });
});
it("exits through the horizontal edge for a vertical run", () => {
expect(clipToCard({ x: 0, y: 0 }, { x: 0, y: 500 }, half)).toEqual({ x: 0, y: 50 });
});
it("picks the nearer edge on a diagonal", () => {
// Slope 1 against a 2:1 card: the vertical edge is reached first, so the
// result sits ON x = 100 with |y| < 50. Clipping to the wrong axis puts
// the endpoint outside the card and the line visibly overshoots.
const point = clipToCard({ x: 0, y: 0 }, { x: 500, y: 500 }, half);
expect(point.x).toBeCloseTo(50);
expect(point.y).toBeCloseTo(50);
});
it("returns the centre when both points coincide", () => {
// Two tables laid out at the same position would otherwise divide by zero
// and emit NaN into the SVG path, which renders nothing at all.
expect(clipToCard({ x: 7, y: 7 }, { x: 7, y: 7 }, half)).toEqual({ x: 7, y: 7 });
});
it("handles negative directions symmetrically", () => {
expect(clipToCard({ x: 0, y: 0 }, { x: -500, y: 0 }, half)).toEqual({ x: -100, y: 0 });
});
});
+16 -1
View File
@@ -45,6 +45,14 @@ describe("groupBySchema", () => {
expect(sections).toHaveLength(1);
expect(sections[0].tables[0].name).toBe("orders");
});
it("tags every section with the schema fallback key", () => {
// The render sites fall back to `translate(section.fallbackKey)` when
// `label` is empty — schema sections must carry docs.noSchema, not the
// sibling docs.noGroup key groupByTableGroup uses.
const sections = groupBySchema(snapshot([table("public", "orders")]));
expect(sections[0].fallbackKey).toBe("docs.noSchema");
});
});
describe("groupByTableGroup", () => {
@@ -62,11 +70,18 @@ describe("groupByTableGroup", () => {
expect(sections[0].note).toBe("Checkout.");
});
it("collects ungrouped tables into a trailing (no group) section", () => {
it("collects ungrouped tables into a trailing, unlabelled section", () => {
const sections = groupByTableGroup(snapshot([table("core", "orders", "order-mgmt"), table("core", "users", null)], groups));
const last = sections[sections.length - 1];
expect(last.key).toBe("");
// Empty, not a hardcoded "(no group)": the render sites translate this via
// `translate(section.fallbackKey)` when `label` is falsy. A non-empty
// English literal here would bypass that fallback and render untranslated
// in every non-English locale — this guarded a real defect, not a
// hypothetical one.
expect(last.label).toBe("");
expect(last.fallbackKey).toBe("docs.noGroup");
expect(last.hue).toBeNull();
expect(last.tables.map((t) => t.name)).toEqual(["users"]);
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { formatDocsHash, parseDocsHash } from "../docsRoute";
import type { SchemaSnapshot } from "../types";
// `formatVersion` is top-level on SchemaSnapshot, not inside `project`.
const snapshot = {
formatVersion: 1,
project: { name: "shop", databaseType: "postgres", database: "shop", schemas: ["public"], generatedAt: "2026-08-06T00:00:00Z", note: null },
tables: [
{ schema: "public", name: "orders", columns: [], indexes: [] },
{ schema: null, name: "a/b", columns: [], indexes: [] },
],
enums: [{ schema: "public", name: "order_status", values: ["pending"] }],
relationships: [],
groups: [],
warnings: [],
} as unknown as SchemaSnapshot;
describe("parseDocsHash", () => {
it("reads a table route", () => {
expect(parseDocsHash("#/table/public.orders", snapshot, true)).toEqual({ kind: "table", key: "public.orders" });
});
it("decodes an identifier containing a slash", () => {
// A table named `a/b` must survive the round trip; an undecoded hash
// would split into a bogus segment and silently fall back to the index.
expect(parseDocsHash("#/table/a%2Fb", snapshot, true)).toEqual({ kind: "table", key: "a/b" });
});
it("reads an enum route", () => {
expect(parseDocsHash("#/enum/order_status", snapshot, true)).toEqual({ kind: "enum", name: "order_status" });
});
it("falls back to the index for a table that is not in the snapshot", () => {
// A deep link into a since-dropped table is the EXPECTED case for a file
// someone saved months ago. It must never render a blank page.
expect(parseDocsHash("#/table/public.gone", snapshot, true)).toEqual({ kind: "index" });
});
it("falls back to the index for junk, empty and bare hashes", () => {
for (const hash of ["", "#", "#/", "#/nonsense", "#/table", "#/table/", "not-a-hash"]) {
expect(parseDocsHash(hash, snapshot, true), hash).toEqual({ kind: "index" });
}
});
it("refuses the diagram route when the host has not enabled it", () => {
// The dialog passes diagram="external" and keeps its button to the full
// SchemaDiagramDialog. A hash must not render a view that host declined.
expect(parseDocsHash("#/diagram", snapshot, false)).toEqual({ kind: "index" });
expect(parseDocsHash("#/diagram", snapshot, true)).toEqual({ kind: "diagram" });
});
});
describe("formatDocsHash", () => {
it("round-trips every route kind", () => {
for (const route of [{ kind: "index" }, { kind: "table", key: "public.orders" }, { kind: "enum", name: "order_status" }, { kind: "diagram" }] as const) {
expect(parseDocsHash(formatDocsHash(route), snapshot, true)).toEqual(route);
}
});
it("encodes a slash in an identifier", () => {
expect(formatDocsHash({ kind: "table", key: "a/b" })).toBe("#/table/a%2Fb");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const stylesDir = path.resolve(__dirname, "../../styles");
/** Every `--name:` custom property declared in a stylesheet. */
function declaredTokens(file: string): Set<string> {
const source = readFileSync(path.join(stylesDir, file), "utf8");
return new Set(Array.from(source.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm), (match) => match[1]));
}
describe("design tokens", () => {
it("defines the tokens the docs viewer resolves against", () => {
// These are the utilities used in src/docs/**: bg-background,
// text-foreground, text-muted-foreground, bg-muted, border-border,
// focus:border-ring. If a token stops being declared here the export
// renders unstyled while the app, which has globals.css, looks fine —
// so this must be checked against tokens.css, not globals.css.
const tokens = declaredTokens("tokens.css");
for (const token of ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--ring"]) {
expect(tokens.has(token), `tokens.css must declare ${token}`).toBe(true);
}
});
it("keeps globals.css importing the extracted tokens", () => {
const globals = readFileSync(path.join(stylesDir, "globals.css"), "utf8");
expect(globals.includes('@import "./tokens.css"')).toBe(true);
});
it("declares the dark overrides after the light ones", () => {
// Custom properties resolve at use time, but override order still
// decides which block wins. A tokens.css with .dark BEFORE :root leaves
// dark mode rendering light values.
const source = readFileSync(path.join(stylesDir, "tokens.css"), "utf8");
const root = source.search(/^:root\s*\{/m);
const dark = source.search(/^\.dark\s*\{/m);
expect(root, "tokens.css must declare :root").toBeGreaterThan(-1);
expect(dark, "tokens.css must declare .dark").toBeGreaterThan(-1);
expect(dark).toBeGreaterThan(root);
});
});
@@ -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>
+32
View File
@@ -0,0 +1,32 @@
export interface Point {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
/**
* Where a line from one card's centre towards another leaves the first card.
*
* Without this, edges terminate under the card and appear to sprout from a
* table's middle. Scaling both axes and taking the smaller factor picks
* whichever edge the ray actually reaches first.
*
* `half` is the card's HALF width and height, measured from its centre.
*/
export function clipToCard(from: Point, to: Point, half: Size): Point {
const dx = to.x - from.x;
const dy = to.y - from.y;
if (dx === 0 && dy === 0) {
// Coincident centres would divide by zero and put NaN in the path data,
// which renders as nothing rather than as an error.
return { x: from.x, y: from.y };
}
const scaleX = dx === 0 ? Number.POSITIVE_INFINITY : half.width / Math.abs(dx);
const scaleY = dy === 0 ? Number.POSITIVE_INFINITY : half.height / Math.abs(dy);
const scale = Math.min(scaleX, scaleY);
return { x: from.x + dx * scale, y: from.y + dy * scale };
}
+21 -1
View File
@@ -5,6 +5,21 @@ export interface IndexSection {
/** Schema name, group id, or "" for the ungrouped bucket. */
key: string;
label: string;
/**
* Locale key to show in place of `label` when it's empty — "docs.noSchema"
* from groupBySchema, "docs.noGroup" from groupByTableGroup. This module
* stays translator-free (it is pure, tested without Vue, and every other
* pure module in src/docs/ follows the same rule), so it hands back a KEY
* rather than calling translate() itself; the render site decides how.
*
* Carrying the key on the section — rather than each render site inferring
* it from a `mode` prop — means a fallback can never silently pick the
* wrong word for the section it labels: WikiIndex.vue doesn't even receive
* `mode`, so a mode-based guess there would either require threading a prop
* that has no other use, or duplicate this same schema/group decision at a
* second call site to keep in sync with this one.
*/
fallbackKey: "docs.noSchema" | "docs.noGroup";
/** Group hue, or null for schema sections and the ungrouped bucket. */
hue: number | null;
note: string | null;
@@ -32,6 +47,7 @@ export function groupBySchema(snapshot: SchemaSnapshot): IndexSection[] {
.map(([key, tables]) => ({
key,
label: key,
fallbackKey: "docs.noSchema" as const,
hue: null,
note: null,
tables: [...tables].sort(byName),
@@ -52,6 +68,7 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
sections.push({
key: group.id,
label: group.name,
fallbackKey: "docs.noGroup",
hue: group.hue,
note: group.note,
tables,
@@ -63,7 +80,10 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
const ungrouped = snapshot.tables.filter((table) => table.groupId === null || !known.has(table.groupId)).sort(byName);
if (ungrouped.length > 0) {
sections.push({ key: "", label: "(no group)", hue: null, note: null, tables: ungrouped });
// Empty, not "(no group)": the render sites already fall back to a
// translated label when `label` is empty (see IndexSection.fallbackKey).
// A non-empty English literal here would bypass that fallback entirely.
sections.push({ key: "", label: "", fallbackKey: "docs.noGroup", hue: null, note: null, tables: ungrouped });
}
return sections;
+61
View File
@@ -0,0 +1,61 @@
import { qualifiedTableKey } from "./docsKeys";
import type { SchemaSnapshot } from "./types";
/**
* Where the viewer is pointing.
*
* This exists so the standalone export can drive navigation from
* `location.hash` without DocsApp itself touching the URL: DBX has no
* router, and a viewer that wrote to the address bar would hijack the host
* application's.
*/
export type DocsRoute = { kind: "index" } | { kind: "table"; key: string } | { kind: "enum"; name: string } | { kind: "diagram" };
const INDEX: DocsRoute = { kind: "index" };
/**
* Resolve a hash against a snapshot.
*
* Anything unrecognised — junk, a table that no longer exists, the diagram
* route on a host that did not enable it — resolves to the index. A saved
* file whose schema has since changed is the expected case, not an exotic
* one, and must never render blank.
*/
export function parseDocsHash(hash: string, snapshot: SchemaSnapshot, allowDiagram: boolean): DocsRoute {
if (!hash.startsWith("#")) return INDEX;
const segments = hash.slice(1).replace(/^\//, "").split("/");
const [kind, ...rest] = segments;
const identifier = rest.join("/");
if (kind === "diagram" && identifier === "") return allowDiagram ? { kind: "diagram" } : INDEX;
if (identifier === "") return INDEX;
let decoded: string;
try {
decoded = decodeURIComponent(identifier);
} catch {
// A malformed percent-escape throws rather than returning null.
return INDEX;
}
if (kind === "table") {
return snapshot.tables.some((table) => qualifiedTableKey(table) === decoded) ? { kind: "table", key: decoded } : INDEX;
}
if (kind === "enum") {
return (snapshot.enums ?? []).some((value) => value.name === decoded) ? { kind: "enum", name: decoded } : INDEX;
}
return INDEX;
}
export function formatDocsHash(route: DocsRoute): string {
switch (route.kind) {
case "table":
return `#/table/${encodeURIComponent(route.key)}`;
case "enum":
return `#/enum/${encodeURIComponent(route.name)}`;
case "diagram":
return "#/diagram";
default:
return "#/";
}
}