@adia-ai/a2ui-compose 0.0.1

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.
@@ -0,0 +1,820 @@
1
+ /**
2
+ * HTML → A2UI Transpiler
3
+ *
4
+ * Converts arbitrary HTML/DOM into A2UI flat adjacency component messages.
5
+ * Three layers:
6
+ * Layer 1: Deterministic tag mapping (HTML tag → A2UI type)
7
+ * Layer 2: Structure inference (div/span → Column/Row/Card via styles)
8
+ * Layer 3: LLM reasoning (optional, for ambiguous structures)
9
+ *
10
+ * @module transpiler
11
+ */
12
+
13
+ import {
14
+ reverseRegistry, HTML_TAG_MAP, INPUT_TYPE_MAP, ARIA_ROLE_MAP,
15
+ SKIP_TAGS, extractProps, inferGap,
16
+ } from './transpiler-maps.js';
17
+ import { validateSchema } from '../../validator/validator.js';
18
+ import { getContext } from '../engine/reference.js';
19
+
20
+ // ═══════════════════════════════════════════════════════════════
21
+ // PUBLIC API
22
+ // ═══════════════════════════════════════════════════════════════
23
+
24
+ /**
25
+ * Transpile an HTML string to A2UI messages.
26
+ *
27
+ * @param {string} html — Raw HTML markup
28
+ * @param {object} [options]
29
+ * @param {'instant'|'reasoning'} [options.mode='instant']
30
+ * @param {object} [options.llmAdapter] — Required for reasoning mode
31
+ * @param {string} [options.surfaceId='default']
32
+ * @returns {Promise<{ messages: object[], validation: object, suggestions: string[] }>}
33
+ */
34
+ export async function transpileHTML(html, options = {}) {
35
+ const { mode = 'instant', llmAdapter, surfaceId = 'default' } = options;
36
+
37
+ if (!html || typeof html !== 'string' || !html.trim()) {
38
+ return {
39
+ messages: [...fallbackMessage('No HTML content to convert')],
40
+ validation: { score: 0, valid: false, checks: [] },
41
+ suggestions: ['Provide HTML markup to convert'],
42
+ };
43
+ }
44
+
45
+ // Parse HTML into a walkable tree
46
+ const root = parseHTMLMinimal(html);
47
+
48
+ // Walk the tree — Layer 1 + Layer 2
49
+ const context = {
50
+ usedIds: new Set(),
51
+ components: [],
52
+ isRoot: true,
53
+ };
54
+
55
+ const rootId = walkNode(root, context);
56
+
57
+ // Ensure root component has id "root"
58
+ if (rootId && context.components.length > 0) {
59
+ const rootComp = context.components.find(c => c.id === rootId);
60
+ if (rootComp && rootComp.id !== 'root') {
61
+ // Rename to "root" and update any parent references
62
+ const oldId = rootComp.id;
63
+ rootComp.id = 'root';
64
+ for (const comp of context.components) {
65
+ if (comp.children) {
66
+ comp.children = comp.children.map(id => id === oldId ? 'root' : id);
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ const messages = [{
73
+ type: 'updateComponents',
74
+ surfaceId,
75
+ components: context.components,
76
+ }];
77
+
78
+ let validation = validateSchema(messages);
79
+ const suggestions = generateSuggestions(html, context.components, validation);
80
+
81
+ // Layer 3: LLM reasoning if instant mode scored low
82
+ if (mode === 'reasoning' && llmAdapter && validation.score < 85) {
83
+ try {
84
+ const improved = await reasoningPass(html, messages, validation, llmAdapter);
85
+ if (improved) {
86
+ const improvedValidation = validateSchema(improved);
87
+ if (improvedValidation.score > validation.score) {
88
+ return {
89
+ messages: improved,
90
+ validation: improvedValidation,
91
+ suggestions: generateSuggestions(html, improved[0]?.components || [], improvedValidation),
92
+ };
93
+ }
94
+ }
95
+ } catch {
96
+ // Keep instant result on LLM failure
97
+ }
98
+ }
99
+
100
+ return { messages, validation, suggestions };
101
+ }
102
+
103
+ /**
104
+ * Transpile a live DOM element to A2UI messages (browser-only).
105
+ * Uses getComputedStyle for better structure inference.
106
+ *
107
+ * @param {HTMLElement} rootElement
108
+ * @param {object} [options]
109
+ * @returns {Promise<{ messages: object[], validation: object, suggestions: string[] }>}
110
+ */
111
+ export async function transpileDOM(rootElement, options = {}) {
112
+ const html = rootElement.outerHTML;
113
+ return transpileHTML(html, options);
114
+ }
115
+
116
+ // ═══════════════════════════════════════════════════════════════
117
+ // TREE WALKER
118
+ // ═══════════════════════════════════════════════════════════════
119
+
120
+ /**
121
+ * Recursively walk a DOM node and produce A2UI components.
122
+ *
123
+ * @param {MinimalElement} el
124
+ * @param {object} context — { usedIds, components, isRoot }
125
+ * @returns {string|null} — The generated ID for this node, or null if skipped
126
+ */
127
+ function walkNode(el, context) {
128
+ const tag = (el.tagName || '').toLowerCase();
129
+
130
+ // Skip non-element nodes, skip tags, empty text
131
+ if (!tag) {
132
+ // Text node
133
+ const text = (el.textContent || '').trim();
134
+ if (!text) return null;
135
+ // Create a Text component for bare text
136
+ const id = generateId('Text', el, context.usedIds);
137
+ context.components.push({ id, component: 'Text', textContent: text, variant: 'body' });
138
+ return id;
139
+ }
140
+
141
+ if (SKIP_TAGS.has(tag)) return null;
142
+
143
+ // ── Resolve A2UI type ──
144
+
145
+ let a2uiType = null;
146
+ let defaultProps = {};
147
+
148
+ // 1. Already an A2UI component (*-n tag)?
149
+ if (reverseRegistry.has(tag)) {
150
+ a2uiType = reverseRegistry.get(tag);
151
+ }
152
+ // 2. ARIA role?
153
+ else if (el.getAttribute('role') && ARIA_ROLE_MAP[el.getAttribute('role')]) {
154
+ a2uiType = ARIA_ROLE_MAP[el.getAttribute('role')];
155
+ }
156
+ // 3. Input element? Use type sub-map
157
+ else if (tag === 'input') {
158
+ const inputType = el.getAttribute('type') || 'text';
159
+ const mapped = INPUT_TYPE_MAP[inputType];
160
+ if (mapped) {
161
+ a2uiType = mapped.type;
162
+ defaultProps = { ...mapped.props };
163
+ } else {
164
+ a2uiType = 'TextField';
165
+ }
166
+ // Hidden inputs are skipped
167
+ if (inputType === 'hidden') return null;
168
+ }
169
+ // 4. HTML tag map?
170
+ else if (HTML_TAG_MAP[tag]) {
171
+ a2uiType = HTML_TAG_MAP[tag].type;
172
+ defaultProps = { ...HTML_TAG_MAP[tag].props };
173
+ }
174
+ // 5. div/span → Layer 2 structure inference
175
+ else if (tag === 'div' || tag === 'span') {
176
+ const inferred = inferStructure(el);
177
+ a2uiType = inferred.type;
178
+ defaultProps = inferred.props || {};
179
+ }
180
+ // 6. Unknown → Column (container)
181
+ else {
182
+ a2uiType = 'Column';
183
+ }
184
+
185
+ // ── Generate ID ──
186
+ const id = context.isRoot ? 'root' : generateId(a2uiType, el, context.usedIds);
187
+ context.isRoot = false;
188
+
189
+ // ── Extract props ──
190
+ const extractedProps = extractProps(el, a2uiType);
191
+ const props = { ...defaultProps, ...extractedProps };
192
+
193
+ // ── Handle children ──
194
+
195
+ // Leaf types: these render their own text, don't recurse into children
196
+ const LEAF_TYPES = new Set([
197
+ 'Text', 'Button', 'Kbd', 'Code', 'Badge', 'Image', 'Divider',
198
+ 'TextField', 'CheckBox', 'Toggle', 'Radio', 'Slider', 'Search',
199
+ 'Upload', 'ColorPicker', 'DateTimeInput', 'TextArea', 'OtpInput',
200
+ ]);
201
+
202
+ if (LEAF_TYPES.has(a2uiType)) {
203
+ context.components.push({ id, component: a2uiType, ...props });
204
+ return id;
205
+ }
206
+
207
+ const children = el.children || [];
208
+ const childElements = children.filter(c => c.tagName || (c.textContent || '').trim());
209
+
210
+ // Container with no children
211
+ if (childElements.length === 0) {
212
+ context.components.push({ id, component: a2uiType, ...props });
213
+ return id;
214
+ }
215
+
216
+ const childIds = [];
217
+ for (const child of childElements) {
218
+ const childId = walkNode(child, context);
219
+ if (childId) childIds.push(childId);
220
+ }
221
+
222
+ // Flatten: single-child container chains (div > div > div > content → just content)
223
+ if (childIds.length === 1 && ['Column', 'Row'].includes(a2uiType) && !Object.keys(props).length) {
224
+ // This container adds nothing — return the child directly
225
+ return childIds[0];
226
+ }
227
+
228
+ // ── Card content model enforcement ──
229
+ // Card must follow: Card > Header + Section > Column + Footer
230
+ if (a2uiType === 'Card') {
231
+ return buildCardStructure(id, childIds, props, context);
232
+ }
233
+
234
+ const component = { id, component: a2uiType, ...props };
235
+ if (childIds.length > 0) component.children = childIds;
236
+ context.components.push(component);
237
+
238
+ return id;
239
+ }
240
+
241
+ /**
242
+ * Build a proper Card structure: Card > Header + Section > Column + Footer.
243
+ * Classifies child components into header (headings), footer (buttons), and
244
+ * body (everything else wrapped in Section > Column).
245
+ *
246
+ * @param {string} cardId
247
+ * @param {string[]} childIds — IDs of already-created child components
248
+ * @param {Record<string, unknown>} cardProps
249
+ * @param {object} context
250
+ * @returns {string} — The card's ID
251
+ */
252
+ function buildCardStructure(cardId, childIds, cardProps, context) {
253
+ const headerIds = [];
254
+ const footerIds = [];
255
+ const bodyIds = [];
256
+
257
+ for (const childId of childIds) {
258
+ const child = context.components.find(c => c.id === childId);
259
+ if (!child) { bodyIds.push(childId); continue; }
260
+
261
+ const type = child.component;
262
+
263
+ // Header and Footer pass through directly (already correct type)
264
+ if (type === 'Header') { headerIds.push(childId); continue; }
265
+ if (type === 'Footer') { footerIds.push(childId); continue; }
266
+
267
+ // Headings (Text h1-h5) go into a synthesized Header
268
+ if (type === 'Text' && child.variant && /^h[1-5]$/.test(child.variant)) {
269
+ headerIds.push(childId);
270
+ continue;
271
+ }
272
+
273
+ // Buttons go into a synthesized Footer
274
+ if (type === 'Button') { footerIds.push(childId); continue; }
275
+
276
+ // Section passes through (but needs Column wrapping check)
277
+ if (type === 'Section') { bodyIds.push(childId); continue; }
278
+
279
+ // Everything else → body
280
+ bodyIds.push(childId);
281
+ }
282
+
283
+ const cardChildren = [];
284
+
285
+ // ── Build Header ──
286
+ if (headerIds.length > 0) {
287
+ // Check if there's already a Header component among them
288
+ const existingHeader = headerIds.find(id => context.components.find(c => c.id === id)?.component === 'Header');
289
+ if (existingHeader) {
290
+ cardChildren.push(existingHeader);
291
+ // Add any remaining header items as body instead
292
+ for (const hid of headerIds) {
293
+ if (hid !== existingHeader) bodyIds.unshift(hid);
294
+ }
295
+ } else {
296
+ // Wrap headings in a new Header — ensure data-heading is set
297
+ for (const hid of headerIds) {
298
+ const child = context.components.find(c => c.id === hid);
299
+ if (child && child.variant && /^h[1-5]$/.test(child.variant) && !child['data-heading']) {
300
+ child['data-heading'] = 'section';
301
+ }
302
+ }
303
+ const hdrId = generateId('Header', null, context.usedIds);
304
+ context.components.push({ id: hdrId, component: 'Header', children: headerIds });
305
+ cardChildren.push(hdrId);
306
+ }
307
+ }
308
+
309
+ // ── Build Section > Column for body content ──
310
+ if (bodyIds.length > 0) {
311
+ // Check if there's already a Section among them
312
+ const existingSection = bodyIds.find(id => context.components.find(c => c.id === id)?.component === 'Section');
313
+ if (existingSection && bodyIds.length === 1) {
314
+ // Single Section — ensure it has a Column wrapper
315
+ ensureSectionColumn(existingSection, context);
316
+ cardChildren.push(existingSection);
317
+ } else {
318
+ // Wrap body in Section > Column
319
+ const colId = generateId('Column', null, context.usedIds);
320
+ context.components.push({ id: colId, component: 'Column', children: bodyIds });
321
+
322
+ const secId = generateId('Section', null, context.usedIds);
323
+ context.components.push({ id: secId, component: 'Section', children: [colId] });
324
+ cardChildren.push(secId);
325
+ }
326
+ }
327
+
328
+ // ── Build Footer ──
329
+ if (footerIds.length > 0) {
330
+ // Check if there's already a Footer component among them
331
+ const existingFooter = footerIds.find(id => context.components.find(c => c.id === id)?.component === 'Footer');
332
+ if (existingFooter) {
333
+ cardChildren.push(existingFooter);
334
+ } else {
335
+ const ftrId = generateId('Footer', null, context.usedIds);
336
+ context.components.push({ id: ftrId, component: 'Footer', children: footerIds });
337
+ cardChildren.push(ftrId);
338
+ }
339
+ }
340
+
341
+ const card = { id: cardId, component: 'Card', ...cardProps };
342
+ if (cardChildren.length > 0) card.children = cardChildren;
343
+ context.components.push(card);
344
+ return cardId;
345
+ }
346
+
347
+ /**
348
+ * Ensure a Section component has a Column wrapper for its children.
349
+ * If the Section's children are not wrapped in a Column, insert one.
350
+ */
351
+ function ensureSectionColumn(sectionId, context) {
352
+ const section = context.components.find(c => c.id === sectionId);
353
+ if (!section || !section.children?.length) return;
354
+
355
+ // Already has a Column as direct child — good
356
+ const firstChild = context.components.find(c => c.id === section.children[0]);
357
+ if (firstChild?.component === 'Column') return;
358
+
359
+ // Wrap all children in a Column
360
+ const colId = generateId('Column', null, context.usedIds);
361
+ context.components.push({ id: colId, component: 'Column', children: [...section.children] });
362
+ section.children = [colId];
363
+ }
364
+
365
+ // ═══════════════════════════════════════════════════════════════
366
+ // LAYER 2: STRUCTURE INFERENCE
367
+ // ═══════════════════════════════════════════════════════════════
368
+
369
+ /**
370
+ * Infer the A2UI type for a div/span based on its structure and inline styles.
371
+ *
372
+ * @param {MinimalElement} el
373
+ * @returns {{ type: string, props?: Record<string, unknown> }}
374
+ */
375
+ function inferStructure(el) {
376
+ const style = parseInlineStyle(el.getAttribute('style') || '');
377
+ const display = style.get('display') || '';
378
+ const flexDir = style.get('flex-direction') || '';
379
+ const flexWrap = style.get('flex-wrap') || '';
380
+ const tag = (el.tagName || '').toLowerCase();
381
+
382
+ // Grid
383
+ if (display.includes('grid')) {
384
+ const cols = style.get('grid-template-columns') || '';
385
+ const colCount = cols ? cols.split(/\s+/).filter(s => s && s !== 'auto').length : 0;
386
+ return { type: 'Grid', props: colCount > 0 ? { columns: String(colCount) } : {} };
387
+ }
388
+
389
+ // Flex row
390
+ if (display.includes('flex') && (flexDir === 'row' || flexDir === '' || !flexDir)) {
391
+ const gap = style.get('gap') || style.get('column-gap') || '';
392
+ const props = {};
393
+ if (gap) props.gap = inferGap(gap);
394
+ if (flexWrap === 'wrap') props.wrap = true;
395
+ return { type: 'Row', props };
396
+ }
397
+
398
+ // Flex column
399
+ if (display.includes('flex') && flexDir === 'column') {
400
+ const gap = style.get('gap') || style.get('row-gap') || '';
401
+ const props = {};
402
+ if (gap) props.gap = inferGap(gap);
403
+ return { type: 'Column', props };
404
+ }
405
+
406
+ // Has header + footer children → Card
407
+ const children = el.children || [];
408
+ const childTags = children.map(c => (c.tagName || '').toLowerCase());
409
+ if (childTags.includes('header') && childTags.includes('footer')) {
410
+ return { type: 'Card' };
411
+ }
412
+ if (childTags.includes('header') || childTags.some(t => /^h[1-6]$/.test(t))) {
413
+ return { type: 'Card' };
414
+ }
415
+
416
+ // Text-only div
417
+ const hasElementChildren = children.some(c => c.tagName);
418
+ if (!hasElementChildren) {
419
+ const text = (el.textContent || '').trim();
420
+ if (text) return { type: 'Text', props: { variant: 'body' } };
421
+ }
422
+
423
+ // Span defaults to Text
424
+ if (tag === 'span') {
425
+ return { type: 'Text', props: { variant: 'body' } };
426
+ }
427
+
428
+ // Default div → Column
429
+ return { type: 'Column' };
430
+ }
431
+
432
+ // ═══════════════════════════════════════════════════════════════
433
+ // LAYER 3: LLM REASONING
434
+ // ═══════════════════════════════════════════════════════════════
435
+
436
+ /**
437
+ * Run the LLM reasoning pass on HTML that scored low in instant mode.
438
+ *
439
+ * @param {string} html — Original HTML
440
+ * @param {object[]} instantMessages — Instant mode result
441
+ * @param {object} validation — Instant mode validation
442
+ * @param {object} llmAdapter — LLM adapter instance
443
+ * @returns {Promise<object[]|null>} — Improved A2UI messages, or null
444
+ */
445
+ async function reasoningPass(html, instantMessages, validation, llmAdapter) {
446
+ const context = getContext('convert html', 2);
447
+ const failedChecks = validation.checks?.filter(c => !c.passed) || [];
448
+
449
+ const systemPrompt = buildTranspilerPrompt(context);
450
+ const userPrompt = `Convert this HTML to A2UI flat adjacency components:\n\n${html}\n\n`
451
+ + (failedChecks.length > 0
452
+ ? `The instant conversion had these issues:\n${failedChecks.map(c => `- ${c.name}: ${c.detail}`).join('\n')}\n\nFix these issues in your output.`
453
+ : '');
454
+
455
+ const response = await llmAdapter.complete({
456
+ messages: [{ role: 'user', content: userPrompt }],
457
+ systemPrompt,
458
+ });
459
+
460
+ return parseA2UIResponse(response.content);
461
+ }
462
+
463
+ /**
464
+ * Build the LLM system prompt for HTML → A2UI conversion.
465
+ */
466
+ function buildTranspilerPrompt(context) {
467
+ const parts = [];
468
+
469
+ parts.push(`You are an A2UI transpiler. Convert HTML markup into A2UI flat adjacency components.
470
+ Output ONLY a valid JSON array. No markdown fences, no explanation.
471
+
472
+ Output: [{ "type": "updateComponents", "surfaceId": "default", "components": [...] }]
473
+ Each component: { "id": "<unique>", "component": "<A2UIType>", "children": ["<id>", ...], ...props }
474
+ Root must have id "root".
475
+
476
+ RULES:
477
+ - Use ONLY registered A2UI types (never raw HTML tags like div, span, input, form)
478
+ - Card anatomy: Card > Header + Section + Footer. Section > Column > content.
479
+ - Header children: use Text with variant h3/h4 for title (+ data-heading="section"), variant caption for subtitle. Card CSS auto-targets native heading/small — no slot="heading" needed. Only action buttons need slot="action".
480
+ - Text: variant h1-h6 for headings, body for paragraphs, caption for small text
481
+ - TextField for text inputs, ChoicePicker for selects, CheckBox for checkboxes
482
+ - Button: text prop for label, variant primary/outline/ghost/danger
483
+ - Preserve all text content, form field names, placeholders, image src/alt`);
484
+
485
+ if (context.components?.length > 0) {
486
+ const catalog = context.components.map(c => {
487
+ const props = Array.isArray(c.properties)
488
+ ? c.properties.map(p => typeof p === 'string' ? p : p.name).join(', ')
489
+ : '';
490
+ return `- ${c.type}: ${props || '(no props)'}`;
491
+ }).join('\n');
492
+ parts.push(`AVAILABLE TYPES:\n${catalog}`);
493
+ }
494
+
495
+ return parts.join('\n\n');
496
+ }
497
+
498
+ // ═══════════════════════════════════════════════════════════════
499
+ // HTML PARSER (lightweight, Node.js compatible)
500
+ // ═══════════════════════════════════════════════════════════════
501
+
502
+ /**
503
+ * @typedef {object} MinimalElement
504
+ * @property {string} tagName
505
+ * @property {Map<string, string>} attributes
506
+ * @property {MinimalElement[]} children
507
+ * @property {string} textContent
508
+ * @property {(name: string) => string|null} getAttribute
509
+ */
510
+
511
+ const SELF_CLOSING = new Set([
512
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
513
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
514
+ ]);
515
+
516
+ /**
517
+ * Parse an HTML string into a minimal element tree.
518
+ * Not a full HTML parser — handles common patterns for transpilation.
519
+ *
520
+ * @param {string} html
521
+ * @returns {MinimalElement}
522
+ */
523
+ export function parseHTMLMinimal(html) {
524
+ // Strip doctype, comments
525
+ let src = html.replace(/<!DOCTYPE[^>]*>/gi, '').replace(/<!--[\s\S]*?-->/g, '').trim();
526
+
527
+ // If wrapped in <html>, extract <body> content
528
+ const bodyMatch = src.match(/<body[^>]*>([\s\S]*)<\/body>/i);
529
+ if (bodyMatch) src = bodyMatch[1].trim();
530
+
531
+ // Parse into tokens
532
+ const tokens = tokenize(src);
533
+
534
+ // Build tree
535
+ const root = { tagName: '', attributes: new Map(), children: [], textContent: '' };
536
+ root.getAttribute = (name) => root.attributes.get(name) ?? null;
537
+
538
+ const stack = [root];
539
+
540
+ for (const token of tokens) {
541
+ const parent = stack[stack.length - 1];
542
+
543
+ if (token.type === 'text') {
544
+ const text = token.value.trim();
545
+ if (text) {
546
+ parent.children.push({
547
+ tagName: '',
548
+ attributes: new Map(),
549
+ children: [],
550
+ textContent: text,
551
+ getAttribute: () => null,
552
+ });
553
+ }
554
+ } else if (token.type === 'open') {
555
+ const el = {
556
+ tagName: token.tag,
557
+ attributes: token.attrs,
558
+ children: [],
559
+ textContent: '',
560
+ getAttribute(name) { return this.attributes.get(name) ?? null; },
561
+ };
562
+ parent.children.push(el);
563
+
564
+ if (!SELF_CLOSING.has(token.tag) && !token.selfClose) {
565
+ stack.push(el);
566
+ }
567
+ } else if (token.type === 'close') {
568
+ // Pop stack until we find the matching open tag
569
+ for (let i = stack.length - 1; i > 0; i--) {
570
+ if (stack[i].tagName === token.tag) {
571
+ // Compute textContent for the closed element
572
+ stack[i].textContent = getTextContent(stack[i]);
573
+ stack.length = i;
574
+ break;
575
+ }
576
+ }
577
+ }
578
+ }
579
+
580
+ // Compute textContent for root
581
+ root.textContent = getTextContent(root);
582
+
583
+ // If root has exactly one child, return it directly
584
+ if (root.children.length === 1 && root.children[0].tagName) {
585
+ return root.children[0];
586
+ }
587
+
588
+ return root;
589
+ }
590
+
591
+ /**
592
+ * Tokenize HTML into open/close/text tokens.
593
+ */
594
+ function tokenize(html) {
595
+ const tokens = [];
596
+ let i = 0;
597
+
598
+ while (i < html.length) {
599
+ if (html[i] === '<') {
600
+ // Closing tag
601
+ if (html[i + 1] === '/') {
602
+ const end = html.indexOf('>', i);
603
+ if (end === -1) break;
604
+ const tag = html.slice(i + 2, end).trim().toLowerCase();
605
+ tokens.push({ type: 'close', tag });
606
+ i = end + 1;
607
+ } else {
608
+ // Opening tag
609
+ const end = html.indexOf('>', i);
610
+ if (end === -1) break;
611
+ const content = html.slice(i + 1, end);
612
+ const selfClose = content.endsWith('/');
613
+ const clean = selfClose ? content.slice(0, -1).trim() : content.trim();
614
+
615
+ // Parse tag name and attributes
616
+ const spaceIdx = clean.search(/[\s/]/);
617
+ const tag = (spaceIdx === -1 ? clean : clean.slice(0, spaceIdx)).toLowerCase();
618
+ const attrStr = spaceIdx === -1 ? '' : clean.slice(spaceIdx).trim();
619
+ const attrs = parseAttributes(attrStr);
620
+
621
+ tokens.push({ type: 'open', tag, attrs, selfClose });
622
+ i = end + 1;
623
+ }
624
+ } else {
625
+ // Text content
626
+ const nextTag = html.indexOf('<', i);
627
+ const text = nextTag === -1 ? html.slice(i) : html.slice(i, nextTag);
628
+ if (text) tokens.push({ type: 'text', value: text });
629
+ i = nextTag === -1 ? html.length : nextTag;
630
+ }
631
+ }
632
+
633
+ return tokens;
634
+ }
635
+
636
+ /**
637
+ * Parse HTML attribute string into a Map.
638
+ */
639
+ function parseAttributes(str) {
640
+ const attrs = new Map();
641
+ if (!str) return attrs;
642
+
643
+ const re = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
644
+ let match;
645
+ while ((match = re.exec(str))) {
646
+ const name = match[1].toLowerCase();
647
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
648
+ attrs.set(name, value);
649
+ }
650
+
651
+ return attrs;
652
+ }
653
+
654
+ /**
655
+ * Recursively get text content from a minimal element.
656
+ */
657
+ function getTextContent(el) {
658
+ if (!el.children || el.children.length === 0) {
659
+ return el.textContent || '';
660
+ }
661
+ return el.children.map(c => c.textContent || getTextContent(c)).join(' ').replace(/\s+/g, ' ').trim();
662
+ }
663
+
664
+ // ═══════════════════════════════════════════════════════════════
665
+ // HELPERS
666
+ // ═══════════════════════════════════════════════════════════════
667
+
668
+ /**
669
+ * Parse an inline style attribute into a Map.
670
+ */
671
+ function parseInlineStyle(style) {
672
+ const map = new Map();
673
+ if (!style) return map;
674
+ for (const decl of style.split(';')) {
675
+ const colon = decl.indexOf(':');
676
+ if (colon === -1) continue;
677
+ const prop = decl.slice(0, colon).trim().toLowerCase();
678
+ const value = decl.slice(colon + 1).trim();
679
+ if (prop && value) map.set(prop, value);
680
+ }
681
+ return map;
682
+ }
683
+
684
+ /**
685
+ * Generate a unique, semantic ID for an A2UI component.
686
+ */
687
+ function generateId(type, el, usedIds) {
688
+ // Try semantic hints
689
+ const candidates = [
690
+ el?.getAttribute?.('id'),
691
+ el?.getAttribute?.('name'),
692
+ el?.getAttribute?.('aria-label'),
693
+ ].filter(Boolean);
694
+
695
+ for (const hint of candidates) {
696
+ const id = slugify(hint);
697
+ if (id && !usedIds.has(id)) {
698
+ usedIds.add(id);
699
+ return id;
700
+ }
701
+ }
702
+
703
+ // Fallback: type-based with counter
704
+ const base = slugify(type);
705
+ let id = base;
706
+ let counter = 1;
707
+ while (usedIds.has(id)) {
708
+ id = `${base}-${++counter}`;
709
+ }
710
+ usedIds.add(id);
711
+ return id;
712
+ }
713
+
714
+ /**
715
+ * Convert a string to a kebab-case slug suitable for IDs.
716
+ */
717
+ function slugify(str) {
718
+ if (!str) return '';
719
+ return str
720
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
721
+ .toLowerCase()
722
+ .replace(/[^a-z0-9]+/g, '-')
723
+ .replace(/^-|-$/g, '')
724
+ .slice(0, 30);
725
+ }
726
+
727
+ /**
728
+ * Create a fallback A2UI message array with wired retry button.
729
+ * @returns {object[]} Array of A2UI messages (updateComponents + wireComponents)
730
+ */
731
+ function fallbackMessage(reason) {
732
+ const updateMsg = {
733
+ type: 'updateComponents',
734
+ surfaceId: 'default',
735
+ components: [
736
+ { id: 'root', component: 'Card', children: ['sec', 'ftr'] },
737
+ { id: 'sec', component: 'Section', children: ['alert'] },
738
+ { id: 'alert', component: 'Alert', variant: 'error', title: 'Conversion Error', description: reason },
739
+ { id: 'ftr', component: 'Footer', children: ['retry'] },
740
+ { id: 'retry', component: 'Button', text: 'Try Again', icon: 'arrow-clockwise', variant: 'primary' },
741
+ ],
742
+ };
743
+
744
+ const wireMsg = {
745
+ type: 'wireComponents',
746
+ surfaceId: 'default',
747
+ actions: [
748
+ {
749
+ event: { event: 'press', target: 'retry' },
750
+ handler: 'emit-event',
751
+ config: { eventName: 'a2ui-retry', detail: { source: 'transpiler' } },
752
+ },
753
+ ],
754
+ };
755
+
756
+ return [updateMsg, wireMsg];
757
+ }
758
+
759
+ /**
760
+ * Parse an LLM response string into A2UI messages (reused from generator pattern).
761
+ */
762
+ function parseA2UIResponse(content) {
763
+ if (!content || typeof content !== 'string') return null;
764
+
765
+ let json = content.trim();
766
+ const fenceMatch = json.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
767
+ if (fenceMatch) json = fenceMatch[1].trim();
768
+
769
+ try {
770
+ const parsed = JSON.parse(json);
771
+ if (Array.isArray(parsed)) {
772
+ if (parsed[0]?.type === 'updateComponents') return parsed;
773
+ if (parsed[0]?.id && parsed[0]?.component) {
774
+ return [{ type: 'updateComponents', surfaceId: 'default', components: parsed }];
775
+ }
776
+ }
777
+ if (parsed?.type === 'updateComponents') return [parsed];
778
+ return null;
779
+ } catch {
780
+ return null;
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Generate suggestions based on the transpilation result.
786
+ */
787
+ function generateSuggestions(html, components, validation) {
788
+ const suggestions = [];
789
+ const types = new Set(components.map(c => c.component));
790
+
791
+ // Check for raw HTML that couldn't be converted
792
+ if (html.includes('<svg')) {
793
+ suggestions.push('SVG elements were skipped — use icon-ui for icons or image-ui for illustrations');
794
+ }
795
+
796
+ if (!types.has('Card') && components.length > 3) {
797
+ suggestions.push('Consider wrapping in a Card for proper container semantics');
798
+ }
799
+
800
+ if (types.has('Section') && !components.some(c => c.component === 'Section' && c.children?.some(id => {
801
+ const child = components.find(x => x.id === id);
802
+ return child?.component === 'Column';
803
+ }))) {
804
+ suggestions.push('Section content should be wrapped in Column');
805
+ }
806
+
807
+ if (html.includes('style=') && (html.includes('#') || html.includes('rgb'))) {
808
+ suggestions.push('Source HTML contained hardcoded colors — A2UI output uses design tokens');
809
+ }
810
+
811
+ if (validation.score < 85) {
812
+ suggestions.push('Try "reasoning" mode for better structure inference');
813
+ }
814
+
815
+ if (components.some(c => ['TextField', 'CheckBox', 'Radio'].includes(c.component) && !c.label)) {
816
+ suggestions.push('Add label prop to form fields for accessibility');
817
+ }
818
+
819
+ return suggestions.slice(0, 4);
820
+ }