@kudzujs/core 0.8.20 → 0.8.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.
@@ -2,7 +2,7 @@ import ts from "typescript"
2
2
  import { createComponentAnalysis } from "./analysis/component-analysis.mjs"
3
3
  import { bindingNames, isFunctionLike, isReferenceIdentifier, isShadowedByParameter, isShadowedIdentifier, unwrapExpression } from "./ast-helpers.mjs"
4
4
  import { generateCommandBehavior } from "./codegen/command-codegen.mjs"
5
- import { createModuleIR, registerBinding, registerCommandHandler, registerDerived, registerKeyedBlock, registerModuleHandler } from "./ir/module-ir.mjs"
5
+ import { createModuleIR, registerBinding, registerCommandHandler, registerDerived, registerEffect, registerKeyedBlock, registerModuleHandler } from "./ir/module-ir.mjs"
6
6
 
7
7
  export function createSemanticArtifact(file) {
8
8
  return { componentAnalysis: createComponentAnalysis(file), moduleIR: createModuleIR(file) }
@@ -160,6 +160,10 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
160
160
  return registerKeyedBlock(moduleIR, descriptor)
161
161
  }
162
162
 
163
+ function registerEffectResult(handler, descriptor) {
164
+ return registerEffect(moduleIR, { ...descriptor, setup: { exportName: handler.exportName } })
165
+ }
166
+
163
167
  function finalize() {
164
168
  const callbacks = [...nativeHandlers, ...effectHandlers]
165
169
  for (const entry of callbacks) {
@@ -210,6 +214,11 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
210
214
  code: handlerLowering.lowerListExpression(entry),
211
215
  ...(source(entry.expression) ? { source: source(entry.expression) } : {})
212
216
  })
217
+ for (const effect of moduleIR.effects) {
218
+ const handler = moduleIR.handlers.find(candidate => candidate.kind === "module-export" && candidate.role === "effect" && candidate.exportName === effect.setup.exportName)
219
+ if (!handler) throw new Error(`Effect handler ${JSON.stringify(effect.setup.exportName)} was not finalized`)
220
+ effect.setup = { handler: handler.slot }
221
+ }
213
222
  const imports = [...callbacks, ...reactiveBindings].flatMap(entry => entry.imports ?? []).map(importRecord)
214
223
  moduleIR.imports = [...new Map(imports.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry])).values()]
215
224
  moduleIR.clientModules = [...clientModules]
@@ -223,7 +232,7 @@ export function createDescriptorSession({ semantic, handlerUrl, factory, context
223
232
 
224
233
  const importRecord = entry => ({ target: entry.target, kind: entry.kind, local: entry.local, ...(entry.imported ? { imported: entry.imported } : {}), package: Boolean(entry.package) })
225
234
 
226
- return { compileConditional, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult, registerKeyedBlock: registerKeyedBlockResult }
235
+ return { compileConditional, compileEffectCallback, compileEvent, compileListConditional, compileListValue, compileReactiveBinding, finalize, registerDerived: registerDerivedResult, registerEffect: registerEffectResult, registerKeyedBlock: registerKeyedBlockResult }
227
236
  }
228
237
 
229
238
  function directStateIdentifier(expression, setters) {
@@ -0,0 +1,89 @@
1
+ import ts from "typescript"
2
+ import { nearestFunction, referencesIdentifier, unwrapExpression } from "./ast-helpers.mjs"
3
+ import { collectionExpression } from "./collection-analysis.mjs"
4
+ import { referencedStateNames } from "./descriptor-session.mjs"
5
+
6
+ export function analyzeEffectDependencies({ dependencies, node, listEffect, keyedItem, setters, localDeclarations, factory, fail }) {
7
+ const itemDependencies = []
8
+ const ordinaryDependencies = []
9
+ let dependencyItem = listEffect ? keyedItem : undefined
10
+ for (const dependency of dependencies.elements) {
11
+ const value = unwrapExpression(dependency)
12
+ if (!dependencyItem && ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && isDestructuredParameter(value.expression, nearestFunction(node))) dependencyItem = value.expression.text
13
+ const field = dependencyItem && directProperty(dependency, dependencyItem)
14
+ if (field) {
15
+ if (["__proto__", "constructor", "prototype"].includes(field)) fail(dependency, `useEffect() keyed item property "${field}" is not supported`)
16
+ itemDependencies.push(field)
17
+ } else if (dependencyItem && referencesIdentifier(dependency, dependencyItem)) {
18
+ fail(dependency, "useEffect() keyed item dependencies must be direct item.<field> properties")
19
+ } else {
20
+ ordinaryDependencies.push(dependency)
21
+ }
22
+ }
23
+ const invalidDependency = ordinaryDependencies.find(dependency => !ts.isIdentifier(dependency))
24
+ if (invalidDependency) fail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
25
+
26
+ const entries = []
27
+ const dependencyStates = new Map()
28
+ const substitutions = new Map()
29
+ const subscriptions = []
30
+ let hasDerived = false
31
+ const stateNames = new Set(setters.values())
32
+ for (const dependency of ordinaryDependencies) {
33
+ const declarations = localDeclarations?.get(dependency.text)
34
+ const initializer = declarations?.length === 1 ? declarations[0].initializer : undefined
35
+ const directAlias = initializer && ts.isIdentifier(unwrapExpression(initializer)) && stateNames.has(unwrapExpression(initializer).text)
36
+ const derivedStates = initializer && !directAlias ? referencedStateNames(initializer, setters) : new Set()
37
+ if (derivedStates.size) {
38
+ const usedStates = new Set()
39
+ const expression = collectionExpression(initializer, { fail, stateNames, selectorStates: usedStates })
40
+ if (!usedStates.size) fail(dependency, `useEffect() derived dependency "${dependency.text}" must read direct primitive state`)
41
+ entries.push({ kind: "derived", name: dependency.text, expression, states: usedStates, source: initializer })
42
+ for (const name of usedStates) {
43
+ subscriptions.push(factory.createIdentifier(name))
44
+ dependencyStates.set(name, factory.createIdentifier(name))
45
+ }
46
+ substitutions.set(dependency.text, initializer)
47
+ hasDerived = true
48
+ } else {
49
+ subscriptions.push(dependency)
50
+ entries.push({ kind: "signal", name: dependency.text })
51
+ dependencyStates.set(dependency.text, dependency)
52
+ }
53
+ }
54
+ if (!hasDerived) dependencyStates.clear()
55
+ return { dependencyItem, itemDependencies, ordinaryDependencies, entries, dependencyStates, substitutions, subscriptions, hasDerived }
56
+ }
57
+
58
+ export function validateEffectOwnedBrowserResources(callback, returns, fail) {
59
+ const observers = []
60
+ const frameAssignments = []
61
+ const cancellations = new Set()
62
+ const disconnected = new Set()
63
+ const insideCleanup = node => returns.cleanups.some(cleanup => {
64
+ for (let current = node; current; current = current.parent) if (current === cleanup) return true
65
+ return false
66
+ })
67
+ const visit = node => {
68
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isNewExpression(unwrapExpression(node.initializer)) && ts.isIdentifier(unwrapExpression(node.initializer).expression) && unwrapExpression(node.initializer).expression.text === "IntersectionObserver") observers.push(node)
69
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(unwrapExpression(node.left)) && ts.isCallExpression(unwrapExpression(node.right)) && ts.isIdentifier(unwrapExpression(node.right).expression) && unwrapExpression(node.right).expression.text === "requestAnimationFrame") frameAssignments.push(node)
70
+ if (insideCleanup(node) && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "cancelAnimationFrame" && node.arguments.length === 1 && ts.isIdentifier(unwrapExpression(node.arguments[0]))) cancellations.add(unwrapExpression(node.arguments[0]).text)
71
+ if (insideCleanup(node) && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.name.text === "disconnect" && node.arguments.length === 0) disconnected.add(node.expression.expression.text)
72
+ ts.forEachChild(node, visit)
73
+ }
74
+ visit(callback.body)
75
+ for (const observer of observers) if (!disconnected.has(observer.name.text)) fail(observer, `IntersectionObserver effects must disconnect ${JSON.stringify(observer.name.text)} in cleanup`)
76
+ for (const assignment of frameAssignments) {
77
+ const name = unwrapExpression(assignment.left).text
78
+ if (!cancellations.has(name)) fail(assignment, `Animation loop effects must cancel ${JSON.stringify(name)} in cleanup`)
79
+ }
80
+ }
81
+
82
+ function isDestructuredParameter(identifier, fn) {
83
+ return fn?.parameters.some(parameter => ts.isObjectBindingPattern(parameter.name) && parameter.name.elements.some(element => ts.isIdentifier(element.name) && element.name.text === identifier.text)) ?? false
84
+ }
85
+
86
+ function directProperty(expression, objectName) {
87
+ const value = unwrapExpression(expression)
88
+ return ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && value.expression.text === objectName ? value.name.text : undefined
89
+ }
@@ -1,5 +1,5 @@
1
1
  export function createModuleIR(file) {
2
- return { version: 1, file, signals: [], handlers: [], bindings: [], derived: [], keyedBlocks: [], imports: [], clientModules: [] }
2
+ return { version: 1, file, signals: [], handlers: [], bindings: [], derived: [], effects: [], keyedBlocks: [], imports: [], clientModules: [] }
3
3
  }
4
4
 
5
5
  export function registerCommandHandler(moduleIR, commands, source, scope = "module") {
@@ -40,6 +40,12 @@ export function registerDerived(moduleIR, descriptor) {
40
40
  return derived
41
41
  }
42
42
 
43
+ export function registerEffect(moduleIR, descriptor) {
44
+ const effect = { slot: moduleIR.effects.length, ...descriptor }
45
+ moduleIR.effects.push(effect)
46
+ return effect
47
+ }
48
+
43
49
  export function registerKeyedBlock(moduleIR, descriptor) {
44
50
  const block = { slot: moduleIR.keyedBlocks.length, ...descriptor }
45
51
  moduleIR.keyedBlocks.push(block)
@@ -0,0 +1,95 @@
1
+ import { assertCapabilityIR } from "./route-capability-planner.mjs"
2
+
3
+ export function generateListRuntime(source, capabilityIR) {
4
+ assertCapabilityIR(capabilityIR)
5
+ const { lists, effects } = capabilityIR
6
+ let runtime = replaceRequired(source, '"./shared-runtime.js"', '"./kudzu.js"', "shared runtime import")
7
+ runtime = lists.calculated
8
+ ? replaceRequired(runtime, '"./binding-runtime.js"', '"./kudzu-binding.js"', "binding runtime import")
9
+ : replaceRequired(runtime, /^const loadListEvaluator[^\n]+\n/m, "", "list evaluator loader")
10
+ runtime = lists.selectors
11
+ ? replaceRequired(runtime, '"./collection-selector.js"', '"./kudzu-collection-selector.js"', "collection selector import")
12
+ : replaceRequired(runtime, /^import \{ selectCollection \}[^\n]+\n/m, "", "collection selector import")
13
+ if (!lists.indexes) {
14
+ runtime = replaceSequenceRequired(runtime, [
15
+ ["for (const [index, item] of items.entries()) {", "for (const item of items) {", "item iteration"],
16
+ ["const key = list.descriptor.key === null ? index : item?.[list.descriptor.key]", "const key = item?.[list.descriptor.key]", "index key"],
17
+ ["entries.push({ item, index, key, token, value:", "entries.push({ item, key, token, value:", "indexed entry"],
18
+ ["for (const { item, index, key, token, value } of entries) {", "for (const { item, key, token, value } of entries) {", "indexed entry loop"],
19
+ ["fillListItem(node, item, list.descriptor.nested, index)", "fillListItem(node, item, list.descriptor.nested)", "indexed item fill", true],
20
+ ["fillListItem(node, item, list.descriptor.nested, index, mapListItemParts", "fillListItem(node, item, list.descriptor.nested, 0, mapListItemParts", "mapped item fill"],
21
+ ["function addListRoot(list, { item, index = list.roots.size, key, token, value })", "function addListRoot(list, { item, key, token, value })", "indexed list root"],
22
+ ["fillListParts(root, listItemParts(root), listItems.get(owner), 0, __KUDZU_LIST_INDEXES__ ? listIndexes.get(owner) ?? 0 : 0)", "fillListParts(root, listItemParts(root), listItems.get(owner), 0)", "indexed root parts"],
23
+ ["fillListParts(root, parts, item, revision, index, previous)", "fillListParts(root, parts, item, revision, previous)", "indexed fill call"],
24
+ ["function fillListParts(root, parts, item, revision, index = 0, previous)", "function fillListParts(root, parts, item, revision, previous)", "indexed parts signature"],
25
+ ["fillListExpressions(root, parts, item, revision, index)", "fillListExpressions(root, parts, item, revision)", "indexed expressions"],
26
+ ['value?.type === "list-item" ? serializeItem(item) : value?.type === "list-index" ? index : value', 'value?.type === "list-item" ? serializeItem(item) : value', "index capture", true],
27
+ ["evaluate(descriptor, item, index)", "evaluate(descriptor, item)", "indexed evaluator", true],
28
+ ["updateListCondition(marker, descriptor.kind, value, item, index)", "updateListCondition(marker, descriptor.kind, value, item)", "indexed condition"],
29
+ ["function updateListCondition(marker, kind, value, item, index)", "function updateListCondition(marker, kind, value, item)", "indexed condition signature"],
30
+ ["fillListParts(marker, listItemParts(fragment), item, revision, index)", "fillListParts(marker, listItemParts(fragment), item, revision)", "indexed condition parts"],
31
+ ["exports[descriptor.handler](item, index, {", "exports[descriptor.handler](item, undefined, {", "indexed state handler call"]
32
+ ])
33
+ }
34
+ if (!lists.selectors) runtime = replaceRequired(runtime, " && !list.descriptor.selector", "", "selector guards", true)
35
+ if (!lists.indexes) runtime = replaceSequenceRequired(runtime, [
36
+ [" && !list.descriptor.indexed", "", "indexed guards", true],
37
+ [" && list.descriptor.key !== null", "", "key guards", true],
38
+ ["(referenceOnly ? listItems.get(node) !== item : list.values.get(token) !== value) || list.descriptor.indexed || list.descriptor.key === null", "referenceOnly ? listItems.get(node) !== item : list.values.get(token) !== value", "indexed value comparison"]
39
+ ])
40
+ if (lists.rowHooks && !lists.generalRowHooks) runtime = replaceSequenceRequired(replaceRequired(runtime, /\/\* general-row-hooks \*\/[\s\S]*?\/\* general-row-hooks-end \*\/\n/, "", "general row hooks"), [
41
+ ["initializeGeneralRowHooks", "initializeRowStates", "general row initializer", true],
42
+ ["if (__KUDZU_LIST_ROW_HOOKS__) for (let index = 0; index < roots.length; index++) initializeRowStates(descriptor, descriptor.keys[index], roots[index], nested?.owner)", "if (__KUDZU_LIST_ROW_HOOKS__ && descriptor.rowStates) for (let index = 0; index < roots.length; index++) initializeRowStates(descriptor, descriptor.keys[index])", "flat row initialization"],
43
+ ["if (__KUDZU_LIST_ROW_HOOKS__) initializeRowStates(list.descriptor, key, node, list.owner)", "if (__KUDZU_LIST_ROW_HOOKS__ && list.descriptor.rowStates) initializeRowStates(list.descriptor, key, node)", "flat row add", true],
44
+ ["for (const node of registration.list.roots.values()) deleteRowStates(registration.list.descriptor, ownershipPaths.get(node))", "for (const token of registration.list.roots.keys()) deleteFlatRowStates(registration.list.descriptor, token)", "flat registration cleanup"],
45
+ ["deleteRowStates(list.descriptor, ownershipPaths.get(node))", "deleteFlatRowStates(list.descriptor, token)", "flat row cleanup", true],
46
+ [" if (__KUDZU_LIST_ROW_HOOKS__) replaceRowIds(root, rowReplacements.get(root))\n", "", "row ID replacement"],
47
+ [" if (!replacements) return\n", "", "row replacement guard"]
48
+ ])
49
+ if (!effects.itemDependencies) runtime = replaceRequired(runtime, ", notifyListItem", "", "item notification import")
50
+ if (!lists.stableFastPaths) runtime = replaceRequired(runtime, /\/\* stable-list-fast-path \*\/[\s\S]*?\/\* stable-list-fast-path-end \*\/\n/, "", "stable list fast path")
51
+ const stylePatch = ` if (target === "style") {
52
+ const style = serializeStyle(value)
53
+ if (style) node.setAttribute("style", style)
54
+ else node.removeAttribute("style")
55
+ return
56
+ }`
57
+ runtime = replaceRequired(runtime, " /* list-style */", lists.styleCount ? stylePatch : "", "list style")
58
+ if (lists.styleCount) runtime = `import { serializeStyle } from "./kudzu-style.js"\n${runtime}`
59
+ return {
60
+ source: runtime,
61
+ define: {
62
+ __KUDZU_LIST_CONDITIONS__: String(lists.conditions),
63
+ __KUDZU_DEEP_LIST_CONDITIONS__: String(lists.deepConditions),
64
+ __KUDZU_LIST_TEXT_RANGES__: String(lists.textRanges),
65
+ __KUDZU_LIST_ATTRIBUTES__: String(lists.attributes),
66
+ __KUDZU_LIST_EVENTS__: String(lists.events),
67
+ __KUDZU_LIST_EXPRESSIONS__: String(lists.expressions),
68
+ __KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(lists.expressionAttributes),
69
+ __KUDZU_LIST_SEEDS__: String(lists.seeds),
70
+ __KUDZU_LIST_EFFECTS__: String(lists.effects),
71
+ __KUDZU_LIST_ASYNC_PARTS__: String(lists.asyncParts),
72
+ __KUDZU_LIST_MOUNTS__: String(lists.mounts),
73
+ __KUDZU_LIST_ITEM_HOOKS__: String(effects.itemDependencies),
74
+ __KUDZU_LIST_ROW_HOOKS__: String(lists.rowHooks),
75
+ __KUDZU_LIST_ROW_REFS__: String(lists.rowRefs),
76
+ __KUDZU_COMPLEX_LIST_ROW_STATE__: String(lists.complexRowState),
77
+ __KUDZU_NESTED_LISTS__: String(lists.nested),
78
+ __KUDZU_COLLECTION_SELECTORS__: String(lists.selectors),
79
+ __KUDZU_STATIC_COLLECTIONS__: String(lists.static),
80
+ __KUDZU_LIST_INDEXES__: String(lists.indexes),
81
+ __KUDZU_LIST_STABLE_FAST_PATHS__: String(lists.stableFastPaths),
82
+ __KUDZU_SVG_LISTS__: String(lists.svg)
83
+ }
84
+ }
85
+ }
86
+
87
+ function replaceRequired(source, search, replacement, label, all = false) {
88
+ const output = all ? source.replaceAll(search, replacement) : source.replace(search, replacement)
89
+ if (output === source) throw new Error(`${label} specialization did not match list-runtime.js`)
90
+ return output
91
+ }
92
+
93
+ function replaceSequenceRequired(source, replacements) {
94
+ return replacements.reduce((output, [search, replacement, label, all]) => replaceRequired(output, search, replacement, label, all), source)
95
+ }
@@ -0,0 +1,72 @@
1
+ import { join } from "node:path"
2
+
3
+ export function createParamCodegen({ browserPath, inlineJson, relativeModulePath }) {
4
+ return function printParamEntry(schema, params, searchParams, searchParamsWritable, output, assetsDirectory, base, runtimeName, navigable) {
5
+ const hasSearch = searchParams.length || searchParamsWritable
6
+ const signature = hasSearch ? "pathname, search" : "pathname"
7
+ const prefix = navigable ? `export function initializeParams(${signature}) {\n${searchParamsWritable ? "globalThis.__kSetSearchParams = setSearchParams\n" : ""}` : `${schema ? "let pathname = location.pathname\n" : ""}${hasSearch ? "let search = location.search\n" : ""}`
8
+ const suffix = navigable ? "\n}" : ""
9
+ const pathname = schema ? `const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
10
+ const schema = ${inlineJson(schema.segments)}
11
+ const params = ${inlineJson(params)}
12
+ let path = pathname
13
+ if (base.length) {
14
+ const pathSegments = path.slice(1).split("/")
15
+ if (pathSegments.length < base.length || base.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) throw new Error("Runtime route is outside the configured base")
16
+ path = "/" + pathSegments.slice(base.length).join("/")
17
+ }
18
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1)
19
+ const segments = path.slice(1).split("/")
20
+ if (segments.length !== schema.length) throw new Error("Runtime route does not match its fallback pattern")
21
+ const values = Object.create(null)
22
+ for (let index = 0; index < schema.length; index++) {
23
+ const segment = schema[index]
24
+ const value = decodeSegment(segments[index], Boolean(segment.param))
25
+ if (segment.literal !== undefined && value !== segment.literal) throw new Error("Runtime route literal does not match")
26
+ if (segment.param) values[segment.param] = value
27
+ }
28
+ for (const param of params) {
29
+ const value = values[param.name]
30
+ browserState.set(param.id, value)
31
+ commitDom(param.id, value)
32
+ }
33
+ function decodeSegment(raw, param) {
34
+ if (param && /%(?:2f|5c)/i.test(raw)) throw new Error("Runtime route parameter contains an encoded separator")
35
+ let value
36
+ try { value = decodeURIComponent(raw) } catch { throw new Error("Runtime route parameter has malformed encoding") }
37
+ const decodedDots = value.replace(/%2e/gi, ".")
38
+ if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Runtime route parameter is invalid")
39
+ return value
40
+ }
41
+ ` : ""
42
+ const searchInitializer = searchParamsWritable && searchParams.length ? `function initializeSearch(search) {
43
+ const query = new URLSearchParams(search)
44
+ for (const param of ${inlineJson(searchParams)}) {
45
+ const value = query.get(param.name)
46
+ browserState.set(param.id, value)
47
+ commitDom(param.id, value)
48
+ }
49
+ }
50
+ ` : ""
51
+ const query = searchParams.length ? searchParamsWritable ? "initializeSearch(search)\n" : `const query = new URLSearchParams(search)
52
+ for (const param of ${inlineJson(searchParams)}) {
53
+ const value = query.get(param.name)
54
+ browserState.set(param.id, value)
55
+ commitDom(param.id, value)
56
+ }
57
+ ` : ""
58
+ const writer = searchParamsWritable ? `
59
+ function setSearchParams(update, replace) {
60
+ const next = update(new URLSearchParams(location.search))
61
+ if (!(next instanceof URLSearchParams)) throw new Error("React Router search parameter updater must return URLSearchParams")
62
+ const url = new URL(location.href)
63
+ url.search = next.toString()
64
+ history[replace ? "replaceState" : "pushState"](null, "", url)
65
+ ${searchParams.length ? "initializeSearch(location.search)" : ""}
66
+ }
67
+ ${navigable ? "" : `globalThis.__kSetSearchParams = setSearchParams
68
+ addEventListener("popstate", () => ${searchParams.length ? "initializeSearch(location.search)" : "undefined"})`}` : ""
69
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
70
+ ${searchInitializer}${prefix}${pathname}${query}${suffix}${writer}`
71
+ }
72
+ }
@@ -1,9 +1,11 @@
1
1
  export function usesRouteDependencyRuntime({ plan, navigable, hasBindings, hasLists }) {
2
+ assertRouteIR(plan)
2
3
  const hasDependencies = plan.effects.some(effect => effect.dependencies?.length)
3
4
  return !navigable && hasDependencies && !plan.effects.some(effect => effect.owner) && !hasBindings && !hasLists && !plan.events.some(event => event.native)
4
5
  }
5
6
 
6
7
  export function planRouteCapabilities(plans, { routes = new Map(), navigationRouteCount = 0 } = {}) {
8
+ for (const plan of plans) assertRouteIR(plan)
7
9
  const commandEvents = new Set()
8
10
  const nativeEvents = new Set()
9
11
  const bindings = { count: 0, text: false, svgConditions: false }
@@ -90,6 +92,7 @@ export function planRouteCapabilities(plans, { routes = new Map(), navigationRou
90
92
  lists.mounts ||= lists.conditions || lists.nested
91
93
 
92
94
  return {
95
+ version: 1,
93
96
  routes: routeCounts,
94
97
  events: { command: [...commandEvents].sort(), native: [...nativeEvents].sort(), hasNativeHandlers: nativeEvents.size > 0 },
95
98
  bindings,
@@ -103,6 +106,38 @@ export function planRouteCapabilities(plans, { routes = new Map(), navigationRou
103
106
  }
104
107
  }
105
108
 
109
+ function assertRouteIR(plan) {
110
+ if (plan?.version !== 1) throw new Error(`Unsupported RouteIR version: ${JSON.stringify(plan?.version)}`)
111
+ if (!["states", "params", "searchParams", "events", "effects", "bindings", "conditions", "lists"].every(name => Array.isArray(plan[name])) || typeof plan.searchParamsWritable !== "boolean") throw new Error("Invalid RouteIR v1 structure")
112
+ if (plan.states.some((state, slot) => state?.slot !== slot || typeof state.id !== "string" || typeof state.name !== "string" || !Object.hasOwn(state, "initialValue") || state.lifetime !== undefined && !["layout", "route"].includes(state.lifetime) || state.internal !== undefined && state.internal !== true)) throw new Error("Invalid RouteIR v1 state")
113
+ if ([...plan.params, ...plan.searchParams].some(param => !isRecord(param) || typeof param.name !== "string" || typeof param.id !== "string")) throw new Error("Invalid RouteIR v1 parameter")
114
+ if (plan.events.some(event => !isRecord(event) || typeof event.event !== "string" || event.commands !== undefined && (!Array.isArray(event.commands) || event.commands.some(command => !Array.isArray(command))) || event.native !== undefined && (!isRecord(event.native) || typeof event.native.module !== "string" || typeof event.native.handler !== "string" || !isRecord(event.native.states) || !isRecord(event.native.scope)))) throw new Error("Invalid RouteIR v1 event")
115
+ if (plan.effects.some(effect => !isRecord(effect) || typeof effect.module !== "string" || typeof effect.handler !== "string" || !isRecord(effect.states) || !isRecord(effect.scope))) throw new Error("Invalid RouteIR v1 effect")
116
+ if (plan.bindings.some(binding => !isRecord(binding) || typeof binding.target !== "string") || plan.conditions.some(condition => !isRecord(condition) || condition.svg !== undefined && typeof condition.svg !== "boolean") || plan.lists.some(list => !isRouteList(list))) throw new Error("Invalid RouteIR v1 binding, condition, or list")
117
+ }
118
+
119
+ export function assertCapabilityIR(capabilityIR) {
120
+ if (capabilityIR?.version !== 1) throw new Error(`Unsupported CapabilityIR version: ${JSON.stringify(capabilityIR?.version)}`)
121
+ const sections = ["routes", "events", "bindings", "lists", "effects", "captures", "runtime"]
122
+ if (!sections.every(name => isRecord(capabilityIR[name]))) throw new Error("Invalid CapabilityIR v1 structure")
123
+ if (!["behaviors", "regularBehaviors", "regularStateSeeds", "dependencyStateSeeds"].every(name => isCount(capabilityIR.routes[name]))) throw new Error("Invalid CapabilityIR v1 route counts")
124
+ if (!["command", "native"].every(name => Array.isArray(capabilityIR.events[name]) && capabilityIR.events[name].every(event => typeof event === "string")) || typeof capabilityIR.events.hasNativeHandlers !== "boolean") throw new Error("Invalid CapabilityIR v1 events")
125
+ if (!isCount(capabilityIR.bindings.count) || !["text", "svgConditions"].every(name => typeof capabilityIR.bindings[name] === "boolean")) throw new Error("Invalid CapabilityIR v1 bindings")
126
+ const listFlags = ["conditions", "svg", "deepConditions", "textRanges", "attributes", "events", "expressions", "expressionAttributes", "seeds", "effects", "rowHooks", "rowRefs", "complexRowState", "nested", "selectors", "calculated", "static", "indexes", "stableFastPaths", "generalRowHooks", "asyncParts", "mounts"]
127
+ if (!isCount(capabilityIR.lists.count) || !isCount(capabilityIR.lists.styleCount) || !listFlags.every(name => typeof capabilityIR.lists[name] === "boolean")) throw new Error("Invalid CapabilityIR v1 lists")
128
+ if (!["any", "derivedDependencies", "itemDependencies", "captures", "navigable", "navigableOwners"].every(name => typeof capabilityIR.effects[name] === "boolean") || !["nestedState", "setter"].every(name => typeof capabilityIR.captures[name] === "boolean") || !["shared", "dependency"].every(name => typeof capabilityIR.runtime[name] === "boolean")) throw new Error("Invalid CapabilityIR v1 effect, capture, or runtime flags")
129
+ }
130
+
131
+ const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value)
132
+ const isCount = value => Number.isSafeInteger(value) && value >= 0
133
+ const isRouteList = list => isRecord(list)
134
+ && typeof list.id === "string"
135
+ && typeof list.state === "string"
136
+ && (typeof list.key === "string" || list.key === null)
137
+ && Array.isArray(list.keys)
138
+ && ["svg", "static", "indexed", "reducer", "mount", "nested", "effects", "conditions", "conditionHandlers", "textRanges", "attributes", "events", "expressions", "expressionAttributes", "fastRelease"].every(name => list[name] === undefined || list[name] === true)
139
+ && ["selector", "children", "expressionStates", "rowStates", "rowConditions", "rowRefs"].every(name => list[name] === undefined || Array.isArray(list[name]))
140
+
106
141
  function hasCaptureType(value, type) {
107
142
  if (!value || typeof value !== "object") return false
108
143
  if (value.type === type) return true
@@ -0,0 +1,146 @@
1
+ import { assertCapabilityIR } from "./route-capability-planner.mjs"
2
+
3
+ export function generateCoreRuntime(source, capabilityIR) {
4
+ assertCapabilityIR(capabilityIR)
5
+ const { effects, events, routes } = capabilityIR
6
+ let runtime = specializeRuntime(source, events.command, routes.regularStateSeeds > 0)
7
+ if (!effects.itemDependencies && capabilityIR.runtime.shared) runtime = replaceRequired(runtime, /\/\* list-item-hooks \*\/[\s\S]*?\/\* list-item-hooks-end \*\/\n/, "", "list item hooks", "shared-runtime.js")
8
+ if (effects.navigable) runtime = replaceRequired(runtime, "export function registerCommitter(commit) {\n committers.push(commit)\n}", "export function registerCommitter(commit) {\n committers.push(commit)\n return () => {\n const index = committers.indexOf(commit)\n if (index !== -1) committers.splice(index, 1)\n }\n}", "navigable committer", "shared-runtime.js")
9
+ if (effects.navigableOwners) runtime = replaceSequenceRequired(runtime, [
10
+ ["export function registerMountHook(mount) {\n mountHooks.push(mount)\n}", "export function registerMountHook(mount) {\n mountHooks.push(mount)\n return () => {\n const index = mountHooks.indexOf(mount)\n if (index !== -1) mountHooks.splice(index, 1)\n }\n}", "navigable mount hook"],
11
+ ["export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n}", "export function registerUnmountHook(unmount) {\n unmountHooks.push(unmount)\n return () => {\n const index = unmountHooks.indexOf(unmount)\n if (index !== -1) unmountHooks.splice(index, 1)\n }\n}", "navigable unmount hook"]
12
+ ], "shared-runtime.js")
13
+ return runtime
14
+ }
15
+
16
+ export function generateEffectRuntime(source, capabilityIR) {
17
+ assertCapabilityIR(capabilityIR)
18
+ const { captures, effects } = capabilityIR
19
+ return {
20
+ source: effects.captures ? replaceRequired(source, '"./serialization.js"', '"./kudzu-serialization.js"', "serialization import", "effect-runtime.js") : replaceRequired(source, /^import[^\n]+\n/, "", "serialization import", "effect-runtime.js"),
21
+ define: {
22
+ "globalThis.__KUDZU_CAPTURE_SETTER__": String(captures.setter),
23
+ "globalThis.__KUDZU_EFFECT_CAPTURES__": String(effects.captures)
24
+ }
25
+ }
26
+ }
27
+
28
+ export function generateBindingRuntime(source, capabilityIR, navigable) {
29
+ assertCapabilityIR(capabilityIR)
30
+ let runtime = replaceRequired(source, '"./shared-runtime.js"', '"./kudzu.js"', "shared runtime import", "binding-runtime.js")
31
+ runtime = replaceRequired(runtime, '"./serialization.js"', '"./kudzu-serialization.js"', "serialization import", "binding-runtime.js")
32
+ runtime = replaceRequired(runtime, '"./style.js"', '"./kudzu-style.js"', "style import", "binding-runtime.js")
33
+ if (navigable) runtime = specializeNavigationTextDescriptors(runtime)
34
+ return {
35
+ source: runtime,
36
+ define: {
37
+ "globalThis.__KUDZU_TEXT_BINDINGS__": String(capabilityIR.bindings.text),
38
+ "globalThis.__KUDZU_SVG_CONDITIONS__": String(capabilityIR.bindings.svgConditions),
39
+ "globalThis.__KUDZU_CAPTURE_STATE__": String(capabilityIR.captures.nestedState)
40
+ }
41
+ }
42
+ }
43
+
44
+ export function generateNativeRuntime(source, capabilityIR) {
45
+ assertCapabilityIR(capabilityIR)
46
+ return {
47
+ source: specializeEvents(replaceRequired(replaceRequired(source, '"./shared-runtime.js"', '"./kudzu.js"', "shared runtime import", "native-runtime.js"), '"./serialization.js"', '"./kudzu-serialization.js"', "serialization import", "native-runtime.js"), capabilityIR.events.native),
48
+ define: { "globalThis.__KUDZU_CAPTURE_SETTER__": String(capabilityIR.captures.setter) }
49
+ }
50
+ }
51
+
52
+ export function generateNavigationRuntime(source, group) {
53
+ let runtime = replaceSequenceRequired(source, [
54
+ ["__KUDZU_NAVIGATION_ROUTES__", JSON.stringify(group.records).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029"), "route records"],
55
+ ["__KUDZU_APPLICATION_ID__", JSON.stringify(group.applicationId), "application ID"],
56
+ ["__KUDZU_LAYOUT_ID__", JSON.stringify(group.layoutId), "layout ID"],
57
+ ['"./shared-runtime.js"', '"./kudzu.js"', "shared runtime import"]
58
+ ], "navigation-runtime.js")
59
+ runtime = specializeNavigationPatterns(runtime, group.records.some(record => record.segments))
60
+ return specializeNavigationEffects(runtime, group.hasEffects || group.hasParams)
61
+ }
62
+
63
+ export function specializeRuntime(source, events, hasStateSeed) {
64
+ const specialized = specializeEvents(source, events)
65
+ if (hasStateSeed) return specialized
66
+ return replaceSequenceRequired(specialized, [
67
+ [" const initialState = document.body.dataset.kState\n", "", "initial state read"],
68
+ [/^ if \(initialState\).*\n/m, "", "initial state parse"]
69
+ ], "runtime source")
70
+ }
71
+
72
+ function specializeEvents(source, events) {
73
+ return replaceRequired(source, /const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`, "event names", "runtime source")
74
+ }
75
+
76
+ function replaceRequired(source, search, replacement, label, file) {
77
+ const output = source.replace(search, replacement)
78
+ if (output === source) throw new Error(`${label} specialization did not match ${file}`)
79
+ return output
80
+ }
81
+
82
+ function replaceSequenceRequired(source, replacements, file) {
83
+ return replacements.reduce((output, [search, replacement, label]) => replaceRequired(output, search, replacement, label, file), source)
84
+ }
85
+
86
+ function specializeNavigationEffects(source, enabled) {
87
+ if (enabled) return source
88
+ return replaceSequenceRequired(source, [
89
+ ["const noDispose = async () => {}\nlet routeDispose = noDispose\nlet layoutDispose = noDispose\nconst ready = mountInitial()\n", "", "initial effect lifecycle"],
90
+ [`addEventListener("pagehide", event => {
91
+ if (event.persisted) return
92
+ ++revision
93
+ request?.abort()
94
+ void (async () => {
95
+ await routeDispose()
96
+ await layoutDispose()
97
+ })()
98
+ })
99
+ `, "", "pagehide effect lifecycle"],
100
+ [`
101
+ async function mountInitial() {
102
+ try {
103
+ const record = matchRoute(location.pathname)
104
+ if (!record) throw new Error("Initial navigation route does not match")
105
+ const capabilities = await loadCapabilities(validate(document, record))
106
+ capabilities.params?.(location.pathname, location.search)
107
+ layoutDispose = await capabilities.effects?.mountLayoutEffects?.() ?? noDispose
108
+ routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
109
+ } catch (error) {
110
+ console.error(error)
111
+ }
112
+ }
113
+ `, "", "initial effect mount"],
114
+ [" await ready\n", "", "initial effect readiness"],
115
+ [" const { incoming, parsed, capabilities } = documentResult\n", " const { incoming, parsed } = documentResult\n", "navigation capability result"],
116
+ [" await routeDispose()\n if (current !== revision) return\n", "", "route effect disposal"],
117
+ [" commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)\n", " commit(incoming, parsed.nodes)\n", "navigation capability commit"],
118
+ [" routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose\n", "", "route effect mount"],
119
+ [" return { incoming, parsed, capabilities: await loadCapabilities(parsed), record }\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n return { incoming, parsed, record }\n", "navigation capability load"],
120
+ [`
121
+ async function loadCapabilities(parsed) {
122
+ const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
123
+ const params = modules.filter(module => typeof module.initializeParams === "function")
124
+ const effects = modules.filter(module => typeof module.mountRouteEffects === "function")
125
+ if (params.length > 1 || effects.length > 1) throw new Error("Navigation document has duplicate route capabilities")
126
+ return { params: params[0]?.initializeParams, effects: effects[0] }
127
+ }
128
+ `, "", "capability loader"]
129
+ ], "navigation-runtime.js")
130
+ }
131
+
132
+ function specializeNavigationPatterns(source, enabled) {
133
+ if (enabled) return source
134
+ return replaceRequired(source, /function matchRoute\(pathname\) \{[\s\S]+?\n\}\n\nfunction fallback/, `function matchRoute(pathname) {
135
+ return routes.find(record => record.path === pathname)
136
+ }
137
+
138
+ function fallback`, "exact route matcher", "navigation-runtime.js")
139
+ }
140
+
141
+ function specializeNavigationTextDescriptors(source) {
142
+ return replaceSequenceRequired(source, [
143
+ ["const textDescriptors = globalThis.__KUDZU_TEXT_BINDINGS__ && typeof document !== \"undefined\" ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []", "const textDescriptors = () => globalThis.__KUDZU_TEXT_BINDINGS__ ? JSON.parse(document.body.dataset.kTextBindings ?? \"[]\") : []", "text descriptor reader"],
144
+ ["const descriptor = textDescriptors[Number(node.data.slice(\"k-text:\".length))]", "const descriptor = textDescriptors()[Number(node.data.slice(\"k-text:\".length))]", "text descriptor lookup"]
145
+ ], "binding-runtime.js")
146
+ }
@@ -44,7 +44,8 @@ export function createWorkerCompiler({
44
44
  return { worker, url, specifier: specifierNode.text, options }
45
45
  }
46
46
 
47
- const rewriteEffect = (callback, file, sourceFile, sourceFiles, workerReferences, factory, context) => {
47
+ const rewriteEffect = (callback, file, sourceFile, sourceFiles, factory, context) => {
48
+ const workers = []
48
49
  const visit = node => {
49
50
  const value = candidate(node, sourceFile)
50
51
  if (value) {
@@ -56,12 +57,17 @@ export function createWorkerCompiler({
56
57
  if (!sourceFiles.has(target)) throw sourceNodeError(url.arguments[0], sourceFile, `Relative TypeScript Worker ${JSON.stringify(specifier)} must resolve to an existing .worker.ts file under src/`)
57
58
  const identity = `${sourceRelative.replaceAll(sep, "/")}:${ts.getOriginalNode(node).getStart(sourceFile)}`
58
59
  const placeholder = `/__kudzu_worker_${createHash("sha256").update(identity).digest("hex").slice(0, 16)}__.js`
59
- workerReferences.push({ root: target, placeholder })
60
+ const original = ts.getOriginalNode(node)
61
+ workers.push({
62
+ root: sourceRelative.replaceAll(sep, "/"),
63
+ placeholder,
64
+ source: { file: relative(root, sourceFile.fileName).replaceAll(sep, "/"), start: original.getStart(sourceFile), end: original.end }
65
+ })
60
66
  return factory.updateNewExpression(worker, worker.expression, worker.typeArguments, [factory.createStringLiteral(placeholder), options])
61
67
  }
62
68
  return ts.visitEachChild(node, visit, context)
63
69
  }
64
- return ts.visitEachChild(callback, visit, context)
70
+ return { callback: ts.visitEachChild(callback, visit, context), workers }
65
71
  }
66
72
 
67
73
  const rejectConstructions = (expression, sourceFile, message) => {
@@ -124,7 +130,7 @@ export function createWorkerCompiler({
124
130
  }
125
131
 
126
132
  const emit = async (references, sourceFiles, assetsDirectory, base, minify) => {
127
- const roots = [...new Set(references.map(reference => reference.root))].sort()
133
+ const roots = [...new Set(references.map(reference => resolve(sourceDirectory, reference.root)))].sort()
128
134
  if (!roots.length) return new Map()
129
135
  await validateGraphs(roots, sourceFiles)
130
136
  const workerDirectory = resolve(assetsDirectory, "workers")
@@ -150,12 +156,12 @@ export function createWorkerCompiler({
150
156
  for (const [output, metadata] of Object.entries(result.metafile.outputs)) {
151
157
  if (!metadata.entryPoint) continue
152
158
  const entry = resolve(root, metadata.entryPoint)
153
- const rootReferences = references.filter(reference => reference.root === entry)
159
+ const rootReferences = references.filter(reference => resolve(sourceDirectory, reference.root) === entry)
154
160
  const outputFile = resolve(root, output)
155
161
  const url = assetPath(base, relative(outputDirectory, outputFile).replaceAll(sep, "/"))
156
162
  for (const reference of rootReferences) emitted.set(reference.placeholder, url)
157
163
  }
158
- for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${relative(root, reference.root)}`)
164
+ for (const reference of references) if (!emitted.has(reference.placeholder)) throw new Error(`Worker entry was not emitted: ${reference.root}`)
159
165
  return emitted
160
166
  }
161
167