@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.
@@ -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
  * }
@@ -46,6 +48,7 @@ export function compileTemplate(template, componentNames, actionNames, rpcNameMa
46
48
  const lines = template.split('\n');
47
49
  let inStyle = false;
48
50
  let inScript = false;
51
+ let scriptBuffer = [];
49
52
  for (let i = 0; i < lines.length; i++) {
50
53
  const line = lines[i];
51
54
  const trimmed = line.trim();
@@ -58,13 +61,30 @@ export function compileTemplate(template, componentNames, actionNames, rpcNameMa
58
61
  inStyle = false;
59
62
  continue;
60
63
  }
61
- // Track <script> blocks — emit client JS as literal, no parsing
62
- if (trimmed.match(/<script[\s>]/i))
64
+ // Track <script> blocks — transform reactive ($:) client syntax first.
65
+ if (!inScript && trimmed.match(/<script[\s>]/i)) {
63
66
  inScript = true;
67
+ scriptBuffer = [line];
68
+ if (trimmed.match(/<\/script>/i)) {
69
+ const transformed = transformClientScriptBlock(scriptBuffer.join('\n'));
70
+ for (const scriptLine of transformed.split('\n')) {
71
+ out.push(`__html += \`${escapeLiteral(scriptLine)}\\n\`;`);
72
+ }
73
+ scriptBuffer = [];
74
+ inScript = false;
75
+ }
76
+ continue;
77
+ }
64
78
  if (inScript) {
65
- out.push(`__html += \`${escapeLiteral(line)}\\n\`;`);
66
- if (trimmed.match(/<\/script>/i))
79
+ scriptBuffer.push(line);
80
+ if (trimmed.match(/<\/script>/i)) {
81
+ const transformed = transformClientScriptBlock(scriptBuffer.join('\n'));
82
+ for (const scriptLine of transformed.split('\n')) {
83
+ out.push(`__html += \`${escapeLiteral(scriptLine)}\\n\`;`);
84
+ }
85
+ scriptBuffer = [];
67
86
  inScript = false;
87
+ }
68
88
  continue;
69
89
  }
70
90
  // Skip empty lines
@@ -147,6 +167,159 @@ export function compileTemplate(template, componentNames, actionNames, rpcNameMa
147
167
  }
148
168
  return out.join('\n');
149
169
  }
170
+ function transformClientScriptBlock(block) {
171
+ const match = block.match(/^([\s\S]*?<script\b[^>]*>)([\s\S]*?)(<\/script>\s*)$/i);
172
+ if (!match)
173
+ return block;
174
+ const openTag = match[1];
175
+ const body = match[2];
176
+ const closeTag = match[3];
177
+ if (!/\$\s*:/.test(body)) {
178
+ const transpiled = transpileTypeScript(body, 'client-script.ts');
179
+ return `${openTag}${transpiled}${closeTag}`;
180
+ }
181
+ const out = [];
182
+ const lines = body.split('\n');
183
+ const reactiveVars = new Set();
184
+ const rewritten = lines.map((line) => {
185
+ const m = line.match(/^(\s*)let\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]+);\s*$/);
186
+ if (!m)
187
+ return line;
188
+ const indent = m[1] ?? '';
189
+ const name = m[2];
190
+ const expr = (m[3] ?? '').trim();
191
+ if (!expr || (!expr.startsWith('[') && !expr.startsWith('{')))
192
+ return line;
193
+ reactiveVars.add(name);
194
+ return `${indent}let ${name} = __k$.state(${expr});`;
195
+ });
196
+ const assignRegexes = Array.from(reactiveVars).map((name) => ({
197
+ name,
198
+ re: new RegExp(`^(\\s*)${name}\\s*=\\s*([^;]+);\\s*$`),
199
+ }));
200
+ let inReactiveBlock = false;
201
+ let blockIndent = '';
202
+ let blockDepth = 0;
203
+ for (const line of rewritten) {
204
+ let current = line;
205
+ for (const { name, re } of assignRegexes) {
206
+ const am = current.match(re);
207
+ if (!am)
208
+ continue;
209
+ const indent = am[1] ?? '';
210
+ const expr = (am[2] ?? '').trim();
211
+ current = `${indent}${name} = __k$.replace(${name}, ${expr});`;
212
+ break;
213
+ }
214
+ if (!inReactiveBlock) {
215
+ const rm = current.match(/^(\s*)\$:\s*(.*)$/);
216
+ if (!rm) {
217
+ out.push(current);
218
+ continue;
219
+ }
220
+ const indent = rm[1] ?? '';
221
+ const expr = (rm[2] ?? '').trim();
222
+ if (!expr)
223
+ continue;
224
+ if (expr.startsWith('{')) {
225
+ const tail = expr.slice(1);
226
+ out.push(`${indent}__k$.effect(() => {`);
227
+ inReactiveBlock = true;
228
+ blockIndent = indent;
229
+ blockDepth = 1 + braceDelta(tail);
230
+ if (tail.trim())
231
+ out.push(`${indent}${tail}`);
232
+ if (blockDepth <= 0) {
233
+ out.push(`${indent}});`);
234
+ inReactiveBlock = false;
235
+ blockIndent = '';
236
+ blockDepth = 0;
237
+ }
238
+ continue;
239
+ }
240
+ const normalized = expr.endsWith(';') ? expr : `${expr};`;
241
+ out.push(`${indent}__k$.effect(() => { ${normalized} });`);
242
+ continue;
243
+ }
244
+ const nextDepth = blockDepth + braceDelta(current);
245
+ if (nextDepth <= 0 && current.trim() === '}') {
246
+ out.push(`${blockIndent}});`);
247
+ inReactiveBlock = false;
248
+ blockIndent = '';
249
+ blockDepth = 0;
250
+ continue;
251
+ }
252
+ out.push(current);
253
+ blockDepth = nextDepth;
254
+ }
255
+ if (inReactiveBlock)
256
+ out.push(`${blockIndent}});`);
257
+ let insertAt = 0;
258
+ while (insertAt < out.length) {
259
+ const t = out[insertAt].trim();
260
+ if (!t || t.startsWith('//')) {
261
+ insertAt++;
262
+ continue;
263
+ }
264
+ if (/^\/\*/.test(t)) {
265
+ insertAt++;
266
+ continue;
267
+ }
268
+ if (/^import\s/.test(t)) {
269
+ insertAt++;
270
+ continue;
271
+ }
272
+ break;
273
+ }
274
+ out.splice(insertAt, 0, 'const __k$ = window.__kuratchiReactive;');
275
+ const transpiled = transpileTypeScript(out.join('\n'), 'client-script.ts');
276
+ return `${openTag}${transpiled}${closeTag}`;
277
+ }
278
+ function braceDelta(line) {
279
+ let delta = 0;
280
+ let inSingle = false;
281
+ let inDouble = false;
282
+ let inTemplate = false;
283
+ let escaped = false;
284
+ for (let i = 0; i < line.length; i++) {
285
+ const ch = line[i];
286
+ if (escaped) {
287
+ escaped = false;
288
+ continue;
289
+ }
290
+ if (ch === '\\') {
291
+ escaped = true;
292
+ continue;
293
+ }
294
+ if (!inDouble && !inTemplate && ch === "'" && !inSingle) {
295
+ inSingle = true;
296
+ continue;
297
+ }
298
+ if (inSingle && ch === "'") {
299
+ inSingle = false;
300
+ continue;
301
+ }
302
+ if (!inSingle && !inTemplate && ch === '"' && !inDouble) {
303
+ inDouble = true;
304
+ continue;
305
+ }
306
+ if (inDouble && ch === '"') {
307
+ inDouble = false;
308
+ continue;
309
+ }
310
+ if (!inSingle && !inDouble && ch === '`') {
311
+ inTemplate = !inTemplate;
312
+ continue;
313
+ }
314
+ if (inSingle || inDouble || inTemplate)
315
+ continue;
316
+ if (ch === '{')
317
+ delta++;
318
+ else if (ch === '}')
319
+ delta--;
320
+ }
321
+ return delta;
322
+ }
150
323
  function findMatching(src, openPos, openChar, closeChar) {
151
324
  let depth = 0;
152
325
  let quote = null;
@@ -382,8 +555,9 @@ function expandShorthands(line) {
382
555
  return line;
383
556
  }
384
557
  /**
385
- * Compile a single HTML line, replacing {expr} with escaped output
386
- * and {=html expr} with raw output. Handles attribute values like value={x}.
558
+ * Compile a single HTML line, replacing {expr} with escaped output,
559
+ * {@html expr} with sanitized HTML, and {@raw expr} with raw output.
560
+ * Handles attribute values like value={x}.
387
561
  */
388
562
  function compileHtmlLine(line, actionNames, rpcNameMap) {
389
563
  // Expand shorthand syntax before main compilation
@@ -405,10 +579,22 @@ function compileHtmlLine(line, actionNames, rpcNameMap) {
405
579
  // Find matching closing brace
406
580
  const closeIdx = findClosingBrace(line, braceIdx);
407
581
  const inner = line.slice(braceIdx + 1, closeIdx).trim();
408
- // Raw HTML: {=html expr}
409
- if (inner.startsWith('=html ')) {
582
+ // Sanitized HTML: {@html expr}
583
+ if (inner.startsWith('@html ')) {
584
+ const expr = inner.slice(6).trim();
585
+ result += `\${__sanitizeHtml(${expr})}`;
586
+ hasExpr = true;
587
+ }
588
+ else if (inner.startsWith('@raw ')) {
589
+ // Unsafe raw HTML: {@raw expr}
590
+ const expr = inner.slice(5).trim();
591
+ result += `\${__rawHtml(${expr})}`;
592
+ hasExpr = true;
593
+ }
594
+ else if (inner.startsWith('=html ')) {
595
+ // Legacy alias for raw HTML: {=html expr}
410
596
  const expr = inner.slice(6).trim();
411
- result += `\${${expr}}`;
597
+ result += `\${__rawHtml(${expr})}`;
412
598
  hasExpr = true;
413
599
  }
414
600
  else {
@@ -610,10 +796,26 @@ function findClosingBrace(src, openPos) {
610
796
  */
611
797
  export function generateRenderFunction(template) {
612
798
  const body = compileTemplate(template);
613
- return `function render(data) {
614
- const __esc = (v) => {
615
- if (v == null) return '';
616
- return String(v)
799
+ return `function render(data) {
800
+ const __rawHtml = (v) => {
801
+ if (v == null) return '';
802
+ return String(v);
803
+ };
804
+ const __sanitizeHtml = (v) => {
805
+ let html = __rawHtml(v);
806
+ html = html.replace(/<script\\b[^>]*>[\\s\\S]*?<\\/script>/gi, '');
807
+ html = html.replace(/<iframe\\b[^>]*>[\\s\\S]*?<\\/iframe>/gi, '');
808
+ html = html.replace(/<object\\b[^>]*>[\\s\\S]*?<\\/object>/gi, '');
809
+ html = html.replace(/<embed\\b[^>]*>/gi, '');
810
+ html = html.replace(/\\son[a-z]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi, '');
811
+ html = html.replace(/\\s(href|src|xlink:href)\\s*=\\s*([\"'])\\s*javascript:[\\s\\S]*?\\2/gi, ' $1="#"');
812
+ html = html.replace(/\\s(href|src|xlink:href)\\s*=\\s*javascript:[^\\s>]+/gi, ' $1="#"');
813
+ html = html.replace(/\\ssrcdoc\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi, '');
814
+ return html;
815
+ };
816
+ const __esc = (v) => {
817
+ if (v == null) return '';
818
+ return String(v)
617
819
  .replace(/&/g, '&amp;')
618
820
  .replace(/</g, '&lt;')
619
821
  .replace(/>/g, '&gt;')
@@ -623,3 +825,4 @@ export function generateRenderFunction(template) {
623
825
  ${body}
624
826
  }`;
625
827
  }
828
+ import { transpileTypeScript } from './transpile.js';
@@ -0,0 +1 @@
1
+ export declare function transpileTypeScript(source: string, contextLabel: string, compilerOptions?: Record<string, unknown>): string;
@@ -0,0 +1,61 @@
1
+ import { createRequire } from 'node:module';
2
+ import * as fs from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ const require = createRequire(import.meta.url);
5
+ let tsImpl;
6
+ let bunTranspiler;
7
+ function transpileWithBun(source) {
8
+ const BunRuntime = globalThis.Bun;
9
+ if (!BunRuntime?.Transpiler)
10
+ return null;
11
+ if (!bunTranspiler) {
12
+ bunTranspiler = new BunRuntime.Transpiler({ loader: 'ts' });
13
+ }
14
+ const output = bunTranspiler.transformSync(source);
15
+ return typeof output === 'string' ? output.trim() : String(output).trim();
16
+ }
17
+ function getTypeScript() {
18
+ if (!tsImpl) {
19
+ const localTsPath = fileURLToPath(new URL('../../node_modules/typescript/lib/typescript.js', import.meta.url));
20
+ if (fs.existsSync(localTsPath)) {
21
+ tsImpl = require(localTsPath);
22
+ }
23
+ else {
24
+ tsImpl = require(require.resolve('typescript', { paths: [process.cwd()] }));
25
+ }
26
+ }
27
+ return tsImpl;
28
+ }
29
+ function formatDiagnostic(diag) {
30
+ if (!diag.file || typeof diag.start !== 'number') {
31
+ return getTypeScript().flattenDiagnosticMessageText(diag.messageText, '\n');
32
+ }
33
+ const pos = diag.file.getLineAndCharacterOfPosition(diag.start);
34
+ const message = getTypeScript().flattenDiagnosticMessageText(diag.messageText, '\n');
35
+ return `${diag.file.fileName}:${pos.line + 1}:${pos.character + 1} ${message}`;
36
+ }
37
+ export function transpileTypeScript(source, contextLabel, compilerOptions = {}) {
38
+ if (!source.trim())
39
+ return source;
40
+ const bunOutput = transpileWithBun(source);
41
+ if (bunOutput !== null)
42
+ return bunOutput;
43
+ const ts = getTypeScript();
44
+ const result = ts.transpileModule(source, {
45
+ compilerOptions: {
46
+ target: ts.ScriptTarget.ES2022,
47
+ module: ts.ModuleKind.ESNext,
48
+ importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
49
+ verbatimModuleSyntax: false,
50
+ ...compilerOptions,
51
+ },
52
+ reportDiagnostics: true,
53
+ fileName: contextLabel,
54
+ });
55
+ const diagnostics = (result.diagnostics || []).filter((diag) => diag.category === ts.DiagnosticCategory.Error);
56
+ if (diagnostics.length > 0) {
57
+ const rendered = diagnostics.map(formatDiagnostic).join('\n');
58
+ throw new Error(`[kuratchi compiler] TypeScript transpile failed for ${contextLabel}\n${rendered}`);
59
+ }
60
+ return result.outputText.trim();
61
+ }
package/dist/create.js CHANGED
@@ -915,7 +915,7 @@ function genLoginPage() {
915
915
  footerText="Don't have an account?"
916
916
  footerLink="Sign up"
917
917
  footerHref="/auth/signup"
918
- error={__error}
918
+ error={signIn.error}
919
919
  >
920
920
  <form action={signIn} method="POST" class="kui-auth-form">
921
921
  <div class="kui-field">
@@ -943,7 +943,7 @@ function genSignupPage() {
943
943
  footerText="Already have an account?"
944
944
  footerLink="Sign in"
945
945
  footerHref="/auth/login"
946
- error={__error}
946
+ error={signUp.error}
947
947
  >
948
948
  <form action={signUp} method="POST" class="kui-auth-form">
949
949
  <div class="kui-field">
package/dist/index.d.ts CHANGED
@@ -6,8 +6,10 @@
6
6
  export { createApp } from './runtime/app.js';
7
7
  export { defineConfig } from './runtime/config.js';
8
8
  export { defineRuntime } from './runtime/runtime.js';
9
- export { getCtx, getRequest, getLocals, getParams, getParam, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './runtime/context.js';
10
- export { kuratchiDO, doRpc } from './runtime/do.js';
9
+ export { getCtx, getEnv, getRequest, getLocals, getParams, getParam, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './runtime/context.js';
10
+ export { kuratchiDO, doRpc, getDb } from './runtime/do.js';
11
+ export { ActionError } from './runtime/action.js';
12
+ export { PageError } from './runtime/page-error.js';
11
13
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO, matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './runtime/containers.js';
12
14
  export type { AppConfig, kuratchiConfig, DatabaseConfig, AuthConfig, RouteContext, RouteModule, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './runtime/types.js';
13
15
  export type { RpcOf } from './runtime/do.js';
package/dist/index.js CHANGED
@@ -7,8 +7,10 @@
7
7
  export { createApp } from './runtime/app.js';
8
8
  export { defineConfig } from './runtime/config.js';
9
9
  export { defineRuntime } from './runtime/runtime.js';
10
- export { getCtx, getRequest, getLocals, getParams, getParam, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './runtime/context.js';
11
- export { kuratchiDO, doRpc } from './runtime/do.js';
10
+ export { getCtx, getEnv, getRequest, getLocals, getParams, getParam, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './runtime/context.js';
11
+ export { kuratchiDO, doRpc, getDb } from './runtime/do.js';
12
+ export { ActionError } from './runtime/action.js';
13
+ export { PageError } from './runtime/page-error.js';
12
14
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO,
13
15
  // Compatibility aliases
14
16
  matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './runtime/containers.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * ActionError — throw from a form action to surface a user-facing error.
3
+ *
4
+ * Throwing an ActionError makes the error message available in the template
5
+ * as `actionName.error` (e.g. `signIn.error`). Throwing a plain Error in
6
+ * production shows a generic "Action failed" message instead.
7
+ */
8
+ export declare class ActionError extends Error {
9
+ readonly isActionError = true;
10
+ constructor(message: string);
11
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * ActionError — throw from a form action to surface a user-facing error.
3
+ *
4
+ * Throwing an ActionError makes the error message available in the template
5
+ * as `actionName.error` (e.g. `signIn.error`). Throwing a plain Error in
6
+ * production shows a generic "Action failed" message instead.
7
+ */
8
+ export class ActionError extends Error {
9
+ isActionError = true;
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = 'ActionError';
13
+ }
14
+ }
@@ -39,10 +39,37 @@ export function createApp(config) {
39
39
  }
40
40
  const route = routes[match.index];
41
41
  context.params = match.params;
42
+ // --- API routes: dispatch to method handler ---
43
+ if ('__api' in route && route.__api) {
44
+ const method = request.method;
45
+ if (method === 'OPTIONS') {
46
+ const handler = route['OPTIONS'];
47
+ if (typeof handler === 'function')
48
+ return handler(context);
49
+ const allowed = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
50
+ .filter(m => typeof route[m] === 'function').join(', ');
51
+ return new Response(null, {
52
+ status: 204,
53
+ headers: { 'Allow': allowed, 'Access-Control-Allow-Methods': allowed },
54
+ });
55
+ }
56
+ const handler = route[method];
57
+ if (typeof handler !== 'function') {
58
+ const allowed = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
59
+ .filter(m => typeof route[m] === 'function').join(', ');
60
+ return new Response(JSON.stringify({ error: 'Method Not Allowed' }), {
61
+ status: 405,
62
+ headers: { 'content-type': 'application/json', 'Allow': allowed },
63
+ });
64
+ }
65
+ return handler(context);
66
+ }
67
+ // From here, route is a page route (RouteModule)
68
+ const pageRoute = route;
42
69
  // --- RPC calls: POST ?/_rpc/functionName ---
43
70
  if (request.method === 'POST' && url.searchParams.has('_rpc')) {
44
71
  const fnName = url.searchParams.get('_rpc');
45
- const rpcFn = route.rpc?.[fnName];
72
+ const rpcFn = pageRoute.rpc?.[fnName];
46
73
  if (!rpcFn) {
47
74
  return new Response(JSON.stringify({ error: `RPC function '${fnName}' not found` }), {
48
75
  status: 404,
@@ -68,7 +95,7 @@ export function createApp(config) {
68
95
  const actionParam = [...url.searchParams.keys()].find(k => k.startsWith('/'));
69
96
  if (actionParam) {
70
97
  const actionName = actionParam.slice(1); // remove leading /
71
- const actionFn = route.actions?.[actionName];
98
+ const actionFn = pageRoute.actions?.[actionName];
72
99
  if (!actionFn) {
73
100
  return new Response(`Action '${actionName}' not found`, { status: 404 });
74
101
  }
@@ -76,22 +103,22 @@ export function createApp(config) {
76
103
  const formData = await request.formData();
77
104
  const actionResult = await actionFn(formData, env, context);
78
105
  // After action, re-run load and re-render with action result
79
- const loadData = route.load ? await route.load(context) : {};
106
+ const loadData = pageRoute.load ? await pageRoute.load(context) : {};
80
107
  const data = { ...loadData, actionResult, actionName };
81
- return renderPage(route, data, layouts);
108
+ return renderPage(pageRoute, data, layouts);
82
109
  }
83
110
  catch (err) {
84
111
  // Re-render with error
85
- const loadData = route.load ? await route.load(context) : {};
112
+ const loadData = pageRoute.load ? await pageRoute.load(context) : {};
86
113
  const data = { ...loadData, actionError: err.message, actionName };
87
- return renderPage(route, data, layouts);
114
+ return renderPage(pageRoute, data, layouts);
88
115
  }
89
116
  }
90
117
  }
91
118
  // --- GET: load + render ---
92
119
  try {
93
- const data = route.load ? await route.load(context) : {};
94
- return renderPage(route, data, layouts);
120
+ const data = pageRoute.load ? await pageRoute.load(context) : {};
121
+ return renderPage(pageRoute, data, layouts);
95
122
  }
96
123
  catch (err) {
97
124
  return new Response(`Server Error: ${err.message}`, {
@@ -14,8 +14,10 @@ export interface BreadcrumbItem {
14
14
  }
15
15
  /** Called by the framework at the start of each request */
16
16
  export declare function __setRequestContext(ctx: any, request: Request): void;
17
- /** Get the execution context (waitUntil, passThroughOnException) */
18
- export declare function getCtx(): ExecutionContext;
17
+ /** Get the execution context (Worker: ExecutionContext, DO: DurableObjectState) */
18
+ export declare function getCtx(): any;
19
+ /** Get the current environment bindings */
20
+ export declare function getEnv<T = Record<string, any>>(): T;
19
21
  /** Get the current request */
20
22
  export declare function getRequest(): Request;
21
23
  /** Get request-scoped locals (session, auth, custom data) */
@@ -45,3 +47,7 @@ export declare function __setLocal(key: string, value: any): void;
45
47
  export declare function __getLocals(): Record<string, any>;
46
48
  /** HTML-escape a value for safe output in templates */
47
49
  export declare function __esc(v: any): string;
50
+ /** Convert a value to a raw HTML string (unsafe, no escaping). */
51
+ export declare function __rawHtml(v: any): string;
52
+ /** Best-effort HTML sanitizer for {@html ...} template output. */
53
+ export declare function __sanitizeHtml(v: any): string;
@@ -7,6 +7,7 @@
7
7
  * Workers are single-threaded per request — module-scoped
8
8
  * variables are safe and require no Node.js compat flags.
9
9
  */
10
+ import { __getDoSelf } from './do.js';
10
11
  let __ctx = null;
11
12
  let __request = null;
12
13
  let __locals = {};
@@ -22,12 +23,22 @@ export function __setRequestContext(ctx, request) {
22
23
  get locals() { return __locals; },
23
24
  };
24
25
  }
25
- /** Get the execution context (waitUntil, passThroughOnException) */
26
+ /** Get the execution context (Worker: ExecutionContext, DO: DurableObjectState) */
26
27
  export function getCtx() {
28
+ const doSelf = __getDoSelf();
29
+ if (doSelf)
30
+ return doSelf.ctx;
27
31
  if (!__ctx)
28
32
  throw new Error('getCtx() called outside of a request context');
29
33
  return __ctx;
30
34
  }
35
+ /** Get the current environment bindings */
36
+ export function getEnv() {
37
+ const doSelf = __getDoSelf();
38
+ if (doSelf)
39
+ return doSelf.env;
40
+ return globalThis.__cloudflare_env__;
41
+ }
31
42
  /** Get the current request */
32
43
  export function getRequest() {
33
44
  if (!__request)
@@ -121,3 +132,22 @@ export function __esc(v) {
121
132
  .replace(/"/g, '&quot;')
122
133
  .replace(/'/g, '&#39;');
123
134
  }
135
+ /** Convert a value to a raw HTML string (unsafe, no escaping). */
136
+ export function __rawHtml(v) {
137
+ if (v == null)
138
+ return '';
139
+ return String(v);
140
+ }
141
+ /** Best-effort HTML sanitizer for {@html ...} template output. */
142
+ export function __sanitizeHtml(v) {
143
+ let html = __rawHtml(v);
144
+ html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
145
+ html = html.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '');
146
+ html = html.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '');
147
+ html = html.replace(/<embed\b[^>]*>/gi, '');
148
+ html = html.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
149
+ html = html.replace(/\s(href|src|xlink:href)\s*=\s*(["'])\s*javascript:[\s\S]*?\2/gi, ' $1="#"');
150
+ html = html.replace(/\s(href|src|xlink:href)\s*=\s*javascript:[^\s>]+/gi, ' $1="#"');
151
+ html = html.replace(/\ssrcdoc\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
152
+ return html;
153
+ }
@@ -12,6 +12,12 @@
12
12
  * The compiler uses __registerDoResolver / __getDoStub internally.
13
13
  * User code never touches these — they're wired up from kuratchi.config.ts.
14
14
  */
15
+ /** @internal — called by compiler-generated method wrappers */
16
+ export declare function __setDoContext(self: any): void;
17
+ /** Get the DO's ORM database instance */
18
+ export declare function getDb<T = Record<string, any>>(): T;
19
+ /** @internal — read by context.ts getCtx()/getEnv() */
20
+ export declare function __getDoSelf(): any;
15
21
  /** @internal — called by compiler-generated init code */
16
22
  export declare function __registerDoResolver(binding: string, resolver: () => Promise<any>): void;
17
23
  /** @internal — called by compiler-generated init code */
@@ -13,6 +13,21 @@
13
13
  * User code never touches these — they're wired up from kuratchi.config.ts.
14
14
  */
15
15
  // ── Internal: stub resolver registry ────────────────────────
16
+ let __doSelf = null;
17
+ /** @internal — called by compiler-generated method wrappers */
18
+ export function __setDoContext(self) {
19
+ __doSelf = self;
20
+ }
21
+ /** Get the DO's ORM database instance */
22
+ export function getDb() {
23
+ if (!__doSelf)
24
+ throw new Error('getDb() called outside of a DO context');
25
+ return __doSelf.db;
26
+ }
27
+ /** @internal — read by context.ts getCtx()/getEnv() */
28
+ export function __getDoSelf() {
29
+ return __doSelf;
30
+ }
16
31
  const _resolvers = new Map();
17
32
  const _classBindings = new WeakMap();
18
33
  /** @internal — called by compiler-generated init code */
@@ -5,5 +5,5 @@ export { Router, filePathToPattern } from './router.js';
5
5
  export { getCtx, getRequest, getLocals, getParams, getParam, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './context.js';
6
6
  export { kuratchiDO, doRpc } from './do.js';
7
7
  export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO, matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './containers.js';
8
- export type { AppConfig, Env, AuthConfig, RouteContext, RouteModule, LayoutModule, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './types.js';
8
+ export type { AppConfig, Env, AuthConfig, RouteContext, RouteModule, ApiRouteModule, HttpMethod, LayoutModule, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './types.js';
9
9
  export type { RpcOf } from './do.js';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * PageError — throw from a route's load scope to return a specific HTTP status page.
3
+ *
4
+ * Without PageError, any thrown error becomes a 500. PageError lets you return
5
+ * the correct HTTP status (404, 403, 401, etc.) and an optional message.
6
+ *
7
+ * @example
8
+ * const post = await db.posts.findOne({ id: params.id });
9
+ * if (!post) throw new PageError(404);
10
+ * if (!post.isPublished) throw new PageError(403, 'This post is not published');
11
+ */
12
+ export declare class PageError extends Error {
13
+ readonly isPageError = true;
14
+ readonly status: number;
15
+ constructor(status: number, message?: string);
16
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * PageError — throw from a route's load scope to return a specific HTTP status page.
3
+ *
4
+ * Without PageError, any thrown error becomes a 500. PageError lets you return
5
+ * the correct HTTP status (404, 403, 401, etc.) and an optional message.
6
+ *
7
+ * @example
8
+ * const post = await db.posts.findOne({ id: params.id });
9
+ * if (!post) throw new PageError(404);
10
+ * if (!post.isPublished) throw new PageError(403, 'This post is not published');
11
+ */
12
+ export class PageError extends Error {
13
+ isPageError = true;
14
+ status;
15
+ constructor(status, message) {
16
+ super(message);
17
+ this.name = 'PageError';
18
+ this.status = status;
19
+ }
20
+ }