@kuratchi/js 0.0.3 → 0.0.5

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.
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * HTML file parser.
3
3
  *
4
- * Extracts the top-level <script> block (before the HTML document).
4
+ * Extracts the top-level compile-time <script> block (before the HTML document).
5
+ * A top-level block containing reactive `$:` labels is preserved in template output
6
+ * as client script.
5
7
  * Everything else is the template — full HTML with native JS flow control.
6
8
  * <style> inside the HTML is NOT extracted; it's part of the template.
7
9
  */
8
10
  export interface ParsedFile {
9
- /** Script content (the server-side code, minus imports) */
11
+ /** Script content (compile-time code, minus imports) */
10
12
  script: string | null;
13
+ /** Explicit server-side load function exported from the route script */
14
+ loadFunction: string | null;
11
15
  /** Template — the full HTML document with inline JS flow control */
12
16
  template: string;
13
17
  /** All imports from the script block */
@@ -30,11 +34,23 @@ export interface ParsedFile {
30
34
  key?: string;
31
35
  rpcId?: string;
32
36
  }>;
37
+ /** Imports found in a top-level client script block */
38
+ clientImports: string[];
39
+ /** Top-level names returned from explicit load() */
40
+ loadReturnVars: string[];
41
+ /** Local aliases for Cloudflare Workers env imported from cloudflare:workers */
42
+ workerEnvAliases: string[];
43
+ }
44
+ interface ParseFileOptions {
45
+ kind?: 'route' | 'layout' | 'component';
46
+ filePath?: string;
33
47
  }
34
48
  /**
35
49
  * Parse a .html route file.
36
50
  *
37
- * The <script> block at the top is extracted for the compiler.
51
+ * The top-level compile-time <script> block is extracted for the compiler.
52
+ * If it includes reactive `$:` labels, it is preserved in template output.
38
53
  * Everything else (the HTML document) becomes the template.
39
54
  */
40
- export declare function parseFile(source: string): ParsedFile;
55
+ export declare function parseFile(source: string, options?: ParseFileOptions): ParsedFile;
56
+ export {};
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * HTML file parser.
3
3
  *
4
- * Extracts the top-level <script> block (before the HTML document).
4
+ * Extracts the top-level compile-time <script> block (before the HTML document).
5
+ * A top-level block containing reactive `$:` labels is preserved in template output
6
+ * as client script.
5
7
  * Everything else is the template — full HTML with native JS flow control.
6
8
  * <style> inside the HTML is NOT extracted; it's part of the template.
7
9
  */
10
+ function hasReactiveLabel(scriptBody) {
11
+ return /\$\s*:/.test(scriptBody);
12
+ }
8
13
  function splitTopLevel(input, delimiter) {
9
14
  const parts = [];
10
15
  let start = 0;
@@ -103,6 +108,220 @@ function pushIdentifier(name, out) {
103
108
  if (!out.includes(name))
104
109
  out.push(name);
105
110
  }
111
+ function findMatchingToken(input, openPos, openChar, closeChar) {
112
+ let depth = 0;
113
+ let quote = null;
114
+ let escaped = false;
115
+ for (let i = openPos; i < input.length; i++) {
116
+ const ch = input[i];
117
+ if (quote) {
118
+ if (escaped) {
119
+ escaped = false;
120
+ continue;
121
+ }
122
+ if (ch === '\\') {
123
+ escaped = true;
124
+ continue;
125
+ }
126
+ if (ch === quote)
127
+ quote = null;
128
+ continue;
129
+ }
130
+ if (ch === '"' || ch === "'" || ch === '`') {
131
+ quote = ch;
132
+ continue;
133
+ }
134
+ if (ch === openChar)
135
+ depth++;
136
+ else if (ch === closeChar) {
137
+ depth--;
138
+ if (depth === 0)
139
+ return i;
140
+ }
141
+ }
142
+ return -1;
143
+ }
144
+ function extractCloudflareEnvAliases(importLine) {
145
+ if (!/from\s+['"]cloudflare:workers['"]/.test(importLine))
146
+ return [];
147
+ const namedMatch = importLine.match(/import\s*\{([\s\S]*?)\}\s*from\s+['"]cloudflare:workers['"]/);
148
+ if (!namedMatch)
149
+ return [];
150
+ const aliases = [];
151
+ for (const rawPart of splitTopLevel(namedMatch[1], ',')) {
152
+ const part = rawPart.trim();
153
+ if (!part)
154
+ continue;
155
+ const envMatch = part.match(/^env(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
156
+ if (envMatch)
157
+ aliases.push(envMatch[1] || 'env');
158
+ }
159
+ return aliases;
160
+ }
161
+ function extractReturnObjectKeys(body) {
162
+ const keys = [];
163
+ let i = 0;
164
+ let depthParen = 0;
165
+ let depthBracket = 0;
166
+ let depthBrace = 0;
167
+ let quote = null;
168
+ let escaped = false;
169
+ while (i < body.length) {
170
+ const ch = body[i];
171
+ if (quote) {
172
+ if (escaped) {
173
+ escaped = false;
174
+ i++;
175
+ continue;
176
+ }
177
+ if (ch === '\\') {
178
+ escaped = true;
179
+ i++;
180
+ continue;
181
+ }
182
+ if (ch === quote)
183
+ quote = null;
184
+ i++;
185
+ continue;
186
+ }
187
+ if (ch === '"' || ch === "'" || ch === '`') {
188
+ quote = ch;
189
+ i++;
190
+ continue;
191
+ }
192
+ if (ch === '(')
193
+ depthParen++;
194
+ else if (ch === ')')
195
+ depthParen = Math.max(0, depthParen - 1);
196
+ else if (ch === '[')
197
+ depthBracket++;
198
+ else if (ch === ']')
199
+ depthBracket = Math.max(0, depthBracket - 1);
200
+ else if (ch === '{')
201
+ depthBrace++;
202
+ else if (ch === '}')
203
+ depthBrace = Math.max(0, depthBrace - 1);
204
+ if (depthParen !== 0 || depthBracket !== 0 || depthBrace !== 0) {
205
+ i++;
206
+ continue;
207
+ }
208
+ const rest = body.slice(i);
209
+ const returnMatch = /^return\b/.exec(rest);
210
+ if (!returnMatch) {
211
+ i++;
212
+ continue;
213
+ }
214
+ i += returnMatch[0].length;
215
+ while (i < body.length && /\s/.test(body[i]))
216
+ i++;
217
+ if (body[i] !== '{')
218
+ continue;
219
+ const closeIdx = findMatchingToken(body, i, '{', '}');
220
+ if (closeIdx === -1)
221
+ break;
222
+ const objectBody = body.slice(i + 1, closeIdx);
223
+ for (const rawProp of splitTopLevel(objectBody, ',')) {
224
+ const prop = rawProp.trim();
225
+ if (!prop || prop.startsWith('...'))
226
+ continue;
227
+ const keyMatch = prop.match(/^([A-Za-z_$][\w$]*)\s*(?::|$)/);
228
+ if (keyMatch)
229
+ pushIdentifier(keyMatch[1], keys);
230
+ }
231
+ break;
232
+ }
233
+ return keys;
234
+ }
235
+ function extractExplicitLoad(scriptBody) {
236
+ let i = 0;
237
+ let depthParen = 0;
238
+ let depthBracket = 0;
239
+ let depthBrace = 0;
240
+ let quote = null;
241
+ let escaped = false;
242
+ while (i < scriptBody.length) {
243
+ const ch = scriptBody[i];
244
+ if (quote) {
245
+ if (escaped) {
246
+ escaped = false;
247
+ i++;
248
+ continue;
249
+ }
250
+ if (ch === '\\') {
251
+ escaped = true;
252
+ i++;
253
+ continue;
254
+ }
255
+ if (ch === quote)
256
+ quote = null;
257
+ i++;
258
+ continue;
259
+ }
260
+ if (ch === '"' || ch === "'" || ch === '`') {
261
+ quote = ch;
262
+ i++;
263
+ continue;
264
+ }
265
+ if (ch === '(')
266
+ depthParen++;
267
+ else if (ch === ')')
268
+ depthParen = Math.max(0, depthParen - 1);
269
+ else if (ch === '[')
270
+ depthBracket++;
271
+ else if (ch === ']')
272
+ depthBracket = Math.max(0, depthBracket - 1);
273
+ else if (ch === '{')
274
+ depthBrace++;
275
+ else if (ch === '}')
276
+ depthBrace = Math.max(0, depthBrace - 1);
277
+ if (depthParen !== 0 || depthBracket !== 0 || depthBrace !== 0) {
278
+ i++;
279
+ continue;
280
+ }
281
+ const rest = scriptBody.slice(i);
282
+ const fnMatch = /^export\s+(async\s+)?function\s+load\s*/.exec(rest);
283
+ if (!fnMatch) {
284
+ i++;
285
+ continue;
286
+ }
287
+ const openParen = i + fnMatch[0].length;
288
+ if (scriptBody[openParen] !== '(') {
289
+ throw new Error('[kuratchi compiler] Could not parse exported load() declaration.');
290
+ }
291
+ const closeParen = findMatchingToken(scriptBody, openParen, '(', ')');
292
+ if (closeParen === -1) {
293
+ throw new Error('[kuratchi compiler] Could not parse exported load() parameters.');
294
+ }
295
+ let openBrace = closeParen + 1;
296
+ while (openBrace < scriptBody.length && /\s/.test(scriptBody[openBrace]))
297
+ openBrace++;
298
+ if (scriptBody[openBrace] !== '{') {
299
+ throw new Error('[kuratchi compiler] export load() must use a function body.');
300
+ }
301
+ const closeBrace = findMatchingToken(scriptBody, openBrace, '{', '}');
302
+ if (closeBrace === -1) {
303
+ throw new Error('[kuratchi compiler] Could not parse exported load() body.');
304
+ }
305
+ return {
306
+ loadFunction: scriptBody.slice(i, closeBrace + 1).trim(),
307
+ remainingScript: `${scriptBody.slice(0, i)}\n${scriptBody.slice(closeBrace + 1)}`.trim(),
308
+ returnVars: extractReturnObjectKeys(scriptBody.slice(openBrace + 1, closeBrace)),
309
+ };
310
+ }
311
+ return { loadFunction: null, remainingScript: scriptBody.trim(), returnVars: [] };
312
+ }
313
+ function hasFrameworkEnvEscapeHatch(source) {
314
+ return /\bglobalThis\.__cloudflare_env__\b/.test(source) || /\b__cloudflare_env__\b/.test(source);
315
+ }
316
+ function buildEnvAccessError(kind, filePath, detail) {
317
+ const label = filePath || kind;
318
+ const guidance = kind === 'route'
319
+ ? 'Route top-level <script> is server-side. Client/reactive scripts cannot access Cloudflare env.'
320
+ : kind === 'layout'
321
+ ? 'Layout scripts cannot access Cloudflare env directly.'
322
+ : 'Component scripts cannot access Cloudflare env directly.';
323
+ return new Error(`[kuratchi compiler] ${label}\n${detail}\n${guidance}\nRead env on the server and pass the value into the template explicitly.`);
324
+ }
106
325
  function collectPatternNames(pattern, out) {
107
326
  const p = pattern.trim();
108
327
  if (!p)
@@ -401,22 +620,34 @@ function extractTopLevelFunctionNames(scriptBody) {
401
620
  /**
402
621
  * Parse a .html route file.
403
622
  *
404
- * The <script> block at the top is extracted for the compiler.
623
+ * The top-level compile-time <script> block is extracted for the compiler.
624
+ * If it includes reactive `$:` labels, it is preserved in template output.
405
625
  * Everything else (the HTML document) becomes the template.
406
626
  */
407
- export function parseFile(source) {
627
+ export function parseFile(source, options = {}) {
628
+ const kind = options.kind || 'route';
408
629
  let script = null;
630
+ let clientScript = null;
409
631
  let template = source;
410
- // Extract the first <script>...</script> block (the server-side framework script)
411
- // Only match if it appears before any HTML document content
632
+ // Extract the first top-level compile-time <script>...</script> block.
633
+ // Client blocks (reactive `$:` labels) stay in the template
634
+ // and are emitted for browser execution.
412
635
  const scriptMatch = template.match(/^(\s*)<script(\s[^>]*)?\s*>([\s\S]*?)<\/script>/);
413
636
  if (scriptMatch) {
414
- script = scriptMatch[3].trim();
415
- template = template.slice(scriptMatch[0].length).trim();
637
+ const body = scriptMatch[3].trim();
638
+ if (!hasReactiveLabel(body)) {
639
+ script = body;
640
+ template = template.slice(scriptMatch[0].length).trim();
641
+ }
642
+ else {
643
+ clientScript = body;
644
+ }
416
645
  }
417
646
  // Extract all imports from script
418
647
  const serverImports = [];
648
+ const clientImports = [];
419
649
  const componentImports = {};
650
+ const workerEnvAliases = [];
420
651
  if (script) {
421
652
  // Support both single-line and multiline static imports.
422
653
  const importRegex = /^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm;
@@ -440,24 +671,62 @@ export function parseFile(source) {
440
671
  else {
441
672
  serverImports.push(line);
442
673
  }
674
+ for (const alias of extractCloudflareEnvAliases(line)) {
675
+ if (!workerEnvAliases.includes(alias))
676
+ workerEnvAliases.push(alias);
677
+ }
678
+ }
679
+ }
680
+ if (clientScript) {
681
+ const importRegex = /^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm;
682
+ let m;
683
+ while ((m = importRegex.exec(clientScript)) !== null) {
684
+ const line = m[0].trim();
685
+ clientImports.push(line);
686
+ if (extractCloudflareEnvAliases(line).length > 0) {
687
+ throw buildEnvAccessError(kind, options.filePath, 'Client <script> blocks cannot import env from cloudflare:workers.');
688
+ }
689
+ }
690
+ if (hasFrameworkEnvEscapeHatch(clientScript)) {
691
+ throw buildEnvAccessError(kind, options.filePath, 'Client <script> blocks cannot access framework env internals.');
443
692
  }
444
693
  }
445
694
  // Extract top-level variable declarations from script body (after removing imports)
446
695
  const dataVars = [];
696
+ const loadReturnVars = [];
447
697
  let scriptBody = '';
698
+ let loadFunction = null;
448
699
  if (script) {
449
700
  // Remove import lines to get the script body
450
- scriptBody = script.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '').trim();
451
- const topLevelVars = extractTopLevelDataVars(scriptBody);
701
+ const rawScriptBody = script.replace(/^\s*import[\s\S]*?from\s+['"][^'"]+['"]\s*;?/gm, '').trim();
702
+ const explicitLoad = extractExplicitLoad(rawScriptBody);
703
+ scriptBody = explicitLoad.remainingScript;
704
+ loadFunction = explicitLoad.loadFunction;
705
+ for (const name of explicitLoad.returnVars) {
706
+ if (!loadReturnVars.includes(name))
707
+ loadReturnVars.push(name);
708
+ }
709
+ if (hasFrameworkEnvEscapeHatch(rawScriptBody)) {
710
+ throw buildEnvAccessError(kind, options.filePath, 'Route/component scripts cannot access framework env internals.');
711
+ }
712
+ if (workerEnvAliases.length > 0 && kind !== 'route') {
713
+ throw buildEnvAccessError(kind, options.filePath, `Imported env from cloudflare:workers in a ${kind} script.`);
714
+ }
715
+ const transpiledScriptBody = transpileTypeScript(scriptBody, 'route-script.ts');
716
+ const topLevelVars = extractTopLevelDataVars(transpiledScriptBody);
452
717
  for (const v of topLevelVars)
453
718
  dataVars.push(v);
454
- const topLevelFns = extractTopLevelFunctionNames(scriptBody);
719
+ const topLevelFns = extractTopLevelFunctionNames(transpiledScriptBody);
455
720
  for (const fn of topLevelFns) {
456
721
  if (!dataVars.includes(fn))
457
722
  dataVars.push(fn);
458
723
  }
724
+ for (const name of loadReturnVars) {
725
+ if (!dataVars.includes(name))
726
+ dataVars.push(name);
727
+ }
459
728
  }
460
- const hasLoad = scriptBody.length > 0;
729
+ const hasLoad = scriptBody.length > 0 || !!loadFunction;
461
730
  // Strip HTML comments from the template before scanning for action references.
462
731
  // This prevents commented-out code (<!-- ... -->) from being parsed as live
463
732
  // action expressions, which would cause false "Invalid action expression" errors.
@@ -530,5 +799,20 @@ export function parseFile(source) {
530
799
  if (!exists)
531
800
  dataGetQueries.push({ fnName, argsExpr, asName, key });
532
801
  }
533
- return { script, template, serverImports, hasLoad, actionFunctions, dataVars, componentImports, pollFunctions, dataGetQueries };
802
+ return {
803
+ script,
804
+ loadFunction,
805
+ template,
806
+ serverImports,
807
+ hasLoad,
808
+ actionFunctions,
809
+ dataVars,
810
+ componentImports,
811
+ pollFunctions,
812
+ dataGetQueries,
813
+ clientImports,
814
+ loadReturnVars,
815
+ workerEnvAliases,
816
+ };
534
817
  }
818
+ import { transpileTypeScript } from './transpile.js';
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Syntax:
5
5
  * {expression} → escaped output
6
- * {=html expression} → raw HTML output (unescaped)
6
+ * {@html expression} → sanitized HTML output
7
+ * {@raw expression} → raw HTML output (unescaped)
8
+ * {=html expression} → legacy alias for {@raw expression}
7
9
  * for (const x of arr) { → JS for loop (inline in HTML)
8
10
  * <li>{x.name}</li>
9
11
  * }