@pathmx/core 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +172 -0
- package/README.md +111 -0
- package/actions/actions.ts +307 -0
- package/actions/draft.ts +105 -0
- package/actions/transaction.ts +164 -0
- package/assets.ts +149 -0
- package/authority/access.ts +820 -0
- package/authority/actors.ts +271 -0
- package/authority/agents.ts +95 -0
- package/authority/credentials.ts +144 -0
- package/authority/model.ts +71 -0
- package/authority/sharing.ts +89 -0
- package/browser-bundle.ts +61 -0
- package/canonical.ts +41 -0
- package/cli/dev.ts +134 -0
- package/cli/export.ts +74 -0
- package/cli/index.ts +52 -0
- package/cli/lint.ts +164 -0
- package/cli/mv.ts +30 -0
- package/cli/perms.ts +182 -0
- package/cli/plugin-packages.ts +159 -0
- package/cli/plugins.ts +95 -0
- package/cli/query.ts +225 -0
- package/cli/readme.md +95 -0
- package/cli/release.ts +75 -0
- package/cli/rm.ts +22 -0
- package/cli/serve.ts +100 -0
- package/cli/tree.ts +131 -0
- package/cli/update.ts +78 -0
- package/cli/utils.ts +53 -0
- package/core.ts +96 -0
- package/database.ts +189 -0
- package/dependencies.ts +60 -0
- package/dist/pathmx.js +1224 -0
- package/document.ts +194 -0
- package/environment.ts +110 -0
- package/environments/bun-plugins.ts +68 -0
- package/environments/bun-publish.ts +194 -0
- package/environments/bun.ts +223 -0
- package/globals.d.ts +14 -0
- package/graph.ts +153 -0
- package/host/compiler.ts +314 -0
- package/host/create.ts +66 -0
- package/host/engine.ts +630 -0
- package/host/plugin-host.ts +487 -0
- package/host/render.ts +102 -0
- package/host/response.ts +20 -0
- package/host/shell.html +15 -0
- package/host/source-sync.ts +254 -0
- package/html-attributes.ts +15 -0
- package/html.ts +48 -0
- package/icons/LICENSE +43 -0
- package/icons/lucide.ts +54 -0
- package/index.ts +75 -0
- package/invalidation.ts +85 -0
- package/log.ts +84 -0
- package/ops/href.ts +87 -0
- package/ops/index.ts +9 -0
- package/ops/move-source.ts +114 -0
- package/ops/plan.ts +47 -0
- package/ops/remove-source.ts +70 -0
- package/package.json +46 -0
- package/path.ts +75 -0
- package/pattern.ts +314 -0
- package/perf.ts +88 -0
- package/plugins/actor-path.ts +43 -0
- package/plugins/actor-profile.ts +55 -0
- package/plugins/agent-browsing.ts +183 -0
- package/plugins/app-component.ts +23 -0
- package/plugins/block-layout/index.ts +78 -0
- package/plugins/block-layout/theme.css +90 -0
- package/plugins/callout/index.ts +236 -0
- package/plugins/callout/theme.css +138 -0
- package/plugins/canonical-links.ts +144 -0
- package/plugins/code-highlight/client.ts +89 -0
- package/plugins/code-highlight/index.ts +147 -0
- package/plugins/code-highlight/theme.css +89 -0
- package/plugins/context.ts +131 -0
- package/plugins/datatype/Datatype[wdth,wght].woff2 +0 -0
- package/plugins/datatype/OFL.txt +96 -0
- package/plugins/datatype/index.ts +287 -0
- package/plugins/definition-list.ts +236 -0
- package/plugins/directive-definitions.ts +13 -0
- package/plugins/embedding/client.ts +88 -0
- package/plugins/embedding/index.ts +43 -0
- package/plugins/image/index.ts +68 -0
- package/plugins/image/theme.css +43 -0
- package/plugins/include.ts +119 -0
- package/plugins/index.ts +91 -0
- package/plugins/input/authoring.ts +236 -0
- package/plugins/input/config.ts +85 -0
- package/plugins/input/index.ts +359 -0
- package/plugins/input/model.ts +173 -0
- package/plugins/input/state.ts +272 -0
- package/plugins/layout.ts +313 -0
- package/plugins/literate/client.ts +208 -0
- package/plugins/literate/index.ts +742 -0
- package/plugins/math/index.ts +140 -0
- package/plugins/meta.ts +42 -0
- package/plugins/navigation/index.ts +158 -0
- package/plugins/navigation/model.ts +364 -0
- package/plugins/preset.ts +65 -0
- package/plugins/query/index.ts +322 -0
- package/plugins/rss/index.ts +46 -0
- package/plugins/runtime/actions.ts +62 -0
- package/plugins/runtime/client.ts +8 -0
- package/plugins/runtime/document.ts +506 -0
- package/plugins/runtime/error.ts +154 -0
- package/plugins/runtime/heading-links.ts +31 -0
- package/plugins/runtime/index.ts +104 -0
- package/plugins/runtime/live.ts +51 -0
- package/plugins/runtime/navigation.ts +113 -0
- package/plugins/runtime/pending.ts +53 -0
- package/plugins/runtime/publication.ts +19 -0
- package/plugins/runtime/start.ts +177 -0
- package/plugins/runtime/static-client.ts +6 -0
- package/plugins/runtime/static-live.ts +188 -0
- package/plugins/runtime/view-transition.ts +72 -0
- package/plugins/runtime/viewport.ts +110 -0
- package/plugins/schema.ts +24 -0
- package/plugins/scripts.ts +140 -0
- package/plugins/source-preview/client.ts +256 -0
- package/plugins/source-preview/index.ts +132 -0
- package/plugins/source-preview/model.ts +11 -0
- package/plugins/source-preview/preview.css +107 -0
- package/plugins/styles.ts +145 -0
- package/plugins/theme/components.ts +10 -0
- package/plugins/theme/foundation.css +358 -0
- package/plugins/theme/index.ts +333 -0
- package/plugins/theme/prose.css +284 -0
- package/plugins/theme/scheme.js +63 -0
- package/plugins/theme/toggle.css +44 -0
- package/plugins/theme/view-transition.css +68 -0
- package/plugins/toc/client.ts +146 -0
- package/plugins/toc/index.ts +232 -0
- package/plugins/toc/theme.css +172 -0
- package/plugins/types.ts +538 -0
- package/problem.ts +41 -0
- package/project.ts +6 -0
- package/publish/build.ts +402 -0
- package/publish/concurrent.ts +32 -0
- package/publish/identity.ts +63 -0
- package/publish/index.ts +11 -0
- package/publish/location.ts +167 -0
- package/publish/model.ts +58 -0
- package/publish/plan.ts +300 -0
- package/publish/validate.ts +150 -0
- package/query/candidate-index.ts +101 -0
- package/query/dependency-index.ts +99 -0
- package/query/fields.ts +216 -0
- package/query/invalidation.ts +48 -0
- package/query/kernel.ts +776 -0
- package/query/model.ts +79 -0
- package/repository.ts +374 -0
- package/runtime-profile.ts +34 -0
- package/schema/model.ts +92 -0
- package/schema/registry.ts +642 -0
- package/schema/standard.ts +226 -0
- package/server/context.ts +94 -0
- package/server/css-imports.ts +66 -0
- package/server/http.ts +333 -0
- package/server/page.ts +21 -0
- package/server/protocols.ts +12 -0
- package/server/representations.ts +66 -0
- package/server/router.ts +131 -0
- package/server/routes.ts +202 -0
- package/server/serve.ts +5 -0
- package/server/watch.ts +120 -0
- package/source/blocks.ts +71 -0
- package/source/format.ts +164 -0
- package/source/index.ts +67 -0
- package/source/interpolate.ts +63 -0
- package/source/markdown/blocks.ts +46 -0
- package/source/markdown/comments.ts +32 -0
- package/source/markdown/fences.ts +67 -0
- package/source/markdown/headings.ts +88 -0
- package/source/markdown/index.ts +34 -0
- package/source/markdown/inline-code.ts +6 -0
- package/source/markdown/links.ts +326 -0
- package/source/markdown/lists.ts +223 -0
- package/source/markdown/prose.ts +74 -0
- package/source/markdown/scan.ts +79 -0
- package/source/markdown/text.ts +82 -0
- package/source/meta.ts +87 -0
- package/source/source.ts +121 -0
- package/source/topmatter.ts +57 -0
- package/source/utils.ts +21 -0
- package/source-diagnostic.ts +63 -0
- package/text-file.ts +26 -0
- package/util.ts +35 -0
- package/view/components.ts +499 -0
- package/view/model.ts +91 -0
- package/view/projection.ts +162 -0
- package/view/session.ts +146 -0
- package/view/syntax.ts +372 -0
package/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
|
+
}
|