From 9de39365dcb4a138fb392dbe9c8fc04485592326 Mon Sep 17 00:00:00 2001 From: James Sheldon Date: Sun, 8 Mar 2026 11:55:52 -0600 Subject: [PATCH 1/2] feat: improve SVG diagram embedding and add release automation - Add descriptive alt text to SVG image references (e.g. ![User diagram]) - Add diagramEmbed option ('file' | 'inline') for embedding SVG directly in markdown - Wrap all diagram outputs in responsive container div for horizontal scrolling - Enhance SKILL.md with ERD, relationships table, policy matrix, field summaries - Fix mixin-inherited fields not appearing in SKILL.md via getAllFields - Route SKILL.md through SVG rendering pipeline - Set up Release Please for automated versioning and changelog generation - Document conventional commit format in CONTRIBUTING.md --- .github/workflows/release-please.yml | 17 + .release-please-manifest.json | 3 + CONTRIBUTING.md | 84 ++++- README.md | 37 +- eslint.config.js | 8 +- package.json | 8 +- release-please-config.json | 18 + src/generator.ts | 86 ++++- src/renderers/diagram-processor.ts | 65 +++- src/renderers/skill-page.ts | 529 +++++++++++++++++++++------ src/types.ts | 8 +- test/generator/diagram-svg.test.ts | 229 +++++++----- test/generator/skill-page.test.ts | 178 ++++++++- test/integration/showcase.test.ts | 17 +- 14 files changed, 998 insertions(+), 289 deletions(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..2b7d172 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,17 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a945449..9114087 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -310,23 +310,15 @@ pnpm test -- --update ### Prerequisites -From the repository root: - ```bash pnpm install pnpm build ``` -The plugin package is at `packages/plugins/documentation/`. - ### Build ```bash -# Build this package only (run from packages/plugins/documentation/) pnpm build - -# Build from repo root (builds all packages via Turbo) -cd /path/to/zenstack && pnpm build ``` The build uses `tsup-node` for bundling and `tsc --noEmit` for type checking. @@ -346,22 +338,80 @@ This plugin was built with strict TDD. When adding features: pnpm lint ``` -Uses the shared `@zenstackhq/eslint-config`. +Uses `eslint-config-canonical` with project-specific overrides defined in `eslint.config.js`. ### Preview output -To visually inspect what the plugin generates, point the output at a local directory: +To visually inspect what the plugin generates: ```bash -# In any project with a schema.zmodel: -plugin documentation { - provider = '../path/to/packages/plugins/documentation' - output = './preview-output' -} -pnpm exec zenstack generate +pnpm run build +npx ts-node scripts/preview.ts +``` + +Then browse the generated Markdown files in `preview-output/` or render them with your preferred viewer. + +## Commit conventions + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) and [Release Please](https://github.com/googleapis/release-please) for automated versioning and changelog generation. + +### Commit message format + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] ``` -Then browse the generated Markdown files or render them with your preferred viewer. +### Types + +| Type | Purpose | Version bump | +|---|---|---| +| `feat` | New feature or capability | minor (0.x → 0.x+1) | +| `fix` | Bug fix | patch (0.x.y → 0.x.y+1) | +| `perf` | Performance improvement | patch | +| `docs` | Documentation only | none | +| `test` | Adding or updating tests | none | +| `refactor` | Code change that neither fixes a bug nor adds a feature | none | +| `chore` | Maintenance tasks (deps, CI config) | none | +| `ci` | CI/CD workflow changes | none | + +### Breaking changes + +Append `!` after the type to indicate a breaking change: + +``` +feat!: rename diagramFormat option to svgMode +``` + +Or include a `BREAKING CHANGE:` footer: + +``` +feat: overhaul diagram rendering pipeline + +BREAKING CHANGE: diagramFormat option renamed to svgMode +``` + +Breaking changes bump the major version (or minor while pre-1.0). + +### Examples + +``` +feat: add diagramEmbed option for inline SVG embedding +fix: use descriptive alt text in SVG image references +docs: document responsive wrapper behavior +refactor: extract wrapResponsive helper from processDiagrams +test: add inline SVG embedding tests +chore: update dev dependencies +``` + +### How it works + +1. You merge PRs to `main` using conventional commit messages +2. Release Please reads the commit history and opens a "Release PR" with a version bump and auto-generated `CHANGELOG.md` +3. Merging the Release PR creates a GitHub Release, which triggers the npm publish workflow ## Frequently touched files diff --git a/README.md b/README.md index 8cab8cd..de4a3d5 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ plugin documentation { generateErd = true erdTheme = 'github-light' diagramFormat = 'svg' + diagramEmbed = 'inline' } ``` @@ -160,6 +161,7 @@ plugin documentation { | `erdFormat` | `"svg"`, `"mmd"`, or `"both"` | `"both"` | Which ERD output format(s) to produce | | `erdTheme` | `string` | default | [beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid) theme name for SVG rendering | | `diagramFormat` | `"mermaid"`, `"svg"`, or `"both"` | `"mermaid"` | How per-page Mermaid diagrams are rendered (see [Per-Page SVG Diagrams](#per-page-svg-diagrams)) | +| `diagramEmbed` | `"file"` or `"inline"` | `"file"` | Whether SVGs are written as companion files or embedded directly in the markdown (see [Per-Page SVG Diagrams](#per-page-svg-diagrams)) | ## ERD SVG Export @@ -188,7 +190,7 @@ See all 15 themes rendered against the showcase schema in the [theme gallery](./ ## Per-Page SVG Diagrams -By default, diagrams on model, view, enum, type, procedure, and relationship pages are rendered as inline Mermaid code blocks. Set `diagramFormat` to render them as SVG images instead: +By default, diagrams on model, view, enum, type, procedure, relationship, and SKILL pages are rendered as inline Mermaid code blocks. Set `diagramFormat` to render them as SVG images instead: ```prisma plugin documentation { @@ -199,13 +201,40 @@ plugin documentation { } ``` +### Diagram format + | Value | Behavior | |---|---| | `"mermaid"` | Inline ` ```mermaid ` code blocks (default, requires a Mermaid-capable viewer) | -| `"svg"` | SVG content embedded directly in the markdown — works everywhere, including Notion, plain Markdown viewers, and GitHub | -| `"both"` | Embedded SVG with the Mermaid source in a collapsible `
` block | +| `"svg"` | Companion `.svg` files referenced via `![Entity diagram](./Entity-diagram.svg)` — works everywhere | +| `"both"` | SVG image reference with the Mermaid source in a collapsible `
` block | + +### Embed mode + +Control whether SVGs are written as separate files or embedded directly in the markdown: + +```prisma +plugin documentation { + provider = 'zenstack-docs-plugin' + output = './docs/schema' + diagramFormat = 'svg' + diagramEmbed = 'inline' +} +``` + +| Value | Behavior | +|---|---| +| `"file"` | SVG written as companion files next to each `.md` page (default) | +| `"inline"` | Raw `` XML embedded directly in the markdown — fully self-contained, no separate files | + +The `diagramEmbed` option only takes effect when `diagramFormat` is `"svg"` or `"both"`. + +### Additional features + +- **Descriptive alt text** — image references use the entity name (e.g. `![User diagram]`) for accessibility and hover tooltips +- **Responsive wrapper** — all SVG outputs (file references and inline) are wrapped in `
` so large diagrams scroll horizontally instead of overflowing the page -When set to `"svg"` or `"both"`, the rendered SVG is embedded inline in each `.md` page — no separate files needed. The `erdTheme` option applies to all per-page SVGs as well as the standalone ERD. +The `erdTheme` option applies to all per-page SVGs as well as the standalone ERD. ## Enriching Your Documentation diff --git a/eslint.config.js b/eslint.config.js index 2b47b8a..03d0174 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,6 +37,12 @@ export default tseslint.config( }, }, { - ignores: ['dist/', 'preview-output/', 'zenstack/'], + ignores: [ + 'dist/', + 'preview-output/', + 'zenstack/', + 'release-please-config.json', + '.release-please-manifest.json', + ], }, ); diff --git a/package.json b/package.json index bac8509..ca6c44f 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,12 @@ "exports": { ".": { "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "default": "./dist/index.js", + "types": "./dist/index.d.ts" }, "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" + "default": "./dist/index.cjs", + "types": "./dist/index.d.cts" } }, "./package.json": "./package.json" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..40edb87 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node", + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "chore", "section": "Miscellaneous", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "refactor", "section": "Refactoring", "hidden": true } + ] + } + } +} diff --git a/src/generator.ts b/src/generator.ts index e053310..3ab7727 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -53,6 +53,7 @@ export async function generate(context: CliGeneratorContext): Promise { let filesGenerated = 0; const diagFmt = pluginOptions.diagramFormat ?? 'mermaid'; + const diagEmbed = pluginOptions.diagramEmbed ?? 'file'; const diagTheme = pluginOptions.erdTheme; const modelsDir = path.join(outputDir, 'models'); @@ -88,7 +89,13 @@ export async function generate(context: CliGeneratorContext): Promise { options, procedures, }); - await writePageWithDiagrams(mdPath, content, diagFmt, diagTheme); + await writePageWithDiagrams( + mdPath, + content, + diagFmt, + diagEmbed, + diagTheme, + ); filesGenerated++; } } @@ -108,7 +115,13 @@ export async function generate(context: CliGeneratorContext): Promise { options, view, }); - await writePageWithDiagrams(mdPath, content, diagFmt, diagTheme); + await writePageWithDiagrams( + mdPath, + content, + diagFmt, + diagEmbed, + diagTheme, + ); filesGenerated++; } } @@ -119,7 +132,7 @@ export async function generate(context: CliGeneratorContext): Promise { genCtx, relations: allRelations, }); - await writePageWithDiagrams(mdPath, content, diagFmt, diagTheme); + await writePageWithDiagrams(mdPath, content, diagFmt, diagEmbed, diagTheme); filesGenerated++; } @@ -142,7 +155,13 @@ export async function generate(context: CliGeneratorContext): Promise { options, typeDef, }); - await writePageWithDiagrams(mdPath, content, diagFmt, diagTheme); + await writePageWithDiagrams( + mdPath, + content, + diagFmt, + diagEmbed, + diagTheme, + ); filesGenerated++; } } @@ -164,7 +183,13 @@ export async function generate(context: CliGeneratorContext): Promise { navigation: enumNav.get(enumDecl.name), options, }); - await writePageWithDiagrams(mdPath, content, diagFmt, diagTheme); + await writePageWithDiagrams( + mdPath, + content, + diagFmt, + diagEmbed, + diagTheme, + ); filesGenerated++; } } @@ -186,24 +211,35 @@ export async function generate(context: CliGeneratorContext): Promise { options, proc, }); - await writePageWithDiagrams(mdPath, content, diagFmt, diagTheme); + await writePageWithDiagrams( + mdPath, + content, + diagFmt, + diagEmbed, + diagTheme, + ); filesGenerated++; } } if (pluginOptions.generateSkill) { - writeFile( - path.join(outputDir, 'SKILL.md'), - renderSkillPage({ - enums, - hasRelationships, - models, - procedures, - schema: context.model, - title: pluginOptions.title ?? 'Schema Documentation', - typeDefs, - views, - }), + const skillMdPath = path.join(outputDir, 'SKILL.md'); + const skillContent = renderSkillPage({ + enums, + hasRelationships, + models, + procedures, + relations: allRelations, + title: pluginOptions.title ?? 'Schema Documentation', + typeDefs, + views, + }); + await writePageWithDiagrams( + skillMdPath, + skillContent, + diagFmt, + diagEmbed, + diagTheme, ); filesGenerated++; } @@ -266,6 +302,11 @@ function resolveOutputDir(options: PluginOptions, defaultPath: string): string { */ function resolvePluginOptions(raw: Record): PluginOptions { return { + diagramEmbed: (['file', 'inline'] as const).includes( + raw['diagramEmbed'] as 'file' | 'inline', + ) + ? (raw['diagramEmbed'] as 'file' | 'inline') + : 'file', diagramFormat: (['mermaid', 'svg', 'both'] as const).includes( raw['diagramFormat'] as 'both' | 'mermaid' | 'svg', ) @@ -311,11 +352,18 @@ async function writePageWithDiagrams( filePath: string, content: string, diagramFormat: 'both' | 'mermaid' | 'svg', + embed: 'file' | 'inline', theme?: string, ): Promise { const baseName = path.basename(filePath, '.md'); const dir = path.dirname(filePath); - const result = await processDiagrams(content, baseName, diagramFormat, theme); + const result = await processDiagrams( + content, + baseName, + diagramFormat, + embed, + theme, + ); writeFile(filePath, result.markdown); for (const svg of result.svgFiles) { writeFile(path.join(dir, svg.filename), svg.content); diff --git a/src/renderers/diagram-processor.ts b/src/renderers/diagram-processor.ts index 2d9c1b1..98b211a 100644 --- a/src/renderers/diagram-processor.ts +++ b/src/renderers/diagram-processor.ts @@ -14,12 +14,14 @@ const MERMAID_BLOCK_RE = /```mermaid\n(.*?)```/gsu; /** * Extracts inline Mermaid code blocks from markdown, renders each to SVG, - * and replaces the blocks with image references to companion SVG files. + * and replaces the blocks with SVG image references (file or inline) wrapped + * in a responsive container. */ export async function processDiagrams( markdown: string, baseName: string, format: 'both' | 'mermaid' | 'svg', + embed: 'file' | 'inline', theme?: string, ): Promise { if (format === 'mermaid') { @@ -50,26 +52,57 @@ export async function processDiagrams( continue; } - svgFiles.push({ content: svg, filename: svgFilename }); + const altText = `${baseName} diagram`; - const imgRef = `![diagram](./${svgFilename})`; + if (embed === 'inline') { + const inlineSvg = wrapResponsive(svg); - if (format === 'svg') { - result = result.replace(fullMatch, imgRef); + if (format === 'svg') { + result = result.replace(fullMatch, inlineSvg); + } else { + const replacement = [ + inlineSvg, + '', + '
', + 'Mermaid source', + '', + fullMatch, + '', + '
', + ].join('\n'); + result = result.replace(fullMatch, replacement); + } } else { - const replacement = [ - imgRef, - '', - '
', - 'Mermaid source', - '', - fullMatch, - '', - '
', - ].join('\n'); - result = result.replace(fullMatch, replacement); + svgFiles.push({ content: svg, filename: svgFilename }); + const imgRef = wrapResponsive(`![${altText}](./${svgFilename})`); + + if (format === 'svg') { + result = result.replace(fullMatch, imgRef); + } else { + const replacement = [ + imgRef, + '', + '
', + 'Mermaid source', + '', + fullMatch, + '', + '
', + ].join('\n'); + result = result.replace(fullMatch, replacement); + } } } return { markdown: result, svgFiles }; } + +function wrapResponsive(inner: string): string { + return [ + '
', + '', + inner, + '', + '
', + ].join('\n'); +} diff --git a/src/renderers/skill-page.ts b/src/renderers/skill-page.ts index 71f1c9f..fd9137b 100644 --- a/src/renderers/skill-page.ts +++ b/src/renderers/skill-page.ts @@ -5,7 +5,12 @@ import { resolveTypeName, stripCommentPrefix, } from '../extractors'; -import { type SkillPageProps } from '../types'; +import { + type Relationship, + type RelationType, + type SkillPageProps, +} from '../types'; +import { relationDedupKey, relationToMermaid } from './erd'; import { type DataField, type DataModel, @@ -14,6 +19,7 @@ import { type Procedure, type TypeDef, } from '@zenstackhq/language/ast'; +import { getAllFields } from '@zenstackhq/language/utils'; type SkillCounts = { enums: number; @@ -36,6 +42,7 @@ export function renderSkillPage(props: SkillPageProps): string { hasRelationships, models, procedures, + relations, title, typeDefs, views, @@ -51,9 +58,12 @@ export function renderSkillPage(props: SkillPageProps): string { return [ ...renderFrontmatter(title), ...renderOverview(title, counts, models, views), + ...renderRelationshipMap(relations), + ...renderRelationshipsTable(relations), ...renderConventions(models, typeDefs), + ...renderPolicyMatrix(models), ...renderConstraints(models), - ...renderWorkflow(procedures, hasRelationships), + ...renderWorkflow(models, procedures, relations, hasRelationships), ...renderEntityReference(models, enums, typeDefs, views), ...renderFooter(hasRelationships), ].join('\n'); @@ -65,7 +75,7 @@ export function renderSkillPage(props: SkillPageProps): string { function detectComputedFields(models: DataModel[]): string[] { const computed: string[] = []; for (const m of models) { - for (const f of m.fields) { + for (const f of getAllFields(m, true)) { if (f.attributes.some((a) => getAttributeName(a) === '@computed')) { const desc = f.comments ? stripCommentPrefix(f.comments) : ''; const descPart = desc ? ` — ${desc}` : ''; @@ -83,7 +93,7 @@ function detectComputedFields(models: DataModel[]): string[] { function detectFKExamples(models: DataModel[]): string[] { const fks: string[] = []; for (const m of models) { - for (const f of m.fields) { + for (const f of getAllFields(m, true)) { if (!(f.type.reference?.ref && isDataModel(f.type.reference.ref))) { continue; } @@ -238,8 +248,6 @@ function formatCountSummary(counts: SkillCounts): string { return parts.join(', '); } -// --- Analysis helpers --- - /** * Returns true if any model's access policy references `auth()`. */ @@ -258,34 +266,6 @@ function hasAuthRules(models: DataModel[]): boolean { ); } -/** - * Summarizes a model's relation fields as human-readable "field → Target (cardinality)" lines. - */ -function modelRelationLines(model: DataModel): string[] { - const rels = model.fields.filter( - (f) => f.type.reference?.ref && isDataModel(f.type.reference.ref), - ); - if (rels.length === 0) { - return []; - } - - return rels - .map((f) => { - const ref = f.type.reference?.ref; - if (!ref) { - return ''; - } - - const card = f.type.array - ? 'has many' - : f.type.optional - ? 'optional' - : 'required'; - return `- ${f.name} → ${ref.name} (${card})`; - }) - .filter(Boolean); -} - /** * Returns a pluralized count string (e.g. "3 models", "1 view"). */ @@ -294,23 +274,16 @@ function plural(n: number, word: string): string { } /** - * Renders access policies and validation rules that agents must respect. + * Renders validation rules that agents must respect. */ function renderConstraints(models: DataModel[]): string[] { - const modelsWithPolicies = models.filter((m) => - m.attributes.some((a) => { - const name = a.decl.ref?.name; - return name === '@@allow' || name === '@@deny'; - }), - ); - const validationEntries: Array<{ field: string; model: string; rule: string; }> = []; for (const model of models) { - for (const field of model.fields) { + for (const field of getAllFields(model, true)) { for (const attribute of field.attributes) { const attributeDecl = attribute.decl.ref; if (!attributeDecl) { @@ -332,77 +305,34 @@ function renderConstraints(models: DataModel[]): string[] { } } - if (modelsWithPolicies.length === 0 && validationEntries.length === 0) { + if (validationEntries.length === 0) { return []; } const lines: string[] = []; - lines.push('## Constraints You Must Respect'); + lines.push('## Validation'); + lines.push(''); + lines.push( + 'These constraints are enforced at the schema level. When generating test data, seed scripts, or form inputs, produce values that satisfy them.', + ); lines.push(''); - if (modelsWithPolicies.length > 0) { - lines.push('### Access Policies'); - lines.push(''); - lines.push( - 'ZenStack enforces these rules at the ORM level. Your code does not need to re-implement them, but you must be aware of them when reasoning about what operations will succeed or fail.', - ); - if (hasAuthRules(models)) { - lines.push(''); - lines.push( - '> Some rules reference `auth()` — the currently authenticated user. Operations that require `auth()` will fail for unauthenticated requests.', - ); - } - - lines.push(''); - - for (const model of modelsWithPolicies.sort((a, b) => - a.name.localeCompare(b.name), - )) { - const rules: string[] = []; - for (const attribute of model.attributes) { - const name = attribute.decl.ref?.name; - if (name !== '@@allow' && name !== '@@deny') { - continue; - } - - const effect = name === '@@allow' ? 'allow' : 'deny'; - const args = attribute.args - .map((a) => a.$cstNode?.text ?? '') - .join(', '); - rules.push(`${effect}(${args})`); - } - - lines.push(`**${model.name}**: ${rules.join(' · ')}`); - lines.push(''); - } + const byModel = new Map>(); + for (const entry of validationEntries) { + const list = byModel.get(entry.model) ?? []; + list.push({ field: entry.field, rule: entry.rule }); + byModel.set(entry.model, list); } - if (validationEntries.length > 0) { - lines.push('### Validation'); - lines.push(''); - lines.push( - 'These constraints are enforced at the schema level. When generating test data, seed scripts, or form inputs, produce values that satisfy them.', - ); - lines.push(''); - - const byModel = new Map>(); - for (const entry of validationEntries) { - const list = byModel.get(entry.model) ?? []; - list.push({ field: entry.field, rule: entry.rule }); - byModel.set(entry.model, list); - } - - for (const modelName of [...byModel.keys()].sort()) { - const rules = byModel - .get(modelName)! - .map((r) => `${r.field}: ${r.rule}`) - .join(', '); - lines.push(`- **${modelName}**: ${rules}`); - } - - lines.push(''); + for (const modelName of [...byModel.keys()].sort()) { + const rules = byModel + .get(modelName)! + .map((r) => `${r.field}: ${r.rule}`) + .join(', '); + lines.push(`- **${modelName}**: ${rules}`); } + lines.push(''); return lines; } @@ -456,8 +386,6 @@ function renderConventions(models: DataModel[], typeDefs: TypeDef[]): string[] { return lines; } -// --- Instructional sections --- - /** * Renders the full entity reference with Prisma declaration blocks and doc page links. */ @@ -485,16 +413,7 @@ function renderEntityReference( lines.push(...renderModelDeclaration(model, 'model')); lines.push('```'); lines.push(''); - - const rels = modelRelationLines(model); - if (rels.length > 0) { - lines.push('Relationships:'); - for (const r of rels) { - lines.push(r); - } - - lines.push(''); - } + lines.push(...renderFieldSummary(model)); lines.push(`[${model.name} (Model)](./models/${model.name}.md)`); lines.push(''); @@ -579,6 +498,81 @@ function renderEnumDeclaration(e: Enum): string[] { return lines; } +/** + * Renders a compact field summary listing required, optional, auto-generated, and unique fields. + */ +function renderFieldSummary(model: DataModel): string[] { + const allFields = getAllFields(model, true); + const required: string[] = []; + const optional: string[] = []; + const autoGenerated: string[] = []; + const unique: string[] = []; + + for (const field of allFields) { + if (field.type.reference?.ref && isDataModel(field.type.reference.ref)) { + continue; + } + + const typeName = resolveTypeName(field.type); + const hasDefault = field.attributes.some( + (a) => getAttributeName(a) === '@default', + ); + const hasUpdatedAt = field.attributes.some( + (a) => getAttributeName(a) === '@updatedAt', + ); + const hasComputed = field.attributes.some( + (a) => getAttributeName(a) === '@computed', + ); + const hasId = field.attributes.some((a) => getAttributeName(a) === '@id'); + const hasUnique = field.attributes.some( + (a) => getAttributeName(a) === '@unique', + ); + + if (hasDefault || hasUpdatedAt || hasComputed) { + const defaultAttribute = field.attributes.find( + (a) => getAttributeName(a) === '@default', + ); + const annotation = hasComputed + ? '@computed' + : hasUpdatedAt + ? '@updatedAt' + : `@default(${defaultAttribute?.args[0]?.$cstNode?.text ?? ''})`; + autoGenerated.push(`\`${field.name}\` (${annotation})`); + } else if (field.type.optional) { + optional.push(`\`${field.name}\` (${typeName}?)`); + } else if (!hasId) { + required.push(`\`${field.name}\` (${typeName})`); + } + + if (hasUnique) { + unique.push(`\`${field.name}\``); + } + } + + const lines: string[] = []; + if (required.length > 0) { + lines.push(`Required fields: ${required.join(', ')}`); + } + + if (optional.length > 0) { + lines.push(`Optional fields: ${optional.join(', ')}`); + } + + if (autoGenerated.length > 0) { + lines.push(`Auto-generated: ${autoGenerated.join(', ')}`); + } + + if (unique.length > 0) { + lines.push(`Unique constraints: ${unique.join(', ')}`); + } + + if (lines.length > 0) { + lines.push(''); + } + + return lines; +} + /** * Renders the footer with links to the full index and relationships pages. */ @@ -618,6 +612,32 @@ function renderFrontmatter(title: string): string[] { ]; } +/** + * Generates Prisma-style include patterns from One→Many and Many→Many relationships. + */ +function renderIncludePatterns(relations: Relationship[]): string[] { + const patterns: string[] = []; + const seen = new Set(); + + for (const rel of relations) { + if (rel.type !== 'One\u2192Many' && rel.type !== 'Many\u2192Many') { + continue; + } + + const key = `${rel.from}.${rel.field}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + patterns.push( + `- \`${rel.from}\` with \`${rel.to}\`: \`include: { ${rel.field}: true }\``, + ); + } + + return patterns.slice(0, 8); +} + /** * Renders a complete model/view declaration block with comments, fields, and attributes. */ @@ -642,10 +662,12 @@ function renderModelDeclaration( : ''; lines.push(`${keyword} ${model.name}${mixinPart} {`); - for (const field of model.fields) { + for (const field of getAllFields(model, true)) { const fieldDesc = stripCommentPrefix(field.comments); if (fieldDesc) { - lines.push(` /// ${fieldDesc}`); + for (const commentLine of fieldDesc.split('\n')) { + lines.push(` /// ${commentLine}`); + } } lines.push(fieldDeclarationLine(field)); @@ -665,8 +687,6 @@ function renderModelDeclaration( return lines; } -// --- Entity Reference --- - /** * Renders the schema overview section with entity counts and a categorized entity list. */ @@ -706,6 +726,185 @@ function renderOverview( return lines; } +/** + * Renders an access policy matrix table (Model x Operation). + */ +function renderPolicyMatrix(models: DataModel[]): string[] { + const modelsWithPolicies = models.filter((m) => + m.attributes.some((a) => { + const name = a.decl.ref?.name; + return name === '@@allow' || name === '@@deny'; + }), + ); + + if (modelsWithPolicies.length === 0) { + return []; + } + + const operations = ['create', 'read', 'update', 'delete', 'all']; + + const lines: string[] = [ + '## Access Policies', + '', + 'ZenStack enforces these rules at the ORM level. Your code does not need to re-implement them, but you must be aware of them when reasoning about what operations will succeed or fail.', + '', + ]; + + if (hasAuthRules(models)) { + lines.push( + '> Some rules reference `auth()` — the currently authenticated user. Operations that require `auth()` will fail for unauthenticated requests.', + ); + lines.push(''); + } + + lines.push( + '| Model | Operation | Rule | Effect |', + '| ----- | --------- | ---- | ------ |', + ); + + for (const model of modelsWithPolicies.sort((a, b) => + a.name.localeCompare(b.name), + )) { + for (const attribute of model.attributes) { + const name = attribute.decl.ref?.name; + if (name !== '@@allow' && name !== '@@deny') { + continue; + } + + const effect = name === '@@allow' ? 'allow' : 'deny'; + const argTexts = attribute.args.map((a) => a.$cstNode?.text ?? ''); + const operation = argTexts[0]?.replaceAll(/['"]/gu, '') ?? 'all'; + const condition = argTexts[1] ?? 'true'; + + const matchedOps = + operation === 'all' + ? operations.filter((o) => o !== 'all') + : [operation]; + + for (const op of matchedOps) { + lines.push(`| ${model.name} | ${op} | ${condition} | ${effect} |`); + } + } + } + + lines.push(''); + return lines; +} + +/** + * Renders a compact Mermaid ERD showing only entity names and relationship connectors. + */ +function renderRelationshipMap(relations: Relationship[]): string[] { + if (relations.length === 0) { + return []; + } + + const seen = new Set(); + const connectorLines: string[] = []; + for (const rel of relations) { + const key = relationDedupKey(rel); + if (seen.has(key)) { + continue; + } + + seen.add(key); + connectorLines.push(relationToMermaid(rel)); + } + + if (connectorLines.length === 0) { + return []; + } + + return [ + '## Relationship Map', + '', + '```mermaid', + 'erDiagram', + ...connectorLines, + '```', + '', + ]; +} + +/** + * Renders a consolidated relationships quick-reference table. + */ +function renderRelationshipsTable(relations: Relationship[]): string[] { + if (relations.length === 0) { + return []; + } + + const lines: string[] = [ + '## Relationships', + '', + '| From | Field | To | Cardinality |', + '| ---- | ----- | -- | ----------- |', + ]; + + const labelMap: Record = { + 'Many\u2192Many': 'Many-to-Many', + 'Many\u2192One': 'Many-to-One', + 'Many\u2192One?': 'Many-to-One (optional)', + 'One\u2192Many': 'One-to-Many', + 'One\u2192One': 'One-to-One', + 'One\u2192One?': 'One-to-One (optional)', + }; + + for (const rel of relations) { + lines.push( + `| ${rel.from} | ${rel.field} | ${rel.to} | ${labelMap[rel.type]} |`, + ); + } + + lines.push(''); + return lines; +} + +/** + * Lists the minimum required fields (non-optional, no-default, non-relation) per model. + */ +function renderRequiredFieldsPerModel(models: DataModel[]): string[] { + const lines: string[] = []; + for (const model of [...models].sort((a, b) => + a.name.localeCompare(b.name), + )) { + const allFields = getAllFields(model, true); + const required = allFields.filter((f) => { + if (f.type.optional || f.type.array) { + return false; + } + + if (f.type.reference?.ref && isDataModel(f.type.reference.ref)) { + return false; + } + + if ( + f.attributes.some((a) => { + const name = getAttributeName(a); + return ( + name === '@default' || name === '@updatedAt' || name === '@computed' + ); + }) + ) { + return false; + } + + return true; + }); + + if (required.length === 0) { + continue; + } + + const fieldList = required + .map((f) => `\`${f.name}\` (${resolveTypeName(f.type)})`) + .join(', '); + lines.push(`- **${model.name}**: ${fieldList}`); + } + + return lines; +} + /** * Renders a type definition declaration block with fields and doc comments. */ @@ -736,7 +935,9 @@ function renderTypeDeclaration(td: TypeDef): string[] { * Renders step-by-step guidance for writing queries, calling procedures, and generating test data. */ function renderWorkflow( + models: DataModel[], procedures: Procedure[], + relations: Relationship[], hasRelationships: boolean, ): string[] { const lines: string[] = []; @@ -756,6 +957,30 @@ function renderWorkflow( lines.push('5. For full field details, follow the entity documentation link'); lines.push(''); + const includePatterns = renderIncludePatterns(relations); + if (includePatterns.length > 0) { + lines.push('**Common include patterns:**'); + lines.push(''); + for (const pattern of includePatterns) { + lines.push(pattern); + } + + lines.push(''); + } + + const requiredFieldsSection = renderRequiredFieldsPerModel(models); + if (requiredFieldsSection.length > 0) { + lines.push('### Creating records'); + lines.push(''); + lines.push('Minimum required fields per model:'); + lines.push(''); + for (const line of requiredFieldsSection) { + lines.push(line); + } + + lines.push(''); + } + if (procedures.length > 0) { lines.push('### Calling procedures'); lines.push(''); @@ -814,6 +1039,14 @@ function renderWorkflow( lines.push('- Fields with `@computed` cannot be set — they are derived'); lines.push(''); + const creationOrder = topologicalSort(models); + if (creationOrder.length > 1) { + lines.push( + `**Creation order** (respects FK dependencies): ${creationOrder.map((n) => `\`${n}\``).join(' → ')}`, + ); + lines.push(''); + } + if (hasRelationships) { lines.push('### Understanding relationships'); lines.push(''); @@ -825,3 +1058,59 @@ function renderWorkflow( return lines; } + +/** + * Topologically sorts models by FK dependencies so dependent models come after their parents. + */ +function topologicalSort(models: DataModel[]): string[] { + const modelNames = new Set(models.map((m) => m.name)); + const deps = new Map>(); + for (const m of models) { + deps.set(m.name, new Set()); + } + + for (const model of models) { + for (const field of getAllFields(model, true)) { + if ( + field.type.reference?.ref && + isDataModel(field.type.reference.ref) && + !field.type.array && + !field.type.optional + ) { + const target = field.type.reference.ref.name; + if (modelNames.has(target) && target !== model.name) { + deps.get(model.name)!.add(target); + } + } + } + } + + const sorted: string[] = []; + const visited = new Set(); + const visiting = new Set(); + + function visit(name: string): void { + if (visited.has(name)) { + return; + } + + if (visiting.has(name)) { + return; + } + + visiting.add(name); + for (const dep of deps.get(name) ?? []) { + visit(dep); + } + + visiting.delete(name); + visited.add(name); + sorted.push(name); + } + + for (const name of [...modelNames].sort()) { + visit(name); + } + + return sorted; +} diff --git a/src/types.ts b/src/types.ts index af2f1f1..bbf4fbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,6 +69,12 @@ export type NavLink = { * User-facing plugin options from the ZModel plugin block. */ export type PluginOptions = { + /** + * Whether SVG diagrams are written as companion files (`'file'`) or embedded + * inline in the markdown (`'inline'`). Only applies when `diagramFormat` is + * `'svg'` or `'both'`. + */ + diagramEmbed?: 'file' | 'inline'; diagramFormat?: 'both' | 'mermaid' | 'svg'; erdFormat?: 'both' | 'mmd' | 'svg'; erdTheme?: string; @@ -146,7 +152,7 @@ export type SkillPageProps = { hasRelationships: boolean; models: DataModel[]; procedures: Procedure[]; - schema: Model; + relations: Relationship[]; title: string; typeDefs: TypeDef[]; views: DataModel[]; diff --git a/test/generator/diagram-svg.test.ts b/test/generator/diagram-svg.test.ts index e542f35..e161b64 100644 --- a/test/generator/diagram-svg.test.ts +++ b/test/generator/diagram-svg.test.ts @@ -3,54 +3,67 @@ import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; +const TWO_MODEL_SCHEMA = ` + model User { + id String @id @default(cuid()) + name String + posts Post[] + } + model Post { + id String @id @default(cuid()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId String + } +`; + describe('documentation plugin: per-page SVG diagrams', () => { it('model page references companion SVG file when diagramFormat is svg', async () => { - const tmpDir = await generateFromSchema( - ` - model User { - id String @id @default(cuid()) - name String - posts Post[] - } - model Post { - id String @id @default(cuid()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId String - } - `, - { diagramFormat: 'svg' }, - ); + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramFormat: 'svg', + }); const userDocument = readDocument(tmpDir, 'models', 'User.md'); expect(userDocument).not.toContain('```mermaid'); - expect(userDocument).toContain('![diagram](./User-diagram.svg)'); + expect(userDocument).toContain('![User diagram](./User-diagram.svg)'); const svgPath = path.join(tmpDir, 'models', 'User-diagram.svg'); expect(fs.existsSync(svgPath)).toBe(true); expect(fs.readFileSync(svgPath, 'utf8')).toContain(' { - const tmpDir = await generateFromSchema( - ` - model User { - id String @id @default(cuid()) - name String - posts Post[] - } - model Post { - id String @id @default(cuid()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId String - } - `, - { diagramFormat: 'both' }, + it('uses descriptive alt text based on entity name', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramFormat: 'svg', + }); + + const userDocument = readDocument(tmpDir, 'models', 'User.md'); + expect(userDocument).toContain('![User diagram]'); + expect(userDocument).not.toContain('![diagram]'); + + const postDocument = readDocument(tmpDir, 'models', 'Post.md'); + expect(postDocument).toContain('![Post diagram]'); + }); + + it('wraps diagram output in responsive container div', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramFormat: 'svg', + }); + + const userDocument = readDocument(tmpDir, 'models', 'User.md'); + expect(userDocument).toContain( + '
', ); + expect(userDocument).toContain('
'); + }); + + it('model page has SVG image and collapsible mermaid when diagramFormat is both', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramFormat: 'both', + }); const userDocument = readDocument(tmpDir, 'models', 'User.md'); - expect(userDocument).toContain('![diagram](./User-diagram.svg)'); + expect(userDocument).toContain('![User diagram](./User-diagram.svg)'); expect(userDocument).toContain('```mermaid'); expect(userDocument).toContain('
'); expect(userDocument).toContain('Mermaid source'); @@ -61,25 +74,11 @@ describe('documentation plugin: per-page SVG diagrams', () => { }); it('default behavior preserves inline mermaid with no SVG files', async () => { - const tmpDir = await generateFromSchema( - ` - model User { - id String @id @default(cuid()) - name String - posts Post[] - } - model Post { - id String @id @default(cuid()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId String - } - `, - ); + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA); const userDocument = readDocument(tmpDir, 'models', 'User.md'); expect(userDocument).toContain('```mermaid'); - expect(userDocument).not.toContain('![diagram]'); + expect(userDocument).not.toContain('![User diagram]'); expect(fs.existsSync(path.join(tmpDir, 'models', 'User-diagram.svg'))).toBe( false, ); @@ -99,7 +98,7 @@ describe('documentation plugin: per-page SVG diagrams', () => { const enumDocument = readDocument(tmpDir, 'enums', 'Role.md'); expect(enumDocument).not.toContain('```mermaid'); - expect(enumDocument).toContain('![diagram](./Role-diagram.svg)'); + expect(enumDocument).toContain('![Role diagram](./Role-diagram.svg)'); expect(fs.existsSync(path.join(tmpDir, 'enums', 'Role-diagram.svg'))).toBe( true, ); @@ -119,51 +118,32 @@ describe('documentation plugin: per-page SVG diagrams', () => { const procDocument = readDocument(tmpDir, 'procedures', 'getUser.md'); expect(procDocument).not.toContain('```mermaid'); - expect(procDocument).toContain('![diagram](./getUser-diagram.svg)'); + expect(procDocument).toContain('![getUser diagram](./getUser-diagram.svg)'); expect( fs.existsSync(path.join(tmpDir, 'procedures', 'getUser-diagram.svg')), ).toBe(true); }); it('relationships page gets companion SVG when diagramFormat is svg', async () => { - const tmpDir = await generateFromSchema( - ` - model User { - id String @id @default(cuid()) - posts Post[] - } - model Post { - id String @id @default(cuid()) - author User @relation(fields: [authorId], references: [id]) - authorId String - } - `, - { diagramFormat: 'svg' }, - ); + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramFormat: 'svg', + }); const relDocument = readDocument(tmpDir, 'relationships.md'); expect(relDocument).not.toContain('```mermaid'); - expect(relDocument).toContain('![diagram](./relationships-diagram.svg)'); + expect(relDocument).toContain( + '![relationships diagram](./relationships-diagram.svg)', + ); expect(fs.existsSync(path.join(tmpDir, 'relationships-diagram.svg'))).toBe( true, ); }); it('erdTheme applies to companion SVG files', async () => { - const tmpDir = await generateFromSchema( - ` - model User { - id String @id @default(cuid()) - posts Post[] - } - model Post { - id String @id @default(cuid()) - author User @relation(fields: [authorId], references: [id]) - authorId String - } - `, - { diagramFormat: 'svg', erdTheme: 'dracula' }, - ); + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramFormat: 'svg', + erdTheme: 'dracula', + }); const svgPath = path.join(tmpDir, 'models', 'User-diagram.svg'); expect(fs.existsSync(svgPath)).toBe(true); @@ -186,9 +166,94 @@ describe('documentation plugin: per-page SVG diagrams', () => { const viewDocument = readDocument(tmpDir, 'views', 'UserProfile.md'); expect(viewDocument).not.toContain('```mermaid'); - expect(viewDocument).toContain('![diagram](./UserProfile-diagram.svg)'); + expect(viewDocument).toContain( + '![UserProfile diagram](./UserProfile-diagram.svg)', + ); expect( fs.existsSync(path.join(tmpDir, 'views', 'UserProfile-diagram.svg')), ).toBe(true); }); }); + +describe('documentation plugin: inline SVG embedding', () => { + it('embeds SVG content directly in markdown when diagramEmbed is inline', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramEmbed: 'inline', + diagramFormat: 'svg', + }); + + const userDocument = readDocument(tmpDir, 'models', 'User.md'); + expect(userDocument).not.toContain('```mermaid'); + expect(userDocument).toContain(''); + expect(userDocument).not.toContain('![User diagram]'); + + expect(fs.existsSync(path.join(tmpDir, 'models', 'User-diagram.svg'))).toBe( + false, + ); + }); + + it('wraps inline SVG in responsive container', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramEmbed: 'inline', + diagramFormat: 'svg', + }); + + const userDocument = readDocument(tmpDir, 'models', 'User.md'); + expect(userDocument).toContain( + '
', + ); + }); + + it('inline mode with both format includes SVG and collapsible mermaid source', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramEmbed: 'inline', + diagramFormat: 'both', + }); + + const userDocument = readDocument(tmpDir, 'models', 'User.md'); + expect(userDocument).toContain(''); + expect(userDocument).toContain('Mermaid source'); + + expect(fs.existsSync(path.join(tmpDir, 'models', 'User-diagram.svg'))).toBe( + false, + ); + }); + + it('inline mode does not produce companion SVG files', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramEmbed: 'inline', + diagramFormat: 'svg', + }); + + const modelsDir = path.join(tmpDir, 'models'); + const files = fs.readdirSync(modelsDir); + const svgFiles = files.filter((f) => f.endsWith('.svg')); + expect(svgFiles).toHaveLength(0); + }); + + it('diagramEmbed is ignored when diagramFormat is mermaid', async () => { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramEmbed: 'inline', + diagramFormat: 'mermaid', + }); + + const userDocument = readDocument(tmpDir, 'models', 'User.md'); + expect(userDocument).toContain('```mermaid'); + expect(userDocument).not.toContain(' { + const tmpDir = await generateFromSchema(TWO_MODEL_SCHEMA, { + diagramFormat: 'svg', + }); + + const userDocument = readDocument(tmpDir, 'models', 'User.md'); + expect(userDocument).toContain('![User diagram](./User-diagram.svg)'); + expect(fs.existsSync(path.join(tmpDir, 'models', 'User-diagram.svg'))).toBe( + true, + ); + }); +}); diff --git a/test/generator/skill-page.test.ts b/test/generator/skill-page.test.ts index a9fb4fd..6520024 100644 --- a/test/generator/skill-page.test.ts +++ b/test/generator/skill-page.test.ts @@ -25,7 +25,7 @@ describe('documentation plugin: SKILL.md', () => { skill = readDocument(tmpDir, 'SKILL.md'); }); - it('contains YAML frontmatter, workflow guidance, and omits Constraints when absent', () => { + it('contains YAML frontmatter, workflow guidance, and omits policies/validation when absent', () => { expect(skill).toMatch(/^---\n/u); expect(skill).toContain('name:'); expect(skill).toContain('description:'); @@ -36,7 +36,8 @@ describe('documentation plugin: SKILL.md', () => { expect(skill).toContain('### Writing queries or mutations'); expect(skill).toContain('### Generating test data'); - expect(skill).not.toContain('## Constraints You Must Respect'); + expect(skill).not.toContain('## Access Policies'); + expect(skill).not.toContain('## Validation'); }); }); @@ -100,6 +101,40 @@ describe('documentation plugin: SKILL.md', () => { expect(skill).toContain('`authorId`'); }); + + it('includes relationship map with Mermaid ERD', () => { + expect(skill).toContain('## Relationship Map'); + expect(skill).toContain('```mermaid'); + expect(skill).toContain('erDiagram'); + }); + + it('includes relationships table with cardinality', () => { + expect(skill).toContain('## Relationships'); + expect(skill).toContain('| From | Field | To | Cardinality |'); + expect(skill).toContain('User'); + expect(skill).toContain('Post'); + }); + + it('includes field summaries after prisma blocks', () => { + const userParts = skill.split('#### User'); + const userSection = userParts[1]!.split('####')[0]!; + expect(userSection).toContain('Required fields:'); + expect(userSection).toContain('Auto-generated:'); + }); + + it('includes common include patterns in workflow', () => { + expect(skill).toContain('**Common include patterns:**'); + expect(skill).toContain('include:'); + }); + + it('includes creation order in test data section', () => { + expect(skill).toContain('**Creation order**'); + }); + + it('includes required fields per model', () => { + expect(skill).toContain('### Creating records'); + expect(skill).toContain('Minimum required fields per model:'); + }); }); it('lists ALL entities in overview, not capped', async () => { @@ -114,8 +149,8 @@ describe('documentation plugin: SKILL.md', () => { expect(skill).not.toContain('...and'); }); - describe('constraints', () => { - it('includes access policies and notes auth() dependency', async () => { + describe('access policies', () => { + it('renders policy matrix table and notes auth() dependency', async () => { const tmpDir = await generateFromSchema( ` model User { @@ -130,16 +165,19 @@ describe('documentation plugin: SKILL.md', () => { ); const skill = readDocument(tmpDir, 'SKILL.md'); - expect(skill).toContain('## Constraints You Must Respect'); - expect(skill).toContain('### Access Policies'); - expect(skill).toContain('**User**'); - expect(skill).toContain("allow('read', true)"); - expect(skill).toContain("deny('delete'"); + expect(skill).toContain('## Access Policies'); + expect(skill).toContain('| Model | Operation | Rule | Effect |'); + expect(skill).toContain('| User |'); + expect(skill).toContain('| read |'); + expect(skill).toContain('| allow |'); + expect(skill).toContain('| deny |'); expect(skill).toContain('auth()'); expect(skill).toContain('unauthenticated'); }); + }); - it('includes validation with instructional context', async () => { + describe('validation', () => { + it('renders validation section with instructional context', async () => { const tmpDir = await generateFromSchema( ` model User { @@ -153,7 +191,7 @@ describe('documentation plugin: SKILL.md', () => { ); const skill = readDocument(tmpDir, 'SKILL.md'); - expect(skill).toContain('### Validation'); + expect(skill).toContain('## Validation'); expect(skill).toContain('email: @email'); expect(skill).toContain('name: @length'); expect(skill).toContain('bio: @contains'); @@ -218,6 +256,13 @@ describe('documentation plugin: SKILL.md', () => { expect(skill).toContain('@@index([email])'); }); + it('includes mixin-inherited fields in model declarations', () => { + const userParts = skill.split('#### User'); + const userSection = userParts[1]!.split('####')[0]!; + expect(userSection).toContain('createdAt DateTime @default(now())'); + expect(userSection).toContain('updatedAt DateTime @updatedAt'); + }); + it('renders enums, types, and views as full prisma declaration blocks', () => { expect(skill).toContain('### Enums'); expect(skill).toContain('#### Role'); @@ -238,15 +283,13 @@ describe('documentation plugin: SKILL.md', () => { expect(skill).toContain('view UserReport {'); }); - it('includes relationships under model declaration', () => { + it('includes relationships in consolidated table instead of per-model', () => { + expect(skill).toContain('## Relationships'); + expect(skill).toContain('| From | Field | To | Cardinality |'); + const userParts = skill.split('#### User'); const userSection = userParts[1]!.split('####')[0]!; - expect(userSection).toContain('Relationships:'); - expect(userSection).toContain('posts → Post (has many)'); - - const postParts = skill.split('#### Post'); - const postSection = postParts[1]!.split('####')[0]!; - expect(postSection).toContain('author → User (required)'); + expect(userSection).not.toContain('Relationships:'); }); it('uses entity name and type in link text', () => { @@ -304,4 +347,103 @@ describe('documentation plugin: SKILL.md', () => { ); expect(readDocument(tmpDir, 'SKILL.md')).toContain('Acme Platform'); }); + + describe('topological sort for creation order', () => { + it('orders models respecting FK dependencies', async () => { + const tmpDir = await generateFromSchema( + ` + model Organization { + id String @id @default(cuid()) + users User[] + } + model User { + id String @id @default(cuid()) + org Organization @relation(fields: [orgId], references: [id]) + orgId String + posts Post[] + } + model Post { + id String @id @default(cuid()) + author User @relation(fields: [authorId], references: [id]) + authorId String + } + `, + { generateSkill: true }, + ); + const skill = readDocument(tmpDir, 'SKILL.md'); + + expect(skill).toContain('**Creation order**'); + const orderMatch = skill.match(/\*\*Creation order\*\*[^:]*:\s*(.+)/u); + expect(orderMatch).not.toBeNull(); + const order = orderMatch![1]!; + const orgIdx = order.indexOf('Organization'); + const userIdx = order.indexOf('User'); + const postIdx = order.indexOf('Post'); + expect(orgIdx).toBeLessThan(userIdx); + expect(userIdx).toBeLessThan(postIdx); + }); + }); + + describe('SVG diagram pipeline', () => { + it('routes SKILL.md through writePageWithDiagrams when diagramFormat is both', async () => { + const tmpDir = await generateFromSchema( + ` + model User { + id String @id @default(cuid()) + posts Post[] + } + model Post { + id String @id @default(cuid()) + author User @relation(fields: [authorId], references: [id]) + authorId String + } + `, + { diagramFormat: 'both', generateSkill: true }, + ); + const skill = readDocument(tmpDir, 'SKILL.md'); + expect(skill).toContain('![SKILL diagram]'); + expect(skill).toContain('
'); + expect(skill).toContain('Mermaid source'); + }); + + it('replaces mermaid with SVG reference when diagramFormat is svg', async () => { + const tmpDir = await generateFromSchema( + ` + model User { + id String @id @default(cuid()) + posts Post[] + } + model Post { + id String @id @default(cuid()) + author User @relation(fields: [authorId], references: [id]) + authorId String + } + `, + { diagramFormat: 'svg', generateSkill: true }, + ); + const skill = readDocument(tmpDir, 'SKILL.md'); + expect(skill).toContain('![SKILL diagram]'); + expect(skill).not.toContain('```mermaid'); + }); + + it('keeps mermaid blocks when diagramFormat is mermaid (default)', async () => { + const tmpDir = await generateFromSchema( + ` + model User { + id String @id @default(cuid()) + posts Post[] + } + model Post { + id String @id @default(cuid()) + author User @relation(fields: [authorId], references: [id]) + authorId String + } + `, + { generateSkill: true }, + ); + const skill = readDocument(tmpDir, 'SKILL.md'); + expect(skill).toContain('```mermaid'); + expect(skill).not.toContain('![SKILL diagram]'); + }); + }); }); diff --git a/test/integration/showcase.test.ts b/test/integration/showcase.test.ts index e74604d..27a816a 100644 --- a/test/integration/showcase.test.ts +++ b/test/integration/showcase.test.ts @@ -547,11 +547,13 @@ describe('integration: showcase schema', () => { expect(skill).toContain('Timestamps'); expect(skill).toContain('**Computed fields**'); - // Constraints - expect(skill).toContain('## Constraints You Must Respect'); - expect(skill).toContain('### Access Policies'); - expect(skill).toContain("allow('read', true)"); - expect(skill).toContain('### Validation'); + // Access Policies (matrix table) + expect(skill).toContain('## Access Policies'); + expect(skill).toContain('| Model | Operation | Rule | Effect |'); + expect(skill).toContain('| allow |'); + + // Validation + expect(skill).toContain('## Validation'); expect(skill).toContain('@email'); expect(skill).toContain('@length'); @@ -585,8 +587,9 @@ describe('integration: showcase schema', () => { expect(skill).toContain('#### UserProfile'); expect(skill).toContain('view UserProfile {'); - // Relationships inline under models - expect(skill).toContain('Relationships:'); + // Relationships table (consolidated, not per-model) + expect(skill).toContain('## Relationships'); + expect(skill).toContain('| From | Field | To | Cardinality |'); // Links use entity name and type, not "Full documentation" expect(skill).toContain('[User (Model)](./models/User.md)'); From 3b4baabf3938331e311d23810b88d442b30b9c03 Mon Sep 17 00:00:00 2001 From: James Sheldon Date: Sun, 8 Mar 2026 11:58:20 -0600 Subject: [PATCH 2/2] fix: reorder exports conditions so types precedes default Suppress jsonc/sort-keys for package.json to prevent the lint rule from re-alphabetizing the exports map and breaking TypeScript resolution. --- eslint.config.js | 7 +++++++ package.json | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 03d0174..183e27d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -36,6 +36,13 @@ export default tseslint.config( 'unicorn/prevent-abbreviations': 'off', }, }, + { + files: ['package.json'], + rules: { + // types must precede default in exports conditions for TypeScript resolution + 'jsonc/sort-keys': 'off', + }, + }, { ignores: [ 'dist/', diff --git a/package.json b/package.json index ca6c44f..bac8509 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,12 @@ "exports": { ".": { "import": { - "default": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, "require": { - "default": "./dist/index.cjs", - "types": "./dist/index.d.cts" + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" } }, "./package.json": "./package.json"