@kudzujs/core 0.5.10 → 0.6.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.
@@ -54,8 +54,12 @@ export function renderPage<Props = Record<string, never>>(
54
54
  effectAsset?: string
55
55
  paramAsset?: string
56
56
  runtimeParams?: string[]
57
+ navigationAsset?: string
58
+ applicationId?: string
59
+ layoutId?: string
57
60
  },
58
- props?: Props
61
+ props?: Props,
62
+ layout?: (props: { children: unknown }) => unknown | Promise<unknown>
59
63
  ): Promise<{
60
64
  html: string
61
65
  hasBehaviors: boolean
@@ -66,14 +70,14 @@ export function renderPage<Props = Record<string, never>>(
66
70
  hasListStyles: boolean
67
71
  hasStateSeed: boolean
68
72
  plan: {
69
- states: Array<{ id: string; name: string; initialValue: unknown }>
73
+ states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route" }>
70
74
  params: Array<{ name: string; id: string }>
71
75
  events: Array<{
72
76
  event: string
73
77
  commands?: Array<[string, string, unknown]>
74
78
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
75
79
  }>
76
- effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; dependencies?: string[]; cleanup?: true }>
80
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; lifetime?: "layout" | "route"; dependencies?: string[]; cleanup?: true; owner?: string; list?: true }>
77
81
  bindings: Array<{
78
82
  target: string
79
83
  state?: string
@@ -14,6 +14,7 @@ const listConditionalMarker = Symbol("kudzu.listConditional")
14
14
  const refMarker = Symbol("kudzu.ref")
15
15
  const contextMarker = Symbol("kudzu.context")
16
16
  const contextProviderMarker = Symbol("kudzu.contextProvider")
17
+ const routeScopeMarker = Symbol("kudzu.routeScope")
17
18
  const noSelectValue = Symbol("kudzu.no-select-value")
18
19
 
19
20
  let renderContext
@@ -23,23 +24,24 @@ export function useState(initialValue, name) {
23
24
  throw new Error("useState() can only run while rendering a Kudzu component")
24
25
  }
25
26
 
26
- const id = `s${renderContext.nextState++}`
27
+ const id = nextRenderId("s")
27
28
  const signal = createSignal(id, initialValue)
28
29
 
29
30
  const setter = () => {
30
31
  throw new Error("State setters are compiled into ordered browser behaviors")
31
32
  }
32
33
  Object.defineProperty(setter, setterMarker, { value: id })
33
- renderContext.states[id] = { name: name ?? id, initialValue }
34
+ renderContext.states[id] = { name: name ?? id, initialValue, ...(renderContext.scoped ? { lifetime: renderContext.renderScope } : {}) }
34
35
  return [signal, setter]
35
36
  }
36
37
 
37
38
  export function useParams() {
39
+ if (renderContext?.renderScope === "layout") throw new Error("useParams() is only supported in route scope")
38
40
  if (!renderContext?.runtimeParamNames?.length) throw new Error("useParams() requires export const runtimeParams = true on a bracket page")
39
41
  if (!renderContext.params) {
40
42
  const params = Object.create(null)
41
- renderContext.paramEntries = renderContext.runtimeParamNames.map((name, index) => {
42
- const id = `p${index}`
43
+ renderContext.paramEntries = renderContext.runtimeParamNames.map(name => {
44
+ const id = nextRenderId("p")
43
45
  params[name] = createSignal(id, "")
44
46
  return { name, id }
45
47
  })
@@ -71,7 +73,28 @@ export function useEffect(callback, dependencies, module, handler, states, scope
71
73
  if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() dependencies must be primitive Kudzu state or runtime parameter identifiers`)
72
74
  return dependency.id
73
75
  })
74
- renderContext.effects.push({ module, handler, states, scope, source, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(cleanup ? { cleanup: true } : {}) })
76
+ let owner
77
+ let list = false
78
+ if (renderContext.listDepth) {
79
+ const effects = renderContext.listRoot?.effects
80
+ if (!effects) throw new Error(`${source} useEffect() inside keyed lists must belong to the direct row component`)
81
+ const index = effects.length
82
+ if (renderContext.listTemplate) {
83
+ owner = nextRenderId("e")
84
+ renderContext.listEffectOwners.push(owner)
85
+ list = true
86
+ } else {
87
+ owner = renderContext.listEffectOwners[index]
88
+ if (!owner) throw new Error(`${source} Keyed row effects must have the same hook order for every item`)
89
+ }
90
+ effects.push(owner)
91
+ } else if (renderContext.conditionDepth) {
92
+ const owners = renderContext.effectOwners.at(-1)
93
+ if (!owners) throw new Error(`${source} useEffect() inside conditional DOM must belong to a rendered function component`)
94
+ owner = nextRenderId("e")
95
+ owners.push(owner)
96
+ }
97
+ if (!renderContext.listDepth || list) renderContext.effects.push({ module, handler, states, scope, source, renderScope: renderContext.renderScope, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(cleanup ? { cleanup: true } : {}), ...(owner ? { owner } : {}), ...(list ? { list: true } : {}) })
75
98
  renderContext.hasBehaviors = true
76
99
  renderContext.hasEffects = true
77
100
  }
@@ -83,7 +106,7 @@ function validEffectDependency(value) {
83
106
  export function useRef(initialValue) {
84
107
  if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
85
108
  if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
86
- return { [refMarker]: true, id: `r${renderContext.nextRef++}`, current: null }
109
+ return { [refMarker]: true, id: nextRenderId("r"), current: null }
87
110
  }
88
111
 
89
112
  export function createContext(defaultValue) {
@@ -267,19 +290,26 @@ function serializeCapture(name, value, seen) {
267
290
  }
268
291
  }
269
292
 
270
- export async function renderPage(component, metadata = {}, props = {}) {
271
- 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: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
293
+ export async function renderPage(component, metadata = {}, props = {}, layout) {
294
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
272
295
 
273
296
  try {
274
- const body = await renderNode({ type: component, props })
297
+ const page = { [routeScopeMarker]: true, component, props }
298
+ const body = await renderNode(layout ? { type: layout, props: { children: page } } : { type: component, props })
275
299
  renderContext.effects = renderContext.effects.map(effect => {
276
300
  try {
301
+ if (metadata.navigationAsset && effect.owner) throw new Error("useEffect() inside conditional or keyed DOM is not supported in a configured navigation group yet; move the effect to the layout or route component body, or remove the route from navigation")
302
+ const descriptor = nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, typeof read === "function" ? read() : read]))
277
303
  return {
278
304
  module: effect.module,
279
305
  handler: effect.handler,
280
306
  ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
281
307
  ...(effect.cleanup ? { cleanup: true } : {}),
282
- ...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
308
+ ...(effect.owner ? { owner: effect.owner } : {}),
309
+ ...(effect.list ? { list: true } : {}),
310
+ ...(renderContext.scoped ? { lifetime: effect.renderScope } : {}),
311
+ states: descriptor.states,
312
+ scope: descriptor.scope
283
313
  }
284
314
  } catch (error) {
285
315
  throw new Error(`${effect.source} ${error.message}`)
@@ -287,26 +317,30 @@ export async function renderPage(component, metadata = {}, props = {}) {
287
317
  })
288
318
  const title = escapeHtml(metadata.title ?? "Kudzu")
289
319
  const head = renderMetadata(metadata)
320
+ const capability = metadata.navigationAsset ? " data-k-capability" : ""
290
321
  const styles = metadata.styles === false
291
322
  ? ""
292
323
  : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
293
324
  const runtime = renderContext.hasBehaviors
294
- ? `<script type="module" src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
325
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
295
326
  : ""
296
327
  const nativeRuntime = renderContext.hasNativeBehaviors
297
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
328
+ ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
298
329
  : ""
299
330
  const paramRuntime = renderContext.hasParams
300
- ? `<script type="module" src="${escapeAttribute(metadata.paramAsset)}"></script>`
331
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.paramAsset)}"></script>`
301
332
  : ""
302
333
  const bindingRuntime = renderContext.hasBindings
303
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
334
+ ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
304
335
  : ""
305
336
  const listRuntime = renderContext.hasLists
306
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
337
+ ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
307
338
  : ""
308
339
  const effectRuntime = renderContext.hasEffects
309
- ? `<script type="module" src="${escapeAttribute(metadata.effectAsset)}"></script>`
340
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.effectAsset)}"></script>`
341
+ : ""
342
+ const navigationRuntime = metadata.navigationAsset
343
+ ? `<script type="module" data-k-capability src="${escapeAttribute(metadata.navigationAsset)}"></script>`
310
344
  : ""
311
345
  const listStates = new Set(renderContext.lists.map(list => list.state))
312
346
  const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
@@ -324,7 +358,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
324
358
  : ""
325
359
 
326
360
  return {
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>`,
361
+ 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}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}"` : ""}>${body}</body></html>`,
328
362
  hasBehaviors: renderContext.hasBehaviors,
329
363
  hasEffects: renderContext.hasEffects,
330
364
  hasParams: renderContext.hasParams,
@@ -349,16 +383,17 @@ export async function renderPage(component, metadata = {}, props = {}) {
349
383
 
350
384
  function renderMetadata(metadata) {
351
385
  const tags = []
386
+ const owned = metadata.navigationAsset ? " data-k-head" : ""
352
387
  const meta = (name, content, property = false) => {
353
- if (content) tags.push(`<meta ${property ? "property" : "name"}="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`)
388
+ if (content) tags.push(`<meta${owned} ${property ? "property" : "name"}="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`)
354
389
  }
355
390
 
356
391
  if (metadata.description) meta("description", metadata.description)
357
392
  if (metadata.themeColor) meta("theme-color", metadata.themeColor)
358
- if (metadata.url) tags.push(`<link rel="canonical" href="${escapeAttribute(metadata.url)}">`)
359
- if (metadata.icon) tags.push(`<link rel="icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.icon))}">`)
360
- if (metadata.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.appleTouchIcon))}">`)
361
- if (metadata.manifest) tags.push(`<link rel="manifest" href="${escapeAttribute(baseUrl(metadata.base, metadata.manifest))}">`)
393
+ if (metadata.url) tags.push(`<link${owned} rel="canonical" href="${escapeAttribute(metadata.url)}">`)
394
+ if (metadata.icon) tags.push(`<link${owned} rel="icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.icon))}">`)
395
+ if (metadata.appleTouchIcon) tags.push(`<link${owned} rel="apple-touch-icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.appleTouchIcon))}">`)
396
+ if (metadata.manifest) tags.push(`<link${owned} rel="manifest" href="${escapeAttribute(baseUrl(metadata.base, metadata.manifest))}">`)
362
397
 
363
398
  meta("og:title", metadata.title, true)
364
399
  meta("og:description", metadata.description, true)
@@ -404,6 +439,16 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
404
439
  return escapeHtml(node)
405
440
  }
406
441
  if (node instanceof Promise) return renderNode(await node, namespace, selectValue)
442
+ if (node?.[routeScopeMarker]) {
443
+ const previousScope = renderContext.renderScope
444
+ renderContext.renderScope = "route"
445
+ try {
446
+ const html = await renderNode({ type: node.component, props: node.props }, namespace, selectValue)
447
+ return `<template data-k-route-start></template>${html}<template data-k-route-end></template>`
448
+ } finally {
449
+ renderContext.renderScope = previousScope
450
+ }
451
+ }
407
452
  if (node?.[contextProviderMarker]) {
408
453
  renderContext.contexts.push([node.context, node.value])
409
454
  try {
@@ -418,7 +463,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
418
463
  if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
419
464
  if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
420
465
 
421
- const id = `c${renderContext.nextCondition++}`
466
+ const id = nextRenderId("c")
422
467
  renderContext.conditionDepth++
423
468
  const truthy = await renderNode(node.truthy(), namespace, selectValue)
424
469
  const falsy = await renderNode(node.falsy(), namespace, selectValue)
@@ -479,7 +524,19 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
479
524
  }
480
525
 
481
526
  if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children, namespace, selectValue)
482
- if (typeof node.type === "function") return renderNode(await node.type(node.props), namespace, selectValue)
527
+ if (typeof node.type === "function") {
528
+ if (!renderContext.conditionDepth) return renderNode(await node.type(node.props), namespace, selectValue)
529
+ const owners = []
530
+ renderContext.effectOwners.push(owners)
531
+ let result
532
+ try {
533
+ result = await node.type(node.props)
534
+ } finally {
535
+ renderContext.effectOwners.pop()
536
+ }
537
+ const html = await renderNode(result, namespace, selectValue)
538
+ return owners.map(owner => `<template data-k-effect="${owner}"></template>`).join("") + html
539
+ }
483
540
 
484
541
  const tag = node.type
485
542
  const props = node.props ?? {}
@@ -508,6 +565,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
508
565
  const root = renderContext.listRoot
509
566
  renderContext.listRoot = undefined
510
567
  if (root.template) attributes += ` data-k-list-root="${root.id}"`
568
+ if (root.effects.length) {
569
+ attributes += ` data-k-effects='${escapeJsonAttribute(root.effects)}'`
570
+ if (!root.template) attributes += ` data-k-effect-item='${escapeJsonAttribute(root.item)}'`
571
+ }
511
572
  }
512
573
 
513
574
  for (const [rawName, value] of Object.entries(props)) {
@@ -520,7 +581,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
520
581
  }
521
582
  if (rawName === "selected" && selectValue !== noSelectValue) continue
522
583
  if (/^on/i.test(rawName) && !/^on[A-Z]/.test(rawName)) throw new Error(`${rawName} must use a camelCase event handler`)
523
- if (rawName.toLowerCase().startsWith("data-k-")) throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
584
+ if (rawName.toLowerCase().startsWith("data-k-") && rawName.toLowerCase() !== "data-k-native") throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
524
585
  if (["ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
525
586
  throw new Error(`Reactive ${rawName} is not supported`)
526
587
  }
@@ -613,16 +674,19 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
613
674
 
614
675
  async function renderList(node, namespace, selectValue) {
615
676
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
616
- const id = `l${renderContext.nextList++}`
677
+ const id = nextRenderId("l")
617
678
  const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]) }
618
679
  renderContext.listDepth++
619
680
  const previousListFields = renderContext.listFields
681
+ const previousListEffectOwners = renderContext.listEffectOwners
620
682
  try {
621
683
  renderContext.listTemplate = true
684
+ renderContext.listEffectOwners = []
622
685
  renderContext.listFields = new Set([node.keyField])
623
- renderContext.listRoot = { id, template: true }
686
+ renderContext.listRoot = { id, template: true, effects: [], item: {} }
624
687
  const template = await renderNode(node.render({}), namespace, selectValue)
625
- if (template.includes("data-k-native-")) descriptor.mount = true
688
+ if (template.includes("data-k-native-") || template.includes("data-k-effects=")) descriptor.mount = true
689
+ if (template.includes("data-k-effects=")) descriptor.effects = true
626
690
  if (template.includes("data-k-list-condition")) descriptor.conditions = true
627
691
  if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
628
692
  if (template.includes("data-k-list-attrs")) descriptor.attributes = true
@@ -635,7 +699,7 @@ async function renderList(node, namespace, selectValue) {
635
699
  renderContext.listTemplate = false
636
700
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
637
701
  for (const item of node.items.value) {
638
- renderContext.listRoot = { id, key: item[node.keyField], template: false }
702
+ renderContext.listRoot = { id, key: item[node.keyField], template: false, effects: [], item }
639
703
  current += await renderNode(node.render(item), namespace, selectValue)
640
704
  }
641
705
  renderContext.lists.push(descriptor)
@@ -647,10 +711,17 @@ async function renderList(node, namespace, selectValue) {
647
711
  renderContext.listTemplate = false
648
712
  renderContext.listInitialMarkers = false
649
713
  renderContext.listFields = previousListFields
714
+ renderContext.listEffectOwners = previousListEffectOwners
650
715
  renderContext.listDepth--
651
716
  }
652
717
  }
653
718
 
719
+ function nextRenderId(kind) {
720
+ if (renderContext.scoped) return `${renderContext.renderScope === "layout" ? "l" : "r"}${kind}${renderContext.counters[renderContext.renderScope][kind]++}`
721
+ const counters = { s: "nextState", r: "nextRef", c: "nextCondition", l: "nextList", e: "nextEffect", p: "nextParam" }
722
+ return `${kind}${renderContext[counters[kind]]++}`
723
+ }
724
+
654
725
  function optionValue(props) {
655
726
  if (props.value != null) return bindingValue(props.value)
656
727
  return Array.isArray(props.children) ? props.children.join("") : props.children ?? ""
@@ -1,17 +1,19 @@
1
1
  import { deserialize } from "./serialization.js"
2
2
 
3
- export function createEffectContext(state, stateIds, commit, serializedScope = {}) {
3
+ export function createEffectContext(state, stateIds, commit, serializedScope = {}, active = () => true) {
4
4
  const changed = new Set()
5
5
  let scheduled = false
6
6
 
7
7
  const flush = () => {
8
8
  scheduled = false
9
+ if (!active()) return changed.clear()
9
10
  const ids = [...changed]
10
11
  changed.clear()
11
12
  for (const id of ids) commit(id, state.get(id))
12
13
  }
13
14
 
14
15
  const setId = (id, value) => {
16
+ if (!active()) return
15
17
  const current = state.get(id)
16
18
  state.set(id, typeof value === "function" ? value(current) : value)
17
19
  changed.add(id)
@@ -7,7 +7,7 @@ const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
7
7
  const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
8
8
  const itemParts = new WeakMap()
9
9
  const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
10
- const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
10
+ const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}${__KUDZU_LIST_EFFECTS__ ? ",[data-k-effects]" : ""}`
11
11
 
12
12
  function commitLists(id) {
13
13
  const lists = listTargets.get(id)
@@ -114,8 +114,11 @@ function updateList(list) {
114
114
  } else node.remove()
115
115
  }
116
116
  if (added) {
117
- if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(additions)
118
- parent.insertBefore(additions, list.boundary)
117
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
118
+ const addedNodes = [...additions.childNodes]
119
+ parent.insertBefore(additions, list.boundary)
120
+ for (const node of addedNodes) mountDom(node)
121
+ } else parent.insertBefore(additions, list.boundary)
119
122
  list.container ??= parent
120
123
  }
121
124
  let anchor = list.boundary
@@ -149,6 +152,7 @@ function fillListItem(root, item) {
149
152
  }
150
153
 
151
154
  function fillListParts(root, parts, item, revision) {
155
+ if (__KUDZU_LIST_EFFECTS__) for (const node of parts.effects) node.dataset.kEffectItem = JSON.stringify(item)
152
156
  for (const [node, field] of parts.directTexts) {
153
157
  const text = item?.[field]
154
158
  const value = text == null ? "" : String(text)
@@ -203,7 +207,7 @@ function fillListParts(root, parts, item, revision) {
203
207
  function listItemParts(root) {
204
208
  let parts = itemParts.get(root)
205
209
  if (parts) return parts
206
- parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
210
+ parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [], effects: [] }
207
211
  for (const node of matching(root, itemPartsSelector)) {
208
212
  if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
209
213
  if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
@@ -214,6 +218,7 @@ function listItemParts(root) {
214
218
  parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
215
219
  conditionOwners.set(node, root)
216
220
  }
221
+ if (__KUDZU_LIST_EFFECTS__ && node.hasAttribute("data-k-effects")) parts.effects.push(node)
217
222
  }
218
223
  itemParts.set(root, parts)
219
224
  return parts
@@ -230,7 +235,8 @@ function listItemPartPlan(template) {
230
235
  events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([node, events]) => [indexes.get(node), events]) : [],
231
236
  expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
232
237
  expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
233
- conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
238
+ conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
239
+ effects: __KUDZU_LIST_EFFECTS__ ? parts.effects.map(node => indexes.get(node)) : []
234
240
  }
235
241
  }
236
242
 
@@ -246,7 +252,8 @@ function mapListItemParts(parts, root) {
246
252
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
247
253
  conditionOwners.set(target[index], root)
248
254
  return [target[index], descriptor]
249
- }) : []
255
+ }) : [],
256
+ effects: __KUDZU_LIST_EFFECTS__ ? parts.effects.map(index => target[index]) : []
250
257
  })
251
258
  }
252
259
 
@@ -0,0 +1,220 @@
1
+ import { browserState, mountDom, unmountDom } from "./shared-runtime.js"
2
+
3
+ const routes = new Set(__KUDZU_NAVIGATION_ROUTES__)
4
+ const applicationId = __KUDZU_APPLICATION_ID__
5
+ const layoutId = __KUDZU_LAYOUT_ID__
6
+ const navigationAsset = new URL(import.meta.url).pathname
7
+ const status = document.createElement("div")
8
+ status.dataset.kNavigationStatus = ""
9
+ status.setAttribute("role", "status")
10
+ status.setAttribute("aria-live", "polite")
11
+ status.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;padding:0;margin:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0"
12
+ document.body.append(status)
13
+
14
+ let request
15
+ let revision = 0
16
+ const documents = new Map()
17
+ let observer
18
+ let idle
19
+ let idleAnchors
20
+ const noDispose = async () => {}
21
+ let routeDispose = noDispose
22
+ let layoutDispose = noDispose
23
+ const ready = mountInitial()
24
+
25
+ document.addEventListener("click", event => {
26
+ const anchor = event.target.closest?.("a[href]")
27
+ if (!eligibleClick(event, anchor)) return
28
+ const url = new URL(anchor.href)
29
+ event.preventDefault()
30
+ navigate(url, true)
31
+ })
32
+ document.addEventListener("pointerover", event => prefetchAnchor(event.target.closest?.("a[href]")))
33
+ document.addEventListener("focusin", event => prefetchAnchor(event.target.closest?.("a[href]")))
34
+
35
+ addEventListener("popstate", () => navigate(new URL(location.href), false))
36
+ addEventListener("pagehide", event => {
37
+ if (event.persisted) return
38
+ ++revision
39
+ request?.abort()
40
+ void (async () => {
41
+ await routeDispose()
42
+ await layoutDispose()
43
+ })()
44
+ })
45
+ discover()
46
+
47
+ async function mountInitial() {
48
+ try {
49
+ const effects = await loadCapabilities(validate(document))
50
+ layoutDispose = await effects?.mountLayoutEffects?.() ?? noDispose
51
+ routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
52
+ } catch (error) {
53
+ console.error(error)
54
+ }
55
+ }
56
+
57
+ function eligibleClick(event, anchor) {
58
+ if (!anchor || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
59
+ return eligibleAnchor(anchor)
60
+ }
61
+
62
+ function eligibleAnchor(anchor) {
63
+ if (anchor.hasAttribute("download") || anchor.hasAttribute("data-k-native") || !["", "_self"].includes(anchor.target)) return false
64
+ if (anchor.relList?.contains("external")) return false
65
+ const url = new URL(anchor.href)
66
+ if (url.hash && url.pathname === location.pathname && url.search === location.search) return false
67
+ return url.origin === location.origin && routes.has(url.pathname)
68
+ }
69
+
70
+ function discover() {
71
+ const anchors = [...document.querySelectorAll("a[href]")].filter(eligibleAnchor)
72
+ idleAnchors = anchors
73
+ prune(anchors)
74
+ observer?.disconnect()
75
+ if ("IntersectionObserver" in globalThis) {
76
+ observer ??= new IntersectionObserver(entries => {
77
+ for (const entry of entries) if (entry.isIntersecting) {
78
+ observer.unobserve(entry.target)
79
+ prefetchAnchor(entry.target)
80
+ }
81
+ }, { rootMargin: "200px" })
82
+ for (const anchor of anchors) observer.observe(anchor)
83
+ } else if (idle === undefined) {
84
+ const schedule = globalThis.requestIdleCallback ?? (callback => setTimeout(callback, 0))
85
+ idle = schedule(() => {
86
+ idle = undefined
87
+ for (const anchor of idleAnchors) prefetchAnchor(anchor)
88
+ })
89
+ }
90
+ }
91
+
92
+ function prefetchAnchor(anchor) {
93
+ if (!eligibleAnchor(anchor)) return
94
+ const url = new URL(anchor.href)
95
+ prune([...document.querySelectorAll("a[href]")].filter(eligibleAnchor))
96
+ if (documents.has(url.href)) return
97
+ const pending = fetchDocument(url)
98
+ documents.set(url.href, pending)
99
+ pending.catch(() => {
100
+ if (documents.get(url.href) === pending) documents.delete(url.href)
101
+ })
102
+ }
103
+
104
+ function prune(anchors) {
105
+ const retained = new Set([location.href, ...anchors.map(anchor => anchor.href)])
106
+ for (const key of documents.keys()) if (!retained.has(key)) documents.delete(key)
107
+ }
108
+
109
+ async function navigate(url, push) {
110
+ await ready
111
+ const current = ++revision
112
+ request?.abort()
113
+ request = new AbortController()
114
+ let committed = false
115
+ try {
116
+ let documentResult
117
+ const cached = documents.get(url.href)
118
+ if (cached) {
119
+ try { documentResult = await cached }
120
+ catch { documentResult = await fetchDocument(url, request.signal) }
121
+ } else documentResult = await fetchDocument(url, request.signal)
122
+ documents.set(url.href, Promise.resolve(documentResult))
123
+ const { incoming, parsed } = documentResult
124
+ const effects = await loadCapabilities(parsed)
125
+ if (current !== revision) return
126
+ await routeDispose()
127
+ if (current !== revision) return
128
+ commit(incoming, parsed.nodes)
129
+ routeDispose = await effects?.mountRouteEffects?.() ?? noDispose
130
+ committed = true
131
+ if (push) history.pushState(null, "", url)
132
+ updateHead(incoming)
133
+ focusAndScroll(url)
134
+ status.textContent = `Navigated to ${document.title}`
135
+ discover()
136
+ } catch (error) {
137
+ if (current !== revision || error.name === "AbortError") return
138
+ if (push) location.assign(url.href)
139
+ else location.reload()
140
+ if (committed) return
141
+ }
142
+ }
143
+
144
+ async function loadCapabilities(parsed) {
145
+ const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
146
+ return modules.find(module => typeof module.mountRouteEffects === "function")
147
+ }
148
+
149
+ async function fetchDocument(url, signal) {
150
+ const response = await fetch(url, { signal, redirect: "manual", headers: { accept: "text/html" } })
151
+ if (!response.ok || response.redirected || response.type === "opaqueredirect" || !response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error("Navigation response is not successful nonredirected HTML")
152
+ const incoming = new DOMParser().parseFromString(await response.text(), "text/html")
153
+ return { incoming, parsed: validate(incoming) }
154
+ }
155
+
156
+ function validate(incoming) {
157
+ if (incoming.body.dataset.kApplication !== applicationId || incoming.body.dataset.kLayout !== layoutId) throw new Error("Navigation document identity does not match")
158
+ const starts = incoming.querySelectorAll("template[data-k-route-start]")
159
+ const ends = incoming.querySelectorAll("template[data-k-route-end]")
160
+ if (starts.length !== 1 || ends.length !== 1) throw new Error("Navigation document must contain exactly one route marker pair")
161
+ const nodes = between(starts[0], ends[0])
162
+ const assets = [...incoming.querySelectorAll("script[data-k-capability][src]")].map(script => {
163
+ const url = new URL(script.src)
164
+ if (url.origin !== location.origin) throw new Error("Navigation capability asset must be same-origin")
165
+ return url.pathname
166
+ })
167
+ if (!assets.includes(navigationAsset)) throw new Error("Navigation capability asset is missing")
168
+ return { nodes, assets: [...new Set(assets)] }
169
+ }
170
+
171
+ function commit(incoming, incomingNodes) {
172
+ const start = document.querySelector("template[data-k-route-start]")
173
+ const end = document.querySelector("template[data-k-route-end]")
174
+ if (!start || !end || document.querySelectorAll("template[data-k-route-start],template[data-k-route-end]").length !== 2) throw new Error("Current route markers are invalid")
175
+ const outgoing = between(start, end)
176
+ for (const node of outgoing) unmountDom(node)
177
+ for (const node of outgoing) node.remove()
178
+ for (const id of [...browserState.keys()]) if (id.startsWith("r")) browserState.delete(id)
179
+ for (const [id, value, compact] of JSON.parse(incoming.body.dataset.kState ?? "[]")) if (id.startsWith("r")) browserState.set(id, compact ? value[1].map(row => Object.fromEntries(value[0].map((field, index) => [field, row[index]]))) : value)
180
+ if (incoming.body.dataset.kTextBindings === undefined) delete document.body.dataset.kTextBindings
181
+ else document.body.dataset.kTextBindings = incoming.body.dataset.kTextBindings
182
+ const nodes = incomingNodes.map(node => document.importNode(node, true))
183
+ end.before(...nodes)
184
+ for (const node of nodes) mountDom(node)
185
+ }
186
+
187
+ function between(start, end) {
188
+ if (start.parentNode !== end.parentNode) throw new Error("Route markers must share a parent")
189
+ const nodes = []
190
+ for (let node = start.nextSibling; node && node !== end; node = node.nextSibling) nodes.push(node)
191
+ if (!nodes.length && start.nextSibling !== end) throw new Error("Route marker pair is invalid")
192
+ return nodes
193
+ }
194
+
195
+ function updateHead(incoming) {
196
+ document.title = incoming.title
197
+ document.head.querySelectorAll("[data-k-head]").forEach(node => node.remove())
198
+ document.head.append(...[...incoming.head.querySelectorAll("[data-k-head]")].map(node => document.importNode(node, true)))
199
+ }
200
+
201
+ function focusAndScroll(url) {
202
+ const hashTarget = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
203
+ const target = hashTarget ?? routeElement("h1") ?? routeElement("main")
204
+ if (target) {
205
+ if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1")
206
+ target.focus({ preventScroll: true })
207
+ }
208
+ if (hashTarget) hashTarget.scrollIntoView()
209
+ else scrollTo(0, 0)
210
+ }
211
+
212
+ function routeElement(selector) {
213
+ const start = document.querySelector("template[data-k-route-start]")
214
+ const end = document.querySelector("template[data-k-route-end]")
215
+ for (const node of between(start, end)) {
216
+ if (node.matches?.(selector)) return node
217
+ const match = node.querySelector?.(selector)
218
+ if (match) return match
219
+ }
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.5.10",
3
+ "version": "0.6.0",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "bin/",
26
26
  "framework/",
27
+ "GOAL_A.md",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],