From a3adacf89b76a5f6f960f6214a79384bc37cba0c Mon Sep 17 00:00:00 2001 From: Tobias Binna Date: Tue, 24 Mar 2026 19:20:35 +0800 Subject: [PATCH] docs: generate reference properties from source Closes #191 --- AGENTS.md | 20 + CLAUDE.md | 1 + docs/.vitepress/config.mts | 49 ++ docs/reference/executors.md | 185 ++------ docs/reference/generators.md | 57 +-- nx.json | 5 + .../nx-forge/src/executors/build/schema.json | 2 +- .../src/executors/package/schema.json | 4 +- tools/docs/.eslintrc.json | 37 ++ tools/docs/README.md | 11 + tools/docs/jest.config.ts | 9 + tools/docs/package.json | 11 + tools/docs/project.json | 51 +++ tools/docs/src/index.ts | 1 + tools/docs/src/lib/reference-docs.spec.ts | 269 +++++++++++ tools/docs/src/lib/reference-docs.ts | 428 ++++++++++++++++++ tools/docs/tsconfig.json | 16 + tools/docs/tsconfig.lib.json | 10 + tools/docs/tsconfig.spec.json | 15 + tsconfig.base.json | 3 +- 20 files changed, 972 insertions(+), 212 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 tools/docs/.eslintrc.json create mode 100644 tools/docs/README.md create mode 100644 tools/docs/jest.config.ts create mode 100644 tools/docs/package.json create mode 100644 tools/docs/project.json create mode 100644 tools/docs/src/index.ts create mode 100644 tools/docs/src/lib/reference-docs.spec.ts create mode 100644 tools/docs/src/lib/reference-docs.ts create mode 100644 tools/docs/tsconfig.json create mode 100644 tools/docs/tsconfig.lib.json create mode 100644 tools/docs/tsconfig.spec.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f4285e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# AGENTS.md + +## Task Completion Requirements +- Always review your work and verify the results of your work using the appropriate tools before considering tasks completed. + +## Project Snapshot + +[Nx plugin](https://nx.dev) for [Atlassian Forge](https://developer.atlassian.com/platform/forge/) that aims to assist in efficient, scalable app development and remove the mental overhead of how to set up a Forge project. + +## Core Priorities + +1. Reliability first. +2. Simplicity and correctness. +3. Avoid accidental complexity. + +If a tradeoff is required, choose correctness and robustness over short-term convenience. + +## Maintainability + +Long-term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. If you are not sure, research to find a good solution. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6a9d615..f29341b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,6 +1,25 @@ +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; import { defineConfig } from 'vitepress'; +import { + injectReferenceOptions, + validateReferenceDocs, +} from '../../tools/docs/src/lib/reference-docs'; const base = '/nx-forge/'; +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); + +function normalizeViteId(id: string): string { + return id.replace(/\\/g, '/').split('?', 1)[0]; +} + +function isReferenceMarkdownFile(id: string): boolean { + const normalizedId = normalizeViteId(id); + + return ( + normalizedId.includes('/docs/reference/') && normalizedId.endsWith('.md') + ); +} // https://vitepress.dev/reference/site-config export default defineConfig({ @@ -18,6 +37,36 @@ export default defineConfig({ ], ], cleanUrls: true, + vite: { + plugins: [ + { + name: 'nx-forge-reference-docs', + enforce: 'pre', + buildStart() { + validateReferenceDocs(workspaceRoot); + }, + load(id) { + if (!isReferenceMarkdownFile(id)) { + return null; + } + + const filePath = normalizeViteId(id); + const markdown = readFileSync(filePath, 'utf8'); + + return injectReferenceOptions(markdown, filePath, workspaceRoot); + }, + transform(code, id) { + if (!isReferenceMarkdownFile(id)) { + return null; + } + + const filePath = normalizeViteId(id); + + return injectReferenceOptions(code, filePath, workspaceRoot); + }, + }, + ], + }, themeConfig: { // https://vitepress.dev/reference/default-theme-config search: { diff --git a/docs/reference/executors.md b/docs/reference/executors.md index 8d40166..26fa886 100644 --- a/docs/reference/executors.md +++ b/docs/reference/executors.md @@ -4,8 +4,6 @@ Documents [the executors](https://nx.dev/concepts/executors-and-configurations) Append `--help` or `-h` for any of the plugin executors to explore all available options. -[//]: # (Used https://brianwendt.github.io/json-schema-md-doc/ to generate the properties markdown from schema.json files) - ## Forge ```shell @@ -20,52 +18,30 @@ Where a custom executor is provided by Nx Forge, prefer using the custom executo ::: -**_Properties_** +### Options -- outputPath `required` - - _The output path of the Forge app files._ - - Type: `string` +| Option | Type | Description | Default | +| --- |----------------------------|-------------------------------------| --- | +| `--outputPath` | `string`
**[required]** | Output path of the Forge app files. | - | Nx Forge-provided executors ensure that the Nx project configuration is updated where necessary when the Forge command has run. For example, registering a Forge app using `nx forge register` will invoke the Forge CLI directly and only update the Forge app ID in the manifest file in the output directory. The next time you run `nx build ` the manifest file in the output path will be overwritten. The correct way to do this is to run [`nx register `](#register), which will update the app ID of the `manifest.yml` within the `` project root. ## Build +::: warning +The `build` executor is deprecated in favor of a native Nx build (webpack or esbuild) in combination with the `package` executor. Refer to the [migration guide](../guides/migrating-to-package-executor) for more information. +::: + ```shell nx build ``` -Builds the Forge app project named `` to the directory specified in the `outputPath` property. If the Forge app project has dependent resource projects (Custom UI), this will build dependent projects first before building the Forge app itself. +Builds the Forge app project named `` to the directory specified in the `outputPath` option. If the Forge app project has dependent resource projects (Custom UI), this will build dependent projects first before building the Forge app itself. -::: warning -The `build` executor is deprecated in favor of a native Nx build (webpack or esbuild) in combination with the `package` executor. Refer to the [migration guide](../guides/migrating-to-package-executor) for more information. -::: +### Options -**_Properties_** - -- outputPath `required` - - _Output path of the generated files._ - - Type: `string` -- customUIPath - - _Custom UI output path relative to the outputPath._ - - Type: `string` -- resourceOutputPathMap - - _Map of resource project names to their respective output path (relative to the workspace root)._ - - Type: `object` - - Default: `{}` -- watch - - _Enable re-building when files change._ - - Type: `boolean` - - Default: _false_ -- sourceMap - - _Output sourcemaps. Use 'hidden' for use with error reporting tools without generating sourcemap comment._ -- webpackConfig - - _Path to a function which takes a webpack config, some context and returns the resulting webpack config. See https://nx.dev/guides/customize-webpack_ - - Type: `string` -- uiKit2Packaging - - _Enables UI Kit compatible packaging._ - - Type: `boolean` - - Default: _false_ + ## Package @@ -79,26 +55,9 @@ Packages the Forge app project named `` into a deployable art The `package` executor is intended to be used with a standard Nx `build` executor, for example, Webpack or esbuild. Refer to the [migration guide](../guides/migrating-to-package-executor) for more information. ::: -**_Properties_** - - - outputPath `required` - - _Output path of the generated files._ - - Type: `string` - - resourcePath - - _Path where resource files such as Custom UI output is placed relative to the outputPath._ - - Type: `string` - - resourceOutputPathMap - - _Map of resource project names to their respective output path (relative to the workspace root)._ - - Type: `object` - - Default: `{}` -- tsConfig - - _The path for the TypeScript configuration file, relative to the current project._ - - Type: `string` - - Default: _"tsconfig.app.json"_ -- uiKit2Packaging - - _Enables UI Kit compatible packaging._ - - Type: `boolean` - - Default: _false_ +### Options + + This executor will copy the output of dependent resource project builds to the `resourcePath` directory. To do this, the executor tries to infer the output path of dependent resources (Custom UI, UI Kit) from the dependent project's `build` target configuration as follows: @@ -118,30 +77,9 @@ Deploys the Forge app project named `` to the Forge platform. _Mirrors the [deploy command](https://developer.atlassian.com/platform/forge/cli-reference/deploy/) of the Forge CLI._ -**_Properties_** - -- outputPath `required` - - _The output path of the Forge app files._ - - Type: `string` -- environment - - _Environment to deploy to._ - - Type: `string` - - Default: _"development"_ -- verify - - _Run pre-deployment checks._ - - Type: `boolean` - - Default: _true_ -- interactive - - _Run deployment with or without input prompts._ - - Type: `boolean` - - Default: _true_ -- verbose - - _Run deployment in verbose mode._ - - Type: `boolean` - - Default: _false_ -- manifestTransform - - _A JSONata expression that transforms the manifest.yml content before the deployment._ - - Type: `string` +### Options + + For details on how to use the `manifestTransform` parameter, refer to the [guide on transforming the manifest](../guides/transforming-the-manifest). @@ -155,27 +93,15 @@ Registers the Forge app project named `` with the Forge platf _Mirrors the [register command](https://developer.atlassian.com/platform/forge/cli-reference/register/) of the Forge CLI._ -**_Properties_** - -- outputPath `required` - - _The output path of the Forge app files._ - - Type: `string` -- appName `required` - - _Name of the app on the Forge platform. The app name can include dashes, spaces, and underscores. Defaults to the project name_ - - Type: `string` -- developerSpaceId - - _ID of the Forge developer space this app should be part of._ - - Type: `string` -- acceptTerms - - _Automatically accept terms and conditions in non-interactive mode._ - - Type: `boolean` - - Default: _false_ -- verbose - - _Run registration in verbose mode._ - - Type: `boolean` - - Default: _false_ - -## Install +### Options + + + +## Install + +::: warning +The `install` executor is deprecated in favor of the [`forge` executor](#forge). +::: ```shell nx install @@ -185,42 +111,9 @@ Installs the Forge app project named `` for the given site an _Mirrors the [install command](https://developer.atlassian.com/platform/forge/cli-reference/install/) of the Forge CLI._ -**_Properties_** - -- outputPath `required` - - _The output path of the Forge app files._ - - Type: `string` -- site `required` - - _Atlassian site URL (example.atlassian.net)_ - - Type: `string` -- product `required` - - _Atlassian product: jira, confluence, compass, bitbucket_ - - Type: `string` - - The value is restricted to the following: - 1. _"jira"_ - 2. _"confluence"_ - 3. _"compass"_ - 4. _"bitbucket"_ -- environment - - _Environment to install to._ - - Type: `string` - - Default: _"development"_ -- upgrade - - _Upgrade an existing installation._ - - Type: `boolean` - - Default: _false_ -- confirmScopes - - _Skip confirmation of scopes for the app before installing or upgrading the app._ - - Type: `boolean` - - Default: _false_ -- interactive - - _Run installation with or without input prompts._ - - Type: `boolean` - - Default: _true_ -- verbose - - _Run installation in verbose mode._ - - Type: `boolean` - - Default: _false_ +### Options + + ## Tunnel @@ -233,20 +126,6 @@ Starts the `tunnel` target for all Custom UI projects defined in the `manifest.y _Mirrors the [tunnel command](https://developer.atlassian.com/platform/forge/cli-reference/tunnel/) of the Forge CLI._ -**_Properties_** - -- outputPath `required` - - _The output path of the Forge app files._ - - Type: `string` -- debug - - _Run Forge tunnel in debug mode._ - - Type: `boolean` - - Default: _false_ -- verbose - - _Run Forge tunnel in verbose mode._ - - Type: `boolean` - - Default: _false_ -- preTunnelTimeout - - _Max milliseconds to wait for tunnel preparation tasks._ - - Type: `number` - - Default: `5000` +### Options + + diff --git a/docs/reference/generators.md b/docs/reference/generators.md index 37122b9..523470d 100644 --- a/docs/reference/generators.md +++ b/docs/reference/generators.md @@ -4,8 +4,6 @@ Documents [the generators](https://nx.dev/features/generate-code) provided by th Append `--help` or `-h` for any of the plugin generators to explore all available options. -[//]: # (Used https://brianwendt.github.io/json-schema-md-doc/ to generate the properties markdown from schema.json files) - ## Application ```shell @@ -14,57 +12,6 @@ nx generate @toolsplus/nx-forge:app apps/ Generates a blank Forge app project named ``. In almost all cases, you probably want to run [the Forge app registration task](executors#register) immediately after this generator to register the app with the Forge platform. -**_Properties_** +### Options -- directory `required` - - _Directory of the new application_ - - Type: `string` -- name - - _Name of the application._ - - Type: `string` - - The value must match this pattern: `^[a-zA-Z][^:]*$` -- bundler - - _Bundler which is used to package the application_ - - Type: `string` - - The value is restricted to the following: - 1. _"esbuild"_ - 2. _"webpack"_ - - Default: _"webpack"_ -- skipFormat - - _Skip formatting files._ - - Type: `boolean` - - Default: _false_ -- linter - - _Tool to use for running lint checks._ - - Type: `string` - - The value is restricted to the following: - 1. _"eslint"_ - 2. _"none"_ - - Default: _"eslint"_ -- unitTestRunner - - _Test runner to use for unit tests._ - - Type: `string` - - The value is restricted to the following: - 1. _"jest"_ - 2. _"none"_ - - Default: _"jest"_ -- tags - - _Add tags to the project (used for linting)_ - - Type: `string` -- swcJest - - _Use `@swc/jest` instead `ts-jest` for faster test compilation._ - - Type: `boolean` - - Default: _false_ -- babelJest - - _Use `babel` instead of `ts-jest`._ - - Type: `boolean` - - Default: _false_ - - _Deprecated: Use `--swcJest` instead_ -- js - - _Generate JavaScript files rather than TypeScript files._ - - Type: `boolean` - - Default: _false_ -- setParserOptionsProject - - _Whether or not to configure the ESLint `parserOptions.project` option. We do not do this by default for lint performance reasons._ - - Type: `boolean` - - Default: _false_ + diff --git a/nx.json b/nx.json index 891063f..69c40e9 100644 --- a/nx.json +++ b/nx.json @@ -26,6 +26,11 @@ "@nx/eslint:lint": { "inputs": ["default", "{workspaceRoot}/.eslintrc.json"], "cache": true + }, + "@nx/js:tsc": { + "cache": true, + "dependsOn": ["^build"], + "inputs": ["production", "^production"] } }, "namedInputs": { diff --git a/packages/nx-forge/src/executors/build/schema.json b/packages/nx-forge/src/executors/build/schema.json index edf5550..b3da993 100644 --- a/packages/nx-forge/src/executors/build/schema.json +++ b/packages/nx-forge/src/executors/build/schema.json @@ -44,7 +44,7 @@ }, "uiKit2Packaging": { "type": "boolean", - "description": "Enables UI Kit compatible packaging.", + "description": "Enables UI Kit compatible packaging (experimental).", "default": false } }, diff --git a/packages/nx-forge/src/executors/package/schema.json b/packages/nx-forge/src/executors/package/schema.json index 3c772ba..41bb7d9 100644 --- a/packages/nx-forge/src/executors/package/schema.json +++ b/packages/nx-forge/src/executors/package/schema.json @@ -21,11 +21,11 @@ "tsConfig": { "type": "string", "default": "tsconfig.app.json", - "description": "The path for the TypeScript configuration file, relative to the current project." + "description": "Path for the TypeScript configuration file, relative to the current project." }, "uiKit2Packaging": { "type": "boolean", - "description": "Enables UI Kit compatible packaging.", + "description": "Enables UI Kit compatible packaging (experimental).", "default": false } }, diff --git a/tools/docs/.eslintrc.json b/tools/docs/.eslintrc.json new file mode 100644 index 0000000..03eac78 --- /dev/null +++ b/tools/docs/.eslintrc.json @@ -0,0 +1,37 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"] + } + ] + } + }, + { + "files": ["./package.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/nx-plugin-checks": "error" + } + } + ] +} diff --git a/tools/docs/README.md b/tools/docs/README.md new file mode 100644 index 0000000..7f54a0b --- /dev/null +++ b/tools/docs/README.md @@ -0,0 +1,11 @@ +# docs-tools + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build docs-tools` to build the library. + +## Running unit tests + +Run `nx test docs-tools` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/tools/docs/jest.config.ts b/tools/docs/jest.config.ts new file mode 100644 index 0000000..1318024 --- /dev/null +++ b/tools/docs/jest.config.ts @@ -0,0 +1,9 @@ +export default { + displayName: 'docs-tools', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/tools/docs', +}; diff --git a/tools/docs/package.json b/tools/docs/package.json new file mode 100644 index 0000000..b5f18cd --- /dev/null +++ b/tools/docs/package.json @@ -0,0 +1,11 @@ +{ + "name": "docs-tools", + "version": "0.0.1", + "dependencies": { + "tslib": "^2.3.0" + }, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "private": true +} diff --git a/tools/docs/project.json b/tools/docs/project.json new file mode 100644 index 0000000..f0ec1fd --- /dev/null +++ b/tools/docs/project.json @@ -0,0 +1,51 @@ +{ + "name": "docs-tools", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/docs/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/tools/docs", + "main": "tools/docs/src/index.ts", + "tsConfig": "tools/docs/tsconfig.lib.json", + "assets": [ + "tools/docs/*.md", + { + "input": "./tools/docs/src", + "glob": "**/!(*.ts)", + "output": "./src" + }, + { + "input": "./tools/docs/src", + "glob": "**/*.d.ts", + "output": "./src" + }, + { + "input": "./tools/docs", + "glob": "generators.json", + "output": "." + }, + { + "input": "./tools/docs", + "glob": "executors.json", + "output": "." + } + ] + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "tools/docs/jest.config.ts" + } + } + } +} diff --git a/tools/docs/src/index.ts b/tools/docs/src/index.ts new file mode 100644 index 0000000..43b6a07 --- /dev/null +++ b/tools/docs/src/index.ts @@ -0,0 +1 @@ +export * from './lib/reference-docs'; diff --git a/tools/docs/src/lib/reference-docs.spec.ts b/tools/docs/src/lib/reference-docs.spec.ts new file mode 100644 index 0000000..b0deccb --- /dev/null +++ b/tools/docs/src/lib/reference-docs.spec.ts @@ -0,0 +1,269 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; +import { + injectReferenceOptions, + loadReferenceItems, + parseOptionMarkers, + renderOptionsMarkdown, + validateReferenceDocs, +} from './reference-docs'; + +describe('reference docs utilities', () => { + const workspaceRoot = resolve(__dirname, '../../../../'); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('parses valid option markers', () => { + expect( + parseOptionMarkers( + [ + '## Application', + '', + '', + ].join('\n'), + 'docs/reference/generators.md' + ) + ).toEqual([ + { + filePath: 'docs/reference/generators.md', + kind: 'generator', + name: 'application', + raw: '', + }, + ]); + }); + + it('fails on malformed option markers', () => { + expect(() => + parseOptionMarkers( + '', + 'docs/reference/generators.md' + ) + ).toThrow( + 'Malformed nx-forge:options marker in docs/reference/generators.md' + ); + }); + + it('renders generator options from schema metadata', () => { + const applicationItem = loadReferenceItems(workspaceRoot).find( + (item) => item.kind === 'generator' && item.name === 'application' + ); + + expect(applicationItem).toBeDefined(); + + const markdown = renderOptionsMarkdown(applicationItem!); + + expect(markdown).toContain('| Option | Type | Description | Default |'); + expect(markdown).toContain('`--directory`'); + expect(markdown).toContain('`--dir`'); + expect(markdown).toContain('`string` **[required]**'); + expect(markdown).toContain('Pattern: `^[a-zA-Z][^:]*$`'); + expect(markdown).toContain( + 'Deprecated: Use --swcJest instead for faster compilation' + ); + expect(markdown).not.toContain('skipFormat'); + expect(markdown).not.toContain('skipPackageJson'); + expect(markdown).not.toContain('rootProject'); + }); + + it('renders executor options and excludes hidden options', () => { + const items = loadReferenceItems(workspaceRoot); + const buildItem = items.find( + (item) => item.kind === 'executor' && item.name === 'build' + ); + const installItem = items.find( + (item) => item.kind === 'executor' && item.name === 'install' + ); + const tunnelItem = items.find( + (item) => item.kind === 'executor' && item.name === 'tunnel' + ); + + expect(buildItem).toBeDefined(); + expect(installItem).toBeDefined(); + expect(tunnelItem).toBeDefined(); + + const buildMarkdown = renderOptionsMarkdown(buildItem!); + const installMarkdown = renderOptionsMarkdown(installItem!); + const tunnelMarkdown = renderOptionsMarkdown(tunnelItem!); + + expect(buildMarkdown).toContain('| `boolean \\| string` |'); + expect(buildMarkdown).toContain( + 'Enables UI Kit compatible packaging (experimental).' + ); + expect(installMarkdown).toContain('`--license`'); + expect(installMarkdown).toContain('`-l`'); + expect(installMarkdown).not.toContain('outputPath'); + expect(tunnelMarkdown).not.toContain('preTunnelTimeout'); + }); + + it('injects generated options into the reference pages', () => { + const generatorsPath = join(workspaceRoot, 'docs/reference/generators.md'); + const executorsPath = join(workspaceRoot, 'docs/reference/executors.md'); + + const injectedGenerators = injectReferenceOptions( + readFileSync(generatorsPath, 'utf8'), + generatorsPath, + workspaceRoot + ); + const injectedExecutors = injectReferenceOptions( + readFileSync(executorsPath, 'utf8'), + executorsPath, + workspaceRoot + ); + + expect(injectedGenerators).not.toContain('nx-forge:options'); + expect(injectedExecutors).not.toContain('nx-forge:options'); + expect(injectedGenerators).toContain('| Option | Type | Description | Default |'); + expect(injectedGenerators).toContain( + 'Pattern: `^[a-zA-Z][^:]*$`' + ); + expect(injectedExecutors).toContain('`--license`'); + expect(injectedExecutors).toContain( + 'Enables UI Kit compatible packaging (experimental).' + ); + }); + + it('validates the current workspace reference docs', () => { + expect(() => validateReferenceDocs(workspaceRoot)).not.toThrow(); + }); + + it('fails validation when a public schema-backed item is missing a marker', () => { + const tempWorkspace = mkdtempSync(join(tmpdir(), 'nx-forge-reference-docs-')); + + try { + mkdirSync(join(tempWorkspace, 'packages/nx-forge/src/generators/example'), { + recursive: true, + }); + mkdirSync(join(tempWorkspace, 'docs/reference'), { + recursive: true, + }); + + writeFileSync( + join(tempWorkspace, 'packages/nx-forge/generators.json'), + JSON.stringify( + { + generators: { + example: { + schema: './src/generators/example/schema.json', + }, + }, + }, + null, + 2 + ) + ); + writeFileSync( + join(tempWorkspace, 'packages/nx-forge/executors.json'), + JSON.stringify({ executors: {} }, null, 2) + ); + writeFileSync( + join(tempWorkspace, 'packages/nx-forge/src/generators/example/schema.json'), + JSON.stringify( + { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Example option.', + }, + }, + }, + null, + 2 + ) + ); + writeFileSync( + join(tempWorkspace, 'docs/reference/generators.md'), + '# Generators\n' + ); + writeFileSync( + join(tempWorkspace, 'docs/reference/executors.md'), + '# Executors\n' + ); + + expect(() => validateReferenceDocs(tempWorkspace)).toThrow( + 'Missing reference docs markers for: generator=example' + ); + } finally { + rmSync(tempWorkspace, { force: true, recursive: true }); + } + }); + + it('renders both short and long aliases when provided', () => { + const tempWorkspace = mkdtempSync(join(tmpdir(), 'nx-forge-reference-docs-')); + + try { + mkdirSync(join(tempWorkspace, 'packages/nx-forge/src/generators/example'), { + recursive: true, + }); + mkdirSync(join(tempWorkspace, 'docs/reference'), { + recursive: true, + }); + + writeFileSync( + join(tempWorkspace, 'packages/nx-forge/generators.json'), + JSON.stringify( + { + generators: { + example: { + schema: './src/generators/example/schema.json', + }, + }, + }, + null, + 2 + ) + ); + writeFileSync( + join(tempWorkspace, 'packages/nx-forge/executors.json'), + JSON.stringify({ executors: {} }, null, 2) + ); + writeFileSync( + join(tempWorkspace, 'packages/nx-forge/src/generators/example/schema.json'), + JSON.stringify( + { + type: 'object', + properties: { + target: { + type: 'string', + description: 'Example option.', + alias: 't', + aliases: ['target-name'], + }, + }, + }, + null, + 2 + ) + ); + writeFileSync( + join(tempWorkspace, 'docs/reference/generators.md'), + '\n' + ); + writeFileSync( + join(tempWorkspace, 'docs/reference/executors.md'), + '# Executors\n' + ); + + const exampleItem = loadReferenceItems(tempWorkspace).find( + (item) => item.kind === 'generator' && item.name === 'example' + ); + + expect(exampleItem).toBeDefined(); + expect(renderOptionsMarkdown(exampleItem!)).toContain( + '`-t`, `--target-name`' + ); + } finally { + rmSync(tempWorkspace, { force: true, recursive: true }); + } + }); +}); diff --git a/tools/docs/src/lib/reference-docs.ts b/tools/docs/src/lib/reference-docs.ts new file mode 100644 index 0000000..16aed8a --- /dev/null +++ b/tools/docs/src/lib/reference-docs.ts @@ -0,0 +1,428 @@ +import { readdirSync, readFileSync } from 'fs'; +import { dirname, join, relative, resolve } from 'path'; + +type ItemKind = 'executor' | 'generator'; + +type JsonSchema = { + $id?: string; + properties?: Record; + required?: string[]; +}; + +type JsonSchemaOption = { + type?: string | string[]; + oneOf?: JsonSchemaOption[]; + enum?: unknown[]; + alias?: string; + aliases?: string[]; + description?: string; + visible?: boolean; + hidden?: boolean; + default?: unknown; + pattern?: string; + ['x-deprecated']?: boolean | string; + ['x-priority']?: 'important' | 'internal'; +}; + +type GeneratorRegistryEntry = { + hidden?: boolean; + schema: string; +}; + +type ExecutorRegistryEntry = + | string + | { + schema: string; + }; + +type ReferenceOption = { + aliases?: string[]; + defaultValue?: unknown; + deprecated?: boolean | string; + description?: string; + enumValues?: unknown[]; + name: string; + pattern?: string; + required: boolean; + schemaId?: string; + typeLabel?: string; +}; + +type ReferenceItem = { + kind: ItemKind; + name: string; + options: ReferenceOption[]; + schemaId?: string; +}; + +type OptionMarker = { + filePath: string; + kind: ItemKind; + name: string; + raw: string; +}; + +const EXECUTORS_REGISTRY_PATH = 'packages/nx-forge/executors.json'; +const GENERATORS_REGISTRY_PATH = 'packages/nx-forge/generators.json'; +const REFERENCE_DOCS_DIR = 'docs/reference'; +const MARKER_PREFIX = 'nx-forge:options'; +const OPTION_MARKER_REGEX = + //g; +const OPTION_MARKER_COMMENT_REGEX = //g; +const OPTION_MARKER_EXACT_REGEX = + /^$/; + +export function getReferenceMarkdownFilePaths(workspaceRoot: string): string[] { + const referenceDocsDir = join(workspaceRoot, REFERENCE_DOCS_DIR); + + return readdirSync(referenceDocsDir) + .filter((entry) => entry.endsWith('.md')) + .sort() + .map((entry) => join(referenceDocsDir, entry)); +} + +export function loadReferenceItems(workspaceRoot: string): ReferenceItem[] { + return [ + ...loadRegistryItems( + workspaceRoot, + 'executor', + EXECUTORS_REGISTRY_PATH, + 'executors' + ), + ...loadRegistryItems( + workspaceRoot, + 'generator', + GENERATORS_REGISTRY_PATH, + 'generators' + ), + ]; +} + +export function validateReferenceDocs(workspaceRoot: string): void { + const items = loadReferenceItems(workspaceRoot); + const itemsByKey = createReferenceItemsMap(items); + const markerKeys = new Set(); + + for (const filePath of getReferenceMarkdownFilePaths(workspaceRoot)) { + const markdown = readFileSync(filePath, 'utf8'); + const markers = parseOptionMarkers(markdown, filePath); + + for (const marker of markers) { + const itemKey = getItemKey(marker.kind, marker.name); + + if (!itemsByKey.has(itemKey)) { + throw new Error( + `Reference docs marker ${marker.raw} in ${toWorkspacePath( + workspaceRoot, + marker.filePath + )} points to an unknown or filtered-out item.` + ); + } + + markerKeys.add(itemKey); + } + } + + const missingMarkerItems = items.filter( + (item) => !markerKeys.has(getItemKey(item.kind, item.name)) + ); + + if (missingMarkerItems.length > 0) { + throw new Error( + `Missing reference docs markers for: ${missingMarkerItems + .map((item) => `${item.kind}=${item.name}`) + .join(', ')}` + ); + } +} + +export function injectReferenceOptions( + markdown: string, + filePath: string, + workspaceRoot: string +): string { + const itemsByKey = createReferenceItemsMap(loadReferenceItems(workspaceRoot)); + + parseOptionMarkers(markdown, filePath); + + return markdown.replace( + OPTION_MARKER_REGEX, + (_fullMatch, kind: ItemKind, name: string) => { + const item = itemsByKey.get(getItemKey(kind, name)); + + if (!item) { + throw new Error( + `Reference docs marker ${kind}=${name} in ${toWorkspacePath( + workspaceRoot, + filePath + )} points to an unknown or filtered-out item.` + ); + } + + return renderOptionsMarkdown(item); + } + ); +} + +export function parseOptionMarkers( + markdown: string, + filePath = 'markdown' +): OptionMarker[] { + const markers: OptionMarker[] = []; + + for (const match of markdown.matchAll(OPTION_MARKER_COMMENT_REGEX)) { + const raw = match[0]; + const parsedMarker = raw.match(OPTION_MARKER_EXACT_REGEX); + + if (!parsedMarker) { + throw new Error( + `Malformed ${MARKER_PREFIX} marker in ${filePath}: ${raw}` + ); + } + + markers.push({ + filePath, + kind: parsedMarker[1] as ItemKind, + name: parsedMarker[2], + raw, + }); + } + + return markers; +} + +export function renderOptionsMarkdown(item: ReferenceItem): string { + const header = [ + '| Option | Type | Description | Default |', + '| --- | --- | --- | --- |', + ]; + const rows = item.options.map((option) => renderOptionRow(option)); + + return [...header, ...rows].join('\n'); +} + +function loadRegistryItems( + workspaceRoot: string, + kind: ItemKind, + registryPath: string, + collectionKey: 'executors' | 'generators' +): ReferenceItem[] { + const absoluteRegistryPath = join(workspaceRoot, registryPath); + const registry = JSON.parse(readFileSync(absoluteRegistryPath, 'utf8')) as { + executors?: Record; + generators?: Record; + }; + const registryEntries = registry[collectionKey] ?? {}; + + return Object.entries(registryEntries).flatMap(([name, rawEntry]) => { + const entry = normalizeRegistryEntry(rawEntry); + + if (entry.hidden) { + return []; + } + + const absoluteSchemaPath = resolve(dirname(absoluteRegistryPath), entry.schema); + const schema = JSON.parse(readFileSync(absoluteSchemaPath, 'utf8')) as JsonSchema; + + return [ + { + kind, + name, + options: normalizeOptions(schema), + schemaId: schema.$id, + }, + ]; + }); +} + +function normalizeRegistryEntry( + rawEntry: ExecutorRegistryEntry | GeneratorRegistryEntry +): GeneratorRegistryEntry { + if (typeof rawEntry === 'string') { + return { schema: rawEntry }; + } + + return rawEntry; +} + +function normalizeOptions(schema: JsonSchema): ReferenceOption[] { + const requiredOptions = new Set(schema.required ?? []); + + return Object.entries(schema.properties ?? {}).flatMap(([name, option]) => { + if ( + option.hidden === true || + option.visible === false || + option['x-priority'] === 'internal' + ) { + return []; + } + + return [ + { + aliases: normalizeAliases(option), + defaultValue: option.default, + deprecated: option['x-deprecated'], + description: option.description, + enumValues: option.enum, + name, + pattern: option.pattern, + required: requiredOptions.has(name), + schemaId: schema.$id, + typeLabel: renderTypeLabel(option), + }, + ]; + }); +} + +function createReferenceItemsMap(items: ReferenceItem[]): Map { + return new Map(items.map((item) => [getItemKey(item.kind, item.name), item])); +} + +function getItemKey(kind: ItemKind, name: string): string { + return `${kind}:${name}`; +} + +function renderOptionRow(option: ReferenceOption): string { + return `| ${renderOptionCell(option)} | ${renderTypeCell( + option + )} | ${renderDescriptionCell(option)} | ${renderDefaultCell( + option.defaultValue + )} |`; +} + +function renderOptionCell(option: ReferenceOption): string { + const lines = [ + `\`--${option.name}\``, + ...(option.aliases ? [renderAliasesInline(option.aliases)] : []), + ]; + + return escapeTableCell(lines.join('
')); +} + +function renderTypeCell(option: ReferenceOption): string { + const lines = [ + option.typeLabel ? `\`${option.typeLabel}\`` : '-', + ...(option.required ? ['**[required]**'] : []), + ]; + + return escapeTableCell(lines.join(' ')); +} + +function renderDescriptionCell(option: ReferenceOption): string { + const lines = [ + ...(option.description ? [formatDescription(option.description)] : []), + ...(option.enumValues ? [renderEnumValues(option.enumValues)] : []), + ...(option.pattern + ? [`Pattern: \`${escapeInlineCode(option.pattern)}\``] + : []), + ...renderDeprecation(option.deprecated), + ]; + + return escapeTableCell(lines.join('
')); +} + +function renderDefaultCell(defaultValue: unknown): string { + return escapeTableCell( + defaultValue !== undefined ? formatDefaultValue(defaultValue) : '-' + ); +} + +function normalizeAliases(option: JsonSchemaOption): string[] | undefined { + const aliases = [ + ...(option.alias ? [option.alias] : []), + ...Array.from(option.aliases ?? []), + ]; + const normalizedAliases = dedupe(aliases); + + return normalizedAliases.length > 0 ? normalizedAliases : undefined; +} + +function getOptionAnchorId(option: ReferenceOption): string { + const rawId = option.schemaId + ? `${option.schemaId}-${option.name}` + : `option-${option.name}`; + + const normalized = rawId.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + return trimDashes(normalized); +} + +function renderAliasesInline(aliases: string[]): string { + return aliases + .map((alias) => (alias.length === 1 ? `\`-${alias}\`` : `\`--${alias}\``)) + .join(', '); +} + +function renderEnumValues(enumValues: unknown[]): string { + return `Choices: ${enumValues + .map((enumValue) => `\`${escapeInlineCode(JSON.stringify(enumValue))}\``) + .join(', ')}`; +} + +function renderDeprecation(deprecated: boolean | string | undefined): string[] { + if (!deprecated) { + return []; + } + + if (typeof deprecated === 'string') { + return [`Deprecated: ${formatDescription(deprecated)}`]; + } + + return ['Deprecated.']; +} + +function renderTypeLabel(option: JsonSchemaOption): string | undefined { + if (Array.isArray(option.oneOf) && option.oneOf.length > 0) { + return dedupe(option.oneOf.map((entry) => renderTypeLabel(entry))).join( + ' | ' + ); + } + + if (Array.isArray(option.type)) { + return dedupe(option.type).join(' | '); + } + + return option.type; +} + +function formatDefaultValue(defaultValue: unknown): string { + return `\`${escapeInlineCode(JSON.stringify(defaultValue))}\``; +} + +function formatDescription(text: string): string { + return text.replace(/(https?:\/\/[^\s)]+)/g, (url) => `<${url}>`); +} + +function escapeInlineCode(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`'); +} + +function escapeTableCell(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, ' '); +} + +function dedupe(values: Array): string[] { + return [...new Set(values.filter((value): value is string => Boolean(value)))]; +} + +function trimDashes(value: string): string { + let start = 0; + let end = value.length; + + while (start < end && value[start] === '-') { + start += 1; + } + + while (end > start && value[end - 1] === '-') { + end -= 1; + } + + return value.slice(start, end); +} + +function toWorkspacePath(workspaceRoot: string, filePath: string): string { + return relative(workspaceRoot, filePath) || filePath; +} diff --git a/tools/docs/tsconfig.json b/tools/docs/tsconfig.json new file mode 100644 index 0000000..19b9eec --- /dev/null +++ b/tools/docs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tools/docs/tsconfig.lib.json b/tools/docs/tsconfig.lib.json new file mode 100644 index 0000000..33eca2c --- /dev/null +++ b/tools/docs/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/tools/docs/tsconfig.spec.json b/tools/docs/tsconfig.spec.json new file mode 100644 index 0000000..0d3c604 --- /dev/null +++ b/tools/docs/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index bc19a38..e9037d7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,7 +15,8 @@ "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { - "@toolsplus/nx-forge": ["packages/nx-forge/src/index.ts"] + "@toolsplus/nx-forge": ["packages/nx-forge/src/index.ts"], + "docs-tools": ["tools/docs/src/index.ts"] } }, "exclude": ["node_modules", "tmp"]