@kudzujs/core 0.7.29 → 0.8.0
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 +5 -3
- package/RELEASES.md +50 -0
- package/framework/README.md +5 -3
- package/framework/build.mjs +332 -50
- package/framework/core.d.ts +2 -0
- package/framework/core.mjs +11 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@ HTML-first TSX framework with synchronous state semantics and no virtual DOM.
|
|
|
8
8
|
|
|
9
9
|
Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTML, CSS, and only the route-specific ESM capabilities actually used. Static pages ship zero JavaScript. React, hydration, a VDOM, and a retained browser component tree are not part of the output.
|
|
10
10
|
|
|
11
|
-
> Experimental `0.
|
|
11
|
+
> Experimental `0.8.x`: the compiler API and supported TSX surface may change.
|
|
12
12
|
|
|
13
|
-
**Latest release: 0.
|
|
13
|
+
**Latest release: 0.8.0 - URL-backed custom hooks.** Relative React custom hooks, reachable source graphs, imported structured calculations and static collections, event-only package ESM, and native SVG now compose across a fourteen-route FIRE migration without adding React or a hook runtime. Read the [release notes](./RELEASES.md#080---url-backed-custom-hooks) or open the [release page](https://kudzujs.cloud/releases/0.8.0).
|
|
14
14
|
|
|
15
15
|
- [Documentation](https://kudzujs.cloud/docs)
|
|
16
16
|
- [Installation guide](https://kudzujs.cloud/docs#install)
|
|
@@ -84,7 +84,9 @@ ordinary React-shaped TSX
|
|
|
84
84
|
- Native document navigation is the default; static routes do not load a client runtime.
|
|
85
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
86
|
- A direct named or aliased React Router `useParams()` call on a `runtimeParams` bracket route reuses Kudzu's route-specific pathname reader.
|
|
87
|
-
-
|
|
87
|
+
- React Router `useSearchParams()` supports direct static `get("name")` locals and inline setter updaters, lowering reads and history writes to one route-specific query capability.
|
|
88
|
+
- Only TypeScript modules reachable from pages are compiled. Imported immutable direct maps can fold to static HTML, while direct fields from relative structured calculations reevaluate through route binding ESM.
|
|
89
|
+
- Package imports used directly inside JSX event callbacks are removed from build modules and retained only in bundled route handler ESM.
|
|
88
90
|
- A named or aliased React Router `useNavigate()` top-level binding lowers direct nested-callback calls with safe static root-relative destinations to native `location.assign()` or `location.replace()` navigation.
|
|
89
91
|
- Unsupported nearby patterns fail during the build with a source location and actionable boundary.
|
|
90
92
|
|
package/RELEASES.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Kudzu Releases
|
|
2
2
|
|
|
3
|
+
## 0.8.0 - URL-backed custom hooks
|
|
4
|
+
|
|
5
|
+
Kudzu 0.8.0 migrates practical relative React custom hooks that combine local state, effects, direct actions, writable URL search parameters, and guarded browser storage while preserving static HTML and route-specific capabilities.
|
|
6
|
+
|
|
7
|
+
### New in 0.8.0
|
|
8
|
+
|
|
9
|
+
- Named or default zero-argument custom hooks imported from relative TypeScript modules can expose direct shorthand `useState` value/setter pairs and callbacks that capture those states.
|
|
10
|
+
- React Router `useSearchParams()` accepts a top-level `[params, setParams]` tuple. Direct browser callbacks may pass one synchronous inline updater and optionally exactly `{ replace: true }`.
|
|
11
|
+
- Query writes use native `URLSearchParams` and History APIs, immediately recommit affected route signals, and follow browser `popstate` without an SPA router.
|
|
12
|
+
- Custom-hook mount and dependency effects can restore validated values from `localStorage`, persist subsequent state, and keep deterministic build-time fallbacks.
|
|
13
|
+
- Page entries now define the compiled TypeScript graph, so unreachable migration modules no longer block a build while reachable invalid imports retain diagnostics.
|
|
14
|
+
- Direct maps over imported immutable JSON-safe arrays fold to static HTML; relative calculation helpers can drive direct reactive result fields through route binding ESM.
|
|
15
|
+
- Package imports referenced directly inside JSX event handlers erase from build modules and bundle only into route handlers, enabling ExcelJS export without package execution during rendering.
|
|
16
|
+
- FIRE migration validation covers all fourteen routes, production Tailwind/Inter assets, URL and storage restoration, reset, presets, native SVG charts, Quiz and keyed Debt flows, and a real Excel workbook buffer.
|
|
17
|
+
- Static sibling routes continue to ship no JavaScript; React, React Router, hydration, a VDOM, and a browser hook dispatcher remain absent.
|
|
18
|
+
|
|
19
|
+
### Boundary
|
|
20
|
+
|
|
21
|
+
Custom hooks must be synchronous zero-argument relative imports with one final direct shorthand object return. Caller destructuring cannot use aliases, defaults, or rest. Writable search parameters require direct inline updater callbacks. Relative calculation results require direct static field reads. Package imports must be referenced directly inside intrinsic JSX event callbacks. Dynamic query reads, direct-value setters, arbitrary options, mutable value refs, generic reactive objects, calculated collection fields, helper-indirect package use, and callback graphs with private captures remain unsupported.
|
|
22
|
+
|
|
23
|
+
### Upgrade
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @kudzujs/core@^0.8.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 0.7.30 - Reactive number formatting
|
|
30
|
+
|
|
31
|
+
Kudzu 0.7.30 supports fixed-locale `Intl.NumberFormat` display chains in reactive JSX locals, restoring conventional comma-formatted controlled inputs while preserving narrow render-call validation.
|
|
32
|
+
|
|
33
|
+
### New in 0.7.30
|
|
34
|
+
|
|
35
|
+
- Reactive text and attributes accept `new Intl.NumberFormat("literal").format(Math.round(expression))` over supported state-derived primitive expressions.
|
|
36
|
+
- Initial HTML is formatted during the build and existing binding ESM reevaluates the same native expression after state changes.
|
|
37
|
+
- No collection selector opcode, shared formatting runtime, hydration, or browser component instance is added.
|
|
38
|
+
- Locale values must be direct string literals; dynamic locales and options remain unsupported.
|
|
39
|
+
- Optional calls, aliases, shadowed `Intl` or `Math`, arbitrary constructors, and other render calls retain source diagnostics.
|
|
40
|
+
- A migration-derived FIRE `CurrencyInput` restores `100,000` annual and `8,333` monthly display while preserving annualized state updates.
|
|
41
|
+
- The complete suite passes 123/123 tests, including emitted evaluator and invalid dynamic-locale coverage.
|
|
42
|
+
|
|
43
|
+
### Boundary
|
|
44
|
+
|
|
45
|
+
This is display formatting only. The accepted expression requires one static locale and exactly `Math.round(expression)` as the format argument. Collection selectors and effect dependency expressions keep their existing pure expression language.
|
|
46
|
+
|
|
47
|
+
### Upgrade
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install @kudzujs/core@^0.7.30
|
|
51
|
+
```
|
|
52
|
+
|
|
3
53
|
## 0.7.29 - Hermetic TypeScript checks
|
|
4
54
|
|
|
5
55
|
Kudzu 0.7.29 makes project and migration-fixture typechecks independent of unrelated ambient types and package declarations installed in ancestor directories.
|
package/framework/README.md
CHANGED
|
@@ -2,19 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
|
|
4
4
|
|
|
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`. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `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, including type-only `as const` and `satisfies` wrappers, 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.
|
|
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`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `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, including type-only `as const` and `satisfies` wrappers, 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
|
+
|
|
7
|
+
Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
|
|
6
8
|
|
|
7
9
|
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
10
|
|
|
9
11
|
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
12
|
|
|
11
|
-
A named or aliased
|
|
13
|
+
A named or aliased React Router `useSearchParams` import may initialize one top-level `const [params]` or `const [params, setParams]` binding. Each top-level `const value = params.get("literal")` lowers to one cached nullable `useSearchParam()` signal. Direct setter calls inside nested browser callbacks accept one synchronous inline updater over native `URLSearchParams`; no options pushes history and exactly `{ replace: true }` replaces it. The route parameter asset recommits changed query signals and follows `popstate`. Missing keys remain `null`, direct text renders blank, and nullable attributes are removed. Dynamic names, direct-value setters, other methods on the outer params object, aliases, wrapped reads, and layout ownership are rejected. Routes without query reads or writes do not emit this branch or a parameter asset.
|
|
12
14
|
|
|
13
15
|
A named or aliased React Router `useNavigate` import may initialize one top-level `const` identifier. A direct call from a nested browser callback with one safe static root-relative string lowers to `location.assign()` after applying `base`; exactly `{ replace: true }` lowers to `location.replace()`. This deliberately performs native document navigation even when enhanced navigation is configured. Dynamic or relative destinations, render-time calls, aliases passed as values, and options such as `state`, `relative`, or `preventScrollReset` are rejected. Routes without an actual navigation handler emit no browser JavaScript.
|
|
14
16
|
|
|
15
17
|
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.
|
|
16
18
|
|
|
17
|
-
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. Reactive text and attributes may reference recursively chained top-level immutable locals derived through supported pure primitive expressions from direct state; the compiler substitutes those expressions into the existing binding evaluator and subscribes every source state. 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.
|
|
19
|
+
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. Reactive text and attributes may reference recursively chained top-level immutable locals derived through supported pure primitive expressions from direct state; the compiler substitutes those expressions into the existing binding evaluator and subscribes every source state. Fixed-locale `new Intl.NumberFormat("literal").format(Math.round(expression))` display chains reuse that binding ESM, while dynamic locales and options remain unsupported. 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.
|
|
18
20
|
|
|
19
21
|
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.
|
|
20
22
|
|
package/framework/build.mjs
CHANGED
|
@@ -40,12 +40,16 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
40
40
|
await mkdir(outputDirectory, { recursive: true })
|
|
41
41
|
|
|
42
42
|
const projectFiles = await walk(sourceDirectory)
|
|
43
|
-
const
|
|
43
|
+
const allSourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file) && !file.endsWith(".d.ts")).sort()
|
|
44
44
|
const configuredStyleSources = new Set(configuredStyles.sources.map(style => style.source))
|
|
45
45
|
const discoveredCssFiles = projectFiles.filter(file => file.toLowerCase().endsWith(".css") && !configuredStyleSources.has(file)).sort()
|
|
46
|
-
if (!
|
|
46
|
+
if (!allSourceFiles.length) throw new Error("No TypeScript files found in src/")
|
|
47
|
+
const allSourceFileSet = new Set(allSourceFiles)
|
|
48
|
+
const sourceIndex = new Map(await Promise.all(allSourceFiles.map(async file => [file, await readFile(file, "utf8")])))
|
|
49
|
+
const pageFiles = allSourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
|
|
50
|
+
if (!pageFiles.length) throw new Error("No pages found in src/pages/")
|
|
51
|
+
const sourceFiles = reachableSourceFiles(pageFiles, allSourceFileSet, sourceIndex)
|
|
47
52
|
const sourceFileSet = new Set(sourceFiles)
|
|
48
|
-
const sourceIndex = new Map(await Promise.all(sourceFiles.map(async file => [file, await readFile(file, "utf8")])))
|
|
49
53
|
const staticFiles = await safeStaticFiles(projectFiles)
|
|
50
54
|
const cssFiles = orderSourceStyles(discoveredCssFiles, sourceFiles, sourceIndex, staticFiles)
|
|
51
55
|
const importedAssets = new Set()
|
|
@@ -59,9 +63,6 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
59
63
|
if (handlerModule) handlerModules.push(handlerModule)
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
const pageFiles = sourceFiles.filter(file => file.startsWith(`${pagesDirectory}${sep}`) && file.endsWith(".tsx"))
|
|
63
|
-
if (!pageFiles.length) throw new Error("No pages found in src/pages/")
|
|
64
|
-
|
|
65
66
|
let behaviorCount = 0
|
|
66
67
|
let regularBehaviorCount = 0
|
|
67
68
|
let bindingCount = 0
|
|
@@ -153,7 +154,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
153
154
|
const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
|
|
154
155
|
pageEntries.push({ route, html: result.html, usesDependencyRuntime })
|
|
155
156
|
plans.push({ route: routePath, ...result.plan })
|
|
156
|
-
if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, searchParams: result.plan.searchParams, usesDependencyRuntime, navigable })
|
|
157
|
+
if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, searchParams: result.plan.searchParams, searchParamsWritable: result.plan.searchParamsWritable, usesDependencyRuntime, navigable })
|
|
157
158
|
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
|
|
158
159
|
if (result.plan.events.some(event => event.native)) nativeEntries.push({
|
|
159
160
|
path: nativePath,
|
|
@@ -386,7 +387,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
386
387
|
for (const entry of paramEntries) {
|
|
387
388
|
const output = join(assetsDirectory, entry.path)
|
|
388
389
|
await mkdir(dirname(output), { recursive: true })
|
|
389
|
-
await writeJavaScript(output, printParamEntry(entry.schema, entry.params, entry.searchParams, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
|
|
390
|
+
await writeJavaScript(output, printParamEntry(entry.schema, entry.params, entry.searchParams, entry.searchParamsWritable, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
|
|
390
391
|
}
|
|
391
392
|
for (const entry of effectEntries) {
|
|
392
393
|
const output = join(assetsDirectory, entry.path)
|
|
@@ -403,7 +404,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
403
404
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
404
405
|
await writeJavaScript(output, await compileClientModule(file, sourceFileSet, staticFiles, importedAssets, cssModules, base), minify)
|
|
405
406
|
}
|
|
406
|
-
if (clientModules.length) {
|
|
407
|
+
if (clientModules.length || emittedHandlerModules.some(module => module.hasPackageImports)) {
|
|
407
408
|
await bundle({
|
|
408
409
|
entryPoints: emittedHandlerModules.map(module => join(assetsDirectory, module.path)),
|
|
409
410
|
outbase: join(assetsDirectory, "handlers"),
|
|
@@ -1406,9 +1407,10 @@ async function invokeCleanup() {
|
|
|
1406
1407
|
}${disposal}`
|
|
1407
1408
|
}
|
|
1408
1409
|
|
|
1409
|
-
function printParamEntry(schema, params, searchParams, output, assetsDirectory, base, runtimeName, navigable) {
|
|
1410
|
-
const
|
|
1411
|
-
const
|
|
1410
|
+
function printParamEntry(schema, params, searchParams, searchParamsWritable, output, assetsDirectory, base, runtimeName, navigable) {
|
|
1411
|
+
const hasSearch = searchParams.length || searchParamsWritable
|
|
1412
|
+
const signature = hasSearch ? "pathname, search" : "pathname"
|
|
1413
|
+
const prefix = navigable ? `export function initializeParams(${signature}) {\n${searchParamsWritable ? "globalThis.__kSetSearchParams = setSearchParams\n" : ""}` : `${schema ? "let pathname = location.pathname\n" : ""}${hasSearch ? "let search = location.search\n" : ""}`
|
|
1412
1414
|
const suffix = navigable ? "\n}" : ""
|
|
1413
1415
|
const pathname = schema ? `const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
|
|
1414
1416
|
const schema = ${inlineJson(schema.segments)}
|
|
@@ -1443,15 +1445,35 @@ function decodeSegment(raw, param) {
|
|
|
1443
1445
|
return value
|
|
1444
1446
|
}
|
|
1445
1447
|
` : ""
|
|
1446
|
-
const
|
|
1448
|
+
const searchInitializer = searchParamsWritable && searchParams.length ? `function initializeSearch(search) {
|
|
1449
|
+
const query = new URLSearchParams(search)
|
|
1450
|
+
for (const param of ${inlineJson(searchParams)}) {
|
|
1451
|
+
const value = query.get(param.name)
|
|
1452
|
+
browserState.set(param.id, value)
|
|
1453
|
+
commitDom(param.id, value)
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
` : ""
|
|
1457
|
+
const query = searchParams.length ? searchParamsWritable ? "initializeSearch(search)\n" : `const query = new URLSearchParams(search)
|
|
1447
1458
|
for (const param of ${inlineJson(searchParams)}) {
|
|
1448
1459
|
const value = query.get(param.name)
|
|
1449
1460
|
browserState.set(param.id, value)
|
|
1450
1461
|
commitDom(param.id, value)
|
|
1451
1462
|
}
|
|
1452
1463
|
` : ""
|
|
1464
|
+
const writer = searchParamsWritable ? `
|
|
1465
|
+
function setSearchParams(update, replace) {
|
|
1466
|
+
const next = update(new URLSearchParams(location.search))
|
|
1467
|
+
if (!(next instanceof URLSearchParams)) throw new Error("React Router search parameter updater must return URLSearchParams")
|
|
1468
|
+
const url = new URL(location.href)
|
|
1469
|
+
url.search = next.toString()
|
|
1470
|
+
history[replace ? "replaceState" : "pushState"](null, "", url)
|
|
1471
|
+
${searchParams.length ? "initializeSearch(location.search)" : ""}
|
|
1472
|
+
}
|
|
1473
|
+
${navigable ? "" : `globalThis.__kSetSearchParams = setSearchParams
|
|
1474
|
+
addEventListener("popstate", () => ${searchParams.length ? "initializeSearch(location.search)" : "undefined"})`}` : ""
|
|
1453
1475
|
return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
|
|
1454
|
-
${prefix}${pathname}${query}${suffix}`
|
|
1476
|
+
${searchInitializer}${prefix}${pathname}${query}${suffix}${writer}`
|
|
1455
1477
|
}
|
|
1456
1478
|
|
|
1457
1479
|
function hasCaptureType(value, type) {
|
|
@@ -1758,7 +1780,7 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
|
|
|
1758
1780
|
if (!nativeHandlers.length && !effectHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
1759
1781
|
const callbacks = [...nativeHandlers, ...effectHandlers]
|
|
1760
1782
|
const moduleSource = [
|
|
1761
|
-
printClientImports(callbacks.flatMap(handler => handler.imports), handlerPath),
|
|
1783
|
+
printClientImports([...callbacks, ...reactiveBindings].flatMap(handler => handler.imports ?? []), handlerPath),
|
|
1762
1784
|
...callbacks.map(handler => printNativeHandler(handler)),
|
|
1763
1785
|
...reactiveBindings.map(entry => printReactiveBinding(entry)),
|
|
1764
1786
|
...listExpressions.map(entry => printListExpression(entry))
|
|
@@ -1769,7 +1791,7 @@ async function compile(file, sourceFiles, sourceIndex, staticFiles, importedAsse
|
|
|
1769
1791
|
})
|
|
1770
1792
|
const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
1771
1793
|
if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
1772
|
-
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports] }
|
|
1794
|
+
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, hasEffects: effectHandlers.length > 0, clientImports: [...clientImports], hasPackageImports: [...callbacks, ...reactiveBindings].some(entry => entry.imports?.some(import_ => import_.package)) }
|
|
1773
1795
|
}
|
|
1774
1796
|
|
|
1775
1797
|
function emittedPackageReference(source, file, packages) {
|
|
@@ -1784,6 +1806,30 @@ function emittedPackageReference(source, file, packages) {
|
|
|
1784
1806
|
return found
|
|
1785
1807
|
}
|
|
1786
1808
|
|
|
1809
|
+
function reachableSourceFiles(entries, sourceFiles, sourceIndex) {
|
|
1810
|
+
const reachable = new Set()
|
|
1811
|
+
const queue = [...entries]
|
|
1812
|
+
while (queue.length) {
|
|
1813
|
+
const file = queue.pop()
|
|
1814
|
+
if (reachable.has(file)) continue
|
|
1815
|
+
reachable.add(file)
|
|
1816
|
+
const sourceFile = parseSourceFile(file, sourceIndex.get(file))
|
|
1817
|
+
const visit = node => {
|
|
1818
|
+
const specifier = (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && runtimeModuleReference(node) && node.moduleSpecifier
|
|
1819
|
+
if (specifier && ts.isStringLiteral(specifier) && specifier.text.startsWith(".") && !isStaticImport(specifier.text)) {
|
|
1820
|
+
try { queue.push(resolveSourceImport(file, specifier.text, sourceFiles)) } catch {}
|
|
1821
|
+
}
|
|
1822
|
+
const worker = relativeWorkerCandidate(node, sourceFile)
|
|
1823
|
+
if (worker && ts.isStringLiteral(worker.url.arguments[0]) && worker.url.arguments[0].text.endsWith(".worker.ts")) {
|
|
1824
|
+
try { queue.push(resolveSourceImport(file, worker.url.arguments[0].text, sourceFiles)) } catch {}
|
|
1825
|
+
}
|
|
1826
|
+
ts.forEachChild(node, visit)
|
|
1827
|
+
}
|
|
1828
|
+
visit(sourceFile)
|
|
1829
|
+
}
|
|
1830
|
+
return [...reachable].sort()
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1787
1833
|
function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
1788
1834
|
const links = new Set()
|
|
1789
1835
|
const params = new Set()
|
|
@@ -1814,8 +1860,11 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1814
1860
|
|
|
1815
1861
|
let searchHelper = "__kUseSearchParam"
|
|
1816
1862
|
while (sourceFile.text.includes(searchHelper)) searchHelper += "_"
|
|
1863
|
+
let searchWriterHelper = "__kUseSearchParamsWriter"
|
|
1864
|
+
while (sourceFile.text.includes(searchWriterHelper)) searchWriterHelper += "_"
|
|
1817
1865
|
const searchDeclarations = new Set()
|
|
1818
1866
|
const searchReads = new Map()
|
|
1867
|
+
const searchWrites = new Map()
|
|
1819
1868
|
const searchObjects = []
|
|
1820
1869
|
const collectSearchHooks = node => {
|
|
1821
1870
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && searchHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
@@ -1823,29 +1872,31 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1823
1872
|
const statement = declaration?.parent?.parent
|
|
1824
1873
|
const owner = nearestFunction(node)
|
|
1825
1874
|
const first = ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name) ? declaration.name.elements[0] : undefined
|
|
1826
|
-
|
|
1827
|
-
|
|
1875
|
+
const second = ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name) ? declaration.name.elements[1] : undefined
|
|
1876
|
+
if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body || !ts.isBindingElement(first) || !ts.isIdentifier(first.name) || declaration.name.elements.length > 2 || second && (!ts.isBindingElement(second) || !ts.isIdentifier(second.name))) {
|
|
1877
|
+
throw sourceNodeError(node, sourceFile, "React Router useSearchParams must initialize one top-level const [params] or [params, setParams] binding")
|
|
1828
1878
|
}
|
|
1829
|
-
const entry = { name: first.name.text, declaration, statement, owner }
|
|
1879
|
+
const entry = { name: first.name.text, setter: second?.name.text, declaration, statement, owner }
|
|
1830
1880
|
searchDeclarations.add(declaration)
|
|
1831
1881
|
searchObjects.push(entry)
|
|
1832
1882
|
}
|
|
1833
1883
|
ts.forEachChild(node, collectSearchHooks)
|
|
1834
1884
|
}
|
|
1835
1885
|
collectSearchHooks(sourceFile)
|
|
1836
|
-
const localBindingShadowed = (node, entry) => {
|
|
1886
|
+
const localBindingShadowed = (node, entry, name = entry.name) => {
|
|
1837
1887
|
for (let current = node.parent; current && current !== entry.owner; current = current.parent) {
|
|
1838
|
-
if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(
|
|
1839
|
-
if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement,
|
|
1840
|
-
if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement,
|
|
1841
|
-
if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(
|
|
1842
|
-
if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current,
|
|
1888
|
+
if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(name)) || functionVarDeclaresName(current, name))) return true
|
|
1889
|
+
if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, name))) return true
|
|
1890
|
+
if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, name)))) return true
|
|
1891
|
+
if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(name)) return true
|
|
1892
|
+
if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, name)) return true
|
|
1843
1893
|
}
|
|
1844
1894
|
return false
|
|
1845
1895
|
}
|
|
1846
1896
|
for (const entry of searchObjects) {
|
|
1847
1897
|
const collectReads = node => {
|
|
1848
1898
|
if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
|
|
1899
|
+
if (entry.setter && ts.isCallExpression(node.parent) && node.parent.arguments.includes(node) && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === entry.setter) return
|
|
1849
1900
|
const property = node.parent
|
|
1850
1901
|
const call = property?.parent
|
|
1851
1902
|
const declaration = call?.parent
|
|
@@ -1862,6 +1913,28 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1862
1913
|
ts.forEachChild(node, collectReads)
|
|
1863
1914
|
}
|
|
1864
1915
|
collectReads(entry.owner.body)
|
|
1916
|
+
if (!entry.setter) continue
|
|
1917
|
+
const collectWrites = node => {
|
|
1918
|
+
if (ts.isIdentifier(node) && node.text === entry.setter && isReferenceIdentifier(node) && !localBindingShadowed(node, entry, entry.setter)) {
|
|
1919
|
+
const call = node.parent
|
|
1920
|
+
if (!ts.isCallExpression(call) || call.expression !== node || call.questionDotToken || call.typeArguments?.length || nearestFunction(call) === entry.owner) throw sourceNodeError(node, sourceFile, "React Router search parameter setters may only be called directly from a nested browser callback")
|
|
1921
|
+
if (call.arguments.length < 1 || call.arguments.length > 2) throw sourceNodeError(call, sourceFile, "React Router search parameter setters require one inline updater and optional { replace: true }")
|
|
1922
|
+
const updater = unwrapExpression(call.arguments[0])
|
|
1923
|
+
if ((!ts.isArrowFunction(updater) && !ts.isFunctionExpression(updater)) || updater.asteriskToken || updater.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || updater.parameters.length !== 1 || !ts.isIdentifier(updater.parameters[0].name)) throw sourceNodeError(call.arguments[0], sourceFile, "React Router search parameter setters require one synchronous inline updater with one identifier parameter")
|
|
1924
|
+
let replace = false
|
|
1925
|
+
if (call.arguments.length === 2) {
|
|
1926
|
+
const options = unwrapExpression(call.arguments[1])
|
|
1927
|
+
const property = ts.isObjectLiteralExpression(options) && options.properties.length === 1 ? options.properties[0] : undefined
|
|
1928
|
+
const name = property && ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
|
|
1929
|
+
if (name !== "replace" || property.initializer.kind !== ts.SyntaxKind.TrueKeyword) throw sourceNodeError(call.arguments[1], sourceFile, "React Router search parameter setters only support exactly { replace: true } as a second argument")
|
|
1930
|
+
replace = true
|
|
1931
|
+
}
|
|
1932
|
+
searchWrites.set(call, { updater, replace })
|
|
1933
|
+
return
|
|
1934
|
+
}
|
|
1935
|
+
ts.forEachChild(node, collectWrites)
|
|
1936
|
+
}
|
|
1937
|
+
collectWrites(entry.owner.body)
|
|
1865
1938
|
}
|
|
1866
1939
|
|
|
1867
1940
|
const navigateDeclarations = new Set()
|
|
@@ -1941,11 +2014,21 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1941
2014
|
const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
|
|
1942
2015
|
const visitor = node => {
|
|
1943
2016
|
if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration) || navigateDeclarations.has(declaration))) {
|
|
1944
|
-
const declarations = node.declarationList.declarations.
|
|
2017
|
+
const declarations = node.declarationList.declarations.flatMap(declaration => {
|
|
2018
|
+
if (navigateDeclarations.has(declaration)) return []
|
|
2019
|
+
if (!searchDeclarations.has(declaration)) return [ts.visitEachChild(declaration, visitor, context)]
|
|
2020
|
+
const entry = searchObjects.find(candidate => candidate.declaration === declaration)
|
|
2021
|
+
if (!entry?.setter) return []
|
|
2022
|
+
return [factory.updateVariableDeclaration(declaration, declaration.name, declaration.exclamationToken, declaration.type, factory.createCallExpression(factory.createIdentifier(searchWriterHelper), undefined, []))]
|
|
2023
|
+
})
|
|
1945
2024
|
if (!declarations.length) return undefined
|
|
1946
|
-
return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations
|
|
2025
|
+
return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations))
|
|
1947
2026
|
}
|
|
1948
2027
|
if (ts.isCallExpression(node) && searchReads.has(node)) return factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
|
|
2028
|
+
if (ts.isCallExpression(node) && searchWrites.has(node)) {
|
|
2029
|
+
const { updater, replace } = searchWrites.get(node)
|
|
2030
|
+
return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "__kSetSearchParams"), undefined, [ts.visitNode(updater, visitor), replace ? factory.createTrue() : factory.createFalse()])
|
|
2031
|
+
}
|
|
1949
2032
|
if (ts.isCallExpression(node) && navigateCalls.has(node)) {
|
|
1950
2033
|
const { method, destination } = navigateCalls.get(node)
|
|
1951
2034
|
return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "location"), method), undefined, [factory.createStringLiteral(destination)])
|
|
@@ -1962,7 +2045,7 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1962
2045
|
}
|
|
1963
2046
|
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")
|
|
1964
2047
|
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")
|
|
1965
|
-
if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only
|
|
2048
|
+
if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only initialize the supported top-level tuple binding")
|
|
1966
2049
|
if (ts.isIdentifier(node) && navigateHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useNavigate imports may only initialize the supported top-level navigate binding")
|
|
1967
2050
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
|
|
1968
2051
|
const clause = node.importClause
|
|
@@ -1979,7 +2062,8 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
|
|
|
1979
2062
|
if (!params.size && !searchHooks.size) return normalized
|
|
1980
2063
|
const imports = [
|
|
1981
2064
|
...[...params].map(name => factory.createImportSpecifier(false, name === "useParams" ? undefined : factory.createIdentifier("useParams"), factory.createIdentifier(name))),
|
|
1982
|
-
...(
|
|
2065
|
+
...(searchReads.size ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParam"), factory.createIdentifier(searchHelper))] : []),
|
|
2066
|
+
...(searchObjects.some(entry => entry.setter) ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParamsWriter"), factory.createIdentifier(searchWriterHelper))] : [])
|
|
1983
2067
|
]
|
|
1984
2068
|
const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(imports)), factory.createStringLiteral("@kudzujs/core"))
|
|
1985
2069
|
const statements = [...normalized.statements]
|
|
@@ -2515,7 +2599,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2515
2599
|
return context => sourceFile => {
|
|
2516
2600
|
const factory = context.factory
|
|
2517
2601
|
const hasLinkElements = /<link/i.test(sourceFile.text)
|
|
2518
|
-
const
|
|
2602
|
+
const importedStaticCollections = importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex)
|
|
2603
|
+
const importedCollections = new Set(importedStaticCollections.keys())
|
|
2604
|
+
sourceFile = normalizeImportedStaticCollections(sourceFile, importedStaticCollections, factory, context)
|
|
2605
|
+
ts.setParentRecursive(sourceFile, false)
|
|
2519
2606
|
sourceFile = normalizeReactRouterSyntax(sourceFile, factory, context, base)
|
|
2520
2607
|
ts.setParentRecursive(sourceFile, false)
|
|
2521
2608
|
sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
|
|
@@ -2531,6 +2618,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2531
2618
|
ts.setParentRecursive(sourceFile, false)
|
|
2532
2619
|
rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles)
|
|
2533
2620
|
const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
|
|
2621
|
+
const packageBindings = packageImportBindings(sourceFile)
|
|
2622
|
+
for (const [name] of packageBindings) {
|
|
2623
|
+
const references = referenceIdentifiers(sourceFile, name)
|
|
2624
|
+
const invalid = references.find(reference => !insideJsxEventHandler(reference, sourceFile))
|
|
2625
|
+
if (invalid) throw sourceNodeError(invalid, sourceFile, `Package import ${JSON.stringify(name)} may only be referenced directly inside JSX event handlers`)
|
|
2626
|
+
}
|
|
2534
2627
|
const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
|
|
2535
2628
|
const importedSources = new Map()
|
|
2536
2629
|
const importedSource = target => {
|
|
@@ -2554,6 +2647,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2554
2647
|
return imported
|
|
2555
2648
|
}
|
|
2556
2649
|
const importedCollectionTransforms = new Map()
|
|
2650
|
+
const importedCalculationFunctions = new Map()
|
|
2557
2651
|
for (const [name, binding] of importBindings) {
|
|
2558
2652
|
if (binding.kind === "namespace") continue
|
|
2559
2653
|
try {
|
|
@@ -2573,8 +2667,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2573
2667
|
return store
|
|
2574
2668
|
}
|
|
2575
2669
|
const functions = new Map()
|
|
2670
|
+
const customHookFunctionsByOwner = new Map()
|
|
2576
2671
|
const components = new Map()
|
|
2577
2672
|
const contexts = new Set()
|
|
2673
|
+
const customHooks = new Map()
|
|
2578
2674
|
const jsxLocalDeclarations = new Map()
|
|
2579
2675
|
const jsxLocalsByFunction = new Map()
|
|
2580
2676
|
const listLocalDeclarations = new WeakSet()
|
|
@@ -2598,9 +2694,73 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2598
2694
|
let usesComponentRef = false
|
|
2599
2695
|
let usesComponentEffects = false
|
|
2600
2696
|
|
|
2697
|
+
const resolveCustomHook = (binding, call) => {
|
|
2698
|
+
const exportName = binding.kind === "default" ? "default" : binding.imported
|
|
2699
|
+
const key = `${binding.target}:${exportName}`
|
|
2700
|
+
if (customHooks.has(key)) return customHooks.get(key)
|
|
2701
|
+
const hook = resolveComponentExport(binding.target, exportName, importedSource, sourceFiles)
|
|
2702
|
+
const hookSource = hook.getSourceFile()
|
|
2703
|
+
if (hook.parameters.length || hook.asteriskToken || hook.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !ts.isBlock(hook.body)) throw sourceNodeError(hook, hookSource, "Relative custom hooks must be synchronous zero-argument functions with a block body")
|
|
2704
|
+
const returns = hook.body.statements.filter(ts.isReturnStatement)
|
|
2705
|
+
const returned = returns.length === 1 && returns[0] === hook.body.statements.at(-1) && returns[0].expression ? unwrapExpression(returns[0].expression) : undefined
|
|
2706
|
+
if (!returned || !ts.isObjectLiteralExpression(returned)) throw sourceNodeError(hook.body, hookSource, "Relative custom hooks must end with one direct object return")
|
|
2707
|
+
|
|
2708
|
+
const states = new Map()
|
|
2709
|
+
const callbacks = new Map()
|
|
2710
|
+
for (const statement of hook.body.statements) {
|
|
2711
|
+
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) continue
|
|
2712
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2713
|
+
if (ts.isArrayBindingPattern(declaration.name) && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState") {
|
|
2714
|
+
const [state, setter] = declaration.name.elements
|
|
2715
|
+
if (declaration.name.elements.length === 2 && state && setter && ts.isBindingElement(state) && ts.isBindingElement(setter) && ts.isIdentifier(state.name) && ts.isIdentifier(setter.name)) states.set(setter.name.text, state.name.text)
|
|
2716
|
+
}
|
|
2717
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) callbacks.set(declaration.name.text, declaration.initializer)
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
const fields = new Set()
|
|
2721
|
+
for (const property of returned.properties) {
|
|
2722
|
+
if (!ts.isShorthandPropertyAssignment(property)) throw sourceNodeError(property, hookSource, "Relative custom hooks must return direct shorthand bindings")
|
|
2723
|
+
fields.add(property.name.text)
|
|
2724
|
+
}
|
|
2725
|
+
for (const [name, callback] of callbacks) {
|
|
2726
|
+
const capture = nativeCaptureNames(callback, states).values().next().value
|
|
2727
|
+
if (capture) throw sourceNodeError(callback, hookSource, `Relative custom hook callback ${JSON.stringify(name)} cannot capture private binding ${JSON.stringify(capture)}`)
|
|
2728
|
+
}
|
|
2729
|
+
const analysis = { callbacks, fields, states }
|
|
2730
|
+
customHooks.set(key, analysis)
|
|
2731
|
+
return analysis
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2601
2734
|
const collect = node => {
|
|
2602
2735
|
if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
|
|
2603
2736
|
const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
|
|
2737
|
+
if (callName && /^use[A-Z]/.test(callName) && importBindings.has(callName) && importBindings.get(callName).kind !== "namespace" && !resolvedZustandStore(importBindings.get(callName))) {
|
|
2738
|
+
if (!isLocalConst(node) || !ts.isObjectBindingPattern(node.name) || node.initializer.arguments.length) throw sourceNodeError(node, sourceFile, "Relative custom hooks must initialize one top-level const object destructuring with no arguments")
|
|
2739
|
+
const hook = resolveCustomHook(importBindings.get(callName), node.initializer)
|
|
2740
|
+
const names = new Set()
|
|
2741
|
+
for (const element of node.name.elements) {
|
|
2742
|
+
if (element.dotDotDotToken || element.propertyName || element.initializer || !ts.isIdentifier(element.name)) throw sourceNodeError(element, sourceFile, "Relative custom hook results must use direct identifier shorthand without aliases, defaults, or rest")
|
|
2743
|
+
const name = element.name.text
|
|
2744
|
+
if (!hook.fields.has(name)) throw sourceNodeError(element, sourceFile, `Relative custom hook does not directly return ${JSON.stringify(name)}`)
|
|
2745
|
+
names.add(name)
|
|
2746
|
+
}
|
|
2747
|
+
const owner = nearestFunction(node)
|
|
2748
|
+
if (!owner) throw sourceNodeError(node, sourceFile, "Relative custom hooks cannot be used outside a Kudzu component")
|
|
2749
|
+
const setters = settersByFunction.get(owner) ?? new Map()
|
|
2750
|
+
for (const [setter, state] of hook.states) {
|
|
2751
|
+
if (names.has(setter) !== names.has(state)) throw sourceNodeError(node.name, sourceFile, `Relative custom hook state ${JSON.stringify(state)} and setter ${JSON.stringify(setter)} must be destructured together`)
|
|
2752
|
+
if (names.has(setter)) setters.set(setter, state)
|
|
2753
|
+
}
|
|
2754
|
+
settersByFunction.set(owner, setters)
|
|
2755
|
+
for (const name of names) {
|
|
2756
|
+
if (hook.callbacks.has(name)) {
|
|
2757
|
+
const callbacks = customHookFunctionsByOwner.get(owner) ?? new Map()
|
|
2758
|
+
callbacks.set(name, hook.callbacks.get(name))
|
|
2759
|
+
customHookFunctionsByOwner.set(owner, callbacks)
|
|
2760
|
+
}
|
|
2761
|
+
else if (![...hook.states].some(([setter, state]) => name === setter || name === state)) throw sourceNodeError(node.name, sourceFile, `Relative custom hook result ${JSON.stringify(name)} must be a direct useState value, setter, or callback`)
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2604
2764
|
if (ts.isIdentifier(node.name) && callName && importBindings.has(callName) && importBindings.get(callName).kind !== "namespace") {
|
|
2605
2765
|
const storeImport = importBindings.get(callName)
|
|
2606
2766
|
const store = resolvedZustandStore(storeImport)
|
|
@@ -2677,6 +2837,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2677
2837
|
ts.forEachChild(node, collect)
|
|
2678
2838
|
}
|
|
2679
2839
|
collect(sourceFile)
|
|
2840
|
+
const functionsForNode = node => {
|
|
2841
|
+
const callbacks = customHookFunctionsByOwner.get(nearestFunction(node))
|
|
2842
|
+
return callbacks ? new Map([...functions, ...callbacks]) : functions
|
|
2843
|
+
}
|
|
2680
2844
|
for (const [owner, declarations] of jsxLocalDeclarations) {
|
|
2681
2845
|
const names = new Set()
|
|
2682
2846
|
let changed = true
|
|
@@ -2722,6 +2886,63 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2722
2886
|
const fail = (node, message) => {
|
|
2723
2887
|
throw sourceNodeError(node, sourceFile, message)
|
|
2724
2888
|
}
|
|
2889
|
+
const validateImportedCalculation = (call, field) => {
|
|
2890
|
+
const name = call.expression.text
|
|
2891
|
+
let calculation = importedCalculationFunctions.get(name)
|
|
2892
|
+
if (!calculation) {
|
|
2893
|
+
const binding = importBindings.get(name)
|
|
2894
|
+
try {
|
|
2895
|
+
calculation = resolveComponentExport(binding.target, binding.kind === "default" ? "default" : binding.imported, importedSource, sourceFiles)
|
|
2896
|
+
} catch {
|
|
2897
|
+
fail(call.expression, "Reactive imported calculations must resolve to a directly exported relative TypeScript function")
|
|
2898
|
+
}
|
|
2899
|
+
importedCalculationFunctions.set(name, calculation)
|
|
2900
|
+
}
|
|
2901
|
+
if (calculation.asteriskToken || calculation.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) fail(call.expression, "Reactive imported calculations must be synchronous functions")
|
|
2902
|
+
if (calculation.parameters.length !== call.arguments.length) fail(call, "Reactive imported calculations require one direct argument for each declared parameter")
|
|
2903
|
+
const returns = ts.isBlock(calculation.body) ? [] : [unwrapExpression(calculation.body)]
|
|
2904
|
+
const collectReturns = node => {
|
|
2905
|
+
if (node !== calculation.body && isFunctionLike(node)) return
|
|
2906
|
+
if (ts.isReturnStatement(node)) returns.push(node.expression ? unwrapExpression(node.expression) : null)
|
|
2907
|
+
ts.forEachChild(node, collectReturns)
|
|
2908
|
+
}
|
|
2909
|
+
if (ts.isBlock(calculation.body)) collectReturns(calculation.body)
|
|
2910
|
+
if (ts.isBlock(calculation.body) && !ts.isReturnStatement(calculation.body.statements.at(-1))) fail(call.expression, "Reactive imported calculations must end with an unconditional return")
|
|
2911
|
+
if (!returns.length || returns.some(returned => !returned || !ts.isObjectLiteralExpression(returned))) fail(call.expression, "Reactive imported calculations must return a plain object")
|
|
2912
|
+
const fieldExists = returns.every(returned => returned.properties.some(property => ts.isSpreadAssignment(property) || !ts.isComputedPropertyName(property.name) && property.name.text === field))
|
|
2913
|
+
if (!fieldExists) fail(call.parent, `Reactive imported calculation does not return field ${JSON.stringify(field)}`)
|
|
2914
|
+
}
|
|
2915
|
+
const validateReactiveJsxExpression = (expression, allowedNames) => {
|
|
2916
|
+
const value = unwrapExpression(expression)
|
|
2917
|
+
const formatAccess = ts.isCallExpression(value) && !value.questionDotToken && ts.isPropertyAccessExpression(value.expression) && !value.expression.questionDotToken && value.expression.name.text === "format" ? value.expression : undefined
|
|
2918
|
+
const formatter = formatAccess && unwrapExpression(formatAccess.expression)
|
|
2919
|
+
const constructor = formatter && ts.isNewExpression(formatter) && ts.isPropertyAccessExpression(formatter.expression) && formatter.expression.name.text === "NumberFormat" && ts.isIdentifier(formatter.expression.expression) && formatter.expression.expression.text === "Intl" ? formatter : undefined
|
|
2920
|
+
if (!constructor) {
|
|
2921
|
+
const validate = node => {
|
|
2922
|
+
const current = unwrapExpression(node)
|
|
2923
|
+
if (ts.isPropertyAccessExpression(current) && ts.isCallExpression(unwrapExpression(current.expression))) {
|
|
2924
|
+
const call = unwrapExpression(current.expression)
|
|
2925
|
+
if (ts.isIdentifier(call.expression) && importBindings.has(call.expression.text) && importBindings.get(call.expression.text).kind !== "namespace") {
|
|
2926
|
+
validateImportedCalculation(call, current.name.text)
|
|
2927
|
+
for (const argument of call.arguments) collectionExpression(argument, {}, (target, message) => fail(target, message.replace("Rendered collection", "Reactive imported calculation")), allowedNames)
|
|
2928
|
+
return factory.createNumericLiteral(0)
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
return ts.visitEachChild(current, validate, context)
|
|
2932
|
+
}
|
|
2933
|
+
const normalized = ts.visitNode(value, validate)
|
|
2934
|
+
collectionExpression(normalized, {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
|
|
2935
|
+
return
|
|
2936
|
+
}
|
|
2937
|
+
const intl = constructor.expression.expression
|
|
2938
|
+
if (!isUnshadowedGlobal(intl, sourceFile)) fail(intl, "Reactive JSX Intl.NumberFormat requires the unshadowed global Intl object")
|
|
2939
|
+
if (constructor.arguments?.length !== 1 || !ts.isStringLiteral(constructor.arguments[0])) fail(constructor, "Reactive JSX Intl.NumberFormat requires exactly one static string locale")
|
|
2940
|
+
const rounded = value.arguments.length === 1 ? unwrapExpression(value.arguments[0]) : undefined
|
|
2941
|
+
const roundAccess = rounded && ts.isCallExpression(rounded) && !rounded.questionDotToken && rounded.arguments.length === 1 && ts.isPropertyAccessExpression(rounded.expression) && !rounded.expression.questionDotToken && rounded.expression.name.text === "round" && ts.isIdentifier(rounded.expression.expression) && rounded.expression.expression.text === "Math" ? rounded.expression : undefined
|
|
2942
|
+
if (!roundAccess) fail(value, "Reactive JSX Intl.NumberFormat format() requires exactly Math.round(expression)")
|
|
2943
|
+
if (!isUnshadowedGlobal(roundAccess.expression, sourceFile)) fail(roundAccess.expression, "Reactive JSX Intl.NumberFormat requires the unshadowed global Math object")
|
|
2944
|
+
collectionExpression(rounded.arguments[0], {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
|
|
2945
|
+
}
|
|
2725
2946
|
const resolveReactiveJsxExpression = (expression, owner, setters) => {
|
|
2726
2947
|
const declarations = jsxLocalDeclarations.get(owner)
|
|
2727
2948
|
if (!declarations) return expression
|
|
@@ -2758,7 +2979,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2758
2979
|
if (!usedStates.size) return expression
|
|
2759
2980
|
const captures = captureNames(expanded, expanded, setters)
|
|
2760
2981
|
const allowedNames = new Set([...setters.values(), ...captures])
|
|
2761
|
-
|
|
2982
|
+
validateReactiveJsxExpression(expanded, allowedNames)
|
|
2762
2983
|
return expanded
|
|
2763
2984
|
}
|
|
2764
2985
|
const componentSpecializations = new WeakMap()
|
|
@@ -2910,7 +3131,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2910
3131
|
}
|
|
2911
3132
|
const setters = new Map(parentSetters)
|
|
2912
3133
|
for (const state of aggregate.ordinaryStates) setters.set(state.setter, state.state)
|
|
2913
|
-
if (jsxSetterCallbackProps(node, setters,
|
|
3134
|
+
if (jsxSetterCallbackProps(node, setters, functionsForNode(node), reducersForNode(node, reducersByFunction)).length) fail(node, "Setter callbacks cannot cross a second component boundary")
|
|
2914
3135
|
const nested = specializeComponentCall(node, component, sourceFile, factory, context, fail, "Nested setter-callback", true, true, new Set(setters.values()))
|
|
2915
3136
|
if (dynamic && (nested.hookDeclarations.length || nested.effects.length)) fail(node, "Hookful nested setter-callback components require an unconditional or statically truthy render path")
|
|
2916
3137
|
nested.root = expandSetterComponents(nested.root, component.getSourceFile(), [...trail, component], nested, setters)
|
|
@@ -2990,7 +3211,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2990
3211
|
const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
|
|
2991
3212
|
for (const attribute of attributes.properties) {
|
|
2992
3213
|
if (!ts.isJsxAttribute(attribute) || !callbackProps.includes(attribute.name.text) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !ts.isIdentifier(attribute.initializer.expression)) continue
|
|
2993
|
-
const callback =
|
|
3214
|
+
const callback = functionsForNode(attribute).get(attribute.initializer.expression.text)
|
|
2994
3215
|
if (callback) substitutions.set(attribute.initializer.expression.text, callback)
|
|
2995
3216
|
}
|
|
2996
3217
|
if (substitutions.size) {
|
|
@@ -3039,14 +3260,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3039
3260
|
}
|
|
3040
3261
|
for (const [name, component] of components) {
|
|
3041
3262
|
for (const call of jsxTagUses(sourceFile, name)) {
|
|
3042
|
-
const callbackProps = jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(),
|
|
3263
|
+
const callbackProps = jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functionsForNode(call), reducersForNode(call, reducersByFunction))
|
|
3043
3264
|
if (callbackProps.length) specializeSetterCallbacks(call, component.function, callbackProps, false)
|
|
3044
3265
|
}
|
|
3045
3266
|
}
|
|
3046
3267
|
for (const [name, binding] of importBindings) {
|
|
3047
3268
|
if (binding.kind === "namespace") continue
|
|
3048
3269
|
const calls = jsxTagUses(sourceFile, name)
|
|
3049
|
-
const callbackCalls = calls.map(call => ({ call, callbackProps: jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(),
|
|
3270
|
+
const callbackCalls = calls.map(call => ({ call, callbackProps: jsxSetterCallbackProps(call, settersByFunction.get(nearestFunction(call)) ?? new Map(), functionsForNode(call), reducersForNode(call, reducersByFunction)) })).filter(entry => entry.callbackProps.length)
|
|
3050
3271
|
if (!callbackCalls.length) continue
|
|
3051
3272
|
const imported = binding.kind === "default" ? "default" : binding.imported
|
|
3052
3273
|
let component
|
|
@@ -3306,13 +3527,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3306
3527
|
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral("@kudzujs/core"), node.attributes)
|
|
3307
3528
|
}
|
|
3308
3529
|
|
|
3530
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && packageBindings.size && importDeclarationNames(node).some(name => packageBindings.has(name))) return undefined
|
|
3531
|
+
|
|
3309
3532
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
3533
|
+
if (!runtimeModuleReference(node)) return node
|
|
3310
3534
|
if (isStaticImport(node.moduleSpecifier.text)) return staticImportEntry(node, sourceFile, file, staticFiles, importedAssets, cssModules, base, factory)?.replacement
|
|
3311
3535
|
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
3312
3536
|
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
3313
3537
|
}
|
|
3314
3538
|
|
|
3315
3539
|
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
3540
|
+
if (!runtimeModuleReference(node)) return node
|
|
3316
3541
|
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
3317
3542
|
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
3318
3543
|
}
|
|
@@ -3462,6 +3687,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3462
3687
|
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createIdentifier("undefined"))
|
|
3463
3688
|
}
|
|
3464
3689
|
|
|
3690
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && importBindings.has(node.initializer.expression.text)) {
|
|
3691
|
+
const setters = settersForNode(node, settersByFunction)
|
|
3692
|
+
const stateNames = new Set(setters.values())
|
|
3693
|
+
const rewrite = current => {
|
|
3694
|
+
if (ts.isShorthandPropertyAssignment(current) && stateNames.has(current.name.text)) return factory.createPropertyAssignment(current.name, factory.createPropertyAccessExpression(current.name, "value"))
|
|
3695
|
+
if (ts.isIdentifier(current) && stateNames.has(current.text) && isReferenceIdentifier(current)) return factory.createPropertyAccessExpression(current, "value")
|
|
3696
|
+
return ts.visitEachChild(current, rewrite, context)
|
|
3697
|
+
}
|
|
3698
|
+
if (referencedStateNames(node.initializer, setters).size) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, ts.visitNode(node.initializer, rewrite))
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3465
3701
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && jsxLocalsByFunction.get(nearestFunction(node))?.has(node.name.text) && referencesIdentifier(nearestFunction(node).body, node.name.text)) {
|
|
3466
3702
|
const compiled = compileRenderExpression(node.initializer, node)
|
|
3467
3703
|
if (compiled !== node.initializer) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
|
|
@@ -3518,7 +3754,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3518
3754
|
if ((usedStates.size || captures.size) && !ts.isIdentifier(expression) && !containsJsx(expression)) {
|
|
3519
3755
|
usesBehavior = true
|
|
3520
3756
|
usesBinding = true
|
|
3521
|
-
return factory.updateJsxExpression(node, compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl))
|
|
3757
|
+
return factory.updateJsxExpression(node, compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports))
|
|
3522
3758
|
}
|
|
3523
3759
|
}
|
|
3524
3760
|
|
|
@@ -3531,14 +3767,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3531
3767
|
if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
|
|
3532
3768
|
usesBehavior = true
|
|
3533
3769
|
usesBinding = true
|
|
3534
|
-
const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
|
|
3770
|
+
const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports)
|
|
3535
3771
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
|
|
3536
3772
|
}
|
|
3537
3773
|
}
|
|
3538
3774
|
|
|
3539
3775
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.text)) {
|
|
3540
3776
|
const setters = settersForNode(node, settersByFunction)
|
|
3541
|
-
const event = compileEvent(node.initializer.expression, setters, reducersForNode(node, reducersByFunction),
|
|
3777
|
+
const event = compileEvent(node.initializer.expression, setters, reducersForNode(node, reducersByFunction), functionsForNode(node), factory, nativeHandlers, handlerUrl, listEventItems.get(node), new Map([...importBindings, ...packageBindings]), clientImports)
|
|
3542
3778
|
if (event) {
|
|
3543
3779
|
usesBehavior = true
|
|
3544
3780
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
|
|
@@ -4681,13 +4917,13 @@ function isJsxLocalValue(expression, known) {
|
|
|
4681
4917
|
return Boolean(parts && (isJsxLocalValue(parts.truthy, known) || isJsxLocalValue(parts.falsy, known)))
|
|
4682
4918
|
}
|
|
4683
4919
|
|
|
4684
|
-
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
4920
|
+
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
|
|
4685
4921
|
const parts = conditionalParts(expression)
|
|
4686
4922
|
const state = parts && directStateIdentifier(parts.condition, setters)
|
|
4687
4923
|
if (state && isPrimitiveDefaultLiteral(parts.truthy) && isPrimitiveDefaultLiteral(parts.falsy)) {
|
|
4688
4924
|
return factory.createCallExpression(factory.createIdentifier("__kSelect"), undefined, [state, parts.truthy, parts.falsy])
|
|
4689
4925
|
}
|
|
4690
|
-
return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl))
|
|
4926
|
+
return factory.createCallExpression(factory.createIdentifier("__kBinding"), undefined, compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports))
|
|
4691
4927
|
}
|
|
4692
4928
|
|
|
4693
4929
|
function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
@@ -4703,11 +4939,14 @@ function directStateIdentifier(expression, setters) {
|
|
|
4703
4939
|
return ts.isIdentifier(value) && new Set(setters.values()).has(value.text) ? value : undefined
|
|
4704
4940
|
}
|
|
4705
4941
|
|
|
4706
|
-
function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
4942
|
+
function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
|
|
4707
4943
|
const usedStates = referencedStateNames(expression, setters)
|
|
4708
|
-
const
|
|
4944
|
+
const importedNames = referencedImportedBindings(expression, importBindings)
|
|
4945
|
+
const imports = [...importedNames].map(name => importBindings.get(name))
|
|
4946
|
+
for (const entry of imports) if (!entry.package) clientImports.add(entry.target)
|
|
4947
|
+
const captures = new Set([...captureNames(expression, expression, setters)].filter(name => !importedNames.has(name)))
|
|
4709
4948
|
const exportName = `binding${reactiveBindings.length}`
|
|
4710
|
-
reactiveBindings.push({ exportName, expression, captures, states: usedStates })
|
|
4949
|
+
reactiveBindings.push({ exportName, expression, captures, states: usedStates, imports })
|
|
4711
4950
|
const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
4712
4951
|
factory.createStringLiteral(name),
|
|
4713
4952
|
factory.createIdentifier(name)
|
|
@@ -4780,7 +5019,7 @@ function compileNativeCallback(expression, setters, reducers, factory, entries,
|
|
|
4780
5019
|
const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
|
|
4781
5020
|
imports.push(...[...usedReducers].map(name => reducers.get(name).import).filter(Boolean))
|
|
4782
5021
|
const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
|
|
4783
|
-
for (const entry of imports) clientImports.add(entry.target)
|
|
5022
|
+
for (const entry of imports) if (!entry.package) clientImports.add(entry.target)
|
|
4784
5023
|
const usedStates = nativeStateNames(expression, setters)
|
|
4785
5024
|
const exportName = `${prefix}${entries.length}`
|
|
4786
5025
|
entries.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), reducers: new Map([...reducers].filter(([name]) => usedReducers.has(name))), snapshotNested })
|
|
@@ -4843,7 +5082,7 @@ function compileOptimizedEvent(expression, setters, factory) {
|
|
|
4843
5082
|
}
|
|
4844
5083
|
|
|
4845
5084
|
const nativeGlobals = new Set([
|
|
4846
|
-
"Array", "ArrayBuffer", "BigInt", "Boolean", "Date", "Error", "Event", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "Worker", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
|
|
5085
|
+
"Array", "ArrayBuffer", "BigInt", "Blob", "Boolean", "Date", "Error", "Event", "FormData", "Infinity", "Intl", "JSON", "Map", "Math", "NaN", "Number", "Object", "Promise", "Proxy", "RangeError", "ReferenceError", "Reflect", "RegExp", "Set", "String", "Symbol", "TypeError", "URL", "URLSearchParams", "WeakMap", "WeakSet", "WebSocket", "Worker", "atob", "btoa", "clearInterval", "clearTimeout", "console", "crypto", "document", "fetch", "globalThis", "history", "isFinite", "isNaN", "location", "navigator", "parseFloat", "parseInt", "queueMicrotask", "requestAnimationFrame", "setInterval", "setTimeout", "structuredClone", "undefined", "window"
|
|
4847
5086
|
])
|
|
4848
5087
|
|
|
4849
5088
|
function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
|
|
@@ -4928,7 +5167,7 @@ function referencedImportedBindings(expression, imports) {
|
|
|
4928
5167
|
if (ts.isIdentifier(node) && imports.has(node.text) && isReferenceIdentifier(node)) names.add(node.text)
|
|
4929
5168
|
ts.forEachChild(node, visit)
|
|
4930
5169
|
}
|
|
4931
|
-
visit(expression.body)
|
|
5170
|
+
visit(expression.body ?? expression)
|
|
4932
5171
|
return names
|
|
4933
5172
|
}
|
|
4934
5173
|
|
|
@@ -5100,18 +5339,61 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
|
|
|
5100
5339
|
return bindings
|
|
5101
5340
|
}
|
|
5102
5341
|
|
|
5342
|
+
function packageImportBindings(sourceFile) {
|
|
5343
|
+
const bindings = new Map()
|
|
5344
|
+
const rejectDynamic = node => {
|
|
5345
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
5346
|
+
const specifier = node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0]) ? node.arguments[0].text : null
|
|
5347
|
+
if (specifier === null) throw sourceNodeError(node, sourceFile, "Dynamic import specifiers are not supported")
|
|
5348
|
+
if (!specifier.startsWith(".")) throw sourceNodeError(node, sourceFile, `Dynamic package import ${JSON.stringify(specifier)} is not supported`)
|
|
5349
|
+
}
|
|
5350
|
+
ts.forEachChild(node, rejectDynamic)
|
|
5351
|
+
}
|
|
5352
|
+
rejectDynamic(sourceFile)
|
|
5353
|
+
for (const node of sourceFile.statements) {
|
|
5354
|
+
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) continue
|
|
5355
|
+
const target = node.moduleSpecifier.text
|
|
5356
|
+
if (!node.importClause) {
|
|
5357
|
+
if (!target.startsWith(".") && !["react", "react-router-dom", "@kudzujs/core"].includes(target) && !target.startsWith("@kudzujs/core/")) throw sourceNodeError(node, sourceFile, `Side-effect package import ${JSON.stringify(target)} is not supported`)
|
|
5358
|
+
continue
|
|
5359
|
+
}
|
|
5360
|
+
if (node.importClause.isTypeOnly) continue
|
|
5361
|
+
if (target.startsWith(".") || target.startsWith("node:") || target === "react" || target === "react-router-dom" || target === "@kudzujs/core" || target.startsWith("@kudzujs/core/")) continue
|
|
5362
|
+
if (node.importClause.name) bindings.set(node.importClause.name.text, { kind: "default", local: node.importClause.name.text, target, package: true })
|
|
5363
|
+
const named = node.importClause.namedBindings
|
|
5364
|
+
if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { kind: "namespace", local: named.name.text, target, package: true })
|
|
5365
|
+
if (named && ts.isNamedImports(named)) for (const entry of named.elements) if (!entry.isTypeOnly) bindings.set(entry.name.text, { kind: "named", imported: (entry.propertyName ?? entry.name).text, local: entry.name.text, target, package: true })
|
|
5366
|
+
}
|
|
5367
|
+
return bindings
|
|
5368
|
+
}
|
|
5369
|
+
|
|
5103
5370
|
function importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex) {
|
|
5104
|
-
|
|
5371
|
+
return new Set(importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex).keys())
|
|
5372
|
+
}
|
|
5373
|
+
|
|
5374
|
+
function importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex) {
|
|
5375
|
+
const collections = new Map()
|
|
5105
5376
|
for (const [name, binding] of clientImportBindings(sourceFile, file, sourceFiles)) {
|
|
5106
5377
|
if (binding.kind !== "named") continue
|
|
5107
5378
|
const imported = parseSourceFile(binding.target, sourceIndex.get(binding.target))
|
|
5108
5379
|
for (const statement of imported.statements) {
|
|
5109
5380
|
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
|
|
5110
5381
|
const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === binding.imported)
|
|
5111
|
-
if (declaration?.initializer && ts.isArrayLiteralExpression(unwrapExpression(declaration.initializer)) && isSerializableStateLiteral(declaration.initializer))
|
|
5382
|
+
if (declaration?.initializer && ts.isArrayLiteralExpression(unwrapExpression(declaration.initializer)) && isSerializableStateLiteral(declaration.initializer)) collections.set(name, unwrapExpression(declaration.initializer))
|
|
5112
5383
|
}
|
|
5113
5384
|
}
|
|
5114
|
-
return
|
|
5385
|
+
return collections
|
|
5386
|
+
}
|
|
5387
|
+
|
|
5388
|
+
function normalizeImportedStaticCollections(sourceFile, collections, factory, context) {
|
|
5389
|
+
if (!collections.size) return sourceFile
|
|
5390
|
+
const visitor = node => {
|
|
5391
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === "map" && ts.isIdentifier(node.expression) && collections.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
|
|
5392
|
+
return factory.updatePropertyAccessExpression(node, synthesizeTree(cloneAst(collections.get(node.expression.text), factory, context)), node.name)
|
|
5393
|
+
}
|
|
5394
|
+
return ts.visitEachChild(node, visitor, context)
|
|
5395
|
+
}
|
|
5396
|
+
return ts.visitNode(sourceFile, visitor)
|
|
5115
5397
|
}
|
|
5116
5398
|
|
|
5117
5399
|
function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
|
|
@@ -5200,7 +5482,7 @@ function printClientImports(entries, handlerPath) {
|
|
|
5200
5482
|
const groups = Map.groupBy(unique.values(), entry => entry.target)
|
|
5201
5483
|
const imports = []
|
|
5202
5484
|
for (const [target, group] of groups) {
|
|
5203
|
-
const specifier = relativeModulePath(handlerPath, clientModulePath(target))
|
|
5485
|
+
const specifier = group[0].package ? target : relativeModulePath(handlerPath, clientModulePath(target))
|
|
5204
5486
|
const defaults = group.filter(entry => entry.kind === "default")
|
|
5205
5487
|
const named = group.filter(entry => entry.kind === "named")
|
|
5206
5488
|
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)}`)
|
package/framework/core.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export function useReducer<State, Action>(reducer: Reducer<State, Action>, initi
|
|
|
13
13
|
export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
|
|
14
14
|
export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
|
|
15
15
|
export function useSearchParam(name: string): string | null
|
|
16
|
+
export function useSearchParamsWriter(): [undefined, undefined]
|
|
16
17
|
|
|
17
18
|
export interface RefObject<T> {
|
|
18
19
|
readonly current: T | null
|
|
@@ -96,6 +97,7 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
96
97
|
states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route"; internal?: true }>
|
|
97
98
|
params: Array<{ name: string; id: string }>
|
|
98
99
|
searchParams: Array<{ name: string; id: string }>
|
|
100
|
+
searchParamsWritable: boolean
|
|
99
101
|
events: Array<{
|
|
100
102
|
event: string
|
|
101
103
|
commands?: Array<[string, string, unknown]>
|
package/framework/core.mjs
CHANGED
|
@@ -139,6 +139,15 @@ export function useSearchParam(name) {
|
|
|
139
139
|
return signal
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
export function useSearchParamsWriter() {
|
|
143
|
+
if (!renderContext) throw new Error("useSearchParamsWriter() can only run while rendering a Kudzu component")
|
|
144
|
+
if (renderContext.renderScope === "layout") throw new Error("useSearchParamsWriter() is only supported in route scope")
|
|
145
|
+
renderContext.searchParamsWritable = true
|
|
146
|
+
renderContext.hasBehaviors = true
|
|
147
|
+
renderContext.hasParams = true
|
|
148
|
+
return [undefined, undefined]
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
function createSignal(id, value) {
|
|
143
152
|
return {
|
|
144
153
|
[signalMarker]: true,
|
|
@@ -432,7 +441,7 @@ function serializeCapture(name, value, seen) {
|
|
|
432
441
|
}
|
|
433
442
|
|
|
434
443
|
export async function renderPage(component, metadata = {}, props = {}, layout) {
|
|
435
|
-
renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
444
|
+
renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
436
445
|
|
|
437
446
|
try {
|
|
438
447
|
const page = { [routeScopeMarker]: true, component, props }
|
|
@@ -513,6 +522,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
|
|
|
513
522
|
states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
|
|
514
523
|
params: renderContext.paramEntries,
|
|
515
524
|
searchParams: renderContext.searchParamEntries,
|
|
525
|
+
searchParamsWritable: renderContext.searchParamsWritable,
|
|
516
526
|
events: renderContext.events,
|
|
517
527
|
effects: renderContext.effects,
|
|
518
528
|
bindings: renderContext.bindings,
|