@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/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, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&#39;')
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)} -&gt; ${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
+ }