feat: new cem analyzer plugin (cem-plugin)
This commit is contained in:
parent
861310c6bf
commit
2da6cd5afc
22 changed files with 2339 additions and 65 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>`.
|
||||||
|
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
202
docs/src/content/docs/guides/cem-plugin.mdx
Normal file
202
docs/src/content/docs/guides/cem-plugin.mdx
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/*'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
1493
pnpm-lock.yaml
1493
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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
208
src/cem-plugin.js
Normal 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
|
||||||
41
storybook/.storybook/main.js
Normal file
41
storybook/.storybook/main.js
Normal 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] } },
|
||||||
|
}),
|
||||||
|
}
|
||||||
16
storybook/.storybook/preview.js
Normal file
16
storybook/.storybook/preview.js
Normal 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
28
storybook/README.md
Normal 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.
|
||||||
41
storybook/custom-elements-manifest.config.mjs
Normal file
41
storybook/custom-elements-manifest.config.mjs
Normal 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
25
storybook/package.json
Normal 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"
|
||||||
|
}
|
||||||
14
storybook/stories/hello-world.stories.js
Normal file
14
storybook/stories/hello-world.stories.js
Normal 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' } }
|
||||||
41
storybook/stories/prop-types.stories.js
Normal file
41
storybook/stories/prop-types.stories.js
Normal 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',
|
||||||
|
}
|
||||||
29
storybook/stories/typed-button.stories.js
Normal file
29
storybook/stories/typed-button.stories.js
Normal 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
187
test/cem-plugin.test.mjs
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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 [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue