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
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -15,4 +15,13 @@ dist/
|
|||
.eslintcache
|
||||
|
||||
# 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,
|
||||
"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 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 -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 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>`.
|
||||
|
||||
|
|
|
|||
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.
|
||||
|
||||
## 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?
|
||||
|
||||
There are many ways to get in touch:
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export default defineConfig({
|
|||
'styling',
|
||||
'just-parts',
|
||||
'life-cycle-hooks',
|
||||
'cem-plugin',
|
||||
'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.
|
||||
|
||||
Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'.
|
||||
|
||||
</Aside>
|
||||
|
||||
### Typed props in TypeScript
|
||||
|
|
|
|||
|
|
@ -18,7 +18,15 @@ export default [
|
|||
files: ['**/*.config.{js,mjs,cjs}', 'scripts/**/*.{js,mjs}'],
|
||||
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",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./cem-plugin": {
|
||||
"types": "./dist/cem-plugin.d.ts",
|
||||
"import": "./dist/cem-plugin.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"import": "./dist/*.js"
|
||||
|
|
@ -43,6 +47,8 @@
|
|||
"demo": "pnpm -F demo dev",
|
||||
"demo:build": "pnpm -F demo build",
|
||||
"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",
|
||||
"size-limit": "pnpm run build && size-limit",
|
||||
"clean": "rm -rf dist",
|
||||
|
|
@ -76,6 +82,7 @@
|
|||
"url": "https://github.com/ayo-run/wcb/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "^0.11.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@size-limit/preset-small-lib": "^12.0.0",
|
||||
"@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/)
|
||||
- 'docs/'
|
||||
- 'demo/'
|
||||
- 'storybook/'
|
||||
allowBuilds:
|
||||
esbuild: true # insurance for core build
|
||||
'@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', () => {
|
||||
it('exposes every documented utility', () => {
|
||||
for (const name of [
|
||||
|
|
|
|||
Loading…
Reference in a new issue