@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.
@@ -0,0 +1,109 @@
1
+ import type { MergedSchema } from '../../types'
2
+
3
+ /**
4
+ * Shared test fixture: 2 tables (one generated, one not), 1 view,
5
+ * a mermaid ERD string, and various tags.
6
+ */
7
+ export function makeTestSchema(): MergedSchema {
8
+ return {
9
+ title: 'Test Schema Docs',
10
+ generatedAt: '2026-03-19T00:00:00.000Z',
11
+ mermaidERD: `erDiagram
12
+ users {
13
+ bigserial id PK
14
+ text email
15
+ text name
16
+ }
17
+ posts {
18
+ bigserial id PK
19
+ bigint user_id FK
20
+ text title
21
+ }
22
+ posts }o--o| users : posts_user_id_fkey`,
23
+ tables: [
24
+ {
25
+ name: 'users',
26
+ description: 'User accounts table',
27
+ previously: 'old_users',
28
+ isGenerated: false,
29
+ columns: [
30
+ { name: 'id', type: 'bigserial', nullable: false, isPrimaryKey: true, isForeignKey: false, tags: [] },
31
+ {
32
+ name: 'email',
33
+ type: 'text',
34
+ nullable: true,
35
+ description: 'Primary email',
36
+ previously: 'email_address',
37
+ isPrimaryKey: false,
38
+ isForeignKey: false,
39
+ tags: [],
40
+ },
41
+ { name: 'name', type: 'text', nullable: false, isPrimaryKey: false, isForeignKey: false, tags: [] },
42
+ ],
43
+ indexes: [{ name: 'users_email_idx', unique: true, parts: [{ column: 'email' }] }],
44
+ primaryKey: { parts: [{ column: 'id' }] },
45
+ foreignKeys: [],
46
+ tags: [
47
+ { namespace: 'docs', tag: 'description', args: ['User accounts table'] },
48
+ { namespace: 'audit', tag: 'track', args: { operations: ['INSERT', 'UPDATE'] } },
49
+ { namespace: 'rls', tag: null, args: ['admin_only'] },
50
+ ],
51
+ },
52
+ {
53
+ name: 'posts',
54
+ isGenerated: true,
55
+ generatedBy: 'audit',
56
+ columns: [
57
+ { name: 'id', type: 'bigserial', nullable: false, isPrimaryKey: true, isForeignKey: false, tags: [] },
58
+ { name: 'user_id', type: 'bigint', nullable: false, isPrimaryKey: false, isForeignKey: true, tags: [] },
59
+ { name: 'title', type: 'text', nullable: true, isPrimaryKey: false, isForeignKey: false, tags: [] },
60
+ ],
61
+ indexes: [],
62
+ primaryKey: { parts: [{ column: 'id' }] },
63
+ foreignKeys: [
64
+ {
65
+ name: 'posts_user_id_fkey',
66
+ columns: ['user_id'],
67
+ references: { table: 'users', columns: ['id'] },
68
+ },
69
+ ],
70
+ tags: [],
71
+ },
72
+ ],
73
+ views: [
74
+ {
75
+ name: 'active_users',
76
+ description: 'Active users view',
77
+ columns: [
78
+ { name: 'id', type: 'bigserial', nullable: false, isPrimaryKey: false, isForeignKey: false, tags: [] },
79
+ { name: 'email', type: 'text', nullable: true, isPrimaryKey: false, isForeignKey: false, tags: [] },
80
+ ],
81
+ tags: [{ namespace: 'docs', tag: 'description', args: ['Active users view'] }],
82
+ },
83
+ ],
84
+ extraRelationships: [],
85
+ annotations: [
86
+ { object: 'users', text: 'Audited (insert, update)' },
87
+ { object: 'users', text: 'RLS enabled' },
88
+ ],
89
+ extraColumnHeaders: ['Anonymization'],
90
+ extraColumnData: new Map([['users:email:Anonymization', 'Masked: anon.fake_email()']]),
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Minimal schema with no tables, no views -- for edge case testing.
96
+ */
97
+ export function makeMinimalSchema(): MergedSchema {
98
+ return {
99
+ title: 'Empty Schema',
100
+ generatedAt: '2026-03-19T00:00:00.000Z',
101
+ mermaidERD: 'erDiagram',
102
+ tables: [],
103
+ views: [],
104
+ extraRelationships: [],
105
+ annotations: [],
106
+ extraColumnHeaders: [],
107
+ extraColumnData: new Map(),
108
+ }
109
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderHtml } from '../../renderers/html'
3
+ import { makeMinimalSchema, makeTestSchema } from './fixture'
4
+
5
+ describe('renderHtml', () => {
6
+ const schema = makeTestSchema()
7
+
8
+ it('output starts with <!DOCTYPE html>', () => {
9
+ const result = renderHtml(schema)
10
+ expect(result.trimStart()).toMatch(/^<!DOCTYPE html>/)
11
+ })
12
+
13
+ it('output contains <title> with schema title', () => {
14
+ const result = renderHtml(schema)
15
+ expect(result).toContain('<title>Test Schema Docs</title>')
16
+ })
17
+
18
+ it('output contains sidebar nav with table links', () => {
19
+ const result = renderHtml(schema)
20
+ expect(result).toContain('class="sidebar"')
21
+ expect(result).toContain('<a href="#users">users</a>')
22
+ expect(result).toContain('<a href="#posts">posts</a>')
23
+ })
24
+
25
+ it('output contains sidebar Views section with view links', () => {
26
+ const result = renderHtml(schema)
27
+ expect(result).toContain('<h2>Views</h2>')
28
+ expect(result).toContain('<a href="#active_users">active_users</a>')
29
+ })
30
+
31
+ it('output contains <pre class="mermaid"> with ERD content', () => {
32
+ const result = renderHtml(schema)
33
+ expect(result).toContain('<pre class="mermaid">')
34
+ expect(result).toContain('erDiagram')
35
+ })
36
+
37
+ it('output contains <section id="{tableName}"> for each table', () => {
38
+ const result = renderHtml(schema)
39
+ expect(result).toContain('<section id="users">')
40
+ expect(result).toContain('<section id="posts">')
41
+ expect(result).toContain('<section id="active_users">')
42
+ })
43
+
44
+ it('generated tables have generated-badge span', () => {
45
+ const result = renderHtml(schema)
46
+ expect(result).toContain('class="generated-badge"')
47
+ expect(result).toContain('Generated by @audit')
48
+ })
49
+
50
+ it('columns rendered in HTML table', () => {
51
+ const result = renderHtml(schema)
52
+ expect(result).toContain('<th>Column</th>')
53
+ expect(result).toContain('<th>Type</th>')
54
+ expect(result).toContain('<td>bigserial</td>')
55
+ })
56
+
57
+ it('annotations rendered as <span class="annotation">', () => {
58
+ const result = renderHtml(schema)
59
+ expect(result).toContain('<span class="annotation">')
60
+ expect(result).toContain('Audited (insert, update)')
61
+ expect(result).toContain('RLS enabled')
62
+ })
63
+
64
+ it('HTML-escaped content (no raw < or > in user strings)', () => {
65
+ // Create schema with potentially dangerous content
66
+ const dangerousSchema = makeTestSchema()
67
+ dangerousSchema.tables[0].description = '<script>alert("xss")</script>'
68
+ dangerousSchema.title = 'Test <b>bold</b> title'
69
+
70
+ const result = renderHtml(dangerousSchema)
71
+ expect(result).not.toContain('<script>alert')
72
+ expect(result).toContain('&lt;script&gt;')
73
+ expect(result).toContain('&lt;b&gt;bold&lt;/b&gt;')
74
+ })
75
+
76
+ it('script tag contains IntersectionObserver', () => {
77
+ const result = renderHtml(schema)
78
+ expect(result).toContain('<script type="module">')
79
+ expect(result).toContain('IntersectionObserver')
80
+ })
81
+
82
+ it('embedded CSS contains sidebar and content styles', () => {
83
+ const result = renderHtml(schema)
84
+ expect(result).toContain('<style>')
85
+ expect(result).toContain('.sidebar')
86
+ expect(result).toContain('.content')
87
+ expect(result).toContain('.annotation')
88
+ expect(result).toContain('.generated-badge')
89
+ })
90
+
91
+ it('no Views sidebar section when no views present', () => {
92
+ const minimal = makeMinimalSchema()
93
+ const result = renderHtml(minimal)
94
+ // Should not have the Views heading in sidebar
95
+ // The sidebar should not contain Views h2 since no views
96
+ const sidebarMatch = result.match(/<nav class="sidebar">([\s\S]*?)<\/nav>/)
97
+ expect(sidebarMatch).toBeTruthy()
98
+ expect(sidebarMatch![1]).not.toContain('<h2>Views</h2>')
99
+ })
100
+
101
+ it('table with previously shows "formerly" paragraph', () => {
102
+ const result = renderHtml(schema)
103
+ expect(result).toContain('class="previously"')
104
+ expect(result).toContain('formerly: old_users')
105
+ })
106
+
107
+ it('column with previously shows "formerly" in description cell', () => {
108
+ const result = renderHtml(schema)
109
+ expect(result).toContain('formerly: email_address')
110
+ })
111
+
112
+ it('description paragraph has class "description"', () => {
113
+ const result = renderHtml(schema)
114
+ expect(result).toContain('class="description"')
115
+ })
116
+
117
+ it('timestamp paragraph has class "timestamp"', () => {
118
+ const result = renderHtml(schema)
119
+ expect(result).toContain('class="timestamp"')
120
+ expect(result).toContain('Generated:')
121
+ })
122
+ })
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderMarkdown } from '../../renderers/markdown'
3
+ import { makeMinimalSchema, makeTestSchema } from './fixture'
4
+
5
+ describe('renderMarkdown', () => {
6
+ // Reuse the shared fixture for most tests
7
+ const schema = makeTestSchema()
8
+
9
+ // Generate once for all tests
10
+ const _output = renderMarkdown(schema)
11
+
12
+ it('output contains title as H1', () => {
13
+ const result = renderMarkdown(schema)
14
+ expect(result).toContain('# Test Schema Docs')
15
+ })
16
+
17
+ it('output contains mermaid code block with erDiagram content', () => {
18
+ const result = renderMarkdown(schema)
19
+ expect(result).toContain('```mermaid')
20
+ expect(result).toContain('erDiagram')
21
+ expect(result).toContain('bigserial id PK')
22
+ expect(result).toContain('```')
23
+ })
24
+
25
+ it('output contains table of contents with anchor links', () => {
26
+ const result = renderMarkdown(schema)
27
+ expect(result).toContain('## Contents')
28
+ expect(result).toMatch(/\[users\]\(#users\)/)
29
+ expect(result).toMatch(/\[posts\]\(#posts\)/)
30
+ expect(result).toMatch(/\[active_users\]\(#active_users\)/)
31
+ })
32
+
33
+ it('each table has columns table with correct headers', () => {
34
+ const result = renderMarkdown(schema)
35
+ expect(result).toContain('| Column | Type | Nullable | PK | FK | Description |')
36
+ })
37
+
38
+ it('generated tables have "(Generated by @namespace)" annotation', () => {
39
+ const result = renderMarkdown(schema)
40
+ expect(result).toContain('(Generated by @audit)')
41
+ })
42
+
43
+ it('non-generated tables do not have generated annotation', () => {
44
+ const result = renderMarkdown(schema)
45
+ // "users" section should NOT have "Generated by"
46
+ const usersIdx = result.indexOf('### users')
47
+ const postsIdx = result.indexOf('### posts')
48
+ const between = result.slice(usersIdx, postsIdx)
49
+ expect(between).not.toContain('Generated by')
50
+ })
51
+
52
+ it('table with previously shows "formerly" line', () => {
53
+ const result = renderMarkdown(schema)
54
+ expect(result).toContain('*formerly: old_users*')
55
+ })
56
+
57
+ it('column with previously shows "formerly" in description cell', () => {
58
+ const result = renderMarkdown(schema)
59
+ expect(result).toContain('*formerly: email_address*')
60
+ })
61
+
62
+ it('annotations rendered as blockquotes', () => {
63
+ const result = renderMarkdown(schema)
64
+ expect(result).toContain('> Audited (insert, update)')
65
+ expect(result).toContain('> RLS enabled')
66
+ })
67
+
68
+ it('extra column headers appear in column table', () => {
69
+ const result = renderMarkdown(schema)
70
+ expect(result).toContain('Anonymization')
71
+ expect(result).toContain('Masked: anon.fake_email()')
72
+ })
73
+
74
+ it('FK section rendered when foreignKeys present', () => {
75
+ const result = renderMarkdown(schema)
76
+ expect(result).toContain('#### Foreign Keys')
77
+ expect(result).toContain('posts_user_id_fkey')
78
+ expect(result).toContain('users(id)')
79
+ })
80
+
81
+ it('indexes section rendered when indexes present', () => {
82
+ const result = renderMarkdown(schema)
83
+ expect(result).toContain('#### Indexes')
84
+ expect(result).toContain('users_email_idx')
85
+ expect(result).toContain('UNIQUE')
86
+ })
87
+
88
+ it('views section rendered when views present', () => {
89
+ const result = renderMarkdown(schema)
90
+ expect(result).toContain('## Views')
91
+ expect(result).toContain('### active_users')
92
+ })
93
+
94
+ it('empty schema produces minimal output (title + empty sections)', () => {
95
+ const minimal = makeMinimalSchema()
96
+ const result = renderMarkdown(minimal)
97
+ expect(result).toContain('# Empty Schema')
98
+ expect(result).toContain('```mermaid')
99
+ expect(result).not.toContain('## Views')
100
+ })
101
+
102
+ it('column rows contain correct PK/FK markers', () => {
103
+ const result = renderMarkdown(schema)
104
+ // The "id" column in users should have PK=Y
105
+ // We need to find the users columns table and check the id row
106
+ const lines = result.split('\n')
107
+ const idLine = lines.find((l) => l.includes('| id |') && l.includes('bigserial'))
108
+ expect(idLine).toBeDefined()
109
+ expect(idLine).toContain('| Y |')
110
+
111
+ // user_id in posts should have FK=Y
112
+ const fkLine = lines.find((l) => l.includes('| user_id |'))
113
+ expect(fkLine).toBeDefined()
114
+ expect(fkLine).toMatch(/\|\s*Y\s*\|/)
115
+ })
116
+
117
+ it('generated timestamp is present', () => {
118
+ const result = renderMarkdown(schema)
119
+ expect(result).toContain('*Generated:')
120
+ })
121
+ })
package/src/atlas.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Atlas integration for ns-docs.
3
+ *
4
+ * BEFORE (Phase 6): Used execFileSync to shell out to Atlas CLI.
5
+ * AFTER (Phase 7): Atlas WASI inspect is run by the CLI compile command.
6
+ * The afterCompile hook receives the already-inspected schema.
7
+ *
8
+ * This module now provides conversion utilities from Atlas WASI types
9
+ * to the ns-docs internal types used by merge.ts and renderers.
10
+ */
11
+ import type { AtlasRealm } from '@sqldoc/atlas'
12
+ import type { AtlasSchema } from './types.ts'
13
+
14
+ /** Convert Atlas WASI realm to ns-docs AtlasSchema format */
15
+ export function realmToDocsSchema(realm: AtlasRealm): AtlasSchema {
16
+ // Map from lowercase Atlas WASI types to ns-docs internal types
17
+ return {
18
+ schemas: realm.schemas.map((s) => ({
19
+ name: s.name,
20
+ tables: (s.tables ?? []).map((t) => ({
21
+ name: t.name,
22
+ columns: (t.columns ?? []).map((c) => ({
23
+ name: c.name,
24
+ type: c.type?.raw ?? c.type?.T ?? 'unknown',
25
+ null: c.type?.null,
26
+ })),
27
+ indexes: (t.indexes ?? []).map((idx) => ({
28
+ name: idx.name ?? '',
29
+ unique: idx.unique,
30
+ parts: (idx.parts ?? []).map((p) => ({ column: p.column ?? '' })),
31
+ })),
32
+ primary_key: t.primary_key
33
+ ? {
34
+ parts: (t.primary_key.parts ?? []).map((p) => ({ column: p.column ?? '' })),
35
+ }
36
+ : undefined,
37
+ foreign_keys: (t.foreign_keys ?? []).map((fk) => ({
38
+ name: fk.symbol ?? '',
39
+ columns: fk.columns ?? [],
40
+ references: {
41
+ table: fk.ref_table ?? '',
42
+ columns: fk.ref_columns ?? [],
43
+ },
44
+ })),
45
+ })),
46
+ views: (s.views ?? []).map((v) => ({
47
+ name: v.name,
48
+ columns: (v.columns ?? []).map((c) => ({
49
+ name: c.name,
50
+ type: c.type?.raw ?? c.type?.T ?? 'unknown',
51
+ null: c.type?.null,
52
+ })),
53
+ })),
54
+ })),
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { AtlasRealm } from '@sqldoc/atlas'
2
+ import type { NamespacePlugin, ProjectContext, ProjectOutput } from '@sqldoc/core'
3
+ import { realmToDocsSchema } from './atlas.ts'
4
+ import { mergeSchemaWithTags } from './merge.ts'
5
+ import { generateMermaidERD } from './mermaid.ts'
6
+ import { renderHtml } from './renderers/html.ts'
7
+ import { renderMarkdown } from './renderers/markdown.ts'
8
+ import type { DocsConfig } from './types.ts'
9
+
10
+ const plugin: NamespacePlugin = {
11
+ apiVersion: 1,
12
+ name: 'docs',
13
+ tags: {
14
+ emit: {
15
+ description: 'Include or exclude this object from documentation',
16
+ targets: ['table', 'view', 'function', 'type'],
17
+ args: [{ type: 'boolean' }],
18
+ },
19
+ description: {
20
+ description: 'Set custom description text for this object in documentation',
21
+ targets: ['table', 'column', 'view', 'function', 'type'],
22
+ args: [{ type: 'string' }],
23
+ },
24
+ previously: {
25
+ description: 'Indicate this object was renamed from a previous name',
26
+ targets: ['table', 'column'],
27
+ args: [{ type: 'string' }],
28
+ },
29
+ },
30
+
31
+ async afterCompile(ctx: ProjectContext): Promise<ProjectOutput> {
32
+ const config = ctx.config as Partial<DocsConfig> | undefined
33
+ if (!config) {
34
+ return { files: [] }
35
+ }
36
+ if (!config.output) {
37
+ throw new Error('ns-docs config requires "output" (file path, e.g. "docs/schema.html")')
38
+ }
39
+ if (!config.format) {
40
+ throw new Error('ns-docs config requires "format" ("markdown" or "html")')
41
+ }
42
+ const { format, output: outputPath } = config
43
+ const title = config.title ?? 'Schema Documentation'
44
+
45
+ // Atlas realm is provided by CLI compile (WASI inspect already ran)
46
+ const realm = ctx.atlasRealm as AtlasRealm | undefined
47
+ if (!realm) {
48
+ throw new Error('ns-docs requires Atlas schema. Run with a database connection (devUrl in config).')
49
+ }
50
+
51
+ // Convert Atlas WASI types to ns-docs types
52
+ const schema = realmToDocsSchema(realm)
53
+ const mermaid = generateMermaidERD(realm)
54
+
55
+ // Merge schema with sqldoc tags
56
+ const merged = mergeSchemaWithTags(schema, mermaid, ctx.allFileTags, ctx.outputs, title, ctx.docsMeta)
57
+
58
+ // Render to chosen format
59
+ const content = format === 'html' ? renderHtml(merged) : renderMarkdown(merged)
60
+
61
+ return {
62
+ files: [{ filePath: outputPath, content }],
63
+ }
64
+ },
65
+ }
66
+
67
+ export default plugin
68
+ export type { DocsConfig }