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

+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);
});
});