@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/document.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type { DocumentAsset, Source } from "./plugins/types"
2
+ import type { SourceBlock } from "./source"
3
+ import { escapeHtml } from "./util"
4
+
5
+ /**
6
+ * Canonical head order: charset, title, other metas, preloads, stylesheets,
7
+ * inline styles, scripts. Contribution order breaks ties (Array.sort is
8
+ * stable), so plugins never need a placement option — the engine owns layout.
9
+ */
10
+ export function documentAssetRank(asset: DocumentAsset) {
11
+ if (asset.type === "meta") return asset.attrs.charset != null ? 0 : 2
12
+ if (asset.type === "title") return 1
13
+ if (asset.type === "preload") return 3
14
+ if (asset.type === "stylesheet") return 4
15
+ if (asset.type === "style") return 5
16
+ return 6
17
+ }
18
+
19
+ export function documentAssetKey(asset: DocumentAsset) {
20
+ if (asset.type === "style") return `style:${asset.text}`
21
+ if (asset.type === "script") {
22
+ if ("name" in asset && asset.name) return `script:name:${asset.name}`
23
+ if ("source" in asset) return `script:source:${asset.source}`
24
+ return "href" in asset ? `script:${asset.href}` : `script:${asset.text}`
25
+ }
26
+ if (asset.type === "title") return "title"
27
+ if (asset.type === "preload") {
28
+ return `preload:${asset.href}:${asset.as}`
29
+ }
30
+ if (asset.type === "meta") {
31
+ const { charset, name, property } = asset.attrs
32
+ return `meta:${charset != null ? "charset" : (name ?? property ?? JSON.stringify(asset.attrs))}:${asset.attrs.media ?? ""}`
33
+ }
34
+ if ("name" in asset && asset.name) return `stylesheet:name:${asset.name}`
35
+ return `stylesheet:${"href" in asset ? asset.href : ""}`
36
+ }
37
+
38
+ function pushUniqueAsset(assets: DocumentAsset[], asset: DocumentAsset) {
39
+ const key = documentAssetKey(asset)
40
+ if (assets.some((item) => documentAssetKey(item) === key)) return
41
+ assets.push(
42
+ Object.freeze(
43
+ asset.type === "meta"
44
+ ? { ...asset, attrs: Object.freeze({ ...asset.attrs }) }
45
+ : { ...asset },
46
+ ),
47
+ )
48
+ }
49
+
50
+ /** Page and document contexts share one uniqueness-preserving asset bag. */
51
+ export function createAssetBag(assets: DocumentAsset[] = []) {
52
+ return {
53
+ assets,
54
+ addAsset(...items: DocumentAsset[]) {
55
+ for (const asset of items) pushUniqueAsset(assets, asset)
56
+ },
57
+ }
58
+ }
59
+
60
+ export function closeInline(text: string, tag: "style" | "script") {
61
+ return text.replace(new RegExp(`<\\/${tag}`, "gi"), `<\\/${tag}`)
62
+ }
63
+
64
+ export function renderDocumentAssets(assets: readonly DocumentAsset[]) {
65
+ return assets
66
+ .map((asset) => {
67
+ if (asset.type === "title") {
68
+ return `<title>${escapeHtml(asset.text)}</title>`
69
+ }
70
+ if (asset.type === "meta") {
71
+ const attrs = Object.entries(asset.attrs)
72
+ .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`)
73
+ .join("")
74
+ return `<meta${attrs}>`
75
+ }
76
+ if (asset.type === "preload") {
77
+ const contentType = asset.contentType
78
+ ? ` type="${escapeHtml(asset.contentType)}"`
79
+ : ""
80
+ const crossorigin = asset.crossorigin ? " crossorigin" : ""
81
+ return `<link rel="preload" href="${escapeHtml(asset.href)}" as="${escapeHtml(asset.as)}"${contentType}${crossorigin}>`
82
+ }
83
+ if (asset.type === "stylesheet") {
84
+ const href = "href" in asset ? asset.href : ""
85
+ const media = asset.media ? ` media="${escapeHtml(asset.media)}"` : ""
86
+ return `<link rel="stylesheet" href="${escapeHtml(href)}"${media}>`
87
+ }
88
+ if (asset.type === "style") {
89
+ return `<style>${closeInline(asset.text, "style")}</style>`
90
+ }
91
+ const moduleAttr = asset.module ? ` type="module"` : ""
92
+ if ("href" in asset) {
93
+ return `<script${moduleAttr} src="${escapeHtml(asset.href)}"></script>`
94
+ }
95
+ if ("source" in asset) {
96
+ throw new Error(`Unresolved authored script asset: ${asset.source}`)
97
+ }
98
+ return `<script${moduleAttr}>${closeInline(asset.text, "script")}</script>`
99
+ })
100
+ .join("")
101
+ }
102
+
103
+ export function headingIdsForBlock(source: Source, block: SourceBlock) {
104
+ let offset = 0
105
+ for (const item of source.blocks) {
106
+ if (item.id === block.id) {
107
+ return source.headings.slice(offset, offset + item.headings.length)
108
+ }
109
+ offset += item.headings.length
110
+ }
111
+ return block.headings
112
+ }
113
+
114
+ type HeadingRef = { depth: number; id: string }
115
+
116
+ const TASK_INSTRUCTION =
117
+ /(<li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox"[^>]*><strong>[\s\S]*?<\/strong>)\r?\n/g
118
+
119
+ /** Preserve exam-style instruction lines beneath a leading bold task title. */
120
+ export function preserveTaskInstructions(html: string) {
121
+ if (!html.includes('class="task-list-item"')) return html
122
+ return html.replace(TASK_INSTRUCTION, "$1<br>")
123
+ }
124
+
125
+ /** Normalize Markdown heading ids and add accessible sibling permalinks. */
126
+ export function applyHeadingPermalinks(
127
+ html: string,
128
+ headings: readonly HeadingRef[],
129
+ renderedHeadings: readonly HeadingRef[],
130
+ ) {
131
+ if (!headings.length) return html
132
+ let index = 0
133
+ return html.replace(
134
+ /<h([1-6]) id="([^"]*)">[\s\S]*?<\/h\1>/g,
135
+ (headingHtml, renderedDepth: string, renderedId: string) => {
136
+ const heading = headings[index]
137
+ const renderedHeading = renderedHeadings[index]
138
+ if (
139
+ !heading ||
140
+ !renderedHeading ||
141
+ Number(renderedDepth) !== renderedHeading.depth ||
142
+ renderedId !== escapeHtml(renderedHeading.id)
143
+ )
144
+ return headingHtml
145
+
146
+ index++
147
+ const id = escapeHtml(heading.id)
148
+ const normalizedHtml = headingHtml.replace(
149
+ `<h${renderedDepth} id="${renderedId}">`,
150
+ `<h${renderedDepth} id="${id}">`,
151
+ )
152
+ if (!id) return normalizedHtml
153
+ return [
154
+ `<div class="pmx-heading level-h${renderedDepth}">`,
155
+ normalizedHtml,
156
+ `<a class="pmx-heading-anchor" href="#${id}" aria-labelledby="${id}">`,
157
+ `</a></div>`,
158
+ ].join("")
159
+ },
160
+ )
161
+ }
162
+
163
+ const BLOCK_FLOW_OPEN = '<template data-pathmx-block-flow="">'
164
+ const BLOCK_FLOW_CLOSE = "</template>"
165
+
166
+ /** Mark compiled Block sections that replace their authored placeholder Block. */
167
+ export function wrapBlockFlow(html: string) {
168
+ return `${BLOCK_FLOW_OPEN}${html}${BLOCK_FLOW_CLOSE}`
169
+ }
170
+
171
+ /** Return a complete Block-flow expansion, leaving ordinary Block HTML alone. */
172
+ export function unwrapBlockFlow(html: string) {
173
+ const value = html.trim()
174
+ if (!value.startsWith(BLOCK_FLOW_OPEN) || !value.endsWith(BLOCK_FLOW_CLOSE)) {
175
+ return
176
+ }
177
+ return value.slice(BLOCK_FLOW_OPEN.length, -BLOCK_FLOW_CLOSE.length).trim()
178
+ }
179
+
180
+ export function wrapBlockSection(
181
+ block: Pick<SourceBlock, "id" | "path" | "type">,
182
+ html: string,
183
+ options?: { id?: string },
184
+ ) {
185
+ const id = options?.id ?? block.id
186
+ const typeAttr =
187
+ block.type !== "block" ? ` data-type="${escapeHtml(block.type)}"` : ""
188
+ return `<section id="${escapeHtml(id)}" data-pmx-block data-pmx-block-path="${escapeHtml(block.path)}" class="pmx-prose"${typeAttr}>\n${html.trim()}\n</section>`
189
+ }
190
+
191
+ /** The canonical authored page region inserted into zero or more layouts. */
192
+ export function wrapPageArticle(source: Source, html: string) {
193
+ return `<article class="pmx-document" data-type="${escapeHtml(source.type)}" data-source="${escapeHtml(source.id)}">${html}</article>`
194
+ }
package/environment.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type { SqlConnection } from "./database.ts"
2
+
3
+ type Awaitable<T> = T | Promise<T>
4
+
5
+ export type HtmlElement = {
6
+ readonly tagName: string
7
+ readonly attributes: Iterable<[string, string]>
8
+ getAttribute(name: string): string | null
9
+ setAttribute(name: string, value: string): void
10
+ removeAttribute(name: string): void
11
+ before(content: string, options?: { html?: boolean }): void
12
+ after(content: string, options?: { html?: boolean }): void
13
+ prepend(content: string, options?: { html?: boolean }): void
14
+ setInnerContent(content: string, options?: { html?: boolean }): void
15
+ remove(): void
16
+ onEndTag(handler: () => void): void
17
+ }
18
+
19
+ export type HtmlTextChunk = {
20
+ readonly text: string
21
+ readonly lastInTextNode: boolean
22
+ remove(): void
23
+ after(content: string, options?: { html?: boolean }): void
24
+ }
25
+
26
+ export type HtmlRewriteHandler = {
27
+ element?(element: HtmlElement): void
28
+ text?(chunk: HtmlTextChunk): void
29
+ }
30
+
31
+ export type HtmlRewriteRule = readonly [
32
+ selector: string,
33
+ handler: HtmlRewriteHandler,
34
+ ]
35
+
36
+ export type HtmlStringRewrite = (
37
+ html: string,
38
+ rules: readonly HtmlRewriteRule[],
39
+ ) => string
40
+
41
+ export type HtmlRewrite = {
42
+ (html: string, rules: readonly HtmlRewriteRule[]): string
43
+ (response: Response, rules: readonly HtmlRewriteRule[]): Response
44
+ }
45
+
46
+ export type PathMXStorage = {
47
+ /** Return file keys relative to `root`. */
48
+ list: (root: string) => Awaitable<Iterable<string>>
49
+ readText: (path: string) => Awaitable<string>
50
+ readBlob: (path: string) => Awaitable<Blob>
51
+ /** Write text, creating any parent containers required by the adapter. */
52
+ writeText: (path: string, content: string) => Awaitable<void>
53
+ delete: (path: string) => Awaitable<void>
54
+ /** Return whether a readable storage object exists at `path`. */
55
+ exists: (path: string) => Awaitable<boolean>
56
+ }
57
+
58
+ export type PathMXYaml = {
59
+ parse: (text: string) => unknown
60
+ stringify: (value: Record<string, unknown>) => string
61
+ }
62
+
63
+ export type PathMXPath = {
64
+ basename: (path: string) => string
65
+ dirname: (path: string) => string
66
+ extname: (path: string) => string
67
+ join: (...paths: string[]) => string
68
+ normalize: (path: string) => string
69
+ relative: (from: string, to: string) => string
70
+ resolve: (...paths: string[]) => string
71
+ }
72
+
73
+ export type PathMXDatabaseEnvironment = Readonly<{
74
+ /** Join an adapter-owned state root to a database filename. */
75
+ path(root: string, filename: string): string
76
+ open(path: string): Awaitable<SqlConnection>
77
+ }>
78
+
79
+ /** One output of bundling a browser entrypoint, held in memory. */
80
+ export type BundledAsset =
81
+ | {
82
+ /** Output-relative artifact path, e.g. `client.js`. */
83
+ path: string
84
+ type: "script" | "style"
85
+ text: string
86
+ }
87
+ | {
88
+ /** Binary output retained without a lossy text conversion. */
89
+ path: string
90
+ type: "file"
91
+ blob: Blob
92
+ }
93
+
94
+ export type PathMXEnvironment = {
95
+ renderMarkdown: (markdown: string) => string
96
+ /** Apply selector transforms through the runtime's HTML implementation. */
97
+ rewriteHtml: HtmlRewrite
98
+ /** Load a package-owned asset independently of repository storage. */
99
+ loadAsset: (url: URL) => Awaitable<Blob>
100
+ /** Bundle a browser entrypoint (ts/tsx/js/css) into in-memory assets. */
101
+ bundle: (entrypoint: string | URL) => Promise<BundledAsset[]>
102
+ /** Resolve a server module; adapters may use imports, manifests, or maps. */
103
+ loadModule: (specifier: string) => Promise<unknown | undefined>
104
+ /** POSIX logical path operations; these are not storage/filesystem calls. */
105
+ path: PathMXPath
106
+ yaml: PathMXYaml
107
+ storage: PathMXStorage
108
+ /** Optional SQL capability, opened lazily only by plugins that declare state. */
109
+ database?: PathMXDatabaseEnvironment
110
+ }
@@ -0,0 +1,68 @@
1
+ import { existsSync } from "node:fs"
2
+ import { readdir } from "node:fs/promises"
3
+ import { join, resolve } from "node:path"
4
+ import type { Log } from "../log.ts"
5
+ import {
6
+ expandPluginContributions,
7
+ type PluginContribution,
8
+ } from "../plugins/preset.ts"
9
+ import { projectDirectory } from "../project.ts"
10
+ import { bunEnvironment } from "./bun.ts"
11
+
12
+ const TOP_LEVEL_PLUGIN = /^[^/]+\.plugin\.(?:ts|tsx|js|jsx)$/
13
+ const DIRECTORY_PLUGIN = /^index\.plugin\.(?:ts|tsx|js|jsx)$/
14
+
15
+ export function defaultPluginsRoot(pathsRoot: string) {
16
+ return projectDirectory(pathsRoot, "plugins")
17
+ }
18
+
19
+ async function pluginEntrypoints(root: string) {
20
+ if (!existsSync(root)) return []
21
+ const entries = await readdir(root, { withFileTypes: true })
22
+ const topLevel = entries
23
+ .filter((entry) => entry.isFile() && TOP_LEVEL_PLUGIN.test(entry.name))
24
+ .map((entry) => join(root, entry.name))
25
+ const nested = await Promise.all(
26
+ entries
27
+ .filter((entry) => entry.isDirectory())
28
+ .map(async (directory) => {
29
+ const directoryRoot = join(root, directory.name)
30
+ const children = await readdir(directoryRoot, { withFileTypes: true })
31
+ return children
32
+ .filter(
33
+ (child) => child.isFile() && DIRECTORY_PLUGIN.test(child.name),
34
+ )
35
+ .map((child) => join(directoryRoot, child.name))
36
+ }),
37
+ )
38
+ return [...topLevel, ...nested.flat()].sort()
39
+ }
40
+
41
+ function repositoryContribution(
42
+ value: unknown,
43
+ entrypoint: string,
44
+ ): PluginContribution {
45
+ try {
46
+ expandPluginContributions([value])
47
+ return value as PluginContribution
48
+ } catch (cause) {
49
+ throw new Error(
50
+ `Repository Plugin ${entrypoint} must default-export a Plugin, PluginFactory, or PluginPreset.`,
51
+ { cause },
52
+ )
53
+ }
54
+ }
55
+
56
+ /** Load deterministic, trusted server plugins authored beside a Bun repository. */
57
+ export async function discoverRepositoryPlugins(pathsRoot: string, log: Log) {
58
+ const plugins: PluginContribution[] = []
59
+ const root = resolve(defaultPluginsRoot(pathsRoot))
60
+ for (const entrypoint of await pluginEntrypoints(root)) {
61
+ const module = (await bunEnvironment.loadModule(entrypoint)) as
62
+ { default?: unknown } | undefined
63
+ const contribution = repositoryContribution(module?.default, entrypoint)
64
+ log.info("plugin.discover", { entrypoint })
65
+ plugins.push(contribution)
66
+ }
67
+ return plugins
68
+ }
@@ -0,0 +1,194 @@
1
+ import { copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises"
2
+ import {
3
+ basename,
4
+ dirname,
5
+ isAbsolute,
6
+ join,
7
+ parse,
8
+ relative,
9
+ resolve,
10
+ sep,
11
+ } from "node:path"
12
+ import type {
13
+ PublicationBundle,
14
+ PublicationManifest,
15
+ PublicationStore,
16
+ } from "../publish/model.ts"
17
+
18
+ const WRITE_CONCURRENCY = 32
19
+
20
+ async function writeBatches<T>(
21
+ items: readonly T[],
22
+ write: (item: T) => Promise<void>,
23
+ ) {
24
+ for (let start = 0; start < items.length; start += WRITE_CONCURRENCY) {
25
+ await Promise.all(items.slice(start, start + WRITE_CONCURRENCY).map(write))
26
+ }
27
+ }
28
+
29
+ function isMissing(error: unknown) {
30
+ return (
31
+ typeof error === "object" &&
32
+ error !== null &&
33
+ "code" in error &&
34
+ error.code === "ENOENT"
35
+ )
36
+ }
37
+
38
+ async function moveExisting(path: string, backup: string) {
39
+ try {
40
+ await rename(path, backup)
41
+ return true
42
+ } catch (error) {
43
+ if (isMissing(error)) return false
44
+ throw error
45
+ }
46
+ }
47
+
48
+ function artifactPath(root: string, artifact: string) {
49
+ const path = resolve(root, artifact)
50
+ const local = relative(root, path)
51
+ if (
52
+ !artifact ||
53
+ isAbsolute(artifact) ||
54
+ local === ".." ||
55
+ local.startsWith(`..${sep}`) ||
56
+ isAbsolute(local)
57
+ ) {
58
+ throw new Error(`Invalid publication artifact path: ${artifact}`)
59
+ }
60
+ if (local === join(".pathmx", "manifest.json")) {
61
+ throw new Error(
62
+ "Publication artifact conflicts with .pathmx/manifest.json.",
63
+ )
64
+ }
65
+ return path
66
+ }
67
+
68
+ function directoryStore(root: string): PublicationStore {
69
+ return Object.freeze({
70
+ async put(artifact: string, body: Blob) {
71
+ const path = artifactPath(root, artifact)
72
+ await mkdir(dirname(path), { recursive: true })
73
+ await Bun.write(path, body)
74
+ return Object.freeze({
75
+ size: body.size,
76
+ read: async () => Bun.file(path),
77
+ })
78
+ },
79
+ })
80
+ }
81
+
82
+ async function writeArtifacts(
83
+ store: PublicationStore,
84
+ bundle: PublicationBundle,
85
+ ) {
86
+ await writeBatches(bundle.artifacts, async (item) => {
87
+ await store.put(item.path, await item.read())
88
+ })
89
+ }
90
+
91
+ function pageOutput(pathname: string) {
92
+ return pathname === "/"
93
+ ? "index.html"
94
+ : join(pathname.replace(/^\/+|\/+$/g, ""), "index.html")
95
+ }
96
+
97
+ async function writePageRoutes(root: string, bundle: PublicationBundle) {
98
+ const artifacts = new Map(
99
+ bundle.artifacts.map((artifact) => [artifact.path, artifact]),
100
+ )
101
+ const pages = bundle.manifest.routes.filter((route) => route.type === "page")
102
+ await writeBatches(pages, async (route) => {
103
+ const representation = route.representations.find(
104
+ (representation) =>
105
+ representation.default && representation.type === "html",
106
+ )
107
+ const artifact = representation
108
+ ? artifacts.get(representation.artifact)
109
+ : undefined
110
+ if (!artifact) {
111
+ throw new Error(
112
+ `Page route has no default HTML artifact: ${route.pathname}`,
113
+ )
114
+ }
115
+ const output = pageOutput(route.pathname)
116
+ const collision = artifacts.get(output)
117
+ if (collision && collision !== artifact) {
118
+ throw new Error(
119
+ `Directory publication cannot represent both ${route.pathname} and /${output}.`,
120
+ )
121
+ }
122
+ const path = artifactPath(root, output)
123
+ await mkdir(dirname(path), { recursive: true })
124
+ await copyFile(artifactPath(root, artifact.path), path)
125
+ })
126
+ }
127
+
128
+ async function finalizePublication(root: string, bundle: PublicationBundle) {
129
+ await writePageRoutes(root, bundle)
130
+ const manifestPath = join(root, ".pathmx", "manifest.json")
131
+ await mkdir(dirname(manifestPath), { recursive: true })
132
+ await Bun.write(manifestPath, JSON.stringify(bundle.manifest, null, 2) + "\n")
133
+ }
134
+
135
+ async function publishAtomically<T>(
136
+ output: string,
137
+ prepare: (staging: string) => Promise<T>,
138
+ ) {
139
+ const destination = resolve(output)
140
+ if (destination === parse(destination).root) {
141
+ throw new Error("Publication output cannot be a filesystem root.")
142
+ }
143
+ const parent = dirname(destination)
144
+ await mkdir(parent, { recursive: true })
145
+ const staging = await mkdtemp(join(parent, `.${basename(destination)}-`))
146
+ const backup = `${staging}.previous`
147
+ let movedExisting = false
148
+ try {
149
+ const result = await prepare(staging)
150
+ movedExisting = await moveExisting(destination, backup)
151
+ try {
152
+ await rename(staging, destination)
153
+ } catch (error) {
154
+ if (movedExisting) {
155
+ await rename(backup, destination)
156
+ movedExisting = false
157
+ }
158
+ throw error
159
+ }
160
+ if (movedExisting) {
161
+ await rm(backup, { recursive: true, force: true }).catch(() => undefined)
162
+ movedExisting = false
163
+ }
164
+ return result
165
+ } catch (error) {
166
+ await rm(staging, { recursive: true, force: true })
167
+ if (movedExisting) await rename(backup, destination)
168
+ throw error
169
+ }
170
+ }
171
+
172
+ /** Materialize a prebuilt publication bundle as a directory-style static tree. */
173
+ export async function writePublication(
174
+ bundle: PublicationBundle,
175
+ output: string,
176
+ ) {
177
+ return publishAtomically(output, async (staging) => {
178
+ await writeArtifacts(directoryStore(staging), bundle)
179
+ await finalizePublication(staging, bundle)
180
+ return bundle.manifest
181
+ })
182
+ }
183
+
184
+ /** Build into private directory staging and atomically expose the completed tree. */
185
+ export async function publishToDirectory(
186
+ output: string,
187
+ build: (store: PublicationStore) => Promise<PublicationBundle>,
188
+ ): Promise<PublicationManifest> {
189
+ return publishAtomically(output, async (staging) => {
190
+ const bundle = await build(directoryStore(staging))
191
+ await finalizePublication(staging, bundle)
192
+ return bundle.manifest
193
+ })
194
+ }