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.
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.
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.
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.
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.
Related
- Types & the Custom Elements Manifest — the parent topic on declaration artefacts.
- Distribution, Testing & Tooling — the section this deep dive belongs to.
- Generating a Custom Elements Manifest — the source this file is derived from.
- Declaring HTMLElementTagNameMap Entries — the artefact covering TypeScript consumers.
- Syncing HTML Attributes to JavaScript Properties — where attribute validation actually belongs.