Publishing HTML Data for Editor Autocomplete: Completion for Consumers Who Never Import

A large share of a component library’s consumers never open a TypeScript file. They write HTML in a template, a CMS block, a Rails view, or a static site generator, and none of the type declarations a package ships will ever reach them. For those consumers the question “what attributes does <wfc-banner> accept?” is answered by reading documentation, guessing, or giving up.

Editors answer it for native elements automatically, and they will answer it for custom elements too — from a small JSON file the package publishes describing its tags. It is the cheapest documentation a component library can ship, it is generated rather than written, and it reaches exactly the consumers least likely to read a documentation site. This deep dive belongs to Types & the Custom Elements Manifest inside Distribution, Testing & Tooling.

Problem Statement: The Attribute Nobody Can Discover

<!-- A consumer's template. No TypeScript anywhere in this project. -->
<wfc-banner tone="warn" dismissable>
  Deployment is degraded.
</wfc-banner>

Two mistakes, neither reported. The tone attribute accepts info, warning, or danger, and warn is not among them — so the component falls back to its default and the banner renders in the wrong colour. And dismissable is a misspelling of dismissible, so the close button never appears. The editor offered no completion for the tag, no list of valid values, and no warning for the unknown attribute, because as far as it knows wfc-banner is an unknown element and unknown elements accept anything.

The team’s usual answer — “it is in the documentation” — is true and does not help, because the consumer had no reason to think there was anything to look up.

Which consumers each published artefact reaches Type declarations serve TypeScript consumers, JSX augmentations serve framework consumers, and only a custom data file reaches consumers writing plain HTML in templates and content systems. Coverage by consumer type .d.ts declarations TypeScript app code plain .html files CMS and template blocks requires an import and a TypeScript project JSX augmentations React, Preact, Solid plain .html files server-rendered templates one entry point per JSX runtime html custom data plain .html files templates and partials any project, no build one setting, one file, every consumer covered

Root-Cause Analysis

Editors type-check and complete HTML from a built-in dataset describing the elements and attributes of the HTML standard. Custom elements are, by construction, not in it — the whole point of the custom element name rule (a hyphen in the tag) is that the platform reserves that space for authors, so no editor can know what those tags mean.

The escape hatch is a custom data file: a small JSON document, versioned by a version field, listing tags with their descriptions, attributes, allowed values, and documentation links. An editor pointed at one merges it with the built-in dataset, and from that moment the custom element behaves like a native one for completion, hover documentation, and unknown-attribute diagnostics.

Three properties make it a good fit for a component library.

It is data, not code. No runtime, no build integration on the consumer side, no dependency. A consumer adds one setting pointing at a path inside node_modules and gets completion in every HTML file in the project.

It describes the HTML surface exactly. Tags, attributes, and enumerated values are the whole of what an HTML author can write, which is precisely the subset the type system cannot reach and documentation describes least reliably.

It is derivable. Every field it needs already exists in the custom elements manifest, so it should be generated in the same analysis pass rather than maintained separately.

Debugging Pitfall — the file that ships but cannot be read. A package with an exports map blocks deep imports by default, and editors resolve the custom data path through the filesystem rather than through module resolution — so a file present in the tarball but absent from files, or a path the consumer wrote against a directory layout that changed, both fail silently with no completion and no error. Verify by running npm pack --dry-run and confirming the JSON is listed, then by opening a scratch project that installs the tarball and typing the tag.

There is one more subtlety worth planning for: attribute values in the data file are hints, not constraints. An editor offers them in a completion list and, depending on configuration, may warn about values outside the list — but it will not fail a build. The file improves discoverability; it does not enforce correctness. Enforcement, where it matters, belongs to the component’s own attribute validation, which is the subject of syncing HTML attributes to JavaScript properties.

Production-Safe Pattern

Generate the file from the manifest in the same pass, publish it as a package file, and document the one-line consumer setup.

// scripts/html-data.mjs — run immediately after the manifest is generated.
import { readFileSync, writeFileSync } from 'node:fs';

const manifest = JSON.parse(readFileSync('custom-elements.json', 'utf8'));

/** Turn "'info' | 'warning' | 'danger'" into editor value hints. */
const valuesFromUnion = (text) => {
  if (!text) return undefined;
  const found = [...text.matchAll(/'([^']+)'/g)].map((match) => ({ name: match[1] }));
  return found.length ? found : undefined;
};

const tags = [];
for (const module of manifest.modules ?? []) {
  for (const declaration of module.declarations ?? []) {
    if (!declaration.tagName) continue;

    tags.push({
      name: declaration.tagName,
      description: {
        kind: 'markdown',
        value: declaration.summary ?? declaration.description ?? ''
      },
      attributes: (declaration.attributes ?? []).map((attribute) => ({
        name: attribute.name,
        description: {
          kind: 'markdown',
          value: [
            attribute.description ?? '',
            attribute.default ? `\n\nDefault: \`${attribute.default}\`` : ''
          ].join('')
        },
        values: valuesFromUnion(attribute.type?.text)
      })),
      references: [{
        name: 'Component documentation',
        url: `https://example.com/components/${declaration.tagName}`
      }]
    });
  }
}

writeFileSync(
  'vscode.html-custom-data.json',
  `${JSON.stringify({ version: 1.1, tags }, null, 2)}\n`
);
{
  "name": "@wfc/components",
  "customElements": "custom-elements.json",
  "files": ["dist", "custom-elements.json", "vscode.html-custom-data.json"],
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "./custom-elements.json": "./custom-elements.json",
    "./vscode.html-custom-data.json": "./vscode.html-custom-data.json"
  },
  "scripts": {
    "analyze": "custom-elements-manifest analyze && node scripts/html-data.mjs",
    "prepack": "npm run build && npm run analyze",
    "verify:data": "npm run analyze && git diff --exit-code custom-elements.json vscode.html-custom-data.json"
  }
}
// The consumer's side: one setting, committed to their repository.
{
  "html.customData": [
    "./node_modules/@wfc/components/vscode.html-custom-data.json"
  ]
}

Four details carry the reliability. Generation runs in the same script as the manifest analysis, so the two artefacts cannot describe different commits. The file is listed in files and given an explicit exports entry, so both the filesystem path and module resolution work. The verify:data script fails CI on any drift, exactly as the manifest check does. And every tag carries a references link, so hovering the tag in an editor offers a route to the full documentation rather than only the one-line summary.

The description objects use the markdown form deliberately: editors render it, so a description can include a short code example or a link without becoming unreadable plain text.

From a JSDoc annotation to a completion in a consumer's editor An attribute annotation becomes a manifest field, then an editor data entry with value hints, and finally a completion list with descriptions in a plain HTML file. 1. annotation @attr {'info'|'warning' |'danger'} tone 2. manifest field attributes[0].type.text carries the union 3. editor data values: info, warning, danger 4. completion offered inline with descriptions Break the chain at step one and the completion appears with no values — an annotation gap, not a generation bug. Which is why the union type in @attr is production code: it is the only place the valid values are written down.

Verification

import { readFileSync } from 'node:fs';
import { strict as assert } from 'node:assert';

const data = JSON.parse(readFileSync('vscode.html-custom-data.json', 'utf8'));
const manifest = JSON.parse(readFileSync('custom-elements.json', 'utf8'));

// 1. The schema version editors expect.
assert.equal(data.version, 1.1);

// 2. Every custom element in the manifest appears in the editor data.
const manifestTags = manifest.modules
  .flatMap((mod) => mod.declarations ?? [])
  .filter((decl) => decl.tagName)
  .map((decl) => decl.tagName)
  .sort();
const dataTags = data.tags.map((tag) => tag.name).sort();
assert.deepEqual(dataTags, manifestTags, 'editor data must cover every declared tag');

// 3. Attribute lists match the manifest exactly — no invented or dropped attributes.
for (const tag of data.tags) {
  const declaration = manifest.modules
    .flatMap((mod) => mod.declarations ?? [])
    .find((decl) => decl.tagName === tag.name);
  const fromManifest = (declaration.attributes ?? []).map((attr) => attr.name).sort();
  const fromData = tag.attributes.map((attr) => attr.name).sort();
  assert.deepEqual(fromData, fromManifest, `${tag.name}: attribute lists must agree`);
}

// 4. Every tag has a description — an empty one is worse than useless in a hover.
for (const tag of data.tags) {
  assert.ok(tag.description?.value?.length > 0, `${tag.name} needs a description`);
}

The manual check takes a minute and is worth doing once per release: create a scratch directory, install the packed tarball, add the html.customData setting, open an .html file, and type <wfc-. The completion list should appear with descriptions; typing tone=" should offer the three valid values. If the tag completes but the values do not, the union type was never annotated in the source, which is an annotation gap rather than a generation bug.

When to Use vs When to Avoid

Situation Ship editor custom data?
Published component library Yes — it reaches the consumers other artefacts miss
Design system used across many teams Yes, and document the one-line setup in the getting-started guide
Components consumed only from TypeScript Optional — declarations already cover those consumers
Application-private components Yes, and check the data file into the app repository
Components whose tags are generated at runtime No — there is no stable tag to describe
Prototype with no external consumers Skip until someone outside the team writes the markup

The fourth row is the underused case. An application with its own custom elements benefits from exactly the same file, generated from its own source and referenced by a relative path in the workspace settings — no publishing involved. That gives every developer on the team completion for the team’s own components, which is often the first place the value becomes obvious.

Setup effort against the number of consumers each artefact reaches The editor data file requires one consumer-side setting and no build integration, while type declarations and JSX augmentations require a TypeScript project and a per-runtime import. What each artefact asks of the consumer html custom data 1 setting no build, no import, works in every project .d.ts declarations TypeScript project plus an import of the module JSX augmentation TypeScript + matching runtime entry point one per runtime Why it is worth generating even for a small library It is the only artefact that costs the consumer nothing and reaches people who never open a build config.

Frequently Asked Questions

Do I have to write the custom data file by hand?

No, and you should not. Every field it needs already exists in the custom elements manifest, so generate it in the same analysis pass and verify both artefacts with one no-diff CI check.

Does the file enforce valid attribute values?

No. Editors treat listed values as completion hints and may warn about others, but nothing fails a build. Enforcement belongs to the component’s own attribute handling; the data file is about discoverability.

Why does completion not appear after installing the package?

Usually the file was omitted from files, or the consumer’s path setting points at a layout that changed. Confirm with npm pack --dry-run that the JSON ships, then test the exact path from a scratch project.

Is this useful for components used only inside one application?

Very. Generate the file from the application’s own source and point the workspace settings at a relative path — no publishing needed, and every developer gets completion for the team’s own elements.