@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.
@@ -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, '&amp;')
618
+ .replace(/</g, '&lt;')
619
+ .replace(/>/g, '&gt;')
620
+ .replace(/"/g, '&quot;')
621
+ .replace(/'/g, '&#39;');
622
+ };
623
+ ${body}
624
+ }`;
625
+ }
@@ -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>;