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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

## v0.3.3 - 2026.03.10

### Fixed

- Fix subpath import resolution for esm.sh URLs (e.g. `@studiometa/js-toolkit/utils` now correctly resolves to `esm.sh/@studiometa/js-toolkit@x.y.z/utils`) ([#58](https://github.com/studiometa/playground/pull/58), [2f6994f](https://github.com/studiometa/playground/commit/2f6994f))
- Fix version lookup for subpath imports using the package name instead of the full specifier ([#58](https://github.com/studiometa/playground/pull/58))
- Reject bare npm package names as `source` values with a warning and esm.sh fallback — `source` now only supports local file paths/globs ([#58](https://github.com/studiometa/playground/pull/58))
- Fix bundle duplication by auto-externalizing import map specifiers in self-hosted builds ([#58](https://github.com/studiometa/playground/pull/58))

## v0.3.2 - 2026.03.10

### Added
Expand Down
115 changes: 115 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# CLAUDE.md — @studiometa/playground

## Project overview

A packaged web development playground (code editor + live preview) built as an npm package. Consumers create a playground by importing the `playgroundPreset` into their webpack config.

**Monorepo** with 3 workspace packages:

| Package | Path | Description |
| -------------------------------- | ------------------------------ | ----------------------------------------------- |
| `@studiometa/playground` | `packages/playground/` | Core package — preset, plugins, frontend editor |
| `@studiometa/playground-preview` | `packages/playground-preview/` | Web component for embedding playground previews |
| `@studiometa/playground-demo` | `packages/demo/` | Demo app (dev + test consumer) |

## Tech stack

- **Node.js** 22+ (see `.nvmrc`)
- **Build:** `@studiometa/webpack-config` (webpack 5) with custom presets
- **Frontend JS:** `@studiometa/js-toolkit` (data-attribute driven components)
- **Editor:** `modern-monaco` (Monaco with built-in LSP + Shiki)
- **CSS:** Tailwind CSS v4
- **Testing:** Vitest 4 with `happy-dom` for DOM tests
- **Linting:** oxlint (scripts), stylelint (CSS), prettier (formatting)
- **Self-hosted deps:** tsdown (rolldown + rolldown-plugin-dts)
- **CI:** GitHub Actions — build, test, coverage (Codecov)
- **Publish:** npm with provenance, triggered by version tags

## Commands

```bash
npm run dev # Watch playground + demo dev server
npm run build # Build the playground package
npm run demo:build # Build the demo (runs build first)
npm run demo:preview # Preview the built demo

npm run lint # Run all linters (oxlint + stylelint + prettier)
npm run fix # Auto-fix all linters

npm test # Run tests (vitest run)
npm run test:watch # Watch mode
npm run test:ci # Tests + coverage
```

## Architecture

### Dependency resolution pipeline

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.
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.

### Frontend

- Components in `src/front/js/components/` — `@studiometa/js-toolkit` components
- Templates in `src/front/templates/` — Twig templates
- Import map is injected into the iframe as `<script type="importmap">`

## Git & branching

- **Git Flow:** `main` (production) + `develop` (integration)
- **Branches:** `feature/#<issue>-description`, `release/<version>`, `hotfix/<version>`
- **Push new branches immediately** after creation

## Commit messages

- **Language:** English
- **Style:** imperative, descriptive (no conventional commit prefixes)
- **Co-authorship** is mandatory: `Co-authored-by: Claude <claude@anthropic.com>`
- **Atomic commits:** one logical change per commit

```bash
# ✅ Good
git commit -m "Fix subpath import resolution for esm.sh URLs

Co-authored-by: Claude <claude@anthropic.com>"

# ❌ Bad
git commit -m "feat: fix imports"
```

## Changelog

- File: `CHANGELOG.md` — [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format, **in English**
- Categories: Added, Changed, Fixed, Deprecated, Removed, Security
- Always include PR and commit references: `([#58](url), [2f6994f](url))`
- Add entries to `[Unreleased]` section first
- On release: replace `[Unreleased]` heading with `## vX.Y.Z - YYYY.MM.DD`
- Commit changelog changes separately from code

## Versioning & releases

- **Semantic Versioning:** `MAJOR.MINOR.PATCH`
- Root `package.json` has a `postversion` script that syncs workspace versions
- Release process:
1. Create `release/X.Y.Z` branch from `develop`
2. `npm version X.Y.Z --no-git-tag-version` (bumps all packages)
3. Update `CHANGELOG.md`
4. PR into `main`, merge, tag → triggers npm publish via GitHub Actions

## Testing

- Test files: `*.test.ts` next to source files
- Vitest projects: `playground` (node) and `playground-preview` (happy-dom)
- Always run `npm test` before committing
- Write tests for all new logic — aim for full coverage of utility functions

## Important rules

1. **Never use `git add .`** — stage specific files only
2. **Always run lint + tests** before committing
3. **Ask before merging/finishing** release/hotfix branches
4. **The `source` field** in dependency configs only supports local file paths (not bare npm names)
5. **Self-hosted bundles** auto-externalize import map specifiers to prevent duplication
110 changes: 30 additions & 80 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ playgroundPreset({

## Dependencies

The `dependencies` option provides a declarative way to manage packages available in the script editor. It automatically generates import map entries and, for self-hosted packages, configures the necessary build pipeline.
The `dependencies` option provides a declarative way to manage packages available in the script editor. It automatically generates import map entries and, for self-hosted dependencies, bundles them into `.js` + `.d.ts` files with [tsdown](https://tsdown.dev/).

### esm.sh (default)

Expand All @@ -83,97 +83,23 @@ playgroundPreset({
});
```

### Self-hosted: copy

Copy pre-built `.js` and `.d.ts` files from an npm package in `node_modules`. Useful for packages that already ship ES modules and type declarations:

```js
playgroundPreset({
dependencies: [{ specifier: '@studiometa/js-toolkit', source: '@studiometa/js-toolkit' }],
});
```

### Self-hosted: bundle

Bundle an npm package into a single ESM file with esbuild. Useful for packages with many internal modules or CommonJS dependencies:

```js
playgroundPreset({
dependencies: [{ specifier: 'morphdom', source: 'morphdom', bundle: true }],
});
```

### Self-hosted: TypeScript

Transpile local TypeScript sources to `.js` with esbuild and generate `.d.ts` declarations with [tsgo](https://github.com/nicolo-ribaudo/typescript-go) (`@typescript/native-preview`). The `source` field supports glob patterns for multi-file packages:
**Subpath imports** are fully supported. The version is resolved from the package name and placed correctly in the esm.sh URL:

```js
playgroundPreset({
dependencies: [
{
specifier: '@studiometa/ui',
source: '../ui/**/*.ts',
typescript: true,
entry: '../ui/index.ts', // optional, explicit entry for tsgo
},
'@studiometa/js-toolkit',
'@studiometa/js-toolkit/utils',
// → https://esm.sh/@studiometa/js-toolkit@<version>/utils
],
});
```

> **Note:** TypeScript dependency processing requires `@typescript/native-preview` as a devDependency. Install it with `npm install -D @typescript/native-preview`.

Relative `.js` imports in the generated `.d.ts` files are automatically rewritten to `.d.ts`, so modern-monaco's TypeScript worker can resolve types when fetching over HTTP.

### Combining with `importMap`

The `dependencies` option can be combined with the legacy `importMap` option. Manual `importMap` entries take precedence over entries generated from `dependencies`:

```js
playgroundPreset({
dependencies: ['deepmerge'],
importMap: {
// This overrides the esm.sh URL for deepmerge
deepmerge: '/static/custom/deepmerge.js',
},
});
```

## Dependencies

The `dependencies` option provides a declarative way to manage packages available in the script editor. It automatically generates import map entries and, for self-hosted dependencies, bundles them into `.js` + `.d.ts` files with [tsdown](https://tsdown.dev/).

### esm.sh (default)

The simplest way to add a dependency is a plain string. It resolves via [esm.sh](https://esm.sh), which serves proper ESM bundles with TypeScript types out of the box:

```js
playgroundPreset({
dependencies: ['deepmerge', '@motionone/easing'],
});
```

Versions are inferred from your `package.json` when available. You can also pin them explicitly:

```js
playgroundPreset({
dependencies: [{ specifier: 'deepmerge', version: '5.1.0' }],
});
```

### Self-hosted

Adding a `source` field bundles the dependency with tsdown into a single ESM file (`.js`) and a bundled type declaration (`.d.ts`). All npm types are inlined in the `.d.ts` output so the browser-based TypeScript editor can resolve them without additional fetches.

**From an npm package:**

```js
playgroundPreset({
dependencies: [
{ specifier: 'morphdom', source: 'morphdom' },
{ specifier: '@studiometa/js-toolkit', source: '@studiometa/js-toolkit' },
],
});
```
The `source` field must be a **local file path** (relative, absolute, or glob). Bare npm package names (e.g. `"morphdom"`) are not supported as source values — npm packages should use esm.sh resolution instead (omit the `source` field).

**From local TypeScript sources:**

Expand Down Expand Up @@ -215,6 +141,30 @@ playgroundPreset({

> **Note:** Self-hosted dependencies require `tsdown` as a devDependency. Install it with `npm install -D tsdown`.

### Bundle deduplication

Self-hosted bundles automatically **externalize** any specifier that already exists in the import map. This prevents inlining shared dependencies that the browser already resolves via esm.sh.

For example, if `@studiometa/ui` is self-hosted and depends on `@studiometa/js-toolkit`, and `@studiometa/js-toolkit` is also in the import map (via esm.sh), the bundled `@studiometa/ui/index.js` will contain `import ... from "@studiometa/js-toolkit"` instead of inlining it. The browser's import map resolves that to the esm.sh URL at runtime — no duplication.

```js
playgroundPreset({
dependencies: [
'@motionone/easing',
'deepmerge',
'morphdom',
'@studiometa/js-toolkit',
'@studiometa/js-toolkit/utils',
{
specifier: '@studiometa/ui',
source: '../ui/**/*.ts',
entry: '../ui/index.ts',
},
],
});
// @studiometa/ui bundle will NOT inline @studiometa/js-toolkit, deepmerge, etc.
```

### Type resolution

The browser-based Monaco editor discovers type declarations via the `x-typescript-types` HTTP response header on `.js` files. The build emits a `_headers` file that maps each bundled `.js` to its `.d.ts` counterpart:
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.2",
"version": "0.3.3",
"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.2",
"version": "0.3.3",
"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.2",
"version": "0.3.3",
"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.2",
"version": "0.3.3",
"type": "module",
"publishConfig": {
"access": "public"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,28 @@ describe('PlaygroundDependenciesPlugin', () => {
expect(resolvePublicPath('/custom', '/play/')).toBe('/custom');
});
});

describe('importMapKeys', () => {
it('defaults to empty array when not provided', () => {
const p = new PlaygroundDependenciesPlugin([], '/tmp');
expect(p.importMapKeys).toEqual([]);
});

it('stores import map keys from constructor', () => {
const keys = ['@studiometa/js-toolkit', 'deepmerge', 'morphdom', '@studiometa/ui'];
const p = new PlaygroundDependenciesPlugin([], '/tmp', undefined, keys);
expect(p.importMapKeys).toEqual(keys);
});

it('accepts empty importMapKeys', () => {
const p = new PlaygroundDependenciesPlugin([], '/tmp', undefined, []);
expect(p.importMapKeys).toEqual([]);
});

it('preserves publicPath when importMapKeys are provided', () => {
const p = new PlaygroundDependenciesPlugin([], '/tmp', '/play', ['deepmerge']);
expect(p.publicPath).toBe('/play');
expect(p.importMapKeys).toEqual(['deepmerge']);
});
});
});
Loading
Loading