@kuratchi/js 0.0.1
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 +29 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +78 -0
- package/dist/compiler/index.d.ts +34 -0
- package/dist/compiler/index.js +2200 -0
- package/dist/compiler/parser.d.ts +40 -0
- package/dist/compiler/parser.js +534 -0
- package/dist/compiler/template.d.ts +30 -0
- package/dist/compiler/template.js +625 -0
- package/dist/create.d.ts +7 -0
- package/dist/create.js +876 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +15 -0
- package/dist/runtime/app.d.ts +12 -0
- package/dist/runtime/app.js +118 -0
- package/dist/runtime/config.d.ts +5 -0
- package/dist/runtime/config.js +6 -0
- package/dist/runtime/containers.d.ts +61 -0
- package/dist/runtime/containers.js +127 -0
- package/dist/runtime/context.d.ts +54 -0
- package/dist/runtime/context.js +134 -0
- package/dist/runtime/do.d.ts +81 -0
- package/dist/runtime/do.js +123 -0
- package/dist/runtime/index.d.ts +8 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/router.d.ts +29 -0
- package/dist/runtime/router.js +73 -0
- package/dist/runtime/types.d.ts +207 -0
- package/dist/runtime/types.js +4 -0
- package/package.json +50 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template compiler — native JS flow control in HTML.
|
|
3
|
+
*
|
|
4
|
+
* Syntax:
|
|
5
|
+
* {expression} → escaped output
|
|
6
|
+
* {=html expression} → raw HTML output (unescaped)
|
|
7
|
+
* for (const x of arr) { → JS for loop (inline in HTML)
|
|
8
|
+
* <li>{x.name}</li>
|
|
9
|
+
* }
|
|
10
|
+
* if (condition) { → JS if block
|
|
11
|
+
* <p>yes</p>
|
|
12
|
+
* } else {
|
|
13
|
+
* <p>no</p>
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* The compiler scans line-by-line:
|
|
17
|
+
* - Lines that are pure JS control flow (for/if/else/}) → emitted as JS
|
|
18
|
+
* - Everything else → emitted as HTML string with {expr} interpolation
|
|
19
|
+
*/
|
|
20
|
+
// Patterns that identify a line as JS control flow (trimmed)
|
|
21
|
+
const JS_CONTROL_PATTERNS = [
|
|
22
|
+
/^\s*for\s*\(/, // for (...)
|
|
23
|
+
/^\s*if\s*\(/, // if (...)
|
|
24
|
+
/^\s*switch\s*\(/, // switch (...)
|
|
25
|
+
/^\s*case\s+.+:\s*$/, // case ...:
|
|
26
|
+
/^\s*default\s*:\s*$/, // default:
|
|
27
|
+
/^\s*break\s*;\s*$/, // break;
|
|
28
|
+
/^\s*continue\s*;\s*$/, // continue;
|
|
29
|
+
/^\s*\}\s*else\s*if\s*\(/, // } else if (...)
|
|
30
|
+
/^\s*\}\s*else\s*\{?\s*$/, // } else { or } else
|
|
31
|
+
/^\s*\}\s*$/, // }
|
|
32
|
+
/^\s*\w[\w.]*\s*(\+\+|--)\s*;\s*$/, // varName++; varName--;
|
|
33
|
+
/^\s*(let|const|var)\s+/, // let x = ...; const y = ...;
|
|
34
|
+
];
|
|
35
|
+
function isJsControlLine(line) {
|
|
36
|
+
return JS_CONTROL_PATTERNS.some(p => p.test(line));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Compile a template string into a JS render function body.
|
|
40
|
+
*
|
|
41
|
+
* The generated code expects `data` in scope (destructured load return)
|
|
42
|
+
* and an `__esc` helper for HTML-escaping.
|
|
43
|
+
*/
|
|
44
|
+
export function compileTemplate(template, componentNames, actionNames, rpcNameMap) {
|
|
45
|
+
const out = ['let __html = "";'];
|
|
46
|
+
const lines = template.split('\n');
|
|
47
|
+
let inStyle = false;
|
|
48
|
+
let inScript = false;
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
// Track <style> blocks — emit CSS as literal, no parsing
|
|
53
|
+
if (trimmed.match(/<style[\s>]/i))
|
|
54
|
+
inStyle = true;
|
|
55
|
+
if (inStyle) {
|
|
56
|
+
out.push(`__html += \`${escapeLiteral(line)}\\n\`;`);
|
|
57
|
+
if (trimmed.match(/<\/style>/i))
|
|
58
|
+
inStyle = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Track <script> blocks — emit client JS as literal, no parsing
|
|
62
|
+
if (trimmed.match(/<script[\s>]/i))
|
|
63
|
+
inScript = true;
|
|
64
|
+
if (inScript) {
|
|
65
|
+
out.push(`__html += \`${escapeLiteral(line)}\\n\`;`);
|
|
66
|
+
if (trimmed.match(/<\/script>/i))
|
|
67
|
+
inScript = false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Skip empty lines
|
|
71
|
+
if (!trimmed) {
|
|
72
|
+
out.push('__html += "\\n";');
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// One-line inline if/else with branch content:
|
|
76
|
+
// if (cond) { text/html } else { text/html }
|
|
77
|
+
// Compile branch content as template output instead of raw JS.
|
|
78
|
+
const inlineIfElse = tryCompileInlineIfElseLine(trimmed, actionNames, rpcNameMap);
|
|
79
|
+
if (inlineIfElse) {
|
|
80
|
+
out.push(...inlineIfElse);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// JS control flow lines → emit as raw JS
|
|
84
|
+
if (isJsControlLine(trimmed)) {
|
|
85
|
+
out.push(trimmed);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Component tags: <StatCard attr="val" attr={expr} /> (PascalCase, explicitly imported)
|
|
89
|
+
if (componentNames && componentNames.size > 0) {
|
|
90
|
+
// Multi-line component tag: if line starts with <PascalCase but doesn't close,
|
|
91
|
+
// join continuation lines until we find the closing > or />
|
|
92
|
+
let joinedTrimmed = trimmed;
|
|
93
|
+
let joinedExtra = 0;
|
|
94
|
+
const multiLineStart = trimmed.match(/^<([A-Z]\w*)(?:\s|$)/);
|
|
95
|
+
if (multiLineStart && !trimmed.match(/>/) && componentNames.has(multiLineStart[1])) {
|
|
96
|
+
// Keep joining lines until we find > or />
|
|
97
|
+
let j = i + 1;
|
|
98
|
+
while (j < lines.length) {
|
|
99
|
+
const nextTrimmed = lines[j].trim();
|
|
100
|
+
joinedTrimmed += ' ' + nextTrimmed;
|
|
101
|
+
joinedExtra++;
|
|
102
|
+
if (nextTrimmed.match(/\/?>$/))
|
|
103
|
+
break;
|
|
104
|
+
j++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Self-closing: <Card attr="x" />
|
|
108
|
+
const componentLine = tryCompileComponentTag(joinedTrimmed, componentNames, actionNames, rpcNameMap);
|
|
109
|
+
if (componentLine) {
|
|
110
|
+
i += joinedExtra;
|
|
111
|
+
out.push(componentLine);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Opening tag with children: <Card attr="x">
|
|
115
|
+
const openResult = tryMatchComponentOpen(joinedTrimmed, componentNames, actionNames);
|
|
116
|
+
if (openResult) {
|
|
117
|
+
i += joinedExtra;
|
|
118
|
+
// Collect lines until the matching </TagName>
|
|
119
|
+
const childLines = [];
|
|
120
|
+
let depth = 1;
|
|
121
|
+
i++;
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const childTrimmed = lines[i].trim();
|
|
124
|
+
// Check for nested opening of the same component
|
|
125
|
+
if (childTrimmed.match(new RegExp(`^<${openResult.tagName}[\\s>]`)) && !childTrimmed.match(/\/>/)) {
|
|
126
|
+
depth++;
|
|
127
|
+
}
|
|
128
|
+
if (childTrimmed === `</${openResult.tagName}>`) {
|
|
129
|
+
depth--;
|
|
130
|
+
if (depth === 0)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
childLines.push(lines[i]);
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
// Compile children into a sub-render block
|
|
137
|
+
const childTemplate = childLines.join('\n');
|
|
138
|
+
const childBody = compileTemplate(childTemplate, componentNames, actionNames, rpcNameMap);
|
|
139
|
+
// Wrap in an IIFE that returns the children HTML
|
|
140
|
+
const childrenExpr = `(function() { ${childBody}; return __html; })()`;
|
|
141
|
+
out.push(`__html += ${openResult.funcName}({ ${openResult.propsStr}${openResult.propsStr ? ', ' : ''}children: ${childrenExpr} }, __esc);`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// HTML line → compile {expr} interpolations
|
|
146
|
+
out.push(compileHtmlLine(line, actionNames, rpcNameMap));
|
|
147
|
+
}
|
|
148
|
+
return out.join('\n');
|
|
149
|
+
}
|
|
150
|
+
function findMatching(src, openPos, openChar, closeChar) {
|
|
151
|
+
let depth = 0;
|
|
152
|
+
let quote = null;
|
|
153
|
+
let escaped = false;
|
|
154
|
+
for (let i = openPos; i < src.length; i++) {
|
|
155
|
+
const ch = src[i];
|
|
156
|
+
if (quote) {
|
|
157
|
+
if (escaped) {
|
|
158
|
+
escaped = false;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (ch === '\\') {
|
|
162
|
+
escaped = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ch === quote)
|
|
166
|
+
quote = null;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
170
|
+
quote = ch;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (ch === openChar)
|
|
174
|
+
depth++;
|
|
175
|
+
if (ch === closeChar) {
|
|
176
|
+
depth--;
|
|
177
|
+
if (depth === 0)
|
|
178
|
+
return i;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return -1;
|
|
182
|
+
}
|
|
183
|
+
function compileInlineBranchContent(content, actionNames, rpcNameMap) {
|
|
184
|
+
const c = content.trim();
|
|
185
|
+
if (!c)
|
|
186
|
+
return [];
|
|
187
|
+
const compiled = compileHtmlLine(c, actionNames, rpcNameMap);
|
|
188
|
+
return [compiled.replace(/\\n`;/, '`;')];
|
|
189
|
+
}
|
|
190
|
+
function tryCompileInlineIfElseLine(line, actionNames, rpcNameMap) {
|
|
191
|
+
if (!line.startsWith('if'))
|
|
192
|
+
return null;
|
|
193
|
+
const ifMatch = line.match(/^if\s*\(/);
|
|
194
|
+
if (!ifMatch)
|
|
195
|
+
return null;
|
|
196
|
+
const openParen = line.indexOf('(');
|
|
197
|
+
const closeParen = findMatching(line, openParen, '(', ')');
|
|
198
|
+
if (openParen === -1 || closeParen === -1)
|
|
199
|
+
return null;
|
|
200
|
+
const condition = line.slice(openParen + 1, closeParen).trim();
|
|
201
|
+
if (!condition)
|
|
202
|
+
return null;
|
|
203
|
+
const firstOpenBrace = line.indexOf('{', closeParen + 1);
|
|
204
|
+
if (firstOpenBrace === -1)
|
|
205
|
+
return null;
|
|
206
|
+
const firstCloseBrace = findMatching(line, firstOpenBrace, '{', '}');
|
|
207
|
+
if (firstCloseBrace === -1)
|
|
208
|
+
return null;
|
|
209
|
+
const afterFirst = line.slice(firstCloseBrace + 1);
|
|
210
|
+
let pos = 0;
|
|
211
|
+
while (pos < afterFirst.length && /\s/.test(afterFirst[pos]))
|
|
212
|
+
pos++;
|
|
213
|
+
if (!afterFirst.slice(pos).startsWith('else'))
|
|
214
|
+
return null;
|
|
215
|
+
pos += 'else'.length;
|
|
216
|
+
while (pos < afterFirst.length && /\s/.test(afterFirst[pos]))
|
|
217
|
+
pos++;
|
|
218
|
+
if (afterFirst[pos] !== '{')
|
|
219
|
+
return null;
|
|
220
|
+
const elseOpen = firstCloseBrace + 1 + pos;
|
|
221
|
+
const elseClose = findMatching(line, elseOpen, '{', '}');
|
|
222
|
+
if (elseClose === -1)
|
|
223
|
+
return null;
|
|
224
|
+
const trailing = line.slice(elseClose + 1).trim();
|
|
225
|
+
if (trailing.length > 0)
|
|
226
|
+
return null;
|
|
227
|
+
const thenContent = line.slice(firstOpenBrace + 1, firstCloseBrace);
|
|
228
|
+
const elseContent = line.slice(elseOpen + 1, elseClose);
|
|
229
|
+
const out = [];
|
|
230
|
+
out.push(`if (${condition}) {`);
|
|
231
|
+
out.push(...compileInlineBranchContent(thenContent, actionNames, rpcNameMap));
|
|
232
|
+
out.push(`} else {`);
|
|
233
|
+
out.push(...compileInlineBranchContent(elseContent, actionNames, rpcNameMap));
|
|
234
|
+
out.push(`}`);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/** Derive the __c_ function name from a fileName (may include package prefix like @kuratchi/ui:badge) */
|
|
238
|
+
function componentFuncName(fileName) {
|
|
239
|
+
const name = fileName.includes(':') ? fileName.split(':').pop() : fileName;
|
|
240
|
+
return '__c_' + name.replace(/[\/\-]/g, '_');
|
|
241
|
+
}
|
|
242
|
+
/** Parse component attributes string into a JS object literal fragment */
|
|
243
|
+
function parseComponentAttrs(attrsStr, actionNames) {
|
|
244
|
+
const props = [];
|
|
245
|
+
let i = 0;
|
|
246
|
+
while (i < attrsStr.length) {
|
|
247
|
+
while (i < attrsStr.length && /\s/.test(attrsStr[i]))
|
|
248
|
+
i++;
|
|
249
|
+
if (i >= attrsStr.length)
|
|
250
|
+
break;
|
|
251
|
+
const nameStart = i;
|
|
252
|
+
while (i < attrsStr.length && /[\w-]/.test(attrsStr[i]))
|
|
253
|
+
i++;
|
|
254
|
+
if (i === nameStart) {
|
|
255
|
+
i++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const key = attrsStr.slice(nameStart, i).replace(/-/g, '_'); // kebab-case -> snake_case for JS
|
|
259
|
+
while (i < attrsStr.length && /\s/.test(attrsStr[i]))
|
|
260
|
+
i++;
|
|
261
|
+
if (attrsStr[i] !== '=') {
|
|
262
|
+
props.push(`${key}: true`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
i++; // skip '='
|
|
266
|
+
while (i < attrsStr.length && /\s/.test(attrsStr[i]))
|
|
267
|
+
i++;
|
|
268
|
+
if (i >= attrsStr.length) {
|
|
269
|
+
props.push(`${key}: true`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
if (attrsStr[i] === '"' || attrsStr[i] === "'") {
|
|
273
|
+
const quote = attrsStr[i];
|
|
274
|
+
i++;
|
|
275
|
+
const valueStart = i;
|
|
276
|
+
while (i < attrsStr.length) {
|
|
277
|
+
if (attrsStr[i] === '\\') {
|
|
278
|
+
i += 2;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (attrsStr[i] === quote)
|
|
282
|
+
break;
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
const literal = attrsStr.slice(valueStart, i);
|
|
286
|
+
props.push(`${key}: ${JSON.stringify(literal)}`);
|
|
287
|
+
if (i < attrsStr.length && attrsStr[i] === quote)
|
|
288
|
+
i++;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (attrsStr[i] === '{') {
|
|
292
|
+
const closeIdx = findClosingBrace(attrsStr, i);
|
|
293
|
+
const expr = attrsStr.slice(i + 1, closeIdx).trim();
|
|
294
|
+
// If this expression is a known server action function, pass its name as a string
|
|
295
|
+
// literal instead of a variable reference — action functions are not in scope in
|
|
296
|
+
// the render function, but their string names are what the runtime dispatches on.
|
|
297
|
+
const isAction = actionNames?.has(expr);
|
|
298
|
+
props.push(isAction ? `${key}: ${JSON.stringify(expr)}` : `${key}: (${expr})`);
|
|
299
|
+
i = closeIdx + 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const valueStart = i;
|
|
303
|
+
while (i < attrsStr.length && !/\s/.test(attrsStr[i]))
|
|
304
|
+
i++;
|
|
305
|
+
const bare = attrsStr.slice(valueStart, i);
|
|
306
|
+
props.push(`${key}: ${JSON.stringify(bare)}`);
|
|
307
|
+
}
|
|
308
|
+
return props.join(', ');
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Try to compile a self-closing component tag into a function call.
|
|
312
|
+
* Returns null if the line is not a component tag.
|
|
313
|
+
*
|
|
314
|
+
* Matches: <StatCard attr="literal" attr={expr} />
|
|
315
|
+
* Generates: __html += __c_stat_card({ attr: "literal", attr: (expr) }, __esc);
|
|
316
|
+
*/
|
|
317
|
+
function tryCompileComponentTag(line, componentNames, actionNames, rpcNameMap) {
|
|
318
|
+
// Match self-closing tags: <TagName ...attrs... />
|
|
319
|
+
const selfCloseMatch = line.match(/^<([A-Z]\w*)\s*(.*?)\s*\/>\s*$/);
|
|
320
|
+
// Match open+close on same line with no content: <TagName ...attrs...></TagName>
|
|
321
|
+
const emptyPairMatch = !selfCloseMatch ? line.match(/^<([A-Z]\w*)\s*(.*?)\s*><\/\1>\s*$/) : null;
|
|
322
|
+
// Match open+close on same line with inline content: <TagName ...attrs...>content</TagName>
|
|
323
|
+
const inlinePairMatch = !selfCloseMatch && !emptyPairMatch ? line.match(/^<([A-Z]\w*)\s*(.*?)>([\s\S]+)<\/\1>\s*$/) : null;
|
|
324
|
+
const match = selfCloseMatch || emptyPairMatch;
|
|
325
|
+
if (match) {
|
|
326
|
+
const tagName = match[1];
|
|
327
|
+
const fileName = componentNames.get(tagName);
|
|
328
|
+
if (!fileName)
|
|
329
|
+
return null;
|
|
330
|
+
const funcName = componentFuncName(fileName);
|
|
331
|
+
const propsStr = parseComponentAttrs(match[2].trim(), actionNames);
|
|
332
|
+
return `__html += ${funcName}({ ${propsStr} }, __esc);`;
|
|
333
|
+
}
|
|
334
|
+
if (inlinePairMatch) {
|
|
335
|
+
const tagName = inlinePairMatch[1];
|
|
336
|
+
const fileName = componentNames.get(tagName);
|
|
337
|
+
if (!fileName)
|
|
338
|
+
return null;
|
|
339
|
+
const funcName = componentFuncName(fileName);
|
|
340
|
+
const propsStr = parseComponentAttrs(inlinePairMatch[2].trim(), actionNames);
|
|
341
|
+
const innerContent = inlinePairMatch[3];
|
|
342
|
+
// Compile the inline content as a mini-template to handle {expr} interpolation
|
|
343
|
+
const childBody = compileTemplate(innerContent, componentNames, actionNames, rpcNameMap);
|
|
344
|
+
const childrenExpr = `(function() { ${childBody}; return __html; })()`;
|
|
345
|
+
return `__html += ${funcName}({ ${propsStr}${propsStr ? ', ' : ''}children: ${childrenExpr} }, __esc);`;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Try to match a component opening tag (not self-closing).
|
|
351
|
+
* Returns parsed info if matched, null otherwise.
|
|
352
|
+
*
|
|
353
|
+
* Matches: <Card attr="x" attr={expr}>
|
|
354
|
+
*/
|
|
355
|
+
function tryMatchComponentOpen(line, componentNames, actionNames) {
|
|
356
|
+
const match = line.match(/^<([A-Z]\w*)(\s[^>]*)?>\s*$/);
|
|
357
|
+
if (!match)
|
|
358
|
+
return null;
|
|
359
|
+
const tagName = match[1];
|
|
360
|
+
const fileName = componentNames.get(tagName);
|
|
361
|
+
if (!fileName)
|
|
362
|
+
return null;
|
|
363
|
+
const funcName = componentFuncName(fileName);
|
|
364
|
+
const propsStr = parseComponentAttrs((match[2] || '').trim(), actionNames);
|
|
365
|
+
return { tagName, funcName, propsStr };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Expand shorthand attribute syntax before main compilation:
|
|
369
|
+
* style={color} → style="color: {color}"
|
|
370
|
+
* <div {id}> → <div id="{id}">
|
|
371
|
+
*/
|
|
372
|
+
function expandShorthands(line) {
|
|
373
|
+
// style={prop} → style="prop: {prop}" (CSS property shorthand)
|
|
374
|
+
line = line.replace(/\bstyle=\{(\w+)\}/g, (_match, prop) => {
|
|
375
|
+
return `style="${prop}: {${prop}}"`;
|
|
376
|
+
});
|
|
377
|
+
// Bare {ident} inside a tag → attr="{ident}"
|
|
378
|
+
// Only match simple identifiers in attribute position (after < or after a space inside a tag)
|
|
379
|
+
line = line.replace(/(<\w[\w-]*\s(?:[^>]*?\s)?)\{(\w+)\}(?=[\s/>])/g, (_match, before, ident) => {
|
|
380
|
+
return `${before}${ident}="{${ident}}"`;
|
|
381
|
+
});
|
|
382
|
+
return line;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Compile a single HTML line, replacing {expr} with escaped output
|
|
386
|
+
* and {=html expr} with raw output. Handles attribute values like value={x}.
|
|
387
|
+
*/
|
|
388
|
+
function compileHtmlLine(line, actionNames, rpcNameMap) {
|
|
389
|
+
// Expand shorthand syntax before main compilation
|
|
390
|
+
line = expandShorthands(line);
|
|
391
|
+
let result = '';
|
|
392
|
+
let pos = 0;
|
|
393
|
+
let hasExpr = false;
|
|
394
|
+
while (pos < line.length) {
|
|
395
|
+
const braceIdx = line.indexOf('{', pos);
|
|
396
|
+
if (braceIdx === -1) {
|
|
397
|
+
// No more braces — rest is literal
|
|
398
|
+
result += escapeLiteral(line.slice(pos));
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
// Literal text before the brace
|
|
402
|
+
if (braceIdx > pos) {
|
|
403
|
+
result += escapeLiteral(line.slice(pos, braceIdx));
|
|
404
|
+
}
|
|
405
|
+
// Find matching closing brace
|
|
406
|
+
const closeIdx = findClosingBrace(line, braceIdx);
|
|
407
|
+
const inner = line.slice(braceIdx + 1, closeIdx).trim();
|
|
408
|
+
// Raw HTML: {=html expr}
|
|
409
|
+
if (inner.startsWith('=html ')) {
|
|
410
|
+
const expr = inner.slice(6).trim();
|
|
411
|
+
result += `\${${expr}}`;
|
|
412
|
+
hasExpr = true;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// Check if this is an attribute value: attr={expr}
|
|
416
|
+
const charBefore = braceIdx > 0 ? line[braceIdx - 1] : '';
|
|
417
|
+
if (charBefore === '=') {
|
|
418
|
+
// Check what attribute this is for
|
|
419
|
+
// Look backwards from braceIdx to find the attribute name
|
|
420
|
+
const beforeBrace = line.slice(0, braceIdx);
|
|
421
|
+
const attrMatch = beforeBrace.match(/([\w-]+)=$/);
|
|
422
|
+
const attrName = attrMatch ? attrMatch[1] : '';
|
|
423
|
+
if (attrName === 'data-dialog-data') {
|
|
424
|
+
// data-dialog-data={expr} → data-dialog-data="JSON.stringify(expr)" (HTML-escaped)
|
|
425
|
+
result += `"\${__esc(JSON.stringify(${inner}))}"`;
|
|
426
|
+
hasExpr = true;
|
|
427
|
+
pos = closeIdx + 1;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
else if (attrName === 'data-refresh') {
|
|
431
|
+
// data-refresh={fn(args)} or data-refresh={keyExpr}
|
|
432
|
+
// - fn(args): emit refresh function + serialized args
|
|
433
|
+
// - otherwise: emit refresh token string
|
|
434
|
+
const callMatch = inner.match(/^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/);
|
|
435
|
+
result = result.replace(new RegExp(`\\s*${attrName}=$`), '');
|
|
436
|
+
if (callMatch) {
|
|
437
|
+
const fnName = callMatch[1];
|
|
438
|
+
const rpcName = rpcNameMap?.get(fnName) || fnName;
|
|
439
|
+
const argsExpr = (callMatch[2] || '').trim();
|
|
440
|
+
result += ` data-refresh="${rpcName}"`;
|
|
441
|
+
if (argsExpr) {
|
|
442
|
+
result += ` data-refresh-args="\${__esc(JSON.stringify([${argsExpr}]))}"`;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
result += ` data-refresh="\${__esc(${inner})}"`;
|
|
447
|
+
}
|
|
448
|
+
hasExpr = true;
|
|
449
|
+
pos = closeIdx + 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
else if (attrName === 'data-post' || attrName === 'data-put' || attrName === 'data-patch' || attrName === 'data-delete') {
|
|
453
|
+
// data-post={fn(args)} etc — action-style declarative calls
|
|
454
|
+
const method = attrName.slice('data-'.length).toUpperCase();
|
|
455
|
+
const callMatch = inner.match(/^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/);
|
|
456
|
+
result = result.replace(new RegExp(`\\s*${attrName}=$`), '');
|
|
457
|
+
if (callMatch && actionNames?.has(callMatch[1])) {
|
|
458
|
+
const fnName = callMatch[1];
|
|
459
|
+
const argsExpr = (callMatch[2] || '').trim();
|
|
460
|
+
result += ` data-action="${fnName}" data-action-event="click" data-action-method="${method}"`;
|
|
461
|
+
result += ` data-args="\${__esc(JSON.stringify([${argsExpr}]))}"`;
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
result += ` ${attrName}="${escapeLiteral(inner)}"`;
|
|
465
|
+
}
|
|
466
|
+
hasExpr = true;
|
|
467
|
+
pos = closeIdx + 1;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
else if (attrName === 'data-get' || attrName === 'data-loading' || attrName === 'data-error' || attrName === 'data-empty' || attrName === 'data-success') {
|
|
471
|
+
// data-get={fn(args)} and companion state attrs
|
|
472
|
+
// Emit stable metadata attributes instead of evaluating expression in SSR.
|
|
473
|
+
const callMatch = inner.match(/^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/);
|
|
474
|
+
result = result.replace(new RegExp(`\\s*${attrName}=$`), '');
|
|
475
|
+
if (callMatch) {
|
|
476
|
+
const fnName = callMatch[1];
|
|
477
|
+
const rpcName = rpcNameMap?.get(fnName) || fnName;
|
|
478
|
+
const argsExpr = (callMatch[2] || '').trim();
|
|
479
|
+
result += ` ${attrName}="${rpcName}"`;
|
|
480
|
+
if (argsExpr) {
|
|
481
|
+
result += ` ${attrName}-args="\${__esc(JSON.stringify([${argsExpr}]))}"`;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
// Non-call expression mode (e.g., data-get={someUrl})
|
|
486
|
+
result += ` ${attrName}="\${__esc(${inner})}"`;
|
|
487
|
+
}
|
|
488
|
+
hasExpr = true;
|
|
489
|
+
pos = closeIdx + 1;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
else if (attrName === 'data-poll') {
|
|
493
|
+
// data-poll={fn(args)} → data-poll="fnName" data-poll-args="[serialized]"
|
|
494
|
+
const pollCallMatch = inner.match(/^(\w+)\((.*)\)$/);
|
|
495
|
+
if (pollCallMatch) {
|
|
496
|
+
const fnName = pollCallMatch[1];
|
|
497
|
+
const rpcName = rpcNameMap?.get(fnName) || fnName;
|
|
498
|
+
const argsExpr = pollCallMatch[2].trim();
|
|
499
|
+
// Remove the trailing "data-poll=" we already appended
|
|
500
|
+
result = result.replace(/\s*data-poll=$/, '');
|
|
501
|
+
// Emit data-poll and data-poll-args attributes
|
|
502
|
+
result += ` data-poll="${rpcName}"`;
|
|
503
|
+
if (argsExpr) {
|
|
504
|
+
result += ` data-poll-args="\${__esc(JSON.stringify([${argsExpr}]))}"`;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
hasExpr = true;
|
|
508
|
+
pos = closeIdx + 1;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
else if (attrName === 'action') {
|
|
512
|
+
// action={fnName} -> server action dispatch via hidden _action field.
|
|
513
|
+
const isSimpleIdentifier = /^[A-Za-z_$][\w$]*$/.test(inner);
|
|
514
|
+
// When actionNames is undefined we're inside a shared component template.
|
|
515
|
+
// Allow any simple identifier — the consuming route is responsible for
|
|
516
|
+
// importing the function, and the runtime validates at dispatch time.
|
|
517
|
+
const isServerAction = isSimpleIdentifier && (actionNames === undefined || actionNames.has(inner));
|
|
518
|
+
if (!isServerAction) {
|
|
519
|
+
throw new Error(`Invalid action expression: "${inner}". Use action={myActionFn} for server actions.`);
|
|
520
|
+
}
|
|
521
|
+
// Remove trailing `action=` from output and inject _action hidden field.
|
|
522
|
+
result = result.replace(/\s*action=$/, '');
|
|
523
|
+
pos = closeIdx + 1;
|
|
524
|
+
const tagEnd = line.indexOf('>', pos);
|
|
525
|
+
if (tagEnd !== -1) {
|
|
526
|
+
result += escapeLiteral(line.slice(pos, tagEnd + 1));
|
|
527
|
+
// In a route context, inner is the literal action function name (a string key).
|
|
528
|
+
// In a component context (actionNames === undefined), inner is a prop name whose
|
|
529
|
+
// value at runtime is the action name string — emit it as a dynamic expression.
|
|
530
|
+
const actionValue = actionNames === undefined
|
|
531
|
+
? `\${__esc(${inner})}`
|
|
532
|
+
: inner;
|
|
533
|
+
result += `\\n<input type="hidden" name="_action" value="${actionValue}">`;
|
|
534
|
+
pos = tagEnd + 1;
|
|
535
|
+
}
|
|
536
|
+
hasExpr = true;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
else if (/^on[A-Za-z]+$/.test(attrName)) {
|
|
540
|
+
// onClick/onChange/...={expr} — check if it's a server action or plain client JS
|
|
541
|
+
const eventName = attrName.slice(2).toLowerCase();
|
|
542
|
+
const actionCallMatch = inner.match(/^(\w+)\((.*)\)$/);
|
|
543
|
+
if (actionCallMatch && actionNames?.has(actionCallMatch[1])) {
|
|
544
|
+
// Server action: onClick={deleteTodo(id)}
|
|
545
|
+
// → data attributes consumed by the client action bridge
|
|
546
|
+
const fnName = actionCallMatch[1];
|
|
547
|
+
const argsExpr = actionCallMatch[2].trim();
|
|
548
|
+
// Remove the trailing "onX=" we already appended
|
|
549
|
+
result = result.replace(new RegExp(`\\s*${attrName}=$`), '');
|
|
550
|
+
// Emit data-action, data-args, and which browser event should trigger it.
|
|
551
|
+
result += ` data-action="${fnName}" data-args="\${__esc(JSON.stringify([${argsExpr}]))}" data-action-event="${eventName}"`;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
// Plain client-side event handler: onClick={myFn()}
|
|
555
|
+
// Emit as native inline handler (lowercased event attribute).
|
|
556
|
+
result = result.replace(new RegExp(`\\s*${attrName}=$`), ` on${eventName}=`);
|
|
557
|
+
result += `"${escapeLiteral(inner)}"`;
|
|
558
|
+
}
|
|
559
|
+
hasExpr = true;
|
|
560
|
+
pos = closeIdx + 1;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
else if (attrName === 'disabled' || attrName === 'checked' || attrName === 'hidden' || attrName === 'readonly') {
|
|
564
|
+
// Boolean attributes: disabled={expr} → conditionally include
|
|
565
|
+
result += `"\${${inner} ? '' : undefined}"`;
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// Regular attribute: value={expr} → value="escaped"
|
|
569
|
+
result += `"\${__esc(${inner})}"`;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
result += `\${__esc(${inner})}`;
|
|
574
|
+
}
|
|
575
|
+
hasExpr = true;
|
|
576
|
+
}
|
|
577
|
+
pos = closeIdx + 1;
|
|
578
|
+
}
|
|
579
|
+
// If the line had expressions, use template literal; otherwise plain string
|
|
580
|
+
if (hasExpr) {
|
|
581
|
+
return `__html += \`${result}\\n\`;`;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
return `__html += \`${result}\\n\`;`;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** Escape characters that would break a JS template literal. */
|
|
588
|
+
function escapeLiteral(text) {
|
|
589
|
+
return text
|
|
590
|
+
.replace(/\\/g, '\\\\')
|
|
591
|
+
.replace(/`/g, '\\`')
|
|
592
|
+
.replace(/\$\{/g, '\\${');
|
|
593
|
+
}
|
|
594
|
+
/** Find the matching closing `}` for an opening `{`, handling nesting. */
|
|
595
|
+
function findClosingBrace(src, openPos) {
|
|
596
|
+
let depth = 0;
|
|
597
|
+
for (let i = openPos; i < src.length; i++) {
|
|
598
|
+
if (src[i] === '{')
|
|
599
|
+
depth++;
|
|
600
|
+
if (src[i] === '}') {
|
|
601
|
+
depth--;
|
|
602
|
+
if (depth === 0)
|
|
603
|
+
return i;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return src.length - 1;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Generate the full render function source code.
|
|
610
|
+
*/
|
|
611
|
+
export function generateRenderFunction(template) {
|
|
612
|
+
const body = compileTemplate(template);
|
|
613
|
+
return `function render(data) {
|
|
614
|
+
const __esc = (v) => {
|
|
615
|
+
if (v == null) return '';
|
|
616
|
+
return String(v)
|
|
617
|
+
.replace(/&/g, '&')
|
|
618
|
+
.replace(/</g, '<')
|
|
619
|
+
.replace(/>/g, '>')
|
|
620
|
+
.replace(/"/g, '"')
|
|
621
|
+
.replace(/'/g, ''');
|
|
622
|
+
};
|
|
623
|
+
${body}
|
|
624
|
+
}`;
|
|
625
|
+
}
|
package/dist/create.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kuratchi create <project-name>` — scaffold a new KuratchiJS project
|
|
3
|
+
*
|
|
4
|
+
* Interactive prompts for feature selection, then generates
|
|
5
|
+
* a ready-to-run project with the selected stack.
|
|
6
|
+
*/
|
|
7
|
+
export declare function create(projectName?: string, flags?: string[]): Promise<void>;
|