@kudzujs/core 0.4.13 → 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 +34 -11
- package/framework/README.md +1 -1
- package/framework/build.mjs +199 -12
- package/framework/list-runtime.js +12 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -247,7 +247,18 @@ async function load() {
|
|
|
247
247
|
}
|
|
248
248
|
```
|
|
249
249
|
|
|
250
|
-
|
|
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.
|
|
251
262
|
|
|
252
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.
|
|
253
264
|
|
|
@@ -285,6 +296,7 @@ Supported:
|
|
|
285
296
|
- Build-time async components
|
|
286
297
|
- Primitive `useState` bindings
|
|
287
298
|
- Synchronous and async event handlers
|
|
299
|
+
- Relative imported helpers in native handlers
|
|
288
300
|
- Serializable component-local captures
|
|
289
301
|
- Direct text DOM patches
|
|
290
302
|
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
@@ -300,7 +312,7 @@ Not implemented yet:
|
|
|
300
312
|
|
|
301
313
|
- Block-scoped JSX locals and reusable keyed-list aliases
|
|
302
314
|
- Server actions and request-time SSR
|
|
303
|
-
-
|
|
315
|
+
- React package islands
|
|
304
316
|
- HMR and framework DevTools
|
|
305
317
|
|
|
306
318
|
## Benchmarks
|
|
@@ -323,6 +335,17 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
323
335
|
|
|
324
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.
|
|
325
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
|
+
|
|
326
349
|
### Static Journal Page
|
|
327
350
|
|
|
328
351
|
Same content and CSS across every fixture:
|
|
@@ -343,15 +366,15 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
|
|
|
343
366
|
|
|
344
367
|
| Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
|
|
345
368
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
346
|
-
| Astro | Yes | **324 B** | **43.6 KB** |
|
|
347
|
-
| Kudzu | Yes | 5.
|
|
348
|
-
| Next.js | Yes | 182.2 KB | 695.2 KB |
|
|
349
|
-
|
|
|
350
|
-
|
|
|
351
|
-
| Svelte CSR | No | 12.9 KB | 33.1 KB |
|
|
352
|
-
| Qwik CSR | No | 22.2 KB | 64.1 KB |
|
|
353
|
-
|
|
354
|
-
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
|
|
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.
|
|
355
378
|
|
|
356
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.
|
|
357
380
|
|
package/framework/README.md
CHANGED
|
@@ -12,6 +12,6 @@
|
|
|
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.
|
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()
|
|
@@ -501,7 +546,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
501
546
|
|
|
502
547
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
|
|
503
548
|
const setters = settersForNode(node, settersByFunction)
|
|
504
|
-
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)
|
|
505
550
|
if (event) {
|
|
506
551
|
usesBehavior = true
|
|
507
552
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
|
|
@@ -810,17 +855,20 @@ function factoryNull() {
|
|
|
810
855
|
return ts.factory.createNull()
|
|
811
856
|
}
|
|
812
857
|
|
|
813
|
-
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem) {
|
|
858
|
+
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem, importBindings, clientImports) {
|
|
814
859
|
if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
|
|
815
860
|
if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
|
|
816
861
|
|
|
817
862
|
const optimized = compileOptimizedEvent(expression, setters, factory)
|
|
818
863
|
if (optimized) return optimized
|
|
819
864
|
|
|
820
|
-
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)
|
|
821
869
|
const usedStates = nativeStateNames(expression, setters)
|
|
822
870
|
const exportName = `handler${nativeHandlers.length}`
|
|
823
|
-
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))) })
|
|
824
872
|
const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
825
873
|
factory.createStringLiteral(name),
|
|
826
874
|
factory.createIdentifier(name)
|
|
@@ -871,6 +919,16 @@ function nativeCaptureNames(expression, setters) {
|
|
|
871
919
|
return captureNames(expression, expression.body, setters)
|
|
872
920
|
}
|
|
873
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
|
+
|
|
874
932
|
function captureNames(declarationRoot, referenceRoot, setters) {
|
|
875
933
|
const local = new Set()
|
|
876
934
|
const collectDeclarations = node => {
|
|
@@ -937,6 +995,135 @@ function settersForNode(node, settersByFunction) {
|
|
|
937
995
|
return new Map()
|
|
938
996
|
}
|
|
939
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
|
+
|
|
940
1127
|
function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
941
1128
|
const factory = ts.factory
|
|
942
1129
|
const stateNames = new Set(setters.values())
|
|
@@ -7,7 +7,7 @@ const imports = new Map()
|
|
|
7
7
|
const revisions = new WeakMap()
|
|
8
8
|
const itemParts = new WeakMap()
|
|
9
9
|
const conditionOwners = new WeakMap()
|
|
10
|
-
const itemPartsSelector =
|
|
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]" : ""}`
|
|
11
11
|
|
|
12
12
|
function commitLists(id) {
|
|
13
13
|
const lists = listTargets.get(id)
|
|
@@ -33,7 +33,7 @@ function mountLists(root) {
|
|
|
33
33
|
const roots = listRoots(start, end)
|
|
34
34
|
const templateRoot = start.content.firstElementChild
|
|
35
35
|
const parts = listItemPartPlan(templateRoot)
|
|
36
|
-
for (const root of roots) descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
|
|
36
|
+
for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
|
|
37
37
|
if (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 = {
|
|
@@ -183,10 +183,12 @@ function fillListParts(root, parts, item, revision) {
|
|
|
183
183
|
}).catch(error => console.error(error))
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
|
|
@@ -200,7 +202,7 @@ function listItemParts(root) {
|
|
|
200
202
|
if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
|
|
201
203
|
if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
202
204
|
if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
203
|
-
if (node.hasAttribute("data-k-list-condition")) {
|
|
205
|
+
if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
|
|
204
206
|
parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
|
|
205
207
|
conditionOwners.set(node, root)
|
|
206
208
|
}
|
|
@@ -220,7 +222,7 @@ function listItemPartPlan(template) {
|
|
|
220
222
|
events: parts.events.map(([node, events]) => [indexes.get(node), events]),
|
|
221
223
|
expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
|
|
222
224
|
expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
223
|
-
conditions: parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor])
|
|
225
|
+
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
|
|
224
226
|
}
|
|
225
227
|
}
|
|
226
228
|
|
|
@@ -233,10 +235,10 @@ function mapListItemParts(parts, root) {
|
|
|
233
235
|
events: parts.events.map(([index, events]) => [target[index], events]),
|
|
234
236
|
expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
|
|
235
237
|
expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
|
|
236
|
-
conditions: parts.conditions.map(([index, descriptor]) => {
|
|
238
|
+
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
|
|
237
239
|
conditionOwners.set(target[index], root)
|
|
238
240
|
return [target[index], descriptor]
|
|
239
|
-
})
|
|
241
|
+
}) : []
|
|
240
242
|
})
|
|
241
243
|
}
|
|
242
244
|
|