@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,58 @@
1
+ export const PUBLICATION_VERSION = 3 as const
2
+
3
+ export type PublicationRepresentation = Readonly<{
4
+ type: "html" | "markdown" | "file"
5
+ artifact: string
6
+ contentType: string
7
+ default?: true
8
+ accept?: string
9
+ alternatePath?: string
10
+ headers?: Readonly<Record<string, string>>
11
+ }>
12
+
13
+ export type PublicationRoute = Readonly<{
14
+ pathname: string
15
+ type: "page" | "source" | "asset" | "artifact"
16
+ sourcePath?: string
17
+ representations: readonly PublicationRepresentation[]
18
+ }>
19
+
20
+ export type PublicationManifest = Readonly<{
21
+ version: typeof PUBLICATION_VERSION
22
+ /** Deterministic Merkle root of the canonical route contract and artifacts. */
23
+ build: string
24
+ origin: string
25
+ /** Normalized public mount path. `/` means the origin root. */
26
+ basePath: string
27
+ routes: readonly PublicationRoute[]
28
+ artifacts: readonly PublicationArtifactRecord[]
29
+ }>
30
+
31
+ export type PublicationArtifactRecord = Readonly<{
32
+ path: string
33
+ contentType: string
34
+ size: number
35
+ /** Stable cache identity; HTML is fingerprinted before its leaf is embedded. */
36
+ leaf: string
37
+ }>
38
+
39
+ export type PublicationStoredBody = Readonly<{
40
+ size: number
41
+ read: () => Promise<Blob>
42
+ }>
43
+
44
+ /** Adapter-owned staging whose writes remain private until adapter commit. */
45
+ export type PublicationStore = Readonly<{
46
+ put: (path: string, body: Blob) => Promise<PublicationStoredBody>
47
+ }>
48
+
49
+ export type PublicationArtifact = PublicationArtifactRecord &
50
+ Readonly<{
51
+ /** Reusable adapter reader that does not depend on live Engine state. */
52
+ read: () => Promise<Blob>
53
+ }>
54
+
55
+ export type PublicationBundle = Readonly<{
56
+ manifest: PublicationManifest
57
+ artifacts: readonly PublicationArtifact[]
58
+ }>
@@ -0,0 +1,300 @@
1
+ import { mapConcurrent } from "./concurrent.ts"
2
+ import { artifactLeaf, publicationRoot } from "./identity.ts"
3
+ import {
4
+ PUBLICATION_VERSION,
5
+ type PublicationArtifact,
6
+ type PublicationArtifactRecord,
7
+ type PublicationManifest,
8
+ type PublicationRepresentation,
9
+ type PublicationRoute,
10
+ type PublicationStore,
11
+ } from "./model.ts"
12
+
13
+ type ArtifactInput = Readonly<{
14
+ path: string
15
+ contentType: string
16
+ size?: number
17
+ read: () => Promise<Blob>
18
+ }>
19
+
20
+ type PlannedArtifact = {
21
+ path: string
22
+ contentType: string
23
+ size?: number
24
+ leaf?: string
25
+ read: () => Promise<Blob>
26
+ }
27
+
28
+ const OMITTED_HEADERS = new Set([
29
+ "content-length",
30
+ "content-type",
31
+ "date",
32
+ "server-timing",
33
+ "set-cookie",
34
+ "transfer-encoding",
35
+ ])
36
+
37
+ type MutableRoute = {
38
+ pathname: string
39
+ type: PublicationRoute["type"]
40
+ sourcePath?: string
41
+ representations: PublicationRepresentation[]
42
+ }
43
+
44
+ export class PublicationError extends Error {
45
+ constructor(message: string) {
46
+ super(message)
47
+ this.name = "PublicationError"
48
+ }
49
+ }
50
+
51
+ export function comparePaths(left: string, right: string) {
52
+ return left < right ? -1 : left > right ? 1 : 0
53
+ }
54
+
55
+ export function canonicalPublishedPath(value: string) {
56
+ if (!value.startsWith("/") || value.includes("?") || value.includes("#")) {
57
+ throw new PublicationError(`Invalid publication route: ${value}`)
58
+ }
59
+ const url = new URL(value, "https://pathmx.local")
60
+ if (url.pathname !== value || value.split("/").includes("..")) {
61
+ throw new PublicationError(`Invalid publication route: ${value}`)
62
+ }
63
+ return value
64
+ }
65
+
66
+ export function canonicalBasePath(value = "/") {
67
+ const path = canonicalPublishedPath(value || "/")
68
+ if (path !== "/" && path.endsWith("/")) {
69
+ throw new PublicationError(
70
+ `Publication base path must not end with a slash: ${value}`,
71
+ )
72
+ }
73
+ return path
74
+ }
75
+
76
+ export function mountedPublishedPath(basePath: string, pathname: string) {
77
+ const canonical = canonicalPublishedPath(pathname)
78
+ if (basePath === "/") return canonical
79
+ return canonical === "/" ? basePath : `${basePath}${canonical}`
80
+ }
81
+
82
+ export function pageArtifact(pathname: string) {
83
+ const page =
84
+ pathname === "/"
85
+ ? "index.html"
86
+ : `${pathname.replace(/^\/+|\/+$/g, "")}/index.html`
87
+ return `.pathmx/artifacts/pages/${page}`
88
+ }
89
+
90
+ export function exactArtifact(pathname: string) {
91
+ const path = pathname.replace(/^\/+/, "")
92
+ if (!path)
93
+ throw new PublicationError(`Route requires an artifact: ${pathname}`)
94
+ return path
95
+ }
96
+
97
+ export function responseHeaders(response: Response) {
98
+ const entries = [...response.headers]
99
+ .filter(([name]) => !OMITTED_HEADERS.has(name.toLowerCase()))
100
+ .sort(([left], [right]) => comparePaths(left, right))
101
+ return entries.length ? Object.freeze(Object.fromEntries(entries)) : undefined
102
+ }
103
+
104
+ export function fileRepresentation(
105
+ artifact: string,
106
+ contentType: string,
107
+ headers?: Readonly<Record<string, string>>,
108
+ ): PublicationRepresentation {
109
+ return Object.freeze({
110
+ type: "file",
111
+ default: true,
112
+ artifact,
113
+ contentType: contentType || "application/octet-stream",
114
+ ...(headers ? { headers } : {}),
115
+ })
116
+ }
117
+
118
+ export class PublicationPlan {
119
+ private routes = new Map<string, MutableRoute>()
120
+ private artifacts = new Map<string, PlannedArtifact>()
121
+
122
+ hasRoute(pathname: string) {
123
+ return this.routes.has(pathname)
124
+ }
125
+
126
+ addRoute(route: MutableRoute) {
127
+ if (this.routes.has(route.pathname)) {
128
+ throw new PublicationError(
129
+ `Duplicate publication route: ${route.pathname}`,
130
+ )
131
+ }
132
+ this.routes.set(route.pathname, route)
133
+ }
134
+
135
+ addArtifact(artifact: ArtifactInput) {
136
+ if (this.artifacts.has(artifact.path)) {
137
+ throw new PublicationError(
138
+ `Duplicate publication artifact: ${artifact.path}`,
139
+ )
140
+ }
141
+ this.artifacts.set(artifact.path, {
142
+ path: artifact.path,
143
+ contentType: artifact.contentType,
144
+ size: artifact.size,
145
+ read: artifact.read,
146
+ })
147
+ }
148
+
149
+ async materialize(store: PublicationStore) {
150
+ const pending = this.sortedArtifacts().filter(
151
+ (artifact) => artifact.leaf === undefined,
152
+ )
153
+ await mapConcurrent(pending, (artifact) =>
154
+ this.materializeArtifact(artifact, store),
155
+ )
156
+ }
157
+
158
+ async stagePage(path: string, store: PublicationStore) {
159
+ const artifact = this.requireArtifact(path)
160
+ if (artifact.leaf !== undefined) {
161
+ throw new PublicationError(`Page artifact was already staged: ${path}`)
162
+ }
163
+ const body = await artifact.read()
164
+ artifact.leaf = await artifactLeaf(
165
+ artifact.path,
166
+ artifact.contentType,
167
+ body,
168
+ )
169
+ await this.storeStampedPage(artifact, body, store)
170
+ }
171
+
172
+ buildRoot(origin: URL, basePath: string) {
173
+ return publicationRoot(
174
+ origin,
175
+ basePath,
176
+ this.routeList(),
177
+ this.artifactRecordList(),
178
+ )
179
+ }
180
+
181
+ pageLeaves() {
182
+ const artifacts = new Map(
183
+ this.artifactRecordList().map((artifact) => [artifact.path, artifact]),
184
+ )
185
+ return Object.freeze(
186
+ Object.fromEntries(
187
+ this.routeList()
188
+ .filter((route) => route.type === "page")
189
+ .map((route) => {
190
+ const path = route.representations.find(
191
+ (representation) => representation.type === "html",
192
+ )?.artifact
193
+ const artifact = path ? artifacts.get(path) : undefined
194
+ if (!artifact) {
195
+ throw new PublicationError(
196
+ `Page route has no HTML artifact leaf: ${route.pathname}`,
197
+ )
198
+ }
199
+ return [route.pathname, artifact.leaf]
200
+ }),
201
+ ),
202
+ )
203
+ }
204
+
205
+ manifest(build: string, origin: URL, basePath: string): PublicationManifest {
206
+ return Object.freeze({
207
+ version: PUBLICATION_VERSION,
208
+ build,
209
+ origin: origin.origin,
210
+ basePath,
211
+ routes: Object.freeze(this.routeList()),
212
+ artifacts: Object.freeze(this.artifactRecordList()),
213
+ })
214
+ }
215
+
216
+ private routeList() {
217
+ return [...this.routes.values()]
218
+ .sort((left, right) => comparePaths(left.pathname, right.pathname))
219
+ .map((route) =>
220
+ Object.freeze({
221
+ ...route,
222
+ representations: Object.freeze([...route.representations]),
223
+ }),
224
+ )
225
+ }
226
+
227
+ artifactList(): PublicationArtifact[] {
228
+ return this.sortedArtifacts().map((artifact) => {
229
+ if (artifact.size === undefined) {
230
+ throw new PublicationError(
231
+ `Publication artifact was not fingerprinted: ${artifact.path}`,
232
+ )
233
+ }
234
+ if (artifact.leaf === undefined) {
235
+ throw new PublicationError(
236
+ `Publication artifact has no leaf: ${artifact.path}`,
237
+ )
238
+ }
239
+ return Object.freeze({
240
+ path: artifact.path,
241
+ contentType: artifact.contentType,
242
+ size: artifact.size,
243
+ leaf: artifact.leaf,
244
+ read: artifact.read,
245
+ })
246
+ })
247
+ }
248
+
249
+ private artifactRecordList(): PublicationArtifactRecord[] {
250
+ return this.artifactList().map(({ read: _, ...artifact }) => artifact)
251
+ }
252
+
253
+ private requireArtifact(path: string) {
254
+ const artifact = this.artifacts.get(path)
255
+ if (!artifact) throw new PublicationError(`Missing artifact: ${path}`)
256
+ return artifact
257
+ }
258
+
259
+ private async materializeArtifact(
260
+ artifact: PlannedArtifact,
261
+ store: PublicationStore,
262
+ ) {
263
+ if (artifact.leaf !== undefined) return
264
+ const body = await artifact.read()
265
+ const leaf = await artifactLeaf(artifact.path, artifact.contentType, body)
266
+ const stored = await store.put(artifact.path, body)
267
+ artifact.size = stored.size
268
+ artifact.leaf = leaf
269
+ artifact.read = stored.read
270
+ }
271
+
272
+ private async storeStampedPage(
273
+ artifact: PlannedArtifact,
274
+ source: Blob,
275
+ store: PublicationStore,
276
+ ) {
277
+ if (!artifact.leaf) {
278
+ throw new PublicationError(`Missing artifact leaf: ${artifact.path}`)
279
+ }
280
+ const html = await source.text()
281
+ if (!html.includes("<html")) {
282
+ throw new PublicationError(
283
+ `HTML artifact has no document root: ${artifact.path}`,
284
+ )
285
+ }
286
+ const body = new Blob(
287
+ [html.replace("<html", `<html data-pmx-page="${artifact.leaf}"`)],
288
+ { type: artifact.contentType },
289
+ )
290
+ const stored = await store.put(artifact.path, body)
291
+ artifact.size = stored.size
292
+ artifact.read = stored.read
293
+ }
294
+
295
+ private sortedArtifacts() {
296
+ return [...this.artifacts.values()].sort((left, right) =>
297
+ comparePaths(left.path, right.path),
298
+ )
299
+ }
300
+ }
@@ -0,0 +1,150 @@
1
+ import type { Engine } from "../host/engine.ts"
2
+ import { interpolateText } from "../source/index.ts"
3
+ import { publicPath } from "../server/router.ts"
4
+ import type {
5
+ PublicationArtifact,
6
+ PublicationBundle,
7
+ PublicationRoute,
8
+ } from "./model.ts"
9
+ import { mountedPublishedPath, PublicationError } from "./plan.ts"
10
+
11
+ const NETWORK_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
12
+
13
+ function htmlArtifact(
14
+ route: PublicationRoute,
15
+ artifacts: ReadonlyMap<string, PublicationArtifact>,
16
+ ) {
17
+ const path = route.representations.find(
18
+ (representation) => representation.type === "html",
19
+ )?.artifact
20
+ return path ? artifacts.get(path) : undefined
21
+ }
22
+
23
+ function internalPath(value: string, route: string, origin: string) {
24
+ if (NETWORK_URL.test(value.trim())) return
25
+ try {
26
+ const url = new URL(value, new URL(route, origin))
27
+ return url.origin === origin ? url.pathname : undefined
28
+ } catch {
29
+ return
30
+ }
31
+ }
32
+
33
+ function unresolvedPaths(
34
+ app: Engine,
35
+ route: PublicationRoute,
36
+ origin: string,
37
+ basePath: string,
38
+ ) {
39
+ const source = route.sourcePath
40
+ ? app.repo.atPath(route.sourcePath)
41
+ : undefined
42
+ if (!source) return new Set<string>()
43
+ const canonicalRoute = app.isPage(source) ? publicPath(source) : source.path
44
+ return new Set(
45
+ app.graph
46
+ .linksFrom(source.path)
47
+ .filter((edge) => edge.type === "unresolved")
48
+ .map((edge) => {
49
+ const pathname = internalPath(
50
+ interpolateText(edge.href, source.data),
51
+ canonicalRoute,
52
+ origin,
53
+ )
54
+ return pathname ? mountedPublishedPath(basePath, pathname) : undefined
55
+ })
56
+ .filter((path): path is string => path != null),
57
+ )
58
+ }
59
+
60
+ /** Validate that exported HTML depends only on materialized public routes. */
61
+ export async function validatePublication(
62
+ app: Engine,
63
+ publication: PublicationBundle,
64
+ ) {
65
+ const routes = new Set(
66
+ publication.manifest.routes.flatMap((route) => {
67
+ const path = internalPath(
68
+ route.pathname,
69
+ "/",
70
+ publication.manifest.origin,
71
+ )
72
+ return path ? [path] : []
73
+ }),
74
+ )
75
+ const artifacts = new Map(
76
+ publication.artifacts.map((artifact) => [artifact.path, artifact]),
77
+ )
78
+ const issues: string[] = []
79
+
80
+ for (const route of publication.manifest.routes) {
81
+ const artifact = htmlArtifact(route, artifacts)
82
+ if (!artifact) continue
83
+ const unresolved = unresolvedPaths(
84
+ app,
85
+ route,
86
+ publication.manifest.origin,
87
+ publication.manifest.basePath,
88
+ )
89
+ const html = await (await artifact.read()).text()
90
+ app.environment.rewriteHtml(html, [
91
+ [
92
+ "a[href], link[href], script[src], img[src], source[src], form",
93
+ {
94
+ element(element) {
95
+ const attribute =
96
+ element.getAttribute("href") != null ? "href" : "src"
97
+ const value = element.getAttribute(attribute)
98
+ if (value) {
99
+ const pathname = internalPath(
100
+ value,
101
+ route.pathname,
102
+ publication.manifest.origin,
103
+ )
104
+ if (
105
+ pathname &&
106
+ !routes.has(pathname) &&
107
+ !unresolved.has(pathname)
108
+ ) {
109
+ issues.push(
110
+ `${route.pathname}: ${attribute} references unpublished route ${pathname}`,
111
+ )
112
+ }
113
+ }
114
+
115
+ if (element.tagName !== "form") return
116
+ const method = (
117
+ element.getAttribute("method") || "get"
118
+ ).toLowerCase()
119
+ const action = element.getAttribute("action") || route.pathname
120
+ const pathname = internalPath(
121
+ action,
122
+ route.pathname,
123
+ publication.manifest.origin,
124
+ )
125
+ if (method === "get") {
126
+ if (pathname && !routes.has(pathname)) {
127
+ issues.push(
128
+ `${route.pathname}: GET form references unpublished route ${pathname}`,
129
+ )
130
+ }
131
+ } else if (pathname) {
132
+ issues.push(
133
+ `${route.pathname}: ${method.toUpperCase()} form requires server route ${pathname}`,
134
+ )
135
+ }
136
+ },
137
+ },
138
+ ],
139
+ ])
140
+ }
141
+
142
+ if (issues.length) {
143
+ throw new PublicationError(
144
+ `Static publication contains unavailable routes:\n${[...new Set(issues)]
145
+ .sort()
146
+ .map((issue) => `- ${issue}`)
147
+ .join("\n")}`,
148
+ )
149
+ }
150
+ }
@@ -0,0 +1,101 @@
1
+ import type { Source, SourceBlock } from "../source/index.ts"
2
+
3
+ type CandidateSelector = Readonly<{
4
+ type?: string
5
+ has: readonly string[]
6
+ }>
7
+
8
+ export type IndexedBlock = Readonly<{
9
+ source: Source
10
+ block: SourceBlock
11
+ }>
12
+
13
+ /** @internal Minimal candidate indexes shared by the built-in query roots. */
14
+ export class QueryIndex {
15
+ private sources = new Set<Source>()
16
+ private sourcesByPath = new Map<string, Source>()
17
+ private sourcesByType = new Map<string, Set<Source>>()
18
+ private sourcesByProp = new Map<string, Set<Source>>()
19
+ private blocks = new Set<IndexedBlock>()
20
+ private blocksBySource = new Map<string, IndexedBlock[]>()
21
+ private blocksByType = new Map<string, Set<IndexedBlock>>()
22
+ private blocksByProp = new Map<string, Set<IndexedBlock>>()
23
+
24
+ replace(source: Source, registeredProps: ReadonlySet<string>) {
25
+ this.delete(source.path)
26
+ this.sources.add(source)
27
+ this.sourcesByPath.set(source.path, source)
28
+ this.add(this.sourcesByType, source.type, source)
29
+ for (const prop of Object.keys(source.data)) {
30
+ if (registeredProps.has(prop)) this.add(this.sourcesByProp, prop, source)
31
+ }
32
+
33
+ const blocks = source.blocks.map((block) =>
34
+ Object.freeze({ source, block }),
35
+ )
36
+ this.blocksBySource.set(source.path, blocks)
37
+ for (const indexed of blocks) {
38
+ this.blocks.add(indexed)
39
+ this.add(this.blocksByType, indexed.block.type, indexed)
40
+ for (const prop of Object.keys(indexed.block.data)) {
41
+ if (registeredProps.has(prop)) {
42
+ this.add(this.blocksByProp, prop, indexed)
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ delete(sourcePath: string) {
49
+ const source = this.sourcesByPath.get(sourcePath)
50
+ if (source) {
51
+ this.sources.delete(source)
52
+ this.sourcesByPath.delete(sourcePath)
53
+ this.remove(this.sourcesByType, source.type, source)
54
+ for (const prop of Object.keys(source.data)) {
55
+ this.remove(this.sourcesByProp, prop, source)
56
+ }
57
+ }
58
+ for (const indexed of this.blocksBySource.get(sourcePath) ?? []) {
59
+ this.blocks.delete(indexed)
60
+ this.remove(this.blocksByType, indexed.block.type, indexed)
61
+ for (const prop of Object.keys(indexed.block.data)) {
62
+ this.remove(this.blocksByProp, prop, indexed)
63
+ }
64
+ }
65
+ this.blocksBySource.delete(sourcePath)
66
+ }
67
+
68
+ sourceCandidates(selector: CandidateSelector) {
69
+ let candidates: Iterable<Source> = selector.type
70
+ ? (this.sourcesByType.get(selector.type) ?? [])
71
+ : this.sources
72
+ for (const prop of selector.has) {
73
+ const matching = this.sourcesByProp.get(prop) ?? new Set<Source>()
74
+ candidates = [...candidates].filter((source) => matching.has(source))
75
+ }
76
+ return candidates
77
+ }
78
+
79
+ blockCandidates(selector: CandidateSelector) {
80
+ let candidates: Iterable<IndexedBlock> = selector.type
81
+ ? (this.blocksByType.get(selector.type) ?? [])
82
+ : this.blocks
83
+ for (const prop of selector.has) {
84
+ const matching = this.blocksByProp.get(prop) ?? new Set<IndexedBlock>()
85
+ candidates = [...candidates].filter((block) => matching.has(block))
86
+ }
87
+ return candidates
88
+ }
89
+
90
+ private add<T>(map: Map<string, Set<T>>, key: string, value: T) {
91
+ const values = map.get(key) ?? new Set<T>()
92
+ values.add(value)
93
+ map.set(key, values)
94
+ }
95
+
96
+ private remove<T>(map: Map<string, Set<T>>, key: string, value: T) {
97
+ const values = map.get(key)
98
+ values?.delete(value)
99
+ if (values?.size === 0) map.delete(key)
100
+ }
101
+ }
@@ -0,0 +1,99 @@
1
+ export type QueryInvalidation = Readonly<{
2
+ key: string
3
+ actors?: readonly string[]
4
+ }>
5
+
6
+ function actorScope(actor: string) {
7
+ return `actor:${actor}`
8
+ }
9
+
10
+ function scopeActor(scope: string) {
11
+ return scope.startsWith("actor:") ? scope.slice("actor:".length) : undefined
12
+ }
13
+
14
+ /** Per-viewer dependency graph for composed and Plugin-derived queries. */
15
+ export class QueryDependencyIndex {
16
+ private byPlan = new Map<string, Map<string, Set<string>>>()
17
+ private byDependency = new Map<string, Map<string, Set<string>>>()
18
+
19
+ constructor(private maxScopesPerPlan: number) {}
20
+
21
+ replace(plan: string, scope: string, dependencies: Set<string>) {
22
+ const scopes = this.byPlan.get(plan) ?? new Map<string, Set<string>>()
23
+ const current = scopes.get(scope) ?? new Set<string>()
24
+ scopes.delete(scope)
25
+ scopes.set(scope, new Set(dependencies))
26
+ this.byPlan.set(plan, scopes)
27
+ for (const dependency of current) {
28
+ if (!dependencies.has(dependency)) this.unlink(dependency, plan, scope)
29
+ }
30
+ for (const dependency of dependencies) {
31
+ if (!current.has(dependency)) this.link(dependency, plan, scope)
32
+ }
33
+ while (scopes.size > this.maxScopesPerPlan) {
34
+ const oldest = scopes.keys().next().value as string | undefined
35
+ if (oldest === undefined) break
36
+ this.deleteScope(plan, oldest)
37
+ }
38
+ }
39
+
40
+ deleteScope(plan: string, scope: string) {
41
+ const scopes = this.byPlan.get(plan)
42
+ const removed = scopes?.get(scope)
43
+ if (!scopes || !removed) return
44
+ scopes.delete(scope)
45
+ for (const dependency of removed) this.unlink(dependency, plan, scope)
46
+ if (scopes.size === 0) this.byPlan.delete(plan)
47
+ }
48
+
49
+ clear(plan: string, actors?: readonly string[]) {
50
+ const scopes = actors
51
+ ? actors.map(actorScope)
52
+ : [...(this.byPlan.get(plan)?.keys() ?? [])]
53
+ for (const scope of scopes) this.deleteScope(plan, scope)
54
+ }
55
+
56
+ hasDependents(dependency: string) {
57
+ return this.byDependency.has(dependency)
58
+ }
59
+
60
+ dependentsOf(
61
+ dependency: string,
62
+ actors?: readonly string[],
63
+ ): QueryInvalidation[] {
64
+ const invalidations: QueryInvalidation[] = []
65
+ for (const [key, scopes] of this.byDependency.get(dependency) ?? []) {
66
+ if (actors) {
67
+ const affected = actors.filter((actor) => scopes.has(actorScope(actor)))
68
+ if (affected.length) invalidations.push({ key, actors: affected })
69
+ continue
70
+ }
71
+ if (scopes.has("anonymous")) {
72
+ invalidations.push({ key })
73
+ continue
74
+ }
75
+ const affected = [...scopes].flatMap((scope) => {
76
+ const actor = scopeActor(scope)
77
+ return actor ? [actor] : []
78
+ })
79
+ if (affected.length) invalidations.push({ key, actors: affected })
80
+ }
81
+ return invalidations
82
+ }
83
+
84
+ private link(dependency: string, plan: string, scope: string) {
85
+ const plans = this.byDependency.get(dependency) ?? new Map()
86
+ const scopes = plans.get(plan) ?? new Set<string>()
87
+ scopes.add(scope)
88
+ plans.set(plan, scopes)
89
+ this.byDependency.set(dependency, plans)
90
+ }
91
+
92
+ private unlink(dependency: string, plan: string, scope: string) {
93
+ const plans = this.byDependency.get(dependency)
94
+ const scopes = plans?.get(plan)
95
+ scopes?.delete(scope)
96
+ if (scopes?.size === 0) plans?.delete(plan)
97
+ if (plans?.size === 0) this.byDependency.delete(dependency)
98
+ }
99
+ }