@relevate/katachi 0.1.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/CONTRIBUTING.md +60 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/bin/katachi.mjs +30 -0
- package/dist/api/index.d.ts +54 -0
- package/dist/api/index.js +45 -0
- package/dist/api/jsx.d.ts +26 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +77 -0
- package/dist/core/ast.d.ts +115 -0
- package/dist/core/ast.js +51 -0
- package/dist/core/build.d.ts +15 -0
- package/dist/core/build.js +107 -0
- package/dist/core/compiler.d.ts +9 -0
- package/dist/core/compiler.js +9 -0
- package/dist/core/example-fixtures.d.ts +5 -0
- package/dist/core/example-fixtures.js +54 -0
- package/dist/core/parser.d.ts +5 -0
- package/dist/core/parser.js +637 -0
- package/dist/core/types.d.ts +65 -0
- package/dist/core/types.js +1 -0
- package/dist/core/verify.d.ts +25 -0
- package/dist/core/verify.js +270 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/targets/askama.d.ts +11 -0
- package/dist/targets/askama.js +122 -0
- package/dist/targets/index.d.ts +5 -0
- package/dist/targets/index.js +60 -0
- package/dist/targets/react.d.ts +4 -0
- package/dist/targets/react.js +28 -0
- package/dist/targets/shared.d.ts +36 -0
- package/dist/targets/shared.js +278 -0
- package/dist/targets/static-jsx.d.ts +4 -0
- package/dist/targets/static-jsx.js +28 -0
- package/dist/verify-examples.d.ts +1 -0
- package/dist/verify-examples.js +14 -0
- package/docs/architecture.md +122 -0
- package/docs/getting-started.md +154 -0
- package/docs/syntax.md +236 -0
- package/docs/targets.md +53 -0
- package/examples/basic/README.md +67 -0
- package/examples/basic/components/badge-chip.html +3 -0
- package/examples/basic/components/comparison-table.html +24 -0
- package/examples/basic/components/glyph.html +6 -0
- package/examples/basic/components/hover-note.html +6 -0
- package/examples/basic/components/media-frame.html +15 -0
- package/examples/basic/components/notice-panel.html +24 -0
- package/examples/basic/components/resource-tile.html +24 -0
- package/examples/basic/components/stack-shell.html +3 -0
- package/examples/basic/dist/askama/badge-chip.rs +18 -0
- package/examples/basic/dist/askama/comparison-table.rs +47 -0
- package/examples/basic/dist/askama/glyph.rs +21 -0
- package/examples/basic/dist/askama/hover-note.rs +19 -0
- package/examples/basic/dist/askama/includes/badge-chip.html +5 -0
- package/examples/basic/dist/askama/includes/comparison-table.html +34 -0
- package/examples/basic/dist/askama/includes/glyph.html +6 -0
- package/examples/basic/dist/askama/includes/hover-note.html +6 -0
- package/examples/basic/dist/askama/includes/media-frame.html +23 -0
- package/examples/basic/dist/askama/includes/notice-panel.html +34 -0
- package/examples/basic/dist/askama/includes/resource-tile.html +42 -0
- package/examples/basic/dist/askama/includes/stack-shell.html +5 -0
- package/examples/basic/dist/askama/media-frame.rs +37 -0
- package/examples/basic/dist/askama/notice-panel.rs +49 -0
- package/examples/basic/dist/askama/resource-tile.rs +59 -0
- package/examples/basic/dist/askama/stack-shell.rs +17 -0
- package/examples/basic/dist/jsx-static/badge-chip.tsx +18 -0
- package/examples/basic/dist/jsx-static/comparison-table.tsx +53 -0
- package/examples/basic/dist/jsx-static/glyph.tsx +21 -0
- package/examples/basic/dist/jsx-static/hover-note.tsx +19 -0
- package/examples/basic/dist/jsx-static/media-frame.tsx +41 -0
- package/examples/basic/dist/jsx-static/notice-panel.tsx +53 -0
- package/examples/basic/dist/jsx-static/resource-tile.tsx +63 -0
- package/examples/basic/dist/jsx-static/stack-shell.tsx +17 -0
- package/examples/basic/dist/react/badge-chip.tsx +18 -0
- package/examples/basic/dist/react/comparison-table.tsx +53 -0
- package/examples/basic/dist/react/glyph.tsx +21 -0
- package/examples/basic/dist/react/hover-note.tsx +19 -0
- package/examples/basic/dist/react/media-frame.tsx +41 -0
- package/examples/basic/dist/react/notice-panel.tsx +53 -0
- package/examples/basic/dist/react/resource-tile.tsx +63 -0
- package/examples/basic/dist/react/stack-shell.tsx +17 -0
- package/examples/basic/src/templates/badge-chip.template.tsx +18 -0
- package/examples/basic/src/templates/comparison-table.template.tsx +35 -0
- package/examples/basic/src/templates/glyph.template.tsx +17 -0
- package/examples/basic/src/templates/hover-note.template.tsx +17 -0
- package/examples/basic/src/templates/media-frame.template.tsx +25 -0
- package/examples/basic/src/templates/notice-panel.template.tsx +40 -0
- package/examples/basic/src/templates/resource-tile.template.tsx +51 -0
- package/examples/basic/src/templates/stack-shell.template.tsx +13 -0
- package/examples/basic/tsconfig.json +10 -0
- package/package.json +69 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface Fixture {
|
|
2
|
+
name: string;
|
|
3
|
+
source: string;
|
|
4
|
+
generated: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Canonicalizes Askama/HTML output enough to separate semantic regressions from
|
|
8
|
+
* serialization and formatting differences.
|
|
9
|
+
*/
|
|
10
|
+
export declare function normalizeAskama(source: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Runs the Askama fixture comparison and sets a failing process exit code on
|
|
13
|
+
* functional mismatches.
|
|
14
|
+
*/
|
|
15
|
+
export interface VerifyAskamaOptions {
|
|
16
|
+
fixtures?: Fixture[];
|
|
17
|
+
logger?: Pick<Console, "log" | "error">;
|
|
18
|
+
}
|
|
19
|
+
export interface VerifyAskamaResult {
|
|
20
|
+
ok: string[];
|
|
21
|
+
formatOnly: string[];
|
|
22
|
+
failures: string[];
|
|
23
|
+
missing: string[];
|
|
24
|
+
}
|
|
25
|
+
export declare function verifyAskamaFixtures(options?: VerifyAskamaOptions): VerifyAskamaResult;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Canonicalizes Askama/HTML output enough to separate semantic regressions from
|
|
4
|
+
* serialization and formatting differences.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeAskama(source) {
|
|
7
|
+
const input = source.replace(/\r\n/g, "\n");
|
|
8
|
+
const tokens = [];
|
|
9
|
+
let index = 0;
|
|
10
|
+
while (index < input.length) {
|
|
11
|
+
if (input.startsWith("{{", index)) {
|
|
12
|
+
const end = input.indexOf("}}", index);
|
|
13
|
+
tokens.push(normalizeAskamaPrint(input.slice(index, end + 2)));
|
|
14
|
+
index = end + 2;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (input.startsWith("{%", index)) {
|
|
18
|
+
const end = input.indexOf("%}", index);
|
|
19
|
+
tokens.push(normalizeAskamaStatement(input.slice(index, end + 2)));
|
|
20
|
+
index = end + 2;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (input[index] === "<") {
|
|
24
|
+
const { tag, nextIndex } = readHtmlTag(input, index);
|
|
25
|
+
tokens.push(normalizeHtmlTag(tag));
|
|
26
|
+
index = nextIndex;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let end = index;
|
|
30
|
+
while (end < input.length &&
|
|
31
|
+
!input.startsWith("{{", end) &&
|
|
32
|
+
!input.startsWith("{%", end) &&
|
|
33
|
+
input[end] !== "<") {
|
|
34
|
+
end += 1;
|
|
35
|
+
}
|
|
36
|
+
const text = input.slice(index, end).replace(/\s+/g, " ").trim();
|
|
37
|
+
if (text) {
|
|
38
|
+
tokens.push(text);
|
|
39
|
+
}
|
|
40
|
+
index = end;
|
|
41
|
+
}
|
|
42
|
+
let normalized = tokens.join("");
|
|
43
|
+
let previous = "";
|
|
44
|
+
normalized = normalized.replace(/<(img|input|br|hr|meta|link|source|track|wbr|area|base|col|embed|param)([^>]*?)(?<!\/)>/gi, "<$1$2/>");
|
|
45
|
+
while (previous !== normalized) {
|
|
46
|
+
previous = normalized;
|
|
47
|
+
normalized = normalized.replace(/<([A-Za-z][A-Za-z0-9:_-]*)([^>]*)><\/\1>/g, "<$1$2/>");
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
function readTemplateBlock(source, startIndex) {
|
|
52
|
+
const delimiter = source.startsWith("{%", startIndex)
|
|
53
|
+
? "%}"
|
|
54
|
+
: source.startsWith("{{", startIndex)
|
|
55
|
+
? "}}"
|
|
56
|
+
: null;
|
|
57
|
+
if (!delimiter) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const end = source.indexOf(delimiter, startIndex);
|
|
61
|
+
if (end === -1) {
|
|
62
|
+
return {
|
|
63
|
+
block: source.slice(startIndex),
|
|
64
|
+
nextIndex: source.length,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
block: source.slice(startIndex, end + delimiter.length),
|
|
69
|
+
nextIndex: end + delimiter.length,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function readHtmlTag(source, startIndex) {
|
|
73
|
+
let index = startIndex;
|
|
74
|
+
let quote = null;
|
|
75
|
+
while (index < source.length) {
|
|
76
|
+
const templateBlock = readTemplateBlock(source, index);
|
|
77
|
+
if (templateBlock) {
|
|
78
|
+
index = templateBlock.nextIndex;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const char = source[index];
|
|
82
|
+
const next = source[index + 1];
|
|
83
|
+
if (quote) {
|
|
84
|
+
if (char === "\\" && next) {
|
|
85
|
+
index += 2;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (char === quote) {
|
|
89
|
+
quote = null;
|
|
90
|
+
}
|
|
91
|
+
index += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (char === '"' || char === "'") {
|
|
95
|
+
quote = char;
|
|
96
|
+
index += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (char === ">") {
|
|
100
|
+
return {
|
|
101
|
+
tag: source.slice(startIndex, index + 1),
|
|
102
|
+
nextIndex: index + 1,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
index += 1;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
tag: source.slice(startIndex),
|
|
109
|
+
nextIndex: source.length,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function normalizeAskamaPrint(token) {
|
|
113
|
+
return `{{${token.slice(2, -2).trim()}}}`;
|
|
114
|
+
}
|
|
115
|
+
function normalizeAskamaStatement(token) {
|
|
116
|
+
return `{%${token.slice(2, -2).trim()}%}`;
|
|
117
|
+
}
|
|
118
|
+
function normalizeAttributeValue(name, rawValue) {
|
|
119
|
+
let value = rawValue
|
|
120
|
+
.replace(/\r\n/g, "\n")
|
|
121
|
+
.replace(/\{\{\s*/g, "{{")
|
|
122
|
+
.replace(/\s*\}\}/g, "}}")
|
|
123
|
+
.replace(/\{%\s*/g, "{%")
|
|
124
|
+
.replace(/\s*%\}/g, "%}")
|
|
125
|
+
.replace(/\s+/g, " ")
|
|
126
|
+
.trim();
|
|
127
|
+
if (name === "class" || name === "className") {
|
|
128
|
+
value = value.replace(/%\}\s+\{%/g, "%}{%");
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
function normalizeHtmlTag(tag) {
|
|
133
|
+
const voidTags = new Set([
|
|
134
|
+
"area",
|
|
135
|
+
"base",
|
|
136
|
+
"br",
|
|
137
|
+
"col",
|
|
138
|
+
"embed",
|
|
139
|
+
"hr",
|
|
140
|
+
"img",
|
|
141
|
+
"input",
|
|
142
|
+
"link",
|
|
143
|
+
"meta",
|
|
144
|
+
"param",
|
|
145
|
+
"source",
|
|
146
|
+
"track",
|
|
147
|
+
"wbr",
|
|
148
|
+
]);
|
|
149
|
+
const trimmed = tag.trim();
|
|
150
|
+
if (trimmed.startsWith("</")) {
|
|
151
|
+
const name = trimmed.slice(2, -1).trim();
|
|
152
|
+
return `</${name}>`;
|
|
153
|
+
}
|
|
154
|
+
const selfClosing = trimmed.endsWith("/>");
|
|
155
|
+
const body = trimmed.slice(1, trimmed.length - (selfClosing ? 2 : 1)).trim();
|
|
156
|
+
let cursor = 0;
|
|
157
|
+
let tagName = "";
|
|
158
|
+
while (cursor < body.length && !/\s/.test(body[cursor])) {
|
|
159
|
+
tagName += body[cursor];
|
|
160
|
+
cursor += 1;
|
|
161
|
+
}
|
|
162
|
+
const attrs = [];
|
|
163
|
+
const seenAttrs = new Set();
|
|
164
|
+
while (cursor < body.length) {
|
|
165
|
+
while (cursor < body.length && /\s/.test(body[cursor])) {
|
|
166
|
+
cursor += 1;
|
|
167
|
+
}
|
|
168
|
+
if (cursor >= body.length) {
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
let name = "";
|
|
172
|
+
while (cursor < body.length && !/[\s=]/.test(body[cursor])) {
|
|
173
|
+
name += body[cursor];
|
|
174
|
+
cursor += 1;
|
|
175
|
+
}
|
|
176
|
+
while (cursor < body.length && /\s/.test(body[cursor])) {
|
|
177
|
+
cursor += 1;
|
|
178
|
+
}
|
|
179
|
+
let value = null;
|
|
180
|
+
if (body[cursor] === "=") {
|
|
181
|
+
cursor += 1;
|
|
182
|
+
while (cursor < body.length && /\s/.test(body[cursor])) {
|
|
183
|
+
cursor += 1;
|
|
184
|
+
}
|
|
185
|
+
if (body[cursor] === '"' || body[cursor] === "'") {
|
|
186
|
+
const quote = body[cursor];
|
|
187
|
+
cursor += 1;
|
|
188
|
+
let rawValue = "";
|
|
189
|
+
while (cursor < body.length) {
|
|
190
|
+
const templateBlock = readTemplateBlock(body, cursor);
|
|
191
|
+
if (templateBlock) {
|
|
192
|
+
rawValue += templateBlock.block;
|
|
193
|
+
cursor = templateBlock.nextIndex;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (body[cursor] === quote) {
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
rawValue += body[cursor];
|
|
200
|
+
cursor += 1;
|
|
201
|
+
}
|
|
202
|
+
cursor += 1;
|
|
203
|
+
value = normalizeAttributeValue(name, rawValue);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
let rawValue = "";
|
|
207
|
+
while (cursor < body.length && !/\s/.test(body[cursor])) {
|
|
208
|
+
rawValue += body[cursor];
|
|
209
|
+
cursor += 1;
|
|
210
|
+
}
|
|
211
|
+
value = normalizeAttributeValue(name, rawValue);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (seenAttrs.has(name)) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
seenAttrs.add(name);
|
|
218
|
+
attrs.push(value === null ? name : `${name}="${value}"`);
|
|
219
|
+
}
|
|
220
|
+
const joinedAttrs = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
|
|
221
|
+
const forceSelfClosing = selfClosing || voidTags.has(tagName);
|
|
222
|
+
return forceSelfClosing
|
|
223
|
+
? `<${tagName}${joinedAttrs}/>`
|
|
224
|
+
: `<${tagName}${joinedAttrs}>`;
|
|
225
|
+
}
|
|
226
|
+
export function verifyAskamaFixtures(options = {}) {
|
|
227
|
+
const fixtureList = options.fixtures ?? [];
|
|
228
|
+
const logger = options.logger ?? console;
|
|
229
|
+
let hasFailure = false;
|
|
230
|
+
let hasFormatOnly = false;
|
|
231
|
+
const result = {
|
|
232
|
+
ok: [],
|
|
233
|
+
formatOnly: [],
|
|
234
|
+
failures: [],
|
|
235
|
+
missing: [],
|
|
236
|
+
};
|
|
237
|
+
for (const fixture of fixtureList) {
|
|
238
|
+
if (!existsSync(fixture.generated)) {
|
|
239
|
+
hasFailure = true;
|
|
240
|
+
result.missing.push(fixture.name);
|
|
241
|
+
logger.error(`missing generated: ${fixture.name}`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const sourceRaw = readFileSync(fixture.source, "utf8").replace(/\r\n/g, "\n").trim();
|
|
245
|
+
const generatedRaw = readFileSync(fixture.generated, "utf8").replace(/\r\n/g, "\n").trim();
|
|
246
|
+
const source = normalizeAskama(sourceRaw);
|
|
247
|
+
const generated = normalizeAskama(generatedRaw);
|
|
248
|
+
if (sourceRaw === generatedRaw) {
|
|
249
|
+
result.ok.push(fixture.name);
|
|
250
|
+
logger.log(`ok: ${fixture.name}`);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (source === generated) {
|
|
254
|
+
hasFormatOnly = true;
|
|
255
|
+
result.formatOnly.push(fixture.name);
|
|
256
|
+
logger.log(`format-only: ${fixture.name}`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
hasFailure = true;
|
|
260
|
+
result.failures.push(fixture.name);
|
|
261
|
+
logger.error(`functional mismatch: ${fixture.name}`);
|
|
262
|
+
}
|
|
263
|
+
if (hasFailure) {
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
}
|
|
266
|
+
if (!hasFailure && hasFormatOnly) {
|
|
267
|
+
logger.log("no functional mismatches; only formatting/serialization differences remain");
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { buildProject } from "./core/build.js";
|
|
2
|
+
export type { BuildProjectOptions, BuildProjectResult } from "./core/build.js";
|
|
3
|
+
export { parseTemplateFile } from "./core/parser.js";
|
|
4
|
+
export { verifyAskamaFixtures, normalizeAskama } from "./core/verify.js";
|
|
5
|
+
export * from "./api/index.js";
|
|
6
|
+
export type { BuildTemplate, ComponentRegistration, ComponentRegistry, OutputTarget, ParsedTemplate, TargetOutputFile, TemplateImport, TemplateProp, } from "./core/types.js";
|
|
7
|
+
export * from "./core/ast.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Node } from "../core/ast.js";
|
|
2
|
+
import type { BuildTemplate } from "../core/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Emits portable AST nodes into Askama template source.
|
|
5
|
+
*/
|
|
6
|
+
export declare function emitAskama(node: Node, indent?: number, componentRegistry?: BuildTemplate["componentRegistry"]): string;
|
|
7
|
+
/**
|
|
8
|
+
* Emits the Rust `Template` wrapper for Askama consumption.
|
|
9
|
+
*/
|
|
10
|
+
export declare function emitAskamaComponent(template: BuildTemplate): string;
|
|
11
|
+
export declare function emitAskamaPartial(template: BuildTemplate): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { emitAskamaExpr, escapeDoubleQuotes, toCamelCase, toRustType, wrapHtmlAttribute, } from "./shared.js";
|
|
2
|
+
/**
|
|
3
|
+
* Emits an HTML attribute for Askama output.
|
|
4
|
+
*/
|
|
5
|
+
function emitAskamaAttr(name, value) {
|
|
6
|
+
switch (value.kind) {
|
|
7
|
+
case "text":
|
|
8
|
+
return `${name}=${wrapHtmlAttribute(value.value)}`;
|
|
9
|
+
case "expr":
|
|
10
|
+
return `${name}=${wrapHtmlAttribute(`{{ ${emitAskamaExpr(value.expr)} }}`)}`;
|
|
11
|
+
case "classList": {
|
|
12
|
+
const parts = [];
|
|
13
|
+
for (const item of value.items) {
|
|
14
|
+
if (item.kind === "static") {
|
|
15
|
+
parts.push(item.value);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
parts.push(`{% if ${emitAskamaExpr(item.test)} %}${item.value}{% endif %}`);
|
|
19
|
+
}
|
|
20
|
+
return `${name}=${wrapHtmlAttribute(parts.join(" ").trim())}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Emits portable AST nodes into Askama template source.
|
|
26
|
+
*/
|
|
27
|
+
export function emitAskama(node, indent = 0, componentRegistry = {}) {
|
|
28
|
+
const pad = " ".repeat(indent);
|
|
29
|
+
switch (node.kind) {
|
|
30
|
+
case "text":
|
|
31
|
+
return `${pad}${node.value}`;
|
|
32
|
+
case "slot":
|
|
33
|
+
return `${pad}{{ ${node.name}|safe }}`;
|
|
34
|
+
case "print":
|
|
35
|
+
return `${pad}{{ ${emitAskamaExpr(node.expr)}${node.safe ? "|safe" : ""} }}`;
|
|
36
|
+
case "if": {
|
|
37
|
+
const thenPart = node.then
|
|
38
|
+
.map((child) => emitAskama(child, indent + 1, componentRegistry))
|
|
39
|
+
.join("\n");
|
|
40
|
+
const elsePart = (node.else ?? [])
|
|
41
|
+
.map((child) => emitAskama(child, indent + 1, componentRegistry))
|
|
42
|
+
.join("\n");
|
|
43
|
+
if (elsePart) {
|
|
44
|
+
return `${pad}{% if ${emitAskamaExpr(node.test)} %}\n${thenPart}\n${pad}{% else %}\n${elsePart}\n${pad}{% endif %}`;
|
|
45
|
+
}
|
|
46
|
+
return `${pad}{% if ${emitAskamaExpr(node.test)} %}\n${thenPart}\n${pad}{% endif %}`;
|
|
47
|
+
}
|
|
48
|
+
case "for": {
|
|
49
|
+
const body = node.children
|
|
50
|
+
.map((child) => emitAskama(child, indent + 1, componentRegistry))
|
|
51
|
+
.join("\n");
|
|
52
|
+
return `${pad}{% for ${node.item} in ${emitAskamaExpr(node.each)} %}\n${body}\n${pad}{% endfor %}`;
|
|
53
|
+
}
|
|
54
|
+
case "element": {
|
|
55
|
+
const attrEntries = Object.entries(node.attrs ?? {});
|
|
56
|
+
const attrs = attrEntries.length
|
|
57
|
+
? `\n${attrEntries
|
|
58
|
+
.map(([name, value]) => `${pad} ${emitAskamaAttr(name, value)}`)
|
|
59
|
+
.join("\n")}\n${pad}`
|
|
60
|
+
: "";
|
|
61
|
+
const children = (node.children ?? []).map((child) => emitAskama(child, indent + 1, componentRegistry));
|
|
62
|
+
if (children.length === 0) {
|
|
63
|
+
return `${pad}<${node.tag}${attrs}/>`;
|
|
64
|
+
}
|
|
65
|
+
return `${pad}<${node.tag}${attrs}>\n${children.join("\n")}\n${pad}</${node.tag}>`;
|
|
66
|
+
}
|
|
67
|
+
case "component": {
|
|
68
|
+
const registration = componentRegistry[node.name];
|
|
69
|
+
if (!registration) {
|
|
70
|
+
throw new Error(`Missing Askama component registration for ${node.name}`);
|
|
71
|
+
}
|
|
72
|
+
const lines = [];
|
|
73
|
+
for (const [propName, propValue] of Object.entries(node.props ?? {})) {
|
|
74
|
+
if (propValue.kind === "text") {
|
|
75
|
+
lines.push(`${pad}{% let ${propName} = "${escapeDoubleQuotes(propValue.value)}" %}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (propValue.kind === "expr") {
|
|
79
|
+
lines.push(`${pad}{% let ${propName} = ${emitAskamaExpr(propValue.expr)} %}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Component prop ${propName} on ${node.name} must be text or expr for Askama include output`);
|
|
83
|
+
}
|
|
84
|
+
if ((node.children ?? []).length > 0) {
|
|
85
|
+
lines.push(`${pad}{% let children %}`);
|
|
86
|
+
lines.push(...(node.children ?? []).map((child) => emitAskama(child, indent + 1, componentRegistry)));
|
|
87
|
+
lines.push(`${pad}{% endlet %}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push(`${pad}{% include "${registration.include}" %}`);
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Emits the Rust `Template` wrapper for Askama consumption.
|
|
96
|
+
*/
|
|
97
|
+
export function emitAskamaComponent(template) {
|
|
98
|
+
const structName = `${template.name}Template`;
|
|
99
|
+
const props = template.props ?? [];
|
|
100
|
+
const needsLifetime = props.some((prop) => toRustType(prop.type).includes("'a"));
|
|
101
|
+
const lifetime = needsLifetime ? "<'a>" : "";
|
|
102
|
+
const fields = props
|
|
103
|
+
.map((prop) => ` pub ${toCamelCase(prop.name)}: ${toRustType(prop.type)},`)
|
|
104
|
+
.join("\n");
|
|
105
|
+
const source = emitAskama(template.template, 0, template.componentRegistry ?? {}).replace(/#"/g, '#\\"');
|
|
106
|
+
return `use askama::Template;
|
|
107
|
+
|
|
108
|
+
#[derive(Template)]
|
|
109
|
+
#[template(
|
|
110
|
+
ext = "html",
|
|
111
|
+
source = r#"
|
|
112
|
+
${source}
|
|
113
|
+
"#
|
|
114
|
+
)]
|
|
115
|
+
pub struct ${structName}${lifetime} {
|
|
116
|
+
${fields}
|
|
117
|
+
}
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
export function emitAskamaPartial(template) {
|
|
121
|
+
return `${emitAskama(template.template, 0, template.componentRegistry ?? {})}\n`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { emitAskamaComponent, emitAskamaPartial } from "./askama.js";
|
|
2
|
+
import { emitReactComponent } from "./react.js";
|
|
3
|
+
import { emitStaticJsxComponent } from "./static-jsx.js";
|
|
4
|
+
/**
|
|
5
|
+
* Central registry for output formats. The build driver only depends on this interface.
|
|
6
|
+
*/
|
|
7
|
+
export const outputTargets = [
|
|
8
|
+
{
|
|
9
|
+
id: "react",
|
|
10
|
+
outputSubdir: "react",
|
|
11
|
+
extension: ".tsx",
|
|
12
|
+
emitFiles(template) {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
fileName: `${template.fileName}.tsx`,
|
|
16
|
+
content: `${emitReactComponent(template)}\n`,
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "jsx-static",
|
|
23
|
+
outputSubdir: "jsx-static",
|
|
24
|
+
extension: ".tsx",
|
|
25
|
+
emitFiles(template) {
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
fileName: `${template.fileName}.tsx`,
|
|
29
|
+
content: `${emitStaticJsxComponent(template)}\n`,
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "askama",
|
|
36
|
+
outputSubdir: "askama",
|
|
37
|
+
extension: ".rs",
|
|
38
|
+
emitFiles(template) {
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
fileName: `${template.fileName}.rs`,
|
|
42
|
+
content: `${emitAskamaComponent(template)}\n`,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "askama-includes",
|
|
49
|
+
outputSubdir: "askama/includes",
|
|
50
|
+
extension: ".html",
|
|
51
|
+
emitFiles(template) {
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
fileName: `${template.fileName}.html`,
|
|
55
|
+
content: emitAskamaPartial(template),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { buildTsxComponentSource, emitTsxExpr, emitTsxNode } from "./shared.js";
|
|
2
|
+
/**
|
|
3
|
+
* Emits attributes for React-compatible TSX output.
|
|
4
|
+
*/
|
|
5
|
+
function emitReactAttr(name, value) {
|
|
6
|
+
const attrName = name === "class" ? "className" : name;
|
|
7
|
+
switch (value.kind) {
|
|
8
|
+
case "text":
|
|
9
|
+
return `${attrName}=${JSON.stringify(value.value)}`;
|
|
10
|
+
case "expr":
|
|
11
|
+
return `${attrName}={${emitTsxExpr(value.expr)}}`;
|
|
12
|
+
case "classList": {
|
|
13
|
+
const items = value.items.map((item) => {
|
|
14
|
+
if (item.kind === "static") {
|
|
15
|
+
return JSON.stringify(item.value);
|
|
16
|
+
}
|
|
17
|
+
return `${emitTsxExpr(item.test)} ? ${JSON.stringify(item.value)} : null`;
|
|
18
|
+
});
|
|
19
|
+
return `${attrName}={[${items.join(", ")}].filter(Boolean).join(" ")}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function emitReact(node, indent = 0) {
|
|
24
|
+
return emitTsxNode(node, emitReactAttr, indent);
|
|
25
|
+
}
|
|
26
|
+
export function emitReactComponent(template) {
|
|
27
|
+
return buildTsxComponentSource(template, emitReact(template.template, 2));
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AttrValue, Expr, Node } from "../core/ast.js";
|
|
2
|
+
import type { BuildTemplate } from "../core/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Escapes a string for insertion into Rust string literals used by Askama wrappers.
|
|
5
|
+
*/
|
|
6
|
+
declare function escapeDoubleQuotes(value: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Chooses a stable quote style for generated HTML attributes so mixed template
|
|
9
|
+
* syntax stays readable and editor highlighting does not break.
|
|
10
|
+
*/
|
|
11
|
+
declare function wrapHtmlAttribute(value: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Emits a portable expression into TSX/React-family output.
|
|
14
|
+
*/
|
|
15
|
+
export declare function emitTsxExpr(expr: Expr): string;
|
|
16
|
+
/**
|
|
17
|
+
* Emits a portable expression into Askama syntax.
|
|
18
|
+
*/
|
|
19
|
+
export declare function emitAskamaExpr(expr: Expr): string;
|
|
20
|
+
export type TsxAttrEmitter = (name: string, value: AttrValue) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Shared JSX/TSX tree emitter used by both React and static JSX targets.
|
|
23
|
+
*/
|
|
24
|
+
export declare function emitTsxNode(node: Node, emitAttr: TsxAttrEmitter, indent?: number): string;
|
|
25
|
+
export declare function toCamelCase(value: string): string;
|
|
26
|
+
export declare function toRustType(type: string): string;
|
|
27
|
+
export declare function toTsType(type: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Builds import lines for nested template components in TSX-family targets.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildTsxImportLines(template: BuildTemplate): string;
|
|
32
|
+
/**
|
|
33
|
+
* Wraps an emitted TSX template body in a component module.
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildTsxComponentSource(template: BuildTemplate, body: string): string;
|
|
36
|
+
export { escapeDoubleQuotes, wrapHtmlAttribute };
|