@typecad/ui 1.0.0-alpha.12 → 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.
- package/assets/fonts/dejavu/DejaVuSans-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans-Oblique.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSans.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono-Bold.ttf +0 -0
- package/assets/fonts/dejavu/DejaVuSansMono.ttf +0 -0
- package/assets/fonts/dejavu/LICENSE +187 -0
- package/assets/fonts/dejavu/README.md +49 -0
- package/dist/engine-index.js +2 -0
- package/dist/preview/build-program.d.ts +7 -1
- package/dist/preview/build-program.js +94 -15
- package/dist/preview/host-gfx.d.ts +3 -0
- package/dist/preview/host-gfx.js +30 -3
- package/dist/preview/host-ui-runtime.d.ts +115 -0
- package/dist/preview/host-ui-runtime.js +928 -46
- package/dist/ui-engine/block-layout.js +25 -11
- package/dist/ui-engine/color.d.ts +6 -0
- package/dist/ui-engine/color.js +80 -2
- package/dist/ui-engine/compat-report.d.ts +8 -0
- package/dist/ui-engine/compat-report.js +124 -0
- package/dist/ui-engine/css-imports.d.ts +4 -0
- package/dist/ui-engine/css-imports.js +70 -0
- package/dist/ui-engine/css-parser.js +58 -14
- package/dist/ui-engine/default-font.d.ts +25 -0
- package/dist/ui-engine/default-font.js +80 -0
- package/dist/ui-engine/font-assets.d.ts +2 -2
- package/dist/ui-engine/font-assets.js +22 -5
- package/dist/ui-engine/html-parser.d.ts +4 -0
- package/dist/ui-engine/html-parser.js +241 -26
- package/dist/ui-engine/image-assets.d.ts +8 -1
- package/dist/ui-engine/image-assets.js +35 -2
- package/dist/ui-engine/image-decode.d.ts +22 -0
- package/dist/ui-engine/image-decode.js +194 -0
- package/dist/ui-engine/inline-parser.js +1 -1
- package/dist/ui-engine/layout-engine.d.ts +19 -0
- package/dist/ui-engine/layout-engine.js +59 -3
- package/dist/ui-engine/model.d.ts +18 -0
- package/dist/ui-engine/model.js +75 -7
- package/dist/ui-engine/runtime-header/blend-bodies.js +29 -9
- package/dist/ui-engine/runtime-header/canvas-helpers.js +3 -0
- package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -111
- package/dist/ui-engine/runtime-header/cuttlefish-gfx.js +21 -0
- package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -179
- package/dist/ui-engine/runtime-header/forward-decls.js +215 -167
- package/dist/ui-engine/runtime-header/init-press-input.js +138 -135
- package/dist/ui-engine/runtime-header/keyboard.js +682 -380
- package/dist/ui-engine/runtime-header/node-draw-body.js +1656 -1386
- package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -214
- package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -568
- package/dist/ui-engine/runtime-header/scroll-physics.js +25 -1
- package/dist/ui-engine/runtime-header/state-bindings-nav.js +9 -0
- package/dist/ui-engine/runtime-header/structs.js +228 -216
- package/dist/ui-engine/runtime-header/text-rendering.js +888 -770
- package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -116
- package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +897 -633
- package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -25
- package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +78 -23
- package/dist/ui-engine/runtime-header/types-defines.js +110 -93
- package/dist/ui-engine/shadcn-kit.d.ts +1 -0
- package/dist/ui-engine/shadcn-kit.js +502 -0
- package/dist/ui-engine/style-resolver.d.ts +4 -0
- package/dist/ui-engine/style-resolver.js +58 -6
- package/dist/ui-engine/transpile-ui.js +12 -5
- package/dist/ui-engine/ua-stylesheet.d.ts +22 -1
- package/dist/ui-engine/ua-stylesheet.js +119 -32
- package/dist/ui-engine/ui-lowering.js +37 -3
- package/dist/ui-engine/ui-registry.js +32 -4
- package/dist/ui-engine/yoga-layout.js +57 -52
- package/package.json +9 -3
- package/src/engine-index.ts +53 -51
- package/src/preview/build-program.ts +810 -734
- package/src/preview/host-gfx.ts +30 -3
- package/src/preview/host-ui-runtime.ts +4133 -3289
- package/src/ui-engine/block-layout.ts +24 -11
- package/src/ui-engine/color.ts +77 -2
- package/src/ui-engine/compat-report.ts +139 -0
- package/src/ui-engine/css-imports.ts +69 -0
- package/src/ui-engine/css-parser.ts +64 -15
- package/src/ui-engine/default-font.ts +95 -0
- package/src/ui-engine/font-assets.ts +545 -525
- package/src/ui-engine/html-parser.ts +615 -397
- package/src/ui-engine/image-assets.ts +37 -2
- package/src/ui-engine/image-decode.ts +244 -0
- package/src/ui-engine/inline-parser.ts +1 -1
- package/src/ui-engine/layout-engine.ts +61 -3
- package/src/ui-engine/model.ts +1317 -1230
- package/src/ui-engine/runtime-header/blend-bodies.ts +29 -9
- package/src/ui-engine/runtime-header/canvas-helpers.ts +3 -0
- package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -117
- package/src/ui-engine/runtime-header/cuttlefish-gfx.ts +21 -0
- package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -185
- package/src/ui-engine/runtime-header/forward-decls.ts +227 -179
- package/src/ui-engine/runtime-header/init-press-input.ts +144 -141
- package/src/ui-engine/runtime-header/keyboard.ts +689 -386
- package/src/ui-engine/runtime-header/node-draw-body.ts +1681 -1411
- package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -220
- package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -574
- package/src/ui-engine/runtime-header/scroll-physics.ts +25 -1
- package/src/ui-engine/runtime-header/state-bindings-nav.ts +9 -0
- package/src/ui-engine/runtime-header/structs.ts +234 -222
- package/src/ui-engine/runtime-header/text-rendering.ts +894 -776
- package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -122
- package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -638
- package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -31
- package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +78 -23
- package/src/ui-engine/runtime-header/types-defines.ts +116 -99
- package/src/ui-engine/shadcn-kit.ts +503 -0
- package/src/ui-engine/style-resolver.ts +471 -416
- package/src/ui-engine/transpile-ui.ts +12 -5
- package/src/ui-engine/ua-stylesheet.ts +137 -31
- package/src/ui-engine/ui-lowering.ts +486 -452
- package/src/ui-engine/ui-registry.ts +556 -524
- package/src/ui-engine/yoga-layout.ts +307 -309
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
}
|