diff --git a/nx.json b/nx.json index 7e7265dfe..1c0fcda9a 100644 --- a/nx.json +++ b/nx.json @@ -345,7 +345,7 @@ "releaseTagPattern": "v{version}" }, "plugins": [ - "./tools/zod2md-jsdocs/src/nx-plugin.ts", + "./tools/zod2md-jsdocs/src/lib/plugin/nx-plugin.ts", { "plugin": "@push-based/nx-verdaccio", "options": { diff --git a/packages/models/project.json b/packages/models/project.json index ed87fe9fa..b91756b64 100644 --- a/packages/models/project.json +++ b/packages/models/project.json @@ -3,14 +3,10 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/models/src", "projectType": "library", + "implicitDependencies": ["zod2md-jsdocs"], "targets": { "build": { - "dependsOn": [ - "^build", - "generate-docs", - { "projects": "zod2md-jsdocs", "target": "build" }, - { "projects": "zod2md-jsdocs", "target": "ts-patch" } - ] + "dependsOn": ["^build", "generate-docs", "ts-patch"] }, "lint": {}, "unit-test": {}, diff --git a/testing/test-nx-utils/src/lib/utils/nx.ts b/testing/test-nx-utils/src/lib/utils/nx.ts index 97d565ccf..992fb1370 100644 --- a/testing/test-nx-utils/src/lib/utils/nx.ts +++ b/testing/test-nx-utils/src/lib/utils/nx.ts @@ -11,7 +11,7 @@ import { libraryGenerator } from '@nx/js'; import type { LibraryGeneratorSchema } from '@nx/js/src/generators/library/schema'; import path from 'node:path'; import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace'; -import { executeProcess } from '@code-pushup/utils'; +import { executeProcess } from '@code-pushup/test-utils'; export function executorContext< T extends { projectName: string; cwd?: string }, @@ -95,11 +95,28 @@ export async function nxShowProjectJson( cwd: string, project: string, ) { - const { code, stderr, stdout } = await executeProcess({ - command: 'npx', - args: ['nx', 'show', `project --json ${project}`], - cwd, - }); + try { + const { stderr, stdout } = await executeProcess({ + command: 'npx', + args: ['nx', 'show', 'project', '--json', project], + cwd, + }); - return { code, stderr, projectJson: JSON.parse(stdout) as T }; + return { + code: 0, + stderr, + projectJson: JSON.parse(stdout) as T, + }; + } catch (error: unknown) { + const execError = error as { + code?: number; + stderr?: string; + stdout?: string; + }; + return { + code: execError.code ?? 1, + stderr: execError.stderr ?? String(error), + projectJson: JSON.parse(execError.stdout ?? '{}') as T, + }; + } } diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index 2b34c05b6..8a6d14377 100644 --- a/testing/test-utils/src/index.ts +++ b/testing/test-utils/src/index.ts @@ -10,3 +10,4 @@ export * from './lib/utils/file-system.js'; export * from './lib/utils/create-npm-workshpace.js'; export * from './lib/utils/project-graph.js'; export * from './lib/utils/test-folder-setup.js'; +export * from './lib/utils/execute-process.js'; diff --git a/testing/test-utils/src/lib/utils/execute-process.ts b/testing/test-utils/src/lib/utils/execute-process.ts new file mode 100644 index 000000000..300e6c216 --- /dev/null +++ b/testing/test-utils/src/lib/utils/execute-process.ts @@ -0,0 +1,12 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +export async function executeProcess(cfg: { + command: string; + args: string[]; + cwd: string; +}) { + const execFileAsync = promisify(execFile); + const { command, args, cwd } = cfg; + return await execFileAsync(command, args, { cwd }); +} diff --git a/tools/zod2md-jsdocs/README.md b/tools/zod2md-jsdocs/README.md index 1ac8a9d53..e5a471aa4 100644 --- a/tools/zod2md-jsdocs/README.md +++ b/tools/zod2md-jsdocs/README.md @@ -4,44 +4,9 @@ A comprehensive toolset for generating and enhancing TypeScript documentation fr ## What's Included -This package provides two main components: +This package provides the following components: -1. **[Nx Plugin](./docs/zod2md-jsdocs-nx-plugin.md)** - Automatically generates documentation targets for projects with Zod schemas -2. **[TypeScript Transformer](./docs/zod2md-jsdocs-ts-transformer.md)** - Enhances generated type definitions with JSDoc comments and schema metadata - -## Quick Start - -### Using the Nx Plugin - -Add the plugin to your `nx.json`: - -```jsonc -{ - "plugins": ["./tools/zod2md-jsdocs/src/nx-plugin.ts"], -} -``` - -Create a `zod2md.config.ts` in your project, and you'll automatically get a `generate-docs` target. - -[Learn more about the Nx Plugin →](./docs/zod2md-jsdocs-nx-plugin.md) - -### Using the TypeScript Transformer - -1. Install ts-patch: `ts-patch install` -2. Add to your `tsconfig.json`: - -```json -{ - "compilerOptions": { - "plugins": [ - { - "transform": "./tools/zod2md-jsdocs/dist/src", - "afterDeclarations": true, - "baseUrl": "https://github.com/code-pushup/cli/blob/main/packages//docs/models-reference.md" - } - ] - } -} -``` - -[Learn more about the TypeScript Transformer →](./docs/zod2md-jsdocs-ts-transformer.md) +1. **[Nx Plugin](./src/lib/plugin/README.md)** - Automatically generates documentation targets for projects with Zod schemas +2. **[Nx Configuration Generator](./src/lib/generators/configuration/README.md)** - Automatically generates documentation targets for projects with Zod schemas +3. **[Nx Sync Zod2Md Setup Generator](./src/lib/generators/sync-zod2md-setup/README.md)** - Automatically generates documentation targets for projects with Zod schemas +4. **[TypeScript Transformer](./src/lib/transformers/README.md)** - Enhances generated type definitions with JSDoc comments and schema metadata diff --git a/tools/zod2md-jsdocs/docs/zod2md-jsdocs-nx-plugin.md b/tools/zod2md-jsdocs/docs/zod2md-jsdocs-nx-plugin.md deleted file mode 100644 index 266205a8d..000000000 --- a/tools/zod2md-jsdocs/docs/zod2md-jsdocs-nx-plugin.md +++ /dev/null @@ -1,80 +0,0 @@ -# @code-pushup/zod2md-jsdocs-nx-plugin - -The Nx Plugin for [zod2md](https://github.com/matejchalk/zod2md), a tool for generating documentation from Zod schemas. - -Why should you use this plugin? - -- Zero setup cost. Just add a `zod2md.config.ts` file and you're good to go. -- Automatic target generation -- Minimal configuration -- Automated caching and dependency tracking - -## Usage - -```jsonc -// nx.json -{ - //... - "plugins": ["./tools/zod2md-jsdocs-nx-plugin/src/lib/plugin.ts"], -} -``` - -Now every project with a `zod2md.config.ts` file will have a `generate-docs` target automatically created. - -- `nx run :generate-docs` - -Run it and the project will automatically generate documentation from your Zod schemas. - -```text -Root/ -├── project-name/ -│ ├── zod2md.config.ts -│ ├── docs/ -│ │ └── project-name-reference.md 👈 generated -│ └── ... -└── ... -``` - -The generated target: - -1. Runs `zod2md` with the project's configuration -2. Formats the generated markdown with Prettier -3. Caches the result for better performance - -### Passing zod2md options - -You can override the config and output paths when running the target: - -```bash -# Use custom output file -nx generate-docs my-project --output=docs/custom-api.md - -# Use custom config file -nx generate-docs my-project --config=custom-zod2md.config.ts - -# Use both -nx generate-docs my-project --config=custom.config.ts --output=docs/api.md -``` - -Default values: - -- `config`: `{projectRoot}/zod2md.config.ts` -- `output`: `{projectRoot}/docs/{projectName}-reference.md` - -## Configuration - -Create a `zod2md.config.ts` file in your project: - -```ts -import type { Config } from 'zod2md'; - -export default { - entry: 'packages/models/src/index.ts', - tsconfig: 'packages/models/tsconfig.lib.json', - format: 'esm', - title: 'Models reference', - output: 'packages/models/docs/models-reference.md', -} satisfies Config; -``` - -For a full list of configuration options visit the [zod2md documentation](https://github.com/matejchalk/zod2md?tab=readme-ov-file#configuration). diff --git a/tools/zod2md-jsdocs/eslint.config.js b/tools/zod2md-jsdocs/eslint.config.js index 467b6c94b..f0ea93505 100644 --- a/tools/zod2md-jsdocs/eslint.config.js +++ b/tools/zod2md-jsdocs/eslint.config.js @@ -1,11 +1,53 @@ +const tseslint = require('typescript-eslint'); const baseConfig = require('../../eslint.config.js').default; -module.exports = [ +module.exports = tseslint.config( ...baseConfig, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + }, + { + files: ['**/*.ts'], + rules: { + // Nx plugins don't yet support ESM: https://github.com/nrwl/nx/issues/15682 + 'unicorn/prefer-module': 'off', + // used instead of verbatimModuleSyntax tsconfig flag (requires ESM) + '@typescript-eslint/consistent-type-imports': [ + 'warn', + { + fixStyle: 'inline-type-imports', + disallowTypeAnnotations: false, + }, + ], + '@typescript-eslint/consistent-type-exports': [ + 'warn', + { fixMixedExportsWithInlineTypeSpecifier: true }, + ], + // `import path from 'node:path'` incompatible with CJS runtime, prefer `import * as path from 'node:path'` + 'unicorn/import-style': [ + 'warn', + { styles: { 'node:path': { namespace: true } } }, + ], + // `import { logger } from '@nx/devkit' is OK here + 'no-restricted-imports': 'off', + }, + }, { files: ['**/*.json'], rules: { '@nx/dependency-checks': 'error', }, }, -]; + { + files: ['**/package.json', '**/generators.json'], + rules: { + '@nx/nx-plugin-checks': 'error', + }, + }, +); diff --git a/tools/zod2md-jsdocs/generators.json b/tools/zod2md-jsdocs/generators.json new file mode 100644 index 000000000..43b4840d3 --- /dev/null +++ b/tools/zod2md-jsdocs/generators.json @@ -0,0 +1,19 @@ +{ + "generators": { + "sync-zod2md-setup": { + "factory": "./src/lib/generators/sync-zod2md-setup/sync-zod2md-setup", + "schema": "./src/lib/generators/sync-zod2md-setup/schema.json", + "description": "sync-zod2md-setup generator" + }, + "configuration": { + "factory": "./src/lib/generators/configuration/configuration", + "schema": "./src/lib/generators/configuration/schema.json", + "description": "configuration generator" + }, + "sync-zod2md-docs": { + "factory": "./src/lib/generators/sync-zod2md-docs/sync-zod2md-docs", + "schema": "./src/lib/generators/sync-zod2md-docs/schema.json", + "description": "sync-zod2md-docs generator" + } + } +} diff --git a/tools/zod2md-jsdocs/package.json b/tools/zod2md-jsdocs/package.json index 83bddede8..53a8ba858 100644 --- a/tools/zod2md-jsdocs/package.json +++ b/tools/zod2md-jsdocs/package.json @@ -1,15 +1,17 @@ { - "name": "@code-pushup/zod2md-jsdocs", + "name": "@tooling/zod2md-jsdocs", "version": "0.0.0", "description": "TypeScript transformers enhancing models with JSDoc and schema metadata", "type": "commonjs", "main": "./src/index.js", "dependencies": { + "@nx/devkit": "22.3.3", "ts-patch": "^3.3.0", "typescript": "5.7.3", - "@nx/devkit": "22.3.3" + "zod2md": "^0.2.4" }, "files": [ "src" - ] + ], + "generators": "./generators.json" } diff --git a/tools/zod2md-jsdocs/project.json b/tools/zod2md-jsdocs/project.json index 505fd1cf4..70ffcbb29 100644 --- a/tools/zod2md-jsdocs/project.json +++ b/tools/zod2md-jsdocs/project.json @@ -4,9 +4,53 @@ "sourceRoot": "tools/zod2md-jsdocs/src", "projectType": "library", "targets": { - "lint": {}, - "build": {}, + "build": { + "options": { + "assets": [ + "{projectRoot}/*.md", + { + "input": "./tools/zod2md-jsdocs/src", + "glob": "**/!(*.ts)", + "output": "./src" + }, + { + "input": "./tools/zod2md-jsdocs/src", + "glob": "**/*.d.ts", + "output": "./src" + }, + { + "input": "./tools/zod2md-jsdocs", + "glob": "generators.json", + "output": "." + }, + { + "input": "./tools/zod2md-jsdocs", + "glob": "executors.json", + "output": "." + } + ] + } + }, + "lint": { + "options": { + "lintFilePatterns": [ + "tools/zod2md-jsdocs/**/*.ts", + "tools/zod2md-jsdocs/package.json", + "tools/zod2md-jsdocs/generators.json" + ] + } + }, + "lint-report": { + "options": { + "lintFilePatterns": [ + "tools/zod2md-jsdocs/**/*.ts", + "tools/zod2md-jsdocs/package.json", + "tools/zod2md-jsdocs/generators.json" + ] + } + }, "unit-test": {}, + "int-test": {}, "ts-patch": { "command": "ts-patch install", "cache": true, @@ -17,5 +61,6 @@ } ] } - } + }, + "tags": ["scope:tooling", "type:feature", "publishable"] } diff --git a/tools/zod2md-jsdocs/src/index.ts b/tools/zod2md-jsdocs/src/index.ts index b0994ba4e..d613620af 100644 --- a/tools/zod2md-jsdocs/src/index.ts +++ b/tools/zod2md-jsdocs/src/index.ts @@ -1,8 +1,7 @@ -import { annotateTypeDefinitions } from './lib/transformers.js'; +import { annotateTypeDefinitions } from './lib/transformers/transformers.js'; export { annotateTypeDefinitions, generateJSDocComment, -} from './lib/transformers.js'; - +} from './lib/transformers/transformers.js'; export default annotateTypeDefinitions; diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/README.md b/tools/zod2md-jsdocs/src/lib/generators/configuration/README.md new file mode 100644 index 000000000..6cbe4d3f5 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/README.md @@ -0,0 +1,35 @@ +# Configuration Generator + +#### @tooling/zod2md-jsdocs:configuration + +## Usage + +`nx generate @tooling/zod2md-jsdocs:configuration` + +By default, the Nx plugin will search for existing configuration files. If they are not present it creates a `zod2md.config.ts` and adds a target to your `project.json` file. + +You can specify the project explicitly as follows: + +`nx g @tooling/zod2md-jsdocs:configuration ` + +```text +Root/ +├── project-name/ +│ ├── project.json 👈 updated +│ ├── zod2md.config.ts 👈 generated +│ └── ... +└── ... +``` + +Show what will be generated without writing to disk: + +`nx g configuration ... --dry-run` + +## Options + +| Name | type | description | +| ----------------- | --------------------------- | ------------------------------------------------------ | +| **--project** | `string` (REQUIRED) | The name of the project. | +| **--targetName** | `string` (DEFAULT 'zod2md') | The id used to identify a target in your project.json. | +| **--skipProject** | `boolean` (DEFAULT false) | Skip adding the target to `project.json`. | +| **--skipConfig** | `boolean` (DEFAULT false) | Skip adding the `zod2md.config.ts` to project root. | diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/__snapshots__/root-zod2md.config.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/__snapshots__/root-zod2md.config.ts new file mode 100644 index 000000000..2b8929fe0 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/__snapshots__/root-zod2md.config.ts @@ -0,0 +1,8 @@ +import type { Config } from 'zod2md'; +export default { + entry: 'libs/test-app/src/index.ts', + format: 'esm', + title: 'test-app reference', + output: 'libs/test-app/docs/test-app-reference.md', + tsconfig: 'libs/test-app/tsconfig.lib.json', +} satisfies Config; diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/constants.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/constants.ts new file mode 100644 index 000000000..82640c970 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_ZOD2MD_CONFIG_FILE_NAME = 'zod2md.config.ts'; diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/files/zod2md.config.ts.template b/tools/zod2md-jsdocs/src/lib/generators/configuration/files/zod2md.config.ts.template new file mode 100644 index 000000000..a5be48ad9 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/files/zod2md.config.ts.template @@ -0,0 +1,11 @@ +import type { Config } from 'zod2md'; + +// see: https://github.com/matejchalk/zod2md?tab=readme-ov-file#configuration-file-reference +export default { + <% if (entry) { %>entry: "<%- entry %>",<% } %> + <% if (format) { %>format: "<%- format %>",<% } %> + <% if (title) { %>title: "<%- title %>",<% } %> + <% if (output) { %>output: "<%- output %>",<% } %> + <% if (tsconfig) { %>tsconfig: "<%- tsconfig %>",<% } %> + <% if (transformName) { %>transformName: "<%- transformName %>",<% } %> +} satisfies Config; diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/generator.int.test.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/generator.int.test.ts new file mode 100644 index 000000000..be211f77d --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/generator.int.test.ts @@ -0,0 +1,51 @@ +import { + type Tree, + addProjectConfiguration, + logger, + readProjectConfiguration, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import * as path from 'node:path'; +import { DEFAULT_ZOD2MD_CONFIG_FILE_NAME } from './constants.js'; +import { configurationGenerator } from './generator.js'; + +describe('configurationGenerator', () => { + let tree: Tree; + const testProjectName = 'test-app'; + const loggerInfoSpy = vi.spyOn(logger, 'info'); + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'test-app', { + root: 'test-app', + }); + }); + afterEach(() => { + tree.delete(testProjectName); + }); + it('should skip config creation if skipConfig is used', async () => { + await configurationGenerator(tree, { + project: testProjectName, + skipConfig: true, + entry: 'src/index.ts', + output: 'docs/test-app-reference.md', + title: 'Test App Reference', + }); + readProjectConfiguration(tree, testProjectName); + expect( + tree.read( + path.join('libs', testProjectName, DEFAULT_ZOD2MD_CONFIG_FILE_NAME), + ), + ).toBeNull(); + expect(loggerInfoSpy).toHaveBeenCalledWith('Skip config file creation'); + }); + it('should skip formatting', async () => { + await configurationGenerator(tree, { + project: testProjectName, + skipFormat: true, + entry: 'src/index.ts', + output: 'docs/test-app-reference.md', + title: 'Test App Reference', + }); + expect(loggerInfoSpy).toHaveBeenCalledWith('Skip formatting files'); + }); +}); diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/generator.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/generator.ts new file mode 100644 index 000000000..6e40d9e6a --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/generator.ts @@ -0,0 +1,27 @@ +import { + type Tree, + formatFiles, + logger, + readProjectConfiguration, +} from '@nx/devkit'; +import type { ConfigurationGeneratorOptions } from './schema.js'; +import { generateZod2MdConfig } from './zod2md-config.js'; + +export async function configurationGenerator( + tree: Tree, + options: ConfigurationGeneratorOptions, +) { + const projectConfiguration = readProjectConfiguration(tree, options.project); + const { skipConfig, skipFormat } = options; + if (skipConfig === true) { + logger.info('Skip config file creation'); + } else { + generateZod2MdConfig(tree, projectConfiguration.root); + } + if (skipFormat === true) { + logger.info('Skip formatting files'); + } else { + await formatFiles(tree); + } +} +export default configurationGenerator; diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/schema.d.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/schema.d.ts new file mode 100644 index 000000000..cad8eec37 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/schema.d.ts @@ -0,0 +1,7 @@ +import type { Config } from 'zod2md'; + +export type ConfigurationGeneratorOptions = Config & { + project: string; + skipConfig?: boolean; + skipFormat?: boolean; +}; diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/schema.json b/tools/zod2md-jsdocs/src/lib/generators/configuration/schema.json new file mode 100644 index 000000000..e37a1e986 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "AddConfigurationToProject", + "title": "Add Zod2Md configuration to a project", + "description": "Add Zod2Md configuration to a project", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the project.", + "x-prompt": "Which project should configure zod2md?", + "x-dropdown": "projects", + "$default": { + "$source": "argv", + "index": 0 + } + }, + "bin": { + "type": "string", + "description": "Path to Zod2Md CLI" + }, + "skipConfig": { + "type": "boolean", + "description": "Skip adding the zod2md.config.ts to the project root.", + "$default": "false" + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting of changed files", + "$default": "false" + } + }, + "required": ["project"] +} diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/tsconfig.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/tsconfig.ts new file mode 100644 index 000000000..14eac7002 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/tsconfig.ts @@ -0,0 +1,87 @@ +import type { Tree } from '@nx/devkit'; +import * as path from 'node:path'; + +export function getFirstExistingTsConfig( + tree: Tree, + projectRoot: string, + options?: { + tsconfigType?: string | string[]; + }, +): string | undefined { + const { tsconfigType = ['lib'] } = options ?? {}; + const supportedTypeNames = [ + ...new Set([ + ...(Array.isArray(tsconfigType) ? tsconfigType : [tsconfigType]), + 'lib', + 'none', + ]), + ]; + const existingType = supportedTypeNames.find(type => + tree.exists( + path.join( + projectRoot, + type === 'none' ? `tsconfig.json` : `tsconfig.${type}.json`, + ), + ), + ); + return existingType + ? path.join( + projectRoot, + existingType === 'none' + ? `tsconfig.json` + : `tsconfig.${existingType}.json`, + ) + : undefined; +} +export type PluginDefinition = { + transform: string; + afterDeclarations: boolean; + baseUrl: string; +}; +export type TsConfig = { + compilerOptions: { + plugins: PluginDefinition[]; + }; +}; +export function addZod2MdTransformToTsConfig( + tree: Tree, + root: string, + options: { + projectName: string; + tsconfigType?: string; + baseUrl: string; + }, +) { + const { tsconfigType, projectName, baseUrl } = options; + const firstExistingTsc = getFirstExistingTsConfig(tree, root, { + tsconfigType, + }); + if (!firstExistingTsc) { + throw new Error(`No config tsconfig.json file exists.`); + } + const tscJson = JSON.parse(tree.read(firstExistingTsc)?.toString() ?? `{}`); + const compilerOptions = tscJson.compilerOptions ?? {}; + const plugins = (compilerOptions.plugins ?? []) as PluginDefinition[]; + const hasTransformPlugin = plugins.some( + plugin => plugin.transform === './tools/zod2md-jsdocs/dist', + ); + if (!hasTransformPlugin) { + tree.write( + firstExistingTsc, + JSON.stringify({ + ...tscJson, + compilerOptions: { + ...compilerOptions, + plugins: [ + ...plugins, + { + transform: './tools/zod2md-jsdocs/dist', + afterDeclarations: true, + baseUrl: `${baseUrl}/docs/${projectName}-reference.md`, + }, + ], + }, + }), + ); + } +} diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/tsconfig.unit.test.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/tsconfig.unit.test.ts new file mode 100644 index 000000000..7a53c80d5 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/tsconfig.unit.test.ts @@ -0,0 +1,84 @@ +import type * as devKit from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import * as path from 'node:path'; +import { DEFAULT_ZOD2MD_CONFIG_FILE_NAME } from './constants.js'; +import { addZod2MdTransformToTsConfig } from './tsconfig.js'; + +describe('addZod2MdTransformToTsConfig', () => { + let tree: devKit.Tree; + const testProjectName = 'test-app'; + const tsconfigLibPath = path.join(testProjectName, 'tsconfig.lib.json'); + const zod2mdPath = path.join( + testProjectName, + DEFAULT_ZOD2MD_CONFIG_FILE_NAME, + ); + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write( + tsconfigLibPath, + JSON.stringify({ + compilerOptions: { + plugins: [ + { + transform: './tools/zod2md-jsdocs/dist', + afterDeclarations: true, + baseUrl: 'http://example.com/docs/test-app-reference.md', + }, + ], + }, + }), + ); + tree.write( + zod2mdPath, + ` + export default { + entry: './src/index.ts', + output: './docs/api.md', + title: 'API Documentation', + }; + `, + ); + }); + it('should fail on missing tsconfig.json', () => { + tree.delete(tsconfigLibPath); + expect(() => + addZod2MdTransformToTsConfig(tree, testProjectName, { + projectName: testProjectName, + baseUrl: 'http://example.com', + }), + ).toThrow('No config tsconfig.json file exists.'); + }); + it('should update tsconfig.lib.json with transform', () => { + tree.write( + tsconfigLibPath, + JSON.stringify({ compilerOptions: { plugins: [] } }), + ); + addZod2MdTransformToTsConfig(tree, testProjectName, { + projectName: testProjectName, + baseUrl: 'http://example.com', + }); + expect( + JSON.parse(tree.read(tsconfigLibPath)?.toString() ?? '{}'), + ).toStrictEqual( + expect.objectContaining({ + compilerOptions: expect.objectContaining({ + plugins: [ + { + transform: './tools/zod2md-jsdocs/dist', + afterDeclarations: true, + baseUrl: 'http://example.com/docs/test-app-reference.md', + }, + ], + }), + }), + ); + }); + it('should skip creation if config already configured', () => { + expect(() => + addZod2MdTransformToTsConfig(tree, testProjectName, { + projectName: testProjectName, + baseUrl: 'http://example.com', + }), + ).not.toThrow(); + }); +}); diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/types.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/types.ts new file mode 100644 index 000000000..8dfe864ce --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/types.ts @@ -0,0 +1,9 @@ +export type ItemOrArray = T | T[]; +export type ExtractArray = T extends unknown[] ? T : never; +export type ExecutableCode = { + fileImports: ItemOrArray; + codeStrings: ItemOrArray; +}; +export type ExtractArrays> = { + [K in keyof T]: ExtractArray; +}; diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/utils.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/utils.ts new file mode 100644 index 000000000..f2cbf6f9a --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/utils.ts @@ -0,0 +1,43 @@ +export function normalizeItemOrArray(itemOrArray: T | T[]): T[]; +export function normalizeItemOrArray( + itemOrArray: T | T[] | undefined, +): T[] | undefined { + if (itemOrArray == null) { + return undefined; + } + if (Array.isArray(itemOrArray)) { + return itemOrArray; + } + return [itemOrArray]; +} +export function formatObjectToFormattedJsString( + jsonObj?: + | { + [key: string]: unknown; + } + | unknown[], +): string | undefined { + if (!jsonObj) { + return; + } + const jsonString = JSON.stringify(jsonObj, null, 2); + return jsonString.replace(/"(\w+)":/g, '$1:'); +} +export function formatArrayToLinesOfJsString( + lines?: string[], + separator = '\n', +) { + if (lines == null || lines.length === 0) { + return; + } + return lines.join(separator).replace(/'/g, '"'); +} +export function formatArrayToJSArray(lines?: string[]) { + if (!Array.isArray(lines)) { + return; + } + return `[${formatArrayToLinesOfJsString(lines, ',\n') ?? ''}]`.replace( + /"/g, + '', + ); +} diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/utils.unit.test.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/utils.unit.test.ts new file mode 100644 index 000000000..584f39d29 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/utils.unit.test.ts @@ -0,0 +1,54 @@ +import { + formatArrayToJSArray, + formatArrayToLinesOfJsString, + normalizeItemOrArray, +} from './utils.js'; + +describe('formatArrayToJSArray', () => { + it('should return array as JS', () => { + expect(formatArrayToJSArray(['plugin1()', 'plugin2()'])) + .toMatchInlineSnapshot(` + "[plugin1(), + plugin2()]" + `); + }); + it('should return empty array as JS for empty items', () => { + expect(formatArrayToJSArray([])).toMatchInlineSnapshot('"[]"'); + }); + it('should return undefined for undefined values', () => { + expect(formatArrayToJSArray(undefined)).toBeUndefined(); + }); +}); +describe('formatArrayToLinesOfJsString', () => { + it('should return lines as JS', () => { + expect(formatArrayToLinesOfJsString([`import plugin from "../nx-plugin";`])) + .toMatchInlineSnapshot(` + "import plugin from "../nx-plugin";" + `); + }); + it('should return lines as JS with normalized quotes', () => { + expect( + formatArrayToLinesOfJsString([ + `import { CoreConfig } from '@zod2md/models';`, + `import plugin from "../mx-plugin";`, + ]), + ).toMatchInlineSnapshot(` + "import { CoreConfig } from "@zod2md/models"; + import plugin from "../mx-plugin";" + `); + }); + it('should return undefined for empty items', () => { + expect(formatArrayToLinesOfJsString([])).toBeUndefined(); + }); + it('should return undefined for nullish values', () => { + expect(formatArrayToLinesOfJsString()).toBeUndefined(); + }); +}); +describe('normalizeItemOrArray', () => { + it('should turn string into string array', () => { + expect(normalizeItemOrArray('myPlugin()')).toStrictEqual(['myPlugin()']); + }); + it('should keep string array', () => { + expect(normalizeItemOrArray('myPlugin()')).toStrictEqual(['myPlugin()']); + }); +}); diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/zod2md-config.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/zod2md-config.ts new file mode 100644 index 000000000..f72ec726c --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/zod2md-config.ts @@ -0,0 +1,81 @@ +import { type Tree, generateFiles, logger } from '@nx/devkit'; +import * as path from 'node:path'; +import * as ts from 'typescript'; +import type { Config } from 'zod2md'; + +export type GenerateZod2MdConfigOptions = Config; +export function getFirstExistingConfig(tree: Tree, projectRoot: string) { + const supportedFormats = ['ts', 'mjs', 'js']; + return supportedFormats.find(ext => + tree.exists(path.join(projectRoot, `zod2md.config.${ext}`)), + ); +} +export function generateZod2MdConfig( + tree: Tree, + root: string, + options?: GenerateZod2MdConfigOptions, +) { + const firstExistingFormat = getFirstExistingConfig(tree, root); + if (firstExistingFormat) { + logger.warn( + `No config file created as zod2md.config.${firstExistingFormat} file already exists.`, + ); + } else { + const { + entry = path.join(root, 'src/index.ts'), + format = 'esm', + title = `${path.basename(root)} reference`, + output = path.join(root, `docs/${path.basename(root)}-reference.md`), + tsconfig = path.join(root, 'tsconfig.lib.json'), + transformName, + } = options ?? {}; + generateFiles(tree, path.join(__dirname, 'files'), root, { + entry, + format, + title, + output, + tsconfig, + transformName, + }); + } +} +export function readDefaultExportObject< + T extends Record = Record, +>(tree: Tree, filePath: string): T | null { + const content = tree.read(filePath)?.toString(); + if (!content) { + return null; + } + const source = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + ); + let result: Record | null = null; + source.forEachChild(node => { + if (!ts.isExportAssignment(node)) { + return; + } + const expr = ts.isSatisfiesExpression?.(node.expression) + ? node.expression.expression + : node.expression; + if (!ts.isObjectLiteralExpression(expr)) { + return; + } + result = expr.properties.reduce>((acc, prop) => { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + ts.isStringLiteral(prop.initializer) + ) { + return { + ...acc, + [prop.name.text]: prop.initializer.text, + }; + } + return acc; + }, {}); + }); + return result as T | null; +} diff --git a/tools/zod2md-jsdocs/src/lib/generators/configuration/zod2md-config.unit.test.ts b/tools/zod2md-jsdocs/src/lib/generators/configuration/zod2md-config.unit.test.ts new file mode 100644 index 000000000..3589c3685 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/configuration/zod2md-config.unit.test.ts @@ -0,0 +1,98 @@ +import * as devKit from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import * as path from 'node:path'; +import stripAnsi from 'strip-ansi'; +import { generateZod2MdConfig } from './zod2md-config.js'; + +describe('generateZod2MdConfig options', () => { + let tree: devKit.Tree; + const testProjectName = 'test-app'; + const generateFilesSpy = vi.spyOn(devKit, 'generateFiles'); + const loggerWarnSpy = vi.spyOn(devKit.logger, 'warn'); + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + devKit.addProjectConfiguration(tree, testProjectName, { + root: 'test-app', + }); + }); + afterEach(() => { + generateFilesSpy.mockReset(); + }); + it('should create zod2md.config.ts with options', () => { + generateZod2MdConfig(tree, testProjectName, { + entry: 'test-app/src/main.ts', + format: 'esm' as const, + title: 'App Types', + output: 'test-app/docs/main.md', + }); + expect(generateFilesSpy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.any(String), + expect.objectContaining({ + entry: expect.pathToMatch('test-app/src/main.ts'), + format: 'esm', + title: 'App Types', + output: expect.pathToMatch('test-app/docs/main.md'), + }), + ); + }); + it('should call generateFilesSpy', () => { + generateZod2MdConfig(tree, testProjectName); + expect(generateFilesSpy).toHaveBeenCalledOnce(); + }); + it('should skip creation if config already exists', () => { + tree.write(path.join(testProjectName, 'zod2md.config.js'), ''); + generateZod2MdConfig(tree, testProjectName); + expect(generateFilesSpy).toHaveBeenCalledTimes(0); + expect(loggerWarnSpy).toHaveBeenCalledOnce(); + expect(loggerWarnSpy).toHaveBeenCalledWith( + stripAnsi( + 'No config file created as zod2md.config.js file already exists.', + ), + ); + }); + it('should use correct templates', () => { + generateZod2MdConfig(tree, testProjectName); + expect(generateFilesSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining( + path.join( + 'zod2md-jsdocs', + 'src', + 'lib', + 'generators', + 'configuration', + 'files', + ), + ), + expect.any(String), + expect.any(Object), + ); + }); + it('should use correct testProjectName', () => { + generateZod2MdConfig(tree, testProjectName); + expect(generateFilesSpy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + testProjectName, + expect.any(Object), + ); + }); + it('should use default options', () => { + generateZod2MdConfig(tree, testProjectName); + expect(generateFilesSpy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.any(String), + expect.objectContaining({ + entry: expect.pathToMatch('test-app/src/index.ts'), + format: 'esm', + title: 'test-app reference', + output: expect.pathToMatch('test-app/docs/test-app-reference.md'), + transformName: undefined, + tsconfig: expect.pathToMatch('test-app/tsconfig.lib.json'), + }), + ); + }); +}); diff --git a/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/README.md b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/README.md new file mode 100644 index 000000000..2484adb14 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/README.md @@ -0,0 +1,103 @@ +# Sync Zod2Md Setup Generator (Nx sync generator) + +**Package:** `@tooling/zod2md-jsdocs` +**Generator:** `sync-zod2md-setup` + +--- + +## Usage + +```bash +nx generate @tooling/zod2md-jsdocs:sync-zod2md-setup +``` + +The sync generator automatically targets **all projects that already contain a** +`zod2md.config.ts` file. + +--- + +## What the generator does + +For each matching project, the generator performs the following checks and fixes: + +### 1. TypeScript configuration + +- Searches for the **first existing** `tsconfig.*.json` file (e.g. `tsconfig.json`, `tsconfig.lib.json`) +- If found: + - Verifies that the **Zod2Md TypeScript plugin options** are configured + - Automatically patches the `tsconfig` file if options are missing or incorrect + +--- + +### 2. Project configuration (`project.json`) + +- Verifies the presence of a **Zod2Md docs target** +- Verifies that the `build` target has the correct `dependsOn` configuration +- If missing: + - Adds the Zod2Md target + - Updates `build.dependsOn` accordingly + +--- + +## Example project structure + +```txt +Root/ +├── libs/ +│ └── project-name/ +│ ├── zod2md.config.ts 🔎 triggers the sync generator +│ ├── project.json 👈 checks targets + build.dependsOn +│ ├── tsconfig.lib.json 👈 configures zod2md-jsdocs TS plugin +│ └── src/ +│ └── index.ts +└── ... +``` + +--- + +## Targeted behavior summary + +- ✔ No `zod2md.config.ts` → project is ignored +- ✔ Missing TS plugin config → patched automatically +- ✔ Missing Zod2Md target → added automatically +- ✔ Missing `build.dependsOn` entries → updated automatically +- ✔ Fully configured project → no changes, no errors + +--- + +## Per-project usage (optional) + +You can still scope execution to a single project: + +```bash +nx g @tooling/zod2md-jsdocs:sync-zod2md-setup project-name +``` + +--- + +## Previewing the generator + +To preview what the generator would change without applying modifications: + +```bash +nx g @tooling/zod2md-jsdocs:sync-zod2md-setup --dry-run +``` + +This is especially useful when integrating the sync generator into CI or workspace maintenance workflows. + +--- + +## Registering / Testing the generator + +To register the generator in your workspace: + +```bash +nx g @tooling/zod2md-jsdocs:sync-zod2md-setup --register +``` + +## Notes + +- This is a **sync generator**, not a build step +- It is safe to run repeatedly +- All changes are deterministic and idempotent +- Designed to work seamlessly with `nx sync` and automated workflows diff --git a/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/files/src/index.ts.template b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/files/src/index.ts.template new file mode 100644 index 000000000..877d43027 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/files/src/index.ts.template @@ -0,0 +1 @@ +const variable = "<%= name %>"; \ No newline at end of file diff --git a/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/schema.d.ts b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/schema.d.ts new file mode 100644 index 000000000..846e982b1 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/schema.d.ts @@ -0,0 +1 @@ +export interface SyncZod2mdSetupGeneratorSchema {} diff --git a/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/schema.json b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/schema.json new file mode 100644 index 000000000..5ebcf70e4 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "SyncZod2mdSetup", + "title": "", + "type": "object", + "properties": {} +} diff --git a/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/sync-zod2md-setup.ts b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/sync-zod2md-setup.ts new file mode 100644 index 000000000..96e409693 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/sync-zod2md-setup.ts @@ -0,0 +1,290 @@ +import { + type ProjectConfiguration, + type TargetConfiguration, + type Tree, + createProjectGraphAsync, + formatFiles, + readJson, + updateJson, +} from '@nx/devkit'; +import * as path from 'node:path'; +import { + GENERATE_DOCS_TARGET_NAME, + PATCH_TS_TARGET_NAME, +} from '../../plugin/constants.js'; +import { DEFAULT_ZOD2MD_CONFIG_FILE_NAME } from '../configuration/constants.js'; +import { + type PluginDefinition, + addZod2MdTransformToTsConfig, + getFirstExistingTsConfig, +} from '../configuration/tsconfig.js'; + +const missingTsconfig = 'missing-tsconfig' as const; +const missingTarget = 'missing-target' as const; +const missingBuildDeps = 'missing-build-depends-on' as const; +const missingTsPlugin = 'missing-ts-plugin' as const; +type SyncIssueDataMap = { + [missingTsconfig]: undefined; + [missingTarget]: { + target: string; + }; + [missingBuildDeps]: { + missing: readonly string[]; + }; + [missingTsPlugin]: undefined; +}; +type SyncIssue = { + [K in keyof SyncIssueDataMap]: { + type: K; + projectRoot: string; + data: SyncIssueDataMap[K]; + }; +}[keyof SyncIssueDataMap]; +const REQUIRED_BUILD_DEPENDS_ON = [ + GENERATE_DOCS_TARGET_NAME, + PATCH_TS_TARGET_NAME, +] as const; +const zod2mdConfigPath = (projectRoot: string) => + path.join(projectRoot, DEFAULT_ZOD2MD_CONFIG_FILE_NAME); +function isDependencyPresent( + dependsOn: TargetConfiguration['dependsOn'] | undefined, + targetName: string, +): boolean { + if (!dependsOn) { + return false; + } + const deps = Array.isArray(dependsOn) ? dependsOn : [dependsOn]; + return deps.some(dep => { + if (typeof dep === 'string') { + return dep === targetName || dep.endsWith(`:${targetName}`); + } + if (typeof dep === 'object' && dep != null && 'target' in dep) { + return dep.target === targetName; + } + return false; + }); +} +const getMissingDependsOn = ( + dependsOn: TargetConfiguration['dependsOn'] | undefined, + required: readonly string[], +): readonly string[] => + required.filter(target => !isDependencyPresent(dependsOn, target)); +function hasZod2MdPlugin( + tree: Tree, + tsconfigPath: string | undefined, +): boolean { + if (!tsconfigPath || !tree.exists(tsconfigPath)) { + return false; + } + const tscJson = readJson<{ + compilerOptions?: { + plugins?: PluginDefinition[]; + }; + }>(tree, tsconfigPath); + const plugins = (tscJson.compilerOptions?.plugins ?? + []) as PluginDefinition[]; + return plugins.some( + plugin => plugin.transform === './tools/zod2md-jsdocs/dist', + ); +} +function collectIssues({ + tree, + projectRoot, + tsconfigPath, + hasGenerateDocsTarget, + missingDependsOn, + hasPlugin, +}: { + tree: Tree; + projectRoot: string; + tsconfigPath: string | undefined; + hasGenerateDocsTarget: boolean; + missingDependsOn: readonly string[]; + hasPlugin: boolean; +}): readonly SyncIssue[] { + return [ + ...(!tsconfigPath || !tree.exists(tsconfigPath) + ? [{ type: missingTsconfig, projectRoot, data: undefined }] + : []), + ...(hasGenerateDocsTarget + ? [] + : [ + { + type: missingTarget, + projectRoot, + data: { target: GENERATE_DOCS_TARGET_NAME }, + }, + ]), + ...(missingDependsOn.length > 0 + ? [ + { + type: missingBuildDeps, + projectRoot, + data: { missing: missingDependsOn }, + }, + ] + : []), + ...(tsconfigPath && !hasPlugin + ? [{ type: missingTsPlugin, projectRoot, data: undefined }] + : []), + ]; +} +function analyzeProject( + tree: Tree, + project: ProjectConfiguration & { + name: string; + root: string; + }, + projectGraphTargets?: ProjectConfiguration['targets'], +): { + issues: readonly SyncIssue[]; + tsconfigPath: string | undefined; + missingDependsOn: readonly string[]; +} { + const tsconfigPath = getFirstExistingTsConfig(tree, project.root); + const missingDependsOn = getMissingDependsOn( + project.targets?.build?.dependsOn, + REQUIRED_BUILD_DEPENDS_ON, + ); + const hasPlugin = hasZod2MdPlugin(tree, tsconfigPath); + const hasGenerateDocsTarget = + projectGraphTargets?.[GENERATE_DOCS_TARGET_NAME] !== undefined; + return { + issues: collectIssues({ + tree, + projectRoot: project.root, + tsconfigPath, + hasGenerateDocsTarget, + missingDependsOn, + hasPlugin, + }), + tsconfigPath, + missingDependsOn, + }; +} +function normalizeDependsOn( + existingDependsOn: TargetConfiguration['dependsOn'] | undefined, + missingTargets: readonly string[], +): TargetConfiguration['dependsOn'] { + const existing = existingDependsOn + ? Array.isArray(existingDependsOn) + ? existingDependsOn + : [existingDependsOn] + : []; + const missing = missingTargets.filter( + target => !isDependencyPresent(existingDependsOn, target), + ); + return [...existing, ...missing]; +} +function applyFixes( + tree: Tree, + projectRoot: string, + tsconfigPath: string | undefined, + missingDependsOn: readonly string[], +) { + if (tsconfigPath) { + addZod2MdTransformToTsConfig(tree, projectRoot, { + projectName: path.basename(projectRoot), + baseUrl: `https://github.com/code-pushup/cli/blob/main/${projectRoot}`, + }); + } + if (missingDependsOn.length > 0) { + updateJson(tree, `${projectRoot}/project.json`, projectJson => { + const build = projectJson.targets?.build ?? {}; + const existingDependsOn = build.dependsOn; + const normalizedDependsOn = normalizeDependsOn( + existingDependsOn, + missingDependsOn, + ); + return { + ...projectJson, + targets: { + ...projectJson.targets, + build: { + ...build, + dependsOn: normalizedDependsOn, + }, + }, + }; + }); + } +} +function formatIssues(issues: readonly SyncIssue[]): string | undefined { + if (issues.length === 0) { + return undefined; + } + const grouped = issues.reduce< + Record + >( + (acc, issue) => ({ + ...acc, + [issue.type]: [...(acc[issue.type] ?? []), issue], + }), + {} as Record, + ); + return [ + grouped[missingTsconfig]?.length + ? `Missing tsconfig in:\n${grouped[missingTsconfig] + .map(i => ` - ${i.projectRoot}`) + .join('\n')}` + : null, + grouped[missingTarget]?.length + ? `Missing "${GENERATE_DOCS_TARGET_NAME}" target in:\n${grouped[ + missingTarget + ] + .map(i => ` - ${i.projectRoot}`) + .join('\n')}` + : null, + grouped[missingBuildDeps]?.length + ? `Missing build.dependsOn entries:\n${grouped[missingBuildDeps] + .map( + i => + ` - ${i.projectRoot}: ${( + i.data as { + missing: string[]; + } + ).missing.join(', ')}`, + ) + .join('\n')}` + : null, + grouped[missingTsPlugin]?.length + ? `Missing Zod2Md TypeScript plugin configuration in:\n${grouped[ + missingTsPlugin + ] + .map(i => ` - ${i.projectRoot}`) + .join('\n')}` + : null, + ] + .filter(Boolean) + .join('\n\n'); +} +export async function syncZod2mdSetupGenerator(tree: Tree, _?: unknown) { + const graph = await createProjectGraphAsync(); + const results = Object.values(graph.nodes) + .filter(node => tree.exists(zod2mdConfigPath(node.data.root))) + .map(node => ({ + root: node.data.root, + analysis: analyzeProject( + tree, + { + ...node.data, + name: node.name, + root: node.data.root, + }, + node.data.targets, + ), + })); + const changesBefore = tree.listChanges().length; + results.forEach(({ root, analysis }) => + applyFixes(tree, root, analysis.tsconfigPath, analysis.missingDependsOn), + ); + const changesAfter = tree.listChanges().length; + const allIssues = results.flatMap(r => r.analysis.issues); + if (changesAfter > changesBefore) { + await formatFiles(tree); + } + return { + outOfSyncMessage: formatIssues(allIssues), + }; +} +export default syncZod2mdSetupGenerator; diff --git a/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/sync-zod2md-setup.unit.test.ts b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/sync-zod2md-setup.unit.test.ts new file mode 100644 index 000000000..b612f9edd --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/generators/sync-zod2md-setup/sync-zod2md-setup.unit.test.ts @@ -0,0 +1,243 @@ +import * as devkit from '@nx/devkit'; +import { readJson, updateJson } from '@nx/devkit'; +import type { NxProjectPackageJsonConfiguration } from 'nx/src/utils/package-json'; +import { generateWorkspaceAndProject } from '@code-pushup/test-nx-utils'; +import { + GENERATE_DOCS_TARGET_NAME, + PATCH_TS_TARGET_NAME, +} from '../../plugin/constants.js'; +import { addZod2MdTransformToTsConfig } from '../configuration/tsconfig.js'; +import { generateZod2MdConfig } from '../configuration/zod2md-config.js'; +import { syncZod2mdSetupGenerator } from './sync-zod2md-setup.js'; + +describe('sync-zod2md-setup generator', () => { + const createProjectGraphAsyncSpy = vi.spyOn( + devkit, + 'createProjectGraphAsync', + ); + let tree: devkit.Tree; + const projectName = 'test'; + const projectRoot = `libs/${projectName}`; + const zod2mdConfigPath = `${projectRoot}/zod2md.config.ts`; + const projectConfig: NxProjectPackageJsonConfiguration = { + root: projectRoot, + targets: { + build: { + dependsOn: ['^build', GENERATE_DOCS_TARGET_NAME, PATCH_TS_TARGET_NAME], + }, + [GENERATE_DOCS_TARGET_NAME]: { + executor: 'zod2md-jsdocs:zod2md', + options: { + config: zod2mdConfigPath, + }, + outputs: ['{projectRoot}/docs'], + }, + }, + tags: [], + }; + beforeEach(async () => { + tree = await generateWorkspaceAndProject({ + name: 'test', + directory: projectRoot, + }); + addZod2MdTransformToTsConfig(tree, projectRoot, { + projectName, + baseUrl: 'http://example.com', + }); + generateZod2MdConfig(tree, projectRoot, { + entry: `${projectRoot}/src/index.ts`, + output: `${projectRoot}/docs/test-reference.md`, + title: 'Test reference', + }); + createProjectGraphAsyncSpy.mockResolvedValue({ + nodes: { + [projectName]: { + name: projectName, + type: 'lib', + data: projectConfig, + }, + }, + dependencies: {}, + }); + updateJson(tree, `${projectRoot}/project.json`, config => ({ + ...config, + name: projectName, + targets: { + ...projectConfig.targets, + }, + })); + }); + it('should pass if missing zod2md.config', async () => { + tree.delete(zod2mdConfigPath); + await expect(syncZod2mdSetupGenerator(tree)).resolves.toStrictEqual({ + outOfSyncMessage: undefined, + }); + expect(tree.exists(zod2mdConfigPath)).toBeFalse(); + }); + it('should fail if missing tsconfig file', async () => { + tree.delete(`${projectRoot}/tsconfig.json`); + tree.delete(`${projectRoot}/tsconfig.lib.json`); + await expect(syncZod2mdSetupGenerator(tree)).resolves.toStrictEqual({ + outOfSyncMessage: expect.stringContaining(`Missing tsconfig in: + - ${projectRoot}`), + }); + expect(tree.exists(`${projectRoot}/zod2md.config.ts`)).toBeTrue(); + }); + it('should fail if missing "zod2md" target in project config', async () => { + updateJson(tree, `${projectRoot}/project.json`, config => ({ + ...config, + name: projectName, + targets: { + build: { + dependsOn: [ + '^build', + GENERATE_DOCS_TARGET_NAME, + PATCH_TS_TARGET_NAME, + ], + }, + }, + })); + createProjectGraphAsyncSpy.mockResolvedValue({ + nodes: { + [projectName]: { + name: projectName, + type: 'lib', + data: { + ...projectConfig, + targets: { + build: { + dependsOn: [ + '^build', + GENERATE_DOCS_TARGET_NAME, + PATCH_TS_TARGET_NAME, + ], + }, + }, + }, + }, + }, + dependencies: {}, + }); + await expect(syncZod2mdSetupGenerator(tree)).resolves.toStrictEqual({ + outOfSyncMessage: + expect.stringContaining(`Missing "generate-docs" target in: + - ${projectRoot}`), + }); + expect(tree.exists(`${projectRoot}/zod2md.config.ts`)).toBeTrue(); + }); + it('should fail if missing "dependsOn" targets in build target', async () => { + updateJson(tree, `${projectRoot}/project.json`, config => ({ + ...config, + name: projectName, + targets: { + ...projectConfig.targets, + build: { + dependsOn: ['^build'], + }, + }, + })); + createProjectGraphAsyncSpy.mockResolvedValue({ + nodes: { + [projectName]: { + name: projectName, + type: 'lib', + data: { + ...projectConfig, + targets: { + ...projectConfig.targets, + build: { + dependsOn: ['^build'], + }, + }, + }, + }, + }, + dependencies: {}, + }); + await expect(syncZod2mdSetupGenerator(tree)).resolves.toStrictEqual({ + outOfSyncMessage: + expect.stringContaining(`Missing build.dependsOn entries: + - libs/test: generate-docs, ts-patch`), + }); + expect(tree.exists(`${projectRoot}/zod2md.config.ts`)).toBeTrue(); + }); + it('should fail if missing Zod2Md TypeScript plugin configuration', async () => { + updateJson(tree, `${projectRoot}/tsconfig.lib.json`, config => ({ + ...config, + compilerOptions: { + ...config.compilerOptions, + plugins: [], + }, + })); + await expect(syncZod2mdSetupGenerator(tree)).resolves.toStrictEqual({ + outOfSyncMessage: + expect.stringContaining(`Missing Zod2Md TypeScript plugin configuration in: + - ${projectRoot}`), + }); + expect(tree.exists(`${projectRoot}/zod2md.config.ts`)).toBeTrue(); + }); + it('should pass if zod2md setup is correct', async () => { + await expect(syncZod2mdSetupGenerator(tree)).resolves.toStrictEqual({ + outOfSyncMessage: undefined, + }); + }); + it('should not duplicate dependencies when they exist as objects', async () => { + const objectFormDependsOn = [ + '^build', + { + target: GENERATE_DOCS_TARGET_NAME, + projects: 'self', + }, + ]; + updateJson(tree, `${projectRoot}/project.json`, config => ({ + ...config, + name: projectName, + targets: { + ...projectConfig.targets, + build: { + dependsOn: objectFormDependsOn, + }, + }, + })); + createProjectGraphAsyncSpy.mockResolvedValue({ + nodes: { + [projectName]: { + name: projectName, + type: 'lib', + data: { + ...projectConfig, + targets: { + ...projectConfig.targets, + build: { + dependsOn: objectFormDependsOn, + }, + }, + }, + }, + }, + dependencies: {}, + }); + await expect(syncZod2mdSetupGenerator(tree)).resolves.toStrictEqual({ + outOfSyncMessage: + expect.stringContaining(`Missing build.dependsOn entries: + - libs/test: ts-patch`), + }); + const projectJson = readJson(tree, `${projectRoot}/project.json`) as { + targets?: { + build?: { + dependsOn?: unknown[]; + }; + }; + }; + const dependsOn = projectJson.targets?.build?.dependsOn ?? []; + const generateDocsCount = dependsOn.filter( + dep => + dep === GENERATE_DOCS_TARGET_NAME || + (typeof dep === 'object' && + dep != null && + 'target' in dep && + dep.target === GENERATE_DOCS_TARGET_NAME), + ).length; + expect(generateDocsCount).toBe(1); + }); +}); diff --git a/tools/zod2md-jsdocs/src/lib/plugin/README.md b/tools/zod2md-jsdocs/src/lib/plugin/README.md new file mode 100644 index 000000000..594fd02be --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/plugin/README.md @@ -0,0 +1,136 @@ +# Zod2Md Nx Plugin + +**Package:** `@tooling/zod2md-jsdocs` +**Plugin name:** `zod2md-jsdocs-nx-plugin` + +This Nx plugin automatically wires **Zod → Markdown documentation generation** +into your workspace by detecting `zod2md.config.js` files and configuring +projects accordingly. + +--- + +## What this plugin does + +Whenever a `zod2md.config.js` file is found, it: + +- Registers a **documentation generation target** +- Ensures documentation is generated **before build** +- Adds a TypeScript patching target +- Registers a **sync generator** to keep the setup consistent + +All of this happens automatically — no manual `project.json` editing required. + +--- + +## How it works + +The plugin scans your workspace for: + +```txt +**/zod2md.config.ts +``` + +For every match, it infers a project and adds the following targets. + +--- + +## Generated targets + +### `generate-docs` + +Generates Markdown documentation from Zod schemas. + +- Runs `zod2md` +- Formats output with `prettier` +- Fully cacheable +- Produces deterministic outputs + +```ts +generate-docs: { + executor: 'nx:run-commands', + options: { + commands: [ + 'zod2md --config {projectRoot}/zod2md.config.ts --output {projectRoot}/docs/{projectName}-reference.md', + 'prettier --write {projectRoot}/docs/{projectName}-reference.md' + ], + parallel: false + }, + cache: true, + inputs: ['production', '^production', '{projectRoot}/zod2md.config.ts'], + outputs: ['{projectRoot}/docs/{projectName}-reference.md'] +} +``` + +--- + +### `patch-ts` + +Ensures the TypeScript compiler is patched correctly. + +- Runs `ts-patch install` +- Cached +- Uses a runtime check to avoid unnecessary work + +```ts +patch-ts: { + command: 'ts-patch install', + cache: true, + inputs: [ + 'sharedGlobals', + { runtime: 'ts-patch check' } + ] +} +``` + +--- + +### `build` integration + +The plugin automatically updates the `build` target so that: + +- Documentation is generated before building +- The sync generator is registered + +```ts +build: { + dependsOn: [ + { target: 'generate-docs', projects: 'self' } + ], + syncGenerators: [ + './tools/zod2md-jsdocs/dist:sync-zod2md-setup' + ] +} +``` + +--- + +## Sync generator integration + +Each inferred project is automatically wired to use the +**`sync-zod2md-setup`** sync generator. + +This ensures: + +- TypeScript plugin configuration stays correct +- Required targets remain present +- `build.dependsOn` stays consistent +- The setup is safe to re-run and self-healing + +--- + +## Example project layout + +```txt +libs/my-lib/ +├── zod2md.config.ts 👈 detected by the plugin +├── project.json 👈 targets injected automatically +├── tsconfig.lib.json 👈 patched by the sync generator +├── docs/ +│ └── my-lib-reference.md 👈 generated output +└── src/ + └── index.ts +``` + +--- + +Simply add a `zod2md.config.ts` file — the plugin handles the rest. diff --git a/tools/zod2md-jsdocs/src/lib/plugin/constants.ts b/tools/zod2md-jsdocs/src/lib/plugin/constants.ts new file mode 100644 index 000000000..e632e5016 --- /dev/null +++ b/tools/zod2md-jsdocs/src/lib/plugin/constants.ts @@ -0,0 +1,2 @@ +export const GENERATE_DOCS_TARGET_NAME = 'generate-docs'; +export const PATCH_TS_TARGET_NAME = 'ts-patch'; diff --git a/tools/zod2md-jsdocs/src/nx-plugin.ts b/tools/zod2md-jsdocs/src/lib/plugin/nx-plugin.ts similarity index 58% rename from tools/zod2md-jsdocs/src/nx-plugin.ts rename to tools/zod2md-jsdocs/src/lib/plugin/nx-plugin.ts index 36cefc983..f9e05c688 100644 --- a/tools/zod2md-jsdocs/src/nx-plugin.ts +++ b/tools/zod2md-jsdocs/src/lib/plugin/nx-plugin.ts @@ -1,14 +1,10 @@ import type { CreateNodesV2, NxPlugin, TargetConfiguration } from '@nx/devkit'; import * as path from 'node:path'; -const ZOD2MD_CONFIG_FILE = 'zod2md.config.ts'; -const GENERATE_DOCS_TARGET_NAME = 'generate-docs'; - type DocsTargetConfigParams = { config: string; output: string; }; - function createDocsTargetConfig({ config, output, @@ -27,27 +23,50 @@ function createDocsTargetConfig({ outputs: [output], }; } - +const DEFAULT_ZOD2MD_CONFIG_FILE_NAME = 'zod2md.config.{js,ts}'; +const GENERATE_DOCS_TARGET_NAME = 'generate-docs'; +const PATCH_TS_TARGET_NAME = 'patch-ts'; const createNodesV2: CreateNodesV2 = [ - `**/${ZOD2MD_CONFIG_FILE}`, - async configFilePaths => + `**/${DEFAULT_ZOD2MD_CONFIG_FILE_NAME}`, + async (configFilePaths, _options) => Promise.all( configFilePaths.map(async configFilePath => { const projectRoot = path.dirname(configFilePath); const normalizedProjectRoot = projectRoot === '.' ? '' : projectRoot; const output = '{projectRoot}/docs/{projectName}-reference.md'; - const config = `{projectRoot}/${ZOD2MD_CONFIG_FILE}`; - + const configFileName = path.basename(configFilePath); + const config = `{projectRoot}/${configFileName}`; return [ configFilePath, { projects: { [normalizedProjectRoot]: { targets: { + build: { + dependsOn: [ + { + target: GENERATE_DOCS_TARGET_NAME, + projects: 'self', + }, + ], + syncGenerators: [ + './tools/zod2md-jsdocs/dist:sync-zod2md-setup', + ], + }, [GENERATE_DOCS_TARGET_NAME]: createDocsTargetConfig({ config, output, }), + [PATCH_TS_TARGET_NAME]: { + command: 'ts-patch install', + cache: true, + inputs: [ + 'sharedGlobals', + { + runtime: 'ts-patch check', + }, + ], + }, }, }, }, @@ -56,10 +75,8 @@ const createNodesV2: CreateNodesV2 = [ }), ), ]; - const nxPlugin: NxPlugin = { name: 'zod2md-jsdocs-nx-plugin', createNodesV2, }; - export default nxPlugin; diff --git a/tools/zod2md-jsdocs/docs/zod2md-jsdocs-ts-transformer.md b/tools/zod2md-jsdocs/src/lib/transformers/README.md similarity index 100% rename from tools/zod2md-jsdocs/docs/zod2md-jsdocs-ts-transformer.md rename to tools/zod2md-jsdocs/src/lib/transformers/README.md diff --git a/tools/zod2md-jsdocs/src/lib/transformers.ts b/tools/zod2md-jsdocs/src/lib/transformers/transformers.ts similarity index 90% rename from tools/zod2md-jsdocs/src/lib/transformers.ts rename to tools/zod2md-jsdocs/src/lib/transformers/transformers.ts index 4c7257f61..865a85d54 100644 --- a/tools/zod2md-jsdocs/src/lib/transformers.ts +++ b/tools/zod2md-jsdocs/src/lib/transformers/transformers.ts @@ -1,14 +1,7 @@ import type { PluginConfig, TransformerExtras } from 'ts-patch'; import type * as ts from 'typescript'; - -/* eslint-disable-next-line no-duplicate-imports */ import tsInstance from 'typescript'; -/** - * Generates a JSDoc comment for a given type name and base URL. - * @param typeName - * @param baseUrl - */ export function generateJSDocComment( typeName: string, baseUrl: string, @@ -23,14 +16,12 @@ export function generateJSDocComment( * @see {@link ${markdownLink}} `; } - export function annotateTypeDefinitions( _program: ts.Program, pluginConfig: PluginConfig, extras?: TransformerExtras, ): ts.TransformerFactory { const baseUrl = pluginConfig.baseUrl as string | undefined; - if (!baseUrl) { throw new Error( 'zod2md-jsdocs: "baseUrl" option is required. ' + diff --git a/tools/zod2md-jsdocs/src/lib/transformers.unit.test.ts b/tools/zod2md-jsdocs/src/lib/transformers/transformers.unit.test.ts similarity index 99% rename from tools/zod2md-jsdocs/src/lib/transformers.unit.test.ts rename to tools/zod2md-jsdocs/src/lib/transformers/transformers.unit.test.ts index 01b256e51..8c8a465d9 100644 --- a/tools/zod2md-jsdocs/src/lib/transformers.unit.test.ts +++ b/tools/zod2md-jsdocs/src/lib/transformers/transformers.unit.test.ts @@ -17,17 +17,14 @@ describe('generateJSDocComment', () => { " `); }); - it('should use type name in description', () => { const result = generateJSDocComment('SchemaName123', 'https://example.com'); expect(result).toContain('Type Definition: `SchemaName123`'); }); - it('should convert type name to lowercase in the link anchor', () => { const result = generateJSDocComment('SchemaName123', 'https://example.com'); expect(result).toContain('#schemaname123'); }); - it('should baseUrl in the link', () => { const result = generateJSDocComment('Schema', 'https://example.com'); expect(result).toContain('https://example.com#'); diff --git a/tools/zod2md-jsdocs/tsconfig.json b/tools/zod2md-jsdocs/tsconfig.json index 2392f83bd..c4b4d0790 100644 --- a/tools/zod2md-jsdocs/tsconfig.json +++ b/tools/zod2md-jsdocs/tsconfig.json @@ -11,7 +11,7 @@ "path": "./tsconfig.lib.json" }, { - "path": "./tsconfig.spec.json" + "path": "./tsconfig.test.json" } ] } diff --git a/tools/zod2md-jsdocs/tsconfig.lib.json b/tools/zod2md-jsdocs/tsconfig.lib.json index 83e97ea66..8009d16e7 100644 --- a/tools/zod2md-jsdocs/tsconfig.lib.json +++ b/tools/zod2md-jsdocs/tsconfig.lib.json @@ -8,5 +8,21 @@ "esModuleInterop": true }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"] + "exclude": [ + "**/__snapshots__/**", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "vitest.unit.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] } diff --git a/tools/zod2md-jsdocs/tsconfig.spec.json b/tools/zod2md-jsdocs/tsconfig.test.json similarity index 73% rename from tools/zod2md-jsdocs/tsconfig.spec.json rename to tools/zod2md-jsdocs/tsconfig.test.json index 23bd3a52a..bbdb83e52 100644 --- a/tools/zod2md-jsdocs/tsconfig.spec.json +++ b/tools/zod2md-jsdocs/tsconfig.test.json @@ -11,18 +11,14 @@ ] }, "include": [ - "vite.config.ts", - "vite.config.mts", - "vitest.config.ts", - "vitest.config.mts", "vitest.unit.config.ts", + "vitest.int.config.ts", + "mocks/**/*.ts", "src/**/*.test.ts", - "src/**/*.spec.ts", "src/**/*.test.tsx", - "src/**/*.spec.tsx", "src/**/*.test.js", - "src/**/*.spec.js", "src/**/*.test.jsx", + "src/**/*.d.ts", "src/**/*.spec.jsx", "src/**/*.d.ts", "../../testing/test-setup/src/vitest.d.ts" diff --git a/tools/zod2md-jsdocs/vitest.int.config.ts b/tools/zod2md-jsdocs/vitest.int.config.ts new file mode 100644 index 000000000..7f300c6cd --- /dev/null +++ b/tools/zod2md-jsdocs/vitest.int.config.ts @@ -0,0 +1,3 @@ +import { createIntTestConfig } from '../../testing/test-setup-config/src/index.js'; + +export default createIntTestConfig('zod2md-jsdocs');