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.
Files changed (126) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +13784 -10579
  3. package/dist/cli/index.js.map +93 -61
  4. package/dist/types/core/config-input.d.ts +87 -8
  5. package/dist/types/core/data.d.ts +21 -0
  6. package/dist/types/core/deployment-env.d.ts +6 -0
  7. package/dist/types/core/diagnostics.d.ts +23 -0
  8. package/dist/types/core/i18n-ui.d.ts +140 -140
  9. package/dist/types/core/schema.d.ts +549 -370
  10. package/dist/types/core/sources/types.d.ts +3 -1
  11. package/dist/types/core/standard-schema.d.ts +41 -0
  12. package/dist/types/core/types.d.ts +23 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/dist/types/openapi/references.d.ts +12 -7
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +22 -3
  19. package/docs/advanced/changelog.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +1 -1
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +40 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +15 -2
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +11 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +116 -4
  34. package/docs/reference/cli.mdx +79 -1
  35. package/docs/reference/frontmatter.mdx +29 -1
  36. package/package.json +3 -3
  37. package/skills/blume-migrate/SKILL.md +170 -0
  38. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  39. package/skills/blume-migrate/references/docusaurus.md +95 -0
  40. package/skills/blume-migrate/references/fumadocs.md +95 -0
  41. package/skills/blume-migrate/references/mintlify.md +156 -0
  42. package/skills/blume-migrate/references/monorepo.md +224 -0
  43. package/skills/blume-migrate/references/nextra.md +76 -0
  44. package/skills/blume-migrate/references/starlight.md +116 -0
  45. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
  46. package/src/ai/llms.ts +15 -0
  47. package/src/astro/adapter-root.ts +70 -0
  48. package/src/astro/component-slots.ts +3 -2
  49. package/src/astro/generate.ts +132 -42
  50. package/src/astro/index.ts +1 -0
  51. package/src/astro/pages.ts +18 -3
  52. package/src/astro/templates.ts +158 -56
  53. package/src/audit/agent.ts +114 -0
  54. package/src/audit/catalog.ts +826 -0
  55. package/src/audit/checks/assets.ts +177 -0
  56. package/src/audit/checks/content.ts +231 -0
  57. package/src/audit/checks/duplicates.ts +131 -0
  58. package/src/audit/checks/i18n.ts +246 -0
  59. package/src/audit/checks/indexability.ts +213 -0
  60. package/src/audit/checks/links.ts +223 -0
  61. package/src/audit/checks/llms.ts +135 -0
  62. package/src/audit/checks/network.ts +272 -0
  63. package/src/audit/checks/og-image.ts +113 -0
  64. package/src/audit/checks/redirects.ts +87 -0
  65. package/src/audit/checks/robots.ts +114 -0
  66. package/src/audit/checks/sitemap.ts +229 -0
  67. package/src/audit/checks/social.ts +238 -0
  68. package/src/audit/crawl.ts +259 -0
  69. package/src/audit/graph.ts +74 -0
  70. package/src/audit/html.ts +54 -0
  71. package/src/audit/image-size.ts +63 -0
  72. package/src/audit/locate.ts +33 -0
  73. package/src/audit/redirects.ts +74 -0
  74. package/src/audit/report.ts +278 -0
  75. package/src/audit/run.ts +198 -0
  76. package/src/audit/snapshot.ts +189 -0
  77. package/src/audit/types.ts +214 -0
  78. package/src/audit/url.ts +103 -0
  79. package/src/cli/commands/audit.ts +205 -0
  80. package/src/cli/commands/build.ts +51 -12
  81. package/src/cli/index.ts +2 -0
  82. package/src/components/content/Callout.astro +8 -2
  83. package/src/components/content/Prompt.astro +25 -13
  84. package/src/components/content/Tabs.astro +98 -15
  85. package/src/components/layout/Breadcrumbs.astro +1 -1
  86. package/src/components/layout/Header.astro +5 -8
  87. package/src/components/layout/Logo.astro +13 -1
  88. package/src/components/layout/PageFeedback.astro +2 -2
  89. package/src/components/layout/PageLayout.astro +9 -9
  90. package/src/components/layout/Pagination.astro +7 -7
  91. package/src/components/layout/RootLayout.astro +9 -11
  92. package/src/components/layout/Search.astro +36 -7
  93. package/src/components/layout/TableOfContents.astro +1 -1
  94. package/src/components/layout/nav-utils.ts +9 -7
  95. package/src/components/openapi/Authorization.astro +80 -0
  96. package/src/components/openapi/Operation.astro +19 -1
  97. package/src/components/openapi/ParametersTable.astro +1 -1
  98. package/src/components/openapi/security.ts +201 -0
  99. package/src/components/openapi/snippets.ts +42 -13
  100. package/src/core/config-input.ts +94 -8
  101. package/src/core/data.ts +18 -2
  102. package/src/core/deployment-env.ts +9 -0
  103. package/src/core/diagnostics.ts +59 -12
  104. package/src/core/links.ts +2 -91
  105. package/src/core/nav-diagnostics.ts +48 -4
  106. package/src/core/navigation.ts +55 -13
  107. package/src/core/probe.ts +136 -0
  108. package/src/core/project-graph.ts +8 -0
  109. package/src/core/schema.ts +100 -1
  110. package/src/core/sources/normalize.ts +198 -25
  111. package/src/core/sources/types.ts +3 -1
  112. package/src/core/sources/watch.ts +5 -0
  113. package/src/core/standard-schema.ts +54 -0
  114. package/src/core/types.ts +23 -0
  115. package/src/deploy/adapter-output.ts +27 -15
  116. package/src/deploy/headers.ts +66 -0
  117. package/src/deploy/redirects.ts +49 -9
  118. package/src/markdown/index.ts +2 -0
  119. package/src/markdown/language-icon.ts +2 -1
  120. package/src/markdown/table-wrap.ts +43 -0
  121. package/src/og/card.ts +128 -36
  122. package/src/og/index.ts +1 -1
  123. package/src/og/logo.ts +21 -0
  124. package/src/openapi/references.ts +19 -16
  125. package/src/search/popular.ts +33 -0
  126. 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
+ }
@@ -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
- applyBaseToRedirects,
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 = applyBaseToRedirects(config.redirects, config.basePath);
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 (Vercel/Netlify) writes its deploy bundle relative to the
483
- // Astro root — which Blume points at the hidden `.blume` runtime — so the
484
- // bundle lands where the deploy platform never looks. Surface it up to the
485
- // project root before publishing artifacts into the served static dir.
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
- <div class="flex-1 [&>:first-child]:mt-0! [&>:last-child]:mb-0!">
64
- {title && <p class="mb-1 font-semibold text-foreground">{title}</p>}
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>