@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
@@ -1,4 +1,4 @@
1
- import type { AttrValue, Expr, Node } from "../core/ast.js";
1
+ import type { AttrValue, Expr, Node, TagName } from "../core/ast.js";
2
2
  import type { BuildTemplate } from "../core/types.js";
3
3
  /**
4
4
  * Escapes a string for insertion into Rust string literals used by Askama wrappers.
@@ -17,11 +17,22 @@ export declare function emitTsxExpr(expr: Expr): string;
17
17
  * Emits a portable expression into Askama syntax.
18
18
  */
19
19
  export declare function emitAskamaExpr(expr: Expr): string;
20
+ export declare function emitInterpolatedTagName(tag: TagName, emitExpr: (expr: Expr) => string): string;
21
+ export declare function emitTsxTagExpr(tag: TagName): string;
22
+ interface TsxEmitContext {
23
+ hoistedTagNames: WeakMap<Extract<Node, {
24
+ kind: "element";
25
+ }>, string>;
26
+ }
20
27
  export type TsxAttrEmitter = (name: string, value: AttrValue) => string;
21
28
  /**
22
29
  * Shared JSX/TSX tree emitter used by both React and static JSX targets.
23
30
  */
24
- export declare function emitTsxNode(node: Node, emitAttr: TsxAttrEmitter, indent?: number): string;
31
+ export declare function emitTsxNode(node: Node, emitAttr: TsxAttrEmitter, indent?: number, context?: TsxEmitContext): string;
32
+ /**
33
+ * React-specific JSX/TSX tree emitter that uses <Fragment key={...}> in .map() calls.
34
+ */
35
+ export declare function emitReactNode(node: Node, emitAttr: TsxAttrEmitter, indent?: number, context?: TsxEmitContext): string;
25
36
  export declare function toCamelCase(value: string): string;
26
37
  export declare function toRustType(type: string): string;
27
38
  export declare function toTsType(type: string): string;
@@ -32,5 +43,14 @@ export declare function buildTsxImportLines(template: BuildTemplate): string;
32
43
  /**
33
44
  * Wraps an emitted TSX template body in a component module.
34
45
  */
35
- export declare function buildTsxComponentSource(template: BuildTemplate, body: string): string;
46
+ export declare function buildTsxComponentSource(template: BuildTemplate, body: string, hoists?: string[]): string;
47
+ /**
48
+ * Wraps an emitted React TSX template body in a component module.
49
+ * Only imports ReactNode when a prop uses it, and imports Fragment when needed.
50
+ */
51
+ export declare function buildReactComponentSource(template: BuildTemplate, body: string, hoists?: string[]): string;
52
+ export declare function emitTsxWithHoists(template: BuildTemplate, emitNode: (node: Node, emitAttr: TsxAttrEmitter, indent: number, context?: TsxEmitContext) => string, emitAttr: TsxAttrEmitter): {
53
+ body: string;
54
+ hoists: string[];
55
+ };
36
56
  export { escapeDoubleQuotes, wrapHtmlAttribute };
@@ -69,6 +69,8 @@ export function emitTsxExpr(expr) {
69
69
  return `(${emittedArg} != null)`;
70
70
  case "isNone":
71
71
  return `(${emittedArg} == null)`;
72
+ default:
73
+ return emittedArg;
72
74
  }
73
75
  }
74
76
  case "raw":
@@ -110,6 +112,8 @@ export function emitAskamaExpr(expr) {
110
112
  return `${emittedArg}.is_some()`;
111
113
  case "isNone":
112
114
  return `${emittedArg}.is_none()`;
115
+ default:
116
+ return emittedArg;
113
117
  }
114
118
  }
115
119
  case "raw":
@@ -126,10 +130,114 @@ export function emitAskamaExpr(expr) {
126
130
  return `!(${emitAskamaExpr(expr.expr)})`;
127
131
  }
128
132
  }
133
+ function emitTagInterpolationPart(expr, emitExpr) {
134
+ return expr.kind === "string" ? expr.value : `{{ ${emitExpr(expr)} }}`;
135
+ }
136
+ export function emitInterpolatedTagName(tag, emitExpr) {
137
+ if (tag.kind === "static") {
138
+ return tag.name;
139
+ }
140
+ return tag.parts.map((part) => emitTagInterpolationPart(part, emitExpr)).join("");
141
+ }
142
+ export function emitTsxTagExpr(tag) {
143
+ if (tag.kind === "static") {
144
+ return JSON.stringify(tag.name);
145
+ }
146
+ if (tag.parts.length === 1 && tag.parts[0]?.kind !== "string") {
147
+ return emitTsxExpr(tag.parts[0]);
148
+ }
149
+ const segments = tag.parts.map((part) => {
150
+ if (part.kind === "string") {
151
+ return part.value.replace(/[`\\$]/g, "\\$&");
152
+ }
153
+ return `\${${emitTsxExpr(part)}}`;
154
+ });
155
+ return `\`${segments.join("")}\``;
156
+ }
157
+ function exprUsesBoundName(expr, boundNames) {
158
+ switch (expr.kind) {
159
+ case "var":
160
+ return boundNames.has(expr.name);
161
+ case "string":
162
+ case "bool":
163
+ case "number":
164
+ return false;
165
+ case "intrinsic":
166
+ return expr.args.some((arg) => exprUsesBoundName(arg, boundNames));
167
+ case "raw":
168
+ return Array.from(boundNames).some((name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(expr.source));
169
+ case "eq":
170
+ case "neq":
171
+ case "and":
172
+ case "or":
173
+ return (exprUsesBoundName(expr.left, boundNames) || exprUsesBoundName(expr.right, boundNames));
174
+ case "not":
175
+ return exprUsesBoundName(expr.expr, boundNames);
176
+ }
177
+ }
178
+ function tagUsesBoundName(tag, boundNames) {
179
+ if (tag.kind === "static") {
180
+ return false;
181
+ }
182
+ return tag.parts.some((part) => exprUsesBoundName(part, boundNames));
183
+ }
184
+ function collectHoistedDynamicTags(node, hoists, hoistedTagNames, boundNames = new Set(), nextId = { value: 0 }) {
185
+ switch (node.kind) {
186
+ case "if":
187
+ node.then.forEach((child) => collectHoistedDynamicTags(child, hoists, hoistedTagNames, boundNames, nextId));
188
+ (node.else ?? []).forEach((child) => collectHoistedDynamicTags(child, hoists, hoistedTagNames, boundNames, nextId));
189
+ return;
190
+ case "for": {
191
+ const loopBoundNames = new Set(boundNames);
192
+ loopBoundNames.add(node.item);
193
+ if (node.indexName) {
194
+ loopBoundNames.add(node.indexName);
195
+ }
196
+ node.children.forEach((child) => collectHoistedDynamicTags(child, hoists, hoistedTagNames, loopBoundNames, nextId));
197
+ return;
198
+ }
199
+ case "element":
200
+ if (node.tag.kind === "dynamic" && !tagUsesBoundName(node.tag, boundNames)) {
201
+ nextId.value += 1;
202
+ const tagName = nextId.value === 1 ? "Tag" : `Tag${nextId.value}`;
203
+ hoistedTagNames.set(node, tagName);
204
+ hoists.push(` const ${tagName} = ${emitTsxTagExpr(node.tag)} as ElementType;`);
205
+ }
206
+ (node.children ?? []).forEach((child) => collectHoistedDynamicTags(child, hoists, hoistedTagNames, boundNames, nextId));
207
+ return;
208
+ case "component":
209
+ (node.children ?? []).forEach((child) => collectHoistedDynamicTags(child, hoists, hoistedTagNames, boundNames, nextId));
210
+ return;
211
+ default:
212
+ return;
213
+ }
214
+ }
215
+ function buildTsxEmitContext(template) {
216
+ const hoists = [];
217
+ const context = {
218
+ hoistedTagNames: new WeakMap(),
219
+ };
220
+ collectHoistedDynamicTags(template.template, hoists, context.hoistedTagNames);
221
+ return { context, hoists };
222
+ }
223
+ function emitDynamicTsxElement(tagExpr, tagComponentName, attrs, children, emitAttr, indent, emitNode, context) {
224
+ const pad = " ".repeat(indent);
225
+ const attrEntries = Object.entries(attrs);
226
+ const attrBlock = attrEntries.length > 0
227
+ ? `\n${attrEntries
228
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
229
+ .join("\n")}\n${pad} `
230
+ : "";
231
+ if (children.length === 0) {
232
+ return `${pad}{(() => {\n${pad} const ${tagComponentName} = ${tagExpr};\n${pad} return <${tagComponentName}${attrBlock} />;\n${pad}})()}`;
233
+ }
234
+ const childBlock = children.map((child) => emitNode(child, emitAttr, indent + 3, context)).join("\n");
235
+ return `${pad}{(() => {\n${pad} const ${tagComponentName} = ${tagExpr};\n${pad} return (\n${pad} <${tagComponentName}${attrBlock}>\n${childBlock}\n${pad} </${tagComponentName}>\n${pad} );\n${pad}})()}`;
236
+ }
129
237
  /**
130
238
  * Shared JSX/TSX tree emitter used by both React and static JSX targets.
131
239
  */
132
- export function emitTsxNode(node, emitAttr, indent = 0) {
240
+ export function emitTsxNode(node, emitAttr, indent = 0, context) {
133
241
  const pad = " ".repeat(indent);
134
242
  switch (node.kind) {
135
243
  case "text":
@@ -140,10 +248,10 @@ export function emitTsxNode(node, emitAttr, indent = 0) {
140
248
  return `${pad}{${emitTsxExpr(node.expr)}}`;
141
249
  case "if": {
142
250
  const thenPart = node.then
143
- .map((child) => emitTsxNode(child, emitAttr, indent + 2))
251
+ .map((child) => emitTsxNode(child, emitAttr, indent + 2, context))
144
252
  .join("\n");
145
253
  const elsePart = (node.else ?? [])
146
- .map((child) => emitTsxNode(child, emitAttr, indent + 2))
254
+ .map((child) => emitTsxNode(child, emitAttr, indent + 2, context))
147
255
  .join("\n");
148
256
  if (elsePart) {
149
257
  return `${pad}{${emitTsxExpr(node.test)} ? (\n${pad} <>\n${thenPart}\n${pad} </>\n${pad}) : (\n${pad} <>\n${elsePart}\n${pad} </>\n${pad})}`;
@@ -156,35 +264,154 @@ export function emitTsxNode(node, emitAttr, indent = 0) {
156
264
  ? `${node.item}, ${node.indexName}`
157
265
  : `${node.item}, __index`;
158
266
  const body = node.children
159
- .map((child) => emitTsxNode(child, emitAttr, indent + 2))
267
+ .map((child) => emitTsxNode(child, emitAttr, indent + 2, context))
160
268
  .join("\n");
161
269
  return `${pad}{(${eachExpr} ?? []).map((${iteratorArgs}) => (\n${pad} <>\n${body}\n${pad} </>\n${pad}))}`;
162
270
  }
163
271
  case "element": {
272
+ if (node.tag.kind === "dynamic") {
273
+ const hoistedTagName = context?.hoistedTagNames.get(node);
274
+ if (hoistedTagName) {
275
+ const attrEntries = Object.entries(node.attrs ?? {});
276
+ const multilineOpen = `${pad}<${hoistedTagName}\n${attrEntries
277
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
278
+ .join("\n")}\n${pad}>`;
279
+ const children = (node.children ?? []).map((child) => emitTsxNode(child, emitAttr, indent + 1, context));
280
+ if (children.length === 0) {
281
+ if (attrEntries.length === 0) {
282
+ return `${pad}<${hoistedTagName} />`;
283
+ }
284
+ return `${pad}<${hoistedTagName}\n${attrEntries
285
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
286
+ .join("\n")}\n${pad}/>`;
287
+ }
288
+ if (attrEntries.length === 0) {
289
+ return `${pad}<${hoistedTagName}>\n${children.join("\n")}\n${pad}</${hoistedTagName}>`;
290
+ }
291
+ return `${multilineOpen}\n${children.join("\n")}\n${pad}</${hoistedTagName}>`;
292
+ }
293
+ return emitDynamicTsxElement(emitTsxTagExpr(node.tag), "KatachiTag", node.attrs ?? {}, node.children ?? [], emitAttr, indent, emitTsxNode, context);
294
+ }
295
+ const attrEntries = Object.entries(node.attrs ?? {});
296
+ const multilineOpen = `${pad}<${node.tag.name}\n${attrEntries
297
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
298
+ .join("\n")}\n${pad}>`;
299
+ const children = (node.children ?? []).map((child) => emitTsxNode(child, emitAttr, indent + 1, context));
300
+ if (children.length === 0) {
301
+ if (attrEntries.length === 0) {
302
+ return `${pad}<${node.tag.name} />`;
303
+ }
304
+ return `${pad}<${node.tag.name}\n${attrEntries
305
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
306
+ .join("\n")}\n${pad}/>`;
307
+ }
308
+ if (attrEntries.length === 0) {
309
+ return `${pad}<${node.tag.name}>\n${children.join("\n")}\n${pad}</${node.tag.name}>`;
310
+ }
311
+ return `${multilineOpen}\n${children.join("\n")}\n${pad}</${node.tag.name}>`;
312
+ }
313
+ case "component": {
314
+ const propEntries = Object.entries(node.props ?? {});
315
+ const multilineOpen = `${pad}<${node.name}\n${propEntries
316
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
317
+ .join("\n")}\n${pad}>`;
318
+ const children = (node.children ?? []).map((child) => emitTsxNode(child, emitAttr, indent + 1, context));
319
+ if (children.length === 0) {
320
+ if (propEntries.length === 0) {
321
+ return `${pad}<${node.name} />`;
322
+ }
323
+ return `${pad}<${node.name}\n${propEntries
324
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
325
+ .join("\n")}\n${pad}/>`;
326
+ }
327
+ if (propEntries.length === 0) {
328
+ return `${pad}<${node.name}>\n${children.join("\n")}\n${pad}</${node.name}>`;
329
+ }
330
+ return `${multilineOpen}\n${children.join("\n")}\n${pad}</${node.name}>`;
331
+ }
332
+ }
333
+ }
334
+ /**
335
+ * React-specific JSX/TSX tree emitter that uses <Fragment key={...}> in .map() calls.
336
+ */
337
+ export function emitReactNode(node, emitAttr, indent = 0, context) {
338
+ const pad = " ".repeat(indent);
339
+ switch (node.kind) {
340
+ case "text":
341
+ return `${pad}${node.value}`;
342
+ case "slot":
343
+ return `${pad}{${node.name}}`;
344
+ case "print":
345
+ return `${pad}{${emitTsxExpr(node.expr)}}`;
346
+ case "if": {
347
+ const thenPart = node.then
348
+ .map((child) => emitReactNode(child, emitAttr, indent + 2, context))
349
+ .join("\n");
350
+ const elsePart = (node.else ?? [])
351
+ .map((child) => emitReactNode(child, emitAttr, indent + 2, context))
352
+ .join("\n");
353
+ if (elsePart) {
354
+ return `${pad}{${emitTsxExpr(node.test)} ? (\n${pad} <>\n${thenPart}\n${pad} </>\n${pad}) : (\n${pad} <>\n${elsePart}\n${pad} </>\n${pad})}`;
355
+ }
356
+ return `${pad}{${emitTsxExpr(node.test)} && (\n${pad} <>\n${thenPart}\n${pad} </>\n${pad})}`;
357
+ }
358
+ case "for": {
359
+ const eachExpr = emitTsxExpr(node.each);
360
+ const indexVar = node.indexName ?? "__index";
361
+ const iteratorArgs = `${node.item}, ${indexVar}`;
362
+ const body = node.children
363
+ .map((child) => emitReactNode(child, emitAttr, indent + 2, context))
364
+ .join("\n");
365
+ return `${pad}{(${eachExpr} ?? []).map((${iteratorArgs}) => (\n${pad} <Fragment key={${indexVar}}>\n${body}\n${pad} </Fragment>\n${pad}))}`;
366
+ }
367
+ case "element": {
368
+ if (node.tag.kind === "dynamic") {
369
+ const hoistedTagName = context?.hoistedTagNames.get(node);
370
+ if (hoistedTagName) {
371
+ const attrEntries = Object.entries(node.attrs ?? {});
372
+ const multilineOpen = `${pad}<${hoistedTagName}\n${attrEntries
373
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
374
+ .join("\n")}\n${pad}>`;
375
+ const children = (node.children ?? []).map((child) => emitReactNode(child, emitAttr, indent + 1, context));
376
+ if (children.length === 0) {
377
+ if (attrEntries.length === 0) {
378
+ return `${pad}<${hoistedTagName} />`;
379
+ }
380
+ return `${pad}<${hoistedTagName}\n${attrEntries
381
+ .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
382
+ .join("\n")}\n${pad}/>`;
383
+ }
384
+ if (attrEntries.length === 0) {
385
+ return `${pad}<${hoistedTagName}>\n${children.join("\n")}\n${pad}</${hoistedTagName}>`;
386
+ }
387
+ return `${multilineOpen}\n${children.join("\n")}\n${pad}</${hoistedTagName}>`;
388
+ }
389
+ return emitDynamicTsxElement(emitTsxTagExpr(node.tag), "KatachiTag", node.attrs ?? {}, node.children ?? [], emitAttr, indent, emitReactNode, context);
390
+ }
164
391
  const attrEntries = Object.entries(node.attrs ?? {});
165
- const multilineOpen = `${pad}<${node.tag}\n${attrEntries
392
+ const multilineOpen = `${pad}<${node.tag.name}\n${attrEntries
166
393
  .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
167
394
  .join("\n")}\n${pad}>`;
168
- const children = (node.children ?? []).map((child) => emitTsxNode(child, emitAttr, indent + 1));
395
+ const children = (node.children ?? []).map((child) => emitReactNode(child, emitAttr, indent + 1, context));
169
396
  if (children.length === 0) {
170
397
  if (attrEntries.length === 0) {
171
- return `${pad}<${node.tag} />`;
398
+ return `${pad}<${node.tag.name} />`;
172
399
  }
173
- return `${pad}<${node.tag}\n${attrEntries
400
+ return `${pad}<${node.tag.name}\n${attrEntries
174
401
  .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
175
402
  .join("\n")}\n${pad}/>`;
176
403
  }
177
404
  if (attrEntries.length === 0) {
178
- return `${pad}<${node.tag}>\n${children.join("\n")}\n${pad}</${node.tag}>`;
405
+ return `${pad}<${node.tag.name}>\n${children.join("\n")}\n${pad}</${node.tag.name}>`;
179
406
  }
180
- return `${multilineOpen}\n${children.join("\n")}\n${pad}</${node.tag}>`;
407
+ return `${multilineOpen}\n${children.join("\n")}\n${pad}</${node.tag.name}>`;
181
408
  }
182
409
  case "component": {
183
410
  const propEntries = Object.entries(node.props ?? {});
184
411
  const multilineOpen = `${pad}<${node.name}\n${propEntries
185
412
  .map(([name, value]) => `${pad} ${emitAttr(name, value)}`)
186
413
  .join("\n")}\n${pad}>`;
187
- const children = (node.children ?? []).map((child) => emitTsxNode(child, emitAttr, indent + 1));
414
+ const children = (node.children ?? []).map((child) => emitReactNode(child, emitAttr, indent + 1, context));
188
415
  if (children.length === 0) {
189
416
  if (propEntries.length === 0) {
190
417
  return `${pad}<${node.name} />`;
@@ -219,6 +446,10 @@ export function toRustType(type) {
219
446
  return "i64";
220
447
  case "children":
221
448
  return "&'a str";
449
+ case "children[]":
450
+ return "&'a [&'a str]";
451
+ case "children[][]":
452
+ return "&'a [&'a [&'a str]]";
222
453
  case "string[]":
223
454
  return "&'a [&'a str]";
224
455
  case "string[][]":
@@ -237,6 +468,10 @@ export function toTsType(type) {
237
468
  return "number";
238
469
  case "children":
239
470
  return "ReactNode";
471
+ case "children[]":
472
+ return "ReactNode[]";
473
+ case "children[][]":
474
+ return "ReactNode[][]";
240
475
  default:
241
476
  return type;
242
477
  }
@@ -252,16 +487,83 @@ export function buildTsxImportLines(template) {
252
487
  .filter((line) => Boolean(line))
253
488
  .join("\n");
254
489
  }
490
+ /**
491
+ * Checks whether the AST contains any "for" nodes, which means
492
+ * the React target needs to import Fragment.
493
+ */
494
+ function astUsesForNode(node) {
495
+ switch (node.kind) {
496
+ case "for":
497
+ return true;
498
+ case "if":
499
+ return (node.then.some(astUsesForNode) || (node.else ?? []).some(astUsesForNode));
500
+ case "element":
501
+ return (node.children ?? []).some(astUsesForNode);
502
+ case "component":
503
+ return (node.children ?? []).some(astUsesForNode);
504
+ default:
505
+ return false;
506
+ }
507
+ }
255
508
  /**
256
509
  * Wraps an emitted TSX template body in a component module.
257
510
  */
258
- export function buildTsxComponentSource(template, body) {
511
+ export function buildTsxComponentSource(template, body, hoists = []) {
512
+ const props = template.props ?? [];
513
+ const propsTypeName = `${template.name}Props`;
514
+ const propLines = props.map((prop) => ` ${prop.name}${prop.optional ? "?" : ""}: ${toTsType(prop.type)};`);
515
+ const destructuredProps = props.map((prop) => prop.name).join(", ");
516
+ const componentImports = buildTsxImportLines(template);
517
+ const needsElementType = hoists.length > 0;
518
+ return `import type { ${needsElementType ? "ElementType, " : ""}ReactNode } from "react";
519
+ ${componentImports ? `${componentImports}\n` : ""}
520
+
521
+ export type ${propsTypeName} = {
522
+ ${propLines.join("\n")}
523
+ };
524
+
525
+ export default function ${template.name}({ ${destructuredProps} }: ${propsTypeName}) {
526
+ ${hoists.join("\n")}${hoists.length > 0 ? "\n" : ""} return (
527
+ ${body}
528
+ );
529
+ }
530
+ `;
531
+ }
532
+ /**
533
+ * Wraps an emitted React TSX template body in a component module.
534
+ * Only imports ReactNode when a prop uses it, and imports Fragment when needed.
535
+ */
536
+ export function buildReactComponentSource(template, body, hoists = []) {
259
537
  const props = template.props ?? [];
260
538
  const propsTypeName = `${template.name}Props`;
261
539
  const propLines = props.map((prop) => ` ${prop.name}${prop.optional ? "?" : ""}: ${toTsType(prop.type)};`);
262
540
  const destructuredProps = props.map((prop) => prop.name).join(", ");
263
541
  const componentImports = buildTsxImportLines(template);
264
- return `import type { ReactNode } from "react";
542
+ const needsReactNode = props.some((prop) => prop.type === "children" || prop.type === "children[]" || prop.type === "children[][]");
543
+ const needsFragment = astUsesForNode(template.template);
544
+ const needsElementType = hoists.length > 0;
545
+ const reactImports = [];
546
+ if (needsFragment) {
547
+ reactImports.push("Fragment");
548
+ }
549
+ const reactTypeImports = [];
550
+ if (needsElementType) {
551
+ reactTypeImports.push("ElementType");
552
+ }
553
+ if (needsReactNode) {
554
+ reactTypeImports.push("ReactNode");
555
+ }
556
+ let importLine = "";
557
+ if (reactImports.length > 0 && reactTypeImports.length > 0) {
558
+ importLine = `import { ${reactImports.join(", ")}, type ${reactTypeImports.join(", type ")} } from "react";`;
559
+ }
560
+ else if (reactImports.length > 0) {
561
+ importLine = `import { ${reactImports.join(", ")} } from "react";`;
562
+ }
563
+ else if (reactTypeImports.length > 0) {
564
+ importLine = `import type { ${reactTypeImports.join(", ")} } from "react";`;
565
+ }
566
+ return `${importLine}
265
567
  ${componentImports ? `${componentImports}\n` : ""}
266
568
 
267
569
  export type ${propsTypeName} = {
@@ -269,10 +571,17 @@ ${propLines.join("\n")}
269
571
  };
270
572
 
271
573
  export default function ${template.name}({ ${destructuredProps} }: ${propsTypeName}) {
272
- return (
574
+ ${hoists.join("\n")}${hoists.length > 0 ? "\n" : ""} return (
273
575
  ${body}
274
576
  );
275
577
  }
276
578
  `;
277
579
  }
580
+ export function emitTsxWithHoists(template, emitNode, emitAttr) {
581
+ const { context, hoists } = buildTsxEmitContext(template);
582
+ return {
583
+ body: emitNode(template.template, emitAttr, 2, context),
584
+ hoists,
585
+ };
586
+ }
278
587
  export { escapeDoubleQuotes, wrapHtmlAttribute };
@@ -1,4 +1,4 @@
1
- import { buildTsxComponentSource, emitTsxExpr, emitTsxNode } from "./shared.js";
1
+ import { buildTsxComponentSource, emitTsxExpr, emitTsxNode, emitTsxWithHoists } from "./shared.js";
2
2
  /**
3
3
  * Emits TSX meant to read more statically by inlining class string interpolation.
4
4
  */
@@ -14,15 +14,28 @@ function emitStaticJsxAttr(name, value) {
14
14
  if (item.kind === "static") {
15
15
  return item.value;
16
16
  }
17
+ if (item.kind === "dynamic") {
18
+ return `\${${emitTsxExpr(item.expr)}}`;
19
+ }
17
20
  return `\${${emitTsxExpr(item.test)} ? ${JSON.stringify(item.value)} : ""}`;
18
21
  });
19
22
  return `${attrName}={\`${segments.join(" ").trim()}\`}`;
20
23
  }
24
+ case "concat": {
25
+ const segments = value.parts.map((part) => {
26
+ if (part.kind === "string") {
27
+ return part.value;
28
+ }
29
+ return `\${${emitTsxExpr(part)}}`;
30
+ });
31
+ return `${attrName}={\`${segments.join("")}\`}`;
32
+ }
21
33
  }
22
34
  }
23
35
  export function emitStaticJsx(node, indent = 0) {
24
36
  return emitTsxNode(node, emitStaticJsxAttr, indent);
25
37
  }
26
38
  export function emitStaticJsxComponent(template) {
27
- return buildTsxComponentSource(template, emitStaticJsx(template.template, 2));
39
+ const { body, hoists } = emitTsxWithHoists(template, emitTsxNode, emitStaticJsxAttr);
40
+ return buildTsxComponentSource(template, body, hoists);
28
41
  }
@@ -62,7 +62,6 @@ Responsibilities:
62
62
  - normalize `className` to internal `class`
63
63
  - convert `If` and `For`
64
64
  - convert `{children}` to slot nodes
65
- - convert `{safe(value)}` to safe print nodes
66
65
  - resolve imported template components later during build
67
66
 
68
67
  The parser is currently handwritten and string-based. That is fine for a prototype, but a stronger long-term direction is to parse real TSX via Babel, SWC, or the TypeScript compiler and then lower from that AST.
@@ -118,11 +118,13 @@ By default, Katachi writes:
118
118
  - `dist/jsx-static/**/*.tsx`
119
119
  - `dist/askama/**/*.rs`
120
120
  - `dist/askama/includes/**/*.html`
121
+ - `dist/liquid/snippets/**/*.liquid`
121
122
 
122
123
  Typical usage:
123
124
 
124
125
  - use `dist/react` in your editor or React app
125
126
  - use `dist/askama` and `dist/askama/includes` in your Rust/Askama app
127
+ - use `dist/liquid/snippets` in Shopify themes or other Liquid consumers
126
128
 
127
129
  If you are evaluating Katachi for a shared component library, this is the
128
130
  normal model: author once, then consume the generated output from each target
package/docs/syntax.md CHANGED
@@ -21,7 +21,7 @@ A template file should export:
21
21
  Example:
22
22
 
23
23
  ```tsx
24
- import { For, If, isEmpty, len, safe, type TemplateNode } from "@relevate/katachi";
24
+ import { Element, For, If, isEmpty, len, type TemplateNode } from "@relevate/katachi";
25
25
 
26
26
  export type Props = {
27
27
  title: string;
@@ -32,9 +32,9 @@ export type Props = {
32
32
  export default function Example({ title, rows, children }: Props) {
33
33
  return (
34
34
  <section>
35
- <h2>{title}</h2>
35
+ <Element tag={["h", 2]}>{title}</Element>
36
36
  <For each={rows} as="row">
37
- <div>{safe(row[0])}</div>
37
+ <div>{row[0]}</div>
38
38
  </For>
39
39
  <If test={len(rows) == 0}>
40
40
  <p>Empty</p>
@@ -63,6 +63,22 @@ Normal lowercase JSX tags work as expected:
63
63
  <img src={src} alt={alt} />
64
64
  ```
65
65
 
66
+ ### Dynamic intrinsic elements
67
+
68
+ Use `Element` when the tag name itself needs to vary.
69
+
70
+ ```tsx
71
+ import { Element } from "@relevate/katachi";
72
+
73
+ <Element tag={["h", level]} className="headline">
74
+ {title}
75
+ </Element>
76
+ ```
77
+
78
+ `tag` accepts either a plain expression like `tag={tagName}` or a structured
79
+ tuple like `tag={["h", level]}` when you want a fixed prefix with one dynamic
80
+ part.
81
+
66
82
  ### Imported template components
67
83
 
68
84
  Capitalized tags are treated as template component invocations.
@@ -119,16 +135,27 @@ Optional index binding:
119
135
  </For>
120
136
  ```
121
137
 
122
- ### `safe(...)`
138
+ ### `TemplateNode`
123
139
 
124
- Use `safe(...)` for raw/safe HTML output.
140
+ Use `TemplateNode` for props or children that carry markup-like content.
125
141
 
126
142
  ```tsx
127
- import { safe } from "@relevate/katachi";
143
+ import type { TemplateNode } from "@relevate/katachi";
128
144
 
129
- <div>{safe(value)}</div>
145
+ type Props = {
146
+ title_html: TemplateNode;
147
+ children?: TemplateNode;
148
+ };
149
+
150
+ <h2>{title_html}</h2>
151
+ <div>{children}</div>
130
152
  ```
131
153
 
154
+ For Askama output, `TemplateNode` values are treated as markup content and are
155
+ emitted with `|safe`. On Liquid output, they are emitted as plain Liquid
156
+ output, so trusted or sanitized HTML should be handled before it reaches the
157
+ target.
158
+
132
159
  ### Portable helpers
133
160
 
134
161
  Use Katachi's portable helpers instead of target-specific template methods.
@@ -202,8 +229,8 @@ portable helpers in new Katachi templates:
202
229
  - `ClassValue`
203
230
  - `TemplateNode`
204
231
  - `If`
232
+ - `Element`
205
233
  - `For`
206
- - `safe`
207
234
  - `len`
208
235
  - `isEmpty`
209
236
  - `isSome`
package/docs/targets.md CHANGED
@@ -31,11 +31,19 @@ outputs in a real project.
31
31
  - file type: `.html`
32
32
  - purpose: Askama partial output
33
33
 
34
+ ### `liquid`
35
+
36
+ - output folder: `dist/liquid/snippets`
37
+ - file type: `.liquid`
38
+ - purpose: Shopify Liquid snippet output
39
+
34
40
  ## Which output should you use?
35
41
 
36
42
  - Use `dist/react` if your consumer is a React app or an editor surface built in React.
37
43
  - Use `dist/jsx-static` if you want a TSX artifact that reads a bit more statically.
38
44
  - Use `dist/askama` and `dist/askama/includes` if your consumer is Rust + Askama.
45
+ - Use `dist/liquid/snippets` if your consumer is a Shopify theme or another
46
+ Liquid environment.
39
47
 
40
48
  ## Relative imports and includes
41
49
 
@@ -45,6 +53,14 @@ That means:
45
53
 
46
54
  - a nested React component import stays relative in `dist/react`
47
55
  - a nested Askama include stays relative in `dist/askama/includes`
56
+ - a nested Shopify Liquid component becomes a `{% render %}` call using the
57
+ snippet path in `dist/liquid/snippets`
58
+
59
+ ## Liquid-specific notes
60
+
61
+ - The Liquid target emits Shopify-compatible snippet files.
62
+ - `TemplateNode` values lower to plain Liquid output on this target, so trusted
63
+ or sanitized HTML should be handled before the Liquid layer.
48
64
 
49
65
  ## Internal note
50
66
 
@@ -17,7 +17,7 @@ usage:
17
17
  - dynamic `className` arrays
18
18
  - `If`
19
19
  - nested `For`
20
- - `safe(...)`
20
+ - `TemplateNode` content props
21
21
  - mixed HTML and expression attributes
22
22
 
23
23
  ## Example components
@@ -44,6 +44,7 @@ That writes generated output to:
44
44
  - `examples/basic/dist/react`
45
45
  - `examples/basic/dist/jsx-static`
46
46
  - `examples/basic/dist/askama`
47
+ - `examples/basic/dist/liquid/snippets`
47
48
 
48
49
  ## Verify the public Askama fixtures
49
50