Generating a Custom Elements Manifest: Analyser Configuration and Drift Prevention
A manifest is only useful if it is true. The failure that actually happens in production is not a missing manifest — it is a manifest that describes a component the package no longer contains, because it was generated once, committed, and then never regenerated after a refactor. Editors then offer completions for attributes that were removed, documentation shows events that no longer fire, and framework wrapper generators emit props that map to nothing.
Preventing that is a pipeline problem, not an authoring one. The analyser runs from source, the output is committed, and CI fails if regenerating produces a diff. This deep dive belongs to Types & the Custom Elements Manifest inside Distribution, Testing & Tooling.
Problem Statement: The Manifest That Describes Last Month’s Component
// custom-elements.json — committed six weeks ago
{
"schemaVersion": "1.0.0",
"modules": [{
"kind": "javascript-module",
"path": "src/banner.js",
"declarations": [{
"kind": "class",
"name": "WfcBanner",
"tagName": "wfc-banner",
"attributes": [
{ "name": "variant", "type": { "text": "'info' | 'warn'" } } // renamed to `tone`
],
"events": [
{ "name": "dismiss", "type": { "text": "CustomEvent" } } // renamed to `banner-dismiss`
]
}]
}]
}
// src/banner.js — today
class WfcBanner extends HTMLElement {
static observedAttributes = ['tone', 'dismissible']; // `variant` is gone
dismiss(reason = 'user') {
this.dispatchEvent(new CustomEvent('banner-dismiss', { detail: { reason } }));
}
}
customElements.define('wfc-banner', WfcBanner);
Nothing errors. The package publishes, the editor offers variant= as a completion, a consumer writes it, and the attribute is silently ignored at runtime because it is not observed. A wrapper generator emits an onDismiss prop that listens for an event that is never dispatched. Every one of those failures is silent, and each is discovered by a consumer rather than by the team that caused it.
Root-Cause Analysis
The manifest is a derived artefact that is committed to source control, and every artefact in that category has the same failure mode: the derivation is not re-run, and nothing notices. Lockfiles, generated clients, and compiled protocol buffers all share it, and they all share the same remedy — regenerate in CI and fail on a diff.
Two properties of the analyser make the drift especially easy to introduce.
Analysis is static. The analyser reads source text; it does not run the component. So it can find customElements.define('wfc-banner', WfcBanner) and the observedAttributes array, but it cannot know that dispatchEvent is called with a template literal, or that an attribute name comes from a constant defined in another module. Anything it cannot see must be supplied by a JSDoc annotation, and an annotation that is not updated alongside the code is exactly the drift under discussion.
Framework flavours change what is inferable. Component classes built on a base library declare their reactive properties in a static block the analyser understands only if told which flavour to expect. Running the analyser without the right flag produces a manifest that is valid and incomplete — declarations appear with no attributes at all — which looks like a component with no API rather than an analysis failure.
The third factor is ordering. The manifest must be generated from the same source the build compiled, and it must be generated before the tarball is assembled. A script hung off prepublishOnly runs after the version bump and, depending on the tool, after packing — so the shipped JSON can describe a different commit than the shipped JavaScript.
dist/ seems tidier and quietly destroys the result: bundlers strip JSDoc comments, minifiers rename classes, and transpilers rewrite class fields into constructor assignments the analyser no longer recognises. Every annotation-derived field — slots, parts, events, CSS properties — disappears, leaving a manifest that lists tag names and nothing else. Always analyse src/.
Production-Safe Configuration
The analyser is configured by a file at the package root. The configuration below analyses source, excludes tests and stories, emits both the manifest and the editor data file, and is wired so that generation always precedes packing.
// custom-elements-manifest.config.mjs
export default {
globs: ['src/**/*.js'],
exclude: ['src/**/*.test.js', 'src/**/*.stories.js', 'src/**/fixtures/**'],
outdir: '.',
litelement: false, // set true for Lit-based components
dev: false,
packagejson: true, // write the `customElements` field into package.json
plugins: [
// Emit editor custom-data alongside the manifest, from the same analysis.
{
name: 'html-custom-data',
packageLinkPhase({ customElementsManifest }) {
const tags = [];
for (const mod of customElementsManifest.modules ?? []) {
for (const decl of mod.declarations ?? []) {
if (!decl.tagName) continue;
tags.push({
name: decl.tagName,
description: decl.summary ?? decl.description ?? '',
attributes: (decl.attributes ?? []).map((attr) => ({
name: attr.name,
description: attr.description ?? '',
values: parseUnion(attr.type?.text)
}))
});
}
}
this.emitFile?.('vscode.html-custom-data.json',
JSON.stringify({ version: 1.1, tags }, null, 2));
}
}
]
};
/** Turn a union type string like "'info' | 'warning'" into editor value hints. */
function parseUnion(text) {
if (!text) return undefined;
const values = [...text.matchAll(/'([^']+)'/g)].map((match) => ({ name: match[1] }));
return values.length ? values : undefined;
}
{
"scripts": {
"build": "node scripts/build.mjs",
"analyze": "custom-elements-manifest analyze",
"prepack": "npm run build && npm run analyze",
"verify:manifest": "npm run analyze && git diff --exit-code custom-elements.json vscode.html-custom-data.json"
}
}
# .github/workflows/ci.yml — the check that makes the artefact trustworthy
- name: Verify generated artefacts are current
run: npm run verify:manifest
Four decisions carry the reliability. Analysis reads src/, so annotations survive. Tests and stories are excluded, so demo components never leak into the published API surface. Generation is hung off prepack, which every packaging path runs, rather than prepublishOnly, which some do not. And verify:manifest regenerates and asserts an empty diff, converting “someone should re-run this” into a failing build.
The plugin deriving editor data from the same analysis is the pattern to generalise: any second artefact — a documentation JSON, a wrapper-generation input, a props table — should be derived in the same pass rather than by a separate tool reading the manifest later, so there is exactly one moment where drift could occur and it is guarded by one check.
Verification
Beyond the no-diff check, assert the manifest’s content rather than only its freshness. A small test that reads the JSON and compares it against the components’ real behaviour catches the case where source and annotations drifted together.
import { readFileSync } from 'node:fs';
import { strict as assert } from 'node:assert';
const manifest = JSON.parse(readFileSync('custom-elements.json', 'utf8'));
const declarations = manifest.modules
.flatMap((mod) => mod.declarations ?? [])
.filter((decl) => decl.tagName);
// 1. Every declared tag is actually registered when the package is imported.
await import('./src/index.js');
for (const decl of declarations) {
assert.ok(customElements.get(decl.tagName), `${decl.tagName} must be defined`);
}
// 2. Declared attributes match observedAttributes exactly — no extras, none missing.
for (const decl of declarations) {
const ctor = customElements.get(decl.tagName);
const observed = new Set(ctor.observedAttributes ?? []);
const declared = new Set((decl.attributes ?? []).map((attr) => attr.name));
assert.deepEqual([...declared].sort(), [...observed].sort(),
`${decl.tagName}: manifest attributes must match observedAttributes`);
}
// 3. Every declared event name is dispatched by the component at least once
// across the component's own test fixtures.
assert.ok(declarations.every((decl) => (decl.events ?? []).every((ev) => /^[a-z][a-z0-9-]*$/.test(ev.name))),
'event names must be lowercase and hyphenated');
// 4. The schema version is the one the tooling expects.
assert.equal(manifest.schemaVersion, '1.0.0');
Assertion two is the highest-value one and costs nothing: it mechanically ties the documented attribute list to the runtime’s own observedAttributes, which is the pair that drifts most often. Running npm pack --dry-run in CI and asserting that custom-elements.json appears in the file list closes the other common gap — a correct manifest that was never published.
When to Use vs When to Avoid
| Situation | Approach |
|---|---|
| Published component library | Generate, commit, verify with a no-diff CI check |
| Internal design system in a monorepo | Same, plus consume the manifest from sibling packages |
| Single application-private component | Skip — no external consumer needs the artefact |
| Components generated at build time | Analyse the generated source, not the runtime output |
| Package with a mix of Lit and vanilla classes | Run the analyser once per flavour, merge the outputs |
| Prototype or spike | Skip until the package has an external consumer |
The mixed-flavour row is a real situation in libraries that migrated incrementally. Running the analyser twice with different flags and merging the modules arrays is straightforward; running it once with the wrong flag produces a manifest that silently omits the other half of the components’ APIs.
Frequently Asked Questions
Should the analyser read src/ or dist/?
Always src/. Bundlers strip the JSDoc comments the analyser relies on, and transpilation rewrites class fields into forms it no longer recognises, so analysing build output silently drops every annotation-derived field.
How do I stop the manifest going stale?
Regenerate it in CI and fail on any diff. That converts a derived artefact from something someone has to remember into something the build guarantees, exactly as a lockfile check does.
Why is my manifest missing all the attributes?
Usually the analyser was run without the flavour flag for the base library the components use, so it could not recognise the reactive-property declaration. The output is valid and empty rather than an error, which is why it goes unnoticed.
Which lifecycle script should generate it?
prepack. It runs for every packaging path including npm pack and npm publish, whereas prepublishOnly runs only on publish and, depending on the tool, after the tarball has already been assembled.
Related
- Types & the Custom Elements Manifest — the parent topic on declaration artefacts.
- Distribution, Testing & Tooling — the section this deep dive belongs to.
- Publishing HTML Data for Editor Autocomplete — the derived artefact the plugin above emits.
- Declaring HTMLElementTagNameMap Entries — the type side of the same information.
- Configuring package.json exports — making the generated files reachable by consumers.