@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
package/README.md CHANGED
@@ -9,9 +9,10 @@ Today it can emit:
9
9
  - static-oriented TSX components
10
10
  - Askama Rust wrapper files
11
11
  - Askama include partials
12
+ - Shopify Liquid snippets
12
13
 
13
14
  Katachi is still early, but it is already usable if you need one component
14
- source that can target both React-style environments and Askama.
15
+ source that can target React-style environments, Askama, or Shopify Liquid.
15
16
 
16
17
  ## Getting Started
17
18
 
@@ -77,6 +78,7 @@ By default, Katachi writes:
77
78
  - `dist/jsx-static`
78
79
  - `dist/askama`
79
80
  - `dist/askama/includes`
81
+ - `dist/liquid/snippets`
80
82
 
81
83
  If you want custom paths:
82
84
 
@@ -124,6 +126,13 @@ pnpm exec katachi build
124
126
 
125
127
  That generates target-specific files under `dist/`.
126
128
 
129
+ If you are working inside the Katachi source repository itself, use the local
130
+ bin entrypoint instead:
131
+
132
+ ```bash
133
+ node ./bin/katachi.mjs build --project ./examples/basic
134
+ ```
135
+
127
136
  ## What Katachi Generates
128
137
 
129
138
  By default, build output goes to:
@@ -132,6 +141,7 @@ By default, build output goes to:
132
141
  - `dist/jsx-static/**/*.tsx`
133
142
  - `dist/askama/**/*.rs`
134
143
  - `dist/askama/includes/**/*.html`
144
+ - `dist/liquid/snippets/**/*.liquid`
135
145
 
136
146
  Nested templates preserve their relative directory layout.
137
147
 
@@ -142,11 +152,11 @@ Nested templates preserve their relative directory layout.
142
152
  - dynamic `class` and `className` arrays
143
153
  - `If`
144
154
  - `For`
145
- - `safe(...)`
146
155
  - nested components
147
156
  - React output
148
157
  - static-oriented TSX output
149
158
  - Askama output
159
+ - Shopify Liquid snippet output
150
160
 
151
161
  ## Why Katachi Exists
152
162
 
package/bin/katachi.mjs CHANGED
File without changes
@@ -11,6 +11,13 @@ export type IfProps = {
11
11
  test: unknown;
12
12
  children?: TemplateNode;
13
13
  };
14
+ export type ElementTagPart = string | number | boolean | null | undefined;
15
+ export type ElementTag = string | readonly [string, ElementTagPart] | readonly [string, ElementTagPart, string];
16
+ export type ElementProps = {
17
+ tag: ElementTag;
18
+ children?: TemplateNode;
19
+ [attrName: string]: unknown;
20
+ };
14
21
  export type ForProps<T = unknown> = {
15
22
  each: readonly T[] | T[] | null | undefined;
16
23
  as: string;
@@ -22,16 +29,15 @@ export type ForProps<T = unknown> = {
22
29
  * templates directly and never evaluates this function during normal use.
23
30
  */
24
31
  export declare function If(_props: IfProps): TemplateNode;
32
+ /**
33
+ * Placeholder runtime export for dynamic intrinsic tags in template files.
34
+ */
35
+ export declare function Element(_props: ElementProps): TemplateNode;
25
36
  /**
26
37
  * Placeholder runtime export for template files. The compiler reads source
27
38
  * templates directly and never evaluates this function during normal use.
28
39
  */
29
40
  export declare function For<T>(_props: ForProps<T>): TemplateNode;
30
- /**
31
- * Marks a printed value as safe in Katachi templates. This is a no-op at the
32
- * API layer because escaping is handled by target emitters.
33
- */
34
- export declare function safe<T>(value: T): T;
35
41
  /**
36
42
  * Portable length helper for Katachi templates.
37
43
  */
package/dist/api/index.js CHANGED
@@ -6,18 +6,17 @@ export function If(_props) {
6
6
  return null;
7
7
  }
8
8
  /**
9
- * Placeholder runtime export for template files. The compiler reads source
10
- * templates directly and never evaluates this function during normal use.
9
+ * Placeholder runtime export for dynamic intrinsic tags in template files.
11
10
  */
12
- export function For(_props) {
11
+ export function Element(_props) {
13
12
  return null;
14
13
  }
15
14
  /**
16
- * Marks a printed value as safe in Katachi templates. This is a no-op at the
17
- * API layer because escaping is handled by target emitters.
15
+ * Placeholder runtime export for template files. The compiler reads source
16
+ * templates directly and never evaluates this function during normal use.
18
17
  */
19
- export function safe(value) {
20
- return value;
18
+ export function For(_props) {
19
+ return null;
21
20
  }
22
21
  /**
23
22
  * Portable length helper for Katachi templates.
package/dist/cli/index.js CHANGED
@@ -6,14 +6,16 @@ function printHelp() {
6
6
  console.log(`Katachi
7
7
 
8
8
  Usage:
9
- katachi build [--project <dir>] [--templates <dir>] [--dist <dir>]
9
+ katachi build [--project <dir>] [--templates <dir>] [--dist <dir>] [--target <name>]...
10
10
  katachi verify:examples
11
11
  katachi help
12
12
 
13
- Defaults:
14
- --project current working directory
15
- --templates <project>/src/templates
16
- --dist <project>/dist`);
13
+ Options:
14
+ --project Project root directory (default: cwd)
15
+ --templates Template source directory (default: <project>/src/templates)
16
+ --dist Output directory (default: <project>/dist)
17
+ --target Emit only the specified target(s). Can be repeated.
18
+ Available: react, jsx-static, askama, askama-includes, liquid`);
17
19
  }
18
20
  function parseArgs(argv) {
19
21
  const [commandArg, ...rest] = argv;
@@ -40,6 +42,12 @@ function parseArgs(argv) {
40
42
  index += 1;
41
43
  continue;
42
44
  }
45
+ if (current === "--target" && next) {
46
+ options.targets ??= [];
47
+ options.targets.push(next);
48
+ index += 1;
49
+ continue;
50
+ }
43
51
  throw new Error(`Unknown or incomplete option: ${current}`);
44
52
  }
45
53
  return options;
@@ -55,6 +63,7 @@ function run() {
55
63
  projectRoot: options.projectRoot,
56
64
  templatesDir: options.templatesDir,
57
65
  distDir: options.distDir,
66
+ targets: options.targets,
58
67
  });
59
68
  return;
60
69
  }
@@ -49,6 +49,9 @@ export type ClassItem = {
49
49
  kind: "when";
50
50
  test: Expr;
51
51
  value: string;
52
+ } | {
53
+ kind: "dynamic";
54
+ expr: Expr;
52
55
  };
53
56
  export type AttrValue = {
54
57
  kind: "text";
@@ -59,6 +62,16 @@ export type AttrValue = {
59
62
  } | {
60
63
  kind: "classList";
61
64
  items: ClassItem[];
65
+ } | {
66
+ kind: "concat";
67
+ parts: Expr[];
68
+ };
69
+ export type TagName = {
70
+ kind: "static";
71
+ name: string;
72
+ } | {
73
+ kind: "dynamic";
74
+ parts: Expr[];
62
75
  };
63
76
  export type Node = {
64
77
  kind: "text";
@@ -69,7 +82,6 @@ export type Node = {
69
82
  } | {
70
83
  kind: "print";
71
84
  expr: Expr;
72
- safe?: boolean;
73
85
  } | {
74
86
  kind: "if";
75
87
  test: Expr;
@@ -83,7 +95,7 @@ export type Node = {
83
95
  indexName?: string | null;
84
96
  } | {
85
97
  kind: "element";
86
- tag: string;
98
+ tag: TagName;
87
99
  attrs?: Record<string, AttrValue>;
88
100
  children?: Node[];
89
101
  } | {
@@ -106,10 +118,11 @@ export declare const not: (expr: Expr) => Expr;
106
118
  export declare const textAttr: (value: string) => AttrValue;
107
119
  export declare const exprAttr: (expr: Expr) => AttrValue;
108
120
  export declare const classList: (...items: ClassItem[]) => AttrValue;
121
+ export declare const concatAttr: (...parts: Expr[]) => AttrValue;
109
122
  export declare const textNode: (value: string) => Node;
110
123
  export declare const slotNode: (name: string) => Node;
111
- export declare const printNode: (expr: Expr, safe?: boolean) => Node;
124
+ export declare const printNode: (expr: Expr) => Node;
112
125
  export declare const ifNode: (test: Expr, thenNodes: Node[], elseNodes?: Node[]) => Node;
113
126
  export declare const forNode: (item: string, each: Expr, children?: Node[], indexName?: string | null) => Node;
114
- export declare const elementNode: (tag: string, attrs?: Record<string, AttrValue>, children?: Node[]) => Node;
127
+ export declare const elementNode: (tag: string | TagName, attrs?: Record<string, AttrValue>, children?: Node[]) => Node;
115
128
  export declare const componentNode: (name: string, props?: Record<string, AttrValue>, children?: Node[]) => Node;
package/dist/core/ast.js CHANGED
@@ -21,9 +21,10 @@ export const not = (expr) => ({ kind: "not", expr });
21
21
  export const textAttr = (value) => ({ kind: "text", value });
22
22
  export const exprAttr = (expr) => ({ kind: "expr", expr });
23
23
  export const classList = (...items) => ({ kind: "classList", items });
24
+ export const concatAttr = (...parts) => ({ kind: "concat", parts });
24
25
  export const textNode = (value) => ({ kind: "text", value });
25
26
  export const slotNode = (name) => ({ kind: "slot", name });
26
- export const printNode = (expr, safe = false) => ({ kind: "print", expr, safe });
27
+ export const printNode = (expr) => ({ kind: "print", expr });
27
28
  export const ifNode = (test, thenNodes, elseNodes = []) => ({
28
29
  kind: "if",
29
30
  test,
@@ -39,7 +40,7 @@ export const forNode = (item, each, children = [], indexName = null) => ({
39
40
  });
40
41
  export const elementNode = (tag, attrs = {}, children = []) => ({
41
42
  kind: "element",
42
- tag,
43
+ tag: typeof tag === "string" ? { kind: "static", name: tag } : tag,
43
44
  attrs,
44
45
  children,
45
46
  });
@@ -3,6 +3,8 @@ export interface BuildProjectOptions {
3
3
  projectRoot?: string;
4
4
  distDir?: string;
5
5
  templatesDir?: string;
6
+ /** Emit only the specified target IDs. When omitted, all targets are emitted. */
7
+ targets?: string[];
6
8
  logger?: Pick<Console, "log">;
7
9
  }
8
10
  export interface BuildProjectResult {
@@ -47,6 +47,15 @@ export function buildProject(options = {}) {
47
47
  const templatesDir = options.templatesDir ?? resolve(projectRoot, "src/templates");
48
48
  const logger = options.logger ?? console;
49
49
  const writtenFiles = [];
50
+ const allTargetIds = outputTargets.map((t) => t.id);
51
+ let activeTargets = outputTargets;
52
+ if (options.targets && options.targets.length > 0) {
53
+ const unknown = options.targets.filter((t) => !allTargetIds.includes(t));
54
+ if (unknown.length > 0) {
55
+ throw new Error(`Unknown target(s): ${unknown.join(", ")}. Available: ${allTargetIds.join(", ")}`);
56
+ }
57
+ activeTargets = outputTargets.filter((t) => options.targets.includes(t.id));
58
+ }
50
59
  mkdirSync(distDir, { recursive: true });
51
60
  const templateFiles = collectTemplateFiles(templatesDir);
52
61
  const parsedTemplates = templateFiles.map((filePath) => {
@@ -79,11 +88,14 @@ export function buildProject(options = {}) {
79
88
  componentRegistry[entry.localName] = {
80
89
  reactImport: toRelativeModulePath(template.relativePath, importedTemplate.relativePath.replace(/\.template\.tsx$/, "")),
81
90
  include: toRelativeIncludePath(template.relativePath, importedTemplate.relativePath),
91
+ liquidSnippet: importedTemplate.relativePath
92
+ .replace(/\.template\.tsx$/, "")
93
+ .replaceAll("\\", "/"),
82
94
  };
83
95
  }
84
96
  template.componentRegistry = componentRegistry;
85
97
  const templateDir = dirname(template.relativePath);
86
- for (const target of outputTargets) {
98
+ for (const target of activeTargets) {
87
99
  for (const output of target.emitFiles(template)) {
88
100
  const outputPath = join(distDir, target.outputSubdir, templateDir, output.fileName);
89
101
  mkdirSync(dirname(outputPath), { recursive: true });
@@ -1,4 +1,4 @@
1
- import { and, classList, componentNode, elementNode, eq, exprAttr, forNode, intrinsic, ifNode, n, neq, not, or, printNode, raw, s, slotNode, textAttr, textNode, v, } from "./ast.js";
1
+ import { and, classList, componentNode, concatAttr, elementNode, eq, exprAttr, forNode, intrinsic, ifNode, n, neq, not, or, printNode, raw, s, slotNode, textAttr, textNode, v, } from "./ast.js";
2
2
  /**
3
3
  * Handwritten parser for Katachi's current restricted TSX subset.
4
4
  *
@@ -188,12 +188,9 @@ function parseExpr(source) {
188
188
  if (/^-?\d+(\.\d+)?$/.test(input)) {
189
189
  return n(Number(input));
190
190
  }
191
- if (input.startsWith("!(") && input.endsWith(")")) {
192
- return not(parseExpr(input.slice(2, -1)));
193
- }
194
- if (input.startsWith("!") && !input.startsWith("!=")) {
195
- return not(parseExpr(input.slice(1)));
196
- }
191
+ // Binary operators are searched BEFORE unary `!` because they have lower
192
+ // precedence. `!isEmpty(x) && y` must split at `&&` first, yielding
193
+ // `and(not(isEmpty(x)), y)` — not `not(and(isEmpty(x), y))`.
197
194
  for (const operator of ["||", "&&", "===", "!==", "==", "!="]) {
198
195
  const operatorIndex = findTopLevelOperator(input, operator);
199
196
  if (operatorIndex !== -1) {
@@ -208,6 +205,12 @@ function parseExpr(source) {
208
205
  return neq(left, right);
209
206
  }
210
207
  }
208
+ if (input.startsWith("!(") && input.endsWith(")")) {
209
+ return not(parseExpr(input.slice(2, -1)));
210
+ }
211
+ if (input.startsWith("!") && !input.startsWith("!=")) {
212
+ return not(parseExpr(input.slice(1)));
213
+ }
211
214
  if ((input.startsWith('"') && input.endsWith('"')) ||
212
215
  (input.startsWith("'") && input.endsWith("'"))) {
213
216
  return s(unquote(input));
@@ -232,23 +235,90 @@ function parseExpr(source) {
232
235
  }
233
236
  return raw(input);
234
237
  }
238
+ function findLastTopLevelOperator(input, operator) {
239
+ let depthParen = 0;
240
+ let depthBracket = 0;
241
+ let depthBrace = 0;
242
+ let quote = null;
243
+ let lastIndex = -1;
244
+ for (let index = 0; index <= input.length - operator.length; index += 1) {
245
+ const char = input[index];
246
+ const next = input[index + 1];
247
+ if (quote) {
248
+ if (char === "\\" && next) {
249
+ index += 1;
250
+ continue;
251
+ }
252
+ if (char === quote) {
253
+ quote = null;
254
+ }
255
+ continue;
256
+ }
257
+ if (char === '"' || char === "'") {
258
+ quote = char;
259
+ continue;
260
+ }
261
+ if (char === "(")
262
+ depthParen += 1;
263
+ if (char === ")")
264
+ depthParen -= 1;
265
+ if (char === "[")
266
+ depthBracket += 1;
267
+ if (char === "]")
268
+ depthBracket -= 1;
269
+ if (char === "{")
270
+ depthBrace += 1;
271
+ if (char === "}")
272
+ depthBrace -= 1;
273
+ if (depthParen === 0 &&
274
+ depthBracket === 0 &&
275
+ depthBrace === 0 &&
276
+ input.slice(index, index + operator.length) === operator) {
277
+ lastIndex = index;
278
+ }
279
+ }
280
+ return lastIndex;
281
+ }
235
282
  function parseClassList(source) {
236
283
  const input = source.trim();
237
284
  const listBody = input.slice(1, -1);
238
285
  const items = splitTopLevel(listBody, ",").map((item) => {
239
- if (item.includes("&&")) {
240
- const parts = item.split("&&");
241
- const test = parts[0] ?? "";
242
- const value = parts.slice(1).join("&&");
286
+ const trimmed = item.trim();
287
+ // Conditional class: `expr && "class-name"`
288
+ // Use the LAST top-level && so that chained conditions like
289
+ // `isSome(x) && !isEmpty(x) && "cls"` correctly split into
290
+ // test=`isSome(x) && !isEmpty(x)` and value=`"cls"`
291
+ const andIndex = findLastTopLevelOperator(trimmed, "&&");
292
+ if (andIndex !== -1) {
293
+ const test = trimmed.slice(0, andIndex).trim();
294
+ const value = trimmed.slice(andIndex + 2).trim();
243
295
  return {
244
296
  kind: "when",
245
297
  test: parseExpr(test),
246
298
  value: unquote(value),
247
299
  };
248
300
  }
301
+ // Bare quoted string: "class-name" or 'class-name'
302
+ const unquoted = unquote(trimmed);
303
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
304
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
305
+ return {
306
+ kind: "static",
307
+ value: unquoted,
308
+ };
309
+ }
310
+ // Bare identifier or expression: className, someVar, etc.
311
+ // These are dynamic class items (variable references)
312
+ if (/^[A-Za-z_][A-Za-z0-9_.]*$/.test(trimmed)) {
313
+ return {
314
+ kind: "dynamic",
315
+ expr: v(trimmed),
316
+ };
317
+ }
318
+ // Any other expression (function calls, etc.)
249
319
  return {
250
- kind: "static",
251
- value: unquote(item),
320
+ kind: "dynamic",
321
+ expr: parseExpr(trimmed),
252
322
  };
253
323
  });
254
324
  return classList(...items);
@@ -262,6 +332,12 @@ function parseAttrValue(name, source) {
262
332
  inner.endsWith("]")) {
263
333
  return parseClassList(inner);
264
334
  }
335
+ // Non-class array attributes: parse as concat (e.g., href={["#", variant, "-icon"]})
336
+ if (inner.startsWith("[") && inner.endsWith("]")) {
337
+ const arrayBody = inner.slice(1, -1);
338
+ const parts = splitTopLevel(arrayBody, ",").map((part) => parseExpr(part.trim()));
339
+ return concatAttr(...parts);
340
+ }
265
341
  return exprAttr(parseExpr(inner));
266
342
  }
267
343
  return textAttr(unquote(input));
@@ -348,6 +424,24 @@ function normalizeElementAttrs(attrs) {
348
424
  }
349
425
  return normalized;
350
426
  }
427
+ function parseDynamicTag(value) {
428
+ if (!value) {
429
+ throw new Error("<Element> requires a `tag={...}` prop");
430
+ }
431
+ if (value.kind === "text") {
432
+ return { kind: "dynamic", parts: [s(value.value)] };
433
+ }
434
+ if (value.kind === "expr") {
435
+ return { kind: "dynamic", parts: [value.expr] };
436
+ }
437
+ if (value.kind === "concat") {
438
+ if (value.parts.length === 0) {
439
+ throw new Error("<Element> requires at least one tag part");
440
+ }
441
+ return { kind: "dynamic", parts: value.parts };
442
+ }
443
+ throw new Error("<Element> tag must be a string, expression, or string/expression tuple");
444
+ }
351
445
  function readOpenTag(source, startIndex) {
352
446
  let index = startIndex;
353
447
  let quote = null;
@@ -426,6 +520,12 @@ function parseNodes(source, startIndex = 0, untilTagName = null) {
426
520
  nodes.push(forNode(item, each, parsedChildren.nodes, indexName));
427
521
  continue;
428
522
  }
523
+ if (tagName === "Element") {
524
+ const normalizedAttrs = normalizeElementAttrs(attrs);
525
+ const { tag: tagAttr, ...restAttrs } = normalizedAttrs;
526
+ nodes.push(elementNode(parseDynamicTag(tagAttr), restAttrs, parsedChildren.nodes));
527
+ continue;
528
+ }
429
529
  if (/^[A-Z]/.test(tagName)) {
430
530
  nodes.push(componentNode(tagName, attrs, parsedChildren.nodes));
431
531
  }
@@ -453,9 +553,6 @@ function parseNodes(source, startIndex = 0, untilTagName = null) {
453
553
  if (inner === "children") {
454
554
  nodes.push(slotNode(inner));
455
555
  }
456
- else if (inner.startsWith("safe(") && inner.endsWith(")")) {
457
- nodes.push(printNode(parseExpr(inner.slice(5, -1)), true));
458
- }
459
556
  else {
460
557
  nodes.push(printNode(parseExpr(inner)));
461
558
  }
@@ -484,6 +581,16 @@ function normalizePropType(type) {
484
581
  type === "TemplateNode") {
485
582
  return "children";
486
583
  }
584
+ if (type === "ReactNode[]" ||
585
+ type === "React.ReactNode[]" ||
586
+ type === "TemplateNode[]") {
587
+ return "children[]";
588
+ }
589
+ if (type === "ReactNode[][]" ||
590
+ type === "React.ReactNode[][]" ||
591
+ type === "TemplateNode[][]") {
592
+ return "children[][]";
593
+ }
487
594
  if (type === "ClassValue") {
488
595
  return "string";
489
596
  }
@@ -20,6 +20,7 @@ export interface TemplateProp {
20
20
  export interface ComponentRegistration {
21
21
  reactImport: string;
22
22
  include: string;
23
+ liquidSnippet?: string;
23
24
  }
24
25
  export type ComponentRegistry = Record<string, ComponentRegistration>;
25
26
  /**
@@ -1,11 +1,13 @@
1
1
  import type { Node } from "../core/ast.js";
2
2
  import type { BuildTemplate } from "../core/types.js";
3
+ type ValueTypeMap = Record<string, string>;
3
4
  /**
4
5
  * Emits portable AST nodes into Askama template source.
5
6
  */
6
- export declare function emitAskama(node: Node, indent?: number, componentRegistry?: BuildTemplate["componentRegistry"]): string;
7
+ export declare function emitAskama(node: Node, indent?: number, componentRegistry?: BuildTemplate["componentRegistry"], valueTypes?: ValueTypeMap): string;
7
8
  /**
8
9
  * Emits the Rust `Template` wrapper for Askama consumption.
9
10
  */
10
11
  export declare function emitAskamaComponent(template: BuildTemplate): string;
11
12
  export declare function emitAskamaPartial(template: BuildTemplate): string;
13
+ export {};
@@ -1,4 +1,18 @@
1
- import { emitAskamaExpr, escapeDoubleQuotes, toCamelCase, toRustType, wrapHtmlAttribute, } from "./shared.js";
1
+ import { emitAskamaExpr, emitInterpolatedTagName, escapeDoubleQuotes, toCamelCase, toRustType, wrapHtmlAttribute, } from "./shared.js";
2
+ function inferEachItemType(type) {
3
+ if (type === "children[]")
4
+ return "children";
5
+ if (type === "children[][]")
6
+ return "children[]";
7
+ if (type === "string[]")
8
+ return "string";
9
+ if (type === "string[][]")
10
+ return "string[]";
11
+ return undefined;
12
+ }
13
+ function shouldPrintSafe(node, valueTypes) {
14
+ return node.expr.kind === "var" && valueTypes[node.expr.name] === "children";
15
+ }
2
16
  /**
3
17
  * Emits an HTML attribute for Askama output.
4
18
  */
@@ -15,16 +29,29 @@ function emitAskamaAttr(name, value) {
15
29
  parts.push(item.value);
16
30
  continue;
17
31
  }
32
+ if (item.kind === "dynamic") {
33
+ parts.push(`{{ ${emitAskamaExpr(item.expr)} }}`);
34
+ continue;
35
+ }
18
36
  parts.push(`{% if ${emitAskamaExpr(item.test)} %}${item.value}{% endif %}`);
19
37
  }
20
38
  return `${name}=${wrapHtmlAttribute(parts.join(" ").trim())}`;
21
39
  }
40
+ case "concat": {
41
+ const segments = value.parts.map((part) => {
42
+ if (part.kind === "string") {
43
+ return part.value;
44
+ }
45
+ return `{{ ${emitAskamaExpr(part)} }}`;
46
+ });
47
+ return `${name}=${wrapHtmlAttribute(segments.join(""))}`;
48
+ }
22
49
  }
23
50
  }
24
51
  /**
25
52
  * Emits portable AST nodes into Askama template source.
26
53
  */
27
- export function emitAskama(node, indent = 0, componentRegistry = {}) {
54
+ export function emitAskama(node, indent = 0, componentRegistry = {}, valueTypes = {}) {
28
55
  const pad = " ".repeat(indent);
29
56
  switch (node.kind) {
30
57
  case "text":
@@ -32,13 +59,13 @@ export function emitAskama(node, indent = 0, componentRegistry = {}) {
32
59
  case "slot":
33
60
  return `${pad}{{ ${node.name}|safe }}`;
34
61
  case "print":
35
- return `${pad}{{ ${emitAskamaExpr(node.expr)}${node.safe ? "|safe" : ""} }}`;
62
+ return `${pad}{{ ${emitAskamaExpr(node.expr)}${shouldPrintSafe(node, valueTypes) ? "|safe" : ""} }}`;
36
63
  case "if": {
37
64
  const thenPart = node.then
38
- .map((child) => emitAskama(child, indent + 1, componentRegistry))
65
+ .map((child) => emitAskama(child, indent + 1, componentRegistry, valueTypes))
39
66
  .join("\n");
40
67
  const elsePart = (node.else ?? [])
41
- .map((child) => emitAskama(child, indent + 1, componentRegistry))
68
+ .map((child) => emitAskama(child, indent + 1, componentRegistry, valueTypes))
42
69
  .join("\n");
43
70
  if (elsePart) {
44
71
  return `${pad}{% if ${emitAskamaExpr(node.test)} %}\n${thenPart}\n${pad}{% else %}\n${elsePart}\n${pad}{% endif %}`;
@@ -46,8 +73,10 @@ export function emitAskama(node, indent = 0, componentRegistry = {}) {
46
73
  return `${pad}{% if ${emitAskamaExpr(node.test)} %}\n${thenPart}\n${pad}{% endif %}`;
47
74
  }
48
75
  case "for": {
76
+ const loopValueTypes = { ...valueTypes };
77
+ loopValueTypes[node.item] = inferEachItemType(node.each.kind === "var" ? valueTypes[node.each.name] : undefined) ?? "string";
49
78
  const body = node.children
50
- .map((child) => emitAskama(child, indent + 1, componentRegistry))
79
+ .map((child) => emitAskama(child, indent + 1, componentRegistry, loopValueTypes))
51
80
  .join("\n");
52
81
  return `${pad}{% for ${node.item} in ${emitAskamaExpr(node.each)} %}\n${body}\n${pad}{% endfor %}`;
53
82
  }
@@ -58,11 +87,12 @@ export function emitAskama(node, indent = 0, componentRegistry = {}) {
58
87
  .map(([name, value]) => `${pad} ${emitAskamaAttr(name, value)}`)
59
88
  .join("\n")}\n${pad}`
60
89
  : "";
61
- const children = (node.children ?? []).map((child) => emitAskama(child, indent + 1, componentRegistry));
90
+ const children = (node.children ?? []).map((child) => emitAskama(child, indent + 1, componentRegistry, valueTypes));
91
+ const tagName = emitInterpolatedTagName(node.tag, emitAskamaExpr);
62
92
  if (children.length === 0) {
63
- return `${pad}<${node.tag}${attrs}/>`;
93
+ return `${pad}<${tagName}${attrs}/>`;
64
94
  }
65
- return `${pad}<${node.tag}${attrs}>\n${children.join("\n")}\n${pad}</${node.tag}>`;
95
+ return `${pad}<${tagName}${attrs}>\n${children.join("\n")}\n${pad}</${tagName}>`;
66
96
  }
67
97
  case "component": {
68
98
  const registration = componentRegistry[node.name];
@@ -83,7 +113,7 @@ export function emitAskama(node, indent = 0, componentRegistry = {}) {
83
113
  }
84
114
  if ((node.children ?? []).length > 0) {
85
115
  lines.push(`${pad}{% let children %}`);
86
- lines.push(...(node.children ?? []).map((child) => emitAskama(child, indent + 1, componentRegistry)));
116
+ lines.push(...(node.children ?? []).map((child) => emitAskama(child, indent + 1, componentRegistry, valueTypes)));
87
117
  lines.push(`${pad}{% endlet %}`);
88
118
  }
89
119
  lines.push(`${pad}{% include "${registration.include}" %}`);
@@ -102,7 +132,8 @@ export function emitAskamaComponent(template) {
102
132
  const fields = props
103
133
  .map((prop) => ` pub ${toCamelCase(prop.name)}: ${toRustType(prop.type)},`)
104
134
  .join("\n");
105
- const source = emitAskama(template.template, 0, template.componentRegistry ?? {}).replace(/#"/g, '#\\"');
135
+ const valueTypes = Object.fromEntries(props.map((prop) => [prop.name, prop.type]));
136
+ const source = emitAskama(template.template, 0, template.componentRegistry ?? {}, valueTypes).replace(/#"/g, '#\\"');
106
137
  return `use askama::Template;
107
138
 
108
139
  #[derive(Template)]
@@ -118,5 +149,6 @@ ${fields}
118
149
  `;
119
150
  }
120
151
  export function emitAskamaPartial(template) {
121
- return `${emitAskama(template.template, 0, template.componentRegistry ?? {})}\n`;
152
+ const valueTypes = Object.fromEntries((template.props ?? []).map((prop) => [prop.name, prop.type]));
153
+ return `${emitAskama(template.template, 0, template.componentRegistry ?? {}, valueTypes)}\n`;
122
154
  }
@@ -1,4 +1,5 @@
1
1
  import { emitAskamaComponent, emitAskamaPartial } from "./askama.js";
2
+ import { emitLiquidSnippet } from "./liquid.js";
2
3
  import { emitReactComponent } from "./react.js";
3
4
  import { emitStaticJsxComponent } from "./static-jsx.js";
4
5
  /**
@@ -57,4 +58,17 @@ export const outputTargets = [
57
58
  ];
58
59
  },
59
60
  },
61
+ {
62
+ id: "liquid",
63
+ outputSubdir: "liquid/snippets",
64
+ extension: ".liquid",
65
+ emitFiles(template) {
66
+ return [
67
+ {
68
+ fileName: `${template.fileName}.liquid`,
69
+ content: emitLiquidSnippet(template),
70
+ },
71
+ ];
72
+ },
73
+ },
60
74
  ];
@@ -0,0 +1,2 @@
1
+ import type { BuildTemplate } from "../core/types.js";
2
+ export declare function emitLiquidSnippet(template: BuildTemplate): string;