@kudzujs/core 0.5.6 → 0.5.7

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 CHANGED
@@ -97,6 +97,27 @@ export default function Post({ title }: { title: string }) {
97
97
 
98
98
  This emits `/posts/oak` and `/posts/pine`. Parameter values must be safe single path segments; missing, unsafe, and duplicate routes fail the build.
99
99
 
100
+ When a bracket value exists only in the request URL, opt into one static fallback document and read it with `useParams()`:
101
+
102
+ ```tsx
103
+ // src/pages/items/[id].tsx
104
+ import { useEffect, useParams } from "@kudzujs/core"
105
+
106
+ export const runtimeParams = true
107
+
108
+ export default function ItemPage() {
109
+ const { id } = useParams<{ id: string }>()
110
+
111
+ useEffect(() => {
112
+ fetch(`/api/items/${encodeURIComponent(id)}`)
113
+ }, [])
114
+
115
+ return <h1>Item {id}</h1>
116
+ }
117
+ ```
118
+
119
+ This emits `dist/items/[id]/index.html` and a route-specific pathname matcher. `getStaticPaths()` and `runtimeParams` are mutually exclusive. Runtime parameters occupy complete path segments, decode once, and reject empty, malformed, separator, control, and traversal-like values. The development server resolves deep links automatically. Production static hosts must try exact files first, then internally rewrite matching paths to the fallback file while preserving the browser URL; `.kudzu/kudzu-plan.json` and `afterBuild()` expose ordered `rewrites` for host adapters. Navigation remains ordinary `<a>` document navigation, not an SPA router.
120
+
100
121
  Static trusted HTML can be rendered without a transform layer:
101
122
 
102
123
  ```tsx
@@ -110,8 +131,8 @@ Every CSS file under `src` is copied to the same relative path under `dist/asset
110
131
  ```js
111
132
  export default {
112
133
  base: "/newsletter",
113
- async afterBuild({ outDir, routes, plans, base }) {
114
- // Write RSS, sitemap, search indexes, or other static artifacts.
134
+ async afterBuild({ outDir, routes, plans, rewrites, base }) {
135
+ // Write host rewrites, RSS, sitemap, or other static artifacts.
115
136
  }
116
137
  }
117
138
  ```
@@ -395,6 +416,7 @@ Supported:
395
416
  - File-based static routes
396
417
  - Build-time async components
397
418
  - Dynamic static routes with build-time props
419
+ - Runtime bracket parameters with static fallback documents and host rewrite metadata
398
420
  - Static trusted `dangerouslySetInnerHTML`
399
421
  - Base-path deployments, multiple CSS files, and `afterBuild`
400
422
  - Primitive `useState` bindings
@@ -1,6 +1,6 @@
1
1
  # Framework Internals
2
2
 
3
- - `build.mjs`: TSX compilation, static and `getStaticPaths` routes, base paths, CSS collection, post-build hooks, behavior extraction, static HTML output, and the development server.
3
+ - `build.mjs`: TSX compilation, static, `getStaticPaths`, and runtime-fallback routes, base paths, CSS collection, post-build hooks, behavior extraction, static HTML output, and the development server.
4
4
  - `core.mjs`: server-side JSX rendering, state slots, context providers, behavior metadata, and serializable capture validation.
5
5
  - `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
6
6
  - `runtime.js`: command-only runtime for direct state-to-text patches.
@@ -13,6 +13,6 @@
13
13
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
14
14
  - `*.d.ts`: public TypeScript and JSX declarations.
15
15
 
16
- Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; mount effects add `effect-runtime.js` and one route-specific entry. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
16
+ Static routes receive no browser runtime. Command routes receive `runtime.js`; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; mount effects add `effect-runtime.js` and one route-specific entry. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
17
17
 
18
18
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -47,6 +47,8 @@ export async function build({ quiet = false, minify = true } = {}) {
47
47
  let stateSeedCount = 0
48
48
  const plans = []
49
49
  const effectEntries = []
50
+ const paramEntries = []
51
+ const rewrites = []
50
52
  const emittedRoutes = new Set()
51
53
  const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
52
54
 
@@ -55,24 +57,40 @@ export async function build({ quiet = false, minify = true } = {}) {
55
57
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
56
58
  if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
57
59
 
58
- const entries = await staticPathEntries(module, pageFile)
60
+ const runtimeSchema = runtimeRouteSchema(module, pageFile)
61
+ if (runtimeSchema) {
62
+ const conflicting = rewrites.find(rewrite => sameRuntimePrecedence(rewrite, runtimeSchema))
63
+ if (conflicting) throw new Error(`Ambiguous runtime routes: ${conflicting.route} and ${runtimeSchema.route}`)
64
+ rewrites.push({
65
+ route: runtimeSchema.route,
66
+ pattern: withBase(base, `/${runtimeSchema.route}`),
67
+ file: `${runtimeSchema.route}/index.html`,
68
+ params: runtimeSchema.params,
69
+ segments: runtimeSchema.segments
70
+ })
71
+ }
72
+ const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile)
59
73
  for (const { params, props } of entries) {
60
- const route = routeFromPage(pageFile, params)
74
+ const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
61
75
  const routePath = withBase(base, `/${route}`)
62
76
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
77
+ const paramPath = `params/${route}/index.js`
63
78
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
64
79
  emittedRoutes.add(routePath)
65
80
  const result = await renderPage(module.default, {
66
81
  ...(module.metadata ?? {}),
67
82
  styles: styleUrls.length ? styleUrls : false,
68
83
  base,
69
- effectAsset: assetPath(base, `assets/${effectPath}`)
84
+ effectAsset: assetPath(base, `assets/${effectPath}`),
85
+ paramAsset: assetPath(base, `assets/${paramPath}`),
86
+ runtimeParams: runtimeSchema?.params
70
87
  }, props)
71
88
  const routeDirectory = join(outputDirectory, route)
72
89
  await mkdir(routeDirectory, { recursive: true })
73
90
  await writeFile(join(routeDirectory, "index.html"), result.html)
74
91
  plans.push({ route: routePath, ...result.plan })
75
- if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects })
92
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params })
93
+ if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined })
76
94
  if (result.hasBehaviors) behaviorCount++
77
95
  if (result.hasBindings) bindingCount++
78
96
  if (result.hasLists) listCount++
@@ -165,10 +183,15 @@ export async function build({ quiet = false, minify = true } = {}) {
165
183
  await mkdir(resolve(output, ".."), { recursive: true })
166
184
  await writeJavaScript(output, handlerModule.code, minify)
167
185
  }
186
+ for (const entry of paramEntries) {
187
+ const output = join(assetsDirectory, entry.path)
188
+ await mkdir(dirname(output), { recursive: true })
189
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base), minify)
190
+ }
168
191
  for (const entry of effectEntries) {
169
192
  const output = join(assetsDirectory, entry.path)
170
193
  await mkdir(dirname(output), { recursive: true })
171
- await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base), minify)
194
+ await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath), minify)
172
195
  }
173
196
  const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
174
197
  for (const file of clientModules) {
@@ -194,7 +217,8 @@ export async function build({ quiet = false, minify = true } = {}) {
194
217
  })
195
218
  await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
196
219
  }
197
- await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
220
+ const sortedRewrites = rewrites.sort((left, right) => runtimeSpecificity(right) - runtimeSpecificity(left) || left.pattern.localeCompare(right.pattern))
221
+ await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans, rewrites: sortedRewrites }, null, 2))
198
222
  for (const file of cssFiles) {
199
223
  const output = join(assetsDirectory, relative(sourceDirectory, file))
200
224
  await mkdir(dirname(output), { recursive: true })
@@ -203,7 +227,7 @@ export async function build({ quiet = false, minify = true } = {}) {
203
227
  if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
204
228
  if (config.afterBuild !== undefined) {
205
229
  if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
206
- await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans })
230
+ await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites })
207
231
  }
208
232
 
209
233
  if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
@@ -219,7 +243,7 @@ function specializeNativeRuntime(source, events, modules) {
219
243
  return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
220
244
  }
221
245
 
222
- function printEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
246
+ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
223
247
  const moduleUrls = [...new Set(effects.map(effect => effect.module))]
224
248
  const modules = moduleUrls.map(url => {
225
249
  const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
@@ -229,6 +253,7 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
229
253
  const imports = [
230
254
  `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
231
255
  `import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
256
+ ...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
232
257
  ...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
233
258
  ]
234
259
  const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
@@ -245,6 +270,42 @@ for (const effect of effects) {
245
270
  }`
246
271
  }
247
272
 
273
+ function printParamEntry(schema, params, output, assetsDirectory, base) {
274
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}
275
+ const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
276
+ const schema = ${inlineJson(schema.segments)}
277
+ const params = ${inlineJson(params)}
278
+ let path = location.pathname
279
+ if (base.length) {
280
+ const pathSegments = path.slice(1).split("/")
281
+ if (pathSegments.length < base.length || base.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) throw new Error("Runtime route is outside the configured base")
282
+ path = "/" + pathSegments.slice(base.length).join("/")
283
+ }
284
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
285
+ const segments = path.slice(1).split("/")
286
+ if (segments.length !== schema.length) throw new Error("Runtime route does not match its fallback pattern")
287
+ const values = Object.create(null)
288
+ for (let index = 0; index < schema.length; index++) {
289
+ const segment = schema[index]
290
+ const value = decodeSegment(segments[index], Boolean(segment.param))
291
+ if (segment.literal !== undefined && value !== segment.literal) throw new Error("Runtime route literal does not match")
292
+ if (segment.param) values[segment.param] = value
293
+ }
294
+ for (const param of params) {
295
+ const value = values[param.name]
296
+ browserState.set(param.id, value)
297
+ commitDom(param.id, value)
298
+ }
299
+ function decodeSegment(raw, param) {
300
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Runtime route parameter contains an encoded separator")
301
+ let value
302
+ try { value = decodeURIComponent(raw) } catch { throw new Error("Runtime route parameter has malformed encoding") }
303
+ const decodedDots = value.replace(/%2e/gi, ".")
304
+ if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Runtime route parameter is invalid")
305
+ return value
306
+ }`
307
+ }
308
+
248
309
  function hasCaptureType(value, type) {
249
310
  if (!value || typeof value !== "object") return false
250
311
  if (value.type === type) return true
@@ -316,7 +377,8 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
316
377
  const server = createServer(async (request, response) => {
317
378
  try {
318
379
  const url = new URL(request.url, "http://localhost")
319
- const pathname = decodeURIComponent(url.pathname)
380
+ const rawPathname = url.pathname
381
+ const pathname = decodeURIComponent(rawPathname)
320
382
  if (pathname === "/__kudzu_reload") {
321
383
  response.writeHead(200, {
322
384
  "content-type": "text/event-stream; charset=utf-8",
@@ -336,15 +398,24 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
336
398
  return
337
399
  }
338
400
 
339
- const relativePath = stripBase(pathname, base).replace(/^\/+/, "")
401
+ const relativePath = stripBaseStrict(pathname, decodeURIComponent(base)).replace(/^\/+/, "")
340
402
  let file = resolve(outputDirectory, relativePath)
341
403
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
342
404
 
343
405
  if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
344
406
  if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
407
+ let matchedRoute
408
+ if (!(await exists(file)) && !buildError) {
409
+ const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
410
+ const rewrite = plan.rewrites?.find(entry => runtimePathValues(rawPathname, entry, browserPath(base)))
411
+ if (rewrite) {
412
+ file = resolve(outputDirectory, rewrite.file)
413
+ matchedRoute = rewrite.pattern
414
+ }
415
+ }
345
416
  const isHtml = extname(file) === ".html"
346
417
  const content = isHtml
347
- ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
418
+ ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(withBase(base, stripBaseStrict(pathname, decodeURIComponent(base))), matchedRoute))
348
419
  : await readFile(file)
349
420
  response.writeHead(200, {
350
421
  "content-type": contentType(file),
@@ -397,22 +468,58 @@ function injectDevClient(html, session, revision, schema) {
397
468
  return `${html}${devClient(session, revision, schema)}`
398
469
  }
399
470
 
400
- function stripBase(path, base) {
471
+ function stripBaseStrict(path, base) {
401
472
  if (!base) return path
402
473
  if (path === base) return "/"
403
- return path.startsWith(`${base}/`) ? path.slice(base.length) : path
474
+ if (path.startsWith(`${base}/`)) return path.slice(base.length)
475
+ throw new Error("Path is outside the configured base")
404
476
  }
405
477
 
406
- async function devSchema(pathname) {
478
+ async function devSchema(pathname, matchedRoute) {
407
479
  try {
408
480
  const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
409
- const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
481
+ const route = matchedRoute ?? (pathname.replace(/\/(?:index\.html)?$/, "") || "/")
410
482
  return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
411
483
  } catch {
412
484
  return []
413
485
  }
414
486
  }
415
487
 
488
+ function runtimePathValues(pathname, rewrite, base) {
489
+ try {
490
+ let path = stripBrowserBase(pathname, base)
491
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
492
+ const rawSegments = path.slice(1).split("/")
493
+ if (rawSegments.length !== rewrite.segments.length) return undefined
494
+ const values = Object.create(null)
495
+ for (let index = 0; index < rewrite.segments.length; index++) {
496
+ const segment = rewrite.segments[index]
497
+ const value = decodeRuntimeSegment(rawSegments[index], Boolean(segment.param))
498
+ if (segment.literal !== undefined && value !== segment.literal) return undefined
499
+ if (segment.param) values[segment.param] = value
500
+ }
501
+ return values
502
+ } catch {
503
+ return undefined
504
+ }
505
+ }
506
+
507
+ function stripBrowserBase(path, base) {
508
+ if (!base) return path
509
+ const pathSegments = path.slice(1).split("/")
510
+ const baseSegments = base.slice(1).split("/").map(segment => decodeURIComponent(segment))
511
+ if (pathSegments.length < baseSegments.length || baseSegments.some((segment, index) => decodeRuntimeSegment(pathSegments[index], false) !== segment)) throw new Error("Path is outside the configured base")
512
+ return `/${pathSegments.slice(baseSegments.length).join("/")}`
513
+ }
514
+
515
+ function decodeRuntimeSegment(raw, param) {
516
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Encoded separator")
517
+ const value = decodeURIComponent(raw)
518
+ const decodedDots = value.replace(/%2e/gi, ".")
519
+ if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Invalid runtime parameter")
520
+ return value
521
+ }
522
+
416
523
  function inlineJson(value) {
417
524
  return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
418
525
  }
@@ -1960,10 +2067,17 @@ async function loadConfig() {
1960
2067
 
1961
2068
  function normalizeBase(value) {
1962
2069
  if (value == null || value === "" || value === "/") return ""
1963
- if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || value.split("/").includes("..")) throw new Error("kudzu.config base must be a root-relative path")
2070
+ if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || /%(?:2f|5c)/i.test(value)) throw new Error("kudzu.config base must be a root-relative path")
2071
+ let decoded
2072
+ try { decoded = decodeURIComponent(value) } catch { throw new Error("kudzu.config base must be a root-relative path") }
2073
+ if (/[\\?#\0]/.test(decoded) || decoded.split("/").includes("..") || [...decoded].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159)) throw new Error("kudzu.config base must be a root-relative path")
1964
2074
  return value.replace(/\/+$/, "")
1965
2075
  }
1966
2076
 
2077
+ function browserPath(path) {
2078
+ return path ? new URL(path, "http://kudzu.local").pathname : ""
2079
+ }
2080
+
1967
2081
  function assetPath(base, path) {
1968
2082
  return `${base}/${path}`
1969
2083
  }
@@ -1986,6 +2100,43 @@ async function staticPathEntries(module, file) {
1986
2100
  })
1987
2101
  }
1988
2102
 
2103
+ function runtimeRouteSchema(module, file) {
2104
+ if (!Object.hasOwn(module, "runtimeParams")) return undefined
2105
+ if (module.runtimeParams !== true) throw new Error(`${relative(root, file)} runtimeParams must be exactly true`)
2106
+ if (typeof module.getStaticPaths === "function") throw new Error(`${relative(root, file)} runtimeParams cannot be combined with getStaticPaths()`)
2107
+ const route = pageRoutePattern(file)
2108
+ if (route.includes("[...")) throw new Error(`Catch-all routes are not supported: ${route}`)
2109
+ const names = new Set()
2110
+ const segments = route.split("/").map(segment => {
2111
+ const match = segment.match(/^\[([^\]]+)\]$/)
2112
+ if (!match) {
2113
+ if (/[\[\]]/.test(segment)) throw new Error(`${relative(root, file)} runtime parameters must occupy a complete path segment`)
2114
+ return { literal: segment }
2115
+ }
2116
+ const name = match[1]
2117
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || ["__proto__", "constructor", "prototype"].includes(name)) throw new Error(`${relative(root, file)} invalid runtime parameter name ${JSON.stringify(name)}`)
2118
+ if (names.has(name)) throw new Error(`${relative(root, file)} duplicate runtime parameter ${JSON.stringify(name)}`)
2119
+ names.add(name)
2120
+ return { param: name }
2121
+ })
2122
+ if (!names.size) throw new Error(`${relative(root, file)} runtimeParams requires a bracket page`)
2123
+ return { route, segments, params: [...names] }
2124
+ }
2125
+
2126
+ function pageRoutePattern(file) {
2127
+ const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
2128
+ return page === "index" ? "" : page.replace(/\/index$/, "")
2129
+ }
2130
+
2131
+ function runtimeSpecificity(schema) {
2132
+ return schema.segments.filter(segment => segment.literal !== undefined).length
2133
+ }
2134
+
2135
+ function sameRuntimePrecedence(left, right) {
2136
+ if (left.segments.length !== right.segments.length || runtimeSpecificity(left) !== runtimeSpecificity(right)) return false
2137
+ return left.segments.every((segment, index) => segment.literal === undefined || right.segments[index].literal === undefined || segment.literal === right.segments[index].literal)
2138
+ }
2139
+
1989
2140
  function routeFromPage(file, params = {}) {
1990
2141
  const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
1991
2142
  if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
@@ -2,6 +2,7 @@ export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
2
 
3
3
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
4
4
  export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
5
+ export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
5
6
 
6
7
  export interface RefObject<T> {
7
8
  readonly current: T | null
@@ -48,18 +49,22 @@ export function renderPage<Props = Record<string, never>>(
48
49
  styles?: boolean | string[]
49
50
  base?: string
50
51
  effectAsset?: string
52
+ paramAsset?: string
53
+ runtimeParams?: string[]
51
54
  },
52
55
  props?: Props
53
56
  ): Promise<{
54
57
  html: string
55
58
  hasBehaviors: boolean
56
59
  hasEffects: boolean
60
+ hasParams: boolean
57
61
  hasBindings: boolean
58
62
  hasLists: boolean
59
63
  hasListStyles: boolean
60
64
  hasStateSeed: boolean
61
65
  plan: {
62
66
  states: Array<{ id: string; name: string; initialValue: unknown }>
67
+ params: Array<{ name: string; id: string }>
63
68
  events: Array<{
64
69
  event: string
65
70
  commands?: Array<[string, string, unknown]>
@@ -24,10 +24,37 @@ export function useState(initialValue, name) {
24
24
  }
25
25
 
26
26
  const id = `s${renderContext.nextState++}`
27
- const signal = {
27
+ const signal = createSignal(id, initialValue)
28
+
29
+ const setter = () => {
30
+ throw new Error("State setters are compiled into ordered browser behaviors")
31
+ }
32
+ Object.defineProperty(setter, setterMarker, { value: id })
33
+ renderContext.states[id] = { name: name ?? id, initialValue }
34
+ return [signal, setter]
35
+ }
36
+
37
+ export function useParams() {
38
+ if (!renderContext?.runtimeParamNames?.length) throw new Error("useParams() requires export const runtimeParams = true on a bracket page")
39
+ if (!renderContext.params) {
40
+ const params = Object.create(null)
41
+ renderContext.paramEntries = renderContext.runtimeParamNames.map((name, index) => {
42
+ const id = `p${index}`
43
+ params[name] = createSignal(id, "")
44
+ return { name, id }
45
+ })
46
+ renderContext.params = Object.freeze(params)
47
+ renderContext.hasBehaviors = true
48
+ renderContext.hasParams = true
49
+ }
50
+ return renderContext.params
51
+ }
52
+
53
+ function createSignal(id, value) {
54
+ return {
28
55
  [signalMarker]: true,
29
56
  id,
30
- value: initialValue,
57
+ value,
31
58
  valueOf() {
32
59
  return this.value
33
60
  },
@@ -35,13 +62,6 @@ export function useState(initialValue, name) {
35
62
  return String(this.value)
36
63
  }
37
64
  }
38
-
39
- const setter = () => {
40
- throw new Error("State setters are compiled into ordered browser behaviors")
41
- }
42
- Object.defineProperty(setter, setterMarker, { value: id })
43
- renderContext.states[id] = { name: name ?? id, initialValue }
44
- return [signal, setter]
45
65
  }
46
66
 
47
67
  export function useEffect(callback, dependencies, module, handler, states, scope, source) {
@@ -242,7 +262,7 @@ function serializeCapture(name, value, seen) {
242
262
  }
243
263
 
244
264
  export async function renderPage(component, metadata = {}, props = {}) {
245
- renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasBindings: false, hasLists: false, hasListStyles: false }
265
+ renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
246
266
 
247
267
  try {
248
268
  const body = await renderNode({ type: component, props })
@@ -268,6 +288,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
268
288
  const nativeRuntime = renderContext.hasNativeBehaviors
269
289
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
270
290
  : ""
291
+ const paramRuntime = renderContext.hasParams
292
+ ? `<script type="module" src="${escapeAttribute(metadata.paramAsset)}"></script>`
293
+ : ""
271
294
  const bindingRuntime = renderContext.hasBindings
272
295
  ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
273
296
  : ""
@@ -293,15 +316,17 @@ export async function renderPage(component, metadata = {}, props = {}) {
293
316
  : ""
294
317
 
295
318
  return {
296
- html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
319
+ html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
297
320
  hasBehaviors: renderContext.hasBehaviors,
298
321
  hasEffects: renderContext.hasEffects,
322
+ hasParams: renderContext.hasParams,
299
323
  hasBindings: renderContext.hasBindings,
300
324
  hasLists: renderContext.hasLists,
301
325
  hasListStyles: renderContext.hasListStyles,
302
326
  hasStateSeed: initialState.length > 0,
303
327
  plan: {
304
328
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
329
+ params: renderContext.paramEntries,
305
330
  events: renderContext.events,
306
331
  effects: renderContext.effects,
307
332
  bindings: renderContext.bindings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "build": "node ./bin/kudzu.mjs build",
48
48
  "dev": "node ./bin/kudzu.mjs dev",
49
49
  "check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json --noEmit && node ./bin/kudzu.mjs build",
50
- "test": "node --test",
50
+ "test": "node --test test/*.test.mjs",
51
51
  "prepublishOnly": "npm run check && npm test",
52
52
  "deploy": "wrangler deploy",
53
53
  "preview": "wrangler dev"