Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof typeof expectedFields>).every(field =>
isEqual(packageJson[field], expectedFields[field]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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: [] });
});
});

Expand Down Expand Up @@ -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' }]);

Expand Down
Loading