@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
package/dist/targets/shared.d.ts
CHANGED
|
@@ -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 };
|
package/dist/targets/shared.js
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
const { body, hoists } = emitTsxWithHoists(template, emitTsxNode, emitStaticJsxAttr);
|
|
40
|
+
return buildTsxComponentSource(template, body, hoists);
|
|
28
41
|
}
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
package/docs/getting-started.md
CHANGED
|
@@ -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,
|
|
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
|
-
<
|
|
35
|
+
<Element tag={["h", 2]}>{title}</Element>
|
|
36
36
|
<For each={rows} as="row">
|
|
37
|
-
<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
|
-
### `
|
|
138
|
+
### `TemplateNode`
|
|
123
139
|
|
|
124
|
-
Use `
|
|
140
|
+
Use `TemplateNode` for props or children that carry markup-like content.
|
|
125
141
|
|
|
126
142
|
```tsx
|
|
127
|
-
import {
|
|
143
|
+
import type { TemplateNode } from "@relevate/katachi";
|
|
128
144
|
|
|
129
|
-
|
|
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
|
|
package/examples/basic/README.md
CHANGED
|
@@ -17,7 +17,7 @@ usage:
|
|
|
17
17
|
- dynamic `className` arrays
|
|
18
18
|
- `If`
|
|
19
19
|
- nested `For`
|
|
20
|
-
- `
|
|
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
|
|