@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 +24 -2
- package/framework/README.md +2 -2
- package/framework/build.mjs +167 -16
- package/framework/core.d.ts +5 -0
- package/framework/core.mjs +36 -11
- package/package.json +2 -2
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,
|
|
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
|
package/framework/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Framework Internals
|
|
2
2
|
|
|
3
|
-
- `build.mjs`: TSX compilation, static
|
|
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.
|
package/framework/build.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
471
|
+
function stripBaseStrict(path, base) {
|
|
401
472
|
if (!base) return path
|
|
402
473
|
if (path === base) return "/"
|
|
403
|
-
|
|
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) ||
|
|
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}`)
|
package/framework/core.d.ts
CHANGED
|
@@ -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]>
|
package/framework/core.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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"
|