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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/demo/meta.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
156 changes: 155 additions & 1 deletion packages/playground/src/lib/utils/resolve-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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([
Expand Down
81 changes: 79 additions & 2 deletions packages/playground/src/lib/utils/resolve-dependencies.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** 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.
*
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading