@typecad/ui 1.0.0-alpha.11 → 1.0.0-alpha.13

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.
Files changed (135) hide show
  1. package/README.md +21 -1
  2. package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
  3. package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  4. package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
  5. package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
  6. package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
  7. package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
  8. package/assets/fonts/dejavu/LICENSE +187 -0
  9. package/assets/fonts/dejavu/README.md +49 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +74 -0
  12. package/dist/engine-index.js +2 -0
  13. package/dist/preview/build-program.d.ts +7 -1
  14. package/dist/preview/build-program.js +94 -15
  15. package/dist/preview/host-gfx.d.ts +3 -0
  16. package/dist/preview/host-gfx.js +30 -3
  17. package/dist/preview/host-ui-runtime.d.ts +115 -0
  18. package/dist/preview/host-ui-runtime.js +928 -46
  19. package/dist/ui-engine/block-layout.js +25 -11
  20. package/dist/ui-engine/color.d.ts +6 -0
  21. package/dist/ui-engine/color.js +80 -2
  22. package/dist/ui-engine/compat-report.d.ts +8 -0
  23. package/dist/ui-engine/compat-report.js +124 -0
  24. package/dist/ui-engine/css-imports.d.ts +4 -0
  25. package/dist/ui-engine/css-imports.js +70 -0
  26. package/dist/ui-engine/css-parser.js +58 -14
  27. package/dist/ui-engine/default-font.d.ts +25 -0
  28. package/dist/ui-engine/default-font.js +80 -0
  29. package/dist/ui-engine/font-assets.d.ts +2 -2
  30. package/dist/ui-engine/font-assets.js +22 -5
  31. package/dist/ui-engine/html-parser.d.ts +4 -0
  32. package/dist/ui-engine/html-parser.js +241 -26
  33. package/dist/ui-engine/image-assets.d.ts +8 -1
  34. package/dist/ui-engine/image-assets.js +35 -2
  35. package/dist/ui-engine/image-decode.d.ts +22 -0
  36. package/dist/ui-engine/image-decode.js +194 -0
  37. package/dist/ui-engine/inline-parser.js +1 -1
  38. package/dist/ui-engine/layout-engine.d.ts +19 -0
  39. package/dist/ui-engine/layout-engine.js +59 -3
  40. package/dist/ui-engine/model.d.ts +18 -0
  41. package/dist/ui-engine/model.js +76 -8
  42. package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
  43. package/dist/ui-engine/runtime-header/canvas-helpers.js +5 -2
  44. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
  45. package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
  46. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
  47. package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
  48. package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
  49. package/dist/ui-engine/runtime-header/keyboard.js +682 -380
  50. package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1377
  51. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
  52. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
  53. package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
  54. package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
  55. package/dist/ui-engine/runtime-header/structs.js +228 -216
  56. package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
  57. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
  58. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
  59. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
  60. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +79 -23
  61. package/dist/ui-engine/runtime-header/types-defines.js +110 -93
  62. package/dist/ui-engine/shadcn-kit.d.ts +1 -0
  63. package/dist/ui-engine/shadcn-kit.js +502 -0
  64. package/dist/ui-engine/style-resolver.d.ts +4 -0
  65. package/dist/ui-engine/style-resolver.js +58 -6
  66. package/dist/ui-engine/transpile-ui.js +12 -5
  67. package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
  68. package/dist/ui-engine/ua-stylesheet.js +119 -32
  69. package/dist/ui-engine/ui-lowering.js +37 -3
  70. package/dist/ui-engine/ui-registry.js +32 -4
  71. package/dist/ui-engine/yoga-layout.js +57 -52
  72. package/dist/wizard/config-writer.d.ts +49 -0
  73. package/dist/wizard/config-writer.js +366 -0
  74. package/dist/wizard/display-catalog.d.ts +75 -0
  75. package/dist/wizard/display-catalog.js +207 -0
  76. package/dist/wizard/index.d.ts +7 -0
  77. package/dist/wizard/index.js +10 -0
  78. package/dist/wizard/integration-wizard.d.ts +15 -0
  79. package/dist/wizard/integration-wizard.js +486 -0
  80. package/dist/wizard/prompts.d.ts +32 -0
  81. package/dist/wizard/prompts.js +95 -0
  82. package/dist/wizard/starter-ui.d.ts +1 -0
  83. package/dist/wizard/starter-ui.js +56 -0
  84. package/package.json +18 -5
  85. package/src/cli.ts +87 -0
  86. package/src/engine-index.ts +53 -51
  87. package/src/preview/build-program.ts +810 -734
  88. package/src/preview/host-gfx.ts +30 -3
  89. package/src/preview/host-ui-runtime.ts +4133 -3289
  90. package/src/ui-engine/block-layout.ts +24 -11
  91. package/src/ui-engine/color.ts +77 -2
  92. package/src/ui-engine/compat-report.ts +139 -0
  93. package/src/ui-engine/css-imports.ts +69 -0
  94. package/src/ui-engine/css-parser.ts +64 -15
  95. package/src/ui-engine/default-font.ts +95 -0
  96. package/src/ui-engine/font-assets.ts +545 -525
  97. package/src/ui-engine/html-parser.ts +615 -397
  98. package/src/ui-engine/image-assets.ts +37 -2
  99. package/src/ui-engine/image-decode.ts +244 -0
  100. package/src/ui-engine/inline-parser.ts +1 -1
  101. package/src/ui-engine/layout-engine.ts +61 -3
  102. package/src/ui-engine/model.ts +1317 -1230
  103. package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
  104. package/src/ui-engine/runtime-header/canvas-helpers.ts +5 -2
  105. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
  106. package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
  107. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
  108. package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
  109. package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
  110. package/src/ui-engine/runtime-header/keyboard.ts +689 -386
  111. package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1402
  112. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
  113. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
  114. package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
  115. package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
  116. package/src/ui-engine/runtime-header/structs.ts +234 -222
  117. package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
  118. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
  119. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
  120. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
  121. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +79 -23
  122. package/src/ui-engine/runtime-header/types-defines.ts +116 -99
  123. package/src/ui-engine/shadcn-kit.ts +503 -0
  124. package/src/ui-engine/style-resolver.ts +471 -416
  125. package/src/ui-engine/transpile-ui.ts +12 -5
  126. package/src/ui-engine/ua-stylesheet.ts +137 -31
  127. package/src/ui-engine/ui-lowering.ts +486 -452
  128. package/src/ui-engine/ui-registry.ts +556 -524
  129. package/src/ui-engine/yoga-layout.ts +307 -309
  130. package/src/wizard/config-writer.ts +404 -0
  131. package/src/wizard/display-catalog.ts +273 -0
  132. package/src/wizard/index.ts +38 -0
  133. package/src/wizard/integration-wizard.ts +619 -0
  134. package/src/wizard/prompts.ts +145 -0
  135. package/src/wizard/starter-ui.ts +58 -0
@@ -1,397 +1,615 @@
1
- // ---------------------------------------------------------------------------
2
- // HTML parser — uses linkedom for robust DOM parsing, then adapts to the
3
- // UIElementNode shape that the rest of the pipeline expects.
4
- //
5
- // Supported subset:
6
- // - One <screen> root (required, exactly one).
7
- // - Child elements: <text>, <button>, <view> (a generic container).
8
- // - Attributes: id="...", class="a b".
9
- // - Text content of leaf elements.
10
- //
11
- // The public API (parseHtml: string → UIElementNode) is unchanged — callers
12
- // don't know whether linkedom or a regex parser is behind it.
13
- // ---------------------------------------------------------------------------
14
-
15
- import { parseHTML } from "linkedom";
16
- import type { Diagnostic } from "@typecad/cuttlefish/api/shared";
17
- import { collectInlineSequence, INLINE_TAGS, InlineItem } from "./inline-parser.js";
18
-
19
- export interface UIElementNode {
20
- tag: string;
21
- /** Original HTML tag before remapping (label/a/div/...), so CSS tag
22
- * selectors still match remapped elements. Equals tag when no remap. */
23
- origTag?: string;
24
- id?: string;
25
- classes: string[];
26
- text?: string;
27
- /** Value attribute (for <option>, <radio>). */
28
- value?: string;
29
- /** Name attribute (for <radio>: groups radios together). */
30
- name?: string;
31
- /** Checked attribute (for <radio>: initially selected). */
32
- checked?: boolean;
33
- /** Min/max attributes (for <range>). */
34
- min?: string;
35
- max?: string;
36
- /** Input type (for <input>: "text" | "number"). */
37
- type?: "text" | "number";
38
- /** Placeholder (for <input>). */
39
- placeholder?: string;
40
- /** Max length (for <input>). */
41
- maxlength?: number;
42
- /** Keyboard ref id (for <input>). */
43
- keyboard?: string;
44
- /** HTML hidden attribute: removes the element subtree from layout/rendering. */
45
- hidden?: boolean;
46
- /** Inline style attribute: style="color: red; font-size: 16px" */
47
- inlineStyle?: string;
48
- /** Navigation target for <a href="#screenId"> links. */
49
- href?: string;
50
- /** Image source path for <img src="...">. */
51
- src?: string;
52
- /** Image width in pixels (for <img>). */
53
- imgWidth?: number;
54
- /** Image height in pixels (for <img>). */
55
- imgHeight?: number;
56
- /** Item height in pixels (for <list item-height="24">). */
57
- itemHeight?: number;
58
- /** Canvas buffer width in pixels (for <canvas>). */
59
- canvasW?: number;
60
- /** Canvas buffer height in pixels (for <canvas>). */
61
- canvasH?: number;
62
-
63
- /** Disabled state */
64
- disabled?: boolean;
65
- children: UIElementNode[];
66
- /** Ordered inline content sequence (text/element/break items). Present only
67
- * for text nodes with mixed inline children; absent for plain-text nodes. */
68
- inline?: InlineItem[];
69
- /** True when the text content contains a `{expr}` interpolation, which the
70
- * auto-wire layer lowers to an implicit ui.bind(node,'text',...) text
71
- * binding. Plain text (no braces) is unchanged. */
72
- hasInterpolation?: boolean;
73
- /** Declarative event handlers from on:* attributes (e.g. on:click="save").
74
- * Keys: click | hold | release | change. Values: a named TS export function
75
- * the transpiler emits as a standalone C++ function; the handler table
76
- * references it by name. Absent when no on:* attributes are present. */
77
- events?: { click?: string; hold?: string; release?: string; change?: string };
78
- /** Declarative two-way bindings from bind:* attributes (e.g. bind:text="ssid").
79
- * Keys: text | value. Values: a signal name the node reflects the signal
80
- * (one-way: signal node), and user input writes back (node signal.set).
81
- * Absent when no bind:* attributes are present. */
82
- bind?: { text?: string; value?: string };
83
- /** TS handle name (screen.<ref>), separate from the CSS #id selector target.
84
- * When absent, falls back to `id` (backward-compatible). Lets an author keep
85
- * a CSS id without leaking every styled element into the TS surface. */
86
- ref?: string;
87
- /** For <select>: parsed option list from <option> children. */
88
- options?: Array<{ value: string; text: string }>;
89
- }
90
-
91
- /** A single key in a keyboard template. */
92
- export interface UIKeyTemplate {
93
- /** Character to insert, or label for special keys. */
94
- ch: string;
95
- /** 0=char, 1=shift, 2=backspace, 3=ok, 4=page-swap. */
96
- special: 0 | 1 | 2 | 3 | 4;
97
- /** CSS classes from <key class="..."> for styling. */
98
- classes?: string[];
99
- }
100
-
101
- /** A keyboard template parsed from <keyboard>. */
102
- export interface KeyboardTemplate {
103
- id: string;
104
- variant: "alpha" | "number";
105
- rows: UIKeyTemplate[][];
106
- /** CSS classes from <keyboard class="..."> for styling the keyboard background. */
107
- classes?: string[];
108
- }
109
-
110
- export interface ParsedHtml {
111
- /** The first <screen> tree (backward compat). */
112
- tree: UIElementNode;
113
- /** All <screen> roots (for multi-screen navigation). */
114
- screens: UIElementNode[];
115
- keyboards: KeyboardTemplate[];
116
- }
117
-
118
- const SUPPORTED_TAGS = new Set(["screen", "text", "button", "view", "check", "select", "option", "label", "radio", "progress", "range", "input", "keyboard", "row", "key", "style", "a", "img", "list", "canvas", "br"]);
119
-
120
- /** HTML tag aliases — common HTML elements remapped to internal primitives.
121
- * Semantic block containers -> view; inline/heading text tags -> text.
122
- * Applied before the SUPPORTED_TAGS check so authors can write familiar HTML. */
123
- const TAG_REMAP: Record<string, string> = {
124
- // Block-level containers -> view (flexbox/positioning surface)
125
- body: "view", div: "view", header: "view", footer: "view", nav: "view",
126
- main: "view", section: "view", article: "view", aside: "view",
127
- // Inline/heading text -> text
128
- span: "text", p: "text",
129
- h1: "text", h2: "text", h3: "text", h4: "text", h5: "text", h6: "text",
130
- // Styling tags -> text (inline; resolver applies bold/italic/underline defaults
131
- // and absorbs them into the parent's run list).
132
- b: "text", strong: "text", i: "text", em: "text", u: "text",
133
- };
134
-
135
- /** Extract <style>...</style> block contents from HTML source.
136
- * Returns the concatenated CSS text (empty if no style blocks). */
137
- export function extractStyleBlocks(src: string): string {
138
- const matches = src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi);
139
- return Array.from(matches).map(m => m[1]).join("\n");
140
- }
141
-
142
- export function parseHtml(src: string, diagnostics?: Diagnostic[]): UIElementNode {
143
- return parseAllScreens(src, diagnostics)[0];
144
- }
145
-
146
- /** Parse all <screen> roots from HTML. Returns one tree per screen.
147
- * Used for multi-screen navigation (<a href="#screenId">). */
148
- export function parseAllScreens(src: string, diagnostics?: Diagnostic[]): UIElementNode[] {
149
- const withoutComments = src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
150
-
151
- const wrapped = `<div id="__root__">${withoutComments}</div>`;
152
- const { document } = parseHTML(wrapped);
153
- const root = document.getElementById("__root__");
154
- if (!root) {
155
- throw new Error("UI HTML: failed to parse document");
156
- }
157
-
158
- const screenEls = Array.from(root.children).filter(
159
- (c) => c.tagName.toLowerCase() === "screen",
160
- );
161
-
162
- if (screenEls.length === 0) {
163
- throw new Error("UI HTML must have at least one <screen> root element");
164
- }
165
-
166
- return screenEls.map(el => domToUIElementNode(el, diagnostics));
167
- }
168
-
169
- /** Parse HTML, returning both the <screen> tree and any <keyboard> templates. */
170
- export function parseHtmlWithKeyboards(src: string, diagnostics?: Diagnostic[]): ParsedHtml {
171
- const withoutComments = src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
172
- const wrapped = `<div id="__root__">${withoutComments}</div>`;
173
- const { document } = parseHTML(wrapped);
174
- const root = document.getElementById("__root__");
175
- if (!root) {
176
- throw new Error("UI HTML: failed to parse document");
177
- }
178
-
179
- // Parse keyboards first (they are siblings of <screen>, not children).
180
- const keyboards: KeyboardTemplate[] = [];
181
- for (const child of Array.from(root.children)) {
182
- if (child.tagName.toLowerCase() !== "keyboard") continue;
183
- keyboards.push(parseKeyboardElement(child));
184
- }
185
-
186
- // Parse all screens (for multi-screen navigation) + keyboards.
187
- const screens = parseAllScreens(src, diagnostics);
188
- const tree = screens[0];
189
- return { tree, screens, keyboards };
190
- }
191
-
192
- /** Parse a <keyboard> element into a KeyboardTemplate. */
193
- function parseKeyboardElement(el: Element): KeyboardTemplate {
194
- const id = el.getAttribute("id") || "";
195
- const variantAttr = el.getAttribute("variant");
196
- const variant: "alpha" | "number" = variantAttr === "number" ? "number" : "alpha";
197
- const classAttr = el.getAttribute("class") || "";
198
- const classes = classAttr.split(/\s+/).filter(Boolean);
199
- const rows: UIKeyTemplate[][] = [];
200
- for (const rowEl of Array.from(el.children)) {
201
- if (rowEl.tagName.toLowerCase() !== "row") continue;
202
- const row: UIKeyTemplate[] = [];
203
- for (const keyEl of Array.from(rowEl.children)) {
204
- if (keyEl.tagName.toLowerCase() !== "key") continue;
205
- row.push(parseKeyElement(keyEl));
206
- }
207
- if (row.length > 0) rows.push(row);
208
- }
209
- return { id, variant, rows, classes: classes.length > 0 ? classes : undefined };
210
- }
211
-
212
- /** Parse a <key> element. Special keys are identified by label or special attr. */
213
- function parseKeyElement(el: Element): UIKeyTemplate {
214
- const label = el.textContent?.trim() || "";
215
- const classAttr = el.getAttribute("class") || "";
216
- const classes = classAttr.split(/\s+/).filter(Boolean);
217
- const specialAttr = el.getAttribute("special");
218
- let special: 0 | 1 | 2 | 3 | 4 = 0;
219
- if (specialAttr !== null) {
220
- const s = parseInt(specialAttr, 10);
221
- if (s >= 1 && s <= 4) special = s as 1 | 2 | 3 | 4;
222
- } else {
223
- // Recognize special keys by conventional labels.
224
- if (label === "⇧" || label.toUpperCase() === "SHIFT") special = 1;
225
- else if (label === "⌫" || label.toUpperCase() === "BACKSPACE") special = 2;
226
- else if (label.toUpperCase() === "OK") special = 3;
227
- else if (label === "123" || label.toUpperCase() === "ABC") special = 4;
228
- }
229
- return { ch: label, special, classes: classes.length > 0 ? classes : undefined };
230
- }
231
-
232
- /** Adapt a DOM element to UIElementNode, recursively walking children. */
233
- function domToUIElementNode(el: Element, diagnostics?: Diagnostic[]): UIElementNode {
234
- const tag = el.tagName.toLowerCase();
235
-
236
- // <label> and <a> are treated as <text> internally; HTML aliases
237
- // (div/header/span/p/h1-h6/...) remap to view or text.
238
- const remapped = TAG_REMAP[tag];
239
- const effectiveTag = remapped ? remapped
240
- : (tag === "label" || tag === "a") ? "text" : tag;
241
-
242
- if (!SUPPORTED_TAGS.has(effectiveTag)) {
243
- throw new Error(`Unsupported tag <${tag}> — supported: ${[...SUPPORTED_TAGS].join(", ")}`);
244
- }
245
-
246
- const id = el.getAttribute("id") || undefined;
247
- const refAttr = el.getAttribute("ref") || undefined;
248
- const classAttr = el.getAttribute("class") || "";
249
- const classes = classAttr.split(/\s+/).filter(Boolean);
250
- const valueAttr = el.getAttribute("value") || undefined;
251
- const nameAttr = el.getAttribute("name") || undefined;
252
- const checkedAttr = el.hasAttribute("checked");
253
- const minAttr = el.getAttribute("min") || undefined;
254
- const maxAttr = el.getAttribute("max") || undefined;
255
- const typeAttr = tag === "input"
256
- ? (el.getAttribute("type") === "number" ? "number" : "text")
257
- : undefined;
258
- const placeholderAttr = el.getAttribute("placeholder") || undefined;
259
- const maxlengthAttr = el.getAttribute("maxlength");
260
- // <input> defaults maxlength to 16 when absent or unparseable.
261
- const maxlengthNum = tag === "input"
262
- ? (maxlengthAttr ? (parseInt(maxlengthAttr, 10) || 16) : 16)
263
- : undefined;
264
- const keyboardAttr = el.getAttribute("keyboard") || undefined;
265
- const hiddenAttr = el.hasAttribute("hidden");
266
- const inlineStyleAttr = el.getAttribute("style") || undefined;
267
- const hrefAttr = (tag === "a" || tag === "button") ? (el.getAttribute("href") || undefined) : undefined;
268
-
269
- // Directive value normalization: Svelte encloses directive values in braces
270
- // (on:click={handler}, bind:value={signal}). The bare-string form
271
- // (on:click="handler") is also accepted for backward compatibility. Strip the
272
- // surrounding braces when present so both forms produce the same value.
273
- const normalizeDirective = (v: string | null): string | undefined => {
274
- if (!v) return undefined;
275
- const trimmed = v.trim();
276
- if (!trimmed) return undefined;
277
- // Svelte brace form: {expr} → expr
278
- const braceMatch = /^\{(.+)\}$/.exec(trimmed);
279
- return braceMatch ? braceMatch[1].trim() : trimmed;
280
- };
281
-
282
- // Declarative on:* event attributes → named-function references. Accept both
283
- // Svelte form (on:click={fn}) and quoted form (on:click="fn").
284
- const EVENT_KINDS = ["click", "hold", "release", "change"] as const;
285
- const events: { click?: string; hold?: string; release?: string; change?: string } = {};
286
- for (const kind of EVENT_KINDS) {
287
- const v = normalizeDirective(el.getAttribute(`on:${kind}`));
288
- if (v) events[kind] = v;
289
- }
290
- const hasEvents = Object.keys(events).length > 0;
291
-
292
- // Declarative bind:* two-way bindings → signal names. Accept both
293
- // Svelte form (bind:text={signal}) and quoted form (bind:text="signal").
294
- const BIND_KINDS = ["text", "value"] as const;
295
- const bind: { text?: string; value?: string } = {};
296
- for (const kind of BIND_KINDS) {
297
- const v = normalizeDirective(el.getAttribute(`bind:${kind}`));
298
- if (v) bind[kind] = v;
299
- }
300
- const hasBind = Object.keys(bind).length > 0;
301
- const srcAttr = tag === "img" ? (el.getAttribute("src") || undefined) : undefined;
302
- const imgWidthAttr = tag === "img" ? parseInt(el.getAttribute("width") || "0", 10) : undefined;
303
- const imgHeightAttr = tag === "img" ? parseInt(el.getAttribute("height") || "0", 10) : undefined;
304
- const itemHeightAttr = tag === "list" ? (parseInt(el.getAttribute("item-height") || "24", 10) || 24) : undefined;
305
- const canvasWAttr = tag === "canvas" ? (parseInt(el.getAttribute("width") || "0", 10) || 0) : undefined;
306
- const canvasHAttr = tag === "canvas" ? (parseInt(el.getAttribute("height") || "0", 10) || 0) : undefined;
307
- const disabledAttr = el.hasAttribute("disabled");
308
-
309
- // For <select>, parse <option> children into an options list
310
- if (tag === "select") {
311
- const optionEls = Array.from(el.children).filter(c => c.tagName.toLowerCase() === "option");
312
- if (optionEls.length > 0) {
313
- const options = optionEls.map(opt => ({
314
- value: opt.getAttribute("value") || opt.textContent?.trim() || "",
315
- text: opt.textContent?.trim() || "",
316
- }));
317
- // Use the first option's text as the initial display text
318
- const firstText = options[0]?.text ?? "";
319
- return {
320
- tag: "select",
321
- id, classes, text: firstText, value: valueAttr,
322
- hidden: hiddenAttr,
323
- inlineStyle: inlineStyleAttr,
324
- children: [],
325
- options,
326
- };
327
- }
328
- // Fallback: comma-separated text (legacy shorthand)
329
- const text = el.textContent?.trim() || "";
330
- const optNames = text.split(",").map(s => s.trim()).filter(Boolean);
331
- return {
332
- tag: "select",
333
- id, classes, text: optNames[0] || "",
334
- hidden: hiddenAttr,
335
- inlineStyle: inlineStyleAttr,
336
- children: [],
337
- options: optNames.map(t => ({ value: t.toLowerCase(), text: t })),
338
- };
339
- }
340
-
341
- // Text content: only direct text, not children's text.
342
- let text: string | undefined;
343
- const childElements = Array.from(el.children).filter((c) => {
344
- const ct = c.tagName.toLowerCase();
345
- // Accept native tags and HTML aliases (div/span/p/h1-h6/...) that remap later.
346
- const accepted = SUPPORTED_TAGS.has(ct) || TAG_REMAP[ct] !== undefined;
347
- if (!accepted && ct !== "option" && ct !== "br" && diagnostics) {
348
- diagnostics.push({
349
- severity: "warning",
350
- message: `Unknown HTML tag <${ct}> — ignored.`,
351
- hint: `Supported tags: ${[...SUPPORTED_TAGS].sort().join(", ")}.`,
352
- code: "unknown-html-tag",
353
- source: ct,
354
- });
355
- }
356
- return accepted && ct !== "option" && ct !== "br";
357
- });
358
-
359
- // Inline-bearing text nodes collect an ordered inline sequence instead of a
360
- // single text string. Only effective-tag "text" can be inline-bearing;
361
- // collectInlineSequence returns undefined for plain text or block-child nodes.
362
- let inline: InlineItem[] | undefined;
363
- if (effectiveTag === "text") {
364
- inline = collectInlineSequence(el, diagnostics ?? []);
365
- }
366
-
367
- // Text content: only direct text, and only when there's no inline sequence
368
- // (inline content is captured above; the plain-text path is unchanged).
369
- let hasInterpolation = false;
370
- if (!inline && childElements.length === 0) {
371
- const parts: string[] = [];
372
- for (const child of Array.from(el.childNodes)) {
373
- if ((child as any).nodeType === 3) {
374
- parts.push(child.textContent ?? "");
375
- continue;
376
- }
377
- const childTag = (child as Element).tagName?.toLowerCase();
378
- if (childTag === "br") parts.push("\n");
379
- }
380
- const tc = parts.join("").trim();
381
- if (tc) {
382
- text = tc;
383
- // Detect a `{expr}` interpolation (non-empty content between braces).
384
- // Inline + interpolation is rejected later by the run-text-binding guard.
385
- hasInterpolation = /\{[^{}]+\}/.test(tc);
386
- }
387
- }
388
-
389
- const remappedFrom = (remapped || tag === "label" || tag === "a") && tag !== effectiveTag ? tag : undefined;
390
- const node: UIElementNode = { tag: effectiveTag, origTag: remappedFrom, id, classes, text, value: valueAttr, name: nameAttr, checked: checkedAttr, min: minAttr, max: maxAttr, type: typeAttr, placeholder: placeholderAttr, maxlength: maxlengthNum, keyboard: keyboardAttr, hidden: hiddenAttr, inlineStyle: inlineStyleAttr, href: hrefAttr, src: srcAttr, imgWidth: imgWidthAttr || undefined, imgHeight: imgHeightAttr || undefined, itemHeight: itemHeightAttr, canvasW: canvasWAttr, canvasH: canvasHAttr, disabled: disabledAttr, inline, hasInterpolation, events: hasEvents ? events : undefined, bind: hasBind ? bind : undefined, ref: refAttr, children: [] };
391
- for (const child of childElements) {
392
- // Inline children are absorbed into `inline`; don't also emit them as nodes.
393
- if (inline && INLINE_TAGS.has(child.tagName.toLowerCase())) continue;
394
- node.children.push(domToUIElementNode(child, diagnostics));
395
- }
396
- return node;
397
- }
1
+ // ---------------------------------------------------------------------------
2
+ // HTML parser — uses linkedom for robust DOM parsing, then adapts to the
3
+ // UIElementNode shape that the rest of the pipeline expects.
4
+ //
5
+ // Supported subset:
6
+ // - One <screen> root (required, exactly one).
7
+ // - Child elements: <text>, <button>, <view> (a generic container).
8
+ // - Attributes: id="...", class="a b".
9
+ // - Text content of leaf elements.
10
+ //
11
+ // The public API (parseHtml: string → UIElementNode) is unchanged — callers
12
+ // don't know whether linkedom or a regex parser is behind it.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { parseHTML } from "linkedom";
16
+ import type { Diagnostic } from "@typecad/cuttlefish/api/shared";
17
+ import { collectInlineSequence, INLINE_TAGS, InlineItem } from "./inline-parser.js";
18
+
19
+ export interface UIElementNode {
20
+ tag: string;
21
+ /** Original HTML tag before remapping (label/a/div/...), so CSS tag
22
+ * selectors still match remapped elements. Equals tag when no remap. */
23
+ origTag?: string;
24
+ id?: string;
25
+ classes: string[];
26
+ text?: string;
27
+ /** Value attribute (for <option>, <radio>). */
28
+ value?: string;
29
+ /** Name attribute (for <radio>: groups radios together). */
30
+ name?: string;
31
+ /** Checked attribute (for <radio>: initially selected). */
32
+ checked?: boolean;
33
+ /** Min/max attributes (for <range>). */
34
+ min?: string;
35
+ max?: string;
36
+ /** Input type (for <input>: "text" | "number"). */
37
+ type?: "text" | "number";
38
+ /** Placeholder (for <input>). */
39
+ placeholder?: string;
40
+ /** Max length (for <input>). */
41
+ maxlength?: number;
42
+ /** Keyboard ref id (for <input>). */
43
+ keyboard?: string;
44
+ /** HTML hidden attribute: removes the element subtree from layout/rendering. */
45
+ hidden?: boolean;
46
+ /** Inline style attribute: style="color: red; font-size: 16px" */
47
+ inlineStyle?: string;
48
+ /** Navigation target for <a href="#screenId"> links. */
49
+ href?: string;
50
+ /** Image source path for <img src="...">. */
51
+ src?: string;
52
+ /** Image width in pixels (for <img>). */
53
+ imgWidth?: number;
54
+ /** Image height in pixels (for <img>). */
55
+ imgHeight?: number;
56
+ /** Item height in pixels (for <list item-height="24">). */
57
+ itemHeight?: number;
58
+ /** Canvas buffer width in pixels (for <canvas>). */
59
+ canvasW?: number;
60
+ /** Canvas buffer height in pixels (for <canvas>). */
61
+ canvasH?: number;
62
+
63
+ /** Disabled state */
64
+ disabled?: boolean;
65
+ /** <drawer side="bottom|top|left|right"> — the edge the panel slides from. */
66
+ drawerSide?: string;
67
+ /** <toast duration="2500"> ms a toast stays open before auto-closing. */
68
+ toastDuration?: number;
69
+ children: UIElementNode[];
70
+ /** Ordered inline content sequence (text/element/break items). Present only
71
+ * for text nodes with mixed inline children; absent for plain-text nodes. */
72
+ inline?: InlineItem[];
73
+ /** True when the text content contains a `{expr}` interpolation, which the
74
+ * auto-wire layer lowers to an implicit ui.bind(node,'text',...) text
75
+ * binding. Plain text (no braces) is unchanged. */
76
+ hasInterpolation?: boolean;
77
+ /** Declarative event handlers from on:* attributes (e.g. on:click="save").
78
+ * Keys: click | hold | release | change. Values: a named TS export function
79
+ * the transpiler emits as a standalone C++ function; the handler table
80
+ * references it by name. Absent when no on:* attributes are present. */
81
+ events?: { click?: string; hold?: string; release?: string; change?: string };
82
+ /** Declarative two-way bindings from bind:* attributes (e.g. bind:text="ssid").
83
+ * Keys: text | value. Values: a signal name — the node reflects the signal
84
+ * (one-way: signal → node), and user input writes back (node signal.set).
85
+ * Absent when no bind:* attributes are present. */
86
+ bind?: { text?: string; value?: string };
87
+ /** TS handle name (screen.<ref>), separate from the CSS #id selector target.
88
+ * When absent, falls back to `id` (backward-compatible). Lets an author keep
89
+ * a CSS id without leaking every styled element into the TS surface. */
90
+ ref?: string;
91
+ /** For <select>: parsed option list from <option> children. */
92
+ options?: Array<{ value: string; text: string }>;
93
+ }
94
+
95
+ /** A single key in a keyboard template. */
96
+ export interface UIKeyTemplate {
97
+ /** Character to insert, or label for special keys. */
98
+ ch: string;
99
+ /** 0=char, 1=shift, 2=backspace, 3=ok, 4=page-swap. */
100
+ special: 0 | 1 | 2 | 3 | 4;
101
+ /** CSS classes from <key class="..."> for styling. */
102
+ classes?: string[];
103
+ }
104
+
105
+ /** A keyboard template parsed from <keyboard>. */
106
+ export interface KeyboardTemplate {
107
+ id: string;
108
+ variant: "alpha" | "number";
109
+ rows: UIKeyTemplate[][];
110
+ /** CSS classes from <keyboard class="..."> for styling the keyboard background. */
111
+ classes?: string[];
112
+ }
113
+
114
+ export interface ParsedHtml {
115
+ /** The first <screen> tree (backward compat). */
116
+ tree: UIElementNode;
117
+ /** All <screen> roots (for multi-screen navigation). */
118
+ screens: UIElementNode[];
119
+ keyboards: KeyboardTemplate[];
120
+ }
121
+
122
+ const SUPPORTED_TAGS = new Set(["screen", "text", "button", "view", "check", "select", "option", "label", "radio", "progress", "range", "input", "keyboard", "row", "key", "style", "a", "img", "list", "canvas", "br", "drawer", "dialog", "toast"]);
123
+
124
+ /** Document-furniture tags that never render. Skipped silently (unlike unknown
125
+ * tags, which fall back to generic containers with a warning). */
126
+ const METADATA_TAGS = new Set(["link", "meta", "title", "head", "script", "source", "track", "col", "colgroup"]);
127
+
128
+ /** Web elements that cannot work on microcontroller targets (vector graphics,
129
+ * media, embedded browsing). Their subtree still renders as generic containers
130
+ * content is never dropped but the diagnostic says WHY, with the native
131
+ * alternative, instead of a generic unknown-tag warning. */
132
+ const UNSUPPORTED_TAGS: Record<string, string> = {
133
+ svg: "vector graphics have no renderer — draw via <canvas> or export a bitmap for <img>",
134
+ video: "there is no video pipeline on MCU targets",
135
+ audio: "there is no audio pipeline on MCU targets",
136
+ iframe: "there is no browser engine to embed",
137
+ embed: "there is no plugin/content engine on MCU targets",
138
+ object: "there is no plugin/content engine on MCU targets",
139
+ picture: "art-direction source selection is unsupported — a child <img> still renders",
140
+ };
141
+
142
+ /** HTML tag aliases common HTML elements remapped to internal primitives.
143
+ * Semantic block containers -> view; inline/heading text tags -> text.
144
+ * Applied before the SUPPORTED_TAGS check so authors can write familiar HTML. */
145
+ const TAG_REMAP: Record<string, string> = {
146
+ // Block-level containers -> view (flexbox/positioning surface)
147
+ body: "view", div: "view", header: "view", footer: "view", nav: "view",
148
+ main: "view", section: "view", article: "view", aside: "view",
149
+ // Forms are layout-transparent here (no submission model) — the wrapper is
150
+ // just a container; children render normally.
151
+ form: "view", fieldset: "view",
152
+ // Lists: the container is a plain view; <li> gets a marker prefix below.
153
+ ul: "view", ol: "view", li: "text",
154
+ // Definition lists: dt = bold term, dd = indented description (UA rules).
155
+ dl: "view", dt: "text", dd: "text",
156
+ // Tables: equal-width flex approximation — tr is a row, td/th are stretched
157
+ // cells (UA rules); thead/tbody/tfoot are plain row groups. Approximation
158
+ // surfaced via the html-table-approximation diagnostic.
159
+ table: "view", tr: "view", td: "text", th: "text",
160
+ thead: "view", tbody: "view", tfoot: "view", caption: "text",
161
+ // Horizontal rule -> 1px rule (styled by the UA stylesheet).
162
+ hr: "view",
163
+ // Inline/heading text -> text
164
+ span: "text", p: "text", small: "text", output: "text",
165
+ h1: "text", h2: "text", h3: "text", h4: "text", h5: "text", h6: "text",
166
+ // Styling tags -> text (inline; resolver applies bold/italic/underline defaults
167
+ // and absorbs them into the parent's run list).
168
+ b: "text", strong: "text", i: "text", em: "text", u: "text",
169
+ // Code phrase groups -> text; the UA stylesheet gives them the mono family.
170
+ code: "text", kbd: "text", samp: "text", pre: "text",
171
+ // Meter is the same bar primitive as progress (min/max/value).
172
+ meter: "progress",
173
+ // Multiline text area -> single-line input (single-line OSK at runtime).
174
+ textarea: "input",
175
+ };
176
+
177
+ /** <input type="..."> spellings that remap to the dedicated control tags. */
178
+ const INPUT_TYPE_REMAP: Record<string, string> = {
179
+ checkbox: "check",
180
+ radio: "radio",
181
+ range: "range",
182
+ };
183
+
184
+ /** Extract <style>...</style> block contents from HTML source.
185
+ * Returns the concatenated CSS text (empty if no style blocks). */
186
+ export function extractStyleBlocks(src: string): string {
187
+ const matches = src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi);
188
+ return Array.from(matches).map(m => m[1]).join("\n");
189
+ }
190
+
191
+ export function parseHtml(src: string, diagnostics?: Diagnostic[]): UIElementNode {
192
+ return parseAllScreens(src, diagnostics)[0];
193
+ }
194
+
195
+ // HTML-spec parsers treat `<view .../>` as an OPEN tag for non-void elements
196
+ // (XML-style self-closing is not HTML), so JSX-habit markup silently nests:
197
+ // `<view class="a"/><view class="b"/>` puts b INSIDE a. Expand every
198
+ // self-closing non-void tag to an explicit pair before parsing. Void
199
+ // elements (img, hr, input, ...) already parse correctly and are skipped.
200
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
201
+ function expandSelfClosingTags(src: string): string {
202
+ return src.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)\/>/g, (m, tag: string, attrs: string) => {
203
+ if (VOID_TAGS.has(tag.toLowerCase())) return m;
204
+ return `<${tag}${attrs}></${tag}>`;
205
+ });
206
+ }
207
+
208
+ /** Parse all <screen> roots from HTML. Returns one tree per screen.
209
+ * Used for multi-screen navigation (<a href="#screenId">). */
210
+ export function parseAllScreens(src: string, diagnostics?: Diagnostic[]): UIElementNode[] {
211
+ const withoutComments = expandSelfClosingTags(src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ""));
212
+
213
+ const wrapped = `<div id="__root__">${withoutComments}</div>`;
214
+ const { document } = parseHTML(wrapped);
215
+ const root = document.getElementById("__root__");
216
+ if (!root) {
217
+ throw new Error("UI HTML: failed to parse document");
218
+ }
219
+
220
+ const screenEls = Array.from(root.children).filter(
221
+ (c) => c.tagName.toLowerCase() === "screen",
222
+ );
223
+
224
+ if (screenEls.length === 0) {
225
+ throw new Error("UI HTML must have at least one <screen> root element");
226
+ }
227
+
228
+ return screenEls.map(el => domToUIElementNode(el, diagnostics));
229
+ }
230
+
231
+ /** Parse HTML, returning both the <screen> tree and any <keyboard> templates. */
232
+ export function parseHtmlWithKeyboards(src: string, diagnostics?: Diagnostic[]): ParsedHtml {
233
+ const withoutComments = expandSelfClosingTags(src.replace(/<!--[\s\S]*?-->/g, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ""));
234
+ const wrapped = `<div id="__root__">${withoutComments}</div>`;
235
+ const { document } = parseHTML(wrapped);
236
+ const root = document.getElementById("__root__");
237
+ if (!root) {
238
+ throw new Error("UI HTML: failed to parse document");
239
+ }
240
+
241
+ // Parse keyboards first (they are siblings of <screen>, not children).
242
+ const keyboards: KeyboardTemplate[] = [];
243
+ for (const child of Array.from(root.children)) {
244
+ if (child.tagName.toLowerCase() !== "keyboard") continue;
245
+ keyboards.push(parseKeyboardElement(child));
246
+ }
247
+
248
+ // Parse all screens (for multi-screen navigation) + keyboards.
249
+ const screens = parseAllScreens(src, diagnostics);
250
+ const tree = screens[0];
251
+ return { tree, screens, keyboards };
252
+ }
253
+
254
+ /** Parse a <keyboard> element into a KeyboardTemplate. */
255
+ function parseKeyboardElement(el: Element): KeyboardTemplate {
256
+ const id = el.getAttribute("id") || "";
257
+ const variantAttr = el.getAttribute("variant");
258
+ const variant: "alpha" | "number" = variantAttr === "number" ? "number" : "alpha";
259
+ const classAttr = el.getAttribute("class") || "";
260
+ const classes = classAttr.split(/\s+/).filter(Boolean);
261
+ const rows: UIKeyTemplate[][] = [];
262
+ for (const rowEl of Array.from(el.children)) {
263
+ if (rowEl.tagName.toLowerCase() !== "row") continue;
264
+ const row: UIKeyTemplate[] = [];
265
+ for (const keyEl of Array.from(rowEl.children)) {
266
+ if (keyEl.tagName.toLowerCase() !== "key") continue;
267
+ row.push(parseKeyElement(keyEl));
268
+ }
269
+ if (row.length > 0) rows.push(row);
270
+ }
271
+ return { id, variant, rows, classes: classes.length > 0 ? classes : undefined };
272
+ }
273
+
274
+ /** Parse a <key> element. Special keys are identified by label or special attr. */
275
+ function parseKeyElement(el: Element): UIKeyTemplate {
276
+ const label = el.textContent?.trim() || "";
277
+ const classAttr = el.getAttribute("class") || "";
278
+ const classes = classAttr.split(/\s+/).filter(Boolean);
279
+ const specialAttr = el.getAttribute("special");
280
+ let special: 0 | 1 | 2 | 3 | 4 = 0;
281
+ if (specialAttr !== null) {
282
+ const s = parseInt(specialAttr, 10);
283
+ if (s >= 1 && s <= 4) special = s as 1 | 2 | 3 | 4;
284
+ } else {
285
+ // Recognize special keys by conventional labels.
286
+ if (label === "⇧" || label.toUpperCase() === "SHIFT") special = 1;
287
+ else if (label === "⌫" || label.toUpperCase() === "BACKSPACE") special = 2;
288
+ else if (label.toUpperCase() === "OK") special = 3;
289
+ else if (label === "123" || label.toUpperCase() === "ABC") special = 4;
290
+ }
291
+ return { ch: label, special, classes: classes.length > 0 ? classes : undefined };
292
+ }
293
+
294
+ /** Adapt a DOM element to UIElementNode, recursively walking children. */
295
+ function domToUIElementNode(el: Element, diagnostics?: Diagnostic[]): UIElementNode {
296
+ const tag = el.tagName.toLowerCase();
297
+
298
+ // <label> and <a> are treated as <text> internally; HTML aliases
299
+ // (div/header/span/p/h1-h6/...) remap to view or text.
300
+ const remapped = TAG_REMAP[tag];
301
+ let effectiveTag = remapped ? remapped
302
+ : (tag === "label" || tag === "a") ? "text" : tag;
303
+
304
+ // <input type="checkbox|radio|range"> the spelling web authors type // reflexively remaps to the dedicated control tags with identical behavior.
305
+ // Other non-text types (email, date, ...) have no MCU counterpart: they
306
+ // normalize to a single-line text input with a warning.
307
+ if (tag === "input") {
308
+ const inputType = (el.getAttribute("type") || "text").toLowerCase();
309
+ const remap = INPUT_TYPE_REMAP[inputType];
310
+ if (remap) {
311
+ effectiveTag = remap;
312
+ } else if (inputType !== "text" && inputType !== "number") {
313
+ diagnostics?.push({
314
+ severity: "warning",
315
+ message: `<input type="${inputType}"> has no embedded equivalent — treated as a single-line text input.`,
316
+ hint: `Supported types: text, number, checkbox, radio, range.`,
317
+ code: "html-input-type-unsupported",
318
+ source: "input",
319
+ });
320
+ }
321
+ }
322
+
323
+ if (!SUPPORTED_TAGS.has(effectiveTag)) {
324
+ // Web behavior: unknown elements are generic boxes (this is why custom
325
+ // elements work in HTML). Content is NEVER dropped — a text-only leaf
326
+ // becomes a text node, anything with element children a container.
327
+ const hasElementChildren = Array.from(el.children).some(
328
+ (c) => !METADATA_TAGS.has(c.tagName.toLowerCase()),
329
+ );
330
+ effectiveTag = hasElementChildren ? "view" : "text";
331
+ const knownUnsupported = UNSUPPORTED_TAGS[tag];
332
+ if (knownUnsupported) {
333
+ diagnostics?.push({
334
+ severity: "warning",
335
+ message: `<${tag}> is not supported on microcontroller targets — rendered as a generic container.`,
336
+ hint: `${knownUnsupported}.`,
337
+ code: "unsupported-html-tag",
338
+ source: tag,
339
+ });
340
+ } else {
341
+ diagnostics?.push({
342
+ severity: "warning",
343
+ message: `Unknown HTML tag <${tag}> — rendered as a generic ${hasElementChildren ? "container" : "text"} element.`,
344
+ hint: `Supported tags: ${[...SUPPORTED_TAGS].sort().join(", ")}.`,
345
+ code: "unknown-html-tag",
346
+ source: tag,
347
+ });
348
+ }
349
+ }
350
+
351
+ const id = el.getAttribute("id") || undefined;
352
+ const refAttr = el.getAttribute("ref") || undefined;
353
+ const classAttr = el.getAttribute("class") || "";
354
+ const classes = classAttr.split(/\s+/).filter(Boolean);
355
+ const valueAttr = el.getAttribute("value") || undefined;
356
+ const nameAttr = el.getAttribute("name") || undefined;
357
+ const checkedAttr = el.hasAttribute("checked");
358
+ const minAttr = el.getAttribute("min") || undefined;
359
+ const maxAttr = el.getAttribute("max") || undefined;
360
+ // Text-input specifics only apply to the text/number input (not to
361
+ // checkbox/radio/range inputs remapped above, and not to <textarea>).
362
+ const isTextInput = tag === "input" && effectiveTag === "input";
363
+ if (tag === "textarea") {
364
+ diagnostics?.push({
365
+ severity: "warning",
366
+ message: `<textarea> renders as a single-line <input> (the on-screen keyboard is single-line).`,
367
+ code: "html-textarea-single-line",
368
+ source: "textarea",
369
+ });
370
+ }
371
+ const typeAttr = isTextInput
372
+ ? (el.getAttribute("type") === "number" ? "number" : "text")
373
+ : undefined;
374
+ const placeholderAttr = isTextInput ? (el.getAttribute("placeholder") || undefined) : undefined;
375
+ const maxlengthAttr = el.getAttribute("maxlength");
376
+ // <input> defaults maxlength to 16 when absent or unparseable.
377
+ const maxlengthNum = isTextInput
378
+ ? (maxlengthAttr ? (parseInt(maxlengthAttr, 10) || 16) : 16)
379
+ : undefined;
380
+ const keyboardAttr = isTextInput ? (el.getAttribute("keyboard") || undefined) : undefined;
381
+ const hiddenAttr = el.hasAttribute("hidden");
382
+ const inlineStyleAttr = el.getAttribute("style") || undefined;
383
+ const hrefAttr = (tag === "a" || tag === "button") ? (el.getAttribute("href") || undefined) : undefined;
384
+
385
+ // Directive value normalization: Svelte encloses directive values in braces
386
+ // (on:click={handler}, bind:value={signal}). The bare-string form
387
+ // (on:click="handler") is also accepted for backward compatibility. Strip the
388
+ // surrounding braces when present so both forms produce the same value.
389
+ const normalizeDirective = (v: string | null): string | undefined => {
390
+ if (!v) return undefined;
391
+ const trimmed = v.trim();
392
+ if (!trimmed) return undefined;
393
+ // Svelte brace form: {expr} → expr
394
+ const braceMatch = /^\{(.+)\}$/.exec(trimmed);
395
+ return braceMatch ? braceMatch[1].trim() : trimmed;
396
+ };
397
+
398
+ // Declarative on:* event attributes → named-function references. Accept both
399
+ // Svelte form (on:click={fn}) and quoted form (on:click="fn").
400
+ const EVENT_KINDS = ["click", "hold", "release", "change"] as const;
401
+ const events: { click?: string; hold?: string; release?: string; change?: string } = {};
402
+ for (const kind of EVENT_KINDS) {
403
+ const v = normalizeDirective(el.getAttribute(`on:${kind}`));
404
+ if (v) events[kind] = v;
405
+ }
406
+ const hasEvents = Object.keys(events).length > 0;
407
+
408
+ // Declarative bind:* two-way bindings → signal names. Accept both
409
+ // Svelte form (bind:text={signal}) and quoted form (bind:text="signal").
410
+ const BIND_KINDS = ["text", "value"] as const;
411
+ const bind: { text?: string; value?: string } = {};
412
+ for (const kind of BIND_KINDS) {
413
+ const v = normalizeDirective(el.getAttribute(`bind:${kind}`));
414
+ if (v) bind[kind] = v;
415
+ }
416
+ const hasBind = Object.keys(bind).length > 0;
417
+ const srcAttr = tag === "img" ? (el.getAttribute("src") || undefined) : undefined;
418
+ const imgWidthAttr = tag === "img" ? parseInt(el.getAttribute("width") || "0", 10) : undefined;
419
+ const imgHeightAttr = tag === "img" ? parseInt(el.getAttribute("height") || "0", 10) : undefined;
420
+ const itemHeightAttr = tag === "list" ? (parseInt(el.getAttribute("item-height") || "24", 10) || 24) : undefined;
421
+ const canvasWAttr = tag === "canvas" ? (parseInt(el.getAttribute("width") || "0", 10) || 0) : undefined;
422
+ const canvasHAttr = tag === "canvas" ? (parseInt(el.getAttribute("height") || "0", 10) || 0) : undefined;
423
+ const disabledAttr = el.hasAttribute("disabled");
424
+
425
+ // For <select>, parse <option> children into an options list
426
+ if (tag === "select") {
427
+ const optionEls = Array.from(el.children).filter(c => c.tagName.toLowerCase() === "option");
428
+ if (optionEls.length > 0) {
429
+ const options = optionEls.map(opt => ({
430
+ value: opt.getAttribute("value") || opt.textContent?.trim() || "",
431
+ text: opt.textContent?.trim() || "",
432
+ }));
433
+ // Use the first option's text as the initial display text
434
+ const firstText = options[0]?.text ?? "";
435
+ return {
436
+ tag: "select",
437
+ id, classes, text: firstText, value: valueAttr,
438
+ hidden: hiddenAttr,
439
+ inlineStyle: inlineStyleAttr,
440
+ children: [],
441
+ options,
442
+ };
443
+ }
444
+ // Fallback: comma-separated text (legacy shorthand)
445
+ const text = el.textContent?.trim() || "";
446
+ const optNames = text.split(",").map(s => s.trim()).filter(Boolean);
447
+ return {
448
+ tag: "select",
449
+ id, classes, text: optNames[0] || "",
450
+ hidden: hiddenAttr,
451
+ inlineStyle: inlineStyleAttr,
452
+ children: [],
453
+ options: optNames.map(t => ({ value: t.toLowerCase(), text: t })),
454
+ };
455
+ }
456
+
457
+ // Text content: only direct text, not children's text.
458
+ let text: string | undefined;
459
+ // Element children that become nodes: everything except document metadata
460
+ // (skipped silently) and option/br (consumed by the select/text paths).
461
+ // Unknown tags are KEPT — their own recursive call renders them as generic
462
+ // containers, so no subtree is ever dropped for being unrecognized.
463
+ const childElements = Array.from(el.children).filter((c) => {
464
+ const ct = c.tagName.toLowerCase();
465
+ if (METADATA_TAGS.has(ct)) return false;
466
+ return ct !== "option" && ct !== "br";
467
+ });
468
+ // Non-empty direct text runs (trimmed). Runs that sit next to element
469
+ // children become anonymous text children below — the CSS anonymous-box
470
+ // model: a container's stray text is never silently dropped.
471
+ const directTextRuns: string[] = [];
472
+ for (const child of Array.from(el.childNodes)) {
473
+ if ((child as any).nodeType === 3) {
474
+ const t = (child.textContent ?? "").trim();
475
+ if (t) directTextRuns.push(t);
476
+ }
477
+ }
478
+
479
+ // Inline-bearing text nodes collect an ordered inline sequence instead of a
480
+ // single text string. Only effective-tag "text" can be inline-bearing;
481
+ // collectInlineSequence returns undefined for plain text or block-child nodes.
482
+ let inline: InlineItem[] | undefined;
483
+ if (effectiveTag === "text") {
484
+ inline = collectInlineSequence(el, diagnostics ?? []);
485
+ }
486
+ // A text tag whose inline collection failed (block child inside <p>/<text>)
487
+ // keeps its tag — selectors must keep matching it — and its stray text is
488
+ // preserved as anonymous text children below (the anonymous-block-box model).
489
+
490
+ // Text content: only direct text, and only when there's no inline sequence
491
+ // (inline content is captured above; the plain-text path is unchanged).
492
+ let hasInterpolation = false;
493
+ if (!inline && childElements.length === 0) {
494
+ const parts: string[] = [];
495
+ for (const child of Array.from(el.childNodes)) {
496
+ if ((child as any).nodeType === 3) {
497
+ parts.push(child.textContent ?? "");
498
+ continue;
499
+ }
500
+ const childTag = (child as Element).tagName?.toLowerCase();
501
+ if (childTag === "br") parts.push("\n");
502
+ }
503
+ const tc = parts.join("").trim();
504
+ if (tc) {
505
+ text = tc;
506
+ // Detect a `{expr}` interpolation (non-empty content between braces).
507
+ // Inline + interpolation is rejected later by the run-text-binding guard.
508
+ hasInterpolation = /\{[^{}]+\}/.test(tc);
509
+ }
510
+ }
511
+
512
+ // <table> approximation note: rows are flex rows of equal-width stretched
513
+ // cells. Visible, not silent — see the UA stylesheet for the styling.
514
+ if (tag === "table") {
515
+ diagnostics?.push({
516
+ severity: "info",
517
+ message: `<table> renders as equal-width flex columns (tr = row, td/th = cells) — no auto column sizing, colspan, or rowspan.`,
518
+ hint: `Set explicit widths or flex-grow on cells for custom column sizes.`,
519
+ code: "html-table-approximation",
520
+ source: "table",
521
+ });
522
+ }
523
+ // colspan/rowspan have no equal-width-flex equivalent — warn when present.
524
+ if (tag === "td" || tag === "th") {
525
+ for (const span of ["colspan", "rowspan"]) {
526
+ if (el.hasAttribute(span)) {
527
+ diagnostics?.push({
528
+ severity: "warning",
529
+ message: `<${tag}> ${span} is not supported — the cell renders as a single equal-width column/row.`,
530
+ hint: `Split the content across cells, or restructure with a flex row.`,
531
+ code: "html-table-span-unsupported",
532
+ source: tag,
533
+ });
534
+ }
535
+ }
536
+ }
537
+
538
+ // <li> list markers: text-only items get a prefix ("• " for <ul>, "N. " for
539
+ // <ol>) so plain lists read correctly with zero styling. Items with block
540
+ // children render as containers without a marker (style those yourself).
541
+ if (tag === "li" && (text || inline)) {
542
+ const parentTag = (el.parentElement as Element | null)?.tagName?.toLowerCase();
543
+ let marker = "• ";
544
+ if (parentTag === "ol") {
545
+ let n = 0;
546
+ for (const sib of Array.from(el.parentElement!.children)) {
547
+ if (sib === el) break;
548
+ if (sib.tagName.toLowerCase() === "li") n++;
549
+ }
550
+ marker = `${n + 1}. `;
551
+ }
552
+ if (text) text = marker + text;
553
+ if (inline) inline = [{ kind: "text", text: marker }, ...inline];
554
+ }
555
+
556
+ const remappedFrom = (remapped || tag === "label" || tag === "a") && tag !== effectiveTag ? tag : undefined;
557
+ const drawerSideAttr = tag === "dialog"
558
+ ? "center"
559
+ : (tag === "drawer" || tag === "toast") ? (el.getAttribute("side") ?? "bottom").toLowerCase() : undefined;
560
+ const toastDurationAttr = tag === "toast"
561
+ ? Math.max(500, parseInt(el.getAttribute("duration") || "2500", 10) || 2500)
562
+ : undefined;
563
+ const node: UIElementNode = { tag: effectiveTag, origTag: remappedFrom, id, classes, text, value: valueAttr, name: nameAttr, drawerSide: drawerSideAttr, toastDuration: toastDurationAttr, checked: checkedAttr, min: minAttr, max: maxAttr, type: typeAttr, placeholder: placeholderAttr, maxlength: maxlengthNum, keyboard: keyboardAttr, hidden: hiddenAttr, inlineStyle: inlineStyleAttr, href: hrefAttr, src: srcAttr, imgWidth: imgWidthAttr || undefined, imgHeight: imgHeightAttr || undefined, itemHeight: itemHeightAttr, canvasW: canvasWAttr, canvasH: canvasHAttr, disabled: disabledAttr, inline, hasInterpolation, events: hasEvents ? events : undefined, bind: hasBind ? bind : undefined, ref: refAttr, children: [] };
564
+
565
+ // Anonymous inline wrap: stray text next to ONLY-inline element children
566
+ // flows as one text line (CSS anonymous inline boxes) instead of stacking
567
+ // "Total:" above the <b>3</b> run. Requires stray text to trigger — inline
568
+ // elements alone keep the legacy stacked behavior (deliberate chip layout).
569
+ if (!inline && childElements.length > 0 && directTextRuns.length > 0) {
570
+ const allInline = childElements.every((c) => INLINE_TAGS.has(c.tagName.toLowerCase()));
571
+ if (allInline) {
572
+ const seq = collectInlineSequence(el, diagnostics ?? []);
573
+ if (seq) {
574
+ node.children.push({
575
+ tag: "text",
576
+ classes: [],
577
+ inline: seq,
578
+ hasInterpolation: directTextRuns.some((t) => /\{[^{}]+\}/.test(t)),
579
+ children: [],
580
+ });
581
+ return node;
582
+ }
583
+ }
584
+ }
585
+
586
+ // Children in document order, interleaving anonymous text children for
587
+ // stray text runs (never dropped — the CSS anonymous-block-box model).
588
+ // Inline children already absorbed by an inline[] sequence are skipped.
589
+ let textBuffer = "";
590
+ const flushText = (): void => {
591
+ const t = textBuffer.trim();
592
+ textBuffer = "";
593
+ if (!t) return;
594
+ node.children.push({
595
+ tag: "text",
596
+ classes: [],
597
+ text: t,
598
+ hasInterpolation: /\{[^{}]+\}/.test(t),
599
+ children: [],
600
+ });
601
+ };
602
+ for (const child of Array.from(el.childNodes)) {
603
+ if ((child as any).nodeType === 3) {
604
+ if (childElements.length > 0 && !inline) textBuffer += child.textContent ?? "";
605
+ continue;
606
+ }
607
+ const childTag = (child as Element).tagName?.toLowerCase();
608
+ if (!childTag || METADATA_TAGS.has(childTag) || childTag === "option" || childTag === "br") continue;
609
+ if (inline && INLINE_TAGS.has(childTag)) continue;
610
+ flushText();
611
+ node.children.push(domToUIElementNode(child as Element, diagnostics));
612
+ }
613
+ flushText();
614
+ return node;
615
+ }