Skip to content

Commit 0b3e82c

Browse files
committed
feat(plugin-documentation): add @zenstackhq/plugin-documentation package
Automatically generate rich, browsable Markdown documentation from ZModel schemas. Every model, view, enum, type, and procedure gets its own page with fields, relationships, access policies, validation rules, Mermaid diagrams, and cross-links. Key capabilities: - Full-schema ERD generation with SVG export via beautiful-mermaid - Per-page SVG diagrams (diagramFormat: 'svg' | 'both' | 'mermaid') - AI agent skill file generation (SKILL.md) - 15 built-in themes for SVG rendering - Multi-file schema support with source file tracking - Configurable sections: policies, validation, indexes, relationships
1 parent d49c39e commit 0b3e82c

File tree

133 files changed

+25366
-276
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

133 files changed

+25366
-276
lines changed

packages/plugins/documentation/.gitignore

Whitespace-only changes.
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
# Contributing to @zenstackhq/plugin-documentation
2+
3+
This guide covers how the plugin works internally, how the codebase is organized, and how to extend or modify it.
4+
5+
## Architecture
6+
7+
### Data flow
8+
9+
The plugin is a pure function: **ZModel AST in, Markdown files out**.
10+
11+
```text
12+
schema.zmodel
13+
14+
15+
┌─────────────┐
16+
│ ZenStack │ `zenstack generate` invokes the CliPlugin interface
17+
│ CLI │
18+
└──────┬──────┘
19+
│ context.model (full AST)
20+
21+
┌─────────────┐
22+
│ generator │ Walks the AST, dispatches to per-entity renderers
23+
└──────┬──────┘
24+
25+
┌────┼────┬──────────┬──────────┬──────────┐
26+
▼ ▼ ▼ ▼ ▼ ▼
27+
index model enum type view procedure
28+
page page page page page page
29+
│ │ │ │ │ │
30+
└────┴────┴──────────┴──────────┴──────────┘
31+
32+
33+
output directory (Markdown files)
34+
```
35+
36+
There are no runtime dependencies beyond `@zenstackhq/language` (AST types) and `@zenstackhq/sdk` (the `CliPlugin` interface). No template engines, no Markdown libraries, no network calls.
37+
38+
### Plugin entry point
39+
40+
`src/index.ts` exports a `CliPlugin` object. The CLI calls `plugin.generate(context)` during `zenstack generate`.
41+
42+
```typescript
43+
const plugin: CliPlugin = {
44+
name: 'Documentation Generator',
45+
statusText: 'Generating documentation',
46+
generate,
47+
};
48+
```
49+
50+
The `generate` function lives in `src/generator.ts`. It is the orchestrator:
51+
52+
1. Resolves the output directory from `pluginOptions.output`
53+
2. Resolves render options (`includeRelationships`, `fieldOrder`, etc.)
54+
3. Filters AST declarations into models, views, types, enums, and procedures
55+
4. Creates output subdirectories (`models/`, `enums/`, etc.)
56+
5. Calls the appropriate renderer for each entity, writes the returned string to disk
57+
6. Renders the index page last (it needs generation stats like file count and duration)
58+
59+
### Source layout
60+
61+
```text
62+
src/
63+
├── index.ts # CliPlugin default export
64+
├── generator.ts # Orchestrator — walks AST, writes files
65+
├── types.ts # Shared interfaces (RenderOptions, DocMeta, Relationship, etc.)
66+
├── extractors.ts # AST data extraction utilities
67+
└── renderers/
68+
├── common.ts # Shared rendering: headers, breadcrumbs, sections, navigation
69+
├── index-page.ts # Renders index.md
70+
├── model-page.ts # Renders models/<Name>.md
71+
├── view-page.ts # Renders views/<Name>.md
72+
├── enum-page.ts # Renders enums/<Name>.md
73+
├── type-page.ts # Renders types/<Name>.md
74+
├── procedure-page.ts # Renders procedures/<Name>.md
75+
├── relationships-page.ts # Renders relationships.md
76+
└── skill-page.ts # Renders SKILL.md (LLM-optimized reference)
77+
```
78+
79+
### Key interfaces
80+
81+
**`RenderOptions`** — Passed to every renderer. Controls which sections to include and field ordering:
82+
83+
```typescript
84+
interface RenderOptions {
85+
includeRelationships: boolean;
86+
includePolicies: boolean;
87+
includeValidation: boolean;
88+
includeIndexes: boolean;
89+
fieldOrder: 'declaration' | 'alphabetical';
90+
schemaDir?: string;
91+
genCtx?: GenerationContext;
92+
}
93+
```
94+
95+
**`GenerationContext`** — Metadata about the generation run (schema file name, date, duration, file count):
96+
97+
```typescript
98+
interface GenerationContext {
99+
schemaFile: string;
100+
generatedAt: string;
101+
durationMs?: number;
102+
filesGenerated?: number;
103+
}
104+
```
105+
106+
**`DocMeta`** — Extracted `@@meta('doc:*')` annotations:
107+
108+
```typescript
109+
interface DocMeta {
110+
category?: string;
111+
since?: string;
112+
deprecated?: string;
113+
}
114+
```
115+
116+
## How renderers work
117+
118+
Every renderer follows the same pattern:
119+
120+
1. Accept the AST node(s) and `RenderOptions`
121+
2. Build an array of strings (`string[]`), where each element is one line
122+
3. Join with `'\n'` and return the full page content
123+
124+
```typescript
125+
export function renderModelPage(
126+
model: DataModel,
127+
options: RenderOptions,
128+
procedures: Procedure[],
129+
nav: Navigation | undefined,
130+
): string {
131+
const lines: string[] = [];
132+
lines.push(...generatedHeader(options.genCtx));
133+
lines.push(...breadcrumbs('Models', model.name, '../'));
134+
// ... build sections ...
135+
return lines.join('\n');
136+
}
137+
```
138+
139+
**Why arrays instead of string concatenation?** Pushing to an array and joining at the end is the most efficient pattern for iterative string construction in Node.js. It avoids creating intermediate string objects on every append. This was validated with benchmarks during development.
140+
141+
### Adding a new section to an existing page
142+
143+
1. Write a failing test in the appropriate `test/generator/*.test.ts` file
144+
2. Add the section rendering logic to the renderer in `src/renderers/`
145+
3. If the section needs AST data extraction, add a helper in `src/extractors.ts`
146+
4. If the section should be toggleable, add a boolean to `RenderOptions` and `resolveRenderOptions()`
147+
5. Run tests, verify the new section appears, update snapshots
148+
149+
### Adding a new entity type
150+
151+
1. Create a new renderer in `src/renderers/<entity>-page.ts`
152+
2. Add it to `src/generator.ts` — filter the declarations, create the subdirectory, call the renderer
153+
3. Add the entity to `renderIndexPage` so it appears on the index
154+
4. Create a new test file `test/generator/<entity>-page.test.ts`
155+
5. Add integration coverage in the relevant `test/integration/*.test.ts` files
156+
157+
## Extractors
158+
159+
`src/extractors.ts` contains pure functions for pulling data out of the ZModel AST. These are decoupled from rendering so they can be tested independently if needed, and so renderers stay focused on output formatting.
160+
161+
Key extractors:
162+
163+
| Function | Purpose |
164+
|---|---|
165+
| `stripCommentPrefix` | Strips `///` from AST comment strings |
166+
| `getFieldTypeName` | Resolves a field's type to a display string, optionally with Markdown links |
167+
| `getDefaultValue` | Extracts the `@default(...)` value |
168+
| `getFieldAttributes` | Formats non-default, non-computed, non-meta attributes |
169+
| `extractDocMeta` | Pulls `@@meta('doc:*')` annotations into a `DocMeta` object |
170+
| `extractFieldDocExample` | Extracts `@meta('doc:example', '...')` values |
171+
| `collectRelationships` | Builds a flat list of relationships from all models |
172+
| `isIgnoredModel` | Checks for `@@ignore` attribute |
173+
| `getSourceFilePath` | Resolves the `.zmodel` file a node was defined in (via CST) |
174+
| `getRelativeSourcePath` | Makes the source path relative to the schema directory |
175+
| `extractProcedureComments` | Extracts comments from procedure CST (procedures store comments differently) |
176+
177+
## Common rendering helpers
178+
179+
`src/renderers/common.ts` provides shared utilities used across all renderers:
180+
181+
| Function | Purpose |
182+
|---|---|
183+
| `generatedHeader` | The `> [!CAUTION]` auto-generated banner |
184+
| `breadcrumbs` | `Index / EntityType / Name` navigation |
185+
| `sectionHeading` | Consistent `## Emoji SectionName` with `<a id="...">` anchor |
186+
| `navigationFooter` | Prev/next links between entities |
187+
| `referencesSection` | "See also" link to ZenStack docs |
188+
| `declarationBlock` | Collapsible `<details>` with raw ZModel source |
189+
| `renderDescription` | `///` comments formatted as blockquotes |
190+
| `renderMetadata` | Category, since, deprecated, source path as inline metadata |
191+
| `buildNavList` | Builds prev/next navigation from a sorted list of entity names |
192+
193+
## Design decisions
194+
195+
### No runtime dependencies
196+
197+
The plugin depends only on `@zenstackhq/language` and `@zenstackhq/sdk`. No template engines (Handlebars, EJS, etc.) and no Markdown rendering libraries. This keeps the dependency surface minimal and avoids version conflicts in the monorepo.
198+
199+
### Explicit HTML anchors
200+
201+
Section headings and field rows include `<a id="...">` anchors for deep-linking. We can't rely on Markdown renderers to generate consistent heading IDs because some renderers strip emojis, some slugify differently, and some don't generate IDs at all. Explicit anchors guarantee that cross-links work everywhere.
202+
203+
### Deterministic output
204+
205+
- Entities of each type are sorted alphabetically
206+
- Field order defaults to declaration order but can be set to alphabetical
207+
- Timestamps use date-only (`YYYY-MM-DD`) format, no time component
208+
209+
This keeps diffs clean when docs are committed to version control.
210+
211+
### String arrays over template engines
212+
213+
The array-push-join pattern was chosen over template engines for three reasons:
214+
215+
1. **Performance** — No parsing overhead, no template compilation
216+
2. **Type safety** — Template engines typically work with string interpolation, losing TypeScript's help
217+
3. **Conditional sections** — Sections that should be omitted when empty are trivially handled by not pushing any lines, rather than needing template `if` blocks
218+
219+
## Testing
220+
221+
### Test organization
222+
223+
Tests are split by feature area, mirroring the renderer structure:
224+
225+
```text
226+
test/
227+
├── utils.ts # Shared helpers
228+
├── generator/ # Unit tests by page type
229+
│ ├── common.test.ts # Cross-page features (15 tests)
230+
│ ├── index-page.test.ts # Index page (17 tests)
231+
│ ├── model-page.test.ts # Model page (46 tests)
232+
│ ├── enum-page.test.ts # Enum page (9 tests)
233+
│ ├── type-view-page.test.ts # Type + View pages (14 tests)
234+
│ ├── procedure-page.test.ts # Procedure page (13 tests)
235+
│ ├── skill-page.test.ts # SKILL.md generation (30 tests)
236+
│ └── snapshot.test.ts # Full schema snapshot (1 test)
237+
└── integration/ # Tests against real schemas
238+
├── samples.test.ts # Sample project schemas (4 tests)
239+
├── showcase.test.ts # Comprehensive showcase schema (23 tests)
240+
├── e2e-schemas.test.ts # E2E test schemas (6 tests)
241+
└── multifile.test.ts # Multi-file import schemas (3 tests)
242+
```
243+
244+
### Test approach
245+
246+
Tests call the public `generate()` function through `generateFromSchema()` or `generateFromFile()` helpers, then assert on the generated Markdown output. This is intentional — it tests the full pipeline (AST loading → extraction → rendering → file writing) rather than internal functions.
247+
248+
```typescript
249+
it('renders fields table with types and descriptions', async () => {
250+
const tmpDir = await generateFromSchema(`
251+
model User {
252+
id String @id @default(cuid())
253+
/// User's email address.
254+
email String @unique
255+
}
256+
`);
257+
const doc = readDoc(tmpDir, 'models', 'User.md');
258+
expect(doc).toContain('| `id`');
259+
expect(doc).toContain("User's email address");
260+
});
261+
```
262+
263+
### Test utilities
264+
265+
`test/utils.ts` provides:
266+
267+
| Export | Purpose |
268+
|---|---|
269+
| `generateFromSchema(schema, options?)` | Parse inline ZModel, generate docs to a temp directory, return the path |
270+
| `generateFromFile(schemaFile, options?)` | Generate docs from a `.zmodel` file |
271+
| `readDoc(tmpDir, ...segments)` | Read a generated doc file |
272+
| `findFieldLine(doc, fieldName)` | Find the table row for a specific field (matches `field-<name>` anchor) |
273+
| `findBrokenLinks(outputDir)` | Walk all generated `.md` files, verify all relative links resolve to existing files |
274+
275+
### Snapshot tests
276+
277+
`snapshot.test.ts` captures the full output for a representative schema. It uses a `stabilize()` function to redact dynamic content before comparison:
278+
279+
- Temporary file paths (contain random UUIDs)
280+
- Generation duration (varies per run)
281+
- Generation date (varies per day)
282+
283+
Update snapshots with:
284+
285+
```bash
286+
pnpm test -- --update
287+
```
288+
289+
### Link integrity tests
290+
291+
Many tests use `findBrokenLinks()` to verify that every internal link in the generated docs resolves to an existing file. This catches cross-linking regressions. Integration tests always include a broken-link check.
292+
293+
### Running tests
294+
295+
```bash
296+
# Run all tests
297+
pnpm test
298+
299+
# Run a specific test file
300+
pnpm test -- test/generator/model-page.test.ts
301+
302+
# Run tests matching a pattern
303+
pnpm test -- -t "renders fields table"
304+
305+
# Update snapshots
306+
pnpm test -- --update
307+
```
308+
309+
## Development workflow
310+
311+
### Prerequisites
312+
313+
From the repository root:
314+
315+
```bash
316+
pnpm install
317+
pnpm build
318+
```
319+
320+
The plugin package is at `packages/plugins/documentation/`.
321+
322+
### Build
323+
324+
```bash
325+
# Build this package only (run from packages/plugins/documentation/)
326+
pnpm build
327+
328+
# Build from repo root (builds all packages via Turbo)
329+
cd /path/to/zenstack && pnpm build
330+
```
331+
332+
The build uses `tsup-node` for bundling and `tsc --noEmit` for type checking.
333+
334+
### Test-driven development
335+
336+
This plugin was built with strict TDD. When adding features:
337+
338+
1. Write a failing test (RED)
339+
2. Implement the minimum code to make it pass (GREEN)
340+
3. Refactor if needed
341+
4. Commit
342+
343+
### Linting
344+
345+
```bash
346+
pnpm lint
347+
```
348+
349+
Uses the shared `@zenstackhq/eslint-config`.
350+
351+
### Preview output
352+
353+
To visually inspect what the plugin generates, point the output at a local directory:
354+
355+
```bash
356+
# In any project with a schema.zmodel:
357+
plugin documentation {
358+
provider = '../path/to/packages/plugins/documentation'
359+
output = './preview-output'
360+
}
361+
npx zenstack generate
362+
```
363+
364+
Then browse the generated Markdown files or render them with your preferred viewer.
365+
366+
## Frequently touched files
367+
368+
When making changes, these are the files you'll most commonly modify:
369+
370+
| Change type | Files |
371+
|---|---|
372+
| New section on model pages | `src/renderers/model-page.ts`, `test/generator/model-page.test.ts` |
373+
| New index page content | `src/renderers/index-page.ts`, `test/generator/index-page.test.ts` |
374+
| New AST extraction logic | `src/extractors.ts` |
375+
| New configuration option | `src/types.ts`, `src/extractors.ts` (`resolveRenderOptions`), `src/generator.ts` |
376+
| Cross-page rendering changes | `src/renderers/common.ts`, `test/generator/common.test.ts` |
377+
| New entity type | `src/generator.ts`, new renderer, new test file |

0 commit comments

Comments
 (0)