From c590c3fb48f78822c31ac505ea8d744f081dffff Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Wed, 15 Apr 2026 14:59:32 +0200 Subject: [PATCH 01/13] feat(react-headless-components): add support for sub-path imports --- .../library/package.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 5de707fdcb7339..9233ff3c49137e 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -40,6 +40,18 @@ "import": "./lib/index.js", "require": "./lib-commonjs/index.js" }, + "./*": { + "types": "./dist/components/*/index.d.ts", + "node": "./lib-commonjs/components/*/index.js", + "import": "./lib/components/*/index.js", + "require": "./lib-commonjs/components/*/index.js" + }, + "./utils": { + "types": "./dist/utils/index.d.ts", + "node": "./lib-commonjs/utils/index.js", + "import": "./lib/utils/index.js", + "require": "./lib-commonjs/utils/index.js" + }, "./package.json": "./package.json" }, "beachball": { From a1e422db8af010f6edb61b7038962c06bbd50150 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Apr 2026 18:17:30 +0200 Subject: [PATCH 02/13] feat(workspace-plugin): add subpath api-extractor dts rolluping and report generation --- tools/workspace-plugin/generators.json | 5 + .../executors/generate-api/executor.spec.ts | 375 +++++++++++++++++- .../src/executors/generate-api/executor.ts | 175 +++++++- .../src/executors/generate-api/utils.ts | 37 ++ .../add-export-subpath/index.spec.ts | 275 +++++++++++++ .../generators/add-export-subpath/index.ts | 137 +++++++ .../generators/add-export-subpath/schema.json | 31 ++ .../generators/add-export-subpath/schema.ts | 6 + .../src/plugins/workspace-plugin.ts | 99 ++++- 9 files changed, 1109 insertions(+), 31 deletions(-) create mode 100644 tools/workspace-plugin/src/executors/generate-api/utils.ts create mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts create mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/index.ts create mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/schema.json create mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/schema.ts diff --git a/tools/workspace-plugin/generators.json b/tools/workspace-plugin/generators.json index 3a882f4f0b4220..d0a4555714958c 100644 --- a/tools/workspace-plugin/generators.json +++ b/tools/workspace-plugin/generators.json @@ -1,5 +1,10 @@ { "generators": { + "add-export-subpath": { + "implementation": "./src/generators/add-export-subpath/index.ts", + "schema": "./src/generators/add-export-subpath/schema.json", + "description": "Add a named sub-path export to a vNext library and scaffold the corresponding api-extractor config for dts rollup generation." + }, "bundle-size-configuration": { "factory": "./src/generators/bundle-size-configuration/generator", "schema": "./src/generators/bundle-size-configuration/schema.json", diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 729a0ace1645ca..eb736c716577b4 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -6,7 +6,7 @@ import { type ExtractorResult, } from '@microsoft/api-extractor'; import { basename, join } from 'node:path'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { type TsConfig } from '../../types'; @@ -232,3 +232,376 @@ describe('GenerateApi Executor', () => { expect(output.success).toBe(true); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Additional file-based sub-path configs (api-extractor.*.json) +// ───────────────────────────────────────────────────────────────────────────── + +describe('GenerateApi Executor – additional sub-path configs', () => { + afterEach(() => { + cleanup(); + }); + + /** + * Extends the base "valid" fixture with a second api-extractor config + * (e.g. api-extractor.utils.json) to simulate a named sub-path entry. + */ + function prepareFixtureWithSubpathConfig() { + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + // Write the sub-path api-extractor config that references an already-emitted dts file + writeFileSync( + join(projRoot, 'config', 'api-extractor.utils.json'), + serializeJson({ + mainEntryPointFilePath: '/dts/utils/index.d.ts', + apiReport: { enabled: false }, + docModel: { enabled: false }, + dtsRollup: { enabled: true, untrimmedFilePath: '/dist/utils/index.d.ts' }, + tsdocMetadata: { enabled: false }, + }), + 'utf-8', + ); + + execSyncMock.mockImplementation(() => { + // Simulate tsc emitting declaration files for both main and utils entry + mkdirSync(join(projRoot, 'dts', 'utils'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'index.d.ts'), 'export const foo: number;', 'utf-8'); + writeFileSync(join(projRoot, 'dts', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); + }); + + return { paths, context }; + } + + it('invokes api-extractor twice when a sub-path config is present', async () => { + const { context } = prepareFixtureWithSubpathConfig(); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor(options, context); + + // Should have been called once for primary + once for utils + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(2); + expect(output.success).toBe(true); + }); + + it('passes the sub-path mainEntryPointFilePath to api-extractor', async () => { + const { paths, context } = prepareFixtureWithSubpathConfig(); + + const extractorConfigs: string[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + extractorConfigs.push(cfg.mainEntryPointFilePath); + return { succeeded: true } as ExtractorResult; + }); + + await executor(options, context); + + // Second call should use the utils entry point + expect(extractorConfigs[1]).toContain('utils/index.d.ts'); + }); + + it('returns false and stops after the first failing sub-path config', async () => { + const { context } = prepareFixtureWithSubpathConfig(); + + let callCount = 0; + jest.spyOn(Extractor, 'invoke').mockImplementation(() => { + callCount++; + // first call (primary) succeeds; second call (sub-path) fails + return { succeeded: callCount === 1, errorCount: callCount === 1 ? 0 : 1, warningCount: 0 } as ExtractorResult; + }); + + const output = await executor(options, context); + + expect(callCount).toBe(2); + expect(output.success).toBe(false); + }); + + it('ignores the primary api-extractor.json when scanning for additional configs', async () => { + const { paths, context } = prepareFixtureWithSubpathConfig(); + + const extractorConfigPaths: string[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + // mainEntryPointFilePath is absolute at this stage + extractorConfigPaths.push(cfg.mainEntryPointFilePath); + return { succeeded: true } as ExtractorResult; + }); + + await executor(options, context); + + // Should only be called once for primary + once for utils; NOT a third time for primary again + expect(extractorConfigPaths).toHaveLength(2); + // The additional config entry should not duplicate the primary one + const primaryEntry = join(paths.projRoot, 'dts', 'index.d.ts'); + const additionalEntry = join(paths.projRoot, 'dts', 'utils', 'index.d.ts'); + expect(extractorConfigPaths[0]).toBe(primaryEntry); + expect(extractorConfigPaths[1]).toBe(additionalEntry); + }); + + it('routes api report for sub-path config to etc/..api.md', async () => { + const { paths, context } = prepareFixtureWithSubpathConfigAndApiReport(); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor(options, context); + + const subpathConfig = capturedConfigs[1]; + expect(subpathConfig.apiReportEnabled).toBe(true); + expect(subpathConfig.reportFilePath).toBe(join(paths.projRoot, 'etc', 'proj.utils.api.md')); + }); + + /** + * Like prepareFixtureWithSubpathConfig but with api report enabled and a unique reportFileName. + */ + function prepareFixtureWithSubpathConfigAndApiReport() { + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + writeFileSync( + join(projRoot, 'config', 'api-extractor.utils.json'), + serializeJson({ + mainEntryPointFilePath: '/dts/utils/index.d.ts', + apiReport: { enabled: true, reportFileName: '.utils' }, + docModel: { enabled: false }, + dtsRollup: { enabled: true, untrimmedFilePath: '/dist/utils/index.d.ts' }, + tsdocMetadata: { enabled: false }, + }), + 'utf-8', + ); + + execSyncMock.mockImplementation(() => { + mkdirSync(join(projRoot, 'dts', 'utils'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'index.d.ts'), 'export const foo: number;', 'utf-8'); + writeFileSync(join(projRoot, 'dts', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); + }); + + return { paths, context }; + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Wildcard export expansion +// ───────────────────────────────────────────────────────────────────────────── + +describe('GenerateApi Executor – wildcard export expansion', () => { + afterEach(() => { + cleanup(); + }); + + /** + * Creates a fixture with a wildcard export "./*" whose types pattern resolves + * to one emitted .d.ts per sub-directory under src/items/. + * The primary api-extractor.json uses a relative path from config/ to dts/src/. + */ + function prepareWildcardFixture(subDirNames: string[]) { + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + writeFileSync( + join(projRoot, 'package.json'), + serializeJson({ + name: '@proj/proj', + types: 'dist/index.d.ts', + exports: { + '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + './*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }, + './package.json': './package.json', + }, + }), + 'utf-8', + ); + + writeFileSync( + join(projRoot, 'config', 'api-extractor.json'), + serializeJson({ + mainEntryPointFilePath: '../dts/src/index.d.ts', + apiReport: { enabled: false }, + docModel: { enabled: false }, + dtsRollup: { enabled: true }, + tsdocMetadata: { enabled: false }, + }), + 'utf-8', + ); + + for (const name of subDirNames) { + mkdirSync(join(projRoot, 'src', 'items', name), { recursive: true }); + } + + execSyncMock.mockImplementation(() => { + mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); + for (const name of subDirNames) { + mkdirSync(join(projRoot, 'dts', 'src', 'items', name), { recursive: true }); + writeFileSync( + join(projRoot, 'dts', 'src', 'items', name, 'index.d.ts'), + `export const value: string;`, + 'utf-8', + ); + } + }); + + return { paths, context }; + } + + it('calls api-extractor once per wildcard sub-directory in addition to the primary', async () => { + const subDirs = ['alpha', 'beta', 'gamma']; + const { context } = prepareWildcardFixture(subDirs); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor(options, context); + + // primary (1) + one per sub-directory + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); + expect(output.success).toBe(true); + }); + + it('passes the correct mainEntryPointFilePath for each wildcard sub-directory', async () => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareWildcardFixture(subDirs); + + const capturedEntries: string[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedEntries.push(cfg.mainEntryPointFilePath); + return { succeeded: true } as ExtractorResult; + }); + + await executor(options, context); + + const wildcardEntries = capturedEntries.slice(1); // skip primary + for (const name of subDirs) { + expect(wildcardEntries.some(p => p.includes(`items/${name}/index.d.ts`))).toBe(true); + } + }); + + it('sets the dts rollup untrimmedFilePath to dist/{wildcard-path}/{name}/index.d.ts', async () => { + const { paths, context } = prepareWildcardFixture(['alpha']); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor(options, context); + + const wildcardConfig = capturedConfigs[1]; // second call is the wildcard entry + expect(wildcardConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'items', 'alpha', 'index.d.ts')); + }); + + it('skips wildcard exports with no types field', async () => { + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + writeFileSync( + join(projRoot, 'package.json'), + serializeJson({ + name: '@proj/proj', + types: 'dist/index.d.ts', + exports: { + '.': { import: './lib/index.js' }, + './*': { import: './lib/items/*/index.js' }, // no types field + './package.json': './package.json', + }, + }), + 'utf-8', + ); + + execSyncMock.mockImplementation(() => { + mkdirSync(join(projRoot, 'dts')); + writeFileSync(join(projRoot, 'dts', 'index.d.ts'), 'export const x: 1;', 'utf-8'); + }); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + await executor(options, context); + + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); // primary only + }); + + it('skips wildcard expansion when the resolved source directory does not exist', async () => { + const { paths, context } = prepareFixture('valid', {}); + const { projRoot } = paths; + + writeFileSync( + join(projRoot, 'package.json'), + serializeJson({ + name: '@proj/proj', + types: 'dist/index.d.ts', + exports: { + '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + './*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }, + }, + }), + 'utf-8', + ); + + writeFileSync( + join(projRoot, 'config', 'api-extractor.json'), + serializeJson({ + mainEntryPointFilePath: '../dts/src/index.d.ts', + apiReport: { enabled: false }, + docModel: { enabled: false }, + dtsRollup: { enabled: true }, + tsdocMetadata: { enabled: false }, + }), + 'utf-8', + ); + + execSyncMock.mockImplementation(() => { + mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const x: 1;', 'utf-8'); + // src/items/ intentionally NOT created + }); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor(options, context); + + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); // primary only + expect(output.success).toBe(true); + }); + + it('routes api report for each wildcard component to etc/{componentName}.api.md', async () => { + const subDirs = ['alpha', 'beta']; + const { paths, context } = prepareWildcardFixture(subDirs); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor(options, context); + + const wildcardConfigs = capturedConfigs.slice(1); // skip primary + for (const name of subDirs) { + const cfg = wildcardConfigs.find(c => c.mainEntryPointFilePath.includes(`items/${name}/`))!; + expect(cfg.apiReportEnabled).toBe(true); + expect(cfg.reportFilePath).toBe(join(paths.projRoot, 'etc', `${name}.api.md`)); + } + }); +}); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index 1f9b9b822c5dc1..9fd93f00c72b65 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -1,6 +1,6 @@ import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; import { execSync } from 'node:child_process'; import { Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor'; @@ -9,6 +9,7 @@ import type { GenerateApiExecutorSchema } from './schema'; import type { PackageJson, TsConfig } from '../../types'; import { measureEnd, measureStart } from '../../utils'; import { isCI } from './lib/shared'; +import { listAdditionalApiExtractorConfigs, hasWildcardTypedExport } from './utils'; const runExecutor: PromiseExecutor = async (schema, context) => { measureStart('GenerateApiExecutor'); @@ -29,11 +30,34 @@ export default runExecutor; interface NormalizedOptions extends ReturnType {} async function runGenerateApi(options: NormalizedOptions, context: ExecutorContext): Promise { - if (generateTypeDeclarations(options)) { - return apiExtractor(options, context); + if (!generateTypeDeclarations(options)) { + return false; } - return false; + // Run primary api-extractor config + if (!apiExtractor({ configPath: options.config }, options, context)) { + return false; + } + + // Run additional file-based sub-path configs (e.g. api-extractor.utils.json) + const additionalConfigs = listAdditionalApiExtractorConfigs(dirname(options.config), options.config); + for (const configPath of additionalConfigs) { + verboseLog(`Running api-extractor for sub-path config: ${relative(options.projectAbsolutePath, configPath)}`); + if (!apiExtractor({ configPath }, options, context)) { + return false; + } + } + + // Expand wildcard exports and run api-extractor for each resolved component dir + const wildcardConfigs = getWildcardExportConfigs(options); + for (const configObject of wildcardConfigs) { + verboseLog(`Running api-extractor for wildcard component: ${configObject.mainEntryPointFilePath}`); + if (!apiExtractor({ configObject }, options, context)) { + return false; + } + } + + return true; } function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorContext) { @@ -92,15 +116,29 @@ function generateTypeDeclarations(options: NormalizedOptions) { } } -function apiExtractor(options: NormalizedOptions, context: ExecutorContext) { - const extractorConfigPath = options.config; +function apiExtractor( + configSource: { configPath: string } | { configObject: IConfigFile }, + options: NormalizedOptions, + context: ExecutorContext, +) { + let rawExtractorConfig: IConfigFile; + let configObjectFullPath: string; + + if ('configPath' in configSource) { + rawExtractorConfig = ExtractorConfig.loadFile(configSource.configPath); + configObjectFullPath = configSource.configPath; + } else { + rawExtractorConfig = configSource.configObject; + // For programmatically-built configs, reuse the primary config path as the resolution base + // so that token resolution matches the same directory as file-based configs. + configObjectFullPath = options.config; + } // Load,parse,customize and prepare the api-extractor.json file for API Extractor API - const rawExtractorConfig = ExtractorConfig.loadFile(extractorConfigPath); customizeExtractorConfig(rawExtractorConfig); const extractorConfig = ExtractorConfig.prepare({ configObject: rawExtractorConfig, - configObjectFullPath: extractorConfigPath, + configObjectFullPath, packageJsonFullPath: options.packageJsonPath, }); @@ -136,6 +174,125 @@ function apiExtractor(options: NormalizedOptions, context: ExecutorContext) { } } +/** +/** + * Reads the package.json exports map and expands wildcard entries (e.g. "./*") into individual + * api-extractor config objects — one per source directory found under the resolved source path. + * + * Example: "./*" with types "./dist/components/STAR/index.d.ts" expands to one config per + * directory found in "src/components/". + */ +function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { + const packageJson: PackageJson = parseJson(readFileSync(options.packageJsonPath, 'utf-8')); + + const exports = packageJson.exports ?? {}; + const configs: IConfigFile[] = []; + + if (!hasWildcardTypedExport(exports as Record)) { + return configs; + } + + // Derive the declaration base path from the primary api-extractor config's mainEntryPointFilePath. + // The primary config uses tokens (, , ) which we + // resolve here so that programmatic configs for wildcard entries land in the same output tree. + const primaryRawConfig = parseJson<{ mainEntryPointFilePath?: string }>(readFileSync(options.config, 'utf-8')); + const primaryMainEntryTemplate = primaryRawConfig?.mainEntryPointFilePath; + if (!primaryMainEntryTemplate) { + return configs; + } + + const unscopedPackageName = (packageJson.name ?? '').replace(/^@[^/]+\//, ''); + // and in api-extractor.json are NOT replaced before path.resolve — + // they act as literal path segments that the subsequent "../" chain traverses through. + // path.resolve(configDir, "/../../../../../../...") naturally normalizes to the correct path. + const configDir = dirname(options.config); + const resolvedPrimaryEntry = resolve( + configDir, + primaryMainEntryTemplate.replace(//g, unscopedPackageName), + ); + + // The resolved primary entry ends with /src/index.d.ts; everything before is the "declaration base" + // e.g. .../dist/out-tsc/types/packages/react-components/react-headless-components-preview/library + const srcIndexSuffix = '/src/index.d.ts'; + if (!resolvedPrimaryEntry.endsWith(srcIndexSuffix)) { + verboseLog( + `Primary mainEntryPointFilePath "${resolvedPrimaryEntry}" does not end with "${srcIndexSuffix}". ` + + `Skipping wildcard export expansion.`, + 'warn', + ); + return configs; + } + const declarationBase = resolvedPrimaryEntry.slice(0, -srcIndexSuffix.length); + + for (const [exportKey, exportValue] of Object.entries(exports)) { + // Only process wildcard entries that have a `types` field + if (!exportKey.includes('*')) { + continue; + } + if (typeof exportValue !== 'object' || !exportValue.types) { + continue; + } + + // e.g. types = "./dist/components/*/index.d.ts" + // Split on the wildcard to get prefix/suffix: ["./dist/components/", "/index.d.ts"] + const typePattern = exportValue.types; + const starIdx = typePattern.indexOf('*'); + if (starIdx === -1) { + continue; + } + const typesPrefix = typePattern.slice(0, starIdx); // "./dist/components/" + + // Derive the source path pattern from the types pattern: + // "./dist/components/*/index.d.ts" → "src/components/*/index.d.ts" + // Strip the leading "./" from typesPrefix to get the relative dist path + const distRelativePrefix = typesPrefix.replace(/^\.\//, ''); + // Replace leading "dist/" with "src/" to find source files + const srcRelativePrefix = distRelativePrefix.replace(/^dist\//, 'src/'); + const srcComponentsDir = join(options.projectAbsolutePath, srcRelativePrefix); + + if (!existsSync(srcComponentsDir)) { + verboseLog(`Wildcard export source dir not found, skipping: ${srcComponentsDir}`, 'warn'); + continue; + } + + let componentDirs: string[]; + try { + componentDirs = readdirSync(srcComponentsDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { + continue; + } + + for (const componentName of componentDirs) { + // declarationBase ends at the package root (e.g. .../library), declaration files live at + // declarationBase/src/components/{name}/index.d.ts + const mainEntryPointFilePath = join(declarationBase, srcRelativePrefix, componentName, 'index.d.ts'); + + // Construct the dts rollup output path + // typesPrefix is e.g. "./dist/components/" → projectAbsolutePath/dist/components/{name}/index.d.ts + const dtsRollupPath = join(options.projectAbsolutePath, distRelativePrefix, componentName, 'index.d.ts'); + + const configObject: IConfigFile = { + // projectFolder must be set for programmatic configs (no implicit derivation from config file path) + projectFolder: options.projectAbsolutePath, + mainEntryPointFilePath, + apiReport: { enabled: true, reportFileName: componentName, reportFolder: '/etc/' }, + docModel: { enabled: false }, + dtsRollup: { + enabled: true, + untrimmedFilePath: dtsRollupPath, + }, + tsdocMetadata: { enabled: false }, + }; + + configs.push(configObject); + } + } + + return configs; +} + function getTsConfigForApiExtractor(options: { tsConfig: TsConfig; packageJson: PackageJson; diff --git a/tools/workspace-plugin/src/executors/generate-api/utils.ts b/tools/workspace-plugin/src/executors/generate-api/utils.ts new file mode 100644 index 00000000000000..03fc03e9a450a5 --- /dev/null +++ b/tools/workspace-plugin/src/executors/generate-api/utils.ts @@ -0,0 +1,37 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Returns `api-extractor.*.json` file paths found in `configDir`, excluding `primaryConfigPath`. + * The primary `api-extractor.json` is always excluded by the regex (it has no dot-separated name + * segment between `api-extractor` and `.json`), but `primaryConfigPath` is excluded as an extra + * safety net for custom config locations. + */ +export function listAdditionalApiExtractorConfigs(configDir: string, primaryConfigPath: string): string[] { + if (!existsSync(configDir)) { + return []; + } + let entries: string[]; + try { + entries = readdirSync(configDir); + } catch { + return []; + } + return entries + .filter(f => /^api-extractor\..+\.json$/.test(f)) + .map(f => join(configDir, f)) + .filter(p => p !== primaryConfigPath); +} + +/** + * Returns `true` when the package.json `exports` map contains at least one wildcard key (e.g. + * `"./*"`) whose value is an object with a `types` field. + */ +export function hasWildcardTypedExport(exports: Record | undefined): boolean { + if (!exports) { + return false; + } + return Object.entries(exports).some( + ([key, val]) => key.includes('*') && typeof val === 'object' && val !== null && 'types' in val, + ); +} diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts b/tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts new file mode 100644 index 00000000000000..052531fb46dc98 --- /dev/null +++ b/tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts @@ -0,0 +1,275 @@ +import { addProjectConfiguration, joinPathFragments, readJson, Tree, writeJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import addExportSubpathGenerator from './index'; +import type { PackageJson } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PROJECT_NAME = 'react-headless-components-preview'; +const NPM_NAME = `@proj/${PROJECT_NAME}`; +const PROJECT_ROOT = `packages/react-components/${PROJECT_NAME}/library`; + +function createProject(tree: Tree, overrides: Partial = {}) { + addProjectConfiguration(tree, PROJECT_NAME, { + root: PROJECT_ROOT, + projectType: 'library', + sourceRoot: `${PROJECT_ROOT}/src`, + tags: ['vNext', 'platform:web'], + }); + + writeJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json'), { + name: NPM_NAME, + version: '0.0.0', + main: 'lib-commonjs/index.js', + module: 'lib/index.js', + typings: './dist/index.d.ts', + files: ['*.md', 'dist/*.d.ts', 'lib', 'lib-commonjs'], + exports: { + '.': { + types: './dist/index.d.ts', + node: './lib-commonjs/index.js', + import: './lib/index.js', + require: './lib-commonjs/index.js', + }, + './package.json': './package.json', + }, + ...overrides, + }); + + // Primary api-extractor config + writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json'), { + $schema: 'https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json', + extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', + mainEntryPointFilePath: + '/../../../../../../dist/out-tsc/types/packages/react-components/react-headless-components-preview/library/src/index.d.ts', + }); + + // Source entry + tree.write(joinPathFragments(PROJECT_ROOT, 'src', 'index.ts'), `export * from './utils';`); + + return tree; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('add-export-subpath generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + createProject(tree); + }); + + describe('api-extractor config', () => { + it('creates config/api-extractor.{subpath}.json', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + expect(tree.exists(joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json'))).toBe(true); + }); + + it('sets mainEntryPointFilePath to the sub-path entrypoint', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); + + expect(config.mainEntryPointFilePath).toContain('/src/utils/index.d.ts'); + }); + + it('derives mainEntryPointFilePath from custom sourceEntrypoint', async () => { + await addExportSubpathGenerator(tree, { + project: PROJECT_NAME, + subpath: 'tokens', + sourceEntrypoint: 'tokens/index.ts', + }); + + const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.tokens.json')); + + expect(config.mainEntryPointFilePath).toContain('/src/tokens/index.d.ts'); + }); + + it('sets dtsRollup.untrimmedFilePath to dist/{subpath}/index.d.ts', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); + + expect(config.dtsRollup).toEqual({ + enabled: true, + untrimmedFilePath: '/dist/utils/index.d.ts', + }); + }); + + it('disables apiReport by default', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); + + expect(config.apiReport.enabled).toBe(false); + }); + + it('enables apiReport and sets reportFileName when enableApiReport is true', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils', enableApiReport: true }); + + const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); + + expect(config.apiReport.enabled).toBe(true); + expect(config.apiReport.reportFileName).toBe('.utils'); + }); + + it('extends the common v-next base config', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); + + expect(config.extends).toBe('@fluentui/scripts-api-extractor/api-extractor.common.v-next.json'); + }); + + it('throws if the sub-path config already exists', async () => { + writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json'), {}); + + await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( + /already exists/, + ); + }); + + it('throws if primary api-extractor.json is missing', async () => { + tree.delete(joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json')); + + await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( + /Cannot find primary api-extractor.json/, + ); + }); + + it('throws if primary api-extractor.json has no mainEntryPointFilePath', async () => { + writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json'), { + extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', + // no mainEntryPointFilePath + }); + + await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( + /"mainEntryPointFilePath" is not set/, + ); + }); + + it('throws if primary mainEntryPointFilePath does not end with /src/index.d.ts', async () => { + writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json'), { + extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', + mainEntryPointFilePath: '/dist/index.d.ts', + }); + + await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( + /Could not derive/, + ); + }); + }); + + describe('package.json exports', () => { + it('adds a ./{subpath} export entry', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); + + expect(pkg.exports?.['./utils']).toBeDefined(); + }); + + it('sets the types field to dist/{subpath}/index.d.ts', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); + const entry = pkg.exports?.['./utils']; + + expect(typeof entry === 'object' && entry.types).toBe('./dist/utils/index.d.ts'); + }); + + it('derives JS paths from the main export entry', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); + const entry = pkg.exports?.['./utils']; + + expect(typeof entry === 'object' && entry).toMatchObject({ + node: './lib-commonjs/utils/index.js', + import: './lib/utils/index.js', + require: './lib-commonjs/utils/index.js', + }); + }); + + it('inserts the sub-path entry before ./package.json', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); + const keys = Object.keys(pkg.exports ?? {}); + const utilsIdx = keys.indexOf('./utils'); + const pkgJsonIdx = keys.indexOf('./package.json'); + + expect(utilsIdx).toBeGreaterThan(-1); + expect(pkgJsonIdx).toBeGreaterThan(utilsIdx); + }); + + it('preserves pre-existing exports', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); + + expect(pkg.exports?.['.']).toBeDefined(); + expect(pkg.exports?.['./package.json']).toBeDefined(); + }); + + it('adds dist/{subpath}/*.d.ts to package.json files', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); + + expect(pkg.files).toContain('dist/utils/*.d.ts'); + }); + + it('does not duplicate files entry when run twice with different subpaths', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + // Simulate a second sub-path added later + writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.preview.json'), {}); + // Delete it so second run creates fresh + tree.delete(joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.preview.json')); + + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'preview' }); + + const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); + const utilsCount = (pkg.files ?? []).filter((f: string) => f === 'dist/utils/*.d.ts').length; + const previewCount = (pkg.files ?? []).filter((f: string) => f === 'dist/preview/*.d.ts').length; + + expect(utilsCount).toBe(1); + expect(previewCount).toBe(1); + }); + }); + + describe('source stub', () => { + it('creates src/{subpath}/index.ts if it does not exist', async () => { + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + expect(tree.exists(joinPathFragments(PROJECT_ROOT, 'src', 'utils', 'index.ts'))).toBe(true); + }); + + it('does not overwrite an existing src/{subpath}/index.ts', async () => { + const existingContent = `export const value = 42;\n`; + tree.write(joinPathFragments(PROJECT_ROOT, 'src', 'utils', 'index.ts'), existingContent); + + await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); + + expect(tree.read(joinPathFragments(PROJECT_ROOT, 'src', 'utils', 'index.ts'), 'utf-8')).toBe(existingContent); + }); + + it('respects a custom sourceEntrypoint for the stub path', async () => { + await addExportSubpathGenerator(tree, { + project: PROJECT_NAME, + subpath: 'tokens', + sourceEntrypoint: 'tokens/index.ts', + }); + + expect(tree.exists(joinPathFragments(PROJECT_ROOT, 'src', 'tokens', 'index.ts'))).toBe(true); + }); + }); +}); diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/index.ts b/tools/workspace-plugin/src/generators/add-export-subpath/index.ts new file mode 100644 index 00000000000000..bc145d26253575 --- /dev/null +++ b/tools/workspace-plugin/src/generators/add-export-subpath/index.ts @@ -0,0 +1,137 @@ +import { formatFiles, generateFiles, joinPathFragments, readJson, updateJson, type Tree } from '@nx/devkit'; +import * as path from 'node:path'; + +import { getProjectConfig } from '../../utils'; +import type { PackageJson, TsConfig } from '../../types'; +import type { AddExportSubpathGeneratorSchema } from './schema'; + +export default async function addExportSubpathGenerator(tree: Tree, schema: AddExportSubpathGeneratorSchema) { + const { project, subpath, enableApiReport = false } = schema; + const sourceEntrypoint = schema.sourceEntrypoint ?? `${subpath}/index.ts`; + + const { projectConfig, paths } = getProjectConfig(tree, { packageName: project }); + + // ── 1. Derive paths ────────────────────────────────────────────────────────── + + const primaryApiExtractorPath = joinPathFragments(paths.configRoot, 'api-extractor.json'); + const subpathApiExtractorPath = joinPathFragments(paths.configRoot, `api-extractor.${subpath}.json`); + + if (!tree.exists(primaryApiExtractorPath)) { + throw new Error( + `Cannot find primary api-extractor.json at "${primaryApiExtractorPath}". ` + + `Make sure the project has already been set up with generate-api.`, + ); + } + + if (tree.exists(subpathApiExtractorPath)) { + throw new Error( + `Sub-path config already exists at "${subpathApiExtractorPath}". ` + + `If you want to regenerate it, delete the file first.`, + ); + } + + // ── 2. Derive mainEntryPointFilePath from the primary config ───────────────── + + const primaryConfig = readJson<{ mainEntryPointFilePath?: string }>(tree, primaryApiExtractorPath); + const primaryMainEntry = primaryConfig.mainEntryPointFilePath; + + if (!primaryMainEntry) { + throw new Error( + `"mainEntryPointFilePath" is not set in "${primaryApiExtractorPath}". ` + + `The primary api-extractor.json must declare this field.`, + ); + } + + // Replace the terminal index.d.ts segment with the sub-path entrypoint: + // e.g. "...library/src/index.d.ts" → "...library/src/utils/index.d.ts" + // sourceEntrypoint may be "utils/index.ts" — convert .ts → .d.ts + const sourceDtsRelative = sourceEntrypoint.replace(/\.tsx?$/, '.d.ts'); + const newMainEntry = primaryMainEntry.replace(/\/src\/index\.d\.ts$/, `/src/${sourceDtsRelative}`); + + if (newMainEntry === primaryMainEntry) { + throw new Error( + `Could not derive new mainEntryPointFilePath from "${primaryMainEntry}". ` + + `Expected the primary config to end with "/src/index.d.ts".`, + ); + } + + // ── 3. Derive dtsRollup output path ────────────────────────────────────────── + + // The output will be dist/{subpath}/index.d.ts + // e.g. /dist/utils/index.d.ts + const dtsOutputPath = `/dist/${subpath}/index.d.ts`; + + // ── 4. Write config/api-extractor.{subpath}.json ──────────────────────────── + + const subpathConfig: Record = { + $schema: 'https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json', + extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', + mainEntryPointFilePath: newMainEntry, + apiReport: { + enabled: enableApiReport, + ...(enableApiReport ? { reportFileName: `.${subpath}` } : {}), + }, + dtsRollup: { + enabled: true, + untrimmedFilePath: dtsOutputPath, + }, + }; + + tree.write(subpathApiExtractorPath, JSON.stringify(subpathConfig, null, 2) + '\n'); + + // ── 5. Create src/{subpath}/index.ts stub if it doesn't exist ──────────────── + + const sourceEntrypointPath = joinPathFragments(paths.sourceRoot, sourceEntrypoint); + if (!tree.exists(sourceEntrypointPath)) { + tree.write(sourceEntrypointPath, `// TODO: add exports for the "${subpath}" sub-path\n`); + } + + // ── 6. Derive the JS module output paths from existing main export ───────────── + + const packageJson = readJson(tree, paths.packageJson); + const mainExport = typeof packageJson.exports?.['.'] === 'object' ? packageJson.exports['.'] : null; + + // Derive default JS paths based on the main entry (replace lib/index.js → lib/{subpath}/index.js) + const deriveJsPath = (base: string | undefined) => { + if (!base || typeof base !== 'string') return undefined; + return base.replace(/\/index\.js$/, `/${subpath}/index.js`); + }; + + const newExportEntry: PackageJson['exports'][string] = { + types: `./dist/${subpath}/index.d.ts`, + node: deriveJsPath(mainExport?.node) ?? `./lib-commonjs/${subpath}/index.js`, + import: deriveJsPath(mainExport?.import) ?? `./lib/${subpath}/index.js`, + require: deriveJsPath(mainExport?.require) ?? `./lib-commonjs/${subpath}/index.js`, + }; + + // ── 7. Update package.json exports + files ──────────────────────────────────── + + updateJson(tree, paths.packageJson, json => { + json.exports = json.exports ?? {}; + + // Insert the new sub-path entry before "./package.json" + const updatedExports: PackageJson['exports'] = {}; + let inserted = false; + for (const [key, value] of Object.entries(json.exports)) { + if (key === './package.json' && !inserted) { + updatedExports[`./${subpath}`] = newExportEntry; + inserted = true; + } + updatedExports[key] = value; + } + if (!inserted) { + updatedExports[`./${subpath}`] = newExportEntry; + } + json.exports = updatedExports; + + // Update files to include the new dist output + const dtsGlob = `dist/${subpath}/*.d.ts`; + if (!json.files?.includes(dtsGlob)) { + json.files = [...(json.files ?? []), dtsGlob]; + } + + return json; + }); + + await formatFiles(tree); +} diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/schema.json b/tools/workspace-plugin/src/generators/add-export-subpath/schema.json new file mode 100644 index 00000000000000..816e052d638c4f --- /dev/null +++ b/tools/workspace-plugin/src/generators/add-export-subpath/schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "id": "add-export-subpath", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the project to add the sub-path export to.", + "$default": { "$source": "argv", "index": 0 }, + "x-priority": "important", + "x-prompt": "What is the project name?" + }, + "subpath": { + "type": "string", + "description": "The sub-path name (e.g. 'utils'). Used as './utils' in the exports map and to derive config/api-extractor.utils.json.", + "x-priority": "important", + "x-prompt": "What is the export sub-path name (e.g. 'utils')?" + }, + "sourceEntrypoint": { + "type": "string", + "description": "Path of the source entrypoint relative to src/ (defaults to '{subpath}/index.ts')." + }, + "enableApiReport": { + "type": "boolean", + "description": "Whether to generate an api.md report for this sub-path entry.", + "default": false + } + }, + "required": ["project", "subpath"] +} diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/schema.ts b/tools/workspace-plugin/src/generators/add-export-subpath/schema.ts new file mode 100644 index 00000000000000..79352b33934a8e --- /dev/null +++ b/tools/workspace-plugin/src/generators/add-export-subpath/schema.ts @@ -0,0 +1,6 @@ +export interface AddExportSubpathGeneratorSchema { + project: string; + subpath: string; + sourceEntrypoint?: string; + enableApiReport?: boolean; +} diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.ts index d6208797e27394..442edf2691a5a5 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.ts @@ -23,6 +23,7 @@ import { buildCleanTarget } from './clean-plugin'; import { buildFormatTarget } from './format-plugin'; import { buildTypeCheckTarget } from './type-check-plugin'; import { measureStart, measureEnd } from '../utils'; +import { listAdditionalApiExtractorConfigs, hasWildcardTypedExport } from '../executors/generate-api/utils'; export interface WorkspacePluginOptions { testSSR?: TargetPluginOption; @@ -201,27 +202,7 @@ function buildWorkspaceProjectConfiguration( // library - targets['generate-api'] = { - cache: true, - executor: '@fluentui/workspace-plugin:generate-api', - inputs: [ - '{projectRoot}/config/api-extractor.json', - '{projectRoot}/tsconfig.json', - '{projectRoot}/tsconfig.lib.json', - '{projectRoot}/src/**/*.tsx?', - // trigger affected or cache invalidation on generate-api target if scripts-api-extractor changed - '{workspaceRoot}/scripts/api-extractor/api-extractor.*.json', - { externalDependencies: ['@microsoft/api-extractor', 'typescript'] }, - ], - outputs: [`{projectRoot}/dist/index.d.ts`, `{projectRoot}/etc/${config.projectJSON.name}.api.md`], - metadata: { - technologies: ['typescript', 'api-extractor'], - help: { - command: `${config.pmc.exec} nx run ${config.projectJSON.name}:generate-api --help`, - example: {}, - }, - }, - }; + targets['generate-api'] = buildGenerateApiTarget(projectRoot, config); targets.build = { cache: true, @@ -287,6 +268,82 @@ function buildWorkspaceProjectConfiguration( return { targets }; } +function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): TargetConfiguration { + const { extraInputs, extraOutputs } = buildExtraInputsAndOutputsForApiExtractorConfigs(); + + return { + cache: true, + executor: '@fluentui/workspace-plugin:generate-api', + inputs: [ + '{projectRoot}/config/api-extractor.json', + ...extraInputs, + '{projectRoot}/tsconfig.json', + '{projectRoot}/tsconfig.lib.json', + '{projectRoot}/src/**/*.tsx?', + // trigger affected or cache invalidation on generate-api target if scripts-api-extractor changed + '{workspaceRoot}/scripts/api-extractor/api-extractor.*.json', + { externalDependencies: ['@microsoft/api-extractor', 'typescript'] }, + ], + outputs: [`{projectRoot}/dist/index.d.ts`, `{projectRoot}/etc/${config.projectJSON.name}.api.md`, ...extraOutputs], + metadata: { + technologies: ['typescript', 'api-extractor'], + help: { + command: `${config.pmc.exec} nx run ${config.projectJSON.name}:generate-api --help`, + example: {}, + }, + }, + }; + + function buildExtraInputsAndOutputsForApiExtractorConfigs() { + const extraInputs: string[] = []; + const extraOutputs: string[] = []; + + // Pick up additional api-extractor.*.json configs (sub-path entries, e.g. api-extractor.utils.json) + const configDir = join(projectRoot, 'config'); + const primaryConfigPath = join(configDir, 'api-extractor.json'); + const additionalConfigFiles = listAdditionalApiExtractorConfigs(configDir, primaryConfigPath); + + for (const absPath of additionalConfigFiles) { + const configFile = absPath.slice(configDir.length + 1); // filename only + extraInputs.push(`{projectRoot}/config/${configFile}`); + // Parse the config to derive outputs (dtsRollup untrimmedFilePath + apiReport reportFileName) + try { + const parsed = readJsonFile<{ + dtsRollup?: { untrimmedFilePath?: string }; + apiReport?: { enabled?: boolean; reportFileName?: string }; + }>(absPath); + if (parsed.dtsRollup?.untrimmedFilePath) { + // untrimmedFilePath may contain api-extractor tokens like — replace with {projectRoot} + extraOutputs.push( + parsed.dtsRollup.untrimmedFilePath + .replace('', '{projectRoot}') + .replace('', '{projectRoot}'), + ); + } + if (parsed.apiReport?.enabled && parsed.apiReport.reportFileName) { + const unscopedPackageName = (config.packageJSON.name ?? '').replace(/^@[^/]+\//, ''); + const resolvedReportFileName = parsed.apiReport.reportFileName.replace( + //g, + unscopedPackageName, + ); + extraOutputs.push(`{projectRoot}/etc/${resolvedReportFileName}.api.md`); + } + } catch { + // ignore parse errors; outputs will just be incomplete for this file + } + } + + // Wildcard exports: if the package.json has a wildcard export key with a types field, + // the executor will expand it at runtime — add the dist glob and api report glob as outputs so nx can track them. + if (hasWildcardTypedExport(config.packageJSON.exports as Record)) { + extraOutputs.push('{projectRoot}/dist/**/*.d.ts'); + extraOutputs.push('{projectRoot}/etc/*.api.md'); + } + + return { extraInputs, extraOutputs }; + } +} + function buildTestTarget( projectRoot: string, options: Required, From ac48bfef4da6e40681c3482195b5f6a73eed468d Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Apr 2026 18:56:29 +0200 Subject: [PATCH 03/13] feat: make wildCardExpansion flag configurable --- .../executors/generate-api/executor.spec.ts | 38 +++++++++++++++++++ .../src/executors/generate-api/executor.ts | 20 +++++++--- .../src/executors/generate-api/schema.d.ts | 1 + .../src/executors/generate-api/schema.json | 5 +++ 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index eb736c716577b4..84d7cd61ff7bb7 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -602,6 +602,44 @@ describe('GenerateApi Executor – wildcard export expansion', () => { const cfg = wildcardConfigs.find(c => c.mainEntryPointFilePath.includes(`items/${name}/`))!; expect(cfg.apiReportEnabled).toBe(true); expect(cfg.reportFilePath).toBe(join(paths.projRoot, 'etc', `${name}.api.md`)); + expect(cfg.reportTempFilePath).toBe(join(paths.projRoot, 'temp', `${name}.api.md`)); } }); + + it('skips wildcard expansion when enableWildcardExpansion is false', async () => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareWildcardFixture(subDirs); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor({ ...options, enableWildcardExpansion: false }, context); + + // Only the primary config should run — no wildcard expansion + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); + expect(output.success).toBe(true); + }); + + it('expands wildcards by default (enableWildcardExpansion not set)', async () => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareWildcardFixture(subDirs); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + // Pass default options — no enableWildcardExpansion key + const output = await executor(options, context); + + // primary (1) + one per sub-directory + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); + expect(output.success).toBe(true); + }); }); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index 9fd93f00c72b65..ef59be52c0020e 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -49,11 +49,13 @@ async function runGenerateApi(options: NormalizedOptions, context: ExecutorConte } // Expand wildcard exports and run api-extractor for each resolved component dir - const wildcardConfigs = getWildcardExportConfigs(options); - for (const configObject of wildcardConfigs) { - verboseLog(`Running api-extractor for wildcard component: ${configObject.mainEntryPointFilePath}`); - if (!apiExtractor({ configObject }, options, context)) { - return false; + if (options.enableWildcardExpansion) { + const wildcardConfigs = getWildcardExportConfigs(options); + for (const configObject of wildcardConfigs) { + verboseLog(`Running api-extractor for wildcard component: ${configObject.mainEntryPointFilePath}`); + if (!apiExtractor({ configObject }, options, context)) { + return false; + } } } @@ -65,6 +67,7 @@ function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorCo config: '{projectRoot}/config/api-extractor.json', local: true, diagnostics: false, + enableWildcardExpansion: true, }; const resolvedSchema = { ...defaults, ...schema }; @@ -277,7 +280,12 @@ function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { // projectFolder must be set for programmatic configs (no implicit derivation from config file path) projectFolder: options.projectAbsolutePath, mainEntryPointFilePath, - apiReport: { enabled: true, reportFileName: componentName, reportFolder: '/etc/' }, + apiReport: { + enabled: true, + reportFileName: componentName, + reportFolder: '/etc/', + reportTempFolder: '/temp/', + }, docModel: { enabled: false }, dtsRollup: { enabled: true, diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts index a4c1b28b945894..8283aa47ec9353 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts +++ b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts @@ -2,4 +2,5 @@ export interface GenerateApiExecutorSchema { config?: string; local?: boolean; diagnostics?: boolean; + enableWildcardExpansion?: boolean; } diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.json b/tools/workspace-plugin/src/executors/generate-api/schema.json index 0fa20bb5ba3c19..65ad37dd857759 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.json +++ b/tools/workspace-plugin/src/executors/generate-api/schema.json @@ -19,6 +19,11 @@ "type": "boolean", "description": "Show diagnostic messages used for troubleshooting problems with API Extractor", "default": false + }, + "enableWildcardExpansion": { + "type": "boolean", + "description": "Whether to expand wildcard export map entries (e.g. './*') and run api-extractor for each resolved sub-directory. When false, only the primary config and explicit sub-path configs are processed.", + "default": true } }, "required": [] From 7baac3e90e3232c97b8136f6935d4b17eaf1d5fd Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Apr 2026 19:42:25 +0200 Subject: [PATCH 04/13] chore: remove hard coupling to repo folder structure and chunk logic in small functions --- .../executors/generate-api/executor.spec.ts | 12 +- .../src/executors/generate-api/executor.ts | 263 +++++++++++------- .../src/executors/generate-api/utils.ts | 14 +- 3 files changed, 175 insertions(+), 114 deletions(-) diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 84d7cd61ff7bb7..836f66c92fa4e6 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -398,7 +398,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { /** * Creates a fixture with a wildcard export "./*" whose types pattern resolves - * to one emitted .d.ts per sub-directory under src/items/. + * to one emitted .d.ts per sub-directory under dts/src/items/. * The primary api-extractor.json uses a relative path from config/ to dts/src/. */ function prepareWildcardFixture(subDirNames: string[]) { @@ -431,10 +431,6 @@ describe('GenerateApi Executor – wildcard export expansion', () => { 'utf-8', ); - for (const name of subDirNames) { - mkdirSync(join(projRoot, 'src', 'items', name), { recursive: true }); - } - execSyncMock.mockImplementation(() => { mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); @@ -537,7 +533,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); // primary only }); - it('skips wildcard expansion when the resolved source directory does not exist', async () => { + it('skips wildcard expansion when the resolved declaration directory does not exist', async () => { const { paths, context } = prepareFixture('valid', {}); const { projRoot } = paths; @@ -569,7 +565,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { execSyncMock.mockImplementation(() => { mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const x: 1;', 'utf-8'); - // src/items/ intentionally NOT created + // dts/src/items/ intentionally NOT created }); const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( @@ -585,7 +581,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { expect(output.success).toBe(true); }); - it('routes api report for each wildcard component to etc/{componentName}.api.md', async () => { + it('routes api report for each wildcard entry to etc/{name}.api.md', async () => { const subDirs = ['alpha', 'beta']; const { paths, context } = prepareWildcardFixture(subDirs); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index ef59be52c0020e..08be685a1960eb 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -9,7 +9,7 @@ import type { GenerateApiExecutorSchema } from './schema'; import type { PackageJson, TsConfig } from '../../types'; import { measureEnd, measureStart } from '../../utils'; import { isCI } from './lib/shared'; -import { listAdditionalApiExtractorConfigs, hasWildcardTypedExport } from './utils'; +import { listAdditionalApiExtractorConfigs, hasWildcardTypedExport, isWildcardTypedEntry } from './utils'; const runExecutor: PromiseExecutor = async (schema, context) => { measureStart('GenerateApiExecutor'); @@ -52,7 +52,7 @@ async function runGenerateApi(options: NormalizedOptions, context: ExecutorConte if (options.enableWildcardExpansion) { const wildcardConfigs = getWildcardExportConfigs(options); for (const configObject of wildcardConfigs) { - verboseLog(`Running api-extractor for wildcard component: ${configObject.mainEntryPointFilePath}`); + verboseLog(`Running api-extractor for wildcard entry: ${configObject.mainEntryPointFilePath}`); if (!apiExtractor({ configObject }, options, context)) { return false; } @@ -124,24 +124,13 @@ function apiExtractor( options: NormalizedOptions, context: ExecutorContext, ) { - let rawExtractorConfig: IConfigFile; - let configObjectFullPath: string; - - if ('configPath' in configSource) { - rawExtractorConfig = ExtractorConfig.loadFile(configSource.configPath); - configObjectFullPath = configSource.configPath; - } else { - rawExtractorConfig = configSource.configObject; - // For programmatically-built configs, reuse the primary config path as the resolution base - // so that token resolution matches the same directory as file-based configs. - configObjectFullPath = options.config; - } + const { rawConfig, fullPath } = resolveConfigSource(); // Load,parse,customize and prepare the api-extractor.json file for API Extractor API - customizeExtractorConfig(rawExtractorConfig); + customizeExtractorConfig(rawConfig); const extractorConfig = ExtractorConfig.prepare({ - configObject: rawExtractorConfig, - configObjectFullPath, + configObject: rawConfig, + configObjectFullPath: fullPath, packageJsonFullPath: options.packageJsonPath, }); @@ -166,6 +155,25 @@ function apiExtractor( ); return false; + /** + * Resolves the config source into a raw IConfigFile and the full path used for token resolution. + * File-based sources are loaded from disk; programmatic configs reuse the primary config path. + */ + function resolveConfigSource(): { rawConfig: IConfigFile; fullPath: string } { + if ('configPath' in configSource) { + return { + rawConfig: ExtractorConfig.loadFile(configSource.configPath), + fullPath: configSource.configPath, + }; + } + + return { + rawConfig: configSource.configObject, + // Reuse the primary config path so that token resolution matches file-based configs. + fullPath: options.config, + }; + } + function customizeExtractorConfig(apiExtractorConfig: IConfigFile) { apiExtractorConfig.compiler = getTsConfigForApiExtractor({ packageJson: parseJson(readFileSync(options.packageJsonPath, 'utf-8')), @@ -177,128 +185,177 @@ function apiExtractor( } } -/** /** * Reads the package.json exports map and expands wildcard entries (e.g. "./*") into individual * api-extractor config objects — one per source directory found under the resolved source path. * - * Example: "./*" with types "./dist/components/STAR/index.d.ts" expands to one config per - * directory found in "src/components/". + * Example: "./*" with types "./dist/items/STAR/index.d.ts" expands to one config per + * directory found in "src/items/". */ function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { const packageJson: PackageJson = parseJson(readFileSync(options.packageJsonPath, 'utf-8')); const exports = packageJson.exports ?? {}; - const configs: IConfigFile[] = []; if (!hasWildcardTypedExport(exports as Record)) { - return configs; + return []; } - // Derive the declaration base path from the primary api-extractor config's mainEntryPointFilePath. - // The primary config uses tokens (, , ) which we - // resolve here so that programmatic configs for wildcard entries land in the same output tree. - const primaryRawConfig = parseJson<{ mainEntryPointFilePath?: string }>(readFileSync(options.config, 'utf-8')); - const primaryMainEntryTemplate = primaryRawConfig?.mainEntryPointFilePath; - if (!primaryMainEntryTemplate) { - return configs; + const declarationBase = resolveDeclarationBase(options, packageJson); + if (!declarationBase) { + return []; } - const unscopedPackageName = (packageJson.name ?? '').replace(/^@[^/]+\//, ''); - // and in api-extractor.json are NOT replaced before path.resolve — - // they act as literal path segments that the subsequent "../" chain traverses through. - // path.resolve(configDir, "/../../../../../../...") naturally normalizes to the correct path. - const configDir = dirname(options.config); - const resolvedPrimaryEntry = resolve( - configDir, - primaryMainEntryTemplate.replace(//g, unscopedPackageName), - ); - - // The resolved primary entry ends with /src/index.d.ts; everything before is the "declaration base" - // e.g. .../dist/out-tsc/types/packages/react-components/react-headless-components-preview/library - const srcIndexSuffix = '/src/index.d.ts'; - if (!resolvedPrimaryEntry.endsWith(srcIndexSuffix)) { - verboseLog( - `Primary mainEntryPointFilePath "${resolvedPrimaryEntry}" does not end with "${srcIndexSuffix}". ` + - `Skipping wildcard export expansion.`, - 'warn', - ); - return configs; - } - const declarationBase = resolvedPrimaryEntry.slice(0, -srcIndexSuffix.length); + const configs: IConfigFile[] = []; for (const [exportKey, exportValue] of Object.entries(exports)) { - // Only process wildcard entries that have a `types` field - if (!exportKey.includes('*')) { + if (!isWildcardTypedEntry(exportKey, exportValue)) { continue; } - if (typeof exportValue !== 'object' || !exportValue.types) { + + const pathPrefixes = parseWildcardTypesPattern(exportValue.types); + if (!pathPrefixes) { continue; } - // e.g. types = "./dist/components/*/index.d.ts" - // Split on the wildcard to get prefix/suffix: ["./dist/components/", "/index.d.ts"] - const typePattern = exportValue.types; - const starIdx = typePattern.indexOf('*'); - if (starIdx === -1) { + const declarationScanDir = join(declarationBase, pathPrefixes.wildcardSubPath); + const subDirs = listSubDirectories(declarationScanDir); + if (!subDirs) { continue; } - const typesPrefix = typePattern.slice(0, starIdx); // "./dist/components/" - // Derive the source path pattern from the types pattern: - // "./dist/components/*/index.d.ts" → "src/components/*/index.d.ts" - // Strip the leading "./" from typesPrefix to get the relative dist path + for (const dirName of subDirs) { + configs.push( + createWildcardEntryConfig({ + projectAbsolutePath: options.projectAbsolutePath, + declarationBase, + wildcardSubPath: pathPrefixes.wildcardSubPath, + distRelativePrefix: pathPrefixes.distRelativePrefix, + dirName, + }), + ); + } + } + + return configs; + + /** + * Resolves the declaration base path from the primary api-extractor config's mainEntryPointFilePath. + * The primary config uses tokens (, , ) which we + * resolve here so that programmatic configs for wildcard entries land in the same output tree. + * + * @returns The absolute path to the declaration base, or `null` if it cannot be resolved. + */ + function resolveDeclarationBase(opts: NormalizedOptions, pkgJson: PackageJson): string | null { + const primaryRawConfig = parseJson<{ mainEntryPointFilePath?: string }>(readFileSync(opts.config, 'utf-8')); + const primaryMainEntryTemplate = primaryRawConfig?.mainEntryPointFilePath; + if (!primaryMainEntryTemplate) { + return null; + } + + const unscopedPackageName = (pkgJson.name ?? '').replace(/^@[^/]+\//, ''); + // and in api-extractor.json are NOT replaced before path.resolve — + // they act as literal path segments that the subsequent "../" chain traverses through. + // path.resolve(configDir, "/../../../../../../...") naturally normalizes to the correct path. + const configDir = dirname(opts.config); + const resolvedPrimaryEntry = resolve( + configDir, + primaryMainEntryTemplate.replace(//g, unscopedPackageName), + ); + + const indexDtsSuffix = '/index.d.ts'; + if (!resolvedPrimaryEntry.endsWith(indexDtsSuffix)) { + verboseLog( + `Primary mainEntryPointFilePath "${resolvedPrimaryEntry}" does not end with "${indexDtsSuffix}". ` + + `Skipping wildcard export expansion.`, + 'warn', + ); + return null; + } + + return resolvedPrimaryEntry.slice(0, -indexDtsSuffix.length); + } + + /** + * Parses a wildcard types pattern and derives the dist-relative prefix and the + * wildcard sub-path (the portion after the first path segment). + * + * Example: "./dist/items/STAR/index.d.ts" + * → distRelativePrefix: "dist/items/" + * → wildcardSubPath: "items/" + * + * @returns The path prefixes, or `null` if the pattern cannot be parsed. + */ + function parseWildcardTypesPattern(typesPattern: string): { + distRelativePrefix: string; + wildcardSubPath: string; + } | null { + const starIdx = typesPattern.indexOf('*'); + if (starIdx === -1) { + return null; + } + + const typesPrefix = typesPattern.slice(0, starIdx); const distRelativePrefix = typesPrefix.replace(/^\.\//, ''); - // Replace leading "dist/" with "src/" to find source files - const srcRelativePrefix = distRelativePrefix.replace(/^dist\//, 'src/'); - const srcComponentsDir = join(options.projectAbsolutePath, srcRelativePrefix); - if (!existsSync(srcComponentsDir)) { - verboseLog(`Wildcard export source dir not found, skipping: ${srcComponentsDir}`, 'warn'); - continue; + // Extract the sub-path by stripping the first path segment (the dist directory name). + // e.g. "dist/items/" → "items/" + const firstSlashIdx = distRelativePrefix.indexOf('/'); + const wildcardSubPath = firstSlashIdx === -1 ? '' : distRelativePrefix.slice(firstSlashIdx + 1); + + return { distRelativePrefix, wildcardSubPath }; + } + + /** + * Lists immediate sub-directories of the given path. + * + * @returns Array of directory names, or `null` if the path does not exist or cannot be read. + */ + function listSubDirectories(dirPath: string): string[] | null { + if (!existsSync(dirPath)) { + verboseLog(`Wildcard export source dir not found, skipping: ${dirPath}`, 'warn'); + return null; } - let componentDirs: string[]; try { - componentDirs = readdirSync(srcComponentsDir, { withFileTypes: true }) + return readdirSync(dirPath, { withFileTypes: true }) .filter(e => e.isDirectory()) .map(e => e.name); } catch { - continue; - } - - for (const componentName of componentDirs) { - // declarationBase ends at the package root (e.g. .../library), declaration files live at - // declarationBase/src/components/{name}/index.d.ts - const mainEntryPointFilePath = join(declarationBase, srcRelativePrefix, componentName, 'index.d.ts'); - - // Construct the dts rollup output path - // typesPrefix is e.g. "./dist/components/" → projectAbsolutePath/dist/components/{name}/index.d.ts - const dtsRollupPath = join(options.projectAbsolutePath, distRelativePrefix, componentName, 'index.d.ts'); - - const configObject: IConfigFile = { - // projectFolder must be set for programmatic configs (no implicit derivation from config file path) - projectFolder: options.projectAbsolutePath, - mainEntryPointFilePath, - apiReport: { - enabled: true, - reportFileName: componentName, - reportFolder: '/etc/', - reportTempFolder: '/temp/', - }, - docModel: { enabled: false }, - dtsRollup: { - enabled: true, - untrimmedFilePath: dtsRollupPath, - }, - tsdocMetadata: { enabled: false }, - }; - - configs.push(configObject); + return null; } } - return configs; + /** + * Creates an api-extractor IConfigFile for a single wildcard sub-directory entry. + */ + function createWildcardEntryConfig(params: { + projectAbsolutePath: string; + declarationBase: string; + wildcardSubPath: string; + distRelativePrefix: string; + dirName: string; + }): IConfigFile { + const mainEntryPointFilePath = join(params.declarationBase, params.wildcardSubPath, params.dirName, 'index.d.ts'); + const dtsRollupPath = join(params.projectAbsolutePath, params.distRelativePrefix, params.dirName, 'index.d.ts'); + + return { + projectFolder: params.projectAbsolutePath, + mainEntryPointFilePath, + apiReport: { + enabled: true, + reportFileName: params.dirName, + reportFolder: '/etc/', + reportTempFolder: '/temp/', + }, + docModel: { enabled: false }, + dtsRollup: { + enabled: true, + untrimmedFilePath: dtsRollupPath, + }, + tsdocMetadata: { enabled: false }, + }; + } } function getTsConfigForApiExtractor(options: { diff --git a/tools/workspace-plugin/src/executors/generate-api/utils.ts b/tools/workspace-plugin/src/executors/generate-api/utils.ts index 03fc03e9a450a5..b10183af2ed413 100644 --- a/tools/workspace-plugin/src/executors/generate-api/utils.ts +++ b/tools/workspace-plugin/src/executors/generate-api/utils.ts @@ -23,6 +23,16 @@ export function listAdditionalApiExtractorConfigs(configDir: string, primaryConf .filter(p => p !== primaryConfigPath); } +/** + * Checks whether a single export map entry is a wildcard entry with a `types` field. + */ +export function isWildcardTypedEntry( + exportKey: string, + exportValue: unknown, +): exportValue is { types: string } & Record { + return exportKey.includes('*') && typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; +} + /** * Returns `true` when the package.json `exports` map contains at least one wildcard key (e.g. * `"./*"`) whose value is an object with a `types` field. @@ -31,7 +41,5 @@ export function hasWildcardTypedExport(exports: Record | undefi if (!exports) { return false; } - return Object.entries(exports).some( - ([key, val]) => key.includes('*') && typeof val === 'object' && val !== null && 'types' in val, - ); + return Object.entries(exports).some(([key, val]) => isWildcardTypedEntry(key, val)); } From 03f0c3962b3a5c7ad4a04a2a573b8cc6c2bf1f49 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 16 Apr 2026 19:51:14 +0200 Subject: [PATCH 05/13] refactor: simplifyu generate-api input/output cache creation within ws plugin --- .../src/plugins/workspace-plugin.spec.ts | 65 +++++++++++++++++++ .../src/plugins/workspace-plugin.ts | 35 ++-------- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts index c6adde7aaa4b5f..5d8a57db7c1a85 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts @@ -821,6 +821,71 @@ describe(`workspace-plugin`, () => { `); }); + describe('generate-api target', () => { + const v9LibFiles = { + 'proj/project.json': serializeJson({ + root: 'proj', + name: 'proj', + projectType: 'library', + tags: ['vNext'], + } satisfies ProjectConfiguration), + 'proj/package.json': serializeJson({ + name: '@proj/proj', + private: true, + } satisfies Partial), + }; + + async function setupAndGetGenerateApiTarget(extraFiles: Record = {}) { + await tempFs.createFiles({ ...v9LibFiles, ...extraFiles }); + const results = await createNodesFunction(['proj/project.json'], options, context); + return getTargets(results)?.['generate-api']; + } + + it('should use default inputs and outputs for a standard v9 library', async () => { + const target = await setupAndGetGenerateApiTarget(); + + expect(target?.inputs).toEqual([ + '{projectRoot}/config/api-extractor.json', + '{projectRoot}/tsconfig.json', + '{projectRoot}/tsconfig.lib.json', + '{projectRoot}/src/**/*.tsx?', + '{workspaceRoot}/scripts/api-extractor/api-extractor.*.json', + { externalDependencies: ['@microsoft/api-extractor', 'typescript'] }, + ]); + expect(target?.outputs).toEqual(['{projectRoot}/dist/index.d.ts', '{projectRoot}/etc/proj.api.md']); + }); + + it('should add glob outputs when additional api-extractor configs exist', async () => { + const target = await setupAndGetGenerateApiTarget({ + 'proj/config/api-extractor.json': '{}', + 'proj/config/api-extractor.utils.json': serializeJson({ + apiReport: { enabled: true, reportFileName: 'utils' }, + dtsRollup: { untrimmedFilePath: '/dist/utils/index.d.ts' }, + }), + }); + + expect(target?.inputs).toContain('{projectRoot}/config/api-extractor.utils.json'); + expect(target?.outputs).toContain('{projectRoot}/dist/**/*.d.ts'); + expect(target?.outputs).toContain('{projectRoot}/etc/*.api.md'); + }); + + it('should add glob outputs when package has wildcard typed exports', async () => { + const target = await setupAndGetGenerateApiTarget({ + 'proj/package.json': serializeJson({ + name: '@proj/proj', + private: true, + exports: { + '.': { types: './dist/index.d.ts' }, + './*': { types: './dist/components/*/index.d.ts' }, + }, + } satisfies Partial), + }); + + expect(target?.outputs).toContain('{projectRoot}/dist/**/*.d.ts'); + expect(target?.outputs).toContain('{projectRoot}/etc/*.api.md'); + }); + }); + it('should add verify-packaging task only if package is not private', async () => { await tempFs.createFiles({ 'proj/project.json': serializeJson({ diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.ts index 442edf2691a5a5..6c608d35fd5b08 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.ts @@ -298,7 +298,6 @@ function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): const extraInputs: string[] = []; const extraOutputs: string[] = []; - // Pick up additional api-extractor.*.json configs (sub-path entries, e.g. api-extractor.utils.json) const configDir = join(projectRoot, 'config'); const primaryConfigPath = join(configDir, 'api-extractor.json'); const additionalConfigFiles = listAdditionalApiExtractorConfigs(configDir, primaryConfigPath); @@ -306,36 +305,14 @@ function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): for (const absPath of additionalConfigFiles) { const configFile = absPath.slice(configDir.length + 1); // filename only extraInputs.push(`{projectRoot}/config/${configFile}`); - // Parse the config to derive outputs (dtsRollup untrimmedFilePath + apiReport reportFileName) - try { - const parsed = readJsonFile<{ - dtsRollup?: { untrimmedFilePath?: string }; - apiReport?: { enabled?: boolean; reportFileName?: string }; - }>(absPath); - if (parsed.dtsRollup?.untrimmedFilePath) { - // untrimmedFilePath may contain api-extractor tokens like — replace with {projectRoot} - extraOutputs.push( - parsed.dtsRollup.untrimmedFilePath - .replace('', '{projectRoot}') - .replace('', '{projectRoot}'), - ); - } - if (parsed.apiReport?.enabled && parsed.apiReport.reportFileName) { - const unscopedPackageName = (config.packageJSON.name ?? '').replace(/^@[^/]+\//, ''); - const resolvedReportFileName = parsed.apiReport.reportFileName.replace( - //g, - unscopedPackageName, - ); - extraOutputs.push(`{projectRoot}/etc/${resolvedReportFileName}.api.md`); - } - } catch { - // ignore parse errors; outputs will just be incomplete for this file - } } - // Wildcard exports: if the package.json has a wildcard export key with a types field, - // the executor will expand it at runtime — add the dist glob and api report glob as outputs so nx can track them. - if (hasWildcardTypedExport(config.packageJSON.exports as Record)) { + const hasExtraEntryPoints = + additionalConfigFiles.length > 0 || hasWildcardTypedExport(config.packageJSON.exports as Record); + + // When any additional or wildcard entry points exist, use broad globs for outputs + // — the executor resolves exact paths at runtime. + if (hasExtraEntryPoints) { extraOutputs.push('{projectRoot}/dist/**/*.d.ts'); extraOutputs.push('{projectRoot}/etc/*.api.md'); } From 42449449ab01d9eecb48c7fc658762d15151acb5 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 09:02:13 +0200 Subject: [PATCH 06/13] chore: syntax update --- tools/workspace-plugin/src/executors/generate-api/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/workspace-plugin/src/executors/generate-api/utils.ts b/tools/workspace-plugin/src/executors/generate-api/utils.ts index b10183af2ed413..f086f3c3dae49b 100644 --- a/tools/workspace-plugin/src/executors/generate-api/utils.ts +++ b/tools/workspace-plugin/src/executors/generate-api/utils.ts @@ -18,9 +18,9 @@ export function listAdditionalApiExtractorConfigs(configDir: string, primaryConf return []; } return entries - .filter(f => /^api-extractor\..+\.json$/.test(f)) - .map(f => join(configDir, f)) - .filter(p => p !== primaryConfigPath); + .filter(filename => /^api-extractor\..+\.json$/.test(filename)) + .map(filename => join(configDir, filename)) + .filter(filepath => filepath !== primaryConfigPath); } /** From c383f37ae5fffb05c033eb5a0ef50ff130260f4c Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 10:48:29 +0200 Subject: [PATCH 07/13] feat(workspace-plugin): make wildcard expansion opt in and support generateApi config propagation from build->generate-api target --- .../src/executors/build/executor.ts | 10 +++++- .../src/executors/build/schema.d.ts | 2 +- .../src/executors/build/schema.json | 16 +++++++-- .../executors/generate-api/executor.spec.ts | 34 +++++++++++++----- .../src/executors/generate-api/executor.ts | 4 +-- .../src/executors/generate-api/schema.d.ts | 2 +- .../src/executors/generate-api/schema.json | 6 ++-- .../src/plugins/workspace-plugin.spec.ts | 36 ++++++++++++------- .../src/plugins/workspace-plugin.ts | 11 ++++-- 9 files changed, 88 insertions(+), 33 deletions(-) diff --git a/tools/workspace-plugin/src/executors/build/executor.ts b/tools/workspace-plugin/src/executors/build/executor.ts index 668a93c3300dcd..8c14640b6a3f0a 100644 --- a/tools/workspace-plugin/src/executors/build/executor.ts +++ b/tools/workspace-plugin/src/executors/build/executor.ts @@ -8,6 +8,7 @@ import { NormalizedOptions, normalizeOptions, processAsyncQueue, runInParallel, import { measureEnd, measureStart } from '../../utils'; import generateApiExecutor from '../generate-api/executor'; +import { type GenerateApiExecutorSchema } from '../generate-api/schema'; import { type BuildExecutorSchema } from './schema'; @@ -22,7 +23,14 @@ const runExecutor: PromiseExecutor = async (schema, context () => runInParallel( () => runBuild(options, context), - () => (options.generateApi ? generateApiExecutor({}, context).then(res => res.success) : Promise.resolve(true)), + () => { + if (!options.generateApi) { + return Promise.resolve(true); + } + const generateApiSchema: GenerateApiExecutorSchema = + typeof options.generateApi === 'object' ? options.generateApi : {}; + return generateApiExecutor(generateApiSchema, context).then(res => res.success); + }, ), () => copyAssets(assetFiles), ); diff --git a/tools/workspace-plugin/src/executors/build/schema.d.ts b/tools/workspace-plugin/src/executors/build/schema.d.ts index 6fd6ea036aebdd..fa79c57c2ee026 100644 --- a/tools/workspace-plugin/src/executors/build/schema.d.ts +++ b/tools/workspace-plugin/src/executors/build/schema.d.ts @@ -33,7 +33,7 @@ export interface BuildExecutorSchema { /** * Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API */ - generateApi?: boolean; + generateApi?: boolean | { resolveExportWildcards?: boolean }; /** * Enable Griffel raw styles output. * This will generate additional files with '.styles.raw.js' extension that contain Griffel raw styles diff --git a/tools/workspace-plugin/src/executors/build/schema.json b/tools/workspace-plugin/src/executors/build/schema.json index 07738deab70c83..3e6deaf608cda1 100644 --- a/tools/workspace-plugin/src/executors/build/schema.json +++ b/tools/workspace-plugin/src/executors/build/schema.json @@ -29,8 +29,20 @@ } }, "generateApi": { - "type": "boolean", - "description": "Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API", + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "resolveExportWildcards": { + "type": "boolean", + "description": "Whether to resolve wildcard export map entries (e.g. './*') and run api-extractor for each resolved sub-directory." + } + }, + "additionalProperties": false + } + ], + "description": "Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API. Pass an object to configure generate-api executor options.", "default": true }, "enableGriffelRawStyles": { diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 836f66c92fa4e6..465080e59fc604 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -458,7 +458,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } as ExtractorResult), ); - const output = await executor(options, context); + const output = await executor({ ...options, resolveExportWildcards: true }, context); // primary (1) + one per sub-directory expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); @@ -475,7 +475,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { return { succeeded: true } as ExtractorResult; }); - await executor(options, context); + await executor({ ...options, resolveExportWildcards: true }, context); const wildcardEntries = capturedEntries.slice(1); // skip primary for (const name of subDirs) { @@ -492,7 +492,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { return { succeeded: true } as ExtractorResult; }); - await executor(options, context); + await executor({ ...options, resolveExportWildcards: true }, context); const wildcardConfig = capturedConfigs[1]; // second call is the wildcard entry expect(wildcardConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'items', 'alpha', 'index.d.ts')); @@ -591,7 +591,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { return { succeeded: true } as ExtractorResult; }); - await executor(options, context); + await executor({ ...options, resolveExportWildcards: true }, context); const wildcardConfigs = capturedConfigs.slice(1); // skip primary for (const name of subDirs) { @@ -602,7 +602,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } }); - it('skips wildcard expansion when enableWildcardExpansion is false', async () => { + it('skips wildcard expansion when resolveExportWildcards is false', async () => { const subDirs = ['alpha', 'beta']; const { context } = prepareWildcardFixture(subDirs); @@ -613,14 +613,14 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } as ExtractorResult), ); - const output = await executor({ ...options, enableWildcardExpansion: false }, context); + const output = await executor({ ...options, resolveExportWildcards: false }, context); // Only the primary config should run — no wildcard expansion expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); expect(output.success).toBe(true); }); - it('expands wildcards by default (enableWildcardExpansion not set)', async () => { + it('skips wildcard expansion by default (resolveExportWildcards not set)', async () => { const subDirs = ['alpha', 'beta']; const { context } = prepareWildcardFixture(subDirs); @@ -631,9 +631,27 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } as ExtractorResult), ); - // Pass default options — no enableWildcardExpansion key + // Pass default options — no resolveExportWildcards key const output = await executor(options, context); + // Only the primary config should run — wildcard expansion is opt-in + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); + expect(output.success).toBe(true); + }); + + it('expands wildcards when resolveExportWildcards is true', async () => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareWildcardFixture(subDirs); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor({ ...options, resolveExportWildcards: true }, context); + // primary (1) + one per sub-directory expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); expect(output.success).toBe(true); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index 08be685a1960eb..1678f5f49a266a 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -49,7 +49,7 @@ async function runGenerateApi(options: NormalizedOptions, context: ExecutorConte } // Expand wildcard exports and run api-extractor for each resolved component dir - if (options.enableWildcardExpansion) { + if (options.resolveExportWildcards) { const wildcardConfigs = getWildcardExportConfigs(options); for (const configObject of wildcardConfigs) { verboseLog(`Running api-extractor for wildcard entry: ${configObject.mainEntryPointFilePath}`); @@ -67,7 +67,7 @@ function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorCo config: '{projectRoot}/config/api-extractor.json', local: true, diagnostics: false, - enableWildcardExpansion: true, + resolveExportWildcards: false, }; const resolvedSchema = { ...defaults, ...schema }; diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts index 8283aa47ec9353..abb8fce457248e 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts +++ b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts @@ -2,5 +2,5 @@ export interface GenerateApiExecutorSchema { config?: string; local?: boolean; diagnostics?: boolean; - enableWildcardExpansion?: boolean; + resolveExportWildcards?: boolean; } diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.json b/tools/workspace-plugin/src/executors/generate-api/schema.json index 65ad37dd857759..c6ecf14ebc7e03 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.json +++ b/tools/workspace-plugin/src/executors/generate-api/schema.json @@ -20,10 +20,10 @@ "description": "Show diagnostic messages used for troubleshooting problems with API Extractor", "default": false }, - "enableWildcardExpansion": { + "resolveExportWildcards": { "type": "boolean", - "description": "Whether to expand wildcard export map entries (e.g. './*') and run api-extractor for each resolved sub-directory. When false, only the primary config and explicit sub-path configs are processed.", - "default": true + "description": "Whether to resolve wildcard export map entries (e.g. './*') and run api-extractor for each resolved sub-directory. When false, only the primary config and explicit sub-path configs are processed.", + "default": false } }, "required": [] diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts index 5d8a57db7c1a85..1b7d4215e6577b 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts @@ -869,20 +869,32 @@ describe(`workspace-plugin`, () => { expect(target?.outputs).toContain('{projectRoot}/etc/*.api.md'); }); - it('should add glob outputs when package has wildcard typed exports', async () => { - const target = await setupAndGetGenerateApiTarget({ - 'proj/package.json': serializeJson({ - name: '@proj/proj', - private: true, - exports: { - '.': { types: './dist/index.d.ts' }, - './*': { types: './dist/components/*/index.d.ts' }, + it('should add glob outputs when user configures resolveExportWildcards on generate-api target', async () => { + const extraFiles = { + 'proj/project.json': serializeJson({ + root: 'proj', + name: 'proj', + projectType: 'library', + tags: ['vNext'], + targets: { + 'generate-api': { + options: { + resolveExportWildcards: true, + }, + }, }, - } satisfies Partial), - }); + } satisfies ProjectConfiguration), + }; + await tempFs.createFiles({ ...v9LibFiles, ...extraFiles }); + const results = await createNodesFunction(['proj/project.json'], options, context); + const targets = getTargets(results); - expect(target?.outputs).toContain('{projectRoot}/dist/**/*.d.ts'); - expect(target?.outputs).toContain('{projectRoot}/etc/*.api.md'); + const generateApiTarget = targets?.['generate-api']; + expect(generateApiTarget?.outputs).toContain('{projectRoot}/dist/**/*.d.ts'); + expect(generateApiTarget?.outputs).toContain('{projectRoot}/etc/*.api.md'); + + const buildTarget = targets?.['build']; + expect(buildTarget?.options?.generateApi).toEqual({ resolveExportWildcards: true }); }); }); diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.ts index 6c608d35fd5b08..7d6ad369d7f545 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.ts @@ -23,7 +23,7 @@ import { buildCleanTarget } from './clean-plugin'; import { buildFormatTarget } from './format-plugin'; import { buildTypeCheckTarget } from './type-check-plugin'; import { measureStart, measureEnd } from '../utils'; -import { listAdditionalApiExtractorConfigs, hasWildcardTypedExport } from '../executors/generate-api/utils'; +import { listAdditionalApiExtractorConfigs } from '../executors/generate-api/utils'; export interface WorkspacePluginOptions { testSSR?: TargetPluginOption; @@ -204,6 +204,9 @@ function buildWorkspaceProjectConfiguration( targets['generate-api'] = buildGenerateApiTarget(projectRoot, config); + const userEnabledResolveExportWildcards = + config.projectJSON.targets?.['generate-api']?.options?.resolveExportWildcards === true; + targets.build = { cache: true, executor: '@fluentui/workspace-plugin:build', @@ -216,6 +219,7 @@ function buildWorkspaceProjectConfiguration( config.tags.includes('ships-amd') ? { module: 'amd', outputPath: 'lib-amd' } : null, ].filter(Boolean) as BuildExecutorSchema['moduleOutput'], enableGriffelRawStyles: true, + ...(userEnabledResolveExportWildcards ? { generateApi: { resolveExportWildcards: true } } : {}), // NOTE: assets should be set per project needs // assets: [], } satisfies BuildExecutorSchema, @@ -269,6 +273,8 @@ function buildWorkspaceProjectConfiguration( } function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): TargetConfiguration { + const resolveExportWildcards = config.projectJSON.targets?.['generate-api']?.options?.resolveExportWildcards === true; + const { extraInputs, extraOutputs } = buildExtraInputsAndOutputsForApiExtractorConfigs(); return { @@ -307,8 +313,7 @@ function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): extraInputs.push(`{projectRoot}/config/${configFile}`); } - const hasExtraEntryPoints = - additionalConfigFiles.length > 0 || hasWildcardTypedExport(config.packageJSON.exports as Record); + const hasExtraEntryPoints = additionalConfigFiles.length > 0 || resolveExportWildcards; // When any additional or wildcard entry points exist, use broad globs for outputs // — the executor resolves exact paths at runtime. From 3a21e842711756e0f71a2c596dd8f4bb649b8fb8 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 13:26:52 +0200 Subject: [PATCH 08/13] feat(wsp): make sub-path api generation fully automatic without need of additional api-extractor configuration, remove initially boostrap nx generator which doesn't cover wilcards --- tools/workspace-plugin/generators.json | 5 - .../src/executors/build/schema.d.ts | 2 +- .../src/executors/build/schema.json | 18 +- .../executors/generate-api/executor.spec.ts | 320 ++++++++++-------- .../src/executors/generate-api/executor.ts | 160 ++++++--- .../src/executors/generate-api/schema.d.ts | 2 +- .../src/executors/generate-api/schema.json | 19 +- .../src/executors/generate-api/utils.ts | 37 +- .../add-export-subpath/index.spec.ts | 275 --------------- .../generators/add-export-subpath/index.ts | 137 -------- .../generators/add-export-subpath/schema.json | 31 -- .../generators/add-export-subpath/schema.ts | 6 - .../src/plugins/workspace-plugin.spec.ts | 20 +- .../src/plugins/workspace-plugin.ts | 52 ++- 14 files changed, 364 insertions(+), 720 deletions(-) delete mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts delete mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/index.ts delete mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/schema.json delete mode 100644 tools/workspace-plugin/src/generators/add-export-subpath/schema.ts diff --git a/tools/workspace-plugin/generators.json b/tools/workspace-plugin/generators.json index d0a4555714958c..3a882f4f0b4220 100644 --- a/tools/workspace-plugin/generators.json +++ b/tools/workspace-plugin/generators.json @@ -1,10 +1,5 @@ { "generators": { - "add-export-subpath": { - "implementation": "./src/generators/add-export-subpath/index.ts", - "schema": "./src/generators/add-export-subpath/schema.json", - "description": "Add a named sub-path export to a vNext library and scaffold the corresponding api-extractor config for dts rollup generation." - }, "bundle-size-configuration": { "factory": "./src/generators/bundle-size-configuration/generator", "schema": "./src/generators/bundle-size-configuration/schema.json", diff --git a/tools/workspace-plugin/src/executors/build/schema.d.ts b/tools/workspace-plugin/src/executors/build/schema.d.ts index fa79c57c2ee026..5316c9a4054f76 100644 --- a/tools/workspace-plugin/src/executors/build/schema.d.ts +++ b/tools/workspace-plugin/src/executors/build/schema.d.ts @@ -33,7 +33,7 @@ export interface BuildExecutorSchema { /** * Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API */ - generateApi?: boolean | { resolveExportWildcards?: boolean }; + generateApi?: boolean | { exportSubpaths?: boolean | { apiReport?: boolean } }; /** * Enable Griffel raw styles output. * This will generate additional files with '.styles.raw.js' extension that contain Griffel raw styles diff --git a/tools/workspace-plugin/src/executors/build/schema.json b/tools/workspace-plugin/src/executors/build/schema.json index 3e6deaf608cda1..b327a9df840088 100644 --- a/tools/workspace-plugin/src/executors/build/schema.json +++ b/tools/workspace-plugin/src/executors/build/schema.json @@ -34,9 +34,21 @@ { "type": "object", "properties": { - "resolveExportWildcards": { - "type": "boolean", - "description": "Whether to resolve wildcard export map entries (e.g. './*') and run api-extractor for each resolved sub-directory." + "exportSubpaths": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "apiReport": { + "type": "boolean", + "description": "Whether to generate api.md reports for each resolved sub-path entry." + } + }, + "additionalProperties": false + } + ], + "description": "Whether to read non-root export map entries from package.json and run api-extractor for each resolved sub-path." } }, "additionalProperties": false diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 465080e59fc604..7006e336da7e4c 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -234,174 +234,111 @@ describe('GenerateApi Executor', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// Additional file-based sub-path configs (api-extractor.*.json) +// Export subpath resolution // ───────────────────────────────────────────────────────────────────────────── -describe('GenerateApi Executor – additional sub-path configs', () => { +describe('GenerateApi Executor – export subpath resolution', () => { afterEach(() => { cleanup(); }); /** - * Extends the base "valid" fixture with a second api-extractor config - * (e.g. api-extractor.utils.json) to simulate a named sub-path entry. + * Creates a fixture with a wildcard export "./*" whose types pattern resolves + * to one emitted .d.ts per sub-directory under dts/src/items/. + * The primary api-extractor.json uses a relative path from config/ to dts/src/. */ - function prepareFixtureWithSubpathConfig() { + function prepareWildcardFixture(subDirNames: string[]) { const { paths, context } = prepareFixture('valid', {}); const { projRoot } = paths; - // Write the sub-path api-extractor config that references an already-emitted dts file writeFileSync( - join(projRoot, 'config', 'api-extractor.utils.json'), + join(projRoot, 'package.json'), + serializeJson({ + name: '@proj/proj', + types: 'dist/index.d.ts', + exports: { + '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + './*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }, + './package.json': './package.json', + }, + }), + 'utf-8', + ); + + writeFileSync( + join(projRoot, 'config', 'api-extractor.json'), serializeJson({ - mainEntryPointFilePath: '/dts/utils/index.d.ts', + mainEntryPointFilePath: '../dts/src/index.d.ts', apiReport: { enabled: false }, docModel: { enabled: false }, - dtsRollup: { enabled: true, untrimmedFilePath: '/dist/utils/index.d.ts' }, + dtsRollup: { enabled: true }, tsdocMetadata: { enabled: false }, }), 'utf-8', ); execSyncMock.mockImplementation(() => { - // Simulate tsc emitting declaration files for both main and utils entry - mkdirSync(join(projRoot, 'dts', 'utils'), { recursive: true }); - writeFileSync(join(projRoot, 'dts', 'index.d.ts'), 'export const foo: number;', 'utf-8'); - writeFileSync(join(projRoot, 'dts', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); + mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); + for (const name of subDirNames) { + mkdirSync(join(projRoot, 'dts', 'src', 'items', name), { recursive: true }); + writeFileSync( + join(projRoot, 'dts', 'src', 'items', name, 'index.d.ts'), + `export const value: string;`, + 'utf-8', + ); + } }); return { paths, context }; } - it('invokes api-extractor twice when a sub-path config is present', async () => { - const { context } = prepareFixtureWithSubpathConfig(); - - const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( - () => - ({ - succeeded: true, - } as ExtractorResult), - ); - - const output = await executor(options, context); - - // Should have been called once for primary + once for utils - expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(2); - expect(output.success).toBe(true); - }); - - it('passes the sub-path mainEntryPointFilePath to api-extractor', async () => { - const { paths, context } = prepareFixtureWithSubpathConfig(); - - const extractorConfigs: string[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - extractorConfigs.push(cfg.mainEntryPointFilePath); - return { succeeded: true } as ExtractorResult; - }); - - await executor(options, context); - - // Second call should use the utils entry point - expect(extractorConfigs[1]).toContain('utils/index.d.ts'); - }); - - it('returns false and stops after the first failing sub-path config', async () => { - const { context } = prepareFixtureWithSubpathConfig(); - - let callCount = 0; - jest.spyOn(Extractor, 'invoke').mockImplementation(() => { - callCount++; - // first call (primary) succeeds; second call (sub-path) fails - return { succeeded: callCount === 1, errorCount: callCount === 1 ? 0 : 1, warningCount: 0 } as ExtractorResult; - }); - - const output = await executor(options, context); - - expect(callCount).toBe(2); - expect(output.success).toBe(false); - }); - - it('ignores the primary api-extractor.json when scanning for additional configs', async () => { - const { paths, context } = prepareFixtureWithSubpathConfig(); - - const extractorConfigPaths: string[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - // mainEntryPointFilePath is absolute at this stage - extractorConfigPaths.push(cfg.mainEntryPointFilePath); - return { succeeded: true } as ExtractorResult; - }); - - await executor(options, context); - - // Should only be called once for primary + once for utils; NOT a third time for primary again - expect(extractorConfigPaths).toHaveLength(2); - // The additional config entry should not duplicate the primary one - const primaryEntry = join(paths.projRoot, 'dts', 'index.d.ts'); - const additionalEntry = join(paths.projRoot, 'dts', 'utils', 'index.d.ts'); - expect(extractorConfigPaths[0]).toBe(primaryEntry); - expect(extractorConfigPaths[1]).toBe(additionalEntry); - }); - - it('routes api report for sub-path config to etc/..api.md', async () => { - const { paths, context } = prepareFixtureWithSubpathConfigAndApiReport(); - - const capturedConfigs: ExtractorConfig[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedConfigs.push(cfg); - return { succeeded: true } as ExtractorResult; - }); - - await executor(options, context); - - const subpathConfig = capturedConfigs[1]; - expect(subpathConfig.apiReportEnabled).toBe(true); - expect(subpathConfig.reportFilePath).toBe(join(paths.projRoot, 'etc', 'proj.utils.api.md')); - }); - /** - * Like prepareFixtureWithSubpathConfig but with api report enabled and a unique reportFileName. + * Creates a fixture with a named export "./utils" that has a types field. */ - function prepareFixtureWithSubpathConfigAndApiReport() { + function prepareNamedExportFixture() { const { paths, context } = prepareFixture('valid', {}); const { projRoot } = paths; writeFileSync( - join(projRoot, 'config', 'api-extractor.utils.json'), + join(projRoot, 'package.json'), + serializeJson({ + name: '@proj/proj', + types: 'dist/index.d.ts', + exports: { + '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + './utils': { types: './dist/utils/index.d.ts', import: './lib/utils/index.js' }, + './package.json': './package.json', + }, + }), + 'utf-8', + ); + + writeFileSync( + join(projRoot, 'config', 'api-extractor.json'), serializeJson({ - mainEntryPointFilePath: '/dts/utils/index.d.ts', - apiReport: { enabled: true, reportFileName: '.utils' }, + mainEntryPointFilePath: '../dts/src/index.d.ts', + apiReport: { enabled: false }, docModel: { enabled: false }, - dtsRollup: { enabled: true, untrimmedFilePath: '/dist/utils/index.d.ts' }, + dtsRollup: { enabled: true }, tsdocMetadata: { enabled: false }, }), 'utf-8', ); execSyncMock.mockImplementation(() => { - mkdirSync(join(projRoot, 'dts', 'utils'), { recursive: true }); - writeFileSync(join(projRoot, 'dts', 'index.d.ts'), 'export const foo: number;', 'utf-8'); - writeFileSync(join(projRoot, 'dts', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); + mkdirSync(join(projRoot, 'dts', 'src', 'utils'), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); + writeFileSync(join(projRoot, 'dts', 'src', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); }); return { paths, context }; } -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Wildcard export expansion -// ───────────────────────────────────────────────────────────────────────────── - -describe('GenerateApi Executor – wildcard export expansion', () => { - afterEach(() => { - cleanup(); - }); /** - * Creates a fixture with a wildcard export "./*" whose types pattern resolves - * to one emitted .d.ts per sub-directory under dts/src/items/. - * The primary api-extractor.json uses a relative path from config/ to dts/src/. + * Creates a fixture with both wildcard and named exports. */ - function prepareWildcardFixture(subDirNames: string[]) { + function prepareMixedExportFixture(subDirNames: string[]) { const { paths, context } = prepareFixture('valid', {}); const { projRoot } = paths; @@ -412,6 +349,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { types: 'dist/index.d.ts', exports: { '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + './utils': { types: './dist/utils/index.d.ts', import: './lib/utils/index.js' }, './*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }, './package.json': './package.json', }, @@ -432,8 +370,9 @@ describe('GenerateApi Executor – wildcard export expansion', () => { ); execSyncMock.mockImplementation(() => { - mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); + mkdirSync(join(projRoot, 'dts', 'src', 'utils'), { recursive: true }); writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); + writeFileSync(join(projRoot, 'dts', 'src', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); for (const name of subDirNames) { mkdirSync(join(projRoot, 'dts', 'src', 'items', name), { recursive: true }); writeFileSync( @@ -447,6 +386,8 @@ describe('GenerateApi Executor – wildcard export expansion', () => { return { paths, context }; } + // ── Wildcard exports ────────────────────────────────────────────────────── + it('calls api-extractor once per wildcard sub-directory in addition to the primary', async () => { const subDirs = ['alpha', 'beta', 'gamma']; const { context } = prepareWildcardFixture(subDirs); @@ -458,7 +399,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } as ExtractorResult), ); - const output = await executor({ ...options, resolveExportWildcards: true }, context); + const output = await executor({ ...options, exportSubpaths: true }, context); // primary (1) + one per sub-directory expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); @@ -475,7 +416,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { return { succeeded: true } as ExtractorResult; }); - await executor({ ...options, resolveExportWildcards: true }, context); + await executor({ ...options, exportSubpaths: true }, context); const wildcardEntries = capturedEntries.slice(1); // skip primary for (const name of subDirs) { @@ -492,7 +433,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { return { succeeded: true } as ExtractorResult; }); - await executor({ ...options, resolveExportWildcards: true }, context); + await executor({ ...options, exportSubpaths: true }, context); const wildcardConfig = capturedConfigs[1]; // second call is the wildcard entry expect(wildcardConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'items', 'alpha', 'index.d.ts')); @@ -591,7 +532,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { return { succeeded: true } as ExtractorResult; }); - await executor({ ...options, resolveExportWildcards: true }, context); + await executor({ ...options, exportSubpaths: true }, context); const wildcardConfigs = capturedConfigs.slice(1); // skip primary for (const name of subDirs) { @@ -602,7 +543,7 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } }); - it('skips wildcard expansion when resolveExportWildcards is false', async () => { + it('skips export subpath expansion when exportSubpaths is false', async () => { const subDirs = ['alpha', 'beta']; const { context } = prepareWildcardFixture(subDirs); @@ -613,14 +554,14 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } as ExtractorResult), ); - const output = await executor({ ...options, resolveExportWildcards: false }, context); + const output = await executor({ ...options, exportSubpaths: false }, context); - // Only the primary config should run — no wildcard expansion + // Only the primary config should run — no subpath expansion expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); expect(output.success).toBe(true); }); - it('skips wildcard expansion by default (resolveExportWildcards not set)', async () => { + it('skips export subpath expansion by default (exportSubpaths not set)', async () => { const subDirs = ['alpha', 'beta']; const { context } = prepareWildcardFixture(subDirs); @@ -631,15 +572,15 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } as ExtractorResult), ); - // Pass default options — no resolveExportWildcards key + // Pass default options — no exportSubpaths key const output = await executor(options, context); - // Only the primary config should run — wildcard expansion is opt-in + // Only the primary config should run — subpath expansion is opt-in expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); expect(output.success).toBe(true); }); - it('expands wildcards when resolveExportWildcards is true', async () => { + it('expands wildcards when exportSubpaths is true', async () => { const subDirs = ['alpha', 'beta']; const { context } = prepareWildcardFixture(subDirs); @@ -650,10 +591,121 @@ describe('GenerateApi Executor – wildcard export expansion', () => { } as ExtractorResult), ); - const output = await executor({ ...options, resolveExportWildcards: true }, context); + const output = await executor({ ...options, exportSubpaths: true }, context); // primary (1) + one per sub-directory expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); expect(output.success).toBe(true); }); + + // ── Named exports ──────────────────────────────────────────────────────── + + it('creates config for named export ./utils with correct mainEntryPointFilePath', async () => { + const { context } = prepareNamedExportFixture(); + + const capturedEntries: string[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedEntries.push(cfg.mainEntryPointFilePath); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: true }, context); + + // primary + utils + expect(capturedEntries).toHaveLength(2); + expect(capturedEntries[1]).toContain('utils/index.d.ts'); + }); + + it('derives reportFileName from named export key', async () => { + const { context } = prepareNamedExportFixture(); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: true }, context); + + const utilsConfig = capturedConfigs[1]; + expect(utilsConfig.reportFilePath).toContain('utils.api.md'); + }); + + it('enables apiReport by default for named exports when exportSubpaths is true', async () => { + const { context } = prepareNamedExportFixture(); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: true }, context); + + const utilsConfig = capturedConfigs[1]; + expect(utilsConfig.apiReportEnabled).toBe(true); + }); + + it('disables apiReport for named exports when exportSubpaths: { apiReport: false }', async () => { + const { context } = prepareNamedExportFixture(); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: { apiReport: false } }, context); + + const utilsConfig = capturedConfigs[1]; + expect(utilsConfig.apiReportEnabled).toBe(false); + }); + + it('sets dts rollup path for named export to dist/{subpath}/index.d.ts', async () => { + const { paths, context } = prepareNamedExportFixture(); + + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: true }, context); + + const utilsConfig = capturedConfigs[1]; + expect(utilsConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'utils', 'index.d.ts')); + }); + + it('processes both named and wildcard exports in a single package', async () => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareMixedExportFixture(subDirs); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); + + const output = await executor({ ...options, exportSubpaths: true }, context); + + // primary (1) + utils (1) + wildcard sub-dirs (2) + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + 1 + subDirs.length); + expect(output.success).toBe(true); + }); + + it('skips "." and "./package.json" entries', async () => { + const { context } = prepareNamedExportFixture(); + + const capturedEntries: string[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedEntries.push(cfg.mainEntryPointFilePath); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: true }, context); + + // Only primary + utils — "." and "./package.json" skipped + expect(capturedEntries).toHaveLength(2); + }); }); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index 1678f5f49a266a..4da2b69df7ec90 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -1,6 +1,6 @@ import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { execSync } from 'node:child_process'; import { Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor'; @@ -9,7 +9,7 @@ import type { GenerateApiExecutorSchema } from './schema'; import type { PackageJson, TsConfig } from '../../types'; import { measureEnd, measureStart } from '../../utils'; import { isCI } from './lib/shared'; -import { listAdditionalApiExtractorConfigs, hasWildcardTypedExport, isWildcardTypedEntry } from './utils'; +import { isWildcardTypedEntry, isNamedTypedEntry } from './utils'; const runExecutor: PromiseExecutor = async (schema, context) => { measureStart('GenerateApiExecutor'); @@ -39,20 +39,11 @@ async function runGenerateApi(options: NormalizedOptions, context: ExecutorConte return false; } - // Run additional file-based sub-path configs (e.g. api-extractor.utils.json) - const additionalConfigs = listAdditionalApiExtractorConfigs(dirname(options.config), options.config); - for (const configPath of additionalConfigs) { - verboseLog(`Running api-extractor for sub-path config: ${relative(options.projectAbsolutePath, configPath)}`); - if (!apiExtractor({ configPath }, options, context)) { - return false; - } - } - - // Expand wildcard exports and run api-extractor for each resolved component dir - if (options.resolveExportWildcards) { - const wildcardConfigs = getWildcardExportConfigs(options); - for (const configObject of wildcardConfigs) { - verboseLog(`Running api-extractor for wildcard entry: ${configObject.mainEntryPointFilePath}`); + // Expand export subpaths and run api-extractor for each resolved entry + if (options.exportSubpaths.enabled) { + const subpathConfigs = getExportSubpathConfigs(options); + for (const configObject of subpathConfigs) { + verboseLog(`Running api-extractor for export subpath entry: ${configObject.mainEntryPointFilePath}`); if (!apiExtractor({ configObject }, options, context)) { return false; } @@ -67,10 +58,16 @@ function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorCo config: '{projectRoot}/config/api-extractor.json', local: true, diagnostics: false, - resolveExportWildcards: false, }; const resolvedSchema = { ...defaults, ...schema }; + // Normalize exportSubpaths into { enabled, apiReport } + const rawExportSubpaths = resolvedSchema.exportSubpaths; + const exportSubpaths = + typeof rawExportSubpaths === 'object' && rawExportSubpaths !== null + ? { enabled: true, apiReport: rawExportSubpaths.apiReport !== false } + : { enabled: rawExportSubpaths === true, apiReport: true }; + const project = context.projectsConfigurations!.projects[context.projectName!]; const resolveLocalFlag = Boolean(process.env.__FORCE_API_MD_UPDATE__) || (isCI() ? false : resolvedSchema.local); @@ -89,6 +86,7 @@ function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorCo return { ...resolvedSchema, + exportSubpaths, local: resolveLocalFlag, config: resolveConfig.result!, project, @@ -186,52 +184,78 @@ function apiExtractor( } /** - * Reads the package.json exports map and expands wildcard entries (e.g. "./*") into individual - * api-extractor config objects — one per source directory found under the resolved source path. + * Reads the package.json exports map and resolves both wildcard entries (e.g. "./*") and named + * entries (e.g. "./utils") into individual api-extractor config objects. * - * Example: "./*" with types "./dist/items/STAR/index.d.ts" expands to one config per - * directory found in "src/items/". + * - Wildcard entries are expanded into one config per sub-directory found under the resolved source path. + * - Named entries produce a single config each, derived directly from their types field. + * - The root export (".") and "./package.json" are always skipped. */ -function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { +function getExportSubpathConfigs(options: NormalizedOptions): IConfigFile[] { const packageJson: PackageJson = parseJson(readFileSync(options.packageJsonPath, 'utf-8')); const exports = packageJson.exports ?? {}; - if (!hasWildcardTypedExport(exports as Record)) { - return []; - } - const declarationBase = resolveDeclarationBase(options, packageJson); if (!declarationBase) { return []; } + const apiReportEnabled = options.exportSubpaths.apiReport; + const configs: IConfigFile[] = []; for (const [exportKey, exportValue] of Object.entries(exports)) { - if (!isWildcardTypedEntry(exportKey, exportValue)) { + // Skip root and package.json entries + if (exportKey === '.' || exportKey === './package.json') { continue; } - const pathPrefixes = parseWildcardTypesPattern(exportValue.types); - if (!pathPrefixes) { - continue; - } + // Wildcard entries: expand into sub-directories + if (isWildcardTypedEntry(exportKey, exportValue)) { + const pathPrefixes = parseWildcardTypesPattern(exportValue.types); + if (!pathPrefixes) { + continue; + } - const declarationScanDir = join(declarationBase, pathPrefixes.wildcardSubPath); - const subDirs = listSubDirectories(declarationScanDir); - if (!subDirs) { + const declarationScanDir = join(declarationBase, pathPrefixes.wildcardSubPath); + const subDirs = listSubDirectories(declarationScanDir); + if (!subDirs) { + continue; + } + + for (const dirName of subDirs) { + configs.push( + createSubpathEntryConfig({ + projectAbsolutePath: options.projectAbsolutePath, + declarationBase, + subPath: pathPrefixes.wildcardSubPath + dirName, + distRelativePath: pathPrefixes.distRelativePrefix + dirName, + reportFileName: dirName, + apiReportEnabled, + }), + ); + } continue; } - for (const dirName of subDirs) { + // Named entries: create config directly from types field + if (isNamedTypedEntry(exportKey, exportValue)) { + const parsed = parseNamedTypesPattern(exportValue.types); + if (!parsed) { + continue; + } + + const subpathName = exportKey.replace(/^\.\//, ''); + configs.push( - createWildcardEntryConfig({ + createSubpathEntryConfig({ projectAbsolutePath: options.projectAbsolutePath, declarationBase, - wildcardSubPath: pathPrefixes.wildcardSubPath, - distRelativePrefix: pathPrefixes.distRelativePrefix, - dirName, + subPath: parsed.declarationSubPath, + distRelativePath: parsed.distRelativePath, + reportFileName: subpathName, + apiReportEnabled, }), ); } @@ -267,7 +291,7 @@ function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { if (!resolvedPrimaryEntry.endsWith(indexDtsSuffix)) { verboseLog( `Primary mainEntryPointFilePath "${resolvedPrimaryEntry}" does not end with "${indexDtsSuffix}". ` + - `Skipping wildcard export expansion.`, + `Skipping export subpath expansion.`, 'warn', ); return null; @@ -306,6 +330,39 @@ function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { return { distRelativePrefix, wildcardSubPath }; } + /** + * Parses a named (non-wildcard) types pattern and derives the dist-relative path and + * the declaration sub-path (the portion after the first path segment, minus index.d.ts). + * + * Example: "./dist/utils/index.d.ts" + * → distRelativePath: "dist/utils" + * → declarationSubPath: "utils" + * + * @returns The path components, or `null` if the pattern cannot be parsed. + */ + function parseNamedTypesPattern(typesPattern: string): { + distRelativePath: string; + declarationSubPath: string; + } | null { + const indexDtsSuffix = '/index.d.ts'; + if (!typesPattern.endsWith(indexDtsSuffix)) { + return null; + } + + // Strip "./" prefix and trailing "/index.d.ts" + const distRelativePath = typesPattern.replace(/^\.\//, '').slice(0, -indexDtsSuffix.length); + + // Strip the first path segment (the dist directory name) + const firstSlashIdx = distRelativePath.indexOf('/'); + const declarationSubPath = firstSlashIdx === -1 ? '' : distRelativePath.slice(firstSlashIdx + 1); + + if (!declarationSubPath) { + return null; + } + + return { distRelativePath, declarationSubPath }; + } + /** * Lists immediate sub-directories of the given path. * @@ -313,7 +370,7 @@ function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { */ function listSubDirectories(dirPath: string): string[] | null { if (!existsSync(dirPath)) { - verboseLog(`Wildcard export source dir not found, skipping: ${dirPath}`, 'warn'); + verboseLog(`Export subpath source dir not found, skipping: ${dirPath}`, 'warn'); return null; } @@ -327,24 +384,25 @@ function getWildcardExportConfigs(options: NormalizedOptions): IConfigFile[] { } /** - * Creates an api-extractor IConfigFile for a single wildcard sub-directory entry. + * Creates an api-extractor IConfigFile for a single export sub-path entry. */ - function createWildcardEntryConfig(params: { + function createSubpathEntryConfig(params: { projectAbsolutePath: string; declarationBase: string; - wildcardSubPath: string; - distRelativePrefix: string; - dirName: string; + subPath: string; + distRelativePath: string; + reportFileName: string; + apiReportEnabled: boolean; }): IConfigFile { - const mainEntryPointFilePath = join(params.declarationBase, params.wildcardSubPath, params.dirName, 'index.d.ts'); - const dtsRollupPath = join(params.projectAbsolutePath, params.distRelativePrefix, params.dirName, 'index.d.ts'); + const mainEntryPointFilePath = join(params.declarationBase, params.subPath, 'index.d.ts'); + const dtsRollupPath = join(params.projectAbsolutePath, params.distRelativePath, 'index.d.ts'); return { projectFolder: params.projectAbsolutePath, mainEntryPointFilePath, apiReport: { - enabled: true, - reportFileName: params.dirName, + enabled: params.apiReportEnabled, + reportFileName: params.reportFileName, reportFolder: '/etc/', reportTempFolder: '/temp/', }, @@ -436,7 +494,7 @@ function enableAllowSyntheticDefaultImports(options: { pkgJson: PackageJson }) { return shouldEnable ? { allowSyntheticDefaultImports: true } : null; } -function getApiExtractorConfigPath(schema: Required, projectRoot: string) { +function getApiExtractorConfigPath(schema: Required>, projectRoot: string) { const configPath = schema.config.replace('{projectRoot}', projectRoot); if (!existsSync(configPath)) { diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts index abb8fce457248e..8b8020569728ea 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.d.ts +++ b/tools/workspace-plugin/src/executors/generate-api/schema.d.ts @@ -2,5 +2,5 @@ export interface GenerateApiExecutorSchema { config?: string; local?: boolean; diagnostics?: boolean; - resolveExportWildcards?: boolean; + exportSubpaths?: boolean | { apiReport?: boolean }; } diff --git a/tools/workspace-plugin/src/executors/generate-api/schema.json b/tools/workspace-plugin/src/executors/generate-api/schema.json index c6ecf14ebc7e03..8d2ac506305a65 100644 --- a/tools/workspace-plugin/src/executors/generate-api/schema.json +++ b/tools/workspace-plugin/src/executors/generate-api/schema.json @@ -20,9 +20,22 @@ "description": "Show diagnostic messages used for troubleshooting problems with API Extractor", "default": false }, - "resolveExportWildcards": { - "type": "boolean", - "description": "Whether to resolve wildcard export map entries (e.g. './*') and run api-extractor for each resolved sub-directory. When false, only the primary config and explicit sub-path configs are processed.", + "exportSubpaths": { + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "apiReport": { + "type": "boolean", + "description": "Whether to generate api.md reports for each resolved sub-path entry.", + "default": true + } + }, + "additionalProperties": false + } + ], + "description": "Whether to read non-root export map entries from package.json and run api-extractor for each resolved sub-path. When false or omitted, only the primary config is processed.", "default": false } }, diff --git a/tools/workspace-plugin/src/executors/generate-api/utils.ts b/tools/workspace-plugin/src/executors/generate-api/utils.ts index f086f3c3dae49b..d4dca17c0a2a52 100644 --- a/tools/workspace-plugin/src/executors/generate-api/utils.ts +++ b/tools/workspace-plugin/src/executors/generate-api/utils.ts @@ -1,36 +1,25 @@ -import { existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; - /** - * Returns `api-extractor.*.json` file paths found in `configDir`, excluding `primaryConfigPath`. - * The primary `api-extractor.json` is always excluded by the regex (it has no dot-separated name - * segment between `api-extractor` and `.json`), but `primaryConfigPath` is excluded as an extra - * safety net for custom config locations. + * Checks whether a single export map entry is a wildcard entry with a `types` field. */ -export function listAdditionalApiExtractorConfigs(configDir: string, primaryConfigPath: string): string[] { - if (!existsSync(configDir)) { - return []; - } - let entries: string[]; - try { - entries = readdirSync(configDir); - } catch { - return []; - } - return entries - .filter(filename => /^api-extractor\..+\.json$/.test(filename)) - .map(filename => join(configDir, filename)) - .filter(filepath => filepath !== primaryConfigPath); +export function isWildcardTypedEntry( + exportKey: string, + exportValue: unknown, +): exportValue is { types: string } & Record { + return exportKey.includes('*') && typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; } /** - * Checks whether a single export map entry is a wildcard entry with a `types` field. + * Checks whether a single export map entry is a named (non-wildcard, non-root) entry with a `types` field. + * Skips `"."` and `"./package.json"`. */ -export function isWildcardTypedEntry( +export function isNamedTypedEntry( exportKey: string, exportValue: unknown, ): exportValue is { types: string } & Record { - return exportKey.includes('*') && typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; + if (exportKey === '.' || exportKey === './package.json' || exportKey.includes('*')) { + return false; + } + return typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; } /** diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts b/tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts deleted file mode 100644 index 052531fb46dc98..00000000000000 --- a/tools/workspace-plugin/src/generators/add-export-subpath/index.spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { addProjectConfiguration, joinPathFragments, readJson, Tree, writeJson } from '@nx/devkit'; -import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; - -import addExportSubpathGenerator from './index'; -import type { PackageJson } from '../../types'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const PROJECT_NAME = 'react-headless-components-preview'; -const NPM_NAME = `@proj/${PROJECT_NAME}`; -const PROJECT_ROOT = `packages/react-components/${PROJECT_NAME}/library`; - -function createProject(tree: Tree, overrides: Partial = {}) { - addProjectConfiguration(tree, PROJECT_NAME, { - root: PROJECT_ROOT, - projectType: 'library', - sourceRoot: `${PROJECT_ROOT}/src`, - tags: ['vNext', 'platform:web'], - }); - - writeJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json'), { - name: NPM_NAME, - version: '0.0.0', - main: 'lib-commonjs/index.js', - module: 'lib/index.js', - typings: './dist/index.d.ts', - files: ['*.md', 'dist/*.d.ts', 'lib', 'lib-commonjs'], - exports: { - '.': { - types: './dist/index.d.ts', - node: './lib-commonjs/index.js', - import: './lib/index.js', - require: './lib-commonjs/index.js', - }, - './package.json': './package.json', - }, - ...overrides, - }); - - // Primary api-extractor config - writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json'), { - $schema: 'https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json', - extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', - mainEntryPointFilePath: - '/../../../../../../dist/out-tsc/types/packages/react-components/react-headless-components-preview/library/src/index.d.ts', - }); - - // Source entry - tree.write(joinPathFragments(PROJECT_ROOT, 'src', 'index.ts'), `export * from './utils';`); - - return tree; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('add-export-subpath generator', () => { - let tree: Tree; - - beforeEach(() => { - tree = createTreeWithEmptyWorkspace(); - createProject(tree); - }); - - describe('api-extractor config', () => { - it('creates config/api-extractor.{subpath}.json', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - expect(tree.exists(joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json'))).toBe(true); - }); - - it('sets mainEntryPointFilePath to the sub-path entrypoint', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); - - expect(config.mainEntryPointFilePath).toContain('/src/utils/index.d.ts'); - }); - - it('derives mainEntryPointFilePath from custom sourceEntrypoint', async () => { - await addExportSubpathGenerator(tree, { - project: PROJECT_NAME, - subpath: 'tokens', - sourceEntrypoint: 'tokens/index.ts', - }); - - const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.tokens.json')); - - expect(config.mainEntryPointFilePath).toContain('/src/tokens/index.d.ts'); - }); - - it('sets dtsRollup.untrimmedFilePath to dist/{subpath}/index.d.ts', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); - - expect(config.dtsRollup).toEqual({ - enabled: true, - untrimmedFilePath: '/dist/utils/index.d.ts', - }); - }); - - it('disables apiReport by default', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); - - expect(config.apiReport.enabled).toBe(false); - }); - - it('enables apiReport and sets reportFileName when enableApiReport is true', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils', enableApiReport: true }); - - const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); - - expect(config.apiReport.enabled).toBe(true); - expect(config.apiReport.reportFileName).toBe('.utils'); - }); - - it('extends the common v-next base config', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const config = readJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json')); - - expect(config.extends).toBe('@fluentui/scripts-api-extractor/api-extractor.common.v-next.json'); - }); - - it('throws if the sub-path config already exists', async () => { - writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.utils.json'), {}); - - await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( - /already exists/, - ); - }); - - it('throws if primary api-extractor.json is missing', async () => { - tree.delete(joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json')); - - await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( - /Cannot find primary api-extractor.json/, - ); - }); - - it('throws if primary api-extractor.json has no mainEntryPointFilePath', async () => { - writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json'), { - extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', - // no mainEntryPointFilePath - }); - - await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( - /"mainEntryPointFilePath" is not set/, - ); - }); - - it('throws if primary mainEntryPointFilePath does not end with /src/index.d.ts', async () => { - writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.json'), { - extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', - mainEntryPointFilePath: '/dist/index.d.ts', - }); - - await expect(addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' })).rejects.toThrow( - /Could not derive/, - ); - }); - }); - - describe('package.json exports', () => { - it('adds a ./{subpath} export entry', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); - - expect(pkg.exports?.['./utils']).toBeDefined(); - }); - - it('sets the types field to dist/{subpath}/index.d.ts', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); - const entry = pkg.exports?.['./utils']; - - expect(typeof entry === 'object' && entry.types).toBe('./dist/utils/index.d.ts'); - }); - - it('derives JS paths from the main export entry', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); - const entry = pkg.exports?.['./utils']; - - expect(typeof entry === 'object' && entry).toMatchObject({ - node: './lib-commonjs/utils/index.js', - import: './lib/utils/index.js', - require: './lib-commonjs/utils/index.js', - }); - }); - - it('inserts the sub-path entry before ./package.json', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); - const keys = Object.keys(pkg.exports ?? {}); - const utilsIdx = keys.indexOf('./utils'); - const pkgJsonIdx = keys.indexOf('./package.json'); - - expect(utilsIdx).toBeGreaterThan(-1); - expect(pkgJsonIdx).toBeGreaterThan(utilsIdx); - }); - - it('preserves pre-existing exports', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); - - expect(pkg.exports?.['.']).toBeDefined(); - expect(pkg.exports?.['./package.json']).toBeDefined(); - }); - - it('adds dist/{subpath}/*.d.ts to package.json files', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); - - expect(pkg.files).toContain('dist/utils/*.d.ts'); - }); - - it('does not duplicate files entry when run twice with different subpaths', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - // Simulate a second sub-path added later - writeJson(tree, joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.preview.json'), {}); - // Delete it so second run creates fresh - tree.delete(joinPathFragments(PROJECT_ROOT, 'config', 'api-extractor.preview.json')); - - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'preview' }); - - const pkg = readJson(tree, joinPathFragments(PROJECT_ROOT, 'package.json')); - const utilsCount = (pkg.files ?? []).filter((f: string) => f === 'dist/utils/*.d.ts').length; - const previewCount = (pkg.files ?? []).filter((f: string) => f === 'dist/preview/*.d.ts').length; - - expect(utilsCount).toBe(1); - expect(previewCount).toBe(1); - }); - }); - - describe('source stub', () => { - it('creates src/{subpath}/index.ts if it does not exist', async () => { - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - expect(tree.exists(joinPathFragments(PROJECT_ROOT, 'src', 'utils', 'index.ts'))).toBe(true); - }); - - it('does not overwrite an existing src/{subpath}/index.ts', async () => { - const existingContent = `export const value = 42;\n`; - tree.write(joinPathFragments(PROJECT_ROOT, 'src', 'utils', 'index.ts'), existingContent); - - await addExportSubpathGenerator(tree, { project: PROJECT_NAME, subpath: 'utils' }); - - expect(tree.read(joinPathFragments(PROJECT_ROOT, 'src', 'utils', 'index.ts'), 'utf-8')).toBe(existingContent); - }); - - it('respects a custom sourceEntrypoint for the stub path', async () => { - await addExportSubpathGenerator(tree, { - project: PROJECT_NAME, - subpath: 'tokens', - sourceEntrypoint: 'tokens/index.ts', - }); - - expect(tree.exists(joinPathFragments(PROJECT_ROOT, 'src', 'tokens', 'index.ts'))).toBe(true); - }); - }); -}); diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/index.ts b/tools/workspace-plugin/src/generators/add-export-subpath/index.ts deleted file mode 100644 index bc145d26253575..00000000000000 --- a/tools/workspace-plugin/src/generators/add-export-subpath/index.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { formatFiles, generateFiles, joinPathFragments, readJson, updateJson, type Tree } from '@nx/devkit'; -import * as path from 'node:path'; - -import { getProjectConfig } from '../../utils'; -import type { PackageJson, TsConfig } from '../../types'; -import type { AddExportSubpathGeneratorSchema } from './schema'; - -export default async function addExportSubpathGenerator(tree: Tree, schema: AddExportSubpathGeneratorSchema) { - const { project, subpath, enableApiReport = false } = schema; - const sourceEntrypoint = schema.sourceEntrypoint ?? `${subpath}/index.ts`; - - const { projectConfig, paths } = getProjectConfig(tree, { packageName: project }); - - // ── 1. Derive paths ────────────────────────────────────────────────────────── - - const primaryApiExtractorPath = joinPathFragments(paths.configRoot, 'api-extractor.json'); - const subpathApiExtractorPath = joinPathFragments(paths.configRoot, `api-extractor.${subpath}.json`); - - if (!tree.exists(primaryApiExtractorPath)) { - throw new Error( - `Cannot find primary api-extractor.json at "${primaryApiExtractorPath}". ` + - `Make sure the project has already been set up with generate-api.`, - ); - } - - if (tree.exists(subpathApiExtractorPath)) { - throw new Error( - `Sub-path config already exists at "${subpathApiExtractorPath}". ` + - `If you want to regenerate it, delete the file first.`, - ); - } - - // ── 2. Derive mainEntryPointFilePath from the primary config ───────────────── - - const primaryConfig = readJson<{ mainEntryPointFilePath?: string }>(tree, primaryApiExtractorPath); - const primaryMainEntry = primaryConfig.mainEntryPointFilePath; - - if (!primaryMainEntry) { - throw new Error( - `"mainEntryPointFilePath" is not set in "${primaryApiExtractorPath}". ` + - `The primary api-extractor.json must declare this field.`, - ); - } - - // Replace the terminal index.d.ts segment with the sub-path entrypoint: - // e.g. "...library/src/index.d.ts" → "...library/src/utils/index.d.ts" - // sourceEntrypoint may be "utils/index.ts" — convert .ts → .d.ts - const sourceDtsRelative = sourceEntrypoint.replace(/\.tsx?$/, '.d.ts'); - const newMainEntry = primaryMainEntry.replace(/\/src\/index\.d\.ts$/, `/src/${sourceDtsRelative}`); - - if (newMainEntry === primaryMainEntry) { - throw new Error( - `Could not derive new mainEntryPointFilePath from "${primaryMainEntry}". ` + - `Expected the primary config to end with "/src/index.d.ts".`, - ); - } - - // ── 3. Derive dtsRollup output path ────────────────────────────────────────── - - // The output will be dist/{subpath}/index.d.ts - // e.g. /dist/utils/index.d.ts - const dtsOutputPath = `/dist/${subpath}/index.d.ts`; - - // ── 4. Write config/api-extractor.{subpath}.json ──────────────────────────── - - const subpathConfig: Record = { - $schema: 'https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json', - extends: '@fluentui/scripts-api-extractor/api-extractor.common.v-next.json', - mainEntryPointFilePath: newMainEntry, - apiReport: { - enabled: enableApiReport, - ...(enableApiReport ? { reportFileName: `.${subpath}` } : {}), - }, - dtsRollup: { - enabled: true, - untrimmedFilePath: dtsOutputPath, - }, - }; - - tree.write(subpathApiExtractorPath, JSON.stringify(subpathConfig, null, 2) + '\n'); - - // ── 5. Create src/{subpath}/index.ts stub if it doesn't exist ──────────────── - - const sourceEntrypointPath = joinPathFragments(paths.sourceRoot, sourceEntrypoint); - if (!tree.exists(sourceEntrypointPath)) { - tree.write(sourceEntrypointPath, `// TODO: add exports for the "${subpath}" sub-path\n`); - } - - // ── 6. Derive the JS module output paths from existing main export ───────────── - - const packageJson = readJson(tree, paths.packageJson); - const mainExport = typeof packageJson.exports?.['.'] === 'object' ? packageJson.exports['.'] : null; - - // Derive default JS paths based on the main entry (replace lib/index.js → lib/{subpath}/index.js) - const deriveJsPath = (base: string | undefined) => { - if (!base || typeof base !== 'string') return undefined; - return base.replace(/\/index\.js$/, `/${subpath}/index.js`); - }; - - const newExportEntry: PackageJson['exports'][string] = { - types: `./dist/${subpath}/index.d.ts`, - node: deriveJsPath(mainExport?.node) ?? `./lib-commonjs/${subpath}/index.js`, - import: deriveJsPath(mainExport?.import) ?? `./lib/${subpath}/index.js`, - require: deriveJsPath(mainExport?.require) ?? `./lib-commonjs/${subpath}/index.js`, - }; - - // ── 7. Update package.json exports + files ──────────────────────────────────── - - updateJson(tree, paths.packageJson, json => { - json.exports = json.exports ?? {}; - - // Insert the new sub-path entry before "./package.json" - const updatedExports: PackageJson['exports'] = {}; - let inserted = false; - for (const [key, value] of Object.entries(json.exports)) { - if (key === './package.json' && !inserted) { - updatedExports[`./${subpath}`] = newExportEntry; - inserted = true; - } - updatedExports[key] = value; - } - if (!inserted) { - updatedExports[`./${subpath}`] = newExportEntry; - } - json.exports = updatedExports; - - // Update files to include the new dist output - const dtsGlob = `dist/${subpath}/*.d.ts`; - if (!json.files?.includes(dtsGlob)) { - json.files = [...(json.files ?? []), dtsGlob]; - } - - return json; - }); - - await formatFiles(tree); -} diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/schema.json b/tools/workspace-plugin/src/generators/add-export-subpath/schema.json deleted file mode 100644 index 816e052d638c4f..00000000000000 --- a/tools/workspace-plugin/src/generators/add-export-subpath/schema.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "cli": "nx", - "id": "add-export-subpath", - "type": "object", - "properties": { - "project": { - "type": "string", - "description": "The name of the project to add the sub-path export to.", - "$default": { "$source": "argv", "index": 0 }, - "x-priority": "important", - "x-prompt": "What is the project name?" - }, - "subpath": { - "type": "string", - "description": "The sub-path name (e.g. 'utils'). Used as './utils' in the exports map and to derive config/api-extractor.utils.json.", - "x-priority": "important", - "x-prompt": "What is the export sub-path name (e.g. 'utils')?" - }, - "sourceEntrypoint": { - "type": "string", - "description": "Path of the source entrypoint relative to src/ (defaults to '{subpath}/index.ts')." - }, - "enableApiReport": { - "type": "boolean", - "description": "Whether to generate an api.md report for this sub-path entry.", - "default": false - } - }, - "required": ["project", "subpath"] -} diff --git a/tools/workspace-plugin/src/generators/add-export-subpath/schema.ts b/tools/workspace-plugin/src/generators/add-export-subpath/schema.ts deleted file mode 100644 index 79352b33934a8e..00000000000000 --- a/tools/workspace-plugin/src/generators/add-export-subpath/schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface AddExportSubpathGeneratorSchema { - project: string; - subpath: string; - sourceEntrypoint?: string; - enableApiReport?: boolean; -} diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts index 1b7d4215e6577b..69b0060182c914 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts @@ -855,21 +855,7 @@ describe(`workspace-plugin`, () => { expect(target?.outputs).toEqual(['{projectRoot}/dist/index.d.ts', '{projectRoot}/etc/proj.api.md']); }); - it('should add glob outputs when additional api-extractor configs exist', async () => { - const target = await setupAndGetGenerateApiTarget({ - 'proj/config/api-extractor.json': '{}', - 'proj/config/api-extractor.utils.json': serializeJson({ - apiReport: { enabled: true, reportFileName: 'utils' }, - dtsRollup: { untrimmedFilePath: '/dist/utils/index.d.ts' }, - }), - }); - - expect(target?.inputs).toContain('{projectRoot}/config/api-extractor.utils.json'); - expect(target?.outputs).toContain('{projectRoot}/dist/**/*.d.ts'); - expect(target?.outputs).toContain('{projectRoot}/etc/*.api.md'); - }); - - it('should add glob outputs when user configures resolveExportWildcards on generate-api target', async () => { + it('should add glob outputs when user configures exportSubpaths on generate-api target', async () => { const extraFiles = { 'proj/project.json': serializeJson({ root: 'proj', @@ -879,7 +865,7 @@ describe(`workspace-plugin`, () => { targets: { 'generate-api': { options: { - resolveExportWildcards: true, + exportSubpaths: true, }, }, }, @@ -894,7 +880,7 @@ describe(`workspace-plugin`, () => { expect(generateApiTarget?.outputs).toContain('{projectRoot}/etc/*.api.md'); const buildTarget = targets?.['build']; - expect(buildTarget?.options?.generateApi).toEqual({ resolveExportWildcards: true }); + expect(buildTarget?.options?.generateApi).toEqual({ exportSubpaths: true }); }); }); diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.ts index 7d6ad369d7f545..0a9d1a90a8f115 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.ts @@ -23,7 +23,6 @@ import { buildCleanTarget } from './clean-plugin'; import { buildFormatTarget } from './format-plugin'; import { buildTypeCheckTarget } from './type-check-plugin'; import { measureStart, measureEnd } from '../utils'; -import { listAdditionalApiExtractorConfigs } from '../executors/generate-api/utils'; export interface WorkspacePluginOptions { testSSR?: TargetPluginOption; @@ -204,8 +203,7 @@ function buildWorkspaceProjectConfiguration( targets['generate-api'] = buildGenerateApiTarget(projectRoot, config); - const userEnabledResolveExportWildcards = - config.projectJSON.targets?.['generate-api']?.options?.resolveExportWildcards === true; + const { value: userExportSubpaths, enabled: userEnabledExportSubpaths } = resolveExportSubpathsOption(config); targets.build = { cache: true, @@ -219,7 +217,7 @@ function buildWorkspaceProjectConfiguration( config.tags.includes('ships-amd') ? { module: 'amd', outputPath: 'lib-amd' } : null, ].filter(Boolean) as BuildExecutorSchema['moduleOutput'], enableGriffelRawStyles: true, - ...(userEnabledResolveExportWildcards ? { generateApi: { resolveExportWildcards: true } } : {}), + ...(userEnabledExportSubpaths ? { generateApi: { exportSubpaths: userExportSubpaths } } : null), // NOTE: assets should be set per project needs // assets: [], } satisfies BuildExecutorSchema, @@ -272,17 +270,32 @@ function buildWorkspaceProjectConfiguration( return { targets }; } +function resolveExportSubpathsOption(config: TaskBuilderConfig): { + value: boolean | { apiReport?: boolean }; + enabled: boolean; +} { + const value = config.projectJSON.targets?.['generate-api']?.options?.exportSubpaths; + const enabled = value === true || (typeof value === 'object' && value !== null); + return { value, enabled }; +} + function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): TargetConfiguration { - const resolveExportWildcards = config.projectJSON.targets?.['generate-api']?.options?.resolveExportWildcards === true; + const { enabled: hasExportSubpaths } = resolveExportSubpathsOption(config); - const { extraInputs, extraOutputs } = buildExtraInputsAndOutputsForApiExtractorConfigs(); + const extraOutputs: string[] = []; + + // When exportSubpaths is enabled, use broad globs for outputs + // — the executor resolves exact paths at runtime. + if (hasExportSubpaths) { + extraOutputs.push('{projectRoot}/dist/**/*.d.ts'); + extraOutputs.push('{projectRoot}/etc/*.api.md'); + } return { cache: true, executor: '@fluentui/workspace-plugin:generate-api', inputs: [ '{projectRoot}/config/api-extractor.json', - ...extraInputs, '{projectRoot}/tsconfig.json', '{projectRoot}/tsconfig.lib.json', '{projectRoot}/src/**/*.tsx?', @@ -299,31 +312,6 @@ function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): }, }, }; - - function buildExtraInputsAndOutputsForApiExtractorConfigs() { - const extraInputs: string[] = []; - const extraOutputs: string[] = []; - - const configDir = join(projectRoot, 'config'); - const primaryConfigPath = join(configDir, 'api-extractor.json'); - const additionalConfigFiles = listAdditionalApiExtractorConfigs(configDir, primaryConfigPath); - - for (const absPath of additionalConfigFiles) { - const configFile = absPath.slice(configDir.length + 1); // filename only - extraInputs.push(`{projectRoot}/config/${configFile}`); - } - - const hasExtraEntryPoints = additionalConfigFiles.length > 0 || resolveExportWildcards; - - // When any additional or wildcard entry points exist, use broad globs for outputs - // — the executor resolves exact paths at runtime. - if (hasExtraEntryPoints) { - extraOutputs.push('{projectRoot}/dist/**/*.d.ts'); - extraOutputs.push('{projectRoot}/etc/*.api.md'); - } - - return { extraInputs, extraOutputs }; - } } function buildTestTarget( From 3988271ed95a9c3a99899833c69a8177309f9291 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 14:26:07 +0200 Subject: [PATCH 09/13] fix: resolve cache issues --- .../src/plugins/workspace-plugin.spec.ts | 10 +++++++--- .../src/plugins/workspace-plugin.ts | 18 +++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts index 69b0060182c914..c2e9df915d9b54 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts @@ -591,7 +591,6 @@ describe(`workspace-plugin`, () => { "{projectRoot}/lib", "{projectRoot}/lib-commonjs", "{projectRoot}/dist", - "{projectRoot}/dist/index.d.ts", "{projectRoot}/etc/proj.api.md", ], }, @@ -876,11 +875,16 @@ describe(`workspace-plugin`, () => { const targets = getTargets(results); const generateApiTarget = targets?.['generate-api']; - expect(generateApiTarget?.outputs).toContain('{projectRoot}/dist/**/*.d.ts'); - expect(generateApiTarget?.outputs).toContain('{projectRoot}/etc/*.api.md'); + expect(generateApiTarget?.outputs).toEqual(['{projectRoot}/dist/**/*.d.ts', '{projectRoot}/etc/*.api.md']); const buildTarget = targets?.['build']; expect(buildTarget?.options?.generateApi).toEqual({ exportSubpaths: true }); + expect(buildTarget?.outputs).toEqual([ + '{projectRoot}/lib', + '{projectRoot}/lib-commonjs', + '{projectRoot}/dist', + '{projectRoot}/etc/*.api.md', + ]); }); }); diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.ts index 0a9d1a90a8f115..6e1b444fd4ba0a 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.ts @@ -236,7 +236,8 @@ function buildWorkspaceProjectConfiguration( `{projectRoot}/lib-commonjs`, config.tags.includes('ships-amd') ? `{projectRoot}/lib-amd` : null, `{projectRoot}/dist`, - ...targets['generate-api'].outputs!, + // only spread etc/ outputs from generate-api (dist/ is already covered by {projectRoot}/dist above) + ...targets['generate-api'].outputs!.filter(outputPath => !outputPath.startsWith('{projectRoot}/dist')), ].filter(Boolean) as string[], metadata: { technologies: ['swc', 'typescript', 'api-extractor'], @@ -282,15 +283,6 @@ function resolveExportSubpathsOption(config: TaskBuilderConfig): { function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): TargetConfiguration { const { enabled: hasExportSubpaths } = resolveExportSubpathsOption(config); - const extraOutputs: string[] = []; - - // When exportSubpaths is enabled, use broad globs for outputs - // — the executor resolves exact paths at runtime. - if (hasExportSubpaths) { - extraOutputs.push('{projectRoot}/dist/**/*.d.ts'); - extraOutputs.push('{projectRoot}/etc/*.api.md'); - } - return { cache: true, executor: '@fluentui/workspace-plugin:generate-api', @@ -303,7 +295,11 @@ function buildGenerateApiTarget(projectRoot: string, config: TaskBuilderConfig): '{workspaceRoot}/scripts/api-extractor/api-extractor.*.json', { externalDependencies: ['@microsoft/api-extractor', 'typescript'] }, ], - outputs: [`{projectRoot}/dist/index.d.ts`, `{projectRoot}/etc/${config.projectJSON.name}.api.md`, ...extraOutputs], + // When exportSubpaths is enabled, use broad globs for outputs + // — the executor resolves exact paths at runtime. + outputs: hasExportSubpaths + ? ['{projectRoot}/dist/**/*.d.ts', '{projectRoot}/etc/*.api.md'] + : [`{projectRoot}/dist/index.d.ts`, `{projectRoot}/etc/${config.projectJSON.name}.api.md`], metadata: { technologies: ['typescript', 'api-extractor'], help: { From 82410af89fe77f0da9fa66f7b89fa0ce1c3728f8 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 14:51:12 +0200 Subject: [PATCH 10/13] style: resolve lint issues --- .../src/executors/generate-api/executor.spec.ts | 5 ++++- tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 7006e336da7e4c..f46db8731994fb 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -6,7 +6,7 @@ import { type ExtractorResult, } from '@microsoft/api-extractor'; import { basename, join } from 'node:path'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { type TsConfig } from '../../types'; @@ -538,7 +538,9 @@ describe('GenerateApi Executor – export subpath resolution', () => { for (const name of subDirs) { const cfg = wildcardConfigs.find(c => c.mainEntryPointFilePath.includes(`items/${name}/`))!; expect(cfg.apiReportEnabled).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-deprecated expect(cfg.reportFilePath).toBe(join(paths.projRoot, 'etc', `${name}.api.md`)); + // eslint-disable-next-line @typescript-eslint/no-deprecated expect(cfg.reportTempFilePath).toBe(join(paths.projRoot, 'temp', `${name}.api.md`)); } }); @@ -628,6 +630,7 @@ describe('GenerateApi Executor – export subpath resolution', () => { await executor({ ...options, exportSubpaths: true }, context); const utilsConfig = capturedConfigs[1]; + // eslint-disable-next-line @typescript-eslint/no-deprecated expect(utilsConfig.reportFilePath).toContain('utils.api.md'); }); diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts index c2e9df915d9b54..b1e8cc100dd547 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts @@ -877,7 +877,7 @@ describe(`workspace-plugin`, () => { const generateApiTarget = targets?.['generate-api']; expect(generateApiTarget?.outputs).toEqual(['{projectRoot}/dist/**/*.d.ts', '{projectRoot}/etc/*.api.md']); - const buildTarget = targets?.['build']; + const buildTarget = targets?.build; expect(buildTarget?.options?.generateApi).toEqual({ exportSubpaths: true }); expect(buildTarget?.outputs).toEqual([ '{projectRoot}/lib', From c9a14eef8150a9f51d072fc8c9b2f6b4e5ec6f84 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 15:09:21 +0200 Subject: [PATCH 11/13] chore: undo headless package changes --- .../library/package.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 9233ff3c49137e..5de707fdcb7339 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -40,18 +40,6 @@ "import": "./lib/index.js", "require": "./lib-commonjs/index.js" }, - "./*": { - "types": "./dist/components/*/index.d.ts", - "node": "./lib-commonjs/components/*/index.js", - "import": "./lib/components/*/index.js", - "require": "./lib-commonjs/components/*/index.js" - }, - "./utils": { - "types": "./dist/utils/index.d.ts", - "node": "./lib-commonjs/utils/index.js", - "import": "./lib/utils/index.js", - "require": "./lib-commonjs/utils/index.js" - }, "./package.json": "./package.json" }, "beachball": { From b277429af8ab1d93a62e08684aa1519a75a9af75 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 15:56:43 +0200 Subject: [PATCH 12/13] refactor: cleanup tests and move getExportSubpathConfigs to utils --- .../executors/generate-api/executor.spec.ts | 219 +++------------ .../src/executors/generate-api/executor.ts | 252 +---------------- .../src/executors/generate-api/lib/shared.ts | 8 + .../src/executors/generate-api/lib/utils.ts | 265 ++++++++++++++++++ .../src/executors/generate-api/utils.ts | 34 --- 5 files changed, 321 insertions(+), 457 deletions(-) create mode 100644 tools/workspace-plugin/src/executors/generate-api/lib/utils.ts delete mode 100644 tools/workspace-plugin/src/executors/generate-api/utils.ts diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index f46db8731994fb..31e773d9ab0a61 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -388,57 +388,35 @@ describe('GenerateApi Executor – export subpath resolution', () => { // ── Wildcard exports ────────────────────────────────────────────────────── - it('calls api-extractor once per wildcard sub-directory in addition to the primary', async () => { + it('generates correct configs for each wildcard sub-directory', async () => { const subDirs = ['alpha', 'beta', 'gamma']; - const { context } = prepareWildcardFixture(subDirs); + const { paths, context } = prepareWildcardFixture(subDirs); - const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( - () => - ({ - succeeded: true, - } as ExtractorResult), - ); + const capturedConfigs: ExtractorConfig[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { + capturedConfigs.push(cfg); + return { succeeded: true } as ExtractorResult; + }); const output = await executor({ ...options, exportSubpaths: true }, context); // primary (1) + one per sub-directory - expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); + expect(capturedConfigs).toHaveLength(1 + subDirs.length); expect(output.success).toBe(true); - }); - - it('passes the correct mainEntryPointFilePath for each wildcard sub-directory', async () => { - const subDirs = ['alpha', 'beta']; - const { context } = prepareWildcardFixture(subDirs); - const capturedEntries: string[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedEntries.push(cfg.mainEntryPointFilePath); - return { succeeded: true } as ExtractorResult; - }); - - await executor({ ...options, exportSubpaths: true }, context); - - const wildcardEntries = capturedEntries.slice(1); // skip primary + const wildcardConfigs = capturedConfigs.slice(1); for (const name of subDirs) { - expect(wildcardEntries.some(p => p.includes(`items/${name}/index.d.ts`))).toBe(true); + const cfg = wildcardConfigs.find(c => c.mainEntryPointFilePath.includes(`items/${name}/`))!; + expect(cfg.mainEntryPointFilePath).toContain(`items/${name}/index.d.ts`); + expect(cfg.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'items', name, 'index.d.ts')); + expect(cfg.apiReportEnabled).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(cfg.reportFilePath).toBe(join(paths.projRoot, 'etc', `${name}.api.md`)); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(cfg.reportTempFilePath).toBe(join(paths.projRoot, 'temp', `${name}.api.md`)); } }); - it('sets the dts rollup untrimmedFilePath to dist/{wildcard-path}/{name}/index.d.ts', async () => { - const { paths, context } = prepareWildcardFixture(['alpha']); - - const capturedConfigs: ExtractorConfig[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedConfigs.push(cfg); - return { succeeded: true } as ExtractorResult; - }); - - await executor({ ...options, exportSubpaths: true }, context); - - const wildcardConfig = capturedConfigs[1]; // second call is the wildcard entry - expect(wildcardConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'items', 'alpha', 'index.d.ts')); - }); - it('skips wildcard exports with no types field', async () => { const { paths, context } = prepareFixture('valid', {}); const { projRoot } = paths; @@ -522,104 +500,30 @@ describe('GenerateApi Executor – export subpath resolution', () => { expect(output.success).toBe(true); }); - it('routes api report for each wildcard entry to etc/{name}.api.md', async () => { - const subDirs = ['alpha', 'beta']; - const { paths, context } = prepareWildcardFixture(subDirs); - - const capturedConfigs: ExtractorConfig[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedConfigs.push(cfg); - return { succeeded: true } as ExtractorResult; - }); - - await executor({ ...options, exportSubpaths: true }, context); - - const wildcardConfigs = capturedConfigs.slice(1); // skip primary - for (const name of subDirs) { - const cfg = wildcardConfigs.find(c => c.mainEntryPointFilePath.includes(`items/${name}/`))!; - expect(cfg.apiReportEnabled).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-deprecated - expect(cfg.reportFilePath).toBe(join(paths.projRoot, 'etc', `${name}.api.md`)); - // eslint-disable-next-line @typescript-eslint/no-deprecated - expect(cfg.reportTempFilePath).toBe(join(paths.projRoot, 'temp', `${name}.api.md`)); - } - }); - - it('skips export subpath expansion when exportSubpaths is false', async () => { - const subDirs = ['alpha', 'beta']; - const { context } = prepareWildcardFixture(subDirs); - - const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( - () => - ({ - succeeded: true, - } as ExtractorResult), - ); - - const output = await executor({ ...options, exportSubpaths: false }, context); - - // Only the primary config should run — no subpath expansion - expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); - expect(output.success).toBe(true); - }); - - it('skips export subpath expansion by default (exportSubpaths not set)', async () => { - const subDirs = ['alpha', 'beta']; - const { context } = prepareWildcardFixture(subDirs); - - const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( - () => - ({ - succeeded: true, - } as ExtractorResult), - ); - - // Pass default options — no exportSubpaths key - const output = await executor(options, context); - - // Only the primary config should run — subpath expansion is opt-in - expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); - expect(output.success).toBe(true); - }); - - it('expands wildcards when exportSubpaths is true', async () => { - const subDirs = ['alpha', 'beta']; - const { context } = prepareWildcardFixture(subDirs); - - const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( - () => - ({ - succeeded: true, - } as ExtractorResult), - ); + it.each([{ exportSubpaths: false } as const, {} as const])( + 'skips export subpath expansion when exportSubpaths=%j', + async overrides => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareWildcardFixture(subDirs); + + const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( + () => + ({ + succeeded: true, + } as ExtractorResult), + ); - const output = await executor({ ...options, exportSubpaths: true }, context); + const output = await executor({ ...options, ...overrides }, context); - // primary (1) + one per sub-directory - expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + subDirs.length); - expect(output.success).toBe(true); - }); + expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); + expect(output.success).toBe(true); + }, + ); // ── Named exports ──────────────────────────────────────────────────────── - it('creates config for named export ./utils with correct mainEntryPointFilePath', async () => { - const { context } = prepareNamedExportFixture(); - - const capturedEntries: string[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedEntries.push(cfg.mainEntryPointFilePath); - return { succeeded: true } as ExtractorResult; - }); - - await executor({ ...options, exportSubpaths: true }, context); - - // primary + utils - expect(capturedEntries).toHaveLength(2); - expect(capturedEntries[1]).toContain('utils/index.d.ts'); - }); - - it('derives reportFileName from named export key', async () => { - const { context } = prepareNamedExportFixture(); + it('generates correct config for named export ./utils', async () => { + const { paths, context } = prepareNamedExportFixture(); const capturedConfigs: ExtractorConfig[] = []; jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { @@ -629,24 +533,15 @@ describe('GenerateApi Executor – export subpath resolution', () => { await executor({ ...options, exportSubpaths: true }, context); - const utilsConfig = capturedConfigs[1]; - // eslint-disable-next-line @typescript-eslint/no-deprecated - expect(utilsConfig.reportFilePath).toContain('utils.api.md'); - }); - - it('enables apiReport by default for named exports when exportSubpaths is true', async () => { - const { context } = prepareNamedExportFixture(); - - const capturedConfigs: ExtractorConfig[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedConfigs.push(cfg); - return { succeeded: true } as ExtractorResult; - }); - - await executor({ ...options, exportSubpaths: true }, context); + // primary + utils — "." and "./package.json" are skipped + expect(capturedConfigs).toHaveLength(2); const utilsConfig = capturedConfigs[1]; + expect(utilsConfig.mainEntryPointFilePath).toContain('utils/index.d.ts'); + expect(utilsConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'utils', 'index.d.ts')); expect(utilsConfig.apiReportEnabled).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(utilsConfig.reportFilePath).toContain('utils.api.md'); }); it('disables apiReport for named exports when exportSubpaths: { apiReport: false }', async () => { @@ -664,21 +559,6 @@ describe('GenerateApi Executor – export subpath resolution', () => { expect(utilsConfig.apiReportEnabled).toBe(false); }); - it('sets dts rollup path for named export to dist/{subpath}/index.d.ts', async () => { - const { paths, context } = prepareNamedExportFixture(); - - const capturedConfigs: ExtractorConfig[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedConfigs.push(cfg); - return { succeeded: true } as ExtractorResult; - }); - - await executor({ ...options, exportSubpaths: true }, context); - - const utilsConfig = capturedConfigs[1]; - expect(utilsConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'utils', 'index.d.ts')); - }); - it('processes both named and wildcard exports in a single package', async () => { const subDirs = ['alpha', 'beta']; const { context } = prepareMixedExportFixture(subDirs); @@ -696,19 +576,4 @@ describe('GenerateApi Executor – export subpath resolution', () => { expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + 1 + subDirs.length); expect(output.success).toBe(true); }); - - it('skips "." and "./package.json" entries', async () => { - const { context } = prepareNamedExportFixture(); - - const capturedEntries: string[] = []; - jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { - capturedEntries.push(cfg.mainEntryPointFilePath); - return { succeeded: true } as ExtractorResult; - }); - - await executor({ ...options, exportSubpaths: true }, context); - - // Only primary + utils — "." and "./package.json" skipped - expect(capturedEntries).toHaveLength(2); - }); }); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index 4da2b69df7ec90..bf0961e8f62cd4 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -1,15 +1,14 @@ -import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { execSync } from 'node:child_process'; - +import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; import { Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor'; import type { GenerateApiExecutorSchema } from './schema'; import type { PackageJson, TsConfig } from '../../types'; import { measureEnd, measureStart } from '../../utils'; -import { isCI } from './lib/shared'; -import { isWildcardTypedEntry, isNamedTypedEntry } from './utils'; +import { isCI, verboseLog } from './lib/shared'; +import { getExportSubpathConfigs } from './lib/utils'; const runExecutor: PromiseExecutor = async (schema, context) => { measureStart('GenerateApiExecutor'); @@ -27,7 +26,7 @@ export default runExecutor; // =========== -interface NormalizedOptions extends ReturnType {} +export interface NormalizedOptions extends ReturnType {} async function runGenerateApi(options: NormalizedOptions, context: ExecutorContext): Promise { if (!generateTypeDeclarations(options)) { @@ -183,239 +182,6 @@ function apiExtractor( } } -/** - * Reads the package.json exports map and resolves both wildcard entries (e.g. "./*") and named - * entries (e.g. "./utils") into individual api-extractor config objects. - * - * - Wildcard entries are expanded into one config per sub-directory found under the resolved source path. - * - Named entries produce a single config each, derived directly from their types field. - * - The root export (".") and "./package.json" are always skipped. - */ -function getExportSubpathConfigs(options: NormalizedOptions): IConfigFile[] { - const packageJson: PackageJson = parseJson(readFileSync(options.packageJsonPath, 'utf-8')); - - const exports = packageJson.exports ?? {}; - - const declarationBase = resolveDeclarationBase(options, packageJson); - if (!declarationBase) { - return []; - } - - const apiReportEnabled = options.exportSubpaths.apiReport; - - const configs: IConfigFile[] = []; - - for (const [exportKey, exportValue] of Object.entries(exports)) { - // Skip root and package.json entries - if (exportKey === '.' || exportKey === './package.json') { - continue; - } - - // Wildcard entries: expand into sub-directories - if (isWildcardTypedEntry(exportKey, exportValue)) { - const pathPrefixes = parseWildcardTypesPattern(exportValue.types); - if (!pathPrefixes) { - continue; - } - - const declarationScanDir = join(declarationBase, pathPrefixes.wildcardSubPath); - const subDirs = listSubDirectories(declarationScanDir); - if (!subDirs) { - continue; - } - - for (const dirName of subDirs) { - configs.push( - createSubpathEntryConfig({ - projectAbsolutePath: options.projectAbsolutePath, - declarationBase, - subPath: pathPrefixes.wildcardSubPath + dirName, - distRelativePath: pathPrefixes.distRelativePrefix + dirName, - reportFileName: dirName, - apiReportEnabled, - }), - ); - } - continue; - } - - // Named entries: create config directly from types field - if (isNamedTypedEntry(exportKey, exportValue)) { - const parsed = parseNamedTypesPattern(exportValue.types); - if (!parsed) { - continue; - } - - const subpathName = exportKey.replace(/^\.\//, ''); - - configs.push( - createSubpathEntryConfig({ - projectAbsolutePath: options.projectAbsolutePath, - declarationBase, - subPath: parsed.declarationSubPath, - distRelativePath: parsed.distRelativePath, - reportFileName: subpathName, - apiReportEnabled, - }), - ); - } - } - - return configs; - - /** - * Resolves the declaration base path from the primary api-extractor config's mainEntryPointFilePath. - * The primary config uses tokens (, , ) which we - * resolve here so that programmatic configs for wildcard entries land in the same output tree. - * - * @returns The absolute path to the declaration base, or `null` if it cannot be resolved. - */ - function resolveDeclarationBase(opts: NormalizedOptions, pkgJson: PackageJson): string | null { - const primaryRawConfig = parseJson<{ mainEntryPointFilePath?: string }>(readFileSync(opts.config, 'utf-8')); - const primaryMainEntryTemplate = primaryRawConfig?.mainEntryPointFilePath; - if (!primaryMainEntryTemplate) { - return null; - } - - const unscopedPackageName = (pkgJson.name ?? '').replace(/^@[^/]+\//, ''); - // and in api-extractor.json are NOT replaced before path.resolve — - // they act as literal path segments that the subsequent "../" chain traverses through. - // path.resolve(configDir, "/../../../../../../...") naturally normalizes to the correct path. - const configDir = dirname(opts.config); - const resolvedPrimaryEntry = resolve( - configDir, - primaryMainEntryTemplate.replace(//g, unscopedPackageName), - ); - - const indexDtsSuffix = '/index.d.ts'; - if (!resolvedPrimaryEntry.endsWith(indexDtsSuffix)) { - verboseLog( - `Primary mainEntryPointFilePath "${resolvedPrimaryEntry}" does not end with "${indexDtsSuffix}". ` + - `Skipping export subpath expansion.`, - 'warn', - ); - return null; - } - - return resolvedPrimaryEntry.slice(0, -indexDtsSuffix.length); - } - - /** - * Parses a wildcard types pattern and derives the dist-relative prefix and the - * wildcard sub-path (the portion after the first path segment). - * - * Example: "./dist/items/STAR/index.d.ts" - * → distRelativePrefix: "dist/items/" - * → wildcardSubPath: "items/" - * - * @returns The path prefixes, or `null` if the pattern cannot be parsed. - */ - function parseWildcardTypesPattern(typesPattern: string): { - distRelativePrefix: string; - wildcardSubPath: string; - } | null { - const starIdx = typesPattern.indexOf('*'); - if (starIdx === -1) { - return null; - } - - const typesPrefix = typesPattern.slice(0, starIdx); - const distRelativePrefix = typesPrefix.replace(/^\.\//, ''); - - // Extract the sub-path by stripping the first path segment (the dist directory name). - // e.g. "dist/items/" → "items/" - const firstSlashIdx = distRelativePrefix.indexOf('/'); - const wildcardSubPath = firstSlashIdx === -1 ? '' : distRelativePrefix.slice(firstSlashIdx + 1); - - return { distRelativePrefix, wildcardSubPath }; - } - - /** - * Parses a named (non-wildcard) types pattern and derives the dist-relative path and - * the declaration sub-path (the portion after the first path segment, minus index.d.ts). - * - * Example: "./dist/utils/index.d.ts" - * → distRelativePath: "dist/utils" - * → declarationSubPath: "utils" - * - * @returns The path components, or `null` if the pattern cannot be parsed. - */ - function parseNamedTypesPattern(typesPattern: string): { - distRelativePath: string; - declarationSubPath: string; - } | null { - const indexDtsSuffix = '/index.d.ts'; - if (!typesPattern.endsWith(indexDtsSuffix)) { - return null; - } - - // Strip "./" prefix and trailing "/index.d.ts" - const distRelativePath = typesPattern.replace(/^\.\//, '').slice(0, -indexDtsSuffix.length); - - // Strip the first path segment (the dist directory name) - const firstSlashIdx = distRelativePath.indexOf('/'); - const declarationSubPath = firstSlashIdx === -1 ? '' : distRelativePath.slice(firstSlashIdx + 1); - - if (!declarationSubPath) { - return null; - } - - return { distRelativePath, declarationSubPath }; - } - - /** - * Lists immediate sub-directories of the given path. - * - * @returns Array of directory names, or `null` if the path does not exist or cannot be read. - */ - function listSubDirectories(dirPath: string): string[] | null { - if (!existsSync(dirPath)) { - verboseLog(`Export subpath source dir not found, skipping: ${dirPath}`, 'warn'); - return null; - } - - try { - return readdirSync(dirPath, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name); - } catch { - return null; - } - } - - /** - * Creates an api-extractor IConfigFile for a single export sub-path entry. - */ - function createSubpathEntryConfig(params: { - projectAbsolutePath: string; - declarationBase: string; - subPath: string; - distRelativePath: string; - reportFileName: string; - apiReportEnabled: boolean; - }): IConfigFile { - const mainEntryPointFilePath = join(params.declarationBase, params.subPath, 'index.d.ts'); - const dtsRollupPath = join(params.projectAbsolutePath, params.distRelativePath, 'index.d.ts'); - - return { - projectFolder: params.projectAbsolutePath, - mainEntryPointFilePath, - apiReport: { - enabled: params.apiReportEnabled, - reportFileName: params.reportFileName, - reportFolder: '/etc/', - reportTempFolder: '/temp/', - }, - docModel: { enabled: false }, - dtsRollup: { - enabled: true, - untrimmedFilePath: dtsRollupPath, - }, - tsdocMetadata: { enabled: false }, - }; - } -} - function getTsConfigForApiExtractor(options: { tsConfig: TsConfig; packageJson: PackageJson; @@ -523,9 +289,3 @@ function getTsConfigPathUsedForProduction(projectRoot: string) { return { error: null, result: tsConfigFileForCompilation }; } - -function verboseLog(message: string, kind: keyof typeof logger = 'info') { - if (process.env.NX_VERBOSE_LOGGING === 'true') { - logger[kind](message); - } -} diff --git a/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts b/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts index 90c09255805ff8..3f1393fb3eab73 100644 --- a/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts +++ b/tools/workspace-plugin/src/executors/generate-api/lib/shared.ts @@ -1,3 +1,5 @@ +import { logger } from '@nx/devkit'; + export function isCI() { return ( (process.env.CI && process.env.CI !== 'false') || @@ -5,3 +7,9 @@ export function isCI() { process.env.GITHUB_ACTIONS === 'true' ); } + +export function verboseLog(message: string, kind: keyof typeof logger = 'info') { + if (process.env.NX_VERBOSE_LOGGING === 'true') { + logger[kind](message); + } +} diff --git a/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts new file mode 100644 index 00000000000000..6ec2d4dc32f13a --- /dev/null +++ b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts @@ -0,0 +1,265 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { parseJson } from '@nx/devkit'; +import { type IConfigFile } from '@microsoft/api-extractor'; + +import type { PackageJson } from '../../../types'; + +import type { NormalizedOptions } from '../executor'; +import { verboseLog } from './shared'; + +function isTypedEntry(exportValue: unknown): exportValue is { types: string } & Record { + return typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; +} + +/** + * Checks whether a single export map entry is a wildcard entry with a `types` field. + */ +function isWildcardTypedEntry( + exportKey: string, + exportValue: unknown, +): exportValue is { types: string } & Record { + return exportKey.includes('*') && isTypedEntry(exportValue); +} + +/** + * Checks whether a single export map entry is a named (non-wildcard, non-root) entry with a `types` field. + * Skips `"."` and `"./package.json"`. + */ +function isNamedTypedEntry( + exportKey: string, + exportValue: unknown, +): exportValue is { types: string } & Record { + if (exportKey === '.' || exportKey === './package.json' || exportKey.includes('*')) { + return false; + } + return isTypedEntry(exportValue); +} + +/** + * Reads the package.json exports map and resolves both wildcard entries (e.g. "./*") and named + * entries (e.g. "./utils") into individual api-extractor config objects. + * + * - Wildcard entries are expanded into one config per sub-directory found under the resolved source path. + * - Named entries produce a single config each, derived directly from their types field. + * - The root export (".") and "./package.json" are always skipped. + */ +export function getExportSubpathConfigs(options: NormalizedOptions): IConfigFile[] { + const packageJson: PackageJson = parseJson(readFileSync(options.packageJsonPath, 'utf-8')); + + const exports = packageJson.exports ?? {}; + + const declarationBase = resolveDeclarationBase(options, packageJson); + if (!declarationBase) { + return []; + } + + const apiReportEnabled = options.exportSubpaths.apiReport; + + const configs: IConfigFile[] = []; + + for (const [exportKey, exportValue] of Object.entries(exports)) { + // Wildcard entries: expand into sub-directories + if (isWildcardTypedEntry(exportKey, exportValue)) { + const pathPrefixes = parseWildcardTypesPattern(exportValue.types); + if (!pathPrefixes) { + continue; + } + + const declarationScanDir = join(declarationBase, pathPrefixes.wildcardSubPath); + const subDirs = listSubDirectories(declarationScanDir); + if (!subDirs) { + continue; + } + + for (const dirName of subDirs) { + configs.push( + createSubpathEntryConfig({ + projectAbsolutePath: options.projectAbsolutePath, + declarationBase, + subPath: pathPrefixes.wildcardSubPath + dirName, + distRelativePath: pathPrefixes.distRelativePrefix + dirName, + reportFileName: dirName, + apiReportEnabled, + }), + ); + } + continue; + } + + // Named entries: create config directly from types field + if (isNamedTypedEntry(exportKey, exportValue)) { + const parsed = parseNamedTypesPattern(exportValue.types); + if (!parsed) { + continue; + } + + const subpathName = exportKey.replace(/^\.\//, ''); + + configs.push( + createSubpathEntryConfig({ + projectAbsolutePath: options.projectAbsolutePath, + declarationBase, + subPath: parsed.declarationSubPath, + distRelativePath: parsed.distRelativePath, + reportFileName: subpathName, + apiReportEnabled, + }), + ); + } + } + + return configs; + + /** + * Resolves the declaration base path from the primary api-extractor config's mainEntryPointFilePath. + * The primary config uses tokens (, , ) which we + * resolve here so that programmatic configs for wildcard entries land in the same output tree. + * + * @returns The absolute path to the declaration base, or `null` if it cannot be resolved. + */ + function resolveDeclarationBase(opts: NormalizedOptions, pkgJson: PackageJson): string | null { + const primaryRawConfig = parseJson<{ mainEntryPointFilePath?: string }>(readFileSync(opts.config, 'utf-8')); + const primaryMainEntryTemplate = primaryRawConfig?.mainEntryPointFilePath; + if (!primaryMainEntryTemplate) { + return null; + } + + const unscopedPackageName = (pkgJson.name ?? '').replace(/^@[^/]+\//, ''); + // and in api-extractor.json are NOT replaced before path.resolve — + // they act as literal path segments that the subsequent "../" chain traverses through. + // path.resolve(configDir, "/../../../../../../...") naturally normalizes to the correct path. + const configDir = dirname(opts.config); + const resolvedPrimaryEntry = resolve( + configDir, + primaryMainEntryTemplate.replace(//g, unscopedPackageName), + ); + + const indexDtsSuffix = '/index.d.ts'; + if (!resolvedPrimaryEntry.endsWith(indexDtsSuffix)) { + verboseLog( + `Primary mainEntryPointFilePath "${resolvedPrimaryEntry}" does not end with "${indexDtsSuffix}". ` + + `Skipping export subpath expansion.`, + 'warn', + ); + return null; + } + + return resolvedPrimaryEntry.slice(0, -indexDtsSuffix.length); + } + + /** + * Parses a wildcard types pattern and derives the dist-relative prefix and the + * wildcard sub-path (the portion after the first path segment). + * + * Example: "./dist/items/STAR/index.d.ts" + * → distRelativePrefix: "dist/items/" + * → wildcardSubPath: "items/" + * + * @returns The path prefixes, or `null` if the pattern cannot be parsed. + */ + function parseWildcardTypesPattern(typesPattern: string): { + distRelativePrefix: string; + wildcardSubPath: string; + } | null { + const starIdx = typesPattern.indexOf('*'); + if (starIdx === -1) { + return null; + } + + const typesPrefix = typesPattern.slice(0, starIdx); + const distRelativePrefix = typesPrefix.replace(/^\.\//, ''); + + // Extract the sub-path by stripping the first path segment (the dist directory name). + // e.g. "dist/items/" → "items/" + const firstSlashIdx = distRelativePrefix.indexOf('/'); + const wildcardSubPath = firstSlashIdx === -1 ? '' : distRelativePrefix.slice(firstSlashIdx + 1); + + return { distRelativePrefix, wildcardSubPath }; + } + + /** + * Parses a named (non-wildcard) types pattern and derives the dist-relative path and + * the declaration sub-path (the portion after the first path segment, minus index.d.ts). + * + * Example: "./dist/utils/index.d.ts" + * → distRelativePath: "dist/utils" + * → declarationSubPath: "utils" + * + * @returns The path components, or `null` if the pattern cannot be parsed. + */ + function parseNamedTypesPattern(typesPattern: string): { + distRelativePath: string; + declarationSubPath: string; + } | null { + const indexDtsSuffix = '/index.d.ts'; + if (!typesPattern.endsWith(indexDtsSuffix)) { + return null; + } + + // Strip "./" prefix and trailing "/index.d.ts" + const distRelativePath = typesPattern.replace(/^\.\//, '').slice(0, -indexDtsSuffix.length); + + // Strip the first path segment (the dist directory name) + const firstSlashIdx = distRelativePath.indexOf('/'); + const declarationSubPath = firstSlashIdx === -1 ? '' : distRelativePath.slice(firstSlashIdx + 1); + + if (!declarationSubPath) { + return null; + } + + return { distRelativePath, declarationSubPath }; + } + + /** + * Lists immediate sub-directories of the given path. + * + * @returns Array of directory names, or `null` if the path does not exist or cannot be read. + */ + function listSubDirectories(dirPath: string): string[] | null { + if (!existsSync(dirPath)) { + verboseLog(`Export subpath source dir not found, skipping: ${dirPath}`, 'warn'); + return null; + } + + try { + return readdirSync(dirPath, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { + return null; + } + } + + /** + * Creates an api-extractor IConfigFile for a single export sub-path entry. + */ + function createSubpathEntryConfig(params: { + projectAbsolutePath: string; + declarationBase: string; + subPath: string; + distRelativePath: string; + reportFileName: string; + apiReportEnabled: boolean; + }): IConfigFile { + const mainEntryPointFilePath = join(params.declarationBase, params.subPath, 'index.d.ts'); + const dtsRollupPath = join(params.projectAbsolutePath, params.distRelativePath, 'index.d.ts'); + + return { + projectFolder: params.projectAbsolutePath, + mainEntryPointFilePath, + apiReport: { + enabled: params.apiReportEnabled, + reportFileName: params.reportFileName, + reportFolder: '/etc/', + reportTempFolder: '/temp/', + }, + docModel: { enabled: false }, + dtsRollup: { + enabled: true, + untrimmedFilePath: dtsRollupPath, + }, + tsdocMetadata: { enabled: false }, + }; + } +} diff --git a/tools/workspace-plugin/src/executors/generate-api/utils.ts b/tools/workspace-plugin/src/executors/generate-api/utils.ts deleted file mode 100644 index d4dca17c0a2a52..00000000000000 --- a/tools/workspace-plugin/src/executors/generate-api/utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Checks whether a single export map entry is a wildcard entry with a `types` field. - */ -export function isWildcardTypedEntry( - exportKey: string, - exportValue: unknown, -): exportValue is { types: string } & Record { - return exportKey.includes('*') && typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; -} - -/** - * Checks whether a single export map entry is a named (non-wildcard, non-root) entry with a `types` field. - * Skips `"."` and `"./package.json"`. - */ -export function isNamedTypedEntry( - exportKey: string, - exportValue: unknown, -): exportValue is { types: string } & Record { - if (exportKey === '.' || exportKey === './package.json' || exportKey.includes('*')) { - return false; - } - return typeof exportValue === 'object' && exportValue !== null && 'types' in exportValue; -} - -/** - * Returns `true` when the package.json `exports` map contains at least one wildcard key (e.g. - * `"./*"`) whose value is an object with a `types` field. - */ -export function hasWildcardTypedExport(exports: Record | undefined): boolean { - if (!exports) { - return false; - } - return Object.entries(exports).some(([key, val]) => isWildcardTypedEntry(key, val)); -} From 149f7a72169313e2468aecd00fbf173f8bd2bd70 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Fri, 17 Apr 2026 16:23:49 +0200 Subject: [PATCH 13/13] fixup! refactor: cleanup tests and move getExportSubpathConfigs to utils --- .../executors/generate-api/executor.spec.ts | 136 ++++-------------- 1 file changed, 26 insertions(+), 110 deletions(-) diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 31e773d9ab0a61..64c701c30ea576 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -243,117 +243,30 @@ describe('GenerateApi Executor – export subpath resolution', () => { }); /** - * Creates a fixture with a wildcard export "./*" whose types pattern resolves - * to one emitted .d.ts per sub-directory under dts/src/items/. + * Creates a fixture with configurable export map entries. * The primary api-extractor.json uses a relative path from config/ to dts/src/. */ - function prepareWildcardFixture(subDirNames: string[]) { + function prepareExportFixture(config: { wildcardSubDirs?: string[]; namedExports?: string[] }) { + const { wildcardSubDirs = [], namedExports = [] } = config; const { paths, context } = prepareFixture('valid', {}); const { projRoot } = paths; - writeFileSync( - join(projRoot, 'package.json'), - serializeJson({ - name: '@proj/proj', - types: 'dist/index.d.ts', - exports: { - '.': { types: './dist/index.d.ts', import: './lib/index.js' }, - './*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }, - './package.json': './package.json', - }, - }), - 'utf-8', - ); - - writeFileSync( - join(projRoot, 'config', 'api-extractor.json'), - serializeJson({ - mainEntryPointFilePath: '../dts/src/index.d.ts', - apiReport: { enabled: false }, - docModel: { enabled: false }, - dtsRollup: { enabled: true }, - tsdocMetadata: { enabled: false }, - }), - 'utf-8', - ); - - execSyncMock.mockImplementation(() => { - mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); - writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); - for (const name of subDirNames) { - mkdirSync(join(projRoot, 'dts', 'src', 'items', name), { recursive: true }); - writeFileSync( - join(projRoot, 'dts', 'src', 'items', name, 'index.d.ts'), - `export const value: string;`, - 'utf-8', - ); + const exports: Record = { + '.': { types: './dist/index.d.ts', import: './lib/index.js' }, + }; + if (namedExports.length > 0) { + for (const name of namedExports) { + exports[`./${name}`] = { types: `./dist/${name}/index.d.ts`, import: `./lib/${name}/index.js` }; } - }); - - return { paths, context }; - } - - /** - * Creates a fixture with a named export "./utils" that has a types field. - */ - function prepareNamedExportFixture() { - const { paths, context } = prepareFixture('valid', {}); - const { projRoot } = paths; - - writeFileSync( - join(projRoot, 'package.json'), - serializeJson({ - name: '@proj/proj', - types: 'dist/index.d.ts', - exports: { - '.': { types: './dist/index.d.ts', import: './lib/index.js' }, - './utils': { types: './dist/utils/index.d.ts', import: './lib/utils/index.js' }, - './package.json': './package.json', - }, - }), - 'utf-8', - ); - - writeFileSync( - join(projRoot, 'config', 'api-extractor.json'), - serializeJson({ - mainEntryPointFilePath: '../dts/src/index.d.ts', - apiReport: { enabled: false }, - docModel: { enabled: false }, - dtsRollup: { enabled: true }, - tsdocMetadata: { enabled: false }, - }), - 'utf-8', - ); - - execSyncMock.mockImplementation(() => { - mkdirSync(join(projRoot, 'dts', 'src', 'utils'), { recursive: true }); - writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); - writeFileSync(join(projRoot, 'dts', 'src', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); - }); - - return { paths, context }; - } - - /** - * Creates a fixture with both wildcard and named exports. - */ - function prepareMixedExportFixture(subDirNames: string[]) { - const { paths, context } = prepareFixture('valid', {}); - const { projRoot } = paths; + } + if (wildcardSubDirs.length > 0) { + exports['./*'] = { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }; + } + exports['./package.json'] = './package.json'; writeFileSync( join(projRoot, 'package.json'), - serializeJson({ - name: '@proj/proj', - types: 'dist/index.d.ts', - exports: { - '.': { types: './dist/index.d.ts', import: './lib/index.js' }, - './utils': { types: './dist/utils/index.d.ts', import: './lib/utils/index.js' }, - './*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' }, - './package.json': './package.json', - }, - }), + serializeJson({ name: '@proj/proj', types: 'dist/index.d.ts', exports }), 'utf-8', ); @@ -370,10 +283,13 @@ describe('GenerateApi Executor – export subpath resolution', () => { ); execSyncMock.mockImplementation(() => { - mkdirSync(join(projRoot, 'dts', 'src', 'utils'), { recursive: true }); + mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true }); writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8'); - writeFileSync(join(projRoot, 'dts', 'src', 'utils', 'index.d.ts'), 'export const bar: string;', 'utf-8'); - for (const name of subDirNames) { + for (const name of namedExports) { + mkdirSync(join(projRoot, 'dts', 'src', name), { recursive: true }); + writeFileSync(join(projRoot, 'dts', 'src', name, 'index.d.ts'), `export const ${name}: string;`, 'utf-8'); + } + for (const name of wildcardSubDirs) { mkdirSync(join(projRoot, 'dts', 'src', 'items', name), { recursive: true }); writeFileSync( join(projRoot, 'dts', 'src', 'items', name, 'index.d.ts'), @@ -390,7 +306,7 @@ describe('GenerateApi Executor – export subpath resolution', () => { it('generates correct configs for each wildcard sub-directory', async () => { const subDirs = ['alpha', 'beta', 'gamma']; - const { paths, context } = prepareWildcardFixture(subDirs); + const { paths, context } = prepareExportFixture({ wildcardSubDirs: subDirs }); const capturedConfigs: ExtractorConfig[] = []; jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { @@ -504,7 +420,7 @@ describe('GenerateApi Executor – export subpath resolution', () => { 'skips export subpath expansion when exportSubpaths=%j', async overrides => { const subDirs = ['alpha', 'beta']; - const { context } = prepareWildcardFixture(subDirs); + const { context } = prepareExportFixture({ wildcardSubDirs: subDirs }); const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( () => @@ -523,7 +439,7 @@ describe('GenerateApi Executor – export subpath resolution', () => { // ── Named exports ──────────────────────────────────────────────────────── it('generates correct config for named export ./utils', async () => { - const { paths, context } = prepareNamedExportFixture(); + const { paths, context } = prepareExportFixture({ namedExports: ['utils'] }); const capturedConfigs: ExtractorConfig[] = []; jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { @@ -545,7 +461,7 @@ describe('GenerateApi Executor – export subpath resolution', () => { }); it('disables apiReport for named exports when exportSubpaths: { apiReport: false }', async () => { - const { context } = prepareNamedExportFixture(); + const { context } = prepareExportFixture({ namedExports: ['utils'] }); const capturedConfigs: ExtractorConfig[] = []; jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => { @@ -561,7 +477,7 @@ describe('GenerateApi Executor – export subpath resolution', () => { it('processes both named and wildcard exports in a single package', async () => { const subDirs = ['alpha', 'beta']; - const { context } = prepareMixedExportFixture(subDirs); + const { context } = prepareExportFixture({ wildcardSubDirs: subDirs, namedExports: ['utils'] }); const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation( () =>