@tmorrow/cre8-wc 2.0.8 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/a2ui/catalog.compact.json +2466 -0
- package/a2ui/catalog.json +1243 -105
- package/a2ui/check-layer-parity.mjs +277 -0
- package/a2ui/generate-catalog.mjs +140 -2
- package/a2ui/registry.js +28 -0
- package/a2ui/registry.ts +31 -0
- package/a2ui/smoke-test.mjs +36 -2
- package/a2ui/types.d.ts +5 -0
- package/a2ui/types.ts +5 -0
- package/lib/a2ui/registry.d.ts.map +1 -1
- package/lib/a2ui/registry.js +28 -0
- package/lib/a2ui/registry.js.map +1 -1
- package/lib/a2ui/types.d.ts +5 -0
- package/lib/a2ui/types.d.ts.map +1 -1
- package/lib/a2ui/types.js.map +1 -1
- package/lib/scripts/generate-mcp-manifest.js +38 -0
- package/lib/scripts/generate-mcp-manifest.js.map +1 -1
- package/mcp-manifest.json +8 -1
- package/package.json +6 -4
- package/react-manifest.json +2 -2
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Asserts that every layer describing the component library agrees.
|
|
4
|
+
*
|
|
5
|
+
* node a2ui/check-layer-parity.mjs
|
|
6
|
+
*
|
|
7
|
+
* The library is described six times over — the source that registers the
|
|
8
|
+
* elements, the MCP manifest, the A2UI catalog, the compact projection, the
|
|
9
|
+
* React manifest, and custom-elements.json — each generated by a different
|
|
10
|
+
* script from a different glob. Nothing forced them to agree, and they didn't:
|
|
11
|
+
* an analyzer glob one level too shallow dropped five components from all five
|
|
12
|
+
* derived layers at once, silently, for months.
|
|
13
|
+
*
|
|
14
|
+
* This is the check that makes that unmergeable. It compares the layers against
|
|
15
|
+
* each other rather than against a hardcoded expectation, so it keeps working as
|
|
16
|
+
* components are added.
|
|
17
|
+
*
|
|
18
|
+
* Deliberate omissions are legal but must be declared: `internalElements` in the
|
|
19
|
+
* manifest is the single place they live, and every one is re-verified here.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { execSync } from 'node:child_process';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const WC = join(HERE, '..');
|
|
29
|
+
const REPO = join(WC, '..', '..');
|
|
30
|
+
|
|
31
|
+
const read = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
|
32
|
+
|
|
33
|
+
const failures = [];
|
|
34
|
+
const notes = [];
|
|
35
|
+
function check(name, fn) {
|
|
36
|
+
try {
|
|
37
|
+
const note = fn();
|
|
38
|
+
if (note) notes.push(` ok ${name} — ${note}`);
|
|
39
|
+
else notes.push(` ok ${name}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
failures.push(`FAIL ${name}\n ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertSameSet(label, a, b, aName, bName) {
|
|
46
|
+
const missingFromB = [...a].filter((x) => !b.has(x)).sort();
|
|
47
|
+
const missingFromA = [...b].filter((x) => !a.has(x)).sort();
|
|
48
|
+
if (missingFromB.length || missingFromA.length) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
if (missingFromB.length) parts.push(`in ${aName} but not ${bName}: ${missingFromB.join(', ')}`);
|
|
51
|
+
if (missingFromA.length) parts.push(`in ${bName} but not ${aName}: ${missingFromA.join(', ')}`);
|
|
52
|
+
throw new Error(`${label}\n ${parts.join('\n ')}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Load every layer ─────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const manifest = read(join(WC, 'mcp-manifest.json'));
|
|
59
|
+
const catalog = read(join(WC, 'a2ui', 'catalog.json'));
|
|
60
|
+
const compact = read(join(WC, 'a2ui', 'catalog.compact.json'));
|
|
61
|
+
|
|
62
|
+
const registered = new Set(
|
|
63
|
+
execSync(`grep -rho "customElements.define('[^']*'" "${join(WC, 'components')}" --include='*.ts' || true`)
|
|
64
|
+
.toString()
|
|
65
|
+
.split('\n')
|
|
66
|
+
.map((line) => (line.match(/'([^']+)'/) || [])[1])
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const internal = new Set(manifest.internalElements ?? []);
|
|
71
|
+
const publicSource = new Set([...registered].filter((n) => !internal.has(n)));
|
|
72
|
+
|
|
73
|
+
const manifestNames = new Set(manifest.components.map((c) => c.name));
|
|
74
|
+
const catalogNames = new Set(Object.keys(catalog.$defs.components));
|
|
75
|
+
const compactNames = new Set(compact.components.map((c) => c.name));
|
|
76
|
+
|
|
77
|
+
// ── Component-set parity ─────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
check('every registered element is accounted for', () => {
|
|
80
|
+
if (!registered.size) throw new Error('found no customElements.define calls — the grep is broken');
|
|
81
|
+
const unaccounted = [...registered].filter((n) => !manifestNames.has(n) && !internal.has(n));
|
|
82
|
+
if (unaccounted.length) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`element(s) register but appear in no layer and are not declared internal:\n` +
|
|
85
|
+
` ${unaccounted.join(', ')}\n` +
|
|
86
|
+
` Either export them, or add them to INTERNAL_ELEMENTS in scripts/generate-mcp-manifest.ts.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return `${registered.size} registered, ${internal.size} declared internal`;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
check('declared internals actually exist', () => {
|
|
93
|
+
const ghosts = [...internal].filter((n) => !registered.has(n));
|
|
94
|
+
if (ghosts.length) {
|
|
95
|
+
throw new Error(`declared internal but no longer registered: ${ghosts.join(', ')} — stale entry`);
|
|
96
|
+
}
|
|
97
|
+
const leaked = [...internal].filter((n) => manifestNames.has(n));
|
|
98
|
+
if (leaked.length) throw new Error(`declared internal but present in the manifest: ${leaked.join(', ')}`);
|
|
99
|
+
return internal.size ? [...internal].join(', ') : 'none';
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
check('source (minus internals) == manifest', () =>
|
|
103
|
+
assertSameSet('component sets differ', publicSource, manifestNames, 'source', 'manifest'));
|
|
104
|
+
|
|
105
|
+
check('manifest == catalog', () =>
|
|
106
|
+
assertSameSet('component sets differ', manifestNames, catalogNames, 'manifest', 'catalog'));
|
|
107
|
+
|
|
108
|
+
check('catalog == compact', () =>
|
|
109
|
+
assertSameSet('component sets differ', catalogNames, compactNames, 'catalog', 'compact'));
|
|
110
|
+
|
|
111
|
+
const reactPath = join(WC, 'react-manifest.json');
|
|
112
|
+
check('catalog == react manifest', () => {
|
|
113
|
+
if (!existsSync(reactPath)) throw new Error('react-manifest.json is missing');
|
|
114
|
+
const react = read(reactPath);
|
|
115
|
+
const names = new Set((react.components ?? []).map((c) => c.tagName ?? c.name));
|
|
116
|
+
assertSameSet('component sets differ', catalogNames, names, 'catalog', 'react');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const cemPath = join(REPO, 'custom-elements.json');
|
|
120
|
+
check('catalog == custom-elements.json', () => {
|
|
121
|
+
if (!existsSync(cemPath)) throw new Error('custom-elements.json is missing');
|
|
122
|
+
const cem = read(cemPath);
|
|
123
|
+
const names = new Set(
|
|
124
|
+
(cem.modules ?? []).flatMap((m) => (m.declarations ?? []).map((d) => d.tagName).filter(Boolean))
|
|
125
|
+
);
|
|
126
|
+
assertSameSet(
|
|
127
|
+
'component sets differ (note: build:custom-elements.json uses a shallower glob than build:mcp-manifest)',
|
|
128
|
+
catalogNames,
|
|
129
|
+
names,
|
|
130
|
+
'catalog',
|
|
131
|
+
'custom-elements.json'
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Field-level parity ───────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
check('catalog and compact agree on every prop and enum', () => {
|
|
138
|
+
const compactByName = new Map(compact.components.map((c) => [c.name, c]));
|
|
139
|
+
for (const [name, def] of Object.entries(catalog.$defs.components)) {
|
|
140
|
+
const catProps = def.properties?.props?.properties ?? {};
|
|
141
|
+
const cmpProps = compactByName.get(name)?.props ?? {};
|
|
142
|
+
assertSameSet(`${name}: prop sets differ`, new Set(Object.keys(catProps)), new Set(Object.keys(cmpProps)), 'catalog', 'compact');
|
|
143
|
+
for (const [prop, spec] of Object.entries(catProps)) {
|
|
144
|
+
const a = spec.enum ?? null;
|
|
145
|
+
const b = cmpProps[prop]?.enum ?? null;
|
|
146
|
+
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
|
147
|
+
throw new Error(`${name}.${prop}: enum differs — catalog ${JSON.stringify(a)} vs compact ${JSON.stringify(b)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const total = Object.values(catalog.$defs.components).reduce(
|
|
152
|
+
(n, d) => n + Object.keys(d.properties?.props?.properties ?? {}).length, 0);
|
|
153
|
+
return `${total} props`;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
check('catalog and compact agree on containment', () => {
|
|
157
|
+
const compactByName = new Map(compact.components.map((c) => [c.name, c]));
|
|
158
|
+
for (const [name, def] of Object.entries(catalog.$defs.components)) {
|
|
159
|
+
const cmp = compactByName.get(name);
|
|
160
|
+
const catChildren = def.properties?.children !== undefined;
|
|
161
|
+
if (catChildren !== (cmp.acceptsChildren === true)) {
|
|
162
|
+
throw new Error(`${name}: catalog children=${catChildren} vs compact acceptsChildren=${cmp.acceptsChildren === true}`);
|
|
163
|
+
}
|
|
164
|
+
const catSlots = new Set(Object.keys(def.properties?.slots?.properties ?? {}));
|
|
165
|
+
assertSameSet(`${name}: slot sets differ`, catSlots, new Set(cmp.slots ?? []), 'catalog', 'compact');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
check('manifest, catalog metadata, catalog schema and compact agree on events', () => {
|
|
170
|
+
const compactByName = new Map(compact.components.map((c) => [c.name, c]));
|
|
171
|
+
const native = new Set(catalog['x-native-events'] ?? []);
|
|
172
|
+
if (!native.size) throw new Error('catalog declares no x-native-events — the validator reads that list from here');
|
|
173
|
+
|
|
174
|
+
let total = 0;
|
|
175
|
+
for (const component of manifest.components) {
|
|
176
|
+
const fromManifest = new Set(Object.keys(component.events ?? {}));
|
|
177
|
+
const def = catalog.$defs.components[component.name];
|
|
178
|
+
const fromMetadata = new Set(Object.keys(def['x-events'] ?? {}));
|
|
179
|
+
const fromCompact = new Set(compactByName.get(component.name)?.events ?? []);
|
|
180
|
+
|
|
181
|
+
assertSameSet(`${component.name}: events differ`, fromManifest, fromMetadata, 'manifest', 'catalog x-events');
|
|
182
|
+
assertSameSet(`${component.name}: events differ`, fromMetadata, fromCompact, 'catalog x-events', 'compact');
|
|
183
|
+
|
|
184
|
+
// The validatable schema must admit every declared event, and must not
|
|
185
|
+
// invent any. Compared by admission rather than by set equality because a
|
|
186
|
+
// component may declare a custom event that shadows a native name —
|
|
187
|
+
// `cre8-tag` dispatches its own `change` — in which case the native branch
|
|
188
|
+
// legitimately absorbs it.
|
|
189
|
+
const names = def.properties?.events?.propertyNames;
|
|
190
|
+
if (!names) throw new Error(`${component.name}: no properties.events — events are not schema-validated`);
|
|
191
|
+
const branches = names.anyOf ?? [names];
|
|
192
|
+
const explicit = new Set(branches.flatMap((b) => b.enum ?? []));
|
|
193
|
+
const refsNative = branches.some((b) => b.$ref === '#/$defs/NativeEventName');
|
|
194
|
+
const admits = (n) => explicit.has(n) || (refsNative && native.has(n));
|
|
195
|
+
|
|
196
|
+
const unadmitted = [...fromMetadata].filter((n) => !admits(n));
|
|
197
|
+
if (unadmitted.length) {
|
|
198
|
+
throw new Error(`${component.name}: declared but not admitted by properties.events: ${unadmitted.join(', ')}`);
|
|
199
|
+
}
|
|
200
|
+
const invented = [...explicit].filter((n) => !fromMetadata.has(n) && !native.has(n));
|
|
201
|
+
if (invented.length) {
|
|
202
|
+
throw new Error(`${component.name}: properties.events admits undeclared event(s): ${invented.join(', ')}`);
|
|
203
|
+
}
|
|
204
|
+
total += fromManifest.size;
|
|
205
|
+
}
|
|
206
|
+
return `${total} events across ${manifest.components.length} components, ${native.size} native`;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
check('the schema and the renderer agree on text children', () => {
|
|
210
|
+
const renderer = readFileSync(join(WC, 'a2ui', 'renderer.ts'), 'utf8');
|
|
211
|
+
const rendersText = /typeof\s+child\s*===\s*['"]string['"]/.test(renderer);
|
|
212
|
+
const child = catalog.$defs.Child;
|
|
213
|
+
const schemaAllowsText = !!child?.oneOf?.some((b) => b.type === 'string');
|
|
214
|
+
|
|
215
|
+
if (rendersText && !schemaAllowsText) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'renderer.ts turns string children into text nodes, but the schema does not permit them.\n' +
|
|
218
|
+
' Text renders yet fails validation, and no schema-constrained generator can emit it.'
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (!rendersText && schemaAllowsText) {
|
|
222
|
+
throw new Error('the schema permits text children but the renderer no longer handles them');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Every containment point must route through Child, or the ones that don't
|
|
226
|
+
// silently keep the old behaviour.
|
|
227
|
+
const offenders = [];
|
|
228
|
+
for (const [name, def] of Object.entries(catalog.$defs.components)) {
|
|
229
|
+
const children = def.properties?.children;
|
|
230
|
+
if (children && children.items?.$ref !== '#/$defs/Child') offenders.push(`${name}.children`);
|
|
231
|
+
for (const [slot, spec] of Object.entries(def.properties?.slots?.properties ?? {})) {
|
|
232
|
+
if (spec.items?.$ref !== '#/$defs/Child') offenders.push(`${name}.slots.${slot}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (offenders.length) {
|
|
236
|
+
throw new Error(`containment not routed through $defs/Child: ${offenders.slice(0, 5).join(', ')}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A document root is a component; bare text is not a document.
|
|
240
|
+
if (catalog.properties?.root?.$ref !== '#/$defs/Component') {
|
|
241
|
+
throw new Error('the document root must be a Component, not a Child');
|
|
242
|
+
}
|
|
243
|
+
return `${offenders.length === 0 ? 'all containment routed through Child' : ''}`;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
check('the runtime validator reads its native-event list from the catalog', () => {
|
|
247
|
+
const source = readFileSync(join(WC, 'a2ui', 'registry.ts'), 'utf8');
|
|
248
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
249
|
+
if (!/x-native-events/.test(code)) {
|
|
250
|
+
throw new Error('registry.ts no longer reads x-native-events — it has grown a second copy of the list');
|
|
251
|
+
}
|
|
252
|
+
if (/const\s+NATIVE_DOM_EVENTS\s*=\s*new Set\(\[/.test(code)) {
|
|
253
|
+
throw new Error('registry.ts hardcodes a native-event list again; read catalog["x-native-events"] instead');
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
check('the catalog matches the manifest it was generated from', () => {
|
|
258
|
+
if (catalog['x-a2ui']?.libraryVersion !== manifest.version) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`catalog was built from version ${catalog['x-a2ui']?.libraryVersion}, manifest is ${manifest.version} — regenerate with build:a2ui:catalog`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (compact.libraryVersion !== manifest.version) {
|
|
264
|
+
throw new Error(`compact was built from version ${compact.libraryVersion}, manifest is ${manifest.version}`);
|
|
265
|
+
}
|
|
266
|
+
return `all at ${manifest.version}`;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── Report ───────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
for (const note of notes) console.log(note);
|
|
272
|
+
if (failures.length) {
|
|
273
|
+
console.error('\n' + failures.join('\n'));
|
|
274
|
+
console.error(`\n${failures.length} layer(s) out of parity`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
console.log(`\nok — ${notes.length} parity checks passed; every layer agrees`);
|
|
@@ -6,11 +6,56 @@ import { dirname, resolve } from 'node:path';
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const manifestPath = resolve(__dirname, '..', 'mcp-manifest.json');
|
|
8
8
|
const outPath = resolve(__dirname, 'catalog.json');
|
|
9
|
+
const compactOutPath = resolve(__dirname, 'catalog.compact.json');
|
|
9
10
|
|
|
10
11
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
11
12
|
|
|
12
13
|
const QUOTED_LITERAL = /^"([^"]*)"$/;
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Native DOM events, bindable on every component.
|
|
17
|
+
*
|
|
18
|
+
* Emitted into the catalog as `x-native-events` so the runtime validator can
|
|
19
|
+
* read it from there rather than keeping its own copy. Two lists that must stay
|
|
20
|
+
* equal is exactly the drift this pipeline is supposed to prevent, so there is
|
|
21
|
+
* one list and it lives here, next to the schema it produces.
|
|
22
|
+
*
|
|
23
|
+
* A component's `@fires` tags describe only what it dispatches itself, so these
|
|
24
|
+
* can never be discovered from source — binding `click` to a button is both the
|
|
25
|
+
* most common thing an agent does and undocumentable by the analyzer.
|
|
26
|
+
*/
|
|
27
|
+
const NATIVE_DOM_EVENTS = [
|
|
28
|
+
'click', 'dblclick', 'contextmenu',
|
|
29
|
+
'mousedown', 'mouseup', 'mouseenter', 'mouseleave', 'mouseover', 'mouseout', 'mousemove',
|
|
30
|
+
'pointerdown', 'pointerup', 'pointerenter', 'pointerleave',
|
|
31
|
+
'touchstart', 'touchend', 'touchmove', 'touchcancel',
|
|
32
|
+
'keydown', 'keyup', 'keypress',
|
|
33
|
+
'focus', 'blur', 'focusin', 'focusout',
|
|
34
|
+
'input', 'change', 'submit', 'reset', 'invalid', 'select',
|
|
35
|
+
'scroll', 'wheel', 'resize',
|
|
36
|
+
'copy', 'cut', 'paste',
|
|
37
|
+
'drag', 'dragstart', 'dragend', 'dragenter', 'dragleave', 'dragover', 'drop',
|
|
38
|
+
'load', 'error',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Mirrors the `EventBinding` union in types.ts. */
|
|
42
|
+
const EVENT_BINDING_SCHEMA = {
|
|
43
|
+
description: 'A handler name, or an object naming the handler plus dispatch options.',
|
|
44
|
+
oneOf: [
|
|
45
|
+
{ type: 'string', minLength: 1 },
|
|
46
|
+
{
|
|
47
|
+
type: 'object',
|
|
48
|
+
required: ['handler'],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
handler: { type: 'string', minLength: 1 },
|
|
52
|
+
stopPropagation: { type: 'boolean' },
|
|
53
|
+
preventDefault: { type: 'boolean' },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
14
59
|
const SELECT_OPTION_SCHEMA = {
|
|
15
60
|
type: 'object',
|
|
16
61
|
required: ['label', 'value'],
|
|
@@ -291,7 +336,7 @@ function buildComponent(c) {
|
|
|
291
336
|
def.properties.children = {
|
|
292
337
|
type: 'array',
|
|
293
338
|
description: (rawSlots.default?.description || '').trim() || 'Child instances rendered into the default slot.',
|
|
294
|
-
items: { $ref: '#/$defs/
|
|
339
|
+
items: { $ref: '#/$defs/Child' },
|
|
295
340
|
};
|
|
296
341
|
} else if (hasSlots) {
|
|
297
342
|
const slotProps = {};
|
|
@@ -301,7 +346,7 @@ function buildComponent(c) {
|
|
|
301
346
|
slotProps[name] = {
|
|
302
347
|
type: 'array',
|
|
303
348
|
description: (slot.description || '').trim(),
|
|
304
|
-
items: { $ref: '#/$defs/
|
|
349
|
+
items: { $ref: '#/$defs/Child' },
|
|
305
350
|
};
|
|
306
351
|
slotDescriptions[name] = (slot.description || '').trim();
|
|
307
352
|
}
|
|
@@ -316,6 +361,24 @@ function buildComponent(c) {
|
|
|
316
361
|
|
|
317
362
|
if (Object.keys(events).length) def['x-events'] = events;
|
|
318
363
|
|
|
364
|
+
// Events are declared under `properties`, alongside props and slots, rather
|
|
365
|
+
// than living only in `x-events` metadata. Previously the component defs were
|
|
366
|
+
// `additionalProperties: false` with no `events` key at all, so events were
|
|
367
|
+
// documented but second-class — and an invented event name validated cleanly.
|
|
368
|
+
// Native events are always permitted; declared custom events are added on top.
|
|
369
|
+
def.properties.events = {
|
|
370
|
+
type: 'object',
|
|
371
|
+
description:
|
|
372
|
+
'Event bindings. Each key is an event name; the value names the handler to emit.',
|
|
373
|
+
// Native names are `$ref`'d rather than inlined. Repeating all 46 on each of
|
|
374
|
+
// 85 components cost ~57 KB and made the catalog a third larger for no
|
|
375
|
+
// information gain.
|
|
376
|
+
propertyNames: Object.keys(events).length
|
|
377
|
+
? { anyOf: [{ $ref: '#/$defs/NativeEventName' }, { enum: Object.keys(events).sort() }] }
|
|
378
|
+
: { $ref: '#/$defs/NativeEventName' },
|
|
379
|
+
additionalProperties: { $ref: '#/$defs/EventBinding' },
|
|
380
|
+
};
|
|
381
|
+
|
|
319
382
|
return def;
|
|
320
383
|
}
|
|
321
384
|
|
|
@@ -332,6 +395,9 @@ const catalog = {
|
|
|
332
395
|
$id: `https://cre8.dev/a2ui/catalogs/cre8-wc/${manifest.version}`,
|
|
333
396
|
title: 'cre8-wc A2UI Catalog',
|
|
334
397
|
description: manifest.description,
|
|
398
|
+
// Single source of truth for the native-event allowlist. The runtime validator
|
|
399
|
+
// reads it from here instead of keeping a second copy that could drift.
|
|
400
|
+
'x-native-events': NATIVE_DOM_EVENTS,
|
|
335
401
|
'x-a2ui': {
|
|
336
402
|
catalogId: 'cre8-wc',
|
|
337
403
|
library: manifest.library,
|
|
@@ -349,6 +415,24 @@ const catalog = {
|
|
|
349
415
|
description: 'A component instance in the cre8-wc catalog.',
|
|
350
416
|
oneOf: componentRefs,
|
|
351
417
|
},
|
|
418
|
+
// Slot content is a component *or* literal text. `renderer.ts` has always
|
|
419
|
+
// turned a bare string into a text node, but the schema never said so, and
|
|
420
|
+
// that gap is load-bearing rather than cosmetic: 56 of 85 components carry
|
|
421
|
+
// no text-bearing prop, so their entire visible content arrives this way. A
|
|
422
|
+
// schema-constrained generator — guided decoding, or any JSON-Schema-
|
|
423
|
+
// constrained model — could not give those components any content at all.
|
|
424
|
+
//
|
|
425
|
+
// `root` stays a Component deliberately: a document cannot be bare text.
|
|
426
|
+
Child: {
|
|
427
|
+
description:
|
|
428
|
+
'Slot content: either a nested component instance or literal text, which renders as a text node.',
|
|
429
|
+
oneOf: [{ $ref: '#/$defs/Component' }, { type: 'string' }],
|
|
430
|
+
},
|
|
431
|
+
EventBinding: EVENT_BINDING_SCHEMA,
|
|
432
|
+
NativeEventName: {
|
|
433
|
+
description: 'A native DOM event, bindable on any component.',
|
|
434
|
+
enum: [...NATIVE_DOM_EVENTS].sort(),
|
|
435
|
+
},
|
|
352
436
|
components,
|
|
353
437
|
},
|
|
354
438
|
};
|
|
@@ -357,3 +441,57 @@ writeFileSync(outPath, JSON.stringify(catalog, null, 2) + '\n');
|
|
|
357
441
|
console.log(
|
|
358
442
|
`Wrote ${outPath} (${manifest.components.length} components, ${(JSON.stringify(catalog).length / 1024).toFixed(1)} KB)`
|
|
359
443
|
);
|
|
444
|
+
|
|
445
|
+
// The compact projection: the minimum a model needs to emit valid A2UI. Prose is
|
|
446
|
+
// ~90% of the catalog's bytes and none of its decoding constraint, so dropping it
|
|
447
|
+
// is what lets a small-context model see the design system at all.
|
|
448
|
+
//
|
|
449
|
+
// Emitted here rather than in a separate script so it cannot drift from the
|
|
450
|
+
// catalog it projects.
|
|
451
|
+
function compactProps(propsNode) {
|
|
452
|
+
const out = {};
|
|
453
|
+
for (const [name, spec] of Object.entries(propsNode?.properties ?? {})) {
|
|
454
|
+
out[name] = spec.enum ? { enum: spec.enum } : { type: spec.type ?? 'string' };
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const compactComponents = Object.entries(components)
|
|
460
|
+
.map(([name, def]) => {
|
|
461
|
+
const props = def.properties ?? {};
|
|
462
|
+
const entry = { name, category: def['x-category'] ?? 'Uncategorized' };
|
|
463
|
+
|
|
464
|
+
if (props.props) {
|
|
465
|
+
entry.props = compactProps(props.props);
|
|
466
|
+
if (props.props.required?.length) entry.required = props.props.required;
|
|
467
|
+
}
|
|
468
|
+
// Containment is expressed two ways and consumers need both: `children` for
|
|
469
|
+
// plain containers, `slots` for named regions. Dropping either makes a
|
|
470
|
+
// container look like a leaf.
|
|
471
|
+
if (props.children) entry.acceptsChildren = true;
|
|
472
|
+
if (props.slots) entry.slots = Object.keys(props.slots.properties ?? {});
|
|
473
|
+
// Events live under `x-events`, not under `properties`, which makes them easy
|
|
474
|
+
// to miss — the studio's hand-rolled summary looked for them in the wrong
|
|
475
|
+
// place and so showed the model none of the 22 events the library emits.
|
|
476
|
+
const events = Object.keys(def['x-events'] ?? {});
|
|
477
|
+
if (events.length) entry.events = events;
|
|
478
|
+
return entry;
|
|
479
|
+
})
|
|
480
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
481
|
+
|
|
482
|
+
const compact = {
|
|
483
|
+
contractVersion: 1,
|
|
484
|
+
sourceCatalog: catalog.$id,
|
|
485
|
+
libraryVersion: manifest.version,
|
|
486
|
+
componentCount: compactComponents.length,
|
|
487
|
+
components: compactComponents,
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
writeFileSync(compactOutPath, JSON.stringify(compact, null, 2) + '\n');
|
|
491
|
+
|
|
492
|
+
const fullBytes = JSON.stringify(components).length;
|
|
493
|
+
const compactBytes = JSON.stringify(compact).length;
|
|
494
|
+
console.log(
|
|
495
|
+
`Wrote ${compactOutPath} (${compactComponents.length} components, ` +
|
|
496
|
+
`${(compactBytes / 1024).toFixed(1)} KB, ${(fullBytes / compactBytes).toFixed(1)}x smaller)`
|
|
497
|
+
);
|
package/a2ui/registry.js
CHANGED
|
@@ -87,6 +87,17 @@ function describeType(v) {
|
|
|
87
87
|
return 'array';
|
|
88
88
|
return typeof v;
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Native DOM events, bindable on any component.
|
|
92
|
+
*
|
|
93
|
+
* Read from the catalog's `x-native-events` rather than kept here, so there is
|
|
94
|
+
* one list rather than two that must be held equal. A component's `@fires` tags
|
|
95
|
+
* describe only what it dispatches itself, so these can never be derived from
|
|
96
|
+
* source — `click` on a button is legitimate and undocumentable.
|
|
97
|
+
*/
|
|
98
|
+
function nativeEvents(catalog) {
|
|
99
|
+
return new Set(catalog.schema['x-native-events'] ?? []);
|
|
100
|
+
}
|
|
90
101
|
export function validateSpec(spec, catalog, path = '$') {
|
|
91
102
|
if (!spec || typeof spec !== 'object') {
|
|
92
103
|
throw new Error(`${path}: spec must be an object`);
|
|
@@ -131,7 +142,24 @@ export function validateSpec(spec, catalog, path = '$') {
|
|
|
131
142
|
if (!s.events || typeof s.events !== 'object' || Array.isArray(s.events)) {
|
|
132
143
|
throw new Error(`${path}.events: must be an object`);
|
|
133
144
|
}
|
|
145
|
+
// Custom event *names* are checked against the catalog, the same way props
|
|
146
|
+
// and slots are. Previously only the binding shape was validated, so an
|
|
147
|
+
// invented event bound cleanly and then silently never fired — the worst
|
|
148
|
+
// failure mode available, since the UI renders and simply does nothing.
|
|
149
|
+
//
|
|
150
|
+
// Native DOM events are always allowed: `addEventListener` handles them on
|
|
151
|
+
// any element, and `@fires` documents only what a component dispatches
|
|
152
|
+
// itself. `click` on a button is legitimate and undocumented by design.
|
|
153
|
+
const declaredEvents = new Set(Object.keys(def['x-events'] ?? {}));
|
|
154
|
+
const native = nativeEvents(catalog);
|
|
134
155
|
for (const [evtName, binding] of Object.entries(s.events)) {
|
|
156
|
+
if (!native.has(evtName) && !declaredEvents.has(evtName)) {
|
|
157
|
+
const available = [...declaredEvents].sort().join(', ');
|
|
158
|
+
throw new Error(`${path}.events.${evtName}: not a declared event on ${s.component}. ` +
|
|
159
|
+
(available
|
|
160
|
+
? `Custom events available: ${available}`
|
|
161
|
+
: `${s.component} declares no custom events`));
|
|
162
|
+
}
|
|
135
163
|
if (typeof binding === 'string')
|
|
136
164
|
continue;
|
|
137
165
|
if (!binding || typeof binding !== 'object') {
|
package/a2ui/registry.ts
CHANGED
|
@@ -100,6 +100,18 @@ function describeType(v: unknown): string {
|
|
|
100
100
|
return typeof v;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Native DOM events, bindable on any component.
|
|
105
|
+
*
|
|
106
|
+
* Read from the catalog's `x-native-events` rather than kept here, so there is
|
|
107
|
+
* one list rather than two that must be held equal. A component's `@fires` tags
|
|
108
|
+
* describe only what it dispatches itself, so these can never be derived from
|
|
109
|
+
* source — `click` on a button is legitimate and undocumentable.
|
|
110
|
+
*/
|
|
111
|
+
function nativeEvents(catalog: RegisteredCatalog): Set<string> {
|
|
112
|
+
return new Set(catalog.schema['x-native-events'] ?? []);
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
export function validateSpec(spec: unknown, catalog: RegisteredCatalog, path = '$'): asserts spec is ComponentSpec {
|
|
104
116
|
if (!spec || typeof spec !== 'object') {
|
|
105
117
|
throw new Error(`${path}: spec must be an object`);
|
|
@@ -147,7 +159,26 @@ export function validateSpec(spec: unknown, catalog: RegisteredCatalog, path = '
|
|
|
147
159
|
if (!s.events || typeof s.events !== 'object' || Array.isArray(s.events)) {
|
|
148
160
|
throw new Error(`${path}.events: must be an object`);
|
|
149
161
|
}
|
|
162
|
+
// Custom event *names* are checked against the catalog, the same way props
|
|
163
|
+
// and slots are. Previously only the binding shape was validated, so an
|
|
164
|
+
// invented event bound cleanly and then silently never fired — the worst
|
|
165
|
+
// failure mode available, since the UI renders and simply does nothing.
|
|
166
|
+
//
|
|
167
|
+
// Native DOM events are always allowed: `addEventListener` handles them on
|
|
168
|
+
// any element, and `@fires` documents only what a component dispatches
|
|
169
|
+
// itself. `click` on a button is legitimate and undocumented by design.
|
|
170
|
+
const declaredEvents = new Set(Object.keys(def['x-events'] ?? {}));
|
|
171
|
+
const native = nativeEvents(catalog);
|
|
150
172
|
for (const [evtName, binding] of Object.entries(s.events as Record<string, unknown>)) {
|
|
173
|
+
if (!native.has(evtName) && !declaredEvents.has(evtName)) {
|
|
174
|
+
const available = [...declaredEvents].sort().join(', ');
|
|
175
|
+
throw new Error(
|
|
176
|
+
`${path}.events.${evtName}: not a declared event on ${s.component}. ` +
|
|
177
|
+
(available
|
|
178
|
+
? `Custom events available: ${available}`
|
|
179
|
+
: `${s.component} declares no custom events`)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
151
182
|
if (typeof binding === 'string') continue;
|
|
152
183
|
if (!binding || typeof binding !== 'object') {
|
|
153
184
|
throw new Error(`${path}.events.${evtName}: must be a string or { handler } object`);
|
package/a2ui/smoke-test.mjs
CHANGED
|
@@ -73,9 +73,43 @@ try {
|
|
|
73
73
|
console.log('slot reject:', e.message);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// An invented custom event used to bind cleanly and then never fire — the UI
|
|
77
|
+
// renders and silently does nothing, which is worse than an error.
|
|
78
|
+
const badEvent = { component: 'cre8-modal', events: { 'totally-made-up': 'x' } };
|
|
79
|
+
try {
|
|
80
|
+
validateSpec(badEvent, cat);
|
|
81
|
+
console.error('FAIL: expected rejection of undeclared event');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.log('event reject:', e.message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Binding a real event to the wrong component is the same failure wearing a
|
|
88
|
+
// disguise: the name exists in the library, just not on this component.
|
|
89
|
+
const misattributed = { component: 'cre8-button', events: { 'modal-close': 'x' } };
|
|
90
|
+
try {
|
|
91
|
+
validateSpec(misattributed, cat);
|
|
92
|
+
console.error('FAIL: expected rejection of an event from another component');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.log('misattributed event reject:', e.message);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Native DOM events stay bindable on anything — `@fires` documents only what a
|
|
99
|
+
// component dispatches itself, so the catalog can never list `click`.
|
|
100
|
+
for (const native of ['click', 'input', 'submit', 'keydown', 'focus']) {
|
|
101
|
+
validateSpec({ component: 'cre8-button', events: { [native]: 'h' } }, cat);
|
|
102
|
+
}
|
|
103
|
+
console.log('native events accepted on an undocumented component: ok');
|
|
104
|
+
|
|
105
|
+
// Binds one native event and one custom event, on the component that actually
|
|
106
|
+
// dispatches the custom one. It used to bind `split-button-text-click` to a
|
|
107
|
+
// plain `cre8-button`, which no `cre8-button` ever fires — a binding that
|
|
108
|
+
// renders cleanly and then silently does nothing. Event-name validation now
|
|
109
|
+
// rejects that, so the example has to be a real pairing.
|
|
76
110
|
const eventSpec = {
|
|
77
|
-
component: 'cre8-button',
|
|
78
|
-
props: {
|
|
111
|
+
component: 'cre8-split-button',
|
|
112
|
+
props: { buttonText: 'Save' },
|
|
79
113
|
events: {
|
|
80
114
|
click: { handler: 'save-record', stopPropagation: true },
|
|
81
115
|
'split-button-text-click': 'emit-telemetry',
|
package/a2ui/types.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export interface CatalogSchema {
|
|
|
24
24
|
$defs?: {
|
|
25
25
|
components?: Record<string, CatalogComponentDef>;
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Native DOM events bindable on any component. The single source of truth —
|
|
29
|
+
* the runtime validator reads this rather than keeping its own copy.
|
|
30
|
+
*/
|
|
31
|
+
'x-native-events'?: string[];
|
|
27
32
|
'x-a2ui'?: {
|
|
28
33
|
catalogId?: string;
|
|
29
34
|
library?: string;
|
package/a2ui/types.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface CatalogSchema {
|
|
|
30
30
|
$defs?: {
|
|
31
31
|
components?: Record<string, CatalogComponentDef>;
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Native DOM events bindable on any component. The single source of truth —
|
|
35
|
+
* the runtime validator reads this rather than keeping its own copy.
|
|
36
|
+
*/
|
|
37
|
+
'x-native-events'?: string[];
|
|
33
38
|
'x-a2ui'?: {
|
|
34
39
|
catalogId?: string;
|
|
35
40
|
library?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../a2ui/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAc,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CAKxE;
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../a2ui/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAc,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CAKxE;AA2GD,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,IAAI,SAAM,GAAG,OAAO,CAAC,IAAI,IAAI,aAAa,CAkGjH"}
|