@kudzujs/core 0.4.12 → 0.4.14
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 +45 -13
- package/framework/README.md +2 -2
- package/framework/binding-runtime.js +5 -2
- package/framework/build.mjs +243 -15
- package/framework/core.d.ts +1 -0
- package/framework/core.mjs +53 -8
- package/framework/list-runtime.js +62 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,14 @@ function increaseTwice() {
|
|
|
98
98
|
|
|
99
99
|
The handler above increments by two and patches its bound DOM once. Inspect the generated plan at `.kudzu/kudzu-plan.json`.
|
|
100
100
|
|
|
101
|
+
State may also hold serializable plain objects. Property expressions in JSX text update directly:
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
const [weather, setWeather] = useState({ temperature: 28, label: "Warm" })
|
|
105
|
+
|
|
106
|
+
return <p>{weather.temperature}° {weather.label}</p>
|
|
107
|
+
```
|
|
108
|
+
|
|
101
109
|
## Reactive Attributes
|
|
102
110
|
|
|
103
111
|
`className`, `disabled`, controlled `value`, and controlled `checked` accept normal state-dependent TSX expressions. The same `value` binding works for inputs and selects:
|
|
@@ -209,6 +217,7 @@ const rows = items.map(item =>
|
|
|
209
217
|
style={{ opacity: item.done ? 0.5 : 1 }}
|
|
210
218
|
>
|
|
211
219
|
{item.name.toUpperCase()}
|
|
220
|
+
{item.done ? <strong>Complete</strong> : <span>Pending</span>}
|
|
212
221
|
<button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
|
|
213
222
|
</li>
|
|
214
223
|
)
|
|
@@ -216,9 +225,9 @@ const rows = items.map(item =>
|
|
|
216
225
|
return <ul>{rows}</ul>
|
|
217
226
|
```
|
|
218
227
|
|
|
219
|
-
Kudzu emits initial items as static HTML, then adds, removes, updates, styles, 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. 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.
|
|
228
|
+
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.
|
|
220
229
|
|
|
221
|
-
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, and `key={item.<field>}`. 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, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
230
|
+
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, and `key={item.<field>}`. 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, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested item conditions or lists, item spreads, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
222
231
|
|
|
223
232
|
## Normal JavaScript
|
|
224
233
|
|
|
@@ -238,7 +247,18 @@ async function load() {
|
|
|
238
247
|
}
|
|
239
248
|
```
|
|
240
249
|
|
|
241
|
-
|
|
250
|
+
Native handlers may call default, named, or namespace helpers imported from relative TypeScript modules. Kudzu bundles the reachable helper graph into handler ESM and shared chunks; helper runtime imports must remain relative, and dynamic imports or JSX helpers are rejected. Imported functions cannot be used directly as JSX event callbacks.
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
import { normalizeStatus } from "../lib/status"
|
|
254
|
+
|
|
255
|
+
async function load() {
|
|
256
|
+
const response = await fetch("/api/status")
|
|
257
|
+
setStatus(normalizeStatus(await response.json()))
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, and class instances are not supported as captures.
|
|
242
262
|
|
|
243
263
|
Native handlers use direct DOM listeners with normal `currentTarget`, bubbling, default-action, and propagation semantics. Handler modules load before listener registration, so `preventDefault`, `stopPropagation`, and `stopImmediatePropagation` work synchronously as expected.
|
|
244
264
|
|
|
@@ -276,6 +296,7 @@ Supported:
|
|
|
276
296
|
- Build-time async components
|
|
277
297
|
- Primitive `useState` bindings
|
|
278
298
|
- Synchronous and async event handlers
|
|
299
|
+
- Relative imported helpers in native handlers
|
|
279
300
|
- Serializable component-local captures
|
|
280
301
|
- Direct text DOM patches
|
|
281
302
|
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
@@ -291,7 +312,7 @@ Not implemented yet:
|
|
|
291
312
|
|
|
292
313
|
- Block-scoped JSX locals and reusable keyed-list aliases
|
|
293
314
|
- Server actions and request-time SSR
|
|
294
|
-
-
|
|
315
|
+
- React package islands
|
|
295
316
|
- HMR and framework DevTools
|
|
296
317
|
|
|
297
318
|
## Benchmarks
|
|
@@ -314,6 +335,17 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
314
335
|
|
|
315
336
|
Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
|
|
316
337
|
|
|
338
|
+
#### Imported Helper Cost
|
|
339
|
+
|
|
340
|
+
The same native counter calculation was measured inline and through one relative TypeScript helper. Click medians are per state update from five 20,000-click batches in each of seven fresh Chrome sessions.
|
|
341
|
+
|
|
342
|
+
| Kudzu variant | Files | Initial JS gzip | Total output | Clean build | Click |
|
|
343
|
+
|---|---:|---:|---:|---:|---:|
|
|
344
|
+
| Inline native handler | 5 | 1,827 B | 3,923 B | **426 ms** | **3.78 µs** |
|
|
345
|
+
| Imported helper | 5 | 1,845 B | 3,949 B | 446 ms | 4.47 µs |
|
|
346
|
+
|
|
347
|
+
Bundling removes the helper file boundary, leaving 26 raw bytes and 18 gzip bytes for the function definition and calls. The measured call adds 0.69 µs per state update. The smaller 393 B command-only counter above uses a different optimized runtime path and is not the helper overhead baseline.
|
|
348
|
+
|
|
317
349
|
### Static Journal Page
|
|
318
350
|
|
|
319
351
|
Same content and CSS across every fixture:
|
|
@@ -334,15 +366,15 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
|
|
|
334
366
|
|
|
335
367
|
| Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
|
|
336
368
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
337
|
-
| Astro | Yes | **324 B** | **43.6 KB** |
|
|
338
|
-
| Kudzu | Yes | 5.
|
|
339
|
-
| Next.js | Yes | 182.2 KB | 695.2 KB |
|
|
340
|
-
|
|
|
341
|
-
|
|
|
342
|
-
| Svelte CSR | No | 12.9 KB | 33.1 KB |
|
|
343
|
-
| Qwik CSR | No | 22.2 KB | 64.1 KB |
|
|
344
|
-
|
|
345
|
-
Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 23.
|
|
369
|
+
| Astro | Yes | **324 B** | **43.6 KB** | 834 ms | **4.3 ms** | **3.8 ms** | **1.3 ms** | **3.1 ms** | **12.5 ms** |
|
|
370
|
+
| Kudzu | Yes | 5.1 KB | 60.3 KB | **438 ms** | 7.5 ms | 7.0 ms | 1.8 ms | 6.9 ms | 23.2 ms |
|
|
371
|
+
| Next.js | Yes | 182.2 KB | 695.2 KB | 2983 ms | 7.0 ms | 12.0 ms | 3.9 ms | 6.7 ms | 29.6 ms |
|
|
372
|
+
| React CSR | No | 59.3 KB | 189.4 KB | 1020 ms | 9.5 ms | 11.7 ms | 3.8 ms | 5.3 ms | 30.3 ms |
|
|
373
|
+
| Vue CSR | No | 24.3 KB | 61.3 KB | 773 ms | 11.4 ms | 9.5 ms | 4.1 ms | 6.6 ms | 31.6 ms |
|
|
374
|
+
| Svelte CSR | No | 12.9 KB | 33.1 KB | 828 ms | 5.8 ms | 38.9 ms | 4.0 ms | 5.9 ms | 54.6 ms |
|
|
375
|
+
| Qwik CSR | No | 22.2 KB | 64.1 KB | 594 ms | 9.1 ms | 22.2 ms | 30.8 ms | 19.0 ms | 81.1 ms |
|
|
376
|
+
|
|
377
|
+
Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 23.2 ms, 10.7 ms behind the hand-authored Astro baseline and 7.1 ms ahead of React across all four operations.
|
|
346
378
|
|
|
347
379
|
Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
|
|
348
380
|
|
package/framework/README.md
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
7
|
- `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
|
|
8
8
|
- `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
|
|
9
|
-
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic styles and item-handler scopes, moves, and cleanup.
|
|
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
11
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
12
12
|
- `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
|
|
13
13
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
14
14
|
|
|
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`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators live under `dist/assets/handlers
|
|
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`. 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
16
|
|
|
17
17
|
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
|
@@ -9,11 +9,14 @@ const mountedBindings = new WeakSet()
|
|
|
9
9
|
const mountedConditions = new WeakSet()
|
|
10
10
|
const bindingRegistrations = new WeakMap()
|
|
11
11
|
const conditionRegistrations = new WeakMap()
|
|
12
|
-
const bindingTypes = ["class", "disabled", "value", "checked", "style"]
|
|
12
|
+
const bindingTypes = ["text", "class", "disabled", "value", "checked", "style"]
|
|
13
13
|
const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
|
|
14
14
|
|
|
15
15
|
export function patchBinding(node, target, value) {
|
|
16
|
-
if (target === "
|
|
16
|
+
if (target === "text") {
|
|
17
|
+
const next = value == null ? "" : String(value)
|
|
18
|
+
if (node.textContent !== next) node.textContent = next
|
|
19
|
+
} else if (target === "disabled") {
|
|
17
20
|
node.toggleAttribute("disabled", Boolean(value))
|
|
18
21
|
} else if (target === "checked") {
|
|
19
22
|
node.checked = Boolean(value)
|
package/framework/build.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createServer } from "node:http"
|
|
2
2
|
import { randomUUID } from "node:crypto"
|
|
3
3
|
import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
|
|
4
|
-
import { extname, join, relative, resolve, sep } from "node:path"
|
|
4
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path"
|
|
5
5
|
import { pathToFileURL } from "node:url"
|
|
6
|
-
import { transform } from "esbuild"
|
|
6
|
+
import { build as bundle, transform } from "esbuild"
|
|
7
7
|
import ts from "typescript"
|
|
8
8
|
import { renderPage } from "./core.mjs"
|
|
9
9
|
import { stateSchema } from "./dev-state.js"
|
|
@@ -24,10 +24,11 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
24
24
|
|
|
25
25
|
const sourceFiles = (await walk(sourceDirectory)).filter(file => /\.(?:ts|tsx)$/.test(file)).sort()
|
|
26
26
|
if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
|
|
27
|
+
const sourceFileSet = new Set(sourceFiles)
|
|
27
28
|
|
|
28
29
|
const handlerModules = []
|
|
29
30
|
for (const file of sourceFiles) {
|
|
30
|
-
const handlerModule = await compile(file)
|
|
31
|
+
const handlerModule = await compile(file, sourceFileSet)
|
|
31
32
|
if (handlerModule) handlerModules.push(handlerModule)
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -67,6 +68,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
67
68
|
await mkdir(assetsDirectory, { recursive: true })
|
|
68
69
|
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
69
70
|
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
71
|
+
const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
|
|
70
72
|
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => `/assets/${module.path}`)
|
|
71
73
|
const hasNativeHandlers = nativeModules.length > 0
|
|
72
74
|
if (behaviorCount) {
|
|
@@ -94,7 +96,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
94
96
|
}`
|
|
95
97
|
listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
|
|
96
98
|
if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
|
|
97
|
-
await
|
|
99
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, { __KUDZU_LIST_CONDITIONS__: String(hasListConditions) })
|
|
98
100
|
}
|
|
99
101
|
if (hasNativeHandlers) {
|
|
100
102
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
@@ -107,6 +109,30 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
107
109
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
108
110
|
await writeJavaScript(output, handlerModule.code, minify)
|
|
109
111
|
}
|
|
112
|
+
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
113
|
+
for (const file of clientModules) {
|
|
114
|
+
const output = join(assetsDirectory, clientModulePath(file))
|
|
115
|
+
await mkdir(resolve(output, ".."), { recursive: true })
|
|
116
|
+
await writeJavaScript(output, await compileClientModule(file, sourceFileSet), minify)
|
|
117
|
+
}
|
|
118
|
+
if (clientModules.length) {
|
|
119
|
+
await bundle({
|
|
120
|
+
entryPoints: handlerModules.map(module => join(assetsDirectory, module.path)),
|
|
121
|
+
outbase: join(assetsDirectory, "handlers"),
|
|
122
|
+
outdir: join(assetsDirectory, "handlers"),
|
|
123
|
+
entryNames: "[dir]/[name]",
|
|
124
|
+
chunkNames: "chunks/[name]-[hash]",
|
|
125
|
+
allowOverwrite: true,
|
|
126
|
+
bundle: true,
|
|
127
|
+
splitting: true,
|
|
128
|
+
format: "esm",
|
|
129
|
+
target: "es2022",
|
|
130
|
+
minify,
|
|
131
|
+
legalComments: "none",
|
|
132
|
+
logLevel: "silent"
|
|
133
|
+
})
|
|
134
|
+
await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
|
|
135
|
+
}
|
|
110
136
|
await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
|
|
111
137
|
if (hasStyles) await cp(join(sourceDirectory, "style.css"), join(assetsDirectory, "style.css"))
|
|
112
138
|
if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
|
|
@@ -137,6 +163,22 @@ async function writeJavaScript(file, source, minify) {
|
|
|
137
163
|
await writeFile(file, code)
|
|
138
164
|
}
|
|
139
165
|
|
|
166
|
+
async function writeBundledJavaScript(file, source, minify, define) {
|
|
167
|
+
const result = await bundle({
|
|
168
|
+
stdin: { contents: source, resolveDir: dirname(file), sourcefile: file },
|
|
169
|
+
bundle: true,
|
|
170
|
+
write: false,
|
|
171
|
+
external: ["./kudzu.js", "./kudzu-style.js"],
|
|
172
|
+
define,
|
|
173
|
+
format: "esm",
|
|
174
|
+
target: "es2022",
|
|
175
|
+
minify,
|
|
176
|
+
legalComments: "none",
|
|
177
|
+
logLevel: "silent"
|
|
178
|
+
})
|
|
179
|
+
await writeFile(file, result.outputFiles[0].contents)
|
|
180
|
+
}
|
|
181
|
+
|
|
140
182
|
export function parseDevPort(value) {
|
|
141
183
|
if (value === undefined || value.trim() === "") return 3000
|
|
142
184
|
if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
|
|
@@ -275,11 +317,12 @@ function escapeHtml(value) {
|
|
|
275
317
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
276
318
|
}
|
|
277
319
|
|
|
278
|
-
async function compile(file) {
|
|
320
|
+
async function compile(file, sourceFiles) {
|
|
279
321
|
const source = await readFile(file, "utf8")
|
|
280
322
|
const nativeHandlers = []
|
|
281
323
|
const reactiveBindings = []
|
|
282
324
|
const listExpressions = []
|
|
325
|
+
const clientImports = new Set()
|
|
283
326
|
const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
284
327
|
const result = ts.transpileModule(source, {
|
|
285
328
|
fileName: file,
|
|
@@ -289,7 +332,7 @@ async function compile(file) {
|
|
|
289
332
|
jsx: ts.JsxEmit.ReactJSX,
|
|
290
333
|
jsxImportSource: "@kudzujs/core"
|
|
291
334
|
},
|
|
292
|
-
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, `/assets/${handlerPath}
|
|
335
|
+
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, `/assets/${handlerPath}`, file, sourceFiles, clientImports)] },
|
|
293
336
|
reportDiagnostics: true
|
|
294
337
|
})
|
|
295
338
|
|
|
@@ -304,6 +347,7 @@ async function compile(file) {
|
|
|
304
347
|
|
|
305
348
|
if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
306
349
|
const moduleSource = [
|
|
350
|
+
printClientImports(nativeHandlers.flatMap(handler => handler.imports), handlerPath),
|
|
307
351
|
...nativeHandlers.map(handler => printNativeHandler(handler)),
|
|
308
352
|
...reactiveBindings.map(entry => printReactiveBinding(entry)),
|
|
309
353
|
...listExpressions.map(entry => printListExpression(entry))
|
|
@@ -314,12 +358,13 @@ async function compile(file) {
|
|
|
314
358
|
})
|
|
315
359
|
const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
316
360
|
if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
317
|
-
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
|
|
361
|
+
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
|
|
318
362
|
}
|
|
319
363
|
|
|
320
|
-
function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl) {
|
|
364
|
+
function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, clientImports) {
|
|
321
365
|
return context => sourceFile => {
|
|
322
366
|
const factory = context.factory
|
|
367
|
+
const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
|
|
323
368
|
const settersByFunction = new Map()
|
|
324
369
|
const functions = new Map()
|
|
325
370
|
const jsxLocalDeclarations = new Map()
|
|
@@ -328,6 +373,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
328
373
|
const listLocalUses = new WeakMap()
|
|
329
374
|
const listValues = new WeakMap()
|
|
330
375
|
const listEventItems = new WeakMap()
|
|
376
|
+
const listConditions = new WeakMap()
|
|
331
377
|
let usesBehavior = false
|
|
332
378
|
let usesBinding = false
|
|
333
379
|
let usesConditional = false
|
|
@@ -431,6 +477,15 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
431
477
|
}
|
|
432
478
|
}
|
|
433
479
|
|
|
480
|
+
if (ts.isJsxExpression(node) && node.expression && listConditions.has(node.expression)) {
|
|
481
|
+
const entry = listConditions.get(node.expression)
|
|
482
|
+
return factory.updateJsxExpression(node, compileListConditional({
|
|
483
|
+
...entry,
|
|
484
|
+
truthy: ts.visitNode(entry.truthy, visitor),
|
|
485
|
+
falsy: ts.visitNode(entry.falsy, visitor)
|
|
486
|
+
}, factory, listExpressions, handlerUrl))
|
|
487
|
+
}
|
|
488
|
+
|
|
434
489
|
if (ts.isJsxExpression(node) && node.expression && listValues.has(node.expression)) {
|
|
435
490
|
return factory.updateJsxExpression(node, compileListValue(node.expression, listValues.get(node.expression), factory, listExpressions, handlerUrl))
|
|
436
491
|
}
|
|
@@ -443,7 +498,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
443
498
|
const listParts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
|
|
444
499
|
if (listParts) {
|
|
445
500
|
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
446
|
-
validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems)
|
|
501
|
+
validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems, listConditions)
|
|
447
502
|
usesBehavior = true
|
|
448
503
|
usesList = true
|
|
449
504
|
return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
|
|
@@ -466,6 +521,14 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
466
521
|
return factory.updateJsxExpression(node, compiled)
|
|
467
522
|
}
|
|
468
523
|
}
|
|
524
|
+
const setters = settersForNode(node, settersByFunction)
|
|
525
|
+
const usedStates = referencedStateNames(node.expression, setters)
|
|
526
|
+
const captures = captureNames(node.expression, node.expression, setters)
|
|
527
|
+
if ((usedStates.size || captures.size) && !ts.isIdentifier(node.expression) && !containsJsx(node.expression)) {
|
|
528
|
+
usesBehavior = true
|
|
529
|
+
usesBinding = true
|
|
530
|
+
return factory.updateJsxExpression(node, compileReactiveBinding(node.expression, setters, factory, context, reactiveBindings, handlerUrl))
|
|
531
|
+
}
|
|
469
532
|
}
|
|
470
533
|
|
|
471
534
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
|
|
@@ -483,7 +546,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
483
546
|
|
|
484
547
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
|
|
485
548
|
const setters = settersForNode(node, settersByFunction)
|
|
486
|
-
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node))
|
|
549
|
+
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node), importBindings, clientImports)
|
|
487
550
|
if (event) {
|
|
488
551
|
usesBehavior = true
|
|
489
552
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
|
|
@@ -507,6 +570,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
507
570
|
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listExpression"), factory.createIdentifier("__kListExpression")))
|
|
508
571
|
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listField"), factory.createIdentifier("__kListField")))
|
|
509
572
|
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
|
|
573
|
+
behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listConditional"), factory.createIdentifier("__kListConditional")))
|
|
510
574
|
}
|
|
511
575
|
if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
|
|
512
576
|
const behaviorImport = factory.createImportDeclaration(
|
|
@@ -536,7 +600,7 @@ function keyedListParts(expression, setters) {
|
|
|
536
600
|
return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
|
|
537
601
|
}
|
|
538
602
|
|
|
539
|
-
function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems) {
|
|
603
|
+
function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems, listConditions) {
|
|
540
604
|
const fail = (node, message) => {
|
|
541
605
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
542
606
|
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
@@ -545,6 +609,7 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
545
609
|
const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
|
|
546
610
|
if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
|
|
547
611
|
}
|
|
612
|
+
let conditionDepth = 0
|
|
548
613
|
const visit = node => {
|
|
549
614
|
if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
|
|
550
615
|
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
|
|
@@ -556,7 +621,18 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
556
621
|
}
|
|
557
622
|
if (ts.isJsxExpression(node) && node.expression) {
|
|
558
623
|
const expression = unwrapExpression(node.expression)
|
|
559
|
-
|
|
624
|
+
const condition = conditionalParts(expression)
|
|
625
|
+
if (condition && containsJsx(expression)) {
|
|
626
|
+
if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
|
|
627
|
+
if (!referencesIdentifier(condition.condition, parts.item)) fail(node, "Keyed list item conditions must read the item")
|
|
628
|
+
validateListExpression(condition.condition, parts.item, node, fail)
|
|
629
|
+
listConditions.set(node.expression, { ...condition, item: parts.item })
|
|
630
|
+
conditionDepth++
|
|
631
|
+
visit(condition.truthy)
|
|
632
|
+
visit(condition.falsy)
|
|
633
|
+
conditionDepth--
|
|
634
|
+
return
|
|
635
|
+
}
|
|
560
636
|
const field = directProperty(expression, parts.item)
|
|
561
637
|
const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
|
|
562
638
|
if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
|
|
@@ -645,6 +721,16 @@ function compileListExpression(read, expression, item, factory, listExpressions,
|
|
|
645
721
|
return factory.createCallExpression(factory.createIdentifier("__kListExpression"), undefined, [read, factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)])
|
|
646
722
|
}
|
|
647
723
|
|
|
724
|
+
function compileListConditional(entry, factory, listExpressions, handlerUrl) {
|
|
725
|
+
const exportName = `listExpression${listExpressions.length}`
|
|
726
|
+
listExpressions.push({ exportName, expression: entry.condition, item: entry.item })
|
|
727
|
+
const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), entry.condition)
|
|
728
|
+
const thunk = branch => factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), branch)
|
|
729
|
+
return factory.createCallExpression(factory.createIdentifier("__kListConditional"), undefined, [
|
|
730
|
+
factory.createStringLiteral(entry.kind), read, thunk(entry.truthy), thunk(entry.falsy), factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)
|
|
731
|
+
])
|
|
732
|
+
}
|
|
733
|
+
|
|
648
734
|
function compileListValue(expression, entry, factory, listExpressions, handlerUrl) {
|
|
649
735
|
const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), expression)
|
|
650
736
|
return entry.field
|
|
@@ -769,17 +855,20 @@ function factoryNull() {
|
|
|
769
855
|
return ts.factory.createNull()
|
|
770
856
|
}
|
|
771
857
|
|
|
772
|
-
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem) {
|
|
858
|
+
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem, importBindings, clientImports) {
|
|
773
859
|
if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
|
|
774
860
|
if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
|
|
775
861
|
|
|
776
862
|
const optimized = compileOptimizedEvent(expression, setters, factory)
|
|
777
863
|
if (optimized) return optimized
|
|
778
864
|
|
|
779
|
-
const
|
|
865
|
+
const allCaptures = nativeCaptureNames(expression, setters)
|
|
866
|
+
const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
|
|
867
|
+
const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
|
|
868
|
+
for (const entry of imports) clientImports.add(entry.target)
|
|
780
869
|
const usedStates = nativeStateNames(expression, setters)
|
|
781
870
|
const exportName = `handler${nativeHandlers.length}`
|
|
782
|
-
nativeHandlers.push({ exportName, expression, captures, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
|
|
871
|
+
nativeHandlers.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
|
|
783
872
|
const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
784
873
|
factory.createStringLiteral(name),
|
|
785
874
|
factory.createIdentifier(name)
|
|
@@ -830,6 +919,16 @@ function nativeCaptureNames(expression, setters) {
|
|
|
830
919
|
return captureNames(expression, expression.body, setters)
|
|
831
920
|
}
|
|
832
921
|
|
|
922
|
+
function referencedImportedBindings(expression, imports) {
|
|
923
|
+
const names = new Set()
|
|
924
|
+
const visit = node => {
|
|
925
|
+
if (ts.isIdentifier(node) && imports.has(node.text) && isReferenceIdentifier(node)) names.add(node.text)
|
|
926
|
+
ts.forEachChild(node, visit)
|
|
927
|
+
}
|
|
928
|
+
visit(expression.body)
|
|
929
|
+
return names
|
|
930
|
+
}
|
|
931
|
+
|
|
833
932
|
function captureNames(declarationRoot, referenceRoot, setters) {
|
|
834
933
|
const local = new Set()
|
|
835
934
|
const collectDeclarations = node => {
|
|
@@ -896,6 +995,135 @@ function settersForNode(node, settersByFunction) {
|
|
|
896
995
|
return new Map()
|
|
897
996
|
}
|
|
898
997
|
|
|
998
|
+
function clientImportBindings(sourceFile, file, sourceFiles) {
|
|
999
|
+
const bindings = new Map()
|
|
1000
|
+
for (const node of sourceFile.statements) {
|
|
1001
|
+
if (!ts.isImportDeclaration(node) || !node.importClause || node.importClause.isTypeOnly || !ts.isStringLiteral(node.moduleSpecifier) || !node.moduleSpecifier.text.startsWith(".")) continue
|
|
1002
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
1003
|
+
if (node.importClause.name) bindings.set(node.importClause.name.text, { kind: "default", local: node.importClause.name.text, target })
|
|
1004
|
+
const named = node.importClause.namedBindings
|
|
1005
|
+
if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { kind: "namespace", local: named.name.text, target })
|
|
1006
|
+
if (named && ts.isNamedImports(named)) {
|
|
1007
|
+
for (const entry of named.elements) {
|
|
1008
|
+
if (!entry.isTypeOnly) bindings.set(entry.name.text, { kind: "named", imported: (entry.propertyName ?? entry.name).text, local: entry.name.text, target })
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return bindings
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function printClientImports(entries, handlerPath) {
|
|
1016
|
+
const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
|
|
1017
|
+
const groups = Map.groupBy(unique.values(), entry => entry.target)
|
|
1018
|
+
const imports = []
|
|
1019
|
+
for (const [target, group] of groups) {
|
|
1020
|
+
const specifier = relativeModulePath(handlerPath, clientModulePath(target))
|
|
1021
|
+
const defaults = group.filter(entry => entry.kind === "default")
|
|
1022
|
+
const named = group.filter(entry => entry.kind === "named")
|
|
1023
|
+
if (defaults.length === 1 || named.length) imports.push(`import ${defaults.length === 1 ? `${defaults[0].local}${named.length ? ", " : ""}` : ""}${named.length ? `{ ${named.map(entry => entry.imported === entry.local ? entry.local : `${entry.imported} as ${entry.local}`).join(", ")} }` : ""} from ${JSON.stringify(specifier)}`)
|
|
1024
|
+
if (defaults.length > 1) for (const entry of defaults) imports.push(`import ${entry.local} from ${JSON.stringify(specifier)}`)
|
|
1025
|
+
for (const entry of group.filter(entry => entry.kind === "namespace")) imports.push(`import * as ${entry.local} from ${JSON.stringify(specifier)}`)
|
|
1026
|
+
}
|
|
1027
|
+
return imports.join("\n")
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function collectClientModules(entries, sourceFiles) {
|
|
1031
|
+
const modules = new Set()
|
|
1032
|
+
const queue = [...new Set(entries)]
|
|
1033
|
+
while (queue.length) {
|
|
1034
|
+
const file = queue.shift()
|
|
1035
|
+
if (modules.has(file)) continue
|
|
1036
|
+
const source = await readFile(file, "utf8")
|
|
1037
|
+
const sourceFile = parseSourceFile(file, source)
|
|
1038
|
+
if (containsJsx(sourceFile)) throw new Error(`${relative(root, file)} Imported client helpers must not contain JSX`)
|
|
1039
|
+
rejectUnsupportedClientImports(sourceFile, file)
|
|
1040
|
+
modules.add(file)
|
|
1041
|
+
for (const node of sourceFile.statements) {
|
|
1042
|
+
if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier) || !runtimeModuleReference(node)) continue
|
|
1043
|
+
if (!node.moduleSpecifier.text.startsWith(".")) throw new Error(`${relative(root, file)} Imported client helpers may only use relative runtime imports`)
|
|
1044
|
+
queue.push(resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles))
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const outputs = new Map()
|
|
1048
|
+
for (const file of modules) {
|
|
1049
|
+
const output = clientModulePath(file)
|
|
1050
|
+
if (outputs.has(output)) throw new Error(`${relative(root, file)} and ${relative(root, outputs.get(output))} emit the same client module path`)
|
|
1051
|
+
outputs.set(output, file)
|
|
1052
|
+
}
|
|
1053
|
+
return [...modules].sort()
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function compileClientModule(file, sourceFiles) {
|
|
1057
|
+
const source = await readFile(file, "utf8")
|
|
1058
|
+
const transformer = context => sourceFile => {
|
|
1059
|
+
const factory = context.factory
|
|
1060
|
+
const visitor = node => {
|
|
1061
|
+
if (ts.isImportDeclaration(node) && runtimeModuleReference(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
1062
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
1063
|
+
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(clientModulePath(file), clientModulePath(target))), node.attributes)
|
|
1064
|
+
}
|
|
1065
|
+
if (ts.isExportDeclaration(node) && runtimeModuleReference(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
1066
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
1067
|
+
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(clientModulePath(file), clientModulePath(target))), node.attributes)
|
|
1068
|
+
}
|
|
1069
|
+
return ts.visitEachChild(node, visitor, context)
|
|
1070
|
+
}
|
|
1071
|
+
return ts.visitNode(sourceFile, visitor)
|
|
1072
|
+
}
|
|
1073
|
+
const result = ts.transpileModule(source, {
|
|
1074
|
+
fileName: file,
|
|
1075
|
+
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
|
|
1076
|
+
transformers: { before: [transformer] },
|
|
1077
|
+
reportDiagnostics: true
|
|
1078
|
+
})
|
|
1079
|
+
const errors = result.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
1080
|
+
if (errors.length) throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
1081
|
+
return result.outputText
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function resolveSourceImport(importer, specifier, sourceFiles) {
|
|
1085
|
+
const base = resolve(dirname(importer), specifier)
|
|
1086
|
+
const extension = extname(base)
|
|
1087
|
+
const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
|
|
1088
|
+
const candidates = extension === ".ts" || extension === ".tsx"
|
|
1089
|
+
? [base]
|
|
1090
|
+
: [`${stem}.ts`, `${stem}.tsx`]
|
|
1091
|
+
const matches = candidates.filter(candidate => sourceFiles.has(candidate))
|
|
1092
|
+
if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
|
|
1093
|
+
return matches[0]
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function runtimeModuleReference(node) {
|
|
1097
|
+
if (ts.isExportDeclaration(node)) return !node.isTypeOnly && (!node.exportClause || !ts.isNamedExports(node.exportClause) || node.exportClause.elements.some(entry => !entry.isTypeOnly))
|
|
1098
|
+
const clause = node.importClause
|
|
1099
|
+
if (!clause) return true
|
|
1100
|
+
if (clause.isTypeOnly) return false
|
|
1101
|
+
if (clause.name || clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) return true
|
|
1102
|
+
return clause.namedBindings?.elements.some(entry => !entry.isTypeOnly) ?? false
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function rejectUnsupportedClientImports(sourceFile, file) {
|
|
1106
|
+
const visit = node => {
|
|
1107
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) throw new Error(`${relative(root, file)} Dynamic imports are not supported in imported client helpers`)
|
|
1108
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") throw new Error(`${relative(root, file)} require() is not supported in imported client helpers`)
|
|
1109
|
+
ts.forEachChild(node, visit)
|
|
1110
|
+
}
|
|
1111
|
+
visit(sourceFile)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function parseSourceFile(file, source) {
|
|
1115
|
+
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function clientModulePath(file) {
|
|
1119
|
+
return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function relativeModulePath(from, to) {
|
|
1123
|
+
const path = relative(dirname(from), to).replaceAll(sep, "/")
|
|
1124
|
+
return path.startsWith(".") ? path : `./${path}`
|
|
1125
|
+
}
|
|
1126
|
+
|
|
899
1127
|
function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
900
1128
|
const factory = ts.factory
|
|
901
1129
|
const stateNames = new Set(setters.values())
|
package/framework/core.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export function list(items: unknown, keyField: string, render: (item: unknown) =
|
|
|
24
24
|
export function listField(read: () => unknown, field: string): unknown
|
|
25
25
|
export function listExpression(read: () => unknown, module: string, handler: string): unknown
|
|
26
26
|
export function listItem(): unknown
|
|
27
|
+
export function listConditional(kind: "and" | "ternary", read: () => unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string): unknown
|
|
27
28
|
|
|
28
29
|
export function renderPage(
|
|
29
30
|
component: (props: Record<string, never>) => unknown | Promise<unknown>,
|
package/framework/core.mjs
CHANGED
|
@@ -9,6 +9,7 @@ const listMarker = Symbol("kudzu.list")
|
|
|
9
9
|
const listFieldMarker = Symbol("kudzu.listField")
|
|
10
10
|
const listExpressionMarker = Symbol("kudzu.listExpression")
|
|
11
11
|
const listItemMarker = Symbol("kudzu.listItem")
|
|
12
|
+
const listConditionalMarker = Symbol("kudzu.listConditional")
|
|
12
13
|
const refMarker = Symbol("kudzu.ref")
|
|
13
14
|
const contextMarker = Symbol("kudzu.context")
|
|
14
15
|
const contextProviderMarker = Symbol("kudzu.contextProvider")
|
|
@@ -123,6 +124,10 @@ export function listItem() {
|
|
|
123
124
|
return { [listItemMarker]: true }
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
export function listConditional(kind, read, truthy, falsy, module, handler) {
|
|
128
|
+
return { [listConditionalMarker]: true, kind, value: renderContext?.listTemplate ? undefined : read(), truthy, falsy, module, handler }
|
|
129
|
+
}
|
|
130
|
+
|
|
126
131
|
function validListKey(key) {
|
|
127
132
|
return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
|
|
128
133
|
}
|
|
@@ -216,7 +221,7 @@ function serializeCapture(name, value, seen) {
|
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
export async function renderPage(component, metadata = {}) {
|
|
219
|
-
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
224
|
+
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: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
220
225
|
|
|
221
226
|
try {
|
|
222
227
|
const body = await renderNode({ type: component, props: {} })
|
|
@@ -348,15 +353,44 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
348
353
|
}
|
|
349
354
|
if (node?.[listMarker]) return renderList(node, namespace, selectValue)
|
|
350
355
|
if (node?.[listFieldMarker]) {
|
|
351
|
-
if (renderContext.listTemplate) renderContext.listFields?.add(node.field)
|
|
352
|
-
const marker = renderContext.listTemplate ? ` data-k-list-text="${escapeAttribute(node.field)}"` : ""
|
|
356
|
+
if (renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) renderContext.listFields?.add(node.field)
|
|
357
|
+
const marker = renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch ? ` data-k-list-text="${escapeAttribute(node.field)}"` : ""
|
|
353
358
|
return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
|
|
354
359
|
}
|
|
355
360
|
if (node?.[listExpressionMarker]) {
|
|
356
361
|
const descriptor = { module: node.module, handler: node.handler }
|
|
357
|
-
const marker = renderContext.listTemplate ? ` data-k-list-expression='${escapeJsonAttribute(descriptor)}'` : ""
|
|
362
|
+
const marker = renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch ? ` data-k-list-expression='${escapeJsonAttribute(descriptor)}'` : ""
|
|
358
363
|
return `<template${marker}></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
|
|
359
364
|
}
|
|
365
|
+
if (node?.[bindingMarker]) {
|
|
366
|
+
const descriptor = bindingDescriptor(node)
|
|
367
|
+
const reactive = Object.keys(node.states).length > 0 || Object.keys(node.scopeStates).length > 0 || Object.keys(node.scopeBindings).length > 0
|
|
368
|
+
if (!reactive) return renderNode(node.value, namespace, selectValue)
|
|
369
|
+
renderContext.bindings.push({ target: "text", ...descriptor })
|
|
370
|
+
if (renderContext.conditionDepth || renderContext.listDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
|
|
371
|
+
renderContext.hasBehaviors = true
|
|
372
|
+
renderContext.hasBindings = true
|
|
373
|
+
return `<span data-k-bind-text='${escapeJsonAttribute(descriptor)}'>${escapeHtml(node.value ?? "")}</span>`
|
|
374
|
+
}
|
|
375
|
+
if (node?.[listConditionalMarker]) {
|
|
376
|
+
const descriptor = { kind: node.kind, module: node.module, handler: node.handler }
|
|
377
|
+
const previousBranch = renderContext.listConditionalBranch
|
|
378
|
+
renderContext.listConditionalBranch = true
|
|
379
|
+
let truthy
|
|
380
|
+
let falsy
|
|
381
|
+
try {
|
|
382
|
+
truthy = await renderNode(node.truthy(), namespace, selectValue)
|
|
383
|
+
falsy = await renderNode(node.falsy(), namespace, selectValue)
|
|
384
|
+
} finally {
|
|
385
|
+
renderContext.listConditionalBranch = previousBranch
|
|
386
|
+
}
|
|
387
|
+
const key = conditionKey(node.kind, node.value)
|
|
388
|
+
const current = renderContext.listTemplate
|
|
389
|
+
? ""
|
|
390
|
+
: node.kind === "and" && !node.value ? escapeHtml(renderFalsy(node.value)) : node.value ? truthy : falsy
|
|
391
|
+
const initial = renderContext.listTemplate ? "" : ` data-k-list-current="${escapeAttribute(key)}"`
|
|
392
|
+
return `<template data-k-list-condition='${escapeJsonAttribute(descriptor)}'${initial}><template data-k-list-true>${truthy}</template><template data-k-list-false>${falsy}</template></template>${current}<template data-k-list-condition-end></template>`
|
|
393
|
+
}
|
|
360
394
|
if (!node || typeof node !== "object" || !("type" in node)) {
|
|
361
395
|
throw new Error(`Cannot render ${String(node)}`)
|
|
362
396
|
}
|
|
@@ -459,10 +493,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
459
493
|
}
|
|
460
494
|
|
|
461
495
|
if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
|
|
462
|
-
if (renderContext.listTemplate && listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
|
|
463
|
-
if (renderContext.listTemplate && listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
|
|
464
|
-
if (renderContext.listTemplate && listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
|
|
465
|
-
if (renderContext.listTemplate && directListText) {
|
|
496
|
+
if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
|
|
497
|
+
if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
|
|
498
|
+
if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
|
|
499
|
+
if ((renderContext.listTemplate || renderContext.listInitialMarkers || renderContext.listConditionalBranch) && directListText) {
|
|
466
500
|
renderContext.listFields?.add(directListText.field)
|
|
467
501
|
attributes += ` data-k-list-text="${escapeAttribute(directListText.field)}"`
|
|
468
502
|
}
|
|
@@ -487,10 +521,12 @@ async function renderList(node, namespace, selectValue) {
|
|
|
487
521
|
renderContext.listRoot = { id, template: true }
|
|
488
522
|
const template = await renderNode(node.render({}), namespace, selectValue)
|
|
489
523
|
if (template.includes("data-k-native-")) descriptor.mount = true
|
|
524
|
+
if (template.includes("data-k-list-condition")) descriptor.conditions = true
|
|
490
525
|
const seed = listSeed(node.items.value, renderContext.listFields)
|
|
491
526
|
if (seed) descriptor.seed = seed
|
|
492
527
|
let current = ""
|
|
493
528
|
renderContext.listTemplate = false
|
|
529
|
+
renderContext.listInitialMarkers = Boolean(descriptor.conditions)
|
|
494
530
|
for (const item of node.items.value) {
|
|
495
531
|
renderContext.listRoot = { id, key: item[node.keyField], template: false }
|
|
496
532
|
current += await renderNode(node.render(item), namespace, selectValue)
|
|
@@ -502,6 +538,7 @@ async function renderList(node, namespace, selectValue) {
|
|
|
502
538
|
} finally {
|
|
503
539
|
renderContext.listRoot = undefined
|
|
504
540
|
renderContext.listTemplate = false
|
|
541
|
+
renderContext.listInitialMarkers = false
|
|
505
542
|
renderContext.listFields = previousListFields
|
|
506
543
|
renderContext.listDepth--
|
|
507
544
|
}
|
|
@@ -512,6 +549,14 @@ function optionValue(props) {
|
|
|
512
549
|
return Array.isArray(props.children) ? props.children.join("") : props.children ?? ""
|
|
513
550
|
}
|
|
514
551
|
|
|
552
|
+
function conditionKey(kind, value) {
|
|
553
|
+
return value ? "true" : kind === "and" ? `false:${renderFalsy(value)}` : "false"
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function renderFalsy(value) {
|
|
557
|
+
return value === false || value == null || value === true ? "" : String(value)
|
|
558
|
+
}
|
|
559
|
+
|
|
515
560
|
function reactiveStateIds(descriptor) {
|
|
516
561
|
if (descriptor.state) return new Set([descriptor.state])
|
|
517
562
|
return new Set([
|
|
@@ -6,7 +6,8 @@ const mountedLists = new WeakSet()
|
|
|
6
6
|
const imports = new Map()
|
|
7
7
|
const revisions = new WeakMap()
|
|
8
8
|
const itemParts = new WeakMap()
|
|
9
|
-
const
|
|
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]" : ""}`
|
|
10
11
|
|
|
11
12
|
function commitLists(id) {
|
|
12
13
|
const lists = listTargets.get(id)
|
|
@@ -32,7 +33,7 @@ function mountLists(root) {
|
|
|
32
33
|
const roots = listRoots(start, end)
|
|
33
34
|
const templateRoot = start.content.firstElementChild
|
|
34
35
|
const parts = listItemPartPlan(templateRoot)
|
|
35
|
-
for (const root of roots) mapListItemParts(parts, root)
|
|
36
|
+
for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
|
|
36
37
|
if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
|
|
37
38
|
const items = browserState.get(descriptor.state)
|
|
38
39
|
const list = {
|
|
@@ -144,6 +145,10 @@ function fillListItem(root, item) {
|
|
|
144
145
|
const revision = (revisions.get(root) ?? 0) + 1
|
|
145
146
|
revisions.set(root, revision)
|
|
146
147
|
const parts = listItemParts(root)
|
|
148
|
+
fillListParts(root, parts, item, revision)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fillListParts(root, parts, item, revision) {
|
|
147
152
|
for (const [node, field] of parts.directTexts) {
|
|
148
153
|
const text = item?.[field]
|
|
149
154
|
const value = text == null ? "" : String(text)
|
|
@@ -178,18 +183,29 @@ function fillListItem(root, item) {
|
|
|
178
183
|
}).catch(error => console.error(error))
|
|
179
184
|
}
|
|
180
185
|
}
|
|
186
|
+
if (__KUDZU_LIST_CONDITIONS__) {
|
|
187
|
+
for (const [marker, descriptor] of parts.conditions) {
|
|
188
|
+
evaluate(descriptor, item).then(value => {
|
|
189
|
+
if (revisions.get(root) === revision && root.isConnected) updateListCondition(marker, descriptor.kind, value, item)
|
|
190
|
+
}).catch(error => console.error(error))
|
|
191
|
+
}
|
|
192
|
+
}
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
function listItemParts(root) {
|
|
184
196
|
let parts = itemParts.get(root)
|
|
185
197
|
if (parts) return parts
|
|
186
|
-
parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [] }
|
|
198
|
+
parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
|
|
187
199
|
for (const node of matching(root, itemPartsSelector)) {
|
|
188
200
|
if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
|
|
189
201
|
if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
|
|
190
202
|
if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
|
|
191
203
|
if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
192
204
|
if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
205
|
+
if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
|
|
206
|
+
parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
|
|
207
|
+
conditionOwners.set(node, root)
|
|
208
|
+
}
|
|
193
209
|
}
|
|
194
210
|
itemParts.set(root, parts)
|
|
195
211
|
return parts
|
|
@@ -205,7 +221,8 @@ function listItemPartPlan(template) {
|
|
|
205
221
|
attributes: parts.attributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
206
222
|
events: parts.events.map(([node, events]) => [indexes.get(node), events]),
|
|
207
223
|
expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
|
|
208
|
-
expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes])
|
|
224
|
+
expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
225
|
+
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
|
|
209
226
|
}
|
|
210
227
|
}
|
|
211
228
|
|
|
@@ -217,10 +234,50 @@ function mapListItemParts(parts, root) {
|
|
|
217
234
|
attributes: parts.attributes.map(([index, attributes]) => [target[index], attributes]),
|
|
218
235
|
events: parts.events.map(([index, events]) => [target[index], events]),
|
|
219
236
|
expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
|
|
220
|
-
expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes])
|
|
237
|
+
expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
|
|
238
|
+
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
|
|
239
|
+
conditionOwners.set(target[index], root)
|
|
240
|
+
return [target[index], descriptor]
|
|
241
|
+
}) : []
|
|
221
242
|
})
|
|
222
243
|
}
|
|
223
244
|
|
|
245
|
+
function updateListCondition(marker, kind, value, item) {
|
|
246
|
+
const current = listConditionKey(kind, value)
|
|
247
|
+
if (marker.dataset.kListCurrent === current) return
|
|
248
|
+
let end = marker.nextSibling
|
|
249
|
+
while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches("template[data-k-list-condition-end]"))) end = end.nextSibling
|
|
250
|
+
if (!end) throw new Error("Keyed list condition marker has no end")
|
|
251
|
+
for (let node = marker.nextSibling; node && node !== end;) {
|
|
252
|
+
const next = node.nextSibling
|
|
253
|
+
unmountDom(node)
|
|
254
|
+
node.remove()
|
|
255
|
+
node = next
|
|
256
|
+
}
|
|
257
|
+
const falseText = kind === "and" && !value ? renderFalsy(value) : ""
|
|
258
|
+
const fragment = falseText
|
|
259
|
+
? marker.ownerDocument.createDocumentFragment()
|
|
260
|
+
: marker.content.querySelector(value ? "template[data-k-list-true]" : "template[data-k-list-false]").content.cloneNode(true)
|
|
261
|
+
if (falseText) fragment.append(marker.ownerDocument.createTextNode(falseText))
|
|
262
|
+
const nodes = [...fragment.childNodes]
|
|
263
|
+
const revision = (revisions.get(marker) ?? 0) + 1
|
|
264
|
+
revisions.set(marker, revision)
|
|
265
|
+
fillListParts(marker, listItemParts(fragment), item, revision)
|
|
266
|
+
end.parentNode.insertBefore(fragment, end)
|
|
267
|
+
marker.dataset.kListCurrent = current
|
|
268
|
+
const owner = conditionOwners.get(marker)
|
|
269
|
+
if (owner) itemParts.delete(owner)
|
|
270
|
+
for (const node of nodes) mountDom(node)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function listConditionKey(kind, value) {
|
|
274
|
+
return value ? "true" : kind === "and" ? `false:${renderFalsy(value)}` : "false"
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderFalsy(value) {
|
|
278
|
+
return value === false || value == null || value === true ? "" : String(value)
|
|
279
|
+
}
|
|
280
|
+
|
|
224
281
|
function listRoots(start, end) {
|
|
225
282
|
const roots = []
|
|
226
283
|
for (let node = start.nextSibling; node && node !== end; node = node.nextSibling) {
|