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 @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

## v0.3.4 - 2026.03.12

### Changed

- Move import map `publicPath` prefixing into `PlaygroundDependenciesPlugin`, automatically inferring it from webpack's `output.publicPath` ([#61](https://github.com/studiometa/playground/pull/61), [fe758c0](https://github.com/studiometa/playground/commit/fe758c0))

### Fixed

- Fix TypeScript error by accepting function type for webpack `output.publicPath` ([#61](https://github.com/studiometa/playground/pull/61), [319ec0b](https://github.com/studiometa/playground/commit/319ec0b))

## v0.3.3 - 2026.03.10

### Fixed
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@studiometa/playground-root",
"version": "0.3.3",
"version": "0.3.4",
"description": "A packaged web development playground",
"type": "module",
"private": true,
Expand Down
2 changes: 1 addition & 1 deletion packages/demo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@studiometa/playground-demo",
"version": "0.3.3",
"version": "0.3.4",
"type": "module",
"private": true,
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/playground-preview/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@studiometa/playground-preview",
"version": "0.3.3",
"version": "0.3.4",
"type": "module",
"description": "A web component to embed @studiometa/playground previews anywhere",
"publishConfig": {
Expand Down
2 changes: 1 addition & 1 deletion packages/playground/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@studiometa/playground",
"version": "0.3.3",
"version": "0.3.4",
"type": "module",
"publishConfig": {
"access": "public"
Expand Down
195 changes: 157 additions & 38 deletions packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { PlaygroundDependenciesPlugin } from './PlaygroundDependenciesPlugin.js';
import type { ResolvedDependency } from '../utils/resolve-dependencies.js';

describe('PlaygroundDependenciesPlugin', () => {
const plugin = new PlaygroundDependenciesPlugin([], '/tmp');
Expand Down Expand Up @@ -31,44 +32,6 @@ describe('PlaygroundDependenciesPlugin', () => {
});
});

describe('resolvePublicPath', () => {
const resolvePublicPath = (publicPath: string | undefined, webpackPublicPath?: string) => {
const p = new PlaygroundDependenciesPlugin([], '/tmp', publicPath);
const fakeCompiler = {
options: { output: { publicPath: webpackPublicPath ?? 'auto' } },
};
return (p as any).resolvePublicPath(fakeCompiler);
};

it('uses explicit publicPath when provided', () => {
expect(resolvePublicPath('/play')).toBe('/play');
});

it('strips trailing slash from explicit publicPath', () => {
expect(resolvePublicPath('/play/')).toBe('/play');
});

it('infers from webpack publicPath when no explicit publicPath', () => {
expect(resolvePublicPath(undefined, '/play/')).toBe('/play');
});

it('returns empty string when webpack publicPath is "auto"', () => {
expect(resolvePublicPath(undefined, 'auto')).toBe('');
});

it('returns empty string when webpack publicPath is "/"', () => {
expect(resolvePublicPath(undefined, '/')).toBe('');
});

it('returns empty string when no publicPath at all', () => {
expect(resolvePublicPath(undefined)).toBe('');
});

it('prefers explicit publicPath over webpack publicPath', () => {
expect(resolvePublicPath('/custom', '/play/')).toBe('/custom');
});
});

describe('importMapKeys', () => {
it('defaults to empty array when not provided', () => {
const p = new PlaygroundDependenciesPlugin([], '/tmp');
Expand All @@ -92,4 +55,160 @@ describe('PlaygroundDependenciesPlugin', () => {
expect(p.importMapKeys).toEqual(['deepmerge']);
});
});

describe('importMap', () => {
it('defaults to undefined when not provided', () => {
const p = new PlaygroundDependenciesPlugin([], '/tmp');
expect(p.importMap).toBeUndefined();
});

it('stores import map reference from constructor', () => {
const importMap = { deepmerge: 'https://esm.sh/deepmerge' };
const p = new PlaygroundDependenciesPlugin([], '/tmp', undefined, [], importMap);
expect(p.importMap).toBe(importMap);
});
});

describe('import map publicPath prefixing', () => {
/**
* Helper that simulates the plugin's `apply()` import map mutation
* by calling the `thisCompilation` hook callback directly.
*/
function applyAndGetImportMap(
deps: ResolvedDependency[],
importMap: Record<string, string>,
publicPath?: string,
webpackPublicPath?: string,
): Record<string, string> {
const p = new PlaygroundDependenciesPlugin(deps, '/tmp', publicPath, [], importMap);

// Minimal fake compiler that captures the thisCompilation tap callback
let compilationCallback: ((compilation: unknown) => void) | undefined;
const fakeCompiler = {
options: { output: { publicPath: webpackPublicPath ?? 'auto' } },
webpack: { Compilation: { PROCESS_ASSETS_STAGE_ADDITIONAL: 0 } },
hooks: {
thisCompilation: {
tap(_name: string, cb: (compilation: unknown) => void) {
compilationCallback = cb;
},
},
},
};

p.apply(fakeCompiler as any);

// Trigger the compilation hook with a minimal fake compilation
compilationCallback?.({
hooks: {
processAssets: { tapAsync() {} },
},
});

return importMap;
}

it('prefixes self-hosted entries with explicit publicPath', () => {
const deps: ResolvedDependency[] = [
{
specifier: '@studiometa/ui',
importMapValue: '/static/deps/@studiometa/ui/index.js',
type: 'bundle',
source: '../ui/**/*.ts',
},
];
const importMap = {
'@studiometa/ui': '/static/deps/@studiometa/ui/index.js',
deepmerge: 'https://esm.sh/deepmerge',
};

applyAndGetImportMap(deps, importMap, '/play');

expect(importMap['@studiometa/ui']).toBe('/play/static/deps/@studiometa/ui/index.js');
expect(importMap.deepmerge).toBe('https://esm.sh/deepmerge');
});

it('infers publicPath from webpack output.publicPath', () => {
const deps: ResolvedDependency[] = [
{
specifier: 'demo-lib',
importMapValue: '/static/deps/demo-lib/index.js',
type: 'bundle',
source: './lib/index.ts',
},
];
const importMap = { 'demo-lib': '/static/deps/demo-lib/index.js' };

applyAndGetImportMap(deps, importMap, undefined, '/app/');

expect(importMap['demo-lib']).toBe('/app/static/deps/demo-lib/index.js');
});

it('does not prefix when no publicPath is resolved', () => {
const deps: ResolvedDependency[] = [
{
specifier: 'demo-lib',
importMapValue: '/static/deps/demo-lib/index.js',
type: 'bundle',
source: './lib/index.ts',
},
];
const importMap = { 'demo-lib': '/static/deps/demo-lib/index.js' };

applyAndGetImportMap(deps, importMap);

expect(importMap['demo-lib']).toBe('/static/deps/demo-lib/index.js');
});

it('does not prefix http URLs', () => {
const deps: ResolvedDependency[] = [
{
specifier: 'deepmerge',
importMapValue: 'https://esm.sh/deepmerge',
type: 'esm-sh',
},
];
const importMap = { deepmerge: 'https://esm.sh/deepmerge' };

applyAndGetImportMap(deps, importMap, '/play');

expect(importMap.deepmerge).toBe('https://esm.sh/deepmerge');
});

it('does nothing when no importMap reference is provided', () => {
const deps: ResolvedDependency[] = [
{
specifier: 'demo-lib',
importMapValue: '/static/deps/demo-lib/index.js',
type: 'bundle',
source: './lib/index.ts',
},
];
const p = new PlaygroundDependenciesPlugin(deps, '/tmp', '/play');

let compilationCallback: ((compilation: unknown) => void) | undefined;
const fakeCompiler = {
options: { output: { publicPath: 'auto' } },
webpack: { Compilation: { PROCESS_ASSETS_STAGE_ADDITIONAL: 0 } },
hooks: {
thisCompilation: {
tap(_name: string, cb: (compilation: unknown) => void) {
compilationCallback = cb;
},
},
},
};

p.apply(fakeCompiler as any);

// Should not throw
compilationCallback?.({
hooks: {
processAssets: { tapAsync() {} },
},
});

expect(p.importMap).toBeUndefined();
});
});
});
45 changes: 23 additions & 22 deletions packages/playground/src/lib/plugins/PlaygroundDependenciesPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import type { Compiler } from 'webpack';
import glob from 'fast-glob';
import type { ResolvedDependency } from '../utils/resolve-dependencies.js';
import { resolvePublicPath } from '../utils/resolve-public-path.js';

/**
* Webpack plugin that processes self-hosted playground dependencies.
Expand All @@ -21,45 +22,45 @@ export class PlaygroundDependenciesPlugin {
* the browser's import map already resolves (e.g. via esm.sh).
*/
importMapKeys: string[];
/**
* Reference to the shared import map object (from twigData). Mutated
* at compilation time to prefix self-hosted entries with the resolved
* public path. This works because HtmlWebpackPlugin renders templates
* during compilation, after this plugin's hooks have run.
*/
importMap?: Record<string, string>;

constructor(
dependencies: ResolvedDependency[],
configDir: string,
publicPath?: string,
importMapKeys?: string[],
importMap?: Record<string, string>,
) {
this.dependencies = dependencies;
this.configDir = configDir;
this.publicPath = publicPath ?? '';
this.importMapKeys = importMapKeys ?? [];
}

/**
* Resolve the effective public path.
* Explicit `publicPath` takes precedence, then webpack's `output.publicPath`.
*/
private resolvePublicPath(compiler: Compiler): string {
if (this.publicPath) {
return this.publicPath.replace(/\/+$/, '');
}

const webpackPublicPath = compiler.options.output?.publicPath;
if (
typeof webpackPublicPath === 'string' &&
webpackPublicPath !== 'auto' &&
webpackPublicPath !== '/'
) {
return webpackPublicPath.replace(/\/+$/, '');
}

return '';
this.importMap = importMap;
}

apply(compiler: Compiler) {
const pluginName = 'PlaygroundDependenciesPlugin';
const publicPath = this.resolvePublicPath(compiler);
const publicPath = resolvePublicPath(this.publicPath, compiler.options);

compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
// Prefix self-hosted import map entries with the resolved publicPath.
// The importMap reference is shared with prototyping's twig data, so
// mutating it here is picked up when HtmlWebpackPlugin renders templates.
if (publicPath && this.importMap) {
for (const dep of this.dependencies) {
const currentValue = this.importMap[dep.specifier];
if (currentValue && !currentValue.startsWith('http')) {
this.importMap[dep.specifier] = publicPath + currentValue;
}
}
}

compilation.hooks.processAssets.tapAsync(
{
name: pluginName,
Expand Down
Loading
Loading