@kudzujs/core 0.4.14 → 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 +64 -0
- package/framework/README.md +2 -2
- package/framework/binding-runtime.js +52 -4
- package/framework/build.mjs +105 -27
- 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:
|
|
@@ -294,6 +333,9 @@ Supported:
|
|
|
294
333
|
- Function components, props, children, fragments, and TSX
|
|
295
334
|
- File-based static routes
|
|
296
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`
|
|
297
339
|
- Primitive `useState` bindings
|
|
298
340
|
- Synchronous and async event handlers
|
|
299
341
|
- Relative imported helpers in native handlers
|
|
@@ -346,6 +388,28 @@ The same native counter calculation was measured inline and through one relative
|
|
|
346
388
|
|
|
347
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.
|
|
348
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
|
+
|
|
349
413
|
### Static Journal Page
|
|
350
414
|
|
|
351
415
|
Same content and CSS across every fixture:
|
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
|
|
|
@@ -1289,9 +1317,59 @@ function compiledPath(file) {
|
|
|
1289
1317
|
return join(workDirectory, relative(sourceDirectory, file)).replace(/\.(?:ts|tsx)$/, ".mjs")
|
|
1290
1318
|
}
|
|
1291
1319
|
|
|
1292
|
-
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 = {}) {
|
|
1293
1360
|
const page = relative(pagesDirectory, file).replace(/\\/g, "/").replace(/\.tsx$/, "")
|
|
1294
|
-
|
|
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
|
|
1295
1373
|
}
|
|
1296
1374
|
|
|
1297
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
|
|