@kudzujs/core 0.4.13 → 0.5.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 +98 -11
- package/framework/README.md +3 -3
- package/framework/binding-runtime.js +52 -4
- package/framework/build.mjs +300 -35
- package/framework/core.d.ts +6 -4
- package/framework/core.mjs +40 -15
- package/framework/list-runtime.js +12 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,43 @@ npm run dev
|
|
|
79
79
|
|
|
80
80
|
Pages live in `src/pages`; `index.tsx` maps to `/`. `npm run dev` serves locally on `127.0.0.1`, reloads the browser after successful rebuilds, and shows build failures in an error overlay. Across that full-page reload, compatible Kudzu logical state is briefly preserved by route-unique state variable name for the current pathname, query, and hash, including controlled properties, conditions, and keyed-list arrays. Renamed, removed, and duplicate-named state is skipped. Uncontrolled DOM state, focus, selection, and imperative DOM mutations are not preserved. Set `PORT` to change the default port of `3000`. The development client and state snapshot are dev-only; production output in `dist/` is unaffected.
|
|
81
81
|
|
|
82
|
+
Dynamic static pages use bracket parameters and `getStaticPaths()`:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
// src/pages/posts/[slug].tsx
|
|
86
|
+
export async function getStaticPaths() {
|
|
87
|
+
return [
|
|
88
|
+
{ params: { slug: "oak" }, props: { title: "Oak" } },
|
|
89
|
+
{ params: { slug: "pine" }, props: { title: "Pine" } }
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default function Post({ title }: { title: string }) {
|
|
94
|
+
return <h1>{title}</h1>
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This emits `/posts/oak` and `/posts/pine`. Parameter values must be safe single path segments; missing, unsafe, and duplicate routes fail the build.
|
|
99
|
+
|
|
100
|
+
Static trusted HTML can be rendered without a transform layer:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<article dangerouslySetInnerHTML={{ __html: renderedNotionHtml }} />
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The HTML is intentionally not sanitized. Use only trusted or previously sanitized build-time content. Reactive raw HTML, children on the same element, void elements, and keyed-list raw HTML are rejected.
|
|
107
|
+
|
|
108
|
+
Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
export default {
|
|
112
|
+
base: "/newsletter",
|
|
113
|
+
async afterBuild({ outDir, routes, plans, base }) {
|
|
114
|
+
// Write RSS, sitemap, search indexes, or other static artifacts.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
82
119
|
## State Semantics
|
|
83
120
|
|
|
84
121
|
Kudzu intentionally differs from React's state snapshot behavior:
|
|
@@ -106,6 +143,8 @@ const [weather, setWeather] = useState({ temperature: 28, label: "Warm" })
|
|
|
106
143
|
return <p>{weather.temperature}° {weather.label}</p>
|
|
107
144
|
```
|
|
108
145
|
|
|
146
|
+
Derived text uses comment-bounded text nodes rather than wrapper elements, so table cells, options, SVG text, layout, and element selectors keep their authored structure.
|
|
147
|
+
|
|
109
148
|
## Reactive Attributes
|
|
110
149
|
|
|
111
150
|
`className`, `disabled`, controlled `value`, and controlled `checked` accept normal state-dependent TSX expressions. The same `value` binding works for inputs and selects:
|
|
@@ -247,7 +286,18 @@ async function load() {
|
|
|
247
286
|
}
|
|
248
287
|
```
|
|
249
288
|
|
|
250
|
-
|
|
289
|
+
Native handlers may call default, named, or namespace helpers imported from relative TypeScript modules. Kudzu bundles the reachable helper graph into handler ESM and shared chunks; helper runtime imports must remain relative, and dynamic imports or JSX helpers are rejected. Imported functions cannot be used directly as JSX event callbacks.
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
import { normalizeStatus } from "../lib/status"
|
|
293
|
+
|
|
294
|
+
async function load() {
|
|
295
|
+
const response = await fetch("/api/status")
|
|
296
|
+
setStatus(normalizeStatus(await response.json()))
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, and class instances are not supported as captures.
|
|
251
301
|
|
|
252
302
|
Native handlers use direct DOM listeners with normal `currentTarget`, bubbling, default-action, and propagation semantics. Handler modules load before listener registration, so `preventDefault`, `stopPropagation`, and `stopImmediatePropagation` work synchronously as expected.
|
|
253
303
|
|
|
@@ -283,8 +333,12 @@ Supported:
|
|
|
283
333
|
- Function components, props, children, fragments, and TSX
|
|
284
334
|
- File-based static routes
|
|
285
335
|
- Build-time async components
|
|
336
|
+
- Dynamic static routes with build-time props
|
|
337
|
+
- Static trusted `dangerouslySetInnerHTML`
|
|
338
|
+
- Base-path deployments, multiple CSS files, and `afterBuild`
|
|
286
339
|
- Primitive `useState` bindings
|
|
287
340
|
- Synchronous and async event handlers
|
|
341
|
+
- Relative imported helpers in native handlers
|
|
288
342
|
- Serializable component-local captures
|
|
289
343
|
- Direct text DOM patches
|
|
290
344
|
- Reactive standard, `aria-*`, and `data-*` attributes
|
|
@@ -300,7 +354,7 @@ Not implemented yet:
|
|
|
300
354
|
|
|
301
355
|
- Block-scoped JSX locals and reusable keyed-list aliases
|
|
302
356
|
- Server actions and request-time SSR
|
|
303
|
-
-
|
|
357
|
+
- React package islands
|
|
304
358
|
- HMR and framework DevTools
|
|
305
359
|
|
|
306
360
|
## Benchmarks
|
|
@@ -323,6 +377,39 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
323
377
|
|
|
324
378
|
Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
|
|
325
379
|
|
|
380
|
+
#### Imported Helper Cost
|
|
381
|
+
|
|
382
|
+
The same native counter calculation was measured inline and through one relative TypeScript helper. Click medians are per state update from five 20,000-click batches in each of seven fresh Chrome sessions.
|
|
383
|
+
|
|
384
|
+
| Kudzu variant | Files | Initial JS gzip | Total output | Clean build | Click |
|
|
385
|
+
|---|---:|---:|---:|---:|---:|
|
|
386
|
+
| Inline native handler | 5 | 1,827 B | 3,923 B | **426 ms** | **3.78 µs** |
|
|
387
|
+
| Imported helper | 5 | 1,845 B | 3,949 B | 446 ms | 4.47 µs |
|
|
388
|
+
|
|
389
|
+
Bundling removes the helper file boundary, leaving 26 raw bytes and 18 gzip bytes for the function definition and calls. The measured call adds 0.69 µs per state update. The smaller 393 B command-only counter above uses a different optimized runtime path and is not the helper overhead baseline.
|
|
390
|
+
|
|
391
|
+
#### Wrapper-Free Derived Text
|
|
392
|
+
|
|
393
|
+
The same object-state counter was built with the v0.4.14 span target and the comment-bounded text range. Browser medians use five 20,000-update batches in each of seven fresh Chrome sessions.
|
|
394
|
+
|
|
395
|
+
| Text target | Files | JS gzip | Total output | Clean build | Update |
|
|
396
|
+
|---|---:|---:|---:|---:|---:|
|
|
397
|
+
| Span v0.4.14 | 7 | **4,453 B** | **10,065 B** | **404 ms** | **4.83 µs** |
|
|
398
|
+
| Comment range | 7 | 4,763 B | 10,922 B | 426 ms | 5.03 µs |
|
|
399
|
+
|
|
400
|
+
The range costs 310 B gzip only on pages using derived reactive text. It removes wrapper elements and preserves authored structure across table cells, options, SVG text, selectors, and conditional remounts; ordinary attribute and condition pages tree-shake the range code entirely.
|
|
401
|
+
|
|
402
|
+
### 123-Page Newsletter Build
|
|
403
|
+
|
|
404
|
+
The migration fixture emits the same 123 static detail pages, two stylesheets, base-prefixed URLs, and post-build feed with no browser JavaScript. Seven clean builds compare generated page files with one dynamic page module.
|
|
405
|
+
|
|
406
|
+
| Build model | TSX source files | Pages | JS gzip | Total output | Clean build |
|
|
407
|
+
|---|---:|---:|---:|---:|---:|
|
|
408
|
+
| Generated TSX workaround | 123 | 123 | 0 B | 52.0 KB | 882 ms |
|
|
409
|
+
| `getStaticPaths` | **1** | 123 | 0 B | 52.0 KB | **454 ms** |
|
|
410
|
+
|
|
411
|
+
`getStaticPaths` removes 122 generated source files and cuts clean build time by 48.5% without changing deploy output or runtime cost.
|
|
412
|
+
|
|
326
413
|
### Static Journal Page
|
|
327
414
|
|
|
328
415
|
Same content and CSS across every fixture:
|
|
@@ -343,15 +430,15 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
|
|
|
343
430
|
|
|
344
431
|
| Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
|
|
345
432
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
346
|
-
| Astro | Yes | **324 B** | **43.6 KB** |
|
|
347
|
-
| Kudzu | Yes | 5.
|
|
348
|
-
| Next.js | Yes | 182.2 KB | 695.2 KB |
|
|
349
|
-
|
|
|
350
|
-
|
|
|
351
|
-
| Svelte CSR | No | 12.9 KB | 33.1 KB |
|
|
352
|
-
| Qwik CSR | No | 22.2 KB | 64.1 KB |
|
|
353
|
-
|
|
354
|
-
Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total
|
|
433
|
+
| Astro | Yes | **324 B** | **43.6 KB** | 834 ms | **4.3 ms** | **3.8 ms** | **1.3 ms** | **3.1 ms** | **12.5 ms** |
|
|
434
|
+
| Kudzu | Yes | 5.1 KB | 60.3 KB | **438 ms** | 7.5 ms | 7.0 ms | 1.8 ms | 6.9 ms | 23.2 ms |
|
|
435
|
+
| Next.js | Yes | 182.2 KB | 695.2 KB | 2983 ms | 7.0 ms | 12.0 ms | 3.9 ms | 6.7 ms | 29.6 ms |
|
|
436
|
+
| React CSR | No | 59.3 KB | 189.4 KB | 1020 ms | 9.5 ms | 11.7 ms | 3.8 ms | 5.3 ms | 30.3 ms |
|
|
437
|
+
| Vue CSR | No | 24.3 KB | 61.3 KB | 773 ms | 11.4 ms | 9.5 ms | 4.1 ms | 6.6 ms | 31.6 ms |
|
|
438
|
+
| Svelte CSR | No | 12.9 KB | 33.1 KB | 828 ms | 5.8 ms | 38.9 ms | 4.0 ms | 5.9 ms | 54.6 ms |
|
|
439
|
+
| Qwik CSR | No | 22.2 KB | 64.1 KB | 594 ms | 9.1 ms | 22.2 ms | 30.8 ms | 19.0 ms | 81.1 ms |
|
|
440
|
+
|
|
441
|
+
Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 23.2 ms, 10.7 ms behind the hand-authored Astro baseline and 7.1 ms ahead of React across all four operations.
|
|
355
442
|
|
|
356
443
|
Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
|
|
357
444
|
|
package/framework/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# Framework Internals
|
|
2
2
|
|
|
3
|
-
- `build.mjs`: TSX compilation,
|
|
3
|
+
- `build.mjs`: TSX compilation, static and `getStaticPaths` routes, base paths, CSS collection, post-build hooks, behavior extraction, static HTML output, and the development server.
|
|
4
4
|
- `core.mjs`: server-side JSX rendering, state slots, context providers, behavior metadata, and serializable capture validation.
|
|
5
5
|
- `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
|
|
6
6
|
- `runtime.js`: command-only runtime for direct state-to-text patches.
|
|
7
7
|
- `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
|
|
8
|
-
- `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
|
|
8
|
+
- `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
|
|
9
9
|
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
|
|
10
10
|
- `serialization.js`: capture deserialization shared by binding and native handlers.
|
|
11
11
|
- `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
|
|
12
12
|
- `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
|
|
13
13
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
14
14
|
|
|
15
|
-
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators live under `dist/assets/handlers
|
|
15
|
+
Static routes receive no browser runtime. Command routes receive `runtime.js`; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`. Capability runtimes share state and lifecycle hooks through `shared-runtime.js`. Generated evaluators and their bundled relative TypeScript helpers live under `dist/assets/handlers/`; shared helper chunks are emitted only when multiple handler entries need them. The dev server derives stable state identities from route-unique state variable names in each route plan; every state sharing a duplicate name is omitted. It then injects its SSE reload, short-lived full-URL-scoped logical-state snapshot, and build-error client into responses only, never into `dist/`. Snapshots are consumed even when the next page is static or broken. Reload restoration covers compatible framework state, not uncontrolled DOM state, focus, selection, or imperative mutations.
|
|
16
16
|
|
|
17
17
|
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
|
@@ -9,13 +9,13 @@ const mountedBindings = new WeakSet()
|
|
|
9
9
|
const mountedConditions = new WeakSet()
|
|
10
10
|
const bindingRegistrations = new WeakMap()
|
|
11
11
|
const conditionRegistrations = new WeakMap()
|
|
12
|
-
const
|
|
12
|
+
const textDescriptors = globalThis.__KUDZU_TEXT_BINDINGS__ && typeof document !== "undefined" ? JSON.parse(document.body.dataset.kTextBindings ?? "[]") : []
|
|
13
|
+
const bindingTypes = ["class", "disabled", "value", "checked", "style"]
|
|
13
14
|
const bindingSelector = [...bindingTypes.map(target => `[data-k-bind-${target}]`), "[data-k-bind-attrs]"].join(",")
|
|
14
15
|
|
|
15
16
|
export function patchBinding(node, target, value) {
|
|
16
|
-
if (target === "text") {
|
|
17
|
-
|
|
18
|
-
if (node.textContent !== next) node.textContent = next
|
|
17
|
+
if (globalThis.__KUDZU_TEXT_BINDINGS__ && target === "text") {
|
|
18
|
+
patchText(node, value)
|
|
19
19
|
} else if (target === "disabled") {
|
|
20
20
|
node.toggleAttribute("disabled", Boolean(value))
|
|
21
21
|
} else if (target === "checked") {
|
|
@@ -94,6 +94,25 @@ function mountBindings(root) {
|
|
|
94
94
|
}).catch(error => console.error(error))
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
|
+
if (globalThis.__KUDZU_TEXT_BINDINGS__) {
|
|
98
|
+
for (const node of textBindingStarts(root)) {
|
|
99
|
+
if (mountedBindings.has(node)) continue
|
|
100
|
+
const descriptor = textDescriptors[Number(node.data.slice("k-text:".length))]
|
|
101
|
+
if (!descriptor) continue
|
|
102
|
+
mountedBindings.add(node)
|
|
103
|
+
const registrations = []
|
|
104
|
+
bindingRegistrations.set(node, registrations)
|
|
105
|
+
loadEvaluator(descriptor).then(evaluator => {
|
|
106
|
+
if (!node.isConnected) return
|
|
107
|
+
const binding = { node, target: "text", read: evaluator.read }
|
|
108
|
+
for (const id of evaluator.stateIds) {
|
|
109
|
+
register(bindingTargets, id, binding)
|
|
110
|
+
registrations.push([id, binding])
|
|
111
|
+
}
|
|
112
|
+
patchBinding(node, "text", binding.read())
|
|
113
|
+
}).catch(error => console.error(error))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
97
116
|
}
|
|
98
117
|
|
|
99
118
|
function mountConditions(root) {
|
|
@@ -146,6 +165,13 @@ function unmountBindings(root) {
|
|
|
146
165
|
bindingRegistrations.delete(node)
|
|
147
166
|
mountedBindings.delete(node)
|
|
148
167
|
}
|
|
168
|
+
if (globalThis.__KUDZU_TEXT_BINDINGS__) {
|
|
169
|
+
for (const node of textBindingStarts(root)) {
|
|
170
|
+
for (const [id, binding] of bindingRegistrations.get(node) ?? []) bindingTargets.get(id)?.delete(binding)
|
|
171
|
+
bindingRegistrations.delete(node)
|
|
172
|
+
mountedBindings.delete(node)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
function unmountConditions(root) {
|
|
@@ -227,6 +253,28 @@ function matching(root, selector) {
|
|
|
227
253
|
return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
|
|
228
254
|
}
|
|
229
255
|
|
|
256
|
+
function textBindingStarts(root) {
|
|
257
|
+
const nodes = root.nodeType === 8 && root.data.startsWith("k-text:") ? [root] : []
|
|
258
|
+
const walker = (root.ownerDocument ?? root).createTreeWalker?.(root, 128)
|
|
259
|
+
while (walker?.nextNode()) if (walker.currentNode.data.startsWith("k-text:")) nodes.push(walker.currentNode)
|
|
260
|
+
return nodes
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function patchText(start, value) {
|
|
264
|
+
const next = value == null ? "" : String(value)
|
|
265
|
+
const current = start.nextSibling
|
|
266
|
+
const text = current?.nodeType === 3 ? current : undefined
|
|
267
|
+
const end = text ? text.nextSibling : current
|
|
268
|
+
if (end?.nodeType !== 8 || end.data !== "k-text-end") throw new Error("Reactive text marker has no end")
|
|
269
|
+
if (text) {
|
|
270
|
+
if (next) {
|
|
271
|
+
if (text.data !== next) text.data = next
|
|
272
|
+
} else text.remove()
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (next) end.before(start.ownerDocument.createTextNode(next))
|
|
276
|
+
}
|
|
277
|
+
|
|
230
278
|
function capitalize(value) {
|
|
231
279
|
return value[0].toUpperCase() + value.slice(1)
|
|
232
280
|
}
|
package/framework/build.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createServer } from "node:http"
|
|
2
2
|
import { randomUUID } from "node:crypto"
|
|
3
3
|
import { cp, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises"
|
|
4
|
-
import { extname, join, relative, resolve, sep } from "node:path"
|
|
4
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path"
|
|
5
5
|
import { pathToFileURL } from "node:url"
|
|
6
|
-
import { transform } from "esbuild"
|
|
6
|
+
import { build as bundle, transform } from "esbuild"
|
|
7
7
|
import ts from "typescript"
|
|
8
8
|
import { renderPage } from "./core.mjs"
|
|
9
9
|
import { stateSchema } from "./dev-state.js"
|
|
@@ -17,17 +17,22 @@ const outputDirectory = join(root, "dist")
|
|
|
17
17
|
const devClient = (session, revision, schema) => `<script>(()=>{const show=event=>{let box=document.getElementById("__kudzu_error");if(!box){box=document.createElement("div");box.id="__kudzu_error";box.setAttribute("role","alert");box.setAttribute("aria-live","assertive");box.style.cssText="position:fixed;inset:0;z-index:2147483647;overflow:auto;padding:2rem;background:#200;color:#fff;font:16px/1.5 ui-monospace,monospace";const title=document.createElement("strong"),text=document.createElement("pre");title.textContent="Kudzu build error";text.style.whiteSpace="pre-wrap";box.append(title,text);document.body.append(box)}box.querySelector("pre").textContent=event.data};const schema=${inlineJson(schema)},route=location.pathname+location.search+location.hash,urls=[...document.querySelectorAll('script[type="module"][src]')].map(node=>node.src).filter(url=>/\\/assets\\/kudzu(?:-(?:binding|list|native))?\\.js$/.test(new URL(url).pathname));const devImport=import("/__kudzu_dev.js"),runtimeImports=Promise.allSettled(urls.map(url=>import(url)));const ready=(async()=>{const dev=await devImport,modules=await runtimeImports,runtime=modules.find(result=>result.status==="fulfilled"&&result.value.browserState instanceof Map&&typeof result.value.commitDom==="function")?.value;try{dev.restoreState(sessionStorage,route,runtime?.browserState,schema,runtime?.commitDom)}catch{}return{dev,runtime}})().catch(()=>({}));const events=new EventSource("/__kudzu_reload?session=${session}&revision=${revision}");let reloading=false;events.addEventListener("reload",async()=>{if(reloading)return;reloading=true;try{const{dev,runtime}=await ready;dev?.snapshotState(sessionStorage,route,runtime?.browserState,schema)}catch{}location.reload()});events.addEventListener("build-error",show)})()</script>`
|
|
18
18
|
|
|
19
19
|
export async function build({ quiet = false, minify = true } = {}) {
|
|
20
|
+
const config = await loadConfig()
|
|
21
|
+
const base = normalizeBase(config.base)
|
|
20
22
|
await rm(workDirectory, { recursive: true, force: true })
|
|
21
23
|
await rm(outputDirectory, { recursive: true, force: true })
|
|
22
24
|
await mkdir(workDirectory, { recursive: true })
|
|
23
25
|
await mkdir(outputDirectory, { recursive: true })
|
|
24
26
|
|
|
25
|
-
const
|
|
27
|
+
const projectFiles = await walk(sourceDirectory)
|
|
28
|
+
const sourceFiles = projectFiles.filter(file => /\.(?:ts|tsx)$/.test(file)).sort()
|
|
29
|
+
const cssFiles = projectFiles.filter(file => file.endsWith(".css")).sort()
|
|
26
30
|
if (!sourceFiles.length) throw new Error("No TypeScript files found in src/")
|
|
31
|
+
const sourceFileSet = new Set(sourceFiles)
|
|
27
32
|
|
|
28
33
|
const handlerModules = []
|
|
29
34
|
for (const file of sourceFiles) {
|
|
30
|
-
const handlerModule = await compile(file)
|
|
35
|
+
const handlerModule = await compile(file, sourceFileSet, base)
|
|
31
36
|
if (handlerModule) handlerModules.push(handlerModule)
|
|
32
37
|
}
|
|
33
38
|
|
|
@@ -40,34 +45,44 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
40
45
|
let listStyleCount = 0
|
|
41
46
|
let stateSeedCount = 0
|
|
42
47
|
const plans = []
|
|
43
|
-
const
|
|
48
|
+
const emittedRoutes = new Set()
|
|
49
|
+
const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
|
|
44
50
|
|
|
45
51
|
for (const pageFile of pageFiles) {
|
|
46
52
|
const compiledFile = compiledPath(pageFile)
|
|
47
53
|
const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
|
|
48
54
|
if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
|
|
49
55
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
const entries = await staticPathEntries(module, pageFile)
|
|
57
|
+
for (const { params, props } of entries) {
|
|
58
|
+
const route = routeFromPage(pageFile, params)
|
|
59
|
+
const routePath = withBase(base, `/${route}`)
|
|
60
|
+
if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
|
|
61
|
+
emittedRoutes.add(routePath)
|
|
62
|
+
const result = await renderPage(module.default, {
|
|
63
|
+
...(module.metadata ?? {}),
|
|
64
|
+
styles: styleUrls.length ? styleUrls : false,
|
|
65
|
+
base
|
|
66
|
+
}, props)
|
|
67
|
+
const routeDirectory = join(outputDirectory, route)
|
|
68
|
+
await mkdir(routeDirectory, { recursive: true })
|
|
69
|
+
await writeFile(join(routeDirectory, "index.html"), result.html)
|
|
70
|
+
plans.push({ route: routePath, ...result.plan })
|
|
71
|
+
if (result.hasBehaviors) behaviorCount++
|
|
72
|
+
if (result.hasBindings) bindingCount++
|
|
73
|
+
if (result.hasLists) listCount++
|
|
74
|
+
if (result.hasListStyles) listStyleCount++
|
|
75
|
+
if (result.hasStateSeed) stateSeedCount++
|
|
76
|
+
}
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
const assetsDirectory = join(outputDirectory, "assets")
|
|
67
80
|
await mkdir(assetsDirectory, { recursive: true })
|
|
68
81
|
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
69
82
|
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
70
|
-
const
|
|
83
|
+
const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
|
|
84
|
+
const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
|
|
85
|
+
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
|
|
71
86
|
const hasNativeHandlers = nativeModules.length > 0
|
|
72
87
|
if (behaviorCount) {
|
|
73
88
|
const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
|
|
@@ -81,7 +96,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
81
96
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
82
97
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
83
98
|
.replace('"./style.js"', '"./kudzu-style.js"')
|
|
84
|
-
await
|
|
99
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, { "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings) })
|
|
85
100
|
}
|
|
86
101
|
if (listCount) {
|
|
87
102
|
let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
|
|
@@ -94,7 +109,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
94
109
|
}`
|
|
95
110
|
listRuntime = listRuntime.replace(" /* list-style */", listStyleCount ? stylePatch : "")
|
|
96
111
|
if (listStyleCount) listRuntime = `import { serializeStyle } from "./kudzu-style.js"\n${listRuntime}`
|
|
97
|
-
await
|
|
112
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-list.js"), listRuntime, minify, { __KUDZU_LIST_CONDITIONS__: String(hasListConditions) })
|
|
98
113
|
}
|
|
99
114
|
if (hasNativeHandlers) {
|
|
100
115
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
@@ -107,11 +122,43 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
107
122
|
await mkdir(resolve(output, ".."), { recursive: true })
|
|
108
123
|
await writeJavaScript(output, handlerModule.code, minify)
|
|
109
124
|
}
|
|
125
|
+
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
126
|
+
for (const file of clientModules) {
|
|
127
|
+
const output = join(assetsDirectory, clientModulePath(file))
|
|
128
|
+
await mkdir(resolve(output, ".."), { recursive: true })
|
|
129
|
+
await writeJavaScript(output, await compileClientModule(file, sourceFileSet), minify)
|
|
130
|
+
}
|
|
131
|
+
if (clientModules.length) {
|
|
132
|
+
await bundle({
|
|
133
|
+
entryPoints: handlerModules.map(module => join(assetsDirectory, module.path)),
|
|
134
|
+
outbase: join(assetsDirectory, "handlers"),
|
|
135
|
+
outdir: join(assetsDirectory, "handlers"),
|
|
136
|
+
entryNames: "[dir]/[name]",
|
|
137
|
+
chunkNames: "chunks/[name]-[hash]",
|
|
138
|
+
allowOverwrite: true,
|
|
139
|
+
bundle: true,
|
|
140
|
+
splitting: true,
|
|
141
|
+
format: "esm",
|
|
142
|
+
target: "es2022",
|
|
143
|
+
minify,
|
|
144
|
+
legalComments: "none",
|
|
145
|
+
logLevel: "silent"
|
|
146
|
+
})
|
|
147
|
+
await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
|
|
148
|
+
}
|
|
110
149
|
await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
|
|
111
|
-
|
|
150
|
+
for (const file of cssFiles) {
|
|
151
|
+
const output = join(assetsDirectory, relative(sourceDirectory, file))
|
|
152
|
+
await mkdir(dirname(output), { recursive: true })
|
|
153
|
+
await cp(file, output)
|
|
154
|
+
}
|
|
112
155
|
if (await exists(join(root, "public"))) await cp(join(root, "public"), outputDirectory, { recursive: true })
|
|
156
|
+
if (config.afterBuild !== undefined) {
|
|
157
|
+
if (typeof config.afterBuild !== "function") throw new Error("kudzu.config afterBuild must be a function")
|
|
158
|
+
await config.afterBuild({ root, outDir: outputDirectory, sourceDir: sourceDirectory, base, routes: plans.map(plan => plan.route), plans })
|
|
159
|
+
}
|
|
113
160
|
|
|
114
|
-
if (!quiet) console.log(`Built ${
|
|
161
|
+
if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
|
|
115
162
|
}
|
|
116
163
|
|
|
117
164
|
function specializeEvents(source, events) {
|
|
@@ -137,6 +184,22 @@ async function writeJavaScript(file, source, minify) {
|
|
|
137
184
|
await writeFile(file, code)
|
|
138
185
|
}
|
|
139
186
|
|
|
187
|
+
async function writeBundledJavaScript(file, source, minify, define) {
|
|
188
|
+
const result = await bundle({
|
|
189
|
+
stdin: { contents: source, resolveDir: dirname(file), sourcefile: file },
|
|
190
|
+
bundle: true,
|
|
191
|
+
write: false,
|
|
192
|
+
external: ["./kudzu.js", "./kudzu-serialization.js", "./kudzu-style.js"],
|
|
193
|
+
define,
|
|
194
|
+
format: "esm",
|
|
195
|
+
target: "es2022",
|
|
196
|
+
minify,
|
|
197
|
+
legalComments: "none",
|
|
198
|
+
logLevel: "silent"
|
|
199
|
+
})
|
|
200
|
+
await writeFile(file, result.outputFiles[0].contents)
|
|
201
|
+
}
|
|
202
|
+
|
|
140
203
|
export function parseDevPort(value) {
|
|
141
204
|
if (value === undefined || value.trim() === "") return 3000
|
|
142
205
|
if (!/^\d+$/.test(value)) throw new Error(`Invalid dev server port: ${value}`)
|
|
@@ -147,6 +210,7 @@ export function parseDevPort(value) {
|
|
|
147
210
|
|
|
148
211
|
export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
149
212
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
|
|
213
|
+
const base = normalizeBase((await loadConfig()).base)
|
|
150
214
|
|
|
151
215
|
let buildError
|
|
152
216
|
let revision = 0
|
|
@@ -184,7 +248,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
184
248
|
return
|
|
185
249
|
}
|
|
186
250
|
|
|
187
|
-
const relativePath = pathname.replace(/^\/+/, "")
|
|
251
|
+
const relativePath = stripBase(pathname, base).replace(/^\/+/, "")
|
|
188
252
|
let file = resolve(outputDirectory, relativePath)
|
|
189
253
|
if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
|
|
190
254
|
|
|
@@ -245,6 +309,12 @@ function injectDevClient(html, session, revision, schema) {
|
|
|
245
309
|
return `${html}${devClient(session, revision, schema)}`
|
|
246
310
|
}
|
|
247
311
|
|
|
312
|
+
function stripBase(path, base) {
|
|
313
|
+
if (!base) return path
|
|
314
|
+
if (path === base) return "/"
|
|
315
|
+
return path.startsWith(`${base}/`) ? path.slice(base.length) : path
|
|
316
|
+
}
|
|
317
|
+
|
|
248
318
|
async function devSchema(pathname) {
|
|
249
319
|
try {
|
|
250
320
|
const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
|
|
@@ -275,11 +345,12 @@ function escapeHtml(value) {
|
|
|
275
345
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
276
346
|
}
|
|
277
347
|
|
|
278
|
-
async function compile(file) {
|
|
348
|
+
async function compile(file, sourceFiles, base) {
|
|
279
349
|
const source = await readFile(file, "utf8")
|
|
280
350
|
const nativeHandlers = []
|
|
281
351
|
const reactiveBindings = []
|
|
282
352
|
const listExpressions = []
|
|
353
|
+
const clientImports = new Set()
|
|
283
354
|
const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
284
355
|
const result = ts.transpileModule(source, {
|
|
285
356
|
fileName: file,
|
|
@@ -289,7 +360,7 @@ async function compile(file) {
|
|
|
289
360
|
jsx: ts.JsxEmit.ReactJSX,
|
|
290
361
|
jsxImportSource: "@kudzujs/core"
|
|
291
362
|
},
|
|
292
|
-
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions,
|
|
363
|
+
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, clientImports)] },
|
|
293
364
|
reportDiagnostics: true
|
|
294
365
|
})
|
|
295
366
|
|
|
@@ -304,6 +375,7 @@ async function compile(file) {
|
|
|
304
375
|
|
|
305
376
|
if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
|
|
306
377
|
const moduleSource = [
|
|
378
|
+
printClientImports(nativeHandlers.flatMap(handler => handler.imports), handlerPath),
|
|
307
379
|
...nativeHandlers.map(handler => printNativeHandler(handler)),
|
|
308
380
|
...reactiveBindings.map(entry => printReactiveBinding(entry)),
|
|
309
381
|
...listExpressions.map(entry => printListExpression(entry))
|
|
@@ -314,12 +386,13 @@ async function compile(file) {
|
|
|
314
386
|
})
|
|
315
387
|
const moduleErrors = moduleResult.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
316
388
|
if (moduleErrors.length) throw new Error(moduleErrors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
317
|
-
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
|
|
389
|
+
return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0, clientImports: [...clientImports] }
|
|
318
390
|
}
|
|
319
391
|
|
|
320
|
-
function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl) {
|
|
392
|
+
function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, clientImports) {
|
|
321
393
|
return context => sourceFile => {
|
|
322
394
|
const factory = context.factory
|
|
395
|
+
const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
|
|
323
396
|
const settersByFunction = new Map()
|
|
324
397
|
const functions = new Map()
|
|
325
398
|
const jsxLocalDeclarations = new Map()
|
|
@@ -501,7 +574,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
501
574
|
|
|
502
575
|
if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
|
|
503
576
|
const setters = settersForNode(node, settersByFunction)
|
|
504
|
-
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node))
|
|
577
|
+
const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node), importBindings, clientImports)
|
|
505
578
|
if (event) {
|
|
506
579
|
usesBehavior = true
|
|
507
580
|
return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
|
|
@@ -810,17 +883,20 @@ function factoryNull() {
|
|
|
810
883
|
return ts.factory.createNull()
|
|
811
884
|
}
|
|
812
885
|
|
|
813
|
-
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem) {
|
|
886
|
+
function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem, importBindings, clientImports) {
|
|
814
887
|
if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
|
|
815
888
|
if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
|
|
816
889
|
|
|
817
890
|
const optimized = compileOptimizedEvent(expression, setters, factory)
|
|
818
891
|
if (optimized) return optimized
|
|
819
892
|
|
|
820
|
-
const
|
|
893
|
+
const allCaptures = nativeCaptureNames(expression, setters)
|
|
894
|
+
const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
|
|
895
|
+
const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
|
|
896
|
+
for (const entry of imports) clientImports.add(entry.target)
|
|
821
897
|
const usedStates = nativeStateNames(expression, setters)
|
|
822
898
|
const exportName = `handler${nativeHandlers.length}`
|
|
823
|
-
nativeHandlers.push({ exportName, expression, captures, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
|
|
899
|
+
nativeHandlers.push({ exportName, expression, captures, imports, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))) })
|
|
824
900
|
const states = [...usedStates].map(name => factory.createArrayLiteralExpression([
|
|
825
901
|
factory.createStringLiteral(name),
|
|
826
902
|
factory.createIdentifier(name)
|
|
@@ -871,6 +947,16 @@ function nativeCaptureNames(expression, setters) {
|
|
|
871
947
|
return captureNames(expression, expression.body, setters)
|
|
872
948
|
}
|
|
873
949
|
|
|
950
|
+
function referencedImportedBindings(expression, imports) {
|
|
951
|
+
const names = new Set()
|
|
952
|
+
const visit = node => {
|
|
953
|
+
if (ts.isIdentifier(node) && imports.has(node.text) && isReferenceIdentifier(node)) names.add(node.text)
|
|
954
|
+
ts.forEachChild(node, visit)
|
|
955
|
+
}
|
|
956
|
+
visit(expression.body)
|
|
957
|
+
return names
|
|
958
|
+
}
|
|
959
|
+
|
|
874
960
|
function captureNames(declarationRoot, referenceRoot, setters) {
|
|
875
961
|
const local = new Set()
|
|
876
962
|
const collectDeclarations = node => {
|
|
@@ -937,6 +1023,135 @@ function settersForNode(node, settersByFunction) {
|
|
|
937
1023
|
return new Map()
|
|
938
1024
|
}
|
|
939
1025
|
|
|
1026
|
+
function clientImportBindings(sourceFile, file, sourceFiles) {
|
|
1027
|
+
const bindings = new Map()
|
|
1028
|
+
for (const node of sourceFile.statements) {
|
|
1029
|
+
if (!ts.isImportDeclaration(node) || !node.importClause || node.importClause.isTypeOnly || !ts.isStringLiteral(node.moduleSpecifier) || !node.moduleSpecifier.text.startsWith(".")) continue
|
|
1030
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
1031
|
+
if (node.importClause.name) bindings.set(node.importClause.name.text, { kind: "default", local: node.importClause.name.text, target })
|
|
1032
|
+
const named = node.importClause.namedBindings
|
|
1033
|
+
if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { kind: "namespace", local: named.name.text, target })
|
|
1034
|
+
if (named && ts.isNamedImports(named)) {
|
|
1035
|
+
for (const entry of named.elements) {
|
|
1036
|
+
if (!entry.isTypeOnly) bindings.set(entry.name.text, { kind: "named", imported: (entry.propertyName ?? entry.name).text, local: entry.name.text, target })
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return bindings
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function printClientImports(entries, handlerPath) {
|
|
1044
|
+
const unique = new Map(entries.map(entry => [`${entry.target}:${entry.kind}:${entry.imported ?? ""}:${entry.local}`, entry]))
|
|
1045
|
+
const groups = Map.groupBy(unique.values(), entry => entry.target)
|
|
1046
|
+
const imports = []
|
|
1047
|
+
for (const [target, group] of groups) {
|
|
1048
|
+
const specifier = relativeModulePath(handlerPath, clientModulePath(target))
|
|
1049
|
+
const defaults = group.filter(entry => entry.kind === "default")
|
|
1050
|
+
const named = group.filter(entry => entry.kind === "named")
|
|
1051
|
+
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)}`)
|
|
1052
|
+
if (defaults.length > 1) for (const entry of defaults) imports.push(`import ${entry.local} from ${JSON.stringify(specifier)}`)
|
|
1053
|
+
for (const entry of group.filter(entry => entry.kind === "namespace")) imports.push(`import * as ${entry.local} from ${JSON.stringify(specifier)}`)
|
|
1054
|
+
}
|
|
1055
|
+
return imports.join("\n")
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function collectClientModules(entries, sourceFiles) {
|
|
1059
|
+
const modules = new Set()
|
|
1060
|
+
const queue = [...new Set(entries)]
|
|
1061
|
+
while (queue.length) {
|
|
1062
|
+
const file = queue.shift()
|
|
1063
|
+
if (modules.has(file)) continue
|
|
1064
|
+
const source = await readFile(file, "utf8")
|
|
1065
|
+
const sourceFile = parseSourceFile(file, source)
|
|
1066
|
+
if (containsJsx(sourceFile)) throw new Error(`${relative(root, file)} Imported client helpers must not contain JSX`)
|
|
1067
|
+
rejectUnsupportedClientImports(sourceFile, file)
|
|
1068
|
+
modules.add(file)
|
|
1069
|
+
for (const node of sourceFile.statements) {
|
|
1070
|
+
if ((!ts.isImportDeclaration(node) && !ts.isExportDeclaration(node)) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier) || !runtimeModuleReference(node)) continue
|
|
1071
|
+
if (!node.moduleSpecifier.text.startsWith(".")) throw new Error(`${relative(root, file)} Imported client helpers may only use relative runtime imports`)
|
|
1072
|
+
queue.push(resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles))
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const outputs = new Map()
|
|
1076
|
+
for (const file of modules) {
|
|
1077
|
+
const output = clientModulePath(file)
|
|
1078
|
+
if (outputs.has(output)) throw new Error(`${relative(root, file)} and ${relative(root, outputs.get(output))} emit the same client module path`)
|
|
1079
|
+
outputs.set(output, file)
|
|
1080
|
+
}
|
|
1081
|
+
return [...modules].sort()
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function compileClientModule(file, sourceFiles) {
|
|
1085
|
+
const source = await readFile(file, "utf8")
|
|
1086
|
+
const transformer = context => sourceFile => {
|
|
1087
|
+
const factory = context.factory
|
|
1088
|
+
const visitor = node => {
|
|
1089
|
+
if (ts.isImportDeclaration(node) && runtimeModuleReference(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
1090
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
1091
|
+
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(clientModulePath(file), clientModulePath(target))), node.attributes)
|
|
1092
|
+
}
|
|
1093
|
+
if (ts.isExportDeclaration(node) && runtimeModuleReference(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
1094
|
+
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
1095
|
+
return factory.updateExportDeclaration(node, node.modifiers, node.isTypeOnly, node.exportClause, factory.createStringLiteral(relativeModulePath(clientModulePath(file), clientModulePath(target))), node.attributes)
|
|
1096
|
+
}
|
|
1097
|
+
return ts.visitEachChild(node, visitor, context)
|
|
1098
|
+
}
|
|
1099
|
+
return ts.visitNode(sourceFile, visitor)
|
|
1100
|
+
}
|
|
1101
|
+
const result = ts.transpileModule(source, {
|
|
1102
|
+
fileName: file,
|
|
1103
|
+
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
|
|
1104
|
+
transformers: { before: [transformer] },
|
|
1105
|
+
reportDiagnostics: true
|
|
1106
|
+
})
|
|
1107
|
+
const errors = result.diagnostics?.filter(diagnostic => diagnostic.category === ts.DiagnosticCategory.Error) ?? []
|
|
1108
|
+
if (errors.length) throw new Error(errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n"))
|
|
1109
|
+
return result.outputText
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function resolveSourceImport(importer, specifier, sourceFiles) {
|
|
1113
|
+
const base = resolve(dirname(importer), specifier)
|
|
1114
|
+
const extension = extname(base)
|
|
1115
|
+
const stem = /\.(?:js|jsx|ts|tsx)$/.test(extension) ? base.slice(0, -extension.length) : base
|
|
1116
|
+
const candidates = extension === ".ts" || extension === ".tsx"
|
|
1117
|
+
? [base]
|
|
1118
|
+
: [`${stem}.ts`, `${stem}.tsx`]
|
|
1119
|
+
const matches = candidates.filter(candidate => sourceFiles.has(candidate))
|
|
1120
|
+
if (matches.length !== 1) throw new Error(`${relative(root, importer)} Relative import ${JSON.stringify(specifier)} must resolve to one TypeScript file in src/`)
|
|
1121
|
+
return matches[0]
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function runtimeModuleReference(node) {
|
|
1125
|
+
if (ts.isExportDeclaration(node)) return !node.isTypeOnly && (!node.exportClause || !ts.isNamedExports(node.exportClause) || node.exportClause.elements.some(entry => !entry.isTypeOnly))
|
|
1126
|
+
const clause = node.importClause
|
|
1127
|
+
if (!clause) return true
|
|
1128
|
+
if (clause.isTypeOnly) return false
|
|
1129
|
+
if (clause.name || clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) return true
|
|
1130
|
+
return clause.namedBindings?.elements.some(entry => !entry.isTypeOnly) ?? false
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function rejectUnsupportedClientImports(sourceFile, file) {
|
|
1134
|
+
const visit = node => {
|
|
1135
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) throw new Error(`${relative(root, file)} Dynamic imports are not supported in imported client helpers`)
|
|
1136
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") throw new Error(`${relative(root, file)} require() is not supported in imported client helpers`)
|
|
1137
|
+
ts.forEachChild(node, visit)
|
|
1138
|
+
}
|
|
1139
|
+
visit(sourceFile)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function parseSourceFile(file, source) {
|
|
1143
|
+
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function clientModulePath(file) {
|
|
1147
|
+
return `modules/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function relativeModulePath(from, to) {
|
|
1151
|
+
const path = relative(dirname(from), to).replaceAll(sep, "/")
|
|
1152
|
+
return path.startsWith(".") ? path : `./${path}`
|
|
1153
|
+
}
|
|
1154
|
+
|
|
940
1155
|
function printNativeHandler({ exportName, expression, captures, setters }) {
|
|
941
1156
|
const factory = ts.factory
|
|
942
1157
|
const stateNames = new Set(setters.values())
|
|
@@ -1102,9 +1317,59 @@ function compiledPath(file) {
|
|
|
1102
1317
|
return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
|
|
1103
1318
|
}
|
|
1104
1319
|
|
|
1105
|
-
function
|
|
1320
|
+
async function loadConfig() {
|
|
1321
|
+
for (const name of ["kudzu.config.mjs", "kudzu.config.js"]) {
|
|
1322
|
+
const file = join(root, name)
|
|
1323
|
+
if (!(await exists(file))) continue
|
|
1324
|
+
const config = (await import(`${pathToFileURL(file).href}?v=${Date.now()}-${randomUUID()}`)).default ?? {}
|
|
1325
|
+
if (!isPlainRecord(config)) throw new Error(`${name} must export a default object`)
|
|
1326
|
+
return config
|
|
1327
|
+
}
|
|
1328
|
+
return {}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function normalizeBase(value) {
|
|
1332
|
+
if (value == null || value === "" || value === "/") return ""
|
|
1333
|
+
if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || value.split("/").includes("..")) throw new Error("kudzu.config base must be a root-relative path")
|
|
1334
|
+
return value.replace(/\/+$/, "")
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function assetPath(base, path) {
|
|
1338
|
+
return `${base}/${path}`
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function withBase(base, path) {
|
|
1342
|
+
return base ? `${base}${path}` : path
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
async function staticPathEntries(module, file) {
|
|
1346
|
+
if (typeof module.getStaticPaths !== "function") return [{ params: {}, props: {} }]
|
|
1347
|
+
const entries = await module.getStaticPaths()
|
|
1348
|
+
if (!Array.isArray(entries)) throw new Error(`${relative(root, file)} getStaticPaths() must return an array`)
|
|
1349
|
+
return entries.map((entry, index) => {
|
|
1350
|
+
if (!isPlainRecord(entry)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}] must be an object`)
|
|
1351
|
+
const params = entry.params ?? {}
|
|
1352
|
+
const props = entry.props ?? {}
|
|
1353
|
+
if (!isPlainRecord(params)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].params must be an object`)
|
|
1354
|
+
if (!isPlainRecord(props)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].props must be an object`)
|
|
1355
|
+
return { params, props }
|
|
1356
|
+
})
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function routeFromPage(file, params = {}) {
|
|
1106
1360
|
const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
|
|
1107
|
-
|
|
1361
|
+
if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
|
|
1362
|
+
const filled = page.replace(/\[([^\]]+)\]/g, (_, name) => {
|
|
1363
|
+
if (!Object.hasOwn(params, name)) throw new Error(`Missing param "${name}" for route ${page}`)
|
|
1364
|
+
const value = String(params[name])
|
|
1365
|
+
if (!value || value === "." || value === ".." || /[\\/\0?#]/.test(value)) throw new Error(`Invalid param "${name}" for route ${page}`)
|
|
1366
|
+
return value
|
|
1367
|
+
})
|
|
1368
|
+
return filled === "index" ? "" : filled.replace(/\/index$/, "")
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function isPlainRecord(value) {
|
|
1372
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
1108
1373
|
}
|
|
1109
1374
|
|
|
1110
1375
|
async function walk(directory) {
|
package/framework/core.d.ts
CHANGED
|
@@ -26,8 +26,8 @@ export function listExpression(read: () => unknown, module: string, handler: str
|
|
|
26
26
|
export function listItem(): unknown
|
|
27
27
|
export function listConditional(kind: "and" | "ternary", read: () => unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string): unknown
|
|
28
28
|
|
|
29
|
-
export function renderPage(
|
|
30
|
-
component: (props:
|
|
29
|
+
export function renderPage<Props = Record<string, never>>(
|
|
30
|
+
component: (props: Props) => unknown | Promise<unknown>,
|
|
31
31
|
metadata?: {
|
|
32
32
|
title?: string
|
|
33
33
|
description?: string
|
|
@@ -44,8 +44,10 @@ export function renderPage(
|
|
|
44
44
|
icon?: string
|
|
45
45
|
appleTouchIcon?: string
|
|
46
46
|
manifest?: string
|
|
47
|
-
styles?: boolean
|
|
48
|
-
|
|
47
|
+
styles?: boolean | string[]
|
|
48
|
+
base?: string
|
|
49
|
+
},
|
|
50
|
+
props?: Props
|
|
49
51
|
): Promise<{
|
|
50
52
|
html: string
|
|
51
53
|
hasBehaviors: boolean
|
package/framework/core.mjs
CHANGED
|
@@ -220,27 +220,27 @@ function serializeCapture(name, value, seen) {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
export async function renderPage(component, metadata = {}) {
|
|
224
|
-
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
223
|
+
export async function renderPage(component, metadata = {}, props = {}) {
|
|
224
|
+
renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], textBindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false, hasListStyles: false }
|
|
225
225
|
|
|
226
226
|
try {
|
|
227
|
-
const body = await renderNode({ type: component, props
|
|
227
|
+
const body = await renderNode({ type: component, props })
|
|
228
228
|
const title = escapeHtml(metadata.title ?? "Kudzu")
|
|
229
229
|
const head = renderMetadata(metadata)
|
|
230
230
|
const styles = metadata.styles === false
|
|
231
231
|
? ""
|
|
232
|
-
:
|
|
232
|
+
: (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
|
|
233
233
|
const runtime = renderContext.hasBehaviors
|
|
234
|
-
?
|
|
234
|
+
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu.js")}"></script>`
|
|
235
235
|
: ""
|
|
236
236
|
const nativeRuntime = renderContext.hasNativeBehaviors
|
|
237
|
-
?
|
|
237
|
+
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
|
|
238
238
|
: ""
|
|
239
239
|
const bindingRuntime = renderContext.hasBindings
|
|
240
|
-
?
|
|
240
|
+
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
|
|
241
241
|
: ""
|
|
242
242
|
const listRuntime = renderContext.hasLists
|
|
243
|
-
?
|
|
243
|
+
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
|
|
244
244
|
: ""
|
|
245
245
|
const listStates = new Set(renderContext.lists.map(list => list.state))
|
|
246
246
|
const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
|
|
@@ -253,9 +253,12 @@ export async function renderPage(component, metadata = {}) {
|
|
|
253
253
|
const state = initialState.length
|
|
254
254
|
? ` data-k-state='${escapeJsonAttribute(initialState)}'`
|
|
255
255
|
: ""
|
|
256
|
+
const textBindings = renderContext.textBindings.length
|
|
257
|
+
? ` data-k-text-bindings='${escapeJsonAttribute(renderContext.textBindings)}'`
|
|
258
|
+
: ""
|
|
256
259
|
|
|
257
260
|
return {
|
|
258
|
-
html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}</body></html>`,
|
|
261
|
+
html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${bindingRuntime}${listRuntime}${nativeRuntime}</body></html>`,
|
|
259
262
|
hasBehaviors: renderContext.hasBehaviors,
|
|
260
263
|
hasBindings: renderContext.hasBindings,
|
|
261
264
|
hasLists: renderContext.hasLists,
|
|
@@ -283,9 +286,9 @@ function renderMetadata(metadata) {
|
|
|
283
286
|
if (metadata.description) meta("description", metadata.description)
|
|
284
287
|
if (metadata.themeColor) meta("theme-color", metadata.themeColor)
|
|
285
288
|
if (metadata.url) tags.push(`<link rel="canonical" href="${escapeAttribute(metadata.url)}">`)
|
|
286
|
-
if (metadata.icon) tags.push(`<link rel="icon" href="${escapeAttribute(metadata.icon)}">`)
|
|
287
|
-
if (metadata.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${escapeAttribute(metadata.appleTouchIcon)}">`)
|
|
288
|
-
if (metadata.manifest) tags.push(`<link rel="manifest" href="${escapeAttribute(metadata.manifest)}">`)
|
|
289
|
+
if (metadata.icon) tags.push(`<link rel="icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.icon))}">`)
|
|
290
|
+
if (metadata.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.appleTouchIcon))}">`)
|
|
291
|
+
if (metadata.manifest) tags.push(`<link rel="manifest" href="${escapeAttribute(baseUrl(metadata.base, metadata.manifest))}">`)
|
|
289
292
|
|
|
290
293
|
meta("og:title", metadata.title, true)
|
|
291
294
|
meta("og:description", metadata.description, true)
|
|
@@ -307,6 +310,14 @@ function renderMetadata(metadata) {
|
|
|
307
310
|
return tags.join("")
|
|
308
311
|
}
|
|
309
312
|
|
|
313
|
+
function assetPath(base, path) {
|
|
314
|
+
return `${base ?? ""}/${path}`
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function baseUrl(base, value) {
|
|
318
|
+
return value.startsWith("/") ? `${base ?? ""}${value}` : value
|
|
319
|
+
}
|
|
320
|
+
|
|
310
321
|
async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
311
322
|
if (node == null || node === false || node === true) return ""
|
|
312
323
|
if (Array.isArray(node)) {
|
|
@@ -370,7 +381,9 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
370
381
|
if (renderContext.conditionDepth || renderContext.listDepth) for (const stateId of reactiveStateIds(descriptor)) renderContext.conditionStates.add(stateId)
|
|
371
382
|
renderContext.hasBehaviors = true
|
|
372
383
|
renderContext.hasBindings = true
|
|
373
|
-
|
|
384
|
+
const id = renderContext.textBindings.length
|
|
385
|
+
renderContext.textBindings.push(descriptor)
|
|
386
|
+
return `<!--k-text:${id}-->${escapeHtml(node.value ?? "")}<!--k-text-end-->`
|
|
374
387
|
}
|
|
375
388
|
if (node?.[listConditionalMarker]) {
|
|
376
389
|
const descriptor = { kind: node.kind, module: node.module, handler: node.handler }
|
|
@@ -412,6 +425,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
412
425
|
const listAttributes = []
|
|
413
426
|
const listExpressionAttributes = []
|
|
414
427
|
const listEvents = []
|
|
428
|
+
let rawHtml
|
|
415
429
|
|
|
416
430
|
if (renderContext.listRoot) {
|
|
417
431
|
const root = renderContext.listRoot
|
|
@@ -433,6 +447,14 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
433
447
|
if (["ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
|
|
434
448
|
throw new Error(`Reactive ${rawName} is not supported`)
|
|
435
449
|
}
|
|
450
|
+
if (rawName === "dangerouslySetInnerHTML") {
|
|
451
|
+
if (renderContext.listDepth) throw new Error("dangerouslySetInnerHTML is not supported in keyed lists")
|
|
452
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "__html")) throw new Error("dangerouslySetInnerHTML requires { __html }")
|
|
453
|
+
if (value.__html?.[signalMarker] || value.__html?.[bindingMarker]) throw new Error("Reactive dangerouslySetInnerHTML is not supported")
|
|
454
|
+
if (props.children != null) throw new Error("dangerouslySetInnerHTML cannot be used with children")
|
|
455
|
+
rawHtml = value.__html == null ? "" : String(value.__html)
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
436
458
|
|
|
437
459
|
if (/^on[A-Z]/.test(rawName)) {
|
|
438
460
|
const event = rawName.slice(2).toLowerCase()
|
|
@@ -504,8 +526,11 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
504
526
|
if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
|
|
505
527
|
|
|
506
528
|
const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
|
|
507
|
-
if (voidElements.has(tag))
|
|
508
|
-
|
|
529
|
+
if (voidElements.has(tag)) {
|
|
530
|
+
if (rawHtml !== undefined) throw new Error(`dangerouslySetInnerHTML cannot be used on <${tag}>`)
|
|
531
|
+
return `<${tag}${attributes}>`
|
|
532
|
+
}
|
|
533
|
+
const children = rawHtml ?? (directListText ? escapeHtml(directListText.value ?? "") : await renderNode(props.children, childNamespace, childSelectValue))
|
|
509
534
|
return `<${tag}${attributes}>${children}</${tag}>`
|
|
510
535
|
}
|
|
511
536
|
|
|
@@ -7,7 +7,7 @@ const imports = new Map()
|
|
|
7
7
|
const revisions = new WeakMap()
|
|
8
8
|
const itemParts = new WeakMap()
|
|
9
9
|
const conditionOwners = new WeakMap()
|
|
10
|
-
const itemPartsSelector =
|
|
10
|
+
const itemPartsSelector = `[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
|
|
11
11
|
|
|
12
12
|
function commitLists(id) {
|
|
13
13
|
const lists = listTargets.get(id)
|
|
@@ -33,7 +33,7 @@ function mountLists(root) {
|
|
|
33
33
|
const roots = listRoots(start, end)
|
|
34
34
|
const templateRoot = start.content.firstElementChild
|
|
35
35
|
const parts = listItemPartPlan(templateRoot)
|
|
36
|
-
for (const root of roots) descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
|
|
36
|
+
for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
|
|
37
37
|
if (descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
|
|
38
38
|
const items = browserState.get(descriptor.state)
|
|
39
39
|
const list = {
|
|
@@ -183,10 +183,12 @@ function fillListParts(root, parts, item, revision) {
|
|
|
183
183
|
}).catch(error => console.error(error))
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
186
|
+
if (__KUDZU_LIST_CONDITIONS__) {
|
|
187
|
+
for (const [marker, descriptor] of parts.conditions) {
|
|
188
|
+
evaluate(descriptor, item).then(value => {
|
|
189
|
+
if (revisions.get(root) === revision && root.isConnected) updateListCondition(marker, descriptor.kind, value, item)
|
|
190
|
+
}).catch(error => console.error(error))
|
|
191
|
+
}
|
|
190
192
|
}
|
|
191
193
|
}
|
|
192
194
|
|
|
@@ -200,7 +202,7 @@ function listItemParts(root) {
|
|
|
200
202
|
if (node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
|
|
201
203
|
if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
202
204
|
if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
203
|
-
if (node.hasAttribute("data-k-list-condition")) {
|
|
205
|
+
if (__KUDZU_LIST_CONDITIONS__ && node.hasAttribute("data-k-list-condition")) {
|
|
204
206
|
parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
|
|
205
207
|
conditionOwners.set(node, root)
|
|
206
208
|
}
|
|
@@ -220,7 +222,7 @@ function listItemPartPlan(template) {
|
|
|
220
222
|
events: parts.events.map(([node, events]) => [indexes.get(node), events]),
|
|
221
223
|
expressions: parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]),
|
|
222
224
|
expressionAttributes: parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]),
|
|
223
|
-
conditions: parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor])
|
|
225
|
+
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
|
|
224
226
|
}
|
|
225
227
|
}
|
|
226
228
|
|
|
@@ -233,10 +235,10 @@ function mapListItemParts(parts, root) {
|
|
|
233
235
|
events: parts.events.map(([index, events]) => [target[index], events]),
|
|
234
236
|
expressions: parts.expressions.map(([index, descriptor]) => [target[index], descriptor]),
|
|
235
237
|
expressionAttributes: parts.expressionAttributes.map(([index, attributes]) => [target[index], attributes]),
|
|
236
|
-
conditions: parts.conditions.map(([index, descriptor]) => {
|
|
238
|
+
conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
|
|
237
239
|
conditionOwners.set(target[index], root)
|
|
238
240
|
return [target[index], descriptor]
|
|
239
|
-
})
|
|
241
|
+
}) : []
|
|
240
242
|
})
|
|
241
243
|
}
|
|
242
244
|
|