@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.
- package/GOAL_A.md +175 -0
- package/README.md +94 -10
- package/framework/README.md +9 -3
- package/framework/build.mjs +800 -43
- package/framework/core.d.ts +10 -4
- package/framework/core.mjs +117 -32
- package/framework/dependency-runtime.js +36 -0
- package/framework/effect-runtime.js +3 -1
- package/framework/list-runtime.js +13 -6
- package/framework/navigation-runtime.js +220 -0
- package/package.json +2 -1
|
@@ -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.
|
|
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
|
],
|