From 5c0c24419752949d8b657f0505cb7b71b1757488 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 6 Aug 2026 09:29:30 +0200 Subject: [PATCH 1/3] Add subpath auto-detection from package.json exports Introduce a `subpaths` option on `DependencyConfig` that expands a package's export subpaths automatically instead of declaring each one by hand. - esm.sh dependencies: `subpaths: true` reads the package's `exports` (disk node_modules first, npm registry fallback) and adds each subpath to the import map as its own esm.sh URL; the array form takes an explicit subset. - Local self-hosted dependencies: `subpaths: true` derives the multi-entry code-split `entries` from `exports`, collapsing `.js` export aliases onto the canonical entry to avoid duplicate chunks. Explicit `entries` still win. resolveDependencies is now async to allow the registry fallback. The demo uses `subpaths: true` for both @studiometa/js-toolkit (esm.sh) and demo-lib (local) as an end-to-end proof. Co-authored-by: Claude Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Botz34NmFLdgRgm2QJpKkZ --- CLAUDE.md | 2 +- packages/demo/lib/package.json | 8 + packages/demo/meta.config.js | 6 +- .../PlaygroundDependenciesPlugin.test.ts | 29 ++ .../plugins/PlaygroundDependenciesPlugin.ts | 5 +- .../playground/src/lib/presets/playground.ts | 2 +- .../lib/utils/resolve-dependencies.test.ts | 331 ++++++++++++++---- .../src/lib/utils/resolve-dependencies.ts | 248 ++++++++++++- 8 files changed, 556 insertions(+), 75 deletions(-) create mode 100644 packages/demo/lib/package.json diff --git a/CLAUDE.md b/CLAUDE.md index 2c728e0..991e2ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ npm run test:ci # Tests + coverage The `dependencies` option in `playgroundPreset()` goes through: -1. **`resolveDependencies()`** (`src/lib/utils/resolve-dependencies.ts`) — resolves each dependency into either an esm.sh URL or a self-hosted bundle path. Produces an import map + self-hosted metadata. +1. **`resolveDependencies()`** (`src/lib/utils/resolve-dependencies.ts`) — resolves each dependency into either an esm.sh URL or a self-hosted bundle path. Produces an import map + self-hosted metadata. Supports a `subpaths` option that auto-detects export subpaths from a package's `package.json` `exports` (esm.sh: disk-first then npm registry; local: derives the multi-entry `entries`). 2. **`PlaygroundDependenciesPlugin`** (`src/lib/plugins/PlaygroundDependenciesPlugin.ts`) — webpack plugin that bundles self-hosted dependencies with tsdown into `.js` + `.d.ts`. Emits `_headers` file for `x-typescript-types`. 3. **`playground.ts` preset** (`src/lib/presets/playground.ts`) — orchestrates everything: merges import maps, instantiates plugins, configures webpack. diff --git a/packages/demo/lib/package.json b/packages/demo/lib/package.json new file mode 100644 index 0000000..5f8fb60 --- /dev/null +++ b/packages/demo/lib/package.json @@ -0,0 +1,8 @@ +{ + "name": "demo-lib", + "type": "module", + "exports": { + ".": "./index.ts", + "./manifest": "./manifest.ts" + } +} diff --git a/packages/demo/meta.config.js b/packages/demo/meta.config.js index 4aabeac..d2114fa 100644 --- a/packages/demo/meta.config.js +++ b/packages/demo/meta.config.js @@ -16,14 +16,12 @@ export default defineWebpackConfig({ { specifier: '@studiometa/js-toolkit', esmSh: { bundle: false }, + subpaths: true, }, { specifier: 'demo-lib', source: './lib/**/*.ts', - entries: { - '.': './lib/index.ts', - './manifest': './lib/manifest.ts', - }, + subpaths: true, }, ], loaders: { diff --git a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts index 7f1cde9..e16aaa1 100644 --- a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts +++ b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts @@ -169,6 +169,35 @@ describe('PlaygroundDependenciesPlugin', () => { ); }); + it('prefixes alias specifiers of a multi-entry dependency', () => { + const deps: ResolvedDependency[] = [ + { + specifier: 'demo', + importMapValue: '/static/deps/demo/index.js', + type: 'bundle', + entries: [ + { + subpath: './Foo', + specifier: 'demo/Foo', + name: 'Foo', + source: '../demo/Foo.ts', + importMapValue: '/static/deps/demo/Foo.js', + }, + ], + aliasSpecifiers: ['demo/Foo.js'], + }, + ]; + const importMap = { + 'demo/Foo': '/static/deps/demo/Foo.js', + 'demo/Foo.js': '/static/deps/demo/Foo.js', + }; + + applyAndGetImportMap(deps, importMap, '/play'); + + expect(importMap['demo/Foo']).toBe('/play/static/deps/demo/Foo.js'); + expect(importMap['demo/Foo.js']).toBe('/play/static/deps/demo/Foo.js'); + }); + it('infers publicPath from webpack output.publicPath', () => { const deps: ResolvedDependency[] = [ { diff --git a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts index 1249fcb..9a62c06 100644 --- a/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts +++ b/packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts @@ -65,8 +65,11 @@ export class PlaygroundDependenciesPlugin { for (const dep of this.dependencies) { // A multi-entry dependency contributes one import-map key per entry // subpath; single-entry ones contribute just their own specifier. + // Alias specifiers (e.g. `.js` export aliases collapsed onto a + // canonical entry) are prefixed too — they resolve to the same + // emitted file as their canonical entry. const specifiers = dep.entries?.length - ? dep.entries.map((entry) => entry.specifier) + ? [...dep.entries.map((entry) => entry.specifier), ...(dep.aliasSpecifiers ?? [])] : [dep.specifier]; for (const specifier of specifiers) { const currentValue = this.importMap[specifier]; diff --git a/packages/playground/src/lib/presets/playground.ts b/packages/playground/src/lib/presets/playground.ts index 50c9bee..bb09a82 100644 --- a/packages/playground/src/lib/presets/playground.ts +++ b/packages/playground/src/lib/presets/playground.ts @@ -143,7 +143,7 @@ export function playgroundPreset(options?: PartialDeep) if (options?.dependencies?.length) { const packageJsonPath = resolve(configDir, 'package.json'); - const resolved = resolveDependencies(options.dependencies, packageJsonPath); + const resolved = await resolveDependencies(options.dependencies, packageJsonPath); // Dependencies go first, manual importMap entries take precedence mergedImportMap = { ...resolved.importMap, ...mergedImportMap }; selfHostedDeps = resolved.selfHosted; diff --git a/packages/playground/src/lib/utils/resolve-dependencies.test.ts b/packages/playground/src/lib/utils/resolve-dependencies.test.ts index a3f569b..5c9d080 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.test.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.test.ts @@ -1,11 +1,14 @@ -import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { describe, it, expect, vi } from 'vitest'; import { resolveDependencies, getPackageName, getSubpath, serializeEsmShOptions, + extractSubpathKeys, + resolveExportTarget, } from './resolve-dependencies.js'; describe('serializeEsmShOptions', () => { @@ -109,30 +112,89 @@ describe('getSubpath', () => { }); }); +describe('extractSubpathKeys', () => { + it('returns ["."] for a string exports value', () => { + expect(extractSubpathKeys('./index.js')).toEqual(['.']); + }); + + it('returns ["."] for an array exports value', () => { + expect(extractSubpathKeys(['./index.js'])).toEqual(['.']); + }); + + it('returns ["."] for a conditions-only object (no dotted keys)', () => { + expect(extractSubpathKeys({ import: './index.js', default: './index.js' })).toEqual(['.']); + }); + + it('returns dotted keys from a subpath exports object', () => { + expect(extractSubpathKeys({ '.': './index.js', './utils': './utils/index.js' })).toEqual([ + '.', + './utils', + ]); + }); + + it('excludes ./package.json and wildcard keys', () => { + expect( + extractSubpathKeys({ + '.': './index.js', + './utils': './utils/index.js', + './package.json': './package.json', + './*': './*.js', + }), + ).toEqual(['.', './utils']); + }); + + it('returns ["."] for undefined exports', () => { + expect(extractSubpathKeys(undefined)).toEqual(['.']); + }); +}); + +describe('resolveExportTarget', () => { + it('returns a string value directly', () => { + expect(resolveExportTarget('./index.js')).toBe('./index.js'); + }); + + it('resolves import over default', () => { + expect(resolveExportTarget({ import: './a.js', default: './b.js' })).toBe('./a.js'); + }); + + it('falls back to default when import is missing', () => { + expect(resolveExportTarget({ default: './b.js' })).toBe('./b.js'); + }); + + it('resolves a nested conditions object one level deep', () => { + expect(resolveExportTarget({ import: { default: './nested.js' } })).toBe('./nested.js'); + }); + + it('returns undefined for a non-resolvable value', () => { + expect(resolveExportTarget(undefined)).toBeUndefined(); + expect(resolveExportTarget(42)).toBeUndefined(); + }); +}); + describe('resolveDependencies', () => { - it('resolves plain string to esm.sh URL', () => { - const result = resolveDependencies(['deepmerge']); + it('resolves plain string to esm.sh URL', async () => { + const result = await resolveDependencies(['deepmerge']); expect(result.importMap).toEqual({ deepmerge: 'https://esm.sh/deepmerge', }); expect(result.selfHosted).toEqual([]); }); - it('resolves scoped package to esm.sh URL', () => { - const result = resolveDependencies(['@motionone/easing']); + it('resolves scoped package to esm.sh URL', async () => { + const result = await resolveDependencies(['@motionone/easing']); expect(result.importMap).toEqual({ '@motionone/easing': 'https://esm.sh/@motionone/easing', }); }); - it('uses explicit version', () => { - const result = resolveDependencies([{ specifier: 'deepmerge', version: '5.1.0' }]); + it('uses explicit version', async () => { + const result = await resolveDependencies([{ specifier: 'deepmerge', version: '5.1.0' }]); expect(result.importMap).toEqual({ deepmerge: 'https://esm.sh/deepmerge@5.1.0', }); }); - it('infers version from package.json', () => { + it('infers version from package.json', async () => { const tmpDir = join('/tmp', 'test-resolve-deps-' + Date.now()); mkdirSync(tmpDir, { recursive: true }); const pkgPath = join(tmpDir, 'package.json'); @@ -144,7 +206,7 @@ describe('resolveDependencies', () => { ); try { - const result = resolveDependencies(['deepmerge'], pkgPath); + const result = await resolveDependencies(['deepmerge'], pkgPath); expect(result.importMap).toEqual({ deepmerge: 'https://esm.sh/deepmerge@5.1.0', }); @@ -154,15 +216,15 @@ describe('resolveDependencies', () => { }); describe('subpath imports', () => { - it('resolves scoped package with subpath to correct esm.sh URL', () => { - const result = resolveDependencies(['@studiometa/js-toolkit/utils']); + it('resolves scoped package with subpath to correct esm.sh URL', async () => { + const result = await resolveDependencies(['@studiometa/js-toolkit/utils']); expect(result.importMap).toEqual({ '@studiometa/js-toolkit/utils': 'https://esm.sh/@studiometa/js-toolkit/utils', }); }); - it('resolves scoped package with subpath and version', () => { - const result = resolveDependencies([ + it('resolves scoped package with subpath and version', async () => { + const result = await resolveDependencies([ { specifier: '@studiometa/js-toolkit/utils', version: '3.4.3' }, ]); expect(result.importMap).toEqual({ @@ -170,7 +232,7 @@ describe('resolveDependencies', () => { }); }); - it('infers version from package.json for subpath imports', () => { + it('infers version from package.json for subpath imports', async () => { const tmpDir = join('/tmp', 'test-resolve-deps-subpath-' + Date.now()); mkdirSync(tmpDir, { recursive: true }); const pkgPath = join(tmpDir, 'package.json'); @@ -182,7 +244,7 @@ describe('resolveDependencies', () => { ); try { - const result = resolveDependencies(['@studiometa/js-toolkit/utils'], pkgPath); + const result = await resolveDependencies(['@studiometa/js-toolkit/utils'], pkgPath); expect(result.importMap).toEqual({ '@studiometa/js-toolkit/utils': 'https://esm.sh/@studiometa/js-toolkit@3.4.3/utils', }); @@ -191,15 +253,15 @@ describe('resolveDependencies', () => { } }); - it('resolves unscoped package with subpath', () => { - const result = resolveDependencies([{ specifier: 'lodash/merge', version: '4.17.21' }]); + it('resolves unscoped package with subpath', async () => { + const result = await resolveDependencies([{ specifier: 'lodash/merge', version: '4.17.21' }]); expect(result.importMap).toEqual({ 'lodash/merge': 'https://esm.sh/lodash@4.17.21/merge', }); }); - it('resolves deep subpath correctly', () => { - const result = resolveDependencies([ + it('resolves deep subpath correctly', async () => { + const result = await resolveDependencies([ { specifier: '@studiometa/js-toolkit/utils/css', version: '3.4.3' }, ]); expect(result.importMap).toEqual({ @@ -208,11 +270,63 @@ describe('resolveDependencies', () => { }); }); + describe('esm.sh subpath auto-detection', () => { + it('adds each subpath from an explicit list without reading disk/network', async () => { + const result = await resolveDependencies([ + { specifier: '@studiometa/js-toolkit', subpaths: ['./utils'] }, + ]); + expect(result.importMap['@studiometa/js-toolkit']).toBe( + 'https://esm.sh/@studiometa/js-toolkit', + ); + expect(result.importMap['@studiometa/js-toolkit/utils']).toBe( + 'https://esm.sh/@studiometa/js-toolkit/utils', + ); + }); + + it('detects all subpaths from node_modules exports when subpaths is true', async () => { + // @studiometa/js-toolkit is installed with exports { ".", "./utils" }. + const result = await resolveDependencies([ + { specifier: '@studiometa/js-toolkit', subpaths: true }, + ]); + expect(result.importMap['@studiometa/js-toolkit']).toBe( + 'https://esm.sh/@studiometa/js-toolkit', + ); + expect(result.importMap['@studiometa/js-toolkit/utils']).toBe( + 'https://esm.sh/@studiometa/js-toolkit/utils', + ); + }); + + it('falls back to the npm registry when the package is not on disk', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ + 'dist-tags': { latest: '1.0.0' }, + versions: { '1.0.0': { exports: { '.': {}, './sub': {} } } }, + }), + })), + ); + + try { + const result = await resolveDependencies([ + { specifier: 'not-installed-pkg', subpaths: true }, + ]); + expect(result.importMap['not-installed-pkg']).toBe('https://esm.sh/not-installed-pkg'); + expect(result.importMap['not-installed-pkg/sub']).toBe( + 'https://esm.sh/not-installed-pkg/sub', + ); + } finally { + vi.unstubAllGlobals(); + } + }); + }); + describe('bare npm source rejection', () => { - it('warns and falls back to esm.sh for bare npm source', () => { + it('warns and falls back to esm.sh for bare npm source', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = resolveDependencies([{ specifier: 'morphdom', source: 'morphdom' }]); + const result = await resolveDependencies([{ specifier: 'morphdom', source: 'morphdom' }]); expect(result.importMap).toEqual({ morphdom: 'https://esm.sh/morphdom', }); @@ -222,10 +336,10 @@ describe('resolveDependencies', () => { warn.mockRestore(); }); - it('warns and falls back for scoped npm source', () => { + it('warns and falls back for scoped npm source', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = resolveDependencies([ + const result = await resolveDependencies([ { specifier: '@studiometa/js-toolkit', source: '@studiometa/js-toolkit' }, ]); expect(result.importMap).toEqual({ @@ -236,7 +350,7 @@ describe('resolveDependencies', () => { warn.mockRestore(); }); - it('uses inferred version when falling back from bare npm source', () => { + it('uses inferred version when falling back from bare npm source', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const tmpDir = join('/tmp', 'test-resolve-deps-npm-' + Date.now()); mkdirSync(tmpDir, { recursive: true }); @@ -249,7 +363,7 @@ describe('resolveDependencies', () => { ); try { - const result = resolveDependencies( + const result = await resolveDependencies( [{ specifier: 'morphdom', source: 'morphdom' }], pkgPath, ); @@ -264,8 +378,10 @@ describe('resolveDependencies', () => { }); }); - it('resolves bundle dependency for local TypeScript source', () => { - const result = resolveDependencies([{ specifier: '@studiometa/ui', source: '../ui/**/*.ts' }]); + it('resolves bundle dependency for local TypeScript source', async () => { + const result = await resolveDependencies([ + { specifier: '@studiometa/ui', source: '../ui/**/*.ts' }, + ]); expect(result.importMap).toEqual({ '@studiometa/ui': '/static/deps/@studiometa/ui/index.js', }); @@ -277,8 +393,8 @@ describe('resolveDependencies', () => { }); }); - it('resolves bundle dependency for relative path source', () => { - const result = resolveDependencies([{ specifier: 'demo-lib', source: './lib/index.ts' }]); + it('resolves bundle dependency for relative path source', async () => { + const result = await resolveDependencies([{ specifier: 'demo-lib', source: './lib/index.ts' }]); expect(result.importMap).toEqual({ 'demo-lib': '/static/deps/demo-lib/index.js', }); @@ -290,8 +406,8 @@ describe('resolveDependencies', () => { }); }); - it('resolves bundle dependency for absolute path source', () => { - const result = resolveDependencies([ + it('resolves bundle dependency for absolute path source', async () => { + const result = await resolveDependencies([ { specifier: 'demo-lib', source: '/home/user/lib/index.ts' }, ]); expect(result.importMap).toEqual({ @@ -300,8 +416,8 @@ describe('resolveDependencies', () => { expect(result.selfHosted).toHaveLength(1); }); - it('passes entry field for bundle dependencies', () => { - const result = resolveDependencies([ + it('passes entry field for bundle dependencies', async () => { + const result = await resolveDependencies([ { specifier: '@studiometa/ui', source: '../ui/**/*.ts', @@ -311,10 +427,10 @@ describe('resolveDependencies', () => { expect(result.selfHosted[0].entry).toBe('../ui/index.ts'); }); - it('handles mixed dependencies', () => { + it('handles mixed dependencies', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = resolveDependencies([ + const result = await resolveDependencies([ 'deepmerge', '@studiometa/js-toolkit/utils', { specifier: 'morphdom', source: 'morphdom' }, // bare npm → falls back to esm.sh @@ -328,15 +444,15 @@ describe('resolveDependencies', () => { warn.mockRestore(); }); - it('returns empty results for empty input', () => { - const result = resolveDependencies([]); + it('returns empty results for empty input', async () => { + const result = await resolveDependencies([]); expect(result.importMap).toEqual({}); expect(result.selfHosted).toEqual([]); }); describe('esmSh options', () => { - it('appends ?bundle=false when bundle is false', () => { - const result = resolveDependencies([ + it('appends ?bundle=false when bundle is false', async () => { + const result = await resolveDependencies([ { specifier: '@studiometa/js-toolkit', esmSh: { bundle: false } }, ]); expect(result.importMap).toEqual({ @@ -344,8 +460,8 @@ describe('resolveDependencies', () => { }); }); - it('uses * prefix for external option', () => { - const result = resolveDependencies([ + it('uses * prefix for external option', async () => { + const result = await resolveDependencies([ { specifier: 'preact-render-to-string', version: '5.2.0', esmSh: { external: true } }, ]); expect(result.importMap).toEqual({ @@ -353,8 +469,8 @@ describe('resolveDependencies', () => { }); }); - it('combines multiple options', () => { - const result = resolveDependencies([ + it('combines multiple options', async () => { + const result = await resolveDependencies([ { specifier: 'swr', version: '2.0.0', @@ -366,7 +482,7 @@ describe('resolveDependencies', () => { ); }); - it('infers version from package.json with esmSh options', () => { + it('infers version from package.json with esmSh options', async () => { const tmpDir = join('/tmp', 'test-resolve-deps-esmsh-' + Date.now()); mkdirSync(tmpDir, { recursive: true }); const pkgPath = join(tmpDir, 'package.json'); @@ -378,7 +494,7 @@ describe('resolveDependencies', () => { ); try { - const result = resolveDependencies( + const result = await resolveDependencies( [{ specifier: '@studiometa/js-toolkit', esmSh: { bundle: false } }], pkgPath, ); @@ -390,10 +506,10 @@ describe('resolveDependencies', () => { } }); - it('applies esmSh options on bare npm source fallback', () => { + it('applies esmSh options on bare npm source fallback', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = resolveDependencies([ + const result = await resolveDependencies([ { specifier: 'morphdom', source: 'morphdom', esmSh: { bundle: false } }, ]); expect(result.importMap).toEqual({ @@ -403,10 +519,10 @@ describe('resolveDependencies', () => { warn.mockRestore(); }); - it('applies external prefix on bare npm source fallback', () => { + it('applies external prefix on bare npm source fallback', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = resolveDependencies([ + const result = await resolveDependencies([ { specifier: 'morphdom', source: 'morphdom', esmSh: { external: true } }, ]); expect(result.importMap).toEqual({ @@ -416,8 +532,8 @@ describe('resolveDependencies', () => { warn.mockRestore(); }); - it('ignores esmSh options for self-hosted dependencies', () => { - const result = resolveDependencies([ + it('ignores esmSh options for self-hosted dependencies', async () => { + const result = await resolveDependencies([ { specifier: 'demo-lib', source: './lib/index.ts', esmSh: { bundle: false } as any }, ]); expect(result.importMap).toEqual({ @@ -427,8 +543,8 @@ describe('resolveDependencies', () => { }); describe('multi-entry dependencies', () => { - it('resolves one import-map entry per subpath and a single self-hosted build', () => { - const result = resolveDependencies([ + it('resolves one import-map entry per subpath and a single self-hosted build', async () => { + const result = await resolveDependencies([ { specifier: '@studiometa/ui', source: '../ui/**/*.ts', @@ -472,8 +588,8 @@ describe('resolveDependencies', () => { ]); }); - it('supports side-effect subpaths (e.g. autoload) alongside a barrel', () => { - const result = resolveDependencies([ + it('supports side-effect subpaths (e.g. autoload) alongside a barrel', async () => { + const result = await resolveDependencies([ { specifier: '@studiometa/ui-autoload', entries: { @@ -493,10 +609,10 @@ describe('resolveDependencies', () => { expect(result.selfHosted[0].entries).toHaveLength(3); }); - it('skips non-local entry sources with a warning', () => { + it('skips non-local entry sources with a warning', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = resolveDependencies([ + const result = await resolveDependencies([ { specifier: '@studiometa/ui', entries: { @@ -515,10 +631,10 @@ describe('resolveDependencies', () => { warn.mockRestore(); }); - it('produces no self-hosted build when every entry is skipped', () => { + it('produces no self-hosted build when every entry is skipped', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = resolveDependencies([ + const result = await resolveDependencies([ { specifier: '@studiometa/ui', entries: { '.': 'bare-npm-name' }, @@ -532,9 +648,102 @@ describe('resolveDependencies', () => { }); }); + describe('local self-hosted subpath auto-detection', () => { + it('derives entries from exports and aliases `.js`-suffix keys to one build', async () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'playground-subpaths-')); + writeFileSync( + join(fixtureDir, 'package.json'), + JSON.stringify({ + name: 'demo', + exports: { + '.': './index.ts', + './Foo': './Foo.ts', + './Foo.js': './Foo.ts', + }, + }), + ); + + try { + // configDir derives from packageJsonPath's directory (the fixture dir), + // so a source root of './' lands on the fixture package. + const result = await resolveDependencies( + [{ specifier: 'demo', source: './**/*.ts', subpaths: true }], + join(fixtureDir, 'package.json'), + ); + + expect(result.selfHosted).toHaveLength(1); + const dep = result.selfHosted[0]; + + // `.` → index entry, `./Foo` → Foo entry; `./Foo.js` is NOT a separate entry. + expect(dep.entries).toEqual([ + { + subpath: '.', + specifier: 'demo', + name: 'index', + source: join(fixtureDir, 'index.ts'), + importMapValue: '/static/deps/demo/index.js', + }, + { + subpath: './Foo', + specifier: 'demo/Foo', + name: 'Foo', + source: join(fixtureDir, 'Foo.ts'), + importMapValue: '/static/deps/demo/Foo.js', + }, + ]); + + // `demo/Foo` and `demo/Foo.js` both resolve to the same built file. + expect(result.importMap['demo/Foo']).toBe('/static/deps/demo/Foo.js'); + expect(result.importMap['demo/Foo.js']).toBe('/static/deps/demo/Foo.js'); + expect(result.importMap['demo/Foo']).toBe(result.importMap['demo/Foo.js']); + + expect(dep.aliasSpecifiers).toContain('demo/Foo.js'); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }); + + it('lets explicit entries win over subpaths (no derivation)', async () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'playground-subpaths-explicit-')); + writeFileSync( + join(fixtureDir, 'package.json'), + JSON.stringify({ + name: 'demo', + exports: { + '.': './index.ts', + './Foo': './Foo.ts', + }, + }), + ); + + try { + const result = await resolveDependencies( + [ + { + specifier: 'demo', + source: './**/*.ts', + subpaths: true, + entries: { '.': './index.ts' }, + }, + ], + join(fixtureDir, 'package.json'), + ); + + // Only the explicit entry is used; `./Foo` from exports is ignored. + expect(result.importMap).toEqual({ + demo: '/static/deps/demo/index.js', + }); + expect(result.selfHosted[0].entries).toHaveLength(1); + expect(result.selfHosted[0].aliasSpecifiers).toBeUndefined(); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }); + }); + describe('self-hosted paths have no publicPath prefix', () => { - it('self-hosted entries always get bare paths without prefix', () => { - const result = resolveDependencies([ + it('self-hosted entries always get bare paths without prefix', async () => { + const result = await resolveDependencies([ { specifier: '@studiometa/ui', source: '../ui/**/*.ts' }, ]); expect(result.importMap).toEqual({ diff --git a/packages/playground/src/lib/utils/resolve-dependencies.ts b/packages/playground/src/lib/utils/resolve-dependencies.ts index 182263c..9ae0eaf 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.ts @@ -1,4 +1,5 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, extname, join, resolve } from 'node:path'; /** * Options passed as query parameters to esm.sh URLs. @@ -88,6 +89,30 @@ export type DependencyConfig = * } */ entries?: Record; + /** + * Auto-detect export subpaths from the package's `exports` field so each + * subpath does not have to be declared by hand. + * + * - `true` → detect **all** subpaths from the package's `exports` map. + * - `string[]` (e.g. `['./utils']`) → an explicit subset; no + * `package.json` read is performed. + * + * Behaviour depends on how the dependency is resolved: + * + * - **esm.sh deps** (no `source`): each detected subpath is added to the + * import map as its own esm.sh URL (same version/query/prefix as the + * base specifier). When `true`, the `exports` map is read from disk + * (`node_modules`) when available, otherwise fetched from the npm + * registry. + * - **Local self-hosted deps** (local `source`): each subpath becomes an + * entry of the multi-entry code-split build; its target `.ts`/`.js` + * file is taken from the `exports` map. Ignored when explicit `entries` + * are already set. + * + * @example true + * @example ['./utils', './utils/css'] + */ + subpaths?: boolean | string[]; /** * Options passed as query parameters to the esm.sh URL. * Only applies to esm.sh-resolved dependencies (ignored when `source` is set). @@ -130,6 +155,12 @@ export interface ResolvedDependency { * `static/deps//` directory. */ entries?: ResolvedDependencyEntry[]; + /** + * Extra import-map specifiers (e.g. `./Foo.js` aliases) that resolve to an + * existing built entry's file. Used by the plugin to prefix them with + * publicPath. + */ + aliasSpecifiers?: string[]; } export interface ResolvedDependencies { @@ -183,6 +214,173 @@ function isLocalSource(source: string): boolean { return source.startsWith('.') || source.startsWith('/') || source.includes('*'); } +/** + * Walk up directories from `fromDir` to locate a package's `package.json` in a + * `node_modules` folder. + * + * `require.resolve('/package.json')` is blocked by the package's `exports` + * field in modern packages, so the file is resolved manually. + * + * @returns The first matching path, or `undefined` when none is found. + */ +export function findPackageJsonOnDisk(pkgName: string, fromDir: string): string | undefined { + const parts = pkgName.split('/'); + let dir = fromDir; + for (;;) { + const candidate = join(dir, 'node_modules', ...parts, 'package.json'); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** + * Read the raw `exports` value of a package — from disk (`node_modules`) when + * available, otherwise from the npm registry. + * + * The returned value is the untouched `exports` field (string, array, or + * object) or `undefined` when it cannot be read. + */ +export async function readPackageExports( + pkgName: string, + version: string | undefined, + fromDir: string, +): Promise { + const diskPath = findPackageJsonOnDisk(pkgName, fromDir); + if (diskPath) { + try { + const pkg = JSON.parse(readFileSync(diskPath, 'utf-8')); + return pkg.exports; + } catch { + // Fall through to the registry. + } + } + + try { + const response = await fetch(`https://registry.npmjs.org/${pkgName}`); + if (!response.ok) { + console.warn( + `[playground] Failed to fetch package metadata for "${pkgName}" from the npm registry ` + + `(status ${response.status}). No subpaths detected.`, + ); + return undefined; + } + const data = await response.json(); + const ver = version && data.versions?.[version] ? version : data['dist-tags']?.latest; + return data.versions?.[ver]?.exports; + } catch (error) { + console.warn( + `[playground] Could not read exports for "${pkgName}" from the npm registry: ${String(error)}. ` + + 'No subpaths detected.', + ); + return undefined; + } +} + +/** + * Extract the list of export subpath keys from a raw `exports` value. + * + * When `exports` is an object whose keys are subpaths (start with `.`), returns + * those keys, excluding `./package.json` and any wildcard (`*`) key. Otherwise + * (string/array/conditions-only object) returns `['.']`. The result is unique. + */ +export function extractSubpathKeys(exports: unknown): string[] { + if (exports && typeof exports === 'object' && !Array.isArray(exports)) { + const keys = Object.keys(exports as Record); + const dotted = keys.filter((key) => key.startsWith('.')); + if (dotted.length > 0) { + const filtered = dotted.filter((key) => key !== './package.json' && !key.includes('*')); + return [...new Set(filtered)]; + } + } + return ['.']; +} + +/** + * Resolve an `exports` entry value to a target file path. + * + * Strings are returned as-is. For conditions objects, `import`, `module`, + * `browser`, then `default` are tried in order; a nested conditions object is + * resolved one level deep with the same order. + */ +export function resolveExportTarget(value: unknown, depth = 0): string | undefined { + if (typeof value === 'string') return value; + if (value && typeof value === 'object' && depth < 2) { + const obj = value as Record; + for (const key of ['import', 'module', 'browser', 'default']) { + if (key in obj) { + const resolved = resolveExportTarget(obj[key], depth + 1); + if (resolved) return resolved; + } + } + } + return undefined; +} + +/** + * Derive multi-entry `entries` (and `.js`-suffix aliases) for a local + * self-hosted dependency from its `package.json` `exports` field. + * + * Subpaths that resolve to the **same** target file are grouped: the group's + * canonical key (the first non-`.js`-suffixed key) becomes the build entry; + * every other key becomes an alias pointing at that entry's file. This avoids + * duplicate entries (and filenames like `Foo.js.js`) for packages that expose + * both `./Foo` and `./Foo.js` mapping to one source file. + */ +export function deriveLocalEntries( + source: string, + subpaths: boolean | string[], + configDir: string, +): { entries: Record; aliases: Record } { + const rootRel = source.includes('*') ? source.slice(0, source.indexOf('*')) : source; + const pkgRoot = resolve(configDir, rootRel); + const pkgJsonPath = join(pkgRoot, 'package.json'); + + let pkg: { exports?: unknown }; + try { + pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); + } catch { + console.warn( + `[playground] Could not read "${pkgJsonPath}" to derive subpaths. No entries derived.`, + ); + return { entries: {}, aliases: {} }; + } + + const exports = pkg.exports as Record | undefined; + const keys = Array.isArray(subpaths) ? subpaths : extractSubpathKeys(exports); + + const allowedExtensions = new Set(['.ts', '.tsx', '.js', '.mjs', '.jsx']); + const byTarget = new Map(); + + for (const key of keys) { + const target = resolveExportTarget(exports?.[key]); + if (!target) { + console.warn( + `[playground] No resolvable export target for subpath "${key}" in "${pkgRoot}". Skipped.`, + ); + continue; + } + const abs = resolve(pkgRoot, target); + if (!allowedExtensions.has(extname(abs))) continue; + const list = byTarget.get(abs) ?? []; + list.push(key); + byTarget.set(abs, list); + } + + const entries: Record = {}; + const aliases: Record = {}; + for (const [abs, keyList] of byTarget) { + const canonical = keyList.find((key) => !key.endsWith('.js')) ?? keyList[0]; + entries[canonical] = abs; + for (const key of keyList) { + if (key !== canonical) aliases[key] = canonical; + } + } + + return { entries, aliases }; +} + /** * Serialize `EsmShOptions` into a query string (without leading `?`). * Returns an empty string when no options produce query params. @@ -223,13 +421,15 @@ export function serializeEsmShOptions(options: EsmShOptions): string { * @param dependencies - Array of dependency configurations * @param packageJsonPath - Optional path to consumer's package.json for version inference */ -export function resolveDependencies( +export async function resolveDependencies( dependencies: DependencyConfig[], packageJsonPath?: string, -): ResolvedDependencies { +): Promise { const importMap: Record = {}; const selfHosted: ResolvedDependency[] = []; + const configDir = packageJsonPath ? dirname(packageJsonPath) : process.cwd(); + // Try to read versions from consumer's package.json let pkgVersions: Record = {}; if (packageJsonPath) { @@ -244,9 +444,17 @@ export function resolveDependencies( for (const dep of dependencies) { const config = typeof dep === 'string' ? { specifier: dep } : dep; const { specifier, version, source, entry } = config; - const entries = 'entries' in config ? config.entries : undefined; - const esmSh = 'esmSh' in config ? config.esmSh : undefined; + const subpaths = 'subpaths' in config ? config.subpaths : undefined; + + // `aliasSubpaths` maps a `.js`-suffix alias subpath to its canonical subpath. + let entries = 'entries' in config ? config.entries : undefined; + let aliasSubpaths: Record = {}; + if (!entries && subpaths && source && isLocalSource(source)) { + const derived = deriveLocalEntries(source, subpaths, configDir); + entries = derived.entries; + aliasSubpaths = derived.aliases; + } if (entries && Object.keys(entries).length > 0) { // Multi-entry: one code-split build, shared chunks emitted once. @@ -281,12 +489,24 @@ export function resolveDependencies( if (resolvedEntries.length > 0) { const base = resolvedEntries.find((e) => e.subpath === '.') ?? resolvedEntries[0]; + + // Emit `.js`-suffix alias specifiers that reuse an existing entry's file. + const aliasSpecifiers: string[] = []; + for (const [aliasSub, canonicalSub] of Object.entries(aliasSubpaths)) { + const canonicalEntry = resolvedEntries.find((e) => e.subpath === canonicalSub); + if (!canonicalEntry) continue; + const aliasSpecifier = aliasSub === '.' ? specifier : `${specifier}${aliasSub.slice(1)}`; + importMap[aliasSpecifier] = canonicalEntry.importMapValue; + aliasSpecifiers.push(aliasSpecifier); + } + selfHosted.push({ specifier, importMapValue: base.importMapValue, type: 'bundle', source, entries: resolvedEntries, + ...(aliasSpecifiers.length ? { aliasSpecifiers } : {}), }); } @@ -303,8 +523,22 @@ export function resolveDependencies( const versionedPkg = resolvedVersion ? `${pkgName}@${resolvedVersion}` : pkgName; const prefix = esmSh?.external ? '*' : ''; const query = esmSh ? serializeEsmShOptions(esmSh) : ''; - const esmUrl = `https://esm.sh/${prefix}${versionedPkg}${subpath ?? ''}${query ? `?${query}` : ''}`; - importMap[specifier] = esmUrl; + const buildEsmUrl = (suffix: string) => + `https://esm.sh/${prefix}${versionedPkg}${suffix}${query ? `?${query}` : ''}`; + + importMap[specifier] = buildEsmUrl(subpath ?? ''); + + if (subpaths) { + const subpathKeys = + subpaths === true + ? extractSubpathKeys(await readPackageExports(pkgName, resolvedVersion, configDir)) + : subpaths; + for (const key of subpathKeys) { + if (key === '.') continue; + const suffix = key.slice(1); + importMap[pkgName + suffix] = buildEsmUrl(suffix); + } + } } else if (!isLocalSource(source)) { // Bare npm package name used as source — warn and fall back to esm.sh console.warn( From 14ea5a19e011b3feb9750e414635b7792e48b095 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 6 Aug 2026 09:29:38 +0200 Subject: [PATCH 2/3] Update changelog for dependency subpath auto-detection Co-authored-by: Claude Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Botz34NmFLdgRgm2QJpKkZ --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd4d75d..1b00e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Auto-detect a dependency's export subpaths via the new `subpaths` option on `DependencyConfig`. For esm.sh dependencies, `subpaths: true` reads the package's `package.json` `exports` (disk `node_modules` first, npm registry fallback) and adds each subpath to the import map as its own esm.sh URL, while `subpaths: ['./utils']` uses an explicit list. For local self-hosted dependencies, `subpaths: true` derives the multi-entry code-split `entries` from `exports` (each subpath maps to its `.ts` target), and is ignored when an explicit `entries` map is set - Support multiple entry points for a single self-hosted dependency via the new `entries` map on `DependencyConfig`. All subpaths of a workspace package (e.g. a barrel `.` and a lazy `./manifest`) are built together in one code-split tsdown build, so modules shared between entries are emitted once as a shared chunk and referenced by every entry — a single runtime instance, eliminating the singleton/identity hazard of bundling each subpath separately ([#74](https://github.com/studiometa/playground/pull/74)) ## v0.3.11 - 2026.08.01 From 85520b4085993e5d952fdc470c4166cce46a5af4 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 6 Aug 2026 09:30:28 +0200 Subject: [PATCH 3/3] Add PR reference to changelog entry Co-authored-by: Claude Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Botz34NmFLdgRgm2QJpKkZ --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b00e7a..69de845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Auto-detect a dependency's export subpaths via the new `subpaths` option on `DependencyConfig`. For esm.sh dependencies, `subpaths: true` reads the package's `package.json` `exports` (disk `node_modules` first, npm registry fallback) and adds each subpath to the import map as its own esm.sh URL, while `subpaths: ['./utils']` uses an explicit list. For local self-hosted dependencies, `subpaths: true` derives the multi-entry code-split `entries` from `exports` (each subpath maps to its `.ts` target), and is ignored when an explicit `entries` map is set +- Auto-detect a dependency's export subpaths via the new `subpaths` option on `DependencyConfig`. For esm.sh dependencies, `subpaths: true` reads the package's `package.json` `exports` (disk `node_modules` first, npm registry fallback) and adds each subpath to the import map as its own esm.sh URL, while `subpaths: ['./utils']` uses an explicit list. For local self-hosted dependencies, `subpaths: true` derives the multi-entry code-split `entries` from `exports` (each subpath maps to its `.ts` target), and is ignored when an explicit `entries` map is set ([#75](https://github.com/studiometa/playground/pull/75), [5c0c244](https://github.com/studiometa/playground/commit/5c0c244)) - Support multiple entry points for a single self-hosted dependency via the new `entries` map on `DependencyConfig`. All subpaths of a workspace package (e.g. a barrel `.` and a lazy `./manifest`) are built together in one code-split tsdown build, so modules shared between entries are emitted once as a shared chunk and referenced by every entry — a single runtime instance, eliminating the singleton/identity hazard of bundling each subpath separately ([#74](https://github.com/studiometa/playground/pull/74)) ## v0.3.11 - 2026.08.01