feat(docs): export database documentation as standalone HTML
This commit is contained in:
1 parent
efd0c381ad
commit
63498da121
59 files changed
+4374
-237
No files matched your search
@@ -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
@@ -2,17 +2,18 @@ import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
import ts from "typescript";
|
||||
|
||||
const docsRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function vueFiles(): string[] {
|
||||
function filesWithExtension(extension: string): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== "__tests__" && entry.name !== "fixtures") {
|
||||
walk(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".vue")) {
|
||||
} else if (entry.isFile() && entry.name.endsWith(extension)) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
@@ -21,12 +22,122 @@ function vueFiles(): string[] {
|
||||
return found;
|
||||
}
|
||||
|
||||
function vueFiles(): string[] {
|
||||
return filesWithExtension(".vue");
|
||||
}
|
||||
|
||||
// Every pure module beside the components — docsIndex.ts, docsSearch.ts,
|
||||
// docsWarnings.ts, and so on. "(no group)" lived in one of these
|
||||
// (docsIndex.ts) and was invisible to the guard below until this existed:
|
||||
// that guard only ever walked .vue files, so a literal that never touches a
|
||||
// template — only a data field a template later reads — was outside its
|
||||
// reach entirely.
|
||||
function tsFiles(): string[] {
|
||||
return filesWithExtension(".ts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every namespace-key VALUE currently spelled out in en.ts, long enough and
|
||||
* plain enough to check for. Shared by both the .vue and .ts scans below so
|
||||
* there is exactly one definition of "what counts as a hardcoded literal" —
|
||||
* not two lists that can drift out of step with each other.
|
||||
*/
|
||||
async function namespaceLiterals(): Promise<string[]> {
|
||||
const en = (await import("../../i18n/locales/docs/en")).default as Record<string, unknown>;
|
||||
const literals: string[] = [];
|
||||
const walk = (node: unknown) => {
|
||||
if (typeof node === "string") {
|
||||
// Skip short strings (false positives on words like "LOCAL") and any
|
||||
// string carrying a placeholder, which cannot appear verbatim anyway.
|
||||
if (node.length >= 4 && !node.includes("{")) literals.push(node);
|
||||
return;
|
||||
}
|
||||
if (node && typeof node === "object") Object.values(node).forEach(walk);
|
||||
};
|
||||
walk(en);
|
||||
return literals;
|
||||
}
|
||||
|
||||
function scriptOf(file: string): string {
|
||||
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
|
||||
return `${descriptor.script?.content ?? ""}\n${descriptor.scriptSetup?.content ?? ""}`;
|
||||
}
|
||||
|
||||
const EXPECTED = ["ColumnTable.vue", "DocsApp.vue", "DocsSearch.vue", "DocsSidebar.vue", "EnumPage.vue", "GroupEditor.vue", "GroupPicker.vue", "NoteEditor.vue", "RelationshipList.vue", "TablePage.vue", "WarningBanner.vue", "WikiIndex.vue"];
|
||||
/**
|
||||
* Splits a component's template into the two channels that can actually
|
||||
* reach the reader as text: static text between tags, and the JS
|
||||
* expressions inside `{{ }}` interpolations. Attribute values (`:class="…"`,
|
||||
* `:title="…"`) are deliberately excluded — see the guard below for why.
|
||||
*
|
||||
* The two channels get different treatment below: `staticText` is scanned
|
||||
* with plain substring matching, because it can't contain anything but
|
||||
* literal characters. `interpolations` holds JS, where a bare identifier
|
||||
* (`table.viewDefinition`, `snapshot.project.databaseType`) can innocently
|
||||
* contain a locale word as a substring — only a *quoted* occurrence
|
||||
* (`"literal"` or `'literal'`) inside an expression counts as hardcoded copy.
|
||||
*/
|
||||
function domTextOf(file: string): { staticText: string; interpolations: string[] } {
|
||||
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
|
||||
const template = descriptor.template?.content ?? "";
|
||||
// Comments are prose, not display text, and can otherwise trip a
|
||||
// substring match by coincidence (a comment mentioning "the note" would
|
||||
// false-positive on the `noteHeader` key).
|
||||
const withoutComments = template.replace(/<!--[\s\S]*?-->/g, "");
|
||||
|
||||
const interpolations: string[] = [];
|
||||
const withoutInterpolations = withoutComments.replace(/{{([\s\S]*?)}}/g, (_match, expr) => {
|
||||
interpolations.push(expr);
|
||||
return "";
|
||||
});
|
||||
|
||||
// Whatever sits between a tag's closing `>` and the next `<` is DOM text;
|
||||
// whatever sits before that `>`, inside the tag itself, is an attribute
|
||||
// and mostly doesn't render as visible text — so this intentionally
|
||||
// doesn't look there on purpose. "Mostly" because the split is a regex,
|
||||
// not a parser: an attribute value containing a bare `>` (a comparison
|
||||
// like `v-if="count > 0"`) ends the match early and leaks the tail of
|
||||
// that attribute into a segment. That leak runs in the safe direction —
|
||||
// it can only add text to scan, never drop real display text — so the
|
||||
// risk is a false positive below, never a missed literal.
|
||||
const segments: string[] = [];
|
||||
for (const match of withoutInterpolations.matchAll(/>([^<]*)</g)) {
|
||||
segments.push(match[1]);
|
||||
}
|
||||
return { staticText: segments.join(" "), interpolations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every string literal in a .ts file that could become a runtime VALUE —
|
||||
* and, from there, reach a template as display text, exactly how
|
||||
* docsIndex.ts's `label: "(no group)"` did. Excludes string literals in TYPE
|
||||
* position, e.g. `type NoteSource = "DATABASE" | "LOCAL" | "NONE"`: those
|
||||
* disappear at compile time and can never be displayed, so flagging them
|
||||
* would be the .ts equivalent of the `=== 'LOCAL'` false positive from the
|
||||
* last round. `ts.isLiteralTypeNode` is TypeScript's own distinction
|
||||
* between the two, not a hand-picked exception — the same check the
|
||||
* compiler itself uses to know it is looking at a type, not a value.
|
||||
*
|
||||
* Unlike domTextOf's split of display-vs-comparison text, this collects
|
||||
* every value-position literal with no such distinction — a literal used
|
||||
* only in a comparison (`type === "LOCAL"`) is indistinguishable here from
|
||||
* one that reaches a template as display text. Accepted as this scan's
|
||||
* blind spot rather than chased, for the same reason: the risk is a false
|
||||
* positive, not a missed literal.
|
||||
*/
|
||||
function valueStringLiteralsOf(file: string): string[] {
|
||||
const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
|
||||
const literals: string[] = [];
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isStringLiteral(node) && !ts.isLiteralTypeNode(node.parent)) {
|
||||
literals.push(node.text);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
return literals;
|
||||
}
|
||||
|
||||
const EXPECTED = ["ColumnTable.vue", "DocsApp.vue", "DocsSearch.vue", "DocsSidebar.vue", "EnumPage.vue", "GroupEditor.vue", "GroupPicker.vue", "NoteEditor.vue", "RelationshipList.vue", "SchemaDiagram.vue", "TablePage.vue", "WarningBanner.vue", "WikiIndex.vue"];
|
||||
|
||||
describe("docs viewer component contract", () => {
|
||||
it("finds every expected component", () => {
|
||||
@@ -154,4 +265,65 @@ describe("docs viewer component contract", () => {
|
||||
expect(block, `${selector} must define --group-tint without oklch`).toContain("--group-tint: hsl(");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders no English literal that already has a key", async () => {
|
||||
// Derived from the namespace, NOT an enumerated list of strings. A key
|
||||
// added tomorrow is covered tomorrow. This is the guard Part 3b lacked:
|
||||
// docsNamespaceParity compares locale files to EACH OTHER, so a key that
|
||||
// no component ever calls passes every existing test.
|
||||
const literals = await namespaceLiterals();
|
||||
expect(literals.length).toBeGreaterThan(10);
|
||||
|
||||
const files = vueFiles();
|
||||
expect(files.length).toBe(EXPECTED.length);
|
||||
for (const file of files) {
|
||||
// Deliberately scoped to what reaches the DOM as text — see domTextOf
|
||||
// — rather than the whole file. Two earlier, narrower shapes of this
|
||||
// same guard both ran green over a real defect: matching only
|
||||
// `>literal<` misses a literal buried in a JS fallback
|
||||
// (`section.label || "(no schema)"`, DocsSidebar.vue/WikiIndex.vue);
|
||||
// matching any quoted occurrence anywhere in the template misses
|
||||
// nothing display-related but flags plenty that isn't — a bare
|
||||
// identifier like `table.viewDefinition` or
|
||||
// `snapshot.project.databaseType` shares a substring with a locale
|
||||
// word purely by accident, and an attribute-only comparison like
|
||||
// `noteOf(column)?.source === 'LOCAL'` tests data, not copy.
|
||||
const { staticText, interpolations } = domTextOf(file);
|
||||
for (const literal of literals) {
|
||||
// Static text, e.g. `>Overview<` or `>⬤ LOCAL<` — anything literally
|
||||
// written between tags is definitionally display text, so a plain
|
||||
// substring match is safe here.
|
||||
expect(staticText.includes(literal), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
|
||||
// A JS expression inside a `{{ }}` mustache, e.g.
|
||||
// `section.label || "(no schema)"`. Only a *quoted* occurrence
|
||||
// counts — an unquoted one is a property/variable name, not copy.
|
||||
expect(
|
||||
interpolations.some((expr) => expr.includes(`"${literal}"`) || expr.includes(`'${literal}'`)),
|
||||
`${path.basename(file)} hardcodes "${literal}" in a template expression — call translate() instead`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
// Known gap, accepted rather than chased: a literal passed to an
|
||||
// attribute binding — `:title="'Hardcoded text'"` — can still reach the
|
||||
// reader (as a tooltip, an aria-label, …) and this guard does not see
|
||||
// it, because attribute values are excluded by design to avoid the
|
||||
// data-comparison false positives above. If a future defect turns out
|
||||
// to live in an attribute, that's this guard's known blind spot, not a
|
||||
// regression in it.
|
||||
|
||||
// The pure modules beside the components — docsIndex.ts's
|
||||
// `label: "(no group)"` was exactly this: a literal that never touches a
|
||||
// template directly, only a data field a template reads later. A .vue-only
|
||||
// scan is structurally blind to it no matter how the .vue scan itself is
|
||||
// shaped, so this is a second, independent walk over a different file
|
||||
// extension rather than a wider regex over the same one.
|
||||
const tsSourceFiles = tsFiles();
|
||||
expect(tsSourceFiles.length).toBeGreaterThan(0);
|
||||
for (const file of tsSourceFiles) {
|
||||
const valueLiterals = valueStringLiteralsOf(file);
|
||||
for (const literal of literals) {
|
||||
expect(valueLiterals.includes(literal), `${path.basename(file)} hardcodes "${literal}" — call translate() instead`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clipToCard } from "../diagramGeometry";
|
||||
|
||||
describe("clipToCard", () => {
|
||||
const half = { width: 100, height: 50 };
|
||||
|
||||
it("exits through the vertical edge for a horizontal run", () => {
|
||||
// Centre (0,0) to (500,0): the line leaves through the right edge at
|
||||
// x = +100, not through the top or bottom.
|
||||
expect(clipToCard({ x: 0, y: 0 }, { x: 500, y: 0 }, half)).toEqual({ x: 100, y: 0 });
|
||||
});
|
||||
|
||||
it("exits through the horizontal edge for a vertical run", () => {
|
||||
expect(clipToCard({ x: 0, y: 0 }, { x: 0, y: 500 }, half)).toEqual({ x: 0, y: 50 });
|
||||
});
|
||||
|
||||
it("picks the nearer edge on a diagonal", () => {
|
||||
// Slope 1 against a 2:1 card: the vertical edge is reached first, so the
|
||||
// result sits ON x = 100 with |y| < 50. Clipping to the wrong axis puts
|
||||
// the endpoint outside the card and the line visibly overshoots.
|
||||
const point = clipToCard({ x: 0, y: 0 }, { x: 500, y: 500 }, half);
|
||||
expect(point.x).toBeCloseTo(50);
|
||||
expect(point.y).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it("returns the centre when both points coincide", () => {
|
||||
// Two tables laid out at the same position would otherwise divide by zero
|
||||
// and emit NaN into the SVG path, which renders nothing at all.
|
||||
expect(clipToCard({ x: 7, y: 7 }, { x: 7, y: 7 }, half)).toEqual({ x: 7, y: 7 });
|
||||
});
|
||||
|
||||
it("handles negative directions symmetrically", () => {
|
||||
expect(clipToCard({ x: 0, y: 0 }, { x: -500, y: 0 }, half)).toEqual({ x: -100, y: 0 });
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -45,6 +45,14 @@ describe("groupBySchema", () => {
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].tables[0].name).toBe("orders");
|
||||
});
|
||||
|
||||
it("tags every section with the schema fallback key", () => {
|
||||
// The render sites fall back to `translate(section.fallbackKey)` when
|
||||
// `label` is empty — schema sections must carry docs.noSchema, not the
|
||||
// sibling docs.noGroup key groupByTableGroup uses.
|
||||
const sections = groupBySchema(snapshot([table("public", "orders")]));
|
||||
expect(sections[0].fallbackKey).toBe("docs.noSchema");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByTableGroup", () => {
|
||||
@@ -62,11 +70,18 @@ describe("groupByTableGroup", () => {
|
||||
expect(sections[0].note).toBe("Checkout.");
|
||||
});
|
||||
|
||||
it("collects ungrouped tables into a trailing (no group) section", () => {
|
||||
it("collects ungrouped tables into a trailing, unlabelled section", () => {
|
||||
const sections = groupByTableGroup(snapshot([table("core", "orders", "order-mgmt"), table("core", "users", null)], groups));
|
||||
|
||||
const last = sections[sections.length - 1];
|
||||
expect(last.key).toBe("");
|
||||
// Empty, not a hardcoded "(no group)": the render sites translate this via
|
||||
// `translate(section.fallbackKey)` when `label` is falsy. A non-empty
|
||||
// English literal here would bypass that fallback and render untranslated
|
||||
// in every non-English locale — this guarded a real defect, not a
|
||||
// hypothetical one.
|
||||
expect(last.label).toBe("");
|
||||
expect(last.fallbackKey).toBe("docs.noGroup");
|
||||
expect(last.hue).toBeNull();
|
||||
expect(last.tables.map((t) => t.name)).toEqual(["users"]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDocsHash, parseDocsHash } from "../docsRoute";
|
||||
import type { SchemaSnapshot } from "../types";
|
||||
|
||||
// `formatVersion` is top-level on SchemaSnapshot, not inside `project`.
|
||||
const snapshot = {
|
||||
formatVersion: 1,
|
||||
project: { name: "shop", databaseType: "postgres", database: "shop", schemas: ["public"], generatedAt: "2026-08-06T00:00:00Z", note: null },
|
||||
tables: [
|
||||
{ schema: "public", name: "orders", columns: [], indexes: [] },
|
||||
{ schema: null, name: "a/b", columns: [], indexes: [] },
|
||||
],
|
||||
enums: [{ schema: "public", name: "order_status", values: ["pending"] }],
|
||||
relationships: [],
|
||||
groups: [],
|
||||
warnings: [],
|
||||
} as unknown as SchemaSnapshot;
|
||||
|
||||
describe("parseDocsHash", () => {
|
||||
it("reads a table route", () => {
|
||||
expect(parseDocsHash("#/table/public.orders", snapshot, true)).toEqual({ kind: "table", key: "public.orders" });
|
||||
});
|
||||
|
||||
it("decodes an identifier containing a slash", () => {
|
||||
// A table named `a/b` must survive the round trip; an undecoded hash
|
||||
// would split into a bogus segment and silently fall back to the index.
|
||||
expect(parseDocsHash("#/table/a%2Fb", snapshot, true)).toEqual({ kind: "table", key: "a/b" });
|
||||
});
|
||||
|
||||
it("reads an enum route", () => {
|
||||
expect(parseDocsHash("#/enum/order_status", snapshot, true)).toEqual({ kind: "enum", name: "order_status" });
|
||||
});
|
||||
|
||||
it("falls back to the index for a table that is not in the snapshot", () => {
|
||||
// A deep link into a since-dropped table is the EXPECTED case for a file
|
||||
// someone saved months ago. It must never render a blank page.
|
||||
expect(parseDocsHash("#/table/public.gone", snapshot, true)).toEqual({ kind: "index" });
|
||||
});
|
||||
|
||||
it("falls back to the index for junk, empty and bare hashes", () => {
|
||||
for (const hash of ["", "#", "#/", "#/nonsense", "#/table", "#/table/", "not-a-hash"]) {
|
||||
expect(parseDocsHash(hash, snapshot, true), hash).toEqual({ kind: "index" });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses the diagram route when the host has not enabled it", () => {
|
||||
// The dialog passes diagram="external" and keeps its button to the full
|
||||
// SchemaDiagramDialog. A hash must not render a view that host declined.
|
||||
expect(parseDocsHash("#/diagram", snapshot, false)).toEqual({ kind: "index" });
|
||||
expect(parseDocsHash("#/diagram", snapshot, true)).toEqual({ kind: "diagram" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDocsHash", () => {
|
||||
it("round-trips every route kind", () => {
|
||||
for (const route of [{ kind: "index" }, { kind: "table", key: "public.orders" }, { kind: "enum", name: "order_status" }, { kind: "diagram" }] as const) {
|
||||
expect(parseDocsHash(formatDocsHash(route), snapshot, true)).toEqual(route);
|
||||
}
|
||||
});
|
||||
|
||||
it("encodes a slash in an identifier", () => {
|
||||
expect(formatDocsHash({ kind: "table", key: "a/b" })).toBe("#/table/a%2Fb");
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const stylesDir = path.resolve(__dirname, "../../styles");
|
||||
|
||||
/** Every `--name:` custom property declared in a stylesheet. */
|
||||
function declaredTokens(file: string): Set<string> {
|
||||
const source = readFileSync(path.join(stylesDir, file), "utf8");
|
||||
return new Set(Array.from(source.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm), (match) => match[1]));
|
||||
}
|
||||
|
||||
describe("design tokens", () => {
|
||||
it("defines the tokens the docs viewer resolves against", () => {
|
||||
// These are the utilities used in src/docs/**: bg-background,
|
||||
// text-foreground, text-muted-foreground, bg-muted, border-border,
|
||||
// focus:border-ring. If a token stops being declared here the export
|
||||
// renders unstyled while the app, which has globals.css, looks fine —
|
||||
// so this must be checked against tokens.css, not globals.css.
|
||||
const tokens = declaredTokens("tokens.css");
|
||||
for (const token of ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--ring"]) {
|
||||
expect(tokens.has(token), `tokens.css must declare ${token}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps globals.css importing the extracted tokens", () => {
|
||||
const globals = readFileSync(path.join(stylesDir, "globals.css"), "utf8");
|
||||
expect(globals.includes('@import "./tokens.css"')).toBe(true);
|
||||
});
|
||||
|
||||
it("declares the dark overrides after the light ones", () => {
|
||||
// Custom properties resolve at use time, but override order still
|
||||
// decides which block wins. A tokens.css with .dark BEFORE :root leaves
|
||||
// dark mode rendering light values.
|
||||
const source = readFileSync(path.join(stylesDir, "tokens.css"), "utf8");
|
||||
const root = source.search(/^:root\s*\{/m);
|
||||
const dark = source.search(/^\.dark\s*\{/m);
|
||||
expect(root, "tokens.css must declare :root").toBeGreaterThan(-1);
|
||||
expect(dark, "tokens.css must declare .dark").toBeGreaterThan(-1);
|
||||
expect(dark).toBeGreaterThan(root);
|
||||
});
|
||||
});
|
||||
@@ -68,10 +68,10 @@ function shadowedTitle(column: ColumnInfo): string | undefined {
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="bg-muted/30">
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Column</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Type</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Settings</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Note</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.columnHeader") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.typeHeader") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.settingsHeader") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.noteHeader") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -87,7 +87,7 @@ function shadowedTitle(column: ColumnInfo): string | undefined {
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-muted-foreground">
|
||||
<div class="flex items-start gap-1">
|
||||
<span v-if="noteOf(column)?.source === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium" :title="shadowedTitle(column)">⬤ LOCAL</span>
|
||||
<span v-if="noteOf(column)?.source === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium" :title="shadowedTitle(column)">⬤ {{ translate("docs.localNote") }}</span>
|
||||
<!-- Merged note, for the same reason as TablePage: NoteEditor
|
||||
renders and edits one value, and the local layer alone would
|
||||
hide notes that came from the database. -->
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { searchDocs, type SearchHit } from "../docsSearch";
|
||||
import type { Translate } from "../docsWarnings";
|
||||
import type { SchemaSnapshot } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
snapshot: SchemaSnapshot;
|
||||
translate: Translate;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -71,13 +73,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
<template>
|
||||
<div>
|
||||
<button type="button" class="flex items-center gap-2 rounded border border-border bg-background px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/40" @click="show()">
|
||||
<span>Search</span>
|
||||
<span>{{ translate("docs.searchLabel") }}</span>
|
||||
<span class="rounded bg-muted/50 px-1 py-0.5 text-[10px]">⌘K</span>
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24" @click.self="open = false">
|
||||
<div class="w-full max-w-lg overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
||||
<input ref="input" v-model="query" type="text" placeholder="Search tables, columns, groups, enums…" class="w-full border-b border-border bg-transparent px-3 py-2 text-sm text-foreground outline-none" />
|
||||
<input ref="input" v-model="query" type="text" :placeholder="translate('docs.search')" class="w-full border-b border-border bg-transparent px-3 py-2 text-sm text-foreground outline-none" />
|
||||
<ul class="max-h-80 overflow-y-auto">
|
||||
<li v-if="query.trim() !== '' && hits.length === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">Nothing matches “{{ query }}”.</li>
|
||||
<li v-for="(hit, position) in hits" :key="`${hit.kind}-${hit.context}-${hit.label}-${position}`">
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import type { IndexSection } from "../docsIndex";
|
||||
import { qualifiedTableKey } from "../docsKeys";
|
||||
import { groupStyle } from "../groupColor";
|
||||
import type { Translate } from "../docsWarnings";
|
||||
|
||||
defineProps<{
|
||||
sections: IndexSection[];
|
||||
mode: "schema" | "group";
|
||||
/** Qualified name of the table currently open, or null on the index. */
|
||||
activeKey: string | null;
|
||||
translate: Translate;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,13 +21,13 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<nav class="flex w-64 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border bg-background p-3">
|
||||
<button type="button" class="rounded px-2 py-1 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/40" @click="emit('home')">Overview</button>
|
||||
<button type="button" class="rounded px-2 py-1 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/40" @click="emit('home')">{{ translate("docs.overview") }}</button>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="px-2 text-[10px] uppercase tracking-wide text-muted-foreground">Group by</span>
|
||||
<span class="px-2 text-[10px] uppercase tracking-wide text-muted-foreground">{{ translate("docs.groupBy") }}</span>
|
||||
<div class="flex rounded border border-border p-0.5">
|
||||
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'schema' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'schema')">Schemas</button>
|
||||
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'group' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'group')">Table Groups</button>
|
||||
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'schema' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'schema')">{{ translate("docs.groupBySchema") }}</button>
|
||||
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'group' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'group')">{{ translate("docs.groupByTableGroup") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +35,7 @@ const emit = defineEmits<{
|
||||
<div class="flex items-center gap-1.5 px-2 py-1" :class="{ 'docs-group': section.hue !== null }" :style="groupStyle(section.hue)">
|
||||
<span v-if="section.hue !== null" class="h-2 w-2 shrink-0 rounded-full" style="background-color: var(--group-c)"></span>
|
||||
<span class="truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{{ section.label || "(no schema)" }}
|
||||
{{ section.label || translate(section.fallbackKey) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { qualifiedTableKey } from "../docsKeys";
|
||||
import type { Translate } from "../docsWarnings";
|
||||
import type { FieldRef, Relationship } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -8,6 +9,7 @@ const props = defineProps<{
|
||||
relationships: Relationship[];
|
||||
schema: string | null;
|
||||
table: string;
|
||||
translate: Translate;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -50,8 +52,8 @@ const incoming = computed(() => props.relationships.filter((relationship) => isC
|
||||
<template>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<section>
|
||||
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">References ({{ outgoing.length }})</h4>
|
||||
<p v-if="outgoing.length === 0" class="text-xs text-muted-foreground">This table references no other table.</p>
|
||||
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">{{ translate("docs.references") }} ({{ outgoing.length }})</h4>
|
||||
<p v-if="outgoing.length === 0" class="text-xs text-muted-foreground">{{ translate("docs.noOutgoingRelationships") }}</p>
|
||||
<ul v-else class="flex flex-col gap-1">
|
||||
<li v-for="relationship in outgoing" :key="relationship.id" class="text-xs">
|
||||
<button type="button" class="w-full rounded border border-border bg-background px-2 py-1.5 text-left transition-colors hover:bg-muted/40" @click="emit('select', keyOf(relationship.to))">
|
||||
@@ -67,8 +69,8 @@ const incoming = computed(() => props.relationships.filter((relationship) => isC
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">Referenced by ({{ incoming.length }})</h4>
|
||||
<p v-if="incoming.length === 0" class="text-xs text-muted-foreground">No table references this one.</p>
|
||||
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">{{ translate("docs.referencedBy") }} ({{ incoming.length }})</h4>
|
||||
<p v-if="incoming.length === 0" class="text-xs text-muted-foreground">{{ translate("docs.noIncomingRelationships") }}</p>
|
||||
<ul v-else class="flex flex-col gap-1">
|
||||
<li v-for="relationship in incoming" :key="relationship.id" class="text-xs">
|
||||
<button type="button" class="w-full rounded border border-border bg-background px-2 py-1.5 text-left transition-colors hover:bg-muted/40" @click="emit('select', keyOf(relationship.from))">
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { layoutDiagramTables } from "@/lib/diagram/erDiagram";
|
||||
import { clipToCard } from "../diagramGeometry";
|
||||
import type { Point } from "../diagramGeometry";
|
||||
import { qualifiedTableKey } from "../docsKeys";
|
||||
import { groupStyle } from "../groupColor";
|
||||
import type { SchemaSnapshot } from "../types";
|
||||
|
||||
const props = defineProps<{
|
||||
snapshot: SchemaSnapshot;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [tableKey: string];
|
||||
}>();
|
||||
|
||||
// Mirrors layoutDiagramTables' own defaults (lib/diagram/erDiagram.ts) so the
|
||||
// card geometry drawn here matches the slot the layout actually left for it.
|
||||
const CARD_WIDTH = 260;
|
||||
const CARD_HEIGHT = 220;
|
||||
const MARGIN = 40;
|
||||
const MAX_VISIBLE_COLUMNS = 8;
|
||||
const HALF = { width: CARD_WIDTH / 2, height: CARD_HEIGHT / 2 };
|
||||
|
||||
const positions = computed(() => layoutDiagramTables(props.snapshot.tables.map((table) => ({ name: qualifiedTableKey(table), columns: table.columns }))));
|
||||
|
||||
const groupsById = computed(() => new Map(props.snapshot.groups.map((group) => [group.id, group])));
|
||||
|
||||
interface DiagramCard {
|
||||
key: string;
|
||||
x: number;
|
||||
y: number;
|
||||
hue: number | null;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
const cards = computed<DiagramCard[]>(() =>
|
||||
props.snapshot.tables.map((table) => {
|
||||
const key = qualifiedTableKey(table);
|
||||
const position = positions.value[key] ?? { x: MARGIN, y: MARGIN };
|
||||
const group = table.groupId ? (groupsById.value.get(table.groupId) ?? null) : null;
|
||||
return {
|
||||
key,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
hue: group?.hue ?? null,
|
||||
columns: table.columns.slice(0, MAX_VISIBLE_COLUMNS).map((column) => column.name),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
function centreOf(key: string): Point {
|
||||
const position = positions.value[key] ?? { x: MARGIN, y: MARGIN };
|
||||
return { x: position.x + CARD_WIDTH / 2, y: position.y + CARD_HEIGHT / 2 };
|
||||
}
|
||||
|
||||
interface DiagramEdge {
|
||||
id: string;
|
||||
from: Point;
|
||||
to: Point;
|
||||
}
|
||||
|
||||
const edges = computed<DiagramEdge[]>(() => {
|
||||
const known = new Set(Object.keys(positions.value));
|
||||
return props.snapshot.relationships
|
||||
.map((relationship) => ({
|
||||
relationship,
|
||||
fromKey: qualifiedTableKey({ schema: relationship.from.schema, name: relationship.from.table }),
|
||||
toKey: qualifiedTableKey({ schema: relationship.to.schema, name: relationship.to.table }),
|
||||
}))
|
||||
.filter(({ fromKey, toKey }) => known.has(fromKey) && known.has(toKey))
|
||||
.map(({ relationship, fromKey, toKey }) => {
|
||||
const fromCentre = centreOf(fromKey);
|
||||
const toCentre = centreOf(toKey);
|
||||
return {
|
||||
id: relationship.id,
|
||||
from: clipToCard(fromCentre, toCentre, HALF),
|
||||
to: clipToCard(toCentre, fromCentre, HALF),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Empty-diagram guard: Math.max(0, ...[]) is 0 rather than -Infinity, so a
|
||||
// snapshot with no tables still produces a valid (if empty) canvas.
|
||||
const svgWidth = computed(() => Math.max(0, ...cards.value.map((card) => card.x + CARD_WIDTH)) + MARGIN);
|
||||
const svgHeight = computed(() => Math.max(0, ...cards.value.map((card) => card.y + CARD_HEIGHT)) + MARGIN);
|
||||
|
||||
/**
|
||||
* `background-color` has no effect on SVG shapes — they paint through `fill`
|
||||
* — so the group accent is the card's fill rather than a CSS background, tied
|
||||
* to the same `--group-tint`/`--h` tokens `groupStyle` drives everywhere else
|
||||
* in the viewer.
|
||||
*/
|
||||
function cardFillStyle(hue: number | null): Record<string, string> {
|
||||
return hue === null ? {} : { ...groupStyle(hue), fill: "var(--group-tint)" };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-auto">
|
||||
<svg :width="svgWidth" :height="svgHeight" :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="block">
|
||||
<line v-for="edge in edges" :key="edge.id" :x1="edge.from.x" :y1="edge.from.y" :x2="edge.to.x" :y2="edge.to.y" class="stroke-border" stroke-width="1.5" />
|
||||
|
||||
<g v-for="card in cards" :key="card.key">
|
||||
<rect :x="card.x" :y="card.y" :width="CARD_WIDTH" :height="CARD_HEIGHT" rx="6" class="cursor-pointer stroke-border" :class="card.hue === null ? 'fill-card' : 'docs-group'" :style="cardFillStyle(card.hue)" stroke-width="1" @click="emit('select', card.key)" />
|
||||
<text :x="card.x + 10" :y="card.y + 22" class="pointer-events-none fill-foreground font-mono text-[11px] font-semibold">{{ card.key }}</text>
|
||||
<text v-for="(column, index) in card.columns" :key="column" :x="card.x + 10" :y="card.y + 42 + index * 18" class="pointer-events-none fill-muted-foreground font-mono text-[10px]">
|
||||
{{ column }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,7 +56,7 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
|
||||
one shadows that comment, which is what noteSource and shadowedNote
|
||||
below exist to disclose. -->
|
||||
<div class="flex items-start gap-2">
|
||||
<span v-if="table.noteSource === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium text-muted-foreground" :title="shadowedTitle">⬤ LOCAL</span>
|
||||
<span v-if="table.noteSource === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium text-muted-foreground" :title="shadowedTitle">⬤ {{ translate("docs.localNote") }}</span>
|
||||
<NoteEditor class="min-w-0 flex-1" :model-value="table.note ?? ''" :readonly="readonly" :translate="translate" @update:model-value="emit('edit', { kind: 'tableNote', tableKey: qualified, note: $event })" />
|
||||
</div>
|
||||
|
||||
@@ -64,19 +64,19 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Columns</h3>
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.columns") }}</h3>
|
||||
<ColumnTable :columns="table.columns" :column-notes="table.columnNotes" :table-key="qualified" :readonly="readonly" :translate="translate" @edit="emit('edit', $event)" />
|
||||
</section>
|
||||
|
||||
<section v-if="table.indexes.length > 0">
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Indexes</h3>
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.indexes") }}</h3>
|
||||
<div class="overflow-hidden rounded-md border border-border">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="bg-muted/30">
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Name</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Columns</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Settings</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.nameHeader") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.columns") }}</th>
|
||||
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">{{ translate("docs.settingsHeader") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -100,12 +100,12 @@ const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comme
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h3>
|
||||
<RelationshipList :relationships="relationships" :schema="table.schema" :table="table.name" @select="emit('select', $event)" />
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.relationships") }}</h3>
|
||||
<RelationshipList :relationships="relationships" :schema="table.schema" :table="table.name" :translate="translate" @select="emit('select', $event)" />
|
||||
</section>
|
||||
|
||||
<section v-if="table.viewDefinition">
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Definition</h3>
|
||||
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.definitionHeader") }}</h3>
|
||||
<pre class="overflow-x-auto rounded-md border border-border bg-muted/20 p-2 font-mono text-xs">{{ table.viewDefinition }}</pre>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { IndexSection } from "../docsIndex";
|
||||
import { qualifiedTableKey } from "../docsKeys";
|
||||
import { groupStyle } from "../groupColor";
|
||||
import { renderNote } from "../renderNote";
|
||||
import type { Translate } from "../docsWarnings";
|
||||
|
||||
defineProps<{
|
||||
sections: IndexSection[];
|
||||
translate: Translate;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -18,7 +20,7 @@ const emit = defineEmits<{
|
||||
<section v-for="section in sections" :key="section.key" class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1 border-l-2 pl-3" :class="{ 'docs-group': section.hue !== null }" style="border-color: var(--group-c, var(--border))" :style="groupStyle(section.hue)">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h2 class="text-sm font-semibold text-foreground">{{ section.label || "(no schema)" }}</h2>
|
||||
<h2 class="text-sm font-semibold text-foreground">{{ section.label || translate(section.fallbackKey) }}</h2>
|
||||
<span class="text-xs text-muted-foreground">{{ section.tables.length }} tables</span>
|
||||
</div>
|
||||
<div v-if="section.note" class="text-xs text-muted-foreground" v-html="renderNote(section.note)"></div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a line from one card's centre towards another leaves the first card.
|
||||
*
|
||||
* Without this, edges terminate under the card and appear to sprout from a
|
||||
* table's middle. Scaling both axes and taking the smaller factor picks
|
||||
* whichever edge the ray actually reaches first.
|
||||
*
|
||||
* `half` is the card's HALF width and height, measured from its centre.
|
||||
*/
|
||||
export function clipToCard(from: Point, to: Point, half: Size): Point {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
if (dx === 0 && dy === 0) {
|
||||
// Coincident centres would divide by zero and put NaN in the path data,
|
||||
// which renders as nothing rather than as an error.
|
||||
return { x: from.x, y: from.y };
|
||||
}
|
||||
const scaleX = dx === 0 ? Number.POSITIVE_INFINITY : half.width / Math.abs(dx);
|
||||
const scaleY = dy === 0 ? Number.POSITIVE_INFINITY : half.height / Math.abs(dy);
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
return { x: from.x + dx * scale, y: from.y + dy * scale };
|
||||
}
|
||||
@@ -5,6 +5,21 @@ export interface IndexSection {
|
||||
/** Schema name, group id, or "" for the ungrouped bucket. */
|
||||
key: string;
|
||||
label: string;
|
||||
/**
|
||||
* Locale key to show in place of `label` when it's empty — "docs.noSchema"
|
||||
* from groupBySchema, "docs.noGroup" from groupByTableGroup. This module
|
||||
* stays translator-free (it is pure, tested without Vue, and every other
|
||||
* pure module in src/docs/ follows the same rule), so it hands back a KEY
|
||||
* rather than calling translate() itself; the render site decides how.
|
||||
*
|
||||
* Carrying the key on the section — rather than each render site inferring
|
||||
* it from a `mode` prop — means a fallback can never silently pick the
|
||||
* wrong word for the section it labels: WikiIndex.vue doesn't even receive
|
||||
* `mode`, so a mode-based guess there would either require threading a prop
|
||||
* that has no other use, or duplicate this same schema/group decision at a
|
||||
* second call site to keep in sync with this one.
|
||||
*/
|
||||
fallbackKey: "docs.noSchema" | "docs.noGroup";
|
||||
/** Group hue, or null for schema sections and the ungrouped bucket. */
|
||||
hue: number | null;
|
||||
note: string | null;
|
||||
@@ -32,6 +47,7 @@ export function groupBySchema(snapshot: SchemaSnapshot): IndexSection[] {
|
||||
.map(([key, tables]) => ({
|
||||
key,
|
||||
label: key,
|
||||
fallbackKey: "docs.noSchema" as const,
|
||||
hue: null,
|
||||
note: null,
|
||||
tables: [...tables].sort(byName),
|
||||
@@ -52,6 +68,7 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
|
||||
sections.push({
|
||||
key: group.id,
|
||||
label: group.name,
|
||||
fallbackKey: "docs.noGroup",
|
||||
hue: group.hue,
|
||||
note: group.note,
|
||||
tables,
|
||||
@@ -63,7 +80,10 @@ export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
|
||||
const ungrouped = snapshot.tables.filter((table) => table.groupId === null || !known.has(table.groupId)).sort(byName);
|
||||
|
||||
if (ungrouped.length > 0) {
|
||||
sections.push({ key: "", label: "(no group)", hue: null, note: null, tables: ungrouped });
|
||||
// Empty, not "(no group)": the render sites already fall back to a
|
||||
// translated label when `label` is empty (see IndexSection.fallbackKey).
|
||||
// A non-empty English literal here would bypass that fallback entirely.
|
||||
sections.push({ key: "", label: "", fallbackKey: "docs.noGroup", hue: null, note: null, tables: ungrouped });
|
||||
}
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { qualifiedTableKey } from "./docsKeys";
|
||||
import type { SchemaSnapshot } from "./types";
|
||||
|
||||
/**
|
||||
* Where the viewer is pointing.
|
||||
*
|
||||
* This exists so the standalone export can drive navigation from
|
||||
* `location.hash` without DocsApp itself touching the URL: DBX has no
|
||||
* router, and a viewer that wrote to the address bar would hijack the host
|
||||
* application's.
|
||||
*/
|
||||
export type DocsRoute = { kind: "index" } | { kind: "table"; key: string } | { kind: "enum"; name: string } | { kind: "diagram" };
|
||||
|
||||
const INDEX: DocsRoute = { kind: "index" };
|
||||
|
||||
/**
|
||||
* Resolve a hash against a snapshot.
|
||||
*
|
||||
* Anything unrecognised — junk, a table that no longer exists, the diagram
|
||||
* route on a host that did not enable it — resolves to the index. A saved
|
||||
* file whose schema has since changed is the expected case, not an exotic
|
||||
* one, and must never render blank.
|
||||
*/
|
||||
export function parseDocsHash(hash: string, snapshot: SchemaSnapshot, allowDiagram: boolean): DocsRoute {
|
||||
if (!hash.startsWith("#")) return INDEX;
|
||||
const segments = hash.slice(1).replace(/^\//, "").split("/");
|
||||
const [kind, ...rest] = segments;
|
||||
const identifier = rest.join("/");
|
||||
|
||||
if (kind === "diagram" && identifier === "") return allowDiagram ? { kind: "diagram" } : INDEX;
|
||||
|
||||
if (identifier === "") return INDEX;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(identifier);
|
||||
} catch {
|
||||
// A malformed percent-escape throws rather than returning null.
|
||||
return INDEX;
|
||||
}
|
||||
|
||||
if (kind === "table") {
|
||||
return snapshot.tables.some((table) => qualifiedTableKey(table) === decoded) ? { kind: "table", key: decoded } : INDEX;
|
||||
}
|
||||
if (kind === "enum") {
|
||||
return (snapshot.enums ?? []).some((value) => value.name === decoded) ? { kind: "enum", name: decoded } : INDEX;
|
||||
}
|
||||
return INDEX;
|
||||
}
|
||||
|
||||
export function formatDocsHash(route: DocsRoute): string {
|
||||
switch (route.kind) {
|
||||
case "table":
|
||||
return `#/table/${encodeURIComponent(route.key)}`;
|
||||
case "enum":
|
||||
return `#/enum/${encodeURIComponent(route.name)}`;
|
||||
case "diagram":
|
||||
return "#/diagram";
|
||||
default:
|
||||
return "#/";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user