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
40 changes: 37 additions & 3 deletions src/lib/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,38 @@ const resolveConcreteExport = async (
}
};

/**
* Widen an exports-derived source pattern into a directory-crossing glob.
*
* A package.json `exports` wildcard matches subpaths *including* `/` — the
* `./*.js` key resolves `./auth/session.js` with `*` capturing `auth/session`.
* A plain glob `*`, by contrast, stops at a directory separator, so globbing
* the mapped `src/lib/*.ts` would match only top-level modules and silently
* drop every nested one. When the wildcard sits after a directory separator
* (the ubiquitous `<dir>/*.ext` shape), splice in a globstar segment so the
* glob crosses directories the way Node's resolver does; the globstar matches
* zero-or-more segments, so top-level files still match.
*
* Two shapes are deliberately left un-widened:
* - A mid-segment wildcard (`prefix-*.ts`) has no faithful globstar translation.
* - A bare-root wildcard (`*.ts`, from an empty `sourceDir`) would widen to a
* project-root `**` that rakes in `node_modules`/`dist`. Empty `sourceDir`
* only arises from the multi-`sourcePaths` no-common-prefix layout (which
* `discoverSourceFiles` short-circuits before reaching here) or an explicit
* `sourceRoot: ''`; leaving it non-recursive matches the prior behavior
* without the explosion risk.
*
* Both fall through to matching same-directory files only.
*/
const toRecursiveExportGlob = (pattern: string): string => {
const star = pattern.indexOf('*');
if (star === -1) return pattern;
const before = pattern.slice(0, star);
const after = pattern.slice(star + 1);
if (before.endsWith('/')) return `${before}**/*${after}`;
return pattern;
};

/**
* Expand a wildcard export pattern to matching source files.
*/
Expand All @@ -348,10 +380,12 @@ const expandWildcardExport = async (
exclude: Array<string> | undefined,
discovered: Map<string, string>,
): Promise<void> => {
const sourcePattern = mapDistToSource(distPath, condition, mappingOptions);
if (!sourcePattern) return;
const mappedPattern = mapDistToSource(distPath, condition, mappingOptions);
if (!mappedPattern) return;

// Convert mapped pattern to glob: "src/lib/*.ts" is already a valid glob
// The exports `*` crosses directory separators; a glob `*` does not — widen
// it so nested modules aren't silently dropped.
const sourcePattern = toRecursiveExportGlob(mappedPattern);
const patterns = [sourcePattern];

// For .ts patterns, also try .svelte and .js
Expand Down
81 changes: 81 additions & 0 deletions src/test/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,87 @@ describe('discoverFromExports', () => {
});
});

test('discovers wildcard exports in nested directories', async () => {
// A package.json `exports` wildcard (`./*.js`) matches subpaths including
// `/` — `@scope/pkg/auth/session.js` resolves through it — so discovery
// must recurse into subdirectories, not just the top-level source dir.
await withTestDir(async (dir) => {
await mkdir(join(dir, 'src/lib/auth'), {recursive: true});
await mkdir(join(dir, 'src/lib/db/deep'), {recursive: true});
await writeFile(join(dir, 'src/lib/root.ts'), 'export const r = 0;');
await writeFile(join(dir, 'src/lib/auth/session.ts'), 'export const s = 1;');
await writeFile(join(dir, 'src/lib/auth/Login.svelte'), '<div>login</div>');
await writeFile(join(dir, 'src/lib/db/deep/nested.ts'), 'export const n = 2;');
await writeFile(
join(dir, 'package.json'),
JSON.stringify({
exports: {
'./*.js': {types: './dist/*.d.ts', default: './dist/*.js'},
'./*.svelte': {svelte: './dist/*.svelte', default: './dist/*.svelte'},
},
}),
);

const {files} = await discoverFromExports({projectRoot: dir});
assert.ok(files);
const paths = files.map((f) => f.id).sort();
assert.strictEqual(files.length, 4);
assert.ok(paths.some((p) => p.endsWith('src/lib/root.ts')));
assert.ok(paths.some((p) => p.endsWith('src/lib/auth/session.ts')));
assert.ok(paths.some((p) => p.endsWith('src/lib/auth/Login.svelte')));
assert.ok(paths.some((p) => p.endsWith('src/lib/db/deep/nested.ts')));
});
});

test('honors exclude for nested wildcard matches', async () => {
// Recursive wildcard expansion means `exclude` now governs nested files
// that a non-recursive `src/lib/*.ts` glob never reached. A co-located
// test file (or any excluded subtree) must still be dropped.
await withTestDir(async (dir) => {
await mkdir(join(dir, 'src/lib/auth'), {recursive: true});
await writeFile(join(dir, 'src/lib/auth/session.ts'), 'export const s = 1;');
await writeFile(join(dir, 'src/lib/auth/session.test.ts'), 'export const t = 1;');
await writeFile(
join(dir, 'package.json'),
JSON.stringify({
exports: {'./*.js': {types: './dist/*.d.ts', default: './dist/*.js'}},
}),
);

const {files} = await discoverFromExports({
projectRoot: dir,
exclude: ['**/*.test.ts'],
});
assert.ok(files);
assert.strictEqual(files.length, 1);
assert.ok(files[0]!.id.endsWith('src/lib/auth/session.ts'));
});
});

test('bare-root wildcard (empty sourceDir) does not recurse into subdirectories', async () => {
// Guard the deliberate non-widening: an empty `sourceDir` maps `./dist/*.js`
// to a bare `*.ts`. Widening that to a project-root `**` would rake in
// `node_modules`/`dist`, so it must stay a single-segment match. Only the
// root-level file is found; the nested one is not.
await withTestDir(async (dir) => {
await mkdir(join(dir, 'nested'), {recursive: true});
await writeFile(join(dir, 'root.ts'), 'export const r = 0;');
await writeFile(join(dir, 'nested/deep.ts'), 'export const d = 1;');
await writeFile(
join(dir, 'package.json'),
JSON.stringify({
exports: {'./*.js': {types: './dist/*.d.ts', default: './dist/*.js'}},
}),
);

const {files} = await discoverFromExports({projectRoot: dir, sourceDir: ''});
assert.ok(files);
assert.strictEqual(files.length, 1);
assert.ok(files[0]!.id.endsWith('root.ts'));
assert.ok(!files.some((f) => f.id.endsWith('deep.ts')));
});
});

test('deduplicates across .js and .ts patterns', async () => {
await withTestDir(async (dir) => {
await mkdir(join(dir, 'src/lib'), {recursive: true});
Expand Down