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

@@ -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>