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.

How a stale manifest propagates wrong information downstream A manifest that was not regenerated feeds outdated attribute and event names into editor completion, generated documentation and framework wrappers, and every resulting failure is silent. source today attr: tone event: banner-dismiss manifest committed attr: variant event: dismiss editor completion offers variant= generated docs documents dismiss framework wrapper emits onDismiss consumer result attribute ignored handler never fires docs describe fiction no error anywhere no build failure a no-diff CI check catches all of 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.

Debugging Pitfall — analysing the build output instead of the source. Pointing the analyser at 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.

What static analysis can infer and what must be annotated Tag names, class members and observed attributes are inferable from source, while events, slots, parts and CSS custom properties require JSDoc annotations the analyser cannot derive. Inferred from source tagName from define() public fields and methods observedAttributes entries superclass and module path free, and correct by construction Requires an annotation @fires — name and detail type @slot — name and purpose @csspart — exposed regions @cssprop — themeable tokens absent annotation = absent feature

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.

Which npm lifecycle hook runs for which packaging path prepare and prepack run for pack, publish and git installs, while prepublishOnly runs only on publish, which is why generation belongs in prepack. Coverage of each lifecycle hook npm pack prepack prepare prepublishOnly: no npm publish prepack prepare prepublishOnly: yes git dependency install prepack: no prepare prepublishOnly: no The safe choice Put generation in prepack for tarballs, and in prepare too when consumers install from git.

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.