Skip to content

Commit 1e8e740

Browse files
committed
feat(cli): answer the component contract per component, not per catalog
The agent-facing guidance named dist/components.json as the source of truth, which is an instruction to read 13,634 tokens — 36% of it JSON punctuation — to learn about a handful of components. The bin already answered the same questions for 50 tokens each; the docs pointed past it. `components` now takes several names in one call, and `--used <file>` prints the contract for exactly the components a file contains. It scans for tags rather than compiling, so it still answers for an artifact mid-edit. Across the shipped examples that costs 100-1,576 tokens against 13,634. The manifest keeps its envelope and its exact-version guarantee as bulk data for tools; agent-facing docs now name the CLI only.
1 parent 4629ad6 commit 1e8e740

8 files changed

Lines changed: 283 additions & 34 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ passes, and a diagram that cannot load keeps its fence text. Disable it with
9494

9595
## Components
9696

97-
The runtime ships 87 components. Its `htmdx@2` exact-version `dist/components.json` manifest documents every component's purpose, canonical example, body mode, props, and source.
97+
The runtime ships 89 components. `npx @wix/htmdx components <name...>` prints a component's purpose, canonical example, body mode, props, and source, and `--used <file>` prints that contract for exactly the components a file already contains. The same data ships in bulk as the `htmdx@2` exact-version `dist/components.json` manifest, for tools that consume the whole catalog.
9898

9999
**Report Built-ins** cover summaries, callouts, metrics, charts, tables, timelines, findings, evidence, and risks. Their `markdown` bodies reject nested tags, and each definition's purpose and example state any stricter list or table grammar. Components with `htmdx` bodies accept Markdown, HTML, and nested registered tags; components with `none` bodies accept only empty or self-closing tags.
100100

@@ -288,7 +288,7 @@ Exit codes are `0` clean, `1` problems found, `2` could not run. `--format json`
288288

289289
`lint` accepts an HTML artifact — the source comes from its `<script type="text/htmdx">` block and positions are reported against the artifact — or a bare source file. On top of everything `validate()` reports, it adds two findings that only exist at the artifact level: `unpinned-runtime` (the runtime `<script>` has no pinned version, so a future release can change the artifact) and `runtime-version-mismatch` (the artifact pins a version other than the one linting it). This repo lints its own examples this way in CI.
290290

291-
`components` reads the manifest built next to the bin, so the catalog it prints is the one that version renders — the thing to ask before writing a document rather than guessing at prop names. See the [package README](./packages/htmdx/README.md#command-line) for the full behavior, including how `invalid-html-nesting` dedupes across files in one run.
291+
`components` reads the manifest built next to the bin, so the catalog it prints is the one that version renders — the thing to ask before writing a document rather than guessing at prop names. Name several components in one call, or pass `--used <file>` to get the contract for exactly what an artifact already contains. See the [package README](./packages/htmdx/README.md#command-line) for the full behavior, including how `invalid-html-nesting` dedupes across files in one run.
292292

293293
## Agent skill
294294

@@ -301,7 +301,7 @@ The authoring guidance ships with the runtime and is printed by `htmdx skill`, s
301301
- npm: `@wix/htmdx` · CDN entry: `dist/browser.js` (under 160KB gzip, [budgeted in CI](./packages/htmdx/build/bundle-budget.json)) · module entries: `.`, `./react`, `./testing`, `./components`, `./components/builtins`, `./components/shadcn`
302302
- custom element: `<htmdx-code>` · browser API: `window.Htmdx`
303303
- linting: [`validate()`](#validation-and-linting) · CLI: [`lint`, `compile`, `components`](#command-line)`npx @wix/htmdx lint <files...>`
304-
- component contract: `dist/components.json`
304+
- component contract: `npx @wix/htmdx components <name...>` · bulk data: `dist/components.json`
305305
- agent guidance: `npx @wix/htmdx skill` (topics in [`packages/htmdx/skill/`](./packages/htmdx/skill/)), installable skill in [`skills/htmdx/`](./skills/htmdx/SKILL.md)
306306
- architecture decisions: [`adr/`](./adr/)
307307

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Answer the component contract from the CLI, not the manifest
2+
3+
- Status: accepted
4+
- Date: 2026-07-28
5+
- Extends [component-definitions-as-agent-contracts](component-definitions-as-agent-contracts.md),
6+
which made `dist/components.json` the contract, and
7+
[ship-agent-guidance-from-the-runtime](ship-agent-guidance-from-the-runtime.md),
8+
which put the guidance behind the bin
9+
10+
## Context
11+
12+
`dist/components.json` is the component contract, and the agent-facing docs said
13+
so literally. `skill/authoring.md` and `skill/components.md` both named the
14+
jsDelivr URL as "the source of truth when the network is reachable", which is an
15+
instruction to read the whole document.
16+
17+
Measured with `gpt-tokenizer` (`o200k_base`), that document is 13,634 tokens.
18+
The breakdown is what makes it a problem rather than a cost:
19+
20+
| Part | Tokens | Share |
21+
| --- | ---: | --- |
22+
| JSON punctuation and indentation | 4,873 | 36% |
23+
| `example` | 4,174 | 31% |
24+
| `props` | 2,326 | 17% |
25+
| `purpose` | 1,645 | 12% |
26+
| `name`, `body`, `source` | 616 | 4% |
27+
28+
Better than a third of it is structural syntax — quotes, braces, indentation,
29+
key names repeated 89 times. A machine parses that for free. A model pays for
30+
every token of it.
31+
32+
The same facts already came out of the bin far cheaper. `htmdx components`
33+
lists the catalog in 1,600 tokens; `htmdx components Callout` answers in 50.
34+
Nothing needed building for the common case to cost 3% of what the docs
35+
recommended — the commands existed and the docs pointed past them.
36+
37+
Two gaps kept the CLI from being a complete substitute. It took one name per
38+
invocation, so asking about five components meant five calls. And it had no way
39+
to answer "what does *this artifact* use", which is the question an edit starts
40+
from: an agent editing a file needs the grammar for the components in it, not
41+
for all 89.
42+
43+
## Decision
44+
45+
The CLI is the documented way to read the component contract. `components.json`
46+
keeps its `htmdx@2` envelope, its exact-version guarantee, and its place in the
47+
published package — it is bulk machine-readable data for tools that consume the
48+
whole catalog, and it is no longer what agent-facing documentation points at.
49+
50+
`htmdx components` grows the two modes that make it sufficient:
51+
52+
- `components <name...>` takes several names in one call. One unknown name
53+
fails the call rather than printing a partial answer that reads like a
54+
complete one.
55+
- `components --used <file>` prints the contract for exactly the components a
56+
file contains, taking the source from an artifact's
57+
`<script type="text/htmdx">` block or reading a bare source file directly.
58+
59+
`--used` scans for capitalized tags instead of compiling. Compiling would be
60+
more precise, but the artifact whose contract someone needs is disproportionately
61+
the one that does not compile yet, and an answer that requires a working document
62+
is unavailable exactly when it is wanted. The same tolerance means a tag inside a
63+
code fence counts: over-reporting costs a few lines, and missing a component
64+
costs the answer.
65+
66+
Measured across the shipped examples, `--used` costs 100 tokens (2 components)
67+
to 1,576 (16). `component-tour.html` is the outlier at 7,134, because it is a
68+
catalog demo that names 73 components — the case where reading everything is the
69+
correct answer, and where it still beats the manifest by half.
70+
71+
`--format json` keeps emitting the bare entry for a single name. Several names
72+
and `--used` emit an array. Preserving the single-name shape keeps existing
73+
callers working, at the cost of a response shape that varies with the request.
74+
75+
## Consequences
76+
77+
- The realistic contract read for an edit drops from 13,634 tokens to 100-1,576.
78+
- Two documented sources of truth for one catalog is how they drift, so
79+
agent-facing docs name the CLI only. `components.json` stays documented where
80+
it is consumed as data: the package README and the integration topic.
81+
- `--used` reports what a file mentions, not what it renders. A component named
82+
only inside a code fence appears in the output.
83+
- The manifest's own size is unaddressed. Family examples are the next lever:
84+
60 of 89 components sit in compound families whose canonical examples largely
85+
repeat each other (Avatar's three members ship one identical example,
86+
Breadcrumb's seven ship three), and 3,178 of the 4,174 example tokens belong
87+
to them. Deduplicating in a projection rather than in the manifest would keep
88+
the per-definition example validation this repo's build depends on.

packages/htmdx/README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ CDN caveats:
4848

4949
## Exact-version component manifest
5050

51+
`htmdx components` is the way to read this contract — it answers for the
52+
version that runs it, and it answers for the components asked about rather than
53+
all 89. The manifest below is the same contract as bulk machine-readable data,
54+
for tools that consume the whole catalog.
55+
5156
Every release includes its machine-readable component contract at:
5257

5358
<!-- x-release-please-start-version -->
@@ -535,8 +540,19 @@ browser bundle injects.
535540

536541
Prints the [component manifest](#exact-version-component-manifest) built next to
537542
the bin, so the catalog is the one that version renders. With no argument it
538-
lists every component grouped by source; with a name it prints that component's
539-
purpose, body mode, props, and canonical example.
543+
lists every component grouped by source; with one or more names it prints each
544+
component's purpose, body mode, props, and canonical example.
545+
546+
`--used <file>` prints that contract for exactly the components a file already
547+
contains, which is the shape an edit needs: the catalog for this artifact, not
548+
the catalog for everything. It scans for capitalized tags instead of compiling,
549+
so it still answers for source that is mid-edit and does not compile — and a tag
550+
inside a code fence counts, because over-reporting costs a few lines while
551+
missing a component costs the answer.
552+
553+
```bash
554+
$ npx @wix/htmdx components --used report.html
555+
```
540556

541557
```bash
542558
$ npx @wix/htmdx components Foldout
@@ -560,9 +576,10 @@ example:
560576

561577
A name that does not match exits `1` and suggests the closest entries, by
562578
substring and by edit distance, so a typo or a half-remembered name still lands:
563-
`unknown component "Calout"; did you mean Callout?`. `--format json` prints the
564-
manifest entry, or the whole manifest when no name is given — the shape to read
565-
before writing a document rather than guessing at prop names.
579+
`unknown component "Calout"; did you mean Callout?`. One unknown name among
580+
several fails the whole call, so a partial answer never reads as a complete one.
581+
`--format json` prints the manifest entry for a single name, an array for
582+
several or for `--used`, and the whole manifest when no name is given.
566583

567584
## Agent guidance
568585

packages/htmdx/skill/authoring.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,25 @@ in order is not an alternate view, and tabbing it hides content for no gain.
139139
starts with `Must-have`, `Differentiator`, `Not now`, or `Won't do`.
140140

141141
Body grammar is per component and is enforced at compile time. The `components`
142-
topic has the grammar for every built-in family with a working example. The
143-
exact-version manifest is the source of truth when the network is reachable:
142+
topic has the grammar for every built-in family with a working example. Ask the
143+
CLI for the contract of the components in play rather than reading the whole
144+
catalog:
144145

145146
<!-- x-release-please-start-version -->
146147

147-
```text
148-
https://cdn.jsdelivr.net/npm/@wix/htmdx@4.11.0/dist/components.json
148+
```bash
149+
npx @wix/htmdx@4.11.0 components Callout DataTable # the ones you plan to use
150+
npx @wix/htmdx@4.11.0 components --used report.html # the ones a file already uses
151+
npx @wix/htmdx@4.11.0 components # every name with its purpose
149152
```
150153

151154
<!-- x-release-please-end-version -->
152155

156+
Each command answers for the version that runs it. `dist/components.json` holds
157+
the same contract as machine-readable data, but it is the whole catalog in one
158+
document — reach for it when a tool consumes it, not to answer a question about
159+
a handful of components.
160+
153161
## Author the source
154162

155163
- **Attributes are data.** Every component accepts `class`, `id`, `aria-*`, and

packages/htmdx/skill/components.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
# Component grammar
22

33
The runtime ships two catalogs: 24 report built-ins and a 65-component
4-
shadcn/ui pack. Its exact-version manifest documents every component's purpose,
5-
canonical example, body mode, and props:
4+
shadcn/ui pack. Ask the CLI for a component's purpose, canonical example, body
5+
mode, and props:
66

77
<!-- x-release-please-start-version -->
88

9-
```text
10-
https://cdn.jsdelivr.net/npm/@wix/htmdx@4.11.0/dist/components.json
9+
```bash
10+
npx @wix/htmdx@4.11.0 components Callout DataTable # the ones you plan to use
11+
npx @wix/htmdx@4.11.0 components --used report.html # the ones a file already uses
12+
npx @wix/htmdx@4.11.0 components # every name with its purpose
1113
```
1214

1315
<!-- x-release-please-end-version -->
1416

15-
Read the manifest when the network is reachable. This file covers the grammar
16-
the manifest's `body` field alone does not spell out: the built-ins with a
17-
`markdown` body still parse that Markdown against a fixed row or table shape,
18-
and an invalid body fails the artifact.
17+
`--used` scans for tags rather than compiling, so it still answers for an
18+
artifact that is mid-edit and does not compile yet.
19+
20+
This file covers the grammar a component's `body` field alone does not spell
21+
out: the built-ins with a `markdown` body still parse that Markdown against a
22+
fixed row or table shape, and an invalid body fails the artifact.
1923

2024
## Body modes
2125

packages/htmdx/src/cli/components.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ export function findComponent(manifest: Manifest, name: string): ManifestCompone
3737
return manifest.components.find((entry) => entry.name.toLowerCase() === wanted);
3838
}
3939

40+
const COMPONENT_TAG = /<([A-Z][A-Za-z0-9]*)[\s/>]/g;
41+
42+
// Scans for capitalized tags instead of compiling the source: the artifact
43+
// whose contract someone needs is often the one that does not compile yet.
44+
// A tag inside a code fence counts too — over-reporting costs a few lines,
45+
// missing a component costs the answer.
46+
export function componentsUsedIn(manifest: Manifest, source: string): ManifestComponent[] {
47+
const used = new Set(
48+
Array.from(source.matchAll(COMPONENT_TAG), (match) => match[1].toLowerCase()),
49+
);
50+
return manifest.components.filter((entry) => used.has(entry.name.toLowerCase()));
51+
}
52+
4053
// A miss is usually a typo or a half-remembered name, so point at the closest
4154
// things rather than making the user re-read the whole list. Substring catches
4255
// the half-remembered case ("chart"), edit distance catches the typo ("Calout").
@@ -91,6 +104,10 @@ export function formatList(components: ManifestComponent[], runtime: string): st
91104
return `${lines.join('\n')}\n`;
92105
}
93106

107+
export function formatComponents(entries: ManifestComponent[]): string {
108+
return entries.map(formatComponent).join('\n');
109+
}
110+
94111
export function formatComponent(entry: ManifestComponent): string {
95112
const lines = [entry.name, '', entry.purpose, '', `body: ${entry.body} source: ${entry.source}`];
96113

packages/htmdx/src/cli/index.ts

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import { readFile, writeFile } from 'node:fs/promises';
88
import { relative } from 'node:path';
99
import { compileFile } from './compile';
1010
import {
11+
componentsUsedIn,
1112
findComponent,
12-
formatComponent,
13+
formatComponents,
1314
formatList,
1415
loadManifest,
1516
suggestNames,
17+
type ManifestComponent,
1618
} from './components';
1719
import { lintFile, summarize, type LintDiagnostic, type LintReport } from './lint';
20+
import { extractEmbeddedSource, isArtifact } from './runtime';
1821
import {
1922
DEFAULT_SKILL_TOPIC,
2023
formatTopicList,
@@ -31,14 +34,15 @@ Commands:
3134
lint <files...> Report problems in artifacts and source files
3235
validate <files...> Alias for lint
3336
compile <file> Print the static HTML snapshot
34-
components [name] List the component catalog, or describe one component
37+
components [name...] List the component catalog, or describe named components
3538
skill [topic] Print the authoring guidance for this runtime
3639
3740
Options:
3841
--format <pretty|json> Output format for lint and components (default: pretty)
3942
--strict Treat lint warnings as failures
4043
-o, --out <file> Write compile output to a file instead of stdout
4144
--layout <name> Document layout for compile
45+
--used <file> Describe only the components that <file> already uses
4246
--list, --full, --json Topic selection and format for skill
4347
-h, --help Show this message
4448
@@ -50,6 +54,7 @@ type Args = {
5054
strict: boolean;
5155
out?: string;
5256
layout?: string;
57+
used?: string;
5358
};
5459

5560
async function main(argv: string[]): Promise<number> {
@@ -148,8 +153,20 @@ async function runComponents(args: Args): Promise<number> {
148153
return 2;
149154
}
150155

151-
const [name] = args.files;
152-
if (!name) {
156+
if (args.used !== undefined) {
157+
const content = await read(args.used);
158+
if (content === undefined) {
159+
return 2;
160+
}
161+
const source = isArtifact(args.used, content) ? extractEmbeddedSource(content) : content;
162+
if (source === undefined) {
163+
process.stderr.write(`${args.used}: no <script type="text/htmdx"> block found\n`);
164+
return 2;
165+
}
166+
return writeComponents(componentsUsedIn(manifest, source), args.format);
167+
}
168+
169+
if (args.files.length === 0) {
153170
process.stdout.write(
154171
args.format === 'json'
155172
? `${JSON.stringify(manifest, null, 2)}\n`
@@ -158,18 +175,37 @@ async function runComponents(args: Args): Promise<number> {
158175
return 0;
159176
}
160177

161-
const entry = findComponent(manifest, name);
162-
if (!entry) {
163-
const near = suggestNames(manifest, name);
164-
process.stderr.write(
165-
`unknown component "${name}"${near.length ? `; did you mean ${near.join(', ')}?` : ''}\n`,
166-
);
167-
return 1;
178+
const entries: ManifestComponent[] = [];
179+
for (const name of args.files) {
180+
const entry = findComponent(manifest, name);
181+
if (!entry) {
182+
const near = suggestNames(manifest, name);
183+
process.stderr.write(
184+
`unknown component "${name}"${near.length ? `; did you mean ${near.join(', ')}?` : ''}\n`,
185+
);
186+
return 1;
187+
}
188+
entries.push(entry);
168189
}
169190

170-
process.stdout.write(
171-
args.format === 'json' ? `${JSON.stringify(entry, null, 2)}\n` : formatComponent(entry),
172-
);
191+
// One name has always emitted the bare entry; keep scripts that read it working.
192+
const payload = entries.length === 1 ? entries[0] : entries;
193+
return writeComponents(entries, args.format, payload);
194+
}
195+
196+
function writeComponents(
197+
entries: ManifestComponent[],
198+
format: Args['format'],
199+
payload: unknown = entries,
200+
): number {
201+
if (format === 'json') {
202+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
203+
return 0;
204+
}
205+
206+
// Silence would read as "this file uses nothing", which is also what a bad
207+
// --used path looks like from the caller's side.
208+
process.stdout.write(entries.length ? formatComponents(entries) : 'no components\n');
173209
return 0;
174210
}
175211

@@ -242,6 +278,11 @@ function parseArgs(argv: string[]): Args {
242278
args.layout = argv[index];
243279
continue;
244280
}
281+
if (argument === '--used') {
282+
index += 1;
283+
args.used = argv[index];
284+
continue;
285+
}
245286
args.files.push(argument);
246287
}
247288

0 commit comments

Comments
 (0)