@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.
Files changed (195) hide show
  1. package/LICENSE.md +172 -0
  2. package/README.md +111 -0
  3. package/actions/actions.ts +307 -0
  4. package/actions/draft.ts +105 -0
  5. package/actions/transaction.ts +164 -0
  6. package/assets.ts +149 -0
  7. package/authority/access.ts +820 -0
  8. package/authority/actors.ts +271 -0
  9. package/authority/agents.ts +95 -0
  10. package/authority/credentials.ts +144 -0
  11. package/authority/model.ts +71 -0
  12. package/authority/sharing.ts +89 -0
  13. package/browser-bundle.ts +61 -0
  14. package/canonical.ts +41 -0
  15. package/cli/dev.ts +134 -0
  16. package/cli/export.ts +74 -0
  17. package/cli/index.ts +52 -0
  18. package/cli/lint.ts +164 -0
  19. package/cli/mv.ts +30 -0
  20. package/cli/perms.ts +182 -0
  21. package/cli/plugin-packages.ts +159 -0
  22. package/cli/plugins.ts +95 -0
  23. package/cli/query.ts +225 -0
  24. package/cli/readme.md +95 -0
  25. package/cli/release.ts +75 -0
  26. package/cli/rm.ts +22 -0
  27. package/cli/serve.ts +100 -0
  28. package/cli/tree.ts +131 -0
  29. package/cli/update.ts +78 -0
  30. package/cli/utils.ts +53 -0
  31. package/core.ts +96 -0
  32. package/database.ts +189 -0
  33. package/dependencies.ts +60 -0
  34. package/dist/pathmx.js +1224 -0
  35. package/document.ts +194 -0
  36. package/environment.ts +110 -0
  37. package/environments/bun-plugins.ts +68 -0
  38. package/environments/bun-publish.ts +194 -0
  39. package/environments/bun.ts +223 -0
  40. package/globals.d.ts +14 -0
  41. package/graph.ts +153 -0
  42. package/host/compiler.ts +314 -0
  43. package/host/create.ts +66 -0
  44. package/host/engine.ts +630 -0
  45. package/host/plugin-host.ts +487 -0
  46. package/host/render.ts +102 -0
  47. package/host/response.ts +20 -0
  48. package/host/shell.html +15 -0
  49. package/host/source-sync.ts +254 -0
  50. package/html-attributes.ts +15 -0
  51. package/html.ts +48 -0
  52. package/icons/LICENSE +43 -0
  53. package/icons/lucide.ts +54 -0
  54. package/index.ts +75 -0
  55. package/invalidation.ts +85 -0
  56. package/log.ts +84 -0
  57. package/ops/href.ts +87 -0
  58. package/ops/index.ts +9 -0
  59. package/ops/move-source.ts +114 -0
  60. package/ops/plan.ts +47 -0
  61. package/ops/remove-source.ts +70 -0
  62. package/package.json +46 -0
  63. package/path.ts +75 -0
  64. package/pattern.ts +314 -0
  65. package/perf.ts +88 -0
  66. package/plugins/actor-path.ts +43 -0
  67. package/plugins/actor-profile.ts +55 -0
  68. package/plugins/agent-browsing.ts +183 -0
  69. package/plugins/app-component.ts +23 -0
  70. package/plugins/block-layout/index.ts +78 -0
  71. package/plugins/block-layout/theme.css +90 -0
  72. package/plugins/callout/index.ts +236 -0
  73. package/plugins/callout/theme.css +138 -0
  74. package/plugins/canonical-links.ts +144 -0
  75. package/plugins/code-highlight/client.ts +89 -0
  76. package/plugins/code-highlight/index.ts +147 -0
  77. package/plugins/code-highlight/theme.css +89 -0
  78. package/plugins/context.ts +131 -0
  79. package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
  80. package/plugins/datatype/OFL.txt +96 -0
  81. package/plugins/datatype/index.ts +287 -0
  82. package/plugins/definition-list.ts +236 -0
  83. package/plugins/directive-definitions.ts +13 -0
  84. package/plugins/embedding/client.ts +88 -0
  85. package/plugins/embedding/index.ts +43 -0
  86. package/plugins/image/index.ts +68 -0
  87. package/plugins/image/theme.css +43 -0
  88. package/plugins/include.ts +119 -0
  89. package/plugins/index.ts +91 -0
  90. package/plugins/input/authoring.ts +236 -0
  91. package/plugins/input/config.ts +85 -0
  92. package/plugins/input/index.ts +359 -0
  93. package/plugins/input/model.ts +173 -0
  94. package/plugins/input/state.ts +272 -0
  95. package/plugins/layout.ts +313 -0
  96. package/plugins/literate/client.ts +208 -0
  97. package/plugins/literate/index.ts +742 -0
  98. package/plugins/math/index.ts +140 -0
  99. package/plugins/meta.ts +42 -0
  100. package/plugins/navigation/index.ts +158 -0
  101. package/plugins/navigation/model.ts +364 -0
  102. package/plugins/preset.ts +65 -0
  103. package/plugins/query/index.ts +322 -0
  104. package/plugins/rss/index.ts +46 -0
  105. package/plugins/runtime/actions.ts +62 -0
  106. package/plugins/runtime/client.ts +8 -0
  107. package/plugins/runtime/document.ts +506 -0
  108. package/plugins/runtime/error.ts +154 -0
  109. package/plugins/runtime/heading-links.ts +31 -0
  110. package/plugins/runtime/index.ts +104 -0
  111. package/plugins/runtime/live.ts +51 -0
  112. package/plugins/runtime/navigation.ts +113 -0
  113. package/plugins/runtime/pending.ts +53 -0
  114. package/plugins/runtime/publication.ts +19 -0
  115. package/plugins/runtime/start.ts +177 -0
  116. package/plugins/runtime/static-client.ts +6 -0
  117. package/plugins/runtime/static-live.ts +188 -0
  118. package/plugins/runtime/view-transition.ts +72 -0
  119. package/plugins/runtime/viewport.ts +110 -0
  120. package/plugins/schema.ts +24 -0
  121. package/plugins/scripts.ts +140 -0
  122. package/plugins/source-preview/client.ts +256 -0
  123. package/plugins/source-preview/index.ts +132 -0
  124. package/plugins/source-preview/model.ts +11 -0
  125. package/plugins/source-preview/preview.css +107 -0
  126. package/plugins/styles.ts +145 -0
  127. package/plugins/theme/components.ts +10 -0
  128. package/plugins/theme/foundation.css +358 -0
  129. package/plugins/theme/index.ts +333 -0
  130. package/plugins/theme/prose.css +284 -0
  131. package/plugins/theme/scheme.js +63 -0
  132. package/plugins/theme/toggle.css +44 -0
  133. package/plugins/theme/view-transition.css +68 -0
  134. package/plugins/toc/client.ts +146 -0
  135. package/plugins/toc/index.ts +232 -0
  136. package/plugins/toc/theme.css +172 -0
  137. package/plugins/types.ts +538 -0
  138. package/problem.ts +41 -0
  139. package/project.ts +6 -0
  140. package/publish/build.ts +402 -0
  141. package/publish/concurrent.ts +32 -0
  142. package/publish/identity.ts +63 -0
  143. package/publish/index.ts +11 -0
  144. package/publish/location.ts +167 -0
  145. package/publish/model.ts +58 -0
  146. package/publish/plan.ts +300 -0
  147. package/publish/validate.ts +150 -0
  148. package/query/candidate-index.ts +101 -0
  149. package/query/dependency-index.ts +99 -0
  150. package/query/fields.ts +216 -0
  151. package/query/invalidation.ts +48 -0
  152. package/query/kernel.ts +776 -0
  153. package/query/model.ts +79 -0
  154. package/repository.ts +374 -0
  155. package/runtime-profile.ts +34 -0
  156. package/schema/model.ts +92 -0
  157. package/schema/registry.ts +642 -0
  158. package/schema/standard.ts +226 -0
  159. package/server/context.ts +94 -0
  160. package/server/css-imports.ts +66 -0
  161. package/server/http.ts +333 -0
  162. package/server/page.ts +21 -0
  163. package/server/protocols.ts +12 -0
  164. package/server/representations.ts +66 -0
  165. package/server/router.ts +131 -0
  166. package/server/routes.ts +202 -0
  167. package/server/serve.ts +5 -0
  168. package/server/watch.ts +120 -0
  169. package/source/blocks.ts +71 -0
  170. package/source/format.ts +164 -0
  171. package/source/index.ts +67 -0
  172. package/source/interpolate.ts +63 -0
  173. package/source/markdown/blocks.ts +46 -0
  174. package/source/markdown/comments.ts +32 -0
  175. package/source/markdown/fences.ts +67 -0
  176. package/source/markdown/headings.ts +88 -0
  177. package/source/markdown/index.ts +34 -0
  178. package/source/markdown/inline-code.ts +6 -0
  179. package/source/markdown/links.ts +326 -0
  180. package/source/markdown/lists.ts +223 -0
  181. package/source/markdown/prose.ts +74 -0
  182. package/source/markdown/scan.ts +79 -0
  183. package/source/markdown/text.ts +82 -0
  184. package/source/meta.ts +87 -0
  185. package/source/source.ts +121 -0
  186. package/source/topmatter.ts +57 -0
  187. package/source/utils.ts +21 -0
  188. package/source-diagnostic.ts +63 -0
  189. package/text-file.ts +26 -0
  190. package/util.ts +35 -0
  191. package/view/components.ts +499 -0
  192. package/view/model.ts +91 -0
  193. package/view/projection.ts +162 -0
  194. package/view/session.ts +146 -0
  195. 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
+ }