@sqldoc/ns-docs 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/__tests__/atlas.test.ts +134 -0
- package/src/__tests__/merge.test.ts +401 -0
- package/src/__tests__/mermaid.test.ts +107 -0
- package/src/__tests__/renderers/fixture.ts +109 -0
- package/src/__tests__/renderers/html.test.ts +122 -0
- package/src/__tests__/renderers/markdown.test.ts +121 -0
- package/src/atlas.ts +56 -0
- package/src/index.ts +68 -0
- package/src/merge.ts +226 -0
- package/src/mermaid.ts +67 -0
- package/src/renderers/html.ts +250 -0
- package/src/renderers/markdown.ts +160 -0
- package/src/types.ts +145 -0
package/src/merge.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { CompilerOutput, DocsMeta, SqlTarget } from '@sqldoc/core'
|
|
2
|
+
import type {
|
|
3
|
+
AtlasSchema,
|
|
4
|
+
AtlasTable,
|
|
5
|
+
DocsAnnotation,
|
|
6
|
+
DocsColumnEntry,
|
|
7
|
+
DocsRelationship,
|
|
8
|
+
MergedColumn,
|
|
9
|
+
MergedSchema,
|
|
10
|
+
MergedTable,
|
|
11
|
+
MergedTag,
|
|
12
|
+
MergedView,
|
|
13
|
+
} from './types.ts'
|
|
14
|
+
|
|
15
|
+
interface FileTagData {
|
|
16
|
+
sourceFile: string
|
|
17
|
+
objects: Array<{
|
|
18
|
+
objectName: string
|
|
19
|
+
target: SqlTarget
|
|
20
|
+
tags: Array<{
|
|
21
|
+
namespace: string
|
|
22
|
+
tag: string | null
|
|
23
|
+
args: Record<string, unknown> | unknown[]
|
|
24
|
+
}>
|
|
25
|
+
}>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Normalize SQL identifier for matching: lowercase, strip surrounding quotes */
|
|
29
|
+
function normalizeName(name: string): string {
|
|
30
|
+
const stripped = name.replace(/^["']|["']$/g, '')
|
|
31
|
+
return stripped.toLowerCase()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Check if an object has docs.emit(false) */
|
|
35
|
+
function isExcluded(tags: MergedTag[]): boolean {
|
|
36
|
+
return tags.some((t) => t.namespace === 'docs' && t.tag === 'emit' && (t.args as unknown[])[0] === false)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Extract docs.description value from tags */
|
|
40
|
+
function getDescription(tags: MergedTag[]): string | undefined {
|
|
41
|
+
const descTag = tags.find((t) => t.namespace === 'docs' && t.tag === 'description')
|
|
42
|
+
if (!descTag) return undefined
|
|
43
|
+
return (descTag.args as unknown[])[0] as string | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract docs.previously value from tags */
|
|
47
|
+
function getPreviously(tags: MergedTag[]): string | undefined {
|
|
48
|
+
const prevTag = tags.find((t) => t.namespace === 'docs' && t.tag === 'previously')
|
|
49
|
+
if (!prevTag) return undefined
|
|
50
|
+
return (prevTag.args as unknown[])[0] as string | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build a set of generated table names from compiler outputs */
|
|
54
|
+
function buildGeneratedSet(outputs: CompilerOutput[]): Map<string, string> {
|
|
55
|
+
const generated = new Map<string, string>()
|
|
56
|
+
for (const output of outputs) {
|
|
57
|
+
for (const sqlOut of output.sqlOutputs) {
|
|
58
|
+
// Match CREATE TABLE "name" patterns in generated SQL
|
|
59
|
+
const match = sqlOut.sql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?(\w+)"?/i)
|
|
60
|
+
if (match) {
|
|
61
|
+
const tableName = normalizeName(match[1])
|
|
62
|
+
const ns = sqlOut.sourceTag?.match(/@(\w+)/)?.[1] ?? 'unknown'
|
|
63
|
+
generated.set(tableName, ns)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return generated
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build tag lookup: normalized objectName -> entries with target and tags */
|
|
71
|
+
function buildTagMap(allFileTags: FileTagData[]): Map<string, { target: SqlTarget; tags: MergedTag[] }[]> {
|
|
72
|
+
const map = new Map<string, { target: SqlTarget; tags: MergedTag[] }[]>()
|
|
73
|
+
for (const file of allFileTags) {
|
|
74
|
+
for (const obj of file.objects) {
|
|
75
|
+
const key = normalizeName(obj.objectName)
|
|
76
|
+
const existing = map.get(key) ?? []
|
|
77
|
+
existing.push({
|
|
78
|
+
target: obj.target,
|
|
79
|
+
tags: obj.tags.map((t) => ({
|
|
80
|
+
namespace: t.namespace,
|
|
81
|
+
tag: t.tag,
|
|
82
|
+
args: t.args,
|
|
83
|
+
})),
|
|
84
|
+
})
|
|
85
|
+
map.set(key, existing)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return map
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Get all tags for an object by normalized name (flatten across files, table/view level only) */
|
|
92
|
+
function getObjectTags(tagMap: Map<string, { target: SqlTarget; tags: MergedTag[] }[]>, name: string): MergedTag[] {
|
|
93
|
+
const entries = tagMap.get(normalizeName(name)) ?? []
|
|
94
|
+
return entries.filter((e) => e.target !== 'column').flatMap((e) => e.tags)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Get column-level tags — checks both "column" and "table.column" keys */
|
|
98
|
+
function getColumnTags(
|
|
99
|
+
tagMap: Map<string, { target: SqlTarget; tags: MergedTag[] }[]>,
|
|
100
|
+
columnName: string,
|
|
101
|
+
tableName?: string,
|
|
102
|
+
): MergedTag[] {
|
|
103
|
+
// Try table.column first (Atlas convention), then bare column name
|
|
104
|
+
const keys = tableName
|
|
105
|
+
? [normalizeName(`${tableName}.${columnName}`), normalizeName(columnName)]
|
|
106
|
+
: [normalizeName(columnName)]
|
|
107
|
+
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
const entries = tagMap.get(key) ?? []
|
|
110
|
+
const tags = entries.filter((e) => e.target === 'column').flatMap((e) => e.tags)
|
|
111
|
+
if (tags.length > 0) return tags
|
|
112
|
+
}
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mergeTable(
|
|
117
|
+
table: AtlasTable,
|
|
118
|
+
tagMap: Map<string, { target: SqlTarget; tags: MergedTag[] }[]>,
|
|
119
|
+
generatedSet: Map<string, string>,
|
|
120
|
+
): MergedTable | null {
|
|
121
|
+
const tableTags = getObjectTags(tagMap, table.name)
|
|
122
|
+
if (isExcluded(tableTags)) return null
|
|
123
|
+
|
|
124
|
+
const pkColumns = new Set((table.primary_key?.parts ?? []).map((p) => normalizeName(p.column)))
|
|
125
|
+
const fkColumns = new Set((table.foreign_keys ?? []).flatMap((fk) => fk.columns.map((c) => normalizeName(c))))
|
|
126
|
+
|
|
127
|
+
const columns: MergedColumn[] = table.columns.map((col) => {
|
|
128
|
+
const colTags = getColumnTags(tagMap, col.name, table.name)
|
|
129
|
+
return {
|
|
130
|
+
name: col.name,
|
|
131
|
+
type: col.type,
|
|
132
|
+
nullable: col.null === true,
|
|
133
|
+
description: getDescription(colTags),
|
|
134
|
+
previously: getPreviously(colTags),
|
|
135
|
+
isPrimaryKey: pkColumns.has(normalizeName(col.name)),
|
|
136
|
+
isForeignKey: fkColumns.has(normalizeName(col.name)),
|
|
137
|
+
tags: colTags,
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const normalizedName = normalizeName(table.name)
|
|
142
|
+
const generatedBy = generatedSet.get(normalizedName)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: table.name,
|
|
146
|
+
description: getDescription(tableTags),
|
|
147
|
+
previously: getPreviously(tableTags),
|
|
148
|
+
isGenerated: !!generatedBy,
|
|
149
|
+
generatedBy,
|
|
150
|
+
columns,
|
|
151
|
+
indexes: table.indexes ?? [],
|
|
152
|
+
primaryKey: table.primary_key,
|
|
153
|
+
foreignKeys: table.foreign_keys ?? [],
|
|
154
|
+
tags: tableTags,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function mergeSchemaWithTags(
|
|
159
|
+
schema: AtlasSchema,
|
|
160
|
+
mermaid: string,
|
|
161
|
+
allFileTags: FileTagData[],
|
|
162
|
+
outputs: CompilerOutput[],
|
|
163
|
+
title: string,
|
|
164
|
+
docsMeta: DocsMeta[] = [],
|
|
165
|
+
): MergedSchema {
|
|
166
|
+
const tagMap = buildTagMap(allFileTags)
|
|
167
|
+
const generatedSet = buildGeneratedSet(outputs)
|
|
168
|
+
|
|
169
|
+
const tables: MergedTable[] = []
|
|
170
|
+
const views: MergedView[] = []
|
|
171
|
+
|
|
172
|
+
for (const s of schema.schemas) {
|
|
173
|
+
for (const table of s.tables ?? []) {
|
|
174
|
+
const merged = mergeTable(table, tagMap, generatedSet)
|
|
175
|
+
if (merged) tables.push(merged)
|
|
176
|
+
}
|
|
177
|
+
for (const view of s.views ?? []) {
|
|
178
|
+
const viewTags = getObjectTags(tagMap, view.name)
|
|
179
|
+
if (isExcluded(viewTags)) continue
|
|
180
|
+
views.push({
|
|
181
|
+
name: view.name,
|
|
182
|
+
description: getDescription(viewTags),
|
|
183
|
+
columns: view.columns.map((col) => ({
|
|
184
|
+
name: col.name,
|
|
185
|
+
type: col.type,
|
|
186
|
+
nullable: col.null === true,
|
|
187
|
+
isPrimaryKey: false,
|
|
188
|
+
isForeignKey: false,
|
|
189
|
+
tags: [],
|
|
190
|
+
})),
|
|
191
|
+
tags: viewTags,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Aggregate docs metadata from plugins
|
|
197
|
+
const extraRelationships: DocsRelationship[] = docsMeta.flatMap((d) => d.relationships ?? [])
|
|
198
|
+
const annotations: DocsAnnotation[] = docsMeta.flatMap((d) => d.annotations ?? [])
|
|
199
|
+
|
|
200
|
+
// Collect extra column headers and data
|
|
201
|
+
const allColumnEntries: DocsColumnEntry[] = docsMeta.flatMap((d) => d.columns ?? [])
|
|
202
|
+
const headerSet = new Set<string>()
|
|
203
|
+
const extraColumnData = new Map<string, string>()
|
|
204
|
+
for (const entry of allColumnEntries) {
|
|
205
|
+
headerSet.add(entry.header)
|
|
206
|
+
const key = entry.column
|
|
207
|
+
? `${entry.object.toLowerCase()}:${entry.column.toLowerCase()}:${entry.header}`
|
|
208
|
+
: `${entry.object.toLowerCase()}::${entry.header}`
|
|
209
|
+
// Append if multiple values for same cell
|
|
210
|
+
const existing = extraColumnData.get(key)
|
|
211
|
+
extraColumnData.set(key, existing ? `${existing}, ${entry.value}` : entry.value)
|
|
212
|
+
}
|
|
213
|
+
const extraColumnHeaders = [...headerSet]
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
title,
|
|
217
|
+
generatedAt: new Date().toISOString(),
|
|
218
|
+
tables,
|
|
219
|
+
views,
|
|
220
|
+
mermaidERD: mermaid,
|
|
221
|
+
extraRelationships,
|
|
222
|
+
annotations,
|
|
223
|
+
extraColumnHeaders,
|
|
224
|
+
extraColumnData,
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/mermaid.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { AtlasRealm } from '@sqldoc/atlas'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a Mermaid erDiagram string from an Atlas schema realm.
|
|
5
|
+
* Replaces the `{{ mermaid . }}` Atlas Go template.
|
|
6
|
+
*/
|
|
7
|
+
export function generateMermaidERD(realm: AtlasRealm): string {
|
|
8
|
+
const lines: string[] = ['erDiagram']
|
|
9
|
+
|
|
10
|
+
for (const schema of realm.schemas) {
|
|
11
|
+
// Tables
|
|
12
|
+
for (const table of schema.tables ?? []) {
|
|
13
|
+
lines.push(` ${escapeMermaid(table.name)} {`)
|
|
14
|
+
|
|
15
|
+
// Determine PK and FK columns
|
|
16
|
+
const pkCols = new Set<string>()
|
|
17
|
+
if (table.primary_key?.parts) {
|
|
18
|
+
for (const part of table.primary_key.parts) {
|
|
19
|
+
if (part.column) pkCols.add(part.column)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const fkCols = new Set<string>()
|
|
23
|
+
for (const fk of table.foreign_keys ?? []) {
|
|
24
|
+
for (const col of fk.columns ?? []) {
|
|
25
|
+
fkCols.add(col)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const col of table.columns ?? []) {
|
|
30
|
+
const typeName = col.type?.raw ?? col.type?.T ?? 'unknown'
|
|
31
|
+
const constraints: string[] = []
|
|
32
|
+
if (pkCols.has(col.name)) constraints.push('PK')
|
|
33
|
+
if (fkCols.has(col.name)) constraints.push('FK')
|
|
34
|
+
const constraintStr = constraints.length > 0 ? ` ${constraints.join(',')}` : ''
|
|
35
|
+
lines.push(` ${escapeMermaid(typeName)} ${escapeMermaid(col.name)}${constraintStr}`)
|
|
36
|
+
}
|
|
37
|
+
lines.push(' }')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Views (entity name with columns)
|
|
41
|
+
for (const view of schema.views ?? []) {
|
|
42
|
+
lines.push(` ${escapeMermaid(view.name)} {`)
|
|
43
|
+
for (const col of view.columns ?? []) {
|
|
44
|
+
const typeName = col.type?.raw ?? col.type?.T ?? 'unknown'
|
|
45
|
+
lines.push(` ${escapeMermaid(typeName)} ${escapeMermaid(col.name)}`)
|
|
46
|
+
}
|
|
47
|
+
lines.push(' }')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Foreign key relationships
|
|
51
|
+
for (const table of schema.tables ?? []) {
|
|
52
|
+
for (const fk of table.foreign_keys ?? []) {
|
|
53
|
+
if (fk.ref_table) {
|
|
54
|
+
// Determine cardinality (default: many-to-one)
|
|
55
|
+
lines.push(` ${escapeMermaid(table.name)} }o--|| ${escapeMermaid(fk.ref_table)} : "${fk.symbol ?? 'fk'}"`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return lines.join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeMermaid(name: string): string {
|
|
65
|
+
// Mermaid entity names can't have special chars; replace quotes, spaces
|
|
66
|
+
return name.replace(/"/g, '').replace(/\s+/g, '_')
|
|
67
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { DocsAnnotation, MergedSchema, MergedTable, MergedView } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
function escapeHtml(str: string): string {
|
|
4
|
+
return str
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getTableAnnotations(annotations: DocsAnnotation[], tableName: string): string[] {
|
|
13
|
+
return annotations.filter((a) => a.object.toLowerCase() === tableName.toLowerCase()).map((a) => a.text)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getExtraCell(data: Map<string, string>, tableName: string, colName: string, header: string): string {
|
|
17
|
+
const key = `${tableName.toLowerCase()}:${colName.toLowerCase()}:${header}`
|
|
18
|
+
return data.get(key) ?? '-'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderAnnotationBadges(annotations: string[]): string {
|
|
22
|
+
if (annotations.length === 0) return ''
|
|
23
|
+
const badges = annotations.map((a) => `<span class="annotation">${escapeHtml(a)}</span>`).join('\n ')
|
|
24
|
+
return ` <div class="annotations">\n ${badges}\n </div>`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderTableSection(table: MergedTable, schema: MergedSchema): string {
|
|
28
|
+
const parts: string[] = []
|
|
29
|
+
const extras = schema.extraColumnHeaders
|
|
30
|
+
|
|
31
|
+
parts.push(` <section id="${escapeHtml(table.name)}">`)
|
|
32
|
+
|
|
33
|
+
let heading = ` <h3>${escapeHtml(table.name)}`
|
|
34
|
+
if (table.isGenerated && table.generatedBy) {
|
|
35
|
+
heading += ` <span class="generated-badge">Generated by @${escapeHtml(table.generatedBy)}</span>`
|
|
36
|
+
}
|
|
37
|
+
heading += '</h3>'
|
|
38
|
+
parts.push(heading)
|
|
39
|
+
|
|
40
|
+
if (table.previously) {
|
|
41
|
+
parts.push(` <p class="previously">formerly: ${escapeHtml(table.previously)}</p>`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (table.description) {
|
|
45
|
+
parts.push(` <p class="description">${escapeHtml(table.description)}</p>`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tableAnnots = getTableAnnotations(schema.annotations, table.name)
|
|
49
|
+
const annotBadges = renderAnnotationBadges(tableAnnots)
|
|
50
|
+
if (annotBadges) parts.push(annotBadges)
|
|
51
|
+
|
|
52
|
+
// Column table with extra plugin columns
|
|
53
|
+
const extraHeaders = extras.map((h) => ` <th>${escapeHtml(h)}</th>`).join('\n')
|
|
54
|
+
const rows = table.columns
|
|
55
|
+
.map((col) => {
|
|
56
|
+
const pk = col.isPrimaryKey ? 'Y' : '-'
|
|
57
|
+
const fk = col.isForeignKey ? 'Y' : '-'
|
|
58
|
+
const nullable = col.nullable ? 'Yes' : 'No'
|
|
59
|
+
let desc = col.description ? escapeHtml(col.description) : '-'
|
|
60
|
+
if (col.previously) {
|
|
61
|
+
const formerly = `<em class="previously">formerly: ${escapeHtml(col.previously)}</em>`
|
|
62
|
+
desc = desc === '-' ? formerly : `${desc} ${formerly}`
|
|
63
|
+
}
|
|
64
|
+
const extraCells = extras
|
|
65
|
+
.map((h) => {
|
|
66
|
+
const val = getExtraCell(schema.extraColumnData, table.name, col.name, h)
|
|
67
|
+
return ` <td>${val === '-' ? '-' : escapeHtml(val)}</td>`
|
|
68
|
+
})
|
|
69
|
+
.join('\n')
|
|
70
|
+
return ` <tr>
|
|
71
|
+
<td>${escapeHtml(col.name)}</td>
|
|
72
|
+
<td>${escapeHtml(col.type)}</td>
|
|
73
|
+
<td>${nullable}</td>
|
|
74
|
+
<td>${pk}</td>
|
|
75
|
+
<td>${fk}</td>
|
|
76
|
+
<td>${desc}</td>
|
|
77
|
+
${extraCells}
|
|
78
|
+
</tr>`
|
|
79
|
+
})
|
|
80
|
+
.join('\n')
|
|
81
|
+
|
|
82
|
+
parts.push(` <table>
|
|
83
|
+
<thead>
|
|
84
|
+
<tr>
|
|
85
|
+
<th>Column</th>
|
|
86
|
+
<th>Type</th>
|
|
87
|
+
<th>Nullable</th>
|
|
88
|
+
<th>PK</th>
|
|
89
|
+
<th>FK</th>
|
|
90
|
+
<th>Description</th>
|
|
91
|
+
${extraHeaders}
|
|
92
|
+
</tr>
|
|
93
|
+
</thead>
|
|
94
|
+
<tbody>
|
|
95
|
+
${rows}
|
|
96
|
+
</tbody>
|
|
97
|
+
</table>`)
|
|
98
|
+
|
|
99
|
+
if (table.foreignKeys.length > 0) {
|
|
100
|
+
parts.push(' <h4>Foreign Keys</h4>')
|
|
101
|
+
parts.push(' <ul>')
|
|
102
|
+
for (const fk of table.foreignKeys) {
|
|
103
|
+
const cols = fk.columns.join(', ')
|
|
104
|
+
const refCols = fk.references.columns.join(', ')
|
|
105
|
+
parts.push(
|
|
106
|
+
` <li>${escapeHtml(fk.name)}: ${escapeHtml(cols)} -> ${escapeHtml(fk.references.table)}(${escapeHtml(refCols)})</li>`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
parts.push(' </ul>')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (table.indexes.length > 0) {
|
|
113
|
+
parts.push(' <h4>Indexes</h4>')
|
|
114
|
+
parts.push(' <ul>')
|
|
115
|
+
for (const idx of table.indexes) {
|
|
116
|
+
const idxParts = idx.parts.map((p) => p.column).join(', ')
|
|
117
|
+
const unique = idx.unique ? ' UNIQUE' : ''
|
|
118
|
+
parts.push(` <li>${escapeHtml(idx.name)} (${escapeHtml(idxParts)})${unique}</li>`)
|
|
119
|
+
}
|
|
120
|
+
parts.push(' </ul>')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
parts.push(' </section>')
|
|
124
|
+
return parts.join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderViewSection(view: MergedView): string {
|
|
128
|
+
const parts: string[] = []
|
|
129
|
+
|
|
130
|
+
parts.push(` <section id="${escapeHtml(view.name)}">`)
|
|
131
|
+
parts.push(` <h3>${escapeHtml(view.name)}</h3>`)
|
|
132
|
+
|
|
133
|
+
if (view.description) {
|
|
134
|
+
parts.push(` <p class="description">${escapeHtml(view.description)}</p>`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rows = view.columns
|
|
138
|
+
.map((col) => {
|
|
139
|
+
const desc = col.description ? escapeHtml(col.description) : '-'
|
|
140
|
+
const nullable = col.nullable ? 'Yes' : 'No'
|
|
141
|
+
return ` <tr>
|
|
142
|
+
<td>${escapeHtml(col.name)}</td>
|
|
143
|
+
<td>${escapeHtml(col.type)}</td>
|
|
144
|
+
<td>${nullable}</td>
|
|
145
|
+
<td>-</td>
|
|
146
|
+
<td>-</td>
|
|
147
|
+
<td>${desc}</td>
|
|
148
|
+
</tr>`
|
|
149
|
+
})
|
|
150
|
+
.join('\n')
|
|
151
|
+
|
|
152
|
+
parts.push(` <table>
|
|
153
|
+
<thead>
|
|
154
|
+
<tr>
|
|
155
|
+
<th>Column</th>
|
|
156
|
+
<th>Type</th>
|
|
157
|
+
<th>Nullable</th>
|
|
158
|
+
<th>PK</th>
|
|
159
|
+
<th>FK</th>
|
|
160
|
+
<th>Description</th>
|
|
161
|
+
</tr>
|
|
162
|
+
</thead>
|
|
163
|
+
<tbody>
|
|
164
|
+
${rows}
|
|
165
|
+
</tbody>
|
|
166
|
+
</table>`)
|
|
167
|
+
|
|
168
|
+
parts.push(' </section>')
|
|
169
|
+
return parts.join('\n')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function renderHtml(schema: MergedSchema): string {
|
|
173
|
+
const tableLinks = schema.tables
|
|
174
|
+
.map((t) => ` <a href="#${escapeHtml(t.name)}">${escapeHtml(t.name)}</a>`)
|
|
175
|
+
.join('\n')
|
|
176
|
+
|
|
177
|
+
const viewLinks = schema.views.map((v) => ` <a href="#${escapeHtml(v.name)}">${escapeHtml(v.name)}</a>`).join('\n')
|
|
178
|
+
|
|
179
|
+
const viewsSidebar = schema.views.length > 0 ? `\n <h2>Views</h2>\n${viewLinks}` : ''
|
|
180
|
+
|
|
181
|
+
const tableSections = schema.tables.map((t) => renderTableSection(t, schema)).join('\n')
|
|
182
|
+
const viewSections = schema.views.map(renderViewSection).join('\n')
|
|
183
|
+
|
|
184
|
+
return `<!DOCTYPE html>
|
|
185
|
+
<html lang="en">
|
|
186
|
+
<head>
|
|
187
|
+
<meta charset="UTF-8">
|
|
188
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
189
|
+
<title>${escapeHtml(schema.title)}</title>
|
|
190
|
+
<style>
|
|
191
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
192
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; }
|
|
193
|
+
.sidebar { width: 260px; height: 100vh; position: fixed; overflow-y: auto; background: #f8f9fa; border-right: 1px solid #dee2e6; padding: 16px; }
|
|
194
|
+
.sidebar h1 { font-size: 18px; margin-bottom: 16px; }
|
|
195
|
+
.sidebar h2 { font-size: 14px; text-transform: uppercase; color: #6c757d; margin: 16px 0 8px; }
|
|
196
|
+
.sidebar a { display: block; padding: 4px 8px; color: #212529; text-decoration: none; font-size: 14px; border-radius: 4px; }
|
|
197
|
+
.sidebar a:hover { background: #e9ecef; }
|
|
198
|
+
.sidebar a.active { background: #0d6efd; color: white; }
|
|
199
|
+
.content { margin-left: 260px; padding: 32px; max-width: 1100px; }
|
|
200
|
+
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
|
|
201
|
+
th, td { border: 1px solid #dee2e6; padding: 8px 12px; text-align: left; }
|
|
202
|
+
th { background: #f8f9fa; }
|
|
203
|
+
.annotation { display: inline-block; background: #d1ecf1; color: #0c5460; border-radius: 4px; padding: 2px 8px; font-size: 12px; margin: 2px; }
|
|
204
|
+
.annotations { margin: 8px 0; }
|
|
205
|
+
.generated-badge { display: inline-block; background: #fff3cd; color: #856404; border-radius: 4px; padding: 2px 6px; font-size: 12px; }
|
|
206
|
+
.description { color: #495057; margin: 8px 0; }
|
|
207
|
+
.previously { color: #6c757d; font-style: italic; font-size: 13px; margin: 4px 0; display: block; }
|
|
208
|
+
h3 { margin-top: 32px; padding-top: 16px; border-top: 1px solid #dee2e6; }
|
|
209
|
+
.mermaid { margin: 24px 0; padding: 16px; background: #f8f9fa; border-radius: 8px; }
|
|
210
|
+
.timestamp { color: #6c757d; font-size: 14px; }
|
|
211
|
+
h4 { margin: 16px 0 8px; font-size: 15px; }
|
|
212
|
+
ul { margin: 0 0 16px 24px; }
|
|
213
|
+
li { margin: 4px 0; }
|
|
214
|
+
</style>
|
|
215
|
+
</head>
|
|
216
|
+
<body>
|
|
217
|
+
<nav class="sidebar">
|
|
218
|
+
<h1>${escapeHtml(schema.title)}</h1>
|
|
219
|
+
<h2>Tables</h2>
|
|
220
|
+
${tableLinks}${viewsSidebar}
|
|
221
|
+
</nav>
|
|
222
|
+
<main class="content">
|
|
223
|
+
<p class="timestamp">Generated: ${escapeHtml(schema.generatedAt)}</p>
|
|
224
|
+
<section id="er-diagram">
|
|
225
|
+
<h2>Entity Relationship Diagram</h2>
|
|
226
|
+
<pre class="mermaid">${escapeHtml(schema.mermaidERD)}${schema.extraRelationships
|
|
227
|
+
.map((rel) => `\n ${rel.from} ||..o{ ${rel.to} : "${rel.label}"`)
|
|
228
|
+
.join('')}</pre>
|
|
229
|
+
</section>
|
|
230
|
+
${tableSections}
|
|
231
|
+
${viewSections}
|
|
232
|
+
</main>
|
|
233
|
+
<script type="module">
|
|
234
|
+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
|
235
|
+
mermaid.initialize({ startOnLoad: true });
|
|
236
|
+
|
|
237
|
+
const observer = new IntersectionObserver((entries) => {
|
|
238
|
+
entries.forEach(entry => {
|
|
239
|
+
if (entry.isIntersecting) {
|
|
240
|
+
document.querySelectorAll('.sidebar a').forEach(a => a.classList.remove('active'));
|
|
241
|
+
const link = document.querySelector('.sidebar a[href="#' + entry.target.id + '"]');
|
|
242
|
+
if (link) link.classList.add('active');
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}, { threshold: 0.3 });
|
|
246
|
+
document.querySelectorAll('section[id]').forEach(s => observer.observe(s));
|
|
247
|
+
</script>
|
|
248
|
+
</body>
|
|
249
|
+
</html>`
|
|
250
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { DocsAnnotation, MergedSchema, MergedTable, MergedView } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
function getTableAnnotations(annotations: DocsAnnotation[], tableName: string): string[] {
|
|
4
|
+
return annotations.filter((a) => a.object.toLowerCase() === tableName.toLowerCase()).map((a) => a.text)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function getExtraCell(data: Map<string, string>, tableName: string, colName: string, header: string): string {
|
|
8
|
+
const key = `${tableName.toLowerCase()}:${colName.toLowerCase()}:${header}`
|
|
9
|
+
return data.get(key) ?? '-'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function renderTableSection(table: MergedTable, schema: MergedSchema): string {
|
|
13
|
+
const lines: string[] = []
|
|
14
|
+
const extras = schema.extraColumnHeaders
|
|
15
|
+
|
|
16
|
+
let heading = `### ${table.name}`
|
|
17
|
+
if (table.isGenerated && table.generatedBy) {
|
|
18
|
+
heading += ` (Generated by @${table.generatedBy})`
|
|
19
|
+
}
|
|
20
|
+
lines.push(heading)
|
|
21
|
+
lines.push('')
|
|
22
|
+
|
|
23
|
+
if (table.previously) {
|
|
24
|
+
lines.push(`*formerly: ${table.previously}*`)
|
|
25
|
+
lines.push('')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (table.description) {
|
|
29
|
+
lines.push(table.description)
|
|
30
|
+
lines.push('')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tableAnnots = getTableAnnotations(schema.annotations, table.name)
|
|
34
|
+
if (tableAnnots.length > 0) {
|
|
35
|
+
lines.push(tableAnnots.map((a) => `> ${a}`).join('\n'))
|
|
36
|
+
lines.push('')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Column table header
|
|
40
|
+
const baseHeaders = ['Column', 'Type', 'Nullable', 'PK', 'FK', 'Description']
|
|
41
|
+
const allHeaders = [...baseHeaders, ...extras]
|
|
42
|
+
lines.push(`| ${allHeaders.join(' | ')} |`)
|
|
43
|
+
lines.push(`|${allHeaders.map(() => '------').join('|')}|`)
|
|
44
|
+
|
|
45
|
+
for (const col of table.columns) {
|
|
46
|
+
const pk = col.isPrimaryKey ? 'Y' : '-'
|
|
47
|
+
const fk = col.isForeignKey ? 'Y' : '-'
|
|
48
|
+
const nullable = col.nullable ? 'Yes' : 'No'
|
|
49
|
+
let desc = col.description ?? '-'
|
|
50
|
+
if (col.previously) {
|
|
51
|
+
const formerly = `*formerly: ${col.previously}*`
|
|
52
|
+
desc = desc === '-' ? formerly : `${desc} ${formerly}`
|
|
53
|
+
}
|
|
54
|
+
const baseCells = [col.name, col.type, nullable, pk, fk, desc]
|
|
55
|
+
const extraCells = extras.map((h) => getExtraCell(schema.extraColumnData, table.name, col.name, h))
|
|
56
|
+
lines.push(`| ${[...baseCells, ...extraCells].join(' | ')} |`)
|
|
57
|
+
}
|
|
58
|
+
lines.push('')
|
|
59
|
+
|
|
60
|
+
if (table.foreignKeys.length > 0) {
|
|
61
|
+
lines.push('#### Foreign Keys')
|
|
62
|
+
lines.push('')
|
|
63
|
+
for (const fk of table.foreignKeys) {
|
|
64
|
+
const cols = fk.columns.join(', ')
|
|
65
|
+
const refCols = fk.references.columns.join(', ')
|
|
66
|
+
lines.push(`- ${fk.name}: ${cols} -> ${fk.references.table}(${refCols})`)
|
|
67
|
+
}
|
|
68
|
+
lines.push('')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (table.indexes.length > 0) {
|
|
72
|
+
lines.push('#### Indexes')
|
|
73
|
+
lines.push('')
|
|
74
|
+
for (const idx of table.indexes) {
|
|
75
|
+
const parts = idx.parts.map((p) => p.column).join(', ')
|
|
76
|
+
const unique = idx.unique ? ' UNIQUE' : ''
|
|
77
|
+
lines.push(`- ${idx.name} (${parts})${unique}`)
|
|
78
|
+
}
|
|
79
|
+
lines.push('')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lines.join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderViewSection(view: MergedView): string {
|
|
86
|
+
const lines: string[] = []
|
|
87
|
+
|
|
88
|
+
lines.push(`### ${view.name}`)
|
|
89
|
+
lines.push('')
|
|
90
|
+
|
|
91
|
+
if (view.description) {
|
|
92
|
+
lines.push(view.description)
|
|
93
|
+
lines.push('')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lines.push('| Column | Type | Nullable | PK | FK | Description |')
|
|
97
|
+
lines.push('|------|------|----------|----|----|-------------|')
|
|
98
|
+
for (const col of view.columns) {
|
|
99
|
+
const desc = col.description ?? '-'
|
|
100
|
+
const nullable = col.nullable ? 'Yes' : 'No'
|
|
101
|
+
lines.push(`| ${col.name} | ${col.type} | ${nullable} | - | - | ${desc} |`)
|
|
102
|
+
}
|
|
103
|
+
lines.push('')
|
|
104
|
+
|
|
105
|
+
return lines.join('\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function renderMarkdown(schema: MergedSchema): string {
|
|
109
|
+
const lines: string[] = []
|
|
110
|
+
|
|
111
|
+
lines.push(`# ${schema.title}`)
|
|
112
|
+
lines.push('')
|
|
113
|
+
lines.push(`*Generated: ${schema.generatedAt}*`)
|
|
114
|
+
lines.push('')
|
|
115
|
+
|
|
116
|
+
lines.push('## Contents')
|
|
117
|
+
lines.push('')
|
|
118
|
+
if (schema.tables.length > 0) {
|
|
119
|
+
lines.push('**Tables:**')
|
|
120
|
+
for (const table of schema.tables) {
|
|
121
|
+
lines.push(`- [${table.name}](#${table.name})`)
|
|
122
|
+
}
|
|
123
|
+
lines.push('')
|
|
124
|
+
}
|
|
125
|
+
if (schema.views.length > 0) {
|
|
126
|
+
lines.push('**Views:**')
|
|
127
|
+
for (const view of schema.views) {
|
|
128
|
+
lines.push(`- [${view.name}](#${view.name})`)
|
|
129
|
+
}
|
|
130
|
+
lines.push('')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
lines.push('## Entity Relationship Diagram')
|
|
134
|
+
lines.push('')
|
|
135
|
+
lines.push('```mermaid')
|
|
136
|
+
lines.push(schema.mermaidERD)
|
|
137
|
+
for (const rel of schema.extraRelationships) {
|
|
138
|
+
lines.push(` ${rel.from} ||..o{ ${rel.to} : "${rel.label}"`)
|
|
139
|
+
}
|
|
140
|
+
lines.push('```')
|
|
141
|
+
lines.push('')
|
|
142
|
+
|
|
143
|
+
if (schema.tables.length > 0) {
|
|
144
|
+
lines.push('## Tables')
|
|
145
|
+
lines.push('')
|
|
146
|
+
for (const table of schema.tables) {
|
|
147
|
+
lines.push(renderTableSection(table, schema))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (schema.views.length > 0) {
|
|
152
|
+
lines.push('## Views')
|
|
153
|
+
lines.push('')
|
|
154
|
+
for (const view of schema.views) {
|
|
155
|
+
lines.push(renderViewSection(view))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return lines.join('\n')
|
|
160
|
+
}
|