@kudzujs/core 0.4.14 → 0.5.1
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 +77 -1
- package/framework/README.md +2 -2
- package/framework/binding-runtime.js +52 -4
- package/framework/build.mjs +186 -42
- package/framework/core.d.ts +6 -4
- package/framework/core.mjs +40 -15
- 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:
|
|
@@ -225,9 +264,19 @@ const rows = items.map(item =>
|
|
|
225
264
|
return <ul>{rows}</ul>
|
|
226
265
|
```
|
|
227
266
|
|
|
267
|
+
The root may also be a same-file row component that directly returns one intrinsic element and receives the whole item through one prop:
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
function ItemRow({ item }: { item: Item }) {
|
|
271
|
+
return <li>{item.name}</li>
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const rows = items.map(item => <ItemRow key={item.id} item={item} />)
|
|
275
|
+
```
|
|
276
|
+
|
|
228
277
|
Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX or in one top-level immutable `const` rendered once as a JSX child. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
|
|
229
278
|
|
|
230
|
-
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested item conditions or
|
|
279
|
+
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a local-state `.map`, one identifier callback parameter, one intrinsic JSX root or same-file row component, and `key={item.<field>}`. A row component must directly destructure the whole item prop, directly return one intrinsic element, and be used only by that list. A list alias may only be rendered once and cannot be read by other JavaScript. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested item conditions, lists, or component tags, item spreads, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
231
280
|
|
|
232
281
|
## Normal JavaScript
|
|
233
282
|
|
|
@@ -294,6 +343,9 @@ Supported:
|
|
|
294
343
|
- Function components, props, children, fragments, and TSX
|
|
295
344
|
- File-based static routes
|
|
296
345
|
- Build-time async components
|
|
346
|
+
- Dynamic static routes with build-time props
|
|
347
|
+
- Static trusted `dangerouslySetInnerHTML`
|
|
348
|
+
- Base-path deployments, multiple CSS files, and `afterBuild`
|
|
297
349
|
- Primitive `useState` bindings
|
|
298
350
|
- Synchronous and async event handlers
|
|
299
351
|
- Relative imported helpers in native handlers
|
|
@@ -346,6 +398,28 @@ The same native counter calculation was measured inline and through one relative
|
|
|
346
398
|
|
|
347
399
|
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.
|
|
348
400
|
|
|
401
|
+
#### Wrapper-Free Derived Text
|
|
402
|
+
|
|
403
|
+
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.
|
|
404
|
+
|
|
405
|
+
| Text target | Files | JS gzip | Total output | Clean build | Update |
|
|
406
|
+
|---|---:|---:|---:|---:|---:|
|
|
407
|
+
| Span v0.4.14 | 7 | **4,453 B** | **10,065 B** | **404 ms** | **4.83 µs** |
|
|
408
|
+
| Comment range | 7 | 4,763 B | 10,922 B | 426 ms | 5.03 µs |
|
|
409
|
+
|
|
410
|
+
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.
|
|
411
|
+
|
|
412
|
+
### 123-Page Newsletter Build
|
|
413
|
+
|
|
414
|
+
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.
|
|
415
|
+
|
|
416
|
+
| Build model | TSX source files | Pages | JS gzip | Total output | Clean build |
|
|
417
|
+
|---|---:|---:|---:|---:|---:|
|
|
418
|
+
| Generated TSX workaround | 123 | 123 | 0 B | 52.0 KB | 882 ms |
|
|
419
|
+
| `getStaticPaths` | **1** | 123 | 0 B | 52.0 KB | **454 ms** |
|
|
420
|
+
|
|
421
|
+
`getStaticPaths` removes 122 generated source files and cuts clean build time by 48.5% without changing deploy output or runtime cost.
|
|
422
|
+
|
|
349
423
|
### Static Journal Page
|
|
350
424
|
|
|
351
425
|
Same content and CSS across every fixture:
|
|
@@ -374,6 +448,8 @@ The list starts with 1,000 keyed items, then updates every label, reverses the o
|
|
|
374
448
|
| 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 |
|
|
375
449
|
| 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 |
|
|
376
450
|
|
|
451
|
+
An intrinsic-root versus row-component A/B build produced byte-for-byte identical `dist` output: 5,175 B JS gzip and 61,731 B total. Seven interleaved clean builds measured 444 ms and 441 ms. Browser operation medians totaled 23.8 ms and 25.4 ms respectively; because the deployed HTML and JavaScript are identical, that 1.6 ms difference is measurement variance rather than component runtime overhead.
|
|
452
|
+
|
|
377
453
|
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.
|
|
378
454
|
|
|
379
455
|
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.
|
package/framework/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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.
|
|
@@ -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
|
@@ -17,18 +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/")
|
|
27
31
|
const sourceFileSet = new Set(sourceFiles)
|
|
28
32
|
|
|
29
33
|
const handlerModules = []
|
|
30
34
|
for (const file of sourceFiles) {
|
|
31
|
-
const handlerModule = await compile(file, sourceFileSet)
|
|
35
|
+
const handlerModule = await compile(file, sourceFileSet, base)
|
|
32
36
|
if (handlerModule) handlerModules.push(handlerModule)
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -41,35 +45,44 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
41
45
|
let listStyleCount = 0
|
|
42
46
|
let stateSeedCount = 0
|
|
43
47
|
const plans = []
|
|
44
|
-
const
|
|
48
|
+
const emittedRoutes = new Set()
|
|
49
|
+
const styleUrls = cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`))
|
|
45
50
|
|
|
46
51
|
for (const pageFile of pageFiles) {
|
|
47
52
|
const compiledFile = compiledPath(pageFile)
|
|
48
53
|
const module = await import(`${pathToFileURL(compiledFile).href}?v=${Date.now()}`)
|
|
49
54
|
if (typeof module.default !== "function") throw new Error(`${relative(root, pageFile)} must export a default component`)
|
|
50
55
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
const assetsDirectory = join(outputDirectory, "assets")
|
|
68
80
|
await mkdir(assetsDirectory, { recursive: true })
|
|
69
81
|
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
70
82
|
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
83
|
+
const hasTextBindings = plans.some(plan => plan.bindings.some(binding => binding.target === "text"))
|
|
71
84
|
const hasListConditions = plans.some(plan => plan.lists.some(list => list.conditions))
|
|
72
|
-
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module =>
|
|
85
|
+
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
|
|
73
86
|
const hasNativeHandlers = nativeModules.length > 0
|
|
74
87
|
if (behaviorCount) {
|
|
75
88
|
const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
|
|
@@ -83,7 +96,7 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
83
96
|
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
84
97
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
85
98
|
.replace('"./style.js"', '"./kudzu-style.js"')
|
|
86
|
-
await
|
|
99
|
+
await writeBundledJavaScript(join(assetsDirectory, "kudzu-binding.js"), bindingRuntime, minify, { "globalThis.__KUDZU_TEXT_BINDINGS__": String(hasTextBindings) })
|
|
87
100
|
}
|
|
88
101
|
if (listCount) {
|
|
89
102
|
let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
|
|
@@ -134,10 +147,18 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
134
147
|
await rm(join(assetsDirectory, "modules"), { recursive: true, force: true })
|
|
135
148
|
}
|
|
136
149
|
await writeFile(join(workDirectory, "kudzu-plan.json"), JSON.stringify({ routes: plans }, null, 2))
|
|
137
|
-
|
|
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
|
+
}
|
|
138
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
|
+
}
|
|
139
160
|
|
|
140
|
-
if (!quiet) console.log(`Built ${
|
|
161
|
+
if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
|
|
141
162
|
}
|
|
142
163
|
|
|
143
164
|
function specializeEvents(source, events) {
|
|
@@ -168,7 +189,7 @@ async function writeBundledJavaScript(file, source, minify, define) {
|
|
|
168
189
|
stdin: { contents: source, resolveDir: dirname(file), sourcefile: file },
|
|
169
190
|
bundle: true,
|
|
170
191
|
write: false,
|
|
171
|
-
external: ["./kudzu.js", "./kudzu-style.js"],
|
|
192
|
+
external: ["./kudzu.js", "./kudzu-serialization.js", "./kudzu-style.js"],
|
|
172
193
|
define,
|
|
173
194
|
format: "esm",
|
|
174
195
|
target: "es2022",
|
|
@@ -189,6 +210,7 @@ export function parseDevPort(value) {
|
|
|
189
210
|
|
|
190
211
|
export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
191
212
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
|
|
213
|
+
const base = normalizeBase((await loadConfig()).base)
|
|
192
214
|
|
|
193
215
|
let buildError
|
|
194
216
|
let revision = 0
|
|
@@ -226,7 +248,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
226
248
|
return
|
|
227
249
|
}
|
|
228
250
|
|
|
229
|
-
const relativePath = pathname.replace(/^\/+/, "")
|
|
251
|
+
const relativePath = stripBase(pathname, base).replace(/^\/+/, "")
|
|
230
252
|
let file = resolve(outputDirectory, relativePath)
|
|
231
253
|
if (!file.startsWith(`${outputDirectory}${sep}`) && file !== outputDirectory) throw new Error("Invalid path")
|
|
232
254
|
|
|
@@ -287,6 +309,12 @@ function injectDevClient(html, session, revision, schema) {
|
|
|
287
309
|
return `${html}${devClient(session, revision, schema)}`
|
|
288
310
|
}
|
|
289
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
|
+
|
|
290
318
|
async function devSchema(pathname) {
|
|
291
319
|
try {
|
|
292
320
|
const plan = JSON.parse(await readFile(join(workDirectory, "kudzu-plan.json"), "utf8"))
|
|
@@ -317,7 +345,7 @@ function escapeHtml(value) {
|
|
|
317
345
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
318
346
|
}
|
|
319
347
|
|
|
320
|
-
async function compile(file, sourceFiles) {
|
|
348
|
+
async function compile(file, sourceFiles, base) {
|
|
321
349
|
const source = await readFile(file, "utf8")
|
|
322
350
|
const nativeHandlers = []
|
|
323
351
|
const reactiveBindings = []
|
|
@@ -332,7 +360,7 @@ async function compile(file, sourceFiles) {
|
|
|
332
360
|
jsx: ts.JsxEmit.ReactJSX,
|
|
333
361
|
jsxImportSource: "@kudzujs/core"
|
|
334
362
|
},
|
|
335
|
-
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions,
|
|
363
|
+
transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, assetPath(base, `assets/${handlerPath}`), file, sourceFiles, clientImports)] },
|
|
336
364
|
reportDiagnostics: true
|
|
337
365
|
})
|
|
338
366
|
|
|
@@ -438,6 +466,19 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
438
466
|
if (uses.length) listLocalUses.set(uses[0], parts)
|
|
439
467
|
}
|
|
440
468
|
}
|
|
469
|
+
const renderedLists = new WeakMap()
|
|
470
|
+
const collectRenderedLists = node => {
|
|
471
|
+
if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
472
|
+
const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction))
|
|
473
|
+
if (parts) {
|
|
474
|
+
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
475
|
+
validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions)
|
|
476
|
+
renderedLists.set(node, parts)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
ts.forEachChild(node, collectRenderedLists)
|
|
480
|
+
}
|
|
481
|
+
collectRenderedLists(sourceFile)
|
|
441
482
|
|
|
442
483
|
const visitor = node => {
|
|
443
484
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
@@ -495,10 +536,8 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpression
|
|
|
495
536
|
}
|
|
496
537
|
|
|
497
538
|
if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
|
|
498
|
-
const listParts =
|
|
539
|
+
const listParts = renderedLists.get(node)
|
|
499
540
|
if (listParts) {
|
|
500
|
-
if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
|
|
501
|
-
validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems, listConditions)
|
|
502
541
|
usesBehavior = true
|
|
503
542
|
usesList = true
|
|
504
543
|
return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
|
|
@@ -600,11 +639,35 @@ function keyedListParts(expression, setters) {
|
|
|
600
639
|
return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
|
|
601
640
|
}
|
|
602
641
|
|
|
603
|
-
function validateKeyedList(parts, sourceFile,
|
|
642
|
+
function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, functions) {
|
|
604
643
|
const fail = (node, message) => {
|
|
605
644
|
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
|
606
645
|
throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
|
|
607
646
|
}
|
|
647
|
+
let root = parts.root
|
|
648
|
+
let item = parts.item
|
|
649
|
+
const rootTag = ts.isJsxElement(root) ? root.openingElement.tagName : root.tagName
|
|
650
|
+
if (ts.isIdentifier(rootTag) && rootTag.text[0] === rootTag.text[0].toUpperCase()) {
|
|
651
|
+
const component = functions.get(rootTag.text)
|
|
652
|
+
if (!component) fail(root, `Keyed list component ${rootTag.text} must be declared in the same file`)
|
|
653
|
+
const uses = jsxTagUses(sourceFile, rootTag.text)
|
|
654
|
+
if (uses.length !== 1 || uses[0] !== root) fail(root, `Keyed list component ${rootTag.text} may only be used as this list root`)
|
|
655
|
+
const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
|
|
656
|
+
let itemProp
|
|
657
|
+
for (const attribute of attributes.properties) {
|
|
658
|
+
if (ts.isJsxSpreadAttribute(attribute)) {
|
|
659
|
+
if (referencesIdentifier(attribute.expression, item)) fail(attribute, "Keyed list item spreads are not supported")
|
|
660
|
+
continue
|
|
661
|
+
}
|
|
662
|
+
if (attribute.name.getText() === "key" || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression || !referencesIdentifier(attribute.initializer.expression, item)) continue
|
|
663
|
+
if (!ts.isIdentifier(attribute.initializer.expression) || attribute.initializer.expression.text !== item || itemProp) fail(attribute, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
|
|
664
|
+
itemProp = attribute.name.getText()
|
|
665
|
+
}
|
|
666
|
+
if (ts.isJsxElement(root) && root.children.some(child => referencesIdentifier(child, item))) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
|
|
667
|
+
if (!itemProp) fail(root, `Keyed list component ${rootTag.text} must receive the whole item through one direct prop`)
|
|
668
|
+
item = componentItemParameter(component, itemProp, node => fail(node, `Keyed list component ${rootTag.text} must destructure its item prop`))
|
|
669
|
+
root = componentJsxRoot(component, node => fail(node, `Keyed list component ${rootTag.text} must return one JSX element`))
|
|
670
|
+
}
|
|
608
671
|
const validateElement = node => {
|
|
609
672
|
const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
|
|
610
673
|
if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
|
|
@@ -613,10 +676,10 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
613
676
|
const visit = node => {
|
|
614
677
|
if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
|
|
615
678
|
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
|
|
616
|
-
if (node !==
|
|
617
|
-
if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression,
|
|
679
|
+
if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
|
|
680
|
+
if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
|
|
618
681
|
if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
|
|
619
|
-
listEventItems.set(node,
|
|
682
|
+
listEventItems.set(node, item)
|
|
620
683
|
return
|
|
621
684
|
}
|
|
622
685
|
if (ts.isJsxExpression(node) && node.expression) {
|
|
@@ -624,16 +687,16 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
624
687
|
const condition = conditionalParts(expression)
|
|
625
688
|
if (condition && containsJsx(expression)) {
|
|
626
689
|
if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
|
|
627
|
-
if (!referencesIdentifier(condition.condition,
|
|
628
|
-
validateListExpression(condition.condition,
|
|
629
|
-
listConditions.set(node.expression, { ...condition, item
|
|
690
|
+
if (!referencesIdentifier(condition.condition, item)) fail(node, "Keyed list item conditions must read the item")
|
|
691
|
+
validateListExpression(condition.condition, item, node, fail)
|
|
692
|
+
listConditions.set(node.expression, { ...condition, item })
|
|
630
693
|
conditionDepth++
|
|
631
694
|
visit(condition.truthy)
|
|
632
695
|
visit(condition.falsy)
|
|
633
696
|
conditionDepth--
|
|
634
697
|
return
|
|
635
698
|
}
|
|
636
|
-
const field = directProperty(expression,
|
|
699
|
+
const field = directProperty(expression, item)
|
|
637
700
|
const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
|
|
638
701
|
if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
|
|
639
702
|
if (field && ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
@@ -642,16 +705,47 @@ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItem
|
|
|
642
705
|
listValues.set(node.expression, { field })
|
|
643
706
|
return
|
|
644
707
|
}
|
|
645
|
-
if (referencesIdentifier(expression,
|
|
646
|
-
validateListExpression(expression,
|
|
708
|
+
if (referencesIdentifier(expression, item)) {
|
|
709
|
+
validateListExpression(expression, item, node, fail)
|
|
647
710
|
if (ts.isJsxAttribute(node.parent) && ["ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
|
|
648
|
-
listValues.set(node.expression, { item
|
|
711
|
+
listValues.set(node.expression, { item })
|
|
649
712
|
return
|
|
650
713
|
}
|
|
651
714
|
}
|
|
652
715
|
ts.forEachChild(node, visit)
|
|
653
716
|
}
|
|
654
|
-
visit(
|
|
717
|
+
visit(root)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function componentItemParameter(component, prop, fail) {
|
|
721
|
+
if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component)
|
|
722
|
+
const element = component.parameters[0].name.elements.find(entry => !entry.dotDotDotToken && !entry.initializer && (entry.propertyName ?? entry.name).getText() === prop)
|
|
723
|
+
if (!element || !ts.isIdentifier(element.name)) fail(component.parameters[0])
|
|
724
|
+
return element.name.text
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function componentJsxRoot(component, fail) {
|
|
728
|
+
if (!ts.isBlock(component.body)) {
|
|
729
|
+
const root = unwrapExpression(component.body)
|
|
730
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(component.body)
|
|
731
|
+
return root
|
|
732
|
+
}
|
|
733
|
+
if (component.body.statements.length !== 1 || !ts.isReturnStatement(component.body.statements[0]) || !component.body.statements[0].expression) fail(component.body)
|
|
734
|
+
const statement = component.body.statements[0]
|
|
735
|
+
const root = unwrapExpression(statement.expression)
|
|
736
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(statement)
|
|
737
|
+
return root
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function jsxTagUses(root, name) {
|
|
741
|
+
const uses = []
|
|
742
|
+
const visit = node => {
|
|
743
|
+
const tag = ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
|
|
744
|
+
if (tag && ts.isIdentifier(tag) && tag.text === name) uses.push(node)
|
|
745
|
+
ts.forEachChild(node, visit)
|
|
746
|
+
}
|
|
747
|
+
visit(root)
|
|
748
|
+
return uses
|
|
655
749
|
}
|
|
656
750
|
|
|
657
751
|
const pureListMethods = new Set(["at", "charAt", "charCodeAt", "concat", "endsWith", "includes", "indexOf", "join", "lastIndexOf", "padEnd", "padStart", "repeat", "replace", "replaceAll", "slice", "startsWith", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart"])
|
|
@@ -1289,9 +1383,59 @@ function compiledPath(file) {
|
|
|
1289
1383
|
return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
|
|
1290
1384
|
}
|
|
1291
1385
|
|
|
1292
|
-
function
|
|
1386
|
+
async function loadConfig() {
|
|
1387
|
+
for (const name of ["kudzu.config.mjs", "kudzu.config.js"]) {
|
|
1388
|
+
const file = join(root, name)
|
|
1389
|
+
if (!(await exists(file))) continue
|
|
1390
|
+
const config = (await import(`${pathToFileURL(file).href}?v=${Date.now()}-${randomUUID()}`)).default ?? {}
|
|
1391
|
+
if (!isPlainRecord(config)) throw new Error(`${name} must export a default object`)
|
|
1392
|
+
return config
|
|
1393
|
+
}
|
|
1394
|
+
return {}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function normalizeBase(value) {
|
|
1398
|
+
if (value == null || value === "" || value === "/") return ""
|
|
1399
|
+
if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || value.split("/").includes("..")) throw new Error("kudzu.config base must be a root-relative path")
|
|
1400
|
+
return value.replace(/\/+$/, "")
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function assetPath(base, path) {
|
|
1404
|
+
return `${base}/${path}`
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function withBase(base, path) {
|
|
1408
|
+
return base ? `${base}${path}` : path
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async function staticPathEntries(module, file) {
|
|
1412
|
+
if (typeof module.getStaticPaths !== "function") return [{ params: {}, props: {} }]
|
|
1413
|
+
const entries = await module.getStaticPaths()
|
|
1414
|
+
if (!Array.isArray(entries)) throw new Error(`${relative(root, file)} getStaticPaths() must return an array`)
|
|
1415
|
+
return entries.map((entry, index) => {
|
|
1416
|
+
if (!isPlainRecord(entry)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}] must be an object`)
|
|
1417
|
+
const params = entry.params ?? {}
|
|
1418
|
+
const props = entry.props ?? {}
|
|
1419
|
+
if (!isPlainRecord(params)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].params must be an object`)
|
|
1420
|
+
if (!isPlainRecord(props)) throw new Error(`${relative(root, file)} getStaticPaths()[${index}].props must be an object`)
|
|
1421
|
+
return { params, props }
|
|
1422
|
+
})
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function routeFromPage(file, params = {}) {
|
|
1293
1426
|
const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
|
|
1294
|
-
|
|
1427
|
+
if (page.includes("[...")) throw new Error(`Catch-all routes are not supported: ${page}`)
|
|
1428
|
+
const filled = page.replace(/\[([^\]]+)\]/g, (_, name) => {
|
|
1429
|
+
if (!Object.hasOwn(params, name)) throw new Error(`Missing param "${name}" for route ${page}`)
|
|
1430
|
+
const value = String(params[name])
|
|
1431
|
+
if (!value || value === "." || value === ".." || /[\\/\0?#]/.test(value)) throw new Error(`Invalid param "${name}" for route ${page}`)
|
|
1432
|
+
return value
|
|
1433
|
+
})
|
|
1434
|
+
return filled === "index" ? "" : filled.replace(/\/index$/, "")
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function isPlainRecord(value) {
|
|
1438
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
1295
1439
|
}
|
|
1296
1440
|
|
|
1297
1441
|
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
|
|