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..52b295cb358c6 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,45 @@ 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 `[]`. +- `staticSubpaths` — export map keys the generator does not own. Defaults to `[]`. Single entry point packages omit `metadata.exportMap` entirely and get `{ root: true, -subpathEntryPoints: [] }`. +subpathEntryPoints: [], staticSubpaths: [] }`. 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`. +The generated keys are emitted in one canonical order: `"."` first, `"./package.json"` last, every +other subpath alphabetical in between. + +## Declaring asset subpaths + +Some packages ship export subpaths no source glob can produce — a compiled stylesheet, a raw `.css` +file shipped so a consumer's Tailwind build can `@source` scan it. The generator rebuilds the whole +map on every sync, so an entry it cannot derive is an entry it deletes. `staticSubpaths` names those +keys: + +```jsonc +{ + "metadata": { + "exportMap": { + "root": true, + "subpathEntryPoints": ["src/*.ts"], + "staticSubpaths": ["./styles.css", "./variants.css"], + }, + }, +} +``` + +Only the keys are declared. Their entries stay hand authored in `package.json`, next to the `files` +array that actually ships them, and are read back verbatim on every sync — so there is no second copy +of the paths to drift. Declaring a key with no matching `exports` entry, or one the generator already +derives from source, fails the sync with a message naming the key. + +A project with no source entry points at all (`root: false` and no `subpathEntryPoints`) is skipped +outright: it has no `main`/`module`/`typings` to own and no map to derive, so its hand authored +`exports` are left alone without needing a declaration. + ## 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..783ace31b5230 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,67 @@ describe('export-maps-sync generator', () => { expect(project.readPackageJson()).toEqual(afterFirstRun); }); + describe('static subpaths', () => { + it('preserves declared asset entries while regenerating the source derived ones', async () => { + const project = setupProject({ + name: 'react-windmod', + projectConfig: { + metadata: { exportMap: { root: true, subpathEntryPoints: ['src/*.ts'], staticSubpaths: ['./styles.css'] } }, + }, + sourceFiles: ['src/badge.ts'], + packageJson: { + // `.` carries the legacy flat shape, so the generator has to rewrite it + exports: { '.': './lib/index.js', './styles.css': './dist/styles.css' }, + }, + }); + + const result = await generator(tree); + + expect(result.outOfSyncMessage).toContain('react-windmod'); + expect(project.readPackageJson().exports).toEqual({ + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './badge': { + import: { types: './dist/badge.d.ts', default: './lib/badge.js' }, + require: { types: './dist/badge.d.cts', default: './lib-commonjs/badge.cjs' }, + }, + './styles.css': './dist/styles.css', + './package.json': './package.json', + }); + }); + + it('is a no-op on the second run', async () => { + const project = setupProject({ + name: 'react-windmod', + projectConfig: { + metadata: { exportMap: { root: true, subpathEntryPoints: ['src/*.ts'], staticSubpaths: ['./styles.css'] } }, + }, + sourceFiles: ['src/badge.ts'], + packageJson: { exports: { './styles.css': './dist/styles.css' } }, + }); + + await generator(tree); + const afterFirstRun = project.readPackageJson(); + + const result = await generator(tree); + + expect(result.outOfSyncMessage).toBeUndefined(); + expect(project.readPackageJson()).toEqual(afterFirstRun); + }); + + it('fails loudly when a declared entry was never authored', async () => { + setupProject({ + name: 'react-windmod', + projectConfig: { metadata: { exportMap: { staticSubpaths: ['./styles.css'] } } }, + packageJson: { exports: undefined }, + }); + + await expect(generator(tree)).rejects.toThrow(/no exports\["\.\/styles\.css"\] entry to preserve/); + }); + }); + describe('key ordering', () => { it('repairs a condition ordered so that default shadows types', async () => { const project = setupProject({ @@ -212,5 +273,22 @@ describe('export-maps-sync generator', () => { expect(project.readPackageJson().exports).toBeUndefined(); }); + + it('leaves an asset only project untouched, static declaration and all', async () => { + const exports = { '.': './css/index.css', './styles.css': './dist/styles.css' }; + const project = setupProject({ + name: 'tailwind-theme', + projectConfig: { + metadata: { exportMap: { root: false, subpathEntryPoints: [], staticSubpaths: ['./styles.css'] } }, + }, + packageJson: { exports, main: undefined, module: undefined, typings: undefined }, + }); + + const result = await generator(tree); + + expect(result.outOfSyncMessage).toBeUndefined(); + expect(project.readPackageJson()).toMatchObject({ exports }); + expect(project.readPackageJson().main).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 index 0decba50b4837..740ebd143b155 100644 --- a/tools/workspace-plugin/src/generators/export-maps-sync/index.ts +++ b/tools/workspace-plugin/src/generators/export-maps-sync/index.ts @@ -54,12 +54,14 @@ async function syncProject(tree: Tree, projectConfig: ProjectConfiguration): Pro const config = readExportMapConfig(projectConfig); const entryPoints = await resolveEntryPoints(tree, projectConfig.root, config); + // a project with no source entry points has no `main`/`module`/`typings` to own and no map to + // derive, so it is left entirely alone - hand authored `exports` included. if (entryPoints.length === 0) { return false; } const expectedFields = buildEntryPointFields(packageJson); - const expectedExports = buildExportMap(packageJson, entryPoints); + const expectedExports = buildExportMap(packageJson, entryPoints, config.staticSubpaths); const fieldsInSync = (Object.keys(expectedFields) as Array).every(field => isEqual(packageJson[field], expectedFields[field]), 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..f73ec968c70d8 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,16 +9,17 @@ describe('readExportMapConfig', () => { expect(readExportMapConfig({ root: 'packages/react-button' })).toEqual({ root: true, subpathEntryPoints: [], + staticSubpaths: [], }); }); 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'], staticSubpaths: ['./styles.css'] } }, }); - expect(config).toEqual({ root: false, subpathEntryPoints: ['src/*.ts'] }); + expect(config).toEqual({ root: false, subpathEntryPoints: ['src/*.ts'], staticSubpaths: ['./styles.css'] }); }); it('fills in defaults for a partial declaration', () => { @@ -27,7 +28,7 @@ describe('readExportMapConfig', () => { metadata: { exportMap: { subpathEntryPoints: ['src/*.ts'] } }, }); - expect(config).toEqual({ root: true, subpathEntryPoints: ['src/*.ts'] }); + expect(config).toEqual({ root: true, subpathEntryPoints: ['src/*.ts'], staticSubpaths: [] }); }); }); @@ -208,6 +209,74 @@ describe('buildExportMap', () => { }); }); + describe('static subpaths', () => { + const assetPackage: PackageJson = { + ...esmPackage, + exports: { + './styles.css': './dist/styles.css', + './variants.css': './src/variants.css', + }, + }; + + it('preserves a declared entry verbatim from the current package.json', () => { + const exports = buildExportMap(assetPackage, [rootEntry], ['./styles.css']); + + expect(exports).toEqual({ + '.': { + import: { types: './dist/index.d.ts', default: './lib/index.js' }, + require: { types: './dist/index.d.cts', default: './lib-commonjs/index.cjs' }, + }, + './styles.css': './dist/styles.css', + './package.json': './package.json', + }); + }); + + it('sorts static subpaths in with the generated ones', () => { + const exports = buildExportMap( + assetPackage, + [rootEntry, { key: './tooltip', name: 'tooltip', outputPath: 'tooltip' }], + ['./variants.css', './styles.css'], + ); + + expect(Object.keys(exports!)).toEqual(['.', './styles.css', './tooltip', './variants.css', './package.json']); + }); + + it('keeps preserving the entry on a package with no generated subpaths', () => { + const exports = buildExportMap({ ...assetPackage, type: undefined }, [rootEntry], ['./styles.css']); + + expect(exports!['./styles.css']).toBe('./dist/styles.css'); + }); + + it('preserves a conditional entry, not just a string one', () => { + const themeClassNames = { types: './theme-class-names.d.mts', default: './theme-class-names.mjs' }; + const exports = buildExportMap( + { ...assetPackage, exports: { './theme-class-names': themeClassNames } }, + [rootEntry], + ['./theme-class-names'], + ); + + expect(exports!['./theme-class-names']).toEqual(themeClassNames); + }); + + it('throws when a declared key has no entry to preserve', () => { + expect(() => buildExportMap(assetPackage, [rootEntry], ['./missing.css'])).toThrow( + /declares "\.\/missing\.css", but package.json has no exports\["\.\/missing\.css"\] entry/, + ); + }); + + it('throws when a declared key collides with a generated subpath', () => { + expect(() => + buildExportMap(assetPackage, [rootEntry, { key: './badge', name: 'badge', outputPath: 'badge' }], ['./badge']), + ).toThrow(/declares "\.\/badge", which the generator already derives from source/); + }); + + it('throws when a declaration tries to take over the package.json subpath', () => { + expect(() => buildExportMap(assetPackage, [rootEntry], ['./package.json'])).toThrow( + /declares "\.\/package\.json", which the generator already derives from source/, + ); + }); + }); + it('always exposes the package.json subpath last', () => { const exports = buildExportMap(esmPackage, [rootEntry, { key: './badge', name: 'badge', outputPath: 'badge' }]); 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..99d39e2852bc2 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 @@ -14,7 +14,10 @@ export interface EntryPoint { outputPath: string; } -const DEFAULT_CONFIG: ExportMapConfig = { root: true, subpathEntryPoints: [] }; +const DEFAULT_CONFIG: ExportMapConfig = { root: true, subpathEntryPoints: [], staticSubpaths: [] }; + +const ROOT_KEY = '.'; +const PACKAGE_JSON_KEY = './package.json'; export function readExportMapConfig(projectConfig: ProjectConfiguration): ExportMapConfig { const metadata = projectConfig.metadata as { exportMap?: Partial } | undefined; @@ -28,9 +31,9 @@ export function readExportMapConfig(projectConfig: ProjectConfiguration): Export export async function resolveEntryPoints( tree: Tree, projectRoot: string, - config: ExportMapConfig, + config: Pick, ): Promise { - const entryPoints: EntryPoint[] = config.root ? [{ key: '.', name: 'index', outputPath: 'index' }] : []; + const entryPoints: EntryPoint[] = config.root ? [{ key: ROOT_KEY, name: 'index', outputPath: 'index' }] : []; if (config.subpathEntryPoints.length === 0) { return entryPoints; @@ -76,10 +79,17 @@ function toOutputPath(sourcePathFromSrc: string): string | null { * * 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. + * + * `staticSubpaths` names the keys the generator does not own - their entries are read back verbatim + * from `json.exports` instead of being derived from a source file. */ -export function buildExportMap(json: PackageJson, entryPoints: EntryPoint[]): PackageJson['exports'] { +export function buildExportMap( + json: PackageJson, + entryPoints: EntryPoint[], + staticSubpaths: string[] = [], +): PackageJson['exports'] { const style = json.style ? normalizeEntryPointPath(json.style) : null; - const exports: NonNullable = {}; + const generated: NonNullable = {}; // Opt-in: a package becomes ESM-first by declaring `"type": "module"` in its package.json. if (json.type === 'module') { @@ -87,36 +97,87 @@ export function buildExportMap(json: PackageJson, entryPoints: EntryPoint[]): Pa // 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), + generated[key] = { + ...(key === ROOT_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` }, }; } + } else { + // 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; + + generated[key] = { + types: `./dist/${name}.d.ts`, + ...(key === ROOT_KEY && style ? { style } : null), + node: esm ? { module: esm, default: commonjs } : commonjs, + ...(esm ? { import: esm } : null), + require: commonjs, + }; + } + } + + return orderExportMap({ ...generated, ...readStaticEntries(json, staticSubpaths, generated) }); +} + +/** + * Reads the declared static subpath entries back out of the package's current export map. + * + * Their values are authored in `package.json`, next to the `files` array that ships them, so the + * declaration in `project.json` stays a list of keys rather than a second copy of the paths. + */ +function readStaticEntries( + json: PackageJson, + staticSubpaths: string[], + generated: NonNullable, +): NonNullable { + const entries: NonNullable = {}; + + for (const key of staticSubpaths) { + if (key === PACKAGE_JSON_KEY || key in generated) { + throw new Error( + `${json.name}: metadata.exportMap.staticSubpaths declares "${key}", which the generator already derives from source. Drop it from the declaration.`, + ); + } + + const entry = json.exports?.[key]; + + if (entry === undefined) { + throw new Error( + `${json.name}: metadata.exportMap.staticSubpaths declares "${key}", but package.json has no exports["${key}"] entry to preserve. Author the entry in package.json first.`, + ); + } - exports['./package.json'] = './package.json'; + entries[key] = entry; + } + + return entries; +} + +/** + * Canonical key order: the root entry first, `./package.json` last, every other subpath in between + * sorted alphabetically - so an added subpath lands in one predictable place no matter which + * mechanism produced it. + */ +function orderExportMap(entries: NonNullable): NonNullable { + const ordered: NonNullable = {}; - return exports; + if (ROOT_KEY in entries) { + ordered[ROOT_KEY] = entries[ROOT_KEY]; } - // 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, - }; + for (const key of Object.keys(entries) + .filter(key => key !== ROOT_KEY) + .sort()) { + ordered[key] = entries[key]; } - exports['./package.json'] = './package.json'; + ordered[PACKAGE_JSON_KEY] = PACKAGE_JSON_KEY; - return exports; + return ordered; } /** 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..37b8198e2ee7e 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[]; + /** + * Export map keys the generator does not own, eg. `./styles.css`. Only the keys are declared here; + * their entries stay hand authored in `package.json`, next to the `files` array that ships them, + * and are read back verbatim on every sync. + * + * For asset subpaths that no source glob can produce - a package's compiled stylesheet, a raw + * `.css` source shipped for `@source` scanning - without them a sync deletes the entry. + * @default [] + */ + staticSubpaths: string[]; }