@kudzujs/core 0.5.6 → 0.5.8
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 +38 -3
- package/framework/README.md +2 -2
- package/framework/build.mjs +259 -49
- package/framework/core.d.ts +8 -2
- package/framework/core.mjs +39 -13
- 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
|
```
|
|
@@ -325,10 +346,23 @@ useEffect(async () => {
|
|
|
325
346
|
|
|
326
347
|
Kudzu does not execute the effect during static rendering and does not ship the component. It emits one route-specific effect entry that invokes the compiled callback against existing logical state and direct DOM commit capabilities. Effects may update reactive text, attributes, conditions, and keyed lists. Multiple effects start independently in source order, and one synchronous or asynchronous failure is reported without suppressing later effects.
|
|
327
348
|
|
|
328
|
-
|
|
349
|
+
An effect may directly return an inline cleanup function:
|
|
350
|
+
|
|
351
|
+
```tsx
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
const onResize = () => console.log(window.innerWidth)
|
|
354
|
+
window.addEventListener("resize", onResize)
|
|
355
|
+
|
|
356
|
+
return () => window.removeEventListener("resize", onResize)
|
|
357
|
+
}, [])
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Cleanup runs once when the document leaves outside the browser back-forward cache. Effect-local resources and component state read by nested cleanup closures retain their mount-time values. Cleanup failures are isolated so later cleanups still run. Only inline block-bodied callbacks with a literal empty dependency array are supported. Dependencies, named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected at build time. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry, and effects without cleanup retain their smaller runtime output.
|
|
329
361
|
|
|
330
362
|
A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
|
|
331
363
|
|
|
364
|
+
A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
|
|
365
|
+
|
|
332
366
|
## Normal JavaScript
|
|
333
367
|
|
|
334
368
|
Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
|
|
@@ -395,6 +429,7 @@ Supported:
|
|
|
395
429
|
- File-based static routes
|
|
396
430
|
- Build-time async components
|
|
397
431
|
- Dynamic static routes with build-time props
|
|
432
|
+
- Runtime bracket parameters with static fallback documents and host rewrite metadata
|
|
398
433
|
- Static trusted `dangerouslySetInnerHTML`
|
|
399
434
|
- Base-path deployments, multiple CSS files, and `afterBuild`
|
|
400
435
|
- 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. Effect cleanup integrates with shared unmount hooks when present and otherwise disposes directly on non-persisted `pagehide`; unrelated routes and effects retain their existing runtime. 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,8 @@ 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) {
|
|
247
|
+
const hasCleanup = effects.some(effect => effect.cleanup)
|
|
223
248
|
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
224
249
|
const modules = moduleUrls.map(url => {
|
|
225
250
|
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
@@ -227,12 +252,15 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
|
|
|
227
252
|
return module
|
|
228
253
|
})
|
|
229
254
|
const imports = [
|
|
230
|
-
|
|
255
|
+
hasCleanup
|
|
256
|
+
? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}\nconst { browserState, commitDom } = __kRuntime`
|
|
257
|
+
: `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
|
|
231
258
|
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
259
|
+
...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
|
|
232
260
|
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
233
261
|
]
|
|
234
262
|
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
235
|
-
return `${imports.join("\n")}
|
|
263
|
+
if (!hasCleanup) return `${imports.join("\n")}
|
|
236
264
|
const effects = ${inlineJson(effects)}
|
|
237
265
|
const modules = new Map([${entries}])
|
|
238
266
|
for (const effect of effects) {
|
|
@@ -242,6 +270,75 @@ for (const effect of effects) {
|
|
|
242
270
|
} catch (error) {
|
|
243
271
|
console.error(error)
|
|
244
272
|
}
|
|
273
|
+
}`
|
|
274
|
+
return `${imports.join("\n")}
|
|
275
|
+
const effects = ${inlineJson(effects)}
|
|
276
|
+
const modules = new Map([${entries}])
|
|
277
|
+
const cleanups = []
|
|
278
|
+
for (const effect of effects) {
|
|
279
|
+
try {
|
|
280
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
|
|
281
|
+
if (effect.cleanup && typeof result === "function") cleanups.push(result)
|
|
282
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error(error)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let cleaned = false
|
|
288
|
+
const dispose = root => {
|
|
289
|
+
if (root !== document || cleaned) return
|
|
290
|
+
cleaned = true
|
|
291
|
+
for (const cleanup of cleanups) {
|
|
292
|
+
try {
|
|
293
|
+
const result = cleanup()
|
|
294
|
+
if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
295
|
+
} catch (error) {
|
|
296
|
+
console.error(error)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
cleanups.length = 0
|
|
300
|
+
}
|
|
301
|
+
if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
|
|
302
|
+
addEventListener("pagehide", event => {
|
|
303
|
+
if (event.persisted) return
|
|
304
|
+
if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
|
|
305
|
+
else dispose(document)
|
|
306
|
+
})`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function printParamEntry(schema, params, output, assetsDirectory, base) {
|
|
310
|
+
return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}
|
|
311
|
+
const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
|
|
312
|
+
const schema = ${inlineJson(schema.segments)}
|
|
313
|
+
const params = ${inlineJson(params)}
|
|
314
|
+
let path = location.pathname
|
|
315
|
+
if (base.length) {
|
|
316
|
+
const pathSegments = path.slice(1).split("/")
|
|
317
|
+
if (pathSegments.length < base.length || base.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) throw new Error("Runtime route is outside the configured base")
|
|
318
|
+
path = "/" + pathSegments.slice(base.length).join("/")
|
|
319
|
+
}
|
|
320
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
|
|
321
|
+
const segments = path.slice(1).split("/")
|
|
322
|
+
if (segments.length !== schema.length) throw new Error("Runtime route does not match its fallback pattern")
|
|
323
|
+
const values = Object.create(null)
|
|
324
|
+
for (let index = 0; index < schema.length; index++) {
|
|
325
|
+
const segment = schema[index]
|
|
326
|
+
const value = decodeSegment(segments[index], Boolean(segment.param))
|
|
327
|
+
if (segment.literal !== undefined && value !== segment.literal) throw new Error("Runtime route literal does not match")
|
|
328
|
+
if (segment.param) values[segment.param] = value
|
|
329
|
+
}
|
|
330
|
+
for (const param of params) {
|
|
331
|
+
const value = values[param.name]
|
|
332
|
+
browserState.set(param.id, value)
|
|
333
|
+
commitDom(param.id, value)
|
|
334
|
+
}
|
|
335
|
+
function decodeSegment(raw, param) {
|
|
336
|
+
if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Runtime route parameter contains an encoded separator")
|
|
337
|
+
let value
|
|
338
|
+
try { value = decodeURIComponent(raw) } catch { throw new Error("Runtime route parameter has malformed encoding") }
|
|
339
|
+
const decodedDots = value.replace(/%2e/gi, ".")
|
|
340
|
+
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")
|
|
341
|
+
return value
|
|
245
342
|
}`
|
|
246
343
|
}
|
|
247
344
|
|
|
@@ -316,7 +413,8 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
316
413
|
const server = createServer(async (request, response) => {
|
|
317
414
|
try {
|
|
318
415
|
const url = new URL(request.url, "http://localhost")
|
|
319
|
-
const
|
|
416
|
+
const rawPathname = url.pathname
|
|
417
|
+
const pathname = decodeURIComponent(rawPathname)
|
|
320
418
|
if (pathname === "/__kudzu_reload") {
|
|
321
419
|
response.writeHead(200, {
|
|
322
420
|
"content-type": "text/event-stream; charset=utf-8",
|
|
@@ -336,15 +434,24 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
336
434
|
return
|
|
337
435
|
}
|
|
338
436
|
|
|
339
|
-
const relativePath =
|
|
437
|
+
const relativePath = stripBaseStrict(pathname, decodeURIComponent(base)).replace(/^\/+/, "")
|
|
340
438
|
let file = resolve(outputDirectory, relativePath)
|
|
341
439
|
if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
|
|
342
440
|
|
|
343
441
|
if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
|
|
344
442
|
if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
|
|
443
|
+
let matchedRoute
|
|
444
|
+
if (!(await exists(file)) && !buildError) {
|
|
445
|
+
const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
|
|
446
|
+
const rewrite = plan.rewrites?.find(entry => runtimePathValues(rawPathname, entry, browserPath(base)))
|
|
447
|
+
if (rewrite) {
|
|
448
|
+
file = resolve(outputDirectory, rewrite.file)
|
|
449
|
+
matchedRoute = rewrite.pattern
|
|
450
|
+
}
|
|
451
|
+
}
|
|
345
452
|
const isHtml = extname(file) === ".html"
|
|
346
453
|
const content = isHtml
|
|
347
|
-
? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
|
|
454
|
+
? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(withBase(base, stripBaseStrict(pathname, decodeURIComponent(base))), matchedRoute))
|
|
348
455
|
: await readFile(file)
|
|
349
456
|
response.writeHead(200, {
|
|
350
457
|
"content-type": contentType(file),
|
|
@@ -397,22 +504,58 @@ function injectDevClient(html, session, revision, schema) {
|
|
|
397
504
|
return `${html}${devClient(session, revision, schema)}`
|
|
398
505
|
}
|
|
399
506
|
|
|
400
|
-
function
|
|
507
|
+
function stripBaseStrict(path, base) {
|
|
401
508
|
if (!base) return path
|
|
402
509
|
if (path === base) return "/"
|
|
403
|
-
|
|
510
|
+
if (path.startsWith(`${base}/`)) return path.slice(base.length)
|
|
511
|
+
throw new Error("Path is outside the configured base")
|
|
404
512
|
}
|
|
405
513
|
|
|
406
|
-
async function devSchema(pathname) {
|
|
514
|
+
async function devSchema(pathname, matchedRoute) {
|
|
407
515
|
try {
|
|
408
516
|
const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
|
|
409
|
-
const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
|
|
517
|
+
const route = matchedRoute ?? (pathname.replace(/\/(?:index\.html)?$/, "") || "/")
|
|
410
518
|
return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
|
|
411
519
|
} catch {
|
|
412
520
|
return []
|
|
413
521
|
}
|
|
414
522
|
}
|
|
415
523
|
|
|
524
|
+
function runtimePathValues(pathname, rewrite, base) {
|
|
525
|
+
try {
|
|
526
|
+
let path = stripBrowserBase(pathname, base)
|
|
527
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
|
|
528
|
+
const rawSegments = path.slice(1).split("/")
|
|
529
|
+
if (rawSegments.length !== rewrite.segments.length) return undefined
|
|
530
|
+
const values = Object.create(null)
|
|
531
|
+
for (let index = 0; index < rewrite.segments.length; index++) {
|
|
532
|
+
const segment = rewrite.segments[index]
|
|
533
|
+
const value = decodeRuntimeSegment(rawSegments[index], Boolean(segment.param))
|
|
534
|
+
if (segment.literal !== undefined && value !== segment.literal) return undefined
|
|
535
|
+
if (segment.param) values[segment.param] = value
|
|
536
|
+
}
|
|
537
|
+
return values
|
|
538
|
+
} catch {
|
|
539
|
+
return undefined
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function stripBrowserBase(path, base) {
|
|
544
|
+
if (!base) return path
|
|
545
|
+
const pathSegments = path.slice(1).split("/")
|
|
546
|
+
const baseSegments = base.slice(1).split("/").map(segment => decodeURIComponent(segment))
|
|
547
|
+
if (pathSegments.length < baseSegments.length || baseSegments.some((segment, index) => decodeRuntimeSegment(pathSegments[index], false) !== segment)) throw new Error("Path is outside the configured base")
|
|
548
|
+
return `/${pathSegments.slice(baseSegments.length).join("/")}`
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function decodeRuntimeSegment(raw, param) {
|
|
552
|
+
if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Encoded separator")
|
|
553
|
+
const value = decodeURIComponent(raw)
|
|
554
|
+
const decodedDots = value.replace(/%2e/gi, ".")
|
|
555
|
+
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")
|
|
556
|
+
return value
|
|
557
|
+
}
|
|
558
|
+
|
|
416
559
|
function inlineJson(value) {
|
|
417
560
|
return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
|
|
418
561
|
}
|
|
@@ -699,14 +842,18 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
699
842
|
const [callback, dependencies] = node.arguments
|
|
700
843
|
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
|
|
701
844
|
if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
|
|
845
|
+
if (callback.asteriskToken) fail(callback, "useEffect() callback cannot be a generator")
|
|
702
846
|
if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
|
|
703
847
|
if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
|
|
704
848
|
if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
|
|
705
|
-
if (returnsCleanup(callback)) fail(callback, "useEffect() cleanup functions are not supported")
|
|
706
849
|
if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
|
|
707
|
-
|
|
850
|
+
const returns = effectReturns(callback)
|
|
851
|
+
if (returns.invalid) fail(returns.invalid, "useEffect() return values must be inline cleanup functions")
|
|
852
|
+
const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
|
|
853
|
+
if (invalidCleanup) fail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
|
|
854
|
+
if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) fail(callback, "useEffect() async callbacks cannot return cleanup functions")
|
|
708
855
|
const setters = settersForNode(node, settersByFunction)
|
|
709
|
-
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true)
|
|
856
|
+
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true, returns.cleanup)
|
|
710
857
|
usesBehavior = true
|
|
711
858
|
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
|
|
712
859
|
callback,
|
|
@@ -715,7 +862,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
715
862
|
factory.createStringLiteral(descriptor.exportName),
|
|
716
863
|
descriptor.states,
|
|
717
864
|
descriptor.scope,
|
|
718
|
-
factory.createStringLiteral(sourceLocation(node, sourceFile))
|
|
865
|
+
factory.createStringLiteral(sourceLocation(node, sourceFile)),
|
|
866
|
+
returns.cleanup ? factory.createTrue() : factory.createFalse()
|
|
719
867
|
])
|
|
720
868
|
}
|
|
721
869
|
|
|
@@ -1133,7 +1281,7 @@ function isJsxSyntaxIdentifier(node) {
|
|
|
1133
1281
|
}
|
|
1134
1282
|
|
|
1135
1283
|
function isFunctionLike(node) {
|
|
1136
|
-
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
|
|
1284
|
+
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)
|
|
1137
1285
|
}
|
|
1138
1286
|
|
|
1139
1287
|
function isDestructuredParameter(identifier, fn) {
|
|
@@ -1373,14 +1521,14 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
|
|
|
1373
1521
|
])
|
|
1374
1522
|
}
|
|
1375
1523
|
|
|
1376
|
-
function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false) {
|
|
1524
|
+
function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false, snapshotNested = false) {
|
|
1377
1525
|
const allCaptures = nativeCaptureNames(expression, setters)
|
|
1378
1526
|
const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
|
|
1379
1527
|
const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
|
|
1380
1528
|
for (const entry of imports) clientImports.add(entry.target)
|
|
1381
1529
|
const usedStates = nativeStateNames(expression, setters)
|
|
1382
1530
|
const exportName = `${prefix}${entries.length}`
|
|
1383
|
-
entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
|
|
1531
|
+
entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), snapshotNested })
|
|
1384
1532
|
const value = name => deferValues
|
|
1385
1533
|
? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
|
|
1386
1534
|
: factory.createIdentifier(name)
|
|
@@ -1479,6 +1627,7 @@ function isReferenceIdentifier(node) {
|
|
|
1479
1627
|
if ((ts.isPropertyAccessExpression(parent) && parent.name === node) ||
|
|
1480
1628
|
(ts.isPropertyAssignment(parent) && parent.name === node) ||
|
|
1481
1629
|
(ts.isMethodDeclaration(parent) && parent.name === node) ||
|
|
1630
|
+
((ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent)) && parent.name === node) ||
|
|
1482
1631
|
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
|
1483
1632
|
(ts.isParameter(parent) && parent.name === node) ||
|
|
1484
1633
|
(ts.isFunctionDeclaration(parent) && parent.name === node) ||
|
|
@@ -1496,7 +1645,7 @@ function nearestFunction(node) {
|
|
|
1496
1645
|
|
|
1497
1646
|
function isShadowedByParameter(node, scopeRoot) {
|
|
1498
1647
|
for (let current = node.parent; current; current = current.parent) {
|
|
1499
|
-
if ((
|
|
1648
|
+
if (isFunctionLike(current) && current.parameters.some(parameter => bindingNames(parameter.name).includes(node.text))) return true
|
|
1500
1649
|
if (current === scopeRoot) break
|
|
1501
1650
|
}
|
|
1502
1651
|
return false
|
|
@@ -1626,33 +1775,24 @@ function sourceLocation(node, fallbackSource) {
|
|
|
1626
1775
|
return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
|
|
1627
1776
|
}
|
|
1628
1777
|
|
|
1629
|
-
function
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
}
|
|
1634
|
-
let found = false
|
|
1778
|
+
function effectReturns(callback) {
|
|
1779
|
+
let cleanup = false
|
|
1780
|
+
let invalid
|
|
1781
|
+
const cleanups = []
|
|
1635
1782
|
const visit = node => {
|
|
1636
|
-
if (
|
|
1783
|
+
if (invalid || node !== callback.body && isFunctionLike(node)) return
|
|
1637
1784
|
if (ts.isReturnStatement(node) && node.expression) {
|
|
1638
1785
|
const expression = unwrapExpression(node.expression)
|
|
1639
|
-
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression))
|
|
1786
|
+
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
|
|
1787
|
+
cleanup = true
|
|
1788
|
+
cleanups.push(expression)
|
|
1789
|
+
}
|
|
1790
|
+
else invalid = node
|
|
1640
1791
|
}
|
|
1641
|
-
if (!
|
|
1642
|
-
}
|
|
1643
|
-
visit(callback.body)
|
|
1644
|
-
return found
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
function returnsEffectValue(callback) {
|
|
1648
|
-
let found = false
|
|
1649
|
-
const visit = node => {
|
|
1650
|
-
if (found || node !== callback.body && isFunctionLike(node)) return
|
|
1651
|
-
if (ts.isReturnStatement(node) && node.expression) found = true
|
|
1652
|
-
if (!found) ts.forEachChild(node, visit)
|
|
1792
|
+
if (!invalid) ts.forEachChild(node, visit)
|
|
1653
1793
|
}
|
|
1654
1794
|
visit(callback.body)
|
|
1655
|
-
return
|
|
1795
|
+
return { cleanup, cleanups, invalid }
|
|
1656
1796
|
}
|
|
1657
1797
|
|
|
1658
1798
|
function printClientImports(entries, handlerPath) {
|
|
@@ -1767,9 +1907,11 @@ function relativeModulePath(from, to) {
|
|
|
1767
1907
|
return path.startsWith(".") ? path : `./${path}`
|
|
1768
1908
|
}
|
|
1769
1909
|
|
|
1770
|
-
function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
1910
|
+
function printNativeHandler({ exportName, expression, captures, setters, snapshotNested }) {
|
|
1771
1911
|
const factory = ts.factory
|
|
1772
1912
|
const stateNames = new Set(setters.values())
|
|
1913
|
+
const snapshotNames = snapshotNested ? nestedStateNames(expression, setters) : new Set()
|
|
1914
|
+
const snapshots = new Map([...snapshotNames].map(name => [name, factory.createUniqueName("__kEffectState")]))
|
|
1773
1915
|
const transformer = context => root => {
|
|
1774
1916
|
const visitor = node => {
|
|
1775
1917
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
|
|
@@ -1786,9 +1928,11 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
1786
1928
|
return setterReference(factory, setters.get(node.text))
|
|
1787
1929
|
}
|
|
1788
1930
|
if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
1931
|
+
if (snapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, snapshots.get(node.name.text))
|
|
1789
1932
|
return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(node.name.text)]))
|
|
1790
1933
|
}
|
|
1791
1934
|
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
1935
|
+
if (snapshots.has(node.text) && insideNestedFunction(node, expression)) return snapshots.get(node.text)
|
|
1792
1936
|
return factory.createCallExpression(
|
|
1793
1937
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
|
|
1794
1938
|
undefined,
|
|
@@ -1807,9 +1951,13 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
1807
1951
|
}
|
|
1808
1952
|
const transformed = ts.transform(expression.body, [transformer])
|
|
1809
1953
|
try {
|
|
1810
|
-
|
|
1954
|
+
let body = ts.isBlock(expression.body)
|
|
1811
1955
|
? transformed.transformed[0]
|
|
1812
1956
|
: factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
|
|
1957
|
+
if (snapshots.size) body = factory.updateBlock(body, [
|
|
1958
|
+
factory.createVariableStatement(undefined, factory.createVariableDeclarationList([...snapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(name)]))), ts.NodeFlags.Const)),
|
|
1959
|
+
...body.statements
|
|
1960
|
+
])
|
|
1813
1961
|
const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
|
|
1814
1962
|
if (expression.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) modifiers.push(factory.createModifier(ts.SyntaxKind.AsyncKeyword))
|
|
1815
1963
|
const declaration = factory.createFunctionDeclaration(
|
|
@@ -1827,6 +1975,24 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
1827
1975
|
}
|
|
1828
1976
|
}
|
|
1829
1977
|
|
|
1978
|
+
function nestedStateNames(expression, setters) {
|
|
1979
|
+
const states = new Set(setters.values())
|
|
1980
|
+
const names = new Set()
|
|
1981
|
+
const visit = node => {
|
|
1982
|
+
if (ts.isIdentifier(node) && states.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
|
|
1983
|
+
ts.forEachChild(node, visit)
|
|
1984
|
+
}
|
|
1985
|
+
visit(expression.body)
|
|
1986
|
+
return names
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function insideNestedFunction(node, root) {
|
|
1990
|
+
for (let current = node.parent; current && current !== root; current = current.parent) {
|
|
1991
|
+
if (isFunctionLike(current)) return true
|
|
1992
|
+
}
|
|
1993
|
+
return false
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1830
1996
|
function setterReference(factory, stateName) {
|
|
1831
1997
|
return factory.createArrowFunction(
|
|
1832
1998
|
undefined,
|
|
@@ -1960,10 +2126,17 @@ async function loadConfig() {
|
|
|
1960
2126
|
|
|
1961
2127
|
function normalizeBase(value) {
|
|
1962
2128
|
if (value == null || value === "" || value === "/") return ""
|
|
1963
|
-
if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) ||
|
|
2129
|
+
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")
|
|
2130
|
+
let decoded
|
|
2131
|
+
try { decoded = decodeURIComponent(value) } catch { throw new Error("kudzu.config base must be a root-relative path") }
|
|
2132
|
+
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
2133
|
return value.replace(/\/+$/, "")
|
|
1965
2134
|
}
|
|
1966
2135
|
|
|
2136
|
+
function browserPath(path) {
|
|
2137
|
+
return path ? new URL(path, "http://kudzu.local").pathname : ""
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1967
2140
|
function assetPath(base, path) {
|
|
1968
2141
|
return `${base}/${path}`
|
|
1969
2142
|
}
|
|
@@ -1986,6 +2159,43 @@ async function staticPathEntries(module, file) {
|
|
|
1986
2159
|
})
|
|
1987
2160
|
}
|
|
1988
2161
|
|
|
2162
|
+
function runtimeRouteSchema(module, file) {
|
|
2163
|
+
if (!Object.hasOwn(module, "runtimeParams")) return undefined
|
|
2164
|
+
if (module.runtimeParams !== true) throw new Error(`${relative(root, file)} runtimeParams must be exactly true`)
|
|
2165
|
+
if (typeof module.getStaticPaths === "function") throw new Error(`${relative(root, file)} runtimeParams cannot be combined with getStaticPaths()`)
|
|
2166
|
+
const route = pageRoutePattern(file)
|
|
2167
|
+
if (route.includes("[...")) throw new Error(`Catch-all routes are not supported: ${route}`)
|
|
2168
|
+
const names = new Set()
|
|
2169
|
+
const segments = route.split("/").map(segment => {
|
|
2170
|
+
const match = segment.match(/^\[([^\]]+)\]$/)
|
|
2171
|
+
if (!match) {
|
|
2172
|
+
if (/[\[\]]/.test(segment)) throw new Error(`${relative(root, file)} runtime parameters must occupy a complete path segment`)
|
|
2173
|
+
return { literal: segment }
|
|
2174
|
+
}
|
|
2175
|
+
const name = match[1]
|
|
2176
|
+
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)}`)
|
|
2177
|
+
if (names.has(name)) throw new Error(`${relative(root, file)} duplicate runtime parameter ${JSON.stringify(name)}`)
|
|
2178
|
+
names.add(name)
|
|
2179
|
+
return { param: name }
|
|
2180
|
+
})
|
|
2181
|
+
if (!names.size) throw new Error(`${relative(root, file)} runtimeParams requires a bracket page`)
|
|
2182
|
+
return { route, segments, params: [...names] }
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
function pageRoutePattern(file) {
|
|
2186
|
+
const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
|
|
2187
|
+
return page === "index" ? "" : page.replace(/\/index$/, "")
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function runtimeSpecificity(schema) {
|
|
2191
|
+
return schema.segments.filter(segment => segment.literal !== undefined).length
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function sameRuntimePrecedence(left, right) {
|
|
2195
|
+
if (left.segments.length !== right.segments.length || runtimeSpecificity(left) !== runtimeSpecificity(right)) return false
|
|
2196
|
+
return left.segments.every((segment, index) => segment.literal === undefined || right.segments[index].literal === undefined || segment.literal === right.segments[index].literal)
|
|
2197
|
+
}
|
|
2198
|
+
|
|
1989
2199
|
function routeFromPage(file, params = {}) {
|
|
1990
2200
|
const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
|
|
1991
2201
|
if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
|
package/framework/core.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
|
|
2
|
+
export type EffectCleanup = () => void | Promise<void>
|
|
2
3
|
|
|
3
4
|
export function useState<T>(initialValue: T): [T, StateSetter<T>]
|
|
4
|
-
export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
|
|
5
|
+
export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly []): void
|
|
6
|
+
export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
|
|
5
7
|
|
|
6
8
|
export interface RefObject<T> {
|
|
7
9
|
readonly current: T | null
|
|
@@ -48,24 +50,28 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
48
50
|
styles?: boolean | string[]
|
|
49
51
|
base?: string
|
|
50
52
|
effectAsset?: string
|
|
53
|
+
paramAsset?: string
|
|
54
|
+
runtimeParams?: string[]
|
|
51
55
|
},
|
|
52
56
|
props?: Props
|
|
53
57
|
): Promise<{
|
|
54
58
|
html: string
|
|
55
59
|
hasBehaviors: boolean
|
|
56
60
|
hasEffects: boolean
|
|
61
|
+
hasParams: boolean
|
|
57
62
|
hasBindings: boolean
|
|
58
63
|
hasLists: boolean
|
|
59
64
|
hasListStyles: boolean
|
|
60
65
|
hasStateSeed: boolean
|
|
61
66
|
plan: {
|
|
62
67
|
states: Array<{ id: string; name: string; initialValue: unknown }>
|
|
68
|
+
params: Array<{ name: string; id: string }>
|
|
63
69
|
events: Array<{
|
|
64
70
|
event: string
|
|
65
71
|
commands?: Array<[string, string, unknown]>
|
|
66
72
|
native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
|
|
67
73
|
}>
|
|
68
|
-
effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown
|
|
74
|
+
effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; cleanup?: true }>
|
|
69
75
|
bindings: Array<{
|
|
70
76
|
target: string
|
|
71
77
|
state?: string
|
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,21 +62,14 @@ 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
|
-
export function useEffect(callback, dependencies, module, handler, states, scope, source) {
|
|
67
|
+
export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup) {
|
|
48
68
|
if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
|
|
49
69
|
if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
|
|
50
70
|
throw new Error("useEffect() must be compiled with a literal empty dependency array")
|
|
51
71
|
}
|
|
52
|
-
renderContext.effects.push({ module, handler, states, scope, source })
|
|
72
|
+
renderContext.effects.push({ module, handler, states, scope, source, ...(cleanup ? { cleanup: true } : {}) })
|
|
53
73
|
renderContext.hasBehaviors = true
|
|
54
74
|
renderContext.hasEffects = true
|
|
55
75
|
}
|
|
@@ -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 })
|
|
@@ -251,6 +271,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
251
271
|
return {
|
|
252
272
|
module: effect.module,
|
|
253
273
|
handler: effect.handler,
|
|
274
|
+
...(effect.cleanup ? { cleanup: true } : {}),
|
|
254
275
|
...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
|
|
255
276
|
}
|
|
256
277
|
} catch (error) {
|
|
@@ -268,6 +289,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
268
289
|
const nativeRuntime = renderContext.hasNativeBehaviors
|
|
269
290
|
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
|
|
270
291
|
: ""
|
|
292
|
+
const paramRuntime = renderContext.hasParams
|
|
293
|
+
? `<script type="module" src="${escapeAttribute(metadata.paramAsset)}"></script>`
|
|
294
|
+
: ""
|
|
271
295
|
const bindingRuntime = renderContext.hasBindings
|
|
272
296
|
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
|
|
273
297
|
: ""
|
|
@@ -293,15 +317,17 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
293
317
|
: ""
|
|
294
318
|
|
|
295
319
|
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>`,
|
|
320
|
+
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
321
|
hasBehaviors: renderContext.hasBehaviors,
|
|
298
322
|
hasEffects: renderContext.hasEffects,
|
|
323
|
+
hasParams: renderContext.hasParams,
|
|
299
324
|
hasBindings: renderContext.hasBindings,
|
|
300
325
|
hasLists: renderContext.hasLists,
|
|
301
326
|
hasListStyles: renderContext.hasListStyles,
|
|
302
327
|
hasStateSeed: initialState.length > 0,
|
|
303
328
|
plan: {
|
|
304
329
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
330
|
+
params: renderContext.paramEntries,
|
|
305
331
|
events: renderContext.events,
|
|
306
332
|
effects: renderContext.effects,
|
|
307
333
|
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.8",
|
|
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"
|