@kudzujs/core 0.5.8 → 0.5.10
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 +26 -4
- package/framework/README.md +3 -2
- package/framework/build.mjs +300 -25
- package/framework/core.d.ts +4 -2
- package/framework/core.mjs +20 -6
- package/framework/dependency-runtime.js +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,17 +126,20 @@ Static trusted HTML can be rendered without a transform layer:
|
|
|
126
126
|
|
|
127
127
|
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.
|
|
128
128
|
|
|
129
|
-
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`:
|
|
129
|
+
Every CSS file under `src` is copied to the same relative path under `dist/assets` and linked in deterministic order. Stylesheets produced by another build step can be declared globally so Kudzu still emits them in every document `<head>`. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. Project-page deployments and post-build artifacts use `kudzu.config.mjs`:
|
|
130
130
|
|
|
131
131
|
```js
|
|
132
132
|
export default {
|
|
133
133
|
base: "/newsletter",
|
|
134
|
+
styles: ["/assets/generated.css"],
|
|
134
135
|
async afterBuild({ outDir, routes, plans, rewrites, base }) {
|
|
135
|
-
// Write host rewrites, RSS, sitemap, or other static artifacts.
|
|
136
|
+
// Write generated.css, host rewrites, RSS, sitemap, or other static artifacts.
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
```
|
|
139
140
|
|
|
141
|
+
Do not render `<link rel="stylesheet">` from page or component JSX. Kudzu rejects direct static body stylesheets with a source location and catches computed JSX stylesheet output during rendering. Trusted `dangerouslySetInnerHTML` remains unparsed and is responsible for its own resource tags.
|
|
142
|
+
|
|
140
143
|
## State Semantics
|
|
141
144
|
|
|
142
145
|
Kudzu intentionally differs from React's state snapshot behavior:
|
|
@@ -329,7 +332,7 @@ The original component remains reusable across multiple lists and ordinary JSX.
|
|
|
329
332
|
|
|
330
333
|
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 top-level local or relative-imported row component, and `key={item.<field>}`. Row components accept destructured projected props and top-level single-`const` calculations before one intrinsic return. 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, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
331
334
|
|
|
332
|
-
##
|
|
335
|
+
## Effects
|
|
333
336
|
|
|
334
337
|
Browser-only initial work uses the familiar empty-dependency effect shape:
|
|
335
338
|
|
|
@@ -357,12 +360,30 @@ useEffect(() => {
|
|
|
357
360
|
}, [])
|
|
358
361
|
```
|
|
359
362
|
|
|
360
|
-
Cleanup runs once when the document leaves outside the browser back-forward cache. Effect-local resources and component state read by nested cleanup closures retain their
|
|
363
|
+
Cleanup runs once when the document leaves outside the browser back-forward cache. Effect-local resources and component state read by nested cleanup closures retain their setup-time values. Cleanup failures are isolated so later cleanups still run.
|
|
364
|
+
|
|
365
|
+
Literal arrays of direct primitive `useState` or `useParams` signal identifiers rerun after committed dependency changes:
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
const [event, setEvent] = useState("resize")
|
|
369
|
+
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
const listener = () => console.log(event)
|
|
372
|
+
window.addEventListener(event, listener)
|
|
373
|
+
return () => window.removeEventListener(event, listener)
|
|
374
|
+
}, [event])
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Dependency values are limited to JSON-safe strings, finite numbers, booleans, and `null`; direct signal aliases are accepted, while expressions, property reads, ordinary props or locals, objects, spreads, and dynamic arrays fail the build. Kudzu compares dependencies with `Object.is`, coalesces multiple commits in one turn, invokes every affected previous cleanup in declaration order, awaits asynchronous cleanup, and then runs the affected setups in declaration order. The component itself is not rerun.
|
|
378
|
+
|
|
379
|
+
Effect callbacks must be inline and block-bodied. Named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry. Empty-dependency effects retain their smaller output, and dependency-only capability code is isolated to the routes that use `kudzu-deps.js` unless another capability already requires the shared runtime.
|
|
361
380
|
|
|
362
381
|
A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
|
|
363
382
|
|
|
364
383
|
A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
|
|
365
384
|
|
|
385
|
+
In the matched dependency-rerun fixture, Kudzu shipped 1.5 KB JavaScript gzip and built in 429 ms. Svelte shipped 9.7 KB in 995 ms, Vue 23.8 KB in 943 ms, React 59.2 KB in 1,172 ms, and the hand-written Astro baseline 196 B in 969 ms. Kudzu and Astro emitted initial HTML; the CSR fixtures did not.
|
|
386
|
+
|
|
366
387
|
## Normal JavaScript
|
|
367
388
|
|
|
368
389
|
Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
|
|
@@ -409,6 +430,7 @@ TSX
|
|
|
409
430
|
|
|
410
431
|
- Static pages ship no client JavaScript.
|
|
411
432
|
- Interactive pages receive only the runtime capabilities they use.
|
|
433
|
+
- Interactive route modules are discovered in the document head and retain deferred execution after HTML parsing, overlapping cold downloads with document transfer.
|
|
412
434
|
- Production JavaScript is minified; development output stays readable.
|
|
413
435
|
- Components are authoring units; no component tree is retained in the browser.
|
|
414
436
|
- There is no VDOM, hydration pass, router, or client application runtime.
|
package/framework/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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
|
+
- `dependency-runtime.js`: command runtime with commit observation for dependency effects.
|
|
7
8
|
- `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
|
|
8
9
|
- `binding-runtime.js`: optional generic attributes, form properties, comment-bounded text patches, and conditional range patches.
|
|
9
10
|
- `list-runtime.js`: optional keyed list validation, external item-expression evaluation, item-local conditional ranges, dynamic styles and item-handler scopes, moves, and cleanup.
|
|
@@ -13,6 +14,6 @@
|
|
|
13
14
|
- `dev-state.js`: dev-only, short-lived logical-state snapshot validation and restoration.
|
|
14
15
|
- `*.d.ts`: public TypeScript and JSX declarations.
|
|
15
16
|
|
|
16
|
-
Static routes receive no browser runtime. Command routes receive `runtime.js`; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`;
|
|
17
|
+
Static routes receive no browser runtime. Command routes receive `runtime.js`; dependency effects use route-specific `kudzu-deps.js` unless that route already requires shared commit hooks; runtime bracket pages using `useParams()` add one route-specific pathname matcher; reactive attributes and conditions add `binding-runtime.js`; keyed lists add `list-runtime.js`; native handlers add `native-runtime.js`; effects add `effect-runtime.js` and one route-specific entry. Generated module scripts live in the document head, so cold downloads overlap HTML transfer while standard module deferral preserves execution after parsing. A single effect with one dependency compiles to a direct runner; generic maps, sets, and ordering are reserved for larger effect graphs. Dependency commits coalesce in a microtask; affected cleanups are awaited in declaration order before replacement setups run. Document cleanup integrates with shared unmount hooks when present and otherwise disposes directly on non-persisted `pagehide`. List builds remove unused text-range, attribute, event, expression, condition, seed, and mount branches. Effect builds omit capture deserialization entirely when every effect scope is empty. 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. Runtime fallback rewrites are ordered by specificity in `.kudzu/kudzu-plan.json` and passed to `afterBuild()`; exact static files take precedence in development. 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.
|
|
17
18
|
|
|
18
|
-
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime.
|
|
19
|
+
Page `metadata` can emit description, canonical, favicon, manifest, Open Graph, and Twitter Card tags without a client runtime. Source CSS and global `kudzu.config` styles are emitted in document heads before `afterBuild()` runs; static stylesheet links in component JSX fail compilation instead of loading from the body.
|
package/framework/build.mjs
CHANGED
|
@@ -19,6 +19,7 @@ const devClient = (session, revision, schema) => `<script>(()=>{const show=event
|
|
|
19
19
|
export async function build({ quiet = false, minify = true } = {}) {
|
|
20
20
|
const config = await loadConfig()
|
|
21
21
|
const base = normalizeBase(config.base)
|
|
22
|
+
const configuredStyles = normalizeStyles(config.styles, base)
|
|
22
23
|
await rm(workDirectory, { recursive: true, force: true })
|
|
23
24
|
await rm(outputDirectory, { recursive: true, force: true })
|
|
24
25
|
await mkdir(workDirectory, { recursive: true })
|
|
@@ -41,16 +42,23 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
41
42
|
if (!pageFiles.length) throw new Error("No pages found in src/pages/")
|
|
42
43
|
|
|
43
44
|
let behaviorCount = 0
|
|
45
|
+
let regularBehaviorCount = 0
|
|
44
46
|
let bindingCount = 0
|
|
45
47
|
let listCount = 0
|
|
46
48
|
let listStyleCount = 0
|
|
47
|
-
let
|
|
49
|
+
let regularStateSeedCount = 0
|
|
50
|
+
let dependencyStateSeedCount = 0
|
|
48
51
|
const plans = []
|
|
52
|
+
const pageEntries = []
|
|
49
53
|
const effectEntries = []
|
|
50
54
|
const paramEntries = []
|
|
51
55
|
const rewrites = []
|
|
52
56
|
const emittedRoutes = new Set()
|
|
53
|
-
const styleUrls =
|
|
57
|
+
const styleUrls = [...new Set([
|
|
58
|
+
...cssFiles.map(file => assetPath(base, `assets/${relative(sourceDirectory, file).replaceAll(sep, "/")}`)),
|
|
59
|
+
...configuredStyles
|
|
60
|
+
])]
|
|
61
|
+
const runtimePlaceholder = `/__kudzu_runtime_${randomUUID()}.js`
|
|
54
62
|
|
|
55
63
|
for (const pageFile of pageFiles) {
|
|
56
64
|
const compiledFile = compiledPath(pageFile)
|
|
@@ -81,21 +89,28 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
81
89
|
...(module.metadata ?? {}),
|
|
82
90
|
styles: styleUrls.length ? styleUrls : false,
|
|
83
91
|
base,
|
|
92
|
+
runtimeAsset: runtimePlaceholder,
|
|
84
93
|
effectAsset: assetPath(base, `assets/${effectPath}`),
|
|
85
94
|
paramAsset: assetPath(base, `assets/${paramPath}`),
|
|
86
95
|
runtimeParams: runtimeSchema?.params
|
|
87
96
|
}, props)
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
const hasDependencies = result.plan.effects.some(effect => effect.dependencies?.length)
|
|
98
|
+
const usesDependencyRuntime = hasDependencies && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
|
|
99
|
+
pageEntries.push({ route, html: result.html, usesDependencyRuntime })
|
|
91
100
|
plans.push({ route: routePath, ...result.plan })
|
|
92
|
-
if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params })
|
|
93
|
-
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined })
|
|
94
|
-
if (result.hasBehaviors)
|
|
101
|
+
if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime })
|
|
102
|
+
if (result.hasEffects) effectEntries.push({ path: effectPath, effects: result.plan.effects, paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime })
|
|
103
|
+
if (result.hasBehaviors) {
|
|
104
|
+
behaviorCount++
|
|
105
|
+
if (!usesDependencyRuntime) regularBehaviorCount++
|
|
106
|
+
}
|
|
95
107
|
if (result.hasBindings) bindingCount++
|
|
96
108
|
if (result.hasLists) listCount++
|
|
97
109
|
if (result.hasListStyles) listStyleCount++
|
|
98
|
-
if (result.hasStateSeed)
|
|
110
|
+
if (result.hasStateSeed) {
|
|
111
|
+
if (usesDependencyRuntime) dependencyStateSeedCount++
|
|
112
|
+
else regularStateSeedCount++
|
|
113
|
+
}
|
|
99
114
|
}
|
|
100
115
|
}
|
|
101
116
|
|
|
@@ -119,11 +134,24 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
119
134
|
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
|
|
120
135
|
const hasNativeHandlers = nativeModules.length > 0
|
|
121
136
|
const hasEffects = effectEntries.length > 0
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
const hasSharedRuntime = bindingCount || listCount || hasNativeHandlers
|
|
138
|
+
const hasDependencyRuntime = pageEntries.some(entry => entry.usesDependencyRuntime)
|
|
139
|
+
const runtimeName = usesDependencyRuntime => usesDependencyRuntime ? "kudzu-deps.js" : "kudzu.js"
|
|
140
|
+
for (const entry of pageEntries) {
|
|
141
|
+
const routeDirectory = join(outputDirectory, entry.route)
|
|
142
|
+
await mkdir(routeDirectory, { recursive: true })
|
|
143
|
+
const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
|
|
144
|
+
await writeFile(join(routeDirectory, "index.html"), html)
|
|
145
|
+
}
|
|
146
|
+
if (behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
|
|
147
|
+
const runtimeFile = hasSharedRuntime ? "./shared-runtime.js" : "./runtime.js"
|
|
148
|
+
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, regularStateSeedCount > 0)
|
|
125
149
|
await writeJavaScript(join(assetsDirectory, "kudzu.js"), runtime, minify)
|
|
126
150
|
}
|
|
151
|
+
if (hasDependencyRuntime) {
|
|
152
|
+
const runtime = specializeRuntime(await readFile(new URL("./dependency-runtime.js", import.meta.url), "utf8"), commandEvents, dependencyStateSeedCount > 0)
|
|
153
|
+
await writeJavaScript(join(assetsDirectory, "kudzu-deps.js"), runtime, minify)
|
|
154
|
+
}
|
|
127
155
|
if (bindingCount || hasNativeHandlers || hasEffectCaptures) await writeJavaScript(join(assetsDirectory, "kudzu-serialization.js"), await readFile(new URL("./serialization.js", import.meta.url), "utf8"), minify, {
|
|
128
156
|
"globalThis.__KUDZU_CAPTURE_STATE__": String(hasNestedStateCaptures),
|
|
129
157
|
"globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
|
|
@@ -186,12 +214,12 @@ export async function build({ quiet = false, minify = true } = {}) {
|
|
|
186
214
|
for (const entry of paramEntries) {
|
|
187
215
|
const output = join(assetsDirectory, entry.path)
|
|
188
216
|
await mkdir(dirname(output), { recursive: true })
|
|
189
|
-
await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base), minify)
|
|
217
|
+
await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime)), minify)
|
|
190
218
|
}
|
|
191
219
|
for (const entry of effectEntries) {
|
|
192
220
|
const output = join(assetsDirectory, entry.path)
|
|
193
221
|
await mkdir(dirname(output), { recursive: true })
|
|
194
|
-
await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath), minify)
|
|
222
|
+
await writeJavaScript(output, printEffectEntry(entry.effects, output, handlerModules, assetsDirectory, base, entry.paramPath, runtimeName(entry.usesDependencyRuntime)), minify)
|
|
195
223
|
}
|
|
196
224
|
const clientModules = await collectClientModules(handlerModules.flatMap(module => module.clientImports), sourceFileSet)
|
|
197
225
|
for (const file of clientModules) {
|
|
@@ -243,8 +271,9 @@ function specializeNativeRuntime(source, events, modules) {
|
|
|
243
271
|
return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
|
|
244
272
|
}
|
|
245
273
|
|
|
246
|
-
function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath) {
|
|
274
|
+
function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
|
|
247
275
|
const hasCleanup = effects.some(effect => effect.cleanup)
|
|
276
|
+
const hasDependencies = effects.some(effect => effect.dependencies?.length)
|
|
248
277
|
const moduleUrls = [...new Set(effects.map(effect => effect.module))]
|
|
249
278
|
const modules = moduleUrls.map(url => {
|
|
250
279
|
const module = handlerModules.find(entry => assetPath(base, `assets/${entry.path}`) === url)
|
|
@@ -252,14 +281,117 @@ function printEffectEntry(effects, output, handlerModules, assetsDirectory, base
|
|
|
252
281
|
return module
|
|
253
282
|
})
|
|
254
283
|
const imports = [
|
|
255
|
-
hasCleanup
|
|
256
|
-
? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory,
|
|
257
|
-
: `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory,
|
|
284
|
+
hasCleanup || hasDependencies
|
|
285
|
+
? `import * as __kRuntime from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}\nconst { browserState, commitDom } = __kRuntime`
|
|
286
|
+
: `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}`,
|
|
258
287
|
`import { createEffectContext } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, "kudzu-effect.js")))}`,
|
|
259
288
|
...(paramPath ? [`import ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, paramPath)))}`] : []),
|
|
260
289
|
...modules.map((module, index) => `import * as __kEffectModule${index} from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, module.path)))}`)
|
|
261
290
|
]
|
|
262
291
|
const entries = moduleUrls.map((url, index) => `[${JSON.stringify(url)}, __kEffectModule${index}]`).join(",")
|
|
292
|
+
if (effects.length === 1 && effects[0].dependencies?.length === 1) return printSingleDependencyEffect(imports, effects[0], hasCleanup)
|
|
293
|
+
const disposal = hasCleanup ? `
|
|
294
|
+
let disposed = false
|
|
295
|
+
const dispose = root => {
|
|
296
|
+
if (root !== document || disposed) return
|
|
297
|
+
disposed = true
|
|
298
|
+
active = false
|
|
299
|
+
pending.clear()
|
|
300
|
+
for (const record of records) invokeCleanup(record)
|
|
301
|
+
}
|
|
302
|
+
if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
|
|
303
|
+
addEventListener("pagehide", event => {
|
|
304
|
+
if (event.persisted) return
|
|
305
|
+
if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
|
|
306
|
+
else dispose(document)
|
|
307
|
+
})` : ""
|
|
308
|
+
if (hasDependencies) return `${imports.join("\n")}
|
|
309
|
+
const effects = ${inlineJson(effects)}
|
|
310
|
+
const modules = new Map([${entries}])
|
|
311
|
+
const records = effects.map((effect, index) => ({ effect, index, values: undefined, cleanup: undefined }))
|
|
312
|
+
const dependencies = new Map()
|
|
313
|
+
const pending = new Set()
|
|
314
|
+
let scheduled = false
|
|
315
|
+
let flushing = false
|
|
316
|
+
let active = true
|
|
317
|
+
for (const record of records) {
|
|
318
|
+
for (const id of record.effect.dependencies ?? []) {
|
|
319
|
+
const subscribers = dependencies.get(id) ?? new Set()
|
|
320
|
+
subscribers.add(record)
|
|
321
|
+
dependencies.set(id, subscribers)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
__kRuntime.registerCommitter(id => {
|
|
325
|
+
if (!active) return
|
|
326
|
+
for (const record of dependencies.get(id) ?? []) pending.add(record)
|
|
327
|
+
schedule()
|
|
328
|
+
})
|
|
329
|
+
for (const record of records) {
|
|
330
|
+
try {
|
|
331
|
+
record.values = readDependencies(record)
|
|
332
|
+
invoke(record)
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.error(error)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function schedule() {
|
|
338
|
+
if (!pending.size || scheduled || flushing) return
|
|
339
|
+
scheduled = true
|
|
340
|
+
queueMicrotask(flush)
|
|
341
|
+
}
|
|
342
|
+
async function flush() {
|
|
343
|
+
scheduled = false
|
|
344
|
+
if (!active) return pending.clear()
|
|
345
|
+
flushing = true
|
|
346
|
+
try {
|
|
347
|
+
const selected = [...pending].sort((left, right) => left.index - right.index)
|
|
348
|
+
pending.clear()
|
|
349
|
+
const changed = []
|
|
350
|
+
for (const record of selected) {
|
|
351
|
+
try {
|
|
352
|
+
const values = readDependencies(record)
|
|
353
|
+
if (!record.values || values.some((value, index) => !Object.is(value, record.values[index]))) {
|
|
354
|
+
record.values = values
|
|
355
|
+
changed.push(record)
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error(error)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
for (const record of changed) await invokeCleanup(record)
|
|
362
|
+
if (active) for (const record of changed) invoke(record)
|
|
363
|
+
} finally {
|
|
364
|
+
flushing = false
|
|
365
|
+
if (active) schedule()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function readDependencies(record) {
|
|
369
|
+
return (record.effect.dependencies ?? []).map(id => {
|
|
370
|
+
const value = browserState.get(id)
|
|
371
|
+
if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
|
|
372
|
+
return value
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
function invoke(record) {
|
|
376
|
+
try {
|
|
377
|
+
const effect = record.effect
|
|
378
|
+
const result = modules.get(effect.module)[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
|
|
379
|
+
if (effect.cleanup && typeof result === "function") record.cleanup = result
|
|
380
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.error(error)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function invokeCleanup(record) {
|
|
386
|
+
const cleanup = record.cleanup
|
|
387
|
+
record.cleanup = undefined
|
|
388
|
+
if (!cleanup) return
|
|
389
|
+
try {
|
|
390
|
+
await cleanup()
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error(error)
|
|
393
|
+
}
|
|
394
|
+
}${disposal}`
|
|
263
395
|
if (!hasCleanup) return `${imports.join("\n")}
|
|
264
396
|
const effects = ${inlineJson(effects)}
|
|
265
397
|
const modules = new Map([${entries}])
|
|
@@ -306,8 +438,96 @@ addEventListener("pagehide", event => {
|
|
|
306
438
|
})`
|
|
307
439
|
}
|
|
308
440
|
|
|
309
|
-
function
|
|
310
|
-
|
|
441
|
+
function printSingleDependencyEffect(imports, effect, hasCleanup) {
|
|
442
|
+
const disposal = hasCleanup ? `
|
|
443
|
+
const dispose = root => {
|
|
444
|
+
if (root !== document || !active) return
|
|
445
|
+
active = false
|
|
446
|
+
pending = false
|
|
447
|
+
invokeCleanup()
|
|
448
|
+
}
|
|
449
|
+
if (__kRuntime.registerUnmountHook) __kRuntime.registerUnmountHook(dispose)
|
|
450
|
+
addEventListener("pagehide", event => {
|
|
451
|
+
if (event.persisted) return
|
|
452
|
+
if (__kRuntime.unmountDom) __kRuntime.unmountDom(document)
|
|
453
|
+
else dispose(document)
|
|
454
|
+
})` : ""
|
|
455
|
+
return `${imports.join("\n")}
|
|
456
|
+
const effect = ${inlineJson(effect)}
|
|
457
|
+
const dependency = effect.dependencies[0]
|
|
458
|
+
let value
|
|
459
|
+
let cleanup
|
|
460
|
+
let active = true
|
|
461
|
+
let pending = false
|
|
462
|
+
let scheduled = false
|
|
463
|
+
let running = false
|
|
464
|
+
__kRuntime.registerCommitter(id => {
|
|
465
|
+
if (active && id === dependency) {
|
|
466
|
+
pending = true
|
|
467
|
+
schedule()
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
try {
|
|
471
|
+
value = readDependency()
|
|
472
|
+
invoke()
|
|
473
|
+
} catch (error) {
|
|
474
|
+
console.error(error)
|
|
475
|
+
}
|
|
476
|
+
function schedule() {
|
|
477
|
+
if (!pending || scheduled || running) return
|
|
478
|
+
scheduled = true
|
|
479
|
+
queueMicrotask(flush)
|
|
480
|
+
}
|
|
481
|
+
async function flush() {
|
|
482
|
+
scheduled = false
|
|
483
|
+
if (!active) return
|
|
484
|
+
let next
|
|
485
|
+
try {
|
|
486
|
+
next = readDependency()
|
|
487
|
+
} catch (error) {
|
|
488
|
+
console.error(error)
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
pending = false
|
|
492
|
+
if (Object.is(next, value)) return
|
|
493
|
+
value = next
|
|
494
|
+
running = true
|
|
495
|
+
try {
|
|
496
|
+
await invokeCleanup()
|
|
497
|
+
if (active) invoke()
|
|
498
|
+
} finally {
|
|
499
|
+
running = false
|
|
500
|
+
if (active) schedule()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function readDependency() {
|
|
504
|
+
const next = browserState.get(dependency)
|
|
505
|
+
if (next !== null && typeof next !== "string" && typeof next !== "boolean" && !(typeof next === "number" && Number.isFinite(next) && !Object.is(next, -0))) throw new Error("useEffect() dependency state must remain a JSON-safe primitive")
|
|
506
|
+
return next
|
|
507
|
+
}
|
|
508
|
+
function invoke() {
|
|
509
|
+
try {
|
|
510
|
+
const result = __kEffectModule0[effect.handler](createEffectContext(browserState, effect.states, commitDom, effect.scope))
|
|
511
|
+
if (effect.cleanup && typeof result === "function") cleanup = result
|
|
512
|
+
else if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.error(error)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function invokeCleanup() {
|
|
518
|
+
const current = cleanup
|
|
519
|
+
cleanup = undefined
|
|
520
|
+
if (!current) return
|
|
521
|
+
try {
|
|
522
|
+
await current()
|
|
523
|
+
} catch (error) {
|
|
524
|
+
console.error(error)
|
|
525
|
+
}
|
|
526
|
+
}${disposal}`
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName) {
|
|
530
|
+
return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
|
|
311
531
|
const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
|
|
312
532
|
const schema = ${inlineJson(schema.segments)}
|
|
313
533
|
const params = ${inlineJson(params)}
|
|
@@ -501,7 +721,7 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
|
|
|
501
721
|
}
|
|
502
722
|
|
|
503
723
|
function injectDevClient(html, session, revision, schema) {
|
|
504
|
-
return `${html}${devClient(session, revision, schema)}`
|
|
724
|
+
return `${html}${devClient(session, revision, schema).replace("binding|list|native", "binding|deps|list|native")}`
|
|
505
725
|
}
|
|
506
726
|
|
|
507
727
|
function stripBaseStrict(path, base) {
|
|
@@ -576,6 +796,10 @@ function escapeHtml(value) {
|
|
|
576
796
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
577
797
|
}
|
|
578
798
|
|
|
799
|
+
function escapeAttribute(value) {
|
|
800
|
+
return escapeHtml(value).replaceAll('"', """).replaceAll("'", "'")
|
|
801
|
+
}
|
|
802
|
+
|
|
579
803
|
async function compile(file, sourceFiles, sourceIndex, base) {
|
|
580
804
|
const source = sourceIndex.get(file)
|
|
581
805
|
const nativeHandlers = []
|
|
@@ -625,6 +849,7 @@ async function compile(file, sourceFiles, sourceIndex, base) {
|
|
|
625
849
|
function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, clientImports) {
|
|
626
850
|
return context => sourceFile => {
|
|
627
851
|
const factory = context.factory
|
|
852
|
+
const hasLinkElements = /<link/i.test(sourceFile.text)
|
|
628
853
|
sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
|
|
629
854
|
ts.setParentRecursive(sourceFile, false)
|
|
630
855
|
const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
|
|
@@ -827,6 +1052,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
827
1052
|
if (specializedDeclarations.has(node)) return node
|
|
828
1053
|
if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
|
|
829
1054
|
|
|
1055
|
+
if (hasLinkElements && (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) && isStylesheetLink(node)) {
|
|
1056
|
+
fail(node, "Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
|
|
1057
|
+
}
|
|
1058
|
+
|
|
830
1059
|
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text.startsWith(".")) {
|
|
831
1060
|
const target = resolveSourceImport(file, node.moduleSpecifier.text, sourceFiles)
|
|
832
1061
|
return factory.updateImportDeclaration(node, node.modifiers, node.importClause, factory.createStringLiteral(relativeModulePath(compiledPath(file), compiledPath(target))), node.attributes)
|
|
@@ -838,13 +1067,15 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
838
1067
|
}
|
|
839
1068
|
|
|
840
1069
|
if (hasUseEffectImport && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useEffect") {
|
|
841
|
-
if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal
|
|
1070
|
+
if (node.arguments.length !== 2) fail(node, "useEffect() requires exactly a callback and literal dependency array")
|
|
842
1071
|
const [callback, dependencies] = node.arguments
|
|
843
1072
|
if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) fail(callback, "useEffect() callback must be an inline function")
|
|
844
1073
|
if (ts.isFunctionExpression(callback) && callback.name) fail(callback, "useEffect() callback function must be anonymous")
|
|
845
1074
|
if (callback.asteriskToken) fail(callback, "useEffect() callback cannot be a generator")
|
|
846
1075
|
if (callback.parameters.length) fail(callback, "useEffect() callback cannot declare parameters")
|
|
847
|
-
if (!ts.isArrayLiteralExpression(dependencies)
|
|
1076
|
+
if (!ts.isArrayLiteralExpression(dependencies)) fail(dependencies, "useEffect() dependencies must be a literal array")
|
|
1077
|
+
const invalidDependency = dependencies.elements.find(dependency => !ts.isIdentifier(dependency))
|
|
1078
|
+
if (invalidDependency) fail(invalidDependency, "useEffect() dependencies must be direct state or runtime parameter identifiers")
|
|
848
1079
|
if (!nearestFunction(node)) fail(node, "useEffect() cannot be used outside a Kudzu component")
|
|
849
1080
|
if (!ts.isBlock(callback.body)) fail(callback, "useEffect() callback must use a block body")
|
|
850
1081
|
const returns = effectReturns(callback)
|
|
@@ -1268,6 +1499,19 @@ function jsxTagName(node) {
|
|
|
1268
1499
|
return ts.isJsxElement(node) ? node.openingElement.tagName : ts.isJsxSelfClosingElement(node) ? node.tagName : undefined
|
|
1269
1500
|
}
|
|
1270
1501
|
|
|
1502
|
+
function isStylesheetLink(node) {
|
|
1503
|
+
const element = ts.isJsxElement(node) ? node.openingElement : node
|
|
1504
|
+
if (!ts.isIdentifier(element.tagName) || element.tagName.text.toLowerCase() !== "link") return false
|
|
1505
|
+
const attribute = element.attributes.properties.find(property => ts.isJsxAttribute(property) && property.name.getText().toLowerCase() === "rel")
|
|
1506
|
+
if (!attribute?.initializer) return false
|
|
1507
|
+
const value = ts.isStringLiteral(attribute.initializer)
|
|
1508
|
+
? attribute.initializer.text
|
|
1509
|
+
: ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression && (ts.isStringLiteral(attribute.initializer.expression) || ts.isNoSubstitutionTemplateLiteral(attribute.initializer.expression))
|
|
1510
|
+
? attribute.initializer.expression.text
|
|
1511
|
+
: undefined
|
|
1512
|
+
return value?.toLowerCase().split(/\s+/).includes("stylesheet") ?? false
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1271
1515
|
function isContextProviderValue(node, contexts) {
|
|
1272
1516
|
if (node.name.getText() !== "value") return false
|
|
1273
1517
|
const element = node.parent?.parent
|
|
@@ -1912,6 +2156,8 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1912
2156
|
const stateNames = new Set(setters.values())
|
|
1913
2157
|
const snapshotNames = snapshotNested ? nestedStateNames(expression, setters) : new Set()
|
|
1914
2158
|
const snapshots = new Map([...snapshotNames].map(name => [name, factory.createUniqueName("__kEffectState")]))
|
|
2159
|
+
const captureSnapshotNames = snapshotNested ? nestedCaptureNames(expression, captures) : new Set()
|
|
2160
|
+
const captureSnapshots = new Map([...captureSnapshotNames].map(name => [name, factory.createUniqueName("__kEffectCapture")]))
|
|
1915
2161
|
const transformer = context => root => {
|
|
1916
2162
|
const visitor = node => {
|
|
1917
2163
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
|
|
@@ -1940,9 +2186,11 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1940
2186
|
)
|
|
1941
2187
|
}
|
|
1942
2188
|
if (ts.isShorthandPropertyAssignment(node) && captures.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
|
|
2189
|
+
if (captureSnapshots.has(node.name.text) && insideNestedFunction(node, expression)) return factory.createPropertyAssignment(node.name, captureSnapshots.get(node.name.text))
|
|
1943
2190
|
return factory.createPropertyAssignment(node.name, scopeRead(factory, node.name.text))
|
|
1944
2191
|
}
|
|
1945
2192
|
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
|
|
2193
|
+
if (captureSnapshots.has(node.text) && insideNestedFunction(node, expression)) return captureSnapshots.get(node.text)
|
|
1946
2194
|
return scopeRead(factory, node.text)
|
|
1947
2195
|
}
|
|
1948
2196
|
return ts.visitEachChild(node, visitor, context)
|
|
@@ -1954,8 +2202,12 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1954
2202
|
let body = ts.isBlock(expression.body)
|
|
1955
2203
|
? transformed.transformed[0]
|
|
1956
2204
|
: factory.createBlock([factory.createReturnStatement(transformed.transformed[0])], true)
|
|
1957
|
-
|
|
1958
|
-
|
|
2205
|
+
const snapshotDeclarations = [
|
|
2206
|
+
...[...snapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "get"), undefined, [factory.createStringLiteral(name)]))),
|
|
2207
|
+
...[...captureSnapshots].map(([name, identifier]) => factory.createVariableDeclaration(identifier, undefined, undefined, factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"), undefined, [factory.createStringLiteral(name)])))
|
|
2208
|
+
]
|
|
2209
|
+
if (snapshotDeclarations.length) body = factory.updateBlock(body, [
|
|
2210
|
+
factory.createVariableStatement(undefined, factory.createVariableDeclarationList(snapshotDeclarations, ts.NodeFlags.Const)),
|
|
1959
2211
|
...body.statements
|
|
1960
2212
|
])
|
|
1961
2213
|
const modifiers = [factory.createModifier(ts.SyntaxKind.ExportKeyword)]
|
|
@@ -1975,6 +2227,16 @@ function printNativeHandler({ exportName, expression, captures, setters, snapsho
|
|
|
1975
2227
|
}
|
|
1976
2228
|
}
|
|
1977
2229
|
|
|
2230
|
+
function nestedCaptureNames(expression, captures) {
|
|
2231
|
+
const names = new Set()
|
|
2232
|
+
const visit = node => {
|
|
2233
|
+
if (ts.isIdentifier(node) && captures.has(node.text) && isReferenceIdentifier(node) && insideNestedFunction(node, expression) && !isShadowedIdentifier(node, expression)) names.add(node.text)
|
|
2234
|
+
ts.forEachChild(node, visit)
|
|
2235
|
+
}
|
|
2236
|
+
visit(expression.body)
|
|
2237
|
+
return names
|
|
2238
|
+
}
|
|
2239
|
+
|
|
1978
2240
|
function nestedStateNames(expression, setters) {
|
|
1979
2241
|
const states = new Set(setters.values())
|
|
1980
2242
|
const names = new Set()
|
|
@@ -2124,6 +2386,19 @@ async function loadConfig() {
|
|
|
2124
2386
|
return {}
|
|
2125
2387
|
}
|
|
2126
2388
|
|
|
2389
|
+
function normalizeStyles(value, base) {
|
|
2390
|
+
if (value === undefined) return []
|
|
2391
|
+
if (!Array.isArray(value)) throw new Error("kudzu.config styles must be an array of URLs")
|
|
2392
|
+
return value.map((style, index) => {
|
|
2393
|
+
if (typeof style !== "string" || !style) throw new Error(`kudzu.config styles[${index}] must be a non-empty URL`)
|
|
2394
|
+
if (style.startsWith("//")) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
|
|
2395
|
+
if (style.startsWith("/")) return withBase(base, style)
|
|
2396
|
+
if (!/^https?:\/\//i.test(style)) throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`)
|
|
2397
|
+
try { new URL(style) } catch { throw new Error(`kudzu.config styles[${index}] must be root-relative or an absolute HTTP URL`) }
|
|
2398
|
+
return style
|
|
2399
|
+
})
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2127
2402
|
function normalizeBase(value) {
|
|
2128
2403
|
if (value == null || value === "" || value === "/") return ""
|
|
2129
2404
|
if (typeof value !== "string" || !value.startsWith("/") || /[?#\0]/.test(value) || /%(?:2f|5c)/i.test(value)) throw new Error("kudzu.config base must be a root-relative path")
|
package/framework/core.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
|
|
2
2
|
export type EffectCleanup = () => void | Promise<void>
|
|
3
|
+
export type EffectDependency = string | number | boolean | null
|
|
3
4
|
|
|
4
5
|
export function useState<T>(initialValue: T): [T, StateSetter<T>]
|
|
5
|
-
export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly []): void
|
|
6
|
+
export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
|
|
6
7
|
export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
|
|
7
8
|
|
|
8
9
|
export interface RefObject<T> {
|
|
@@ -49,6 +50,7 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
49
50
|
manifest?: string
|
|
50
51
|
styles?: boolean | string[]
|
|
51
52
|
base?: string
|
|
53
|
+
runtimeAsset?: string
|
|
52
54
|
effectAsset?: string
|
|
53
55
|
paramAsset?: string
|
|
54
56
|
runtimeParams?: string[]
|
|
@@ -71,7 +73,7 @@ export function renderPage<Props = Record<string, never>>(
|
|
|
71
73
|
commands?: Array<[string, string, unknown]>
|
|
72
74
|
native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
|
|
73
75
|
}>
|
|
74
|
-
effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; cleanup?: true }>
|
|
76
|
+
effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; dependencies?: string[]; cleanup?: true }>
|
|
75
77
|
bindings: Array<{
|
|
76
78
|
target: string
|
|
77
79
|
state?: string
|
package/framework/core.mjs
CHANGED
|
@@ -66,14 +66,20 @@ function createSignal(id, value) {
|
|
|
66
66
|
|
|
67
67
|
export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup) {
|
|
68
68
|
if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
|
|
69
|
-
if (typeof callback !== "function" || !Array.isArray(dependencies) ||
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
|
|
70
|
+
const dependencyIds = dependencies.map(dependency => {
|
|
71
|
+
if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() dependencies must be primitive Kudzu state or runtime parameter identifiers`)
|
|
72
|
+
return dependency.id
|
|
73
|
+
})
|
|
74
|
+
renderContext.effects.push({ module, handler, states, scope, source, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(cleanup ? { cleanup: true } : {}) })
|
|
73
75
|
renderContext.hasBehaviors = true
|
|
74
76
|
renderContext.hasEffects = true
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
function validEffectDependency(value) {
|
|
80
|
+
return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
export function useRef(initialValue) {
|
|
78
84
|
if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
|
|
79
85
|
if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
|
|
@@ -271,6 +277,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
271
277
|
return {
|
|
272
278
|
module: effect.module,
|
|
273
279
|
handler: effect.handler,
|
|
280
|
+
...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
|
|
274
281
|
...(effect.cleanup ? { cleanup: true } : {}),
|
|
275
282
|
...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
|
|
276
283
|
}
|
|
@@ -284,7 +291,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
284
291
|
? ""
|
|
285
292
|
: (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
|
|
286
293
|
const runtime = renderContext.hasBehaviors
|
|
287
|
-
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu.js")}"></script>`
|
|
294
|
+
? `<script type="module" src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
|
|
288
295
|
: ""
|
|
289
296
|
const nativeRuntime = renderContext.hasNativeBehaviors
|
|
290
297
|
? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
|
|
@@ -317,7 +324,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
|
|
|
317
324
|
: ""
|
|
318
325
|
|
|
319
326
|
return {
|
|
320
|
-
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}
|
|
327
|
+
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}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</head><body${state}${textBindings}>${body}</body></html>`,
|
|
321
328
|
hasBehaviors: renderContext.hasBehaviors,
|
|
322
329
|
hasEffects: renderContext.hasEffects,
|
|
323
330
|
hasParams: renderContext.hasParams,
|
|
@@ -476,6 +483,13 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
476
483
|
|
|
477
484
|
const tag = node.type
|
|
478
485
|
const props = node.props ?? {}
|
|
486
|
+
if (typeof tag === "string" && tag.toLowerCase() === "link") {
|
|
487
|
+
const rel = Object.entries(props).find(([name]) => name.toLowerCase() === "rel")?.[1]
|
|
488
|
+
const value = rel?.[signalMarker] || rel?.[bindingMarker] ? rel.value : rel
|
|
489
|
+
if (typeof value === "string" && value.toLowerCase().split(/\s+/).includes("stylesheet")) {
|
|
490
|
+
throw new Error("Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
|
|
491
|
+
}
|
|
492
|
+
}
|
|
479
493
|
const directListText = props.children?.[listFieldMarker] ? props.children : undefined
|
|
480
494
|
const childSelectValue = tag === "select"
|
|
481
495
|
? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function applyCommands(state, commands, commit, log = console.log) {
|
|
2
|
+
const changed = new Set()
|
|
3
|
+
for (const [operation, id, operand] of commands) {
|
|
4
|
+
const current = state.get(id)
|
|
5
|
+
if (operation === "log") log(operand, current)
|
|
6
|
+
else {
|
|
7
|
+
state.set(id, operation === "add" ? current + operand : operand)
|
|
8
|
+
changed.add(id)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
for (const id of changed) commit(id, state.get(id))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const browserState = new Map()
|
|
15
|
+
const committers = []
|
|
16
|
+
|
|
17
|
+
export function registerCommitter(commit) {
|
|
18
|
+
committers.push(commit)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function commitDom(id, value) {
|
|
22
|
+
for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
|
|
23
|
+
for (const commit of committers) commit(id)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof document !== "undefined") {
|
|
27
|
+
const initialState = document.body.dataset.kState
|
|
28
|
+
if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
|
|
29
|
+
for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
|
|
30
|
+
|
|
31
|
+
const eventNames = ["click", "input", "change"]
|
|
32
|
+
for (const eventName of eventNames) document.addEventListener(eventName, event => {
|
|
33
|
+
const target = event.target.closest(`[data-k-on-${eventName}]`)
|
|
34
|
+
if (target) applyCommands(browserState, JSON.parse(target.getAttribute(`data-k-on-${eventName}`)), commitDom)
|
|
35
|
+
})
|
|
36
|
+
}
|