@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
package/publish/model.ts
ADDED
|
@@ -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
|
+
}>
|
package/publish/plan.ts
ADDED
|
@@ -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
|
+
}
|