@pathmx/core 0.5.0 → 0.5.1

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
@@ -58,11 +58,15 @@ const app = await createApp({
58
58
  Official packages follow the CLI's release channel:
59
59
 
60
60
  ```sh
61
- pathmx plugins add mermaid react
61
+ pathmx plugins add paths
62
+ pathmx plugins add auth mermaid
62
63
  pathmx plugins list
63
64
  pathmx plugins update
64
65
  ```
65
66
 
67
+ `paths` installs the provider-neutral Path, Completion, and Player experience.
68
+ Auth remains provider-configured and Mermaid remains opt-in.
69
+
66
70
  ## Image directives
67
71
 
68
72
  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))