blume 1.1.3 → 1.2.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/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/dist/cli/index.js +1473 -149
- package/dist/cli/index.js.map +47 -36
- package/dist/types/core/config-input.d.ts +18 -0
- package/dist/types/core/config.d.ts +4 -0
- package/dist/types/core/data.d.ts +3 -0
- package/dist/types/core/schema.d.ts +132 -17
- package/dist/types/core/types.d.ts +5 -3
- package/dist/types/openapi/references.d.ts +6 -0
- package/docs/advanced/api-reference.mdx +27 -0
- package/docs/advanced/changelog.mdx +10 -0
- package/docs/configuration/ai.mdx +38 -2
- package/docs/configuration/customization.mdx +27 -0
- package/docs/configuration/index.mdx +5 -0
- package/docs/content/navigation.mdx +12 -0
- package/docs/reference/cli.mdx +17 -13
- package/docs/reference/eval.mdx +106 -0
- package/docs/reference/meta.ts +1 -1
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +19 -1
- package/src/ai/llms.ts +9 -4
- package/src/ai/mcp/server.ts +48 -14
- package/src/ai/mcp/stdio.ts +35 -0
- package/src/astro/generate.ts +119 -48
- package/src/astro/templates.ts +173 -37
- package/src/audit/checks/duplicates.ts +15 -6
- package/src/audit/checks/indexability.ts +11 -2
- package/src/audit/checks/network.ts +22 -8
- package/src/audit/checks/sitemap.ts +42 -16
- package/src/audit/redirects.ts +12 -1
- package/src/audit/run.ts +13 -3
- package/src/audit/url.ts +21 -2
- package/src/cli/commands/audit.ts +21 -6
- package/src/cli/commands/dev.ts +19 -2
- package/src/cli/commands/eval.ts +291 -0
- package/src/cli/commands/init.ts +9 -4
- package/src/cli/commands/mcp-stdio.ts +36 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/required-secrets.ts +1 -1
- package/src/components/content/AccordionItem.astro +2 -2
- package/src/components/content/Frame.astro +4 -1
- package/src/components/content/Prompt.astro +4 -1
- package/src/components/content/Tooltip.astro +4 -1
- package/src/components/content/TreeFolder.astro +1 -2
- package/src/components/content/Update.astro +45 -0
- package/src/components/islands/AskAI.astro +9 -2
- package/src/components/islands/ask-ai.tsx +23 -4
- package/src/components/islands/hooks.ts +48 -15
- package/src/components/layout/NavTree.astro +37 -19
- package/src/components/layout/ReferenceLayout.astro +4 -0
- package/src/components/layout/RootLayout.astro +14 -3
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/components/openapi/SchemaProperty.astro +3 -3
- package/src/core/config-input.ts +18 -0
- package/src/core/config.ts +4 -0
- package/src/core/data.ts +3 -0
- package/src/core/deployment-env.ts +7 -2
- package/src/core/graph.ts +8 -1
- package/src/core/i18n.ts +10 -2
- package/src/core/navigation.ts +16 -5
- package/src/core/schema.ts +51 -4
- package/src/core/server-features.ts +1 -1
- package/src/core/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/core/types.ts +5 -3
- package/src/eval/agents.ts +340 -0
- package/src/eval/findings.ts +103 -0
- package/src/eval/prompts.ts +78 -0
- package/src/eval/report.ts +214 -0
- package/src/eval/run.ts +290 -0
- package/src/eval/schema.ts +124 -0
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/references.ts +23 -2
- package/src/openapi/render-mdx.ts +39 -11
- package/src/openapi/scalar.ts +1 -0
- package/src/openapi/source.ts +11 -4
- package/src/registry/eject.ts +23 -1
- package/src/search/build.ts +4 -3
|
@@ -1,17 +1,24 @@
|
|
|
1
|
+
import { normalizeBasePath, stripBasePath } from "../../core/base-path.ts";
|
|
1
2
|
import type { Diagnostic } from "../../core/types.ts";
|
|
2
3
|
import { finding } from "../catalog.ts";
|
|
3
4
|
import type { CheckId } from "../catalog.ts";
|
|
4
5
|
import { pageSite } from "../locate.ts";
|
|
5
6
|
import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
|
|
7
|
+
import { decodePath } from "../url.ts";
|
|
6
8
|
|
|
7
|
-
const isNonCanonical = (page: PageSnapshot): boolean => {
|
|
9
|
+
const isNonCanonical = (page: PageSnapshot, deployBase: string): boolean => {
|
|
8
10
|
if (!page.canonical) {
|
|
9
11
|
return false;
|
|
10
12
|
}
|
|
11
13
|
try {
|
|
14
|
+
// Canonicals are emitted as `site + base + route`; page URLs carry no
|
|
15
|
+
// deployment base — without stripping it, every page of a subpath
|
|
16
|
+
// deployment would look non-canonical and escape these checks entirely.
|
|
12
17
|
return (
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
stripBasePath(
|
|
19
|
+
deployBase,
|
|
20
|
+
decodePath(new URL(page.canonical).pathname)
|
|
21
|
+
).replace(/\/$/u, "") !== page.url.replace(/\/$/u, "")
|
|
15
22
|
);
|
|
16
23
|
} catch {
|
|
17
24
|
return false;
|
|
@@ -19,8 +26,9 @@ const isNonCanonical = (page: PageSnapshot): boolean => {
|
|
|
19
26
|
};
|
|
20
27
|
|
|
21
28
|
/** Pages that can meaningfully be compared against each other for duplication. */
|
|
22
|
-
const comparable = (context: AuditContext): PageSnapshot[] =>
|
|
23
|
-
context.
|
|
29
|
+
const comparable = (context: AuditContext): PageSnapshot[] => {
|
|
30
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
31
|
+
return context.pages.filter(
|
|
24
32
|
(page) =>
|
|
25
33
|
page.indexable &&
|
|
26
34
|
// A fallback page renders the default locale's content at a localized URL.
|
|
@@ -29,8 +37,9 @@ const comparable = (context: AuditContext): PageSnapshot[] =>
|
|
|
29
37
|
!page.route?.fallback &&
|
|
30
38
|
// A page that points its canonical elsewhere has already declared itself a
|
|
31
39
|
// duplicate; that's the mechanism working, not a finding.
|
|
32
|
-
!isNonCanonical(page)
|
|
40
|
+
!isNonCanonical(page, deployBase)
|
|
33
41
|
);
|
|
42
|
+
};
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
45
|
* Group pages by a value and report every group with more than one member.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { normalizeBasePath, stripBasePath } from "../../core/base-path.ts";
|
|
1
2
|
import { SITE_INFERRING_ADAPTERS } from "../../core/deployment-env.ts";
|
|
2
3
|
import type { Diagnostic } from "../../core/types.ts";
|
|
3
4
|
import { finding } from "../catalog.ts";
|
|
4
5
|
import { pageSite } from "../locate.ts";
|
|
5
6
|
import { ERROR_ROUTES } from "../types.ts";
|
|
6
7
|
import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
|
|
7
|
-
import { normalizePath, siteOrigin } from "../url.ts";
|
|
8
|
+
import { decodePath, normalizePath, siteOrigin } from "../url.ts";
|
|
8
9
|
|
|
9
10
|
/** The canonical URL parsed, or null when it isn't a usable absolute URL. */
|
|
10
11
|
const parseCanonical = (page: PageSnapshot): URL | null => {
|
|
@@ -76,7 +77,15 @@ const canonicalChecks = (
|
|
|
76
77
|
return found;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
// Canonicals are emitted as `site + base + route`; page URLs and `byUrl`
|
|
81
|
+
// keys carry no deployment base, so strip it (and percent-encoding) before
|
|
82
|
+
// comparing.
|
|
83
|
+
const target = normalizePath(
|
|
84
|
+
stripBasePath(
|
|
85
|
+
normalizeBasePath(context.project.config.deployment.base),
|
|
86
|
+
decodePath(canonical.pathname)
|
|
87
|
+
)
|
|
88
|
+
);
|
|
80
89
|
if (target === normalizePath(page.url)) {
|
|
81
90
|
return found;
|
|
82
91
|
}
|
|
@@ -12,9 +12,17 @@ const SERVER_ERROR = 500;
|
|
|
12
12
|
/** Past this, a page is slow enough that it costs you crawl budget and readers. */
|
|
13
13
|
const SLOW_MS = 1500;
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The live URL a built page is served at, under the `--url` origin. Page URLs
|
|
17
|
+
* come from the built file tree and carry no `deployment.base`, but the live
|
|
18
|
+
* site serves everything under it — probing without the base would 4xx every
|
|
19
|
+
* page of a healthy subpath deployment.
|
|
20
|
+
*/
|
|
21
|
+
const liveUrl = (
|
|
22
|
+
origin: string,
|
|
23
|
+
page: PageSnapshot,
|
|
24
|
+
deployBase: string
|
|
25
|
+
): string => new URL(`${deployBase}${page.url}`, origin).toString();
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Whether the response failed outright, and how. Null when the page is served.
|
|
@@ -144,17 +152,23 @@ export const networkChecks: CheckModule = {
|
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
const found: Diagnostic[] = [];
|
|
147
|
-
const
|
|
155
|
+
const deployBase = normalizeBasePath(
|
|
156
|
+
context.project.config.deployment.base
|
|
157
|
+
);
|
|
158
|
+
const targets = context.pages.map((page) =>
|
|
159
|
+
liveUrl(origin, page, deployBase)
|
|
160
|
+
);
|
|
148
161
|
// robots.txt and sitemap.xml are fetched alongside the pages: they're the
|
|
149
162
|
// two files a crawler asks for first, and a deploy that hides them silently
|
|
150
|
-
// undoes everything else the audit checks.
|
|
151
|
-
|
|
152
|
-
const
|
|
163
|
+
// undoes everything else the audit checks. They sit at the root of the
|
|
164
|
+
// build output, which the host serves under the deployment base.
|
|
165
|
+
const robotsUrl = new URL(`${deployBase}/robots.txt`, origin).toString();
|
|
166
|
+
const sitemapUrl = new URL(`${deployBase}/sitemap.xml`, origin).toString();
|
|
153
167
|
|
|
154
168
|
const results = await probeAll([...targets, robotsUrl, sitemapUrl]);
|
|
155
169
|
|
|
156
170
|
for (const page of context.pages) {
|
|
157
|
-
const result = results.get(liveUrl(origin, page));
|
|
171
|
+
const result = results.get(liveUrl(origin, page, deployBase));
|
|
158
172
|
if (!result) {
|
|
159
173
|
continue;
|
|
160
174
|
}
|
|
@@ -3,7 +3,7 @@ import type { Diagnostic } from "../../core/types.ts";
|
|
|
3
3
|
import { finding } from "../catalog.ts";
|
|
4
4
|
import { pageSite } from "../locate.ts";
|
|
5
5
|
import type { AuditContext, CheckModule } from "../types.ts";
|
|
6
|
-
import { normalizePath, siteOrigin } from "../url.ts";
|
|
6
|
+
import { decodePath, normalizePath, siteOrigin } from "../url.ts";
|
|
7
7
|
|
|
8
8
|
const MAX_SITEMAP_BYTES = 50 * 1024 * 1024;
|
|
9
9
|
const MAX_SITEMAP_URLS = 50_000;
|
|
@@ -18,12 +18,24 @@ const LASTMOD_SLACK_MS = 24 * 60 * 60 * 1000;
|
|
|
18
18
|
/** Error routes are never crawlable destinations, so they belong out of the sitemap. */
|
|
19
19
|
const ERROR_ROUTES = new Set(["/404", "/500"]);
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Site paths listed in the sitemap, normalized for comparison against page
|
|
23
|
+
* URLs. `<loc>`s carry the deployment base and are `encodeURI`'d; page URLs
|
|
24
|
+
* (from the file tree) carry neither, so both are undone here.
|
|
25
|
+
*/
|
|
26
|
+
const sitemapPaths = (
|
|
27
|
+
context: AuditContext,
|
|
28
|
+
deployBase: string
|
|
29
|
+
): Map<string, string> => {
|
|
23
30
|
const paths = new Map<string, string>();
|
|
24
31
|
for (const loc of context.sitemap?.urls ?? []) {
|
|
25
32
|
try {
|
|
26
|
-
paths.set(
|
|
33
|
+
paths.set(
|
|
34
|
+
normalizePath(
|
|
35
|
+
stripBasePath(deployBase, decodePath(new URL(loc).pathname))
|
|
36
|
+
),
|
|
37
|
+
loc
|
|
38
|
+
);
|
|
27
39
|
} catch {
|
|
28
40
|
// A malformed <loc> is reported by SITEMAP_INVALID, not here.
|
|
29
41
|
}
|
|
@@ -31,10 +43,20 @@ const sitemapPaths = (context: AuditContext): Map<string, string> => {
|
|
|
31
43
|
return paths;
|
|
32
44
|
};
|
|
33
45
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The path of a canonical URL, or null when it isn't parseable
|
|
48
|
+
* (CANONICAL_BAD_TARGET reports that). Canonicals are emitted as
|
|
49
|
+
* `site + base + route`, so the deployment base is stripped to keep the result
|
|
50
|
+
* comparable against base-less page URLs.
|
|
51
|
+
*/
|
|
52
|
+
const canonicalPath = (
|
|
53
|
+
canonical: string,
|
|
54
|
+
deployBase: string
|
|
55
|
+
): string | null => {
|
|
36
56
|
try {
|
|
37
|
-
return normalizePath(
|
|
57
|
+
return normalizePath(
|
|
58
|
+
stripBasePath(deployBase, decodePath(new URL(canonical).pathname))
|
|
59
|
+
);
|
|
38
60
|
} catch {
|
|
39
61
|
return null;
|
|
40
62
|
}
|
|
@@ -45,7 +67,8 @@ const checkListedUrl = (
|
|
|
45
67
|
context: AuditContext,
|
|
46
68
|
loc: string,
|
|
47
69
|
origin: string | null,
|
|
48
|
-
file: string
|
|
70
|
+
file: string,
|
|
71
|
+
deployBase: string
|
|
49
72
|
): Diagnostic[] => {
|
|
50
73
|
let parsed: URL;
|
|
51
74
|
try {
|
|
@@ -70,12 +93,10 @@ const checkListedUrl = (
|
|
|
70
93
|
];
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
// `<loc>`s carry the deployment base; page URLs (from
|
|
96
|
+
// `<loc>`s carry the deployment base and are `encodeURI`'d; page URLs (from
|
|
97
|
+
// the file tree) are neither.
|
|
74
98
|
const path = normalizePath(
|
|
75
|
-
stripBasePath(
|
|
76
|
-
normalizeBasePath(context.project.config.deployment.base),
|
|
77
|
-
parsed.pathname
|
|
78
|
-
)
|
|
99
|
+
stripBasePath(deployBase, decodePath(parsed.pathname))
|
|
79
100
|
);
|
|
80
101
|
const page = context.byUrl.get(path);
|
|
81
102
|
if (!page) {
|
|
@@ -104,7 +125,7 @@ const checkListedUrl = (
|
|
|
104
125
|
);
|
|
105
126
|
}
|
|
106
127
|
|
|
107
|
-
const canonical = page.canonical && canonicalPath(page.canonical);
|
|
128
|
+
const canonical = page.canonical && canonicalPath(page.canonical, deployBase);
|
|
108
129
|
if (canonical && canonical !== path) {
|
|
109
130
|
found.push(
|
|
110
131
|
finding(
|
|
@@ -198,10 +219,15 @@ export const sitemapChecks: CheckModule = {
|
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
const origin = siteOrigin(site);
|
|
201
|
-
const
|
|
222
|
+
const deployBase = normalizeBasePath(
|
|
223
|
+
context.project.config.deployment.base
|
|
224
|
+
);
|
|
225
|
+
const listed = sitemapPaths(context, deployBase);
|
|
202
226
|
|
|
203
227
|
for (const loc of sitemap.urls) {
|
|
204
|
-
found.push(
|
|
228
|
+
found.push(
|
|
229
|
+
...checkListedUrl(context, loc, origin, sitemap.file, deployBase)
|
|
230
|
+
);
|
|
205
231
|
}
|
|
206
232
|
|
|
207
233
|
// The other direction: a page that was built, is indexable, and should be
|
package/src/audit/redirects.ts
CHANGED
|
@@ -19,6 +19,17 @@ interface ConfiguredRedirect {
|
|
|
19
19
|
* An external destination (`https://…`) is always `ok`: it's outside the site,
|
|
20
20
|
* so there's no local page to check it against.
|
|
21
21
|
*/
|
|
22
|
+
/**
|
|
23
|
+
* A destination's page path: the part before any query string or fragment. A
|
|
24
|
+
* redirect to `/guide#setup` or `/search?q=x` lands on the `/guide` / `/search`
|
|
25
|
+
* page — the suffix belongs to the browser, not the file tree, so keeping it
|
|
26
|
+
* would report a working redirect as broken.
|
|
27
|
+
*/
|
|
28
|
+
const pathOnly = (value: string): string => {
|
|
29
|
+
const cut = value.search(/[?#]/u);
|
|
30
|
+
return cut === -1 ? value : value.slice(0, cut);
|
|
31
|
+
};
|
|
32
|
+
|
|
22
33
|
export const resolveRedirects = (
|
|
23
34
|
redirects: readonly ConfiguredRedirect[],
|
|
24
35
|
pageUrls: ReadonlySet<string>
|
|
@@ -40,7 +51,7 @@ export const resolveRedirects = (
|
|
|
40
51
|
chain.push(current);
|
|
41
52
|
break;
|
|
42
53
|
}
|
|
43
|
-
const next = normalizePath(current);
|
|
54
|
+
const next = normalizePath(pathOnly(current));
|
|
44
55
|
if (seen.has(next)) {
|
|
45
56
|
chain.push(next);
|
|
46
57
|
return {
|
package/src/audit/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
import { normalizeBasePath } from "../core/base-path.ts";
|
|
3
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
4
4
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
5
5
|
import type { Diagnostic } from "../core/types.ts";
|
|
6
6
|
import { deployStaticDir } from "../deploy/adapter-output.ts";
|
|
@@ -123,9 +123,10 @@ const matches = (id: CheckId, terms: string[]): boolean => {
|
|
|
123
123
|
export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
|
|
124
124
|
const { project } = options;
|
|
125
125
|
const staticDir = deployStaticDir(project.config, project.context);
|
|
126
|
+
const basePath = normalizeBasePath(project.config.basePath);
|
|
126
127
|
|
|
127
128
|
const crawl = await crawlStaticDir({
|
|
128
|
-
basePath
|
|
129
|
+
basePath,
|
|
129
130
|
manifest: project.manifest,
|
|
130
131
|
staticDir,
|
|
131
132
|
});
|
|
@@ -148,7 +149,16 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
|
|
|
148
149
|
pages: crawl.pages,
|
|
149
150
|
project,
|
|
150
151
|
redirects: resolveRedirects(
|
|
151
|
-
|
|
152
|
+
// Redirects are authored as if mounted at root; the built page URLs they
|
|
153
|
+
// are checked against carry `basePath` (it's a real directory in the
|
|
154
|
+
// build), so both sides gain it here — mirroring what
|
|
155
|
+
// `applyBaseToAstroRedirects` does at build time. `withBasePath` is
|
|
156
|
+
// idempotent and leaves external `to` URLs untouched.
|
|
157
|
+
project.config.redirects.map((redirect) => ({
|
|
158
|
+
...redirect,
|
|
159
|
+
from: withBasePath(basePath, redirect.from),
|
|
160
|
+
to: withBasePath(basePath, redirect.to),
|
|
161
|
+
})),
|
|
152
162
|
// Pages and static files both: a redirect may legitimately land on a
|
|
153
163
|
// served asset (`/old-whitepaper` -> `/files/whitepaper.pdf`).
|
|
154
164
|
new Set(
|
package/src/audit/url.ts
CHANGED
|
@@ -22,6 +22,21 @@ export const normalizePath = (path: string): string => {
|
|
|
22
22
|
return trimmed === "" ? "/" : trimmed;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Percent-decode a pathname for comparison against the built file tree. Page
|
|
27
|
+
* URLs and file-index keys come from raw on-disk names, while `URL#pathname`
|
|
28
|
+
* (and the `encodeURI`'d sitemap `<loc>`s) are percent-encoded — a Japanese
|
|
29
|
+
* route would never match its own page without this. Malformed sequences are
|
|
30
|
+
* kept as-is: they can't have come from our own encoder.
|
|
31
|
+
*/
|
|
32
|
+
export const decodePath = (path: string): string => {
|
|
33
|
+
try {
|
|
34
|
+
return decodeURI(path);
|
|
35
|
+
} catch {
|
|
36
|
+
return path;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
25
40
|
/** The origin of `deployment.site`, or null when no site is configured. */
|
|
26
41
|
export const siteOrigin = (site?: string): string | null => {
|
|
27
42
|
if (!site) {
|
|
@@ -73,7 +88,9 @@ export const resolveHref = (
|
|
|
73
88
|
return {
|
|
74
89
|
hash: parsed.hash.slice(1),
|
|
75
90
|
kind: "self-origin",
|
|
76
|
-
path: normalizePath(
|
|
91
|
+
path: normalizePath(
|
|
92
|
+
stripBasePath(deployBase, decodePath(parsed.pathname))
|
|
93
|
+
),
|
|
77
94
|
};
|
|
78
95
|
}
|
|
79
96
|
return { kind: "external", url: parsed.toString() };
|
|
@@ -98,6 +115,8 @@ export const resolveHref = (
|
|
|
98
115
|
return {
|
|
99
116
|
hash: resolved.hash.slice(1),
|
|
100
117
|
kind: "internal",
|
|
101
|
-
path: normalizePath(
|
|
118
|
+
path: normalizePath(
|
|
119
|
+
stripBasePath(deployBase, decodePath(resolved.pathname))
|
|
120
|
+
),
|
|
102
121
|
};
|
|
103
122
|
};
|
|
@@ -40,6 +40,26 @@ export const shouldFail = (
|
|
|
40
40
|
return result.diagnostics.some((d) => failing.has(d.severity));
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Launch the agent CLI, translating a missing executable into the Windows
|
|
45
|
+
* not-found sentinel. Only `ENOENT` means "not installed" — any other spawn
|
|
46
|
+
* failure (`EACCES`, `EMFILE`, …) must surface as itself, not be masked by an
|
|
47
|
+
* irrelevant install hint.
|
|
48
|
+
*/
|
|
49
|
+
const launchAgentCode = async (
|
|
50
|
+
bin: string,
|
|
51
|
+
prompt: string
|
|
52
|
+
): Promise<number> => {
|
|
53
|
+
try {
|
|
54
|
+
return await launchAgent(bin, prompt);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return WINDOWS_COMMAND_NOT_FOUND;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
43
63
|
export const auditCommand = defineCommand({
|
|
44
64
|
args: {
|
|
45
65
|
claude: {
|
|
@@ -163,12 +183,7 @@ export const auditCommand = defineCommand({
|
|
|
163
183
|
process.stderr.write(
|
|
164
184
|
` Handing ${count} finding${count === 1 ? "" : "s"} to ${cli.name}…\n\n`
|
|
165
185
|
);
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
code = await launchAgent(cli.bin, fixPrompt(report));
|
|
169
|
-
} catch {
|
|
170
|
-
code = WINDOWS_COMMAND_NOT_FOUND;
|
|
171
|
-
}
|
|
186
|
+
const code = await launchAgentCode(cli.bin, fixPrompt(report));
|
|
172
187
|
// A POSIX spawn rejects on a missing executable; the Windows shell
|
|
173
188
|
// launch reports it through cmd.exe's 9009 instead. Same diagnosis.
|
|
174
189
|
if (code === WINDOWS_COMMAND_NOT_FOUND) {
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { watch } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
import { dev } from "astro";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
|
+
import { basename, dirname } from "pathe";
|
|
5
6
|
|
|
6
7
|
import { generateRuntime } from "../../astro/generate.ts";
|
|
7
8
|
import { showBlumeErrorOverlay } from "../../astro/integration.ts";
|
|
@@ -202,8 +203,10 @@ export const devCommand = defineCommand({
|
|
|
202
203
|
// Content is watched per source (filesystem uses fs.watch; remote sources
|
|
203
204
|
// are frozen for the session). The remaining project inputs — user pages,
|
|
204
205
|
// config, theme, and component overrides — are watched directly.
|
|
206
|
+
const dirTargets = [project.context.pagesRoot].filter(
|
|
207
|
+
(target) => target !== null
|
|
208
|
+
);
|
|
205
209
|
const fileTargets = [
|
|
206
|
-
project.context.pagesRoot,
|
|
207
210
|
project.context.configFile,
|
|
208
211
|
project.context.themeFile,
|
|
209
212
|
project.context.componentsFile,
|
|
@@ -211,10 +214,24 @@ export const devCommand = defineCommand({
|
|
|
211
214
|
|
|
212
215
|
const disposers = [
|
|
213
216
|
...project.sources.map((source) => source.watch?.(regenerate)),
|
|
214
|
-
...
|
|
217
|
+
...dirTargets.map((target) => {
|
|
215
218
|
const watcher = watch(target, { recursive: true }, regenerate);
|
|
216
219
|
return () => watcher.close();
|
|
217
220
|
}),
|
|
221
|
+
// Single files are watched via their parent directory: fs.watch on the
|
|
222
|
+
// file itself tracks the inode, so a rename-replace save (vim and most
|
|
223
|
+
// "atomic save" editors) orphans the watcher after the first write and
|
|
224
|
+
// every later edit is silently ignored.
|
|
225
|
+
...fileTargets.map((target) => {
|
|
226
|
+
const name = basename(target);
|
|
227
|
+
const watcher = watch(dirname(target), (_event, filename) => {
|
|
228
|
+
// A null filename (some platforms) can't be filtered — regenerate.
|
|
229
|
+
if (!filename || filename === name) {
|
|
230
|
+
regenerate();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return () => watcher.close();
|
|
234
|
+
}),
|
|
218
235
|
].filter((dispose) => dispose !== undefined);
|
|
219
236
|
|
|
220
237
|
const shutdown = async () => {
|