blume 1.0.3 → 1.1.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 +94 -0
- package/dist/cli/index.js +13784 -10579
- package/dist/cli/index.js.map +93 -61
- package/dist/types/core/config-input.d.ts +87 -8
- package/dist/types/core/data.d.ts +21 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +140 -140
- package/dist/types/core/schema.d.ts +549 -370
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +23 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/dist/types/openapi/references.d.ts +12 -7
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +22 -3
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +40 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +15 -2
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +11 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +116 -4
- package/docs/reference/cli.mdx +79 -1
- package/docs/reference/frontmatter.mdx +29 -1
- package/package.json +3 -3
- package/skills/blume-migrate/SKILL.md +170 -0
- package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
- package/skills/blume-migrate/references/docusaurus.md +95 -0
- package/skills/blume-migrate/references/fumadocs.md +95 -0
- package/skills/blume-migrate/references/mintlify.md +156 -0
- package/skills/blume-migrate/references/monorepo.md +224 -0
- package/skills/blume-migrate/references/nextra.md +76 -0
- package/skills/blume-migrate/references/starlight.md +116 -0
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/component-slots.ts +3 -2
- package/src/astro/generate.ts +132 -42
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +158 -56
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +135 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +51 -12
- package/src/cli/index.ts +2 -0
- package/src/components/content/Callout.astro +8 -2
- package/src/components/content/Prompt.astro +25 -13
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +5 -8
- package/src/components/layout/Logo.astro +13 -1
- package/src/components/layout/PageFeedback.astro +2 -2
- package/src/components/layout/PageLayout.astro +9 -9
- package/src/components/layout/Pagination.astro +7 -7
- package/src/components/layout/RootLayout.astro +9 -11
- package/src/components/layout/Search.astro +36 -7
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/layout/nav-utils.ts +9 -7
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +94 -8
- package/src/core/data.ts +18 -2
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +59 -12
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/navigation.ts +55 -13
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +100 -1
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/sources/watch.ts +5 -0
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +23 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/markdown/index.ts +2 -0
- package/src/markdown/language-icon.ts +2 -1
- package/src/markdown/table-wrap.ts +43 -0
- package/src/og/card.ts +128 -36
- package/src/og/index.ts +1 -1
- package/src/og/logo.ts +21 -0
- package/src/openapi/references.ts +19 -16
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +56 -6
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
|
+
import type {
|
|
3
|
+
Diagnostic,
|
|
4
|
+
DiagnosticSeverity,
|
|
5
|
+
RouteManifestEntry,
|
|
6
|
+
} from "../core/types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a check needs in order to run. Anything above `static` is opt-in, and a
|
|
10
|
+
* skipped tier is reported rather than silently omitted — a crawler that
|
|
11
|
+
* quietly doesn't check something is worse than one that says it didn't.
|
|
12
|
+
*/
|
|
13
|
+
export type AuditTier = "static" | "network" | "external";
|
|
14
|
+
|
|
15
|
+
export type AuditCategory =
|
|
16
|
+
| "content"
|
|
17
|
+
| "duplicates"
|
|
18
|
+
| "indexability"
|
|
19
|
+
| "links"
|
|
20
|
+
| "redirects"
|
|
21
|
+
| "social"
|
|
22
|
+
| "i18n"
|
|
23
|
+
| "assets"
|
|
24
|
+
| "sitemap"
|
|
25
|
+
| "robots"
|
|
26
|
+
| "structured-data"
|
|
27
|
+
| "ai"
|
|
28
|
+
| "network";
|
|
29
|
+
|
|
30
|
+
/** A check's static metadata. The catalog is the source of truth for all of it. */
|
|
31
|
+
export interface CheckMeta {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly category: AuditCategory;
|
|
34
|
+
readonly severity: DiagnosticSeverity;
|
|
35
|
+
/** Human title used as the report's group header, e.g. "Title too long". */
|
|
36
|
+
readonly title: string;
|
|
37
|
+
readonly tier: AuditTier;
|
|
38
|
+
/** Default remediation, used as the finding's `suggestion`. */
|
|
39
|
+
readonly fix?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A `<link>`/`<a>` discovered in built HTML. */
|
|
43
|
+
export interface SnapshotLink {
|
|
44
|
+
href: string;
|
|
45
|
+
rel: string | null;
|
|
46
|
+
text: string;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the link sits in the page's prose (`<main>`/`<article>`) rather
|
|
49
|
+
* than site chrome (nav/sidebar/header/footer). Load-bearing: Blume's sidebar
|
|
50
|
+
* links every page from every page, so a link graph that can't tell the two
|
|
51
|
+
* apart reports zero orphans, forever.
|
|
52
|
+
*/
|
|
53
|
+
content: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An image/script/stylesheet referenced by a built page. */
|
|
57
|
+
export interface SnapshotAsset {
|
|
58
|
+
src: string;
|
|
59
|
+
alt?: string | null;
|
|
60
|
+
width?: string | null;
|
|
61
|
+
height?: string | null;
|
|
62
|
+
/** Absolute path in the static dir, when the ref resolves to a local file. */
|
|
63
|
+
file?: string;
|
|
64
|
+
bytes?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Everything one built HTML page contributes to the audit. */
|
|
68
|
+
export interface PageSnapshot {
|
|
69
|
+
/** Absolute path of the built `.html`. */
|
|
70
|
+
file: string;
|
|
71
|
+
/** Site-root-relative URL, e.g. `/docs/getting-started`. */
|
|
72
|
+
url: string;
|
|
73
|
+
bytes: number;
|
|
74
|
+
/** The manifest entry this page renders, when it maps to authored content. */
|
|
75
|
+
route?: RouteManifestEntry;
|
|
76
|
+
/** `route.sourcePath` — the `.mdx` a finding should point the user at. */
|
|
77
|
+
source?: string;
|
|
78
|
+
indexable: boolean;
|
|
79
|
+
|
|
80
|
+
lang: string | null;
|
|
81
|
+
/** Every `<title>`; more than one is itself a finding. */
|
|
82
|
+
titles: string[];
|
|
83
|
+
descriptions: string[];
|
|
84
|
+
canonical: string | null;
|
|
85
|
+
robots: string | null;
|
|
86
|
+
viewport: string | null;
|
|
87
|
+
metaRefresh: string | null;
|
|
88
|
+
headings: { depth: number; text: string }[];
|
|
89
|
+
og: Record<string, string>;
|
|
90
|
+
twitter: Record<string, string>;
|
|
91
|
+
hreflang: { lang: string; href: string }[];
|
|
92
|
+
jsonld: unknown[];
|
|
93
|
+
/** JSON-LD blocks that failed to parse, with the parser's message. */
|
|
94
|
+
jsonldErrors: string[];
|
|
95
|
+
links: SnapshotLink[];
|
|
96
|
+
images: SnapshotAsset[];
|
|
97
|
+
scripts: SnapshotAsset[];
|
|
98
|
+
styles: SnapshotAsset[];
|
|
99
|
+
wordCount: number;
|
|
100
|
+
/** Hash of the normalized prose, for exact-duplicate detection. */
|
|
101
|
+
contentHash: string;
|
|
102
|
+
/** Every element `id` on the page — the targets `#fragment` links can hit. */
|
|
103
|
+
ids: Set<string>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A configured redirect resolved through to its final destination. */
|
|
107
|
+
export interface RedirectResolution {
|
|
108
|
+
from: string;
|
|
109
|
+
to: string;
|
|
110
|
+
status: number;
|
|
111
|
+
/** Every hop from `from` to the final target, inclusive. */
|
|
112
|
+
chain: string[];
|
|
113
|
+
outcome: "ok" | "loop" | "broken" | "chain";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A parsed `sitemap.xml`. */
|
|
117
|
+
export interface SitemapDoc {
|
|
118
|
+
file: string;
|
|
119
|
+
bytes: number;
|
|
120
|
+
/** Absolute `<loc>` URLs, in document order. */
|
|
121
|
+
urls: string[];
|
|
122
|
+
/** Each `<url>` block's `<lastmod>`, keyed by its `<loc>`. */
|
|
123
|
+
lastmod?: Map<string, string>;
|
|
124
|
+
/** Parse failure, when the document isn't usable. */
|
|
125
|
+
error?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A parsed `llms.txt` index. */
|
|
129
|
+
export interface LlmsDoc {
|
|
130
|
+
file: string;
|
|
131
|
+
/** Markdown link targets in document order, with their 1-based line. */
|
|
132
|
+
entries: { url: string; line: number }[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A parsed `robots.txt`. */
|
|
136
|
+
export interface RobotsDoc {
|
|
137
|
+
file: string;
|
|
138
|
+
/** `Disallow:` paths for `User-agent: *`. */
|
|
139
|
+
disallow: string[];
|
|
140
|
+
/** `Sitemap:` declarations. */
|
|
141
|
+
sitemaps: string[];
|
|
142
|
+
/** Lines that aren't a recognized directive, with their 1-based line number. */
|
|
143
|
+
invalid: { line: number; text: string }[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Incoming/outgoing internal-link edges, split by where the link sits. */
|
|
147
|
+
export interface LinkGraph {
|
|
148
|
+
/** url -> urls it links to from its prose. */
|
|
149
|
+
contentOut: Map<string, Set<string>>;
|
|
150
|
+
/** url -> urls whose prose links to it. */
|
|
151
|
+
contentIn: Map<string, Set<string>>;
|
|
152
|
+
/** url -> urls it links to from chrome (nav/sidebar/footer). */
|
|
153
|
+
chromeOut: Map<string, Set<string>>;
|
|
154
|
+
chromeIn: Map<string, Set<string>>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Astro's reserved error routes. Never indexable, never crawlable — by design. */
|
|
158
|
+
export const ERROR_ROUTES: ReadonlySet<string> = new Set(["/404", "/500"]);
|
|
159
|
+
|
|
160
|
+
/** Tunable limits. Not yet configurable — CLI-only until the ids settle. */
|
|
161
|
+
export interface AuditThresholds {
|
|
162
|
+
titleMin: number;
|
|
163
|
+
titleMax: number;
|
|
164
|
+
descriptionMin: number;
|
|
165
|
+
descriptionMax: number;
|
|
166
|
+
minWordCount: number;
|
|
167
|
+
maxHtmlBytes: number;
|
|
168
|
+
maxAssetBytes: number;
|
|
169
|
+
maxRedirectHops: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const DEFAULT_THRESHOLDS: AuditThresholds = {
|
|
173
|
+
// Ahrefs' guidance: 110–160 characters. Under ~110 wastes the snippet
|
|
174
|
+
// space search results give you; over ~160 gets truncated.
|
|
175
|
+
descriptionMax: 160,
|
|
176
|
+
descriptionMin: 110,
|
|
177
|
+
maxAssetBytes: 500 * 1024,
|
|
178
|
+
// Googlebot stops reading an HTML document at 2 MB.
|
|
179
|
+
maxHtmlBytes: 2 * 1024 * 1024,
|
|
180
|
+
maxRedirectHops: 3,
|
|
181
|
+
minWordCount: 50,
|
|
182
|
+
titleMax: 60,
|
|
183
|
+
titleMin: 10,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Everything the check modules read. Assembled once per run. */
|
|
187
|
+
export interface AuditContext {
|
|
188
|
+
project: BlumeProject;
|
|
189
|
+
staticDir: string;
|
|
190
|
+
/** Origin passed via `--url`, for the network tier. */
|
|
191
|
+
origin: string | null;
|
|
192
|
+
pages: PageSnapshot[];
|
|
193
|
+
byUrl: Map<string, PageSnapshot>;
|
|
194
|
+
/** Every file in the static dir: URL path -> size in bytes. */
|
|
195
|
+
files: Map<string, number>;
|
|
196
|
+
/**
|
|
197
|
+
* Raw text of every page's source file, keyed by absolute path. Read once so
|
|
198
|
+
* findings can be anchored to the exact front matter line that fixes them.
|
|
199
|
+
*/
|
|
200
|
+
sources: Map<string, string>;
|
|
201
|
+
graph: LinkGraph;
|
|
202
|
+
redirects: RedirectResolution[];
|
|
203
|
+
sitemap: SitemapDoc | null;
|
|
204
|
+
robots: RobotsDoc | null;
|
|
205
|
+
llms: LlmsDoc | null;
|
|
206
|
+
thresholds: AuditThresholds;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One category's checks. Modules, not per-check closures — see catalog.ts. */
|
|
210
|
+
export interface CheckModule {
|
|
211
|
+
readonly category: AuditCategory;
|
|
212
|
+
readonly tier: AuditTier;
|
|
213
|
+
readonly run: (context: AuditContext) => Diagnostic[] | Promise<Diagnostic[]>;
|
|
214
|
+
}
|
package/src/audit/url.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { stripBasePath } from "../core/base-path.ts";
|
|
2
|
+
|
|
3
|
+
/** What an `href` in built HTML turned out to point at. */
|
|
4
|
+
export type ResolvedHref =
|
|
5
|
+
/** A path on this site. */
|
|
6
|
+
| { kind: "internal"; path: string; hash: string }
|
|
7
|
+
/** An absolute URL that resolves back to this site — should have been a path. */
|
|
8
|
+
| { kind: "self-origin"; path: string; hash: string }
|
|
9
|
+
/** An absolute URL on another origin. */
|
|
10
|
+
| { kind: "external"; url: string }
|
|
11
|
+
/** In-page anchor, `mailto:`, `tel:`, `javascript:`, data URI — not a page link. */
|
|
12
|
+
| { kind: "ignored" };
|
|
13
|
+
|
|
14
|
+
const NON_HTTP_SCHEME = /^(?!https?:)[a-z][a-z0-9+.-]*:/iu;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize a site path for comparison: drop the trailing slash (Astro serves
|
|
18
|
+
* `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
|
|
19
|
+
*/
|
|
20
|
+
export const normalizePath = (path: string): string => {
|
|
21
|
+
const trimmed = path.replace(/\/+$/u, "");
|
|
22
|
+
return trimmed === "" ? "/" : trimmed;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The origin of `deployment.site`, or null when no site is configured. */
|
|
26
|
+
export const siteOrigin = (site?: string): string | null => {
|
|
27
|
+
if (!site) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return new URL(site).origin;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve an `href` found on `pageUrl` into something the link graph can use.
|
|
39
|
+
*
|
|
40
|
+
* An absolute URL pointing back at our own origin is reported separately from a
|
|
41
|
+
* genuine external link: it's an internal link that hardcoded the production
|
|
42
|
+
* domain, which silently breaks on preview deploys and under `basePath`.
|
|
43
|
+
*
|
|
44
|
+
* `deployBase` is the normalized `deployment.base`: emitted hrefs carry it, but
|
|
45
|
+
* the built file tree (and so every page URL and file-index key) does not, so it
|
|
46
|
+
* is stripped here to keep resolved paths comparable. `basePath` is different —
|
|
47
|
+
* Blume mounts it as a real directory in the build, so it stays.
|
|
48
|
+
*/
|
|
49
|
+
export const resolveHref = (
|
|
50
|
+
pageUrl: string,
|
|
51
|
+
href: string,
|
|
52
|
+
origin: string | null,
|
|
53
|
+
deployBase = ""
|
|
54
|
+
): ResolvedHref => {
|
|
55
|
+
const target = href.trim();
|
|
56
|
+
if (target === "" || target.startsWith("#")) {
|
|
57
|
+
return { kind: "ignored" };
|
|
58
|
+
}
|
|
59
|
+
if (NON_HTTP_SCHEME.test(target)) {
|
|
60
|
+
return { kind: "ignored" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Protocol-relative (`//host/x`) is an absolute URL with the page's scheme.
|
|
64
|
+
const absolute = /^https?:\/\//iu.test(target) || target.startsWith("//");
|
|
65
|
+
if (absolute) {
|
|
66
|
+
let parsed: URL;
|
|
67
|
+
try {
|
|
68
|
+
parsed = new URL(target.startsWith("//") ? `https:${target}` : target);
|
|
69
|
+
} catch {
|
|
70
|
+
return { kind: "ignored" };
|
|
71
|
+
}
|
|
72
|
+
if (origin && parsed.origin === origin) {
|
|
73
|
+
return {
|
|
74
|
+
hash: parsed.hash.slice(1),
|
|
75
|
+
kind: "self-origin",
|
|
76
|
+
path: normalizePath(stripBasePath(deployBase, parsed.pathname)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { kind: "external", url: parsed.toString() };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A relative href resolves against the page's own URL. `URL` needs an origin
|
|
83
|
+
// to do that, so borrow a placeholder one and keep only the path. The base
|
|
84
|
+
// carries a trailing slash because Astro's directory build serves `/docs/api`
|
|
85
|
+
// at `/docs/api/` — so in a browser `./auth` there means `/docs/api/auth`, not
|
|
86
|
+
// `/docs/auth`. Resolving against the slashless form would silently mis-target
|
|
87
|
+
// every relative link on the site by one directory level.
|
|
88
|
+
const base =
|
|
89
|
+
pageUrl === "/"
|
|
90
|
+
? "https://blume.invalid/"
|
|
91
|
+
: `https://blume.invalid${pageUrl}/`;
|
|
92
|
+
let resolved: URL;
|
|
93
|
+
try {
|
|
94
|
+
resolved = new URL(target, base);
|
|
95
|
+
} catch {
|
|
96
|
+
return { kind: "ignored" };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
hash: resolved.hash.slice(1),
|
|
100
|
+
kind: "internal",
|
|
101
|
+
path: normalizePath(stripBasePath(deployBase, resolved.pathname)),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AGENTS,
|
|
5
|
+
fixPrompt,
|
|
6
|
+
launchAgent,
|
|
7
|
+
WINDOWS_COMMAND_NOT_FOUND,
|
|
8
|
+
writeAgentReport,
|
|
9
|
+
} from "../../audit/agent.ts";
|
|
10
|
+
import type { AgentKind } from "../../audit/agent.ts";
|
|
11
|
+
import { formatCatalog, formatReport, reportJson } from "../../audit/report.ts";
|
|
12
|
+
import { NoBuildError, runAudit } from "../../audit/run.ts";
|
|
13
|
+
import type { AuditResult } from "../../audit/run.ts";
|
|
14
|
+
import { BlumeError } from "../../core/diagnostics.ts";
|
|
15
|
+
import { scanProject } from "../../core/project-graph.ts";
|
|
16
|
+
import type { DiagnosticSeverity } from "../../core/types.ts";
|
|
17
|
+
import { reportInternalError } from "../internal-error.ts";
|
|
18
|
+
import { flushStdout, logger } from "../log.ts";
|
|
19
|
+
|
|
20
|
+
const SEVERITIES: DiagnosticSeverity[] = ["error", "warning", "info"];
|
|
21
|
+
|
|
22
|
+
/** Severities at or above the gate, e.g. `warning` -> error + warning. */
|
|
23
|
+
const failingSeverities = (gate: DiagnosticSeverity): Set<DiagnosticSeverity> =>
|
|
24
|
+
new Set(SEVERITIES.slice(0, SEVERITIES.indexOf(gate) + 1));
|
|
25
|
+
|
|
26
|
+
const splitTerms = (value: string | undefined): string[] =>
|
|
27
|
+
value
|
|
28
|
+
? value
|
|
29
|
+
.split(",")
|
|
30
|
+
.map((term) => term.trim())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
: [];
|
|
33
|
+
|
|
34
|
+
/** Whether the run should exit non-zero, given the gate. */
|
|
35
|
+
export const shouldFail = (
|
|
36
|
+
result: AuditResult,
|
|
37
|
+
gate: DiagnosticSeverity
|
|
38
|
+
): boolean => {
|
|
39
|
+
const failing = failingSeverities(gate);
|
|
40
|
+
return result.diagnostics.some((d) => failing.has(d.severity));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const auditCommand = defineCommand({
|
|
44
|
+
args: {
|
|
45
|
+
claude: {
|
|
46
|
+
description: "Hand the findings to Claude Code to fix interactively.",
|
|
47
|
+
type: "boolean",
|
|
48
|
+
},
|
|
49
|
+
codex: {
|
|
50
|
+
description: "Hand the findings to Codex to fix interactively.",
|
|
51
|
+
type: "boolean",
|
|
52
|
+
},
|
|
53
|
+
external: {
|
|
54
|
+
description: "Probe outbound links over the network.",
|
|
55
|
+
type: "boolean",
|
|
56
|
+
},
|
|
57
|
+
"fail-on": {
|
|
58
|
+
description:
|
|
59
|
+
"Exit non-zero at this severity or above: error | warning | info. Defaults to error.",
|
|
60
|
+
type: "string",
|
|
61
|
+
},
|
|
62
|
+
json: {
|
|
63
|
+
description: "Emit the report as JSON on stdout (for CI/editors).",
|
|
64
|
+
type: "boolean",
|
|
65
|
+
},
|
|
66
|
+
"list-checks": {
|
|
67
|
+
description: "Print every check the audit can report, then exit.",
|
|
68
|
+
type: "boolean",
|
|
69
|
+
},
|
|
70
|
+
only: {
|
|
71
|
+
description: "Only report these checks or categories (comma-separated).",
|
|
72
|
+
type: "string",
|
|
73
|
+
},
|
|
74
|
+
skip: {
|
|
75
|
+
description: "Suppress these checks or categories (comma-separated).",
|
|
76
|
+
type: "string",
|
|
77
|
+
},
|
|
78
|
+
strict: {
|
|
79
|
+
description: "Alias for --fail-on warning.",
|
|
80
|
+
type: "boolean",
|
|
81
|
+
},
|
|
82
|
+
url: {
|
|
83
|
+
description:
|
|
84
|
+
"Also probe a live deployment (e.g. https://docs.example.com) for status codes, headers, and redirects.",
|
|
85
|
+
type: "string",
|
|
86
|
+
},
|
|
87
|
+
verbose: {
|
|
88
|
+
description: "List every affected page instead of the first few.",
|
|
89
|
+
type: "boolean",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
meta: {
|
|
93
|
+
description: "Audit the built site for SEO and site-health issues.",
|
|
94
|
+
name: "audit",
|
|
95
|
+
},
|
|
96
|
+
async run({ args }) {
|
|
97
|
+
if (args["list-checks"]) {
|
|
98
|
+
process.stdout.write(formatCatalog());
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const root = process.cwd();
|
|
103
|
+
const gate = (args["fail-on"] ??
|
|
104
|
+
(args.strict ? "warning" : "error")) as DiagnosticSeverity;
|
|
105
|
+
if (!SEVERITIES.includes(gate)) {
|
|
106
|
+
logger.error(
|
|
107
|
+
`Invalid --fail-on "${gate}" (use ${SEVERITIES.join(" | ")}).`
|
|
108
|
+
);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
|
|
112
|
+
(kind) => args[kind]
|
|
113
|
+
);
|
|
114
|
+
if (agents.length > 1) {
|
|
115
|
+
logger.error("Pass at most one of --claude or --codex.");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
const [agent] = agents;
|
|
119
|
+
if (agent && args.json) {
|
|
120
|
+
logger.error(`--json and --${agent} are mutually exclusive.`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
let result: AuditResult;
|
|
124
|
+
try {
|
|
125
|
+
// `scanProject`, not `prepareProject`: the audit reads the *existing*
|
|
126
|
+
// build and never regenerates the runtime, so it doesn't contend with a
|
|
127
|
+
// running dev server. Same reasoning as `blume validate`.
|
|
128
|
+
const project = await scanProject(root, { mode: "build" });
|
|
129
|
+
result = await runAudit({
|
|
130
|
+
external: args.external,
|
|
131
|
+
only: splitTerms(args.only),
|
|
132
|
+
origin: args.url,
|
|
133
|
+
project,
|
|
134
|
+
skip: splitTerms(args.skip),
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof NoBuildError) {
|
|
138
|
+
logger.error(`${error.message} Run \`blume build\` first.`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (error instanceof BlumeError) {
|
|
142
|
+
logger.error(error.diagnostic.message);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
reportInternalError(error);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (agent) {
|
|
150
|
+
// Show the same report a plain run would, so the terminal records what
|
|
151
|
+
// was handed off before the agent's own UI takes over the screen.
|
|
152
|
+
process.stderr.write(
|
|
153
|
+
formatReport(result, root, { verbose: args.verbose })
|
|
154
|
+
);
|
|
155
|
+
if (result.diagnostics.length === 0) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const cli = AGENTS[agent];
|
|
159
|
+
const report = await writeAgentReport(result, root);
|
|
160
|
+
const count = result.diagnostics.length;
|
|
161
|
+
// Straight to stderr like the report above it, not `logger.info` —
|
|
162
|
+
// consola drops info-level lines in test and CI environments.
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
` Handing ${count} finding${count === 1 ? "" : "s"} to ${cli.name}…\n\n`
|
|
165
|
+
);
|
|
166
|
+
let code: number;
|
|
167
|
+
try {
|
|
168
|
+
code = await launchAgent(cli.bin, fixPrompt(report));
|
|
169
|
+
} catch {
|
|
170
|
+
code = WINDOWS_COMMAND_NOT_FOUND;
|
|
171
|
+
}
|
|
172
|
+
// A POSIX spawn rejects on a missing executable; the Windows shell
|
|
173
|
+
// launch reports it through cmd.exe's 9009 instead. Same diagnosis.
|
|
174
|
+
if (code === WINDOWS_COMMAND_NOT_FOUND) {
|
|
175
|
+
logger.error(
|
|
176
|
+
`${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
|
|
177
|
+
);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
if (code !== 0) {
|
|
181
|
+
process.exit(code);
|
|
182
|
+
}
|
|
183
|
+
// The gate is a CI concern; a handoff run succeeds when the agent
|
|
184
|
+
// session does, not when the pre-fix site was already clean.
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (args.json) {
|
|
189
|
+
process.stdout.write(reportJson(result, root));
|
|
190
|
+
if (shouldFail(result, gate)) {
|
|
191
|
+
// `process.exit` doesn't flush a piped stdout — without this the JSON is
|
|
192
|
+
// truncated mid-write in exactly the CI setups that consume it.
|
|
193
|
+
await flushStdout();
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
process.stderr.write(formatReport(result, root, { verbose: args.verbose }));
|
|
200
|
+
|
|
201
|
+
if (shouldFail(result, gate)) {
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
});
|
|
@@ -13,11 +13,13 @@ import type { ResolvedConfig } from "../../core/schema.ts";
|
|
|
13
13
|
import { serverFeatures } from "../../core/server-features.ts";
|
|
14
14
|
import type { ProjectContext } from "../../core/types.ts";
|
|
15
15
|
import {
|
|
16
|
+
ADAPTER_IGNORE_DIRS,
|
|
16
17
|
deployStaticDir,
|
|
17
18
|
surfaceAdapterOutput,
|
|
18
19
|
} from "../../deploy/adapter-output.ts";
|
|
20
|
+
import { buildNetlifyHeaders } from "../../deploy/headers.ts";
|
|
19
21
|
import {
|
|
20
|
-
|
|
22
|
+
applyBaseToPlatformRedirects,
|
|
21
23
|
buildNetlifyRedirects,
|
|
22
24
|
buildRedirectManifest,
|
|
23
25
|
buildVercelConfig,
|
|
@@ -73,7 +75,11 @@ const emitRedirectFiles = async (
|
|
|
73
75
|
config: ResolvedConfig,
|
|
74
76
|
distDir: string
|
|
75
77
|
): Promise<void> => {
|
|
76
|
-
const redirects =
|
|
78
|
+
const redirects = applyBaseToPlatformRedirects(
|
|
79
|
+
config.redirects,
|
|
80
|
+
config.basePath,
|
|
81
|
+
config.deployment.base ?? ""
|
|
82
|
+
);
|
|
77
83
|
if (redirects.length === 0 || config.deployment.output !== "static") {
|
|
78
84
|
return;
|
|
79
85
|
}
|
|
@@ -96,6 +102,33 @@ const emitRedirectFiles = async (
|
|
|
96
102
|
logger.success(`Emitted redirect files for ${redirects.length} redirect(s)`);
|
|
97
103
|
};
|
|
98
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Emit a `_headers` file for a static build so Netlify / Cloudflare static
|
|
107
|
+
* hosts serve the raw AI-ready endpoints (`*.md`, `*.mdx`, `*.txt`) with an
|
|
108
|
+
* explicit `charset=utf-8`. Without it those hosts send `text/markdown` /
|
|
109
|
+
* `text/plain` with no charset and browsers fall back to Windows-1252, garbling
|
|
110
|
+
* any non-ASCII docs (#82). A `_headers` shipped in `public/` (copied into dist
|
|
111
|
+
* by Astro before this runs) wins, exactly like `_redirects`. Server adapters
|
|
112
|
+
* set the Content-Type on the Response directly, so this is static-only.
|
|
113
|
+
*/
|
|
114
|
+
const emitHeaderFiles = async (
|
|
115
|
+
config: ResolvedConfig,
|
|
116
|
+
distDir: string
|
|
117
|
+
): Promise<void> => {
|
|
118
|
+
if (
|
|
119
|
+
config.deployment.output !== "static" ||
|
|
120
|
+
existsSync(join(distDir, "_headers"))
|
|
121
|
+
) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await writeFile(
|
|
125
|
+
join(distDir, "_headers"),
|
|
126
|
+
buildNetlifyHeaders(config),
|
|
127
|
+
"utf-8"
|
|
128
|
+
);
|
|
129
|
+
logger.success("Emitted _headers (UTF-8 Content-Type for raw endpoints)");
|
|
130
|
+
};
|
|
131
|
+
|
|
99
132
|
const formatBytes = (bytes: number): string => {
|
|
100
133
|
if (bytes < 1024) {
|
|
101
134
|
return `${bytes} B`;
|
|
@@ -343,6 +376,7 @@ const publishBuildArtifacts = async (
|
|
|
343
376
|
}
|
|
344
377
|
|
|
345
378
|
await emitRedirectFiles(project.config, distDir);
|
|
379
|
+
await emitHeaderFiles(project.config, distDir);
|
|
346
380
|
|
|
347
381
|
const { config } = project;
|
|
348
382
|
const features = serverFeatures(config);
|
|
@@ -479,21 +513,26 @@ export const buildCommand = defineCommand({
|
|
|
479
513
|
return;
|
|
480
514
|
}
|
|
481
515
|
|
|
482
|
-
// A server adapter
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
516
|
+
// A server adapter's deploy bundle is a build artifact — keep it out of
|
|
517
|
+
// version control (Vercel's own CLI ignores `.vercel/` for the same reason).
|
|
518
|
+
// Ignoring it is independent of whether the bundle had to be moved below:
|
|
519
|
+
// Vercel writes straight to the project root, Netlify does not.
|
|
520
|
+
const { adapter } = project.config.deployment;
|
|
521
|
+
const ignoreDir = adapter ? ADAPTER_IGNORE_DIRS[adapter] : undefined;
|
|
522
|
+
if (project.config.deployment.output === "server" && ignoreDir) {
|
|
523
|
+
await ensureGitignore(root, [ignoreDir]);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Netlify writes its deploy bundle relative to the Astro root — which Blume
|
|
527
|
+
// points at the hidden `.blume` runtime — so the bundle lands where the
|
|
528
|
+
// deploy platform never looks. Surface it up to the project root before
|
|
529
|
+
// publishing artifacts into the served static dir.
|
|
486
530
|
const surfaced = await surfaceAdapterOutput(
|
|
487
531
|
project.config,
|
|
488
532
|
project.context
|
|
489
533
|
);
|
|
490
534
|
if (surfaced.moved) {
|
|
491
|
-
logger.success(
|
|
492
|
-
`Surfaced ${project.config.deployment.adapter} output to ${surfaced.to}`
|
|
493
|
-
);
|
|
494
|
-
// The surfaced bundle is a build artifact — keep it out of version control
|
|
495
|
-
// (Vercel's own CLI ignores `.vercel/` for the same reason).
|
|
496
|
-
await ensureGitignore(root, [surfaced.ignore]);
|
|
535
|
+
logger.success(`Surfaced ${adapter} output to ${surfaced.to}`);
|
|
497
536
|
}
|
|
498
537
|
|
|
499
538
|
await publishBuildArtifacts(
|
package/src/cli/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { defineCommand, runMain } from "citty";
|
|
|
2
2
|
|
|
3
3
|
import { getBlumeVersion } from "../core/version.ts";
|
|
4
4
|
import { addCommand } from "./commands/add.ts";
|
|
5
|
+
import { auditCommand } from "./commands/audit.ts";
|
|
5
6
|
import { buildCommand } from "./commands/build.ts";
|
|
6
7
|
import { checkCommand } from "./commands/check.ts";
|
|
7
8
|
import { devCommand } from "./commands/dev.ts";
|
|
@@ -22,6 +23,7 @@ const main = defineCommand({
|
|
|
22
23
|
},
|
|
23
24
|
subCommands: {
|
|
24
25
|
add: addCommand,
|
|
26
|
+
audit: auditCommand,
|
|
25
27
|
build: buildCommand,
|
|
26
28
|
check: checkCommand,
|
|
27
29
|
dev: devCommand,
|
|
@@ -60,8 +60,14 @@ const iconClass: Record<CalloutType, string> = {
|
|
|
60
60
|
<span class:list={["mt-0.5 shrink-0", color ? "" : iconClass[type]]}>
|
|
61
61
|
<Icon color={color} icon={icon ?? iconByType[type]} size={16} />
|
|
62
62
|
</span>
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
{/* The global prose rule leaks a 1rem margin onto these paragraphs/lists even
|
|
64
|
+
though the callout is not-prose; with a title the body isn't the first
|
|
65
|
+
child, so that margin stacks under the title's own gap and reads as too
|
|
66
|
+
much space. Override it here for a uniform, compact gap (important beats
|
|
67
|
+
the unlayered prose rule): every child a small top margin, none on the
|
|
68
|
+
first, and no trailing bottom margin. */}
|
|
69
|
+
<div class="flex-1 [&>*]:mt-2! [&>*]:mb-0! [&>:first-child]:mt-0!">
|
|
70
|
+
{title && <p class="font-semibold text-foreground">{title}</p>}
|
|
65
71
|
<slot />
|
|
66
72
|
</div>
|
|
67
73
|
</aside>
|