@kudzujs/core 0.7.21 → 0.7.23

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
@@ -10,7 +10,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
10
10
 
11
11
  > Experimental `0.7.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **Latest release: 0.7.21 - Composable collections and effects.** Relative pure collection transforms, slice pagination, reactive search, immutable sorting, derived primitive effect dependencies, and simple named setup/cleanup functions now lower into existing compiler-owned capabilities. Read the [release notes](./RELEASES.md#0721---composable-collections-and-effects) or open the [release page](https://kudzujs.cloud/releases/0.7.21).
13
+ **Latest release: 0.7.23 - Router-shaped runtime params.** Named or aliased React Router `useParams()` authoring now redirects to Kudzu's existing route-specific pathname reader with no router runtime. Read the [release notes](./RELEASES.md#0723---router-shaped-runtime-params) or open the [release page](https://kudzujs.cloud/releases/0.7.23).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
@@ -82,6 +82,8 @@ ordinary React-shaped TSX
82
82
  - Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities. Inline or simple `const` setter callbacks and object refs may cross one ordinary component boundary into a direct intrinsic root.
83
83
  - Build-known data and routes become complete HTML through async components and `getStaticPaths()`.
84
84
  - Native document navigation is the default; static routes do not load a client runtime.
85
+ - A named or aliased React Router `Link` with a static root-relative `to` erases to a base-aware native anchor; no router package or runtime is emitted.
86
+ - A direct named or aliased React Router `useParams()` call on a `runtimeParams` bracket route reuses Kudzu's route-specific pathname reader.
85
87
  - Unsupported nearby patterns fail during the build with a source location and actionable boundary.
86
88
 
87
89
  Migration input may retain supported imports from `react`; Kudzu erases those references and never emits or executes React. New Kudzu source should import framework APIs from `@kudzujs/core`.
package/RELEASES.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.23 - Router-shaped runtime params
4
+
5
+ Kudzu 0.7.23 accepts the conventional React Router `useParams()` source shape on runtime bracket routes and redirects it to the existing capability-specific pathname reader without shipping React Router.
6
+
7
+ ### New in 0.7.23
8
+
9
+ - Named or aliased `useParams` imports from `react-router-dom` may be called directly on bracket pages exporting `runtimeParams = true`.
10
+ - One optional TypeScript type argument is preserved through normalization while the runtime call remains argument-free.
11
+ - The React Router binding is redirected to `@kudzujs/core`; no `react-router-dom` module reference survives emitted code.
12
+ - Mixed `Link` and `useParams` imports lower together: Link becomes a base-aware native anchor and params reuse the pathname reader.
13
+ - Runtime parameter values retain existing secure decoding, direct DOM bindings, effects, handlers, and navigation-group lifecycle behavior.
14
+ - Indirect references, runtime arguments, default/namespace imports, and unsupported router hooks fail with source diagnostics.
15
+ - The complete suite passes 112/112 tests with browser coverage for multi-parameter paths and mixed Link/params migration input.
16
+
17
+ ### Boundary
18
+
19
+ React Router `useParams()` lowering requires a direct named or aliased zero-argument call on a bracket route exporting `runtimeParams = true`. Build-known `getStaticPaths()` routes continue to receive params through page props. Catch-all routes, optional segments, indirect calls, runtime arguments, and other router hooks remain unsupported. No SPA router or shared router runtime is added.
20
+
21
+ ### Upgrade
22
+
23
+ ```bash
24
+ npm install @kudzujs/core@^0.7.23
25
+ ```
26
+
27
+ ## 0.7.22 - SVG structures and native links
28
+
29
+ Kudzu 0.7.22 extends ordinary React-shaped structural authoring into SVG and erases the common React Router `Link` form to native navigation without adding an SVG renderer or router runtime.
30
+
31
+ ### New in 0.7.22
32
+
33
+ - Reactive conditional branches inside SVG parse replacement markup in the actual SVG parent context and retain existing DOM ownership semantics.
34
+ - Flat intrinsic keyed lists inside SVG support add, update, reorder, and removal while preserving keyed identity and the SVG namespace.
35
+ - SVG fragment construction is compiled out of HTML-only condition and list builds; the measured matched fixture added 333 B raw / 79 B aggregate gzip JavaScript.
36
+ - A named or aliased `Link` import from `react-router-dom` with one static root-relative `to` lowers to a native `<a href>`, receives the configured `base`, and erases the package import.
37
+ - Dynamic or relative Link destinations, traversal, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics.
38
+ - Effect dependency arrays are explicitly proven for multiple direct primitive states or supported props through existing commit batching and `Object.is` comparison.
39
+ - The complete suite passes 111/111 tests with Chrome coverage for SVG namespace, conditional replacement, keyed identity, Link erasure, and zero-JavaScript static output.
40
+
41
+ ### Measured fixture
42
+
43
+ On the recorded Intel i5-9500 / Chrome 142 environment, 1,000-row SVG medians were 0.8 ms conditional, 1.9 ms update, 8.3 ms reverse, 2.4 ms remove, and 3.5 ms add. The matched HTML control measured 0.7, 1.8, 8.3, 2.5, and 3.6 ms. Link and native-anchor controls emitted byte-identical 248 B HTML and zero JavaScript. Full methodology and raw arrays are in `PERFORMANCE.md`.
44
+
45
+ ### Boundary
46
+
47
+ Structural SVG currently supports reactive conditionals and flat intrinsic keyed lists. Keyed-item conditions, nested SVG lists, reactive MathML, and namespaced attributes remain unsupported. React Router lowering accepts only direct named or aliased `Link` JSX with one safe static root-relative destination and native anchor props. Native document navigation remains the default; no SPA router is included.
48
+
49
+ ### Upgrade
50
+
51
+ ```bash
52
+ npm install @kudzujs/core@^0.7.22
53
+ ```
54
+
3
55
  ## 0.7.21 - Composable collections and effects
4
56
 
5
57
  Kudzu 0.7.21 expands ordinary React-shaped collection pipelines and effect authoring while preserving keyed identity, derived dependency semantics, and capability-specific browser output.
@@ -4,9 +4,13 @@ Kudzu specializes ordinary common React-shaped TSX so migrations need minimal so
4
4
 
5
5
  Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
6
 
7
+ A named or aliased `Link` import from `react-router-dom` may render directly with one static root-relative `to` plus native anchor props. The compiler prefixes the configured `base`, changes the element to `<a href>`, and erases the import, so native navigation remains the default and configured navigation groups see an ordinary eligible anchor. Dynamic or relative destinations, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics. No React Router package code or router runtime is emitted.
8
+
9
+ A named or aliased React Router `useParams` import may also be called directly without runtime arguments on a bracket route exporting `runtimeParams = true`. The compiler redirects that local binding to `@kudzujs/core`, preserving one optional TypeScript type argument, and reuses the existing route-specific pathname matcher. Indirect calls and other router hooks remain unsupported. Build-known `getStaticPaths()` routes use page props instead because no browser pathname capability is needed.
10
+
7
11
  Direct `clsx` calls over literal strings, numbers, arrays, object conditions, and conditional expressions are similarly lowered to ordinary concatenation and conditional expressions. The package import is erased, and dynamic classes continue through the existing binding compiler without serializing or shipping the `clsx` function.
8
12
 
9
- Repeated ordinary same-file and relative-imported child components execute independently at build time, so each `useState` call receives a distinct concrete state ID while shared native handler modules retain per-element state maps and captures. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. A top-level immutable local derived through a supported pure primitive expression from direct state may be an effect dependency: source state commits schedule evaluation, the derived result is compared with `Object.is`, and the expression is substituted into setup and cleanup handlers. Effect setup and directly returned cleanup callbacks may each resolve one top-level simple `const` function in the same component; those functions are substituted into the existing handler graph rather than retained in a browser registry. Reactive conditional descriptors own state created by their direct branch: initial visible output reuses the rendered template IDs, removal deletes those slots, and remount recreates them from serialized initial values. Static sibling routes and branches without local state add no ownership metadata, component function, hook dispatcher, or rerender loop.
13
+ Repeated ordinary same-file and relative-imported child components execute independently at build time, so each `useState` call receives a distinct concrete state ID while shared native handler modules retain per-element state maps and captures. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. Multiple direct primitive dependencies share the existing commit batching path: every value is compared with `Object.is`, and one or more same-turn changes cause one cleanup and rerun. A top-level immutable local derived through a supported pure primitive expression from direct state may also be an effect dependency: source state commits schedule evaluation, the derived result is compared with `Object.is`, and the expression is substituted into setup and cleanup handlers. Effect setup and directly returned cleanup callbacks may each resolve one top-level simple `const` function in the same component; those functions are substituted into the existing handler graph rather than retained in a browser registry. Reactive conditional descriptors own state created by their direct branch: initial visible output reuses the rendered template IDs, removal deletes those slots, and remount recreates them from serialized initial values. Static sibling routes and branches without local state add no ownership metadata, component function, hook dispatcher, or rerender loop.
10
14
 
11
15
  Reduced Zustand migration stores lower to one ordinary layout-lifetime state slot. The compiler accepts one exported `create(set => ({ data, ...actions }))` store with one serializable data property, direct property selectors, and synchronous capture-free actions using one-argument merge-form `set`; selected actions reuse the reducer-style functional update compiler, so same-turn calls observe current logical state and DOM writes still batch. The shared layout must initialize the store before route consumers, outside keyed rows. No Zustand import, store subscription runtime, React hook, or generic external-store capability is emitted.
12
16
 
@@ -31,7 +35,7 @@ Exact relative `.worker.ts` constructors in inline effects are validated and bun
31
35
 
32
36
  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.
33
37
 
34
- 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.
38
+ Inline SVG rendering normalizes an explicit set of common React presentation aliases before static serialization and binding descriptor creation. Reactive aliases use the existing generic `setAttribute` path. Reactive conditionals and flat intrinsic keyed lists store inert branch or row markup on SVG markers and parse it in the actual parent namespace only when replacement nodes are needed; existing condition/list ownership then handles insertion, identity, updates, and removal. Builds without structural SVG compile out that fragment path, and static SVG adds no JavaScript. Keyed-item conditions, nested SVG lists, MathML structures, and namespaced attributes remain unsupported.
35
39
 
36
40
  Same-file, directly exported same-file, and relative-imported component chains receiving a direct local-state array or keyed item are recursively specialized to intrinsic JSX before keyed-list analysis, so their component functions are not retained in the browser. A directly exported row may be reused across static and keyed JSX sites; export-list/default aliases and non-JSX references remain rejected. Missing destructured props use directly serializable primitive, plain-object, or array literal defaults during specialization. One final identifier rest binding may be expanded exactly once at the direct intrinsic root. Rows may own multiple direct-property child maps recursively, nested conditions, latest-item handlers, multiple directly serializable state slots, effects, and `null`-initialized object refs. A block-bodied keyed `map` callback may declare one top-level `const` computed from a direct child collection through the supported pure selector pipeline and then return JSX; the alias must feed exactly one nested keyed list source. Structural list sites and ancestor key paths scope hooks across updates and reorder and release them on removal. Handler modules are emitted only when a rendered descriptor references them. 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 and unrelated fields do nothing. Builds without item dependencies emit no item reader or list-state subscription code.
37
41
 
@@ -122,10 +122,10 @@ function mountConditions(root) {
122
122
  mountedConditions.add(start)
123
123
  const descriptor = JSON.parse(start.dataset.kIf)
124
124
  const end = findEnd(start, descriptor.id)
125
- const truthy = start.content.querySelector("template[data-k-true]")
126
- const falsy = start.content.querySelector("template[data-k-false]")
125
+ const truthy = globalThis.__KUDZU_SVG_CONDITIONS__ && descriptor.svg ? start.dataset.kSvgTrue : start.content.querySelector("template[data-k-true]")
126
+ const falsy = globalThis.__KUDZU_SVG_CONDITIONS__ && descriptor.svg ? start.dataset.kSvgFalse : start.content.querySelector("template[data-k-false]")
127
127
  if (!end || !truthy || !falsy) continue
128
- const condition = { start, end, truthy, falsy, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial), mount: descriptor.mount, owned: descriptor.owned }
128
+ const condition = { start, end, truthy, falsy, svg: globalThis.__KUDZU_SVG_CONDITIONS__ && descriptor.svg, kind: descriptor.kind, current: conditionKey(descriptor.kind, descriptor.initial), mount: descriptor.mount, owned: descriptor.owned }
129
129
  mountConditionStates(condition, Boolean(descriptor.initial), false)
130
130
  const mount = evaluator => {
131
131
  if (!start.isConnected) return
@@ -154,7 +154,9 @@ function updateCondition(condition) {
154
154
  const falseText = condition.kind === "and" && !truthy ? renderFalsy(value) : ""
155
155
  const fragment = falseText
156
156
  ? textFragment(condition.end.ownerDocument, falseText)
157
- : (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
157
+ : globalThis.__KUDZU_SVG_CONDITIONS__ && condition.svg
158
+ ? svgFragment(condition.start, truthy ? condition.truthy : condition.falsy)
159
+ : (truthy ? condition.truthy : condition.falsy).content.cloneNode(true)
158
160
  const nodes = condition.mount ? [...fragment.childNodes] : undefined
159
161
  mountConditionStates(condition, truthy, true)
160
162
  condition.end.parentNode.insertBefore(fragment, condition.end)
@@ -230,6 +232,12 @@ function textFragment(document, value) {
230
232
  return fragment
231
233
  }
232
234
 
235
+ function svgFragment(marker, markup) {
236
+ const range = marker.ownerDocument.createRange()
237
+ range.selectNode(marker)
238
+ return range.createContextualFragment(markup)
239
+ }
240
+
233
241
  function findEnd(start, id) {
234
242
  for (let node = start.nextSibling; node; node = node.nextSibling) {
235
243
  if (node.nodeType === Node.ELEMENT_NODE && node.matches("template[data-k-if-end]") && node.dataset.kIfEnd === id) return node
@@ -199,7 +199,9 @@ export async function build({ quiet = false, minify = true } = {}) {
199
199
  const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
200
200
  const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
201
201
  const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
202
+ const hasSvgConditions = plans.some(plan => plan.conditions.some(condition => condition.svg))
202
203
  const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
204
+ const hasSvgLists = plans.some(plan => plan.lists.some(list => list.svg))
203
205
  const hasDeepListConditions = plans.some(plan => plan.lists.some(list => list.conditionHandlers))
204
206
  const hasListTextRanges = plans.some(plan => plan.lists.some(list => list.textRanges))
205
207
  const hasListAttributes = plans.some(plan => plan.lists.some(list => list.attributes))
@@ -272,6 +274,7 @@ export async function build({ quiet = false, minify = true } = {}) {
272
274
  if (navigationRoutes.length) bindingRuntime = specializeNavigationTextDescriptors(bindingRuntime)
273
275
  await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, {
274
276
  "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings),
277
+ "globalThis.__KUDZU_SVG_CONDITIONS__": String(hasSvgConditions),
275
278
  "globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures)
276
279
  })
277
280
  }
@@ -349,7 +352,8 @@ export async function build({ quiet = false, minify = true } = {}) {
349
352
  __KUDZU_COLLECTION_SELECTORS__: String(hasCollectionSelectors),
350
353
  __KUDZU_STATIC_COLLECTIONS__: String(hasStaticCollections),
351
354
  __KUDZU_LIST_INDEXES__: String(hasListIndexes),
352
- __KUDZU_LIST_STABLE_FAST_PATHS__: String(hasListStableFastPaths)
355
+ __KUDZU_LIST_STABLE_FAST_PATHS__: String(hasListStableFastPaths),
356
+ __KUDZU_SVG_LISTS__: String(hasSvgLists)
353
357
  })
354
358
  if (hasCollectionSelectors && !hasDerivedEffectDependencies) await rm(join(assetsDirectory, "kudzu-collection-selector.js"))
355
359
  }
@@ -1734,7 +1738,8 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
1734
1738
  if (errors.length) {
1735
1739
  throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
1736
1740
  }
1737
- if (hasReactModuleReference(result.outputText, file)) throw new Error(`${relative(root, file)} Runtime React module references are not supported`)
1741
+ const packageReference = emittedPackageReference(result.outputText, file, new Set(["react", "react-router-dom"]))
1742
+ if (packageReference) throw new Error(`${relative(root, file)} Runtime ${packageReference} module references are not supported`)
1738
1743
 
1739
1744
  const output = compiledPath(file)
1740
1745
  await mkdir(resolve(output, ".."), { recursive: true })
@@ -1757,18 +1762,100 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
1757
1762
  return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
1758
1763
  }
1759
1764
 
1760
- function hasReactModuleReference(source, file) {
1765
+ function emittedPackageReference(source, file, packages) {
1761
1766
  const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS)
1762
- let found = false
1767
+ let found
1763
1768
  const visit = node => {
1764
- if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react") found = true
1765
- if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === "react") found = true
1769
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && packages.has(node.moduleSpecifier.text)) found = node.moduleSpecifier.text
1770
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require") && ts.isStringLiteral(node.arguments[0]) && packages.has(node.arguments[0].text)) found = node.arguments[0].text
1766
1771
  if (!found) ts.forEachChild(node, visit)
1767
1772
  }
1768
1773
  visit(sourceFile)
1769
1774
  return found
1770
1775
  }
1771
1776
 
1777
+ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1778
+ const links = new Set()
1779
+ const params = new Set()
1780
+ for (const statement of sourceFile.statements) {
1781
+ if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
1782
+ if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
1783
+ const clause = statement.importClause
1784
+ if (clause?.isTypeOnly) continue
1785
+ if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
1786
+ if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link or useParams imports")
1787
+ const bindings = clause.namedBindings
1788
+ if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link or useParams imports")
1789
+ for (const entry of bindings.elements) {
1790
+ if (entry.isTypeOnly) continue
1791
+ const imported = (entry.propertyName ?? entry.name).text
1792
+ if (imported === "NavLink") throw sourceNodeError(entry, sourceFile, "React Router NavLink active-route semantics cannot be erased to a native anchor")
1793
+ if (imported === "Link") links.add(entry.name.text)
1794
+ else if (imported === "useParams") params.add(entry.name.text)
1795
+ else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link and useParams imports can be lowered`)
1796
+ }
1797
+ }
1798
+ }
1799
+ if (!links.size && !params.size) return sourceFile
1800
+
1801
+ const routerProps = new Set(["discover", "end", "prefetch", "preventScrollReset", "relative", "reloadDocument", "replace", "state", "viewTransition"])
1802
+ const attributes = attributesNode => {
1803
+ const output = []
1804
+ let destination
1805
+ for (const property of attributesNode.properties) {
1806
+ if (ts.isJsxSpreadAttribute(property)) throw sourceNodeError(property, sourceFile, "React Router Link does not support spread attributes during native anchor lowering")
1807
+ const name = property.name.text
1808
+ if (name === "href") throw sourceNodeError(property, sourceFile, "React Router Link must not declare href; Kudzu derives it from to")
1809
+ if (routerProps.has(name)) throw sourceNodeError(property, sourceFile, `React Router Link prop ${JSON.stringify(name)} cannot be erased to a native anchor`)
1810
+ if (name !== "to") {
1811
+ output.push(ts.visitEachChild(property, visitor, context))
1812
+ continue
1813
+ }
1814
+ if (destination !== undefined) throw sourceNodeError(property, sourceFile, "React Router Link requires exactly one to attribute")
1815
+ if (!property.initializer || !ts.isStringLiteral(property.initializer)) throw sourceNodeError(property, sourceFile, 'React Router Link requires a static root-relative to="/path"')
1816
+ destination = property.initializer.text
1817
+ const pathname = destination.match(/^[^?#]*/)[0]
1818
+ let decoded
1819
+ try { decoded = decodeURIComponent(pathname) } catch { throw sourceNodeError(property.initializer, sourceFile, 'React Router Link requires a safe static root-relative to="/path"') }
1820
+ if (!destination.startsWith("/") || destination.startsWith("//") || /%(?:2f|5c)/i.test(pathname) || /[\\\0]/.test(decoded) || decoded.split("/").includes("..") || [...decoded].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159)) throw sourceNodeError(property.initializer, sourceFile, 'React Router Link requires a safe static root-relative to="/path"')
1821
+ output.push(factory.createJsxAttribute(factory.createIdentifier("href"), factory.createStringLiteral(withBase(base, destination))))
1822
+ }
1823
+ if (destination === undefined) throw sourceNodeError(attributesNode.parent, sourceFile, "React Router Link requires exactly one static root-relative to attribute")
1824
+ return factory.updateJsxAttributes(attributesNode, output)
1825
+ }
1826
+ const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
1827
+ const visitor = node => {
1828
+ if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
1829
+ const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
1830
+ const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
1831
+ return factory.updateJsxElement(node, opening, ts.visitNodes(node.children, visitor), closing)
1832
+ }
1833
+ if (ts.isJsxSelfClosingElement(node) && importedLink(node.tagName)) return factory.updateJsxSelfClosingElement(node, factory.createIdentifier("a"), node.typeArguments, attributes(node.attributes))
1834
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && params.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
1835
+ if (node.questionDotToken || node.arguments.length || (node.typeArguments?.length ?? 0) > 1) throw sourceNodeError(node, sourceFile, "React Router useParams must be called directly without runtime arguments and with at most one type argument")
1836
+ return node
1837
+ }
1838
+ if (ts.isIdentifier(node) && links.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router Link imports may only be used as direct JSX elements")
1839
+ if (ts.isIdentifier(node) && params.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useParams imports may only be called directly")
1840
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
1841
+ const clause = node.importClause
1842
+ if (!clause || clause.isTypeOnly) return node
1843
+ const bindings = clause.namedBindings
1844
+ if (!bindings || !ts.isNamedImports(bindings)) return node
1845
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams"].includes((entry.propertyName ?? entry.name).text))
1846
+ if (!elements.length) return undefined
1847
+ return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
1848
+ }
1849
+ return ts.visitEachChild(node, visitor, context)
1850
+ }
1851
+ const normalized = ts.visitNode(sourceFile, visitor)
1852
+ if (!params.size) return normalized
1853
+ const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports([...params].map(name => factory.createImportSpecifier(false, name === "useParams" ? undefined : factory.createIdentifier("useParams"), factory.createIdentifier(name))))), factory.createStringLiteral("@kudzujs/core"))
1854
+ const statements = [...normalized.statements]
1855
+ statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
1856
+ return factory.updateSourceFile(normalized, statements)
1857
+ }
1858
+
1772
1859
  function normalizeClsxSyntax(sourceFile, factory, context) {
1773
1860
  const names = new Set()
1774
1861
  for (const statement of sourceFile.statements) {
@@ -2298,6 +2385,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2298
2385
  const factory = context.factory
2299
2386
  const hasLinkElements = /<link/i.test(sourceFile.text)
2300
2387
  const importedCollections = importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex)
2388
+ sourceFile = normalizeReactRouterSyntax(sourceFile, factory, context, base)
2389
+ ts.setParentRecursive(sourceFile, false)
2301
2390
  sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
2302
2391
  ts.setParentRecursive(sourceFile, false)
2303
2392
  sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
@@ -2316,7 +2405,9 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2316
2405
  const importedSource = target => {
2317
2406
  let imported = importedSources.get(target)
2318
2407
  if (!imported) {
2319
- imported = normalizeClsxSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
2408
+ imported = normalizeReactRouterSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context, base)
2409
+ ts.setParentRecursive(imported, false)
2410
+ imported = normalizeClsxSyntax(imported, factory, context)
2320
2411
  ts.setParentRecursive(imported, false)
2321
2412
  imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
2322
2413
  ts.setParentRecursive(imported, false)
@@ -588,7 +588,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
588
588
  const descriptor = bindingDescriptor(node)
589
589
  const stateIds = reactiveStateIds(descriptor)
590
590
  if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
591
- if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
591
+ if (namespace === "math") throw new Error("Reactive conditional DOM is not supported inside math")
592
592
 
593
593
  const id = renderContext.listRoot || renderContext.listRowRoot ? nextRowRenderId("c") : nextRenderId("c")
594
594
  const renderBranch = async branch => {
@@ -609,14 +609,17 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
609
609
  }
610
610
  const owned = truthy.states.length || falsy.states.length ? { true: truthy.states, false: falsy.states } : undefined
611
611
  const mount = Boolean(owned) || truthy.html.includes("data-k-") || falsy.html.includes("data-k-") || truthy.html.includes("<!--k-text:") || falsy.html.includes("<!--k-text:")
612
- const metadata = { id, kind: node.kind, initial: node.value, ...descriptor, ...(owned ? { owned } : {}), ...(mount ? { mount: true } : {}) }
612
+ const metadata = { id, kind: node.kind, initial: node.value, ...descriptor, ...(namespace === "svg" ? { svg: true } : {}), ...(owned ? { owned } : {}), ...(mount ? { mount: true } : {}) }
613
613
  for (const stateId of stateIds) renderContext.conditionStates.add(stateId)
614
614
  renderContext.conditions.push(metadata)
615
615
  renderContext.hasBehaviors = true
616
616
  renderContext.hasBindings = true
617
617
  const encoded = escapeJsonAttribute(metadata)
618
618
  const current = node.value ? truthy.html : node.kind === "and" ? await renderNode(node.value, namespace, selectValue) : falsy.html
619
- return `<template data-k-if='${encoded}'><template data-k-true>${truthy.html}</template><template data-k-false>${falsy.html}</template></template>${current}<template data-k-if-end="${id}"></template>`
619
+ const branches = namespace === "svg"
620
+ ? ` data-k-svg-true="${escapeAttribute(truthy.html)}" data-k-svg-false="${escapeAttribute(falsy.html)}"></template>`
621
+ : `><template data-k-true>${truthy.html}</template><template data-k-false>${falsy.html}</template></template>`
622
+ return `<template data-k-if='${encoded}'${branches}${current}<template data-k-if-end="${id}"></template>`
620
623
  }
621
624
  if (node?.[listMarker]) return renderList(node, namespace, selectValue)
622
625
  if (node?.[listFieldMarker]) {
@@ -644,6 +647,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
644
647
  return `<!--k-text:${id}-->${escapeHtml(node.value ?? "")}<!--k-text-end-->`
645
648
  }
646
649
  if (node?.[listConditionalMarker]) {
650
+ if (namespace === "svg") throw new Error("Keyed row conditions are not supported inside svg")
647
651
  const descriptor = { kind: node.kind, module: node.module, handler: node.handler }
648
652
  const owner = renderContext.listRoot ?? renderContext.listRowRoot
649
653
  if (owner) owner.conditions = true
@@ -833,12 +837,13 @@ function sharedInitialListMarker() {
833
837
  }
834
838
 
835
839
  async function renderList(node, namespace, selectValue) {
836
- if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
840
+ if (namespace === "math") throw new Error("Reactive keyed lists are not supported inside math")
841
+ if (namespace === "svg" && node.ownerField) throw new Error("Nested reactive keyed lists are not supported inside svg")
837
842
  const ownerRoot = node.ownerField ? renderContext.listRoot ?? renderContext.listRowRoot : undefined
838
843
  const ownerTemplate = Boolean(node.ownerField && renderContext.listTemplate)
839
844
  const rowList = node.ownerField ? nextRowList() : undefined
840
845
  const id = rowList?.id ?? nextRenderId("l")
841
- const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(node.items[internalStateMarker] ? { static: true } : {}), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.selector.length ? { selector: node.selector } : {}), ...(node.selectorStates.length ? { selectorStates: Object.fromEntries(node.selectorStates.map(([name, state]) => [name, state.id])) } : {}), ...(node.indexed ? { indexed: true } : {}), ...(!node.selector.length && node.keyField !== null && node.items[reducerStateMarker] ? { reducer: true } : {}) }
846
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(namespace === "svg" ? { svg: true } : {}), ...(node.items[internalStateMarker] ? { static: true } : {}), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.selector.length ? { selector: node.selector } : {}), ...(node.selectorStates.length ? { selectorStates: Object.fromEntries(node.selectorStates.map(([name, state]) => [name, state.id])) } : {}), ...(node.indexed ? { indexed: true } : {}), ...(!node.selector.length && node.keyField !== null && node.items[reducerStateMarker] ? { reducer: true } : {}) }
842
847
  if (ownerTemplate) {
843
848
  ownerRoot.descriptor.children ??= []
844
849
  ownerRoot.descriptor.children.push({ id, field: node.ownerField, key: node.keyField, ...(node.selector.length ? { selector: node.selector } : {}) })
@@ -904,7 +909,8 @@ async function renderList(node, namespace, selectValue) {
904
909
  renderContext.hasBehaviors = true
905
910
  renderContext.hasLists = true
906
911
  const prototype = node.ownerField && !ownerTemplate ? "" : template
907
- return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${prototype}</template>${current}<template data-k-list-end="${id}"></template>`
912
+ const svgTemplate = namespace === "svg" ? ` data-k-svg-template="${escapeAttribute(prototype)}"` : ""
913
+ return `<template data-k-list='${escapeJsonAttribute(descriptor)}'${svgTemplate}>${namespace === "svg" ? "" : prototype}</template>${current}<template data-k-list-end="${id}"></template>`
908
914
  } finally {
909
915
  renderContext.listRoot = previousListRoot
910
916
  renderContext.listRowRoot = previousListRowRoot
@@ -44,7 +44,7 @@ function mountLists(root) {
44
44
  const end = findEnd(start, descriptor.id)
45
45
  const roots = listRoots(start, end)
46
46
  const nested = __KUDZU_NESTED_LISTS__ ? mountNestedPrototype(start, descriptor, roots) : undefined
47
- const templateRoot = __KUDZU_NESTED_LISTS__ ? nested.templateRoot : start.content.firstElementChild
47
+ const templateRoot = __KUDZU_NESTED_LISTS__ ? nested.templateRoot : listTemplateRoot(start, descriptor)
48
48
  if (__KUDZU_LIST_ROW_HOOKS__) for (let index = 0; index < roots.length; index++) initializeGeneralRowHooks(descriptor, descriptor.keys[index], roots[index], nested?.owner)
49
49
  const parts = listItemPartPlan(templateRoot, descriptor.nested)
50
50
  const staticRows = __KUDZU_STATIC_COLLECTIONS__ && descriptor.static && parts.directFill ? new Map() : undefined
@@ -63,7 +63,8 @@ function mountLists(root) {
63
63
  const list = {
64
64
  start,
65
65
  descriptor,
66
- ...(__KUDZU_NESTED_LISTS__ ? { templateRoot, ...(nested.childPrototypes?.size ? { childPrototypes: nested.childPrototypes } : {}) } : {}),
66
+ templateRoot,
67
+ ...(__KUDZU_NESTED_LISTS__ && nested.childPrototypes?.size ? { childPrototypes: nested.childPrototypes } : {}),
67
68
  parts,
68
69
  ...(__KUDZU_STATIC_COLLECTIONS__ && staticRows ? { staticRows } : {}),
69
70
  ...(__KUDZU_STATIC_COLLECTIONS__ && staticEntries ? { staticEntries, staticPositions: staticEntries.positions } : {}),
@@ -196,7 +197,7 @@ function updateList(list) {
196
197
  let node = list.roots.get(token)
197
198
  if (!node) {
198
199
  const staticRoot = __KUDZU_STATIC_COLLECTIONS__ ? list.staticRows?.get(token)?.cloneNode(true) : undefined
199
- node = staticRoot ?? (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
200
+ node = staticRoot ?? list.templateRoot?.cloneNode(true)
200
201
  if (!staticRoot && node?.dataset.kListRoot !== list.descriptor.id) node = undefined
201
202
  if (!node) throw new Error("Keyed list template has no root element")
202
203
  node.removeAttribute("data-k-list-root")
@@ -360,7 +361,7 @@ function updateStableList(list, items) {
360
361
  keys.add(token)
361
362
  if (index < previous.length && (key !== previous[index][keyField] || appending && item !== previous[index])) stable = false
362
363
  if (!stable || items.length === previous.length + 1 || index < previous.length) continue
363
- let node = (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
364
+ let node = list.templateRoot?.cloneNode(true)
364
365
  if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
365
366
  if (!node) throw new Error("Keyed list template has no root element")
366
367
  node.removeAttribute("data-k-list-root")
@@ -545,7 +546,7 @@ function updateReducerList(list, items) {
545
546
  }
546
547
 
547
548
  function addListRoot(list, { item, index = list.roots.size, key, token, value }) {
548
- let node = (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
549
+ let node = list.templateRoot?.cloneNode(true)
549
550
  if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
550
551
  if (!node) throw new Error("Keyed list template has no root element")
551
552
  node.removeAttribute("data-k-list-root")
@@ -862,7 +863,7 @@ function listOwner(start) {
862
863
  function mountNestedPrototype(start, descriptor, roots) {
863
864
  const owner = descriptor.ownerField ? listOwner(start) : undefined
864
865
  const prototypeStart = owner ? childPrototypes.get(owner)?.get(descriptor.id) : undefined
865
- const templateRoot = prototypeStart?.content.firstElementChild ?? start.content.firstElementChild
866
+ const templateRoot = prototypeStart?.content.firstElementChild ?? listTemplateRoot(start, descriptor)
866
867
  if (!templateRoot) throw new Error("Nested keyed list has no shared row prototype")
867
868
  const prototypes = descriptor.children && new Map(descriptor.children.map(child => [child.id, findChildPrototype(templateRoot, child.id)]))
868
869
  if (prototypes && [...prototypes.values()].some(prototype => !prototype)) throw new Error("Keyed list template has no nested row prototype")
@@ -871,6 +872,13 @@ function mountNestedPrototype(start, descriptor, roots) {
871
872
  return { owner, childPrototypes: prototypes, templateRoot }
872
873
  }
873
874
 
875
+ function listTemplateRoot(start, descriptor) {
876
+ if (!__KUDZU_SVG_LISTS__ || !descriptor.svg) return start.content.firstElementChild
877
+ const range = start.ownerDocument.createRange()
878
+ range.selectNode(start)
879
+ return range.createContextualFragment(start.dataset.kSvgTemplate).firstElementChild
880
+ }
881
+
874
882
  function findChildPrototype(root, id) {
875
883
  for (const element of root.children) {
876
884
  if (element.matches("template[data-k-list]")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",