@pathmx/core 0.5.1 → 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,6 +55,10 @@ 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
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
  )
@@ -15,6 +15,7 @@ import type {
15
15
  HtmlRewrite,
16
16
  PathMXEnvironment,
17
17
  } from "../environment.ts"
18
+ import { isAuthoredStoragePath } from "../storage-path.ts"
18
19
 
19
20
  export { publishToDirectory, writePublication } from "./bun-publish.ts"
20
21
 
@@ -201,9 +202,10 @@ export const bunEnvironment: PathMXEnvironment = {
201
202
  stringify: (value) => YAML.stringify(value, null, 2),
202
203
  },
203
204
  storage: {
204
- list: (root) => [
205
- ...new Bun.Glob("**/*").scanSync({ cwd: root, onlyFiles: true }),
206
- ],
205
+ list: (root) =>
206
+ [
207
+ ...new Bun.Glob("**/*").scanSync({ cwd: root, onlyFiles: true }),
208
+ ].filter(isAuthoredStoragePath),
207
209
  readText: (path) => Bun.file(path).text(),
208
210
  readBlob: (path) => Bun.file(path),
209
211
  writeText: async (path, content) => {
package/host/engine.ts CHANGED
@@ -434,6 +434,13 @@ export class Engine {
434
434
  "index.source",
435
435
  () => {
436
436
  this.forgetNode(source.path)
437
+ if (Object.hasOwn(source.data, "id")) {
438
+ this.log.warn("source.id.ignored", {
439
+ source: source.path,
440
+ message:
441
+ 'Source frontmatter "id" is ignored; Source ids are derived from file paths.',
442
+ })
443
+ }
437
444
  this.compiler.indexSource(source)
438
445
  this.authorityErrors.delete(source.id)
439
446
  const capture = (error: unknown) => {
package/ops/href.ts CHANGED
@@ -23,16 +23,6 @@ export function isRelativeHref(pathname: string) {
23
23
  )
24
24
  }
25
25
 
26
- export function authoredId(source: Source) {
27
- const id = source.data.id
28
- return typeof id === "string" && id.trim() ? id.trim() : undefined
29
- }
30
-
31
- export function isAuthoredIdHref(pathname: string, source: Source) {
32
- const id = authoredId(source)
33
- return Boolean(id && (pathname === id || pathname === `/${id}`))
34
- }
35
-
36
26
  export function relativeHref(
37
27
  fromPath: string,
38
28
  targetPath: string,
@@ -75,7 +65,6 @@ export function retargetHref(
75
65
  ): string | undefined {
76
66
  const { pathname, query, fragment } = splitHref(href)
77
67
  if (!pathname) return
78
- if (isAuthoredIdHref(pathname, oldSource)) return
79
68
 
80
69
  const explicitMd = pathname.endsWith(".md")
81
70
  const target = explicitMd ? newSource.path : publicPath(newSource)
@@ -3,7 +3,6 @@ import { posixPath } from "../path.ts"
3
3
  import { isIndexSource, publicPath } from "../server/router.ts"
4
4
  import { createSource, rewriteLinks, type Source } from "../source/index.ts"
5
5
  import {
6
- authoredId,
7
6
  isRelativeHref,
8
7
  rebaseRelativeHref,
9
8
  retargetHref,
@@ -104,9 +103,12 @@ export async function moveSource(
104
103
  `Moving index source ${source.path} changes the directory route ${publicPath(source)}`,
105
104
  )
106
105
  }
107
- if (!authoredId(source) && source.id !== preview.id) {
106
+ if (
107
+ !isIndexSource(source) &&
108
+ publicPath(source) !== publicPath(preview)
109
+ ) {
108
110
  plan.warnings.push(
109
- `Moving ${source.path} changes its public URL from ${source.id} to ${preview.id}`,
111
+ `Moving ${source.path} changes its public URL from ${publicPath(source)} to ${publicPath(preview)}`,
110
112
  )
111
113
  }
112
114
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pathmx/core",
3
3
  "description": "A minimal, plugin-first framework for Paths and learning applications.",
4
- "version": "0.5.1",
4
+ "version": "0.5.2",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
7
7
  "type": "git",
package/server/watch.ts CHANGED
@@ -3,6 +3,7 @@ import { realpathSync } from "node:fs"
3
3
  import { relative } from "node:path"
4
4
  import type { Engine } from "../host/engine.ts"
5
5
  import { SourceDiagnosticError } from "../source-diagnostic.ts"
6
+ import { isAuthoredStoragePath } from "../storage-path.ts"
6
7
  import { CssImportIndex } from "./css-imports.ts"
7
8
 
8
9
  type UnresolvedLink = {
@@ -91,7 +92,13 @@ export function watchPaths(app: Engine, root: string) {
91
92
  const changedStyles = new Set<string>()
92
93
  for (const event of events) {
93
94
  const rel = relative(base, event.path)
94
- if (!rel || rel.startsWith("..")) continue
95
+ if (
96
+ !rel ||
97
+ rel.startsWith("..") ||
98
+ !isAuthoredStoragePath(rel)
99
+ ) {
100
+ continue
101
+ }
95
102
  app.log.debug("watch", { file: rel, type: event.type })
96
103
  if (rel.endsWith(".md")) await app.syncFromStorage(rel)
97
104
  else if (rel.endsWith(".css")) {
package/source/source.ts CHANGED
@@ -49,7 +49,6 @@ export function sourceIdFromPath(path: string, type = DEFAULT_SOURCE_TYPE) {
49
49
  }
50
50
 
51
51
  export function resolveSourceId(path: string, data: Topmatter) {
52
- if (typeof data.id === "string" && data.id.trim()) return data.id.trim()
53
52
  const type =
54
53
  typeof data.type === "string" && data.type.trim()
55
54
  ? data.type.trim()
@@ -0,0 +1,8 @@
1
+ const CONTROL_DIRECTORIES = new Set([".git", ".pathmx", "node_modules"])
2
+
3
+ /** True when a storage-relative path belongs to authored project content. */
4
+ export function isAuthoredStoragePath(path: string) {
5
+ return path
6
+ .split(/[\\/]/)
7
+ .every((segment) => !CONTROL_DIRECTORIES.has(segment))
8
+ }