@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 CHANGED
@@ -247,7 +247,18 @@ async function load() {
247
247
  }
248
248
  ```
249
249
 
250
- Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
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
- - Imported client helpers and React package islands
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** | 826 ms | **4.1 ms** | **3.7 ms** | **1.4 ms** | **3.3 ms** | **12.5 ms** |
347
- | Kudzu | Yes | 5.4 KB | 61.6 KB | **448 ms** | 8.6 ms | 8.3 ms | 2.4 ms | 8.6 ms | 27.9 ms |
348
- | Next.js | Yes | 182.2 KB | 695.2 KB | 3002 ms | 7.5 ms | 12.3 ms | 4.1 ms | 7.8 ms | 31.7 ms |
349
- | Vue CSR | No | 24.3 KB | 61.3 KB | 765 ms | 11.4 ms | 9.8 ms | 4.4 ms | 7.0 ms | 32.6 ms |
350
- | React CSR | No | 59.3 KB | 189.4 KB | 1039 ms | 9.9 ms | 13.4 ms | 4.7 ms | 6.1 ms | 34.1 ms |
351
- | Svelte CSR | No | 12.9 KB | 33.1 KB | 845 ms | 6.2 ms | 42.9 ms | 4.6 ms | 6.2 ms | 59.9 ms |
352
- | Qwik CSR | No | 22.2 KB | 64.1 KB | 630 ms | 10.7 ms | 27.5 ms | 39.2 ms | 22.2 ms | 99.6 ms |
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 27.9 ms, 15.4 ms behind the hand-authored Astro baseline and 6.2 ms ahead of React across all four operations.
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
 
@@ -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/`. The dev server derives stable state identities from route-unique 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.
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.
@@ -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 writeJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify)
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
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 captures = nativeCaptureNames(expression, setters)
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 = "[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs],[data-k-list-condition]"
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
- for (const [marker, descriptor] of parts.conditions) {
187
- evaluate(descriptor, item).then(value => {
188
- if (revisions.get(root) === revision && root.isConnected) updateListCondition(marker, descriptor.kind, value, item)
189
- }).catch(error => console.error(error))
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",