@kudzujs/core 0.5.8 → 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.
@@ -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.8",
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
  ],