@kudzujs/core 0.6.10 → 0.6.12

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
@@ -180,7 +180,17 @@ const [todos, dispatch] = useReducer(todoReducer, [])
180
180
  dispatch({ type: "add", title: "Ship" })
181
181
  ```
182
182
 
183
- The reduced migration form requires `[state, dispatch]`, exactly two hook arguments, and a synchronous two-parameter reducer exported as a default or named value from a relative TypeScript module. Dispatches in compiled handlers lower to functional state updates; lazy initializers, package, namespace, local, async, and generator reducers are not supported. Reducer dispatch functions cannot be passed through props or context.
183
+ The reduced migration form requires `[state, dispatch]`, exactly two hook arguments, and a synchronous two-parameter reducer exported as a default or named value from a relative TypeScript module. Dispatches in compiled handlers lower to functional state updates. A dispatch may cross one direct prop boundary into a same-file or relative-imported synchronous component whose intrinsic root contains the compiled handler:
184
+
185
+ ```tsx
186
+ function Controls({ dispatch }: { dispatch: Dispatch<TodoAction> }) {
187
+ return <button onClick={() => dispatch({ type: "add", title: "Ship" })}>Add</button>
188
+ }
189
+
190
+ return <Controls dispatch={dispatch} />
191
+ ```
192
+
193
+ Kudzu specializes that call at build time; no function prop or child component survives in the browser. Lazy initializers, package, namespace, local, async, and generator reducers, runtime imports in imported dispatch components, forwarding across another component, and reducer dispatch through context remain unsupported.
184
194
 
185
195
  ## Reactive Attributes
186
196
 
@@ -208,6 +218,14 @@ Regular attributes use the same expressions without an allowlist:
208
218
 
209
219
  Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property. `aria-*` and `data-*` boolean values serialize as `"true"` or `"false"`; ordinary false values remove the attribute. Object `style` values use React-shaped camelCase properties, add `px` to nonzero dimensional numbers, and preserve unitless properties and CSS custom properties. Reactive `dangerouslySetInnerHTML` remains unsupported.
210
220
 
221
+ Inline SVG accepts React-shaped presentation props for static and reactive values. Kudzu preserves native camelCase SVG names such as `viewBox` while mapping common aliases such as `fillRule`, `clipRule`, `strokeWidth`, `strokeLinecap`, `strokeLinejoin`, opacity/color props, `textAnchor`, and `vectorEffect` to their SVG attribute names:
222
+
223
+ ```tsx
224
+ <svg viewBox="0 0 24 24">
225
+ <path fillRule="evenodd" strokeWidth={active ? 2 : 1} strokeLinecap="round" />
226
+ </svg>
227
+ ```
228
+
211
229
  ## DOM Refs
212
230
 
213
231
  Use an object ref to access an element from a normal event handler:
@@ -544,6 +562,7 @@ Supported:
544
562
  - Direct text DOM patches
545
563
  - Reactive standard, `aria-*`, and `data-*` attributes
546
564
  - Reactive object `style` attributes
565
+ - Static and reactive React-shaped SVG presentation attributes
547
566
  - Object DOM refs in native event handlers
548
567
  - Default, nested, and reactive context providers
549
568
  - Controlled `value` and `checked` form properties
@@ -21,9 +21,11 @@ Exact relative `.worker.ts` constructors in inline effects are validated and bun
21
21
 
22
22
  Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
23
23
 
24
+ Inline SVG rendering normalizes an explicit set of common React presentation aliases before static serialization and binding descriptor creation. Reactive aliases therefore use the existing generic `setAttribute` path; static SVG adds no JavaScript and reactive SVG adds no SVG-specific runtime.
25
+
24
26
  Same-file and relative-imported components receiving a direct local-state array prop are specialized to intrinsic JSX before keyed-list analysis, so their component function is not retained in the browser. Handler modules are emitted only when a rendered descriptor references them, preventing specialized imported components from adding dead browser assets. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
25
27
 
26
- The reduced `useReducer` form reuses ordinary state slots. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
28
+ The reduced `useReducer` form reuses ordinary state slots. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. One direct dispatch prop into a same-file or relative-imported synchronous component is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
27
29
 
28
30
  `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
29
31
 
@@ -1774,6 +1774,43 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1774
1774
  stateBackedComponentRoots.push(specialization.root)
1775
1775
  }
1776
1776
  }
1777
+ for (const [name, component] of components) {
1778
+ const calls = jsxTagUses(sourceFile, name)
1779
+ const dispatchCalls = calls.filter(call => jsxCallHasDirectReducerProp(call, reducersForNode(call, reducersByFunction)))
1780
+ if (!dispatchCalls.length) continue
1781
+ if (isExportedDeclaration(component.declaration)) fail(component.declaration, `Reducer-dispatch component ${name} cannot be exported`)
1782
+ if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Reducer-dispatch component ${name} may only be referenced as JSX`)
1783
+ if (dispatchCalls.length !== calls.length) fail(component.declaration, `Reducer-dispatch component ${name} must receive a direct local reducer dispatch at every call`)
1784
+ for (const call of dispatchCalls) {
1785
+ if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
1786
+ const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail, "Reducer-dispatch")
1787
+ if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
1788
+ componentSpecializations.set(call, specialization)
1789
+ }
1790
+ specializedDeclarations.add(component.declaration)
1791
+ }
1792
+ for (const [name, binding] of importBindings) {
1793
+ if (binding.kind === "namespace") continue
1794
+ const calls = jsxTagUses(sourceFile, name)
1795
+ const dispatchCalls = calls.filter(call => jsxCallHasDirectReducerProp(call, reducersForNode(call, reducersByFunction)))
1796
+ if (!dispatchCalls.length) continue
1797
+ const imported = binding.kind === "default" ? "default" : binding.imported
1798
+ let component
1799
+ try {
1800
+ component = resolveComponentExport(binding.target, imported, importedSource, sourceFiles)
1801
+ } catch {
1802
+ fail(dispatchCalls[0], `Reducer dispatch props require a component imported from a relative TypeScript module`)
1803
+ }
1804
+ const componentSource = component.getSourceFile()
1805
+ if (clientImportBindings(componentSource, componentSource.fileName, sourceFiles).size) fail(dispatchCalls[0], "Imported reducer-dispatch components cannot use runtime imports")
1806
+ for (const call of dispatchCalls) {
1807
+ if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
1808
+ const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Reducer-dispatch")
1809
+ if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
1810
+ synthesizeTree(specialization.root)
1811
+ componentSpecializations.set(call, specialization)
1812
+ }
1813
+ }
1777
1814
  const rawRenderedLists = []
1778
1815
  const collectRenderedLists = node => {
1779
1816
  const specialization = componentSpecializations.get(node)
@@ -2259,6 +2296,14 @@ function jsxCallHasDirectStateProp(call, setters) {
2259
2296
  })
2260
2297
  }
2261
2298
 
2299
+ function jsxCallHasDirectReducerProp(call, reducers) {
2300
+ const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2301
+ return attributes.properties.some(attribute => {
2302
+ const value = ts.isJsxAttribute(attribute) && attribute.initializer && ts.isJsxExpression(attribute.initializer) ? unwrapExpression(attribute.initializer.expression) : undefined
2303
+ return value && ts.isIdentifier(value) && reducers.has(value.text)
2304
+ })
2305
+ }
2306
+
2262
2307
  function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
2263
2308
  const fail = (node, message) => {
2264
2309
  throw sourceNodeError(node, sourceFile, message)
@@ -2314,17 +2359,17 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2314
2359
  visit(root)
2315
2360
  }
2316
2361
 
2317
- function specializeComponentCall(call, component, sourceFile, factory, context, fail) {
2318
- if (component.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || component.asteriskToken) fail(component, "Keyed list components must be synchronous")
2319
- if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, "Keyed list components must use one destructured props parameter")
2320
- if (ts.isJsxElement(call) && call.children.some(child => !ts.isJsxText(child) || child.text.trim())) fail(call, "Keyed list component children are not supported")
2362
+ function specializeComponentCall(call, component, sourceFile, factory, context, fail, label = "Keyed list") {
2363
+ if (component.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || component.asteriskToken) fail(component, `${label} components must be synchronous`)
2364
+ if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, `${label} components must use one destructured props parameter`)
2365
+ if (ts.isJsxElement(call) && call.children.some(child => !ts.isJsxText(child) || child.text.trim())) fail(call, `${label} component children are not supported`)
2321
2366
  const callAttributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2322
2367
  const props = new Map()
2323
2368
  let key
2324
2369
  for (const attribute of callAttributes.properties) {
2325
- if (ts.isJsxSpreadAttribute(attribute)) fail(attribute, "Keyed list component prop spreads are not supported")
2370
+ if (ts.isJsxSpreadAttribute(attribute)) fail(attribute, `${label} component prop spreads are not supported`)
2326
2371
  const name = attribute.name.getText()
2327
- if (props.has(name) || name === "key" && key) fail(attribute, `Duplicate keyed list component prop "${name}"`)
2372
+ if (props.has(name) || name === "key" && key) fail(attribute, `Duplicate ${label.toLowerCase()} component prop "${name}"`)
2328
2373
  const value = !attribute.initializer
2329
2374
  ? factory.createTrue()
2330
2375
  : ts.isStringLiteral(attribute.initializer)
@@ -2338,12 +2383,12 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2338
2383
  const substitutions = new Map()
2339
2384
  const acceptedProps = new Set()
2340
2385
  for (const element of component.parameters[0].name.elements) {
2341
- if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name)) fail(element, "Keyed list component props cannot use rest, defaults, or nested destructuring")
2386
+ if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name)) fail(element, `${label} component props cannot use rest, defaults, or nested destructuring`)
2342
2387
  const prop = (element.propertyName ?? element.name).getText()
2343
2388
  acceptedProps.add(prop)
2344
2389
  substitutions.set(element.name.text, props.get(prop) ?? factory.createIdentifier("undefined"))
2345
2390
  }
2346
- for (const prop of props.keys()) if (!acceptedProps.has(prop)) fail(call, `Unknown keyed list component prop "${prop}"`)
2391
+ for (const prop of props.keys()) if (!acceptedProps.has(prop)) fail(call, `Unknown ${label.toLowerCase()} component prop "${prop}"`)
2347
2392
 
2348
2393
  let returned
2349
2394
  const calculations = []
@@ -2353,15 +2398,15 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2353
2398
  } else {
2354
2399
  const statements = [...component.body.statements]
2355
2400
  const last = statements.pop()
2356
- if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, "Keyed list component must end with one JSX return")
2401
+ if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, `${label} component must end with one JSX return`)
2357
2402
  for (const statement of statements) {
2358
2403
  if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect") {
2359
2404
  effectCalls.push(statement.expression)
2360
2405
  continue
2361
2406
  }
2362
- if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, "Keyed list component locals must be single const declarations")
2407
+ if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, `${label} component locals must be single const declarations`)
2363
2408
  const declaration = statement.declarationList.declarations[0]
2364
- if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, "Keyed list component locals must be initialized identifiers")
2409
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
2365
2410
  const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
2366
2411
  calculations.push(calculation)
2367
2412
  substitutions.set(declaration.name.text, calculation)
@@ -2369,11 +2414,11 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2369
2414
  returned = last.expression
2370
2415
  }
2371
2416
  let root = unwrapExpression(substituteClone(returned, substitutions, factory, context))
2372
- if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, "Keyed list component must return one JSX element")
2417
+ if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, `${label} component must return one JSX element`)
2373
2418
  const tag = jsxTagName(root)
2374
- if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(returned, "Keyed list component must directly return an intrinsic JSX element")
2419
+ if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(returned, `${label} component must directly return an intrinsic JSX element`)
2375
2420
  const rootAttributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
2376
- if (rootAttributes.properties.some(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "key")) fail(root, "Keyed list component intrinsic root cannot declare key")
2421
+ if (rootAttributes.properties.some(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "key")) fail(root, `${label} component intrinsic root cannot declare key`)
2377
2422
  if (key) root = addJsxAttribute(root, cloneAst(key, factory, context), factory)
2378
2423
  ts.setParentRecursive(root, false)
2379
2424
  root.parent = call.parent
@@ -17,6 +17,28 @@ const contextMarker = Symbol("kudzu.context")
17
17
  const contextProviderMarker = Symbol("kudzu.contextProvider")
18
18
  const routeScopeMarker = Symbol("kudzu.routeScope")
19
19
  const noSelectValue = Symbol("kudzu.no-select-value")
20
+ const svgAttributeAliases = {
21
+ clipRule: "clip-rule",
22
+ colorInterpolation: "color-interpolation",
23
+ colorInterpolationFilters: "color-interpolation-filters",
24
+ dominantBaseline: "dominant-baseline",
25
+ fillOpacity: "fill-opacity",
26
+ fillRule: "fill-rule",
27
+ floodColor: "flood-color",
28
+ floodOpacity: "flood-opacity",
29
+ shapeRendering: "shape-rendering",
30
+ stopColor: "stop-color",
31
+ stopOpacity: "stop-opacity",
32
+ strokeDasharray: "stroke-dasharray",
33
+ strokeDashoffset: "stroke-dashoffset",
34
+ strokeLinecap: "stroke-linecap",
35
+ strokeLinejoin: "stroke-linejoin",
36
+ strokeMiterlimit: "stroke-miterlimit",
37
+ strokeOpacity: "stroke-opacity",
38
+ strokeWidth: "stroke-width",
39
+ textAnchor: "text-anchor",
40
+ vectorEffect: "vector-effect"
41
+ }
20
42
 
21
43
  let renderContext
22
44
 
@@ -576,6 +598,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
576
598
  const childNamespace = tag === "svg" || tag === "math"
577
599
  ? tag
578
600
  : namespace === "svg" && tag === "foreignObject" ? undefined : namespace
601
+ const svg = tag === "svg" || namespace === "svg"
579
602
  let attributes = ""
580
603
  const attributeBindings = []
581
604
  const listAttributes = []
@@ -636,7 +659,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
636
659
  continue
637
660
  }
638
661
 
639
- const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : rawName
662
+ const name = rawName === "className" ? "class" : rawName === "htmlFor" ? "for" : svg ? svgAttributeAliases[rawName] ?? rawName : rawName
640
663
  const propertyTarget = name === "class" || name === "disabled" || name === "value" || name === "checked" || name === "style"
641
664
  if (value?.[listFieldMarker]) {
642
665
  attributes += renderAttribute(name, value.value)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",