@pathmx/core 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +172 -0
- package/README.md +111 -0
- package/actions/actions.ts +307 -0
- package/actions/draft.ts +105 -0
- package/actions/transaction.ts +164 -0
- package/assets.ts +149 -0
- package/authority/access.ts +820 -0
- package/authority/actors.ts +271 -0
- package/authority/agents.ts +95 -0
- package/authority/credentials.ts +144 -0
- package/authority/model.ts +71 -0
- package/authority/sharing.ts +89 -0
- package/browser-bundle.ts +61 -0
- package/canonical.ts +41 -0
- package/cli/dev.ts +134 -0
- package/cli/export.ts +74 -0
- package/cli/index.ts +52 -0
- package/cli/lint.ts +164 -0
- package/cli/mv.ts +30 -0
- package/cli/perms.ts +182 -0
- package/cli/plugin-packages.ts +159 -0
- package/cli/plugins.ts +95 -0
- package/cli/query.ts +225 -0
- package/cli/readme.md +95 -0
- package/cli/release.ts +75 -0
- package/cli/rm.ts +22 -0
- package/cli/serve.ts +100 -0
- package/cli/tree.ts +131 -0
- package/cli/update.ts +78 -0
- package/cli/utils.ts +53 -0
- package/core.ts +96 -0
- package/database.ts +189 -0
- package/dependencies.ts +60 -0
- package/dist/pathmx.js +1224 -0
- package/document.ts +194 -0
- package/environment.ts +110 -0
- package/environments/bun-plugins.ts +68 -0
- package/environments/bun-publish.ts +194 -0
- package/environments/bun.ts +223 -0
- package/globals.d.ts +14 -0
- package/graph.ts +153 -0
- package/host/compiler.ts +314 -0
- package/host/create.ts +66 -0
- package/host/engine.ts +630 -0
- package/host/plugin-host.ts +487 -0
- package/host/render.ts +102 -0
- package/host/response.ts +20 -0
- package/host/shell.html +15 -0
- package/host/source-sync.ts +254 -0
- package/html-attributes.ts +15 -0
- package/html.ts +48 -0
- package/icons/LICENSE +43 -0
- package/icons/lucide.ts +54 -0
- package/index.ts +75 -0
- package/invalidation.ts +85 -0
- package/log.ts +84 -0
- package/ops/href.ts +87 -0
- package/ops/index.ts +9 -0
- package/ops/move-source.ts +114 -0
- package/ops/plan.ts +47 -0
- package/ops/remove-source.ts +70 -0
- package/package.json +46 -0
- package/path.ts +75 -0
- package/pattern.ts +314 -0
- package/perf.ts +88 -0
- package/plugins/actor-path.ts +43 -0
- package/plugins/actor-profile.ts +55 -0
- package/plugins/agent-browsing.ts +183 -0
- package/plugins/app-component.ts +23 -0
- package/plugins/block-layout/index.ts +78 -0
- package/plugins/block-layout/theme.css +90 -0
- package/plugins/callout/index.ts +236 -0
- package/plugins/callout/theme.css +138 -0
- package/plugins/canonical-links.ts +144 -0
- package/plugins/code-highlight/client.ts +89 -0
- package/plugins/code-highlight/index.ts +147 -0
- package/plugins/code-highlight/theme.css +89 -0
- package/plugins/context.ts +131 -0
- package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
- package/plugins/datatype/OFL.txt +96 -0
- package/plugins/datatype/index.ts +287 -0
- package/plugins/definition-list.ts +236 -0
- package/plugins/directive-definitions.ts +13 -0
- package/plugins/embedding/client.ts +88 -0
- package/plugins/embedding/index.ts +43 -0
- package/plugins/image/index.ts +68 -0
- package/plugins/image/theme.css +43 -0
- package/plugins/include.ts +119 -0
- package/plugins/index.ts +91 -0
- package/plugins/input/authoring.ts +236 -0
- package/plugins/input/config.ts +85 -0
- package/plugins/input/index.ts +359 -0
- package/plugins/input/model.ts +173 -0
- package/plugins/input/state.ts +272 -0
- package/plugins/layout.ts +313 -0
- package/plugins/literate/client.ts +208 -0
- package/plugins/literate/index.ts +742 -0
- package/plugins/math/index.ts +140 -0
- package/plugins/meta.ts +42 -0
- package/plugins/navigation/index.ts +158 -0
- package/plugins/navigation/model.ts +364 -0
- package/plugins/preset.ts +65 -0
- package/plugins/query/index.ts +322 -0
- package/plugins/rss/index.ts +46 -0
- package/plugins/runtime/actions.ts +62 -0
- package/plugins/runtime/client.ts +8 -0
- package/plugins/runtime/document.ts +506 -0
- package/plugins/runtime/error.ts +154 -0
- package/plugins/runtime/heading-links.ts +31 -0
- package/plugins/runtime/index.ts +104 -0
- package/plugins/runtime/live.ts +51 -0
- package/plugins/runtime/navigation.ts +113 -0
- package/plugins/runtime/pending.ts +53 -0
- package/plugins/runtime/publication.ts +19 -0
- package/plugins/runtime/start.ts +177 -0
- package/plugins/runtime/static-client.ts +6 -0
- package/plugins/runtime/static-live.ts +188 -0
- package/plugins/runtime/view-transition.ts +72 -0
- package/plugins/runtime/viewport.ts +110 -0
- package/plugins/schema.ts +24 -0
- package/plugins/scripts.ts +140 -0
- package/plugins/source-preview/client.ts +256 -0
- package/plugins/source-preview/index.ts +132 -0
- package/plugins/source-preview/model.ts +11 -0
- package/plugins/source-preview/preview.css +107 -0
- package/plugins/styles.ts +145 -0
- package/plugins/theme/components.ts +10 -0
- package/plugins/theme/foundation.css +358 -0
- package/plugins/theme/index.ts +333 -0
- package/plugins/theme/prose.css +284 -0
- package/plugins/theme/scheme.js +63 -0
- package/plugins/theme/toggle.css +44 -0
- package/plugins/theme/view-transition.css +68 -0
- package/plugins/toc/client.ts +146 -0
- package/plugins/toc/index.ts +232 -0
- package/plugins/toc/theme.css +172 -0
- package/plugins/types.ts +538 -0
- package/problem.ts +41 -0
- package/project.ts +6 -0
- package/publish/build.ts +402 -0
- package/publish/concurrent.ts +32 -0
- package/publish/identity.ts +63 -0
- package/publish/index.ts +11 -0
- package/publish/location.ts +167 -0
- package/publish/model.ts +58 -0
- package/publish/plan.ts +300 -0
- package/publish/validate.ts +150 -0
- package/query/candidate-index.ts +101 -0
- package/query/dependency-index.ts +99 -0
- package/query/fields.ts +216 -0
- package/query/invalidation.ts +48 -0
- package/query/kernel.ts +776 -0
- package/query/model.ts +79 -0
- package/repository.ts +374 -0
- package/runtime-profile.ts +34 -0
- package/schema/model.ts +92 -0
- package/schema/registry.ts +642 -0
- package/schema/standard.ts +226 -0
- package/server/context.ts +94 -0
- package/server/css-imports.ts +66 -0
- package/server/http.ts +333 -0
- package/server/page.ts +21 -0
- package/server/protocols.ts +12 -0
- package/server/representations.ts +66 -0
- package/server/router.ts +131 -0
- package/server/routes.ts +202 -0
- package/server/serve.ts +5 -0
- package/server/watch.ts +120 -0
- package/source/blocks.ts +71 -0
- package/source/format.ts +164 -0
- package/source/index.ts +67 -0
- package/source/interpolate.ts +63 -0
- package/source/markdown/blocks.ts +46 -0
- package/source/markdown/comments.ts +32 -0
- package/source/markdown/fences.ts +67 -0
- package/source/markdown/headings.ts +88 -0
- package/source/markdown/index.ts +34 -0
- package/source/markdown/inline-code.ts +6 -0
- package/source/markdown/links.ts +326 -0
- package/source/markdown/lists.ts +223 -0
- package/source/markdown/prose.ts +74 -0
- package/source/markdown/scan.ts +79 -0
- package/source/markdown/text.ts +82 -0
- package/source/meta.ts +87 -0
- package/source/source.ts +121 -0
- package/source/topmatter.ts +57 -0
- package/source/utils.ts +21 -0
- package/source-diagnostic.ts +63 -0
- package/text-file.ts +26 -0
- package/util.ts +35 -0
- package/view/components.ts +499 -0
- package/view/model.ts +91 -0
- package/view/projection.ts +162 -0
- package/view/session.ts +146 -0
- package/view/syntax.ts +372 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import morphdom from "morphdom"
|
|
2
|
+
import { APP_COMPONENT_ROOT_ATTRIBUTE } from "../app-component.ts"
|
|
3
|
+
import { startPathMXViewTransition } from "./view-transition.ts"
|
|
4
|
+
import { publicationPath } from "./publication.ts"
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
interface Window {
|
|
8
|
+
__pmxCanonicalView?: HTMLElement
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type RenderType = "navigation" | "traverse" | "action" | "live"
|
|
13
|
+
export type RenderScope = "blocks" | "document"
|
|
14
|
+
|
|
15
|
+
export type RenderDetail = {
|
|
16
|
+
type: RenderType
|
|
17
|
+
from: string
|
|
18
|
+
to: string
|
|
19
|
+
blocks: string[]
|
|
20
|
+
scope: RenderScope
|
|
21
|
+
viewTransitionTypes: readonly string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type LoadedPathMXDocument = {
|
|
25
|
+
document: Document
|
|
26
|
+
responseUrl: URL
|
|
27
|
+
requestedUrl: URL
|
|
28
|
+
view: HTMLElement
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class PathMXDocumentError extends Error {
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.name = "PathMXDocumentError"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PathMXReloadRequiredError extends PathMXDocumentError {
|
|
39
|
+
constructor(message: string) {
|
|
40
|
+
super(message)
|
|
41
|
+
this.name = "PathMXReloadRequiredError"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assetUrl(element: Element, attribute: "href" | "src") {
|
|
46
|
+
try {
|
|
47
|
+
return new URL(element.getAttribute(attribute) || "", location.href)
|
|
48
|
+
} catch {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stylesheetKey(link: Element) {
|
|
54
|
+
const href = assetUrl(link, "href")
|
|
55
|
+
if (!href) return link.getAttribute("href") || ""
|
|
56
|
+
href.search = ""
|
|
57
|
+
if (isHashedAsset(href.pathname)) {
|
|
58
|
+
return href.pathname.replace(/-[0-9a-f]{16}(?=\.[^/]+$)/, "")
|
|
59
|
+
}
|
|
60
|
+
return href.pathname
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isHashedAsset(pathname: string) {
|
|
64
|
+
return pathname.startsWith(publicationPath("/.pmx/"))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scriptKey(script: Element) {
|
|
68
|
+
const src = assetUrl(script, "src")
|
|
69
|
+
if (!src) return script.getAttribute("src") || ""
|
|
70
|
+
src.search = ""
|
|
71
|
+
if (isHashedAsset(src.pathname)) {
|
|
72
|
+
return src.pathname.replace(/-[0-9a-f]{16}(?=\.[^/]+$)/, "")
|
|
73
|
+
}
|
|
74
|
+
return src.href
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runtimeSource(head: HTMLHeadElement) {
|
|
78
|
+
return [...head.querySelectorAll("script[src]")]
|
|
79
|
+
.find((script) => scriptKey(script) === publicationPath("/.pmx/runtime.js"))
|
|
80
|
+
?.getAttribute("src")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function executableScript(script: Element) {
|
|
84
|
+
const executable = document.createElement("script")
|
|
85
|
+
for (const attr of script.attributes) {
|
|
86
|
+
executable.setAttribute(attr.name, attr.value)
|
|
87
|
+
}
|
|
88
|
+
executable.textContent = script.textContent
|
|
89
|
+
return executable
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function metaKey(meta: Element) {
|
|
93
|
+
const name = meta.getAttribute("name")
|
|
94
|
+
const property = meta.getAttribute("property")
|
|
95
|
+
if (!name && !property) return
|
|
96
|
+
return [name, property, meta.getAttribute("media")].join("\0")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reconcileMetadata(
|
|
100
|
+
current: HTMLHeadElement,
|
|
101
|
+
incoming: HTMLHeadElement,
|
|
102
|
+
) {
|
|
103
|
+
const nextTitle = incoming.querySelector("title")
|
|
104
|
+
const title = current.querySelector("title")
|
|
105
|
+
if (nextTitle) {
|
|
106
|
+
if (title) title.textContent = nextTitle.textContent
|
|
107
|
+
else current.prepend(nextTitle.cloneNode(true))
|
|
108
|
+
} else {
|
|
109
|
+
title?.remove()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const currentMetas = [...current.querySelectorAll("meta")]
|
|
113
|
+
const incomingMetas = [...incoming.querySelectorAll("meta")]
|
|
114
|
+
const incomingByKey = new Map(
|
|
115
|
+
incomingMetas.flatMap((meta) => {
|
|
116
|
+
const key = metaKey(meta)
|
|
117
|
+
return key ? [[key, meta] as const] : []
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for (const [key, incomingMeta] of incomingByKey) {
|
|
122
|
+
const existing = currentMetas.find((meta) => metaKey(meta) === key)
|
|
123
|
+
if (existing) {
|
|
124
|
+
for (const attr of [...existing.attributes]) {
|
|
125
|
+
if (!incomingMeta.hasAttribute(attr.name))
|
|
126
|
+
existing.removeAttribute(attr.name)
|
|
127
|
+
}
|
|
128
|
+
for (const attr of [...incomingMeta.attributes]) {
|
|
129
|
+
existing.setAttribute(attr.name, attr.value)
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
current.append(incomingMeta.cloneNode(true))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const meta of currentMetas) {
|
|
137
|
+
const key = metaKey(meta)
|
|
138
|
+
if (key && !incomingByKey.has(key)) meta.remove()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reconcileStylesheets(
|
|
143
|
+
current: HTMLHeadElement,
|
|
144
|
+
incoming: HTMLHeadElement,
|
|
145
|
+
type: RenderType,
|
|
146
|
+
) {
|
|
147
|
+
const currentLinks = [...current.querySelectorAll('link[rel="stylesheet"]')]
|
|
148
|
+
const incomingLinks = [...incoming.querySelectorAll('link[rel="stylesheet"]')]
|
|
149
|
+
const incomingByKey = new Map(
|
|
150
|
+
incomingLinks.map((link) => [stylesheetKey(link), link]),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
for (const [key, incomingLink] of incomingByKey) {
|
|
154
|
+
const currentLink = currentLinks.find((link) => stylesheetKey(link) === key)
|
|
155
|
+
const nextHref = incomingLink.getAttribute("href") || ""
|
|
156
|
+
if (!currentLink) {
|
|
157
|
+
current.append(incomingLink.cloneNode(true))
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
if (isHashedAsset(key)) {
|
|
161
|
+
if (currentLink.getAttribute("href") === nextHref) continue
|
|
162
|
+
const added = incomingLink.cloneNode(true) as HTMLLinkElement
|
|
163
|
+
added.addEventListener("load", () => currentLink.remove(), { once: true })
|
|
164
|
+
currentLink.after(added)
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
if (currentLink.getAttribute("href") === nextHref && type !== "live")
|
|
168
|
+
continue
|
|
169
|
+
const bust = new URL(nextHref, location.href)
|
|
170
|
+
if (type === "live") bust.searchParams.set("t", String(Date.now()))
|
|
171
|
+
currentLink.setAttribute("href", bust.href)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const link of currentLinks) {
|
|
175
|
+
if (!incomingByKey.has(stylesheetKey(link))) link.remove()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function reconcileInlineStyles(
|
|
180
|
+
current: HTMLHeadElement,
|
|
181
|
+
incoming: HTMLHeadElement,
|
|
182
|
+
) {
|
|
183
|
+
for (const style of current.querySelectorAll("style")) style.remove()
|
|
184
|
+
for (const style of incoming.querySelectorAll("style")) {
|
|
185
|
+
current.append(style.cloneNode(true))
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertRuntimeCompatible(
|
|
190
|
+
current: HTMLHeadElement,
|
|
191
|
+
incoming: HTMLHeadElement,
|
|
192
|
+
) {
|
|
193
|
+
if (runtimeSource(current) !== runtimeSource(incoming)) {
|
|
194
|
+
throw new PathMXReloadRequiredError("The PathMX browser runtime changed.")
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reconcileScripts(current: HTMLHeadElement, incoming: HTMLHeadElement) {
|
|
199
|
+
const currentScripts = [...current.querySelectorAll("script[src]")]
|
|
200
|
+
const incomingScripts = [...incoming.querySelectorAll("script[src]")]
|
|
201
|
+
const incomingKeys = new Set(incomingScripts.map(scriptKey))
|
|
202
|
+
|
|
203
|
+
for (const incomingScript of incomingScripts) {
|
|
204
|
+
const key = scriptKey(incomingScript)
|
|
205
|
+
const existing = currentScripts.find((script) => scriptKey(script) === key)
|
|
206
|
+
if (!existing) {
|
|
207
|
+
current.append(executableScript(incomingScript))
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
if (existing.getAttribute("src") === incomingScript.getAttribute("src"))
|
|
211
|
+
continue
|
|
212
|
+
const replacement = executableScript(incomingScript)
|
|
213
|
+
replacement.addEventListener("load", () => existing.remove(), {
|
|
214
|
+
once: true,
|
|
215
|
+
})
|
|
216
|
+
replacement.addEventListener("error", () => replacement.remove(), {
|
|
217
|
+
once: true,
|
|
218
|
+
})
|
|
219
|
+
existing.after(replacement)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const currentScript of currentScripts) {
|
|
223
|
+
if (!incomingKeys.has(scriptKey(currentScript))) currentScript.remove()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function reconcileComponentScripts(
|
|
228
|
+
current: HTMLHeadElement,
|
|
229
|
+
incoming: HTMLHeadElement,
|
|
230
|
+
) {
|
|
231
|
+
const selector = "script[data-pmx-component]"
|
|
232
|
+
const currentScripts = [...current.querySelectorAll(selector)]
|
|
233
|
+
const incomingScripts = [...incoming.querySelectorAll(selector)]
|
|
234
|
+
const incomingByComponent = new Map(
|
|
235
|
+
incomingScripts.map((script) => [
|
|
236
|
+
script.getAttribute("data-pmx-component") ?? "",
|
|
237
|
+
script,
|
|
238
|
+
]),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
for (const [component, incomingScript] of incomingByComponent) {
|
|
242
|
+
const existing = currentScripts.find(
|
|
243
|
+
(script) => script.getAttribute("data-pmx-component") === component,
|
|
244
|
+
)
|
|
245
|
+
if (existing?.isEqualNode(incomingScript)) continue
|
|
246
|
+
const replacement = executableScript(incomingScript)
|
|
247
|
+
if (existing) existing.replaceWith(replacement)
|
|
248
|
+
else current.append(replacement)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (const currentScript of currentScripts) {
|
|
252
|
+
const component = currentScript.getAttribute("data-pmx-component") ?? ""
|
|
253
|
+
if (!incomingByComponent.has(component)) currentScript.remove()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function reconcileHead(
|
|
258
|
+
current: HTMLHeadElement,
|
|
259
|
+
incoming: HTMLHeadElement,
|
|
260
|
+
type: RenderType,
|
|
261
|
+
) {
|
|
262
|
+
assertRuntimeCompatible(current, incoming)
|
|
263
|
+
reconcileMetadata(current, incoming)
|
|
264
|
+
reconcileStylesheets(current, incoming, type)
|
|
265
|
+
reconcileInlineStyles(current, incoming)
|
|
266
|
+
reconcileComponentScripts(current, incoming)
|
|
267
|
+
reconcileScripts(current, incoming)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function reconcileHtmlAttributes(current: HTMLElement, incoming: HTMLElement) {
|
|
271
|
+
for (const name of [
|
|
272
|
+
"lang",
|
|
273
|
+
"data-pmx-base",
|
|
274
|
+
"data-pmx-page",
|
|
275
|
+
"data-pmx-scheme",
|
|
276
|
+
"style",
|
|
277
|
+
]) {
|
|
278
|
+
const value = incoming.getAttribute(name)
|
|
279
|
+
if (value == null) current.removeAttribute(name)
|
|
280
|
+
else current.setAttribute(name, value)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function currentView() {
|
|
285
|
+
const view = document.querySelector("[data-pmx-view]")
|
|
286
|
+
if (!(view instanceof HTMLElement)) {
|
|
287
|
+
throw new PathMXDocumentError("The current PathMX view is missing.")
|
|
288
|
+
}
|
|
289
|
+
return view
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const blockSelector = ".pmx-document > section[data-pmx-block]"
|
|
293
|
+
|
|
294
|
+
function blocksIn(view: HTMLElement) {
|
|
295
|
+
return [...view.querySelectorAll<HTMLElement>(blockSelector)]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function blockMap(blocks: readonly HTMLElement[]) {
|
|
299
|
+
const byId = new Map<string, HTMLElement>()
|
|
300
|
+
for (const block of blocks) {
|
|
301
|
+
if (!block.id || byId.has(block.id)) return
|
|
302
|
+
byId.set(block.id, block)
|
|
303
|
+
}
|
|
304
|
+
return byId
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function shellClone(view: HTMLElement) {
|
|
308
|
+
const clone = view.cloneNode(true) as HTMLElement
|
|
309
|
+
const articles = clone.querySelectorAll<HTMLElement>(".pmx-document")
|
|
310
|
+
if (articles.length !== 1) return
|
|
311
|
+
const article = articles[0]!
|
|
312
|
+
article.replaceChildren(
|
|
313
|
+
article.ownerDocument.createComment("pmx-document-content"),
|
|
314
|
+
)
|
|
315
|
+
for (const app of clone.querySelectorAll<HTMLElement>(
|
|
316
|
+
`[${APP_COMPONENT_ROOT_ATTRIBUTE}]`,
|
|
317
|
+
)) {
|
|
318
|
+
for (const attribute of [...app.attributes]) {
|
|
319
|
+
if (
|
|
320
|
+
attribute.name !== APP_COMPONENT_ROOT_ATTRIBUTE &&
|
|
321
|
+
attribute.name !== "data-component"
|
|
322
|
+
) {
|
|
323
|
+
app.removeAttribute(attribute.name)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
app.replaceChildren()
|
|
327
|
+
}
|
|
328
|
+
return clone
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function sameShell(current: HTMLElement, incoming: HTMLElement) {
|
|
332
|
+
const currentShell = shellClone(current)
|
|
333
|
+
const incomingShell = shellClone(incoming)
|
|
334
|
+
return Boolean(
|
|
335
|
+
currentShell && incomingShell && currentShell.isEqualNode(incomingShell),
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function renderDiff(
|
|
340
|
+
current: HTMLElement,
|
|
341
|
+
incoming: HTMLElement,
|
|
342
|
+
type: RenderType,
|
|
343
|
+
): Pick<RenderDetail, "blocks" | "scope"> {
|
|
344
|
+
const currentBlocks = blocksIn(current)
|
|
345
|
+
const incomingBlocks = blocksIn(incoming)
|
|
346
|
+
const currentById = blockMap(currentBlocks)
|
|
347
|
+
const incomingById = blockMap(incomingBlocks)
|
|
348
|
+
if (!currentById || !incomingById) {
|
|
349
|
+
return { blocks: [], scope: "document" }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const changed = new Set<string>()
|
|
353
|
+
for (const [id, block] of currentById) {
|
|
354
|
+
const next = incomingById.get(id)
|
|
355
|
+
if (!next || !block.isEqualNode(next)) changed.add(id)
|
|
356
|
+
}
|
|
357
|
+
for (const id of incomingById.keys()) {
|
|
358
|
+
if (!currentById.has(id)) changed.add(id)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const currentShared = currentBlocks
|
|
362
|
+
.map((block) => block.id)
|
|
363
|
+
.filter((id) => incomingById.has(id))
|
|
364
|
+
const incomingShared = incomingBlocks
|
|
365
|
+
.map((block) => block.id)
|
|
366
|
+
.filter((id) => currentById.has(id))
|
|
367
|
+
const reordered = currentShared.some(
|
|
368
|
+
(id, index) => incomingShared[index] !== id,
|
|
369
|
+
)
|
|
370
|
+
const local =
|
|
371
|
+
(type === "action" || type === "live") &&
|
|
372
|
+
!reordered &&
|
|
373
|
+
sameShell(current, incoming)
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
blocks: [...changed],
|
|
377
|
+
scope: local ? "blocks" : "document",
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!(window.__pmxCanonicalView instanceof HTMLElement)) {
|
|
382
|
+
window.__pmxCanonicalView = currentView().cloneNode(true) as HTMLElement
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function shouldAnimate(type: RenderType, viewTransition: boolean) {
|
|
386
|
+
return (
|
|
387
|
+
viewTransition &&
|
|
388
|
+
(type === "navigation" || type === "traverse") &&
|
|
389
|
+
typeof document.startViewTransition === "function" &&
|
|
390
|
+
!matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function readPathMXDocument(
|
|
395
|
+
response: Response,
|
|
396
|
+
requestedUrl: URL,
|
|
397
|
+
): Promise<LoadedPathMXDocument> {
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
throw new PathMXDocumentError(
|
|
400
|
+
`PathMX document returned ${response.status}.`,
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
if (!response.headers.get("content-type")?.includes("text/html")) {
|
|
404
|
+
throw new PathMXDocumentError("PathMX navigation did not return HTML.")
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const incoming = new DOMParser().parseFromString(
|
|
408
|
+
await response.text(),
|
|
409
|
+
"text/html",
|
|
410
|
+
)
|
|
411
|
+
if (incoming.documentElement.getAttribute("data-pmx-document") !== "1") {
|
|
412
|
+
throw new PathMXDocumentError("The response is not a PathMX document.")
|
|
413
|
+
}
|
|
414
|
+
const views = incoming.querySelectorAll("[data-pmx-view]")
|
|
415
|
+
if (views.length !== 1 || !(views[0] instanceof HTMLElement)) {
|
|
416
|
+
throw new PathMXDocumentError("The response has no canonical PathMX view.")
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
document: incoming,
|
|
420
|
+
requestedUrl,
|
|
421
|
+
responseUrl: new URL(response.url || requestedUrl.href),
|
|
422
|
+
view: views[0],
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function fetchPathMXDocument(
|
|
427
|
+
url: URL,
|
|
428
|
+
signal: AbortSignal,
|
|
429
|
+
cache: RequestCache = "default",
|
|
430
|
+
) {
|
|
431
|
+
const response = await fetch(url, {
|
|
432
|
+
cache,
|
|
433
|
+
credentials: "same-origin",
|
|
434
|
+
headers: { Accept: "text/html" },
|
|
435
|
+
signal,
|
|
436
|
+
})
|
|
437
|
+
return readPathMXDocument(response, url)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function reconcilePathMXDocument(
|
|
441
|
+
incoming: LoadedPathMXDocument,
|
|
442
|
+
options: {
|
|
443
|
+
type: RenderType
|
|
444
|
+
from: URL
|
|
445
|
+
viewTransition: boolean
|
|
446
|
+
viewTransitionTypes?: readonly string[]
|
|
447
|
+
},
|
|
448
|
+
) {
|
|
449
|
+
const viewTransitionTypes = options.viewTransitionTypes ?? []
|
|
450
|
+
const apply = () => {
|
|
451
|
+
const canonicalView = window.__pmxCanonicalView ?? currentView()
|
|
452
|
+
const diff = renderDiff(canonicalView, incoming.view, options.type)
|
|
453
|
+
const changedBlocks = new Set(diff.blocks)
|
|
454
|
+
const nextCanonicalView = incoming.view.cloneNode(true) as HTMLElement
|
|
455
|
+
reconcileHead(document.head, incoming.document.head, options.type)
|
|
456
|
+
reconcileHtmlAttributes(
|
|
457
|
+
document.documentElement,
|
|
458
|
+
incoming.document.documentElement,
|
|
459
|
+
)
|
|
460
|
+
morphdom(currentView(), incoming.view, {
|
|
461
|
+
onBeforeElUpdated(current, next) {
|
|
462
|
+
if (current.isEqualNode(next)) return false
|
|
463
|
+
if (
|
|
464
|
+
diff.scope === "blocks" &&
|
|
465
|
+
next.matches(blockSelector) &&
|
|
466
|
+
!changedBlocks.has(next.id)
|
|
467
|
+
) {
|
|
468
|
+
return false
|
|
469
|
+
}
|
|
470
|
+
return true
|
|
471
|
+
},
|
|
472
|
+
onBeforeElChildrenUpdated(current) {
|
|
473
|
+
return !current.hasAttribute(APP_COMPONENT_ROOT_ATTRIBUTE)
|
|
474
|
+
},
|
|
475
|
+
})
|
|
476
|
+
window.__pmxCanonicalView = nextCanonicalView
|
|
477
|
+
document.dispatchEvent(
|
|
478
|
+
new CustomEvent<RenderDetail>("pmx:render", {
|
|
479
|
+
detail: {
|
|
480
|
+
type: options.type,
|
|
481
|
+
from: options.from.href,
|
|
482
|
+
to: incoming.requestedUrl.href,
|
|
483
|
+
viewTransitionTypes,
|
|
484
|
+
...diff,
|
|
485
|
+
},
|
|
486
|
+
}),
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!shouldAnimate(options.type, options.viewTransition)) {
|
|
491
|
+
apply()
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let applied = false
|
|
496
|
+
const transition = startPathMXViewTransition(() => {
|
|
497
|
+
applied = true
|
|
498
|
+
apply()
|
|
499
|
+
}, viewTransitionTypes)
|
|
500
|
+
try {
|
|
501
|
+
await transition.updateCallbackDone
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (!applied) apply()
|
|
504
|
+
else throw error
|
|
505
|
+
}
|
|
506
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ProblemDetails } from "../../problem.ts"
|
|
2
|
+
|
|
3
|
+
export type ActionErrorDetail = {
|
|
4
|
+
type: "action"
|
|
5
|
+
outcome: "rejected" | "uncertain"
|
|
6
|
+
message: string
|
|
7
|
+
status?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type RuntimeErrorDetail =
|
|
11
|
+
| ActionErrorDetail
|
|
12
|
+
| {
|
|
13
|
+
type: "navigation" | "traverse"
|
|
14
|
+
outcome: "native"
|
|
15
|
+
message: string
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
type: "live"
|
|
19
|
+
outcome: "stale"
|
|
20
|
+
message: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type RenderedError = {
|
|
24
|
+
describedBy: string | null
|
|
25
|
+
region: HTMLElement
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rendered = new WeakMap<HTMLFormElement, RenderedError>()
|
|
29
|
+
let nextErrorId = 0
|
|
30
|
+
|
|
31
|
+
function isProblemDetails(value: unknown): value is ProblemDetails {
|
|
32
|
+
if (typeof value !== "object" || value == null) return false
|
|
33
|
+
const problem = value as Partial<ProblemDetails>
|
|
34
|
+
return (
|
|
35
|
+
problem.type === "about:blank" &&
|
|
36
|
+
typeof problem.title === "string" &&
|
|
37
|
+
typeof problem.status === "number" &&
|
|
38
|
+
typeof problem.detail === "string"
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class ActionRejectedError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
message: string,
|
|
45
|
+
readonly status: number,
|
|
46
|
+
) {
|
|
47
|
+
super(message)
|
|
48
|
+
this.name = "ActionRejectedError"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function readActionRejection(response: Response) {
|
|
53
|
+
let message = `The server rejected this action (${response.status}).`
|
|
54
|
+
try {
|
|
55
|
+
const contentType = response.headers.get("content-type") ?? ""
|
|
56
|
+
if (contentType.includes("application/problem+json")) {
|
|
57
|
+
const problem: unknown = await response.json()
|
|
58
|
+
if (isProblemDetails(problem) && problem.status === response.status) {
|
|
59
|
+
message = problem.detail
|
|
60
|
+
}
|
|
61
|
+
} else if (contentType.includes("text/plain")) {
|
|
62
|
+
message = (await response.text()).trim() || message
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// The HTTP status remains authoritative when an error body is malformed.
|
|
66
|
+
}
|
|
67
|
+
return new ActionRejectedError(message, response.status)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function describeActionError(error: unknown): ActionErrorDetail {
|
|
71
|
+
if (error instanceof ActionRejectedError) {
|
|
72
|
+
return {
|
|
73
|
+
type: "action",
|
|
74
|
+
outcome: "rejected",
|
|
75
|
+
message: error.message,
|
|
76
|
+
status: error.status,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
type: "action",
|
|
81
|
+
outcome: "uncertain",
|
|
82
|
+
message:
|
|
83
|
+
"The response was interrupted, so this action may have completed. Reload the page before trying again.",
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function reportRuntimeError(detail: RuntimeErrorDetail) {
|
|
88
|
+
document.dispatchEvent(
|
|
89
|
+
new CustomEvent<RuntimeErrorDetail>("pmx:error", { detail }),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clearActionError(form: HTMLFormElement) {
|
|
94
|
+
const current = rendered.get(form)
|
|
95
|
+
if (current) {
|
|
96
|
+
current.region.remove()
|
|
97
|
+
if (current.describedBy == null) form.removeAttribute("aria-describedby")
|
|
98
|
+
else form.setAttribute("aria-describedby", current.describedBy)
|
|
99
|
+
rendered.delete(form)
|
|
100
|
+
}
|
|
101
|
+
form.removeAttribute("data-pmx-failed")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function errorRegion(detail: ActionErrorDetail) {
|
|
105
|
+
const region = document.createElement("div")
|
|
106
|
+
region.id = `pmx-error-${++nextErrorId}`
|
|
107
|
+
region.className = "pmx-error"
|
|
108
|
+
region.setAttribute("data-pmx-error", detail.outcome)
|
|
109
|
+
if (detail.status != null) {
|
|
110
|
+
region.setAttribute("data-pmx-error-status", String(detail.status))
|
|
111
|
+
}
|
|
112
|
+
region.setAttribute("role", "alert")
|
|
113
|
+
|
|
114
|
+
const title = document.createElement("strong")
|
|
115
|
+
title.textContent =
|
|
116
|
+
detail.outcome === "rejected"
|
|
117
|
+
? "Action failed."
|
|
118
|
+
: "Action status is uncertain."
|
|
119
|
+
const message = document.createElement("span")
|
|
120
|
+
message.textContent = ` ${detail.message}`
|
|
121
|
+
region.append(title, message)
|
|
122
|
+
|
|
123
|
+
if (detail.outcome === "uncertain") {
|
|
124
|
+
const reload = document.createElement("button")
|
|
125
|
+
reload.type = "button"
|
|
126
|
+
reload.textContent = "Reload page"
|
|
127
|
+
reload.addEventListener("click", () => location.reload())
|
|
128
|
+
region.append(reload)
|
|
129
|
+
}
|
|
130
|
+
return region
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function showActionError(
|
|
134
|
+
form: HTMLFormElement,
|
|
135
|
+
submitter: HTMLElement | null,
|
|
136
|
+
detail: ActionErrorDetail,
|
|
137
|
+
) {
|
|
138
|
+
clearActionError(form)
|
|
139
|
+
const region = errorRegion(detail)
|
|
140
|
+
const describedBy = form.getAttribute("aria-describedby")
|
|
141
|
+
form.setAttribute(
|
|
142
|
+
"aria-describedby",
|
|
143
|
+
[describedBy, region.id].filter(Boolean).join(" "),
|
|
144
|
+
)
|
|
145
|
+
form.setAttribute("data-pmx-failed", detail.outcome)
|
|
146
|
+
if (form.isConnected) form.after(region)
|
|
147
|
+
else document.querySelector("[data-pmx-view]")?.prepend(region)
|
|
148
|
+
rendered.set(form, { describedBy, region })
|
|
149
|
+
|
|
150
|
+
if (detail.outcome === "uncertain") {
|
|
151
|
+
submitter?.setAttribute("aria-disabled", "true")
|
|
152
|
+
}
|
|
153
|
+
reportRuntimeError(detail)
|
|
154
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const headingSelector =
|
|
2
|
+
".pmx-heading > h1, .pmx-heading > h2, .pmx-heading > h3, .pmx-heading > h4, .pmx-heading > h5, .pmx-heading > h6"
|
|
3
|
+
|
|
4
|
+
const interactiveSelector =
|
|
5
|
+
"a, button, input, select, textarea, summary, [role='button'], [role='link']"
|
|
6
|
+
|
|
7
|
+
/** Make generated heading text activate its accessible sibling permalink. */
|
|
8
|
+
export function installHeadingLinks() {
|
|
9
|
+
document.addEventListener("click", (event) => {
|
|
10
|
+
if (event.defaultPrevented || !(event.target instanceof Element)) return
|
|
11
|
+
|
|
12
|
+
const mouse = event as MouseEvent
|
|
13
|
+
if (
|
|
14
|
+
mouse.button !== 0 ||
|
|
15
|
+
mouse.metaKey ||
|
|
16
|
+
mouse.ctrlKey ||
|
|
17
|
+
mouse.shiftKey ||
|
|
18
|
+
mouse.altKey
|
|
19
|
+
)
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
const heading = event.target.closest<HTMLElement>(headingSelector)
|
|
23
|
+
if (!heading || event.target.closest(interactiveSelector)) return
|
|
24
|
+
const selection = getSelection()
|
|
25
|
+
if (selection && !selection.isCollapsed) return
|
|
26
|
+
|
|
27
|
+
heading.parentElement
|
|
28
|
+
?.querySelector<HTMLAnchorElement>(":scope > .pmx-heading-anchor")
|
|
29
|
+
?.click()
|
|
30
|
+
})
|
|
31
|
+
}
|