From 7bda833207b794d71f12b02801dc1c784dc0fc47 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 24 Aug 2026 13:15:05 +0200 Subject: [PATCH 1/6] chore(workspace-plugin): add export-maps-sync generator Adds an Nx sync generator that keeps package.json entry point fields (main, module, typings) and the exports map in sync with each project's declared entry points, for non-private libraries tagged vNext + platform:web. Motivation: #36606 had to hand-fix 6 headless subpaths that shipped with the legacy flat shape ({types, node, import, require}, .js CJS paths, no .d.cts) mixed into an otherwise migrated map. The exports map is also the source of truth for generate-api, so a missing subpath silently drops a dts rollup. Entry points cannot be inferred from the file layout, so multi entry projects declare them via project.json#metadata.exportMap. Projects without the declaration default to a single root entry, which is what the other 79 packages already have. Also fixes react-headless-components-preview, whose main/module/typings were dead because its exports map had no "." key. --- .github/workflows/pr.yml | 1 + ...-d2505feb-1a3f-46e1-a15b-04c5bbd176a4.json | 7 + nx.json | 6 +- .../react-components/project.json | 6 + .../library/package.json | 10 + .../library/project.json | 6 + tools/workspace-plugin/generators.json | 5 + .../src/generators/export-maps-sync/README.md | 51 ++++ .../generators/export-maps-sync/index.spec.ts | 159 ++++++++++++ .../src/generators/export-maps-sync/index.ts | 89 +++++++ .../export-maps-sync/lib/export-map.spec.ts | 245 ++++++++++++++++++ .../export-maps-sync/lib/export-map.ts | 139 ++++++++++ .../generators/export-maps-sync/schema.d.ts | 4 + .../generators/export-maps-sync/schema.json | 9 + .../src/generators/export-maps-sync/types.ts | 20 ++ .../generators/migrate-converged-pkg/index.ts | 15 +- tools/workspace-plugin/src/types.ts | 4 + 17 files changed, 762 insertions(+), 14 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-d2505feb-1a3f-46e1-a15b-04c5bbd176a4.json create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/README.md create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/index.ts create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/schema.d.ts create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/schema.json create mode 100644 tools/workspace-plugin/src/generators/export-maps-sync/types.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ce2dba4ee93a5..300a003ca35d2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -51,6 +51,7 @@ jobs: - name: Workspace lint run: | yarn nx run workspace-plugin:check-graph + yarn nx sync:check yarn nx g @fluentui/workspace-plugin:tsconfig-base-all --verify yarn nx g @fluentui/workspace-plugin:normalize-package-dependencies --verify diff --git a/change/@fluentui-react-headless-components-preview-d2505feb-1a3f-46e1-a15b-04c5bbd176a4.json b/change/@fluentui-react-headless-components-preview-d2505feb-1a3f-46e1-a15b-04c5bbd176a4.json new file mode 100644 index 0000000000000..8a34e997d9d54 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-d2505feb-1a3f-46e1-a15b-04c5bbd176a4.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "add missing root export map entry", + "packageName": "@fluentui/react-headless-components-preview", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/nx.json b/nx.json index 1686139fa4fd3..8c8836ada9d66 100644 --- a/nx.json +++ b/nx.json @@ -16,7 +16,8 @@ "build": { "dependsOn": ["^build"], "inputs": ["production", "^production", "{workspaceRoot}/scripts/api-extractor/api-extractor.*.json"], - "cache": true + "cache": true, + "syncGenerators": ["@fluentui/workspace-plugin:export-maps-sync"] }, "build-storybook": { "dependsOn": [], @@ -136,6 +137,9 @@ "release": { "projectsRelationship": "independent" }, + "sync": { + "globalGenerators": ["@fluentui/workspace-plugin:export-maps-sync"] + }, "parallel": 3, "useInferencePlugins": false, "defaultBase": "master", diff --git a/packages/react-components/react-components/project.json b/packages/react-components/react-components/project.json index 6849412701718..21ac007f451ec 100644 --- a/packages/react-components/react-components/project.json +++ b/packages/react-components/react-components/project.json @@ -5,6 +5,12 @@ "sourceRoot": "packages/react-components/react-components/src", "tags": ["vNext", "platform:web"], "implicitDependencies": [], + "metadata": { + "exportMap": { + "root": true, + "subpathEntryPoints": ["src/unstable/index.ts"] + } + }, "targets": { "build": { "options": { 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 6103f202fafbd..30b2e77ac4e39 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -79,6 +79,16 @@ "react-dom": ">=16.14.0 <20.0.0" }, "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./lib/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./lib-commonjs/index.cjs" + } + }, "./accordion": { "import": { "types": "./dist/accordion.d.ts", diff --git a/packages/react-components/react-headless-components-preview/library/project.json b/packages/react-components/react-headless-components-preview/library/project.json index ecab81877a1e1..e906edbbc52b6 100644 --- a/packages/react-components/react-headless-components-preview/library/project.json +++ b/packages/react-components/react-headless-components-preview/library/project.json @@ -5,6 +5,12 @@ "sourceRoot": "packages/react-components/react-headless-components-preview/library/src", "tags": ["vNext", "platform:web", "react-headless"], "implicitDependencies": [], + "metadata": { + "exportMap": { + "root": true, + "subpathEntryPoints": ["src/*.ts"] + } + }, "targets": { "generate-api": { "options": { diff --git a/tools/workspace-plugin/generators.json b/tools/workspace-plugin/generators.json index 3a882f4f0b422..ebac3a2495f5d 100644 --- a/tools/workspace-plugin/generators.json +++ b/tools/workspace-plugin/generators.json @@ -40,6 +40,11 @@ "schema": "./src/generators/tsconfig-base-all/schema.json", "description": "Generate tsconfig.base.all.json with merged 'compilerOptions.paths' from v0,v8,v9 tsconfigs" }, + "export-maps-sync": { + "implementation": "./src/generators/export-maps-sync/index.ts", + "schema": "./src/generators/export-maps-sync/schema.json", + "description": "Keep package.json entry point fields and export maps in sync with declared entry points" + }, "workspace-generator": { "implementation": "./src/generators/workspace-generator/index.ts", "schema": "./src/generators/workspace-generator/schema.json", diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/README.md b/tools/workspace-plugin/src/generators/export-maps-sync/README.md new file mode 100644 index 0000000000000..19b149606ccee --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/README.md @@ -0,0 +1,51 @@ +# export-maps-sync + +Nx [sync generator](https://nx.dev/concepts/sync-generators) that keeps `package.json` entry point +fields (`main`, `module`, `typings`) and the `exports` map in sync with each project's declared entry +points. + +Applies to non-private `library` projects tagged both `vNext` and `platform:web`. + +```sh +yarn nx sync # fix +yarn nx sync:check # verify (CI) +``` + +## Declaring entry points + +Entry points cannot be inferred from the file layout, because the same layout means opposite things: + +| Project | top-level `src/*.ts` | export subpaths | +| ----------------------------------- | -------------------------------------- | ------------------- | +| `react-headless-components-preview` | 55 files | all 55 are subpaths | +| `react-button` | `Button.tsx`, `CompoundButton.ts`, ... | none — only `.` | + +So each multi-entry project declares its own, in `project.json`: + +```jsonc +{ + "metadata": { + "exportMap": { + "root": true, + "subpathEntryPoints": ["src/*.ts"] + } + } +} +``` + +- `root` — whether a `"."` entry resolved from `src/index.ts` is exposed. Defaults to `true`. +- `subpathEntryPoints` — globs, relative to the project root, resolving to the source files backing + non-root subpaths. Defaults to `[]`. + +Single entry point packages omit `metadata.exportMap` entirely and get `{ root: true, +subpathEntryPoints: [] }`. + +Source file names map to subpaths by stripping `src/` and the extension, so `src/color-picker.ts` +becomes `./color-picker` and `src/unstable/index.ts` becomes `./unstable`. + +## Why a sync generator + +The `exports` map is the source of truth for `generate-api` (it derives one api-extractor entry per +subpath) and for consumers. A subpath added to `src/` without a matching `exports` entry is silently +unreachable, and a subpath authored with the legacy flat shape silently breaks `require` type +resolution — both shipped before ([#36606](https://github.com/microsoft/fluentui/pull/36606)). diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts b/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts new file mode 100644 index 0000000000000..58b88a25d014a --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts @@ -0,0 +1,159 @@ +import { type ProjectConfiguration, type Tree, readJson, writeJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import type { PackageJson } from '../../types'; +import generator from './index'; + +/** + * Export map shape and entry point resolution are covered by `lib/export-map.spec.ts`. + * These specs cover only what the generator itself owns: project scoping, writes and reporting. + */ +describe('export-maps-sync generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + function setupProject(options: { + name: string; + projectConfig?: Partial; + packageJson?: Partial; + sourceFiles?: string[]; + }) { + const root = `packages/${options.name}`; + + writeJson(tree, `${root}/project.json`, { + name: options.name, + projectType: 'library', + sourceRoot: `${root}/src`, + tags: ['vNext', 'platform:web'], + ...options.projectConfig, + }); + + writeJson(tree, `${root}/package.json`, { + name: `@proj/${options.name}`, + version: '9.0.0', + type: 'module', + main: 'lib-commonjs/index.cjs', + module: 'lib/index.js', + typings: './dist/index.d.ts', + ...options.packageJson, + }); + + tree.write(`${root}/src/index.ts`, 'export {};'); + for (const file of options.sourceFiles ?? []) { + tree.write(`${root}/${file}`, 'export {};'); + } + + return { root, readPackageJson: () => readJson(tree, `${root}/package.json`) }; + } + + it('writes the export map for an out of sync project', async () => { + const project = setupProject({ name: 'react-button', packageJson: { exports: undefined } }); + + await generator(tree); + + expect(project.readPackageJson().exports).toBeDefined(); + }); + + it('restores entry point fields that drifted from the export map', async () => { + const project = setupProject({ + name: 'react-button', + packageJson: { main: 'lib-commonjs/index.js', typings: './lib/index.d.ts' }, + }); + + await generator(tree); + + expect(project.readPackageJson()).toMatchObject({ + main: 'lib-commonjs/index.cjs', + module: 'lib/index.js', + typings: './dist/index.d.ts', + }); + }); + + it('preserves unrelated package.json fields', async () => { + const project = setupProject({ + name: 'react-button', + packageJson: { exports: undefined, dependencies: { '@proj/react-utilities': '^9.0.0' }, sideEffects: false }, + }); + + await generator(tree); + + expect(project.readPackageJson()).toMatchObject({ + dependencies: { '@proj/react-utilities': '^9.0.0' }, + sideEffects: false, + }); + }); + + it('reports every out of sync project', async () => { + setupProject({ name: 'react-button', packageJson: { exports: undefined } }); + setupProject({ name: 'react-tooltip', packageJson: { exports: undefined } }); + + const result = await generator(tree); + + expect(result.outOfSyncMessage).toContain('react-button'); + expect(result.outOfSyncMessage).toContain('react-tooltip'); + }); + + it('is a no-op on the second run', async () => { + const project = setupProject({ + name: 'react-headless', + projectConfig: { metadata: { exportMap: { root: true, subpathEntryPoints: ['src/*.ts'] } } }, + sourceFiles: ['src/badge.ts'], + }); + + await generator(tree); + const afterFirstRun = project.readPackageJson(); + + const result = await generator(tree); + + expect(result.outOfSyncMessage).toBeUndefined(); + expect(project.readPackageJson()).toEqual(afterFirstRun); + }); + + describe('scope', () => { + it.each([ + ['a non web platform project', { tags: ['vNext', 'platform:node'] }], + ['a v8 project', { tags: ['v8', 'platform:web'] }], + ['an untagged project', { tags: [] }], + ['an application', { projectType: 'application' as const }], + ])('leaves %s untouched', async (_name, projectConfig) => { + const project = setupProject({ name: 'some-lib', projectConfig, packageJson: { exports: undefined } }); + + await generator(tree); + + expect(project.readPackageJson().exports).toBeUndefined(); + }); + + it('leaves a private project untouched', async () => { + const project = setupProject({ name: 'some-lib', packageJson: { private: true, exports: undefined } }); + + await generator(tree); + + expect(project.readPackageJson().exports).toBeUndefined(); + }); + + it('skips a project without a package.json', async () => { + writeJson(tree, 'packages/some-lib/project.json', { + name: 'some-lib', + projectType: 'library', + tags: ['vNext', 'platform:web'], + }); + + await expect(generator(tree)).resolves.toEqual({ outOfSyncMessage: undefined }); + }); + + it('skips a project that declares no entry points at all', async () => { + const project = setupProject({ + name: 'some-lib', + projectConfig: { metadata: { exportMap: { root: false, subpathEntryPoints: [] } } }, + packageJson: { exports: undefined }, + }); + + await generator(tree); + + expect(project.readPackageJson().exports).toBeUndefined(); + }); + }); +}); diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/index.ts b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts new file mode 100644 index 0000000000000..143537e9cbe5b --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts @@ -0,0 +1,89 @@ +import { type ProjectConfiguration, type Tree, formatFiles, getProjects, readJson, updateJson } from '@nx/devkit'; +import { isEqual } from 'lodash'; + +import { buildEntryPointFields, buildExportMap, readExportMapConfig, resolveEntryPoints } from './lib/export-map'; +import type { PackageJson } from '../../types'; + +const REQUIRED_TAGS = ['vNext', 'platform:web']; + +export default async function (tree: Tree) { + const outOfSync: string[] = []; + + for (const [projectName, projectConfig] of getProjects(tree)) { + if (!isInScope(tree, projectConfig)) { + continue; + } + + if (await syncProject(tree, projectConfig)) { + outOfSync.push(projectName); + } + } + + await formatFiles(tree); + + return { + outOfSyncMessage: outOfSyncMessage(outOfSync), + }; +} + +function isInScope(tree: Tree, projectConfig: ProjectConfiguration): boolean { + if (projectConfig.projectType !== 'library') { + return false; + } + + const tags = projectConfig.tags ?? []; + if (!REQUIRED_TAGS.every(tag => tags.includes(tag))) { + return false; + } + + const packageJsonPath = `${projectConfig.root}/package.json`; + if (!tree.exists(packageJsonPath)) { + return false; + } + + return !readJson(tree, packageJsonPath).private; +} + +/** + * @returns whether the project was out of sync + */ +async function syncProject(tree: Tree, projectConfig: ProjectConfiguration): Promise { + const packageJsonPath = `${projectConfig.root}/package.json`; + const packageJson = readJson(tree, packageJsonPath); + + const config = readExportMapConfig(projectConfig); + const entryPoints = await resolveEntryPoints(tree, projectConfig.root, config); + + if (entryPoints.length === 0) { + return false; + } + + const expectedFields = buildEntryPointFields(packageJson); + const expectedExports = buildExportMap(packageJson, entryPoints); + + const fieldsInSync = (Object.keys(expectedFields) as Array).every(field => + isEqual(packageJson[field], expectedFields[field]), + ); + + if (fieldsInSync && isEqual(packageJson.exports, expectedExports)) { + return false; + } + + updateJson(tree, packageJsonPath, json => { + Object.assign(json, expectedFields); + json.exports = expectedExports; + + return json; + }); + + return true; +} + +function outOfSyncMessage(outOfSync: string[]): string | undefined { + if (outOfSync.length === 0) { + return undefined; + } + + return `The following projects have an out of date package.json entry point setup (\`exports\`, \`main\`, \`module\`, \`typings\`): +${outOfSync.map(name => ` - ${name}`).join('\n')}`; +} diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts new file mode 100644 index 0000000000000..2177ad5d09de1 --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts @@ -0,0 +1,245 @@ +import { type Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import type { PackageJson } from '../../../types'; +import { buildEntryPointFields, buildExportMap, readExportMapConfig, resolveEntryPoints } from './export-map'; + +describe('readExportMapConfig', () => { + it('defaults to a single root entry point', () => { + expect(readExportMapConfig({ root: 'packages/react-button' })).toEqual({ + root: true, + subpathEntryPoints: [], + }); + }); + + it('reads the declaration from project metadata', () => { + const config = readExportMapConfig({ + root: 'packages/react-headless', + metadata: { exportMap: { root: false, subpathEntryPoints: ['src/*.ts'] } }, + }); + + expect(config).toEqual({ root: false, subpathEntryPoints: ['src/*.ts'] }); + }); + + it('fills in defaults for a partial declaration', () => { + const config = readExportMapConfig({ + root: 'packages/react-headless', + metadata: { exportMap: { subpathEntryPoints: ['src/*.ts'] } }, + }); + + expect(config).toEqual({ root: true, subpathEntryPoints: ['src/*.ts'] }); + }); +}); + +describe('resolveEntryPoints', () => { + const projectRoot = 'packages/react-headless'; + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + function writeSourceFiles(...files: string[]) { + for (const file of files) { + tree.write(`${projectRoot}/${file}`, 'export {};'); + } + } + + it('resolves only the root entry when no subpaths are declared', async () => { + writeSourceFiles('src/index.ts', 'src/CompoundButton.ts'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: true, subpathEntryPoints: [] }); + + expect(entryPoints).toEqual([{ key: '.', name: 'index', outputPath: 'index' }]); + }); + + it('resolves nothing when the package has neither a root nor declared subpaths', async () => { + writeSourceFiles('src/index.ts'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: [] }); + + expect(entryPoints).toEqual([]); + }); + + it('sorts subpaths alphabetically and keeps the root first', async () => { + writeSourceFiles('src/index.ts', 'src/tooltip.ts', 'src/badge.ts', 'src/color-picker.ts'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: true, subpathEntryPoints: ['src/*.ts'] }); + + expect(entryPoints.map(entry => entry.key)).toEqual(['.', './badge', './color-picker', './tooltip']); + }); + + it('never emits the src root index as a subpath', async () => { + writeSourceFiles('src/index.ts', 'src/badge.ts'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: ['src/*.ts'] }); + + expect(entryPoints.map(entry => entry.key)).toEqual(['./badge']); + }); + + it('flattens a directory index into its directory name while keeping the compiled path', async () => { + writeSourceFiles('src/index.ts', 'src/unstable/index.ts'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: false, + subpathEntryPoints: ['src/unstable/index.ts'], + }); + + expect(entryPoints).toEqual([{ key: './unstable', name: 'unstable', outputPath: 'unstable/index' }]); + }); + + it.each(['src/badge.spec.ts', 'src/badge.test.ts', 'src/badge.stories.tsx', 'src/badge.d.ts'])( + 'excludes %s', + async file => { + writeSourceFiles('src/index.ts', file); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: false, + subpathEntryPoints: ['src/*.ts', 'src/*.tsx'], + }); + + expect(entryPoints).toEqual([]); + }, + ); + + it('supports tsx entry points', async () => { + writeSourceFiles('src/badge.tsx'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: ['src/*.tsx'] }); + + expect(entryPoints).toEqual([{ key: './badge', name: 'badge', outputPath: 'badge' }]); + }); + + it('deduplicates a subpath matched by multiple globs', async () => { + writeSourceFiles('src/badge.ts'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: false, + subpathEntryPoints: ['src/*.ts', 'src/badge.ts'], + }); + + expect(entryPoints).toEqual([{ key: './badge', name: 'badge', outputPath: 'badge' }]); + }); +}); + +describe('buildExportMap', () => { + const esmPackage: PackageJson = { + name: '@proj/react-button', + version: '9.0.0', + type: 'module', + main: 'lib-commonjs/index.cjs', + module: 'lib/index.js', + typings: './dist/index.d.ts', + }; + const rootEntry = { key: '.', name: 'index', outputPath: 'index' }; + + describe('esm first packages', () => { + it('builds the conditional import/require shape with no node condition', () => { + expect(buildExportMap(esmPackage, [rootEntry])).toEqual({ + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './package.json': './package.json', + }); + }); + + it('points require types at a .d.cts so node16 CJS consumers resolve a CommonJS declaration', () => { + const exports = buildExportMap(esmPackage, [{ key: './badge', name: 'badge', outputPath: 'badge' }]); + + expect(exports!['./badge']).toEqual({ + import: { types: './dist/badge.d.ts', default: './lib/badge.js' }, + require: { types: './dist/badge.d.cts', default: './lib-commonjs/badge.cjs' }, + }); + }); + + it('rolls declarations up to a flat dist file while compiled output mirrors the source layout', () => { + const exports = buildExportMap(esmPackage, [ + { key: './unstable', name: 'unstable', outputPath: 'unstable/index' }, + ]); + + expect(exports!['./unstable']).toEqual({ + import: { types: './dist/unstable.d.ts', default: './lib/unstable/index.js' }, + require: { types: './dist/unstable.d.cts', default: './lib-commonjs/unstable/index.cjs' }, + }); + }); + + it('exposes the style condition on the root entry only', () => { + const exports = buildExportMap({ ...esmPackage, style: 'dist/index.css' }, [ + rootEntry, + { key: './badge', name: 'badge', outputPath: 'badge' }, + ]); + + expect(exports!['.']).toHaveProperty('style', './dist/index.css'); + expect(exports!['./badge']).not.toHaveProperty('style'); + }); + }); + + describe('commonjs first packages', () => { + const cjsPackage: PackageJson = { + name: '@proj/react-storybook-addon', + version: '9.0.0', + main: 'lib-commonjs/index.js', + module: 'lib/index.js', + typings: './dist/index.d.ts', + }; + + it('keeps the node/module condition shape', () => { + expect(buildExportMap(cjsPackage, [rootEntry])).toEqual({ + '.': { + types: './dist/index.d.ts', + node: { module: './lib/index.js', default: './lib-commonjs/index.js' }, + import: './lib/index.js', + require: './lib-commonjs/index.js', + }, + './package.json': './package.json', + }); + }); + + it('collapses the node condition when the package ships no esm output', () => { + expect(buildExportMap({ ...cjsPackage, module: undefined }, [rootEntry])).toEqual({ + '.': { + types: './dist/index.d.ts', + node: './lib-commonjs/index.js', + require: './lib-commonjs/index.js', + }, + './package.json': './package.json', + }); + }); + }); + + it('always exposes the package.json subpath last', () => { + const exports = buildExportMap(esmPackage, [rootEntry, { key: './badge', name: 'badge', outputPath: 'badge' }]); + + expect(Object.keys(exports!).at(-1)).toBe('./package.json'); + }); + + it('exposes only the package.json subpath when there are no entry points', () => { + expect(buildExportMap(esmPackage, [])).toEqual({ './package.json': './package.json' }); + }); +}); + +describe('buildEntryPointFields', () => { + it('points main at the .cjs output for esm first packages', () => { + expect(buildEntryPointFields({ type: 'module' } as PackageJson)).toEqual({ + main: 'lib-commonjs/index.cjs', + module: 'lib/index.js', + typings: './dist/index.d.ts', + }); + }); + + it('points main at the .js output for commonjs first packages', () => { + expect(buildEntryPointFields({ module: 'lib/index.js' } as PackageJson)).toEqual({ + main: 'lib-commonjs/index.js', + module: 'lib/index.js', + typings: './dist/index.d.ts', + }); + }); + + it('omits module for commonjs first packages that ship no esm output', () => { + expect(buildEntryPointFields({} as PackageJson)).toEqual({ + main: 'lib-commonjs/index.js', + typings: './dist/index.d.ts', + }); + }); +}); diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts new file mode 100644 index 0000000000000..2cb95ba22b8e1 --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts @@ -0,0 +1,139 @@ +import * as path from 'node:path'; + +import { type ProjectConfiguration, type Tree, globAsync, joinPathFragments } from '@nx/devkit'; + +import type { PackageJson } from '../../../types'; +import type { ExportMapConfig } from '../types'; + +export interface EntryPoint { + /** Export map key, eg. `.` or `./color-picker` */ + key: string; + /** Flattened basename used for the dts rollup, eg. `index`, `color-picker`, `unstable` */ + name: string; + /** Compiled path relative to `lib`/`lib-commonjs`, mirroring the source layout, eg. `unstable/index` */ + outputPath: string; +} + +const DEFAULT_CONFIG: ExportMapConfig = { root: true, subpathEntryPoints: [] }; + +export function readExportMapConfig(projectConfig: ProjectConfiguration): ExportMapConfig { + const metadata = projectConfig.metadata as { exportMap?: Partial } | undefined; + + return { ...DEFAULT_CONFIG, ...metadata?.exportMap }; +} + +/** + * Resolves declared entry point globs into deterministic, sorted export map entries. + */ +export async function resolveEntryPoints( + tree: Tree, + projectRoot: string, + config: ExportMapConfig, +): Promise { + const entryPoints: EntryPoint[] = config.root ? [{ key: '.', name: 'index', outputPath: 'index' }] : []; + + if (config.subpathEntryPoints.length === 0) { + return entryPoints; + } + + const matches = await globAsync( + tree, + config.subpathEntryPoints.map(glob => joinPathFragments(projectRoot, glob)), + ); + + const byName = new Map(); + for (const match of matches) { + const outputPath = toOutputPath(path.posix.relative(joinPathFragments(projectRoot, 'src'), match)); + + if (outputPath === null || outputPath === 'index') { + continue; + } + + // `unstable/index` -> `unstable` + byName.set(outputPath.replace(/\/index$/, ''), outputPath); + } + + for (const name of [...byName.keys()].sort()) { + entryPoints.push({ key: `./${name}`, name, outputPath: byName.get(name)! }); + } + + return entryPoints; +} + +/** + * @returns `null` for files that can never be an entry point + */ +function toOutputPath(sourcePathFromSrc: string): string | null { + if (/\.(d\.ts|spec\.[jt]sx?|test\.[jt]sx?|stories\.[jt]sx?)$/.test(sourcePathFromSrc)) { + return null; + } + + return sourcePathFromSrc.replace(/\.[jt]sx?$/, ''); +} + +/** + * Builds the canonical export map for a package. + * + * ESM-first packages (opt-in via `"type": "module"`) get the conditional import/require shape with no + * `node` condition; every other package keeps the CommonJS-first shape. + */ +export function buildExportMap(json: PackageJson, entryPoints: EntryPoint[]): PackageJson['exports'] { + const style = json.style ? normalizeEntryPointPath(json.style) : null; + const exports: NonNullable = {}; + + // Opt-in: a package becomes ESM-first by declaring `"type": "module"` in its package.json. + if (json.type === 'module') { + for (const { key, name, outputPath } of entryPoints) { + // bare Node `import` resolves to valid ESM (`lib/`), `require` resolves to CommonJS + // (`lib-commonjs/*.cjs`). Per-condition `types` point `require` at a `.d.cts` so `node16`/ + // `nodenext` CJS consumers get a CommonJS-flavoured declaration (keeps `@arethetypeswrong/cli` green). + exports[key] = { + ...(key === '.' && style ? { style } : null), + import: { types: `./dist/${name}.d.ts`, default: `./lib/${outputPath}.js` }, + require: { types: `./dist/${name}.d.cts`, default: `./lib-commonjs/${outputPath}.cjs` }, + }; + } + + exports['./package.json'] = './package.json'; + + return exports; + } + + // node / CJS-first packages keep the module-condition shape (no `type: module`): + // bundlers tree-shake via `module`, bare Node stays CommonJS via `default`. + for (const { key, name, outputPath } of entryPoints) { + const commonjs = `./lib-commonjs/${outputPath}.js`; + const esm = json.module ? `./lib/${outputPath}.js` : null; + + exports[key] = { + types: `./dist/${name}.d.ts`, + ...(key === '.' && style ? { style } : null), + node: esm ? { module: esm, default: commonjs } : commonjs, + ...(esm ? { import: esm } : null), + require: commonjs, + }; + } + + exports['./package.json'] = './package.json'; + + return exports; +} + +/** + * Package entry point fields that must stay in lockstep with the export map. + */ +export function buildEntryPointFields(json: PackageJson): Pick { + if (json.type === 'module') { + return { main: 'lib-commonjs/index.cjs', module: 'lib/index.js', typings: './dist/index.d.ts' }; + } + + return { + main: 'lib-commonjs/index.js', + ...(json.module ? { module: 'lib/index.js' } : null), + typings: './dist/index.d.ts', + }; +} + +export function normalizeEntryPointPath(entryPath: string) { + return './' + path.posix.normalize(entryPath); +} diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/schema.d.ts b/tools/workspace-plugin/src/generators/export-maps-sync/schema.d.ts new file mode 100644 index 0000000000000..f06c6a022690f --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/schema.d.ts @@ -0,0 +1,4 @@ +/** + * This generator is invoked by `nx sync` / `nx sync:check` and takes no CLI options. + */ +export type ExportMapsSyncGeneratorSchema = Record; diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/schema.json b/tools/workspace-plugin/src/generators/export-maps-sync/schema.json new file mode 100644 index 0000000000000..9daa58e420a7d --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "id": "export-maps-sync", + "description": "Keep package.json entry point fields and export maps in sync with declared entry points", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/types.ts b/tools/workspace-plugin/src/generators/export-maps-sync/types.ts new file mode 100644 index 0000000000000..db6d13aea9f69 --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/types.ts @@ -0,0 +1,20 @@ +/** + * Per project entry point declaration, provided via `project.json#metadata.exportMap`. + * + * Entry points cannot be inferred from the file layout: `react-headless-components-preview/src/*.ts` + * are all export subpaths, while `react-button/src/*.ts` are internal re-export modules. + */ +export interface ExportMapConfig { + /** + * Whether the package exposes a root (`"."`) entry point resolved from `src/index.ts`. + * @default true + */ + root: boolean; + /** + * Globs, relative to the project root, resolving to the source files backing non-root export + * subpaths. Source paths map to subpaths by stripping `src/` and the extension, so + * `src/color-picker.ts` becomes `./color-picker` and `src/unstable/index.ts` becomes `./unstable`. + * @default [] + */ + subpathEntryPoints: string[]; +} diff --git a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts index 07efbf6a15ca1..e15052cb07310 100644 --- a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts +++ b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts @@ -21,6 +21,7 @@ import ts from 'typescript'; import { getTemplate, uniqueArray } from './lib/utils'; import setupCypressComponentTesting from '../cypress-component-configuration'; import { PackageJson, TsConfig } from '../../types'; +import { buildExportMap } from '../export-maps-sync/lib/export-map'; import { arePromptsEnabled, getProjectConfig, @@ -699,23 +700,11 @@ function updatePackageJson(tree: Tree, options: NormalizedSchemaWithTsConfigs) { // packages get the ESM/CJS conditional export shape (no `node` condition); every other package // keeps the existing CommonJS-first shape below unchanged (this stays a no-op for them). if (json.type === 'module') { - // bare Node `import` resolves to valid ESM (`lib/`), `require` resolves to CommonJS - // (`lib-commonjs/*.cjs`). Per-condition `types` point `require` at a `.d.cts` so `node16`/ - // `nodenext` CJS consumers get a CommonJS-flavoured declaration (keeps `@arethetypeswrong/cli` green). const commonjsCjs = commonjs ? commonjs.replace(/\.js$/, '.cjs') : null; if (commonjsCjs) { json.main = commonjsCjs; } - const esmTypes = json.typings; - const cjsTypes = json.typings ? json.typings.replace(/\.d\.ts$/, '.d.cts') : undefined; - json.exports = { - '.': { - ...(json.style ? { style: normalizePackageEntryPointPaths(json.style) } : null), - ...(esm && esmTypes ? { import: { types: esmTypes, default: esm } } : null), - ...(commonjsCjs && cjsTypes ? { require: { types: cjsTypes, default: commonjsCjs } } : null), - }, - './package.json': './package.json', - }; + json.exports = buildExportMap(json, [{ key: '.', name: 'index', outputPath: 'index' }]); return json; } diff --git a/tools/workspace-plugin/src/types.ts b/tools/workspace-plugin/src/types.ts index 3fb7f43d0099c..f0a334a5cbaf9 100644 --- a/tools/workspace-plugin/src/types.ts +++ b/tools/workspace-plugin/src/types.ts @@ -29,6 +29,10 @@ export interface PackageJson { name: string; main: string; module?: string; + /** + * Marks the package as free of side effects so bundlers can tree-shake unused exports. + */ + sideEffects?: boolean | string[]; /** * Vite and Webpack(sass-loader) consume this field * @see https://github.com/microsoft/fluentui/pull/27274 From 019fdde804cafa006a8ca5a8c28256427d85dd53 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 24 Aug 2026 17:18:05 +0200 Subject: [PATCH 2/6] change files --- ...ct-components-61c0814e-a118-4a22-b34f-c221185da553.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-components-61c0814e-a118-4a22-b34f-c221185da553.json diff --git a/change/@fluentui-react-components-61c0814e-a118-4a22-b34f-c221185da553.json b/change/@fluentui-react-components-61c0814e-a118-4a22-b34f-c221185da553.json new file mode 100644 index 0000000000000..28c9e02e27ba1 --- /dev/null +++ b/change/@fluentui-react-components-61c0814e-a118-4a22-b34f-c221185da553.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "chore(workspace-plugin): add export-maps-sync generator", + "packageName": "@fluentui/react-components", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "none" +} From 210af1f2df689bc94e260533fe627057caf452e8 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 24 Aug 2026 21:50:29 +0200 Subject: [PATCH 3/6] fix(workspace-plugin): detect export map key order drift Review feedback on #36615: the in-sync check used lodash isEqual, which ignores key ordering, so the generator reported "up to date" for maps that were not in the canonical order it produces. This is semantic, not cosmetic. Node resolves the first matching condition, so `types` after `default` silently degrades type resolution - reordering react-text that way made attw report "Used fallback condition" and fail while sync:check still said the workspace was up to date. Comparing with JSON.stringify makes the check order sensitive. Only detection was affected; the write path already assigned the whole object, so ordering was correct whenever the generator did fire. Canonicalises react-components, whose exports listed "./package.json" before "./unstable". Content is unchanged and key order is not semantically meaningful at the subpath level - node looks up exact keys directly and re-sorts pattern keys by specificity. --- .../react-components/package.json | 4 +- .../generators/export-maps-sync/index.spec.ts | 57 +++++++++++++++++++ .../src/generators/export-maps-sync/index.ts | 4 +- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/react-components/react-components/package.json b/packages/react-components/react-components/package.json index 17b5e8b9759db..8cc7d9d2f27ae 100644 --- a/packages/react-components/react-components/package.json +++ b/packages/react-components/react-components/package.json @@ -97,7 +97,6 @@ "default": "./lib-commonjs/index.cjs" } }, - "./package.json": "./package.json", "./unstable": { "import": { "types": "./dist/unstable.d.ts", @@ -107,7 +106,8 @@ "types": "./dist/unstable.d.cts", "default": "./lib-commonjs/unstable/index.cjs" } - } + }, + "./package.json": "./package.json" }, "files": [ "*.md", diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts b/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts index 58b88a25d014a..566d0d45fad67 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts @@ -112,6 +112,63 @@ describe('export-maps-sync generator', () => { expect(project.readPackageJson()).toEqual(afterFirstRun); }); + describe('key ordering', () => { + it('repairs a condition ordered so that default shadows types', async () => { + const project = setupProject({ + name: 'react-button', + packageJson: { + exports: { + '.': { + // node resolves the first matching condition, so this silently degrades type resolution + import: { default: './lib/index.js', types: './dist/index.d.ts' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './package.json': './package.json', + }, + }, + }); + + const result = await generator(tree); + + expect(result.outOfSyncMessage).toContain('react-button'); + expect(Object.keys(project.readPackageJson().exports!['.'] as object)).toEqual(['import', 'require']); + expect(Object.keys((project.readPackageJson().exports!['.'] as Record).import)).toEqual([ + 'types', + 'default', + ]); + }); + + it('repairs subpath keys that are not in canonical order', async () => { + const project = setupProject({ + name: 'react-headless', + projectConfig: { metadata: { exportMap: { root: true, subpathEntryPoints: ['src/*.ts'] } } }, + sourceFiles: ['src/badge.ts', 'src/tooltip.ts'], + packageJson: { + exports: { + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './package.json': './package.json', + './tooltip': { + import: { types: './dist/tooltip.d.ts', default: './lib/tooltip.js' }, + require: { types: './dist/tooltip.d.cts', default: './lib-commonjs/tooltip.cjs' }, + }, + './badge': { + import: { types: './dist/badge.d.ts', default: './lib/badge.js' }, + require: { types: './dist/badge.d.cts', default: './lib-commonjs/badge.cjs' }, + }, + }, + }, + }); + + const result = await generator(tree); + + expect(result.outOfSyncMessage).toContain('react-headless'); + expect(Object.keys(project.readPackageJson().exports!)).toEqual(['.', './badge', './tooltip', './package.json']); + }); + }); + describe('scope', () => { it.each([ ['a non web platform project', { tags: ['vNext', 'platform:node'] }], diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/index.ts b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts index 143537e9cbe5b..0decba50b4837 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/index.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts @@ -65,7 +65,9 @@ async function syncProject(tree: Tree, projectConfig: ProjectConfiguration): Pro isEqual(packageJson[field], expectedFields[field]), ); - if (fieldsInSync && isEqual(packageJson.exports, expectedExports)) { + // condition order is load bearing - node resolves the first match, so `types` after `default` + // silently degrades type resolution. compare order sensitively rather than with a deep equal. + if (fieldsInSync && JSON.stringify(packageJson.exports) === JSON.stringify(expectedExports)) { return false; } From bfd71a7e58bb3af9f0eb40eaa3a6b1f12b92e81f Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 24 Aug 2026 13:24:43 +0200 Subject: [PATCH 4/6] chore(workspace-plugin): add bundle-size-fixtures-sync generator Generates bundle-size fixtures from source so they cannot silently fall behind what a package actually exports. Only fixtures declared in project.json#metadata.bundleSizeFixtures are generated; hand written monosize fixtures in the same folder are left alone. entryPoints kind namespace imports every non-root export subpath, resolved from metadata.exportMap rather than package.json#exports so a drifted export map cannot hide behind a matching drifted fixture. This immediately caught AllComponents.fixture.js missing menu-button, positioning and utils. baseHooks kind named imports every use*Base_unstable hook exported by the project's workspace dependencies, enumerated by walking each dependency's src/index.ts with the TypeScript parser. Source is used rather than the etc/*.api.md rollups because api.md is generated by generate-api, which itself reads package.json#exports - deriving fixtures from it would reintroduce the coupling this harness exists to break. Regenerating BaseHooks.fixture.js reproduces the hand maintained file byte for byte. --- nx.json | 10 +- .../react-components/project.json | 6 + .../bundle-size/AllComponents.fixture.js | 6 + .../library/project.json | 6 + tools/workspace-plugin/generators.json | 5 + .../bundle-size-fixtures-sync/README.md | 56 +++++ .../bundle-size-fixtures-sync/index.spec.ts | 231 ++++++++++++++++++ .../bundle-size-fixtures-sync/index.ts | 144 +++++++++++ .../bundle-size-fixtures-sync/lib/fixtures.ts | 57 +++++ .../lib/public-exports.spec.ts | 140 +++++++++++ .../lib/public-exports.ts | 123 ++++++++++ .../bundle-size-fixtures-sync/schema.d.ts | 4 + .../bundle-size-fixtures-sync/schema.json | 9 + .../bundle-size-fixtures-sync/types.ts | 36 +++ 14 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/README.md create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/fixtures.ts create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.spec.ts create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.ts create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.d.ts create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.json create mode 100644 tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/types.ts diff --git a/nx.json b/nx.json index 8c8836ada9d66..ebd41430fb290 100644 --- a/nx.json +++ b/nx.json @@ -17,7 +17,10 @@ "dependsOn": ["^build"], "inputs": ["production", "^production", "{workspaceRoot}/scripts/api-extractor/api-extractor.*.json"], "cache": true, - "syncGenerators": ["@fluentui/workspace-plugin:export-maps-sync"] + "syncGenerators": [ + "@fluentui/workspace-plugin:export-maps-sync", + "@fluentui/workspace-plugin:bundle-size-fixtures-sync" + ] }, "build-storybook": { "dependsOn": [], @@ -138,7 +141,10 @@ "projectsRelationship": "independent" }, "sync": { - "globalGenerators": ["@fluentui/workspace-plugin:export-maps-sync"] + "globalGenerators": [ + "@fluentui/workspace-plugin:export-maps-sync", + "@fluentui/workspace-plugin:bundle-size-fixtures-sync" + ] }, "parallel": 3, "useInferencePlugins": false, diff --git a/packages/react-components/react-components/project.json b/packages/react-components/react-components/project.json index 21ac007f451ec..da213e7aab2dd 100644 --- a/packages/react-components/react-components/project.json +++ b/packages/react-components/react-components/project.json @@ -9,6 +9,12 @@ "exportMap": { "root": true, "subpathEntryPoints": ["src/unstable/index.ts"] + }, + "bundleSizeFixtures": { + "BaseHooks.fixture.js": { + "kind": "baseHooks", + "name": "react-components: all base hooks" + } } }, "targets": { diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index dece7d02977c8..1a1d989e3e048 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -21,11 +21,13 @@ import * as InteractionTag from '@fluentui/react-headless-components-preview/int import * as Label from '@fluentui/react-headless-components-preview/label'; import * as Link from '@fluentui/react-headless-components-preview/link'; import * as Menu from '@fluentui/react-headless-components-preview/menu'; +import * as MenuButton from '@fluentui/react-headless-components-preview/menu-button'; import * as MessageBar from '@fluentui/react-headless-components-preview/message-bar'; import * as Nav from '@fluentui/react-headless-components-preview/nav'; import * as Overflow from '@fluentui/react-headless-components-preview/overflow'; import * as Persona from '@fluentui/react-headless-components-preview/persona'; import * as Popover from '@fluentui/react-headless-components-preview/popover'; +import * as Positioning from '@fluentui/react-headless-components-preview/positioning'; import * as ProgressBar from '@fluentui/react-headless-components-preview/progress-bar'; import * as Provider from '@fluentui/react-headless-components-preview/provider'; import * as RadioGroup from '@fluentui/react-headless-components-preview/radio-group'; @@ -50,6 +52,7 @@ import * as Toast from '@fluentui/react-headless-components-preview/toast'; import * as ToggleButton from '@fluentui/react-headless-components-preview/toggle-button'; import * as Toolbar from '@fluentui/react-headless-components-preview/toolbar'; import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip'; +import * as Utils from '@fluentui/react-headless-components-preview/utils'; console.log({ Accordion, @@ -75,11 +78,13 @@ console.log({ Label, Link, Menu, + MenuButton, MessageBar, Nav, Overflow, Persona, Popover, + Positioning, ProgressBar, Provider, RadioGroup, @@ -104,6 +109,7 @@ console.log({ ToggleButton, Toolbar, Tooltip, + Utils, }); export default { diff --git a/packages/react-components/react-headless-components-preview/library/project.json b/packages/react-components/react-headless-components-preview/library/project.json index e906edbbc52b6..d5d7ecbe77c75 100644 --- a/packages/react-components/react-headless-components-preview/library/project.json +++ b/packages/react-components/react-headless-components-preview/library/project.json @@ -9,6 +9,12 @@ "exportMap": { "root": true, "subpathEntryPoints": ["src/*.ts"] + }, + "bundleSizeFixtures": { + "AllComponents.fixture.js": { + "kind": "entryPoints", + "name": "react-headless-components-preview: entire library" + } } }, "targets": { diff --git a/tools/workspace-plugin/generators.json b/tools/workspace-plugin/generators.json index ebac3a2495f5d..d1223bccc632c 100644 --- a/tools/workspace-plugin/generators.json +++ b/tools/workspace-plugin/generators.json @@ -45,6 +45,11 @@ "schema": "./src/generators/export-maps-sync/schema.json", "description": "Keep package.json entry point fields and export maps in sync with declared entry points" }, + "bundle-size-fixtures-sync": { + "implementation": "./src/generators/bundle-size-fixtures-sync/index.ts", + "schema": "./src/generators/bundle-size-fixtures-sync/schema.json", + "description": "Keep generated bundle-size fixtures in sync with export maps and base hook exports" + }, "workspace-generator": { "implementation": "./src/generators/workspace-generator/index.ts", "schema": "./src/generators/workspace-generator/schema.json", diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/README.md b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/README.md new file mode 100644 index 0000000000000..026ba1198ca2f --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/README.md @@ -0,0 +1,56 @@ +# bundle-size-fixtures-sync + +Nx [sync generator](https://nx.dev/concepts/sync-generators) that generates `bundle-size` fixtures +from source, so a fixture can never silently fall behind what a package actually exports. + +```sh +yarn nx sync # fix +yarn nx sync:check # verify (CI) +``` + +Only fixtures declared in `project.json#metadata.bundleSizeFixtures` are generated. Hand written +monosize fixtures in the same folder are left alone. + +```jsonc +{ + "metadata": { + "bundleSizeFixtures": { + "AllComponents.fixture.js": { + "kind": "entryPoints", + "name": "react-headless-components-preview: entire library" + } + } + } +} +``` + +`name` is the monosize fixture name, which doubles as the bundle size report baseline key — renaming +it drops that fixture's recorded history. + +## Fixture kinds + +### `entryPoints` + +Namespace imports every non-root export subpath of the project itself. Resolved from +`metadata.exportMap` (see [export-maps-sync](../export-maps-sync/README.md)) rather than from +`package.json#exports`, so a drifted export map cannot hide behind a matching drifted fixture. + +The root entry is deliberately excluded — importing it would pull in the whole library and defeat the +point of per subpath isolation. + +### `baseHooks` + +Named imports every `use*Base_unstable` hook exported by the project's workspace dependencies. + +Named rather than namespace imports are load bearing: a namespace import would retain every styled +component and make `verify-bundle-isolation` meaningless. + +Hooks are enumerated by walking each dependency's `src/index.ts` with the TypeScript parser +(`lib/public-exports.ts`) — no `ts.createProgram` and no type checker, since the Tree has no real file +system and only binding names are needed. Type only exports are excluded, because a type imported as +a value would not survive to runtime. + +Source is used rather than the `etc/*.api.md` rollups on purpose. `api.md` is generated by +`generate-api`, which itself reads `package.json#exports` — deriving fixtures from it would +reintroduce the very coupling this harness exists to break, and would go stale between a source +change and the next `generate-api` run. diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts new file mode 100644 index 0000000000000..09bb7419cf94c --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts @@ -0,0 +1,231 @@ +import { type ProjectConfiguration, type Tree, writeJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import generator from './index'; + +/** + * Barrel walking is covered by `lib/public-exports.spec.ts`. + */ +describe('bundle-size-fixtures-sync generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function setupProject(options: { + name: string; + packageName?: string; + projectConfig?: Partial; + dependencies?: Record; + sourceFiles?: Record; + }) { + const root = `packages/${options.name}`; + + writeJson(tree, `${root}/project.json`, { + name: options.name, + projectType: 'library', + sourceRoot: `${root}/src`, + tags: ['vNext', 'platform:web'], + ...options.projectConfig, + }); + + writeJson(tree, `${root}/package.json`, { + name: options.packageName ?? `@proj/${options.name}`, + version: '9.0.0', + type: 'module', + dependencies: options.dependencies, + }); + + tree.write(`${root}/src/index.ts`, 'export {};'); + for (const [filePath, contents] of Object.entries(options.sourceFiles ?? {})) { + tree.write(`${root}/${filePath}`, contents); + } + + return { root, readFixture: (fileName: string) => tree.read(`${root}/bundle-size/${fileName}`, 'utf-8') }; + } + + describe('entryPoints fixture', () => { + function setupEntryPointsProject(sourceFiles: Record) { + return setupProject({ + name: 'react-headless', + packageName: '@proj/react-headless', + projectConfig: { + metadata: { + exportMap: { root: true, subpathEntryPoints: ['src/*.ts'] }, + bundleSizeFixtures: { + 'AllComponents.fixture.js': { kind: 'entryPoints', name: 'react-headless: entire library' }, + }, + }, + }, + sourceFiles, + }); + } + + it('namespace imports every subpath and logs them', async () => { + const project = setupEntryPointsProject({ 'src/badge.ts': 'export {};', 'src/color-picker.ts': 'export {};' }); + + await generator(tree); + + expect(project.readFixture('AllComponents.fixture.js')).toMatchInlineSnapshot(` + "import * as Badge from '@proj/react-headless/badge'; + import * as ColorPicker from '@proj/react-headless/color-picker'; + + console.log({ + Badge, + ColorPicker, + }); + + export default { + name: 'react-headless: entire library', + }; + " + `); + }); + + it('omits the root entry, which would defeat per subpath isolation', async () => { + const project = setupEntryPointsProject({ 'src/badge.ts': 'export {};' }); + + await generator(tree); + + expect(project.readFixture('AllComponents.fixture.js')).not.toContain("from '@proj/react-headless'"); + }); + + it('picks up a newly added subpath', async () => { + const project = setupEntryPointsProject({ 'src/badge.ts': 'export {};' }); + await generator(tree); + + tree.write('packages/react-headless/src/tooltip.ts', 'export {};'); + const result = await generator(tree); + + expect(result.outOfSyncMessage).toContain('AllComponents.fixture.js'); + expect(project.readFixture('AllComponents.fixture.js')).toContain( + "import * as Tooltip from '@proj/react-headless/tooltip';", + ); + }); + }); + + describe('baseHooks fixture', () => { + function setupSuite() { + setupProject({ + name: 'react-button', + packageName: '@proj/react-button', + sourceFiles: { + 'src/index.ts': [ + `export { Button, useButtonBase_unstable, useCompoundButtonBase_unstable } from './Button';`, + `export type { ButtonProps } from './Button';`, + ].join('\n'), + }, + }); + setupProject({ + name: 'react-avatar', + packageName: '@proj/react-avatar', + sourceFiles: { 'src/index.ts': `export { useAvatarBase_unstable } from './Avatar';` }, + }); + setupProject({ + name: 'react-theme', + packageName: '@proj/react-theme', + sourceFiles: { 'src/index.ts': `export { tokens } from './tokens';` }, + }); + + return setupProject({ + name: 'react-components', + packageName: '@proj/react-components', + dependencies: { + '@proj/react-button': '^9.0.0', + '@proj/react-avatar': '^9.0.0', + '@proj/react-theme': '^9.0.0', + '@swc/helpers': '^0.5.1', + }, + projectConfig: { + metadata: { + bundleSizeFixtures: { + 'BaseHooks.fixture.js': { kind: 'baseHooks', name: 'react-components: all base hooks' }, + }, + }, + }, + }); + } + + it('named imports every base hook grouped and sorted by package', async () => { + const project = setupSuite(); + + await generator(tree); + + expect(project.readFixture('BaseHooks.fixture.js')).toMatchInlineSnapshot(` + "// Named imports only - a namespace import would retain every styled component and defeat the isolation check. + import { useAvatarBase_unstable } from '@proj/react-avatar'; + import { + useButtonBase_unstable, + useCompoundButtonBase_unstable, + } from '@proj/react-button'; + + console.log( + useAvatarBase_unstable, + useButtonBase_unstable, + useCompoundButtonBase_unstable + ); + + export default { + name: 'react-components: all base hooks', + }; + " + `); + }); + + it('picks up a base hook newly exported from a dependency source', async () => { + const project = setupSuite(); + await generator(tree); + + tree.write( + 'packages/react-avatar/src/index.ts', + `export { useAvatarBase_unstable, useAvatarGroupBase_unstable } from './Avatar';`, + ); + const result = await generator(tree); + + expect(result.outOfSyncMessage).toContain('BaseHooks.fixture.js'); + expect(project.readFixture('BaseHooks.fixture.js')).toContain('useAvatarGroupBase_unstable'); + }); + + it('ignores non workspace dependencies', async () => { + const project = setupSuite(); + + await generator(tree); + + expect(project.readFixture('BaseHooks.fixture.js')).not.toContain('@swc/helpers'); + }); + }); + + it('leaves projects without a fixture declaration alone', async () => { + const project = setupProject({ name: 'react-button' }); + + await generator(tree); + + expect(project.readFixture('AllComponents.fixture.js')).toBeNull(); + }); + + it('is a no-op on the second run', async () => { + setupProject({ + name: 'react-headless', + projectConfig: { + metadata: { + exportMap: { root: false, subpathEntryPoints: ['src/*.ts'] }, + bundleSizeFixtures: { + 'AllComponents.fixture.js': { kind: 'entryPoints', name: 'react-headless: entire library' }, + }, + }, + }, + sourceFiles: { 'src/badge.ts': 'export {};' }, + }); + + await generator(tree); + const result = await generator(tree); + + expect(result.outOfSyncMessage).toBeUndefined(); + }); +}); diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts new file mode 100644 index 0000000000000..6893414dff386 --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts @@ -0,0 +1,144 @@ +import { type ProjectConfiguration, type Tree, formatFiles, getProjects, logger, readJson } from '@nx/devkit'; + +import { readExportMapConfig, resolveEntryPoints } from '../export-maps-sync/lib/export-map'; +import type { PackageJson } from '../../types'; +import { + type BaseHookImport, + type EntryPointImport, + isBaseHook, + renderBaseHooksFixture, + renderEntryPointsFixture, + toNamespaceBinding, +} from './lib/fixtures'; +import { collectPublicExports } from './lib/public-exports'; +import type { BundleSizeFixture, BundleSizeFixturesConfig } from './types'; + +export default async function (tree: Tree) { + const projects = getProjects(tree); + const unresolved: string[] = []; + // rendered output is not prettier formatted yet, so drift can only be measured after formatFiles + const contentBeforeWrite = new Map(); + + for (const [, projectConfig] of projects) { + const fixtures = readFixturesConfig(projectConfig); + + if (fixtures === null) { + continue; + } + + for (const [fileName, fixture] of Object.entries(fixtures)) { + const filePath = `${projectConfig.root}/bundle-size/${fileName}`; + const contents = await renderFixture(tree, projects, projectConfig, fixture, unresolved); + + contentBeforeWrite.set(filePath, tree.read(filePath, 'utf-8')); + tree.write(filePath, contents); + } + } + + await formatFiles(tree); + + const outOfSync = [...contentBeforeWrite] + .filter(([filePath, before]) => tree.read(filePath, 'utf-8') !== before) + .map(([filePath]) => filePath); + + if (unresolved.length > 0) { + logger.warn( + `bundle-size-fixtures-sync could not follow these re-exports, generated fixtures may be incomplete:\n${unresolved + .map(entry => ` - ${entry}`) + .join('\n')}`, + ); + } + + return { + outOfSyncMessage: outOfSyncMessage(outOfSync), + }; +} + +function readFixturesConfig(projectConfig: ProjectConfiguration): BundleSizeFixturesConfig | null { + const metadata = projectConfig.metadata as { bundleSizeFixtures?: BundleSizeFixturesConfig } | undefined; + + return metadata?.bundleSizeFixtures ?? null; +} + +function renderFixture( + tree: Tree, + projects: Map, + projectConfig: ProjectConfiguration, + fixture: BundleSizeFixture, + unresolved: string[], +): Promise | string { + if (fixture.kind === 'entryPoints') { + return renderEntryPoints(tree, projectConfig, fixture.name); + } + + return renderBaseHooks(tree, projects, projectConfig, fixture.name, unresolved); +} + +async function renderEntryPoints(tree: Tree, projectConfig: ProjectConfiguration, name: string): Promise { + const packageJson = readJson(tree, `${projectConfig.root}/package.json`); + const entryPoints = await resolveEntryPoints(tree, projectConfig.root, readExportMapConfig(projectConfig)); + + const imports: EntryPointImport[] = entryPoints + // the root entry would pull in the whole library and defeat the point of per subpath isolation + .filter(entryPoint => entryPoint.key !== '.') + .map(entryPoint => ({ + namespace: toNamespaceBinding(entryPoint.name), + moduleSpecifier: `${packageJson.name}/${entryPoint.name}`, + })); + + return renderEntryPointsFixture(imports, name); +} + +function renderBaseHooks( + tree: Tree, + projects: Map, + projectConfig: ProjectConfiguration, + name: string, + unresolved: string[], +): string { + const packageJson = readJson(tree, `${projectConfig.root}/package.json`); + const projectsByPackageName = mapProjectsByPackageName(tree, projects); + + const imports: BaseHookImport[] = []; + + for (const packageName of Object.keys(packageJson.dependencies ?? {}).sort()) { + const dependencyRoot = projectsByPackageName.get(packageName); + if (!dependencyRoot) { + continue; + } + + const publicExports = collectPublicExports(tree, `${dependencyRoot}/src/index.ts`); + unresolved.push(...publicExports.unresolved); + + const hooks = [...publicExports.values].filter(isBaseHook).sort(); + if (hooks.length > 0) { + imports.push({ packageName, hooks }); + } + } + + return renderBaseHooksFixture(imports, name); +} + +function mapProjectsByPackageName(tree: Tree, projects: Map): Map { + const byPackageName = new Map(); + + for (const [, projectConfig] of projects) { + const packageJsonPath = `${projectConfig.root}/package.json`; + if (!tree.exists(packageJsonPath)) { + continue; + } + + byPackageName.set(readJson(tree, packageJsonPath).name, projectConfig.root); + } + + return byPackageName; +} + +function outOfSyncMessage(outOfSync: string[]): string | undefined { + if (outOfSync.length === 0) { + return undefined; + } + + return `The following bundle-size fixtures are out of date: +${outOfSync.map(filePath => ` - ${filePath}`).join('\n')}`; +} diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/fixtures.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/fixtures.ts new file mode 100644 index 0000000000000..fdade7339b38d --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/fixtures.ts @@ -0,0 +1,57 @@ +const BASE_HOOK_PATTERN = /^use\w+Base_unstable$/; + +export interface EntryPointImport { + /** Namespace binding, eg. `ColorPicker` */ + namespace: string; + /** Module specifier, eg. `@fluentui/react-headless-components-preview/color-picker` */ + moduleSpecifier: string; +} + +export interface BaseHookImport { + packageName: string; + hooks: string[]; +} + +export function isBaseHook(exportName: string): boolean { + return BASE_HOOK_PATTERN.test(exportName); +} + +/** `color-picker` -> `ColorPicker` */ +export function toNamespaceBinding(subpath: string): string { + return subpath + .split(/[-/]/) + .map(segment => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(''); +} + +export function renderEntryPointsFixture(imports: EntryPointImport[], name: string): string { + const importLines = imports.map( + ({ namespace, moduleSpecifier }) => `import * as ${namespace} from '${moduleSpecifier}';`, + ); + const logged = imports.map(({ namespace }) => ` ${namespace},`); + + return [...importLines, '', 'console.log({', ...logged, '});', '', renderMonosizeExport(name), ''].join('\n'); +} + +export function renderBaseHooksFixture(imports: BaseHookImport[], name: string): string { + const importLines = imports.map( + ({ packageName, hooks }) => `import {\n${hooks.map(hook => ` ${hook},`).join('\n')}\n} from '${packageName}';`, + ); + const logged = imports.flatMap(({ hooks }) => hooks).map(hook => ` ${hook},`); + + return [ + '// Named imports only - a namespace import would retain every styled component and defeat the isolation check.', + ...importLines, + '', + 'console.log(', + ...logged, + ');', + '', + renderMonosizeExport(name), + '', + ].join('\n'); +} + +function renderMonosizeExport(name: string): string { + return `export default {\n name: '${name}',\n};`; +} diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.spec.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.spec.ts new file mode 100644 index 0000000000000..b65ce6f4bd35d --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.spec.ts @@ -0,0 +1,140 @@ +import { type Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import { collectPublicExports } from './public-exports'; + +describe('collectPublicExports', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + function collect(files: Record, entry = 'src/index.ts') { + for (const [filePath, contents] of Object.entries(files)) { + tree.write(filePath, contents); + } + + return collectPublicExports(tree, entry); + } + + it('collects named re-exports without following the module', () => { + const { values } = collect({ + 'src/index.ts': `export { useButtonBase_unstable, Button } from './Button';`, + }); + + expect([...values]).toEqual(['useButtonBase_unstable', 'Button']); + }); + + it('takes the exported alias rather than the local name', () => { + const { values } = collect({ + 'src/index.ts': `export { useFoo as useFooBase_unstable } from './Foo';`, + }); + + expect([...values]).toEqual(['useFooBase_unstable']); + }); + + it('collects local exported declarations', () => { + const { values } = collect({ + 'src/index.ts': [ + `export const useBadgeBase_unstable = () => {};`, + `export function helper() {}`, + `export class Thing {}`, + `export enum Level {}`, + ].join('\n'), + }); + + expect([...values].sort()).toEqual(['Level', 'Thing', 'helper', 'useBadgeBase_unstable']); + }); + + it('collects a namespace re-export binding', () => { + const { values } = collect({ + 'src/index.ts': `export * as utils from './utils';`, + 'src/utils.ts': `export const toDataAttributeValue = () => {};`, + }); + + expect([...values]).toEqual(['utils']); + }); + + describe('type only exports', () => { + it('excludes an entire type only re-export', () => { + const { values } = collect({ + 'src/index.ts': `export type { ButtonProps, ButtonState } from './Button';`, + }); + + expect([...values]).toEqual([]); + }); + + it('excludes individually type only specifiers', () => { + const { values } = collect({ + 'src/index.ts': `export { type ButtonProps, useButtonBase_unstable } from './Button';`, + }); + + expect([...values]).toEqual(['useButtonBase_unstable']); + }); + + it('excludes interfaces and type aliases', () => { + const { values } = collect({ + 'src/index.ts': [`export interface ButtonProps {}`, `export type ButtonState = { a: 1 };`].join('\n'), + }); + + expect([...values]).toEqual([]); + }); + }); + + describe('star re-exports', () => { + it('follows a relative star re-export', () => { + const { values } = collect({ + 'src/index.ts': `export * from './Button';`, + 'src/Button.ts': `export const useButtonBase_unstable = () => {};`, + }); + + expect([...values]).toEqual(['useButtonBase_unstable']); + }); + + it('follows a star re-export transitively', () => { + const { values } = collect({ + 'src/index.ts': `export * from './components';`, + 'src/components/index.ts': `export * from './Button';`, + 'src/components/Button.ts': `export const useButtonBase_unstable = () => {};`, + }); + + expect([...values]).toEqual(['useButtonBase_unstable']); + }); + + it.each([ + ['a tsx file', 'src/Button.tsx'], + ['a directory index', 'src/Button/index.ts'], + ])('resolves %s', (_name, filePath) => { + const { values } = collect({ + 'src/index.ts': `export * from './Button';`, + [filePath]: `export const useButtonBase_unstable = () => {};`, + }); + + expect([...values]).toEqual(['useButtonBase_unstable']); + }); + + it('reports an unresolvable star re-export instead of silently dropping it', () => { + const { values, unresolved } = collect({ + 'src/index.ts': `export * from '@fluentui/react-utilities';`, + }); + + expect([...values]).toEqual([]); + expect(unresolved).toEqual(['src/index.ts -> @fluentui/react-utilities']); + }); + + it('terminates on a cyclic module graph', () => { + const { values } = collect({ + 'src/index.ts': `export * from './a';`, + 'src/a.ts': `export * from './b';\nexport const fromA = 1;`, + 'src/b.ts': `export * from './a';\nexport const fromB = 2;`, + }); + + expect([...values].sort()).toEqual(['fromA', 'fromB']); + }); + }); + + it('returns nothing for a missing entry file', () => { + expect(collectPublicExports(tree, 'src/nope.ts')).toEqual({ values: new Set(), unresolved: [] }); + }); +}); diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.ts new file mode 100644 index 0000000000000..641ba6a577d55 --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/lib/public-exports.ts @@ -0,0 +1,123 @@ +import * as path from 'node:path'; + +import { type Tree } from '@nx/devkit'; +import ts from 'typescript'; + +export interface PublicExports { + /** Exported value (non type-only) binding names */ + values: Set; + /** `export * from` specifiers that could not be resolved within the workspace */ + unresolved: string[]; +} + +const SOURCE_EXTENSIONS = ['.ts', '.tsx']; + +/** + * Collects the value exports reachable from a barrel entry point. + * + * Walks the module graph off the Tree with the TS parser only - no `ts.createProgram` and no type + * checker, since the Tree has no real file system and only binding names are needed. + */ +export function collectPublicExports(tree: Tree, entryFilePath: string): PublicExports { + const result: PublicExports = { values: new Set(), unresolved: [] }; + const visited = new Set(); + + visit(entryFilePath); + + return result; + + function visit(filePath: string) { + if (visited.has(filePath)) { + return; + } + visited.add(filePath); + + const contents = tree.read(filePath, 'utf-8'); + if (contents === null) { + return; + } + + const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.ESNext, true); + + for (const statement of sourceFile.statements) { + if (ts.isExportDeclaration(statement)) { + visitExportDeclaration(statement, filePath); + continue; + } + + if (hasExportModifier(statement)) { + collectLocalDeclaration(statement); + } + } + } + + function visitExportDeclaration(node: ts.ExportDeclaration, containingFile: string) { + // `export type { ... }` never produces a value binding + if (node.isTypeOnly) { + return; + } + + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const specifier of node.exportClause.elements) { + if (!specifier.isTypeOnly) { + result.values.add(specifier.name.text); + } + } + return; + } + + if (node.exportClause && ts.isNamespaceExport(node.exportClause)) { + result.values.add(node.exportClause.name.text); + return; + } + + // bare `export * from '...'` - the re-exported names are only knowable by following the module + const specifier = node.moduleSpecifier; + if (!specifier || !ts.isStringLiteral(specifier)) { + return; + } + + const resolved = resolveModule(containingFile, specifier.text); + if (resolved === null) { + result.unresolved.push(`${containingFile} -> ${specifier.text}`); + return; + } + + visit(resolved); + } + + function collectLocalDeclaration(node: ts.Statement) { + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + result.values.add(declaration.name.text); + } + } + return; + } + + if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) { + if (node.name) { + result.values.add(node.name.text); + } + } + } + + function resolveModule(containingFile: string, specifier: string): string | null { + if (!specifier.startsWith('.')) { + return null; + } + + const base = path.posix.join(path.posix.dirname(containingFile), specifier); + const candidates = [ + ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), + ...SOURCE_EXTENSIONS.map(extension => `${base}/index${extension}`), + ]; + + return candidates.find(candidate => tree.exists(candidate)) ?? null; + } +} + +function hasExportModifier(node: ts.Statement): boolean { + return Boolean(ts.canHaveModifiers(node) && ts.getModifiers(node)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)); +} diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.d.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.d.ts new file mode 100644 index 0000000000000..6790fe8ceeaac --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.d.ts @@ -0,0 +1,4 @@ +/** + * This generator is invoked by `nx sync` / `nx sync:check` and takes no CLI options. + */ +export type BundleSizeFixturesSyncGeneratorSchema = Record; diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.json b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.json new file mode 100644 index 0000000000000..c9a19dac19b3d --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "id": "bundle-size-fixtures-sync", + "description": "Keep generated bundle-size fixtures in sync with export maps and base hook exports", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/types.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/types.ts new file mode 100644 index 0000000000000..d374b8c545965 --- /dev/null +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/types.ts @@ -0,0 +1,36 @@ +/** + * Per project fixture declaration, provided via `project.json#metadata.bundleSizeFixtures`, keyed by + * the fixture file name within the project's `bundle-size` folder. + * + * Only declared fixtures are generated; hand written monosize fixtures in the same folder are left + * alone. + */ +export type BundleSizeFixturesConfig = Record; + +export type BundleSizeFixture = EntryPointsFixture | BaseHooksFixture; + +interface FixtureBase { + /** + * monosize fixture name. Doubles as the bundle size report baseline key, so renaming it drops the + * recorded history for that fixture. + */ + name: string; +} + +/** + * Namespace imports every non-root export subpath of the project itself, so a subpath missing from + * the export map fails `verify-bundle-isolation` rather than going unnoticed. + */ +export interface EntryPointsFixture extends FixtureBase { + kind: 'entryPoints'; +} + +/** + * Named imports every `use*Base_unstable` hook exported by the project's workspace dependencies. + * + * Named rather than namespace imports are load bearing: a namespace import would retain every styled + * component and make the isolation check meaningless. + */ +export interface BaseHooksFixture extends FixtureBase { + kind: 'baseHooks'; +} From 8c4af38f9367bd11ad39603bd32f93f60715c6b0 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 24 Aug 2026 14:28:29 +0200 Subject: [PATCH 5/6] chore(workspace-plugin): harden verify-packaging and gate attw in CI Adds an export map cross-check to verify-packaging: every file referenced by any condition in package.json#exports must appear in the npm pack file list. The existing assertions only matched broad globs and never looked at the export map, so an entry pointing at a file that was never built passed unnoticed while resolving to nothing at runtime. Paths under src/ are skipped because dev only conditions (eg. ./__dev) resolve to source, which is already asserted as never shipped. Widens verify-packaging from a hardcoded two project include list to every non private v9 library - 6 projects to 91, all passing. Fixes the ships-cjs assertion, which used `lib-commonjs/**/*.(js|map)` and so never matched a `.cjs` file - after the native ESM migration it was only ever passing because sourcemaps ship alongside. Scopes the attw target to esm first packages and adds it to the affected CI gate. CommonJS-first packages always report inherent `CJS default export` interop findings under the node16 profile, so gating them would be permanently red. All 80 esm first packages pass. Also fixes tag leakage in the verify-packaging spec, which pushed onto a shared context mock so tags accumulated across tests. --- .github/workflows/pr.yml | 2 +- nx.json | 9 +- .../verify-packaging/executor.spec.ts | 119 +++++++++++++++++- .../executors/verify-packaging/executor.ts | 52 +++++++- .../src/plugins/workspace-plugin.spec.ts | 17 ++- .../src/plugins/workspace-plugin.ts | 9 +- 6 files changed, 196 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 300a003ca35d2..39b2db8579409 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -74,7 +74,7 @@ jobs: - name: build, test, lint, test-ssr (affected) run: | - FLUENT_JEST_WORKER=2 yarn nx affected -t build test lint type-check test-ssr test-integration verify-packaging verify-bundle-isolation --nxBail + FLUENT_JEST_WORKER=2 yarn nx affected -t build test lint type-check test-ssr test-integration verify-packaging verify-bundle-isolation attw --nxBail - name: 'Check for unstaged changes' run: | diff --git a/nx.json b/nx.json index ebd41430fb290..7fdcd1d549ce0 100644 --- a/nx.json +++ b/nx.json @@ -101,6 +101,7 @@ }, "verify-packaging": { "dependsOn": ["build"], + "inputs": ["production"], "cache": true }, "test-rit--*--prepare": { @@ -160,7 +161,13 @@ "exclude": ["react-theme-stories", "react-migration-v8-v9-stories", "react-migration-v0-v9-stories"] }, "verifyPackaging": { - "include": ["react-text", "react-components"] + "include": [ + "react-components", + "react-headless-components-preview", + "react-utilities", + "react-button", + "react-charts" + ] }, "reactIntegrationTesting": { "targetName": "test-rit", diff --git a/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts b/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts index 5c3992c651774..0ecd8a502c51b 100644 --- a/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts @@ -2,6 +2,7 @@ import { ExecutorContext, logger, stripIndents } from '@nx/devkit'; import { spawnSync } from 'node:child_process'; import { VerifyPackagingExecutorSchema } from './schema'; +import type { PackageJson } from '../../types'; import executor from './executor'; const options: VerifyPackagingExecutorSchema = {}; @@ -25,7 +26,16 @@ jest.mock('node:child_process', () => { }; }); +jest.mock('@nx/devkit', () => { + return { + ...jest.requireActual('@nx/devkit'), + readJsonFile: jest.fn(), + }; +}); + const spawnSyncMock = spawnSync as jest.MockedFunction; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const readJsonFileMock = require('@nx/devkit').readJsonFile as jest.Mock; describe('VerifyPackaging Executor', () => { let loggerErrorSpy: jest.Spied; @@ -218,6 +228,107 @@ describe('VerifyPackaging Executor', () => { cleanup(); }); + + describe('export map', () => { + const npmPackOutput = ` + npm notice 686B LICENSE + npm notice 686B package.json + npm notice 686B README.md + npm notice 686B CHANGELOG.md + npm notice 738B lib/index.js + npm notice 738B lib-commonjs/index.cjs + npm notice 738B dist/index.d.ts + npm notice 738B dist/index.d.cts + `; + + it('should pass when every declared entry point is shipped', async () => { + const { context } = setup({ + context: contextMock, + enableProdMode: false, + projectTags: ['npm:public'], + npmPackOutput, + packageJson: { + name: '@proj/proj', + version: '1.0.0', + main: 'lib-commonjs/index.cjs', + type: 'module', + exports: { + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './package.json': './package.json', + }, + }, + }); + + const output = await executor(options, context); + + expect(loggerErrorSpy.mock.calls.flat()).toEqual([]); + expect(output.success).toBe(true); + }); + + it('should fail when a declared entry point was never built', async () => { + const { context } = setup({ + context: contextMock, + enableProdMode: false, + projectTags: ['npm:public'], + npmPackOutput, + packageJson: { + name: '@proj/proj', + version: '1.0.0', + main: 'lib-commonjs/index.cjs', + type: 'module', + exports: { + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './nope': { + import: { types: './dist/nope.d.ts', default: './lib/nope.js' }, + }, + './package.json': './package.json', + }, + }, + }); + + const output = await executor(options, context); + + expect(output.success).toBe(false); + expect(loggerErrorSpy.mock.calls.flat().join('\n')).toContain( + 'export map declares entry points that are not shipped', + ); + expect(loggerErrorSpy.mock.calls.flat().join('\n')).toContain('dist/nope.d.ts'); + expect(loggerErrorSpy.mock.calls.flat().join('\n')).toContain('lib/nope.js'); + }); + it('should ignore dev only conditions that resolve to source', async () => { + const { context } = setup({ + context: contextMock, + enableProdMode: false, + projectTags: ['npm:public'], + npmPackOutput, + packageJson: { + name: '@proj/proj', + version: '1.0.0', + main: 'lib-commonjs/index.cjs', + type: 'module', + exports: { + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './__dev': { types: './src/index.ts', node: './src/index.ts', require: './src/index.ts' }, + './package.json': './package.json', + }, + }, + }); + + const output = await executor(options, context); + + expect(loggerErrorSpy.mock.calls.flat()).toEqual([]); + expect(output.success).toBe(true); + }); + }); }); function setup(config: { @@ -225,6 +336,7 @@ function setup(config: { projectTags: string[]; npmPackOutput: string; enableProdMode: boolean; + packageJson?: PackageJson; }) { if (config.enableProdMode) { process.env.FLUENT_PROD_BUILD = 'true'; @@ -234,7 +346,12 @@ function setup(config: { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); - config.context.projectsConfigurations!.projects[config.context.projectName!].tags?.push(...config.projectTags); + readJsonFileMock.mockReturnValue( + config.packageJson ?? ({ name: '@proj/proj', version: '1.0.0', main: 'lib-commonjs/index.js' } as PackageJson), + ); + + // assign rather than push, otherwise tags leak into subsequent tests via the shared context mock + config.context.projectsConfigurations!.projects[config.context.projectName!].tags = [...config.projectTags]; return { context: config.context, diff --git a/tools/workspace-plugin/src/executors/verify-packaging/executor.ts b/tools/workspace-plugin/src/executors/verify-packaging/executor.ts index 28990daba5b41..ff99ed1b52bad 100644 --- a/tools/workspace-plugin/src/executors/verify-packaging/executor.ts +++ b/tools/workspace-plugin/src/executors/verify-packaging/executor.ts @@ -1,4 +1,4 @@ -import { type ExecutorContext, type PromiseExecutor, logger, serializeJson } from '@nx/devkit'; +import { type ExecutorContext, type PromiseExecutor, logger, readJsonFile, serializeJson } from '@nx/devkit'; import { spawnSync } from 'node:child_process'; import micromatch from 'micromatch'; @@ -6,6 +6,7 @@ import micromatch from 'micromatch'; import { type VerifyPackagingExecutorSchema } from './schema'; import { join } from 'node:path'; import { measureEnd, measureStart } from '../../utils'; +import type { PackageJson } from '../../types'; const runExecutor: PromiseExecutor = async (schema, context) => { measureStart('VerifyTargetExecutor'); @@ -52,6 +53,7 @@ function normalizeOptions(schema: VerifyPackagingExecutorSchema, context: Execut const defaults = {}; const project = context.projectsConfigurations!.projects[context.projectName!]; const isProduction = Boolean(process.env.FLUENT_PROD_BUILD); + const packageJson: PackageJson = readJsonFile(join(context.root, project.root, 'package.json')); /** * @see https://docs.npmjs.com/cli/v10/commands/npm-publish#files-included-in-package @@ -70,7 +72,7 @@ function normalizeOptions(schema: VerifyPackagingExecutorSchema, context: Execut const filePatterns = { alwaysPublishedFiles, rootConfigFiles, nonProdAssets }; - return { ...defaults, ...schema, project, isProduction, filePatterns }; + return { ...defaults, ...schema, project, isProduction, packageJson, filePatterns }; } function npmPackOutput(options: NormalizedOptions, context: ExecutorContext) { @@ -103,8 +105,10 @@ function assertions( assertNotEmpty(npmPackResult, nonProdAssets, `wont ship non production code related folders/files`), assertEmpty(npmPackResult, 'CHANGELOG.md', 'ships changelog markdown file'), assertEmpty(npmPackResult, 'dist/*', 'ships rolluped dts'), - assertEmpty(npmPackResult, 'lib-commonjs/**/*.(js|map)', 'ships cjs'), + // `type: module` packages emit `.cjs`; without it this only ever matched the sourcemaps + assertEmpty(npmPackResult, 'lib-commonjs/**/*.(js|cjs|map)', 'ships cjs'), assertNotEmpty(npmPackResult, 'src/*', `wont ship source code from "/src"`), + assertExportMapShipped(npmPackResult, options.packageJson), ]; if (!isV8package) { @@ -171,3 +175,45 @@ function assertions( }; } } + +/** + * The export map is the contract consumers resolve against, and also what `generate-api` derives its + * entry points from - an entry pointing at a file that was never built resolves to nothing at runtime. + */ +function assertExportMapShipped(npmPackResult: string[], packageJson: PackageJson) { + const shipped = new Set(npmPackResult); + const missing = collectExportMapPaths(packageJson) + // dev only conditions (eg. `./__dev`) resolve to source, which is asserted as never shipped above + .filter(filePath => !filePath.startsWith('src/')) + .filter(filePath => !shipped.has(filePath)); + + if (missing.length === 0) { + return null; + } + + return { + matches: missing, + message: 'export map declares entry points that are not shipped', + }; +} + +function collectExportMapPaths(packageJson: PackageJson): string[] { + const paths = new Set(); + + collect(packageJson.exports); + + return [...paths].sort(); + + function collect(value: unknown) { + if (typeof value === 'string') { + if (value.startsWith('./')) { + paths.add(value.slice('./'.length)); + } + return; + } + + if (value && typeof value === 'object') { + Object.values(value).forEach(collect); + } + } +} diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts index 75e8562fa07de..31a128fd70cf8 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.spec.ts @@ -60,19 +60,26 @@ describe(`workspace-plugin`, () => { expect(getTargetsNames(results)).toContain('test'); }); - it('should add an optional attw target only when package.json declares exports and is not private', async () => { + it('should add the attw target only for published esm first projects that declare exports', async () => { await tempFs.createFiles({ 'with-exports/project.json': serializeJson({ projectType: 'library', tags: ['vNext'] }), - 'with-exports/package.json': serializeJson({ exports: { '.': './lib/index.js' } }), + 'with-exports/package.json': serializeJson({ type: 'module', exports: { '.': './lib/index.js' } }), 'no-exports/project.json': serializeJson({ projectType: 'library', tags: ['vNext'] }), - 'no-exports/package.json': serializeJson({}), + 'no-exports/package.json': serializeJson({ type: 'module' }), 'private-with-exports/project.json': serializeJson({ projectType: 'library', tags: ['vNext'] }), - 'private-with-exports/package.json': serializeJson({ private: true, exports: { '.': './lib/index.js' } }), + 'private-with-exports/package.json': serializeJson({ + private: true, + type: 'module', + exports: { '.': './lib/index.js' }, + }), + 'commonjs-first/project.json': serializeJson({ projectType: 'library', tags: ['vNext'] }), + 'commonjs-first/package.json': serializeJson({ exports: { '.': './lib-commonjs/index.js' } }), }); const withExports = await createNodesFunction(['with-exports/project.json'], options, context); const noExports = await createNodesFunction(['no-exports/project.json'], options, context); const privateWithExports = await createNodesFunction(['private-with-exports/project.json'], options, context); + const commonjsFirst = await createNodesFunction(['commonjs-first/project.json'], options, context); expect(getTargetsNames(withExports, 'with-exports')).toContain('attw'); expect(getTargets(withExports, 'with-exports')?.attw).toMatchObject({ @@ -82,6 +89,8 @@ describe(`workspace-plugin`, () => { }); expect(getTargetsNames(noExports, 'no-exports')).not.toContain('attw'); expect(getTargetsNames(privateWithExports, 'private-with-exports')).not.toContain('attw'); + // CommonJS-first packages always report inherent `CJS default export` interop findings + expect(getTargetsNames(commonjsFirst, 'commonjs-first')).not.toContain('attw'); }); it('should add lint,test task only if configuration exists', async () => { diff --git a/tools/workspace-plugin/src/plugins/workspace-plugin.ts b/tools/workspace-plugin/src/plugins/workspace-plugin.ts index 780ea2be44bef..cc1263a4498c8 100644 --- a/tools/workspace-plugin/src/plugins/workspace-plugin.ts +++ b/tools/workspace-plugin/src/plugins/workspace-plugin.ts @@ -452,11 +452,16 @@ function buildTestTarget( } function buildAttwTarget(projectRoot: string, config: TaskBuilderConfig): TargetConfiguration | null { - // optional, published-library-only types/exports validation. Not part of `build` or CI gates. if (config.packageJSON.private || !config.packageJSON.exports) { return null; } + // CommonJS-first packages always report `CJS default export` interop findings under the node16 + // profile - inherent to their shape rather than export map defects, so gating them stays red forever + if (config.packageJSON.type !== 'module') { + return null; + } + return { executor: 'nx:run-commands', cache: true, @@ -467,7 +472,7 @@ function buildAttwTarget(projectRoot: string, config: TaskBuilderConfig): Target }, inputs: ['default', { externalDependencies: ['@arethetypeswrong/cli'] }], metadata: { - description: 'Validate package types & export map with @arethetypeswrong/cli (optional)', + description: 'Validate package types & export map with @arethetypeswrong/cli', technologies: ['typescript'], }, }; From 067e915a64bb7781b24098a95c9b71aa30a5048b Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Mon, 24 Aug 2026 22:17:13 +0200 Subject: [PATCH 6/6] feat(workspace-plugin): support wildcard export subpaths Adds `metadata.exportMap.subpathPatterns`, emitting wildcard export entries rather than expanding them: "src/items/*/index.ts" -> "./items/*" Each pattern must contain exactly one `*` and end in `/index.ts`, because generate-api expands a wildcard entry by scanning for sub-directories and reading index.d.ts from each. Any other shape is rejected with an explicit error rather than emitting something generate-api would silently skip. A pattern is just an entry point whose name and outputPath contain `*`, so buildExportMap needed no changes. Declarations stay nested for wildcards (./dist/items/*/index.d.ts) while exact entries keep flattening (./dist/unstable.d.ts) - that asymmetry is what generate-api resolves. Also makes the generator fail when package.json declares an export entry the declaration cannot produce. The generator owns the whole exports object, so such an entry would otherwise be dropped on the next sync. Wildcard support lands first so that error always has an escape hatch. Two downstream consumers needed fixing: - verify-packaging collected literal paths and did set membership, so a wildcard path would never match and would report a false failure. It now translates the pattern to a regex. Deliberately NOT micromatch or path.matchesGlob: an export map `*` substitutes across path separators while a glob `*` stops at `/`, so both would reject a nested subpath. Covered by a test that fails under micromatch. - the bundle-size entryPoints fixture cannot import a wildcard specifier, so it expands patterns to the subpaths they currently resolve to. Verified end to end by declaring a real wildcard on react-headless-components-preview: generate-api expanded it into per-directory api-extractor configs, the build emitted matching lib/ and dist/ output, and the fixture picked up both subpaths. --- .../verify-packaging/executor.spec.ts | 101 ++++++++++++++++++ .../executors/verify-packaging/executor.ts | 18 +++- .../bundle-size-fixtures-sync/index.spec.ts | 24 +++++ .../bundle-size-fixtures-sync/index.ts | 53 +++++++-- .../src/generators/export-maps-sync/README.md | 32 +++++- .../generators/export-maps-sync/index.spec.ts | 40 +++++++ .../src/generators/export-maps-sync/index.ts | 26 +++++ .../export-maps-sync/lib/export-map.spec.ts | 94 ++++++++++++++-- .../export-maps-sync/lib/export-map.ts | 72 +++++++++---- .../src/generators/export-maps-sync/types.ts | 10 ++ 10 files changed, 433 insertions(+), 37 deletions(-) diff --git a/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts b/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts index 0ecd8a502c51b..fd6e17efc10c7 100644 --- a/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/verify-packaging/executor.spec.ts @@ -328,6 +328,107 @@ describe('VerifyPackaging Executor', () => { expect(loggerErrorSpy.mock.calls.flat()).toEqual([]); expect(output.success).toBe(true); }); + + describe('wildcard entries', () => { + const wildcardPackOutput = ` + npm notice 686B LICENSE + npm notice 686B package.json + npm notice 686B README.md + npm notice 686B CHANGELOG.md + npm notice 738B lib/index.js + npm notice 738B lib-commonjs/index.cjs + npm notice 738B dist/index.d.ts + npm notice 738B dist/index.d.cts + npm notice 738B lib/items/one/index.js + npm notice 738B lib-commonjs/items/one/index.cjs + npm notice 738B dist/items/one/index.d.ts + npm notice 738B dist/items/one/index.d.cts + `; + + function wildcardPackageJson(): PackageJson { + return { + name: '@proj/proj', + version: '1.0.0', + main: 'lib-commonjs/index.cjs', + type: 'module', + exports: { + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './items/*': { + import: { types: './dist/items/*/index.d.ts', default: './lib/items/*/index.js' }, + require: { types: './dist/items/*/index.d.cts', default: './lib-commonjs/items/*/index.cjs' }, + }, + './package.json': './package.json', + }, + }; + } + + it('should pass when a wildcard entry resolves to shipped files', async () => { + const { context } = setup({ + context: contextMock, + enableProdMode: false, + projectTags: ['npm:public'], + npmPackOutput: wildcardPackOutput, + packageJson: wildcardPackageJson(), + }); + + const output = await executor(options, context); + + expect(loggerErrorSpy.mock.calls.flat()).toEqual([]); + expect(output.success).toBe(true); + }); + + it('should fail when a wildcard entry resolves to nothing shipped', async () => { + const packageJson = wildcardPackageJson(); + (packageJson.exports as Record)['./missing/*'] = { + import: { types: './dist/missing/*/index.d.ts', default: './lib/missing/*/index.js' }, + }; + + const { context } = setup({ + context: contextMock, + enableProdMode: false, + projectTags: ['npm:public'], + npmPackOutput: wildcardPackOutput, + packageJson, + }); + + const output = await executor(options, context); + + expect(output.success).toBe(false); + expect(loggerErrorSpy.mock.calls.flat().join('\n')).toContain('dist/missing/*/index.d.ts'); + }); + + // an export map `*` substitutes across path separators, so a glob matcher would reject this + it('should pass when a wildcard entry resolves to a nested subpath', async () => { + const { context } = setup({ + context: contextMock, + enableProdMode: false, + projectTags: ['npm:public'], + npmPackOutput: ` + npm notice 686B LICENSE + npm notice 686B package.json + npm notice 686B README.md + npm notice 686B CHANGELOG.md + npm notice 738B lib/index.js + npm notice 738B lib-commonjs/index.cjs + npm notice 738B dist/index.d.ts + npm notice 738B dist/index.d.cts + npm notice 738B lib/items/one/nested/index.js + npm notice 738B lib-commonjs/items/one/nested/index.cjs + npm notice 738B dist/items/one/nested/index.d.ts + npm notice 738B dist/items/one/nested/index.d.cts + `, + packageJson: wildcardPackageJson(), + }); + + const output = await executor(options, context); + + expect(loggerErrorSpy.mock.calls.flat()).toEqual([]); + expect(output.success).toBe(true); + }); + }); }); }); diff --git a/tools/workspace-plugin/src/executors/verify-packaging/executor.ts b/tools/workspace-plugin/src/executors/verify-packaging/executor.ts index ff99ed1b52bad..74d5f3e7029a8 100644 --- a/tools/workspace-plugin/src/executors/verify-packaging/executor.ts +++ b/tools/workspace-plugin/src/executors/verify-packaging/executor.ts @@ -185,7 +185,7 @@ function assertExportMapShipped(npmPackResult: string[], packageJson: PackageJso const missing = collectExportMapPaths(packageJson) // dev only conditions (eg. `./__dev`) resolve to source, which is asserted as never shipped above .filter(filePath => !filePath.startsWith('src/')) - .filter(filePath => !shipped.has(filePath)); + .filter(filePath => !isShipped(filePath)); if (missing.length === 0) { return null; @@ -195,6 +195,22 @@ function assertExportMapShipped(npmPackResult: string[], packageJson: PackageJso matches: missing, message: 'export map declares entry points that are not shipped', }; + + function isShipped(filePath: string): boolean { + if (!filePath.includes('*')) { + return shipped.has(filePath); + } + + // an export map `*` is a substitution token matching across path separators, unlike a glob `*` + const [prefix, suffix] = filePath.split('*'); + const pattern = new RegExp(`^${escapeRegExp(prefix)}.*${escapeRegExp(suffix)}$`); + + return npmPackResult.some(shippedPath => pattern.test(shippedPath)); + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function collectExportMapPaths(packageJson: PackageJson): string[] { diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts index 09bb7419cf94c..a25854ebff546 100644 --- a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.spec.ts @@ -108,6 +108,30 @@ describe('bundle-size-fixtures-sync generator', () => { "import * as Tooltip from '@proj/react-headless/tooltip';", ); }); + + it('expands a wildcard entry into the subpaths it currently resolves to', async () => { + const project = setupProject({ + name: 'react-headless', + packageName: '@proj/react-headless', + projectConfig: { + metadata: { + exportMap: { root: true, subpathEntryPoints: [], subpathPatterns: ['src/items/*/index.ts'] }, + bundleSizeFixtures: { + 'AllComponents.fixture.js': { kind: 'entryPoints', name: 'react-headless: entire library' }, + }, + }, + }, + sourceFiles: { 'src/items/one/index.ts': 'export {};', 'src/items/two/index.ts': 'export {};' }, + }); + + await generator(tree); + + const fixture = project.readFixture('AllComponents.fixture.js'); + expect(fixture).toContain("import * as ItemsOne from '@proj/react-headless/items/one';"); + expect(fixture).toContain("import * as ItemsTwo from '@proj/react-headless/items/two';"); + // the wildcard key itself is not importable + expect(fixture).not.toContain('@proj/react-headless/items/*'); + }); }); describe('baseHooks fixture', () => { diff --git a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts index 6893414dff386..3f212d40f19dc 100644 --- a/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts +++ b/tools/workspace-plugin/src/generators/bundle-size-fixtures-sync/index.ts @@ -1,4 +1,13 @@ -import { type ProjectConfiguration, type Tree, formatFiles, getProjects, logger, readJson } from '@nx/devkit'; +import { + type ProjectConfiguration, + type Tree, + formatFiles, + getProjects, + globAsync, + joinPathFragments, + logger, + readJson, +} from '@nx/devkit'; import { readExportMapConfig, resolveEntryPoints } from '../export-maps-sync/lib/export-map'; import type { PackageJson } from '../../types'; @@ -78,17 +87,47 @@ async function renderEntryPoints(tree: Tree, projectConfig: ProjectConfiguration const packageJson = readJson(tree, `${projectConfig.root}/package.json`); const entryPoints = await resolveEntryPoints(tree, projectConfig.root, readExportMapConfig(projectConfig)); - const imports: EntryPointImport[] = entryPoints + const imports: EntryPointImport[] = []; + + for (const entryPoint of entryPoints) { // the root entry would pull in the whole library and defeat the point of per subpath isolation - .filter(entryPoint => entryPoint.key !== '.') - .map(entryPoint => ({ - namespace: toNamespaceBinding(entryPoint.name), - moduleSpecifier: `${packageJson.name}/${entryPoint.name}`, - })); + if (entryPoint.key === '.') { + continue; + } + + // a wildcard key is not importable as written, so cover every subpath it currently resolves to + const subpaths = entryPoint.key.includes('*') + ? await expandPattern(tree, projectConfig.root, entryPoint.outputPath) + : [entryPoint.name]; + + for (const subpath of subpaths) { + imports.push({ + namespace: toNamespaceBinding(subpath), + moduleSpecifier: `${packageJson.name}/${subpath}`, + }); + } + } return renderEntryPointsFixture(imports, name); } +/** + * `items/*\u200b/index` -> every `items/` that currently exists. + */ +async function expandPattern(tree: Tree, projectRoot: string, outputPath: string): Promise { + const matches = await globAsync(tree, [joinPathFragments(projectRoot, 'src', `${outputPath}.ts`)]); + const srcRoot = joinPathFragments(projectRoot, 'src'); + + return matches + .map(match => + match + .slice(srcRoot.length + 1) + .replace(/\.[jt]sx?$/, '') + .replace(/\/index$/, ''), + ) + .sort(); +} + function renderBaseHooks( tree: Tree, projects: Map, diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/README.md b/tools/workspace-plugin/src/generators/export-maps-sync/README.md index 19b149606ccee..265a59834763c 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/README.md +++ b/tools/workspace-plugin/src/generators/export-maps-sync/README.md @@ -36,13 +36,43 @@ So each multi-entry project declares its own, in `project.json`: - `root` — whether a `"."` entry resolved from `src/index.ts` is exposed. Defaults to `true`. - `subpathEntryPoints` — globs, relative to the project root, resolving to the source files backing non-root subpaths. Defaults to `[]`. +- `subpathPatterns` — subpath _patterns_, emitted as wildcard export entries rather than expanded. + Defaults to `[]`. Single entry point packages omit `metadata.exportMap` entirely and get `{ root: true, -subpathEntryPoints: [] }`. +subpathEntryPoints: [], subpathPatterns: [] }`. Source file names map to subpaths by stripping `src/` and the extension, so `src/color-picker.ts` becomes `./color-picker` and `src/unstable/index.ts` becomes `./unstable`. +## Wildcard subpaths + +`subpathPatterns` emits a wildcard entry instead of one entry per directory: + +```jsonc +{ "subpathPatterns": ["src/items/*/index.ts"] } +``` + +```jsonc +"./items/*": { + "import": { "types": "./dist/items/*/index.d.ts", "default": "./lib/items/*/index.js" }, + "require": { "types": "./dist/items/*/index.d.cts", "default": "./lib-commonjs/items/*/index.cjs" } +} +``` + +Each pattern must contain exactly one `*` and end in `/index.ts` — `generate-api` expands a wildcard +entry by scanning for sub-directories and reading `index.d.ts` from each, so any other shape would be +silently skipped. Anything else fails with an explicit error. + +Note the deliberate asymmetry with exact entries, which flatten their declarations +(`src/unstable/index.ts` → `./dist/unstable.d.ts`): wildcard declarations stay nested, because that is +what `generate-api` resolves. + +## Entries the declaration cannot produce + +The generator owns the whole `exports` object, so an entry it cannot derive would be dropped on the +next sync. Rather than deleting it silently, it fails and points at the declaration to add. + ## Why a sync generator The `exports` map is the source of truth for `generate-api` (it derives one api-extractor entry per diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts b/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts index 566d0d45fad67..f3d62f98b9dd2 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts @@ -169,6 +169,46 @@ describe('export-maps-sync generator', () => { }); }); + describe('undeclarable entries', () => { + it('fails rather than silently dropping an entry the declaration cannot produce', async () => { + setupProject({ + name: 'react-headless', + packageJson: { + exports: { + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './items/*': { + import: { types: './dist/items/*/index.d.ts', default: './lib/items/*/index.js' }, + }, + './package.json': './package.json', + }, + }, + }); + + await expect(generator(tree)).rejects.toThrow('./items/*'); + }); + + it('accepts the entry once it is declared as a pattern', async () => { + const project = setupProject({ + name: 'react-headless', + projectConfig: { + metadata: { exportMap: { root: true, subpathEntryPoints: [], subpathPatterns: ['src/items/*/index.ts'] } }, + }, + sourceFiles: ['src/items/one/index.ts'], + packageJson: { exports: undefined }, + }); + + await generator(tree); + + expect(project.readPackageJson().exports!['./items/*']).toEqual({ + import: { types: './dist/items/*/index.d.ts', default: './lib/items/*/index.js' }, + require: { types: './dist/items/*/index.d.cts', default: './lib-commonjs/items/*/index.cjs' }, + }); + }); + }); + describe('scope', () => { it.each([ ['a non web platform project', { tags: ['vNext', 'platform:node'] }], diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/index.ts b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts index 0decba50b4837..72e1db37c33a9 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/index.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts @@ -61,6 +61,8 @@ async function syncProject(tree: Tree, projectConfig: ProjectConfiguration): Pro const expectedFields = buildEntryPointFields(packageJson); const expectedExports = buildExportMap(packageJson, entryPoints); + assertNoUndeclarableKeys(projectConfig, packageJson, expectedExports); + const fieldsInSync = (Object.keys(expectedFields) as Array).every(field => isEqual(packageJson[field], expectedFields[field]), ); @@ -81,6 +83,30 @@ async function syncProject(tree: Tree, projectConfig: ProjectConfiguration): Pro return true; } +/** + * The generator owns the whole `exports` object, so anything it cannot produce would be dropped on + * the next sync. Surface that as an error rather than deleting a hand written entry silently. + */ +function assertNoUndeclarableKeys( + projectConfig: ProjectConfiguration, + packageJson: PackageJson, + expectedExports: PackageJson['exports'], +): void { + const expectedKeys = new Set(Object.keys(expectedExports ?? {})); + const undeclarable = Object.keys(packageJson.exports ?? {}).filter(key => !expectedKeys.has(key)); + + if (undeclarable.length === 0) { + return; + } + + throw new Error( + `${projectConfig.name} declares export map entries that "metadata.exportMap" cannot produce:\n` + + undeclarable.map(key => ` - ${key}`).join('\n') + + `\n\nDeclare them via "subpathEntryPoints" (exact) or "subpathPatterns" (wildcard) in ` + + `${projectConfig.root}/project.json, otherwise the next sync would drop them.`, + ); +} + function outOfSyncMessage(outOfSync: string[]): string | undefined { if (outOfSync.length === 0) { return undefined; diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts index 2177ad5d09de1..58cc847783a78 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts @@ -9,25 +9,26 @@ describe('readExportMapConfig', () => { expect(readExportMapConfig({ root: 'packages/react-button' })).toEqual({ root: true, subpathEntryPoints: [], + subpathPatterns: [], }); }); it('reads the declaration from project metadata', () => { const config = readExportMapConfig({ root: 'packages/react-headless', - metadata: { exportMap: { root: false, subpathEntryPoints: ['src/*.ts'] } }, + metadata: { exportMap: { root: false, subpathEntryPoints: ['src/*.ts'], subpathPatterns: [] } }, }); - expect(config).toEqual({ root: false, subpathEntryPoints: ['src/*.ts'] }); + expect(config).toEqual({ root: false, subpathEntryPoints: ['src/*.ts'], subpathPatterns: [] }); }); it('fills in defaults for a partial declaration', () => { const config = readExportMapConfig({ root: 'packages/react-headless', - metadata: { exportMap: { subpathEntryPoints: ['src/*.ts'] } }, + metadata: { exportMap: { subpathEntryPoints: ['src/*.ts'], subpathPatterns: [] } }, }); - expect(config).toEqual({ root: true, subpathEntryPoints: ['src/*.ts'] }); + expect(config).toEqual({ root: true, subpathEntryPoints: ['src/*.ts'], subpathPatterns: [] }); }); }); @@ -48,7 +49,11 @@ describe('resolveEntryPoints', () => { it('resolves only the root entry when no subpaths are declared', async () => { writeSourceFiles('src/index.ts', 'src/CompoundButton.ts'); - const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: true, subpathEntryPoints: [] }); + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: true, + subpathEntryPoints: [], + subpathPatterns: [], + }); expect(entryPoints).toEqual([{ key: '.', name: 'index', outputPath: 'index' }]); }); @@ -56,7 +61,11 @@ describe('resolveEntryPoints', () => { it('resolves nothing when the package has neither a root nor declared subpaths', async () => { writeSourceFiles('src/index.ts'); - const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: [] }); + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: false, + subpathEntryPoints: [], + subpathPatterns: [], + }); expect(entryPoints).toEqual([]); }); @@ -64,7 +73,11 @@ describe('resolveEntryPoints', () => { it('sorts subpaths alphabetically and keeps the root first', async () => { writeSourceFiles('src/index.ts', 'src/tooltip.ts', 'src/badge.ts', 'src/color-picker.ts'); - const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: true, subpathEntryPoints: ['src/*.ts'] }); + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: true, + subpathEntryPoints: ['src/*.ts'], + subpathPatterns: [], + }); expect(entryPoints.map(entry => entry.key)).toEqual(['.', './badge', './color-picker', './tooltip']); }); @@ -72,7 +85,11 @@ describe('resolveEntryPoints', () => { it('never emits the src root index as a subpath', async () => { writeSourceFiles('src/index.ts', 'src/badge.ts'); - const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: ['src/*.ts'] }); + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: false, + subpathEntryPoints: ['src/*.ts'], + subpathPatterns: [], + }); expect(entryPoints.map(entry => entry.key)).toEqual(['./badge']); }); @@ -83,6 +100,7 @@ describe('resolveEntryPoints', () => { const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: ['src/unstable/index.ts'], + subpathPatterns: [], }); expect(entryPoints).toEqual([{ key: './unstable', name: 'unstable', outputPath: 'unstable/index' }]); @@ -96,6 +114,7 @@ describe('resolveEntryPoints', () => { const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: ['src/*.ts', 'src/*.tsx'], + subpathPatterns: [], }); expect(entryPoints).toEqual([]); @@ -105,7 +124,11 @@ describe('resolveEntryPoints', () => { it('supports tsx entry points', async () => { writeSourceFiles('src/badge.tsx'); - const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: ['src/*.tsx'] }); + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: false, + subpathEntryPoints: ['src/*.tsx'], + subpathPatterns: [], + }); expect(entryPoints).toEqual([{ key: './badge', name: 'badge', outputPath: 'badge' }]); }); @@ -116,10 +139,52 @@ describe('resolveEntryPoints', () => { const entryPoints = await resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: ['src/*.ts', 'src/badge.ts'], + subpathPatterns: [], }); expect(entryPoints).toEqual([{ key: './badge', name: 'badge', outputPath: 'badge' }]); }); + + describe('subpath patterns', () => { + function resolve(subpathPatterns: string[]) { + return resolveEntryPoints(tree, projectRoot, { root: false, subpathEntryPoints: [], subpathPatterns }); + } + + it('emits a wildcard entry verbatim rather than expanding it', async () => { + writeSourceFiles('src/items/one/index.ts', 'src/items/two/index.ts'); + + await expect(resolve(['src/items/*/index.ts'])).resolves.toEqual([ + { key: './items/*', name: 'items/*/index', outputPath: 'items/*/index' }, + ]); + }); + + it('supports a pattern at the src root', async () => { + await expect(resolve(['src/*/index.ts'])).resolves.toEqual([ + { key: './*', name: '*/index', outputPath: '*/index' }, + ]); + }); + + it('orders patterns after exact subpaths', async () => { + writeSourceFiles('src/badge.ts'); + + const entryPoints = await resolveEntryPoints(tree, projectRoot, { + root: true, + subpathEntryPoints: ['src/*.ts'], + subpathPatterns: ['src/items/*/index.ts'], + }); + + expect(entryPoints.map(entry => entry.key)).toEqual(['.', './badge', './items/*']); + }); + + it.each([ + ['a pattern outside src', 'lib/items/*/index.ts', 'must live under "src/"'], + ['a pattern with no star', 'src/items/index.ts', 'exactly one "*"'], + ['a pattern with two stars', 'src/*/items/*/index.ts', 'exactly one "*"'], + ['a pattern not ending in an index', 'src/items/*.ts', 'must end in "/index.ts"'], + ])('rejects %s', async (_name, pattern, expectedReason) => { + await expect(resolve([pattern])).rejects.toThrow(expectedReason); + }); + }); }); describe('buildExportMap', () => { @@ -164,6 +229,17 @@ describe('buildExportMap', () => { }); }); + it('carries the wildcard through every condition of a pattern entry', () => { + const exports = buildExportMap(esmPackage, [ + { key: './items/*', name: 'items/*/index', outputPath: 'items/*/index' }, + ]); + + expect(exports!['./items/*']).toEqual({ + import: { types: './dist/items/*/index.d.ts', default: './lib/items/*/index.js' }, + require: { types: './dist/items/*/index.d.cts', default: './lib-commonjs/items/*/index.cjs' }, + }); + }); + it('exposes the style condition on the root entry only', () => { const exports = buildExportMap({ ...esmPackage, style: 'dist/index.css' }, [ rootEntry, diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts index 2cb95ba22b8e1..6b58f4f4a03d7 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.ts @@ -6,15 +6,15 @@ import type { PackageJson } from '../../../types'; import type { ExportMapConfig } from '../types'; export interface EntryPoint { - /** Export map key, eg. `.` or `./color-picker` */ + /** Export map key, eg. `.`, `./color-picker` or `./items/*` */ key: string; - /** Flattened basename used for the dts rollup, eg. `index`, `color-picker`, `unstable` */ + /** Path used for the dts rollup, eg. `index`, `color-picker`, `unstable`, `items/*\/index` */ name: string; /** Compiled path relative to `lib`/`lib-commonjs`, mirroring the source layout, eg. `unstable/index` */ outputPath: string; } -const DEFAULT_CONFIG: ExportMapConfig = { root: true, subpathEntryPoints: [] }; +const DEFAULT_CONFIG: ExportMapConfig = { root: true, subpathEntryPoints: [], subpathPatterns: [] }; export function readExportMapConfig(projectConfig: ProjectConfiguration): ExportMapConfig { const metadata = projectConfig.metadata as { exportMap?: Partial } | undefined; @@ -32,34 +32,68 @@ export async function resolveEntryPoints( ): Promise { const entryPoints: EntryPoint[] = config.root ? [{ key: '.', name: 'index', outputPath: 'index' }] : []; - if (config.subpathEntryPoints.length === 0) { - return entryPoints; - } + if (config.subpathEntryPoints.length > 0) { + const matches = await globAsync( + tree, + config.subpathEntryPoints.map(glob => joinPathFragments(projectRoot, glob)), + ); - const matches = await globAsync( - tree, - config.subpathEntryPoints.map(glob => joinPathFragments(projectRoot, glob)), - ); + const byName = new Map(); + for (const match of matches) { + const outputPath = toOutputPath(path.posix.relative(joinPathFragments(projectRoot, 'src'), match)); - const byName = new Map(); - for (const match of matches) { - const outputPath = toOutputPath(path.posix.relative(joinPathFragments(projectRoot, 'src'), match)); + if (outputPath === null || outputPath === 'index') { + continue; + } - if (outputPath === null || outputPath === 'index') { - continue; + // `unstable/index` -> `unstable` + byName.set(outputPath.replace(/\/index$/, ''), outputPath); } - // `unstable/index` -> `unstable` - byName.set(outputPath.replace(/\/index$/, ''), outputPath); + for (const name of [...byName.keys()].sort()) { + entryPoints.push({ key: `./${name}`, name, outputPath: byName.get(name)! }); + } } - for (const name of [...byName.keys()].sort()) { - entryPoints.push({ key: `./${name}`, name, outputPath: byName.get(name)! }); + // patterns are emitted verbatim rather than expanded, so consumers can resolve any matching subpath + for (const pattern of [...config.subpathPatterns].sort()) { + entryPoints.push(toPatternEntryPoint(pattern, projectRoot)); } return entryPoints; } +/** + * `src/items/*\/index.ts` -> key `./items/*`, emitted as a wildcard entry. + */ +function toPatternEntryPoint(pattern: string, projectRoot: string): EntryPoint { + const fail = (reason: string) => { + throw new Error( + `Invalid "metadata.exportMap.subpathPatterns" entry "${pattern}" in ${projectRoot}: ${reason}.\n` + + `Expected a path like "src/items/*/index.ts".`, + ); + }; + + if (!pattern.startsWith('src/')) { + fail('patterns must live under "src/"'); + } + if (pattern.split('*').length !== 2) { + fail('patterns must contain exactly one "*"'); + } + if (!/\/index\.[jt]sx?$/.test(pattern)) { + // generate-api expands wildcards by scanning sub-directories for `index.d.ts` + fail('patterns must end in "/index.ts"'); + } + + const fromSrc = pattern.slice('src/'.length).replace(/\.[jt]sx?$/, ''); + + return { + key: `./${fromSrc.slice(0, fromSrc.indexOf('*') + 1)}`, + name: fromSrc, + outputPath: fromSrc, + }; +} + /** * @returns `null` for files that can never be an entry point */ diff --git a/tools/workspace-plugin/src/generators/export-maps-sync/types.ts b/tools/workspace-plugin/src/generators/export-maps-sync/types.ts index db6d13aea9f69..bc6cd52cbcff5 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/types.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/types.ts @@ -17,4 +17,14 @@ export interface ExportMapConfig { * @default [] */ subpathEntryPoints: string[]; + /** + * Subpath *patterns*, relative to the project root, emitted as wildcard export entries rather than + * being expanded. Each must contain exactly one `*` and end in `/index.ts`, so + * `src/items/*\/index.ts` becomes `./items/*`. + * + * The `/index.ts` shape is required by `generate-api`, which expands a wildcard entry by scanning + * for sub-directories and reading `index.d.ts` from each. + * @default [] + */ + subpathPatterns: string[]; }