Skip to content

Commit 34e803e

Browse files
ArrayKnightclaude
andcommitted
feat(workspace-plugin): let export-maps-sync preserve hand authored asset subpaths
The sync generator rebuilds a project's whole `exports` map from its declared entry points and keeps nothing else, so any subpath it cannot derive from a source file is a subpath it silently deletes on the next `nx sync`. That is fine for packages whose map is entirely TypeScript, and fatal for one shipping a compiled stylesheet (`./styles.css`) or a raw `.css` source published for a consumer's Tailwind `@source` scan (`./variants.css`) - today the only way to keep those is to stay out of the generator's scope by staying `private`. Adds `metadata.exportMap.staticSubpaths`: a list of export keys the generator does not own. Only the keys are declared in `project.json`; the entries stay hand authored in `package.json` next to the `files` array that ships them, and are read back verbatim on every sync, so there is no second copy of the paths to drift. A key with no matching entry, or one the generator already derives from source, throws with the key named. Key emission now runs through a single ordering pass - `"."` first, `"./package.json"` last, everything else alphabetical - so static and generated subpaths land in the same predictable place. Existing packages are unaffected: their generated subpaths were already emitted sorted, and `nx sync:check` reports the workspace up to date before and after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
1 parent 49c0291 commit 34e803e

6 files changed

Lines changed: 281 additions & 29 deletions

File tree

tools/workspace-plugin/src/generators/export-maps-sync/README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,45 @@ So each multi-entry project declares its own, in `project.json`:
3636
- `root` — whether a `"."` entry resolved from `src/index.ts` is exposed. Defaults to `true`.
3737
- `subpathEntryPoints` — globs, relative to the project root, resolving to the source files backing
3838
non-root subpaths. Defaults to `[]`.
39+
- `staticSubpaths` — export map keys the generator does not own. Defaults to `[]`.
3940

4041
Single entry point packages omit `metadata.exportMap` entirely and get `{ root: true,
41-
subpathEntryPoints: [] }`.
42+
subpathEntryPoints: [], staticSubpaths: [] }`.
4243

4344
Source file names map to subpaths by stripping `src/` and the extension, so `src/color-picker.ts`
4445
becomes `./color-picker` and `src/unstable/index.ts` becomes `./unstable`.
4546

47+
The generated keys are emitted in one canonical order: `"."` first, `"./package.json"` last, every
48+
other subpath alphabetical in between.
49+
50+
## Declaring asset subpaths
51+
52+
Some packages ship export subpaths no source glob can produce — a compiled stylesheet, a raw `.css`
53+
file shipped so a consumer's Tailwind build can `@source` scan it. The generator rebuilds the whole
54+
map on every sync, so an entry it cannot derive is an entry it deletes. `staticSubpaths` names those
55+
keys:
56+
57+
```jsonc
58+
{
59+
"metadata": {
60+
"exportMap": {
61+
"root": true,
62+
"subpathEntryPoints": ["src/*.ts"],
63+
"staticSubpaths": ["./styles.css", "./variants.css"],
64+
},
65+
},
66+
}
67+
```
68+
69+
Only the keys are declared. Their entries stay hand authored in `package.json`, next to the `files`
70+
array that actually ships them, and are read back verbatim on every sync — so there is no second copy
71+
of the paths to drift. Declaring a key with no matching `exports` entry, or one the generator already
72+
derives from source, fails the sync with a message naming the key.
73+
74+
A project with no source entry points at all (`root: false` and no `subpathEntryPoints`) is skipped
75+
outright: it has no `main`/`module`/`typings` to own and no map to derive, so its hand authored
76+
`exports` are left alone without needing a declaration.
77+
4678
## Why a sync generator
4779

4880
The `exports` map is the source of truth for `generate-api` (it derives one api-extractor entry per

tools/workspace-plugin/src/generators/export-maps-sync/index.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,67 @@ describe('export-maps-sync generator', () => {
112112
expect(project.readPackageJson()).toEqual(afterFirstRun);
113113
});
114114

115+
describe('static subpaths', () => {
116+
it('preserves declared asset entries while regenerating the source derived ones', async () => {
117+
const project = setupProject({
118+
name: 'react-windmod',
119+
projectConfig: {
120+
metadata: { exportMap: { root: true, subpathEntryPoints: ['src/*.ts'], staticSubpaths: ['./styles.css'] } },
121+
},
122+
sourceFiles: ['src/badge.ts'],
123+
packageJson: {
124+
// `.` carries the legacy flat shape, so the generator has to rewrite it
125+
exports: { '.': './lib/index.js', './styles.css': './dist/styles.css' },
126+
},
127+
});
128+
129+
const result = await generator(tree);
130+
131+
expect(result.outOfSyncMessage).toContain('react-windmod');
132+
expect(project.readPackageJson().exports).toEqual({
133+
'.': {
134+
import: { types: './dist/index.d.ts', default: './lib/index.js' },
135+
require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' },
136+
},
137+
'./badge': {
138+
import: { types: './dist/badge.d.ts', default: './lib/badge.js' },
139+
require: { types: './dist/badge.d.cts', default: './lib-commonjs/badge.cjs' },
140+
},
141+
'./styles.css': './dist/styles.css',
142+
'./package.json': './package.json',
143+
});
144+
});
145+
146+
it('is a no-op on the second run', async () => {
147+
const project = setupProject({
148+
name: 'react-windmod',
149+
projectConfig: {
150+
metadata: { exportMap: { root: true, subpathEntryPoints: ['src/*.ts'], staticSubpaths: ['./styles.css'] } },
151+
},
152+
sourceFiles: ['src/badge.ts'],
153+
packageJson: { exports: { './styles.css': './dist/styles.css' } },
154+
});
155+
156+
await generator(tree);
157+
const afterFirstRun = project.readPackageJson();
158+
159+
const result = await generator(tree);
160+
161+
expect(result.outOfSyncMessage).toBeUndefined();
162+
expect(project.readPackageJson()).toEqual(afterFirstRun);
163+
});
164+
165+
it('fails loudly when a declared entry was never authored', async () => {
166+
setupProject({
167+
name: 'react-windmod',
168+
projectConfig: { metadata: { exportMap: { staticSubpaths: ['./styles.css'] } } },
169+
packageJson: { exports: undefined },
170+
});
171+
172+
await expect(generator(tree)).rejects.toThrow(/no exports\["\.\/styles\.css"\] entry to preserve/);
173+
});
174+
});
175+
115176
describe('key ordering', () => {
116177
it('repairs a condition ordered so that default shadows types', async () => {
117178
const project = setupProject({
@@ -212,5 +273,22 @@ describe('export-maps-sync generator', () => {
212273

213274
expect(project.readPackageJson().exports).toBeUndefined();
214275
});
276+
277+
it('leaves an asset only project untouched, static declaration and all', async () => {
278+
const exports = { '.': './css/index.css', './styles.css': './dist/styles.css' };
279+
const project = setupProject({
280+
name: 'tailwind-theme',
281+
projectConfig: {
282+
metadata: { exportMap: { root: false, subpathEntryPoints: [], staticSubpaths: ['./styles.css'] } },
283+
},
284+
packageJson: { exports, main: undefined, module: undefined, typings: undefined },
285+
});
286+
287+
const result = await generator(tree);
288+
289+
expect(result.outOfSyncMessage).toBeUndefined();
290+
expect(project.readPackageJson()).toMatchObject({ exports });
291+
expect(project.readPackageJson().main).toBeUndefined();
292+
});
215293
});
216294
});

tools/workspace-plugin/src/generators/export-maps-sync/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ async function syncProject(tree: Tree, projectConfig: ProjectConfiguration): Pro
5454
const config = readExportMapConfig(projectConfig);
5555
const entryPoints = await resolveEntryPoints(tree, projectConfig.root, config);
5656

57+
// a project with no source entry points has no `main`/`module`/`typings` to own and no map to
58+
// derive, so it is left entirely alone - hand authored `exports` included.
5759
if (entryPoints.length === 0) {
5860
return false;
5961
}
6062

6163
const expectedFields = buildEntryPointFields(packageJson);
62-
const expectedExports = buildExportMap(packageJson, entryPoints);
64+
const expectedExports = buildExportMap(packageJson, entryPoints, config.staticSubpaths);
6365

6466
const fieldsInSync = (Object.keys(expectedFields) as Array<keyof typeof expectedFields>).every(field =>
6567
isEqual(packageJson[field], expectedFields[field]),

tools/workspace-plugin/src/generators/export-maps-sync/lib/export-map.spec.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@ describe('readExportMapConfig', () => {
99
expect(readExportMapConfig({ root: 'packages/react-button' })).toEqual({
1010
root: true,
1111
subpathEntryPoints: [],
12+
staticSubpaths: [],
1213
});
1314
});
1415

1516
it('reads the declaration from project metadata', () => {
1617
const config = readExportMapConfig({
1718
root: 'packages/react-headless',
18-
metadata: { exportMap: { root: false, subpathEntryPoints: ['src/*.ts'] } },
19+
metadata: { exportMap: { root: false, subpathEntryPoints: ['src/*.ts'], staticSubpaths: ['./styles.css'] } },
1920
});
2021

21-
expect(config).toEqual({ root: false, subpathEntryPoints: ['src/*.ts'] });
22+
expect(config).toEqual({ root: false, subpathEntryPoints: ['src/*.ts'], staticSubpaths: ['./styles.css'] });
2223
});
2324

2425
it('fills in defaults for a partial declaration', () => {
@@ -27,7 +28,7 @@ describe('readExportMapConfig', () => {
2728
metadata: { exportMap: { subpathEntryPoints: ['src/*.ts'] } },
2829
});
2930

30-
expect(config).toEqual({ root: true, subpathEntryPoints: ['src/*.ts'] });
31+
expect(config).toEqual({ root: true, subpathEntryPoints: ['src/*.ts'], staticSubpaths: [] });
3132
});
3233
});
3334

@@ -208,6 +209,74 @@ describe('buildExportMap', () => {
208209
});
209210
});
210211

212+
describe('static subpaths', () => {
213+
const assetPackage: PackageJson = {
214+
...esmPackage,
215+
exports: {
216+
'./styles.css': './dist/styles.css',
217+
'./variants.css': './src/variants.css',
218+
},
219+
};
220+
221+
it('preserves a declared entry verbatim from the current package.json', () => {
222+
const exports = buildExportMap(assetPackage, [rootEntry], ['./styles.css']);
223+
224+
expect(exports).toEqual({
225+
'.': {
226+
import: { types: './dist/index.d.ts', default: './lib/index.js' },
227+
require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' },
228+
},
229+
'./styles.css': './dist/styles.css',
230+
'./package.json': './package.json',
231+
});
232+
});
233+
234+
it('sorts static subpaths in with the generated ones', () => {
235+
const exports = buildExportMap(
236+
assetPackage,
237+
[rootEntry, { key: './tooltip', name: 'tooltip', outputPath: 'tooltip' }],
238+
['./variants.css', './styles.css'],
239+
);
240+
241+
expect(Object.keys(exports!)).toEqual(['.', './styles.css', './tooltip', './variants.css', './package.json']);
242+
});
243+
244+
it('keeps preserving the entry on a package with no generated subpaths', () => {
245+
const exports = buildExportMap({ ...assetPackage, type: undefined }, [rootEntry], ['./styles.css']);
246+
247+
expect(exports!['./styles.css']).toBe('./dist/styles.css');
248+
});
249+
250+
it('preserves a conditional entry, not just a string one', () => {
251+
const themeClassNames = { types: './theme-class-names.d.mts', default: './theme-class-names.mjs' };
252+
const exports = buildExportMap(
253+
{ ...assetPackage, exports: { './theme-class-names': themeClassNames } },
254+
[rootEntry],
255+
['./theme-class-names'],
256+
);
257+
258+
expect(exports!['./theme-class-names']).toEqual(themeClassNames);
259+
});
260+
261+
it('throws when a declared key has no entry to preserve', () => {
262+
expect(() => buildExportMap(assetPackage, [rootEntry], ['./missing.css'])).toThrow(
263+
/declares "\.\/missing\.css", but package.json has no exports\["\.\/missing\.css"\] entry/,
264+
);
265+
});
266+
267+
it('throws when a declared key collides with a generated subpath', () => {
268+
expect(() =>
269+
buildExportMap(assetPackage, [rootEntry, { key: './badge', name: 'badge', outputPath: 'badge' }], ['./badge']),
270+
).toThrow(/declares "\.\/badge", which the generator already derives from source/);
271+
});
272+
273+
it('throws when a declaration tries to take over the package.json subpath', () => {
274+
expect(() => buildExportMap(assetPackage, [rootEntry], ['./package.json'])).toThrow(
275+
/declares "\.\/package\.json", which the generator already derives from source/,
276+
);
277+
});
278+
});
279+
211280
it('always exposes the package.json subpath last', () => {
212281
const exports = buildExportMap(esmPackage, [rootEntry, { key: './badge', name: 'badge', outputPath: 'badge' }]);
213282

0 commit comments

Comments
 (0)