@pathmx/core 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/LICENSE.md +172 -0
  2. package/README.md +111 -0
  3. package/actions/actions.ts +307 -0
  4. package/actions/draft.ts +105 -0
  5. package/actions/transaction.ts +164 -0
  6. package/assets.ts +149 -0
  7. package/authority/access.ts +820 -0
  8. package/authority/actors.ts +271 -0
  9. package/authority/agents.ts +95 -0
  10. package/authority/credentials.ts +144 -0
  11. package/authority/model.ts +71 -0
  12. package/authority/sharing.ts +89 -0
  13. package/browser-bundle.ts +61 -0
  14. package/canonical.ts +41 -0
  15. package/cli/dev.ts +134 -0
  16. package/cli/export.ts +74 -0
  17. package/cli/index.ts +52 -0
  18. package/cli/lint.ts +164 -0
  19. package/cli/mv.ts +30 -0
  20. package/cli/perms.ts +182 -0
  21. package/cli/plugin-packages.ts +159 -0
  22. package/cli/plugins.ts +95 -0
  23. package/cli/query.ts +225 -0
  24. package/cli/readme.md +95 -0
  25. package/cli/release.ts +75 -0
  26. package/cli/rm.ts +22 -0
  27. package/cli/serve.ts +100 -0
  28. package/cli/tree.ts +131 -0
  29. package/cli/update.ts +78 -0
  30. package/cli/utils.ts +53 -0
  31. package/core.ts +96 -0
  32. package/database.ts +189 -0
  33. package/dependencies.ts +60 -0
  34. package/dist/pathmx.js +1224 -0
  35. package/document.ts +194 -0
  36. package/environment.ts +110 -0
  37. package/environments/bun-plugins.ts +68 -0
  38. package/environments/bun-publish.ts +194 -0
  39. package/environments/bun.ts +223 -0
  40. package/globals.d.ts +14 -0
  41. package/graph.ts +153 -0
  42. package/host/compiler.ts +314 -0
  43. package/host/create.ts +66 -0
  44. package/host/engine.ts +630 -0
  45. package/host/plugin-host.ts +487 -0
  46. package/host/render.ts +102 -0
  47. package/host/response.ts +20 -0
  48. package/host/shell.html +15 -0
  49. package/host/source-sync.ts +254 -0
  50. package/html-attributes.ts +15 -0
  51. package/html.ts +48 -0
  52. package/icons/LICENSE +43 -0
  53. package/icons/lucide.ts +54 -0
  54. package/index.ts +75 -0
  55. package/invalidation.ts +85 -0
  56. package/log.ts +84 -0
  57. package/ops/href.ts +87 -0
  58. package/ops/index.ts +9 -0
  59. package/ops/move-source.ts +114 -0
  60. package/ops/plan.ts +47 -0
  61. package/ops/remove-source.ts +70 -0
  62. package/package.json +46 -0
  63. package/path.ts +75 -0
  64. package/pattern.ts +314 -0
  65. package/perf.ts +88 -0
  66. package/plugins/actor-path.ts +43 -0
  67. package/plugins/actor-profile.ts +55 -0
  68. package/plugins/agent-browsing.ts +183 -0
  69. package/plugins/app-component.ts +23 -0
  70. package/plugins/block-layout/index.ts +78 -0
  71. package/plugins/block-layout/theme.css +90 -0
  72. package/plugins/callout/index.ts +236 -0
  73. package/plugins/callout/theme.css +138 -0
  74. package/plugins/canonical-links.ts +144 -0
  75. package/plugins/code-highlight/client.ts +89 -0
  76. package/plugins/code-highlight/index.ts +147 -0
  77. package/plugins/code-highlight/theme.css +89 -0
  78. package/plugins/context.ts +131 -0
  79. package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
  80. package/plugins/datatype/OFL.txt +96 -0
  81. package/plugins/datatype/index.ts +287 -0
  82. package/plugins/definition-list.ts +236 -0
  83. package/plugins/directive-definitions.ts +13 -0
  84. package/plugins/embedding/client.ts +88 -0
  85. package/plugins/embedding/index.ts +43 -0
  86. package/plugins/image/index.ts +68 -0
  87. package/plugins/image/theme.css +43 -0
  88. package/plugins/include.ts +119 -0
  89. package/plugins/index.ts +91 -0
  90. package/plugins/input/authoring.ts +236 -0
  91. package/plugins/input/config.ts +85 -0
  92. package/plugins/input/index.ts +359 -0
  93. package/plugins/input/model.ts +173 -0
  94. package/plugins/input/state.ts +272 -0
  95. package/plugins/layout.ts +313 -0
  96. package/plugins/literate/client.ts +208 -0
  97. package/plugins/literate/index.ts +742 -0
  98. package/plugins/math/index.ts +140 -0
  99. package/plugins/meta.ts +42 -0
  100. package/plugins/navigation/index.ts +158 -0
  101. package/plugins/navigation/model.ts +364 -0
  102. package/plugins/preset.ts +65 -0
  103. package/plugins/query/index.ts +322 -0
  104. package/plugins/rss/index.ts +46 -0
  105. package/plugins/runtime/actions.ts +62 -0
  106. package/plugins/runtime/client.ts +8 -0
  107. package/plugins/runtime/document.ts +506 -0
  108. package/plugins/runtime/error.ts +154 -0
  109. package/plugins/runtime/heading-links.ts +31 -0
  110. package/plugins/runtime/index.ts +104 -0
  111. package/plugins/runtime/live.ts +51 -0
  112. package/plugins/runtime/navigation.ts +113 -0
  113. package/plugins/runtime/pending.ts +53 -0
  114. package/plugins/runtime/publication.ts +19 -0
  115. package/plugins/runtime/start.ts +177 -0
  116. package/plugins/runtime/static-client.ts +6 -0
  117. package/plugins/runtime/static-live.ts +188 -0
  118. package/plugins/runtime/view-transition.ts +72 -0
  119. package/plugins/runtime/viewport.ts +110 -0
  120. package/plugins/schema.ts +24 -0
  121. package/plugins/scripts.ts +140 -0
  122. package/plugins/source-preview/client.ts +256 -0
  123. package/plugins/source-preview/index.ts +132 -0
  124. package/plugins/source-preview/model.ts +11 -0
  125. package/plugins/source-preview/preview.css +107 -0
  126. package/plugins/styles.ts +145 -0
  127. package/plugins/theme/components.ts +10 -0
  128. package/plugins/theme/foundation.css +358 -0
  129. package/plugins/theme/index.ts +333 -0
  130. package/plugins/theme/prose.css +284 -0
  131. package/plugins/theme/scheme.js +63 -0
  132. package/plugins/theme/toggle.css +44 -0
  133. package/plugins/theme/view-transition.css +68 -0
  134. package/plugins/toc/client.ts +146 -0
  135. package/plugins/toc/index.ts +232 -0
  136. package/plugins/toc/theme.css +172 -0
  137. package/plugins/types.ts +538 -0
  138. package/problem.ts +41 -0
  139. package/project.ts +6 -0
  140. package/publish/build.ts +402 -0
  141. package/publish/concurrent.ts +32 -0
  142. package/publish/identity.ts +63 -0
  143. package/publish/index.ts +11 -0
  144. package/publish/location.ts +167 -0
  145. package/publish/model.ts +58 -0
  146. package/publish/plan.ts +300 -0
  147. package/publish/validate.ts +150 -0
  148. package/query/candidate-index.ts +101 -0
  149. package/query/dependency-index.ts +99 -0
  150. package/query/fields.ts +216 -0
  151. package/query/invalidation.ts +48 -0
  152. package/query/kernel.ts +776 -0
  153. package/query/model.ts +79 -0
  154. package/repository.ts +374 -0
  155. package/runtime-profile.ts +34 -0
  156. package/schema/model.ts +92 -0
  157. package/schema/registry.ts +642 -0
  158. package/schema/standard.ts +226 -0
  159. package/server/context.ts +94 -0
  160. package/server/css-imports.ts +66 -0
  161. package/server/http.ts +333 -0
  162. package/server/page.ts +21 -0
  163. package/server/protocols.ts +12 -0
  164. package/server/representations.ts +66 -0
  165. package/server/router.ts +131 -0
  166. package/server/routes.ts +202 -0
  167. package/server/serve.ts +5 -0
  168. package/server/watch.ts +120 -0
  169. package/source/blocks.ts +71 -0
  170. package/source/format.ts +164 -0
  171. package/source/index.ts +67 -0
  172. package/source/interpolate.ts +63 -0
  173. package/source/markdown/blocks.ts +46 -0
  174. package/source/markdown/comments.ts +32 -0
  175. package/source/markdown/fences.ts +67 -0
  176. package/source/markdown/headings.ts +88 -0
  177. package/source/markdown/index.ts +34 -0
  178. package/source/markdown/inline-code.ts +6 -0
  179. package/source/markdown/links.ts +326 -0
  180. package/source/markdown/lists.ts +223 -0
  181. package/source/markdown/prose.ts +74 -0
  182. package/source/markdown/scan.ts +79 -0
  183. package/source/markdown/text.ts +82 -0
  184. package/source/meta.ts +87 -0
  185. package/source/source.ts +121 -0
  186. package/source/topmatter.ts +57 -0
  187. package/source/utils.ts +21 -0
  188. package/source-diagnostic.ts +63 -0
  189. package/text-file.ts +26 -0
  190. package/util.ts +35 -0
  191. package/view/components.ts +499 -0
  192. package/view/model.ts +91 -0
  193. package/view/projection.ts +162 -0
  194. package/view/session.ts +146 -0
  195. package/view/syntax.ts +372 -0
package/pattern.ts ADDED
@@ -0,0 +1,314 @@
1
+ import type { PathMXEnvironment } from "./environment.ts"
2
+ import type { StandardSchemaV1 } from "./schema/standard.ts"
3
+ import { formatSchemaIssues } from "./schema/standard.ts"
4
+ import {
5
+ readTextFileState,
6
+ sameTextFileState,
7
+ type TextFileState,
8
+ } from "./text-file.ts"
9
+
10
+ type Awaitable<T> = T | Promise<T>
11
+
12
+ export type PatternRoot = "paths" | "project"
13
+
14
+ export type PatternFile = Readonly<{
15
+ root: PatternRoot
16
+ path: string
17
+ content: string
18
+ }>
19
+
20
+ export type Pattern<Input = undefined> = Readonly<{
21
+ id: string
22
+ description?: string
23
+ input?: StandardSchemaV1<unknown, Input>
24
+ generate(input: Input): Awaitable<readonly PatternFile[]>
25
+ }>
26
+
27
+ export type PatternConflictPolicy = "error" | "preserve" | "overwrite"
28
+
29
+ export type PatternInstallOptions = Readonly<{
30
+ conflicts?: PatternConflictPolicy
31
+ }>
32
+
33
+ export type PatternFileStatus = "create" | "unchanged" | "conflict" | "replace"
34
+
35
+ export type PatternPlanFile = PatternFile &
36
+ Readonly<{
37
+ destination: string
38
+ status: PatternFileStatus
39
+ before: TextFileState
40
+ }>
41
+
42
+ export type PatternPlan = Readonly<{
43
+ pattern: string
44
+ conflicts: PatternConflictPolicy
45
+ files: readonly PatternPlanFile[]
46
+ }>
47
+
48
+ export type PatternRoots = Readonly<{
49
+ paths: string
50
+ project: string
51
+ }>
52
+
53
+ export type PatternInstaller = Readonly<{
54
+ plan<Input>(
55
+ pattern: Pattern<Input>,
56
+ input?: unknown,
57
+ options?: PatternInstallOptions,
58
+ ): Promise<PatternPlan>
59
+ apply(plan: PatternPlan): Promise<PatternPlan>
60
+ install<Input>(
61
+ pattern: Pattern<Input>,
62
+ input?: unknown,
63
+ options?: PatternInstallOptions,
64
+ ): Promise<PatternPlan>
65
+ }>
66
+
67
+ export class PatternError extends Error {
68
+ constructor(message: string) {
69
+ super(message)
70
+ this.name = "PatternError"
71
+ }
72
+ }
73
+
74
+ const PATTERN_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*(?::[a-z0-9]+(?:-[a-z0-9]+)*)?$/
75
+
76
+ function canonicalPatternId(id: string) {
77
+ if (!PATTERN_ID.test(id)) throw new PatternError(`Invalid Pattern id: ${id}`)
78
+ return id
79
+ }
80
+
81
+ /** Preserve input inference for install-time project Patterns. */
82
+ export function definePattern<Input = undefined>(pattern: Pattern<Input>) {
83
+ return pattern
84
+ }
85
+
86
+ async function validateInput<Input>(pattern: Pattern<Input>, input: unknown) {
87
+ if (!pattern.input) {
88
+ if (input !== undefined) {
89
+ throw new PatternError(`Pattern ${pattern.id} does not accept input.`)
90
+ }
91
+ return undefined as Input
92
+ }
93
+ const result = await pattern.input["~standard"].validate(input)
94
+ if (result.issues) {
95
+ throw new PatternError(
96
+ `Invalid input for Pattern ${pattern.id}: ${formatSchemaIssues(result.issues)}`,
97
+ )
98
+ }
99
+ return result.value
100
+ }
101
+
102
+ function normalizedFile(
103
+ environment: Pick<PathMXEnvironment, "path">,
104
+ roots: PatternRoots,
105
+ file: PatternFile,
106
+ ) {
107
+ if (file.root !== "paths" && file.root !== "project") {
108
+ throw new PatternError(`Invalid Pattern root: ${String(file.root)}`)
109
+ }
110
+ if (typeof file.content !== "string") {
111
+ throw new PatternError(`Pattern file content must be text: ${file.path}`)
112
+ }
113
+ const raw = file.path.replaceAll("\\", "/")
114
+ if (
115
+ !raw ||
116
+ raw.startsWith("/") ||
117
+ /^[a-zA-Z]:\//.test(raw) ||
118
+ raw.split("/").includes("..")
119
+ ) {
120
+ throw new PatternError(
121
+ `Pattern path escapes its ${file.root} root: ${file.path}`,
122
+ )
123
+ }
124
+ const path = environment.path.normalize(raw)
125
+ if (!path || path === ".") {
126
+ throw new PatternError(`Invalid Pattern path: ${file.path}`)
127
+ }
128
+ const root = roots[file.root]
129
+ const destination = environment.path.join(root, path)
130
+ const relative = environment.path.relative(root, destination)
131
+ if (!relative || relative === ".." || relative.startsWith("../")) {
132
+ throw new PatternError(
133
+ `Pattern path escapes its ${file.root} root: ${file.path}`,
134
+ )
135
+ }
136
+ return Object.freeze({ ...file, path, destination })
137
+ }
138
+
139
+ function status(
140
+ before: TextFileState,
141
+ content: string,
142
+ conflicts: PatternConflictPolicy,
143
+ ): PatternFileStatus {
144
+ if (before.type === "missing") return "create"
145
+ if (before.content === content) return "unchanged"
146
+ return conflicts === "overwrite" ? "replace" : "conflict"
147
+ }
148
+
149
+ function conflictPolicy(conflicts: PatternConflictPolicy = "error") {
150
+ if (
151
+ conflicts !== "error" &&
152
+ conflicts !== "preserve" &&
153
+ conflicts !== "overwrite"
154
+ ) {
155
+ throw new PatternError(
156
+ `Invalid Pattern conflict policy: ${String(conflicts)}`,
157
+ )
158
+ }
159
+ return conflicts
160
+ }
161
+
162
+ async function rollback(
163
+ storage: PathMXEnvironment["storage"],
164
+ attempted: readonly PatternPlanFile[],
165
+ ) {
166
+ const errors: unknown[] = []
167
+ for (const file of [...attempted].reverse()) {
168
+ try {
169
+ const current = await readTextFileState(storage, file.destination)
170
+ if (sameTextFileState(current, file.before)) continue
171
+ if (current.type !== "present" || current.content !== file.content) {
172
+ throw new PatternError(
173
+ `Pattern destination changed during rollback: ${file.destination}`,
174
+ )
175
+ }
176
+ if (file.before.type === "missing") {
177
+ await storage.delete(file.destination)
178
+ } else {
179
+ await storage.writeText(file.destination, file.before.content)
180
+ }
181
+ } catch (error) {
182
+ errors.push(error)
183
+ }
184
+ }
185
+ return errors
186
+ }
187
+
188
+ export function createPatternInstaller(
189
+ environment: Pick<PathMXEnvironment, "path" | "storage">,
190
+ roots: PatternRoots,
191
+ ): PatternInstaller {
192
+ const installRoots = Object.freeze({ ...roots })
193
+ const resolve = (file: PatternFile) =>
194
+ normalizedFile(environment, installRoots, file)
195
+
196
+ const plan: PatternInstaller["plan"] = async (pattern, input, options) => {
197
+ const id = canonicalPatternId(pattern.id)
198
+ const conflicts = conflictPolicy(options?.conflicts)
199
+ const generated = await pattern.generate(
200
+ await validateInput(pattern, input),
201
+ )
202
+ const resolved = generated.map(resolve)
203
+ const destinations = new Set<string>()
204
+ for (const file of resolved) {
205
+ if (destinations.has(file.destination)) {
206
+ throw new PatternError(
207
+ `Duplicate Pattern destination: ${file.destination}`,
208
+ )
209
+ }
210
+ destinations.add(file.destination)
211
+ }
212
+ const files = await Promise.all(
213
+ resolved.map(async (file): Promise<PatternPlanFile> => {
214
+ const before = await readTextFileState(
215
+ environment.storage,
216
+ file.destination,
217
+ )
218
+ return Object.freeze({
219
+ ...file,
220
+ before,
221
+ status: status(before, file.content, conflicts),
222
+ })
223
+ }),
224
+ )
225
+ return Object.freeze({
226
+ pattern: id,
227
+ conflicts,
228
+ files: Object.freeze(files),
229
+ })
230
+ }
231
+
232
+ const apply: PatternInstaller["apply"] = async (patternPlan) => {
233
+ canonicalPatternId(patternPlan.pattern)
234
+ conflictPolicy(patternPlan.conflicts)
235
+ for (const file of patternPlan.files) {
236
+ const expected = status(file.before, file.content, patternPlan.conflicts)
237
+ if (file.status !== expected) {
238
+ throw new PatternError(`Invalid Pattern plan: ${file.destination}`)
239
+ }
240
+ }
241
+ const conflicts = patternPlan.files.filter(
242
+ (file) => file.status === "conflict",
243
+ )
244
+ if (patternPlan.conflicts === "error" && conflicts.length) {
245
+ throw new PatternError(
246
+ `Pattern ${patternPlan.pattern} conflicts with existing files: ${conflicts
247
+ .map((file) => file.destination)
248
+ .join(", ")}`,
249
+ )
250
+ }
251
+ const writes = patternPlan.files.filter(
252
+ (file) => file.status === "create" || file.status === "replace",
253
+ )
254
+ const resolved = writes.map((file) => {
255
+ const current = resolve({
256
+ root: file.root,
257
+ path: file.path,
258
+ content: file.content,
259
+ })
260
+ if (current.destination !== file.destination) {
261
+ throw new PatternError(`Invalid Pattern plan: ${file.destination}`)
262
+ }
263
+ return { ...file, destination: current.destination }
264
+ })
265
+ await Promise.all(
266
+ resolved.map(async (file) => {
267
+ let current: TextFileState
268
+ try {
269
+ current = await readTextFileState(
270
+ environment.storage,
271
+ file.destination,
272
+ )
273
+ } catch {
274
+ throw new PatternError(
275
+ `Pattern destination changed before install: ${file.destination}`,
276
+ )
277
+ }
278
+ if (!sameTextFileState(current, file.before)) {
279
+ throw new PatternError(
280
+ `Pattern destination changed before install: ${file.destination}`,
281
+ )
282
+ }
283
+ }),
284
+ )
285
+ const attempted: PatternPlanFile[] = []
286
+ try {
287
+ for (const file of resolved) {
288
+ attempted.push(file)
289
+ await environment.storage.writeText(file.destination, file.content)
290
+ }
291
+ } catch (error) {
292
+ const rollbackErrors = await rollback(environment.storage, attempted)
293
+ if (rollbackErrors.length) {
294
+ throw new AggregateError(
295
+ [error, ...rollbackErrors],
296
+ `Pattern ${patternPlan.pattern} install and rollback failed.`,
297
+ )
298
+ }
299
+ throw error
300
+ }
301
+ return patternPlan
302
+ }
303
+
304
+ const install: PatternInstaller["install"] = async (
305
+ pattern,
306
+ input,
307
+ options,
308
+ ) => {
309
+ const patternPlan = await plan(pattern, input, options)
310
+ return apply(patternPlan)
311
+ }
312
+
313
+ return Object.freeze({ plan, apply, install })
314
+ }
package/perf.ts ADDED
@@ -0,0 +1,88 @@
1
+ export type PerfRecord = {
2
+ name: string
3
+ count: number
4
+ totalMs: number
5
+ maxMs: number
6
+ lastMs: number
7
+ maxLabel?: string
8
+ }
9
+
10
+ export class Perf {
11
+ readonly records = new Map<string, PerfRecord>()
12
+
13
+ measure<T>(name: string, fn: () => T, label?: string): T {
14
+ const start = performance.now()
15
+ try {
16
+ return fn()
17
+ } finally {
18
+ this.add(name, performance.now() - start, label)
19
+ }
20
+ }
21
+
22
+ async measureAsync<T>(
23
+ name: string,
24
+ fn: () => Promise<T>,
25
+ label?: string,
26
+ ): Promise<T> {
27
+ const start = performance.now()
28
+ try {
29
+ return await fn()
30
+ } finally {
31
+ this.add(name, performance.now() - start, label)
32
+ }
33
+ }
34
+
35
+ /** Record an externally measured duration under a span name. */
36
+ record(name: string, ms: number, label?: string) {
37
+ this.add(name, ms, label)
38
+ }
39
+
40
+ snapshot(): PerfRecord[] {
41
+ return [...this.records.values()]
42
+ }
43
+
44
+ format() {
45
+ const rows = this.snapshot()
46
+ if (!rows.length) return ""
47
+ const nameWidth = Math.max(...rows.map((row) => row.name.length))
48
+ const countWidth = Math.max(
49
+ ...rows.map((row) => String(row.count).length),
50
+ )
51
+ return rows
52
+ .map((row) => {
53
+ const name = row.name.padEnd(nameWidth)
54
+ const count = `n=${row.count}`.padEnd(countWidth + 2)
55
+ const total = `${row.totalMs.toFixed(1)} ms`
56
+ const max = `max=${row.maxMs.toFixed(1)} ms`
57
+ const label = row.maxLabel ? ` ${row.maxLabel}` : ""
58
+ return `${name} ${count} ${total} ${max}${label}`
59
+ })
60
+ .join("\n")
61
+ }
62
+
63
+ reset() {
64
+ this.records.clear()
65
+ }
66
+
67
+ private add(name: string, ms: number, label?: string) {
68
+ const current = this.records.get(name)
69
+ if (!current) {
70
+ this.records.set(name, {
71
+ name,
72
+ count: 1,
73
+ totalMs: ms,
74
+ maxMs: ms,
75
+ lastMs: ms,
76
+ maxLabel: label,
77
+ })
78
+ return
79
+ }
80
+ current.count += 1
81
+ current.totalMs += ms
82
+ current.lastMs = ms
83
+ if (ms >= current.maxMs) {
84
+ current.maxMs = ms
85
+ current.maxLabel = label
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,43 @@
1
+ import type { Plugin } from "./types.ts"
2
+ import { escapeHtml } from "../util.ts"
3
+
4
+ export function ActorPathPlugin(): Plugin {
5
+ return {
6
+ id: "actor-path",
7
+ // Rewrite Home-relative paths only after a Principal has been admitted.
8
+ streamSelectors({ principal, source }) {
9
+ const actor = principal.type === "actor" ? principal.actor : undefined
10
+ return {
11
+ "[href^='~/']": {
12
+ element(el) {
13
+ const href = el.getAttribute("href")
14
+ if (!href?.startsWith("~/")) return
15
+ if (actor) el.setAttribute("href", `${actor.home}/${href.slice(2)}`)
16
+ else el.removeAttribute("href")
17
+ },
18
+ },
19
+ "form[action^='~/']": {
20
+ element(el) {
21
+ const action = el.getAttribute("action")
22
+ if (!action?.startsWith("~/")) return
23
+ if (actor)
24
+ el.setAttribute("action", `${actor.home}/${action.slice(2)}`)
25
+ else el.remove()
26
+ },
27
+ },
28
+ 'form[action^="/actions/"]': {
29
+ element(el) {
30
+ if (!actor) {
31
+ el.remove()
32
+ return
33
+ }
34
+ el.prepend(
35
+ `<input type="hidden" name="_target" value="${escapeHtml(source.id)}" />`,
36
+ { html: true },
37
+ )
38
+ },
39
+ },
40
+ }
41
+ },
42
+ }
43
+ }
@@ -0,0 +1,55 @@
1
+ import type { Plugin } from "./types.ts"
2
+ import { isActorSource } from "../authority/actors.ts"
3
+
4
+ export function actorProfileDependency(actorId: string) {
5
+ return {
6
+ type: "plugin" as const,
7
+ plugin: "actor-profile",
8
+ key: actorId,
9
+ }
10
+ }
11
+
12
+ export function ActorProfilePlugin(): Plugin {
13
+ return {
14
+ id: "actor-profile",
15
+ invalidate({ before, after }) {
16
+ const actor = after ?? before
17
+ if (!actor || !isActorSource(actor)) return
18
+ return { dependencies: [actorProfileDependency(actor.id)] }
19
+ },
20
+ streamSelectors({ principal }, app) {
21
+ return {
22
+ "[data-pmx-profile-name]": {
23
+ element(element) {
24
+ const id = element.getAttribute("data-pmx-profile-name") ?? ""
25
+ const profile = app.actors.profile(id)
26
+ element.removeAttribute("data-pmx-profile-name")
27
+ if (profile) element.setInnerContent(profile.name)
28
+ else element.remove()
29
+ },
30
+ },
31
+ "[data-pmx-profile-avatar]": {
32
+ element(element) {
33
+ const id = element.getAttribute("data-pmx-profile-avatar") ?? ""
34
+ const profile = app.actors.profile(id)
35
+ element.removeAttribute("data-pmx-profile-avatar")
36
+ if (profile?.avatar) element.setAttribute("src", profile.avatar)
37
+ else element.remove()
38
+ },
39
+ },
40
+ "[data-pmx-owner]": {
41
+ element(element) {
42
+ const owner = element.getAttribute("data-pmx-owner")
43
+ const actor =
44
+ principal.type === "actor" ? principal.actor : undefined
45
+ const allowed =
46
+ actor?.id === owner ||
47
+ (actor?.type === "user" && actor.role === "staff")
48
+ element.removeAttribute("data-pmx-owner")
49
+ if (!allowed) element.remove()
50
+ },
51
+ },
52
+ }
53
+ },
54
+ }
55
+ }
@@ -0,0 +1,183 @@
1
+ import { deriveSourceMeta, type Source } from "../source/index.ts"
2
+ import { isPageSource, publicPath } from "../server/router.ts"
3
+ import { AGENT_BROWSING_PLUGIN_ID } from "../server/protocols.ts"
4
+ import { escapeHtml } from "../util.ts"
5
+ import {
6
+ route,
7
+ type Plugin,
8
+ type ServerContext,
9
+ type StreamHandler,
10
+ } from "./types.ts"
11
+
12
+ const PUBLIC_CACHE = "public, max-age=300"
13
+ const NO_STREAM_SELECTORS: Record<string, StreamHandler> = Object.freeze({})
14
+
15
+ function hasPublicBacklink(source: Source, ctx: ServerContext) {
16
+ return ctx.graph.linksTo(source.path).some((edge) => {
17
+ if (edge.from === source.path) return false
18
+ const origin = ctx.source(edge.from)
19
+ return origin != null && ctx.isPublic(origin)
20
+ })
21
+ }
22
+
23
+ function discoverablePages(ctx: ServerContext) {
24
+ return ctx
25
+ .sources()
26
+ .filter((source) => {
27
+ if (!isPageSource(source) || !ctx.isPublic(source)) return false
28
+ if (ctx.resolve(publicPath(source)) !== source) return false
29
+ return publicPath(source) === "/" || hasPublicBacklink(source, ctx)
30
+ })
31
+ .sort((left, right) => {
32
+ const leftPath = publicPath(left)
33
+ const rightPath = publicPath(right)
34
+ return leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0
35
+ })
36
+ }
37
+
38
+ function publicationBasePath(ctx: ServerContext) {
39
+ const suffix = `/${ctx.path}`
40
+ if (!ctx.url.pathname.endsWith(suffix)) return "/"
41
+ return ctx.url.pathname.slice(0, -suffix.length) || "/"
42
+ }
43
+
44
+ function mountedPath(ctx: ServerContext, pathname: string) {
45
+ const basePath = publicationBasePath(ctx)
46
+ if (basePath === "/") return pathname
47
+ return pathname === "/" ? basePath : `${basePath}${pathname}`
48
+ }
49
+
50
+ function absoluteUrl(ctx: ServerContext, pathname: string) {
51
+ return new URL(mountedPath(ctx, pathname), ctx.url.origin).href
52
+ }
53
+
54
+ function markdownText(value: string) {
55
+ return value
56
+ .replaceAll("\\", "\\\\")
57
+ .replaceAll("[", "\\[")
58
+ .replaceAll("]", "\\]")
59
+ .replace(/\s+/g, " ")
60
+ .trim()
61
+ }
62
+
63
+ function llmsTxt(ctx: ServerContext) {
64
+ const pages = discoverablePages(ctx)
65
+ const home = pages.find((source) => publicPath(source) === "/") ?? pages[0]
66
+ const homeMeta = home ? deriveSourceMeta(home) : undefined
67
+ const title = markdownText(homeMeta?.title || "PathMX")
68
+ const lines = [
69
+ `# ${title}`,
70
+ "",
71
+ ...(homeMeta?.description
72
+ ? [`> ${markdownText(homeMeta.description)}`, ""]
73
+ : []),
74
+ "## When to use this site",
75
+ "",
76
+ "Use this PathMX site to read, navigate, or cite its public Sources. The listed links return the authored Markdown representation directly. Use the sitemap for the canonical HTML URL index.",
77
+ "",
78
+ "## Public pages",
79
+ "",
80
+ ]
81
+
82
+ if (!pages.length) lines.push("No public pages are currently available.")
83
+ for (const source of pages) {
84
+ const meta = deriveSourceMeta(source)
85
+ const description = meta.description
86
+ ? ` — ${markdownText(meta.description)}`
87
+ : ""
88
+ lines.push(
89
+ `- [${markdownText(meta.title)}](${absoluteUrl(ctx, source.path)})${description}`,
90
+ )
91
+ }
92
+
93
+ lines.push("", `- [XML sitemap](${absoluteUrl(ctx, "/sitemap.xml")})`, "")
94
+ return lines.join("\n")
95
+ }
96
+
97
+ function robotsTxt(ctx: ServerContext) {
98
+ const basePath = publicationBasePath(ctx)
99
+ const root = basePath === "/" ? "/" : `${basePath}/`
100
+ return [
101
+ "User-agent: *",
102
+ `Allow: ${root}`,
103
+ `Disallow: ${mountedPath(ctx, "/.pmx/")}`,
104
+ `Disallow: ${mountedPath(ctx, "/actions/")}`,
105
+ `Disallow: ${mountedPath(ctx, "/api/runtime/")}`,
106
+ "",
107
+ `Host: ${ctx.url.origin}`,
108
+ `Sitemap: ${absoluteUrl(ctx, "/sitemap.xml")}`,
109
+ "",
110
+ ].join("\n")
111
+ }
112
+
113
+ function escapeXml(value: string) {
114
+ return value
115
+ .replaceAll("&", "&amp;")
116
+ .replaceAll('"', "&quot;")
117
+ .replaceAll("'", "&apos;")
118
+ .replaceAll("<", "&lt;")
119
+ .replaceAll(">", "&gt;")
120
+ }
121
+
122
+ function sitemapXml(ctx: ServerContext) {
123
+ const urls = discoverablePages(ctx)
124
+ .map(
125
+ (source) =>
126
+ ` <url><loc>${escapeXml(absoluteUrl(ctx, publicPath(source)))}</loc></url>`,
127
+ )
128
+ .join("\n")
129
+ return [
130
+ '<?xml version="1.0" encoding="UTF-8"?>',
131
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
132
+ urls,
133
+ "</urlset>",
134
+ "",
135
+ ].join("\n")
136
+ }
137
+
138
+ function publicText(body: string, contentType: string) {
139
+ return new Response(body, {
140
+ headers: {
141
+ "Content-Type": contentType,
142
+ "Cache-Control": PUBLIC_CACHE,
143
+ },
144
+ })
145
+ }
146
+
147
+ /** Public discovery documents for LLMs, crawlers, and other browsing agents. */
148
+ export function AgentBrowsingPlugin(): Plugin {
149
+ return {
150
+ id: AGENT_BROWSING_PLUGIN_ID,
151
+ name: "AgentBrowsingPlugin",
152
+ routes: [
153
+ route.get("llms.txt", (ctx) =>
154
+ publicText(llmsTxt(ctx), "text/plain; charset=utf-8"),
155
+ ),
156
+ route.get("robots.txt", (ctx) =>
157
+ publicText(robotsTxt(ctx), "text/plain; charset=utf-8"),
158
+ ),
159
+ route.get("sitemap.xml", (ctx) =>
160
+ publicText(sitemapXml(ctx), "application/xml; charset=utf-8"),
161
+ ),
162
+ ],
163
+ publish(ctx) {
164
+ ctx.route("/llms.txt")
165
+ ctx.route("/robots.txt")
166
+ ctx.route("/sitemap.xml")
167
+ },
168
+ streamSelectors({ source }, app) {
169
+ if (!app.sources.isPage(source)) return NO_STREAM_SELECTORS
170
+ const href = escapeHtml(source.path)
171
+ return {
172
+ head: {
173
+ element(element) {
174
+ element.prepend(
175
+ `<link rel="alternate" type="text/markdown" href="${href}">`,
176
+ { html: true },
177
+ )
178
+ },
179
+ },
180
+ }
181
+ },
182
+ }
183
+ }
@@ -0,0 +1,23 @@
1
+ import { stringifyHtml } from "../html.ts"
2
+ import { stampHtmlRoot } from "../view/syntax.ts"
3
+ import type { ViewComponent } from "./types.ts"
4
+
5
+ /** @internal Browser-owned roots are opaque to document morphing. */
6
+ export const APP_COMPONENT_ROOT_ATTRIBUTE = "data-pmx-app"
7
+
8
+ /** Define a browser-owned View Component whose client is bundled when authored. */
9
+ export function defineAppComponent<Compiled = undefined>(
10
+ definition: ViewComponent<Compiled> & Readonly<{ client: string | URL }>,
11
+ ): ViewComponent<Compiled> {
12
+ const { render, ...component } = definition
13
+ return {
14
+ ...component,
15
+ async render(use, compiled, ctx, app) {
16
+ return stampHtmlRoot(
17
+ stringifyHtml(await render(use, compiled, ctx, app)),
18
+ { [APP_COMPONENT_ROOT_ATTRIBUTE]: "" },
19
+ app.html.rewrite,
20
+ )
21
+ },
22
+ }
23
+ }