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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user