feat(cem-plugin): new distPaths plugin

This commit is contained in:
ayo 2026-07-24 11:00:52 +02:00
parent da6ba502cd
commit f2c05fb7fb
4 changed files with 185 additions and 3 deletions

View file

@ -9,8 +9,8 @@ A plugin for
that teaches it to read wcb's `static props` object.
```js
import { wcbStaticProps } from 'web-component-base/cem-plugin'
// also available as the module's default export
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
// wcbStaticProps is also available as the module's default export
import wcbStaticProps from 'web-component-base/cem-plugin'
```
@ -53,3 +53,36 @@ attributes, so editor completion and Storybook controls have nothing to read.
The `static props` initializer must resolve to an object literal in the same
source file.
## `distPaths(options?)`
A companion plugin that rewrites each module's `path` in the manifest from the
scanned source to the built output a package publishes, so a shipped
`custom-elements.json` points at files consumers can actually import.
```js
// custom-elements-manifest.config.mjs
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
export default {
globs: ['src/**/*.ts'],
outdir: '.',
plugins: [wcbStaticProps(), distPaths()],
}
```
| Option | Type | Default | |
| --------- | ------------------------ | -------- | ---------------------------------------------- |
| `rootDir` | `string` | `'src'` | source directory prefix to replace |
| `outDir` | `string` | `'dist'` | built-output directory to point at |
| `ext` | `Record<string, string>` | see below | extension remap, merged over the defaults |
Zero-config it maps the `rootDir` prefix to `outDir` and rewrites TypeScript
extensions to their emitted JS form — `.ts``.js`, `.mts``.mjs`,
`.cts``.cjs`. Other extensions pass through, so a plain `.js` source only
has its directory swapped. `options.ext` merges over that default map.
It runs in the analyzer's `packageLinkPhase` (after `wcbStaticProps`'s
`analyzePhase`), so ordering the two in the `plugins` array does not matter.
Run `cem analyze` after your build so the files the rewritten paths point at
exist. See the [publishing note in the guide](/cem-plugin/#configure).

View file

@ -49,6 +49,35 @@ Then run the analyzer:
npx cem analyze
```
<Aside type="caution" title="Publishing a package? Point the manifest at your built files">
The analyzer stamps each module's `path` with the exact file it scanned —
so with `globs: ['src/**/*.ts']` every `path` in `custom-elements.json` is a
`.ts` source file. Most packages publish only their built `dist/` output
(and can't `import` a `.ts` at runtime anyway), so a consumer reading that
manifest resolves paths that aren't in the tarball.
Add `distPaths()` after `wcbStaticProps()` to rewrite those paths to the
built output. Zero-config it maps `src/` → `dist/` and TypeScript extensions
to their emitted JS form (`.ts` → `.js`, and `.mts` / `.cts` likewise):
```js
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
export default {
globs: ['src/**/*.ts'],
outdir: '.',
plugins: [wcbStaticProps(), distPaths()],
}
```
For a different layout, override `rootDir` / `outDir` (named to mirror
`tsconfig`): `distPaths({ rootDir: 'lib', outDir: 'dist/esm' })`. Run
`cem analyze` **after** your build so the `dist/` files it points at exist.
Not publishing the manifest — e.g. a local Storybook or editor setup over
your own source — needs none of this.
</Aside>
<Aside type="caution" title="LOOK OUT! `cem analyze` fails by hanging, not by erroring">
Run it **from the directory holding your config and a `package.json`**. Two
things make it appear to do nothing, neither of which prints an error:

View file

@ -204,4 +204,53 @@ export function wcbStaticProps() {
}
}
/**
* A companion analyzer plugin that rewrites each module's `path` from the
* scanned source to its built, published counterpart so a shipped
* `custom-elements.json` points at the files consumers actually import
* (`dist/*.js`) rather than the source the analyzer read (`src/*.ts`), which a
* package typically does not publish.
*
* Zero-config it maps the `src/` prefix to `dist/` and rewrites TypeScript
* extensions to their emitted JS form (`.ts``.js`, `.mts``.mjs`,
* `.cts``.cjs`); other extensions pass through untouched. Override `rootDir` /
* `outDir` (named to mirror `tsconfig`) or extend `ext` for other layouts.
* @param {object} [options]
* @param {string} [options.rootDir] source directory prefix to replace (default `'src'`)
* @param {string} [options.outDir] built-output directory to point at (default `'dist'`)
* @param {Record<string, string>} [options.ext] extension remap, merged over the defaults
* @returns {{name: string, packageLinkPhase: (ctx: any) => void}} a CEM analyzer plugin
* @example
* // custom-elements-manifest.config.mjs
* import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
* export default {
* globs: ['src/**\/*.ts'],
* plugins: [wcbStaticProps(), distPaths()],
* }
*/
export function distPaths(options = {}) {
const withSlash = (dir) => dir.replace(/\/*$/, '/')
const from = withSlash(options.rootDir ?? 'src')
const to = withSlash(options.outDir ?? 'dist')
const ext = { '.ts': '.js', '.mts': '.mjs', '.cts': '.cjs', ...options.ext }
return {
name: 'wcb-dist-paths',
packageLinkPhase({ customElementsManifest }) {
for (const mod of customElementsManifest.modules ?? []) {
if (!mod.path) continue
let path = mod.path.startsWith(from)
? to + mod.path.slice(from.length)
: mod.path
for (const [srcExt, outExt] of Object.entries(ext)) {
if (path.endsWith(srcExt)) {
path = path.slice(0, -srcExt.length) + outExt
break
}
}
mod.path = path
}
},
}
}
export default wcbStaticProps

View file

@ -1,7 +1,7 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
import { create } from '@custom-elements-manifest/analyzer/src/create.js'
import { wcbStaticProps } from '../src/cem-plugin.js'
import { distPaths, wcbStaticProps } from '../src/cem-plugin.js'
import { getKebabCase } from '../src/utils/index.js'
// The analyzer resolves its own `typescript`, which may be a different version
@ -185,3 +185,74 @@ describe('cem-plugin: wcbStaticProps', () => {
expect(doc.members.map((m) => m.name)).toContain('props')
})
})
/**
* Runs the real analyzer over one or more named source files with the given
* plugins installed and returns the module paths from the generated manifest
* `packageLinkPhase` (where `distPaths` runs) fires as part of this.
* @param {string[]} fileNames source paths to stamp on the scanned modules
* @param {object[]} plugins analyzer plugins to install
* @returns {string[]} the resulting `module.path` for each module
*/
function modulePaths(fileNames, plugins) {
const manifest = create({
modules: fileNames.map((fileName) =>
ts.createSourceFile(fileName, '', ts.ScriptTarget.ES2015, true)
),
plugins,
context: { dev: false, thirdPartyCEMs: [] },
})
return manifest.modules.map((m) => m.path)
}
describe('cem-plugin: distPaths', () => {
it('rewrites src/*.ts paths to dist/*.js by default', () => {
expect(modulePaths(['src/wcb-button.ts'], [distPaths()])).toEqual([
'dist/wcb-button.js',
])
})
it('maps .mts/.cts to their emitted JS extensions', () => {
expect(
modulePaths(['src/a.mts', 'src/b.cts', 'src/c.ts'], [distPaths()])
).toEqual(['dist/a.mjs', 'dist/b.cjs', 'dist/c.js'])
})
it('swaps the directory but keeps plain .js sources as-is', () => {
expect(modulePaths(['src/nested/widget.js'], [distPaths()])).toEqual([
'dist/nested/widget.js',
])
})
it('honors rootDir / outDir overrides for non-standard layouts', () => {
expect(
modulePaths(
['lib/x.ts'],
[distPaths({ rootDir: 'lib', outDir: 'dist/esm' })]
)
).toEqual(['dist/esm/x.js'])
})
it('leaves paths outside rootDir untouched (dir), still remaps extension', () => {
expect(modulePaths(['other/x.ts'], [distPaths()])).toEqual(['other/x.js'])
})
it('runs alongside wcbStaticProps without interfering', () => {
const manifest = create({
modules: [
ts.createSourceFile(
'src/cozy-button.ts',
COZY_BUTTON,
ts.ScriptTarget.ES2015,
true
),
],
plugins: [wcbStaticProps(), distPaths()],
context: { dev: false, thirdPartyCEMs: [] },
})
const mod = manifest.modules[0]
expect(mod.path).toBe('dist/cozy-button.js')
const doc = mod.declarations.find((d) => d.name === 'CozyButton')
expect(doc.attributes.map((a) => a.name)).toContain('max-count')
})
})