@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,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
+ }