diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ce2dba4ee93a58..300a003ca35d2e 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-components-61c0814e-a118-4a22-b34f-c221185da553.json b/change/@fluentui-react-components-61c0814e-a118-4a22-b34f-c221185da553.json new file mode 100644 index 00000000000000..28c9e02e27ba1f --- /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" +} 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 00000000000000..8a34e997d9d547 --- /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 1686139fa4fd3a..8c8836ada9d660 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/package.json b/packages/react-components/react-components/package.json index e7ae177b585200..f3dc8bf6bd1860 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/packages/react-components/react-components/project.json b/packages/react-components/react-components/project.json index 6849412701718a..21ac007f451ec8 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 147c0148bcb243..840f14dc56700e 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 ecab81877a1e1f..e906edbbc52b60 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 3a882f4f0b4220..ebac3a2495f5d6 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 00000000000000..19b149606cceeb --- /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 00000000000000..566d0d45fad670 --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts @@ -0,0 +1,216 @@ +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('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'] }], + ['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 00000000000000..0decba50b48370 --- /dev/null +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts @@ -0,0 +1,91 @@ +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]), + ); + + // 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; + } + + 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 00000000000000..2177ad5d09de1d --- /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 00000000000000..2cb95ba22b8e10 --- /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 00000000000000..f06c6a022690f9 --- /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 00000000000000..9daa58e420a7d5 --- /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 00000000000000..db6d13aea9f694 --- /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 07efbf6a15ca12..e15052cb07310a 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 3fb7f43d0099c9..f0a334a5cbaf9e 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