@relevate/katachi 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +12 -2
  2. package/bin/katachi.mjs +0 -0
  3. package/dist/api/index.d.ts +11 -5
  4. package/dist/api/index.js +6 -7
  5. package/dist/cli/index.js +14 -5
  6. package/dist/core/ast.d.ts +17 -4
  7. package/dist/core/ast.js +3 -2
  8. package/dist/core/build.d.ts +2 -0
  9. package/dist/core/build.js +13 -1
  10. package/dist/core/parser.js +123 -16
  11. package/dist/core/types.d.ts +1 -0
  12. package/dist/targets/askama.d.ts +3 -1
  13. package/dist/targets/askama.js +44 -12
  14. package/dist/targets/index.js +14 -0
  15. package/dist/targets/liquid.d.ts +2 -0
  16. package/dist/targets/liquid.js +422 -0
  17. package/dist/targets/react.js +239 -5
  18. package/dist/targets/shared.d.ts +23 -3
  19. package/dist/targets/shared.js +323 -14
  20. package/dist/targets/static-jsx.js +15 -2
  21. package/docs/architecture.md +0 -1
  22. package/docs/getting-started.md +2 -0
  23. package/docs/syntax.md +35 -8
  24. package/docs/targets.md +16 -0
  25. package/examples/basic/README.md +2 -1
  26. package/examples/basic/components/notice-panel.html +2 -2
  27. package/examples/basic/dist/askama/includes/notice-panel.html +2 -2
  28. package/examples/basic/dist/askama/notice-panel.rs +3 -2
  29. package/examples/basic/dist/jsx-static/comparison-table.tsx +2 -2
  30. package/examples/basic/dist/jsx-static/media-frame.tsx +1 -1
  31. package/examples/basic/dist/jsx-static/notice-panel.tsx +6 -4
  32. package/examples/basic/dist/jsx-static/resource-tile.tsx +3 -3
  33. package/examples/basic/dist/liquid/snippets/badge-chip.liquid +5 -0
  34. package/examples/basic/dist/liquid/snippets/comparison-table.liquid +34 -0
  35. package/examples/basic/dist/liquid/snippets/glyph.liquid +6 -0
  36. package/examples/basic/dist/liquid/snippets/hover-note.liquid +6 -0
  37. package/examples/basic/dist/liquid/snippets/media-frame.liquid +23 -0
  38. package/examples/basic/dist/liquid/snippets/notice-panel.liquid +30 -0
  39. package/examples/basic/dist/liquid/snippets/resource-tile.liquid +38 -0
  40. package/examples/basic/dist/liquid/snippets/stack-shell.liquid +5 -0
  41. package/examples/basic/dist/react/badge-chip.tsx +1 -1
  42. package/examples/basic/dist/react/comparison-table.tsx +9 -9
  43. package/examples/basic/dist/react/glyph.tsx +1 -1
  44. package/examples/basic/dist/react/media-frame.tsx +1 -1
  45. package/examples/basic/dist/react/notice-panel.tsx +6 -4
  46. package/examples/basic/dist/react/resource-tile.tsx +3 -3
  47. package/examples/basic/src/templates/comparison-table.template.tsx +5 -5
  48. package/examples/basic/src/templates/media-frame.template.tsx +3 -3
  49. package/examples/basic/src/templates/notice-panel.template.tsx +48 -34
  50. package/examples/basic/src/templates/resource-tile.template.tsx +7 -7
  51. package/package.json +66 -68
@@ -0,0 +1,422 @@
1
+ import { emitInterpolatedTagName, wrapHtmlAttribute } from "./shared.js";
2
+ function nextTempName(context, prefix) {
3
+ context.tempId += 1;
4
+ return `__katachi_${prefix}_${context.tempId}`;
5
+ }
6
+ function pad(indent) {
7
+ return " ".repeat(indent);
8
+ }
9
+ function escapeLiquidString(value) {
10
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
11
+ }
12
+ function translateTsxExprToLiquid(source) {
13
+ let result = source.trim();
14
+ result = result.replace(/([A-Za-z0-9_().]+)\.clone\(\)\.unwrap\(\)/g, "$1");
15
+ result = result.replace(/([A-Za-z0-9_().]+)\.unwrap\(\)/g, "$1");
16
+ result = result.replace(/([A-Za-z0-9_().]+)\.is_some\(\)/g, "$1 != nil");
17
+ result = result.replace(/([A-Za-z0-9_().]+)\.is_none\(\)/g, "$1 == nil");
18
+ result = result.replace(/!\s*([A-Za-z0-9_().]+)\.is_empty\(\)/g, "$1 != blank");
19
+ result = result.replace(/([A-Za-z0-9_().]+)\.is_empty\(\)/g, "$1 == blank");
20
+ result = result.replace(/([A-Za-z0-9_().]+)\.len\(\)/g, "$1.size");
21
+ result = result.replace(/\s===\s/g, " == ");
22
+ result = result.replace(/\s!==\s/g, " != ");
23
+ result = result.replace(/\s==\s/g, " == ");
24
+ result = result.replace(/\s!=\s/g, " != ");
25
+ result = result.replace(/\s&&\s/g, " and ");
26
+ result = result.replace(/\s\|\|\s/g, " or ");
27
+ return result;
28
+ }
29
+ function emitLiquidScalarExpr(expr) {
30
+ switch (expr.kind) {
31
+ case "var":
32
+ return expr.name;
33
+ case "string":
34
+ return `"${escapeLiquidString(expr.value)}"`;
35
+ case "bool":
36
+ return expr.value ? "true" : "false";
37
+ case "number":
38
+ return String(expr.value);
39
+ case "intrinsic": {
40
+ const [arg] = expr.args;
41
+ const emittedArg = arg ? emitLiquidScalarExpr(arg) : null;
42
+ if (!emittedArg) {
43
+ return null;
44
+ }
45
+ if (expr.name === "len") {
46
+ return `${emittedArg}.size`;
47
+ }
48
+ return null;
49
+ }
50
+ case "raw":
51
+ return translateTsxExprToLiquid(expr.source);
52
+ default:
53
+ return null;
54
+ }
55
+ }
56
+ function emitLiquidTagExpr(expr) {
57
+ const scalar = emitLiquidScalarExpr(expr);
58
+ if (scalar) {
59
+ return scalar;
60
+ }
61
+ if (expr.kind === "raw") {
62
+ return translateTsxExprToLiquid(expr.source);
63
+ }
64
+ return "";
65
+ }
66
+ function emitSimpleLiquidCondition(expr) {
67
+ switch (expr.kind) {
68
+ case "var":
69
+ return expr.name;
70
+ case "bool":
71
+ return expr.value ? "true" : "false";
72
+ case "raw":
73
+ return translateTsxExprToLiquid(expr.source);
74
+ case "eq":
75
+ case "neq": {
76
+ const left = emitLiquidScalarExpr(expr.left);
77
+ const right = emitLiquidScalarExpr(expr.right);
78
+ if (!left || !right) {
79
+ return null;
80
+ }
81
+ return `${left} ${expr.kind === "eq" ? "==" : "!="} ${right}`;
82
+ }
83
+ case "intrinsic": {
84
+ const [arg] = expr.args;
85
+ const emittedArg = arg ? emitLiquidScalarExpr(arg) : null;
86
+ if (!emittedArg) {
87
+ return null;
88
+ }
89
+ switch (expr.name) {
90
+ case "isEmpty":
91
+ return `${emittedArg} == blank`;
92
+ case "isSome":
93
+ return `${emittedArg} != nil`;
94
+ case "isNone":
95
+ return `${emittedArg} == nil`;
96
+ default:
97
+ return null;
98
+ }
99
+ }
100
+ default:
101
+ return null;
102
+ }
103
+ }
104
+ function materializeLiquidBooleanExpr(expr, context, indent) {
105
+ const simple = emitSimpleLiquidCondition(expr);
106
+ if (simple) {
107
+ return {
108
+ prelude: [],
109
+ source: simple,
110
+ };
111
+ }
112
+ switch (expr.kind) {
113
+ case "and": {
114
+ const left = materializeLiquidBooleanExpr(expr.left, context, indent);
115
+ const right = materializeLiquidBooleanExpr(expr.right, context, indent);
116
+ const ref = nextTempName(context, "cond");
117
+ return {
118
+ prelude: [
119
+ ...left.prelude,
120
+ ...right.prelude,
121
+ `${pad(indent)}{% assign ${ref} = false %}`,
122
+ `${pad(indent)}{% if ${left.source} %}`,
123
+ `${pad(indent + 1)}{% if ${right.source} %}`,
124
+ `${pad(indent + 2)}{% assign ${ref} = true %}`,
125
+ `${pad(indent + 1)}{% endif %}`,
126
+ `${pad(indent)}{% endif %}`,
127
+ ],
128
+ source: ref,
129
+ };
130
+ }
131
+ case "or": {
132
+ const left = materializeLiquidBooleanExpr(expr.left, context, indent);
133
+ const right = materializeLiquidBooleanExpr(expr.right, context, indent);
134
+ const ref = nextTempName(context, "cond");
135
+ return {
136
+ prelude: [
137
+ ...left.prelude,
138
+ ...right.prelude,
139
+ `${pad(indent)}{% assign ${ref} = false %}`,
140
+ `${pad(indent)}{% if ${left.source} %}`,
141
+ `${pad(indent + 1)}{% assign ${ref} = true %}`,
142
+ `${pad(indent)}{% elsif ${right.source} %}`,
143
+ `${pad(indent + 1)}{% assign ${ref} = true %}`,
144
+ `${pad(indent)}{% endif %}`,
145
+ ],
146
+ source: ref,
147
+ };
148
+ }
149
+ case "not": {
150
+ const inner = materializeLiquidBooleanExpr(expr.expr, context, indent);
151
+ const ref = nextTempName(context, "cond");
152
+ return {
153
+ prelude: [
154
+ ...inner.prelude,
155
+ `${pad(indent)}{% assign ${ref} = false %}`,
156
+ `${pad(indent)}{% unless ${inner.source} %}`,
157
+ `${pad(indent + 1)}{% assign ${ref} = true %}`,
158
+ `${pad(indent)}{% endunless %}`,
159
+ ],
160
+ source: ref,
161
+ };
162
+ }
163
+ default: {
164
+ const ref = nextTempName(context, "cond");
165
+ return {
166
+ prelude: [
167
+ `${pad(indent)}{% assign ${ref} = false %}`,
168
+ ],
169
+ source: ref,
170
+ };
171
+ }
172
+ }
173
+ }
174
+ function materializeLiquidBooleanValue(expr, context, indent) {
175
+ const condition = materializeLiquidBooleanExpr(expr, context, indent);
176
+ const ref = nextTempName(context, "bool");
177
+ return {
178
+ prelude: [
179
+ ...condition.prelude,
180
+ `${pad(indent)}{% assign ${ref} = false %}`,
181
+ `${pad(indent)}{% if ${condition.source} %}`,
182
+ `${pad(indent + 1)}{% assign ${ref} = true %}`,
183
+ `${pad(indent)}{% endif %}`,
184
+ ],
185
+ source: ref,
186
+ };
187
+ }
188
+ function emitLiquidInterpolatedValue(expr, context, indent) {
189
+ const scalar = emitLiquidScalarExpr(expr);
190
+ if (scalar) {
191
+ return {
192
+ prelude: [],
193
+ source: `{{ ${scalar} }}`,
194
+ };
195
+ }
196
+ const booleanExpr = materializeLiquidBooleanValue(expr, context, indent);
197
+ return {
198
+ prelude: booleanExpr.prelude,
199
+ source: `{{ ${booleanExpr.source} }}`,
200
+ };
201
+ }
202
+ function emitLiquidComponentPropValue(value, context, indent) {
203
+ switch (value.kind) {
204
+ case "text":
205
+ return {
206
+ prelude: [],
207
+ source: `"${escapeLiquidString(value.value)}"`,
208
+ };
209
+ case "expr": {
210
+ const scalar = emitLiquidScalarExpr(value.expr);
211
+ if (scalar) {
212
+ return {
213
+ prelude: [],
214
+ source: scalar,
215
+ };
216
+ }
217
+ return materializeLiquidBooleanValue(value.expr, context, indent);
218
+ }
219
+ case "classList": {
220
+ const classVar = nextTempName(context, "class");
221
+ const captureLines = emitLiquidClassCapture(value, classVar, context, indent);
222
+ return {
223
+ prelude: captureLines,
224
+ source: classVar,
225
+ };
226
+ }
227
+ case "concat": {
228
+ const concatVar = nextTempName(context, "concat");
229
+ const fragments = value.parts.map((part) => {
230
+ if (part.kind === "string") {
231
+ return part.value;
232
+ }
233
+ const scalar = emitLiquidScalarExpr(part);
234
+ if (scalar) {
235
+ return `{{ ${scalar} }}`;
236
+ }
237
+ return "";
238
+ });
239
+ return {
240
+ prelude: [
241
+ `${pad(indent)}{% capture ${concatVar} %}${fragments.join("")}{% endcapture %}`,
242
+ ],
243
+ source: concatVar,
244
+ };
245
+ }
246
+ }
247
+ }
248
+ function emitLiquidClassCapture(value, variableName, context, indent) {
249
+ const prelude = [];
250
+ const fragments = [];
251
+ for (const item of value.items) {
252
+ if (item.kind === "static") {
253
+ fragments.push(item.value);
254
+ continue;
255
+ }
256
+ if (item.kind === "dynamic") {
257
+ const scalar = emitLiquidScalarExpr(item.expr);
258
+ if (scalar) {
259
+ fragments.push(`{{ ${scalar} }}`);
260
+ }
261
+ continue;
262
+ }
263
+ const condition = materializeLiquidBooleanExpr(item.test, context, indent);
264
+ prelude.push(...condition.prelude);
265
+ fragments.push(`{% if ${condition.source} %}${item.value}{% endif %}`);
266
+ }
267
+ return [
268
+ ...prelude,
269
+ `${pad(indent)}{% capture ${variableName} %}${fragments.join(" ").trim()}{% endcapture %}`,
270
+ ];
271
+ }
272
+ function emitLiquidAttr(name, value, context, indent) {
273
+ switch (value.kind) {
274
+ case "text":
275
+ return {
276
+ prelude: [],
277
+ source: `${name}=${wrapHtmlAttribute(value.value)}`,
278
+ };
279
+ case "expr": {
280
+ const rendered = emitLiquidInterpolatedValue(value.expr, context, indent);
281
+ return {
282
+ prelude: rendered.prelude,
283
+ source: `${name}=${wrapHtmlAttribute(rendered.source)}`,
284
+ };
285
+ }
286
+ case "classList": {
287
+ const prelude = [];
288
+ const parts = [];
289
+ for (const item of value.items) {
290
+ if (item.kind === "static") {
291
+ parts.push(item.value);
292
+ continue;
293
+ }
294
+ if (item.kind === "dynamic") {
295
+ const scalar = emitLiquidScalarExpr(item.expr);
296
+ if (scalar) {
297
+ parts.push(`{{ ${scalar} }}`);
298
+ }
299
+ continue;
300
+ }
301
+ const condition = materializeLiquidBooleanExpr(item.test, context, indent);
302
+ prelude.push(...condition.prelude);
303
+ parts.push(`{% if ${condition.source} %}${item.value}{% endif %}`);
304
+ }
305
+ return {
306
+ prelude,
307
+ source: `${name}=${wrapHtmlAttribute(parts.join(" ").trim())}`,
308
+ };
309
+ }
310
+ case "concat": {
311
+ const fragments = value.parts.map((part) => {
312
+ if (part.kind === "string") {
313
+ return part.value;
314
+ }
315
+ const scalar = emitLiquidScalarExpr(part);
316
+ if (scalar) {
317
+ return `{{ ${scalar} }}`;
318
+ }
319
+ return "";
320
+ });
321
+ return {
322
+ prelude: [],
323
+ source: `${name}=${wrapHtmlAttribute(fragments.join(""))}`,
324
+ };
325
+ }
326
+ }
327
+ }
328
+ function emitLiquidNode(node, context, indent = 0) {
329
+ switch (node.kind) {
330
+ case "text":
331
+ return `${pad(indent)}${node.value}`;
332
+ case "slot":
333
+ return `${pad(indent)}{{ ${node.name} }}`;
334
+ case "print": {
335
+ const rendered = emitLiquidInterpolatedValue(node.expr, context, indent);
336
+ return [...rendered.prelude, `${pad(indent)}${rendered.source}`].join("\n");
337
+ }
338
+ case "if": {
339
+ const condition = materializeLiquidBooleanExpr(node.test, context, indent);
340
+ const thenBody = node.then.map((child) => emitLiquidNode(child, context, indent + 1)).join("\n");
341
+ const elseBody = (node.else ?? [])
342
+ .map((child) => emitLiquidNode(child, context, indent + 1))
343
+ .join("\n");
344
+ const lines = [...condition.prelude, `${pad(indent)}{% if ${condition.source} %}`, thenBody];
345
+ if (elseBody) {
346
+ lines.push(`${pad(indent)}{% else %}`, elseBody);
347
+ }
348
+ lines.push(`${pad(indent)}{% endif %}`);
349
+ return lines.join("\n");
350
+ }
351
+ case "for": {
352
+ const eachExpr = emitLiquidScalarExpr(node.each);
353
+ if (!eachExpr) {
354
+ throw new Error("Liquid target only supports scalar `each` expressions");
355
+ }
356
+ const body = node.children.map((child) => emitLiquidNode(child, context, indent + 1)).join("\n");
357
+ const lines = [`${pad(indent)}{% for ${node.item} in ${eachExpr} %}`];
358
+ if (node.indexName) {
359
+ lines.push(`${pad(indent + 1)}{% assign ${node.indexName} = forloop.index0 %}`);
360
+ }
361
+ if (body) {
362
+ lines.push(body);
363
+ }
364
+ lines.push(`${pad(indent)}{% endfor %}`);
365
+ return lines.join("\n");
366
+ }
367
+ case "element": {
368
+ const prelude = [];
369
+ const attrEntries = Object.entries(node.attrs ?? {});
370
+ const attrs = attrEntries.map(([name, value]) => {
371
+ const rendered = emitLiquidAttr(name, value, context, indent);
372
+ prelude.push(...rendered.prelude);
373
+ return rendered.source;
374
+ });
375
+ const children = (node.children ?? []).map((child) => emitLiquidNode(child, context, indent + 1));
376
+ const tagName = emitInterpolatedTagName(node.tag, emitLiquidTagExpr);
377
+ const attrBlock = attrs.length
378
+ ? `\n${attrs.map((attr) => `${pad(indent + 1)}${attr}`).join("\n")}\n${pad(indent)}`
379
+ : "";
380
+ if (children.length === 0) {
381
+ return [...prelude, `${pad(indent)}<${tagName}${attrBlock} />`].join("\n");
382
+ }
383
+ return [
384
+ ...prelude,
385
+ `${pad(indent)}<${tagName}${attrBlock}>`,
386
+ ...children,
387
+ `${pad(indent)}</${tagName}>`,
388
+ ].join("\n");
389
+ }
390
+ case "component": {
391
+ const registration = contextTemplateRegistry.get(context)?.[node.name];
392
+ if (!registration?.liquidSnippet) {
393
+ throw new Error(`Missing Liquid component registration for ${node.name}`);
394
+ }
395
+ const prelude = [];
396
+ const args = [];
397
+ for (const [propName, propValue] of Object.entries(node.props ?? {})) {
398
+ const rendered = emitLiquidComponentPropValue(propValue, context, indent);
399
+ prelude.push(...rendered.prelude);
400
+ args.push(`${propName}: ${rendered.source}`);
401
+ }
402
+ if ((node.children ?? []).length > 0) {
403
+ const childrenVar = nextTempName(context, "children_html");
404
+ prelude.push(`${pad(indent)}{% capture ${childrenVar} %}`);
405
+ prelude.push(...(node.children ?? []).map((child) => emitLiquidNode(child, context, indent + 1)));
406
+ prelude.push(`${pad(indent)}{% endcapture %}`);
407
+ args.push(`children_html: ${childrenVar}`);
408
+ }
409
+ const renderArgs = args.length > 0 ? `, ${args.join(", ")}` : "";
410
+ return [
411
+ ...prelude,
412
+ `${pad(indent)}{% render '${registration.liquidSnippet}'${renderArgs} %}`,
413
+ ].join("\n");
414
+ }
415
+ }
416
+ }
417
+ const contextTemplateRegistry = new WeakMap();
418
+ export function emitLiquidSnippet(template) {
419
+ const context = { tempId: 0 };
420
+ contextTemplateRegistry.set(context, template.componentRegistry ?? {});
421
+ return `${emitLiquidNode(template.template, context, 0)}\n`;
422
+ }
@@ -1,12 +1,240 @@
1
- import { buildTsxComponentSource, emitTsxExpr, emitTsxNode } from "./shared.js";
1
+ import { buildReactComponentSource, emitTsxExpr, emitTsxWithHoists, emitReactNode, } from "./shared.js";
2
+ /**
3
+ * HTML attribute name → React JSX equivalent.
4
+ */
5
+ const HTML_TO_REACT_ATTR = {
6
+ class: "className",
7
+ for: "htmlFor",
8
+ tabindex: "tabIndex",
9
+ readonly: "readOnly",
10
+ maxlength: "maxLength",
11
+ colspan: "colSpan",
12
+ rowspan: "rowSpan",
13
+ enctype: "encType",
14
+ contenteditable: "contentEditable",
15
+ crossorigin: "crossOrigin",
16
+ accesskey: "accessKey",
17
+ autocomplete: "autoComplete",
18
+ autofocus: "autoFocus",
19
+ autoplay: "autoPlay",
20
+ cellpadding: "cellPadding",
21
+ cellspacing: "cellSpacing",
22
+ charset: "charSet",
23
+ classid: "classID",
24
+ frameborder: "frameBorder",
25
+ novalidate: "noValidate",
26
+ "stroke-width": "strokeWidth",
27
+ "stroke-linecap": "strokeLinecap",
28
+ "stroke-linejoin": "strokeLinejoin",
29
+ "stroke-dasharray": "strokeDasharray",
30
+ "stroke-dashoffset": "strokeDashoffset",
31
+ "stroke-miterlimit": "strokeMiterlimit",
32
+ "stroke-opacity": "strokeOpacity",
33
+ "fill-opacity": "fillOpacity",
34
+ "fill-rule": "fillRule",
35
+ "clip-path": "clipPath",
36
+ "clip-rule": "clipRule",
37
+ "font-size": "fontSize",
38
+ "font-family": "fontFamily",
39
+ "font-weight": "fontWeight",
40
+ "text-anchor": "textAnchor",
41
+ "text-decoration": "textDecoration",
42
+ "dominant-baseline": "dominantBaseline",
43
+ viewbox: "viewBox",
44
+ };
45
+ /**
46
+ * Boolean HTML attributes that should emit boolean values instead of strings in React.
47
+ */
48
+ const BOOLEAN_ATTRS = new Set([
49
+ "contentEditable",
50
+ "autoFocus",
51
+ "autoPlay",
52
+ "noValidate",
53
+ "readOnly",
54
+ "disabled",
55
+ "checked",
56
+ "selected",
57
+ "multiple",
58
+ "hidden",
59
+ "open",
60
+ "required",
61
+ "spellCheck",
62
+ "draggable",
63
+ ]);
64
+ /**
65
+ * Converts a CSS property name to its camelCase React equivalent.
66
+ */
67
+ function cssPropToCamelCase(prop) {
68
+ // Handle vendor prefixes like -webkit-, -moz-, etc.
69
+ const cleaned = prop.startsWith("-") ? prop.slice(1) : prop;
70
+ return cleaned.replace(/-([a-z])/g, (_match, char) => char.toUpperCase());
71
+ }
72
+ /**
73
+ * Parses a static CSS string into a React CSSProperties-style object literal.
74
+ * e.g., "font-variant-ligatures: none; color: red" → { fontVariantLigatures: "none", color: "red" }
75
+ */
76
+ function cssStringToReactStyle(css) {
77
+ const declarations = css
78
+ .split(";")
79
+ .map((d) => d.trim())
80
+ .filter(Boolean);
81
+ const props = declarations.map((decl) => {
82
+ const colonIdx = decl.indexOf(":");
83
+ if (colonIdx === -1)
84
+ return null;
85
+ const prop = decl.slice(0, colonIdx).trim();
86
+ const value = decl.slice(colonIdx + 1).trim();
87
+ const reactProp = cssPropToCamelCase(prop);
88
+ // Numeric values without units should be numbers, but CSS values are generally strings
89
+ return `${reactProp}: ${JSON.stringify(value)}`;
90
+ }).filter(Boolean);
91
+ return `{{ ${props.join(", ")} }}`;
92
+ }
93
+ /**
94
+ * Emits an expression for use inside a string context (template literal).
95
+ * `and` expressions are converted to ternaries so that `false` doesn't
96
+ * render as the literal string "false".
97
+ * e.g., `!isEmpty(color) && color` → `!isEmpty(color) ? color : ""`
98
+ */
99
+ function emitStringConcatExpr(expr) {
100
+ if (expr.kind === "and") {
101
+ return `(${emitTsxExpr(expr.left)} ? ${emitTsxExpr(expr.right)} : "")`;
102
+ }
103
+ return emitTsxExpr(expr);
104
+ }
105
+ /**
106
+ * Emits a concat AttrValue as a React-compatible expression.
107
+ * For style attributes, converts to CSSProperties object.
108
+ * For other attributes, emits as template literal.
109
+ */
110
+ function emitConcatValue(parts, attrName) {
111
+ if (attrName === "style") {
112
+ return emitConcatStyle(parts);
113
+ }
114
+ // Build a template literal from the parts
115
+ const segments = parts.map((part) => {
116
+ if (part.kind === "string") {
117
+ return part.value;
118
+ }
119
+ return `\${${emitStringConcatExpr(part)}}`;
120
+ });
121
+ return `{\`${segments.join("")}\`}`;
122
+ }
123
+ /**
124
+ * Converts a concat-style style attribute into a React CSSProperties object.
125
+ * e.g., ["background-color: ", color] → {{ backgroundColor: color }}
126
+ */
127
+ function emitConcatStyle(parts) {
128
+ // Reconstruct the CSS template from the parts and parse it
129
+ // Strategy: join string parts and expression placeholders, then parse declarations
130
+ const declarations = [];
131
+ let currentProp = "";
132
+ let currentValueParts = [];
133
+ let parsingProp = true;
134
+ for (const part of parts) {
135
+ if (part.kind === "string") {
136
+ const text = part.value;
137
+ let remaining = text;
138
+ while (remaining.length > 0) {
139
+ if (parsingProp) {
140
+ const colonIdx = remaining.indexOf(":");
141
+ if (colonIdx !== -1) {
142
+ currentProp += remaining.slice(0, colonIdx);
143
+ remaining = remaining.slice(colonIdx + 1).trimStart();
144
+ parsingProp = false;
145
+ }
146
+ else {
147
+ const semiIdx = remaining.indexOf(";");
148
+ if (semiIdx !== -1) {
149
+ remaining = remaining.slice(semiIdx + 1).trimStart();
150
+ }
151
+ else {
152
+ currentProp += remaining;
153
+ remaining = "";
154
+ }
155
+ }
156
+ }
157
+ else {
158
+ // Parsing value
159
+ const semiIdx = remaining.indexOf(";");
160
+ if (semiIdx !== -1) {
161
+ const valueBefore = remaining.slice(0, semiIdx).trim();
162
+ if (valueBefore) {
163
+ currentValueParts.push({ kind: "string", value: valueBefore });
164
+ }
165
+ declarations.push({ prop: currentProp.trim(), valueParts: currentValueParts });
166
+ currentProp = "";
167
+ currentValueParts = [];
168
+ parsingProp = true;
169
+ remaining = remaining.slice(semiIdx + 1).trimStart();
170
+ }
171
+ else {
172
+ if (remaining) {
173
+ currentValueParts.push({ kind: "string", value: remaining });
174
+ }
175
+ remaining = "";
176
+ }
177
+ }
178
+ }
179
+ }
180
+ else {
181
+ // Expression part
182
+ if (parsingProp) {
183
+ // Unusual: expression in property name position. Treat as dynamic.
184
+ currentProp += `__expr__`;
185
+ currentValueParts.push(part);
186
+ }
187
+ else {
188
+ currentValueParts.push(part);
189
+ }
190
+ }
191
+ }
192
+ // Flush remaining declaration
193
+ if (currentProp.trim() && currentValueParts.length > 0) {
194
+ declarations.push({ prop: currentProp.trim(), valueParts: currentValueParts });
195
+ }
196
+ const props = declarations.map(({ prop, valueParts }) => {
197
+ const reactProp = cssPropToCamelCase(prop);
198
+ if (valueParts.length === 1 && valueParts[0].kind === "string") {
199
+ return `${reactProp}: ${JSON.stringify(valueParts[0].value)}`;
200
+ }
201
+ if (valueParts.length === 1) {
202
+ return `${reactProp}: ${emitStringConcatExpr(valueParts[0])}`;
203
+ }
204
+ // Multiple parts: use template literal
205
+ const segments = valueParts.map((p) => p.kind === "string" ? p.value : `\${${emitStringConcatExpr(p)}}`);
206
+ return `${reactProp}: \`${segments.join("")}\``;
207
+ });
208
+ return `{{ ${props.join(", ")} }}`;
209
+ }
210
+ /**
211
+ * Maps an HTML attribute name to its React JSX equivalent.
212
+ */
213
+ function toReactAttrName(name) {
214
+ return HTML_TO_REACT_ATTR[name] ?? name;
215
+ }
2
216
  /**
3
217
  * Emits attributes for React-compatible TSX output.
4
218
  */
5
219
  function emitReactAttr(name, value) {
6
- const attrName = name === "class" ? "className" : name;
220
+ const attrName = toReactAttrName(name);
7
221
  switch (value.kind) {
8
- case "text":
222
+ case "text": {
223
+ // Handle style attribute: convert CSS string to CSSProperties object
224
+ if (attrName === "style") {
225
+ return `${attrName}=${cssStringToReactStyle(value.value)}`;
226
+ }
227
+ // Handle boolean attributes: emit boolean value instead of string
228
+ if (BOOLEAN_ATTRS.has(attrName)) {
229
+ if (value.value === "true" || value.value === "") {
230
+ return `${attrName}={true}`;
231
+ }
232
+ if (value.value === "false") {
233
+ return `${attrName}={false}`;
234
+ }
235
+ }
9
236
  return `${attrName}=${JSON.stringify(value.value)}`;
237
+ }
10
238
  case "expr":
11
239
  return `${attrName}={${emitTsxExpr(value.expr)}}`;
12
240
  case "classList": {
@@ -14,15 +242,21 @@ function emitReactAttr(name, value) {
14
242
  if (item.kind === "static") {
15
243
  return JSON.stringify(item.value);
16
244
  }
245
+ if (item.kind === "dynamic") {
246
+ return emitTsxExpr(item.expr);
247
+ }
17
248
  return `${emitTsxExpr(item.test)} ? ${JSON.stringify(item.value)} : null`;
18
249
  });
19
250
  return `${attrName}={[${items.join(", ")}].filter(Boolean).join(" ")}`;
20
251
  }
252
+ case "concat":
253
+ return `${attrName}=${emitConcatValue(value.parts, attrName)}`;
21
254
  }
22
255
  }
23
256
  export function emitReact(node, indent = 0) {
24
- return emitTsxNode(node, emitReactAttr, indent);
257
+ return emitReactNode(node, emitReactAttr, indent);
25
258
  }
26
259
  export function emitReactComponent(template) {
27
- return buildTsxComponentSource(template, emitReact(template.template, 2));
260
+ const { body, hoists } = emitTsxWithHoists(template, emitReactNode, emitReactAttr);
261
+ return buildReactComponentSource(template, body, hoists);
28
262
  }