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 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', diff --git a/packages/playground/src/lib/utils/resolve-dependencies.test.ts b/packages/playground/src/lib/utils/resolve-dependencies.test.ts index 9e30de0..ebcf635 100644 --- a/packages/playground/src/lib/utils/resolve-dependencies.test.ts +++ b/packages/playground/src/lib/utils/resolve-dependencies.test.ts @@ -1,7 +1,69 @@ 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(''); + }); + + it('skips empty alias object', () => { + expect(serializeEsmShOptions({ alias: {} })).toBe(''); + }); +}); describe('getPackageName', () => { it('returns unscoped package name', () => { @@ -272,6 +334,98 @@ 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('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 }, + ]); + 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