@kudzujs/core 0.4.13 → 0.5.0

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
@@ -79,6 +79,43 @@ npm run dev
79
79
 
80
80
  Pages live in `src/pages`; `index.tsx` maps to `/`. `npm run dev` serves locally on `127.0.0.1`, reloads the browser after successful rebuilds, and shows build failures in an error overlay. Across that full-page reload, compatible Kudzu logical state is briefly preserved by route-unique state variable name for the current pathname, query, and hash, including controlled properties, conditions, and keyed-list arrays. Renamed, removed, and duplicate-named state is skipped. Uncontrolled DOM state, focus, selection, and imperative DOM mutations are not preserved. Set `PORT` to change the default port of `3000`. The development client and state snapshot are dev-only; production output in `dist/` is unaffected.
81
81
 
82
+ Dynamic static pages use bracket parameters and `getStaticPaths()`:
83
+
84
+ ```tsx
85
+ // src/pages/posts/[slug].tsx
86
+ export async function getStaticPaths() {
87
+ return [
88
+ { params: { slug: "oak" }, props: { title: "Oak" } },
89
+ { params: { slug: "pine" }, props: { title: "Pine" } }
90
+ ]
91
+ }
92
+
93
+ export default function Post({ title }: { title: string }) {
94
+ return <h1>{title}</h1>
95
+ }
96
+ ```
97
+
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
+
100
+ Static trusted HTML can be rendered without a transform layer:
101
+
102
+ ```tsx
103
+ <article dangerouslySetInnerHTML={{ __html: renderedNotionHtml }} />
104
+ ```
105
+
106
+ The HTML is intentionally not sanitized. Use only trusted or previously sanitized build-time content. Reactive raw HTML, children on the same element, void elements, and keyed-list raw HTML are rejected.
107
+
108
+ Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
109
+
110
+ ```js
111
+ export default {
112
+ base: "/newsletter",
113
+ async afterBuild({ outDir, routes, plans, base }) {
114
+ // Write RSS, sitemap, search indexes, or other static artifacts.
115
+ }
116
+ }
117
+ ```
118
+
82
119
  ## State Semantics
83
120
 
84
121
  Kudzu intentionally differs from React's state snapshot behavior:
@@ -106,6 +143,8 @@ const [weather, setWeather] = useState({ temperature: 28, label: "Warm" })
106
143
  return <p>{weather.temperature}° {weather.label}</p>
107
144
  ```
108
145
 
146
+ Derived text uses comment-bounded text nodes rather than wrapper elements, so table cells, options, SVG text, layout, and element selectors keep their authored structure.
147
+
109
148
  ## Reactive Attributes
110
149
 
111
150
  `className`, `disabled`, controlled `value`, and controlled `checked` accept normal state-dependent TSX expressions. The same `value` binding works for inputs and selects:
@@ -247,7 +286,18 @@ async function load() {
247
286
  }
248
287
  ```
249
288
 
250
- Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
289
+ Native handlers may call default, named, or namespace helpers imported from relative TypeScript modules. Kudzu bundles the reachable helper graph into handler ESM and shared chunks; helper runtime imports must remain relative, and dynamic imports or JSX helpers are rejected. Imported functions cannot be used directly as JSX event callbacks.
290
+
291
+ ```tsx
292
+ import { normalizeStatus } from "../lib/status"
293
+
294
+ async function load() {
295
+ const response = await fetch("/api/status")
296
+ setStatus(normalizeStatus(await response.json()))
297
+ }
298
+ ```
299
+
300
+ Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, and class instances are not supported as captures.
251
301
 
252
302
  Native handlers use direct DOM listeners with normal `currentTarget`, bubbling, default-action, and propagation semantics. Handler modules load before listener registration, so `preventDefault`, `stopPropagation`, and `stopImmediatePropagation` work synchronously as expected.
253
303
 
@@ -283,8 +333,12 @@ Supported:
283
333
  - Function components, props, children, fragments, and TSX
284
334
  - File-based static routes
285
335
  - Build-time async components
336
+ - Dynamic static routes with build-time props
337
+ - Static trusted `dangerouslySetInnerHTML`
338
+ - Base-path deployments, multiple CSS files, and `afterBuild`
286
339
  - Primitive `useState` bindings
287
340
  - Synchronous and async event handlers
341
+ - Relative imported helpers in native handlers
288
342
  - Serializable component-local captures
289
343
  - Direct text DOM patches
290
344
  - Reactive standard, `aria-*`, and `data-*` attributes
@@ -300,7 +354,7 @@ Not implemented yet:
300
354
 
301
355
  - Block-scoped JSX locals and reusable keyed-list aliases
302
356
  - Server actions and request-time SSR
303
- - Imported client helpers and React package islands
357
+ - React package islands
304
358
  - HMR and framework DevTools
305
359
 
306
360
  ## Benchmarks
@@ -323,6 +377,39 @@ Same counter with initial value `7` and increment/decrement buttons:
323
377
 
324
378
  Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
325
379
 
380
+ #### Imported Helper Cost
381
+
382
+ The same native counter calculation was measured inline and through one relative TypeScript helper. Click medians are per state update from five 20,000-click batches in each of seven fresh Chrome sessions.
383
+
384
+ | Kudzu variant | Files | Initial JS gzip | Total output | Clean build | Click |
385
+ |---|---:|---:|---:|---:|---:|
386
+ | Inline native handler | 5 | 1,827 B | 3,923 B | **426 ms** | **3.78 µs** |
387
+ | Imported helper | 5 | 1,845 B | 3,949 B | 446 ms | 4.47 µs |
388
+
389
+ Bundling removes the helper file boundary, leaving 26 raw bytes and 18 gzip bytes for the function definition and calls. The measured call adds 0.69 µs per state update. The smaller 393 B command-only counter above uses a different optimized runtime path and is not the helper overhead baseline.
390
+
391
+ #### Wrapper-Free Derived Text
392
+
393
+ The same object-state counter was built with the v0.4.14 span target and the comment-bounded text range. Browser medians use five 20,000-update batches in each of seven fresh Chrome sessions.
394
+
395
+ | Text target | Files | JS gzip | Total output | Clean build | Update |
396
+ |---|---:|---:|---:|---:|---:|
397
+ | Span v0.4.14 | 7 | **4,453 B** | **10,065 B** | **404 ms** | **4.83 µs** |
398
+ | Comment range | 7 | 4,763 B | 10,922 B | 426 ms | 5.03 µs |
399
+
400
+ The range costs 310 B gzip only on pages using derived reactive text. It removes wrapper elements and preserves authored structure across table cells, options, SVG text, selectors, and conditional remounts; ordinary attribute and condition pages tree-shake the range code entirely.
401
+
402
+ ### 123-Page Newsletter Build
403
+
404
+ The migration fixture emits the same 123 static detail pages, two stylesheets, base-prefixed URLs, and post-build feed with no browser JavaScript. Seven clean builds compare generated page files with one dynamic page module.
405
+
406
+ | Build model | TSX source files | Pages | JS gzip | Total output | Clean build |
407
+ |---|---:|---:|---:|---:|---:|
408
+ | Generated TSX workaround | 123 | 123 | 0 B | 52.0 KB | 882 ms |
409
+ | `getStaticPaths` | **1** | 123 | 0 B | 52.0 KB | **454 ms** |
410
+
411
+ `getStaticPaths` removes 122 generated source files and cuts clean build time by 48.5% without changing deploy output or runtime cost.
412
+
326
413
  ### Static Journal Page
327
414
 
328
415
  Same content and CSS across every fixture:
@@ -343,15 +430,15 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
343
430
 
344
431
  | Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
345
432
  |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
346
- | Astro | Yes | **324 B** | **43.6 KB** | 826 ms | **4.1 ms** | **3.7 ms** | **1.4 ms** | **3.3 ms** | **12.5 ms** |
347
- | Kudzu | Yes | 5.4 KB | 61.6 KB | **448 ms** | 8.6 ms | 8.3 ms | 2.4 ms | 8.6 ms | 27.9 ms |
348
- | Next.js | Yes | 182.2 KB | 695.2 KB | 3002 ms | 7.5 ms | 12.3 ms | 4.1 ms | 7.8 ms | 31.7 ms |
349
- | Vue CSR | No | 24.3 KB | 61.3 KB | 765 ms | 11.4 ms | 9.8 ms | 4.4 ms | 7.0 ms | 32.6 ms |
350
- | React CSR | No | 59.3 KB | 189.4 KB | 1039 ms | 9.9 ms | 13.4 ms | 4.7 ms | 6.1 ms | 34.1 ms |
351
- | Svelte CSR | No | 12.9 KB | 33.1 KB | 845 ms | 6.2 ms | 42.9 ms | 4.6 ms | 6.2 ms | 59.9 ms |
352
- | Qwik CSR | No | 22.2 KB | 64.1 KB | 630 ms | 10.7 ms | 27.5 ms | 39.2 ms | 22.2 ms | 99.6 ms |
353
-
354
- Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 27.9 ms, 15.4 ms behind the hand-authored Astro baseline and 6.2 ms ahead of React across all four operations.
433
+ | Astro | Yes | **324 B** | **43.6 KB** | 834 ms | **4.3 ms** | **3.8 ms** | **1.3 ms** | **3.1 ms** | **12.5 ms** |
434
+ | Kudzu | Yes | 5.1 KB | 60.3 KB | **438 ms** | 7.5 ms | 7.0 ms | 1.8 ms | 6.9 ms | 23.2 ms |
435
+ | Next.js | Yes | 182.2 KB | 695.2 KB | 2983 ms | 7.0 ms | 12.0 ms | 3.9 ms | 6.7 ms | 29.6 ms |
436
+ | React CSR | No | 59.3 KB | 189.4 KB | 1020 ms | 9.5 ms | 11.7 ms | 3.8 ms | 5.3 ms | 30.3 ms |
437
+ | Vue CSR | No | 24.3 KB | 61.3 KB | 773 ms | 11.4 ms | 9.5 ms | 4.1 ms | 6.6 ms | 31.6 ms |
438
+ | Svelte CSR | No | 12.9 KB | 33.1 KB | 828 ms | 5.8 ms | 38.9 ms | 4.0 ms | 5.9 ms | 54.6 ms |
439
+ | Qwik CSR | No | 22.2 KB | 64.1 KB | 594 ms | 9.1 ms | 22.2 ms | 30.8 ms | 19.0 ms | 81.1 ms |
440
+
441
+ Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 23.2 ms, 10.7 ms behind the hand-authored Astro baseline and 7.1 ms ahead of React across all four operations.
355
442
 
356
443
  Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
357
444
 
@@ -1,17 +1,17 @@
1
1
  # Framework Internals
2
2
 
3
- - `build.mjs`: TSX compilation, file routes, behavior extraction, static HTML output, and the development server.
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.
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.
7
7
  - `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
8
- - `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
8
+ - `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
9
9
  - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
10
10
  - `serialization.js`: capture deserialization shared by binding and native handlers.
11
11
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
12
12
  - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
13
13
  - `*.d.ts`: public TypeScript and JSX declarations.
14
14
 
15
- 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`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators live under `dist/assets/handlers/`. The dev server derives stable state identities from route-unique 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.
15
+ 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`. 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
16
 
17
17
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -9,13 +9,13 @@ const mountedBindings = new WeakSet()
9
9
  const mountedConditions = new WeakSet()
10
10
  const bindingRegistrations = new WeakMap()
11
11
  const conditionRegistrations = new WeakMap()
12
- const bindingTypes = ["text", "class", "disabled", "value", "checked", "style"]
12
+ const textDescriptors = globalThis.__KUDZU_TEXT_BINDINGS__ && typeof document !== "undefined" ? JSON.parse(document.body.dataset.kTextBindings ?? "[]") : []
13
+ const bindingTypes = ["class", "disabled", "value", "checked", "style"]
13
14
  const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
14
15
 
15
16
  export function patchBinding(node, target, value) {
16
- if (target === "text") {
17
- const next = value == null ? "" : String(value)
18
- if (node.textContent !== next) node.textContent = next
17
+ if (globalThis.__KUDZU_TEXT_BINDINGS__ && target === "text") {
18
+ patchText(node, value)
19
19
  } else if (target === "disabled") {
20
20
  node.toggleAttribute("disabled", Boolean(value))
21
21
  } else if (target === "checked") {
@@ -94,6 +94,25 @@ function mountBindings(root) {
94
94
  }).catch(error => console.error(error))
95
95
  }
96
96
  }
97
+ if (globalThis.__KUDZU_TEXT_BINDINGS__) {
98
+ for (const node of textBindingStarts(root)) {
99
+ if (mountedBindings.has(node)) continue
100
+ const descriptor = textDescriptors[Number(node.data.slice("k-text:".length))]
101
+ if (!descriptor) continue
102
+ mountedBindings.add(node)
103
+ const registrations = []
104
+ bindingRegistrations.set(node, registrations)
105
+ loadEvaluator(descriptor).then(evaluator => {
106
+ if (!node.isConnected) return
107
+ const binding = { node, target: "text", read: evaluator.read }
108
+ for (const id of evaluator.stateIds) {
109
+ register(bindingTargets, id, binding)
110
+ registrations.push([id, binding])
111
+ }
112
+ patchBinding(node, "text", binding.read())
113
+ }).catch(error => console.error(error))
114
+ }
115
+ }
97
116
  }
98
117
 
99
118
  function mountConditions(root) {
@@ -146,6 +165,13 @@ function unmountBindings(root) {
146
165
  bindingRegistrations.delete(node)
147
166
  mountedBindings.delete(node)
148
167
  }
168
+ if (globalThis.__KUDZU_TEXT_BINDINGS__) {
169
+ for (const node of textBindingStarts(root)) {
170
+ for (const [id, binding] of bindingRegistrations.get(node) ?? []) bindingTargets.get(id)?.delete(binding)
171
+ bindingRegistrations.delete(node)
172
+ mountedBindings.delete(node)
173
+ }
174
+ }
149
175
  }
150
176
 
151
177
  function unmountConditions(root) {
@@ -227,6 +253,28 @@ function matching(root, selector) {
227
253
  return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
228
254
  }
229
255
 
256
+ function textBindingStarts(root) {
257
+ const nodes = root.nodeType === 8 && root.data.startsWith("k-text:") ? [root] : []
258
+ const walker = (root.ownerDocument ?? root).createTreeWalker?.(root, 128)
259
+ while (walker?.nextNode()) if (walker.currentNode.data.startsWith("k-text:")) nodes.push(walker.currentNode)
260
+ return nodes
261
+ }
262
+
263
+ function patchText(start, value) {
264
+ const next = value == null ? "" : String(value)
265
+ const current = start.nextSibling
266
+ const text = current?.nodeType === 3 ? current : undefined
267
+ const end = text ? text.nextSibling : current
268
+ if (end?.nodeType !== 8 || end.data !== "k-text-end") throw new Error("Reactive text marker has no end")
269
+ if (text) {
270
+ if (next) {
271
+ if (text.data !== next) text.data = next
272
+ } else text.remove()
273
+ return
274
+ }
275
+ if (next) end.before(start.ownerDocument.createTextNode(next))
276
+ }
277
+
230
278
  function capitalize(value) {
231
279
  return value[0].toUpperCase() + value.slice(1)
232
280
  }
@@ -1,9 +1,9 @@
1
1
  import { createServer } from "node:http"
2
2
  import { randomUUID } from "node:crypto"
3
3
  import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
4
- import { extname, join, relative, resolve, sep } from "node:path"
4
+ import { dirname, extname, join, relative, resolve, sep } from "node:path"
5
5
  import { pathToFileURL } from "node:url"
6
- import { transform } from "esbuild"
6
+ import { build as bundle, transform } from "esbuild"
7
7
  import ts from "typescript"
8
8
  import { renderPage } from "./core.mjs"
9
9
  import { stateSchema } from "./dev-state.js"
@@ -17,17 +17,22 @@ const outputDirectory = join(root, "dist")
17
17
  const devClient = (session, revision, schema) => `<script>(()=>{const show=event=>{let box=document.getElementById("__kudzu_error");if(!box){box=document.createElement("div");box.id="__kudzu_error";box.setAttribute("role","alert");box.setAttribute("aria-live","assertive");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace";const title=document.createElement("strong"),text=document.createElement("pre");title.textContent="Kudzu build error";text.style.whiteSpace="pre-wrap";box.append(title,text);document.body.append(box)}box.querySelector("pre").textContent=event.data};const schema=${inlineJson(schema)},route=location.pathname+location.search+location.hash,urls=[...document.querySelectorAll('script[type="module"][src]')].map(node=>node.src).filter(url=>/\\/assets\\/kudzu(?:-(?:binding|list|native))?\\.js$/.test(new URL(url).pathname));const devImport=import("/__kudzu_dev.js"),runtimeImports=Promise.allSettled(urls.map(url=>import(url)));const ready=(async()=>{const dev=await devImport,modules=await runtimeImports,runtime=modules.find(result=>result.status==="fulfilled"&&result.value.browserState instanceof Map&&typeof result.value.commitDom==="function")?.value;try{dev.restoreState(sessionStorage,route,runtime?.browserState,schema,runtime?.commitDom)}catch{}return{dev,runtime}})().catch(()=>({}));const events=new EventSource("/__kudzu_reload?session=${session}&revision=${revision}");let reloading=false;events.addEventListener("reload",async()=>{if(reloading)return;reloading=true;try{const{dev,runtime}=await ready;dev?.snapshotState(sessionStorage,route,runtime?.browserState,schema)}catch{}location.reload()});events.addEventListener("build-error",show)})()</script>`
18
18
 
19
19
  export async function build({ quiet = false, minify = true } = {}) {
20
+ const config = await loadConfig()
21
+ const base = normalizeBase(config.base)
20
22
  await rm(workDirectory, { recursive: true, force: true })
21
23
  await rm(outputDirectory, { recursive: true, force: true })
22
24
  await mkdir(workDirectory, { recursive: true })
23
25
  await mkdir(outputDirectory, { recursive: true })
24
26
 
25
- const sourceFiles = (await walk(sourceDirectory)).filter(file => /\.(?:ts|tsx)$/.test(file)).sort()
27
+ const projectFiles = await walk(sourceDirectory)
28
+ const sourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file)).sort()
29
+ const cssFiles = projectFiles.filter(file => file.endsWith(".css")).sort()
26
30
  if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
31
+ const sourceFileSet = new Set(sourceFiles)
27
32
 
28
33
  const handlerModules = []
29
34
  for (const file of sourceFiles) {
30
- const handlerModule = await compile(file)
35
+ const handlerModule = await compile(file, sourceFileSet, base)
31
36
  if (handlerModule) handlerModules.push(handlerModule)
32
37
  }
33
38
 
@@ -40,34 +45,44 @@ export async function build({ quiet = false, minify = true } = {}) {
40
45
  let listStyleCount = 0
41
46
  let stateSeedCount = 0
42
47
  const plans = []
43
- const hasStyles = await exists(join(sourceDirectory, "style.css"))
48
+ const emittedRoutes = new Set()
49
+ const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
44
50
 
45
51
  for (const pageFile of pageFiles) {
46
52
  const compiledFile = compiledPath(pageFile)
47
53
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
48
54
  if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
49
55
 
50
- const result = await renderPage(module.default, {
51
- ...(module.metadata ?? {}),
52
- styles: hasStyles
53
- })
54
- const route = routeFromPage(pageFile)
55
- const routeDirectory = join(outputDirectory, route)
56
- await mkdir(routeDirectory, { recursive: true })
57
- await writeFile(join(routeDirectory, "index.html"), result.html)
58
- plans.push({ route: `/${route}`, ...result.plan })
59
- if (result.hasBehaviors) behaviorCount++
60
- if (result.hasBindings) bindingCount++
61
- if (result.hasLists) listCount++
62
- if (result.hasListStyles) listStyleCount++
63
- if (result.hasStateSeed) stateSeedCount++
56
+ const entries = await staticPathEntries(module, pageFile)
57
+ for (const { params, props } of entries) {
58
+ const route = routeFromPage(pageFile, params)
59
+ const routePath = withBase(base, `/${route}`)
60
+ if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
61
+ emittedRoutes.add(routePath)
62
+ const result = await renderPage(module.default, {
63
+ ...(module.metadata ?? {}),
64
+ styles: styleUrls.length ? styleUrls : false,
65
+ base
66
+ }, props)
67
+ const routeDirectory = join(outputDirectory, route)
68
+ await mkdir(routeDirectory, { recursive: true })
69
+ await writeFile(join(routeDirectory, "index.html"), result.html)
70
+ plans.push({ route: routePath, ...result.plan })
71
+ if (result.hasBehaviors) behaviorCount++
72
+ if (result.hasBindings) bindingCount++
73
+ if (result.hasLists) listCount++
74
+ if (result.hasListStyles) listStyleCount++
75
+ if (result.hasStateSeed) stateSeedCount++
76
+ }
64
77
  }
65
78
 
66
79
  const assetsDirectory = join(outputDirectory, "assets")
67
80
  await mkdir(assetsDirectory, { recursive: true })
68
81
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
69
82
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
70
- const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => `/assets/${module.path}`)
83
+ const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
84
+ const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
85
+ const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
71
86
  const hasNativeHandlers = nativeModules.length > 0
72
87
  if (behaviorCount) {
73
88
  const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
@@ -81,7 +96,7 @@ export async function build({ quiet = false, minify = true } = {}) {
81
96
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
82
97
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
83
98
  .replace('"./style.js"', '"./kudzu-style.js"')
84
- await writeJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify)
99
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, { "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings) })
85
100
  }
86
101
  if (listCount) {
87
102
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
@@ -94,7 +109,7 @@ export async function build({ quiet = false, minify = true } = {}) {
94
109
  }`
95
110
  listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
96
111
  if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
97
- await writeJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify)
112
+ await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, { __KUDZU_LIST_CONDITIONS__: String(hasListConditions) })
98
113
  }
99
114
  if (hasNativeHandlers) {
100
115
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
@@ -107,11 +122,43 @@ export async function build({ quiet = false, minify = true } = {}) {
107
122
  await mkdir(resolve(output, ".."), { recursive: true })
108
123
  await writeJavaScript(output, handlerModule.code, minify)
109
124
  }
125
+ const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
126
+ for (const file of clientModules) {
127
+ const output = join(assetsDirectory, clientModulePath(file))
128
+ await mkdir(resolve(output, ".."), { recursive: true })
129
+ await writeJavaScript(output, await compileClientModule(file, sourceFileSet), minify)
130
+ }
131
+ if (clientModules.length) {
132
+ await bundle({
133
+ entryPoints: handlerModules.map(module => join(assetsDirectory, module.path)),
134
+ outbase: join(assetsDirectory, "handlers"),
135
+ outdir: join(assetsDirectory, "handlers"),
136
+ entryNames: "[dir]/[name]",
137
+ chunkNames: "chunks/[name]-[hash]",
138
+ allowOverwrite: true,
139
+ bundle: true,
140
+ splitting: true,
141
+ format: "esm",
142
+ target: "es2022",
143
+ minify,
144
+ legalComments: "none",
145
+ logLevel: "silent"
146
+ })
147
+ await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
148
+ }
110
149
  await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
111
- if (hasStyles) await cp(join(sourceDirectory, "style.css"), join(assetsDirectory, "style.css"))
150
+ for (const file of cssFiles) {
151
+ const output = join(assetsDirectory, relative(sourceDirectory, file))
152
+ await mkdir(dirname(output), { recursive: true })
153
+ await cp(file, output)
154
+ }
112
155
  if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
156
+ if (config.afterBuild !== undefined) {
157
+ if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
158
+ await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans })
159
+ }
113
160
 
114
- if (!quiet) console.log(`Built ${pageFiles.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
161
+ if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
115
162
  }
116
163
 
117
164
  function specializeEvents(source, events) {
@@ -137,6 +184,22 @@ async function writeJavaScript(file, source, minify) {
137
184
  await writeFile(file, code)
138
185
  }
139
186
 
187
+ async function writeBundledJavaScript(file, source, minify, define) {
188
+ const result = await bundle({
189
+ stdin: { contents: source, resolveDir: dirname(file), sourcefile: file },
190
+ bundle: true,
191
+ write: false,
192
+ external: ["./kudzu.js", "./kudzu-serialization.js", "./kudzu-style.js"],
193
+ define,
194
+ format: "esm",
195
+ target: "es2022",
196
+ minify,
197
+ legalComments: "none",
198
+ logLevel: "silent"
199
+ })
200
+ await writeFile(file, result.outputFiles[0].contents)
201
+ }
202
+
140
203
  export function parseDevPort(value) {
141
204
  if (value === undefined || value.trim() === "") return 3000
142
205
  if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
@@ -147,6 +210,7 @@ export function parseDevPort(value) {
147
210
 
148
211
  export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
149
212
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
213
+ const base = normalizeBase((await loadConfig()).base)
150
214
 
151
215
  let buildError
152
216
  let revision = 0
@@ -184,7 +248,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
184
248
  return
185
249
  }
186
250
 
187
- const relativePath = pathname.replace(/^\/+/, "")
251
+ const relativePath = stripBase(pathname, base).replace(/^\/+/, "")
188
252
  let file = resolve(outputDirectory, relativePath)
189
253
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
190
254
 
@@ -245,6 +309,12 @@ function injectDevClient(html, session, revision, schema) {
245
309
  return `${html}${devClient(session, revision, schema)}`
246
310
  }
247
311
 
312
+ function stripBase(path, base) {
313
+ if (!base) return path
314
+ if (path === base) return "/"
315
+ return path.startsWith(`${base}/`) ? path.slice(base.length) : path
316
+ }
317
+
248
318
  async function devSchema(pathname) {
249
319
  try {
250
320
  const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
@@ -275,11 +345,12 @@ function escapeHtml(value) {
275
345
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
276
346
  }
277
347
 
278
- async function compile(file) {
348
+ async function compile(file, sourceFiles, base) {
279
349
  const source = await readFile(file, "utf8")
280
350
  const nativeHandlers = []
281
351
  const reactiveBindings = []
282
352
  const listExpressions = []
353
+ const clientImports = new Set()
283
354
  const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
284
355
  const result = ts.transpileModule(source, {
285
356
  fileName: file,
@@ -289,7 +360,7 @@ async function compile(file) {
289
360
  jsx: ts.JsxEmit.ReactJSX,
290
361
  jsxImportSource: "@kudzujs/core"
291
362
  },
292
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, `/assets/${handlerPath}`)] },
363
+ transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, clientImports)] },
293
364
  reportDiagnostics: true
294
365
  })
295
366
 
@@ -304,6 +375,7 @@ async function compile(file) {
304
375
 
305
376
  if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
306
377
  const moduleSource = [
378
+ printClientImports(nativeHandlers.flatMap(handler => handler.imports), handlerPath),
307
379
  ...nativeHandlers.map(handler => printNativeHandler(handler)),
308
380
  ...reactiveBindings.map(entry => printReactiveBinding(entry)),
309
381
  ...listExpressions.map(entry => printListExpression(entry))
@@ -314,12 +386,13 @@ async function compile(file) {
314
386
  })
315
387
  const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
316
388
  if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
317
- return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
389
+ return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
318
390
  }
319
391
 
320
- function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl) {
392
+ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, clientImports) {
321
393
  return context => sourceFile => {
322
394
  const factory = context.factory
395
+ const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
323
396
  const settersByFunction = new Map()
324
397
  const functions = new Map()
325
398
  const jsxLocalDeclarations = new Map()
@@ -501,7 +574,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
501
574
 
502
575
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
503
576
  const setters = settersForNode(node, settersByFunction)
504
- const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node))
577
+ const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node), importBindings, clientImports)
505
578
  if (event) {
506
579
  usesBehavior = true
507
580
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
@@ -810,17 +883,20 @@ function factoryNull() {
810
883
  return ts.factory.createNull()
811
884
  }
812
885
 
813
- function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem) {
886
+ function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem, importBindings, clientImports) {
814
887
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
815
888
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
816
889
 
817
890
  const optimized = compileOptimizedEvent(expression, setters, factory)
818
891
  if (optimized) return optimized
819
892
 
820
- const captures = nativeCaptureNames(expression, setters)
893
+ const allCaptures = nativeCaptureNames(expression, setters)
894
+ const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
895
+ const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
896
+ for (const entry of imports) clientImports.add(entry.target)
821
897
  const usedStates = nativeStateNames(expression, setters)
822
898
  const exportName = `handler${nativeHandlers.length}`
823
- nativeHandlers.push({ exportName, expression, captures, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
899
+ nativeHandlers.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
824
900
  const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
825
901
  factory.createStringLiteral(name),
826
902
  factory.createIdentifier(name)
@@ -871,6 +947,16 @@ function nativeCaptureNames(expression, setters) {
871
947
  return captureNames(expression, expression.body, setters)
872
948
  }
873
949
 
950
+ function referencedImportedBindings(expression, imports) {
951
+ const names = new Set()
952
+ const visit = node => {
953
+ if (ts.isIdentifier(node) && imports.has(node.text) && isReferenceIdentifier(node)) names.add(node.text)
954
+ ts.forEachChild(node, visit)
955
+ }
956
+ visit(expression.body)
957
+ return names
958
+ }
959
+
874
960
  function captureNames(declarationRoot, referenceRoot, setters) {
875
961
  const local = new Set()
876
962
  const collectDeclarations = node => {
@@ -937,6 +1023,135 @@ function settersForNode(node, settersByFunction) {
937
1023
  return new Map()
938
1024
  }
939
1025
 
1026
+ function clientImportBindings(sourceFile, file, sourceFiles) {
1027
+ const bindings = new Map()
1028
+ for (const node of sourceFile.statements) {
1029
+ if (!ts.isImportDeclaration(node) || !node.importClause || node.importClause.isTypeOnly || !ts.isStringLiteral(node.moduleSpecifier) || !node.moduleSpecifier.text.startsWith(".")) continue
1030
+ const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
1031
+ if (node.importClause.name) bindings.set(node.importClause.name.text, { kind: "default", local: node.importClause.name.text, target })
1032
+ const named = node.importClause.namedBindings
1033
+ if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { kind: "namespace", local: named.name.text, target })
1034
+ if (named && ts.isNamedImports(named)) {
1035
+ for (const entry of named.elements) {
1036
+ if (!entry.isTypeOnly) bindings.set(entry.name.text, { kind: "named", imported: (entry.propertyName ?? entry.name).text, local: entry.name.text, target })
1037
+ }
1038
+ }
1039
+ }
1040
+ return bindings
1041
+ }
1042
+
1043
+ function printClientImports(entries, handlerPath) {
1044
+ const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
1045
+ const groups = Map.groupBy(unique.values(), entry => entry.target)
1046
+ const imports = []
1047
+ for (const [target, group] of groups) {
1048
+ const specifier = relativeModulePath(handlerPath, clientModulePath(target))
1049
+ const defaults = group.filter(entry => entry.kind === "default")
1050
+ const named = group.filter(entry => entry.kind === "named")
1051
+ if (defaults.length === 1 || named.length) imports.push(`import ${defaults.length === 1 ? `${defaults[0].local}${named.length ? ", " : ""}` : ""}${named.length ? `{ ${named.map(entry => entry.imported === entry.local ? entry.local : `${entry.imported} as ${entry.local}`).join(", ")} }` : ""} from ${JSON.stringify(specifier)}`)
1052
+ if (defaults.length > 1) for (const entry of defaults) imports.push(`import ${entry.local} from ${JSON.stringify(specifier)}`)
1053
+ for (const entry of group.filter(entry => entry.kind === "namespace")) imports.push(`import * as ${entry.local} from ${JSON.stringify(specifier)}`)
1054
+ }
1055
+ return imports.join("\n")
1056
+ }
1057
+
1058
+ async function collectClientModules(entries, sourceFiles) {
1059
+ const modules = new Set()
1060
+ const queue = [...new Set(entries)]
1061
+ while (queue.length) {
1062
+ const file = queue.shift()
1063
+ if (modules.has(file)) continue
1064
+ const source = await readFile(file, "utf8")
1065
+ const sourceFile = parseSourceFile(file, source)
1066
+ if (containsJsx(sourceFile)) throw new Error(`${relative(root, file)} Imported client helpers must not contain JSX`)
1067
+ rejectUnsupportedClientImports(sourceFile, file)
1068
+ modules.add(file)
1069
+ for (const node of sourceFile.statements) {
1070
+ if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier) || !runtimeModuleReference(node)) continue
1071
+ if (!node.moduleSpecifier.text.startsWith(".")) throw new Error(`${relative(root, file)} Imported client helpers may only use relative runtime imports`)
1072
+ queue.push(resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles))
1073
+ }
1074
+ }
1075
+ const outputs = new Map()
1076
+ for (const file of modules) {
1077
+ const output = clientModulePath(file)
1078
+ if (outputs.has(output)) throw new Error(`${relative(root, file)} and ${relative(root, outputs.get(output))} emit the same client module path`)
1079
+ outputs.set(output, file)
1080
+ }
1081
+ return [...modules].sort()
1082
+ }
1083
+
1084
+ async function compileClientModule(file, sourceFiles) {
1085
+ const source = await readFile(file, "utf8")
1086
+ const transformer = context => sourceFile => {
1087
+ const factory = context.factory
1088
+ const visitor = node => {
1089
+ if (ts.isImportDeclaration(node) && runtimeModuleReference(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
1090
+ const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
1091
+ return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(clientModulePath(file), clientModulePath(target))), node.attributes)
1092
+ }
1093
+ if (ts.isExportDeclaration(node) && runtimeModuleReference(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
1094
+ const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
1095
+ return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(clientModulePath(file), clientModulePath(target))), node.attributes)
1096
+ }
1097
+ return ts.visitEachChild(node, visitor, context)
1098
+ }
1099
+ return ts.visitNode(sourceFile, visitor)
1100
+ }
1101
+ const result = ts.transpileModule(source, {
1102
+ fileName: file,
1103
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
1104
+ transformers: { before: [transformer] },
1105
+ reportDiagnostics: true
1106
+ })
1107
+ const errors = result.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
1108
+ if (errors.length) throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
1109
+ return result.outputText
1110
+ }
1111
+
1112
+ function resolveSourceImport(importer, specifier, sourceFiles) {
1113
+ const base = resolve(dirname(importer), specifier)
1114
+ const extension = extname(base)
1115
+ const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
1116
+ const candidates = extension === ".ts" || extension === ".tsx"
1117
+ ? [base]
1118
+ : [`${stem}.ts`, `${stem}.tsx`]
1119
+ const matches = candidates.filter(candidate => sourceFiles.has(candidate))
1120
+ if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
1121
+ return matches[0]
1122
+ }
1123
+
1124
+ function runtimeModuleReference(node) {
1125
+ if (ts.isExportDeclaration(node)) return !node.isTypeOnly && (!node.exportClause || !ts.isNamedExports(node.exportClause) || node.exportClause.elements.some(entry => !entry.isTypeOnly))
1126
+ const clause = node.importClause
1127
+ if (!clause) return true
1128
+ if (clause.isTypeOnly) return false
1129
+ if (clause.name || clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) return true
1130
+ return clause.namedBindings?.elements.some(entry => !entry.isTypeOnly) ?? false
1131
+ }
1132
+
1133
+ function rejectUnsupportedClientImports(sourceFile, file) {
1134
+ const visit = node => {
1135
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) throw new Error(`${relative(root, file)} Dynamic imports are not supported in imported client helpers`)
1136
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") throw new Error(`${relative(root, file)} require() is not supported in imported client helpers`)
1137
+ ts.forEachChild(node, visit)
1138
+ }
1139
+ visit(sourceFile)
1140
+ }
1141
+
1142
+ function parseSourceFile(file, source) {
1143
+ return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
1144
+ }
1145
+
1146
+ function clientModulePath(file) {
1147
+ return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
1148
+ }
1149
+
1150
+ function relativeModulePath(from, to) {
1151
+ const path = relative(dirname(from), to).replaceAll(sep, "/")
1152
+ return path.startsWith(".") ? path : `./${path}`
1153
+ }
1154
+
940
1155
  function printNativeHandler({ exportName, expression, captures, setters }) {
941
1156
  const factory = ts.factory
942
1157
  const stateNames = new Set(setters.values())
@@ -1102,9 +1317,59 @@ function compiledPath(file) {
1102
1317
  return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
1103
1318
  }
1104
1319
 
1105
- function routeFromPage(file) {
1320
+ async function loadConfig() {
1321
+ for (const name of ["kudzu.config.mjs", "kudzu.config.js"]) {
1322
+ const file = join(root, name)
1323
+ if (!(await exists(file))) continue
1324
+ const config = (await import(`${pathToFileURL(file).href}?v=${Date.now()}-${randomUUID()}`)).default ?? {}
1325
+ if (!isPlainRecord(config)) throw new Error(`${name} must export a default object`)
1326
+ return config
1327
+ }
1328
+ return {}
1329
+ }
1330
+
1331
+ function normalizeBase(value) {
1332
+ if (value == null || value === "" || value === "/") return ""
1333
+ if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || value.split("/").includes("..")) throw new Error("kudzu.config base must be a root-relative path")
1334
+ return value.replace(/\/+$/, "")
1335
+ }
1336
+
1337
+ function assetPath(base, path) {
1338
+ return `${base}/${path}`
1339
+ }
1340
+
1341
+ function withBase(base, path) {
1342
+ return base ? `${base}${path}` : path
1343
+ }
1344
+
1345
+ async function staticPathEntries(module, file) {
1346
+ if (typeof module.getStaticPaths !== "function") return [{ params: {}, props: {} }]
1347
+ const entries = await module.getStaticPaths()
1348
+ if (!Array.isArray(entries)) throw new Error(`${relative(root, file)} getStaticPaths() must return an array`)
1349
+ return entries.map((entry, index) => {
1350
+ if (!isPlainRecord(entry)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}] must be an object`)
1351
+ const params = entry.params ?? {}
1352
+ const props = entry.props ?? {}
1353
+ if (!isPlainRecord(params)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].params must be an object`)
1354
+ if (!isPlainRecord(props)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].props must be an object`)
1355
+ return { params, props }
1356
+ })
1357
+ }
1358
+
1359
+ function routeFromPage(file, params = {}) {
1106
1360
  const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
1107
- return page === "index" ? "" : page.replace(/\/index$/, "")
1361
+ if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
1362
+ const filled = page.replace(/\[([^\]]+)\]/g, (_, name) => {
1363
+ if (!Object.hasOwn(params, name)) throw new Error(`Missing param "${name}" for route ${page}`)
1364
+ const value = String(params[name])
1365
+ if (!value || value === "." || value === ".." || /[\\/\0?#]/.test(value)) throw new Error(`Invalid param "${name}" for route ${page}`)
1366
+ return value
1367
+ })
1368
+ return filled === "index" ? "" : filled.replace(/\/index$/, "")
1369
+ }
1370
+
1371
+ function isPlainRecord(value) {
1372
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
1108
1373
  }
1109
1374
 
1110
1375
  async function walk(directory) {
@@ -26,8 +26,8 @@ export function listExpression(read: () => unknown, module: string, handler: str
26
26
  export function listItem(): unknown
27
27
  export function listConditional(kind: "and" | "ternary", read: () => unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string): unknown
28
28
 
29
- export function renderPage(
30
- component: (props: Record<string, never>) => unknown | Promise<unknown>,
29
+ export function renderPage<Props = Record<string, never>>(
30
+ component: (props: Props) => unknown | Promise<unknown>,
31
31
  metadata?: {
32
32
  title?: string
33
33
  description?: string
@@ -44,8 +44,10 @@ export function renderPage(
44
44
  icon?: string
45
45
  appleTouchIcon?: string
46
46
  manifest?: string
47
- styles?: boolean
48
- }
47
+ styles?: boolean | string[]
48
+ base?: string
49
+ },
50
+ props?: Props
49
51
  ): Promise<{
50
52
  html: string
51
53
  hasBehaviors: boolean
@@ -220,27 +220,27 @@ function serializeCapture(name, value, seen) {
220
220
  }
221
221
  }
222
222
 
223
- export async function renderPage(component, metadata = {}) {
224
- 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: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
223
+ export async function renderPage(component, metadata = {}, props = {}) {
224
+ 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: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
225
225
 
226
226
  try {
227
- const body = await renderNode({ type: component, props: {} })
227
+ const body = await renderNode({ type: component, props })
228
228
  const title = escapeHtml(metadata.title ?? "Kudzu")
229
229
  const head = renderMetadata(metadata)
230
230
  const styles = metadata.styles === false
231
231
  ? ""
232
- : '<link rel="stylesheet" href="/assets/style.css">'
232
+ : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
233
233
  const runtime = renderContext.hasBehaviors
234
- ? '<script type="module" src="/assets/kudzu.js"></script>'
234
+ ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu.js")}"></script>`
235
235
  : ""
236
236
  const nativeRuntime = renderContext.hasNativeBehaviors
237
- ? '<script type="module" src="/assets/kudzu-native.js"></script>'
237
+ ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
238
238
  : ""
239
239
  const bindingRuntime = renderContext.hasBindings
240
- ? '<script type="module" src="/assets/kudzu-binding.js"></script>'
240
+ ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
241
241
  : ""
242
242
  const listRuntime = renderContext.hasLists
243
- ? '<script type="module" src="/assets/kudzu-list.js"></script>'
243
+ ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
244
244
  : ""
245
245
  const listStates = new Set(renderContext.lists.map(list => list.state))
246
246
  const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
@@ -253,9 +253,12 @@ export async function renderPage(component, metadata = {}) {
253
253
  const state = initialState.length
254
254
  ? ` data-k-state='${escapeJsonAttribute(initialState)}'`
255
255
  : ""
256
+ const textBindings = renderContext.textBindings.length
257
+ ? ` data-k-text-bindings='${escapeJsonAttribute(renderContext.textBindings)}'`
258
+ : ""
256
259
 
257
260
  return {
258
- 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}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}</body></html>`,
261
+ 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}</body></html>`,
259
262
  hasBehaviors: renderContext.hasBehaviors,
260
263
  hasBindings: renderContext.hasBindings,
261
264
  hasLists: renderContext.hasLists,
@@ -283,9 +286,9 @@ function renderMetadata(metadata) {
283
286
  if (metadata.description) meta("description", metadata.description)
284
287
  if (metadata.themeColor) meta("theme-color", metadata.themeColor)
285
288
  if (metadata.url) tags.push(`<link rel="canonical" href="${escapeAttribute(metadata.url)}">`)
286
- if (metadata.icon) tags.push(`<link rel="icon" href="${escapeAttribute(metadata.icon)}">`)
287
- if (metadata.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${escapeAttribute(metadata.appleTouchIcon)}">`)
288
- if (metadata.manifest) tags.push(`<link rel="manifest" href="${escapeAttribute(metadata.manifest)}">`)
289
+ if (metadata.icon) tags.push(`<link rel="icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.icon))}">`)
290
+ if (metadata.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.appleTouchIcon))}">`)
291
+ if (metadata.manifest) tags.push(`<link rel="manifest" href="${escapeAttribute(baseUrl(metadata.base, metadata.manifest))}">`)
289
292
 
290
293
  meta("og:title", metadata.title, true)
291
294
  meta("og:description", metadata.description, true)
@@ -307,6 +310,14 @@ function renderMetadata(metadata) {
307
310
  return tags.join("")
308
311
  }
309
312
 
313
+ function assetPath(base, path) {
314
+ return `${base ?? ""}/${path}`
315
+ }
316
+
317
+ function baseUrl(base, value) {
318
+ return value.startsWith("/") ? `${base ?? ""}${value}` : value
319
+ }
320
+
310
321
  async function renderNode(node, namespace, selectValue = noSelectValue) {
311
322
  if (node == null || node === false || node === true) return ""
312
323
  if (Array.isArray(node)) {
@@ -370,7 +381,9 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
370
381
  if (renderContext.conditionDepth || renderContext.listDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
371
382
  renderContext.hasBehaviors = true
372
383
  renderContext.hasBindings = true
373
- return `<span data-k-bind-text='${escapeJsonAttribute(descriptor)}'>${escapeHtml(node.value ?? "")}</span>`
384
+ const id = renderContext.textBindings.length
385
+ renderContext.textBindings.push(descriptor)
386
+ return `<!--k-text:${id}-->${escapeHtml(node.value ?? "")}<!--k-text-end-->`
374
387
  }
375
388
  if (node?.[listConditionalMarker]) {
376
389
  const descriptor = { kind: node.kind, module: node.module, handler: node.handler }
@@ -412,6 +425,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
412
425
  const listAttributes = []
413
426
  const listExpressionAttributes = []
414
427
  const listEvents = []
428
+ let rawHtml
415
429
 
416
430
  if (renderContext.listRoot) {
417
431
  const root = renderContext.listRoot
@@ -433,6 +447,14 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
433
447
  if (["ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
434
448
  throw new Error(`Reactive ${rawName} is not supported`)
435
449
  }
450
+ if (rawName === "dangerouslySetInnerHTML") {
451
+ if (renderContext.listDepth) throw new Error("dangerouslySetInnerHTML is not supported in keyed lists")
452
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "__html")) throw new Error("dangerouslySetInnerHTML requires { __html }")
453
+ if (value.__html?.[signalMarker] || value.__html?.[bindingMarker]) throw new Error("Reactive dangerouslySetInnerHTML is not supported")
454
+ if (props.children != null) throw new Error("dangerouslySetInnerHTML cannot be used with children")
455
+ rawHtml = value.__html == null ? "" : String(value.__html)
456
+ continue
457
+ }
436
458
 
437
459
  if (/^on[A-Z]/.test(rawName)) {
438
460
  const event = rawName.slice(2).toLowerCase()
@@ -504,8 +526,11 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
504
526
  if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
505
527
 
506
528
  const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
507
- if (voidElements.has(tag)) return `<${tag}${attributes}>`
508
- const children = directListText ? escapeHtml(directListText.value ?? "") : await renderNode(props.children, childNamespace, childSelectValue)
529
+ if (voidElements.has(tag)) {
530
+ if (rawHtml !== undefined) throw new Error(`dangerouslySetInnerHTML cannot be used on <${tag}>`)
531
+ return `<${tag}${attributes}>`
532
+ }
533
+ const children = rawHtml ?? (directListText ? escapeHtml(directListText.value ?? "") : await renderNode(props.children, childNamespace, childSelectValue))
509
534
  return `<${tag}${attributes}>${children}</${tag}>`
510
535
  }
511
536
 
@@ -7,7 +7,7 @@ const imports = new Map()
7
7
  const revisions = new WeakMap()
8
8
  const itemParts = new WeakMap()
9
9
  const conditionOwners = new WeakMap()
10
- const itemPartsSelector = "[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs],[data-k-list-condition]"
10
+ const itemPartsSelector = `[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
11
11
 
12
12
  function commitLists(id) {
13
13
  const lists = listTargets.get(id)
@@ -33,7 +33,7 @@ function mountLists(root) {
33
33
  const roots = listRoots(start, end)
34
34
  const templateRoot = start.content.firstElementChild
35
35
  const parts = listItemPartPlan(templateRoot)
36
- for (const root of roots) descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
36
+ for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
37
37
  if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
38
38
  const items = browserState.get(descriptor.state)
39
39
  const list = {
@@ -183,10 +183,12 @@ function fillListParts(root, parts, item, revision) {
183
183
  }).catch(error => console.error(error))
184
184
  }
185
185
  }
186
- for (const [marker, descriptor] of parts.conditions) {
187
- evaluate(descriptor, item).then(value => {
188
- if (revisions.get(root) === revision && root.isConnected) updateListCondition(marker, descriptor.kind, value, item)
189
- }).catch(error => console.error(error))
186
+ if (__KUDZU_LIST_CONDITIONS__) {
187
+ for (const [marker, descriptor] of parts.conditions) {
188
+ evaluate(descriptor, item).then(value => {
189
+ if (revisions.get(root) === revision && root.isConnected) updateListCondition(marker, descriptor.kind, value, item)
190
+ }).catch(error => console.error(error))
191
+ }
190
192
  }
191
193
  }
192
194
 
@@ -200,7 +202,7 @@ function listItemParts(root) {
200
202
  if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
201
203
  if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
202
204
  if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
203
- if (node.hasAttribute("data-k-list-condition")) {
205
+ if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
204
206
  parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
205
207
  conditionOwners.set(node, root)
206
208
  }
@@ -220,7 +222,7 @@ function listItemPartPlan(template) {
220
222
  events: parts.events.map(([node, events]) => [indexes.get(node), events]),
221
223
  expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
222
224
  expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
223
- conditions: parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor])
225
+ conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
224
226
  }
225
227
  }
226
228
 
@@ -233,10 +235,10 @@ function mapListItemParts(parts, root) {
233
235
  events: parts.events.map(([index, events]) => [target[index], events]),
234
236
  expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
235
237
  expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
236
- conditions: parts.conditions.map(([index, descriptor]) => {
238
+ conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
237
239
  conditionOwners.set(target[index], root)
238
240
  return [target[index], descriptor]
239
- })
241
+ }) : []
240
242
  })
241
243
  }
242
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.13",
3
+ "version": "0.5.0",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",