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