@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 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 mermaid react
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 GENERATED_ASSET_LIMIT = 128
4
+ export const ASSET_STORE_LIMIT = 128
3
5
  const encoder = new TextEncoder()
4
6
 
5
- export type ServedAsset = Readonly<{
6
- body: string | Blob
7
- contentType: string
8
- }>
9
-
10
- type StoredAsset = {
11
- name: string
12
- asset: ServedAsset
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
- export async function blobFingerprint(blob: Blob, contentType: string) {
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
- * Publishes generated assets into the local bounded cache. A future CDN or
85
- * object-storage backend should extract byte retention behind a general
86
- * `AssetStore` (`put`, `get`, `href`) while keeping fingerprinting and key
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 GeneratedAssets {
91
- private assets = new Map<string, StoredAsset>()
92
- private latest = new Map<string, ServedAsset & { href: 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 = GENERATED_ASSET_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 current = this.latest.get(name)
113
- if (current?.body === text && current.contentType === contentType) {
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(name, href, { body: text, contentType })
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 current = this.latest.get(name)
126
- if (current?.body === blob && current.contentType === contentType) {
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(name, href, { body: blob, contentType })
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(name: string, href: string, asset: ServedAsset) {
220
+ private retain(key: string, href: string, asset: StoredAsset) {
136
221
  this.assets.delete(href)
137
- this.assets.set(href, { name, asset })
138
- this.latest.set(name, { ...asset, href })
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 { name: oldestName } = this.assets.get(oldestHref)!
227
+ const { key: oldestKey } = this.assets.get(oldestHref)!
143
228
  this.assets.delete(oldestHref)
144
- if (this.latest.get(oldestName)?.href === oldestHref) {
145
- this.latest.delete(oldestName)
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 entry = fileURLToPath(new URL("./index.ts", import.meta.url))
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 LintIssue = UnresolvedLinkIssue | InvalidSourceIssue
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
- return issue.type === "link.unresolved"
41
- ? `${issue.type}\0${issue.source}\0${issue.href}\0${issue.label ?? ""}`
42
- : `${issue.type}\0${issue.source}\0${issue.plugin}\0${issue.message}`
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 noun = issues.length === 1 ? "issue" : "issues"
94
- sections.push(pc.red(`${issues.length} ${noun} found`))
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 (!issues.length) return
159
- process.exitCode = 1
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([process.execPath, "add", "--global", spec], {
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: "inherit",
41
- stderr: "inherit",
57
+ stdout: "pipe",
58
+ stderr: "pipe",
42
59
  })
43
- const exitCode = await child.exited
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 installRelease(releaseSpec())
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
  )