@pathmx/core 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +172 -0
- package/README.md +111 -0
- package/actions/actions.ts +307 -0
- package/actions/draft.ts +105 -0
- package/actions/transaction.ts +164 -0
- package/assets.ts +149 -0
- package/authority/access.ts +820 -0
- package/authority/actors.ts +271 -0
- package/authority/agents.ts +95 -0
- package/authority/credentials.ts +144 -0
- package/authority/model.ts +71 -0
- package/authority/sharing.ts +89 -0
- package/browser-bundle.ts +61 -0
- package/canonical.ts +41 -0
- package/cli/dev.ts +134 -0
- package/cli/export.ts +74 -0
- package/cli/index.ts +52 -0
- package/cli/lint.ts +164 -0
- package/cli/mv.ts +30 -0
- package/cli/perms.ts +182 -0
- package/cli/plugin-packages.ts +159 -0
- package/cli/plugins.ts +95 -0
- package/cli/query.ts +225 -0
- package/cli/readme.md +95 -0
- package/cli/release.ts +75 -0
- package/cli/rm.ts +22 -0
- package/cli/serve.ts +100 -0
- package/cli/tree.ts +131 -0
- package/cli/update.ts +78 -0
- package/cli/utils.ts +53 -0
- package/core.ts +96 -0
- package/database.ts +189 -0
- package/dependencies.ts +60 -0
- package/dist/pathmx.js +1224 -0
- package/document.ts +194 -0
- package/environment.ts +110 -0
- package/environments/bun-plugins.ts +68 -0
- package/environments/bun-publish.ts +194 -0
- package/environments/bun.ts +223 -0
- package/globals.d.ts +14 -0
- package/graph.ts +153 -0
- package/host/compiler.ts +314 -0
- package/host/create.ts +66 -0
- package/host/engine.ts +630 -0
- package/host/plugin-host.ts +487 -0
- package/host/render.ts +102 -0
- package/host/response.ts +20 -0
- package/host/shell.html +15 -0
- package/host/source-sync.ts +254 -0
- package/html-attributes.ts +15 -0
- package/html.ts +48 -0
- package/icons/LICENSE +43 -0
- package/icons/lucide.ts +54 -0
- package/index.ts +75 -0
- package/invalidation.ts +85 -0
- package/log.ts +84 -0
- package/ops/href.ts +87 -0
- package/ops/index.ts +9 -0
- package/ops/move-source.ts +114 -0
- package/ops/plan.ts +47 -0
- package/ops/remove-source.ts +70 -0
- package/package.json +46 -0
- package/path.ts +75 -0
- package/pattern.ts +314 -0
- package/perf.ts +88 -0
- package/plugins/actor-path.ts +43 -0
- package/plugins/actor-profile.ts +55 -0
- package/plugins/agent-browsing.ts +183 -0
- package/plugins/app-component.ts +23 -0
- package/plugins/block-layout/index.ts +78 -0
- package/plugins/block-layout/theme.css +90 -0
- package/plugins/callout/index.ts +236 -0
- package/plugins/callout/theme.css +138 -0
- package/plugins/canonical-links.ts +144 -0
- package/plugins/code-highlight/client.ts +89 -0
- package/plugins/code-highlight/index.ts +147 -0
- package/plugins/code-highlight/theme.css +89 -0
- package/plugins/context.ts +131 -0
- package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
- package/plugins/datatype/OFL.txt +96 -0
- package/plugins/datatype/index.ts +287 -0
- package/plugins/definition-list.ts +236 -0
- package/plugins/directive-definitions.ts +13 -0
- package/plugins/embedding/client.ts +88 -0
- package/plugins/embedding/index.ts +43 -0
- package/plugins/image/index.ts +68 -0
- package/plugins/image/theme.css +43 -0
- package/plugins/include.ts +119 -0
- package/plugins/index.ts +91 -0
- package/plugins/input/authoring.ts +236 -0
- package/plugins/input/config.ts +85 -0
- package/plugins/input/index.ts +359 -0
- package/plugins/input/model.ts +173 -0
- package/plugins/input/state.ts +272 -0
- package/plugins/layout.ts +313 -0
- package/plugins/literate/client.ts +208 -0
- package/plugins/literate/index.ts +742 -0
- package/plugins/math/index.ts +140 -0
- package/plugins/meta.ts +42 -0
- package/plugins/navigation/index.ts +158 -0
- package/plugins/navigation/model.ts +364 -0
- package/plugins/preset.ts +65 -0
- package/plugins/query/index.ts +322 -0
- package/plugins/rss/index.ts +46 -0
- package/plugins/runtime/actions.ts +62 -0
- package/plugins/runtime/client.ts +8 -0
- package/plugins/runtime/document.ts +506 -0
- package/plugins/runtime/error.ts +154 -0
- package/plugins/runtime/heading-links.ts +31 -0
- package/plugins/runtime/index.ts +104 -0
- package/plugins/runtime/live.ts +51 -0
- package/plugins/runtime/navigation.ts +113 -0
- package/plugins/runtime/pending.ts +53 -0
- package/plugins/runtime/publication.ts +19 -0
- package/plugins/runtime/start.ts +177 -0
- package/plugins/runtime/static-client.ts +6 -0
- package/plugins/runtime/static-live.ts +188 -0
- package/plugins/runtime/view-transition.ts +72 -0
- package/plugins/runtime/viewport.ts +110 -0
- package/plugins/schema.ts +24 -0
- package/plugins/scripts.ts +140 -0
- package/plugins/source-preview/client.ts +256 -0
- package/plugins/source-preview/index.ts +132 -0
- package/plugins/source-preview/model.ts +11 -0
- package/plugins/source-preview/preview.css +107 -0
- package/plugins/styles.ts +145 -0
- package/plugins/theme/components.ts +10 -0
- package/plugins/theme/foundation.css +358 -0
- package/plugins/theme/index.ts +333 -0
- package/plugins/theme/prose.css +284 -0
- package/plugins/theme/scheme.js +63 -0
- package/plugins/theme/toggle.css +44 -0
- package/plugins/theme/view-transition.css +68 -0
- package/plugins/toc/client.ts +146 -0
- package/plugins/toc/index.ts +232 -0
- package/plugins/toc/theme.css +172 -0
- package/plugins/types.ts +538 -0
- package/problem.ts +41 -0
- package/project.ts +6 -0
- package/publish/build.ts +402 -0
- package/publish/concurrent.ts +32 -0
- package/publish/identity.ts +63 -0
- package/publish/index.ts +11 -0
- package/publish/location.ts +167 -0
- package/publish/model.ts +58 -0
- package/publish/plan.ts +300 -0
- package/publish/validate.ts +150 -0
- package/query/candidate-index.ts +101 -0
- package/query/dependency-index.ts +99 -0
- package/query/fields.ts +216 -0
- package/query/invalidation.ts +48 -0
- package/query/kernel.ts +776 -0
- package/query/model.ts +79 -0
- package/repository.ts +374 -0
- package/runtime-profile.ts +34 -0
- package/schema/model.ts +92 -0
- package/schema/registry.ts +642 -0
- package/schema/standard.ts +226 -0
- package/server/context.ts +94 -0
- package/server/css-imports.ts +66 -0
- package/server/http.ts +333 -0
- package/server/page.ts +21 -0
- package/server/protocols.ts +12 -0
- package/server/representations.ts +66 -0
- package/server/router.ts +131 -0
- package/server/routes.ts +202 -0
- package/server/serve.ts +5 -0
- package/server/watch.ts +120 -0
- package/source/blocks.ts +71 -0
- package/source/format.ts +164 -0
- package/source/index.ts +67 -0
- package/source/interpolate.ts +63 -0
- package/source/markdown/blocks.ts +46 -0
- package/source/markdown/comments.ts +32 -0
- package/source/markdown/fences.ts +67 -0
- package/source/markdown/headings.ts +88 -0
- package/source/markdown/index.ts +34 -0
- package/source/markdown/inline-code.ts +6 -0
- package/source/markdown/links.ts +326 -0
- package/source/markdown/lists.ts +223 -0
- package/source/markdown/prose.ts +74 -0
- package/source/markdown/scan.ts +79 -0
- package/source/markdown/text.ts +82 -0
- package/source/meta.ts +87 -0
- package/source/source.ts +121 -0
- package/source/topmatter.ts +57 -0
- package/source/utils.ts +21 -0
- package/source-diagnostic.ts +63 -0
- package/text-file.ts +26 -0
- package/util.ts +35 -0
- package/view/components.ts +499 -0
- package/view/model.ts +91 -0
- package/view/projection.ts +162 -0
- package/view/session.ts +146 -0
- package/view/syntax.ts +372 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { SourceTransaction } from "../../actions/transaction.ts"
|
|
2
|
+
import { canonicalSourceId } from "../../canonical.ts"
|
|
3
|
+
import type { PathMXYaml } from "../../environment.ts"
|
|
4
|
+
import { SourceDiagnosticError } from "../../source-diagnostic.ts"
|
|
5
|
+
import {
|
|
6
|
+
formatFrontmatter,
|
|
7
|
+
parseCodeFences,
|
|
8
|
+
splitTopmatter,
|
|
9
|
+
type Source,
|
|
10
|
+
} from "../../source/index.ts"
|
|
11
|
+
import type { UserActor } from "../../authority/model.ts"
|
|
12
|
+
import type { AppContext } from "../types.ts"
|
|
13
|
+
import { inputResponseConfig } from "./config.ts"
|
|
14
|
+
|
|
15
|
+
export type InputValue = string | string[]
|
|
16
|
+
export type InputValues = Record<string, InputValue>
|
|
17
|
+
|
|
18
|
+
export type InputStateRecord = Readonly<{
|
|
19
|
+
actorId: string
|
|
20
|
+
originId: string
|
|
21
|
+
source: Source
|
|
22
|
+
data: Record<string, unknown>
|
|
23
|
+
values: InputValues
|
|
24
|
+
submitted: boolean
|
|
25
|
+
submittedAt?: string
|
|
26
|
+
statusIssue?: string
|
|
27
|
+
}>
|
|
28
|
+
|
|
29
|
+
export type InputStateBinding =
|
|
30
|
+
| Readonly<{ type: "bound"; state: InputStateRecord }>
|
|
31
|
+
| Readonly<{ type: "actor-relative"; state?: InputStateRecord }>
|
|
32
|
+
|
|
33
|
+
function invalidState(source: Source, message: string) {
|
|
34
|
+
return new SourceDiagnosticError({
|
|
35
|
+
source: source.path,
|
|
36
|
+
plugin: "input",
|
|
37
|
+
message: `Invalid Input state in ${source.path}: ${message}`,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
42
|
+
return value != null && typeof value === "object" && !Array.isArray(value)
|
|
43
|
+
? (value as Record<string, unknown>)
|
|
44
|
+
: undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inputStateData(source: Source, yaml: PathMXYaml) {
|
|
48
|
+
if (source.type !== "state") return
|
|
49
|
+
const block = splitTopmatter(source.body, yaml)
|
|
50
|
+
if (block.data.plugin !== "input") return
|
|
51
|
+
const fence = parseCodeFences(block.body).find(
|
|
52
|
+
(candidate) =>
|
|
53
|
+
candidate.lang === "yaml" &&
|
|
54
|
+
candidate.info.split(/\s+/).includes("@state"),
|
|
55
|
+
)
|
|
56
|
+
if (!fence) return
|
|
57
|
+
const parsed = yaml.parse(fence.body)
|
|
58
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
59
|
+
throw invalidState(source, "@state must contain a YAML map.")
|
|
60
|
+
}
|
|
61
|
+
return parsed as Record<string, unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function submission(data: Record<string, unknown>) {
|
|
65
|
+
if (data.status == null) return { submitted: false }
|
|
66
|
+
const status = record(data.status)
|
|
67
|
+
if (!status) {
|
|
68
|
+
return { submitted: false, issue: "status must be a map." }
|
|
69
|
+
}
|
|
70
|
+
if (status.type !== "submitted") return { submitted: false }
|
|
71
|
+
if (typeof status.submitted_at !== "string" || !status.submitted_at.trim()) {
|
|
72
|
+
return {
|
|
73
|
+
submitted: false,
|
|
74
|
+
issue: "submitted status requires submitted_at.",
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const submittedAt = Date.parse(status.submitted_at)
|
|
78
|
+
if (!Number.isFinite(submittedAt)) {
|
|
79
|
+
return {
|
|
80
|
+
submitted: false,
|
|
81
|
+
issue: "submitted_at must be an ISO 8601 timestamp.",
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
submitted: true,
|
|
86
|
+
submittedAt: new Date(submittedAt).toISOString(),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function inputValues(source: Source, data: Record<string, unknown>) {
|
|
91
|
+
const raw = data.values
|
|
92
|
+
if (raw == null) return {}
|
|
93
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
94
|
+
throw invalidState(source, "values must be a map.")
|
|
95
|
+
}
|
|
96
|
+
const values: InputValues = {}
|
|
97
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
98
|
+
if (typeof value === "string") {
|
|
99
|
+
values[name] = value
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
Array.isArray(value) &&
|
|
104
|
+
value.every((item) => typeof item === "string")
|
|
105
|
+
) {
|
|
106
|
+
values[name] = [...new Set(value)]
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
throw invalidState(source, `${name} must be a string or string list.`)
|
|
110
|
+
}
|
|
111
|
+
return values
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseInputState(
|
|
115
|
+
source: Source,
|
|
116
|
+
app: AppContext,
|
|
117
|
+
): InputStateRecord | undefined {
|
|
118
|
+
const data = inputStateData(source, app.yaml)
|
|
119
|
+
if (!data) return
|
|
120
|
+
const originId =
|
|
121
|
+
typeof source.data.for === "string" ? source.data.for.trim() : ""
|
|
122
|
+
if (!originId) {
|
|
123
|
+
throw invalidState(source, "frontmatter for is required.")
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
canonicalSourceId(originId)
|
|
127
|
+
} catch {
|
|
128
|
+
throw invalidState(source, "frontmatter for must be a canonical Source id.")
|
|
129
|
+
}
|
|
130
|
+
const actorId = app.actors.homeActorId(source.id)
|
|
131
|
+
if (!actorId) {
|
|
132
|
+
throw invalidState(source, "state must be stored in an Actor Home.")
|
|
133
|
+
}
|
|
134
|
+
const status = submission(data)
|
|
135
|
+
return {
|
|
136
|
+
actorId,
|
|
137
|
+
originId,
|
|
138
|
+
source,
|
|
139
|
+
data,
|
|
140
|
+
values: inputValues(source, data),
|
|
141
|
+
submitted: status.submitted,
|
|
142
|
+
...(status.submittedAt ? { submittedAt: status.submittedAt } : {}),
|
|
143
|
+
...(status.issue ? { statusIssue: status.issue } : {}),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatInputState(
|
|
148
|
+
origin: Source,
|
|
149
|
+
values: InputValues,
|
|
150
|
+
previous: InputStateRecord | undefined,
|
|
151
|
+
yaml: PathMXYaml,
|
|
152
|
+
) {
|
|
153
|
+
const data = {
|
|
154
|
+
...previous?.data,
|
|
155
|
+
values,
|
|
156
|
+
status: { type: "submitted", submitted_at: new Date().toISOString() },
|
|
157
|
+
}
|
|
158
|
+
const frontmatter = {
|
|
159
|
+
...previous?.source.data,
|
|
160
|
+
type: "state",
|
|
161
|
+
for: origin.id,
|
|
162
|
+
}
|
|
163
|
+
return `${formatFrontmatter(frontmatter, yaml)}\n<!--\nplugin: input\n-->\n\n\`\`\`yaml @state\n${yaml.stringify(data).trimEnd()}\n\`\`\`\n`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export class InputStateIndex {
|
|
167
|
+
private statesByPath = new Map<string, InputStateRecord>()
|
|
168
|
+
private statesById = new Map<string, InputStateRecord>()
|
|
169
|
+
private statesByActorOrigin = new Map<string, InputStateRecord>()
|
|
170
|
+
private boundStateByOriginId = new Map<string, string>()
|
|
171
|
+
private boundOriginByPath = new Map<string, string>()
|
|
172
|
+
|
|
173
|
+
private key(actorId: string, originId: string) {
|
|
174
|
+
return `${actorId}\0${originId}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
index(source: Source, app: AppContext) {
|
|
178
|
+
const stateId = inputResponseConfig(source)?.state
|
|
179
|
+
const record = parseInputState(source, app)
|
|
180
|
+
if (record) {
|
|
181
|
+
const key = this.key(record.actorId, record.originId)
|
|
182
|
+
const existing = this.statesByActorOrigin.get(key)
|
|
183
|
+
if (existing && existing.source.path !== source.path) {
|
|
184
|
+
throw invalidState(
|
|
185
|
+
source,
|
|
186
|
+
`duplicate State for ${record.actorId} and ${record.originId}: ${existing.source.path} and ${source.path}.`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
this.statesByPath.set(source.path, record)
|
|
190
|
+
this.statesById.set(source.id, record)
|
|
191
|
+
this.statesByActorOrigin.set(key, record)
|
|
192
|
+
}
|
|
193
|
+
if (stateId) {
|
|
194
|
+
this.boundStateByOriginId.set(source.id, stateId)
|
|
195
|
+
this.boundOriginByPath.set(source.path, source.id)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
unindex(sourcePath: string) {
|
|
200
|
+
const originId = this.boundOriginByPath.get(sourcePath)
|
|
201
|
+
if (originId) this.boundStateByOriginId.delete(originId)
|
|
202
|
+
this.boundOriginByPath.delete(sourcePath)
|
|
203
|
+
const record = this.statesByPath.get(sourcePath)
|
|
204
|
+
if (!record) return
|
|
205
|
+
this.statesByPath.delete(sourcePath)
|
|
206
|
+
this.statesById.delete(record.source.id)
|
|
207
|
+
this.statesByActorOrigin.delete(this.key(record.actorId, record.originId))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private get(actorId: string, originId: string) {
|
|
211
|
+
return this.statesByActorOrigin.get(this.key(actorId, originId))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
records() {
|
|
215
|
+
return Object.freeze([...this.statesByPath.values()])
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
forActor(actorId: string, originId: string) {
|
|
219
|
+
const stateId = this.boundStateByOriginId.get(originId)
|
|
220
|
+
if (!stateId) return this.get(actorId, originId)
|
|
221
|
+
const state = this.statesById.get(stateId)
|
|
222
|
+
return state?.actorId === actorId && state.originId === originId
|
|
223
|
+
? state
|
|
224
|
+
: undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
resolve(origin: Source, actorId?: string): InputStateBinding {
|
|
228
|
+
const stateId = this.boundStateByOriginId.get(origin.id)
|
|
229
|
+
if (!stateId) {
|
|
230
|
+
return {
|
|
231
|
+
type: "actor-relative",
|
|
232
|
+
state: actorId ? this.get(actorId, origin.id) : undefined,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const state = this.statesById.get(stateId)
|
|
236
|
+
if (!state) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Invalid input-response config in ${origin.path}: bound State does not resolve: ${stateId}.`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (state.originId !== origin.id) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Invalid input-response config in ${origin.path}: ${stateId} points back to ${state.originId}, not ${origin.id}.`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
return { type: "bound", state }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
isBound(origin: Source, stateId: string) {
|
|
250
|
+
return this.boundStateByOriginId.get(origin.id) === stateId
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
transition(source: Source | undefined, app: AppContext) {
|
|
254
|
+
return source ? parseInputState(source, app) : undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async write(
|
|
258
|
+
tx: SourceTransaction,
|
|
259
|
+
actor: UserActor,
|
|
260
|
+
origin: Source,
|
|
261
|
+
values: InputValues,
|
|
262
|
+
yaml: PathMXYaml,
|
|
263
|
+
) {
|
|
264
|
+
const current = this.resolve(origin, actor.id).state
|
|
265
|
+
const content = formatInputState(origin, values, current, yaml)
|
|
266
|
+
if (current) {
|
|
267
|
+
await tx.update(current.source.id, content)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
await tx.create(`${actor.home}/input${origin.id}.state`, content)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { dependencyKey, type Dependency } from "../dependencies.ts"
|
|
2
|
+
import { isIndexSource } from "../server/router.ts"
|
|
3
|
+
import { type Source, type SourceLink } from "../source/index.ts"
|
|
4
|
+
import {
|
|
5
|
+
isExactMarkdownSourceHref,
|
|
6
|
+
parseLinkFragments,
|
|
7
|
+
} from "../source/markdown/links.ts"
|
|
8
|
+
import { scanMarkdownStructure } from "../source/markdown/scan.ts"
|
|
9
|
+
import { SourceDiagnosticError } from "../source-diagnostic.ts"
|
|
10
|
+
import type { AppContext, DocumentAsset, PageContext, Plugin } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
const LAYOUT_TYPE = "layout"
|
|
13
|
+
|
|
14
|
+
// TODO: replace this with a view component?
|
|
15
|
+
const OUTLET_OPEN = /<pmx-content\b/gi
|
|
16
|
+
const OUTLET = /<pmx-content\s*(?:\/\s*>|>\s*<\/pmx-content\s*>)/gi
|
|
17
|
+
|
|
18
|
+
export const layoutDefaultDependency: Dependency = Object.freeze({
|
|
19
|
+
type: "plugin",
|
|
20
|
+
plugin: "layout",
|
|
21
|
+
key: "default",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function layoutTypeDependency(type: string): Dependency {
|
|
25
|
+
return { type: "plugin", plugin: "layout", key: `type:${type}` }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type LayoutRelations = Readonly<{
|
|
29
|
+
local?: SourceLink
|
|
30
|
+
defaults: ReadonlyMap<string, SourceLink>
|
|
31
|
+
}>
|
|
32
|
+
|
|
33
|
+
type CompiledLayout = Readonly<{
|
|
34
|
+
before: string
|
|
35
|
+
after: string
|
|
36
|
+
assets: readonly DocumentAsset[]
|
|
37
|
+
dependencies: readonly Dependency[]
|
|
38
|
+
}>
|
|
39
|
+
|
|
40
|
+
function layoutError(source: Source, message: string): never {
|
|
41
|
+
throw new SourceDiagnosticError({
|
|
42
|
+
source: source.path,
|
|
43
|
+
plugin: "layout",
|
|
44
|
+
message: `Invalid layout in ${source.path}: ${message}`,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function relationLinks(source: Source, indexed: boolean) {
|
|
49
|
+
if (indexed && source.blocks.length) {
|
|
50
|
+
return source.blocks.flatMap((block) => block.links)
|
|
51
|
+
}
|
|
52
|
+
if (indexed && source.links.length) return source.links
|
|
53
|
+
return parseLinkFragments(scanMarkdownStructure(source.body).linkFragments)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readRelations(source: Source, indexed = true): LayoutRelations {
|
|
57
|
+
if (!source.body.includes("@layout")) return { defaults: new Map() }
|
|
58
|
+
const local: SourceLink[] = []
|
|
59
|
+
const defaults = new Map<string, SourceLink>()
|
|
60
|
+
for (const link of relationLinks(source, indexed)) {
|
|
61
|
+
if (link.directive?.name !== "layout") continue
|
|
62
|
+
if (link.kind !== "definition") {
|
|
63
|
+
layoutError(source, `@layout must use a reference definition.`)
|
|
64
|
+
}
|
|
65
|
+
const qualifiers = link.directive.qualifiers
|
|
66
|
+
if (!qualifiers.length) local.push(link)
|
|
67
|
+
else if (qualifiers.length === 1) {
|
|
68
|
+
const type = qualifiers[0]!
|
|
69
|
+
if (defaults.has(type)) {
|
|
70
|
+
layoutError(source, `multiple @layout.${type} definitions.`)
|
|
71
|
+
}
|
|
72
|
+
defaults.set(type, link)
|
|
73
|
+
} else {
|
|
74
|
+
layoutError(
|
|
75
|
+
source,
|
|
76
|
+
`@layout accepts one source type qualifier, received ${qualifiers.join(".")}.`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (local.length > 1) layoutError(source, `multiple @layout definitions.`)
|
|
81
|
+
return { local: local[0], defaults }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function defaultSignatures(source: Source | undefined, indexed: boolean) {
|
|
85
|
+
if (!source) return new Map<string, string>()
|
|
86
|
+
return new Map(
|
|
87
|
+
[...readRelations(source, indexed).defaults].map(([type, link]) => [
|
|
88
|
+
type,
|
|
89
|
+
link.href,
|
|
90
|
+
]),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function changedDefaultDependencies(
|
|
95
|
+
before: Source | undefined,
|
|
96
|
+
after: Source | undefined,
|
|
97
|
+
) {
|
|
98
|
+
const previous = defaultSignatures(before, true)
|
|
99
|
+
const next = defaultSignatures(after, false)
|
|
100
|
+
const changed: Dependency[] = []
|
|
101
|
+
for (const type of new Set([...previous.keys(), ...next.keys()])) {
|
|
102
|
+
if (previous.get(type) === next.get(type)) continue
|
|
103
|
+
changed.push(
|
|
104
|
+
type === "default" ? layoutDefaultDependency : layoutTypeDependency(type),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return changed
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function exactMarkdownTarget(source: Source, link: SourceLink) {
|
|
111
|
+
const href = link.href.trim()
|
|
112
|
+
if (!isExactMarkdownSourceHref(href)) {
|
|
113
|
+
layoutError(
|
|
114
|
+
source,
|
|
115
|
+
`@layout must target an exact Markdown Source, received ${href}.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return href
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveLayout(
|
|
122
|
+
relationSource: Source,
|
|
123
|
+
consumer: Source,
|
|
124
|
+
link: SourceLink,
|
|
125
|
+
app: AppContext,
|
|
126
|
+
) {
|
|
127
|
+
const href = exactMarkdownTarget(relationSource, link)
|
|
128
|
+
const edge = app.graph
|
|
129
|
+
.linksFrom(relationSource.path)
|
|
130
|
+
.find((candidate) => candidate.href === href && candidate.type === "source")
|
|
131
|
+
|
|
132
|
+
const target = edge?.to ? app.sources.get(edge.to) : undefined
|
|
133
|
+
if (!target) {
|
|
134
|
+
layoutError(relationSource, `layout Source not found: ${href}.`)
|
|
135
|
+
}
|
|
136
|
+
if (target.type === "components") {
|
|
137
|
+
app.log.warn("legacy-components", {
|
|
138
|
+
source: relationSource.path,
|
|
139
|
+
target: target.path,
|
|
140
|
+
detail: "Use an authored .layout.md Source for site layout.",
|
|
141
|
+
})
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (target.type !== LAYOUT_TYPE) {
|
|
145
|
+
layoutError(
|
|
146
|
+
relationSource,
|
|
147
|
+
`${href} is a ${target.type} Source, not a layout.`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
if (!app.access.canEmbed(consumer.id, target.id)) {
|
|
151
|
+
layoutError(consumer, `cannot embed layout ${target.path}.`)
|
|
152
|
+
}
|
|
153
|
+
return target
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function splitOutlet(source: Source, html: string) {
|
|
157
|
+
const openings = [...html.matchAll(OUTLET_OPEN)]
|
|
158
|
+
const outlets = [...html.matchAll(OUTLET)]
|
|
159
|
+
if (openings.length !== 1 || outlets.length !== 1) {
|
|
160
|
+
layoutError(
|
|
161
|
+
source,
|
|
162
|
+
`expected exactly one empty <pmx-content />, found ${openings.length}.`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
const outlet = outlets[0]!
|
|
166
|
+
const start = outlet.index!
|
|
167
|
+
return {
|
|
168
|
+
before: html.slice(0, start),
|
|
169
|
+
after: html.slice(start + outlet[0].length),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function dependencyRecorder() {
|
|
174
|
+
const values: Dependency[] = []
|
|
175
|
+
const seen = new Set<string>()
|
|
176
|
+
return {
|
|
177
|
+
values,
|
|
178
|
+
depend(dependency: Dependency) {
|
|
179
|
+
const key = dependencyKey(dependency)
|
|
180
|
+
if (seen.has(key)) return
|
|
181
|
+
seen.add(key)
|
|
182
|
+
values.push(dependency)
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Authored layout selection and composition over the normal plugin pipeline. */
|
|
188
|
+
export function LayoutPlugin(): Plugin {
|
|
189
|
+
const defaults = new Map<
|
|
190
|
+
string,
|
|
191
|
+
Readonly<{ source: Source; links: ReadonlyMap<string, SourceLink> }>
|
|
192
|
+
>()
|
|
193
|
+
const compiled = new Map<string, Promise<CompiledLayout>>()
|
|
194
|
+
|
|
195
|
+
const compile = (source: Source, app: AppContext) => {
|
|
196
|
+
if (source.type !== LAYOUT_TYPE) {
|
|
197
|
+
layoutError(source, `expected a ${LAYOUT_TYPE} Source.`)
|
|
198
|
+
}
|
|
199
|
+
let pending = compiled.get(source.path)
|
|
200
|
+
if (pending) return pending
|
|
201
|
+
pending = (async () => {
|
|
202
|
+
const dependencies = dependencyRecorder()
|
|
203
|
+
const template = await app.compile.document(source, dependencies)
|
|
204
|
+
return {
|
|
205
|
+
...splitOutlet(source, template.html),
|
|
206
|
+
assets: template.assets,
|
|
207
|
+
dependencies: dependencies.values,
|
|
208
|
+
}
|
|
209
|
+
})()
|
|
210
|
+
compiled.set(source.path, pending)
|
|
211
|
+
return pending
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const selectedDefaults = (source: Source) => {
|
|
215
|
+
if (defaults.size > 1) {
|
|
216
|
+
layoutError(source, `multiple root-owned layout configurations.`)
|
|
217
|
+
}
|
|
218
|
+
const first = defaults.values().next()
|
|
219
|
+
return first.done ? undefined : first.value
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const layoutChain = (source: Source, ctx: PageContext, app: AppContext) => {
|
|
223
|
+
if (source.data.layout === false) return []
|
|
224
|
+
const local = readRelations(source).local
|
|
225
|
+
let relation = local
|
|
226
|
+
let relationSource = source
|
|
227
|
+
if (!relation) {
|
|
228
|
+
const selected = selectedDefaults(source)
|
|
229
|
+
const typedDependency = layoutTypeDependency(source.type)
|
|
230
|
+
ctx.depend(typedDependency)
|
|
231
|
+
relation = selected?.links.get(source.type)
|
|
232
|
+
if (!relation) {
|
|
233
|
+
ctx.depend(layoutDefaultDependency)
|
|
234
|
+
relation = selected?.links.get("default")
|
|
235
|
+
}
|
|
236
|
+
relationSource = selected?.source ?? source
|
|
237
|
+
}
|
|
238
|
+
if (!relation) return []
|
|
239
|
+
|
|
240
|
+
const chain: Source[] = []
|
|
241
|
+
const seen = new Set([source.path])
|
|
242
|
+
let consumer = source
|
|
243
|
+
while (relation) {
|
|
244
|
+
const layout = resolveLayout(relationSource, consumer, relation, app)
|
|
245
|
+
if (!layout) break
|
|
246
|
+
if (seen.has(layout.path)) {
|
|
247
|
+
layoutError(
|
|
248
|
+
source,
|
|
249
|
+
`layout cycle: ${[...seen, layout.path].join(" -> ")}.`,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
seen.add(layout.path)
|
|
253
|
+
chain.push(layout)
|
|
254
|
+
ctx.depend({ type: "source", path: layout.path })
|
|
255
|
+
consumer = layout
|
|
256
|
+
relationSource = layout
|
|
257
|
+
relation =
|
|
258
|
+
layout.data.layout === false ? undefined : readRelations(layout).local
|
|
259
|
+
}
|
|
260
|
+
return chain
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
id: "layout",
|
|
265
|
+
schema: {
|
|
266
|
+
sources: {
|
|
267
|
+
layout: {
|
|
268
|
+
label: "Layout",
|
|
269
|
+
description: "Authored page chrome with one content outlet.",
|
|
270
|
+
icon: "panels-top-left",
|
|
271
|
+
color: "violet",
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
index(source, app) {
|
|
277
|
+
const relations = readRelations(source)
|
|
278
|
+
defaults.delete(source.path)
|
|
279
|
+
if (!relations.defaults.size) return
|
|
280
|
+
const root = app.sources.resolve("/")
|
|
281
|
+
if (!isIndexSource(source) || root?.path !== source.path) {
|
|
282
|
+
const qualifier = relations.defaults.keys().next().value
|
|
283
|
+
layoutError(
|
|
284
|
+
source,
|
|
285
|
+
`@layout.${qualifier} is owned by the root route Source only.`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
defaults.set(source.path, { source, links: relations.defaults })
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
unindex(sourcePath) {
|
|
292
|
+
defaults.delete(sourcePath)
|
|
293
|
+
compiled.clear()
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
invalidate({ before, after }) {
|
|
297
|
+
compiled.clear()
|
|
298
|
+
const dependencies = changedDefaultDependencies(before, after)
|
|
299
|
+
if (dependencies.length) return { dependencies }
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
async page(html, ctx, app) {
|
|
303
|
+
const chain = layoutChain(ctx.source, ctx, app)
|
|
304
|
+
for (const source of chain) {
|
|
305
|
+
const template = await compile(source, app)
|
|
306
|
+
for (const dependency of template.dependencies) ctx.depend(dependency)
|
|
307
|
+
ctx.addAsset(...template.assets)
|
|
308
|
+
html = `${template.before}${html}${template.after}`
|
|
309
|
+
}
|
|
310
|
+
return html
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|