@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.
- package/CHANGELOG.md +86 -0
- package/README.md +181 -0
- package/engine/artifacts.js +262 -0
- package/engine/constitution.md +78 -0
- package/engine/context-store.js +218 -0
- package/engine/generator.js +500 -0
- package/engine/pattern-export.js +149 -0
- package/engine/pipeline/engine.js +289 -0
- package/engine/pipeline/types.js +91 -0
- package/engine/reference.js +115 -0
- package/engine/state.js +15 -0
- package/engines/monolithic/_shared.js +1320 -0
- package/engines/monolithic/generate-instant.js +229 -0
- package/engines/monolithic/generate-pro.js +367 -0
- package/engines/monolithic/generate-thinking.js +211 -0
- package/engines/registry.js +195 -0
- package/engines/zettel/_smoke.js +37 -0
- package/engines/zettel/composer.js +146 -0
- package/engines/zettel/fragment-library.js +209 -0
- package/engines/zettel/generate.js +15 -0
- package/engines/zettel/generator-adapter.js +202 -0
- package/engines/zettel/session-store.js +121 -0
- package/engines/zettel/synthesizer.js +343 -0
- package/evals/harness.mjs +193 -0
- package/index.js +16 -0
- package/llm/adapters/anthropic.js +106 -0
- package/llm/adapters/gemini.js +99 -0
- package/llm/adapters/index.js +138 -0
- package/llm/adapters/openai.js +85 -0
- package/llm/adapters/sse.js +50 -0
- package/llm/llm-bridge.js +214 -0
- package/llm/llm-stub.js +69 -0
- package/package.json +41 -0
- package/transpiler/transpiler-maps.js +277 -0
- package/transpiler/transpiler.js +820 -0
|
@@ -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
|
+
}
|