@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/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
|
|
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
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
11
|
+
export function Element(_props) {
|
|
13
12
|
return null;
|
|
14
13
|
}
|
|
15
14
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
return
|
|
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
|
-
|
|
14
|
-
--project
|
|
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
|
}
|
package/dist/core/ast.d.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
});
|
package/dist/core/build.d.ts
CHANGED
|
@@ -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 {
|
package/dist/core/build.js
CHANGED
|
@@ -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
|
|
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 });
|
package/dist/core/parser.js
CHANGED
|
@@ -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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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: "
|
|
251
|
-
|
|
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
|
}
|
package/dist/core/types.d.ts
CHANGED
package/dist/targets/askama.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/targets/askama.js
CHANGED
|
@@ -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
|
|
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}<${
|
|
93
|
+
return `${pad}<${tagName}${attrs}/>`;
|
|
64
94
|
}
|
|
65
|
-
return `${pad}<${
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/targets/index.js
CHANGED
|
@@ -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
|
];
|