@kudzujs/core 0.5.8 → 0.6.0
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/GOAL_A.md +175 -0
- package/README.md +94 -10
- package/framework/README.md +9 -3
- package/framework/build.mjs +800 -43
- package/framework/core.d.ts +10 -4
- package/framework/core.mjs +117 -32
- package/framework/dependency-runtime.js +36 -0
- package/framework/effect-runtime.js +3 -1
- package/framework/list-runtime.js +13 -6
- package/framework/navigation-runtime.js +220 -0
- package/package.json +2 -1
package/framework/build.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http"
|
|
2
|
-
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
3
3
|
import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
|
|
4
4
|
import { dirname, extname, join, relative, resolve, sep } from "node:path"
|
|
5
5
|
import { pathToFileURL } from "node:url"
|
|
@@ -19,6 +19,15 @@ const devClient = (session, revision, schema) => `<script>(()=>{const show=event
|
|
|
19
19
|
export async function build({ quiet = false, minify = true } = {}) {
|
|
20
20
|
const config = await loadConfig()
|
|
21
21
|
const base = normalizeBase(config.base)
|
|
22
|
+
const configuredStyles = normalizeStyles(config.styles, base)
|
|
23
|
+
const navigationRoutes = normalizeNavigation(config.navigation)
|
|
24
|
+
const navigationSet = new Set(navigationRoutes)
|
|
25
|
+
const browserNavigationRoutes = navigationRoutes.map(route => withBase(base, route))
|
|
26
|
+
const navigationAsset = assetPath(base, "assets/kudzu-navigation.js")
|
|
27
|
+
const navigationId = navigationRoutes.length ? createHash("sha256").update(JSON.stringify([...navigationRoutes].sort())).digest("hex").slice(0, 16) : undefined
|
|
28
|
+
const applicationId = navigationId ? `a-${navigationId}` : undefined
|
|
29
|
+
const layoutId = navigationId ? `l-${navigationId}` : undefined
|
|
30
|
+
let navigationLayout
|
|
22
31
|
await rm(workDirectory, { recursive: true, force: true })
|
|
23
32
|
await rm(outputDirectory, { recursive: true, force: true })
|
|
24
33
|
await mkdir(workDirectory, { recursive: true })
|
|
@@ -41,21 +50,30 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
41
50
|
if (!pageFiles.length) throw new Error("No pages found in src/pages/")
|
|
42
51
|
|
|
43
52
|
let behaviorCount = 0
|
|
53
|
+
let regularBehaviorCount = 0
|
|
44
54
|
let bindingCount = 0
|
|
45
55
|
let listCount = 0
|
|
46
56
|
let listStyleCount = 0
|
|
47
|
-
let
|
|
57
|
+
let regularStateSeedCount = 0
|
|
58
|
+
let dependencyStateSeedCount = 0
|
|
48
59
|
const plans = []
|
|
60
|
+
const pageEntries = []
|
|
49
61
|
const effectEntries = []
|
|
50
62
|
const paramEntries = []
|
|
51
63
|
const rewrites = []
|
|
52
64
|
const emittedRoutes = new Set()
|
|
53
|
-
const
|
|
65
|
+
const emittedApplicationRoutes = new Set()
|
|
66
|
+
const styleUrls = [...new Set([
|
|
67
|
+
...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
|
|
68
|
+
...configuredStyles
|
|
69
|
+
])]
|
|
70
|
+
const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
|
|
54
71
|
|
|
55
72
|
for (const pageFile of pageFiles) {
|
|
56
73
|
const compiledFile = compiledPath(pageFile)
|
|
57
74
|
const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
|
|
58
75
|
if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
|
|
76
|
+
if (Object.hasOwn(module, "layout") && typeof module.layout !== "function") throw layoutExportError(pageFile, sourceIndex.get(pageFile))
|
|
59
77
|
|
|
60
78
|
const runtimeSchema = runtimeRouteSchema(module, pageFile)
|
|
61
79
|
if (runtimeSchema) {
|
|
@@ -72,33 +90,52 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
72
90
|
const entries = runtimeSchema ? [{ params: {}, props: {} }] : await staticPathEntries(module, pageFile)
|
|
73
91
|
for (const { params, props } of entries) {
|
|
74
92
|
const route = runtimeSchema?.route ?? routeFromPage(pageFile, params)
|
|
93
|
+
const applicationRoute = `/${route}`
|
|
75
94
|
const routePath = withBase(base, `/${route}`)
|
|
95
|
+
const navigable = navigationSet.has(applicationRoute)
|
|
76
96
|
const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
|
|
77
97
|
const paramPath = `params/${route}/index.js`
|
|
78
98
|
if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
|
|
79
99
|
emittedRoutes.add(routePath)
|
|
100
|
+
emittedApplicationRoutes.add(applicationRoute)
|
|
101
|
+
if (navigable) {
|
|
102
|
+
if (runtimeSchema) throw new Error(`kudzu.config navigation route ${JSON.stringify(routePath)} must be an exact static route; runtime bracket routes are not supported`)
|
|
103
|
+
if (typeof module.layout !== "function") throw new Error(`kudzu.config navigation route ${JSON.stringify(routePath)} must export a layout function so Kudzu can emit route markers`)
|
|
104
|
+
if (navigationLayout && navigationLayout !== module.layout) throw new Error("kudzu.config navigation routes must export the same layout function identity")
|
|
105
|
+
navigationLayout = module.layout
|
|
106
|
+
}
|
|
80
107
|
const result = await renderPage(module.default, {
|
|
81
108
|
...(module.metadata ?? {}),
|
|
82
109
|
styles: styleUrls.length ? styleUrls : false,
|
|
83
110
|
base,
|
|
111
|
+
runtimeAsset: runtimePlaceholder,
|
|
84
112
|
effectAsset: assetPath(base, `assets/${effectPath}`),
|
|
85
113
|
paramAsset: assetPath(base, `assets/${paramPath}`),
|
|
86
|
-
runtimeParams: runtimeSchema?.params
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
114
|
+
runtimeParams: runtimeSchema?.params,
|
|
115
|
+
...(navigable ? { navigationAsset, applicationId, layoutId } : {})
|
|
116
|
+
}, props, module.layout)
|
|
117
|
+
const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
|
|
118
|
+
const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
|
|
119
|
+
pageEntries.push({ route, html: result.html, usesDependencyRuntime })
|
|
91
120
|
plans.push({ route: routePath, ...result.plan })
|
|
92
|
-
if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params })
|
|
93
|
-
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined })
|
|
94
|
-
if (result.hasBehaviors)
|
|
121
|
+
if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
|
|
122
|
+
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
|
|
123
|
+
if (result.hasBehaviors) {
|
|
124
|
+
behaviorCount++
|
|
125
|
+
if (!usesDependencyRuntime) regularBehaviorCount++
|
|
126
|
+
}
|
|
95
127
|
if (result.hasBindings) bindingCount++
|
|
96
128
|
if (result.hasLists) listCount++
|
|
97
129
|
if (result.hasListStyles) listStyleCount++
|
|
98
|
-
if (result.hasStateSeed)
|
|
130
|
+
if (result.hasStateSeed) {
|
|
131
|
+
if (usesDependencyRuntime) dependencyStateSeedCount++
|
|
132
|
+
else regularStateSeedCount++
|
|
133
|
+
}
|
|
99
134
|
}
|
|
100
135
|
}
|
|
101
136
|
|
|
137
|
+
for (const route of navigationRoutes) if (!emittedApplicationRoutes.has(route)) throw new Error(`kudzu.config navigation route ${JSON.stringify(route)} is not an exact emitted route`)
|
|
138
|
+
|
|
102
139
|
const assetsDirectory = join(outputDirectory, "assets")
|
|
103
140
|
await mkdir(assetsDirectory, { recursive: true })
|
|
104
141
|
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
@@ -111,6 +148,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
111
148
|
const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
|
|
112
149
|
const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
|
|
113
150
|
const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
|
|
151
|
+
const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
|
|
114
152
|
const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
|
|
115
153
|
const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
|
|
116
154
|
const hasNestedStateCaptures = hasNestedCaptureState(plans)
|
|
@@ -119,11 +157,26 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
119
157
|
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
|
|
120
158
|
const hasNativeHandlers = nativeModules.length > 0
|
|
121
159
|
const hasEffects = effectEntries.length > 0
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
160
|
+
const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
|
|
161
|
+
const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers || navigationRoutes.length
|
|
162
|
+
const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
|
|
163
|
+
const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
|
|
164
|
+
for (const entry of pageEntries) {
|
|
165
|
+
const routeDirectory = join(outputDirectory, entry.route)
|
|
166
|
+
await mkdir(routeDirectory, { recursive: true })
|
|
167
|
+
const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
|
|
168
|
+
await writeFile(join(routeDirectory, "index.html"), html)
|
|
169
|
+
}
|
|
170
|
+
if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
|
|
171
|
+
const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
|
|
172
|
+
let runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
|
|
173
|
+
if (hasNavigableEffects) runtime = runtime.replace("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}")
|
|
125
174
|
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
126
175
|
}
|
|
176
|
+
if (hasDependencyRuntime) {
|
|
177
|
+
const runtime = specializeRuntime(await readFile(new URL("./dependency-runtime.js", import.meta.url), "utf8"), commandEvents, dependencyStateSeedCount > 0)
|
|
178
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-deps.js"), runtime, minify)
|
|
179
|
+
}
|
|
127
180
|
if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
|
|
128
181
|
"globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
|
|
129
182
|
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
|
|
@@ -138,10 +191,11 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
138
191
|
}
|
|
139
192
|
if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
|
|
140
193
|
if (bindingCount) {
|
|
141
|
-
|
|
194
|
+
let bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
142
195
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
143
196
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
144
197
|
.replace('"./style.js"', '"./kudzu-style.js"')
|
|
198
|
+
if (navigationRoutes.length) bindingRuntime = specializeNavigationTextDescriptors(bindingRuntime)
|
|
145
199
|
await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
|
|
146
200
|
"globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
|
|
147
201
|
"globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
|
|
@@ -166,6 +220,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
166
220
|
__KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
|
|
167
221
|
__KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
|
|
168
222
|
__KUDZU_LIST_SEEDS__: String(hasListSeeds),
|
|
223
|
+
__KUDZU_LIST_EFFECTS__: String(hasListEffects),
|
|
169
224
|
__KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
|
|
170
225
|
__KUDZU_LIST_MOUNTS__: String(hasListMounts)
|
|
171
226
|
})
|
|
@@ -178,6 +233,14 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
178
233
|
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
|
|
179
234
|
})
|
|
180
235
|
}
|
|
236
|
+
if (navigationRoutes.length) {
|
|
237
|
+
const navigationRuntime = (await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8"))
|
|
238
|
+
.replace("__KUDZU_NAVIGATION_ROUTES__", inlineJson(browserNavigationRoutes))
|
|
239
|
+
.replace("__KUDZU_APPLICATION_ID__", JSON.stringify(applicationId))
|
|
240
|
+
.replace("__KUDZU_LAYOUT_ID__", JSON.stringify(layoutId))
|
|
241
|
+
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
242
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-navigation.js"), specializeNavigationEffects(navigationRuntime, hasNavigableEffects), minify)
|
|
243
|
+
}
|
|
181
244
|
for (const handlerModule of handlerModules) {
|
|
182
245
|
const output = join(assetsDirectory, handlerModule.path)
|
|
183
246
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
@@ -186,12 +249,14 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
186
249
|
for (const entry of paramEntries) {
|
|
187
250
|
const output = join(assetsDirectory, entry.path)
|
|
188
251
|
await mkdir(dirname(output), { recursive: true })
|
|
189
|
-
await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base), minify)
|
|
252
|
+
await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime)), minify)
|
|
190
253
|
}
|
|
191
254
|
for (const entry of effectEntries) {
|
|
192
255
|
const output = join(assetsDirectory, entry.path)
|
|
193
256
|
await mkdir(dirname(output), { recursive: true })
|
|
194
|
-
await writeJavaScript(output,
|
|
257
|
+
await writeJavaScript(output, entry.navigable
|
|
258
|
+
? printNavigableEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base)
|
|
259
|
+
: printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
|
|
195
260
|
}
|
|
196
261
|
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
197
262
|
for (const file of clientModules) {
|
|
@@ -237,14 +302,53 @@ function specializeEvents(source, events) {
|
|
|
237
302
|
return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
|
|
238
303
|
}
|
|
239
304
|
|
|
305
|
+
function specializeNavigationEffects(source, enabled) {
|
|
306
|
+
if (enabled) return source
|
|
307
|
+
return source
|
|
308
|
+
.replace("const noDispose = async () => {}\nlet routeDispose = noDispose\nlet layoutDispose = noDispose\nconst ready = mountInitial()\n", "")
|
|
309
|
+
.replace(`addEventListener("pagehide", event => {
|
|
310
|
+
if (event.persisted) return
|
|
311
|
+
++revision
|
|
312
|
+
request?.abort()
|
|
313
|
+
void (async () => {
|
|
314
|
+
await routeDispose()
|
|
315
|
+
await layoutDispose()
|
|
316
|
+
})()
|
|
317
|
+
})
|
|
318
|
+
`, "")
|
|
319
|
+
.replace(`
|
|
320
|
+
async function mountInitial() {
|
|
321
|
+
try {
|
|
322
|
+
const effects = await loadCapabilities(validate(document))
|
|
323
|
+
layoutDispose = await effects?.mountLayoutEffects?.() ?? noDispose
|
|
324
|
+
routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.error(error)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
`, "")
|
|
330
|
+
.replace(" await ready\n", "")
|
|
331
|
+
.replace(" const effects = await loadCapabilities(parsed)\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n")
|
|
332
|
+
.replace(" await routeDispose()\n if (current !== revision) return\n", "")
|
|
333
|
+
.replace(" routeDispose = await effects?.mountRouteEffects?.() ?? noDispose\n", "")
|
|
334
|
+
.replace(`
|
|
335
|
+
async function loadCapabilities(parsed) {
|
|
336
|
+
const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
|
|
337
|
+
return modules.find(module => typeof module.mountRouteEffects === "function")
|
|
338
|
+
}
|
|
339
|
+
`, "")
|
|
340
|
+
}
|
|
341
|
+
|
|
240
342
|
function specializeNativeRuntime(source, events, modules) {
|
|
241
343
|
const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
|
|
242
344
|
const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
|
|
243
345
|
return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
|
|
244
346
|
}
|
|
245
347
|
|
|
246
|
-
function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
|
|
348
|
+
function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
|
|
247
349
|
const hasCleanup = effects.some(effect => effect.cleanup)
|
|
350
|
+
const hasDependencies = effects.some(effect => effect.dependencies?.length)
|
|
351
|
+
const hasOwners = effects.some(effect => effect.owner)
|
|
248
352
|
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
249
353
|
const modules = moduleUrls.map(url => {
|
|
250
354
|
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
@@ -252,14 +356,119 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
|
|
|
252
356
|
return module
|
|
253
357
|
})
|
|
254
358
|
const imports = [
|
|
255
|
-
hasCleanup
|
|
256
|
-
? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory,
|
|
257
|
-
: `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory,
|
|
359
|
+
hasCleanup || hasDependencies || hasOwners
|
|
360
|
+
? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
|
|
361
|
+
: `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
|
|
258
362
|
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
259
363
|
...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
|
|
260
364
|
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
261
365
|
]
|
|
262
366
|
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
367
|
+
if (hasOwners) return printOwnedEffectEntry(imports, effects, entries)
|
|
368
|
+
if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
|
|
369
|
+
const disposal = hasCleanup ? `
|
|
370
|
+
let disposed = false
|
|
371
|
+
const dispose = root => {
|
|
372
|
+
if (root !== document || disposed) return
|
|
373
|
+
disposed = true
|
|
374
|
+
active = false
|
|
375
|
+
pending.clear()
|
|
376
|
+
for (const record of records) invokeCleanup(record)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
|
|
380
|
+
addEventListener("pagehide", event => {
|
|
381
|
+
if (event.persisted) return
|
|
382
|
+
if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
|
|
383
|
+
else dispose(document)
|
|
384
|
+
})` : ""
|
|
385
|
+
if (hasDependencies) return `${imports.join("\n")}
|
|
386
|
+
const effects = ${inlineJson(effects)}
|
|
387
|
+
const modules = new Map([${entries}])
|
|
388
|
+
const records = effects.map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined }))
|
|
389
|
+
const dependencies = new Map()
|
|
390
|
+
const pending = new Set()
|
|
391
|
+
let scheduled = false
|
|
392
|
+
let flushing = false
|
|
393
|
+
let active = true
|
|
394
|
+
for (const record of records) {
|
|
395
|
+
for (const id of record.effect.dependencies ?? []) {
|
|
396
|
+
const subscribers = dependencies.get(id) ?? new Set()
|
|
397
|
+
subscribers.add(record)
|
|
398
|
+
dependencies.set(id, subscribers)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
__kRuntime.registerCommitter(id => {
|
|
402
|
+
if (!active) return
|
|
403
|
+
for (const record of dependencies.get(id) ?? []) pending.add(record)
|
|
404
|
+
schedule()
|
|
405
|
+
})
|
|
406
|
+
for (const record of records) {
|
|
407
|
+
try {
|
|
408
|
+
record.values = readDependencies(record)
|
|
409
|
+
invoke(record)
|
|
410
|
+
} catch (error) {
|
|
411
|
+
console.error(error)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function schedule() {
|
|
415
|
+
if (!pending.size || scheduled || flushing) return
|
|
416
|
+
scheduled = true
|
|
417
|
+
queueMicrotask(flush)
|
|
418
|
+
}
|
|
419
|
+
async function flush() {
|
|
420
|
+
scheduled = false
|
|
421
|
+
if (!active) return pending.clear()
|
|
422
|
+
flushing = true
|
|
423
|
+
try {
|
|
424
|
+
const selected = [...pending].sort((left, right) => left.index - right.index)
|
|
425
|
+
pending.clear()
|
|
426
|
+
const changed = []
|
|
427
|
+
for (const record of selected) {
|
|
428
|
+
try {
|
|
429
|
+
const values = readDependencies(record)
|
|
430
|
+
if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
|
|
431
|
+
record.values = values
|
|
432
|
+
changed.push(record)
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
console.error(error)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const record of changed) await invokeCleanup(record)
|
|
439
|
+
if (active) for (const record of changed) invoke(record)
|
|
440
|
+
} finally {
|
|
441
|
+
flushing = false
|
|
442
|
+
if (active) schedule()
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function readDependencies(record) {
|
|
446
|
+
return (record.effect.dependencies ?? []).map(id => {
|
|
447
|
+
const value = browserState.get(id)
|
|
448
|
+
if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
|
|
449
|
+
return value
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
function invoke(record) {
|
|
453
|
+
try {
|
|
454
|
+
const effect = record.effect
|
|
455
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
|
|
456
|
+
if (effect.cleanup && typeof result === "function") record.cleanup = result
|
|
457
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
458
|
+
} catch (error) {
|
|
459
|
+
console.error(error)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async function invokeCleanup(record) {
|
|
463
|
+
const cleanup = record.cleanup
|
|
464
|
+
record.cleanup = undefined
|
|
465
|
+
if (!cleanup) return
|
|
466
|
+
try {
|
|
467
|
+
await cleanup()
|
|
468
|
+
} catch (error) {
|
|
469
|
+
console.error(error)
|
|
470
|
+
}
|
|
471
|
+
}${disposal}`
|
|
263
472
|
if (!hasCleanup) return `${imports.join("\n")}
|
|
264
473
|
const effects = ${inlineJson(effects)}
|
|
265
474
|
const modules = new Map([${entries}])
|
|
@@ -306,8 +515,415 @@ addEventListener("pagehide", event => {
|
|
|
306
515
|
})`
|
|
307
516
|
}
|
|
308
517
|
|
|
309
|
-
function
|
|
310
|
-
|
|
518
|
+
function printNavigableEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
|
|
519
|
+
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
520
|
+
const modules = moduleUrls.map(url => {
|
|
521
|
+
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
522
|
+
if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
|
|
523
|
+
return module
|
|
524
|
+
})
|
|
525
|
+
const imports = [
|
|
526
|
+
`import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
|
|
527
|
+
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
528
|
+
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
529
|
+
]
|
|
530
|
+
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
531
|
+
return `${imports.join("\n")}
|
|
532
|
+
const effects = ${inlineJson(effects)}
|
|
533
|
+
const modules = new Map([${entries}])
|
|
534
|
+
export const mountLayoutEffects = () => mount("layout")
|
|
535
|
+
export const mountRouteEffects = () => mount("route")
|
|
536
|
+
function mount(lifetime) {
|
|
537
|
+
let active = true
|
|
538
|
+
let flushing
|
|
539
|
+
const records = effects.filter(effect => effect.lifetime === lifetime).map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined, token: undefined }))
|
|
540
|
+
const dependencies = new Map()
|
|
541
|
+
const pending = new Set()
|
|
542
|
+
let scheduled = false
|
|
543
|
+
for (const record of records) for (const id of record.effect.dependencies ?? []) {
|
|
544
|
+
const subscribers = dependencies.get(id) ?? new Set()
|
|
545
|
+
subscribers.add(record)
|
|
546
|
+
dependencies.set(id, subscribers)
|
|
547
|
+
}
|
|
548
|
+
const unsubscribe = dependencies.size ? __kRuntime.registerCommitter(id => {
|
|
549
|
+
if (!active) return
|
|
550
|
+
for (const record of dependencies.get(id) ?? []) pending.add(record)
|
|
551
|
+
if (pending.size && !scheduled && !flushing) {
|
|
552
|
+
scheduled = true
|
|
553
|
+
queueMicrotask(flush)
|
|
554
|
+
}
|
|
555
|
+
}) : undefined
|
|
556
|
+
for (const record of records) {
|
|
557
|
+
try {
|
|
558
|
+
record.values = readDependencies(record)
|
|
559
|
+
invoke(record)
|
|
560
|
+
} catch (error) {
|
|
561
|
+
console.error(error)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async function flush() {
|
|
565
|
+
scheduled = false
|
|
566
|
+
if (!active) return pending.clear()
|
|
567
|
+
const operation = (async () => {
|
|
568
|
+
const selected = [...pending].sort((left, right) => left.index - right.index)
|
|
569
|
+
pending.clear()
|
|
570
|
+
const changed = []
|
|
571
|
+
for (const record of selected) {
|
|
572
|
+
try {
|
|
573
|
+
const values = readDependencies(record)
|
|
574
|
+
if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
|
|
575
|
+
record.values = values
|
|
576
|
+
changed.push(record)
|
|
577
|
+
}
|
|
578
|
+
} catch (error) {
|
|
579
|
+
console.error(error)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
for (const record of changed) await cleanup(record)
|
|
583
|
+
if (active) for (const record of changed) invoke(record)
|
|
584
|
+
})()
|
|
585
|
+
flushing = operation
|
|
586
|
+
try { await operation } finally {
|
|
587
|
+
if (flushing === operation) flushing = undefined
|
|
588
|
+
if (active && pending.size && !scheduled) {
|
|
589
|
+
scheduled = true
|
|
590
|
+
queueMicrotask(flush)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function readDependencies(record) {
|
|
595
|
+
return (record.effect.dependencies ?? []).map(id => {
|
|
596
|
+
const value = __kRuntime.browserState.get(id)
|
|
597
|
+
if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
|
|
598
|
+
return value
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
function invoke(record) {
|
|
602
|
+
const token = { active: true }
|
|
603
|
+
record.token = token
|
|
604
|
+
try {
|
|
605
|
+
const effect = record.effect
|
|
606
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(__kRuntime.browserState, effect.states, __kRuntime.commitDom, effect.scope, () => active && token.active && record.token === token))
|
|
607
|
+
if (effect.cleanup && typeof result === "function") record.cleanup = result
|
|
608
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
609
|
+
} catch (error) {
|
|
610
|
+
console.error(error)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function cleanup(record) {
|
|
614
|
+
if (record.token) record.token.active = false
|
|
615
|
+
record.token = undefined
|
|
616
|
+
const current = record.cleanup
|
|
617
|
+
record.cleanup = undefined
|
|
618
|
+
if (!current) return
|
|
619
|
+
try { await current() } catch (error) { console.error(error) }
|
|
620
|
+
}
|
|
621
|
+
let disposal
|
|
622
|
+
return async function dispose() {
|
|
623
|
+
if (disposal) return disposal
|
|
624
|
+
disposal = (async () => {
|
|
625
|
+
active = false
|
|
626
|
+
unsubscribe?.()
|
|
627
|
+
pending.clear()
|
|
628
|
+
for (const record of records) if (record.token) record.token.active = false
|
|
629
|
+
if (flushing) await flushing
|
|
630
|
+
for (const record of records) await cleanup(record)
|
|
631
|
+
})()
|
|
632
|
+
return disposal
|
|
633
|
+
}
|
|
634
|
+
}`
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function runtimeEffects(effects, lifetimes = false) {
|
|
638
|
+
return effects.map(effect => ({
|
|
639
|
+
module: effect.module,
|
|
640
|
+
handler: effect.handler,
|
|
641
|
+
...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
|
|
642
|
+
...(effect.cleanup ? { cleanup: true } : {}),
|
|
643
|
+
...(effect.owner ? { owner: effect.owner } : {}),
|
|
644
|
+
...(effect.list ? { list: true } : {}),
|
|
645
|
+
...(lifetimes && effect.lifetime ? { lifetime: effect.lifetime } : {}),
|
|
646
|
+
states: effect.states,
|
|
647
|
+
scope: effect.scope
|
|
648
|
+
}))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function printOwnedEffectEntry(imports, effects, entries) {
|
|
652
|
+
return `${imports.join("\n")}
|
|
653
|
+
const effects = ${inlineJson(effects)}
|
|
654
|
+
const modules = new Map([${entries}])
|
|
655
|
+
const records = effects.map((effect, index) => effect.list ? undefined : createRecord(effect, index)).filter(Boolean)
|
|
656
|
+
const listTemplates = new Map(effects.map((effect, index) => effect.list ? [effect.owner, { effect, index }] : undefined).filter(Boolean))
|
|
657
|
+
const owners = new Map(records.filter(record => record.effect.owner).map(record => [record.effect.owner, record]))
|
|
658
|
+
const listRegistrations = new WeakMap()
|
|
659
|
+
const mountedRecords = new Set(records.filter(record => record.mounted))
|
|
660
|
+
const dependencies = new Map()
|
|
661
|
+
const pending = new Set()
|
|
662
|
+
let scheduled = false
|
|
663
|
+
let flushing = false
|
|
664
|
+
let active = true
|
|
665
|
+
for (const record of records) registerDependencies(record)
|
|
666
|
+
function createRecord(effect, index) {
|
|
667
|
+
return { effect, index, mounted: !effect.owner, marker: undefined, version: 0, values: undefined, cleanup: undefined, disposal: undefined }
|
|
668
|
+
}
|
|
669
|
+
function registerDependencies(record) {
|
|
670
|
+
for (const id of record.effect.dependencies ?? []) {
|
|
671
|
+
const subscribers = dependencies.get(id) ?? new Set()
|
|
672
|
+
subscribers.add(record)
|
|
673
|
+
dependencies.set(id, subscribers)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function unregisterDependencies(record) {
|
|
677
|
+
for (const id of record.effect.dependencies ?? []) {
|
|
678
|
+
const subscribers = dependencies.get(id)
|
|
679
|
+
subscribers?.delete(record)
|
|
680
|
+
if (!subscribers?.size) dependencies.delete(id)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
__kRuntime.registerCommitter(id => {
|
|
684
|
+
if (!active) return
|
|
685
|
+
for (const record of dependencies.get(id) ?? []) if (record.mounted) pending.add(record)
|
|
686
|
+
schedule()
|
|
687
|
+
})
|
|
688
|
+
__kRuntime.registerMountHook(root => {
|
|
689
|
+
if (!active) return
|
|
690
|
+
for (const marker of matching(root)) {
|
|
691
|
+
if (marker.dataset.kEffects) {
|
|
692
|
+
if (listRegistrations.has(marker)) continue
|
|
693
|
+
const rowRecords = JSON.parse(marker.dataset.kEffects).map(owner => {
|
|
694
|
+
const template = listTemplates.get(owner)
|
|
695
|
+
if (!template) throw new Error("Keyed row effect template was not emitted")
|
|
696
|
+
const record = createRecord(template.effect, template.index)
|
|
697
|
+
registerDependencies(record)
|
|
698
|
+
mount(record, marker)
|
|
699
|
+
return record
|
|
700
|
+
})
|
|
701
|
+
listRegistrations.set(marker, rowRecords)
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
const record = owners.get(marker.dataset.kEffect)
|
|
705
|
+
if (!record?.mounted) mount(record, marker)
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
__kRuntime.registerUnmountHook(root => {
|
|
709
|
+
if (root === document) {
|
|
710
|
+
if (!active) return
|
|
711
|
+
active = false
|
|
712
|
+
pending.clear()
|
|
713
|
+
for (const record of [...mountedRecords]) unmount(record, record.effect.list)
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
for (const marker of matching(root)) {
|
|
717
|
+
const rowRecords = listRegistrations.get(marker)
|
|
718
|
+
if (rowRecords) {
|
|
719
|
+
for (const record of rowRecords) unmount(record, true)
|
|
720
|
+
listRegistrations.delete(marker)
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
const record = owners.get(marker.dataset.kEffect)
|
|
724
|
+
if (record?.marker === marker) unmount(record)
|
|
725
|
+
}
|
|
726
|
+
})
|
|
727
|
+
for (const record of records) if (record.mounted) start(record)
|
|
728
|
+
__kRuntime.mountDom(document)
|
|
729
|
+
addEventListener("pagehide", event => {
|
|
730
|
+
if (!event.persisted) __kRuntime.unmountDom(document)
|
|
731
|
+
})
|
|
732
|
+
function matching(root) {
|
|
733
|
+
const selector = "template[data-k-effect],[data-k-effects]"
|
|
734
|
+
return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
|
|
735
|
+
}
|
|
736
|
+
function mount(record, marker) {
|
|
737
|
+
record.mounted = true
|
|
738
|
+
record.marker = marker
|
|
739
|
+
mountedRecords.add(record)
|
|
740
|
+
const version = ++record.version
|
|
741
|
+
const begin = () => {
|
|
742
|
+
if (!active || !record.mounted || record.version !== version || !marker.isConnected) return
|
|
743
|
+
start(record)
|
|
744
|
+
}
|
|
745
|
+
if (record.disposal) record.disposal.then(begin)
|
|
746
|
+
else begin()
|
|
747
|
+
}
|
|
748
|
+
function unmount(record, dynamic = false) {
|
|
749
|
+
if (!record.mounted) return
|
|
750
|
+
record.mounted = false
|
|
751
|
+
record.marker = undefined
|
|
752
|
+
mountedRecords.delete(record)
|
|
753
|
+
if (dynamic) unregisterDependencies(record)
|
|
754
|
+
record.version++
|
|
755
|
+
pending.delete(record)
|
|
756
|
+
invokeCleanup(record)
|
|
757
|
+
}
|
|
758
|
+
function start(record) {
|
|
759
|
+
try {
|
|
760
|
+
record.values = readDependencies(record)
|
|
761
|
+
invoke(record)
|
|
762
|
+
} catch (error) {
|
|
763
|
+
console.error(error)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function schedule() {
|
|
767
|
+
if (!pending.size || scheduled || flushing) return
|
|
768
|
+
scheduled = true
|
|
769
|
+
queueMicrotask(flush)
|
|
770
|
+
}
|
|
771
|
+
async function flush() {
|
|
772
|
+
scheduled = false
|
|
773
|
+
if (!active) return pending.clear()
|
|
774
|
+
flushing = true
|
|
775
|
+
try {
|
|
776
|
+
const selected = [...pending].filter(record => record.mounted).sort((left, right) => left.index - right.index)
|
|
777
|
+
pending.clear()
|
|
778
|
+
const changed = []
|
|
779
|
+
for (const record of selected) {
|
|
780
|
+
try {
|
|
781
|
+
const values = readDependencies(record)
|
|
782
|
+
if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
|
|
783
|
+
record.values = values
|
|
784
|
+
changed.push([record, record.version])
|
|
785
|
+
}
|
|
786
|
+
} catch (error) {
|
|
787
|
+
console.error(error)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
for (const [record] of changed) await invokeCleanup(record)
|
|
791
|
+
if (active) for (const [record, version] of changed) if (record.mounted && record.version === version) invoke(record)
|
|
792
|
+
} finally {
|
|
793
|
+
flushing = false
|
|
794
|
+
if (active) schedule()
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function readDependencies(record) {
|
|
798
|
+
return (record.effect.dependencies ?? []).map(id => {
|
|
799
|
+
const value = browserState.get(id)
|
|
800
|
+
if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
|
|
801
|
+
return value
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
function invoke(record) {
|
|
805
|
+
try {
|
|
806
|
+
const effect = record.effect
|
|
807
|
+
const scope = effect.list
|
|
808
|
+
? Object.fromEntries(Object.entries(effect.scope).map(([name, value]) => [name, value?.type === "list-item" ? JSON.parse(record.marker.dataset.kEffectItem) : value]))
|
|
809
|
+
: effect.scope
|
|
810
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, scope))
|
|
811
|
+
if (effect.cleanup && typeof result === "function") record.cleanup = result
|
|
812
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
813
|
+
} catch (error) {
|
|
814
|
+
console.error(error)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function invokeCleanup(record) {
|
|
818
|
+
if (record.disposal) return record.disposal
|
|
819
|
+
const cleanup = record.cleanup
|
|
820
|
+
record.cleanup = undefined
|
|
821
|
+
if (!cleanup) return Promise.resolve()
|
|
822
|
+
const disposal = (async () => {
|
|
823
|
+
try {
|
|
824
|
+
await cleanup()
|
|
825
|
+
} catch (error) {
|
|
826
|
+
console.error(error)
|
|
827
|
+
}
|
|
828
|
+
})()
|
|
829
|
+
record.disposal = disposal
|
|
830
|
+
disposal.finally(() => {
|
|
831
|
+
if (record.disposal === disposal) record.disposal = undefined
|
|
832
|
+
})
|
|
833
|
+
return disposal
|
|
834
|
+
}`
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function printSingleDependencyEffect(imports, effect, hasCleanup) {
|
|
838
|
+
const disposal = hasCleanup ? `
|
|
839
|
+
const dispose = root => {
|
|
840
|
+
if (root !== document || !active) return
|
|
841
|
+
active = false
|
|
842
|
+
pending = false
|
|
843
|
+
invokeCleanup()
|
|
844
|
+
}
|
|
845
|
+
if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
|
|
846
|
+
addEventListener("pagehide", event => {
|
|
847
|
+
if (event.persisted) return
|
|
848
|
+
if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
|
|
849
|
+
else dispose(document)
|
|
850
|
+
})` : ""
|
|
851
|
+
return `${imports.join("\n")}
|
|
852
|
+
const effect = ${inlineJson(effect)}
|
|
853
|
+
const dependency = effect.dependencies[0]
|
|
854
|
+
let value
|
|
855
|
+
let cleanup
|
|
856
|
+
let active = true
|
|
857
|
+
let pending = false
|
|
858
|
+
let scheduled = false
|
|
859
|
+
let running = false
|
|
860
|
+
__kRuntime.registerCommitter(id => {
|
|
861
|
+
if (active && id === dependency) {
|
|
862
|
+
pending = true
|
|
863
|
+
schedule()
|
|
864
|
+
}
|
|
865
|
+
})
|
|
866
|
+
try {
|
|
867
|
+
value = readDependency()
|
|
868
|
+
invoke()
|
|
869
|
+
} catch (error) {
|
|
870
|
+
console.error(error)
|
|
871
|
+
}
|
|
872
|
+
function schedule() {
|
|
873
|
+
if (!pending || scheduled || running) return
|
|
874
|
+
scheduled = true
|
|
875
|
+
queueMicrotask(flush)
|
|
876
|
+
}
|
|
877
|
+
async function flush() {
|
|
878
|
+
scheduled = false
|
|
879
|
+
if (!active) return
|
|
880
|
+
let next
|
|
881
|
+
try {
|
|
882
|
+
next = readDependency()
|
|
883
|
+
} catch (error) {
|
|
884
|
+
console.error(error)
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
pending = false
|
|
888
|
+
if (Object.is(next, value)) return
|
|
889
|
+
value = next
|
|
890
|
+
running = true
|
|
891
|
+
try {
|
|
892
|
+
await invokeCleanup()
|
|
893
|
+
if (active) invoke()
|
|
894
|
+
} finally {
|
|
895
|
+
running = false
|
|
896
|
+
if (active) schedule()
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function readDependency() {
|
|
900
|
+
const next = browserState.get(dependency)
|
|
901
|
+
if (next !== null && typeof next !== "string" && typeof next !== "boolean" && !(typeof next === "number" && Number.isFinite(next) && !Object.is(next, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
|
|
902
|
+
return next
|
|
903
|
+
}
|
|
904
|
+
function invoke() {
|
|
905
|
+
try {
|
|
906
|
+
const result = __kEffectModule0[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
|
|
907
|
+
if (effect.cleanup && typeof result === "function") cleanup = result
|
|
908
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
909
|
+
} catch (error) {
|
|
910
|
+
console.error(error)
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function invokeCleanup() {
|
|
914
|
+
const current = cleanup
|
|
915
|
+
cleanup = undefined
|
|
916
|
+
if (!current) return
|
|
917
|
+
try {
|
|
918
|
+
await current()
|
|
919
|
+
} catch (error) {
|
|
920
|
+
console.error(error)
|
|
921
|
+
}
|
|
922
|
+
}${disposal}`
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName) {
|
|
926
|
+
return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
|
|
311
927
|
const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
|
|
312
928
|
const schema = ${inlineJson(schema.segments)}
|
|
313
929
|
const params = ${inlineJson(params)}
|
|
@@ -501,7 +1117,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
501
1117
|
}
|
|
502
1118
|
|
|
503
1119
|
function injectDevClient(html, session, revision, schema) {
|
|
504
|
-
return `${html}${devClient(session, revision, schema)}`
|
|
1120
|
+
return `${html}${devClient(session, revision, schema).replace("binding|list|native", "binding|deps|list|native")}`
|
|
505
1121
|
}
|
|
506
1122
|
|
|
507
1123
|
function stripBaseStrict(path, base) {
|
|
@@ -576,6 +1192,10 @@ function escapeHtml(value) {
|
|
|
576
1192
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
577
1193
|
}
|
|
578
1194
|
|
|
1195
|
+
function escapeAttribute(value) {
|
|
1196
|
+
return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'")
|
|
1197
|
+
}
|
|
1198
|
+
|
|
579
1199
|
async function compile(file, sourceFiles, sourceIndex, base) {
|
|
580
1200
|
const source = sourceIndex.get(file)
|
|
581
1201
|
const nativeHandlers = []
|
|
@@ -625,6 +1245,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
|
|
|
625
1245
|
function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
|
|
626
1246
|
return context => sourceFile => {
|
|
627
1247
|
const factory = context.factory
|
|
1248
|
+
const hasLinkElements = /<link/i.test(sourceFile.text)
|
|
628
1249
|
sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
|
|
629
1250
|
ts.setParentRecursive(sourceFile, false)
|
|
630
1251
|
const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
|
|
@@ -650,10 +1271,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
650
1271
|
const listValues = new WeakMap()
|
|
651
1272
|
const listEventItems = new WeakMap()
|
|
652
1273
|
const listConditions = new WeakMap()
|
|
1274
|
+
const listEffectEntries = new WeakMap()
|
|
653
1275
|
let usesBehavior = false
|
|
654
1276
|
let usesBinding = false
|
|
655
1277
|
let usesConditional = false
|
|
656
1278
|
let usesList = false
|
|
1279
|
+
let usesListEffects = false
|
|
657
1280
|
|
|
658
1281
|
const collect = node => {
|
|
659
1282
|
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer)) {
|
|
@@ -758,6 +1381,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
758
1381
|
}))
|
|
759
1382
|
const componentSpecializations = new WeakMap()
|
|
760
1383
|
const specializedDeclarations = new WeakSet()
|
|
1384
|
+
const keyedComponentCalls = new Set(rawRenderedLists.map(({ parts }) => parts.root))
|
|
761
1385
|
for (const name of listComponentNames) {
|
|
762
1386
|
let component = components.get(name)
|
|
763
1387
|
const local = Boolean(component)
|
|
@@ -770,7 +1394,11 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
770
1394
|
if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
|
|
771
1395
|
const calls = jsxTagUses(sourceFile, name)
|
|
772
1396
|
if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
|
|
773
|
-
for (const call of calls)
|
|
1397
|
+
for (const call of calls) {
|
|
1398
|
+
const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail)
|
|
1399
|
+
if (specialization.effects.length && !keyedComponentCalls.has(call)) fail(call, "Effectful keyed row components may only be used directly as keyed map rows")
|
|
1400
|
+
componentSpecializations.set(call, specialization)
|
|
1401
|
+
}
|
|
774
1402
|
if (local) specializedDeclarations.add(component.declaration)
|
|
775
1403
|
}
|
|
776
1404
|
const renderedLists = new WeakMap()
|
|
@@ -778,7 +1406,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
778
1406
|
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
779
1407
|
const specialization = componentSpecializations.get(originalParts.root)
|
|
780
1408
|
const root = specialization?.root ?? originalParts.root
|
|
781
|
-
|
|
1409
|
+
let callback = root === originalParts.root ? originalParts.callback : factory.updateArrowFunction(
|
|
782
1410
|
originalParts.callback,
|
|
783
1411
|
originalParts.callback.modifiers,
|
|
784
1412
|
originalParts.callback.typeParameters,
|
|
@@ -798,6 +1426,20 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
798
1426
|
validateListExpression(calculation, parts.item, originalParts.root, fail)
|
|
799
1427
|
}
|
|
800
1428
|
validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions)
|
|
1429
|
+
if (specialization?.effects.length) {
|
|
1430
|
+
usesListEffects = true
|
|
1431
|
+
const statements = specialization.effects.map(entry => {
|
|
1432
|
+
const call = factory.updateCallExpression(entry.call, factory.createIdentifier("__kListUseEffect"), entry.call.typeArguments, entry.call.arguments)
|
|
1433
|
+
synthesizeTree(call)
|
|
1434
|
+
const effectSource = entry.source.getSourceFile()
|
|
1435
|
+
listEffectEntries.set(call, { item: parts.item, source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
|
|
1436
|
+
return factory.createExpressionStatement(call)
|
|
1437
|
+
})
|
|
1438
|
+
callback = factory.updateArrowFunction(callback, callback.modifiers, callback.typeParameters, callback.parameters, callback.type, callback.equalsGreaterThanToken, factory.createBlock([...statements, factory.createReturnStatement(root)], true))
|
|
1439
|
+
ts.setParentRecursive(callback, false)
|
|
1440
|
+
callback.parent = originalParts.callback.parent
|
|
1441
|
+
parts.callback = callback
|
|
1442
|
+
}
|
|
801
1443
|
renderedLists.set(node, parts)
|
|
802
1444
|
}
|
|
803
1445
|
|
|
@@ -827,6 +1469,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
827
1469
|
if (specializedDeclarations.has(node)) return node
|
|
828
1470
|
if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
|
|
829
1471
|
|
|
1472
|
+
if (hasLinkElements && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) && isStylesheetLink(node)) {
|
|
1473
|
+
fail(node, "Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
|
|
1474
|
+
}
|
|
1475
|
+
|
|
830
1476
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
831
1477
|
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
832
1478
|
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
@@ -837,23 +1483,33 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
837
1483
|
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
838
1484
|
}
|
|
839
1485
|
|
|
840
|
-
|
|
841
|
-
|
|
1486
|
+
const listEffect = ts.isCallExpression(node) ? listEffectEntries.get(node) : undefined
|
|
1487
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && node.expression.text === "useEffect" || listEffect)) {
|
|
1488
|
+
const effectFail = (target, message) => {
|
|
1489
|
+
if (listEffect) throw sourceNodeError(listEffect.source, listEffect.sourceFile, message)
|
|
1490
|
+
fail(target, message)
|
|
1491
|
+
}
|
|
1492
|
+
if (node.arguments.length !== 2) effectFail(node, "useEffect() requires exactly a callback and literal dependency array")
|
|
842
1493
|
const [callback, dependencies] = node.arguments
|
|
843
|
-
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback))
|
|
844
|
-
if (ts.isFunctionExpression(callback) && callback.name)
|
|
845
|
-
if (callback.asteriskToken)
|
|
846
|
-
if (callback.parameters.length)
|
|
847
|
-
if (!ts.isArrayLiteralExpression(dependencies)
|
|
1494
|
+
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) effectFail(callback, "useEffect() callback must be an inline function")
|
|
1495
|
+
if (ts.isFunctionExpression(callback) && callback.name) effectFail(callback, "useEffect() callback function must be anonymous")
|
|
1496
|
+
if (callback.asteriskToken) effectFail(callback, "useEffect() callback cannot be a generator")
|
|
1497
|
+
if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
|
|
1498
|
+
if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
|
|
1499
|
+
if (listEffect && dependencies.elements.some(dependency => referencesIdentifier(dependency, listEffect.item))) {
|
|
1500
|
+
effectFail(dependencies, "useEffect() item-property dependencies are not supported in keyed lists; use [] or primitive Kudzu state identifiers")
|
|
1501
|
+
}
|
|
1502
|
+
const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
|
|
1503
|
+
if (invalidDependency) effectFail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
|
|
848
1504
|
if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
|
|
849
|
-
if (!ts.isBlock(callback.body))
|
|
1505
|
+
if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
|
|
850
1506
|
const returns = effectReturns(callback)
|
|
851
|
-
if (returns.invalid)
|
|
1507
|
+
if (returns.invalid) effectFail(returns.invalid, "useEffect() return values must be inline cleanup functions")
|
|
852
1508
|
const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
|
|
853
|
-
if (invalidCleanup)
|
|
854
|
-
if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword))
|
|
1509
|
+
if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
|
|
1510
|
+
if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
|
|
855
1511
|
const setters = settersForNode(node, settersByFunction)
|
|
856
|
-
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect",
|
|
1512
|
+
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", listEffect?.item, true, returns.cleanup)
|
|
857
1513
|
usesBehavior = true
|
|
858
1514
|
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
|
|
859
1515
|
callback,
|
|
@@ -862,7 +1518,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
862
1518
|
factory.createStringLiteral(descriptor.exportName),
|
|
863
1519
|
descriptor.states,
|
|
864
1520
|
descriptor.scope,
|
|
865
|
-
factory.createStringLiteral(sourceLocation(node, sourceFile)),
|
|
1521
|
+
factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
|
|
866
1522
|
returns.cleanup ? factory.createTrue() : factory.createFalse()
|
|
867
1523
|
])
|
|
868
1524
|
}
|
|
@@ -976,6 +1632,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
976
1632
|
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
|
|
977
1633
|
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
|
|
978
1634
|
}
|
|
1635
|
+
if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
|
|
979
1636
|
if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
|
|
980
1637
|
const behaviorImport = factory.createImportDeclaration(
|
|
981
1638
|
undefined,
|
|
@@ -1198,6 +1855,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
1198
1855
|
|
|
1199
1856
|
let returned
|
|
1200
1857
|
const calculations = []
|
|
1858
|
+
const effectCalls = []
|
|
1201
1859
|
if (!ts.isBlock(component.body)) {
|
|
1202
1860
|
returned = component.body
|
|
1203
1861
|
} else {
|
|
@@ -1205,6 +1863,10 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
1205
1863
|
const last = statements.pop()
|
|
1206
1864
|
if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, "Keyed list component must end with one JSX return")
|
|
1207
1865
|
for (const statement of statements) {
|
|
1866
|
+
if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect") {
|
|
1867
|
+
effectCalls.push(statement.expression)
|
|
1868
|
+
continue
|
|
1869
|
+
}
|
|
1208
1870
|
if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, "Keyed list component locals must be single const declarations")
|
|
1209
1871
|
const declaration = statement.declarationList.declarations[0]
|
|
1210
1872
|
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Keyed list component locals must be initialized identifiers")
|
|
@@ -1223,7 +1885,8 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
1223
1885
|
if (key) root = addJsxAttribute(root, cloneAst(key, factory, context), factory)
|
|
1224
1886
|
ts.setParentRecursive(root, false)
|
|
1225
1887
|
root.parent = call.parent
|
|
1226
|
-
|
|
1888
|
+
const effects = effectCalls.map(source => ({ source, call: substituteClone(source, substitutions, factory, context) }))
|
|
1889
|
+
return { root, calculations, effects }
|
|
1227
1890
|
}
|
|
1228
1891
|
|
|
1229
1892
|
function substituteClone(root, substitutions, factory, context) {
|
|
@@ -1256,6 +1919,16 @@ function cloneAst(root, factory, context) {
|
|
|
1256
1919
|
return visit(root)
|
|
1257
1920
|
}
|
|
1258
1921
|
|
|
1922
|
+
function synthesizeTree(root) {
|
|
1923
|
+
const visit = node => {
|
|
1924
|
+
ts.setTextRange(node, { pos: -1, end: -1 })
|
|
1925
|
+
ts.setOriginalNode(node, undefined)
|
|
1926
|
+
ts.forEachChild(node, visit)
|
|
1927
|
+
}
|
|
1928
|
+
visit(root)
|
|
1929
|
+
return root
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1259
1932
|
function addJsxAttribute(root, attribute, factory) {
|
|
1260
1933
|
if (ts.isJsxSelfClosingElement(root)) {
|
|
1261
1934
|
return factory.updateJsxSelfClosingElement(root, root.tagName, root.typeArguments, factory.updateJsxAttributes(root.attributes, [attribute, ...root.attributes.properties]))
|
|
@@ -1268,6 +1941,19 @@ function jsxTagName(node) {
|
|
|
1268
1941
|
return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
|
|
1269
1942
|
}
|
|
1270
1943
|
|
|
1944
|
+
function isStylesheetLink(node) {
|
|
1945
|
+
const element = ts.isJsxElement(node) ? node.openingElement : node
|
|
1946
|
+
if (!ts.isIdentifier(element.tagName) || element.tagName.text.toLowerCase() !== "link") return false
|
|
1947
|
+
const attribute = element.attributes.properties.find(property => ts.isJsxAttribute(property) && property.name.getText().toLowerCase() === "rel")
|
|
1948
|
+
if (!attribute?.initializer) return false
|
|
1949
|
+
const value = ts.isStringLiteral(attribute.initializer)
|
|
1950
|
+
? attribute.initializer.text
|
|
1951
|
+
: ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression && (ts.isStringLiteral(attribute.initializer.expression) || ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))
|
|
1952
|
+
? attribute.initializer.expression.text
|
|
1953
|
+
: undefined
|
|
1954
|
+
return value?.toLowerCase().split(/\s+/).includes("stylesheet") ?? false
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1271
1957
|
function isContextProviderValue(node, contexts) {
|
|
1272
1958
|
if (node.name.getText() !== "value") return false
|
|
1273
1959
|
const element = node.parent?.parent
|
|
@@ -1898,6 +2584,22 @@ function parseSourceFile(file, source) {
|
|
|
1898
2584
|
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
1899
2585
|
}
|
|
1900
2586
|
|
|
2587
|
+
function layoutExportError(file, source) {
|
|
2588
|
+
const sourceFile = parseSourceFile(file, source)
|
|
2589
|
+
for (const statement of sourceFile.statements) {
|
|
2590
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
2591
|
+
const specifier = statement.exportClause.elements.find(entry => entry.name.text === "layout")
|
|
2592
|
+
if (specifier) return sourceNodeError(specifier, sourceFile, "layout export must be a function")
|
|
2593
|
+
}
|
|
2594
|
+
if (ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
2595
|
+
const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === "layout")
|
|
2596
|
+
if (declaration) return sourceNodeError(declaration, sourceFile, "layout export must be a function")
|
|
2597
|
+
}
|
|
2598
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === "layout") return sourceNodeError(statement, sourceFile, "layout export must be a function")
|
|
2599
|
+
}
|
|
2600
|
+
return new Error(`${relative(root, file)} layout export must be a function`)
|
|
2601
|
+
}
|
|
2602
|
+
|
|
1901
2603
|
function clientModulePath(file) {
|
|
1902
2604
|
return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
1903
2605
|
}
|
|
@@ -1912,6 +2614,8 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1912
2614
|
const stateNames = new Set(setters.values())
|
|
1913
2615
|
const snapshotNames = snapshotNested ? nestedStateNames(expression, setters) : new Set()
|
|
1914
2616
|
const snapshots = new Map([...snapshotNames].map(name => [name, factory.createUniqueName("__kEffectState")]))
|
|
2617
|
+
const captureSnapshotNames = snapshotNested ? nestedCaptureNames(expression, captures) : new Set()
|
|
2618
|
+
const captureSnapshots = new Map([...captureSnapshotNames].map(name => [name, factory.createUniqueName("__kEffectCapture")]))
|
|
1915
2619
|
const transformer = context => root => {
|
|
1916
2620
|
const visitor = node => {
|
|
1917
2621
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
|
|
@@ -1940,9 +2644,11 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1940
2644
|
)
|
|
1941
2645
|
}
|
|
1942
2646
|
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
2647
|
+
if (captureSnapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, captureSnapshots.get(node.name.text))
|
|
1943
2648
|
return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
|
|
1944
2649
|
}
|
|
1945
2650
|
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
2651
|
+
if (captureSnapshots.has(node.text) && insideNestedFunction(node, expression)) return captureSnapshots.get(node.text)
|
|
1946
2652
|
return scopeRead(factory, node.text)
|
|
1947
2653
|
}
|
|
1948
2654
|
return ts.visitEachChild(node, visitor, context)
|
|
@@ -1954,8 +2660,12 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1954
2660
|
let body = ts.isBlock(expression.body)
|
|
1955
2661
|
? transformed.transformed[0]
|
|
1956
2662
|
: factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
|
|
1957
|
-
|
|
1958
|
-
|
|
2663
|
+
const snapshotDeclarations = [
|
|
2664
|
+
...[...snapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(name)]))),
|
|
2665
|
+
...[...captureSnapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"), undefined, [factory.createStringLiteral(name)])))
|
|
2666
|
+
]
|
|
2667
|
+
if (snapshotDeclarations.length) body = factory.updateBlock(body, [
|
|
2668
|
+
factory.createVariableStatement(undefined, factory.createVariableDeclarationList(snapshotDeclarations, ts.NodeFlags.Const)),
|
|
1959
2669
|
...body.statements
|
|
1960
2670
|
])
|
|
1961
2671
|
const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
|
|
@@ -1975,6 +2685,16 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1975
2685
|
}
|
|
1976
2686
|
}
|
|
1977
2687
|
|
|
2688
|
+
function nestedCaptureNames(expression, captures) {
|
|
2689
|
+
const names = new Set()
|
|
2690
|
+
const visit = node => {
|
|
2691
|
+
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
|
|
2692
|
+
ts.forEachChild(node, visit)
|
|
2693
|
+
}
|
|
2694
|
+
visit(expression.body)
|
|
2695
|
+
return names
|
|
2696
|
+
}
|
|
2697
|
+
|
|
1978
2698
|
function nestedStateNames(expression, setters) {
|
|
1979
2699
|
const states = new Set(setters.values())
|
|
1980
2700
|
const names = new Set()
|
|
@@ -2124,6 +2844,43 @@ async function loadConfig() {
|
|
|
2124
2844
|
return {}
|
|
2125
2845
|
}
|
|
2126
2846
|
|
|
2847
|
+
function normalizeStyles(value, base) {
|
|
2848
|
+
if (value === undefined) return []
|
|
2849
|
+
if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array of URLs")
|
|
2850
|
+
return value.map((style, index) => {
|
|
2851
|
+
if (typeof style !== "string" || !style) throw new Error(`kudzu.config styles[${index}] must be a non-empty URL`)
|
|
2852
|
+
if (style.startsWith("//")) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
|
|
2853
|
+
if (style.startsWith("/")) return withBase(base, style)
|
|
2854
|
+
if (!/^https?:\/\//i.test(style)) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
|
|
2855
|
+
try { new URL(style) } catch { throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`) }
|
|
2856
|
+
return style
|
|
2857
|
+
})
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
export function normalizeNavigation(value) {
|
|
2861
|
+
if (value === undefined) return []
|
|
2862
|
+
if (!isPlainRecord(value)) throw new Error("kudzu.config navigation must be a plain object")
|
|
2863
|
+
if (Object.keys(value).some(key => key !== "routes")) throw new Error("kudzu.config navigation only supports routes")
|
|
2864
|
+
if (!Array.isArray(value.routes) || !value.routes.length) throw new Error("kudzu.config navigation.routes must be a nonempty array")
|
|
2865
|
+
const routes = value.routes.map((route, index) => {
|
|
2866
|
+
if (typeof route !== "string" || !route.startsWith("/") || route.startsWith("//") || /[?#\\\0]/.test(route) || /%(?:2f|5c)/i.test(route)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
|
|
2867
|
+
let decoded
|
|
2868
|
+
try { decoded = decodeURIComponent(route) } catch { throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`) }
|
|
2869
|
+
if (decoded.split("/").includes("..") || /[?#\\\0]/.test(decoded)) throw new Error(`kudzu.config navigation.routes[${index}] must be a root-relative path without query, hash, or traversal`)
|
|
2870
|
+
return route
|
|
2871
|
+
})
|
|
2872
|
+
if (new Set(routes).size !== routes.length) throw new Error("kudzu.config navigation.routes must contain unique paths")
|
|
2873
|
+
return routes
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
function specializeNavigationTextDescriptors(source) {
|
|
2877
|
+
const dynamic = source
|
|
2878
|
+
.replace("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 ?? \"[]\") : []")
|
|
2879
|
+
.replace("const descriptor = textDescriptors[Number(node.data.slice(\"k-text:\".length))]", "const descriptor = textDescriptors()[Number(node.data.slice(\"k-text:\".length))]")
|
|
2880
|
+
if (dynamic === source) throw new Error("Navigation text descriptor specialization did not match binding-runtime.js")
|
|
2881
|
+
return dynamic
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2127
2884
|
function normalizeBase(value) {
|
|
2128
2885
|
if (value == null || value === "" || value === "/") return ""
|
|
2129
2886
|
if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || /%(?:2f|5c)/i.test(value)) throw new Error("kudzu.config base must be a root-relative path")
|