Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
6 changes: 5 additions & 1 deletion nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down Expand Up @@ -136,6 +137,9 @@
"release": {
"projectsRelationship": "independent"
},
"sync": {
"globalGenerators": ["@fluentui/workspace-plugin:export-maps-sync"]
},
"parallel": 3,
"useInferencePlugins": false,
"defaultBase": "master",
Expand Down
4 changes: 2 additions & 2 deletions packages/react-components/react-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@
"default": "./lib-commonjs/index.cjs"
}
},
"./package.json": "./package.json",
"./unstable": {
"import": {
"types": "./dist/unstable.d.ts",
Expand All @@ -107,7 +106,8 @@
"types": "./dist/unstable.d.cts",
"default": "./lib-commonjs/unstable/index.cjs"
}
}
},
"./package.json": "./package.json"
},
"files": [
"*.md",
Expand Down
6 changes: 6 additions & 0 deletions packages/react-components/react-components/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions tools/workspace-plugin/generators.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions tools/workspace-plugin/src/generators/export-maps-sync/README.md
Original file line number Diff line number Diff line change
@@ -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)).
216 changes: 216 additions & 0 deletions tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectConfiguration>;
packageJson?: Partial<PackageJson>;
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<PackageJson>(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<string, object>).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();
});
});
});
Loading
Loading