@kudzujs/core 0.4.14 → 0.5.1

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:
@@ -225,9 +264,19 @@ const rows = items.map(item =>
225
264
  return <ul>{rows}</ul>
226
265
  ```
227
266
 
267
+ The root may also be a same-file row component that directly returns one intrinsic element and receives the whole item through one prop:
268
+
269
+ ```tsx
270
+ function ItemRow({ item }: { item: Item }) {
271
+ return <li>{item.name}</li>
272
+ }
273
+
274
+ const rows = items.map(item => <ItemRow key={item.id} item={item} />)
275
+ ```
276
+
228
277
  Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
229
278
 
230
- Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested item conditions or lists, item spreads, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
279
+ Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or same-file row component, and `key={item.<field>}`. A row component must directly destructure the whole item prop, directly return one intrinsic element, and be used only by that list. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested item conditions, lists, or component tags, item spreads, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
231
280
 
232
281
  ## Normal JavaScript
233
282
 
@@ -294,6 +343,9 @@ Supported:
294
343
  - Function components, props, children, fragments, and TSX
295
344
  - File-based static routes
296
345
  - Build-time async components
346
+ - Dynamic static routes with build-time props
347
+ - Static trusted `dangerouslySetInnerHTML`
348
+ - Base-path deployments, multiple CSS files, and `afterBuild`
297
349
  - Primitive `useState` bindings
298
350
  - Synchronous and async event handlers
299
351
  - Relative imported helpers in native handlers
@@ -346,6 +398,28 @@ The same native counter calculation was measured inline and through one relative
346
398
 
347
399
  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.
348
400
 
401
+ #### Wrapper-Free Derived Text
402
+
403
+ 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.
404
+
405
+ | Text target | Files | JS gzip | Total output | Clean build | Update |
406
+ |---|---:|---:|---:|---:|---:|
407
+ | Span v0.4.14 | 7 | **4,453 B** | **10,065 B** | **404 ms** | **4.83 µs** |
408
+ | Comment range | 7 | 4,763 B | 10,922 B | 426 ms | 5.03 µs |
409
+
410
+ 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.
411
+
412
+ ### 123-Page Newsletter Build
413
+
414
+ 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.
415
+
416
+ | Build model | TSX source files | Pages | JS gzip | Total output | Clean build |
417
+ |---|---:|---:|---:|---:|---:|
418
+ | Generated TSX workaround | 123 | 123 | 0 B | 52.0 KB | 882 ms |
419
+ | `getStaticPaths` | **1** | 123 | 0 B | 52.0 KB | **454 ms** |
420
+
421
+ `getStaticPaths` removes 122 generated source files and cuts clean build time by 48.5% without changing deploy output or runtime cost.
422
+
349
423
  ### Static Journal Page
350
424
 
351
425
  Same content and CSS across every fixture:
@@ -374,6 +448,8 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
374
448
  | 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 |
375
449
  | 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 |
376
450
 
451
+ An intrinsic-root versus row-component A/B build produced byte-for-byte identical `dist` output: 5,175 B JS gzip and 61,731 B total. Seven interleaved clean builds measured 444 ms and 441 ms. Browser operation medians totaled 23.8 ms and 25.4 ms respectively; because the deployed HTML and JavaScript are identical, that 1.6 ms difference is measurement variance rather than component runtime overhead.
452
+
377
453
  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.
378
454
 
379
455
  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.
@@ -1,11 +1,11 @@
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.
@@ -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
  }
@@ -17,18 +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/")
27
31
  const sourceFileSet = new Set(sourceFiles)
28
32
 
29
33
  const handlerModules = []
30
34
  for (const file of sourceFiles) {
31
- const handlerModule = await compile(file, sourceFileSet)
35
+ const handlerModule = await compile(file, sourceFileSet, base)
32
36
  if (handlerModule) handlerModules.push(handlerModule)
33
37
  }
34
38
 
@@ -41,35 +45,44 @@ export async function build({ quiet = false, minify = true } = {}) {
41
45
  let listStyleCount = 0
42
46
  let stateSeedCount = 0
43
47
  const plans = []
44
- 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, "/")}`))
45
50
 
46
51
  for (const pageFile of pageFiles) {
47
52
  const compiledFile = compiledPath(pageFile)
48
53
  const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
49
54
  if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
50
55
 
51
- const result = await renderPage(module.default, {
52
- ...(module.metadata ?? {}),
53
- styles: hasStyles
54
- })
55
- const route = routeFromPage(pageFile)
56
- const routeDirectory = join(outputDirectory, route)
57
- await mkdir(routeDirectory, { recursive: true })
58
- await writeFile(join(routeDirectory, "index.html"), result.html)
59
- plans.push({ route: `/${route}`, ...result.plan })
60
- if (result.hasBehaviors) behaviorCount++
61
- if (result.hasBindings) bindingCount++
62
- if (result.hasLists) listCount++
63
- if (result.hasListStyles) listStyleCount++
64
- 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
+ }
65
77
  }
66
78
 
67
79
  const assetsDirectory = join(outputDirectory, "assets")
68
80
  await mkdir(assetsDirectory, { recursive: true })
69
81
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
70
82
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
83
+ const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
71
84
  const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
72
- const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => `/assets/${module.path}`)
85
+ const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
73
86
  const hasNativeHandlers = nativeModules.length > 0
74
87
  if (behaviorCount) {
75
88
  const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
@@ -83,7 +96,7 @@ export async function build({ quiet = false, minify = true } = {}) {
83
96
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
84
97
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
85
98
  .replace('"./style.js"', '"./kudzu-style.js"')
86
- 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) })
87
100
  }
88
101
  if (listCount) {
89
102
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
@@ -134,10 +147,18 @@ export async function build({ quiet = false, minify = true } = {}) {
134
147
  await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
135
148
  }
136
149
  await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
137
- 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
+ }
138
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
+ }
139
160
 
140
- 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/`)
141
162
  }
142
163
 
143
164
  function specializeEvents(source, events) {
@@ -168,7 +189,7 @@ async function writeBundledJavaScript(file, source, minify, define) {
168
189
  stdin: { contents: source, resolveDir: dirname(file), sourcefile: file },
169
190
  bundle: true,
170
191
  write: false,
171
- external: ["./kudzu.js", "./kudzu-style.js"],
192
+ external: ["./kudzu.js", "./kudzu-serialization.js", "./kudzu-style.js"],
172
193
  define,
173
194
  format: "esm",
174
195
  target: "es2022",
@@ -189,6 +210,7 @@ export function parseDevPort(value) {
189
210
 
190
211
  export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
191
212
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
213
+ const base = normalizeBase((await loadConfig()).base)
192
214
 
193
215
  let buildError
194
216
  let revision = 0
@@ -226,7 +248,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
226
248
  return
227
249
  }
228
250
 
229
- const relativePath = pathname.replace(/^\/+/, "")
251
+ const relativePath = stripBase(pathname, base).replace(/^\/+/, "")
230
252
  let file = resolve(outputDirectory, relativePath)
231
253
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
232
254
 
@@ -287,6 +309,12 @@ function injectDevClient(html, session, revision, schema) {
287
309
  return `${html}${devClient(session, revision, schema)}`
288
310
  }
289
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
+
290
318
  async function devSchema(pathname) {
291
319
  try {
292
320
  const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
@@ -317,7 +345,7 @@ function escapeHtml(value) {
317
345
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
318
346
  }
319
347
 
320
- async function compile(file, sourceFiles) {
348
+ async function compile(file, sourceFiles, base) {
321
349
  const source = await readFile(file, "utf8")
322
350
  const nativeHandlers = []
323
351
  const reactiveBindings = []
@@ -332,7 +360,7 @@ async function compile(file, sourceFiles) {
332
360
  jsx: ts.JsxEmit.ReactJSX,
333
361
  jsxImportSource: "@kudzujs/core"
334
362
  },
335
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, `/assets/${handlerPath}`, file, sourceFiles, clientImports)] },
363
+ transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, clientImports)] },
336
364
  reportDiagnostics: true
337
365
  })
338
366
 
@@ -438,6 +466,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
438
466
  if (uses.length) listLocalUses.set(uses[0], parts)
439
467
  }
440
468
  }
469
+ const renderedLists = new WeakMap()
470
+ const collectRenderedLists = node => {
471
+ if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
472
+ const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
473
+ if (parts) {
474
+ if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
475
+ validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions)
476
+ renderedLists.set(node, parts)
477
+ }
478
+ }
479
+ ts.forEachChild(node, collectRenderedLists)
480
+ }
481
+ collectRenderedLists(sourceFile)
441
482
 
442
483
  const visitor = node => {
443
484
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
@@ -495,10 +536,8 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
495
536
  }
496
537
 
497
538
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
498
- const listParts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
539
+ const listParts = renderedLists.get(node)
499
540
  if (listParts) {
500
- if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
501
- validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems, listConditions)
502
541
  usesBehavior = true
503
542
  usesList = true
504
543
  return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
@@ -600,11 +639,35 @@ function keyedListParts(expression, setters) {
600
639
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
601
640
  }
602
641
 
603
- function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems, listConditions) {
642
+ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions) {
604
643
  const fail = (node, message) => {
605
644
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
606
645
  throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
607
646
  }
647
+ let root = parts.root
648
+ let item = parts.item
649
+ const rootTag = ts.isJsxElement(root) ? root.openingElement.tagName : root.tagName
650
+ if (ts.isIdentifier(rootTag) && rootTag.text[0] === rootTag.text[0].toUpperCase()) {
651
+ const component = functions.get(rootTag.text)
652
+ if (!component) fail(root, `Keyed list component ${rootTag.text} must be declared in the same file`)
653
+ const uses = jsxTagUses(sourceFile, rootTag.text)
654
+ if (uses.length !== 1 || uses[0] !== root) fail(root, `Keyed list component ${rootTag.text} may only be used as this list root`)
655
+ const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
656
+ let itemProp
657
+ for (const attribute of attributes.properties) {
658
+ if (ts.isJsxSpreadAttribute(attribute)) {
659
+ if (referencesIdentifier(attribute.expression, item)) fail(attribute, "Keyed list item spreads are not supported")
660
+ continue
661
+ }
662
+ if (attribute.name.getText() === "key" || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression || !referencesIdentifier(attribute.initializer.expression, item)) continue
663
+ if (!ts.isIdentifier(attribute.initializer.expression) || attribute.initializer.expression.text !== item || itemProp) fail(attribute, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
664
+ itemProp = attribute.name.getText()
665
+ }
666
+ if (ts.isJsxElement(root) && root.children.some(child => referencesIdentifier(child, item))) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
667
+ if (!itemProp) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
668
+ item = componentItemParameter(component, itemProp, node => fail(node, `Keyed list component ${rootTag.text} must destructure its item prop`))
669
+ root = componentJsxRoot(component, node => fail(node, `Keyed list component ${rootTag.text} must return one JSX element`))
670
+ }
608
671
  const validateElement = node => {
609
672
  const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
610
673
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
@@ -613,10 +676,10 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
613
676
  const visit = node => {
614
677
  if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
615
678
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
616
- if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
617
- if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, parts.item)) fail(node, "Keyed list item spreads are not supported")
679
+ if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
680
+ if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
618
681
  if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
619
- listEventItems.set(node, parts.item)
682
+ listEventItems.set(node, item)
620
683
  return
621
684
  }
622
685
  if (ts.isJsxExpression(node) && node.expression) {
@@ -624,16 +687,16 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
624
687
  const condition = conditionalParts(expression)
625
688
  if (condition && containsJsx(expression)) {
626
689
  if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
627
- if (!referencesIdentifier(condition.condition, parts.item)) fail(node, "Keyed list item conditions must read the item")
628
- validateListExpression(condition.condition, parts.item, node, fail)
629
- listConditions.set(node.expression, { ...condition, item: parts.item })
690
+ if (!referencesIdentifier(condition.condition, item)) fail(node, "Keyed list item conditions must read the item")
691
+ validateListExpression(condition.condition, item, node, fail)
692
+ listConditions.set(node.expression, { ...condition, item })
630
693
  conditionDepth++
631
694
  visit(condition.truthy)
632
695
  visit(condition.falsy)
633
696
  conditionDepth--
634
697
  return
635
698
  }
636
- const field = directProperty(expression, parts.item)
699
+ const field = directProperty(expression, item)
637
700
  const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
638
701
  if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
639
702
  if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
@@ -642,16 +705,47 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
642
705
  listValues.set(node.expression, { field })
643
706
  return
644
707
  }
645
- if (referencesIdentifier(expression, parts.item)) {
646
- validateListExpression(expression, parts.item, node, fail)
708
+ if (referencesIdentifier(expression, item)) {
709
+ validateListExpression(expression, item, node, fail)
647
710
  if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
648
- listValues.set(node.expression, { item: parts.item })
711
+ listValues.set(node.expression, { item })
649
712
  return
650
713
  }
651
714
  }
652
715
  ts.forEachChild(node, visit)
653
716
  }
654
- visit(parts.root)
717
+ visit(root)
718
+ }
719
+
720
+ function componentItemParameter(component, prop, fail) {
721
+ if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component)
722
+ const element = component.parameters[0].name.elements.find(entry => !entry.dotDotDotToken && !entry.initializer && (entry.propertyName ?? entry.name).getText() === prop)
723
+ if (!element || !ts.isIdentifier(element.name)) fail(component.parameters[0])
724
+ return element.name.text
725
+ }
726
+
727
+ function componentJsxRoot(component, fail) {
728
+ if (!ts.isBlock(component.body)) {
729
+ const root = unwrapExpression(component.body)
730
+ if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(component.body)
731
+ return root
732
+ }
733
+ if (component.body.statements.length !== 1 || !ts.isReturnStatement(component.body.statements[0]) || !component.body.statements[0].expression) fail(component.body)
734
+ const statement = component.body.statements[0]
735
+ const root = unwrapExpression(statement.expression)
736
+ if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(statement)
737
+ return root
738
+ }
739
+
740
+ function jsxTagUses(root, name) {
741
+ const uses = []
742
+ const visit = node => {
743
+ const tag = ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
744
+ if (tag && ts.isIdentifier(tag) && tag.text === name) uses.push(node)
745
+ ts.forEachChild(node, visit)
746
+ }
747
+ visit(root)
748
+ return uses
655
749
  }
656
750
 
657
751
  const pureListMethods = new Set(["at", "charAt", "charCodeAt", "concat", "endsWith", "includes", "indexOf", "join", "lastIndexOf", "padEnd", "padStart", "repeat", "replace", "replaceAll", "slice", "startsWith", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart"])
@@ -1289,9 +1383,59 @@ function compiledPath(file) {
1289
1383
  return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
1290
1384
  }
1291
1385
 
1292
- function routeFromPage(file) {
1386
+ async function loadConfig() {
1387
+ for (const name of ["kudzu.config.mjs", "kudzu.config.js"]) {
1388
+ const file = join(root, name)
1389
+ if (!(await exists(file))) continue
1390
+ const config = (await import(`${pathToFileURL(file).href}?v=${Date.now()}-${randomUUID()}`)).default ?? {}
1391
+ if (!isPlainRecord(config)) throw new Error(`${name} must export a default object`)
1392
+ return config
1393
+ }
1394
+ return {}
1395
+ }
1396
+
1397
+ function normalizeBase(value) {
1398
+ if (value == null || value === "" || value === "/") return ""
1399
+ if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || value.split("/").includes("..")) throw new Error("kudzu.config base must be a root-relative path")
1400
+ return value.replace(/\/+$/, "")
1401
+ }
1402
+
1403
+ function assetPath(base, path) {
1404
+ return `${base}/${path}`
1405
+ }
1406
+
1407
+ function withBase(base, path) {
1408
+ return base ? `${base}${path}` : path
1409
+ }
1410
+
1411
+ async function staticPathEntries(module, file) {
1412
+ if (typeof module.getStaticPaths !== "function") return [{ params: {}, props: {} }]
1413
+ const entries = await module.getStaticPaths()
1414
+ if (!Array.isArray(entries)) throw new Error(`${relative(root, file)} getStaticPaths() must return an array`)
1415
+ return entries.map((entry, index) => {
1416
+ if (!isPlainRecord(entry)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}] must be an object`)
1417
+ const params = entry.params ?? {}
1418
+ const props = entry.props ?? {}
1419
+ if (!isPlainRecord(params)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].params must be an object`)
1420
+ if (!isPlainRecord(props)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].props must be an object`)
1421
+ return { params, props }
1422
+ })
1423
+ }
1424
+
1425
+ function routeFromPage(file, params = {}) {
1293
1426
  const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
1294
- return page === "index" ? "" : page.replace(/\/index$/, "")
1427
+ if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
1428
+ const filled = page.replace(/\[([^\]]+)\]/g, (_, name) => {
1429
+ if (!Object.hasOwn(params, name)) throw new Error(`Missing param "${name}" for route ${page}`)
1430
+ const value = String(params[name])
1431
+ if (!value || value === "." || value === ".." || /[\\/\0?#]/.test(value)) throw new Error(`Invalid param "${name}" for route ${page}`)
1432
+ return value
1433
+ })
1434
+ return filled === "index" ? "" : filled.replace(/\/index$/, "")
1435
+ }
1436
+
1437
+ function isPlainRecord(value) {
1438
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
1295
1439
  }
1296
1440
 
1297
1441
  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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.14",
3
+ "version": "0.5.1",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",