@kudzujs/core 0.5.4 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -3
- package/framework/README.md +2 -1
- package/framework/build.mjs +335 -64
- package/framework/core.d.ts +4 -0
- package/framework/core.mjs +40 -3
- package/framework/effect-runtime.js +39 -0
- package/framework/list-runtime.js +50 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
HTML-first TSX framework with synchronous state semantics and no virtual DOM.
|
|
8
8
|
|
|
9
|
-
Kudzu keeps the familiar function-component, props, children, event-handler,
|
|
9
|
+
Kudzu keeps the familiar function-component, props, children, event-handler, `useState`, and mount-effect shape. Static components compile to HTML. Simple interactions compile to small behavior commands, while normal sync or async JavaScript handlers and mount effects compile to external ESM.
|
|
10
10
|
|
|
11
11
|
> Experimental `0.4.x`: the compiler API and supported TSX surface may change.
|
|
12
12
|
|
|
@@ -281,7 +281,7 @@ const rows = items.map(item =>
|
|
|
281
281
|
return <ul>{rows}</ul>
|
|
282
282
|
```
|
|
283
283
|
|
|
284
|
-
The root may also be a top-level same
|
|
284
|
+
The root may also be a top-level row component declared in the same file or imported from a relative TypeScript module. Default, named, aliased, and named re-export imports are resolved at build time. Kudzu specializes each call, so projected props, callback props, and simple local calculations compile to the same intrinsic list template:
|
|
285
285
|
|
|
286
286
|
```tsx
|
|
287
287
|
function ItemRow({ name, done, onRemove }: {
|
|
@@ -306,7 +306,28 @@ const rows = items.map(item => <ItemRow
|
|
|
306
306
|
|
|
307
307
|
The original component remains reusable across multiple lists and ordinary JSX. No component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
|
|
308
308
|
|
|
309
|
-
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level
|
|
309
|
+
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props and top-level single-`const` calculations before one intrinsic return. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
310
|
+
|
|
311
|
+
## Mount Effects
|
|
312
|
+
|
|
313
|
+
Browser-only initial work uses the familiar empty-dependency effect shape:
|
|
314
|
+
|
|
315
|
+
```tsx
|
|
316
|
+
import { useEffect, useState } from "@kudzujs/core"
|
|
317
|
+
|
|
318
|
+
const [items, setItems] = useState([])
|
|
319
|
+
|
|
320
|
+
useEffect(async () => {
|
|
321
|
+
const response = await fetch("/api/items")
|
|
322
|
+
setItems(await response.json())
|
|
323
|
+
}, [])
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Kudzu does not execute the effect during static rendering and does not ship the component. It emits one route-specific effect entry that invokes the compiled callback against existing logical state and direct DOM commit capabilities. Effects may update reactive text, attributes, conditions, and keyed lists. Multiple effects start independently in source order, and one synchronous or asynchronous failure is reported without suppressing later effects.
|
|
327
|
+
|
|
328
|
+
Only inline block-bodied callbacks with a literal empty dependency array are supported. Dependencies, cleanup or other return values, callback parameters, and non-serializable captures are rejected at build time. Pages without effects receive no effect entry and retain their existing output.
|
|
329
|
+
|
|
330
|
+
A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
|
|
310
331
|
|
|
311
332
|
## Normal JavaScript
|
|
312
333
|
|
|
@@ -377,6 +398,7 @@ Supported:
|
|
|
377
398
|
- Static trusted `dangerouslySetInnerHTML`
|
|
378
399
|
- Base-path deployments, multiple CSS files, and `afterBuild`
|
|
379
400
|
- Primitive `useState` bindings
|
|
401
|
+
- Mount-only `useEffect(fn, [])` compiled to route-specific ESM
|
|
380
402
|
- Synchronous and async event handlers
|
|
381
403
|
- Relative imported helpers in native handlers
|
|
382
404
|
- Serializable component-local captures
|
package/framework/README.md
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
- `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
|
|
9
9
|
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
|
|
10
10
|
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
11
|
+
- `effect-runtime.js`: optional state and capture context for route-specific mount-effect entries.
|
|
11
12
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
12
13
|
- `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
|
|
13
14
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
14
15
|
|
|
15
|
-
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js
|
|
16
|
+
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; mount effects add `effect-runtime.js` and one route-specific entry. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
|
|
16
17
|
|
|
17
18
|
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
package/framework/build.mjs
CHANGED
|
@@ -29,10 +29,11 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
29
29
|
const cssFiles = projectFiles.filter(file => file.endsWith(".css")).sort()
|
|
30
30
|
if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
|
|
31
31
|
const sourceFileSet = new Set(sourceFiles)
|
|
32
|
+
const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
|
|
32
33
|
|
|
33
34
|
const handlerModules = []
|
|
34
35
|
for (const file of sourceFiles) {
|
|
35
|
-
const handlerModule = await compile(file, sourceFileSet, base)
|
|
36
|
+
const handlerModule = await compile(file, sourceFileSet, sourceIndex, base)
|
|
36
37
|
if (handlerModule) handlerModules.push(handlerModule)
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -45,6 +46,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
45
46
|
let listStyleCount = 0
|
|
46
47
|
let stateSeedCount = 0
|
|
47
48
|
const plans = []
|
|
49
|
+
const effectEntries = []
|
|
48
50
|
const emittedRoutes = new Set()
|
|
49
51
|
const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
|
|
50
52
|
|
|
@@ -57,17 +59,20 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
57
59
|
for (const { params, props } of entries) {
|
|
58
60
|
const route = routeFromPage(pageFile, params)
|
|
59
61
|
const routePath = withBase(base, `/${route}`)
|
|
62
|
+
const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
|
|
60
63
|
if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
|
|
61
64
|
emittedRoutes.add(routePath)
|
|
62
65
|
const result = await renderPage(module.default, {
|
|
63
66
|
...(module.metadata ?? {}),
|
|
64
67
|
styles: styleUrls.length ? styleUrls : false,
|
|
65
|
-
base
|
|
68
|
+
base,
|
|
69
|
+
effectAsset: assetPath(base, `assets/${effectPath}`)
|
|
66
70
|
}, props)
|
|
67
71
|
const routeDirectory = join(outputDirectory, route)
|
|
68
72
|
await mkdir(routeDirectory, { recursive: true })
|
|
69
73
|
await writeFile(join(routeDirectory, "index.html"), result.html)
|
|
70
74
|
plans.push({ route: routePath, ...result.plan })
|
|
75
|
+
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects })
|
|
71
76
|
if (result.hasBehaviors) behaviorCount++
|
|
72
77
|
if (result.hasBindings) bindingCount++
|
|
73
78
|
if (result.hasLists) listCount++
|
|
@@ -82,19 +87,37 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
82
87
|
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
83
88
|
const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
|
|
84
89
|
const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
|
|
90
|
+
const hasListTextRanges = plans.some(plan => plan.lists.some(list => list.textRanges))
|
|
91
|
+
const hasListAttributes = plans.some(plan => plan.lists.some(list => list.attributes))
|
|
92
|
+
const hasListEvents = plans.some(plan => plan.lists.some(list => list.events))
|
|
93
|
+
const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
|
|
94
|
+
const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
|
|
95
|
+
const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
|
|
96
|
+
const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
|
|
97
|
+
const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
|
|
85
98
|
const hasNestedStateCaptures = hasNestedCaptureState(plans)
|
|
86
99
|
const hasSetterCaptures = hasCaptureType(plans, "setter")
|
|
100
|
+
const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
|
|
87
101
|
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
|
|
88
102
|
const hasNativeHandlers = nativeModules.length > 0
|
|
103
|
+
const hasEffects = effectEntries.length > 0
|
|
89
104
|
if (behaviorCount) {
|
|
90
105
|
const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
|
|
91
106
|
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
|
|
92
107
|
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
93
108
|
}
|
|
94
|
-
if (bindingCount || hasNativeHandlers) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
|
|
109
|
+
if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
|
|
95
110
|
"globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
|
|
96
111
|
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
|
|
97
112
|
})
|
|
113
|
+
if (hasEffects) {
|
|
114
|
+
let effectRuntime = await readFile(new URL("./effect-runtime.js", import.meta.url), "utf8")
|
|
115
|
+
effectRuntime = hasEffectCaptures ? effectRuntime.replace('"./serialization.js"', '"./kudzu-serialization.js"') : effectRuntime.replace(/^import[^\n]+\n/, "")
|
|
116
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-effect.js"), effectRuntime, minify, {
|
|
117
|
+
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures),
|
|
118
|
+
"globalThis.__KUDZU_EFFECT_CAPTURES__": String(hasEffectCaptures)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
98
121
|
if (bindingCount || listStyleCount) await writeJavaScript(join(assetsDirectory, "kudzu-style.js"), await readFile(new URL("./style.js", import.meta.url), "utf8"), minify)
|
|
99
122
|
if (bindingCount) {
|
|
100
123
|
const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
@@ -117,7 +140,17 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
117
140
|
}`
|
|
118
141
|
listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
|
|
119
142
|
if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
|
|
120
|
-
await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, {
|
|
143
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, {
|
|
144
|
+
__KUDZU_LIST_CONDITIONS__: String(hasListConditions),
|
|
145
|
+
__KUDZU_LIST_TEXT_RANGES__: String(hasListTextRanges),
|
|
146
|
+
__KUDZU_LIST_ATTRIBUTES__: String(hasListAttributes),
|
|
147
|
+
__KUDZU_LIST_EVENTS__: String(hasListEvents),
|
|
148
|
+
__KUDZU_LIST_EXPRESSIONS__: String(hasListExpressions),
|
|
149
|
+
__KUDZU_LIST_EXPRESSION_ATTRIBUTES__: String(hasListExpressionAttributes),
|
|
150
|
+
__KUDZU_LIST_SEEDS__: String(hasListSeeds),
|
|
151
|
+
__KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
|
|
152
|
+
__KUDZU_LIST_MOUNTS__: String(hasListMounts)
|
|
153
|
+
})
|
|
121
154
|
}
|
|
122
155
|
if (hasNativeHandlers) {
|
|
123
156
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
@@ -132,6 +165,11 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
132
165
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
133
166
|
await writeJavaScript(output, handlerModule.code, minify)
|
|
134
167
|
}
|
|
168
|
+
for (const entry of effectEntries) {
|
|
169
|
+
const output = join(assetsDirectory, entry.path)
|
|
170
|
+
await mkdir(dirname(output), { recursive: true })
|
|
171
|
+
await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base), minify)
|
|
172
|
+
}
|
|
135
173
|
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
136
174
|
for (const file of clientModules) {
|
|
137
175
|
const output = join(assetsDirectory, clientModulePath(file))
|
|
@@ -181,6 +219,32 @@ function specializeNativeRuntime(source, events, modules) {
|
|
|
181
219
|
return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
|
|
182
220
|
}
|
|
183
221
|
|
|
222
|
+
function printEffectEntry(effects, output, handlerModules, assetsDirectory, base) {
|
|
223
|
+
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
224
|
+
const modules = moduleUrls.map(url => {
|
|
225
|
+
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
226
|
+
if (!module) throw new Error(`Effect handler module was not emitted: ${url}`)
|
|
227
|
+
return module
|
|
228
|
+
})
|
|
229
|
+
const imports = [
|
|
230
|
+
`import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu.js")))}`,
|
|
231
|
+
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
232
|
+
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
233
|
+
]
|
|
234
|
+
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
235
|
+
return `${imports.join("\n")}
|
|
236
|
+
const effects = ${inlineJson(effects)}
|
|
237
|
+
const modules = new Map([${entries}])
|
|
238
|
+
for (const effect of effects) {
|
|
239
|
+
try {
|
|
240
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
|
|
241
|
+
if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error(error)
|
|
244
|
+
}
|
|
245
|
+
}`
|
|
246
|
+
}
|
|
247
|
+
|
|
184
248
|
function hasCaptureType(value, type) {
|
|
185
249
|
if (!value || typeof value !== "object") return false
|
|
186
250
|
if (value.type === type) return true
|
|
@@ -369,9 +433,10 @@ function escapeHtml(value) {
|
|
|
369
433
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
370
434
|
}
|
|
371
435
|
|
|
372
|
-
async function compile(file, sourceFiles, base) {
|
|
373
|
-
const source =
|
|
436
|
+
async function compile(file, sourceFiles, sourceIndex, base) {
|
|
437
|
+
const source = sourceIndex.get(file)
|
|
374
438
|
const nativeHandlers = []
|
|
439
|
+
const effectHandlers = []
|
|
375
440
|
const reactiveBindings = []
|
|
376
441
|
const listExpressions = []
|
|
377
442
|
const clientImports = new Set()
|
|
@@ -384,7 +449,7 @@ async function compile(file, sourceFiles, base) {
|
|
|
384
449
|
jsx: ts.JsxEmit.ReactJSX,
|
|
385
450
|
jsxImportSource: "@kudzujs/core"
|
|
386
451
|
},
|
|
387
|
-
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, clientImports)] },
|
|
452
|
+
transformers: { before: [createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, sourceIndex, clientImports)] },
|
|
388
453
|
reportDiagnostics: true
|
|
389
454
|
})
|
|
390
455
|
|
|
@@ -397,10 +462,11 @@ async function compile(file, sourceFiles, base) {
|
|
|
397
462
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
398
463
|
await writeFile(output, result.outputText)
|
|
399
464
|
|
|
400
|
-
if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
465
|
+
if (!nativeHandlers.length && !effectHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
466
|
+
const callbacks = [...nativeHandlers, ...effectHandlers]
|
|
401
467
|
const moduleSource = [
|
|
402
|
-
printClientImports(
|
|
403
|
-
...
|
|
468
|
+
printClientImports(callbacks.flatMap(handler => handler.imports), handlerPath),
|
|
469
|
+
...callbacks.map(handler => printNativeHandler(handler)),
|
|
404
470
|
...reactiveBindings.map(entry => printReactiveBinding(entry)),
|
|
405
471
|
...listExpressions.map(entry => printListExpression(entry))
|
|
406
472
|
].join("\n")
|
|
@@ -410,15 +476,26 @@ async function compile(file, sourceFiles, base) {
|
|
|
410
476
|
})
|
|
411
477
|
const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
412
478
|
if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
413
|
-
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
|
|
479
|
+
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
|
|
414
480
|
}
|
|
415
481
|
|
|
416
|
-
function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, clientImports) {
|
|
482
|
+
function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
|
|
417
483
|
return context => sourceFile => {
|
|
418
484
|
const factory = context.factory
|
|
419
485
|
sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
|
|
420
486
|
ts.setParentRecursive(sourceFile, false)
|
|
421
487
|
const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
|
|
488
|
+
const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === "@kudzujs/core" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
|
|
489
|
+
const importedSources = new Map()
|
|
490
|
+
const importedSource = target => {
|
|
491
|
+
let imported = importedSources.get(target)
|
|
492
|
+
if (!imported) {
|
|
493
|
+
imported = normalizeRenderControlFlow(parseSourceFile(target, sourceIndex.get(target)), factory, context)
|
|
494
|
+
ts.setParentRecursive(imported, false)
|
|
495
|
+
importedSources.set(target, imported)
|
|
496
|
+
}
|
|
497
|
+
return imported
|
|
498
|
+
}
|
|
422
499
|
const settersByFunction = new Map()
|
|
423
500
|
const functions = new Map()
|
|
424
501
|
const components = new Map()
|
|
@@ -520,8 +597,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
520
597
|
}
|
|
521
598
|
collectRenderedLists(sourceFile)
|
|
522
599
|
const fail = (node, message) => {
|
|
523
|
-
|
|
524
|
-
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
600
|
+
throw sourceNodeError(node, sourceFile, message)
|
|
525
601
|
}
|
|
526
602
|
const rejectUnsupportedRenderControl = node => {
|
|
527
603
|
if (ts.isIfStatement(node) && containsRenderControl(node, jsxLocalsByFunction.get(nearestFunction(node)) ?? new Set())) {
|
|
@@ -540,13 +616,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
540
616
|
const componentSpecializations = new WeakMap()
|
|
541
617
|
const specializedDeclarations = new WeakSet()
|
|
542
618
|
for (const name of listComponentNames) {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if (
|
|
619
|
+
let component = components.get(name)
|
|
620
|
+
const local = Boolean(component)
|
|
621
|
+
if (!component) {
|
|
622
|
+
const binding = importBindings.get(name)
|
|
623
|
+
if (!binding || binding.kind === "namespace") fail(sourceFile, `Keyed list component ${name} must be declared locally or imported from a relative TypeScript module`)
|
|
624
|
+
const imported = binding.kind === "default" ? "default" : binding.imported
|
|
625
|
+
component = { function: resolveComponentExport(binding.target, imported, importedSource, sourceFiles), declaration: undefined }
|
|
626
|
+
}
|
|
627
|
+
if (local && isExportedDeclaration(component.declaration)) fail(component.declaration, `Keyed list component ${name} cannot be exported`)
|
|
546
628
|
const calls = jsxTagUses(sourceFile, name)
|
|
547
|
-
if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
|
|
629
|
+
if (local && identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Keyed list component ${name} may only be referenced as JSX`)
|
|
548
630
|
for (const call of calls) componentSpecializations.set(call, specializeComponentCall(call, component.function, sourceFile, factory, context, fail))
|
|
549
|
-
specializedDeclarations.add(component.declaration)
|
|
631
|
+
if (local) specializedDeclarations.add(component.declaration)
|
|
550
632
|
}
|
|
551
633
|
const renderedLists = new WeakMap()
|
|
552
634
|
for (const { node, parts: originalParts } of rawRenderedLists) {
|
|
@@ -603,11 +685,38 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
603
685
|
if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
|
|
604
686
|
|
|
605
687
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
606
|
-
|
|
688
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
689
|
+
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
607
690
|
}
|
|
608
691
|
|
|
609
692
|
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
610
|
-
|
|
693
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
694
|
+
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
|
|
698
|
+
if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal empty dependency array")
|
|
699
|
+
const [callback, dependencies] = node.arguments
|
|
700
|
+
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
|
|
701
|
+
if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
|
|
702
|
+
if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
|
|
703
|
+
if (!ts.isArrayLiteralExpression(dependencies) || dependencies.elements.length) fail(dependencies, "useEffect() dependencies must be a literal empty array")
|
|
704
|
+
if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
|
|
705
|
+
if (returnsCleanup(callback)) fail(callback, "useEffect() cleanup functions are not supported")
|
|
706
|
+
if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
|
|
707
|
+
if (returnsEffectValue(callback)) fail(callback, "useEffect() return values are not supported")
|
|
708
|
+
const setters = settersForNode(node, settersByFunction)
|
|
709
|
+
const descriptor = compileNativeCallback(callback, setters, factory, effectHandlers, importBindings, clientImports, "effect", undefined, true)
|
|
710
|
+
usesBehavior = true
|
|
711
|
+
return factory.updateCallExpression(node, node.expression, node.typeArguments, [
|
|
712
|
+
callback,
|
|
713
|
+
dependencies,
|
|
714
|
+
factory.createStringLiteral(handlerUrl),
|
|
715
|
+
factory.createStringLiteral(descriptor.exportName),
|
|
716
|
+
descriptor.states,
|
|
717
|
+
descriptor.scope,
|
|
718
|
+
factory.createStringLiteral(sourceLocation(node, sourceFile))
|
|
719
|
+
])
|
|
611
720
|
}
|
|
612
721
|
|
|
613
722
|
if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "useState" && node.initializer.arguments.length === 1) {
|
|
@@ -677,7 +786,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
677
786
|
}
|
|
678
787
|
}
|
|
679
788
|
|
|
680
|
-
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !isContextProviderValue(node, contexts) && !/^on/i.test(node.name.
|
|
789
|
+
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !isContextProviderValue(node, contexts) && !/^on/i.test(node.name.text) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.text.toLowerCase())) {
|
|
681
790
|
const expression = node.initializer.expression
|
|
682
791
|
const setters = settersForNode(node, settersByFunction)
|
|
683
792
|
const usedStates = referencedStateNames(expression, setters)
|
|
@@ -690,15 +799,16 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
690
799
|
}
|
|
691
800
|
}
|
|
692
801
|
|
|
693
|
-
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.
|
|
802
|
+
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.text)) {
|
|
694
803
|
const setters = settersForNode(node, settersByFunction)
|
|
695
804
|
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node), importBindings, clientImports)
|
|
696
805
|
if (event) {
|
|
697
806
|
usesBehavior = true
|
|
698
807
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
|
|
699
808
|
}
|
|
809
|
+
if (ts.isIdentifier(node.initializer.expression) && isDestructuredParameter(node.initializer.expression, nearestFunction(node))) return node
|
|
700
810
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
701
|
-
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${node.name.
|
|
811
|
+
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${node.name.text} must reference a function`)
|
|
702
812
|
}
|
|
703
813
|
|
|
704
814
|
return ts.visitEachChild(node, visitor, context)
|
|
@@ -854,8 +964,7 @@ function keyedListParts(expression, setters) {
|
|
|
854
964
|
|
|
855
965
|
function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
|
|
856
966
|
const fail = (node, message) => {
|
|
857
|
-
|
|
858
|
-
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
967
|
+
throw sourceNodeError(node, sourceFile, message)
|
|
859
968
|
}
|
|
860
969
|
const root = parts.root
|
|
861
970
|
const item = parts.item
|
|
@@ -869,7 +978,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
|
|
|
869
978
|
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
|
|
870
979
|
if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
|
|
871
980
|
if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
|
|
872
|
-
if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.
|
|
981
|
+
if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.text)) {
|
|
873
982
|
listEventItems.set(node, item)
|
|
874
983
|
return
|
|
875
984
|
}
|
|
@@ -888,9 +997,9 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
|
|
|
888
997
|
return
|
|
889
998
|
}
|
|
890
999
|
const field = directProperty(expression, item)
|
|
891
|
-
const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.
|
|
1000
|
+
const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.text === "key"
|
|
892
1001
|
if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
|
|
893
|
-
if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.
|
|
1002
|
+
if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
|
|
894
1003
|
if (isRootKey) return
|
|
895
1004
|
if (field) {
|
|
896
1005
|
listValues.set(node.expression, { field })
|
|
@@ -898,7 +1007,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
|
|
|
898
1007
|
}
|
|
899
1008
|
if (referencesIdentifier(expression, item)) {
|
|
900
1009
|
validateListExpression(expression, item, node, fail)
|
|
901
|
-
if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.
|
|
1010
|
+
if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.text.toLowerCase())) fail(node, `Keyed list item ${node.parent.name.text} is not supported`)
|
|
902
1011
|
listValues.set(node.expression, { item })
|
|
903
1012
|
return
|
|
904
1013
|
}
|
|
@@ -971,6 +1080,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
971
1080
|
|
|
972
1081
|
function substituteClone(root, substitutions, factory, context) {
|
|
973
1082
|
const visit = (node, shadowed = new Set()) => {
|
|
1083
|
+
if (ts.isTypeNode(node)) return cloneAst(node, factory, context)
|
|
974
1084
|
if (ts.isShorthandPropertyAssignment(node) && substitutions.has(node.name.text) && !shadowed.has(node.name.text)) {
|
|
975
1085
|
return factory.createPropertyAssignment(cloneAst(node.name, factory, context), cloneAst(substitutions.get(node.name.text), factory, context))
|
|
976
1086
|
}
|
|
@@ -1026,6 +1136,10 @@ function isFunctionLike(node) {
|
|
|
1026
1136
|
return ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
|
|
1027
1137
|
}
|
|
1028
1138
|
|
|
1139
|
+
function isDestructuredParameter(identifier, fn) {
|
|
1140
|
+
return fn?.parameters.some(parameter => ts.isObjectBindingPattern(parameter.name) && parameter.name.elements.some(element => ts.isIdentifier(element.name) && element.name.text === identifier.text)) ?? false
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1029
1143
|
function isExportedDeclaration(node) {
|
|
1030
1144
|
const statement = ts.isVariableDeclaration(node) ? node.parent?.parent : node
|
|
1031
1145
|
return statement?.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false
|
|
@@ -1057,6 +1171,7 @@ const assignmentOperators = new Set([
|
|
|
1057
1171
|
|
|
1058
1172
|
function validateListExpression(expression, item, source, fail) {
|
|
1059
1173
|
const visit = node => {
|
|
1174
|
+
if (ts.isTypeNode(node)) return
|
|
1060
1175
|
if (ts.isElementAccessExpression(node) && referencesIdentifier(node.expression, item)) {
|
|
1061
1176
|
const key = node.argumentExpression
|
|
1062
1177
|
if (!ts.isStringLiteral(key) && !ts.isNumericLiteral(key)) fail(source, "Derived keyed list item computed properties require a direct string or numeric literal key")
|
|
@@ -1249,26 +1364,37 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
|
|
|
1249
1364
|
const optimized = compileOptimizedEvent(expression, setters, factory)
|
|
1250
1365
|
if (optimized) return optimized
|
|
1251
1366
|
|
|
1367
|
+
const descriptor = compileNativeCallback(expression, setters, factory, nativeHandlers, importBindings, clientImports, "handler", listItem)
|
|
1368
|
+
return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
|
|
1369
|
+
factory.createStringLiteral(handlerUrl),
|
|
1370
|
+
factory.createStringLiteral(descriptor.exportName),
|
|
1371
|
+
descriptor.states,
|
|
1372
|
+
descriptor.scope
|
|
1373
|
+
])
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function compileNativeCallback(expression, setters, factory, entries, importBindings, clientImports, prefix, listItem, deferValues = false) {
|
|
1252
1377
|
const allCaptures = nativeCaptureNames(expression, setters)
|
|
1253
1378
|
const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
|
|
1254
1379
|
const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
|
|
1255
1380
|
for (const entry of imports) clientImports.add(entry.target)
|
|
1256
1381
|
const usedStates = nativeStateNames(expression, setters)
|
|
1257
|
-
const exportName =
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
factory.
|
|
1261
|
-
factory.createIdentifier(name)
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
factory.
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1382
|
+
const exportName = `${prefix}${entries.length}`
|
|
1383
|
+
entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
|
|
1384
|
+
const value = name => deferValues
|
|
1385
|
+
? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createIdentifier(name))
|
|
1386
|
+
: factory.createIdentifier(name)
|
|
1387
|
+
return {
|
|
1388
|
+
exportName,
|
|
1389
|
+
states: factory.createArrayLiteralExpression([...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
1390
|
+
factory.createStringLiteral(name),
|
|
1391
|
+
value(name)
|
|
1392
|
+
]))),
|
|
1393
|
+
scope: factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
|
|
1268
1394
|
factory.createStringLiteral(name),
|
|
1269
|
-
name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) :
|
|
1395
|
+
name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : value(name)
|
|
1270
1396
|
])))
|
|
1271
|
-
|
|
1397
|
+
}
|
|
1272
1398
|
}
|
|
1273
1399
|
|
|
1274
1400
|
function nativeStateNames(expression, setters) {
|
|
@@ -1279,7 +1405,8 @@ function referencedStateNames(root, setters, scopeRoot = root) {
|
|
|
1279
1405
|
const stateNames = new Set(setters.values())
|
|
1280
1406
|
const used = new Set()
|
|
1281
1407
|
const visit = node => {
|
|
1282
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) used.add(setters.get(node.expression.text))
|
|
1408
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, scopeRoot)) used.add(setters.get(node.expression.text))
|
|
1409
|
+
if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, scopeRoot)) used.add(setters.get(node.text))
|
|
1283
1410
|
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedByParameter(node, scopeRoot)) used.add(node.text)
|
|
1284
1411
|
ts.forEachChild(node, visit)
|
|
1285
1412
|
}
|
|
@@ -1318,20 +1445,22 @@ function referencedImportedBindings(expression, imports) {
|
|
|
1318
1445
|
|
|
1319
1446
|
function captureNames(declarationRoot, referenceRoot, setters) {
|
|
1320
1447
|
const local = new Set()
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1448
|
+
if (!isFunctionLike(declarationRoot)) {
|
|
1449
|
+
const collectDeclarations = node => {
|
|
1450
|
+
if (ts.isVariableDeclaration(node)) for (const name of bindingNames(node.name)) local.add(name)
|
|
1451
|
+
if (ts.isParameter(node)) for (const name of bindingNames(node.name)) local.add(name)
|
|
1452
|
+
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) local.add(node.name.text)
|
|
1453
|
+
ts.forEachChild(node, collectDeclarations)
|
|
1454
|
+
}
|
|
1455
|
+
collectDeclarations(declarationRoot)
|
|
1326
1456
|
}
|
|
1327
|
-
collectDeclarations(declarationRoot)
|
|
1328
|
-
|
|
1329
1457
|
const stateNames = new Set(setters.values())
|
|
1330
1458
|
const captures = new Set()
|
|
1331
1459
|
const visit = node => {
|
|
1332
1460
|
if (ts.isTypeNode(node)) return
|
|
1333
|
-
if (ts.isIdentifier(node)
|
|
1334
|
-
|
|
1461
|
+
if (ts.isIdentifier(node)) {
|
|
1462
|
+
const declared = isFunctionLike(declarationRoot) ? isShadowedIdentifier(node, declarationRoot) : local.has(node.text)
|
|
1463
|
+
if (isReferenceIdentifier(node) && !declared && !setters.has(node.text) && !stateNames.has(node.text) && !nativeGlobals.has(node.text)) captures.add(node.text)
|
|
1335
1464
|
}
|
|
1336
1465
|
ts.forEachChild(node, visit)
|
|
1337
1466
|
}
|
|
@@ -1353,7 +1482,7 @@ function isReferenceIdentifier(node) {
|
|
|
1353
1482
|
(ts.isVariableDeclaration(parent) && parent.name === node) ||
|
|
1354
1483
|
(ts.isParameter(parent) && parent.name === node) ||
|
|
1355
1484
|
(ts.isFunctionDeclaration(parent) && parent.name === node) ||
|
|
1356
|
-
(ts.isBindingElement(parent) && parent.name === node) ||
|
|
1485
|
+
(ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) ||
|
|
1357
1486
|
ts.isImportSpecifier(parent) || ts.isImportClause(parent)) return false
|
|
1358
1487
|
return true
|
|
1359
1488
|
}
|
|
@@ -1373,6 +1502,43 @@ function isShadowedByParameter(node, scopeRoot) {
|
|
|
1373
1502
|
return false
|
|
1374
1503
|
}
|
|
1375
1504
|
|
|
1505
|
+
function isShadowedIdentifier(node, scopeRoot) {
|
|
1506
|
+
if (isShadowedByParameter(node, scopeRoot)) return true
|
|
1507
|
+
if (node === scopeRoot) return false
|
|
1508
|
+
if (isFunctionLike(scopeRoot) && scopeRoot.name?.text === node.text) return true
|
|
1509
|
+
if (isFunctionLike(scopeRoot) && functionVarDeclaresName(scopeRoot, node.text)) return true
|
|
1510
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
1511
|
+
if (current === scopeRoot) break
|
|
1512
|
+
if (ts.isBlock(current) && current.statements.some(statement => statementDeclaresName(statement, node.text))) return true
|
|
1513
|
+
if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statementDeclaresName(statement, node.text)))) return true
|
|
1514
|
+
if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
|
|
1515
|
+
if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, node.text)) return true
|
|
1516
|
+
if (isFunctionLike(current) && functionVarDeclaresName(current, node.text)) return true
|
|
1517
|
+
}
|
|
1518
|
+
return false
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function statementDeclaresName(statement, name) {
|
|
1522
|
+
if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.some(declaration => bindingNames(declaration.name).includes(name))
|
|
1523
|
+
return (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function loopDeclaresName(loop, name) {
|
|
1527
|
+
const declaration = ts.isForStatement(loop) ? loop.initializer : loop.initializer
|
|
1528
|
+
return declaration && ts.isVariableDeclarationList(declaration) && declaration.declarations.some(entry => bindingNames(entry.name).includes(name))
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function functionVarDeclaresName(fn, name) {
|
|
1532
|
+
let found = false
|
|
1533
|
+
const visit = node => {
|
|
1534
|
+
if (found || node !== fn.body && isFunctionLike(node)) return
|
|
1535
|
+
if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0 && node.declarations.some(entry => bindingNames(entry.name).includes(name))) found = true
|
|
1536
|
+
if (!found) ts.forEachChild(node, visit)
|
|
1537
|
+
}
|
|
1538
|
+
if (fn.body) visit(fn.body)
|
|
1539
|
+
return found
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1376
1542
|
function settersForNode(node, settersByFunction) {
|
|
1377
1543
|
for (let current = node.parent; current; current = current.parent) {
|
|
1378
1544
|
if (!ts.isFunctionDeclaration(current) && !ts.isFunctionExpression(current) && !ts.isArrowFunction(current)) continue
|
|
@@ -1399,6 +1565,96 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
|
|
|
1399
1565
|
return bindings
|
|
1400
1566
|
}
|
|
1401
1567
|
|
|
1568
|
+
function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
|
|
1569
|
+
const key = `${file}:${exportName}`
|
|
1570
|
+
if (trail.includes(key)) throw new Error(`Imported keyed list component re-export cycle: ${[...trail, key].map(entry => relative(root, entry.slice(0, entry.lastIndexOf(":")))).join(" -> ")}`)
|
|
1571
|
+
const sourceFile = getSource(file)
|
|
1572
|
+
const nextTrail = [...trail, key]
|
|
1573
|
+
|
|
1574
|
+
for (const statement of sourceFile.statements) {
|
|
1575
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
1576
|
+
const isDefault = statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)
|
|
1577
|
+
const isExported = statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
|
1578
|
+
if (exportName === "default" && isDefault || exportName !== "default" && isExported && statement.name?.text === exportName) return statement
|
|
1579
|
+
}
|
|
1580
|
+
if (ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) && exportName !== "default") {
|
|
1581
|
+
const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === exportName)
|
|
1582
|
+
if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer
|
|
1583
|
+
}
|
|
1584
|
+
if (exportName === "default" && ts.isExportAssignment(statement) && !statement.isExportEquals && ts.isIdentifier(statement.expression)) {
|
|
1585
|
+
const component = localComponentDeclaration(sourceFile, statement.expression.text)
|
|
1586
|
+
if (component) return component
|
|
1587
|
+
}
|
|
1588
|
+
if (ts.isExportDeclaration(statement) && ts.isNamedExports(statement.exportClause)) {
|
|
1589
|
+
const entry = statement.exportClause.elements.find(element => !element.isTypeOnly && element.name.text === exportName)
|
|
1590
|
+
if (!entry) continue
|
|
1591
|
+
const imported = (entry.propertyName ?? entry.name).text
|
|
1592
|
+
if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
1593
|
+
if (!statement.moduleSpecifier.text.startsWith(".")) throw sourceNodeError(statement, sourceFile, "Imported keyed list components must use relative TypeScript re-exports")
|
|
1594
|
+
const target = resolveSourceImport(file, statement.moduleSpecifier.text, sourceFiles)
|
|
1595
|
+
return resolveComponentExport(target, imported, getSource, sourceFiles, nextTrail)
|
|
1596
|
+
}
|
|
1597
|
+
const component = localComponentDeclaration(sourceFile, imported)
|
|
1598
|
+
if (component) return component
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
throw new Error(`${relative(root, file)} does not export a statically analyzable keyed list component named ${JSON.stringify(exportName)}`)
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function localComponentDeclaration(sourceFile, name) {
|
|
1605
|
+
for (const statement of sourceFile.statements) {
|
|
1606
|
+
if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) return statement
|
|
1607
|
+
if (ts.isVariableStatement(statement)) {
|
|
1608
|
+
const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === name)
|
|
1609
|
+
if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return undefined
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function sourceNodeError(node, fallbackSource, message) {
|
|
1616
|
+
const original = ts.getOriginalNode(node)
|
|
1617
|
+
const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
|
|
1618
|
+
const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
|
|
1619
|
+
return new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function sourceLocation(node, fallbackSource) {
|
|
1623
|
+
const original = ts.getOriginalNode(node)
|
|
1624
|
+
const sourceFile = original.getSourceFile?.()?.fileName ? original.getSourceFile() : fallbackSource
|
|
1625
|
+
const position = sourceFile.getLineAndCharacterOfPosition(original.getStart(sourceFile))
|
|
1626
|
+
return `${sourceFile.fileName}:${position.line + 1}:${position.character + 1}`
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function returnsCleanup(callback) {
|
|
1630
|
+
if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) {
|
|
1631
|
+
const body = unwrapExpression(callback.body)
|
|
1632
|
+
return ts.isArrowFunction(body) || ts.isFunctionExpression(body)
|
|
1633
|
+
}
|
|
1634
|
+
let found = false
|
|
1635
|
+
const visit = node => {
|
|
1636
|
+
if (found || node !== callback.body && isFunctionLike(node)) return
|
|
1637
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
1638
|
+
const expression = unwrapExpression(node.expression)
|
|
1639
|
+
if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) found = true
|
|
1640
|
+
}
|
|
1641
|
+
if (!found) ts.forEachChild(node, visit)
|
|
1642
|
+
}
|
|
1643
|
+
visit(callback.body)
|
|
1644
|
+
return found
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function returnsEffectValue(callback) {
|
|
1648
|
+
let found = false
|
|
1649
|
+
const visit = node => {
|
|
1650
|
+
if (found || node !== callback.body && isFunctionLike(node)) return
|
|
1651
|
+
if (ts.isReturnStatement(node) && node.expression) found = true
|
|
1652
|
+
if (!found) ts.forEachChild(node, visit)
|
|
1653
|
+
}
|
|
1654
|
+
visit(callback.body)
|
|
1655
|
+
return found
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1402
1658
|
function printClientImports(entries, handlerPath) {
|
|
1403
1659
|
const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
|
|
1404
1660
|
const groups = Map.groupBy(unique.values(), entry => entry.target)
|
|
@@ -1474,7 +1730,7 @@ function resolveSourceImport(importer, specifier, sourceFiles) {
|
|
|
1474
1730
|
const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
|
|
1475
1731
|
const candidates = extension === ".ts" || extension === ".tsx"
|
|
1476
1732
|
? [base]
|
|
1477
|
-
: [`${stem}.ts`, `${stem}.tsx
|
|
1733
|
+
: [`${stem}.ts`, `${stem}.tsx`, join(stem, "index.ts"), join(stem, "index.tsx")]
|
|
1478
1734
|
const matches = candidates.filter(candidate => sourceFiles.has(candidate))
|
|
1479
1735
|
if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
|
|
1480
1736
|
return matches[0]
|
|
@@ -1516,24 +1772,33 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
1516
1772
|
const stateNames = new Set(setters.values())
|
|
1517
1773
|
const transformer = context => root => {
|
|
1518
1774
|
const visitor = node => {
|
|
1519
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text)) {
|
|
1775
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
|
|
1520
1776
|
return factory.createCallExpression(
|
|
1521
1777
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"),
|
|
1522
1778
|
undefined,
|
|
1523
1779
|
[factory.createStringLiteral(setters.get(node.expression.text)), ...node.arguments.map(argument => ts.visitNode(argument, visitor))]
|
|
1524
1780
|
)
|
|
1525
1781
|
}
|
|
1526
|
-
if (ts.
|
|
1782
|
+
if (ts.isShorthandPropertyAssignment(node) && setters.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
1783
|
+
return factory.createPropertyAssignment(node.name, setterReference(factory, setters.get(node.name.text)))
|
|
1784
|
+
}
|
|
1785
|
+
if (ts.isIdentifier(node) && setters.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
1786
|
+
return setterReference(factory, setters.get(node.text))
|
|
1787
|
+
}
|
|
1788
|
+
if (ts.isShorthandPropertyAssignment(node) && stateNames.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
1789
|
+
return factory.createPropertyAssignment(node.name, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(node.name.text)]))
|
|
1790
|
+
}
|
|
1791
|
+
if (ts.isIdentifier(node) && stateNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
1527
1792
|
return factory.createCallExpression(
|
|
1528
1793
|
factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"),
|
|
1529
1794
|
undefined,
|
|
1530
1795
|
[factory.createStringLiteral(node.text)]
|
|
1531
1796
|
)
|
|
1532
1797
|
}
|
|
1533
|
-
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text)) {
|
|
1798
|
+
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
1534
1799
|
return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
|
|
1535
1800
|
}
|
|
1536
|
-
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node)) {
|
|
1801
|
+
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
1537
1802
|
return scopeRead(factory, node.text)
|
|
1538
1803
|
}
|
|
1539
1804
|
return ts.visitEachChild(node, visitor, context)
|
|
@@ -1562,6 +1827,17 @@ function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
|
1562
1827
|
}
|
|
1563
1828
|
}
|
|
1564
1829
|
|
|
1830
|
+
function setterReference(factory, stateName) {
|
|
1831
|
+
return factory.createArrowFunction(
|
|
1832
|
+
undefined,
|
|
1833
|
+
undefined,
|
|
1834
|
+
[factory.createParameterDeclaration(undefined, undefined, "value")],
|
|
1835
|
+
undefined,
|
|
1836
|
+
factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
1837
|
+
factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(stateName), factory.createIdentifier("value")])
|
|
1838
|
+
)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1565
1841
|
function printReactiveBinding({ exportName, expression, captures, states }) {
|
|
1566
1842
|
const factory = ts.factory
|
|
1567
1843
|
const transformer = context => root => {
|
|
@@ -1667,11 +1943,6 @@ function numericExpression(factory, value, negative) {
|
|
|
1667
1943
|
return negative ? factory.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, literal) : literal
|
|
1668
1944
|
}
|
|
1669
1945
|
|
|
1670
|
-
function modulePath(value) {
|
|
1671
|
-
if (/\.(?:ts|tsx|js|jsx)$/.test(value)) return value.replace(/\.(?:ts|tsx|js|jsx)$/, ".mjs")
|
|
1672
|
-
return `${value}.mjs`
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
1946
|
function compiledPath(file) {
|
|
1676
1947
|
return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
|
|
1677
1948
|
}
|
package/framework/core.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
|
|
2
2
|
|
|
3
3
|
export function useState<T>(initialValue: T): [T, StateSetter<T>]
|
|
4
|
+
export function useEffect(effect: () => void | Promise<void>, dependencies: readonly []): void
|
|
4
5
|
|
|
5
6
|
export interface RefObject<T> {
|
|
6
7
|
readonly current: T | null
|
|
@@ -46,11 +47,13 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
46
47
|
manifest?: string
|
|
47
48
|
styles?: boolean | string[]
|
|
48
49
|
base?: string
|
|
50
|
+
effectAsset?: string
|
|
49
51
|
},
|
|
50
52
|
props?: Props
|
|
51
53
|
): Promise<{
|
|
52
54
|
html: string
|
|
53
55
|
hasBehaviors: boolean
|
|
56
|
+
hasEffects: boolean
|
|
54
57
|
hasBindings: boolean
|
|
55
58
|
hasLists: boolean
|
|
56
59
|
hasListStyles: boolean
|
|
@@ -62,6 +65,7 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
62
65
|
commands?: Array<[string, string, unknown]>
|
|
63
66
|
native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
|
|
64
67
|
}>
|
|
68
|
+
effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }>
|
|
65
69
|
bindings: Array<{
|
|
66
70
|
target: string
|
|
67
71
|
state?: string
|
package/framework/core.mjs
CHANGED
|
@@ -44,6 +44,16 @@ export function useState(initialValue, name) {
|
|
|
44
44
|
return [signal, setter]
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function useEffect(callback, dependencies, module, handler, states, scope, source) {
|
|
48
|
+
if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
|
|
49
|
+
if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
|
|
50
|
+
throw new Error("useEffect() must be compiled with a literal empty dependency array")
|
|
51
|
+
}
|
|
52
|
+
renderContext.effects.push({ module, handler, states, scope, source })
|
|
53
|
+
renderContext.hasBehaviors = true
|
|
54
|
+
renderContext.hasEffects = true
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
export function useRef(initialValue) {
|
|
48
58
|
if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
|
|
49
59
|
if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
|
|
@@ -82,8 +92,14 @@ export function nativeBehavior(module, handler, states, scope) {
|
|
|
82
92
|
[nativeBehaviorMarker]: true,
|
|
83
93
|
module,
|
|
84
94
|
handler,
|
|
95
|
+
...nativeDescriptor(states, scope)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function nativeDescriptor(states, scope) {
|
|
100
|
+
return {
|
|
85
101
|
states: Object.fromEntries(states.map(([name, signal]) => {
|
|
86
|
-
if (!signal?.[signalMarker]) throw new Error("A native
|
|
102
|
+
if (!signal?.[signalMarker]) throw new Error("A native callback must target framework state")
|
|
87
103
|
return [name, signal.id]
|
|
88
104
|
})),
|
|
89
105
|
scope: Object.fromEntries(scope.map(([name, value]) => [name, value?.[signalMarker] ? { type: "state", id: value.id } : serializeCapture(name, value, new Set())]))
|
|
@@ -226,10 +242,21 @@ function serializeCapture(name, value, seen) {
|
|
|
226
242
|
}
|
|
227
243
|
|
|
228
244
|
export async function renderPage(component, metadata = {}, props = {}) {
|
|
229
|
-
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
245
|
+
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
230
246
|
|
|
231
247
|
try {
|
|
232
248
|
const body = await renderNode({ type: component, props })
|
|
249
|
+
renderContext.effects = renderContext.effects.map(effect => {
|
|
250
|
+
try {
|
|
251
|
+
return {
|
|
252
|
+
module: effect.module,
|
|
253
|
+
handler: effect.handler,
|
|
254
|
+
...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
throw new Error(`${effect.source} ${error.message}`)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
233
260
|
const title = escapeHtml(metadata.title ?? "Kudzu")
|
|
234
261
|
const head = renderMetadata(metadata)
|
|
235
262
|
const styles = metadata.styles === false
|
|
@@ -247,6 +274,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
247
274
|
const listRuntime = renderContext.hasLists
|
|
248
275
|
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
|
|
249
276
|
: ""
|
|
277
|
+
const effectRuntime = renderContext.hasEffects
|
|
278
|
+
? `<script type="module" src="${escapeAttribute(metadata.effectAsset)}"></script>`
|
|
279
|
+
: ""
|
|
250
280
|
const listStates = new Set(renderContext.lists.map(list => list.state))
|
|
251
281
|
const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
|
|
252
282
|
const initialState = renderContext.hasBehaviors
|
|
@@ -263,8 +293,9 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
263
293
|
: ""
|
|
264
294
|
|
|
265
295
|
return {
|
|
266
|
-
html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}</body></html>`,
|
|
296
|
+
html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
|
|
267
297
|
hasBehaviors: renderContext.hasBehaviors,
|
|
298
|
+
hasEffects: renderContext.hasEffects,
|
|
268
299
|
hasBindings: renderContext.hasBindings,
|
|
269
300
|
hasLists: renderContext.hasLists,
|
|
270
301
|
hasListStyles: renderContext.hasListStyles,
|
|
@@ -272,6 +303,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
272
303
|
plan: {
|
|
273
304
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
274
305
|
events: renderContext.events,
|
|
306
|
+
effects: renderContext.effects,
|
|
275
307
|
bindings: renderContext.bindings,
|
|
276
308
|
conditions: renderContext.conditions,
|
|
277
309
|
lists: renderContext.lists
|
|
@@ -552,6 +584,11 @@ async function renderList(node, namespace, selectValue) {
|
|
|
552
584
|
const template = await renderNode(node.render({}), namespace, selectValue)
|
|
553
585
|
if (template.includes("data-k-native-")) descriptor.mount = true
|
|
554
586
|
if (template.includes("data-k-list-condition")) descriptor.conditions = true
|
|
587
|
+
if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
|
|
588
|
+
if (template.includes("data-k-list-attrs")) descriptor.attributes = true
|
|
589
|
+
if (template.includes("data-k-list-events")) descriptor.events = true
|
|
590
|
+
if (template.includes("data-k-list-expression=")) descriptor.expressions = true
|
|
591
|
+
if (template.includes("data-k-list-expression-attrs")) descriptor.expressionAttributes = true
|
|
555
592
|
const seed = listSeed(node.items.value, renderContext.listFields)
|
|
556
593
|
if (seed) descriptor.seed = seed
|
|
557
594
|
let current = ""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { deserialize } from "./serialization.js"
|
|
2
|
+
|
|
3
|
+
export function createEffectContext(state, stateIds, commit, serializedScope = {}) {
|
|
4
|
+
const changed = new Set()
|
|
5
|
+
let scheduled = false
|
|
6
|
+
|
|
7
|
+
const flush = () => {
|
|
8
|
+
scheduled = false
|
|
9
|
+
const ids = [...changed]
|
|
10
|
+
changed.clear()
|
|
11
|
+
for (const id of ids) commit(id, state.get(id))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const setId = (id, value) => {
|
|
15
|
+
const current = state.get(id)
|
|
16
|
+
state.set(id, typeof value === "function" ? value(current) : value)
|
|
17
|
+
changed.add(id)
|
|
18
|
+
if (!scheduled) {
|
|
19
|
+
scheduled = true
|
|
20
|
+
queueMicrotask(flush)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scope = globalThis.__KUDZU_EFFECT_CAPTURES__
|
|
25
|
+
? Object.fromEntries(Object.entries(serializedScope).map(([name, value]) => [name, deserialize(value, id => state.get(id), globalThis.__KUDZU_CAPTURE_SETTER__ ? setId : undefined)]))
|
|
26
|
+
: undefined
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
get(name) {
|
|
30
|
+
return state.get(stateIds[name])
|
|
31
|
+
},
|
|
32
|
+
scope(name) {
|
|
33
|
+
return globalThis.__KUDZU_EFFECT_CAPTURES__ ? serializedScope[name]?.type === "state" ? state.get(serializedScope[name].id) : scope[name] : undefined
|
|
34
|
+
},
|
|
35
|
+
set(name, value) {
|
|
36
|
+
setId(stateIds[name], value)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -3,11 +3,11 @@ import { browserState, mountDom, registerCommitter, registerMountHook, registerU
|
|
|
3
3
|
const listTargets = new Map()
|
|
4
4
|
const listRegistrations = new WeakMap()
|
|
5
5
|
const mountedLists = new WeakSet()
|
|
6
|
-
const imports = new Map()
|
|
7
|
-
const revisions = new WeakMap()
|
|
6
|
+
const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
|
|
7
|
+
const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
|
|
8
8
|
const itemParts = new WeakMap()
|
|
9
|
-
const conditionOwners = new WeakMap()
|
|
10
|
-
const itemPartsSelector = `[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
|
|
9
|
+
const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
|
|
10
|
+
const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
|
|
11
11
|
|
|
12
12
|
function commitLists(id) {
|
|
13
13
|
const lists = listTargets.get(id)
|
|
@@ -34,13 +34,13 @@ function mountLists(root) {
|
|
|
34
34
|
const templateRoot = start.content.firstElementChild
|
|
35
35
|
const parts = listItemPartPlan(templateRoot)
|
|
36
36
|
for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
|
|
37
|
-
if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
|
|
37
|
+
if (__KUDZU_LIST_SEEDS__ && descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
|
|
38
38
|
const items = browserState.get(descriptor.state)
|
|
39
39
|
const list = {
|
|
40
40
|
start,
|
|
41
41
|
descriptor,
|
|
42
42
|
parts,
|
|
43
|
-
seedFields: descriptor.seed && Object.keys(descriptor.seed),
|
|
43
|
+
seedFields: __KUDZU_LIST_SEEDS__ && descriptor.seed && Object.keys(descriptor.seed),
|
|
44
44
|
roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
|
|
45
45
|
values: new Map(),
|
|
46
46
|
container: roots[0]?.parentNode,
|
|
@@ -77,7 +77,7 @@ function updateList(list) {
|
|
|
77
77
|
const key = item?.[list.descriptor.key]
|
|
78
78
|
if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
|
|
79
79
|
assertListItem(item)
|
|
80
|
-
const seededValue = list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed)
|
|
80
|
+
const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
|
|
81
81
|
if (seededValue === undefined) assertListValue(item, seen, true)
|
|
82
82
|
const token = keyToken(key)
|
|
83
83
|
if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
|
|
@@ -108,13 +108,13 @@ function updateList(list) {
|
|
|
108
108
|
}
|
|
109
109
|
for (const [token, node] of list.roots) {
|
|
110
110
|
if (keys.has(token)) continue
|
|
111
|
-
if (list.descriptor.mount) {
|
|
111
|
+
if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
|
|
112
112
|
unmountDom(node)
|
|
113
113
|
node.remove()
|
|
114
114
|
} else node.remove()
|
|
115
115
|
}
|
|
116
116
|
if (added) {
|
|
117
|
-
if (list.descriptor.mount) mountDom(additions)
|
|
117
|
+
if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(additions)
|
|
118
118
|
parent.insertBefore(additions, list.boundary)
|
|
119
119
|
list.container ??= parent
|
|
120
120
|
}
|
|
@@ -142,8 +142,8 @@ function updateList(list) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function fillListItem(root, item) {
|
|
145
|
-
const revision = (revisions.get(root) ?? 0) + 1
|
|
146
|
-
revisions.set(root, revision)
|
|
145
|
+
const revision = __KUDZU_LIST_ASYNC_PARTS__ ? (revisions.get(root) ?? 0) + 1 : 0
|
|
146
|
+
if (__KUDZU_LIST_ASYNC_PARTS__) revisions.set(root, revision)
|
|
147
147
|
const parts = listItemParts(root)
|
|
148
148
|
fillListParts(root, parts, item, revision)
|
|
149
149
|
}
|
|
@@ -159,30 +159,38 @@ function fillListParts(root, parts, item, revision) {
|
|
|
159
159
|
node.textContent = value
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
-
|
|
163
|
-
patchListText(marker, "template[data-k-list-text-end]", item?.[field])
|
|
162
|
+
if (__KUDZU_LIST_TEXT_RANGES__) {
|
|
163
|
+
for (const [marker, field] of parts.texts) patchListText(marker, "template[data-k-list-text-end]", item?.[field])
|
|
164
164
|
}
|
|
165
|
-
|
|
166
|
-
for (const [
|
|
167
|
-
|
|
168
|
-
for (const [node, events] of parts.events) {
|
|
169
|
-
for (const [event, native] of JSON.parse(events)) {
|
|
170
|
-
native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
|
|
171
|
-
node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
|
|
165
|
+
if (__KUDZU_LIST_ATTRIBUTES__) {
|
|
166
|
+
for (const [node, attributes] of parts.attributes) {
|
|
167
|
+
for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
|
|
172
168
|
}
|
|
173
169
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
170
|
+
if (__KUDZU_LIST_EVENTS__) {
|
|
171
|
+
for (const [node, events] of parts.events) {
|
|
172
|
+
for (const [event, native] of JSON.parse(events)) {
|
|
173
|
+
native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
|
|
174
|
+
node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
178
177
|
}
|
|
179
|
-
|
|
180
|
-
for (const [
|
|
181
|
-
evaluate(
|
|
182
|
-
if (revisions.get(root) === revision && root.isConnected)
|
|
178
|
+
if (__KUDZU_LIST_EXPRESSIONS__) {
|
|
179
|
+
for (const [marker, descriptor] of parts.expressions) {
|
|
180
|
+
evaluate(descriptor, item).then(value => {
|
|
181
|
+
if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
|
|
183
182
|
}).catch(error => console.error(error))
|
|
184
183
|
}
|
|
185
184
|
}
|
|
185
|
+
if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__) {
|
|
186
|
+
for (const [node, attributes] of parts.expressionAttributes) {
|
|
187
|
+
for (const [target, module, handler] of attributes) {
|
|
188
|
+
evaluate({ module, handler }, item).then(value => {
|
|
189
|
+
if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
|
|
190
|
+
}).catch(error => console.error(error))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
186
194
|
if (__KUDZU_LIST_CONDITIONS__) {
|
|
187
195
|
for (const [marker, descriptor] of parts.conditions) {
|
|
188
196
|
evaluate(descriptor, item).then(value => {
|
|
@@ -198,10 +206,10 @@ function listItemParts(root) {
|
|
|
198
206
|
parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
|
|
199
207
|
for (const node of matching(root, itemPartsSelector)) {
|
|
200
208
|
if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
|
|
201
|
-
if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
|
|
202
|
-
if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
|
|
203
|
-
if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
204
|
-
if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
209
|
+
if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
|
|
210
|
+
if (__KUDZU_LIST_EVENTS__ && node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
|
|
211
|
+
if (__KUDZU_LIST_EXPRESSIONS__ && node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
212
|
+
if (__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ && node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
205
213
|
if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
|
|
206
214
|
parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
|
|
207
215
|
conditionOwners.set(node, root)
|
|
@@ -217,11 +225,11 @@ function listItemPartPlan(template) {
|
|
|
217
225
|
const parts = listItemParts(template)
|
|
218
226
|
return {
|
|
219
227
|
directTexts: parts.directTexts.map(([node, field]) => [indexes.get(node), field]),
|
|
220
|
-
texts: parts.texts.map(([node, field]) => [indexes.get(node), field]),
|
|
221
|
-
attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
222
|
-
events: parts.events.map(([node, events]) => [indexes.get(node), events]),
|
|
223
|
-
expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
|
|
224
|
-
expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
228
|
+
texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([node, field]) => [indexes.get(node), field]) : [],
|
|
229
|
+
attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
|
|
230
|
+
events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([node, events]) => [indexes.get(node), events]) : [],
|
|
231
|
+
expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
|
|
232
|
+
expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
|
|
225
233
|
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
|
|
226
234
|
}
|
|
227
235
|
}
|
|
@@ -230,11 +238,11 @@ function mapListItemParts(parts, root) {
|
|
|
230
238
|
const target = [root, ...root.querySelectorAll("*")]
|
|
231
239
|
itemParts.set(root, {
|
|
232
240
|
directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
|
|
233
|
-
texts: parts.texts.map(([index, field]) => [target[index], field]),
|
|
234
|
-
attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
|
|
235
|
-
events: parts.events.map(([index, events]) => [target[index], events]),
|
|
236
|
-
expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
|
|
237
|
-
expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
|
|
241
|
+
texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([index, field]) => [target[index], field]) : [],
|
|
242
|
+
attributes: __KUDZU_LIST_ATTRIBUTES__ ? parts.attributes.map(([index, attributes]) => [target[index], attributes]) : [],
|
|
243
|
+
events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([index, events]) => [target[index], events]) : [],
|
|
244
|
+
expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([index, descriptor]) => [target[index], descriptor]) : [],
|
|
245
|
+
expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]) : [],
|
|
238
246
|
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
|
|
239
247
|
conditionOwners.set(target[index], root)
|
|
240
248
|
return [target[index], descriptor]
|