@kudzujs/core 0.6.20 → 0.6.22

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
@@ -128,18 +128,26 @@ Static trusted HTML can be rendered without a transform layer:
128
128
 
129
129
  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.
130
130
 
131
- Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Stylesheets produced by another build step can be declared globally so Kudzu still emits them in every document `<head>`. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
131
+ Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. A source style entry reads CSS, optionally transforms it, writes its declared output, and links it without an `afterBuild` file pipeline. `publicDir` defaults to `public` and may point elsewhere. Global or page `metadata` may be an object or a function of `{ route, params, props }`, so route props can set document language and head resources before rendering:
132
132
 
133
133
  ```js
134
134
  export default {
135
135
  base: "/newsletter",
136
- styles: ["/assets/generated.css"],
136
+ publicDir: "../public",
137
+ styles: [{
138
+ source: "../src/styles/global.css",
139
+ output: "/assets/styles.css",
140
+ transform: css => transformCss(css)
141
+ }],
142
+ metadata: ({ props }) => ({ lang: props.locale, manifest: "/manifest.json" }),
137
143
  async afterBuild({ outDir, routes, plans, rewrites, base }) {
138
- // Write generated.css, host rewrites, RSS, sitemap, or other static artifacts.
144
+ // Write host rewrites, RSS, sitemap, or other non-document artifacts.
139
145
  }
140
146
  }
141
147
  ```
142
148
 
149
+ The transform may return CSS text or an object with a `css` string, matching common CSS processor results. Page-exported `metadata` takes precedence over config metadata and may use the same function form.
150
+
143
151
  Do not render `<link rel="stylesheet">` from page or component JSX. Kudzu rejects direct static body stylesheets with a source location and catches computed JSX stylesheet output during rendering. Trusted `dangerouslySetInnerHTML` remains unparsed and is responsible for its own resource tags.
144
152
 
145
153
  ## State Semantics
@@ -388,7 +396,18 @@ return <ItemList items={items} />
388
396
 
389
397
  The original row component remains reusable across multiple lists and ordinary JSX. State-backed list wrappers and row components are specialized to intrinsic JSX at build time; no component function or component runtime is shipped to the browser. 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, in one top-level immutable `const` rendered once as a JSX child, or in one synchronous wrapper receiving the state identifier as a direct prop. 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 and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with state as `[version, item.name]`, reruns only rows whose selected value changed; the replacement setup receives the complete latest item. Unrelated fields and reorder do not rerun it, while a key change removes and mounts the row. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
390
398
 
391
- 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 top-level local or relative-imported row component, and `key={item.<field>}`. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Same-file wrappers must be unexported and state-backed at every call; relative default, named/aliased, and direct named re-export wrappers are specialized per qualifying call. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. 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, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package, namespace, and star-export list wrappers, package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
399
+ One nested keyed map may read a direct array property of its parent item. This supports category/item data populated after mount while preserving both parent and child DOM identity across updates and reorder:
400
+
401
+ ```tsx
402
+ {categories.map(category => <section key={category.id}>
403
+ <h2>{category.title}</h2>
404
+ <ul>{category.items.map(item => <li key={item.id}>{item.title}</li>)}</ul>
405
+ </section>)}
406
+ ```
407
+
408
+ The nested collection must be `parent.<field>`, the child row must have one intrinsic root, and child handlers may capture the child item. A second child list, third nesting level, computed collection, parent-item capture from the child row, child conditions, child effects, child components, and child row-local state remain unsupported.
409
+
410
+ 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 top-level local or relative-imported row component, and `key={item.<field>}`. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Same-file wrappers must be unexported and state-backed at every call; relative default, named/aliased, and direct named re-export wrappers are specialized per qualifying call. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. 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, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package, namespace, and star-export list wrappers, package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions beyond the direct one-level form above, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
392
411
 
393
412
  The focused wrapper fixture emits 1,393 B raw / 500 B gzip HTML and 10,719 B raw / 4,665 B gzip JavaScript across its route capabilities. After one warm-up, seven clean builds measured 314.1, 325.3, 322.3, 327.2, 336.1, 322.4, and 315.0 ms, with a 322.4 ms median.
394
413
 
@@ -20,6 +20,7 @@ export async function build({ quiet = false, minify = true } = {}) {
20
20
  const config = await loadConfig()
21
21
  const base = normalizeBase(config.base)
22
22
  const configuredStyles = normalizeStyles(config.styles, base)
23
+ const publicDirectory = normalizePublicDirectory(config.publicDir)
23
24
  const navigationGroups = normalizeNavigation(config.navigation)
24
25
  const navigationRoutes = navigationGroups.flatMap(group => group.routes)
25
26
  const navigationByRoute = new Map(navigationGroups.flatMap(group => group.routes.map(route => [route, group])))
@@ -39,7 +40,8 @@ export async function build({ quiet = false, minify = true } = {}) {
39
40
 
40
41
  const projectFiles = await walk(sourceDirectory)
41
42
  const sourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file)).sort()
42
- const cssFiles = projectFiles.filter(file => file.endsWith(".css")).sort()
43
+ const configuredStyleSources = new Set(configuredStyles.sources.map(style => style.source))
44
+ const cssFiles = projectFiles.filter(file => file.endsWith(".css") && !configuredStyleSources.has(file)).sort()
43
45
  if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
44
46
  const sourceFileSet = new Set(sourceFiles)
45
47
  const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
@@ -74,7 +76,7 @@ export async function build({ quiet = false, minify = true } = {}) {
74
76
  const renderedHandlerUrls = new Set()
75
77
  const styleUrls = [...new Set([
76
78
  ...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
77
- ...configuredStyles
79
+ ...configuredStyles.urls
78
80
  ])]
79
81
  const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
80
82
 
@@ -101,6 +103,9 @@ export async function build({ quiet = false, minify = true } = {}) {
101
103
  const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
102
104
  const applicationRoute = `/${route}`
103
105
  const routePath = withBase(base, `/${route}`)
106
+ const metadataContext = { route: routePath, params, props }
107
+ const configuredMetadata = await resolveDocumentMetadata(config.metadata, metadataContext, "kudzu.config metadata")
108
+ const pageMetadata = await resolveDocumentMetadata(module.metadata, metadataContext, `${relative(root, pageFile)} metadata`)
104
109
  const navigationGroup = navigationByRoute.get(applicationRoute)
105
110
  const navigable = Boolean(navigationGroup)
106
111
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
@@ -123,7 +128,8 @@ export async function build({ quiet = false, minify = true } = {}) {
123
128
  navigationGroup.routeRecords.push(routeRecord)
124
129
  }
125
130
  const result = await renderPage(module.default, {
126
- ...(module.metadata ?? {}),
131
+ ...configuredMetadata,
132
+ ...pageMetadata,
127
133
  styles: styleUrls.length ? styleUrls : false,
128
134
  base,
129
135
  runtimeAsset: runtimePlaceholder,
@@ -175,7 +181,7 @@ export async function build({ quiet = false, minify = true } = {}) {
175
181
  const emittedHandlerModules = handlerModules.filter(module => renderedHandlerUrls.has(assetPath(base, `assets/${module.path}`)))
176
182
  const renderedEffects = new Set(plans.flatMap(plan => plan.effects.map(effect => `${effect.module}:${effect.handler}`)))
177
183
  const renderedWorkerReferences = workerReferences.filter(reference => renderedEffects.has(`${reference.module}:${reference.handler}`))
178
- if (renderedWorkerReferences.length && await exists(join(root, "public", "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
184
+ if (renderedWorkerReferences.length && await exists(join(publicDirectory, "assets", "workers"))) throw new Error("public/assets/workers collides with Kudzu's generated Worker asset namespace")
179
185
  const workerAssets = await emitWorkers(renderedWorkerReferences, sourceFileSet, assetsDirectory, base, minify)
180
186
  for (const module of emittedHandlerModules) {
181
187
  for (const reference of workerReferences) {
@@ -197,9 +203,10 @@ export async function build({ quiet = false, minify = true } = {}) {
197
203
  const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
198
204
  const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
199
205
  const hasListRowStates = plans.some(plan => plan.lists.some(list => list.rowStates))
206
+ const hasNestedLists = plans.some(plan => plan.lists.some(list => list.ownerField))
200
207
  const hasItemDependencies = plans.some(plan => plan.effects.some(effect => effect.itemDependencies?.length))
201
208
  const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
202
- const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
209
+ const hasListMounts = hasListConditions || hasNestedLists || plans.some(plan => plan.lists.some(list => list.mount))
203
210
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
204
211
  const hasSetterCaptures = hasCaptureType(plans, "setter")
205
212
  const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
@@ -278,7 +285,8 @@ export async function build({ quiet = false, minify = true } = {}) {
278
285
  __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
279
286
  __KUDZU_LIST_MOUNTS__: String(hasListMounts),
280
287
  __KUDZU_LIST_ITEM_HOOKS__: String(hasItemDependencies),
281
- __KUDZU_LIST_ROW_STATES__: String(hasListRowStates)
288
+ __KUDZU_LIST_ROW_STATES__: String(hasListRowStates),
289
+ __KUDZU_NESTED_LISTS__: String(hasNestedLists)
282
290
  })
283
291
  }
284
292
  if (hasNativeHandlers) {
@@ -352,7 +360,18 @@ export async function build({ quiet = false, minify = true } = {}) {
352
360
  await mkdir(dirname(output), { recursive: true })
353
361
  await cp(file, output)
354
362
  }
355
- if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
363
+ for (const style of configuredStyles.sources) {
364
+ let css = await readFile(style.source, "utf8")
365
+ if (style.transform) {
366
+ const result = await style.transform(css, { source: style.source, output: style.output })
367
+ css = typeof result === "string" ? result : result?.css
368
+ if (typeof css !== "string") throw new Error(`${style.label}.transform must return CSS text or an object with a css string`)
369
+ }
370
+ const output = join(outputDirectory, style.output.slice(1))
371
+ await mkdir(dirname(output), { recursive: true })
372
+ await writeFile(output, css)
373
+ }
374
+ if (await exists(publicDirectory)) await cp(publicDirectory, outputDirectory, { recursive: true })
356
375
  if (config.afterBuild !== undefined) {
357
376
  if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
358
377
  await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans, rewrites: sortedRewrites })
@@ -1666,6 +1685,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1666
1685
  const listValues = new WeakMap()
1667
1686
  const listEventItems = new WeakMap()
1668
1687
  const listConditions = new WeakMap()
1688
+ const nestedLists = new WeakMap()
1669
1689
  const listEffectEntries = new WeakMap()
1670
1690
  let usesBehavior = false
1671
1691
  let usesBinding = false
@@ -2018,7 +2038,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2018
2038
  calculation.parent = callback
2019
2039
  validateListExpression(calculation, parts.item, originalParts.root, fail)
2020
2040
  }
2021
- validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, settersForNode(originalParts.root, settersByFunction), specialization?.rowState)
2041
+ validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, settersForNode(originalParts.root, settersByFunction), specialization?.rowState, nestedLists)
2022
2042
  if (specialization?.effects.length) {
2023
2043
  usesListEffects = true
2024
2044
  const statements = specialization.effects.map(entry => {
@@ -2185,14 +2205,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2185
2205
  }
2186
2206
 
2187
2207
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
2188
- const listParts = renderedLists.get(node)
2208
+ const nestedParts = nestedLists.get(unwrapExpression(node.expression))
2209
+ const listParts = renderedLists.get(node) ?? nestedParts
2189
2210
  if (listParts) {
2190
2211
  usesBehavior = true
2191
2212
  usesList = true
2192
2213
  return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
2193
2214
  listParts.state,
2194
2215
  factory.createStringLiteral(listParts.keyField),
2195
- ts.visitNode(listParts.callback, visitor)
2216
+ ts.visitNode(listParts.callback, visitor),
2217
+ ...(nestedParts ? [factory.createStringLiteral(listParts.ownerField)] : [])
2196
2218
  ]))
2197
2219
  }
2198
2220
  const conditional = conditionalParts(node.expression)
@@ -2395,6 +2417,25 @@ function keyedListParts(expression, setters) {
2395
2417
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
2396
2418
  }
2397
2419
 
2420
+ function nestedKeyedListParts(expression, parentItem) {
2421
+ const value = unwrapExpression(expression)
2422
+ if (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map") return undefined
2423
+ const collection = value.expression.expression
2424
+ if (!ts.isPropertyAccessExpression(collection) || !ts.isIdentifier(collection.expression) || collection.expression.text !== parentItem) return undefined
2425
+ const callback = value.arguments[0]
2426
+ if (!ts.isArrowFunction(callback) || callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name)) {
2427
+ throw new Error("Nested keyed list map callback must be an arrow function with one identifier parameter")
2428
+ }
2429
+ const root = unwrapExpression(callback.body)
2430
+ if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) throw new Error("Nested keyed list map callback must return one JSX element")
2431
+ const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
2432
+ const key = attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === "key")
2433
+ const item = callback.parameters[0].name.text
2434
+ const keyField = key && ts.isJsxAttribute(key) && key.initializer && ts.isJsxExpression(key.initializer) && key.initializer.expression && directProperty(key.initializer.expression, item)
2435
+ if (!keyField) throw new Error(`Nested keyed list root must have key={${item}.<field>}`)
2436
+ return { callback, root, item, keyField, ownerField: collection.name.text }
2437
+ }
2438
+
2398
2439
  function isStateBackedListComponentCall(call, component, setters) {
2399
2440
  if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) return false
2400
2441
  const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
@@ -2479,7 +2520,7 @@ function insideJsxEventHandler(node, root) {
2479
2520
  return false
2480
2521
  }
2481
2522
 
2482
- function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, setters, rowState) {
2523
+ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, setters, rowState, nestedLists) {
2483
2524
  const fail = (node, message) => {
2484
2525
  throw sourceNodeError(node, sourceFile, message)
2485
2526
  }
@@ -2490,10 +2531,11 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2490
2531
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
2491
2532
  }
2492
2533
  let conditionDepth = 0
2534
+ let nestedList
2493
2535
  const visit = node => {
2494
2536
  if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
2495
2537
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
2496
- 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")
2538
+ if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, parts.nested ? "Keyed lists support at most one nested level" : "Nested keyed list collections must be a direct property of the parent item")
2497
2539
  if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
2498
2540
  if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.text)) {
2499
2541
  listEventItems.set(node, item)
@@ -2501,8 +2543,21 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2501
2543
  }
2502
2544
  if (ts.isJsxExpression(node) && node.expression) {
2503
2545
  const expression = unwrapExpression(node.expression)
2546
+ if (containsJsx(expression) && ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && expression.expression.name.text === "map") {
2547
+ const nested = nestedKeyedListParts(expression, item)
2548
+ if (!nested) fail(expression, parts.nested ? "Keyed lists support at most one nested level" : "Nested keyed list collections must be a direct property of the parent item")
2549
+ if (parts.nested) fail(expression, "Keyed lists support at most one nested level")
2550
+ if (nestedList) fail(expression, "Keyed list rows support one nested keyed list")
2551
+ if (referenceIdentifiers(nested.callback, item).length) fail(nested.root, "Nested keyed list rows cannot capture the parent item")
2552
+ nestedList = nested
2553
+ const nestedParts = { ...nested, state: parts.state, nested: true }
2554
+ nestedLists.set(expression, nestedParts)
2555
+ validateKeyedList(nestedParts, sourceFile, listValues, listEventItems, listConditions, setters, undefined, nestedLists)
2556
+ return
2557
+ }
2504
2558
  const condition = conditionalParts(expression)
2505
2559
  if (condition && containsJsx(expression)) {
2560
+ if (parts.nested) fail(node, "Nested keyed list item conditions are not supported")
2506
2561
  if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
2507
2562
  if (rowState && referencedStateNames(condition.condition, setters).has(rowState.state)) {
2508
2563
  conditionDepth++
@@ -3859,16 +3914,49 @@ async function loadConfig() {
3859
3914
  }
3860
3915
 
3861
3916
  function normalizeStyles(value, base) {
3862
- if (value === undefined) return []
3863
- if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array of URLs")
3864
- return value.map((style, index) => {
3865
- if (typeof style !== "string" || !style) throw new Error(`kudzu.config styles[${index}] must be a non-empty URL`)
3866
- if (style.startsWith("//")) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
3867
- if (style.startsWith("/")) return withBase(base, style)
3868
- if (!/^https?:\/\//i.test(style)) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
3869
- try { new URL(style) } catch { throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`) }
3870
- return style
3871
- })
3917
+ if (value === undefined) return { urls: [], sources: [] }
3918
+ if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array")
3919
+ const urls = []
3920
+ const sources = []
3921
+ for (let index = 0; index < value.length; index++) {
3922
+ const style = value[index]
3923
+ const label = `kudzu.config styles[${index}]`
3924
+ if (typeof style === "string") {
3925
+ if (!style) throw new Error(`${label} must be a non-empty URL`)
3926
+ if (style.startsWith("//")) throw new Error(`${label} must be root-relative or an absolute HTTP URL`)
3927
+ if (style.startsWith("/")) {
3928
+ urls.push(withBase(base, style))
3929
+ continue
3930
+ }
3931
+ if (!/^https?:\/\//i.test(style)) throw new Error(`${label} must be root-relative or an absolute HTTP URL`)
3932
+ try { new URL(style) } catch { throw new Error(`${label} must be root-relative or an absolute HTTP URL`) }
3933
+ urls.push(style)
3934
+ continue
3935
+ }
3936
+ if (!isPlainRecord(style) || Object.keys(style).some(key => !["source", "output", "transform"].includes(key))) throw new Error(`${label} must be a URL or a source style object`)
3937
+ if (typeof style.source !== "string" || !style.source) throw new Error(`${label}.source must be a non-empty file path`)
3938
+ if (typeof style.output !== "string" || !style.output.startsWith("/") || style.output.startsWith("//") || /[%?#\\\0]/.test(style.output) || style.output.split("/").includes("..") || !style.output.endsWith(".css")) throw new Error(`${label}.output must be a root-relative .css path without query, hash, or traversal`)
3939
+ if (style.transform !== undefined && typeof style.transform !== "function") throw new Error(`${label}.transform must be a function`)
3940
+ const entry = { label, source: resolve(root, style.source), output: style.output, transform: style.transform }
3941
+ sources.push(entry)
3942
+ urls.push(withBase(base, style.output))
3943
+ }
3944
+ return { urls, sources }
3945
+ }
3946
+
3947
+ function normalizePublicDirectory(value) {
3948
+ if (value === undefined) return join(root, "public")
3949
+ if (typeof value !== "string" || !value) throw new Error("kudzu.config publicDir must be a non-empty directory path")
3950
+ const directory = resolve(root, value)
3951
+ if (directory === outputDirectory || directory === workDirectory) throw new Error("kudzu.config publicDir cannot be dist or .kudzu")
3952
+ return directory
3953
+ }
3954
+
3955
+ async function resolveDocumentMetadata(value, context, label) {
3956
+ if (value === undefined) return {}
3957
+ const metadata = typeof value === "function" ? await value(context) : value
3958
+ if (!isPlainRecord(metadata)) throw new Error(`${label} must be a plain object or a function returning one`)
3959
+ return metadata
3872
3960
  }
3873
3961
 
3874
3962
  export function normalizeNavigation(value) {
@@ -27,42 +27,50 @@ export function nativeBehavior(module: string, handler: string, states: Array<[s
27
27
  export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
28
28
  export function bindingValue(value: unknown): unknown
29
29
  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
30
- export function list(items: unknown, keyField: string, render: (item: unknown) => unknown): unknown
30
+ export function list(items: unknown, keyField: string, render: (item: unknown) => unknown, ownerField?: string): unknown
31
31
  export function listField(read: () => unknown, field: string): unknown
32
32
  export function listExpression(read: () => unknown, module: string, handler: string): unknown
33
33
  export function listItem(): unknown
34
34
  export function listConditional(kind: "and" | "ternary", read: () => unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string): unknown
35
35
 
36
+ export type PageMetadata = {
37
+ title?: string
38
+ description?: string
39
+ lang?: string
40
+ locale?: string
41
+ siteName?: string
42
+ type?: string
43
+ url?: string
44
+ image?: string
45
+ imageAlt?: string
46
+ twitterCard?: string
47
+ twitterImage?: string
48
+ themeColor?: string
49
+ icon?: string
50
+ appleTouchIcon?: string
51
+ manifest?: string
52
+ styles?: boolean | string[]
53
+ base?: string
54
+ runtimeAsset?: string
55
+ effectAsset?: string
56
+ nativeAsset?: string
57
+ paramAsset?: string
58
+ runtimeParams?: string[]
59
+ navigationAsset?: string
60
+ applicationId?: string
61
+ layoutId?: string
62
+ routeId?: string
63
+ }
64
+
65
+ export type MetadataContext<Props = Record<string, unknown>> = {
66
+ route: string
67
+ params: Record<string, string>
68
+ props: Props
69
+ }
70
+
36
71
  export function renderPage<Props = Record<string, never>>(
37
72
  component: (props: Props) => unknown | Promise<unknown>,
38
- metadata?: {
39
- title?: string
40
- description?: string
41
- lang?: string
42
- locale?: string
43
- siteName?: string
44
- type?: string
45
- url?: string
46
- image?: string
47
- imageAlt?: string
48
- twitterCard?: string
49
- twitterImage?: string
50
- themeColor?: string
51
- icon?: string
52
- appleTouchIcon?: string
53
- manifest?: string
54
- styles?: boolean | string[]
55
- base?: string
56
- runtimeAsset?: string
57
- effectAsset?: string
58
- nativeAsset?: string
59
- paramAsset?: string
60
- runtimeParams?: string[]
61
- navigationAsset?: string
62
- applicationId?: string
63
- layoutId?: string
64
- routeId?: string
65
- },
73
+ metadata?: PageMetadata,
66
74
  props?: Props,
67
75
  layout?: (props: { children: unknown }) => unknown | Promise<unknown>
68
76
  ): Promise<{
@@ -214,10 +214,18 @@ export function stateConditional(kind, state, truthy, falsy) {
214
214
  return { [conditionalMarker]: true, kind, value: state.value, truthy, falsy, state: state.id }
215
215
  }
216
216
 
217
- export function list(items, keyField, render) {
217
+ export function list(items, keyField, render, ownerField) {
218
218
  if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state")
219
+ let values = items.value
220
+ if (ownerField) {
221
+ const owner = renderContext?.listRoot ?? renderContext?.listRowRoot
222
+ if (!owner) throw new Error("A nested keyed list must be rendered inside a keyed row")
223
+ renderContext.listFields?.add(ownerField)
224
+ values = renderContext.listTemplate ? [] : owner.item?.[ownerField]
225
+ if (!Array.isArray(values)) throw new Error(`Nested keyed list property "${ownerField}" must remain an array`)
226
+ }
219
227
  const keys = new Set()
220
- for (const item of items.value) {
228
+ for (const item of values) {
221
229
  const key = item?.[keyField]
222
230
  if (!validListKey(key)) throw new Error(`Keyed list key "${keyField}" must be a string or finite number`)
223
231
  assertListItem(item)
@@ -226,7 +234,7 @@ export function list(items, keyField, render) {
226
234
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
227
235
  keys.add(token)
228
236
  }
229
- return { [listMarker]: true, items, keyField, render }
237
+ return { [listMarker]: true, items, values, keyField, render, ownerField }
230
238
  }
231
239
 
232
240
  export function listField(read, field) {
@@ -347,7 +355,7 @@ function serializeCapture(name, value, seen) {
347
355
  }
348
356
 
349
357
  export async function renderPage(component, metadata = {}, props = {}, layout) {
350
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowConditions: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
358
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
351
359
 
352
360
  try {
353
361
  const page = { [routeScopeMarker]: true, component, props }
@@ -735,23 +743,33 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
735
743
 
736
744
  async function renderList(node, namespace, selectValue) {
737
745
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
738
- const id = nextRenderId("l")
739
- const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]), ...(node.items[reducerStateMarker] ? { reducer: true } : {}) }
746
+ const ownerRoot = node.ownerField ? renderContext.listRoot ?? renderContext.listRowRoot : undefined
747
+ const ownerTemplate = Boolean(node.ownerField && renderContext.listTemplate)
748
+ const id = node.ownerField ? nextRowListId() : nextRenderId("l")
749
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map(item => item[node.keyField]), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.items[reducerStateMarker] ? { reducer: true } : {}) }
750
+ if (ownerTemplate) Object.assign(ownerRoot.descriptor, { child: { field: node.ownerField, key: node.keyField }, mount: true, nested: true })
740
751
  renderContext.listDepth++
752
+ const previousListRoot = renderContext.listRoot
753
+ const previousListRowRoot = renderContext.listRowRoot
754
+ const previousListTemplate = renderContext.listTemplate
755
+ const previousListInitialMarkers = renderContext.listInitialMarkers
741
756
  const previousListFields = renderContext.listFields
742
757
  const previousListEffectOwners = renderContext.listEffectOwners
743
758
  const previousListRowStates = renderContext.listRowStates
744
759
  const previousListRowConditions = renderContext.listRowConditions
760
+ const previousListRowLists = renderContext.listRowLists
745
761
  try {
746
762
  renderContext.listTemplate = true
747
763
  renderContext.listEffectOwners = []
748
764
  renderContext.listRowStates = []
749
765
  renderContext.listRowConditions = []
766
+ renderContext.listRowLists = []
750
767
  renderContext.listFields = new Set([node.keyField])
751
- renderContext.listRoot = { id, state: node.items.id, template: true, effects: [], item: {}, rowIndexes: { s: 0, c: 0 } }
768
+ renderContext.listRoot = { id, state: node.items.id, descriptor, template: true, effects: [], item: {}, rowIndexes: { s: 0, c: 0, l: 0 } }
752
769
  renderContext.listRowRoot = renderContext.listRoot
753
770
  const template = await renderNode(node.render({}), namespace, selectValue)
754
- if (template.includes("data-k-native-") || template.includes("data-k-effects=")) descriptor.mount = true
771
+ if (template.includes("data-k-native-") || template.includes("data-k-effects=") || template.includes("data-k-list=")) descriptor.mount = true
772
+ if (template.includes("data-k-list=")) descriptor.nested = true
755
773
  if (template.includes("data-k-effects=")) descriptor.effects = true
756
774
  if (template.includes("data-k-list-condition")) descriptor.conditions = true
757
775
  if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
@@ -764,33 +782,47 @@ async function renderList(node, namespace, selectValue) {
764
782
  if (renderContext.listRowConditions.length) descriptor.rowConditions = renderContext.listRowConditions.map(({ id }) => id)
765
783
  descriptor.mount = true
766
784
  }
767
- const seed = listSeed(node.items.value, renderContext.listFields)
785
+ const seed = node.ownerField ? undefined : listSeed(node.values, renderContext.listFields)
768
786
  if (seed) descriptor.seed = seed
769
787
  let current = ""
770
788
  renderContext.listTemplate = false
771
789
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
772
- for (const item of node.items.value) {
773
- renderContext.listRoot = { id, state: node.items.id, key: item[node.keyField], template: false, effects: [], item, rowIndexes: { s: 0, c: 0 } }
790
+ for (const item of node.values) {
791
+ renderContext.listRoot = { id, state: node.items.id, descriptor, key: item[node.keyField], template: false, effects: [], item, rowIndexes: { s: 0, c: 0, l: 0 } }
774
792
  renderContext.listRowRoot = renderContext.listRoot
775
793
  current += await renderNode(node.render(item), namespace, selectValue)
776
794
  }
777
- renderContext.lists.push(descriptor)
795
+ if (!node.ownerField || ownerTemplate) renderContext.lists.push(descriptor)
778
796
  renderContext.hasBehaviors = true
779
797
  renderContext.hasLists = true
780
798
  return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
781
799
  } finally {
782
- renderContext.listRoot = undefined
783
- renderContext.listRowRoot = undefined
784
- renderContext.listTemplate = false
785
- renderContext.listInitialMarkers = false
800
+ renderContext.listRoot = previousListRoot
801
+ renderContext.listRowRoot = previousListRowRoot
802
+ renderContext.listTemplate = previousListTemplate
803
+ renderContext.listInitialMarkers = previousListInitialMarkers
786
804
  renderContext.listFields = previousListFields
787
805
  renderContext.listEffectOwners = previousListEffectOwners
788
806
  renderContext.listRowStates = previousListRowStates
789
807
  renderContext.listRowConditions = previousListRowConditions
808
+ renderContext.listRowLists = previousListRowLists
790
809
  renderContext.listDepth--
791
810
  }
792
811
  }
793
812
 
813
+ function nextRowListId() {
814
+ const root = renderContext.listRoot ?? renderContext.listRowRoot
815
+ const index = root.rowIndexes.l++
816
+ if (renderContext.listTemplate) {
817
+ const id = nextRenderId("l")
818
+ renderContext.listRowLists[index] = { id }
819
+ return id
820
+ }
821
+ const entry = renderContext.listRowLists[index]
822
+ if (!entry) throw new Error("Nested keyed lists must have the same order for every parent item")
823
+ return entry.id
824
+ }
825
+
794
826
  function nextRenderId(kind) {
795
827
  if (renderContext.scoped) return `${renderContext.renderScope === "layout" ? "l" : "r"}${kind}${renderContext.counters[renderContext.renderScope][kind]++}`
796
828
  const counters = { s: "nextState", r: "nextRef", c: "nextCondition", l: "nextList", e: "nextEffect", p: "nextParam" }
@@ -7,6 +7,7 @@ const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
7
7
  const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
8
8
  const itemParts = new WeakMap()
9
9
  const listItems = new WeakMap()
10
+ const ownedLists = __KUDZU_NESTED_LISTS__ ? new WeakMap() : undefined
10
11
  const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
11
12
  const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}${__KUDZU_LIST_EFFECTS__ ? ",[data-k-effects]" : ""}`
12
13
 
@@ -37,8 +38,8 @@ function mountLists(root) {
37
38
  const roots = listRoots(start, end)
38
39
  if (__KUDZU_LIST_ROW_STATES__ && descriptor.rowStates) for (let index = 0; index < roots.length; index++) initializeRowStates(descriptor, descriptor.keys[index])
39
40
  const templateRoot = start.content.firstElementChild
40
- const parts = listItemPartPlan(templateRoot)
41
- for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
41
+ const parts = listItemPartPlan(templateRoot, descriptor.nested)
42
+ for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root, descriptor.nested) : mapListItemParts(parts, root, descriptor.nested)
42
43
  if (__KUDZU_LIST_SEEDS__ && descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
43
44
  const items = browserState.get(descriptor.state)
44
45
  const list = {
@@ -50,10 +51,18 @@ function mountLists(root) {
50
51
  values: new Map(),
51
52
  items: undefined,
52
53
  container: roots[0]?.parentNode,
53
- boundary: end
54
+ boundary: end,
55
+ ...(__KUDZU_NESTED_LISTS__ && descriptor.ownerField ? { owner: listOwner(start) } : {})
56
+ }
57
+ if (__KUDZU_NESTED_LISTS__ && descriptor.ownerField) {
58
+ if (!list.owner) throw new Error("Nested keyed list has no parent row")
59
+ if (ownedLists.has(list.owner)) throw new Error("Keyed list rows support one nested keyed list")
60
+ ownedLists.set(list.owner, list)
61
+ listRegistrations.set(start, { list, owner: list.owner })
62
+ } else {
63
+ register(listTargets, descriptor.state, list)
64
+ listRegistrations.set(start, { state: descriptor.state, list })
54
65
  }
55
- register(listTargets, descriptor.state, list)
56
- listRegistrations.set(start, { state: descriptor.state, list })
57
66
  updateList(list)
58
67
  }
59
68
  }
@@ -65,9 +74,13 @@ function unmountLists(root) {
65
74
  function unregisterList(start) {
66
75
  const registration = listRegistrations.get(start)
67
76
  if (registration) {
68
- const lists = listTargets.get(registration.state)
69
- lists?.delete(registration.list)
70
- if (!lists?.size) listTargets.delete(registration.state)
77
+ if (__KUDZU_NESTED_LISTS__ && registration.owner) {
78
+ if (ownedLists.get(registration.owner) === registration.list) ownedLists.delete(registration.owner)
79
+ } else {
80
+ const lists = listTargets.get(registration.state)
81
+ lists?.delete(registration.list)
82
+ if (!lists?.size) listTargets.delete(registration.state)
83
+ }
71
84
  if (__KUDZU_LIST_ROW_STATES__ && registration.list.descriptor.rowStates) for (const token of registration.list.roots.keys()) deleteRowStates(registration.list.descriptor, token)
72
85
  }
73
86
  listRegistrations.delete(start)
@@ -75,8 +88,12 @@ function unregisterList(start) {
75
88
  }
76
89
 
77
90
  function updateList(list) {
78
- const items = browserState.get(list.descriptor.state)
79
- if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
91
+ const items = __KUDZU_NESTED_LISTS__ && list.descriptor.ownerField
92
+ ? listItems.get(list.owner)?.[list.descriptor.ownerField]
93
+ : browserState.get(list.descriptor.state)
94
+ if (!Array.isArray(items)) throw new Error(list.descriptor.ownerField ? `Nested keyed list property "${list.descriptor.ownerField}" must remain an array` : "Keyed list state must remain an array")
95
+ if (__KUDZU_NESTED_LISTS__ && (list.descriptor.child || list.descriptor.ownerField) && list.items && updateNestedList(list, items)) return
96
+ if (__KUDZU_NESTED_LISTS__ && list.descriptor.child) validateChildLists(items, list.descriptor.child)
80
97
  if (list.descriptor.reducer && list.items && updateReducerList(list, items)) return
81
98
  const entries = []
82
99
  const keys = new Set()
@@ -132,15 +149,14 @@ function updateList(list) {
132
149
  if (!node) throw new Error("Keyed list template has no root element")
133
150
  node.removeAttribute("data-k-list-root")
134
151
  if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) initializeRowStates(list.descriptor, key, node)
135
- mapListItemParts(list.parts, node)
136
- fillListItem(node, item)
152
+ mapListItemParts(list.parts, node, list.descriptor.nested)
153
+ fillListItem(node, item, list.descriptor.nested)
137
154
  additions.append(node)
138
155
  added = true
139
156
  } else if (list.values.get(token) !== value) {
140
- fillListItem(node, item)
157
+ fillListItem(node, item, list.descriptor.nested)
141
158
  if (__KUDZU_LIST_ITEM_HOOKS__) notifyListItem(list.descriptor.state, node)
142
159
  }
143
- listItems.set(node, item)
144
160
  next.push([token, node])
145
161
  values.set(token, value)
146
162
  }
@@ -184,6 +200,75 @@ function updateList(list) {
184
200
  list.items = items
185
201
  }
186
202
 
203
+ function updateNestedList(list, items) {
204
+ const previous = list.items
205
+ if (items === previous) return false
206
+ if (items.length === previous.length && items.every((item, index) => item === previous[index])) {
207
+ list.items = items
208
+ return true
209
+ }
210
+ if (items.length === previous.length && items.every((item, index) => item === previous[previous.length - index - 1])) {
211
+ const tokens = [...list.roots.keys()].reverse()
212
+ const parent = list.container ?? list.start.parentNode
213
+ const reordered = parent.ownerDocument.createDocumentFragment()
214
+ reordered.append(...tokens.map(token => list.roots.get(token)))
215
+ parent.insertBefore(reordered, list.boundary)
216
+ list.roots = new Map(tokens.map(token => [token, list.roots.get(token)]))
217
+ list.items = items
218
+ list.container ??= parent
219
+ return true
220
+ }
221
+ if (items.length === previous.length - 1) {
222
+ let removed = 0
223
+ while (removed < items.length && items[removed] === previous[removed]) removed++
224
+ if (items.every((item, index) => item === previous[index >= removed ? index + 1 : index])) {
225
+ removeListRoot(list, keyToken(previous[removed]?.[list.descriptor.key]))
226
+ list.roots = new Map(items.map(item => {
227
+ const token = keyToken(item[list.descriptor.key])
228
+ return [token, list.roots.get(token)]
229
+ }))
230
+ list.items = items
231
+ return true
232
+ }
233
+ }
234
+ if (items.length === previous.length + 1 && previous.every((item, index) => item === items[index])) {
235
+ const entry = nestedListEntry(list, items.at(-1))
236
+ if (list.roots.has(entry.token)) throw new Error(`Duplicate keyed list key: ${String(entry.key)}`)
237
+ addListRoot(list, entry)
238
+ list.items = items
239
+ return true
240
+ }
241
+ if (items.length !== previous.length) return false
242
+ let changed = -1
243
+ for (let index = 0; index < items.length; index++) {
244
+ if (items[index] === previous[index]) continue
245
+ if (changed !== -1) return false
246
+ changed = index
247
+ }
248
+ if (changed === -1) return false
249
+ const item = items[changed]
250
+ if (item?.[list.descriptor.key] !== previous[changed]?.[list.descriptor.key]) return false
251
+ const entry = nestedListEntry(list, item)
252
+ const node = list.roots.get(entry.token)
253
+ if (!node) return false
254
+ if (list.values.get(entry.token) !== entry.value) {
255
+ fillListItem(node, item, list.descriptor.nested)
256
+ if (__KUDZU_LIST_ITEM_HOOKS__) notifyListItem(list.descriptor.state, node)
257
+ list.values.set(entry.token, entry.value)
258
+ }
259
+ list.items = items
260
+ return true
261
+ }
262
+
263
+ function nestedListEntry(list, item) {
264
+ const key = item?.[list.descriptor.key]
265
+ if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
266
+ assertListItem(item)
267
+ if (list.descriptor.child) validateChildLists([item], list.descriptor.child)
268
+ assertListValue(item, new Set(), true)
269
+ return { item, key, token: keyToken(key), value: JSON.stringify(item) }
270
+ }
271
+
187
272
  function updateReducerList(list, items) {
188
273
  const previous = list.items
189
274
  if (items.length === previous.length && items.every((item, index) => item === previous[index])) {
@@ -237,9 +322,8 @@ function addListRoot(list, { item, key, token, value }) {
237
322
  if (!node) throw new Error("Keyed list template has no root element")
238
323
  node.removeAttribute("data-k-list-root")
239
324
  if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) initializeRowStates(list.descriptor, key, node)
240
- mapListItemParts(list.parts, node)
241
- fillListItem(node, item)
242
- listItems.set(node, item)
325
+ mapListItemParts(list.parts, node, list.descriptor.nested)
326
+ fillListItem(node, item, list.descriptor.nested)
243
327
  const parent = list.container ?? list.start.parentNode
244
328
  parent.insertBefore(node, list.boundary)
245
329
  if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(node)
@@ -257,11 +341,16 @@ function removeListRoot(list, token) {
257
341
  list.values.delete(token)
258
342
  }
259
343
 
260
- function fillListItem(root, item) {
344
+ function fillListItem(root, item, nested = false) {
345
+ listItems.set(root, item)
261
346
  const revision = __KUDZU_LIST_ASYNC_PARTS__ ? (revisions.get(root) ?? 0) + 1 : 0
262
347
  if (__KUDZU_LIST_ASYNC_PARTS__) revisions.set(root, revision)
263
- const parts = listItemParts(root)
348
+ const parts = listItemParts(root, nested)
264
349
  fillListParts(root, parts, item, revision)
350
+ if (__KUDZU_NESTED_LISTS__) {
351
+ const child = ownedLists.get(root)
352
+ if (child) updateList(child)
353
+ }
265
354
  }
266
355
 
267
356
  function fillListParts(root, parts, item, revision) {
@@ -317,11 +406,11 @@ function fillListParts(root, parts, item, revision) {
317
406
  }
318
407
  }
319
408
 
320
- function listItemParts(root) {
409
+ function listItemParts(root, nested = false) {
321
410
  let parts = itemParts.get(root)
322
411
  if (parts) return parts
323
412
  parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [], effects: [] }
324
- for (const node of matching(root, itemPartsSelector)) {
413
+ for (const node of nested ? ownedElements(root).filter(node => node.matches(itemPartsSelector)) : matching(root, itemPartsSelector)) {
325
414
  if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
326
415
  if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
327
416
  if (__KUDZU_LIST_EVENTS__ && node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
@@ -337,8 +426,8 @@ function listItemParts(root) {
337
426
  return parts
338
427
  }
339
428
 
340
- function listItemPartPlan(template) {
341
- const source = [template, ...template.querySelectorAll("*")]
429
+ function listItemPartPlan(template, nested = false) {
430
+ const source = nested ? ownedElements(template) : [template, ...template.querySelectorAll("*")]
342
431
  const indexes = new Map(source.map((node, index) => [node, index]))
343
432
  const parts = listItemParts(template)
344
433
  return {
@@ -353,8 +442,8 @@ function listItemPartPlan(template) {
353
442
  }
354
443
  }
355
444
 
356
- function mapListItemParts(parts, root) {
357
- const target = [root, ...root.querySelectorAll("*")]
445
+ function mapListItemParts(parts, root, nested = false) {
446
+ const target = nested ? ownedElements(root) : [root, ...root.querySelectorAll("*")]
358
447
  itemParts.set(root, {
359
448
  directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
360
449
  texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([index, field]) => [target[index], field]) : [],
@@ -406,6 +495,49 @@ function renderFalsy(value) {
406
495
  return value === false || value == null || value === true ? "" : String(value)
407
496
  }
408
497
 
498
+ function validateChildLists(items, child) {
499
+ for (const item of items) {
500
+ const children = item?.[child.field]
501
+ if (!Array.isArray(children)) throw new Error(`Nested keyed list property "${child.field}" must remain an array`)
502
+ const keys = new Set()
503
+ for (const entry of children) {
504
+ const key = entry?.[child.key]
505
+ if (!validListKey(key)) throw new Error(`Keyed list key "${child.key}" must be a string or finite number`)
506
+ assertListItem(entry)
507
+ assertListValue(entry, new Set(), true)
508
+ const token = keyToken(key)
509
+ if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
510
+ keys.add(token)
511
+ }
512
+ }
513
+ }
514
+
515
+ function listOwner(start) {
516
+ let owner = start.parentElement
517
+ while (owner && !listItems.has(owner)) owner = owner.parentElement
518
+ return owner
519
+ }
520
+
521
+ function ownedElements(root) {
522
+ const elements = []
523
+ const visit = node => {
524
+ elements.push(node)
525
+ for (let child = node.firstElementChild; child;) {
526
+ if (child.matches("template[data-k-list]")) {
527
+ const end = findEnd(child, JSON.parse(child.dataset.kList).id)
528
+ elements.push(child, end)
529
+ child = end.nextElementSibling
530
+ } else {
531
+ const next = child.nextElementSibling
532
+ visit(child)
533
+ child = next
534
+ }
535
+ }
536
+ }
537
+ visit(root)
538
+ return elements
539
+ }
540
+
409
541
  function listRoots(start, end) {
410
542
  const roots = []
411
543
  for (let node = start.nextSibling; node && node !== end; node = node.nextSibling) {
@@ -585,8 +717,10 @@ function assertListValue(value, seen, root = false) {
585
717
  }
586
718
 
587
719
  function findEnd(start, id) {
588
- return [...start.ownerDocument.querySelectorAll("template[data-k-list-end]")]
589
- .find(node => node.dataset.kListEnd === id)
720
+ for (let node = start.nextElementSibling; node; node = node.nextElementSibling) {
721
+ if (node.matches("template[data-k-list-end]") && node.dataset.kListEnd === id) return node
722
+ }
723
+ throw new Error("Keyed list marker has no end")
590
724
  }
591
725
 
592
726
  function register(targets, id, entry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.20",
3
+ "version": "0.6.22",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",