@kudzujs/core 0.4.0 → 0.4.3

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
@@ -77,7 +77,7 @@ export default function HomePage() {
77
77
  npm run dev
78
78
  ```
79
79
 
80
- Pages live in `src/pages`; `index.tsx` maps to `/`. Production output is written to `dist/`.
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
82
  ## State Semantics
83
83
 
@@ -150,16 +150,25 @@ Map local array state directly to one keyed JSX element per item:
150
150
 
151
151
  ```tsx
152
152
  const [items, setItems] = useState([
153
- { id: 1, name: "Oak" },
154
- { id: 2, name: "Pine" }
153
+ { id: 1, name: "Oak", done: false },
154
+ { id: 2, name: "Pine", done: true }
155
155
  ])
156
156
 
157
- <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
157
+ <ul>{items.map(item =>
158
+ <li
159
+ key={item.id}
160
+ className={item.done ? "done" : "active"}
161
+ aria-label={`${item.name} item`}
162
+ >
163
+ {item.name.toUpperCase()}
164
+ <button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
165
+ </li>
166
+ )}</ul>
158
167
  ```
159
168
 
160
- Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Each item must be a plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, plain objects, and primitive values.
169
+ Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use delegated events 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.
161
170
 
162
- The MVP requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Item data may appear only as direct `item.<field>` text or attributes. Item-derived expressions, item-local handlers, nested conditions or lists, component tags, and fragments are rejected at build time. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
171
+ 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 direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. 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 conditions or lists, item spreads, component tags, fragments, and reactive `style`, `ref`, or `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
163
172
 
164
173
  ## Normal JavaScript
165
174
 
@@ -181,6 +190,8 @@ async function load() {
181
190
 
182
191
  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.
183
192
 
193
+ Native handlers are delegated after normal event bubbling and run from the target toward matching Kudzu ancestors in deterministic order. Delegated handlers cannot call or reference `preventDefault`, `stopPropagation`, or `stopImmediatePropagation`; the compiler rejects those methods because external ESM cannot apply them with correct synchronous DOM semantics.
194
+
184
195
  ## Rendering
185
196
 
186
197
  ```text
@@ -6,11 +6,12 @@
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
8
  - `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
9
- - `list-runtime.js`: optional keyed list validation, updates, moves, and cleanup.
9
+ - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic 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
+ - `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
12
13
  - `*.d.ts`: public TypeScript and JSX declarations.
13
14
 
14
- 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/`.
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
16
 
16
17
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
@@ -1,9 +1,11 @@
1
1
  import { createServer } from "node:http"
2
+ import { randomUUID } from "node:crypto"
2
3
  import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
3
4
  import { extname, join, relative, resolve, sep } from "node:path"
4
5
  import { pathToFileURL } from "node:url"
5
6
  import ts from "typescript"
6
7
  import { renderPage } from "./core.mjs"
8
+ import { stateSchema } from "./dev-state.js"
7
9
 
8
10
  const root = process.cwd()
9
11
  const sourceDirectory = join(root, "src")
@@ -11,6 +13,8 @@ const pagesDirectory = join(sourceDirectory, "pages")
11
13
  const workDirectory = join(root, ".kudzu")
12
14
  const outputDirectory = join(root, "dist")
13
15
 
16
+ 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>`
17
+
14
18
  export async function build({ quiet = false } = {}) {
15
19
  await rm(workDirectory, { recursive: true, force: true })
16
20
  await rm(outputDirectory, { recursive: true, force: true })
@@ -108,48 +112,149 @@ function specializeRuntime(source, events, hasStateSeed) {
108
112
  .replace(" if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)\n", "")
109
113
  }
110
114
 
111
- export async function dev() {
112
- await build()
115
+ export function parseDevPort(value) {
116
+ if (value === undefined || value.trim() === "") return 3000
117
+ if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
118
+ const port = Number(value)
119
+ if (port > 65535) throw new Error(`Invalid dev server port: ${value}`)
120
+ return port
121
+ }
122
+
123
+ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
124
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
125
+
126
+ let buildError
127
+ let revision = 0
128
+ const session = randomUUID()
129
+ try {
130
+ await build()
131
+ revision++
132
+ } catch (error) {
133
+ buildError = errorText(error)
134
+ console.error(error)
135
+ }
136
+
137
+ const clients = new Set()
113
138
 
114
139
  const server = createServer(async (request, response) => {
115
140
  try {
116
- const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname)
141
+ const url = new URL(request.url, "http://localhost")
142
+ const pathname = decodeURIComponent(url.pathname)
143
+ if (pathname === "/__kudzu_reload") {
144
+ response.writeHead(200, {
145
+ "content-type": "text/event-stream; charset=utf-8",
146
+ "cache-control": "no-cache, no-transform",
147
+ connection: "keep-alive"
148
+ })
149
+ response.write(": connected\n\n")
150
+ clients.add(response)
151
+ request.on("close", () => clients.delete(response))
152
+ if (buildError) sendEvent(response, "build-error", buildError)
153
+ else if (url.searchParams.get("session") !== session || url.searchParams.get("revision") !== String(revision)) sendEvent(response, "reload")
154
+ return
155
+ }
156
+ if (pathname === "/__kudzu_dev.js") {
157
+ response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" })
158
+ response.end(await readFile(new URL("./dev-state.js", import.meta.url)))
159
+ return
160
+ }
161
+
117
162
  const relativePath = pathname.replace(/^\/+/, "")
118
163
  let file = resolve(outputDirectory, relativePath)
119
164
  if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
120
165
 
121
166
  if ((await exists(file)) && (await stat(file)).isDirectory()) file = join(file, "index.html")
122
167
  if (!(await exists(file)) && !extname(file)) file = join(file, "index.html")
123
- const content = await readFile(file)
124
- response.writeHead(200, { "content-type": contentType(file) })
168
+ const isHtml = extname(file) === ".html"
169
+ const content = isHtml
170
+ ? injectDevClient(buildError ? errorPage(buildError) : await readFile(file, "utf8"), session, revision, buildError ? [] : await devSchema(pathname))
171
+ : await readFile(file)
172
+ response.writeHead(200, {
173
+ "content-type": contentType(file),
174
+ "cache-control": "no-store"
175
+ })
125
176
  response.end(content)
126
177
  } catch {
127
- response.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
178
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" })
128
179
  response.end("Not found")
129
180
  }
130
181
  })
131
182
 
132
- server.listen(3000, () => console.log("Kudzu dev server: http://localhost:3000"))
183
+ server.listen(port, "127.0.0.1", () => console.log(`Kudzu dev server: http://127.0.0.1:${server.address().port}`))
133
184
 
134
185
  let timer
135
- const watcher = watch(sourceDirectory, { recursive: true })
136
- for await (const event of watcher) {
137
- clearTimeout(timer)
138
- timer = setTimeout(async () => {
186
+ let rebuilding = false
187
+ let pending = false
188
+ let changedFile
189
+ const rebuild = async () => {
190
+ if (rebuilding) {
191
+ pending = true
192
+ return
193
+ }
194
+ rebuilding = true
195
+ do {
196
+ pending = false
139
197
  try {
140
198
  await build({ quiet: true })
141
- console.log(`Rebuilt after ${event.filename ?? "source change"}`)
199
+ buildError = undefined
200
+ revision++
201
+ console.log(`Rebuilt after ${changedFile ?? "source change"}`)
202
+ for (const client of clients) sendEvent(client, "reload")
142
203
  } catch (error) {
204
+ buildError = errorText(error)
143
205
  console.error(error)
206
+ for (const client of clients) sendEvent(client, "build-error", buildError)
144
207
  }
145
- }, 80)
208
+ } while (pending)
209
+ rebuilding = false
210
+ }
211
+ const watcher = watch(sourceDirectory, { recursive: true })
212
+ for await (const event of watcher) {
213
+ changedFile = event.filename
214
+ clearTimeout(timer)
215
+ timer = setTimeout(rebuild, 80)
146
216
  }
147
217
  }
148
218
 
219
+ function injectDevClient(html, session, revision, schema) {
220
+ return `${html}${devClient(session, revision, schema)}`
221
+ }
222
+
223
+ async function devSchema(pathname) {
224
+ try {
225
+ const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
226
+ const route = pathname.replace(/\/(?:index\.html)?$/, "") || "/"
227
+ return stateSchema(plan.routes.find(entry => entry.route === route)?.states ?? [])
228
+ } catch {
229
+ return []
230
+ }
231
+ }
232
+
233
+ function inlineJson(value) {
234
+ return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
235
+ }
236
+
237
+ function errorPage(error) {
238
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Kudzu build error</title></head><body><div id="__kudzu_error" role="alert" aria-live="assertive" style="position:fixed;inset:0;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace"><strong>Kudzu build error</strong><pre style="white-space:pre-wrap">${escapeHtml(error)}</pre></div></body></html>`
239
+ }
240
+
241
+ function errorText(error) {
242
+ return String(error?.message ?? error)
243
+ }
244
+
245
+ function sendEvent(response, event, data = "") {
246
+ response.write(`event: ${event}\n${String(data).replaceAll("\r", "").split("\n").map(line => `data: ${line}\n`).join("")}\n`)
247
+ }
248
+
249
+ function escapeHtml(value) {
250
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
251
+ }
252
+
149
253
  async function compile(file) {
150
254
  const source = await readFile(file, "utf8")
151
255
  const nativeHandlers = []
152
256
  const reactiveBindings = []
257
+ const listExpressions = []
153
258
  const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
154
259
  const result = ts.transpileModule(source, {
155
260
  fileName: file,
@@ -159,7 +264,7 @@ async function compile(file) {
159
264
  jsx: ts.JsxEmit.ReactJSX,
160
265
  jsxImportSource: "@kudzujs/core"
161
266
  },
162
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, `/assets/${handlerPath}`)] },
267
+ transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, `/assets/${handlerPath}`)] },
163
268
  reportDiagnostics: true
164
269
  })
165
270
 
@@ -172,10 +277,11 @@ async function compile(file) {
172
277
  await mkdir(resolve(output, ".."), { recursive: true })
173
278
  await writeFile(output, result.outputText)
174
279
 
175
- if (!nativeHandlers.length && !reactiveBindings.length) return undefined
280
+ if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
176
281
  const moduleSource = [
177
282
  ...nativeHandlers.map(handler => printNativeHandler(handler)),
178
- ...reactiveBindings.map(entry => printReactiveBinding(entry))
283
+ ...reactiveBindings.map(entry => printReactiveBinding(entry)),
284
+ ...listExpressions.map(entry => printListExpression(entry))
179
285
  ].join("\n")
180
286
  const moduleResult = ts.transpileModule(moduleSource, {
181
287
  compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
@@ -186,12 +292,13 @@ async function compile(file) {
186
292
  return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
187
293
  }
188
294
 
189
- function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
295
+ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl) {
190
296
  return context => sourceFile => {
191
297
  const factory = context.factory
192
298
  const settersByFunction = new Map()
193
299
  const functions = new Map()
194
- const listFieldExpressions = new WeakSet()
300
+ const listValues = new WeakMap()
301
+ const listEventItems = new WeakMap()
195
302
  let usesBehavior = false
196
303
  let usesBinding = false
197
304
  let usesConditional = false
@@ -237,13 +344,20 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
237
344
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
238
345
  }
239
346
 
347
+ if (ts.isJsxExpression(node) && node.expression && listValues.has(node.expression)) {
348
+ return factory.updateJsxExpression(node, compileListValue(node.expression, listValues.get(node.expression), factory, listExpressions, handlerUrl))
349
+ }
350
+
351
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && listValues.has(node.initializer.expression)) {
352
+ return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compileListValue(node.initializer.expression, listValues.get(node.initializer.expression), factory, listExpressions, handlerUrl)))
353
+ }
354
+
240
355
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
241
356
  const listParts = keyedListParts(node.expression, settersForNode(node, settersByFunction))
242
357
  if (listParts) {
243
358
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
244
- validateKeyedList(listParts, sourceFile, listFieldExpressions)
359
+ validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems)
245
360
  usesBehavior = true
246
- usesBinding = true
247
361
  usesList = true
248
362
  return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
249
363
  listParts.state,
@@ -269,7 +383,6 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
269
383
 
270
384
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["style", "key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
271
385
  const expression = node.initializer.expression
272
- if (listFieldExpressions.has(expression)) return node
273
386
  const setters = settersForNode(node, settersByFunction)
274
387
  const usedStates = referencedStateNames(expression, setters)
275
388
  const captures = captureNames(expression, expression, setters)
@@ -283,7 +396,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
283
396
 
284
397
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
285
398
  const setters = settersForNode(node, settersByFunction)
286
- const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl)
399
+ const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node))
287
400
  if (event) {
288
401
  usesBehavior = true
289
402
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
@@ -302,7 +415,12 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
302
415
  if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
303
416
  if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
304
417
  if (usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
305
- if (usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
418
+ if (usesList) {
419
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
420
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listExpression"), factory.createIdentifier("__kListExpression")))
421
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listField"), factory.createIdentifier("__kListField")))
422
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
423
+ }
306
424
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
307
425
  const behaviorImport = factory.createImportDeclaration(
308
426
  undefined,
@@ -331,7 +449,7 @@ function keyedListParts(expression, setters) {
331
449
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
332
450
  }
333
451
 
334
- function validateKeyedList(parts, sourceFile, listFieldExpressions) {
452
+ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems) {
335
453
  const fail = (node, message) => {
336
454
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
337
455
  throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
@@ -341,27 +459,112 @@ function validateKeyedList(parts, sourceFile, listFieldExpressions) {
341
459
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
342
460
  }
343
461
  const visit = node => {
462
+ if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
344
463
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
345
- if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map") fail(node, "Nested keyed lists are not supported")
464
+ 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")
346
465
  if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, parts.item)) fail(node, "Keyed list item spreads are not supported")
347
- if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) fail(node, "Item-local handlers are not supported in keyed lists")
466
+ if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
467
+ listEventItems.set(node, parts.item)
468
+ return
469
+ }
348
470
  if (ts.isJsxExpression(node) && node.expression) {
349
471
  const expression = unwrapExpression(node.expression)
472
+ if (conditionalParts(expression) && containsJsx(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
350
473
  const field = directProperty(expression, parts.item)
351
- const isRootKey = node.parent?.parent === parts.root && ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
474
+ const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
475
+ if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
352
476
  if (field && ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
353
- if (isRootKey || field) {
354
- if (field) listFieldExpressions.add(node.expression)
477
+ if (isRootKey) return
478
+ if (field) {
479
+ listValues.set(node.expression, { field })
480
+ return
481
+ }
482
+ if (referencesIdentifier(expression, parts.item)) {
483
+ validateListExpression(expression, parts.item, node, fail)
484
+ if (ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
485
+ listValues.set(node.expression, { item: parts.item })
355
486
  return
356
487
  }
357
- if (conditionalParts(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
358
- if (referencesIdentifier(expression, parts.item)) fail(node, `Keyed list item expressions must be direct ${parts.item}.<field> reads`)
359
488
  }
360
489
  ts.forEachChild(node, visit)
361
490
  }
362
491
  visit(parts.root)
363
492
  }
364
493
 
494
+ 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"])
495
+ const mutatingListMethods = new Set(["copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift"])
496
+ const pureMathMethods = new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sign", "sqrt", "trunc"])
497
+ const pureListGlobals = new Set(["Boolean", "Infinity", "Math", "NaN", "Number", "String", "undefined"])
498
+ const assignmentOperators = new Set([
499
+ ts.SyntaxKind.EqualsToken, ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken,
500
+ ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskAsteriskEqualsToken, ts.SyntaxKind.SlashEqualsToken,
501
+ ts.SyntaxKind.PercentEqualsToken, ts.SyntaxKind.LessThanLessThanEqualsToken, ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
502
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, ts.SyntaxKind.AmpersandEqualsToken, ts.SyntaxKind.BarEqualsToken,
503
+ ts.SyntaxKind.CaretEqualsToken, ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.AmpersandAmpersandEqualsToken,
504
+ ts.SyntaxKind.QuestionQuestionEqualsToken
505
+ ])
506
+
507
+ function validateListExpression(expression, item, source, fail) {
508
+ const visit = node => {
509
+ if (ts.isElementAccessExpression(node) && referencesIdentifier(node.expression, item)) {
510
+ const key = node.argumentExpression
511
+ if (!ts.isStringLiteral(key) && !ts.isNumericLiteral(key)) fail(source, "Derived keyed list item computed properties require a direct string or numeric literal key")
512
+ if (ts.isStringLiteral(key) && ["__proto__", "constructor", "prototype"].includes(key.text)) fail(source, `Derived keyed list item property "${key.text}" is not supported`)
513
+ }
514
+ if (ts.isPropertyAccessExpression(node) && ["__proto__", "constructor", "prototype"].includes(node.name.text) || ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) && ["__proto__", "constructor", "prototype"].includes(node.argumentExpression.text)) {
515
+ fail(source, "Derived keyed list item expressions cannot read __proto__, prototype, or constructor")
516
+ }
517
+ if (ts.isBinaryExpression(node) && assignmentOperators.has(node.operatorToken.kind) || ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)) {
518
+ fail(source, "Derived keyed list item expressions must be pure; assignments and updates are not supported")
519
+ }
520
+ if (ts.isDeleteExpression(node) || ts.isAwaitExpression(node) || ts.isNewExpression(node) || ts.isYieldExpression(node)) {
521
+ fail(source, "Derived keyed list item expressions must be synchronous and side-effect free; delete, await, yield, and new are not supported")
522
+ }
523
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isTaggedTemplateExpression(node)) {
524
+ fail(source, "Derived keyed list item expressions cannot create or invoke arbitrary functions")
525
+ }
526
+ if (ts.isCallExpression(node)) {
527
+ if (ts.isPropertyAccessExpression(node.expression)) {
528
+ const method = node.expression.name.text
529
+ if (mutatingListMethods.has(method)) fail(source, `Derived keyed list item expressions cannot call mutating method "${method}"`)
530
+ const receiver = node.expression.expression
531
+ const mathCall = ts.isIdentifier(receiver) && receiver.text === "Math" && pureMathMethods.has(method)
532
+ if (!mathCall && !pureListMethods.has(method)) fail(source, `Derived keyed list item expressions cannot call arbitrary method "${method}"`)
533
+ } else if (!ts.isIdentifier(node.expression) || !["Boolean", "Number", "String"].includes(node.expression.text)) {
534
+ fail(source, "Derived keyed list item expressions cannot call arbitrary functions")
535
+ }
536
+ }
537
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node) && node.text !== item && !pureListGlobals.has(node.text)) {
538
+ fail(source, `Derived keyed list item expression identifier "${node.text}" is not allowed`)
539
+ }
540
+ ts.forEachChild(node, visit)
541
+ }
542
+ visit(expression)
543
+ }
544
+
545
+ function containsJsx(root) {
546
+ let found = false
547
+ const visit = node => {
548
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) found = true
549
+ if (!found) ts.forEachChild(node, visit)
550
+ }
551
+ visit(root)
552
+ return found
553
+ }
554
+
555
+ function compileListExpression(read, expression, item, factory, listExpressions, handlerUrl) {
556
+ const exportName = `listExpression${listExpressions.length}`
557
+ listExpressions.push({ exportName, expression, item })
558
+ return factory.createCallExpression(factory.createIdentifier("__kListExpression"), undefined, [read, factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)])
559
+ }
560
+
561
+ function compileListValue(expression, entry, factory, listExpressions, handlerUrl) {
562
+ const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), expression)
563
+ return entry.field
564
+ ? factory.createCallExpression(factory.createIdentifier("__kListField"), undefined, [read, factory.createStringLiteral(entry.field)])
565
+ : compileListExpression(read, expression, entry.item, factory, listExpressions, handlerUrl)
566
+ }
567
+
365
568
  function directProperty(expression, objectName) {
366
569
  const value = unwrapExpression(expression)
367
570
  if (!ts.isPropertyAccessExpression(value) || !ts.isIdentifier(value.expression)) return undefined
@@ -454,10 +657,11 @@ function factoryNull() {
454
657
  return ts.factory.createNull()
455
658
  }
456
659
 
457
- function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
660
+ function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem) {
458
661
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
459
662
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
460
663
 
664
+ rejectNativeEventControls(expression)
461
665
  const optimized = compileOptimizedEvent(expression, setters, factory)
462
666
  if (optimized) return optimized
463
667
 
@@ -469,18 +673,45 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
469
673
  factory.createStringLiteral(name),
470
674
  factory.createIdentifier(name)
471
675
  ]))
472
- const scope = [...captures].map(name => factory.createArrayLiteralExpression([
473
- factory.createStringLiteral(name),
474
- factory.createIdentifier(name)
475
- ]))
476
676
  return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
477
677
  factory.createStringLiteral(handlerUrl),
478
678
  factory.createStringLiteral(exportName),
479
679
  factory.createArrayLiteralExpression(states),
480
- factory.createArrayLiteralExpression(scope)
680
+ factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
681
+ factory.createStringLiteral(name),
682
+ name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : factory.createIdentifier(name)
683
+ ])))
481
684
  ])
482
685
  }
483
686
 
687
+ function rejectNativeEventControls(expression) {
688
+ const controls = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"])
689
+ const found = new Set()
690
+ const eventAliases = new Set()
691
+ const parameter = expression.parameters[0]?.name
692
+ if (parameter && ts.isIdentifier(parameter)) eventAliases.add(parameter.text)
693
+ const visit = node => {
694
+ if (ts.isIdentifier(node) && controls.has(node.text)) found.add(node.text)
695
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isIdentifier(unwrapEventAlias(node.initializer)) && eventAliases.has(unwrapEventAlias(node.initializer).text)) {
696
+ eventAliases.add(node.name.text)
697
+ }
698
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left) && ts.isIdentifier(unwrapEventAlias(node.right)) && eventAliases.has(unwrapEventAlias(node.right).text)) eventAliases.add(node.left.text)
699
+ if (ts.isElementAccessExpression(node) && ts.isIdentifier(unwrapEventAlias(node.expression)) && eventAliases.has(unwrapEventAlias(node.expression).text)) {
700
+ if (ts.isStringLiteral(node.argumentExpression) && controls.has(node.argumentExpression.text)) found.add(node.argumentExpression.text)
701
+ else if (!ts.isStringLiteral(node.argumentExpression)) for (const control of controls) found.add(control)
702
+ }
703
+ ts.forEachChild(node, visit)
704
+ }
705
+ for (const parameter of expression.parameters) visit(parameter)
706
+ visit(expression.body)
707
+ if (found.size) throw new Error(`Delegated native handlers do not support event control methods: ${[...found].sort().join(", ")}`)
708
+ }
709
+
710
+ function unwrapEventAlias(node) {
711
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node)) return unwrapEventAlias(node.expression)
712
+ return node
713
+ }
714
+
484
715
  function nativeStateNames(expression, setters) {
485
716
  return referencedStateNames(expression.body, setters, expression)
486
717
  }
@@ -681,6 +912,19 @@ function printReactiveBinding({ exportName, expression, captures, states }) {
681
912
  }
682
913
  }
683
914
 
915
+ function printListExpression({ exportName, expression, item }) {
916
+ const declaration = ts.factory.createFunctionDeclaration(
917
+ [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
918
+ undefined,
919
+ exportName,
920
+ undefined,
921
+ [ts.factory.createParameterDeclaration(undefined, undefined, item)],
922
+ undefined,
923
+ ts.factory.createBlock([ts.factory.createReturnStatement(expression)], true)
924
+ )
925
+ return ts.createPrinter().printNode(ts.EmitHint.Unspecified, declaration, expression.getSourceFile())
926
+ }
927
+
684
928
  function scopeRead(factory, name) {
685
929
  return factory.createCallExpression(
686
930
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"),
@@ -8,6 +8,9 @@ export function binding(value: unknown, module: string, handler: string, states:
8
8
  export function bindingValue(value: unknown): unknown
9
9
  export function conditional(kind: "and" | "ternary", value: unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
10
10
  export function list(items: unknown, keyField: string, render: (item: unknown) => unknown): unknown
11
+ export function listField(read: () => unknown, field: string): unknown
12
+ export function listExpression(read: () => unknown, module: string, handler: string): unknown
13
+ export function listItem(): unknown
11
14
 
12
15
  export function renderPage(
13
16
  component: (props: Record<string, never>) => unknown | Promise<unknown>,
@@ -5,6 +5,8 @@ const bindingMarker = Symbol("kudzu.binding")
5
5
  const conditionalMarker = Symbol("kudzu.conditional")
6
6
  const listMarker = Symbol("kudzu.list")
7
7
  const listFieldMarker = Symbol("kudzu.listField")
8
+ const listExpressionMarker = Symbol("kudzu.listExpression")
9
+ const listItemMarker = Symbol("kudzu.listItem")
8
10
  const noSelectValue = Symbol("kudzu.no-select-value")
9
11
 
10
12
  let renderContext
@@ -79,13 +81,27 @@ export function list(items, keyField, render) {
79
81
  return { [listMarker]: true, items, keyField, render }
80
82
  }
81
83
 
84
+ export function listField(read, field) {
85
+ return { [listFieldMarker]: true, field, value: renderContext?.listTemplate ? undefined : read() }
86
+ }
87
+
88
+ export function listExpression(read, module, handler) {
89
+ const value = renderContext?.listTemplate ? undefined : read()
90
+ if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
91
+ return { [listExpressionMarker]: true, module, handler, value }
92
+ }
93
+
94
+ export function listItem() {
95
+ return { [listItemMarker]: true }
96
+ }
97
+
82
98
  function validListKey(key) {
83
99
  return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
84
100
  }
85
101
 
86
102
  function assertListItem(item) {
87
103
  const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
88
- if (!item || Array.isArray(item) || prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must be plain objects")
104
+ if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
89
105
  }
90
106
 
91
107
  function assertListValue(value, seen) {
@@ -93,7 +109,7 @@ function assertListValue(value, seen) {
93
109
  if (!value || typeof value !== "object") throw new Error(`Keyed list items must contain only JSON-safe values`)
94
110
  if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
95
111
  const prototype = Object.getPrototypeOf(value)
96
- if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must contain only arrays and plain objects")
112
+ if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
97
113
  if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
98
114
  seen.add(value)
99
115
  const descriptors = Object.getOwnPropertyDescriptors(value)
@@ -139,6 +155,7 @@ function bindingDescriptor(value) {
139
155
  }
140
156
 
141
157
  function serializeCapture(name, value, seen) {
158
+ if (value?.[listItemMarker]) return { type: "list-item" }
142
159
  if (value === null || typeof value === "string" || typeof value === "boolean") return value
143
160
  if (typeof value === "number") {
144
161
  return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
@@ -170,7 +187,7 @@ function serializeCapture(name, value, seen) {
170
187
  }
171
188
 
172
189
  export async function renderPage(component, metadata = {}) {
173
- renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
190
+ renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
174
191
 
175
192
  try {
176
193
  const body = await renderNode({ type: component, props: {} })
@@ -290,6 +307,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
290
307
  if (node?.[listFieldMarker]) {
291
308
  return `<template data-k-list-text="${escapeAttribute(node.field)}"></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
292
309
  }
310
+ if (node?.[listExpressionMarker]) {
311
+ const descriptor = { module: node.module, handler: node.handler }
312
+ return `<template data-k-list-expression='${escapeJsonAttribute(descriptor)}'></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
313
+ }
293
314
  if (!node || typeof node !== "object" || !("type" in node)) {
294
315
  throw new Error(`Cannot render ${String(node)}`)
295
316
  }
@@ -308,6 +329,8 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
308
329
  let attributes = ""
309
330
  const attributeBindings = []
310
331
  const listAttributes = []
332
+ const listExpressionAttributes = []
333
+ const listEvents = []
311
334
 
312
335
  if (renderContext.listRoot) {
313
336
  const root = renderContext.listRoot
@@ -327,15 +350,17 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
327
350
  }
328
351
 
329
352
  if (/^on[A-Z]/.test(rawName)) {
330
- const event = rawName.slice(2).toLowerCase()
353
+ const event = rawName.slice(2).toLowerCase()
331
354
  if (value?.[behaviorMarker]) {
332
355
  const commands = JSON.stringify(value.commands)
333
356
  attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
334
357
  renderContext.events.push({ event, commands: value.commands })
335
358
  } else if (value?.[nativeBehaviorMarker]) {
336
- const native = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
359
+ const template = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
360
+ const native = template
337
361
  attributes += ` data-k-native-${event}='${escapeJsonAttribute(native)}'`
338
362
  renderContext.events.push({ event, native })
363
+ if (renderContext.listDepth && Object.values(template.scope).some(entry => entry?.type === "list-item")) listEvents.push([event, template])
339
364
  renderContext.hasNativeBehaviors = true
340
365
  } else {
341
366
  throw new Error(`${rawName} must reference a compilable event handler`)
@@ -351,6 +376,11 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
351
376
  listAttributes.push([name, value.field])
352
377
  continue
353
378
  }
379
+ if (value?.[listExpressionMarker]) {
380
+ attributes += renderAttribute(name, value.value)
381
+ listExpressionAttributes.push([name, value.module, value.handler])
382
+ continue
383
+ }
354
384
  if (value?.[signalMarker] || value?.[bindingMarker]) {
355
385
  const initialValue = value[signalMarker] ? value.value : value.value
356
386
  const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
@@ -382,6 +412,8 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
382
412
 
383
413
  if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
384
414
  if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
415
+ if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
416
+ if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
385
417
 
386
418
  if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
387
419
 
@@ -394,17 +426,16 @@ async function renderList(node, namespace, selectValue) {
394
426
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
395
427
  const id = `l${renderContext.nextList++}`
396
428
  const descriptor = { id, state: node.items.id, key: node.keyField }
397
- const itemProxy = value => new Proxy({}, {
398
- get: (_, field) => ({ [listFieldMarker]: true, field: String(field), value: value?.[field] })
399
- })
400
429
  renderContext.listDepth++
401
430
  try {
431
+ renderContext.listTemplate = true
402
432
  renderContext.listRoot = { id, template: true }
403
- const template = await renderNode(node.render(itemProxy(undefined)), namespace, selectValue)
433
+ const template = await renderNode(node.render({}), namespace, selectValue)
404
434
  let current = ""
435
+ renderContext.listTemplate = false
405
436
  for (const item of node.items.value) {
406
437
  renderContext.listRoot = { id, key: item[node.keyField], template: false }
407
- current += await renderNode(node.render(itemProxy(item)), namespace, selectValue)
438
+ current += await renderNode(node.render(item), namespace, selectValue)
408
439
  }
409
440
  renderContext.lists.push(descriptor)
410
441
  renderContext.hasBehaviors = true
@@ -412,6 +443,7 @@ async function renderList(node, namespace, selectValue) {
412
443
  return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
413
444
  } finally {
414
445
  renderContext.listRoot = undefined
446
+ renderContext.listTemplate = false
415
447
  renderContext.listDepth--
416
448
  }
417
449
  }
@@ -0,0 +1,98 @@
1
+ const maxAge = 10000
2
+
3
+ export function stateSchema(states) {
4
+ const occurrences = new Map()
5
+ for (const { name } of states) if (typeof name === "string") occurrences.set(name, (occurrences.get(name) ?? 0) + 1)
6
+ return states.flatMap(({ id, name }) => {
7
+ return typeof id === "string" && occurrences.get(name) === 1 ? [[id, name]] : []
8
+ })
9
+ }
10
+
11
+ export function snapshotState(storage, route, state, schema, now = Date.now()) {
12
+ try {
13
+ storage.removeItem(storageKey(route))
14
+ } catch {
15
+ return false
16
+ }
17
+ try {
18
+ const identities = new Map(schema)
19
+ const values = [...state].flatMap(([id, value]) => {
20
+ const identity = identities.get(id)
21
+ return typeof identity === "string" && jsonSafe(value) ? [[identity, value]] : []
22
+ })
23
+ if (values.length) storage.setItem(storageKey(route), JSON.stringify({ time: now, values }))
24
+ return values.length > 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ export function restoreState(storage, route, state, schema, commit, now = Date.now()) {
31
+ let snapshot
32
+ try {
33
+ const raw = storage.getItem(storageKey(route))
34
+ if (raw === null) return []
35
+ storage.removeItem(storageKey(route))
36
+ snapshot = JSON.parse(raw)
37
+ } catch {
38
+ return []
39
+ }
40
+
41
+ if (!(state instanceof Map) || !Array.isArray(schema) || typeof commit !== "function") return []
42
+ if (!snapshot || !Number.isFinite(snapshot.time) || now < snapshot.time || now - snapshot.time > maxAge || !Array.isArray(snapshot.values)) return []
43
+ const ids = new Map(schema.flatMap(entry => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string" && typeof entry[1] === "string" ? [[entry[1], entry[0]]] : []))
44
+ const changes = []
45
+ const seen = new Set()
46
+ for (const entry of snapshot.values) {
47
+ if (!Array.isArray(entry) || entry.length !== 2) continue
48
+ const [identity, value] = entry
49
+ const id = ids.get(identity)
50
+ if (typeof identity !== "string" || seen.has(identity) || !state.has(id) || !jsonSafe(value) || shape(value) !== shape(state.get(id))) continue
51
+ seen.add(identity)
52
+ changes.push({ id, value, original: state.get(id) })
53
+ }
54
+ for (const { id, value } of changes) state.set(id, value)
55
+ try {
56
+ for (const { id } of changes) commit(id, state.get(id))
57
+ } catch {
58
+ for (const { id, original } of changes) state.set(id, original)
59
+ for (const { id } of changes) {
60
+ try { commit(id, state.get(id)) } catch {}
61
+ }
62
+ return []
63
+ }
64
+ return changes.map(({ id }) => id)
65
+ }
66
+
67
+ function storageKey(route) {
68
+ return `__kudzu_state:${route}`
69
+ }
70
+
71
+ function shape(value) {
72
+ if (Array.isArray(value)) return "array"
73
+ if (value === null) return "null"
74
+ return typeof value === "object" ? "object" : typeof value
75
+ }
76
+
77
+ function jsonSafe(value, seen = new Set()) {
78
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true
79
+ if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0)
80
+ if (!value || typeof value !== "object" || seen.has(value) || Object.getOwnPropertySymbols(value).length) return false
81
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) return false
82
+
83
+ const descriptors = Object.getOwnPropertyDescriptors(value)
84
+ if (Array.isArray(value)) {
85
+ if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) return false
86
+ if (Object.keys(value).length !== value.length) return false
87
+ }
88
+ seen.add(value)
89
+ for (const [key, descriptor] of Object.entries(descriptors)) {
90
+ if (Array.isArray(value) && key === "length") continue
91
+ if (!descriptor.enumerable || !("value" in descriptor) || !jsonSafe(descriptor.value, seen)) {
92
+ seen.delete(value)
93
+ return false
94
+ }
95
+ }
96
+ seen.delete(value)
97
+ return true
98
+ }
@@ -3,6 +3,8 @@ import { browserState, mountDom, registerCommitter, registerMountHook, registerU
3
3
  const listTargets = new Map()
4
4
  const listRegistrations = new WeakMap()
5
5
  const mountedLists = new WeakSet()
6
+ const imports = new Map()
7
+ const revisions = new WeakMap()
6
8
 
7
9
  function commitLists(id) {
8
10
  const lists = listTargets.get(id)
@@ -97,20 +99,62 @@ function updateList(list) {
97
99
  }
98
100
 
99
101
  function fillListItem(root, item) {
102
+ const revision = (revisions.get(root) ?? 0) + 1
103
+ revisions.set(root, revision)
100
104
  for (const marker of matching(root, "template[data-k-list-text]")) {
101
- const value = item?.[marker.dataset.kListText]
102
- let end = marker.nextSibling
103
- while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches("template[data-k-list-text-end]"))) end = end.nextSibling
104
- if (!end) throw new Error("Keyed list text marker has no end")
105
- const range = marker.ownerDocument.createRange()
106
- range.setStartAfter(marker)
107
- range.setEndBefore(end)
108
- range.deleteContents()
109
- end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
105
+ patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
110
106
  }
111
107
  for (const node of matching(root, "[data-k-list-attrs]")) {
112
108
  for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
113
109
  }
110
+ for (const node of matching(root, "[data-k-list-events]")) {
111
+ for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
112
+ native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
113
+ node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
114
+ }
115
+ }
116
+ for (const marker of matching(root, "template[data-k-list-expression]")) {
117
+ evaluate(JSON.parse(marker.dataset.kListExpression), item).then(value => {
118
+ if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
119
+ }).catch(error => console.error(error))
120
+ }
121
+ for (const node of matching(root, "[data-k-list-expression-attrs]")) {
122
+ for (const [target, module, handler] of JSON.parse(node.dataset.kListExpressionAttrs)) {
123
+ evaluate({ module, handler }, item).then(value => {
124
+ if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
125
+ }).catch(error => console.error(error))
126
+ }
127
+ }
128
+ }
129
+
130
+ function patchListText(marker, endSelector, value) {
131
+ let end = marker.nextSibling
132
+ while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
133
+ if (!end) throw new Error("Keyed list text marker has no end")
134
+ const range = marker.ownerDocument.createRange()
135
+ range.setStartAfter(marker)
136
+ range.setEndBefore(end)
137
+ range.deleteContents()
138
+ end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
139
+ }
140
+
141
+ function evaluate(descriptor, item) {
142
+ let module = imports.get(descriptor.module)
143
+ if (!module) {
144
+ module = import(descriptor.module)
145
+ imports.set(descriptor.module, module)
146
+ }
147
+ return module.then(exports => {
148
+ const value = exports[descriptor.handler](item)
149
+ if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
150
+ return value
151
+ })
152
+ }
153
+
154
+ function serializeItem(value) {
155
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return value
156
+ if (Array.isArray(value)) return { type: "array", value: value.map(serializeItem) }
157
+ return { type: "object", nullPrototype: false, value: Object.entries(value).map(([key, entry]) => [key, serializeItem(entry)]) }
114
158
  }
115
159
 
116
160
  function patchBinding(node, target, value) {
@@ -142,7 +186,7 @@ function validListKey(key) {
142
186
 
143
187
  function assertListItem(item) {
144
188
  const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
145
- if (!item || Array.isArray(item) || prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must be plain objects")
189
+ if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
146
190
  }
147
191
 
148
192
  function assertListValue(value, seen) {
@@ -150,7 +194,7 @@ function assertListValue(value, seen) {
150
194
  if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
151
195
  if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
152
196
  const prototype = Object.getPrototypeOf(value)
153
- if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must contain only arrays and plain objects")
197
+ if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
154
198
  if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
155
199
  seen.add(value)
156
200
  const descriptors = Object.getOwnPropertyDescriptors(value)
@@ -183,3 +227,7 @@ function matching(root, selector) {
183
227
  function isStringBooleanAttribute(name) {
184
228
  return name.startsWith("aria-") || name.startsWith("data-")
185
229
  }
230
+
231
+ function capitalize(value) {
232
+ return value[0].toUpperCase() + value.slice(1)
233
+ }
@@ -39,19 +39,38 @@ if (typeof document !== "undefined") {
39
39
  const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
40
40
  for (const eventName of eventNames) {
41
41
  document.addEventListener(eventName, event => {
42
- const target = event.target.closest(`[data-k-native-${eventName}]`)
43
- if (!target) return
44
-
45
- const native = JSON.parse(target.dataset[`kNative${capitalize(eventName)}`])
46
- let modulePromise = modules.get(native.module)
47
- if (!modulePromise) {
48
- modulePromise = import(native.module)
49
- modules.set(native.module, modulePromise)
42
+ try {
43
+ dispatchNative(event, snapshotNativeTargets(event, eventName), modules).catch(error => console.error(error))
44
+ } catch (error) {
45
+ console.error(error)
50
46
  }
51
- modulePromise
52
- .then(module => module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target)))
53
- .catch(error => console.error(error))
54
- }, true)
47
+ })
48
+ }
49
+ }
50
+
51
+ function snapshotNativeTargets(event, eventName) {
52
+ const selector = `[data-k-native-${eventName}]`
53
+ const targets = []
54
+ for (let target = event.target.closest(selector); target; target = target.parentElement?.closest(selector)) {
55
+ targets.push({ target, native: JSON.parse(target.dataset[`kNative${capitalize(eventName)}`]) })
56
+ }
57
+ return targets
58
+ }
59
+
60
+ async function dispatchNative(event, targets, modules) {
61
+ for (const { target, native } of targets) {
62
+ let modulePromise = modules.get(native.module)
63
+ if (!modulePromise) {
64
+ modulePromise = import(native.module)
65
+ modules.set(native.module, modulePromise)
66
+ }
67
+ try {
68
+ const module = await modulePromise
69
+ const result = module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target))
70
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
71
+ } catch (error) {
72
+ console.error(error)
73
+ }
55
74
  }
56
75
  }
57
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",