From e06c5b61c6177c4ab3a74561625054b7d8d6740d Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 1 Apr 2026 13:25:04 +0000 Subject: [PATCH 1/4] Add typed esmSh options to DependencyConfig for esm.sh query params Introduce EsmShOptions interface with typed fields for all esm.sh query parameters (bundle, standalone, dev, deps, alias, target, etc.). Dependencies can now pass esmSh options without specifying a version, which is still inferred from package.json. Closes #68 Co-authored-by: Claude --- .../lib/utils/resolve-dependencies.test.ts | 126 +++++++++++++++++- .../src/lib/utils/resolve-dependencies.ts | 81 ++++++++++- 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/packages/playground/src/lib/utils/resolve-dependencies.test.ts b/packages/playground/src/lib/utils/resolve-dependencies.test.ts index 9e30de0..2cec5c0 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.test.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.test.ts @@ -1,7 +1,65 @@ import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { describe, it, expect, vi } from 'vitest'; -import { resolveDependencies, getPackageName, getSubpath } from './resolve-dependencies.js'; +import { + resolveDependencies, + getPackageName, + getSubpath, + serializeEsmShOptions, +} from './resolve-dependencies.js'; + +describe('serializeEsmShOptions', () => { + it('returns empty string for empty options', () => { + expect(serializeEsmShOptions({})).toBe(''); + }); + + it('serializes bundle=false', () => { + expect(serializeEsmShOptions({ bundle: false })).toBe('bundle=false'); + }); + + it('does not serialize bundle=true', () => { + expect(serializeEsmShOptions({ bundle: true })).toBe(''); + }); + + it('serializes boolean flags', () => { + expect(serializeEsmShOptions({ standalone: true })).toBe('standalone'); + expect(serializeEsmShOptions({ raw: true })).toBe('raw'); + expect(serializeEsmShOptions({ dev: true })).toBe('dev'); + expect(serializeEsmShOptions({ noDts: true })).toBe('no-dts'); + expect(serializeEsmShOptions({ keepNames: true })).toBe('keep-names'); + expect(serializeEsmShOptions({ ignoreAnnotations: true })).toBe('ignore-annotations'); + }); + + it('serializes target', () => { + expect(serializeEsmShOptions({ target: 'es2022' })).toBe('target=es2022'); + }); + + it('serializes array options', () => { + expect(serializeEsmShOptions({ exports: ['foo', 'bar'] })).toBe('exports=foo,bar'); + expect(serializeEsmShOptions({ deps: ['react@19', 'react-dom@19'] })).toBe( + 'deps=react@19,react-dom@19', + ); + expect(serializeEsmShOptions({ conditions: ['custom1', 'custom2'] })).toBe( + 'conditions=custom1,custom2', + ); + }); + + it('serializes alias', () => { + expect(serializeEsmShOptions({ alias: { react: 'preact/compat' } })).toBe( + 'alias=react:preact/compat', + ); + }); + + it('combines multiple options', () => { + expect(serializeEsmShOptions({ dev: true, bundle: false, target: 'es2022' })).toBe( + 'bundle=false&dev&target=es2022', + ); + }); + + it('ignores external (handled separately)', () => { + expect(serializeEsmShOptions({ external: true })).toBe(''); + }); +}); describe('getPackageName', () => { it('returns unscoped package name', () => { @@ -272,6 +330,72 @@ describe('resolveDependencies', () => { expect(result.selfHosted).toEqual([]); }); + describe('esmSh options', () => { + it('appends ?bundle=false when bundle is false', () => { + const result = resolveDependencies([ + { specifier: '@studiometa/js-toolkit', esmSh: { bundle: false } }, + ]); + expect(result.importMap).toEqual({ + '@studiometa/js-toolkit': 'https://esm.sh/@studiometa/js-toolkit?bundle=false', + }); + }); + + it('uses * prefix for external option', () => { + const result = resolveDependencies([ + { specifier: 'preact-render-to-string', version: '5.2.0', esmSh: { external: true } }, + ]); + expect(result.importMap).toEqual({ + 'preact-render-to-string': 'https://esm.sh/*preact-render-to-string@5.2.0', + }); + }); + + it('combines multiple options', () => { + const result = resolveDependencies([ + { + specifier: 'swr', + version: '2.0.0', + esmSh: { dev: true, deps: ['react@19'], target: 'es2022' }, + }, + ]); + expect(result.importMap['swr']).toBe( + 'https://esm.sh/swr@2.0.0?dev&target=es2022&deps=react@19', + ); + }); + + it('infers version from package.json with esmSh options', () => { + const tmpDir = join('/tmp', 'test-resolve-deps-esmsh-' + Date.now()); + mkdirSync(tmpDir, { recursive: true }); + const pkgPath = join(tmpDir, 'package.json'); + writeFileSync( + pkgPath, + JSON.stringify({ + dependencies: { '@studiometa/js-toolkit': '^3.4.0' }, + }), + ); + + try { + const result = resolveDependencies( + [{ specifier: '@studiometa/js-toolkit', esmSh: { bundle: false } }], + pkgPath, + ); + expect(result.importMap).toEqual({ + '@studiometa/js-toolkit': 'https://esm.sh/@studiometa/js-toolkit@3.4.0?bundle=false', + }); + } finally { + rmSync(tmpDir, { recursive: true }); + } + }); + + it('ignores esmSh options for self-hosted dependencies', () => { + const result = resolveDependencies([ + { specifier: 'demo-lib', source: './lib/index.ts', esmSh: { bundle: false } as any }, + ]); + expect(result.importMap).toEqual({ + 'demo-lib': '/static/deps/demo-lib/index.js', + }); + }); + }); + describe('self-hosted paths have no publicPath prefix', () => { it('self-hosted entries always get bare paths without prefix', () => { const result = resolveDependencies([ diff --git a/packages/playground/src/lib/utils/resolve-dependencies.ts b/packages/playground/src/lib/utils/resolve-dependencies.ts index 44c534a..108d6b3 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.ts @@ -1,5 +1,39 @@ import { readFileSync } from 'node:fs'; +/** + * Options passed as query parameters to esm.sh URLs. + * + * @see https://esm.sh/#docs + */ +export interface EsmShOptions { + /** Disable default bundling of sub-modules (`?bundle=false`) */ + bundle?: boolean; + /** Bundle with all external deps into a single file (`?standalone`) */ + standalone?: boolean; + /** Import raw source without transformation (`?raw`) */ + raw?: boolean; + /** Development build with process.env.NODE_ENV="development" (`?dev`) */ + dev?: boolean; + /** Disable X-TypeScript-Types header (`?no-dts`) */ + noDts?: boolean; + /** Tree-shake: only export specific members (`?exports=foo,bar`) */ + exports?: string[]; + /** Pin dependency versions (`?deps=react@19,react-dom@19`) */ + deps?: string[]; + /** Alias dependencies (`?alias=react:preact/compat`) */ + alias?: Record; + /** Esbuild target (`?target=es2022`) */ + target?: string; + /** Esbuild conditions (`?conditions=custom1,custom2`) */ + conditions?: string[]; + /** Keep original names (`?keep-names`) */ + keepNames?: boolean; + /** Ignore side-effect annotations (`?ignore-annotations`) */ + ignoreAnnotations?: boolean; + /** Mark all deps as external for import map resolution (`*` prefix) */ + external?: boolean; +} + /** * A single dependency for the playground script editor. * @@ -27,6 +61,14 @@ export type DependencyConfig = source?: string; /** Explicit entry point (useful when source is a glob pattern) */ entry?: string; + /** + * Options passed as query parameters to the esm.sh URL. + * Only applies to esm.sh-resolved dependencies (ignored when `source` is set). + * + * @example { bundle: false } + * @example { dev: true, exports: ['foo', 'bar'] } + */ + esmSh?: EsmShOptions; }; export interface ResolvedDependency { @@ -88,6 +130,35 @@ function isLocalSource(source: string): boolean { return source.startsWith('.') || source.startsWith('/') || source.includes('*'); } +/** + * Serialize `EsmShOptions` into a query string (without leading `?`). + * Returns an empty string when no options produce query params. + * The `external` option is handled separately (via `*` URL prefix). + */ +export function serializeEsmShOptions(options: EsmShOptions): string { + const params: string[] = []; + + if (options.bundle === false) params.push('bundle=false'); + if (options.standalone) params.push('standalone'); + if (options.raw) params.push('raw'); + if (options.dev) params.push('dev'); + if (options.noDts) params.push('no-dts'); + if (options.keepNames) params.push('keep-names'); + if (options.ignoreAnnotations) params.push('ignore-annotations'); + if (options.target) params.push(`target=${options.target}`); + if (options.exports?.length) params.push(`exports=${options.exports.join(',')}`); + if (options.deps?.length) params.push(`deps=${options.deps.join(',')}`); + if (options.conditions?.length) params.push(`conditions=${options.conditions.join(',')}`); + if (options.alias) { + const aliasStr = Object.entries(options.alias) + .map(([k, v]) => `${k}:${v}`) + .join(','); + if (aliasStr) params.push(`alias=${aliasStr}`); + } + + return params.join('&'); +} + /** * Resolve a list of dependency configs into import map entries and * self-hosted dependency metadata. @@ -121,6 +192,8 @@ export function resolveDependencies( const config = typeof dep === 'string' ? { specifier: dep } : dep; const { specifier, version, source, entry } = config; + const esmSh = 'esmSh' in config ? config.esmSh : undefined; + if (!source) { // esm.sh resolution — split specifier into package name + optional subpath const pkgName = getPackageName(specifier); @@ -129,7 +202,9 @@ export function resolveDependencies( const resolvedVersion = version ?? (inferredVersion ? cleanVersion(inferredVersion) : undefined); const versionedPkg = resolvedVersion ? `${pkgName}@${resolvedVersion}` : pkgName; - const esmUrl = `https://esm.sh/${versionedPkg}${subpath ?? ''}`; + const prefix = esmSh?.external ? '*' : ''; + const query = esmSh ? serializeEsmShOptions(esmSh) : ''; + const esmUrl = `https://esm.sh/${prefix}${versionedPkg}${subpath ?? ''}${query ? `?${query}` : ''}`; importMap[specifier] = esmUrl; } else if (!isLocalSource(source)) { // Bare npm package name used as source — warn and fall back to esm.sh @@ -144,7 +219,9 @@ export function resolveDependencies( const resolvedVersion = version ?? (inferredVersion ? cleanVersion(inferredVersion) : undefined); const versionedPkg = resolvedVersion ? `${pkgName}@${resolvedVersion}` : pkgName; - const esmUrl = `https://esm.sh/${versionedPkg}${subpath ?? ''}`; + const prefix = esmSh?.external ? '*' : ''; + const query = esmSh ? serializeEsmShOptions(esmSh) : ''; + const esmUrl = `https://esm.sh/${prefix}${versionedPkg}${subpath ?? ''}${query ? `?${query}` : ''}`; importMap[specifier] = esmUrl; } else { // Local source — bundle with tsdown → single .js + .d.ts From a5e5e836ae841a3bf58dce1d88174a2438fc2dbe Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 1 Apr 2026 13:30:36 +0000 Subject: [PATCH 2/4] Disable esm.sh bundling for @studiometa/js-toolkit in demo Co-authored-by: Claude --- packages/demo/meta.config.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/demo/meta.config.js b/packages/demo/meta.config.js index a10e038..a4a770b 100644 --- a/packages/demo/meta.config.js +++ b/packages/demo/meta.config.js @@ -13,7 +13,10 @@ export default defineWebpackConfig({ tailwindcss: true, syncColorScheme: true, dependencies: [ - '@studiometa/js-toolkit', + { + specifier: '@studiometa/js-toolkit', + esmSh: { bundle: false }, + }, { specifier: 'demo-lib', source: './lib/**/*.ts', From 72b2054c8d9a02d8f0e227733b7e820dccbdb9a3 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 1 Apr 2026 13:35:22 +0000 Subject: [PATCH 3/4] Improve resolve-dependencies test coverage to 100% Co-authored-by: Claude --- .../lib/utils/resolve-dependencies.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/playground/src/lib/utils/resolve-dependencies.test.ts b/packages/playground/src/lib/utils/resolve-dependencies.test.ts index 2cec5c0..ebcf635 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.test.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.test.ts @@ -59,6 +59,10 @@ describe('serializeEsmShOptions', () => { it('ignores external (handled separately)', () => { expect(serializeEsmShOptions({ external: true })).toBe(''); }); + + it('skips empty alias object', () => { + expect(serializeEsmShOptions({ alias: {} })).toBe(''); + }); }); describe('getPackageName', () => { @@ -386,6 +390,32 @@ describe('resolveDependencies', () => { } }); + it('applies esmSh options on bare npm source fallback', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = resolveDependencies([ + { specifier: 'morphdom', source: 'morphdom', esmSh: { bundle: false } }, + ]); + expect(result.importMap).toEqual({ + morphdom: 'https://esm.sh/morphdom?bundle=false', + }); + + warn.mockRestore(); + }); + + it('applies external prefix on bare npm source fallback', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = resolveDependencies([ + { specifier: 'morphdom', source: 'morphdom', esmSh: { external: true } }, + ]); + expect(result.importMap).toEqual({ + morphdom: 'https://esm.sh/*morphdom', + }); + + warn.mockRestore(); + }); + it('ignores esmSh options for self-hosted dependencies', () => { const result = resolveDependencies([ { specifier: 'demo-lib', source: './lib/index.ts', esmSh: { bundle: false } as any }, From 8cb3f287c29c46ab3d72f5ce21cba92394322b9a Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 1 Apr 2026 13:35:35 +0000 Subject: [PATCH 4/4] Update changelog for #68 esmSh options Co-authored-by: Claude --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8fca1..88f2d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Add typed `esmSh` option to `DependencyConfig` for controlling esm.sh query parameters per dependency (e.g. `{ bundle: false }` to prevent singleton duplication) ([#69](https://github.com/studiometa/playground/pull/69), [e06c5b6](https://github.com/studiometa/playground/commit/e06c5b6)) + +### Fixed + +- Disable esm.sh bundling for `@studiometa/js-toolkit` in demo to fix singleton service duplication ([#69](https://github.com/studiometa/playground/pull/69), [a5e5e83](https://github.com/studiometa/playground/commit/a5e5e83)) + ## v0.3.6 - 2026.03.23 ### Added