feat: new cem analyzer plugin (cem-plugin)
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run

This commit is contained in:
ayo 2026-07-19 21:06:22 +02:00
parent 861310c6bf
commit 2da6cd5afc
22 changed files with 2339 additions and 65 deletions

9
.gitignore vendored
View file

@ -16,3 +16,12 @@ dist/
# vitest # vitest
coverage coverage
# storybook (all generated: `pnpm -F storybook analyze` / `build`)
storybook/storybook-static
storybook/custom-elements.json
# VS Code HTML custom data, emitted to the repo root by the same analyze run
# so `html.customData` in .vscode/settings.json can resolve it
vscode.html-custom-data.json
vscode.css-custom-data.json

View file

@ -1,4 +1,5 @@
{ {
"js/ts.implicitProjectConfig.checkJs": true, "js/ts.implicitProjectConfig.checkJs": true,
"editor.formatOnSave": true "editor.formatOnSave": true,
"html.customData": ["./vscode.html-custom-data.json"]
} }

View file

@ -17,9 +17,10 @@ This file provides guidance to AI coding agents when working with code in this r
- `pnpm size-limit` — enforces the byte budgets declared in `package.json` `size-limit` (e.g. `WebComponent.js` ≤ 1.2 KB). Keep additions tiny; this gate is a core project value. - `pnpm size-limit` — enforces the byte budgets declared in `package.json` `size-limit` (e.g. `WebComponent.js` ≤ 1.2 KB). Keep additions tiny; this gate is a core project value.
- `pnpm docs` — run the Astro docs site (`docs/` workspace) locally. - `pnpm docs` — run the Astro docs site (`docs/` workspace) locally.
- `pnpm demo` — run the Vite examples showcase (`demo/` workspace) locally; `pnpm demo:build` builds it. - `pnpm demo` — run the Vite examples showcase (`demo/` workspace) locally; `pnpm demo:build` builds it.
- `pnpm -F storybook dev` — run the Storybook testing ground (`storybook/`) locally; `pnpm -F storybook build` builds it. Both run `cem analyze` first to regenerate `custom-elements.json`. The CEM config imports the plugin from the **published subpath** (`web-component-base/cem-plugin` → `dist/`), so run `pnpm build` at the root first.
- `pnpm test:e2e` — run the browser e2e specs (`test/e2e/`) via Vitest browser mode (Playwright). Defaults to Chromium; `pnpm test:e2e:firefox` / `:webkit` / `:all` target the other engines (the `E2E_BROWSERS` env var, comma-separated, selects instances). `pnpm test:all` runs unit + default e2e. - `pnpm test:e2e` — run the browser e2e specs (`test/e2e/`) via Vitest browser mode (Playwright). Defaults to Chromium; `pnpm test:e2e:firefox` / `:webkit` / `:all` target the other engines (the `E2E_BROWSERS` env var, comma-separated, selects instances). `pnpm test:all` runs unit + default e2e.
pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace with two sub-packages: `docs/` (Astro docs site) and `demo/` (Vite examples showcase, which consumes the root `web-component-base` package as a `workspace:*` dependency). The runnable example sources live in `demo/examples/` and import the package by name (`web-component-base`), not via relative `src/` paths. pnpm is mandatory (a `preinstall` `only-allow pnpm` guard enforces it). This is a pnpm workspace with three sub-packages: `docs/` (Astro docs site), `demo/` (Vite examples showcase, which consumes the root `web-component-base` package as a `workspace:*` dependency), and `storybook/` (Storybook testing ground — it writes no components of its own, it builds stories over the `demo/examples/` components so the CEM plugin is exercised against real ones). The runnable example sources live in `demo/examples/` and import the package by name (`web-component-base`), not via relative `src/` paths.
The demo shares one app shell: `demo/shell.css` (design tokens + component styles, linked by the landing page and every example) and `demo/shell.js` (defines `<app-header>` — itself a `WebComponent` — and `prepend`s it to each example page; `prepend` never reparents the example's own elements, so their lifecycle is untouched). Every example page links both. Keep new examples consistent by adding `<link rel="stylesheet" href="../../shell.css" />` and `<script type="module" src="../../shell.js"></script>`. The demo shares one app shell: `demo/shell.css` (design tokens + component styles, linked by the landing page and every example) and `demo/shell.js` (defines `<app-header>` — itself a `WebComponent` — and `prepend`s it to each example page; `prepend` never reparents the example's own elements, so their lifecycle is untouched). Every example page links both. Keep new examples consistent by adding `<link rel="stylesheet" href="../../shell.css" />` and `<script type="module" src="../../shell.js"></script>`.

View file

@ -42,6 +42,34 @@ class CozyButton extends WebComponent<typeof props> {
The runtime is unchanged — this is types-only, and omitting the type argument keeps the previous behavior. See the [prop access guide](https://webcomponent.io/prop-access/) for details. The runtime is unchanged — this is types-only, and omitting the type argument keeps the previous behavior. See the [prop access guide](https://webcomponent.io/prop-access/) for details.
## Storybook autodocs & controls
Storybook infers autodocs and controls from a Custom Elements Manifest, but the stock analyzer reads `static props` as one opaque `object` and emits no attributes. `web-component-base/cem-plugin` teaches it the convention — dev-time only, so the core stays zero-dependency:
```js
// custom-elements-manifest.config.mjs
import { wcbStaticProps } from 'web-component-base/cem-plugin'
export default {
globs: ['src/**/*.js'],
outdir: '.',
plugins: [wcbStaticProps()],
}
```
`npx cem analyze` then emits a typed attribute per prop — `variant` (string), `disabled` (boolean), `maxCount``max-count` (number) — named with wcb's own `getKebabCase` so they match `observedAttributes`, with wcb internals stripped. Point Storybook at the result:
```js
// .storybook/preview.js
import { setCustomElementsManifest } from '@storybook/web-components-vite'
import manifest from '../custom-elements.json'
setCustomElementsManifest(manifest)
export default { tags: ['autodocs'] }
```
A story only needs `component: 'cozy-button'` — no per-story `argTypes`. See the [full recipe](https://webcomponent.io/cem-plugin/), or the working setup in [`storybook/`](./storybook).
## Want to get in touch? ## Want to get in touch?
There are many ways to get in touch: There are many ways to get in touch:

View file

@ -43,6 +43,7 @@ export default defineConfig({
'styling', 'styling',
'just-parts', 'just-parts',
'life-cycle-hooks', 'life-cycle-hooks',
'cem-plugin',
'library-size', 'library-size',
], ],
}, },

View file

@ -0,0 +1,202 @@
---
title: CEM Analyzer Plugin
slug: cem-plugin
---
import { Aside } from '@astrojs/starlight/components'
A CEM (`custom-elements.json`) is a standard description of the elements a
package defines — their tags, attributes, properties, events and slots — so
tooling can read your components without executing them. Read more at
[custom-elements-manifest.open-wc.org](https://custom-elements-manifest.open-wc.org/)
for the analyzer and its plugin API, or the [schema and
specification](https://github.com/webcomponents/custom-elements-manifest) for
the file format itself.
We provide a CEM Analyzer Plugin `web-component-base/cem-plugin` which allows using the `@custom-elements-manifest/analyzer` by handling wcb's `static props` object. In this guide we show how to use this plugin and the benefits for using it with [Storybook](#storybook) and [code editors](#code-editors).
## Install
```sh
npm i -D @custom-elements-manifest/analyzer
```
## Configure
```js
// custom-elements-manifest.config.mjs
import { wcbStaticProps } from 'web-component-base/cem-plugin'
export default {
globs: ['src/**/*.js'],
outdir: '.',
plugins: [wcbStaticProps()],
}
```
Then run the analyzer:
```sh
npx cem analyze
```
<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:
- **No config found** — it falls back to a default glob of
`**/*.{js,ts,tsx}`, which does _not_ exclude `node_modules`. In a real
project that is tens of thousands of files through the TypeScript parser.
- **No `package.json` in the directory** — it hangs outright.
With globs scoped to your source it should finish in well under a second.
</Aside>
## What it produces
Given a component:
```js
const props = { variant: 'primary', disabled: false, maxCount: 3 }
export class CozyButton extends WebComponent {
static props = props
static shadowRootInit = { mode: 'open' }
static styles = ':host { display: inline-block }'
get template() {
return html`<button>${this.props.variant}</button>`
}
}
customElements.define('cozy-button', CozyButton)
```
`custom-elements.json` gains a typed attribute and a matching public field per prop:
| attribute | type | field | default |
| ----------- | --------- | ---------- | ----------- |
| `variant` | `string` | `variant` | `'primary'` |
| `disabled` | `boolean` | `disabled` | `false` |
| `max-count` | `number` | `maxCount` | `3` |
...and `props`, `shadowRootInit`, `styles`, `strictProps`, `observedAttributes` and `template` are stripped from the public surface.
Two details worth knowing:
- **Types come from the default literal** — `true`/`false` → `boolean`, numeric → `number`, object/array → `object`, everything else → `string`.
- **Attribute names come from wcb's own `getKebabCase`**, the same function `observedAttributes` uses, so manifest names can't drift from what the component actually observes.
The defaults may be written inline or hoisted into a module-level `const` — the latter is required by the [typed props](/prop-access/#typed-props-in-typescript) pattern, and the plugin resolves it either way.
## Storybook
Storybook's web-components renderer builds **autodocs and controls** from a [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest).
## Wire it into Storybook
```js
// .storybook/preview.js
import { setCustomElementsManifest } from '@storybook/web-components-vite'
import manifest from '../custom-elements.json'
setCustomElementsManifest(manifest)
export default { tags: ['autodocs'] }
```
Bind a story to the tag name and Storybook infers the rest — a text field for `variant`, a toggle for `disabled`, a number input for `maxCount`:
```js
// cozy-button.stories.js
import { html } from 'lit'
import '../src/cozy-button.js'
export default {
title: 'Cozy/Button',
component: 'cozy-button', // ← no argTypes needed
render: ({ variant, disabled }) => html`
<cozy-button variant=${variant} disabled=${disabled}></cozy-button>
`,
}
export const Default = { args: { variant: 'primary', disabled: false } }
```
<Aside type="tip">
Regenerate the manifest before starting Storybook (`cem analyze && storybook
dev`) — `custom-elements.json` is a build artifact, so it is usually
gitignored and rebuilt on demand.
</Aside>
This repo runs exactly this setup against the demo components in `storybook/` — see it there for a working reference.
## Code editors
Once `custom-elements.json` exists, editors can offer tag-name and attribute autocomplete for your components — driven by the same `static props` the plugin reads, so the hints can't drift from the code.
<Aside type="caution">
VS Code does **not** read `custom-elements.json` natively. Nothing happens
just because the file exists — you need one of the two routes below.
</Aside>
First, point tooling at the manifest from your `package.json`. This is the field every option here uses to discover it:
```json
{
"customElements": "custom-elements.json"
}
```
### Route 1 — native VS Code, no extension
VS Code's built-in HTML language service reads its own [custom data](https://github.com/microsoft/vscode-custom-data) format. A second analyzer plugin converts the manifest into it, so both files come out of one `cem analyze` run:
```sh
npm i -D cem-plugin-vs-code-custom-data-generator
```
```js
// custom-elements-manifest.config.mjs
import { wcbStaticProps } from 'web-component-base/cem-plugin'
import { generateCustomData } from 'cem-plugin-vs-code-custom-data-generator'
export default {
globs: ['src/**/*.js'],
outdir: '.',
plugins: [wcbStaticProps(), generateCustomData()],
}
```
```json
// .vscode/settings.json
{
"html.customData": ["./vscode.html-custom-data.json"]
}
```
<Aside type="caution">
`html.customData` paths resolve from the **workspace root**, not from the
settings file. If you run `cem analyze` in a subfolder, either point at
`./that-folder/vscode.html-custom-data.json` or give the generator its own
`outdir` — `generateCustomData({ outdir: '..' })` — so the file lands where
the setting expects it.
</Aside>
Restart VS Code and `<cozy-` completes in HTML files, with `variant` / `disabled` / `max-count` offered as attributes and their types and defaults on hover.
**The catch:** `html.customData` only applies to `.html` files. wcb components author their markup in `html` tagged templates inside `.js` / `.ts`, and those do not go through the HTML language service. This route helps whoever writes plain HTML pages against your components — it will not light up inside your own templates.
### Route 2 — a language server extension, for tagged templates
To get the same completions **inside** tagged templates, you need an extension that understands them. The most current option is the [Custom Elements Manifest Language Server](https://marketplace.visualstudio.com/items?itemName=pwrs.cem-language-server-vscode) (`pwrs.cem-language-server-vscode`). It autocompletes tag names and attributes inside template literals in both JS and TS, adds hover documentation for attributes and defaults, and discovers the manifest through the `customElements` field above — no `.vscode/settings.json` needed.
Two alternatives, both worth knowing the state of:
- [`wc-toolkit/wc-language-server`](https://wc-toolkit.com/integrations/web-components-language-server/) — VS Code and JetBrains, also manifest-driven. Self-described as **alpha and experimental**.
- `Matsuuu.custom-elements-language-server-project` — the one you'll find most often in older write-ups. It is alpha, and **its repository was archived in January 2026**, so prefer one of the two above.
<Aside type="note">
This corner of the ecosystem moves quickly and every option is pre-1.0. Route
1 is the conservative choice — it is plain VS Code configuration with no
extension to go stale — at the cost of not covering tagged templates.
</Aside>

View file

@ -38,6 +38,7 @@ Therefore, this will tell the browser that the UI needs a render if the attribut
The `props` property of `WebComponent` works like `HTMLElement.dataset`, except `dataset` is only for attributes prefixed with `data-`. A camelCase counterpart using `props` will give read/write access to any attribute, with or without the `data-` prefix. The `props` property of `WebComponent` works like `HTMLElement.dataset`, except `dataset` is only for attributes prefixed with `data-`. A camelCase counterpart using `props` will give read/write access to any attribute, with or without the `data-` prefix.
Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'. Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'.
</Aside> </Aside>
### Typed props in TypeScript ### Typed props in TypeScript

View file

@ -18,7 +18,15 @@ export default [
files: ['**/*.config.{js,mjs,cjs}', 'scripts/**/*.{js,mjs}'], files: ['**/*.config.{js,mjs,cjs}', 'scripts/**/*.{js,mjs}'],
languageOptions: { globals: globals.node }, languageOptions: { globals: globals.node },
}, },
// The Storybook config files and the CEM analyzer config run in Node.
{ {
ignores: ['site/*', '**/dist/*'], files: [
'storybook/.storybook/*.js',
'**/custom-elements-manifest.config.mjs',
],
languageOptions: { globals: globals.node },
},
{
ignores: ['site/*', '**/dist/*', 'storybook/storybook-static/*'],
}, },
] ]

View file

@ -8,6 +8,10 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./cem-plugin": {
"types": "./dist/cem-plugin.d.ts",
"import": "./dist/cem-plugin.js"
},
"./*": { "./*": {
"types": "./dist/*.d.ts", "types": "./dist/*.d.ts",
"import": "./dist/*.js" "import": "./dist/*.js"
@ -43,6 +47,8 @@
"demo": "pnpm -F demo dev", "demo": "pnpm -F demo dev",
"demo:build": "pnpm -F demo build", "demo:build": "pnpm -F demo build",
"docs": "pnpm -F docs start", "docs": "pnpm -F docs start",
"storybook": "pnpm run build && pnpm -F storybook dev",
"storybook:build": "pnpm run build && pnpm -F storybook build",
"build": "pnpm run clean && tsc && pnpm run copy:source", "build": "pnpm run clean && tsc && pnpm run copy:source",
"size-limit": "pnpm run build && size-limit", "size-limit": "pnpm run build && size-limit",
"clean": "rm -rf dist", "clean": "rm -rf dist",
@ -76,6 +82,7 @@
"url": "https://github.com/ayo-run/wcb/issues" "url": "https://github.com/ayo-run/wcb/issues"
}, },
"devDependencies": { "devDependencies": {
"@custom-elements-manifest/analyzer": "^0.11.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@size-limit/preset-small-lib": "^12.0.0", "@size-limit/preset-small-lib": "^12.0.0",
"@vitest/browser": "4.0.18", "@vitest/browser": "4.0.18",

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ packages:
# include packages in subfolders (e.g. apps/ and packages/) # include packages in subfolders (e.g. apps/ and packages/)
- 'docs/' - 'docs/'
- 'demo/' - 'demo/'
- 'storybook/'
allowBuilds: allowBuilds:
esbuild: true # insurance for core build esbuild: true # insurance for core build
'@parcel/watcher': false '@parcel/watcher': false

208
src/cem-plugin.js Normal file
View file

@ -0,0 +1,208 @@
/**
* @license MIT <https://opensource.org/licenses/MIT>
* @author Ayo Ayco <https://ayo.ayco.io>
*
* A Custom Elements Manifest analyzer plugin that teaches
* `@custom-elements-manifest/analyzer` about wcb's `static props` convention.
*
* This module is **dev-time only** it runs in Node during `cem analyze` and
* never reaches the browser. It is not imported by `WebComponent`, so the core
* stays zero-dependency and within its size budget.
* @see https://webcomponent.io/cem-plugin/
*/
import { getKebabCase } from './utils/index.js'
/** wcb statics that are implementation detail, not public API. */
const WCB_INTERNAL = new Set([
'props',
'shadowRootInit',
'styles',
'strictProps',
'observedAttributes',
'template',
])
/**
* Maps a `static props` default literal to a CEM type string.
* @param {any} ts the TypeScript module handed to the hook
* @param {any} init the property initializer node
* @returns {string} `boolean` | `number` | `object` | `string`
*/
function typeOfDefault(ts, init) {
if (
init.kind === ts.SyntaxKind.TrueKeyword ||
init.kind === ts.SyntaxKind.FalseKeyword
)
return 'boolean'
if (ts.isNumericLiteral(init)) return 'number'
if (ts.isObjectLiteralExpression(init) || ts.isArrayLiteralExpression(init))
return 'object'
return 'string'
}
/**
* Reads a node's modifiers across TypeScript versions.
* @param {any} ts the TypeScript module handed to the hook
* @param {any} node the node to read modifiers from
* @returns {any[]} the modifiers, or an empty array
*/
function modifiersOf(ts, node) {
return (
(ts.canHaveModifiers?.(node) ? ts.getModifiers(node) : node.modifiers) ?? []
)
}
/**
* Unwraps `x as const` / `x satisfies T` down to the underlying expression.
* @param {any} ts the TypeScript module handed to the hook
* @param {any} node the expression node
* @returns {any} the unwrapped expression
*/
function unwrap(ts, node) {
let current = node
while (
current &&
(ts.isAsExpression?.(current) ||
ts.isSatisfiesExpression?.(current) ||
ts.isParenthesizedExpression(current))
)
current = current.expression
return current
}
/**
* Resolves an identifier to the object literal of a module-level `const` in
* the same file. This is what makes the typed-props pattern work:
*
* ```js
* const props = { variant: 'primary' }
* class Foo extends WebComponent { static props = props }
* ```
*
* A class can't reference its own static in its `extends` clause, so typed
* components must hoist the defaults into a const without this the whole
* pattern would yield zero attributes.
* @param {any} ts the TypeScript module handed to the hook
* @param {any} node any node in the source file
* @param {string} name the identifier to resolve
* @returns {any} the object literal, or undefined
*/
function resolveObjectLiteral(ts, node, name) {
for (const statement of node.getSourceFile()?.statements ?? []) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (declaration.name?.getText() !== name || !declaration.initializer)
continue
const initializer = unwrap(ts, declaration.initializer)
if (ts.isObjectLiteralExpression(initializer)) return initializer
}
}
return undefined
}
/**
* Finds the object literal behind a class's `static props`, whether it is
* written inline or hoisted into a module-level const.
* @param {any} ts the TypeScript module handed to the hook
* @param {any} node the class declaration node
* @returns {any} the object literal, or undefined
*/
function findStaticProps(ts, node) {
const declaration = node.members.find(
(member) =>
ts.isPropertyDeclaration(member) &&
modifiersOf(ts, member).some(
(mod) => mod.kind === ts.SyntaxKind.StaticKeyword
) &&
member.name?.getText() === 'props' &&
member.initializer
)
if (!declaration) return undefined
const initializer = unwrap(ts, declaration.initializer)
if (ts.isObjectLiteralExpression(initializer)) return initializer
if (ts.isIdentifier(initializer))
return resolveObjectLiteral(ts, node, initializer.getText())
return undefined
}
/**
* True when the class extends something named `WebComponent`. Used so wcb
* components that declare no props still get their internals stripped.
* @param {any} ts the TypeScript module handed to the hook
* @param {any} node the class declaration node
* @returns {boolean} whether the class extends `WebComponent`
*/
function extendsWebComponent(ts, node) {
return (node.heritageClauses ?? []).some(
(clause) =>
clause.token === ts.SyntaxKind.ExtendsKeyword &&
clause.types.some((t) => t.expression.getText().endsWith('WebComponent'))
)
}
/**
* Teaches the CEM analyzer to read wcb's `static props`: every key becomes a
* public field plus a reflected attribute, named with wcb's own
* `getKebabCase` so manifest attribute names match `observedAttributes`
* exactly. wcb internals are stripped from the public surface.
* @example
* // custom-elements-manifest.config.mjs
* import { wcbStaticProps } from 'web-component-base/cem-plugin'
* export default { globs: ['src/**\/*.js'], plugins: [wcbStaticProps()] }
* @returns {{name: string, analyzePhase: (ctx: any) => void}} a CEM analyzer plugin
*/
export function wcbStaticProps() {
return {
name: 'wcb-static-props',
analyzePhase({ ts, node, moduleDoc }) {
if (!ts.isClassDeclaration(node) || !node.name) return
const className = node.name.getText()
const classDoc = (moduleDoc.declarations ?? []).find(
(declaration) =>
declaration.kind === 'class' && declaration.name === className
)
if (!classDoc) return
const props = findStaticProps(ts, node)
if (!props && !extendsWebComponent(ts, node)) return
classDoc.members = (classDoc.members ?? []).filter(
(member) => !WCB_INTERNAL.has(member.name)
)
classDoc.attributes = classDoc.attributes ?? []
if (!props) return
for (const prop of props.properties) {
// shorthand (`{ variant }`) and spreads carry no inspectable default
if (!ts.isPropertyAssignment(prop)) continue
const fieldName = prop.name.getText().replace(/['"]/g, '')
const attribute = getKebabCase(fieldName)
const type = { text: typeOfDefault(ts, prop.initializer) }
const defaultValue = prop.initializer.getText()
classDoc.members.push({
kind: 'field',
name: fieldName,
privacy: 'public',
type,
default: defaultValue,
attribute,
description: `Reactive prop, reflected to the \`${attribute}\` attribute.`,
})
classDoc.attributes.push({
name: attribute,
fieldName,
type,
default: defaultValue,
})
}
},
}
}
export default wcbStaticProps

View file

@ -0,0 +1,41 @@
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const repo = resolve(root, '..')
const srcDir = resolve(repo, 'src')
/** @type {import('@storybook/web-components-vite').StorybookConfig} */
export default {
stories: ['../stories/**/*.stories.js', '../stories/**/*.mdx'],
addons: ['@storybook/addon-docs'],
framework: { name: '@storybook/web-components-vite', options: {} },
// Same aliasing the demo uses: resolve the library to `src/` so stories get
// instant HMR against the real source instead of the built bundle. (The CEM
// config deliberately does the opposite and imports the plugin from `dist/`
// via its published subpath.)
viteFinal: (config) => ({
...config,
resolve: {
...config.resolve,
alias: [
...(config.resolve?.alias ?? []),
{
find: /^web-component-base\/utils$/,
replacement: resolve(srcDir, 'utils/index.js'),
},
{
find: /^web-component-base\/html$/,
replacement: resolve(srcDir, 'html.js'),
},
{
find: /^web-component-base$/,
replacement: resolve(srcDir, 'index.js'),
},
],
},
// stories import components from the sibling `demo/` workspace
server: { ...config.server, fs: { allow: [repo] } },
}),
}

View file

@ -0,0 +1,16 @@
import { setCustomElementsManifest } from '@storybook/web-components-vite'
import manifest from '../custom-elements.json'
// This single line is what turns the manifest into autodocs + inferred
// controls. Without the wcb CEM plugin the manifest has no `attributes` at
// all, so every story would have to hand-write `argTypes`.
setCustomElementsManifest(manifest)
/** @type {import('@storybook/web-components-vite').Preview} */
export default {
parameters: {
docs: { toc: true },
controls: { expanded: true },
},
tags: ['autodocs'],
}

28
storybook/README.md Normal file
View file

@ -0,0 +1,28 @@
# Storybook testing ground
A real-consumer harness for `web-component-base/cem-plugin`. It writes no components of its own — the stories render the components in [`demo/examples/`](../demo/examples), so the plugin is exercised against the same code the demo site ships.
```sh
pnpm build # at the repo root — the CEM config imports from dist/
pnpm -F storybook dev # runs `cem analyze`, then starts Storybook on :6006
```
## What it proves
- `custom-elements-manifest.config.mjs` imports the plugin from its **published subpath**, so a broken `exports` map fails here the way it would for a consumer.
- Not one story declares `argTypes`. Every control and every row of the attributes table comes from `custom-elements.json`.
- Each default-literal shape gets a story under **Plugin → Inferred control types**, so the controls panel is the assertion: `boolean` → toggle, `number` → number input, `object` → JSON editor, `string` → text field.
- `hello-world` covers the camelCase case — `myName` reaching Storybook as `my-name`.
- `typed-button` covers defaults hoisted into a `const` (the [typed props](../docs/src/content/docs/guides/prop-access.mdx) pattern), which the plugin has to resolve through an identifier.
## Why the globs are an explicit list
The demo pages are standalone, so several reuse the same tag name — `my-counter` is defined in five examples. That's harmless when each page loads alone, but Storybook loads everything into one document, where duplicate tags collide in both `customElements.define` and the manifest lookup. Keep `custom-elements-manifest.config.mjs` to one definition per tag when adding stories.
## VS Code autocomplete
The same `pnpm -F storybook analyze` run also emits `vscode.html-custom-data.json` — to the **repo root**, because `html.customData` in `.vscode/settings.json` resolves paths from the workspace root, not from the settings file. With it in place, `<typed-` completes in any `.html` file, with `variant` / `disabled` / `clicks` offered as attributes. Restart VS Code after the first generation for it to register.
That covers `.html` files only. For the same completions inside `` html`…` `` tagged templates, install the [Custom Elements Manifest Language Server](https://marketplace.visualstudio.com/items?itemName=pwrs.cem-language-server-vscode) extension — it reads the `customElements` field in `package.json` and needs no settings.
`custom-elements.json`, `vscode.html-custom-data.json` and `storybook-static/` are all generated and gitignored.

View file

@ -0,0 +1,41 @@
// Custom Elements Manifest config for the wcb Storybook testing ground.
//
// This is the real consumer path for `web-component-base/cem-plugin`: the
// plugin is imported from its published subpath (so this doubles as a check
// that the packaging works), and the manifest it emits is what Storybook reads
// to build autodocs + controls.
//
// The globs are an explicit list rather than `../demo/examples/**`, because the
// demo pages are standalone — several of them reuse the same tag name
// (`my-counter` is defined in five examples). That is fine when each page loads
// alone, but Storybook loads everything into one document, where duplicate tag
// names collide both in `customElements.define` and in the manifest lookup.
// Keep this list to one definition per tag.
import { wcbStaticProps } from 'web-component-base/cem-plugin'
import { generateCustomData } from 'cem-plugin-vs-code-custom-data-generator'
export default {
globs: [
'../demo/examples/typed-props/index.ts',
'../demo/examples/props-blueprint/hello-world.js',
'../demo/examples/strict-props/index.js',
'../demo/examples/type-restore/Object.mjs',
'../demo/examples/attribute-lifecycle/index.js',
'../demo/examples/on-changes/index.js',
'../demo/examples/demo/BooleanPropTest.mjs',
],
outdir: '.',
plugins: [
wcbStaticProps(),
// Emits vscode.html-custom-data.json, giving VS Code tag + attribute
// completion in .html files. `outdir` points at the repo root because
// `html.customData` in .vscode/settings.json resolves from the workspace
// root, not from the settings file.
//
// `cssFileName: null` turns off the companion CSS custom data file: it is
// built from `@cssprop` / `@csspart` JSDoc tags, which none of these
// components declare, so it only ever came out empty.
generateCustomData({ outdir: '..', cssFileName: null }),
],
}

25
storybook/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "storybook",
"private": true,
"type": "module",
"version": "0.0.0",
"description": "Storybook testing ground for wcb components + the CEM analyzer plugin",
"scripts": {
"analyze": "cem analyze",
"dev": "pnpm run analyze && storybook dev -p 6006 --no-open",
"build": "pnpm run analyze && storybook build"
},
"dependencies": {
"web-component-base": "workspace:*"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "^0.11.0",
"@storybook/addon-docs": "^9.1.15",
"@storybook/web-components-vite": "^9.1.15",
"cem-plugin-vs-code-custom-data-generator": "^1.4.2",
"lit": "^3.3.1",
"storybook": "^9.1.15",
"vite": "^7.3.1"
},
"customElements": "custom-elements.json"
}

View file

@ -0,0 +1,14 @@
import { html } from 'lit'
import '../../demo/examples/props-blueprint/hello-world.js'
// `myName` is the interesting case: the plugin runs it through wcb's own
// `getKebabCase`, so the manifest attribute is `my-name` — exactly what
// `observedAttributes` reflects. Set the control and the element updates.
export default {
title: 'Demo/Hello world',
component: 'hello-world',
render: ({ myName }) => html`<hello-world my-name=${myName}></hello-world>`,
}
export const Default = { args: { myName: 'World' } }
export const Named = { args: { myName: 'Ayo' } }

View file

@ -0,0 +1,41 @@
import { html } from 'lit'
import '../../demo/examples/demo/BooleanPropTest.mjs'
import '../../demo/examples/type-restore/Object.mjs'
import '../../demo/examples/strict-props/index.js'
// One story per default-literal shape the plugin infers a type from, so the
// controls panel is the assertion: boolean → toggle, number → number input,
// object → JSON editor, string → text field.
export const BooleanProps = {
name: 'boolean → toggle',
parameters: {
docs: { description: { story: 'Both props default to `false`.' } },
},
args: { isInline: false, anotherone: false },
render: ({ isInline, anotherone }) => html`
<boolean-prop-test
is-inline=${isInline}
anotherone=${anotherone}
></boolean-prop-test>
`,
}
export const NumberProps = {
name: 'number → number input',
args: { count: 0 },
render: ({ count }) =>
html`<lenient-counter count=${count}></lenient-counter>`,
}
export const ObjectProps = {
name: 'object → JSON editor',
args: { object: { hello: 'worldzz', age: 2 } },
render: ({ object }) =>
html`<my-object object=${JSON.stringify(object)}></my-object>`,
}
export default {
title: 'Plugin/Inferred control types',
component: 'boolean-prop-test',
}

View file

@ -0,0 +1,29 @@
import { html } from 'lit'
import '../../demo/examples/typed-props/index.ts'
// No `argTypes` anywhere in this file: the controls and the attributes table
// come from custom-elements.json, which the wcb CEM plugin filled in from
// `static props = { variant, disabled, clicks }`.
export default {
title: 'Demo/Typed button',
component: 'typed-button',
render: ({ variant, disabled, clicks }) => html`
<typed-button
variant=${variant}
disabled=${disabled}
clicks=${clicks}
></typed-button>
`,
}
export const Default = {
args: { variant: 'primary', disabled: false, clicks: 0 },
}
export const Ghost = {
args: { variant: 'ghost', disabled: false, clicks: 7 },
}
export const Disabled = {
args: { variant: 'secondary', disabled: true, clicks: 0 },
}

187
test/cem-plugin.test.mjs Normal file
View file

@ -0,0 +1,187 @@
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 { getKebabCase } from '../src/utils/index.js'
// The analyzer resolves its own `typescript`, which may be a different version
// than ours. `SyntaxKind` values shift between releases, so a source file built
// by a different instance is invisible to the analyzer's node checks — build it
// with the exact instance the analyzer uses.
const require = createRequire(import.meta.url)
const ts = require(
createRequire(
require.resolve('@custom-elements-manifest/analyzer/src/create.js')
).resolve('typescript')
)
/**
* Runs the real analyzer over a source string with the plugin installed and
* returns the resulting classDoc the same path `cem analyze` takes.
* @param {string} source component source to analyze
* @param {string} [className] class to return the doc for
* @returns {any} the classDoc from the generated manifest
*/
function analyze(source, className = 'CozyButton') {
const manifest = create({
modules: [
ts.createSourceFile(
'my-element.js',
source,
ts.ScriptTarget.ES2015,
true
),
],
plugins: [wcbStaticProps()],
context: { dev: false, thirdPartyCEMs: [] },
})
return manifest.modules[0].declarations.find((d) => d.name === className)
}
const COZY_BUTTON = `
import { WebComponent, html } from 'web-component-base'
export class CozyButton extends WebComponent {
static props = {
variant: 'primary',
disabled: false,
maxCount: 3,
config: { size: 'md' },
items: [],
}
static shadowRootInit = { mode: 'open' }
static styles = ':host { display: block }'
static strictProps = true
get template() {
return html\`<button>\${this.props.variant}</button>\`
}
}
customElements.define('cozy-button', CozyButton)
`
describe('cem-plugin: wcbStaticProps', () => {
const doc = analyze(COZY_BUTTON)
const attrNamed = (name) => doc.attributes.find((a) => a.name === name)
const fieldNamed = (name) => doc.members.find((m) => m.name === name)
it('emits one attribute per declared prop', () => {
expect(doc.attributes.map((a) => a.name).sort()).toEqual([
'config',
'disabled',
'items',
'max-count',
'variant',
])
})
it('infers the type from the default literal', () => {
expect(attrNamed('variant').type).toEqual({ text: 'string' })
expect(attrNamed('disabled').type).toEqual({ text: 'boolean' })
expect(attrNamed('max-count').type).toEqual({ text: 'number' })
expect(attrNamed('config').type).toEqual({ text: 'object' })
expect(attrNamed('items').type).toEqual({ text: 'object' })
})
it('records the default and the camelCase field it maps to', () => {
expect(attrNamed('variant')).toMatchObject({
fieldName: 'variant',
default: "'primary'",
})
expect(attrNamed('max-count').fieldName).toBe('maxCount')
})
it('names attributes with wcb getKebabCase, matching observedAttributes', () => {
for (const attribute of doc.attributes)
expect(attribute.name).toBe(getKebabCase(attribute.fieldName))
})
it('emits a matching public field per prop', () => {
expect(fieldNamed('variant')).toMatchObject({
kind: 'field',
privacy: 'public',
type: { text: 'string' },
attribute: 'variant',
})
expect(fieldNamed('maxCount').attribute).toBe('max-count')
})
it('strips wcb internals from the public surface', () => {
const names = doc.members.map((m) => m.name)
for (const internal of [
'props',
'shadowRootInit',
'styles',
'strictProps',
'observedAttributes',
'template',
])
expect(names, internal).not.toContain(internal)
})
it('keeps the component authors own members', () => {
const withMethod = analyze(`
import { WebComponent } from 'web-component-base'
export class CozyButton extends WebComponent {
static props = { variant: 'primary' }
focusFirst() {}
}
`)
expect(withMethod.members.map((m) => m.name)).toContain('focusFirst')
})
it('strips internals from a wcb component that declares no props', () => {
const doc = analyze(`
import { WebComponent } from 'web-component-base'
export class CozyButton extends WebComponent {
static styles = ':host{}'
get template() { return '' }
}
`)
// the analyzer drops arrays it finds empty, so stripping every member
// leaves no `members` key at all
expect((doc.members ?? []).map((m) => m.name)).not.toContain('styles')
expect(doc.attributes ?? []).toEqual([])
})
// The typed-props pattern hoists the defaults into a const so the class can
// write `extends WebComponent<typeof props>` — a class can't reference its
// own static in its heritage clause. `static props` is then an identifier,
// not an object literal.
it('resolves static props hoisted into a module-level const', () => {
const doc = analyze(`
import { WebComponent } from 'web-component-base'
const buttonProps = { variant: 'primary', maxCount: 2 }
export class CozyButton extends WebComponent {
static props = buttonProps
}
`)
expect(doc.attributes.map((a) => a.name).sort()).toEqual([
'max-count',
'variant',
])
expect(doc.attributes.find((a) => a.name === 'max-count').type).toEqual({
text: 'number',
})
})
it('resolves static props declared with `as const`', () => {
const doc = analyze(`
import { WebComponent } from 'web-component-base'
const buttonProps = { disabled: false } as const
export class CozyButton extends WebComponent {
static props = buttonProps
}
`)
expect(doc.attributes.map((a) => a.name)).toEqual(['disabled'])
})
it('leaves non-wcb classes untouched', () => {
const doc = analyze(
`export class Plain extends HTMLElement { static props = 1 }`,
'Plain'
)
expect(doc.attributes).toBeUndefined()
expect(doc.members.map((m) => m.name)).toContain('props')
})
})

View file

@ -13,6 +13,20 @@ describe('main exports', () => {
}) })
}) })
describe('cem-plugin entry', () => {
it('is reachable from its own subpath', async () => {
const mod = await import('../src/cem-plugin.js')
expect(typeof mod.wcbStaticProps).toBe('function')
expect(mod.wcbStaticProps().name).toBe('wcb-static-props')
})
it('is not reachable from the package root', () => {
// dev-only tooling must never be pulled into the browser bundle or the
// size-limit budget
expect(Object.keys(main)).not.toContain('wcbStaticProps')
})
})
describe('utils exports', () => { describe('utils exports', () => {
it('exposes every documented utility', () => { it('exposes every documented utility', () => {
for (const name of [ for (const name of [