|
| 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