@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.
- package/README.md +12 -2
- package/bin/katachi.mjs +0 -0
- package/dist/api/index.d.ts +11 -5
- package/dist/api/index.js +6 -7
- package/dist/cli/index.js +14 -5
- package/dist/core/ast.d.ts +17 -4
- package/dist/core/ast.js +3 -2
- package/dist/core/build.d.ts +2 -0
- package/dist/core/build.js +13 -1
- package/dist/core/parser.js +123 -16
- package/dist/core/types.d.ts +1 -0
- package/dist/targets/askama.d.ts +3 -1
- package/dist/targets/askama.js +44 -12
- package/dist/targets/index.js +14 -0
- package/dist/targets/liquid.d.ts +2 -0
- package/dist/targets/liquid.js +422 -0
- package/dist/targets/react.js +239 -5
- package/dist/targets/shared.d.ts +23 -3
- package/dist/targets/shared.js +323 -14
- package/dist/targets/static-jsx.js +15 -2
- package/docs/architecture.md +0 -1
- package/docs/getting-started.md +2 -0
- package/docs/syntax.md +35 -8
- package/docs/targets.md +16 -0
- package/examples/basic/README.md +2 -1
- package/examples/basic/components/notice-panel.html +2 -2
- package/examples/basic/dist/askama/includes/notice-panel.html +2 -2
- package/examples/basic/dist/askama/notice-panel.rs +3 -2
- package/examples/basic/dist/jsx-static/comparison-table.tsx +2 -2
- package/examples/basic/dist/jsx-static/media-frame.tsx +1 -1
- package/examples/basic/dist/jsx-static/notice-panel.tsx +6 -4
- package/examples/basic/dist/jsx-static/resource-tile.tsx +3 -3
- package/examples/basic/dist/liquid/snippets/badge-chip.liquid +5 -0
- package/examples/basic/dist/liquid/snippets/comparison-table.liquid +34 -0
- package/examples/basic/dist/liquid/snippets/glyph.liquid +6 -0
- package/examples/basic/dist/liquid/snippets/hover-note.liquid +6 -0
- package/examples/basic/dist/liquid/snippets/media-frame.liquid +23 -0
- package/examples/basic/dist/liquid/snippets/notice-panel.liquid +30 -0
- package/examples/basic/dist/liquid/snippets/resource-tile.liquid +38 -0
- package/examples/basic/dist/liquid/snippets/stack-shell.liquid +5 -0
- package/examples/basic/dist/react/badge-chip.tsx +1 -1
- package/examples/basic/dist/react/comparison-table.tsx +9 -9
- package/examples/basic/dist/react/glyph.tsx +1 -1
- package/examples/basic/dist/react/media-frame.tsx +1 -1
- package/examples/basic/dist/react/notice-panel.tsx +6 -4
- package/examples/basic/dist/react/resource-tile.tsx +3 -3
- package/examples/basic/src/templates/comparison-table.template.tsx +5 -5
- package/examples/basic/src/templates/media-frame.template.tsx +3 -3
- package/examples/basic/src/templates/notice-panel.template.tsx +48 -34
- package/examples/basic/src/templates/resource-tile.template.tsx +7 -7
- 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
|
+
}
|
package/dist/targets/react.js
CHANGED
|
@@ -1,12 +1,240 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
257
|
+
return emitReactNode(node, emitReactAttr, indent);
|
|
25
258
|
}
|
|
26
259
|
export function emitReactComponent(template) {
|
|
27
|
-
|
|
260
|
+
const { body, hoists } = emitTsxWithHoists(template, emitReactNode, emitReactAttr);
|
|
261
|
+
return buildReactComponentSource(template, body, hoists);
|
|
28
262
|
}
|