@pathmx/core 0.5.0 → 0.5.2
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/README.md +9 -1
- package/assets.ts +116 -31
- package/cli/dev.ts +3 -2
- package/cli/lint.ts +50 -8
- package/cli/update.ts +47 -5
- package/dist/pathmx.js +2 -1223
- package/document.ts +15 -5
- package/environments/bun.ts +5 -3
- package/host/authored-assets.ts +98 -0
- package/host/engine.ts +45 -26
- package/host/shell.html +1 -0
- package/host/source-sync.ts +1 -1
- package/ops/href.ts +0 -11
- package/ops/move-source.ts +5 -3
- package/package.json +1 -1
- package/plugins/runtime/document.ts +23 -4
- package/plugins/runtime/runtime.css +6 -0
- package/plugins/runtime/start.ts +1 -0
- package/plugins/styles.ts +25 -24
- package/plugins/types.ts +1 -0
- package/publish/build.ts +15 -5
- package/server/http.ts +52 -8
- package/server/watch.ts +8 -1
- package/source/source.ts +0 -1
- package/storage-path.ts +8 -0
package/README.md
CHANGED
|
@@ -55,14 +55,22 @@ const app = await createApp({
|
|
|
55
55
|
})
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
Routes and Source identities are file-based. A Source-level frontmatter `id`
|
|
59
|
+
is ignored and reported as a non-failing `pmx lint` warning. Block-level ids
|
|
60
|
+
remain authorable for stable local targets.
|
|
61
|
+
|
|
58
62
|
Official packages follow the CLI's release channel:
|
|
59
63
|
|
|
60
64
|
```sh
|
|
61
|
-
pathmx plugins add
|
|
65
|
+
pathmx plugins add paths
|
|
66
|
+
pathmx plugins add auth mermaid
|
|
62
67
|
pathmx plugins list
|
|
63
68
|
pathmx plugins update
|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
`paths` installs the provider-neutral Path, Completion, and Player experience.
|
|
72
|
+
Auth remains provider-configured and Mermaid remains opt-in.
|
|
73
|
+
|
|
66
74
|
## Image directives
|
|
67
75
|
|
|
68
76
|
The default Image Plugin compiles portable image roles from normal Markdown:
|
package/assets.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
import { canonicalPath } from "./canonical.ts"
|
|
2
|
+
|
|
1
3
|
const ASSET_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
|
|
2
|
-
export const
|
|
4
|
+
export const ASSET_STORE_LIMIT = 128
|
|
3
5
|
const encoder = new TextEncoder()
|
|
4
6
|
|
|
5
|
-
export type
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export type StoredAsset =
|
|
8
|
+
| Readonly<{
|
|
9
|
+
type: "generated"
|
|
10
|
+
body: string | Blob
|
|
11
|
+
contentType: string
|
|
12
|
+
}>
|
|
13
|
+
| Readonly<{
|
|
14
|
+
type: "authored"
|
|
15
|
+
sourcePath: string
|
|
16
|
+
body: Blob
|
|
17
|
+
contentType: string
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
type RetainedAsset = {
|
|
21
|
+
key: string
|
|
22
|
+
asset: StoredAsset
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
function avalanche(value: number) {
|
|
@@ -52,7 +62,11 @@ function textFingerprint(text: string, contentType: string) {
|
|
|
52
62
|
return fingerprint.digest()
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
async function fingerprintBlobStream(
|
|
66
|
+
blob: Blob,
|
|
67
|
+
contentType: string,
|
|
68
|
+
chunks?: BlobPart[],
|
|
69
|
+
) {
|
|
56
70
|
const fingerprint = new AssetFingerprint()
|
|
57
71
|
fingerprintHeader(fingerprint, contentType)
|
|
58
72
|
const reader = blob.stream().getReader()
|
|
@@ -61,12 +75,25 @@ export async function blobFingerprint(blob: Blob, contentType: string) {
|
|
|
61
75
|
const chunk = await reader.read()
|
|
62
76
|
if (chunk.done) return fingerprint.digest()
|
|
63
77
|
fingerprint.update(chunk.value)
|
|
78
|
+
chunks?.push(new Uint8Array(chunk.value).buffer)
|
|
64
79
|
}
|
|
65
80
|
} finally {
|
|
66
81
|
reader.releaseLock()
|
|
67
82
|
}
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
export function blobFingerprint(blob: Blob, contentType: string) {
|
|
86
|
+
return fingerprintBlobStream(blob, contentType)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fingerprintedSnapshot(blob: Blob, contentType: string) {
|
|
90
|
+
const chunks: BlobPart[] = []
|
|
91
|
+
return {
|
|
92
|
+
hash: await fingerprintBlobStream(blob, contentType, chunks),
|
|
93
|
+
body: new Blob(chunks, { type: contentType }),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
function validateAssetName(name: string) {
|
|
71
98
|
if (!ASSET_NAME.test(name)) {
|
|
72
99
|
throw new Error(`Invalid generated asset name: ${name}`)
|
|
@@ -80,18 +107,34 @@ function assetHref(name: string, hash: string) {
|
|
|
80
107
|
return `/.pmx/${stem}-${hash}${ext}`
|
|
81
108
|
}
|
|
82
109
|
|
|
110
|
+
function authoredHref(sourcePath: string, hash: string) {
|
|
111
|
+
const slash = sourcePath.lastIndexOf("/")
|
|
112
|
+
const directory = sourcePath.slice(0, slash + 1)
|
|
113
|
+
const name = sourcePath.slice(slash + 1)
|
|
114
|
+
const dot = name.lastIndexOf(".")
|
|
115
|
+
const stem = dot <= 0 ? name : name.slice(0, dot)
|
|
116
|
+
const extension = dot <= 0 ? "" : name.slice(dot)
|
|
117
|
+
return `${directory}${stem}-${hash}${extension}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function authoredSourcePath(value: string) {
|
|
121
|
+
const path = canonicalPath(value)
|
|
122
|
+
if (path === "/" || path === "/.pmx" || path.startsWith("/.pmx/")) {
|
|
123
|
+
throw new Error(`Invalid authored asset path: ${value}`)
|
|
124
|
+
}
|
|
125
|
+
return path
|
|
126
|
+
}
|
|
127
|
+
|
|
83
128
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* policy here. Repository assets may share that store, but keep their own
|
|
88
|
-
* routing and access rules; this LRU must not be their durable CDN origin.
|
|
129
|
+
* Retains generated and authored derivatives in one local bounded cache.
|
|
130
|
+
* Repository assets keep distinct routing and access rules; this LRU is not a
|
|
131
|
+
* durable CDN origin.
|
|
89
132
|
*/
|
|
90
|
-
export class
|
|
91
|
-
private assets = new Map<string,
|
|
92
|
-
private latest = new Map<string,
|
|
133
|
+
export class AssetStore {
|
|
134
|
+
private assets = new Map<string, RetainedAsset>()
|
|
135
|
+
private latest = new Map<string, StoredAsset & { href: string }>()
|
|
93
136
|
|
|
94
|
-
constructor(private limit =
|
|
137
|
+
constructor(private limit = ASSET_STORE_LIMIT) {}
|
|
95
138
|
|
|
96
139
|
get(href: string) {
|
|
97
140
|
const stored = this.assets.get(href)
|
|
@@ -107,42 +150,84 @@ export class GeneratedAssets {
|
|
|
107
150
|
)
|
|
108
151
|
}
|
|
109
152
|
|
|
153
|
+
delete(href: string) {
|
|
154
|
+
const stored = this.assets.get(href)
|
|
155
|
+
if (!stored) return
|
|
156
|
+
this.assets.delete(href)
|
|
157
|
+
if (this.latest.get(stored.key)?.href === href) {
|
|
158
|
+
this.latest.delete(stored.key)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
110
162
|
publishText(name: string, text: string, contentType: string) {
|
|
111
163
|
validateAssetName(name)
|
|
112
|
-
const
|
|
113
|
-
|
|
164
|
+
const key = `generated:${name}`
|
|
165
|
+
const current = this.latest.get(key)
|
|
166
|
+
if (
|
|
167
|
+
current?.type === "generated" &&
|
|
168
|
+
current.body === text &&
|
|
169
|
+
current.contentType === contentType
|
|
170
|
+
) {
|
|
114
171
|
this.get(current.href)
|
|
115
172
|
return current.href
|
|
116
173
|
}
|
|
117
174
|
const href = assetHref(name, textFingerprint(text, contentType))
|
|
118
|
-
this.retain(
|
|
175
|
+
this.retain(key, href, {
|
|
176
|
+
type: "generated",
|
|
177
|
+
body: text,
|
|
178
|
+
contentType,
|
|
179
|
+
})
|
|
119
180
|
return href
|
|
120
181
|
}
|
|
121
182
|
|
|
122
183
|
async publishBlob(name: string, blob: Blob, contentType = blob.type) {
|
|
123
184
|
validateAssetName(name)
|
|
124
185
|
contentType ||= "application/octet-stream"
|
|
125
|
-
const
|
|
126
|
-
|
|
186
|
+
const key = `generated:${name}`
|
|
187
|
+
const current = this.latest.get(key)
|
|
188
|
+
if (
|
|
189
|
+
current?.type === "generated" &&
|
|
190
|
+
current.body === blob &&
|
|
191
|
+
current.contentType === contentType
|
|
192
|
+
) {
|
|
127
193
|
this.get(current.href)
|
|
128
194
|
return current.href
|
|
129
195
|
}
|
|
130
196
|
const href = assetHref(name, await blobFingerprint(blob, contentType))
|
|
131
|
-
this.retain(
|
|
197
|
+
this.retain(key, href, {
|
|
198
|
+
type: "generated",
|
|
199
|
+
body: blob,
|
|
200
|
+
contentType,
|
|
201
|
+
})
|
|
202
|
+
return href
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async publishAuthored(source: string, blob: Blob, contentType = blob.type) {
|
|
206
|
+
const sourcePath = authoredSourcePath(source)
|
|
207
|
+
contentType ||= "application/octet-stream"
|
|
208
|
+
const key = `authored:${sourcePath}`
|
|
209
|
+
const snapshot = await fingerprintedSnapshot(blob, contentType)
|
|
210
|
+
const href = authoredHref(sourcePath, snapshot.hash)
|
|
211
|
+
this.retain(key, href, {
|
|
212
|
+
type: "authored",
|
|
213
|
+
sourcePath,
|
|
214
|
+
body: snapshot.body,
|
|
215
|
+
contentType,
|
|
216
|
+
})
|
|
132
217
|
return href
|
|
133
218
|
}
|
|
134
219
|
|
|
135
|
-
private retain(
|
|
220
|
+
private retain(key: string, href: string, asset: StoredAsset) {
|
|
136
221
|
this.assets.delete(href)
|
|
137
|
-
this.assets.set(href, {
|
|
138
|
-
this.latest.set(
|
|
222
|
+
this.assets.set(href, { key, asset })
|
|
223
|
+
this.latest.set(key, { ...asset, href })
|
|
139
224
|
|
|
140
225
|
while (this.assets.size > this.limit) {
|
|
141
226
|
const oldestHref = this.assets.keys().next().value!
|
|
142
|
-
const {
|
|
227
|
+
const { key: oldestKey } = this.assets.get(oldestHref)!
|
|
143
228
|
this.assets.delete(oldestHref)
|
|
144
|
-
if (this.latest.get(
|
|
145
|
-
this.latest.delete(
|
|
229
|
+
if (this.latest.get(oldestKey)?.href === oldestHref) {
|
|
230
|
+
this.latest.delete(oldestKey)
|
|
146
231
|
}
|
|
147
232
|
}
|
|
148
233
|
}
|
package/cli/dev.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import watcher from "@parcel/watcher"
|
|
2
|
-
import { fileURLToPath } from "node:url"
|
|
3
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
4
3
|
import type { Command } from "commander"
|
|
5
4
|
import { defaultPluginsRoot } from "../environments/bun-plugins.ts"
|
|
@@ -53,7 +52,9 @@ async function runWatched(
|
|
|
53
52
|
resolved: { root: string; actionsRoot?: string },
|
|
54
53
|
port?: number,
|
|
55
54
|
) {
|
|
56
|
-
const
|
|
55
|
+
const invokedEntry = process.argv[1]
|
|
56
|
+
if (!invokedEntry) throw new Error("PathMX CLI entrypoint is unavailable.")
|
|
57
|
+
const entry = resolve(invokedEntry)
|
|
57
58
|
const args = [process.execPath, "--watch", entry, "dev", resolved.root]
|
|
58
59
|
if (resolved.actionsRoot) args.push("--actions", resolved.actionsRoot)
|
|
59
60
|
if (port !== undefined) args.push("--port", String(port))
|
package/cli/lint.ts
CHANGED
|
@@ -19,7 +19,13 @@ export type InvalidSourceIssue = Readonly<{
|
|
|
19
19
|
message: string
|
|
20
20
|
}>
|
|
21
21
|
|
|
22
|
-
export type
|
|
22
|
+
export type SourceWarning = Readonly<{
|
|
23
|
+
type: "source.warning"
|
|
24
|
+
source: string
|
|
25
|
+
message: string
|
|
26
|
+
}>
|
|
27
|
+
|
|
28
|
+
export type LintIssue = UnresolvedLinkIssue | InvalidSourceIssue | SourceWarning
|
|
23
29
|
|
|
24
30
|
export function collectUnresolved(app: Engine) {
|
|
25
31
|
const issues: UnresolvedLinkIssue[] = []
|
|
@@ -37,9 +43,16 @@ export function collectUnresolved(app: Engine) {
|
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
function issueKey(issue: LintIssue) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
switch (issue.type) {
|
|
47
|
+
case "link.unresolved":
|
|
48
|
+
return `${issue.type}\0${issue.source}\0${issue.href}\0${issue.label ?? ""}`
|
|
49
|
+
case "source.invalid":
|
|
50
|
+
return `${issue.type}\0${issue.source}\0${issue.plugin}\0${issue.message}`
|
|
51
|
+
case "source.warning":
|
|
52
|
+
return `${issue.type}\0${issue.source}\0${issue.message}`
|
|
53
|
+
default:
|
|
54
|
+
return issue satisfies never
|
|
55
|
+
}
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
function compareIssues(a: LintIssue, b: LintIssue) {
|
|
@@ -60,6 +73,7 @@ export function formatLintIssues(issues: readonly LintIssue[]) {
|
|
|
60
73
|
|
|
61
74
|
const links: UnresolvedLinkIssue[] = []
|
|
62
75
|
const authoring: InvalidSourceIssue[] = []
|
|
76
|
+
const warnings: SourceWarning[] = []
|
|
63
77
|
for (const issue of issues) {
|
|
64
78
|
switch (issue.type) {
|
|
65
79
|
case "link.unresolved":
|
|
@@ -68,6 +82,9 @@ export function formatLintIssues(issues: readonly LintIssue[]) {
|
|
|
68
82
|
case "source.invalid":
|
|
69
83
|
authoring.push(issue)
|
|
70
84
|
break
|
|
85
|
+
case "source.warning":
|
|
86
|
+
warnings.push(issue)
|
|
87
|
+
break
|
|
71
88
|
default:
|
|
72
89
|
issue satisfies never
|
|
73
90
|
}
|
|
@@ -89,9 +106,24 @@ export function formatLintIssues(issues: readonly LintIssue[]) {
|
|
|
89
106
|
].join("\n"),
|
|
90
107
|
)
|
|
91
108
|
}
|
|
109
|
+
if (warnings.length) {
|
|
110
|
+
sections.push(
|
|
111
|
+
[
|
|
112
|
+
pc.bold(`Warnings (${warnings.length})`),
|
|
113
|
+
...warnings.map((issue) => `${issue.source}: ${issue.message}`),
|
|
114
|
+
].join("\n"),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
92
117
|
|
|
93
|
-
const
|
|
94
|
-
|
|
118
|
+
const failureCount = links.length + authoring.length
|
|
119
|
+
if (failureCount) {
|
|
120
|
+
const noun = failureCount === 1 ? "issue" : "issues"
|
|
121
|
+
sections.push(pc.red(`${failureCount} ${noun} found`))
|
|
122
|
+
}
|
|
123
|
+
if (warnings.length) {
|
|
124
|
+
const noun = warnings.length === 1 ? "warning" : "warnings"
|
|
125
|
+
sections.push(pc.yellow(`${warnings.length} ${noun} found`))
|
|
126
|
+
}
|
|
95
127
|
return sections.join("\n\n")
|
|
96
128
|
}
|
|
97
129
|
|
|
@@ -114,6 +146,15 @@ export async function collectLintIssues(app: Engine) {
|
|
|
114
146
|
addDiagnostic(diagnostic)
|
|
115
147
|
}
|
|
116
148
|
for (const source of app.repo.all()) {
|
|
149
|
+
if (Object.hasOwn(source.data, "id")) {
|
|
150
|
+
const warning: SourceWarning = {
|
|
151
|
+
type: "source.warning",
|
|
152
|
+
source: source.path,
|
|
153
|
+
message:
|
|
154
|
+
'Source frontmatter "id" is ignored; Source ids are derived from file paths. Remove this field.',
|
|
155
|
+
}
|
|
156
|
+
issues.set(issueKey(warning), warning)
|
|
157
|
+
}
|
|
117
158
|
if (!app.isPage(source)) continue
|
|
118
159
|
try {
|
|
119
160
|
await app.compilePage(source)
|
|
@@ -155,8 +196,9 @@ export function registerLint(program: Command) {
|
|
|
155
196
|
})
|
|
156
197
|
const issues = await collectLintIssues(app)
|
|
157
198
|
console.log(formatLintIssues(issues))
|
|
158
|
-
if (
|
|
159
|
-
|
|
199
|
+
if (issues.some((issue) => issue.type !== "source.warning")) {
|
|
200
|
+
process.exitCode = 1
|
|
201
|
+
}
|
|
160
202
|
} catch (error) {
|
|
161
203
|
fail(error)
|
|
162
204
|
}
|
package/cli/update.ts
CHANGED
|
@@ -35,15 +35,52 @@ export async function availableRelease(
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async function installRelease(spec: string) {
|
|
38
|
-
const child = Bun.spawn(
|
|
38
|
+
const child = Bun.spawn(
|
|
39
|
+
[process.execPath, "add", "--global", "--exact", spec],
|
|
40
|
+
{
|
|
41
|
+
stdin: "inherit",
|
|
42
|
+
stdout: "inherit",
|
|
43
|
+
stderr: "inherit",
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
return child.exited
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function globalPackageVersion(output: string, packageName: string) {
|
|
50
|
+
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
51
|
+
return output.match(new RegExp(`${escaped}@([^\\s]+)`))?.[1]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function installedGlobalVersion() {
|
|
55
|
+
const child = Bun.spawn([process.execPath, "pm", "ls", "--global"], {
|
|
39
56
|
stdin: "inherit",
|
|
40
|
-
stdout: "
|
|
41
|
-
stderr: "
|
|
57
|
+
stdout: "pipe",
|
|
58
|
+
stderr: "pipe",
|
|
42
59
|
})
|
|
43
|
-
const exitCode = await
|
|
60
|
+
const [stdout, exitCode] = await Promise.all([
|
|
61
|
+
new Response(child.stdout).text(),
|
|
62
|
+
child.exited,
|
|
63
|
+
])
|
|
64
|
+
if (exitCode !== 0) return
|
|
65
|
+
return globalPackageVersion(stdout, release.package)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type InstallRelease = (spec: string) => Promise<number>
|
|
69
|
+
type InstalledVersion = () => Promise<string | undefined>
|
|
70
|
+
|
|
71
|
+
export async function installCheckedRelease(
|
|
72
|
+
spec: string,
|
|
73
|
+
expected: string,
|
|
74
|
+
install: InstallRelease = installRelease,
|
|
75
|
+
installed: InstalledVersion = installedGlobalVersion,
|
|
76
|
+
) {
|
|
77
|
+
const exitCode = await install(spec)
|
|
44
78
|
if (exitCode !== 0) {
|
|
79
|
+
if ((await installed()) === expected)
|
|
80
|
+
return { type: "installed-with-warning" as const, exitCode }
|
|
45
81
|
throw new Error(`Bun could not install ${spec} (exit ${exitCode}).`)
|
|
46
82
|
}
|
|
83
|
+
return { type: "installed" as const }
|
|
47
84
|
}
|
|
48
85
|
|
|
49
86
|
export function registerUpdate(program: Command) {
|
|
@@ -70,7 +107,12 @@ export function registerUpdate(program: Command) {
|
|
|
70
107
|
console.log(
|
|
71
108
|
`Updating ${release.package} ${release.version} → ${available}`,
|
|
72
109
|
)
|
|
73
|
-
await
|
|
110
|
+
const result = await installCheckedRelease(releaseSpec(), available)
|
|
111
|
+
if (result.type === "installed-with-warning") {
|
|
112
|
+
console.warn(
|
|
113
|
+
`warning: Bun exited with ${result.exitCode}, but ${release.package}@${available} was installed. Check your Bun global package manifest for an unrelated invalid dependency.`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
74
116
|
console.log(
|
|
75
117
|
`PathMX ${available} installed. Run \`pathmx --version\` to verify.`,
|
|
76
118
|
)
|