@kudzujs/core 0.7.30 → 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 +26 -0
- package/framework/README.md +4 -2
- package/framework/build.mjs +314 -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,31 @@
|
|
|
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
|
+
|
|
3
29
|
## 0.7.30 - Reactive number formatting
|
|
4
30
|
|
|
5
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.
|
package/framework/README.md
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
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
|
|
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,13 +2886,52 @@ 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
|
+
}
|
|
2725
2915
|
const validateReactiveJsxExpression = (expression, allowedNames) => {
|
|
2726
2916
|
const value = unwrapExpression(expression)
|
|
2727
2917
|
const formatAccess = ts.isCallExpression(value) && !value.questionDotToken && ts.isPropertyAccessExpression(value.expression) && !value.expression.questionDotToken && value.expression.name.text === "format" ? value.expression : undefined
|
|
2728
2918
|
const formatter = formatAccess && unwrapExpression(formatAccess.expression)
|
|
2729
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
|
|
2730
2920
|
if (!constructor) {
|
|
2731
|
-
|
|
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)
|
|
2732
2935
|
return
|
|
2733
2936
|
}
|
|
2734
2937
|
const intl = constructor.expression.expression
|
|
@@ -2928,7 +3131,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
2928
3131
|
}
|
|
2929
3132
|
const setters = new Map(parentSetters)
|
|
2930
3133
|
for (const state of aggregate.ordinaryStates) setters.set(state.setter, state.state)
|
|
2931
|
-
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")
|
|
2932
3135
|
const nested = specializeComponentCall(node, component, sourceFile, factory, context, fail, "Nested setter-callback", true, true, new Set(setters.values()))
|
|
2933
3136
|
if (dynamic && (nested.hookDeclarations.length || nested.effects.length)) fail(node, "Hookful nested setter-callback components require an unconditional or statically truthy render path")
|
|
2934
3137
|
nested.root = expandSetterComponents(nested.root, component.getSourceFile(), [...trail, component], nested, setters)
|
|
@@ -3008,7 +3211,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3008
3211
|
const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
|
|
3009
3212
|
for (const attribute of attributes.properties) {
|
|
3010
3213
|
if (!ts.isJsxAttribute(attribute) || !callbackProps.includes(attribute.name.text) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !ts.isIdentifier(attribute.initializer.expression)) continue
|
|
3011
|
-
const callback =
|
|
3214
|
+
const callback = functionsForNode(attribute).get(attribute.initializer.expression.text)
|
|
3012
3215
|
if (callback) substitutions.set(attribute.initializer.expression.text, callback)
|
|
3013
3216
|
}
|
|
3014
3217
|
if (substitutions.size) {
|
|
@@ -3057,14 +3260,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3057
3260
|
}
|
|
3058
3261
|
for (const [name, component] of components) {
|
|
3059
3262
|
for (const call of jsxTagUses(sourceFile, name)) {
|
|
3060
|
-
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))
|
|
3061
3264
|
if (callbackProps.length) specializeSetterCallbacks(call, component.function, callbackProps, false)
|
|
3062
3265
|
}
|
|
3063
3266
|
}
|
|
3064
3267
|
for (const [name, binding] of importBindings) {
|
|
3065
3268
|
if (binding.kind === "namespace") continue
|
|
3066
3269
|
const calls = jsxTagUses(sourceFile, name)
|
|
3067
|
-
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)
|
|
3068
3271
|
if (!callbackCalls.length) continue
|
|
3069
3272
|
const imported = binding.kind === "default" ? "default" : binding.imported
|
|
3070
3273
|
let component
|
|
@@ -3324,13 +3527,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3324
3527
|
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral("@kudzujs/core"), node.attributes)
|
|
3325
3528
|
}
|
|
3326
3529
|
|
|
3530
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && packageBindings.size && importDeclarationNames(node).some(name => packageBindings.has(name))) return undefined
|
|
3531
|
+
|
|
3327
3532
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
3533
|
+
if (!runtimeModuleReference(node)) return node
|
|
3328
3534
|
if (isStaticImport(node.moduleSpecifier.text)) return staticImportEntry(node, sourceFile, file, staticFiles, importedAssets, cssModules, base, factory)?.replacement
|
|
3329
3535
|
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
3330
3536
|
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
3331
3537
|
}
|
|
3332
3538
|
|
|
3333
3539
|
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
3540
|
+
if (!runtimeModuleReference(node)) return node
|
|
3334
3541
|
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
3335
3542
|
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
3336
3543
|
}
|
|
@@ -3480,6 +3687,17 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3480
3687
|
return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createIdentifier("undefined"))
|
|
3481
3688
|
}
|
|
3482
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
|
+
|
|
3483
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)) {
|
|
3484
3702
|
const compiled = compileRenderExpression(node.initializer, node)
|
|
3485
3703
|
if (compiled !== node.initializer) return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, compiled)
|
|
@@ -3536,7 +3754,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3536
3754
|
if ((usedStates.size || captures.size) && !ts.isIdentifier(expression) && !containsJsx(expression)) {
|
|
3537
3755
|
usesBehavior = true
|
|
3538
3756
|
usesBinding = true
|
|
3539
|
-
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))
|
|
3540
3758
|
}
|
|
3541
3759
|
}
|
|
3542
3760
|
|
|
@@ -3549,14 +3767,14 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
3549
3767
|
if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
|
|
3550
3768
|
usesBehavior = true
|
|
3551
3769
|
usesBinding = true
|
|
3552
|
-
const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl)
|
|
3770
|
+
const compiled = compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings, clientImports)
|
|
3553
3771
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled))
|
|
3554
3772
|
}
|
|
3555
3773
|
}
|
|
3556
3774
|
|
|
3557
3775
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.text)) {
|
|
3558
3776
|
const setters = settersForNode(node, settersByFunction)
|
|
3559
|
-
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)
|
|
3560
3778
|
if (event) {
|
|
3561
3779
|
usesBehavior = true
|
|
3562
3780
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
|
|
@@ -4699,13 +4917,13 @@ function isJsxLocalValue(expression, known) {
|
|
|
4699
4917
|
return Boolean(parts && (isJsxLocalValue(parts.truthy, known) || isJsxLocalValue(parts.falsy, known)))
|
|
4700
4918
|
}
|
|
4701
4919
|
|
|
4702
|
-
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
4920
|
+
function compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
|
|
4703
4921
|
const parts = conditionalParts(expression)
|
|
4704
4922
|
const state = parts && directStateIdentifier(parts.condition, setters)
|
|
4705
4923
|
if (state && isPrimitiveDefaultLiteral(parts.truthy) && isPrimitiveDefaultLiteral(parts.falsy)) {
|
|
4706
4924
|
return factory.createCallExpression(factory.createIdentifier("__kSelect"), undefined, [state, parts.truthy, parts.falsy])
|
|
4707
4925
|
}
|
|
4708
|
-
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))
|
|
4709
4927
|
}
|
|
4710
4928
|
|
|
4711
4929
|
function compileConditional(kind, expression, truthy, falsy, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
@@ -4721,11 +4939,14 @@ function directStateIdentifier(expression, setters) {
|
|
|
4721
4939
|
return ts.isIdentifier(value) && new Set(setters.values()).has(value.text) ? value : undefined
|
|
4722
4940
|
}
|
|
4723
4941
|
|
|
4724
|
-
function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl) {
|
|
4942
|
+
function compileReactiveExpression(expression, setters, factory, context, reactiveBindings, handlerUrl, importBindings = new Map(), clientImports = new Set()) {
|
|
4725
4943
|
const usedStates = referencedStateNames(expression, setters)
|
|
4726
|
-
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)))
|
|
4727
4948
|
const exportName = `binding${reactiveBindings.length}`
|
|
4728
|
-
reactiveBindings.push({ exportName, expression, captures, states: usedStates })
|
|
4949
|
+
reactiveBindings.push({ exportName, expression, captures, states: usedStates, imports })
|
|
4729
4950
|
const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
4730
4951
|
factory.createStringLiteral(name),
|
|
4731
4952
|
factory.createIdentifier(name)
|
|
@@ -4798,7 +5019,7 @@ function compileNativeCallback(expression, setters, reducers, factory, entries,
|
|
|
4798
5019
|
const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
|
|
4799
5020
|
imports.push(...[...usedReducers].map(name => reducers.get(name).import).filter(Boolean))
|
|
4800
5021
|
const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
|
|
4801
|
-
for (const entry of imports) clientImports.add(entry.target)
|
|
5022
|
+
for (const entry of imports) if (!entry.package) clientImports.add(entry.target)
|
|
4802
5023
|
const usedStates = nativeStateNames(expression, setters)
|
|
4803
5024
|
const exportName = `${prefix}${entries.length}`
|
|
4804
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 })
|
|
@@ -4861,7 +5082,7 @@ function compileOptimizedEvent(expression, setters, factory) {
|
|
|
4861
5082
|
}
|
|
4862
5083
|
|
|
4863
5084
|
const nativeGlobals = new Set([
|
|
4864
|
-
"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"
|
|
4865
5086
|
])
|
|
4866
5087
|
|
|
4867
5088
|
function rewriteEffectWorkers(callback, file, sourceFile, sourceFiles, workerReferences, factory, context) {
|
|
@@ -4946,7 +5167,7 @@ function referencedImportedBindings(expression, imports) {
|
|
|
4946
5167
|
if (ts.isIdentifier(node) && imports.has(node.text) && isReferenceIdentifier(node)) names.add(node.text)
|
|
4947
5168
|
ts.forEachChild(node, visit)
|
|
4948
5169
|
}
|
|
4949
|
-
visit(expression.body)
|
|
5170
|
+
visit(expression.body ?? expression)
|
|
4950
5171
|
return names
|
|
4951
5172
|
}
|
|
4952
5173
|
|
|
@@ -5118,18 +5339,61 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
|
|
|
5118
5339
|
return bindings
|
|
5119
5340
|
}
|
|
5120
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
|
+
|
|
5121
5370
|
function importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex) {
|
|
5122
|
-
|
|
5371
|
+
return new Set(importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex).keys())
|
|
5372
|
+
}
|
|
5373
|
+
|
|
5374
|
+
function importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex) {
|
|
5375
|
+
const collections = new Map()
|
|
5123
5376
|
for (const [name, binding] of clientImportBindings(sourceFile, file, sourceFiles)) {
|
|
5124
5377
|
if (binding.kind !== "named") continue
|
|
5125
5378
|
const imported = parseSourceFile(binding.target, sourceIndex.get(binding.target))
|
|
5126
5379
|
for (const statement of imported.statements) {
|
|
5127
5380
|
if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
|
|
5128
5381
|
const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === binding.imported)
|
|
5129
|
-
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))
|
|
5130
5383
|
}
|
|
5131
5384
|
}
|
|
5132
|
-
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)
|
|
5133
5397
|
}
|
|
5134
5398
|
|
|
5135
5399
|
function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
|
|
@@ -5218,7 +5482,7 @@ function printClientImports(entries, handlerPath) {
|
|
|
5218
5482
|
const groups = Map.groupBy(unique.values(), entry => entry.target)
|
|
5219
5483
|
const imports = []
|
|
5220
5484
|
for (const [target, group] of groups) {
|
|
5221
|
-
const specifier = relativeModulePath(handlerPath, clientModulePath(target))
|
|
5485
|
+
const specifier = group[0].package ? target : relativeModulePath(handlerPath, clientModulePath(target))
|
|
5222
5486
|
const defaults = group.filter(entry => entry.kind === "default")
|
|
5223
5487
|
const named = group.filter(entry => entry.kind === "named")
|
|
5224
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,
|