@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
package/server/http.ts ADDED
@@ -0,0 +1,333 @@
1
+ import {
2
+ ActionNotFoundError,
3
+ ActionUnavailableError,
4
+ ActionValidationError,
5
+ dispatchAction,
6
+ type ActionInput,
7
+ } from "../actions/actions.ts"
8
+ import type { Engine } from "../host/engine.ts"
9
+ import { actionProblem } from "../problem.ts"
10
+ import type { Source } from "../source/index.ts"
11
+ import {
12
+ anonymousPrincipal,
13
+ principalFor,
14
+ type CredentialSession,
15
+ type Principal,
16
+ } from "../authority/model.ts"
17
+ import { renderPage } from "../host/render.ts"
18
+ import { privateResponse, withReadCache } from "../host/response.ts"
19
+ import { pageNotFound, requestScheme } from "./page.ts"
20
+ import { AGENT_BROWSING_PLUGIN_ID, protocolRoute } from "./protocols.ts"
21
+ import { prefersMarkdown, withMarkdownAlternate } from "./representations.ts"
22
+ import { resolveRoute } from "./router.ts"
23
+
24
+ const anonymousSession = Object.freeze({
25
+ type: "anonymous",
26
+ signIn: null,
27
+ }) satisfies CredentialSession
28
+
29
+ function formInput(body: string) {
30
+ const input: ActionInput = {}
31
+ for (const [name, value] of new URLSearchParams(body)) {
32
+ const current = input[name]
33
+ input[name] =
34
+ current == null
35
+ ? value
36
+ : Array.isArray(current)
37
+ ? [...current, value]
38
+ : [current, value]
39
+ }
40
+ return input
41
+ }
42
+
43
+ function resolveTarget(
44
+ app: Engine,
45
+ input: ActionInput,
46
+ referer: string | null,
47
+ ) {
48
+ const targetInput = input._target
49
+ const hinted = typeof targetInput === "string" ? targetInput.trim() : ""
50
+ if (hinted) {
51
+ const source = app.resolve(hinted) ?? app.repo.get(hinted)
52
+ return { source, required: true }
53
+ }
54
+ if (!referer) return
55
+ try {
56
+ return { source: app.resolve(new URL(referer).pathname), required: false }
57
+ } catch {
58
+ return
59
+ }
60
+ }
61
+
62
+ function sameOriginMutation(req: Request, url: URL) {
63
+ const origin = req.headers.get("origin")
64
+ if (origin) return origin === url.origin
65
+ const referer = req.headers.get("referer")
66
+ if (!referer) return false
67
+ try {
68
+ return new URL(referer).origin === url.origin
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ async function handleAction(
75
+ app: Engine,
76
+ req: Request,
77
+ url: URL,
78
+ session: CredentialSession,
79
+ ) {
80
+ if (req.method !== "POST") {
81
+ return new Response("Method Not Allowed", { status: 405 })
82
+ }
83
+ if (session.type !== "authenticated") return pageNotFound()
84
+ if (!sameOriginMutation(req, url)) {
85
+ return actionProblem(req, 403, "Cross-origin mutation denied.")
86
+ }
87
+
88
+ const name = url.pathname.slice("/actions/".length)
89
+ const input = formInput(await req.text())
90
+ const resolvedTarget = resolveTarget(app, input, req.headers.get("referer"))
91
+ const target = resolvedTarget?.source
92
+ if (resolvedTarget?.required && !target) return pageNotFound()
93
+ if (
94
+ target &&
95
+ !app.hasSourceCapability(
96
+ { type: "actor", actor: session.actor },
97
+ target,
98
+ "execute",
99
+ )
100
+ ) {
101
+ return pageNotFound()
102
+ }
103
+ let outcome: Awaited<ReturnType<typeof dispatchAction>>
104
+ try {
105
+ outcome = await dispatchAction(app, name, input, session.actor, target)
106
+ } catch (error) {
107
+ if (error instanceof ActionNotFoundError) {
108
+ return actionProblem(req, 404, error.message)
109
+ }
110
+ if (error instanceof ActionValidationError) {
111
+ return actionProblem(req, 400, error.message)
112
+ }
113
+ if (error instanceof ActionUnavailableError) {
114
+ return actionProblem(req, 403, error.message)
115
+ }
116
+ const message = error instanceof Error ? error.message : "Action failed."
117
+ return actionProblem(req, 400, message)
118
+ }
119
+
120
+ if (req.headers.get("accept")?.includes("application/json")) {
121
+ return privateResponse(
122
+ Response.json({
123
+ ok: true,
124
+ action: name,
125
+ target: target?.id,
126
+ output: outcome.value,
127
+ receipt: { applied: outcome.commit.applied.length },
128
+ }),
129
+ )
130
+ }
131
+
132
+ return privateResponse(
133
+ new Response(null, {
134
+ status: 303,
135
+ headers: { Location: req.headers.get("referer") || "/" },
136
+ }),
137
+ )
138
+ }
139
+
140
+ export async function handleRequest(app: Engine, req: Request) {
141
+ return handleRoutedRequest(app, req, new URL(req.url))
142
+ }
143
+
144
+ /** @internal Render a canonical route at a different public publication URL. */
145
+ export async function handleRequestAtPath(
146
+ app: Engine,
147
+ req: Request,
148
+ pathname: string,
149
+ ) {
150
+ const publicUrl = new URL(req.url)
151
+ const routeUrl = new URL(pathname, publicUrl.origin)
152
+ return handleRoutedRequest(app, req, routeUrl, publicUrl)
153
+ }
154
+
155
+ async function handleRoutedRequest(
156
+ app: Engine,
157
+ req: Request,
158
+ url: URL,
159
+ publicUrl = url,
160
+ ) {
161
+ const start = performance.now()
162
+
163
+ return app.perf.measureAsync(
164
+ "http.request",
165
+ async () => {
166
+ const response = await dispatchRequest(app, req, url, publicUrl)
167
+ const ms = performance.now() - start
168
+ response.headers.set("Server-Timing", `render;dur=${ms.toFixed(1)}`)
169
+ app.log.debug("http.perf", {
170
+ path: url.pathname,
171
+ ms: Number(ms.toFixed(1)),
172
+ status: response.status,
173
+ })
174
+ return response
175
+ },
176
+ publicUrl.pathname,
177
+ )
178
+ }
179
+
180
+ async function dispatchRequest(
181
+ app: Engine,
182
+ req: Request,
183
+ url: URL,
184
+ publicUrl: URL,
185
+ ) {
186
+ if (req.method === "GET" || req.method === "HEAD") {
187
+ const served = serveGenerated(app, url.pathname, req.method === "HEAD")
188
+ if (served) return served
189
+ }
190
+
191
+ if (
192
+ protocolRoute(url.pathname) &&
193
+ (req.method === "GET" || req.method === "HEAD")
194
+ ) {
195
+ const authored = await serveAsset(
196
+ app,
197
+ url.pathname,
198
+ req.method === "HEAD",
199
+ anonymousSession,
200
+ anonymousPrincipal,
201
+ )
202
+ if (authored) return authored
203
+ }
204
+
205
+ const serverRoute = await app.serverRoute(req, url, publicUrl)
206
+ if (serverRoute) return serverRoute
207
+ if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
208
+ return pageNotFound()
209
+ }
210
+
211
+ const session = await app.resolveCredentials(req)
212
+ const principal = principalFor(session)
213
+ return dispatch(app, req, url, session, principal)
214
+ }
215
+
216
+ function serveGenerated(app: Engine, pathname: string, head: boolean) {
217
+ if (!pathname.startsWith("/.pmx/")) return
218
+ const asset = app.servedAsset(pathname)
219
+ if (!asset) return
220
+ return new Response(head ? null : asset.body, {
221
+ headers: {
222
+ "Content-Type": asset.contentType,
223
+ "Cache-Control": "public, max-age=31536000, immutable",
224
+ },
225
+ })
226
+ }
227
+
228
+ async function dispatch(
229
+ app: Engine,
230
+ req: Request,
231
+ url: URL,
232
+ session: CredentialSession,
233
+ principal: Principal,
234
+ ) {
235
+ if (url.pathname.startsWith("/actions/")) {
236
+ return handleAction(app, req, url, session)
237
+ }
238
+ if (req.method !== "GET" && req.method !== "HEAD") {
239
+ return new Response("Method Not Allowed", { status: 405 })
240
+ }
241
+
242
+ // One canonical URL per page: the edge normalizes trailing slashes so
243
+ // canonicalPath stays strict and cache keys stay unique.
244
+ if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
245
+ return new Response(null, {
246
+ status: 308,
247
+ headers: { Location: url.pathname.replace(/\/+$/, "") + url.search },
248
+ })
249
+ }
250
+
251
+ const route = resolveRoute(app.repo, url.pathname)
252
+ if (route?.type === "source") {
253
+ return serveSource(
254
+ app,
255
+ req.method === "HEAD",
256
+ session,
257
+ principal,
258
+ route.source,
259
+ )
260
+ }
261
+ if (route?.type === "page") {
262
+ const agentBrowsing = app.hasPlugin(AGENT_BROWSING_PLUGIN_ID)
263
+ if (agentBrowsing && prefersMarkdown(req)) {
264
+ return withMarkdownAlternate(
265
+ serveSource(
266
+ app,
267
+ req.method === "HEAD",
268
+ session,
269
+ principal,
270
+ route.source,
271
+ ),
272
+ route.source.path,
273
+ )
274
+ }
275
+ let response = await renderPage(app, session, principal, route.source, {
276
+ scheme: requestScheme(req),
277
+ })
278
+ if (req.method === "HEAD") response = new Response(null, response)
279
+ return agentBrowsing
280
+ ? withMarkdownAlternate(response, route.source.path)
281
+ : response
282
+ }
283
+
284
+ return (
285
+ (await serveAsset(
286
+ app,
287
+ url.pathname,
288
+ req.method === "HEAD",
289
+ session,
290
+ principal,
291
+ )) ?? pageNotFound()
292
+ )
293
+ }
294
+
295
+ function serveSource(
296
+ app: Engine,
297
+ head: boolean,
298
+ session: CredentialSession,
299
+ principal: Principal,
300
+ source: Source,
301
+ ) {
302
+ const access = app.readAccess(principal, source)
303
+ if (access === "denied") return pageNotFound()
304
+ return withReadCache(
305
+ new Response(head ? null : source.content, {
306
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
307
+ }),
308
+ access,
309
+ session,
310
+ )
311
+ }
312
+
313
+ async function serveAsset(
314
+ app: Engine,
315
+ pathname: string,
316
+ head: boolean,
317
+ session: CredentialSession,
318
+ principal: Principal,
319
+ ) {
320
+ let path: string
321
+ try {
322
+ path = decodeURIComponent(pathname)
323
+ } catch {
324
+ return
325
+ }
326
+ if (path.includes("..")) return
327
+ const storagePath = app.repo.storagePath(path)
328
+ if (!storagePath || !(await app.repo.isFile(path))) return
329
+ const access = app.readAccess(principal, path)
330
+ if (access === "denied") return pageNotFound()
331
+ const body = head ? null : await app.environment.storage.readBlob(storagePath)
332
+ return withReadCache(new Response(body), access, session)
333
+ }
package/server/page.ts ADDED
@@ -0,0 +1,21 @@
1
+ const SCHEME_COOKIE = "pathmx-scheme"
2
+
3
+ export { pageNotFound } from "../host/render.ts"
4
+
5
+ function cookieValue(header: string | null, name: string) {
6
+ if (!header) return
7
+ for (const part of header.split(";")) {
8
+ const equals = part.indexOf("=")
9
+ if (equals === -1 || part.slice(0, equals).trim() !== name) continue
10
+ try {
11
+ return decodeURIComponent(part.slice(equals + 1).trim())
12
+ } catch {
13
+ return
14
+ }
15
+ }
16
+ }
17
+
18
+ export function requestScheme(request: Request) {
19
+ const value = cookieValue(request.headers.get("cookie"), SCHEME_COOKIE)
20
+ if (value === "light" || value === "dark") return value
21
+ }
@@ -0,0 +1,12 @@
1
+ export const AGENT_BROWSING_PLUGIN_ID = "agent-browsing"
2
+
3
+ const protocolRoutes: Readonly<Record<string, string>> = Object.freeze({
4
+ "/llms.txt": `/api/${AGENT_BROWSING_PLUGIN_ID}/llms.txt`,
5
+ "/robots.txt": `/api/${AGENT_BROWSING_PLUGIN_ID}/robots.txt`,
6
+ "/sitemap.xml": `/api/${AGENT_BROWSING_PLUGIN_ID}/sitemap.xml`,
7
+ })
8
+
9
+ /** @internal Map core-owned standard URLs onto their namespaced Plugin route. */
10
+ export function protocolRoute(pathname: string) {
11
+ return protocolRoutes[pathname]
12
+ }
@@ -0,0 +1,66 @@
1
+ function acceptedQuality(header: string, target: string) {
2
+ const [targetType, targetSubtype] = target.split("/")
3
+ let quality = 0
4
+ let specificity = -1
5
+
6
+ for (const entry of header.split(",")) {
7
+ const [range = "", ...parameters] = entry.split(";")
8
+ const [type, subtype] = range.trim().toLowerCase().split("/")
9
+ if (
10
+ !type ||
11
+ !subtype ||
12
+ (type !== "*" && type !== targetType) ||
13
+ (subtype !== "*" && subtype !== targetSubtype)
14
+ ) {
15
+ continue
16
+ }
17
+
18
+ const candidateSpecificity =
19
+ (type === "*" ? 0 : 1) + (subtype === "*" ? 0 : 1)
20
+ const q = parameters
21
+ .map((parameter) => parameter.trim().match(/^q\s*=\s*(.+)$/i)?.[1])
22
+ .find((value) => value !== undefined)
23
+ const parsed = q === undefined ? 1 : Number(q)
24
+ const candidateQuality =
25
+ Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0
26
+
27
+ if (
28
+ candidateSpecificity > specificity ||
29
+ (candidateSpecificity === specificity && candidateQuality > quality)
30
+ ) {
31
+ specificity = candidateSpecificity
32
+ quality = candidateQuality
33
+ }
34
+ }
35
+
36
+ return quality
37
+ }
38
+
39
+ export function prefersMarkdown(request: Request) {
40
+ const accept = request.headers.get("Accept")
41
+ if (!accept) return false
42
+ const markdown = acceptedQuality(accept, "text/markdown")
43
+ return markdown > 0 && markdown > acceptedQuality(accept, "text/html")
44
+ }
45
+
46
+ function appendVary(headers: Headers, name: string) {
47
+ const values = (headers.get("Vary") ?? "")
48
+ .split(",")
49
+ .map((value) => value.trim())
50
+ .filter(Boolean)
51
+ if (!values.some((value) => value.toLowerCase() === name.toLowerCase())) {
52
+ values.push(name)
53
+ }
54
+ headers.set("Vary", values.join(", "))
55
+ }
56
+
57
+ export function withMarkdownAlternate(response: Response, sourcePath: string) {
58
+ if (response.status >= 400) return response
59
+ appendVary(response.headers, "Accept")
60
+ const markdownPath = new URL(sourcePath, "http://pathmx.local").pathname
61
+ response.headers.append(
62
+ "Link",
63
+ `<${markdownPath}>; rel="alternate"; type="text/markdown"`,
64
+ )
65
+ return response
66
+ }
@@ -0,0 +1,131 @@
1
+ import type { Repository } from "../repository.ts"
2
+ import { resolveSourceId, type Source } from "../source/index.ts"
3
+ import { tryCanonicalPath } from "../canonical.ts"
4
+ import { isActorSource } from "../authority/actors.ts"
5
+
6
+ type IndexRouteTable = {
7
+ revision: number
8
+ byDirectory: Map<string, Source[]>
9
+ }
10
+
11
+ const indexRouteTables = new WeakMap<Repository, IndexRouteTable>()
12
+
13
+ export type Route =
14
+ { type: "page"; source: Source } | { type: "source"; source: Source }
15
+
16
+ // Index-ness is name position only. Types and qualifiers are domain-defined
17
+ // (`index` may be a valid type), so the name slot is the only unambiguous
18
+ // place routing semantics can live — the route table stays inferable from
19
+ // the file listing alone.
20
+ export function isIndexSource(source: Source) {
21
+ return source.name === "index"
22
+ }
23
+
24
+ export function isPageSource(source: Source) {
25
+ return (
26
+ source.type !== "layout" &&
27
+ source.type !== "navigation" &&
28
+ !isActorSource(source)
29
+ )
30
+ }
31
+
32
+ export function sourceDirectory(source: Source) {
33
+ const slash = source.path.lastIndexOf("/")
34
+ return slash <= 0 ? "/" : source.path.slice(0, slash)
35
+ }
36
+
37
+ export function publicPath(source: Source) {
38
+ if (isPageSource(source) && isIndexSource(source))
39
+ return sourceDirectory(source)
40
+ return source.id
41
+ }
42
+
43
+ function sourceById(repo: Repository, path: string) {
44
+ return repo.atPath(path) ?? repo.get(resolveSourceId(path, {}))
45
+ }
46
+
47
+ function normalizePathname(pathname: string) {
48
+ let path: string
49
+ try {
50
+ path = decodeURIComponent(pathname)
51
+ } catch {
52
+ return
53
+ }
54
+ return tryCanonicalPath(path || "/")
55
+ }
56
+
57
+ export function resolveRoute(
58
+ repo: Repository,
59
+ pathname: string,
60
+ ): Route | undefined {
61
+ const path = normalizePathname(pathname)
62
+
63
+ if (!path) return
64
+
65
+ if (path.endsWith(".md")) {
66
+ const source = sourceById(repo, path)
67
+ return source ? { type: "source", source } : undefined
68
+ }
69
+
70
+ const exact = repo.get(resolveSourceId(path, {}))
71
+ if (exact && isPageSource(exact) && exact.id === path)
72
+ return { type: "page", source: exact }
73
+
74
+ const index = indexCandidates(repo, path)[0]
75
+
76
+ if (index) return { type: "page", source: index }
77
+ }
78
+
79
+ // Ties break by resolved id in codepoint order (not localeCompare — host
80
+ // locale must not change routing). Bare `index.md` participates under its
81
+ // default-typed id.
82
+ function indexCandidates(repo: Repository, dir: string) {
83
+ return indexRouteTable(repo).byDirectory.get(dir) ?? []
84
+ }
85
+
86
+ function sortIndexCandidates(sources: Source[]) {
87
+ return sources.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
88
+ }
89
+
90
+ function indexRouteTable(repo: Repository) {
91
+ const cached = indexRouteTables.get(repo)
92
+ if (cached?.revision === repo.revision) return cached
93
+
94
+ const byDirectory = new Map<string, Source[]>()
95
+ for (const source of repo.all()) {
96
+ if (!isPageSource(source) || !isIndexSource(source)) continue
97
+ const dir = sourceDirectory(source)
98
+ const candidates = byDirectory.get(dir)
99
+ if (candidates) candidates.push(source)
100
+ else byDirectory.set(dir, [source])
101
+ }
102
+ for (const candidates of byDirectory.values()) {
103
+ sortIndexCandidates(candidates)
104
+ }
105
+ const table = { revision: repo.revision, byDirectory }
106
+ indexRouteTables.set(repo, table)
107
+ return table
108
+ }
109
+
110
+ export type IndexRouteConflict = {
111
+ dir: string
112
+ winner: Source
113
+ shadowed: Source[]
114
+ }
115
+
116
+ /** Directories where more than one index source claims the route. */
117
+ export function indexRouteConflicts(repo: Repository) {
118
+ const conflicts: IndexRouteConflict[] = []
119
+ const { byDirectory } = indexRouteTable(repo)
120
+ for (const dir of [...byDirectory.keys()].sort()) {
121
+ const candidates = byDirectory.get(dir)!
122
+ if (candidates.length > 1) {
123
+ conflicts.push({
124
+ dir,
125
+ winner: candidates[0]!,
126
+ shadowed: candidates.slice(1),
127
+ })
128
+ }
129
+ }
130
+ return conflicts
131
+ }