@hyperfixi/intent-element 2.4.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/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @hyperfixi/intent-element
3
+ *
4
+ * Browser custom element that validates LSE protocol JSON and executes it
5
+ * via the hyperfixi runtime. Zero-dependency validation via @lokascript/intent;
6
+ * execution delegated to window.hyperfixi.evalLSENode (peer dep).
7
+ *
8
+ * Auto-registers <lse-intent> when loaded as a browser script.
9
+ *
10
+ * @example
11
+ * ```html
12
+ * <script src="hyperfixi.js"></script>
13
+ * <script src="intent-element.iife.global.js"></script>
14
+ *
15
+ * <lse-intent>
16
+ * <script type="application/lse+json">
17
+ * {"action":"toggle","roles":{"patient":{"type":"selector","value":".active"}},"trigger":{"event":"click"}}
18
+ * </script>
19
+ * <button slot="trigger">Toggle sidebar</button>
20
+ * </lse-intent>
21
+ * ```
22
+ */
23
+
24
+ export { LSEIntentElement } from './lse-intent.js';
25
+ export { intentRegistry } from './schema-registry.js';
26
+ export type { SandboxResult } from './sandbox.js';
27
+
28
+ // Auto-register the custom element when loaded in a browser context
29
+ if (typeof customElements !== 'undefined' && !customElements.get('lse-intent')) {
30
+ // Dynamic import to avoid circular reference from the export above
31
+ import('./lse-intent.js').then(({ LSEIntentElement }) => {
32
+ customElements.define('lse-intent', LSEIntentElement);
33
+ });
34
+ }
@@ -0,0 +1,513 @@
1
+ /**
2
+ * <lse-intent> Custom Element
3
+ *
4
+ * Accepts LSE protocol JSON via an inline <script type="application/lse+json">
5
+ * child (or a `src` attribute), validates it, and executes it via the hyperfixi
6
+ * runtime according to a declarative trigger model. Degrades gracefully when
7
+ * the runtime is unavailable.
8
+ *
9
+ * ## Trigger modes
10
+ *
11
+ * The `trigger` attribute controls when validation + execution fires:
12
+ *
13
+ * - `load` (default) — fire immediately on `connectedCallback`
14
+ * - `click` — fire on click anywhere inside the element
15
+ * - `submit` — fire on submit of the closest ancestor `<form>`; preventDefault is called synchronously
16
+ * - `intersect` — fire when the element scrolls into view (IntersectionObserver; one-shot)
17
+ * - `manual` — never fire automatically; caller must invoke `.refresh()`
18
+ * - (any other value) — treated as a DOM event name, wired via addEventListener on the element
19
+ *
20
+ * If no `trigger` attribute is set, the element inspects the JSON wire-format
21
+ * `trigger.event` sugar field (protocol/spec/wire-format.md lines 441–474) and
22
+ * uses it as the event name. If neither is present, defaults to `load`.
23
+ *
24
+ * @example
25
+ * ```html
26
+ * <!-- Load on connect (default) -->
27
+ * <lse-intent>
28
+ * <script type="application/lse+json">
29
+ * {"action":"toggle","roles":{"patient":{"type":"selector","value":".active"}}}
30
+ * </script>
31
+ * </lse-intent>
32
+ *
33
+ * <!-- Fire on click -->
34
+ * <lse-intent trigger="click">
35
+ * <button slot="trigger">Toggle</button>
36
+ * <script type="application/lse+json">
37
+ * {"action":"toggle","roles":{"patient":{"type":"selector","value":".active"}}}
38
+ * </script>
39
+ * </lse-intent>
40
+ *
41
+ * <!-- Auto-wire from JSON trigger.event sugar -->
42
+ * <lse-intent>
43
+ * <button>Toggle</button>
44
+ * <script type="application/lse+json">
45
+ * {"action":"toggle","roles":{"patient":{"type":"selector","value":".active"}},"trigger":{"event":"click"}}
46
+ * </script>
47
+ * </lse-intent>
48
+ *
49
+ * <!-- Manual (caller drives refresh) -->
50
+ * <lse-intent trigger="manual" id="my-intent">...</lse-intent>
51
+ * <script>document.getElementById('my-intent').refresh()</script>
52
+ *
53
+ * <!-- Fetch from URL, fire on intersect -->
54
+ * <lse-intent src="/intents/toggle-sidebar.json" trigger="intersect"></lse-intent>
55
+ * ```
56
+ *
57
+ * Events dispatched on the element:
58
+ * - `lse:validated` — after schema check, before execution (detail: { node, diagnostics })
59
+ * - `lse:executed` — after successful execution (detail: { node, result })
60
+ * - `lse:error` — on validation or execution failure (detail: { diagnostics, error })
61
+ */
62
+
63
+ import { fromProtocolJSON, validateProtocolJSON } from '@lokascript/intent';
64
+ import type { SemanticNode, IRDiagnostic, CommandSchema, SemanticValue } from '@lokascript/intent';
65
+ import { intentRegistry } from './schema-registry.js';
66
+ import { sandboxed } from './sandbox.js';
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Runtime bridge — resolved lazily so the element works without @hyperfixi/core
70
+ // ---------------------------------------------------------------------------
71
+
72
+ interface HyperfiziRuntime {
73
+ evalLSENode(node: SemanticNode, element?: Element): Promise<unknown>;
74
+ }
75
+
76
+ function getRuntime(): HyperfiziRuntime | null {
77
+ const w = globalThis as Record<string, unknown>;
78
+ const api = w['hyperfixi'];
79
+ if (api && typeof (api as Record<string, unknown>)['evalLSENode'] === 'function') {
80
+ return api as HyperfiziRuntime;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Trigger resolution
87
+ // ---------------------------------------------------------------------------
88
+
89
+ type TriggerSpec =
90
+ | { kind: 'load' }
91
+ | { kind: 'manual' }
92
+ | { kind: 'intersect' }
93
+ | { kind: 'submit' }
94
+ | { kind: 'event'; eventName: string };
95
+
96
+ function parseTriggerValue(value: string): TriggerSpec {
97
+ const v = value.trim().toLowerCase();
98
+ if (v === '' || v === 'load') return { kind: 'load' };
99
+ if (v === 'manual') return { kind: 'manual' };
100
+ if (v === 'intersect') return { kind: 'intersect' };
101
+ if (v === 'submit') return { kind: 'submit' };
102
+ return { kind: 'event', eventName: v };
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // <lse-intent>
107
+ // ---------------------------------------------------------------------------
108
+
109
+ interface PreparedNode {
110
+ node: SemanticNode;
111
+ raw: Record<string, unknown>;
112
+ }
113
+
114
+ export class LSEIntentElement extends HTMLElement {
115
+ static observedAttributes = ['src', 'disabled', 'timeout', 'trigger'];
116
+
117
+ private _node: SemanticNode | null = null;
118
+ private _diagnostics: IRDiagnostic[] = [];
119
+ private _abortController: AbortController | null = null;
120
+ private _triggerCleanup: (() => void) | null = null;
121
+ private _initInFlight = false;
122
+ private _initPending = false;
123
+
124
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
125
+
126
+ connectedCallback(): void {
127
+ void this._initialize();
128
+ }
129
+
130
+ disconnectedCallback(): void {
131
+ this._abortController?.abort();
132
+ this._abortController = null;
133
+ this._tearDownTrigger();
134
+ }
135
+
136
+ attributeChangedCallback(name: string, _old: string | null, _new: string | null): void {
137
+ if (name !== 'src' && name !== 'disabled' && name !== 'trigger') return;
138
+ // Only react to attribute changes when connected. Before connect, the
139
+ // connectedCallback will run _initialize once with the final attribute
140
+ // state. This avoids races between setAttribute() calls that happen
141
+ // before the element is appended to the document.
142
+ if (!this.isConnected) return;
143
+ void this._initialize();
144
+ }
145
+
146
+ // ── Public accessors ───────────────────────────────────────────────────────
147
+
148
+ /** The parsed SemanticNode, or null if not yet loaded / invalid. */
149
+ get node(): SemanticNode | null {
150
+ return this._node;
151
+ }
152
+
153
+ /** Diagnostics from the last validation attempt. Returns a snapshot copy. */
154
+ get diagnostics(): readonly IRDiagnostic[] {
155
+ return [...this._diagnostics];
156
+ }
157
+
158
+ /**
159
+ * Re-run preparation and execution explicitly. Useful after updating the
160
+ * inline JSON, and required when `trigger="manual"`. Always executes
161
+ * regardless of trigger mode.
162
+ */
163
+ async refresh(): Promise<void> {
164
+ const prepared = await this._prepare();
165
+ if (!prepared) return;
166
+ await this._execute(prepared.node);
167
+ }
168
+
169
+ // ── Private: initialization and trigger dispatch ───────────────────────────
170
+
171
+ /**
172
+ * Serialize concurrent `_initialize` calls. If one is already running,
173
+ * mark a re-init as pending and return. The in-flight init will re-run
174
+ * once after it finishes so the final attribute/JSON state is reflected.
175
+ */
176
+ private async _initialize(): Promise<void> {
177
+ if (this._initInFlight) {
178
+ this._initPending = true;
179
+ return;
180
+ }
181
+ this._initInFlight = true;
182
+ try {
183
+ await this._doInitialize();
184
+ while (this._initPending) {
185
+ this._initPending = false;
186
+ await this._doInitialize();
187
+ }
188
+ } finally {
189
+ this._initInFlight = false;
190
+ }
191
+ }
192
+
193
+ private async _doInitialize(): Promise<void> {
194
+ // Always tear down any existing trigger wiring before re-initializing.
195
+ this._tearDownTrigger();
196
+
197
+ if (this.hasAttribute('disabled')) return;
198
+
199
+ const prepared = await this._prepare();
200
+ if (!prepared) return;
201
+
202
+ // The element may have been disconnected while _prepare() was awaiting
203
+ // a fetch or JSON parse. If so, don't wire triggers — disconnectedCallback
204
+ // already ran cleanup.
205
+ if (!this.isConnected) return;
206
+
207
+ const spec = this._resolveTriggerSpec(prepared.raw);
208
+
209
+ switch (spec.kind) {
210
+ case 'load':
211
+ await this._execute(prepared.node);
212
+ return;
213
+ case 'manual':
214
+ return;
215
+ case 'intersect':
216
+ this._wireIntersect(prepared.node);
217
+ return;
218
+ case 'submit':
219
+ this._wireSubmit(prepared.node);
220
+ return;
221
+ case 'event':
222
+ this._wireEvent(prepared.node, spec.eventName);
223
+ return;
224
+ }
225
+ }
226
+
227
+ private _resolveTriggerSpec(raw: Record<string, unknown>): TriggerSpec {
228
+ // 1. Explicit trigger attribute wins.
229
+ const attr = this.getAttribute('trigger');
230
+ if (attr !== null) {
231
+ return parseTriggerValue(attr);
232
+ }
233
+
234
+ // 2. Wire-format trigger.event sugar in the JSON.
235
+ const triggerObj = raw['trigger'];
236
+ if (triggerObj && typeof triggerObj === 'object' && !Array.isArray(triggerObj)) {
237
+ const eventName = (triggerObj as Record<string, unknown>)['event'];
238
+ if (typeof eventName === 'string' && eventName.length > 0) {
239
+ return parseTriggerValue(eventName);
240
+ }
241
+ }
242
+
243
+ // 3. Default.
244
+ return { kind: 'load' };
245
+ }
246
+
247
+ // ── Private: trigger wiring ────────────────────────────────────────────────
248
+
249
+ private _tearDownTrigger(): void {
250
+ if (this._triggerCleanup) {
251
+ this._triggerCleanup();
252
+ this._triggerCleanup = null;
253
+ }
254
+ }
255
+
256
+ private _wireEvent(node: SemanticNode, eventName: string): void {
257
+ const handler = (_ev: Event): void => {
258
+ void this._execute(node);
259
+ };
260
+ this.addEventListener(eventName, handler);
261
+ this._triggerCleanup = () => this.removeEventListener(eventName, handler);
262
+ }
263
+
264
+ private _wireSubmit(node: SemanticNode): void {
265
+ const form = this.closest('form');
266
+ if (!form) {
267
+ this._diagnostics.push({
268
+ severity: 'warning',
269
+ code: 'NO_ANCESTOR_FORM',
270
+ message: 'trigger="submit" requires an ancestor <form>; none found.',
271
+ });
272
+ return;
273
+ }
274
+ const handler = (ev: Event): void => {
275
+ // preventDefault synchronously so the form does not navigate while
276
+ // execution is in flight. If execution fails, the diagnostics event
277
+ // still fires but the form stays on the page.
278
+ ev.preventDefault();
279
+ void this._execute(node);
280
+ };
281
+ form.addEventListener('submit', handler);
282
+ this._triggerCleanup = () => form.removeEventListener('submit', handler);
283
+ }
284
+
285
+ private _wireIntersect(node: SemanticNode): void {
286
+ if (typeof IntersectionObserver === 'undefined') {
287
+ this._diagnostics.push({
288
+ severity: 'warning',
289
+ code: 'NO_INTERSECTION_OBSERVER',
290
+ message:
291
+ 'trigger="intersect" requires IntersectionObserver; not available in this environment.',
292
+ });
293
+ return;
294
+ }
295
+ const observer = new IntersectionObserver(entries => {
296
+ for (const entry of entries) {
297
+ if (entry.isIntersecting) {
298
+ // One-shot: disconnect after first intersection.
299
+ observer.disconnect();
300
+ void this._execute(node);
301
+ return;
302
+ }
303
+ }
304
+ });
305
+ observer.observe(this);
306
+ this._triggerCleanup = () => observer.disconnect();
307
+ }
308
+
309
+ // ── Private: prepare + execute ─────────────────────────────────────────────
310
+
311
+ /**
312
+ * Read the JSON, validate, deserialize, and emit `lse:validated`. Returns
313
+ * the prepared node and raw JSON on success, or null on failure (in which
314
+ * case `lse:error` has already been emitted).
315
+ */
316
+ private async _prepare(): Promise<PreparedNode | null> {
317
+ const raw = await this._readJSON();
318
+ if (raw === null) return null;
319
+
320
+ // Validate wire format
321
+ const wireDiags = validateProtocolJSON(raw);
322
+ const hasErrors = wireDiags.some(d => d.severity === 'error');
323
+
324
+ if (hasErrors) {
325
+ this._diagnostics = wireDiags;
326
+ this._node = null;
327
+ this._showError();
328
+ this._emit('lse:error', { diagnostics: wireDiags, error: null });
329
+ return null;
330
+ }
331
+
332
+ // Deserialize to SemanticNode
333
+ let node: SemanticNode;
334
+ try {
335
+ node = fromProtocolJSON(raw as unknown as Parameters<typeof fromProtocolJSON>[0]);
336
+ } catch (err) {
337
+ const error = err instanceof Error ? err : new Error(String(err));
338
+ this._diagnostics = [
339
+ { severity: 'error', code: 'DESERIALIZE_ERROR', message: error.message },
340
+ ];
341
+ this._node = null;
342
+ this._showError();
343
+ this._emit('lse:error', { diagnostics: this._diagnostics, error });
344
+ return null;
345
+ }
346
+
347
+ // Optional schema validation
348
+ const schema = intentRegistry.get(node.action);
349
+ const schemaDiags: IRDiagnostic[] = schema ? this._validateSchema(node, schema) : [];
350
+
351
+ this._node = node;
352
+ this._diagnostics = [...wireDiags, ...schemaDiags];
353
+ this._emit('lse:validated', { node, diagnostics: [...this._diagnostics] });
354
+
355
+ return { node, raw };
356
+ }
357
+
358
+ /**
359
+ * Execute a prepared node via the hyperfixi runtime. Emits `lse:executed`
360
+ * on success or `lse:error` on failure. Safe to call multiple times.
361
+ *
362
+ * **Event-handler unwrap.** If the prepared node is an event-handler (from
363
+ * either verbose wire format or compact `trigger` sugar), the element's
364
+ * own `trigger` attribute has already wired the DOM event listener — the
365
+ * wire-format event metadata is redundant at this point. We unwrap the
366
+ * event-handler and execute each body command directly, rather than passing
367
+ * the event-handler node to `evalLSENode` (which would attempt to re-wire
368
+ * a listener at runtime, effectively discarding the body).
369
+ */
370
+ private async _execute(node: SemanticNode): Promise<void> {
371
+ const runtime = getRuntime();
372
+ if (!runtime) {
373
+ // Runtime not loaded — emit a warning but don't error.
374
+ this._diagnostics.push({
375
+ severity: 'warning',
376
+ code: 'NO_RUNTIME',
377
+ message:
378
+ 'hyperfixi runtime not found. Load hyperfixi.js before intent-element.iife.js to enable execution.',
379
+ });
380
+ return;
381
+ }
382
+
383
+ // Unwrap event-handler nodes into their body commands. See the comment
384
+ // above and examples/llm-native-todo-demo/README.md for the motivation.
385
+ const executables: SemanticNode[] =
386
+ node.kind === 'event-handler'
387
+ ? [...((node as { body?: SemanticNode[] }).body ?? [])]
388
+ : [node];
389
+
390
+ const rawTimeout = this.getAttribute('timeout');
391
+ const timeoutMs = rawTimeout !== null ? parseInt(rawTimeout, 10) || 5000 : 5000;
392
+
393
+ // Execute body commands sequentially. A single failure stops the sequence
394
+ // and emits `lse:error`; all successful commands before the failure have
395
+ // already mutated the DOM.
396
+ const results: unknown[] = [];
397
+ for (const cmd of executables) {
398
+ const result = await sandboxed(() => runtime.evalLSENode(cmd, this), timeoutMs);
399
+ if (result.ok) {
400
+ results.push(result.result);
401
+ } else {
402
+ this._diagnostics.push({
403
+ severity: 'error',
404
+ code: result.timedOut ? 'EXECUTION_TIMEOUT' : 'EXECUTION_ERROR',
405
+ message: result.error?.message ?? 'Unknown execution error',
406
+ });
407
+ this._showError();
408
+ this._emit('lse:error', { diagnostics: [...this._diagnostics], error: result.error });
409
+ return;
410
+ }
411
+ }
412
+
413
+ this._emit('lse:executed', {
414
+ node,
415
+ // For single-body cases keep the legacy `result` shape (first/only value);
416
+ // for multi-body cases expose the full array via `results` too.
417
+ result: results.length === 1 ? results[0] : results,
418
+ results,
419
+ });
420
+ }
421
+
422
+ private async _readJSON(): Promise<Record<string, unknown> | null> {
423
+ // 1. src attribute — fetch remote JSON
424
+ const src = this.getAttribute('src');
425
+ if (src) {
426
+ this._abortController?.abort();
427
+ this._abortController = new AbortController();
428
+ try {
429
+ const response = await fetch(src, { signal: this._abortController.signal });
430
+ if (!response.ok) {
431
+ this._emitFetchError(src, response.status);
432
+ return null;
433
+ }
434
+ return (await response.json()) as Record<string, unknown>;
435
+ } catch (err) {
436
+ // AbortError means the element was disconnected — silently ignore
437
+ if (err instanceof Error && err.name === 'AbortError') return null;
438
+ this._emitFetchError(src, 0, err instanceof Error ? err : undefined);
439
+ return null;
440
+ }
441
+ }
442
+
443
+ // 2. Inline <script type="application/lse+json"> child
444
+ const script = this.querySelector('script[type="application/lse+json"]');
445
+ if (script) {
446
+ try {
447
+ return JSON.parse(script.textContent ?? '') as Record<string, unknown>;
448
+ } catch {
449
+ const diag: IRDiagnostic = {
450
+ severity: 'error',
451
+ code: 'INVALID_JSON',
452
+ message: 'Inline JSON is not valid JSON',
453
+ };
454
+ this._diagnostics = [diag];
455
+ this._showError();
456
+ this._emit('lse:error', { diagnostics: [diag], error: null });
457
+ return null;
458
+ }
459
+ }
460
+
461
+ return null;
462
+ }
463
+
464
+ private _validateSchema(node: SemanticNode, schema: CommandSchema): IRDiagnostic[] {
465
+ const diags: IRDiagnostic[] = [];
466
+ for (const roleSpec of schema.roles) {
467
+ const value = node.roles.get(roleSpec.role) as SemanticValue | undefined;
468
+
469
+ if (roleSpec.required && !value) {
470
+ diags.push({
471
+ severity: 'error',
472
+ code: 'MISSING_REQUIRED_ROLE',
473
+ message: `Required role "${roleSpec.role}" is missing for command "${node.action}"`,
474
+ });
475
+ continue;
476
+ }
477
+
478
+ if (value && roleSpec.expectedTypes && roleSpec.expectedTypes.length > 0) {
479
+ if (
480
+ !roleSpec.expectedTypes.includes(value.type as (typeof roleSpec.expectedTypes)[number])
481
+ ) {
482
+ diags.push({
483
+ severity: 'warning',
484
+ code: 'UNEXPECTED_ROLE_TYPE',
485
+ message: `Role "${roleSpec.role}" has type "${value.type}" but expected one of: ${roleSpec.expectedTypes.join(', ')}`,
486
+ });
487
+ }
488
+ }
489
+ }
490
+ return diags;
491
+ }
492
+
493
+ private _showError(): void {
494
+ const errorSlot = this.querySelector('[slot="error"]');
495
+ if (errorSlot instanceof HTMLElement) {
496
+ errorSlot.style.display = '';
497
+ }
498
+ }
499
+
500
+ private _emit(type: string, detail: Record<string, unknown>): void {
501
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
502
+ }
503
+
504
+ private _emitFetchError(src: string, status: number, error?: Error): void {
505
+ const message = status
506
+ ? `Failed to fetch ${src}: HTTP ${status}`
507
+ : `Failed to fetch ${src}${error ? ': ' + error.message : ''}`;
508
+ const diag: IRDiagnostic = { severity: 'error', code: 'FETCH_ERROR', message };
509
+ this._diagnostics = [diag];
510
+ this._showError();
511
+ this._emit('lse:error', { diagnostics: [diag], error: error ?? null });
512
+ }
513
+ }
package/src/sandbox.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Sandboxed execution wrapper for <lse-intent>.
3
+ *
4
+ * Wraps the hyperfixi runtime call with:
5
+ * - try/catch for execution errors
6
+ * - configurable timeout (default 5s)
7
+ * - structured error reporting
8
+ */
9
+
10
+ export interface SandboxResult {
11
+ ok: boolean;
12
+ result?: unknown;
13
+ error?: Error;
14
+ timedOut?: boolean;
15
+ }
16
+
17
+ const DEFAULT_TIMEOUT_MS = 5000;
18
+
19
+ /**
20
+ * Execute `fn` with a timeout. If it exceeds `timeoutMs`, the promise
21
+ * rejects with an error whose `timedOut` flag is set.
22
+ */
23
+ export async function sandboxed(
24
+ fn: () => Promise<unknown>,
25
+ timeoutMs = DEFAULT_TIMEOUT_MS
26
+ ): Promise<SandboxResult> {
27
+ let timedOut = false;
28
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
29
+
30
+ const timeoutPromise = new Promise<never>((_, reject) => {
31
+ timeoutHandle = setTimeout(() => {
32
+ timedOut = true;
33
+ reject(new Error(`LSE execution timed out after ${timeoutMs}ms`));
34
+ }, timeoutMs);
35
+ });
36
+
37
+ try {
38
+ const result = await Promise.race([fn(), timeoutPromise]);
39
+ clearTimeout(timeoutHandle);
40
+ return { ok: true, result };
41
+ } catch (err) {
42
+ clearTimeout(timeoutHandle);
43
+ const error = err instanceof Error ? err : new Error(String(err));
44
+ return { ok: false, error, timedOut };
45
+ }
46
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Client-side schema registry for <lse-intent>.
3
+ *
4
+ * Schemas registered here are used to validate protocol JSON before execution.
5
+ * Register once per page; the registry is a singleton per module.
6
+ */
7
+
8
+ import type { CommandSchema } from '@lokascript/intent';
9
+
10
+ class IntentSchemaRegistry {
11
+ private schemas = new Map<string, CommandSchema>();
12
+
13
+ /** Register a command schema. Overwrites any existing schema for the same action. */
14
+ register(schema: CommandSchema): void {
15
+ this.schemas.set(schema.action, schema);
16
+ }
17
+
18
+ /** Register multiple schemas at once. */
19
+ registerAll(schemas: CommandSchema[]): void {
20
+ for (const schema of schemas) this.register(schema);
21
+ }
22
+
23
+ /** Look up a schema by action name. Returns undefined if not registered. */
24
+ get(action: string): CommandSchema | undefined {
25
+ return this.schemas.get(action);
26
+ }
27
+
28
+ /** Check whether a schema is registered for the given action. */
29
+ has(action: string): boolean {
30
+ return this.schemas.has(action);
31
+ }
32
+
33
+ /** Remove a schema. */
34
+ unregister(action: string): void {
35
+ this.schemas.delete(action);
36
+ }
37
+
38
+ /** Clear all registered schemas. */
39
+ clear(): void {
40
+ this.schemas.clear();
41
+ }
42
+
43
+ /** Number of registered schemas. */
44
+ get size(): number {
45
+ return this.schemas.size;
46
+ }
47
+
48
+ /**
49
+ * Fetch and register schemas from a JSON endpoint.
50
+ * The endpoint must return an array of CommandSchema objects.
51
+ * Throws if the response is not OK, not an array, or contains malformed schemas.
52
+ */
53
+ async loadFrom(url: string): Promise<void> {
54
+ const response = await fetch(url);
55
+ if (!response.ok) {
56
+ throw new Error(
57
+ `Failed to load schemas from ${url}: ${response.status} ${response.statusText}`
58
+ );
59
+ }
60
+ const body: unknown = await response.json();
61
+ if (!Array.isArray(body)) {
62
+ throw new Error(`Expected array of schemas from ${url}, got ${typeof body}`);
63
+ }
64
+ for (let i = 0; i < body.length; i++) {
65
+ const s = body[i] as Record<string, unknown>;
66
+ if (typeof s?.action !== 'string' || !Array.isArray(s?.roles)) {
67
+ throw new Error(
68
+ `Schema at index ${i} from ${url} is missing required fields "action" (string) and "roles" (array)`
69
+ );
70
+ }
71
+ }
72
+ this.registerAll(body as CommandSchema[]);
73
+ }
74
+ }
75
+
76
+ /** Singleton registry — shared across all <lse-intent> elements on the page. */
77
+ export const intentRegistry = new IntentSchemaRegistry();