@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 +23 -4
- package/framework/build.mjs +110 -22
- package/framework/core.d.ts +37 -29
- package/framework/core.mjs +48 -16
- package/framework/list-runtime.js +161 -27
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/framework/build.mjs
CHANGED
|
@@ -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
|
|
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
|
-
...
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
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) {
|
package/framework/core.d.ts
CHANGED
|
@@ -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<{
|
package/framework/core.mjs
CHANGED
|
@@ -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
|
|
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
|
|
739
|
-
const
|
|
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.
|
|
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.
|
|
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 =
|
|
783
|
-
renderContext.listRowRoot =
|
|
784
|
-
renderContext.listTemplate =
|
|
785
|
-
renderContext.listInitialMarkers =
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
79
|
-
|
|
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
|
-
|
|
589
|
-
.
|
|
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) {
|