blume 1.4.3 → 1.5.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 (194) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/index.js +1621 -576
  3. package/dist/cli/index.js.map +109 -104
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/config-input.d.ts +79 -27
  6. package/dist/types/core/config.d.ts +2 -1
  7. package/dist/types/core/data.d.ts +16 -1
  8. package/dist/types/core/diagnostics.d.ts +5 -1
  9. package/dist/types/core/i18n-ui.d.ts +12 -0
  10. package/dist/types/core/schema.d.ts +112 -15
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +7 -3
  13. package/dist/types/core/types.d.ts +43 -2
  14. package/dist/types/core/ui-packs/index.d.ts +9 -1
  15. package/dist/types/openapi/references.d.ts +6 -5
  16. package/dist/types/seo/x-handle.d.ts +3 -2
  17. package/docs/advanced/api-reference.mdx +8 -6
  18. package/docs/configuration/search.mdx +2 -0
  19. package/docs/configuration/seo.mdx +1 -1
  20. package/docs/content/i18n.mdx +1 -1
  21. package/docs/content/meta.mdx +2 -1
  22. package/docs/content/meta.ts +1 -0
  23. package/docs/content/navigation.mdx +35 -1
  24. package/docs/content/versioning.mdx +106 -0
  25. package/docs/reference/cli.mdx +1 -0
  26. package/docs/reference/frontmatter.mdx +3 -0
  27. package/package.json +3 -1
  28. package/skills/blume-migrate/SKILL.md +2 -2
  29. package/skills/blume-migrate/references/docusaurus.md +1 -1
  30. package/skills/blume-migrate/references/fumadocs.md +1 -1
  31. package/skills/blume-migrate/references/mintlify.md +1 -1
  32. package/src/ai/agent-readability.ts +37 -10
  33. package/src/ai/ask-context.ts +5 -1
  34. package/src/ai/ask.ts +10 -1
  35. package/src/ai/component-markdown.ts +80 -43
  36. package/src/ai/llms.ts +40 -16
  37. package/src/ai/mcp/data.ts +48 -12
  38. package/src/ai/mcp/discovery.ts +28 -11
  39. package/src/ai/mcp/server.ts +183 -38
  40. package/src/ai/mcp/tools.ts +3 -3
  41. package/src/ai/skills.ts +32 -9
  42. package/src/ai/visibility.ts +2 -2
  43. package/src/astro/component-slots.ts +2 -0
  44. package/src/astro/examples.ts +6 -2
  45. package/src/astro/generate.ts +54 -29
  46. package/src/astro/integration.ts +13 -2
  47. package/src/astro/islands.ts +16 -9
  48. package/src/astro/templates.ts +152 -33
  49. package/src/audit/agent.ts +2 -2
  50. package/src/audit/checks/content.ts +26 -11
  51. package/src/audit/checks/dns-aid.ts +3 -0
  52. package/src/audit/checks/indexability.ts +24 -6
  53. package/src/audit/checks/llms.ts +9 -4
  54. package/src/audit/checks/network.ts +2 -0
  55. package/src/audit/checks/social.ts +18 -10
  56. package/src/audit/crawl.ts +37 -9
  57. package/src/audit/report.ts +20 -19
  58. package/src/audit/run.ts +5 -2
  59. package/src/audit/snapshot.ts +2 -4
  60. package/src/audit/types.ts +25 -3
  61. package/src/blume-modules.d.ts +5 -1
  62. package/src/cli/commands/audit.ts +9 -4
  63. package/src/cli/commands/build.ts +15 -9
  64. package/src/cli/commands/dev.ts +2 -0
  65. package/src/cli/commands/doctor.ts +2 -0
  66. package/src/cli/commands/eval.ts +7 -3
  67. package/src/cli/commands/init.ts +9 -9
  68. package/src/cli/commands/mcp-stdio.ts +3 -0
  69. package/src/cli/commands/translate.ts +14 -3
  70. package/src/cli/commands/version.ts +85 -0
  71. package/src/cli/dev-lock.ts +31 -10
  72. package/src/cli/eject-scripts.ts +17 -2
  73. package/src/cli/index.ts +2 -0
  74. package/src/cli/init/questions.ts +1 -1
  75. package/src/cli/init/scaffold.ts +22 -15
  76. package/src/cli/internal-error.ts +1 -0
  77. package/src/components/content/auto-type-table.ts +3 -0
  78. package/src/components/content/diff.ts +9 -5
  79. package/src/components/content/github-info.ts +2 -0
  80. package/src/components/islands/ask-ai.tsx +33 -25
  81. package/src/components/islands/hooks.ts +5 -1
  82. package/src/components/islands/webmcp.ts +49 -12
  83. package/src/components/layout/Header.astro +25 -1
  84. package/src/components/layout/NavSelector.astro +11 -2
  85. package/src/components/layout/NavTree.astro +4 -2
  86. package/src/components/layout/RootLayout.astro +18 -0
  87. package/src/components/layout/Search.astro +77 -13
  88. package/src/components/layout/VersionBanner.astro +39 -0
  89. package/src/components/layout/analytics-client.ts +8 -5
  90. package/src/components/layout/hydration-hint.ts +1 -1
  91. package/src/components/layout/nav-utils.ts +1 -4
  92. package/src/components/layout/overrides.ts +25 -12
  93. package/src/components/layout/search/algolia.ts +18 -5
  94. package/src/components/layout/search/endpoint.ts +3 -0
  95. package/src/components/layout/search/flexsearch.ts +23 -7
  96. package/src/components/layout/search/orama-cloud.ts +1 -1
  97. package/src/components/layout/search/orama.ts +4 -1
  98. package/src/components/layout/search/pagefind.ts +2 -0
  99. package/src/components/layout/search/types.ts +13 -1
  100. package/src/components/layout/search/typesense.ts +19 -3
  101. package/src/components/openapi/ApiOverview.astro +32 -6
  102. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  103. package/src/components/openapi/Bindings.astro +89 -0
  104. package/src/components/openapi/MethodBadge.astro +3 -0
  105. package/src/components/openapi/Operation.astro +7 -2
  106. package/src/components/openapi/PanelTabs.astro +131 -0
  107. package/src/components/openapi/ParametersTable.astro +2 -0
  108. package/src/components/openapi/RequestPanel.astro +12 -119
  109. package/src/components/openapi/async-snippets.ts +174 -0
  110. package/src/components/openapi/async.ts +348 -0
  111. package/src/components/openapi/helpers.ts +52 -20
  112. package/src/components/openapi/security.ts +102 -29
  113. package/src/components/openapi/snippets.ts +11 -11
  114. package/src/core/component-overrides.ts +28 -23
  115. package/src/core/config-input.ts +88 -27
  116. package/src/core/config.ts +20 -7
  117. package/src/core/content.ts +3 -1
  118. package/src/core/data.ts +16 -1
  119. package/src/core/define-components.ts +5 -0
  120. package/src/core/diagnostics.ts +46 -38
  121. package/src/core/frontmatter.ts +33 -7
  122. package/src/core/graph.ts +137 -53
  123. package/src/core/i18n-ui.ts +15 -0
  124. package/src/core/i18n.ts +16 -8
  125. package/src/core/load-module.ts +1 -0
  126. package/src/core/manifest.ts +92 -3
  127. package/src/core/meta.ts +44 -14
  128. package/src/core/nav-diagnostics.ts +3 -3
  129. package/src/core/navigation.ts +247 -67
  130. package/src/core/project-graph.ts +15 -3
  131. package/src/core/schema.ts +213 -67
  132. package/src/core/sources/assets.ts +2 -0
  133. package/src/core/sources/cache.ts +6 -0
  134. package/src/core/sources/github-releases.ts +39 -31
  135. package/src/core/sources/mdx-remote.ts +4 -0
  136. package/src/core/sources/normalize.ts +67 -20
  137. package/src/core/sources/notion.ts +49 -17
  138. package/src/core/sources/portable-text.ts +32 -11
  139. package/src/core/sources/sanity.ts +68 -14
  140. package/src/core/sources/types.ts +4 -0
  141. package/src/core/sources/watch.ts +1 -1
  142. package/src/core/standard-schema.ts +9 -3
  143. package/src/core/text-width.ts +26 -0
  144. package/src/core/tsconfig-aliases.ts +9 -5
  145. package/src/core/types.ts +45 -2
  146. package/src/core/ui-packs/index.ts +9 -1
  147. package/src/core/version-cut.ts +301 -0
  148. package/src/core/version.ts +2 -0
  149. package/src/core/versions.ts +170 -0
  150. package/src/deploy/adapter-output.ts +5 -2
  151. package/src/deploy/cloudflare-negotiation.ts +25 -10
  152. package/src/deploy/sitemap.ts +33 -1
  153. package/src/deploy/vercel-negotiation.ts +11 -4
  154. package/src/eval/report.ts +4 -4
  155. package/src/eval/run.ts +2 -2
  156. package/src/eval/schema.ts +1 -1
  157. package/src/markdown/base-links.ts +6 -6
  158. package/src/markdown/directives.ts +7 -1
  159. package/src/markdown/heading-anchors.ts +17 -6
  160. package/src/markdown/index.ts +73 -24
  161. package/src/markdown/inline-code.ts +14 -2
  162. package/src/markdown/language-icon.ts +6 -2
  163. package/src/markdown/mdast.ts +18 -4
  164. package/src/markdown/package-commands.ts +6 -8
  165. package/src/markdown/table-wrap.ts +4 -1
  166. package/src/markdown/twoslash.ts +2 -0
  167. package/src/og/card.ts +30 -11
  168. package/src/og/derive.ts +43 -27
  169. package/src/openapi/asyncapi.ts +366 -0
  170. package/src/openapi/model.ts +126 -57
  171. package/src/openapi/parse.ts +97 -5
  172. package/src/openapi/references.ts +12 -10
  173. package/src/openapi/render-mdx.ts +73 -34
  174. package/src/openapi/scalar.ts +6 -8
  175. package/src/openapi/source.ts +98 -28
  176. package/src/registry/eject.ts +7 -2
  177. package/src/search/documents.ts +25 -5
  178. package/src/search/facets.ts +7 -5
  179. package/src/search/orama-index.ts +66 -20
  180. package/src/search/popular.ts +10 -5
  181. package/src/search/providers.ts +2 -2
  182. package/src/search/sync/index.ts +2 -0
  183. package/src/search/sync/typesense.ts +4 -2
  184. package/src/seo/jsonld.ts +24 -6
  185. package/src/seo/x-handle.ts +8 -3
  186. package/src/theme/chrome-icons.ts +7 -2
  187. package/src/theme/fonts.ts +8 -4
  188. package/src/theme/icons.ts +4 -2
  189. package/src/theme/palette.ts +22 -14
  190. package/src/translate/meta.ts +15 -6
  191. package/src/translate/report.ts +9 -5
  192. package/src/translate/run.ts +10 -4
  193. package/src/translate/validate.ts +52 -17
  194. package/src/translate/work-list.ts +0 -0
@@ -103,6 +103,35 @@ const sitemapParser = new XMLParser({
103
103
  removeNSPrefix: true,
104
104
  });
105
105
 
106
+ /**
107
+ * What fast-xml-parser produces for a parsed element: a string for text
108
+ * content (`parseTagValue: false`), an object of child elements, an array for
109
+ * a repeated element, or null for a self-closed one.
110
+ */
111
+ type XmlValue =
112
+ | string
113
+ | number
114
+ | boolean
115
+ | null
116
+ | undefined
117
+ | XmlValue[]
118
+ | { [element: string]: XmlValue };
119
+
120
+ /**
121
+ * fast-xml-parser models an element with child elements as an object; a
122
+ * text-only or self-closed element parses to a string or null instead, which
123
+ * these guards reject the same way the crawler always has.
124
+ */
125
+ const isUrlsetElement = (value: XmlValue): value is { url?: XmlValue } =>
126
+ typeof value === "object" && value !== null;
127
+
128
+ const isUrlEntry = (
129
+ value: XmlValue
130
+ ): value is { lastmod?: XmlValue; loc?: XmlValue } =>
131
+ typeof value === "object" && value !== null;
132
+
133
+ const isText = (value: XmlValue): value is string => typeof value === "string";
134
+
106
135
  /**
107
136
  * Parse `sitemap.xml`. Deliberately shallow: the checks only need the `<loc>`
108
137
  * list, each loc's `<lastmod>`, and whether the document is a urlset at all.
@@ -113,9 +142,9 @@ export const parseSitemap = (
113
142
  bytes: number
114
143
  ): SitemapDoc => {
115
144
  const doc: SitemapDoc = { bytes, file, lastmod: new Map(), urls: [] };
116
- let parsed: Record<string, unknown>;
145
+ let parsed: { sitemapindex?: XmlValue; urlset?: XmlValue };
117
146
  try {
118
- parsed = sitemapParser.parse(xml) as Record<string, unknown>;
147
+ parsed = sitemapParser.parse(xml);
119
148
  } catch {
120
149
  doc.error = "no <urlset> element";
121
150
  return doc;
@@ -126,20 +155,19 @@ export const parseSitemap = (
126
155
  : "no <urlset> element";
127
156
  return doc;
128
157
  }
129
- const urlset = parsed.urlset as { url?: unknown } | string | null;
130
- const entries =
131
- typeof urlset === "object" && urlset !== null ? [urlset.url].flat() : [];
158
+ const { urlset } = parsed;
159
+ const entries = isUrlsetElement(urlset) ? [urlset.url].flat() : [];
132
160
  for (const entry of entries) {
133
- if (typeof entry !== "object" || entry === null) {
161
+ if (!isUrlEntry(entry)) {
134
162
  continue;
135
163
  }
136
- const { loc, lastmod } = entry as { loc?: unknown; lastmod?: unknown };
137
- const locText = typeof loc === "string" ? loc.trim() : "";
164
+ const { loc, lastmod } = entry;
165
+ const locText = isText(loc) ? loc.trim() : "";
138
166
  if (!locText) {
139
167
  continue;
140
168
  }
141
169
  doc.urls.push(locText);
142
- if (typeof lastmod === "string" && lastmod.trim() !== "") {
170
+ if (isText(lastmod) && lastmod.trim() !== "") {
143
171
  doc.lastmod?.set(locText, lastmod.trim());
144
172
  }
145
173
  }
@@ -9,26 +9,26 @@ import type { CheckId } from "./catalog.ts";
9
9
  import type { AuditResult } from "./run.ts";
10
10
  import type { AuditCategory, AuditTier } from "./types.ts";
11
11
 
12
- const SEVERITY_COLOR: Record<DiagnosticSeverity, ColorFunction> = {
12
+ const SEVERITY_COLOR = {
13
13
  error: colors.red,
14
14
  info: colors.blue,
15
15
  warning: colors.yellow,
16
- };
16
+ } satisfies Record<DiagnosticSeverity, ColorFunction>;
17
17
 
18
- const GLYPH: Record<DiagnosticSeverity, string> = {
18
+ const GLYPH = {
19
19
  error: "✖",
20
20
  info: "ℹ",
21
21
  warning: "⚠",
22
- };
22
+ } satisfies Record<DiagnosticSeverity, string>;
23
23
 
24
24
  /** How many affected pages to list before collapsing the rest. */
25
25
  const PREVIEW = 3;
26
26
 
27
- /** The tier a category belongs to, for the "skipped" line. */
28
- const TIER_FLAG: Partial<Record<AuditTier, string>> = {
29
- external: "--external",
30
- network: "--url <origin>",
31
- };
27
+ /** The skippable tiers, each with the flag that runs it. */
28
+ const TIER_FLAGS: { tier: AuditTier; flag: string }[] = [
29
+ { flag: "--external", tier: "external" },
30
+ { flag: "--url <origin>", tier: "network" },
31
+ ];
32
32
 
33
33
  interface CheckRollup {
34
34
  id: CheckId;
@@ -55,18 +55,21 @@ export const rollup = (diagnostics: Diagnostic[]): CheckRollup[] => {
55
55
  }
56
56
  }
57
57
 
58
- const order: Record<DiagnosticSeverity, number> = {
58
+ const order = {
59
59
  error: 0,
60
60
  info: 2,
61
61
  warning: 1,
62
- };
62
+ } satisfies Record<DiagnosticSeverity, number>;
63
63
  const checks = [...groups.entries()].map(([id, findings]) => {
64
- const { category, severity, title } = checkMeta(id as CheckId);
64
+ // SAFETY: audit findings are only ever created through `finding()`, whose
65
+ // codes are the check catalog's ids.
66
+ const checkId = id as CheckId;
67
+ const { category, severity, title } = checkMeta(checkId);
65
68
  return {
66
69
  category,
67
70
  count: findings.length,
68
71
  findings,
69
- id: id as CheckId,
72
+ id: checkId,
70
73
  severity,
71
74
  title,
72
75
  };
@@ -95,12 +98,10 @@ export const rollup = (diagnostics: Diagnostic[]): CheckRollup[] => {
95
98
 
96
99
  /** Categories that had no findings but were never run, and the flag that runs them. */
97
100
  const skippedTiers = (tiers: Record<AuditTier, boolean>): string[] =>
98
- (Object.keys(TIER_FLAG) as AuditTier[])
99
- .filter((tier) => !tiers[tier])
100
- .map((tier) => {
101
- const label = CHECKS.filter((check) => check.tier === tier).length;
102
- return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)`)}`;
103
- });
101
+ TIER_FLAGS.filter(({ tier }) => !tiers[tier]).map(({ flag, tier }) => {
102
+ const label = CHECKS.filter((check) => check.tier === tier).length;
103
+ return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${flag} (${label} checks)`)}`;
104
+ });
104
105
 
105
106
  /** How many checks actually ran, i.e. those whose tier was enabled. */
106
107
  const activeChecks = (tiers: Record<AuditTier, boolean>): number =>
package/src/audit/run.ts CHANGED
@@ -183,11 +183,11 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
183
183
  thresholds: DEFAULT_THRESHOLDS,
184
184
  };
185
185
 
186
- const tiers: Record<AuditTier, boolean> = {
186
+ const tiers = {
187
187
  external: Boolean(options.external),
188
188
  network: origin !== null,
189
189
  static: true,
190
- };
190
+ } satisfies Record<AuditTier, boolean>;
191
191
 
192
192
  const results = await Promise.all(
193
193
  MODULES.filter((module) => tiers[module.tier]).map((module) =>
@@ -197,11 +197,14 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
197
197
 
198
198
  let diagnostics = results.flat();
199
199
  if (options.only?.length) {
200
+ // SAFETY: audit diagnostics are created through `finding()`, whose codes
201
+ // all come from the check catalog's `CheckId` set.
200
202
  diagnostics = diagnostics.filter((d) =>
201
203
  matches(d.code as CheckId, options.only ?? [])
202
204
  );
203
205
  }
204
206
  if (options.skip?.length) {
207
+ // SAFETY: same invariant — every audit diagnostic code is a catalog `CheckId`.
205
208
  diagnostics = diagnostics.filter(
206
209
  (d) => !matches(d.code as CheckId, options.skip ?? [])
207
210
  );
@@ -84,9 +84,7 @@ const collectAssets = (
84
84
  .filter((asset) => asset.src.length > 0);
85
85
 
86
86
  /** Parse each JSON-LD block, keeping the parse failures rather than dropping them. */
87
- const collectJsonLd = (
88
- document: HtmlDocument
89
- ): { jsonld: unknown[]; jsonldErrors: string[] } => {
87
+ const collectJsonLd = (document: HtmlDocument) => {
90
88
  const jsonld: unknown[] = [];
91
89
  const jsonldErrors: string[] = [];
92
90
  for (const script of document.querySelectorAll(
@@ -106,7 +104,7 @@ const prefixedMeta = (
106
104
  document: HtmlDocument,
107
105
  keyAttr: "property" | "name",
108
106
  prefix: string
109
- ): Record<string, string> => {
107
+ ) => {
110
108
  const found: Record<string, string> = {};
111
109
  for (const element of document.querySelectorAll(
112
110
  `meta[${keyAttr}^="${prefix}"]`
@@ -161,11 +161,32 @@ export interface LinkGraph {
161
161
  /** Astro's reserved error routes. Never indexable, never crawlable — by design. */
162
162
  export const ERROR_ROUTES: ReadonlySet<string> = new Set(["/404", "/500"]);
163
163
 
164
- /** Tunable limits. Not yet configurable — CLI-only until the ids settle. */
164
+ /**
165
+ * Tunable limits. Not yet configurable — CLI-only until the ids settle.
166
+ *
167
+ * The title and description limits are measured in **display columns**, where
168
+ * a fullwidth or wide character counts 2 and everything else 1 — not in
169
+ * characters. What a search engine truncates is the space the text takes up,
170
+ * and a character count only stands in for that where every character is one
171
+ * column wide, which is true of Latin text and of nothing else. Counted in
172
+ * characters one range cannot serve both scripts: it is at once too strict for
173
+ * a Japanese description (which says in ~60 characters what English needs ~120
174
+ * for, and so reads as "too short") and too loose for a Japanese title (60
175
+ * characters render as wide as 120 Latin ones, and truncate). ASCII and
176
+ * precomposed (NFC) Latin text score identically either way; a decomposed
177
+ * (NFD) combining mark counts 0 where a character count saw 1. East-Asian
178
+ * "ambiguous"-width characters (★ ※ ①) count 1 per `string-width`'s default,
179
+ * though CJK fonts render them fullwidth — resolving that would need the
180
+ * page's language, which the audit does not thread through yet.
181
+ */
165
182
  export interface AuditThresholds {
183
+ /** Display columns, not characters. */
166
184
  titleMin: number;
185
+ /** Display columns, not characters. */
167
186
  titleMax: number;
187
+ /** Display columns, not characters. */
168
188
  descriptionMin: number;
189
+ /** Display columns, not characters. */
169
190
  descriptionMax: number;
170
191
  minWordCount: number;
171
192
  maxHtmlBytes: number;
@@ -174,8 +195,9 @@ export interface AuditThresholds {
174
195
  }
175
196
 
176
197
  export const DEFAULT_THRESHOLDS: AuditThresholds = {
177
- // Ahrefs' guidance: 110–160 characters. Under ~110 wastes the snippet
178
- // space search results give you; over ~160 gets truncated.
198
+ // Ahrefs' guidance: 110–160. Under ~110 wastes the snippet space search
199
+ // results give you; over ~160 gets truncated. Stated there in characters of
200
+ // English, which is the same number of columns.
179
201
  descriptionMax: 160,
180
202
  descriptionMin: 110,
181
203
  maxAssetBytes: 500 * 1024,
@@ -17,7 +17,10 @@ declare module "blume:search-client" {
17
17
 
18
18
  declare module "blume:ask" {
19
19
  /** The generated Ask trigger (see `askComponentTemplate`); empty when Ask is off. */
20
- const Ask: (props: Record<string, unknown>) => unknown;
20
+ // Mirrors the generated `.blume/src/env.d.ts` declaration: the trigger's type
21
+ // is the AskAI island the enabled template re-exports.
22
+ // oxlint-disable-next-line typescript/consistent-type-imports
23
+ const Ask: typeof import("./components/islands/AskAI.astro").default;
21
24
  export default Ask;
22
25
  }
23
26
 
@@ -34,6 +37,7 @@ declare module "blume:data" {
34
37
  // `tsc` (where the Astro TS plugin isn't active). Not shipped in `dist/types`, so
35
38
  // consumers keep Astro's real `.astro` types and get the true prop shapes.
36
39
  declare module "*.astro" {
40
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-returns -- a wildcard shim spans every `.astro` component, whose prop shapes and render output only Astro's own TS plugin can type
37
41
  const component: (props: Record<string, unknown>) => unknown;
38
42
  export default component;
39
43
  }
@@ -53,6 +53,9 @@ const launchAgentCode = async (
53
53
  try {
54
54
  return await launchAgent(cli.bin, prompt);
55
55
  } catch (error) {
56
+ // SAFETY: only the `code` tag is inspected; a spawn failure throws an
57
+ // ErrnoException, and any other thrown value fails the comparison and
58
+ // rethrows unchanged.
56
59
  if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
57
60
  throw error;
58
61
  }
@@ -123,14 +126,16 @@ export const auditCommand = defineCommand({
123
126
  }
124
127
 
125
128
  const root = process.cwd();
126
- const gate = (args["fail-on"] ??
127
- (args.strict ? "warning" : "error")) as DiagnosticSeverity;
128
- if (!SEVERITIES.includes(gate)) {
129
+ const gateInput = args["fail-on"] ?? (args.strict ? "warning" : "error");
130
+ const gate = SEVERITIES.find((severity) => severity === gateInput);
131
+ if (gate === undefined) {
129
132
  logger.error(
130
- `Invalid --fail-on "${gate}" (use ${SEVERITIES.join(" | ")}).`
133
+ `Invalid --fail-on "${gateInput}" (use ${SEVERITIES.join(" | ")}).`
131
134
  );
132
135
  process.exit(1);
133
136
  }
137
+ // SAFETY: AGENTS is a closed record keyed by AgentKind, so its keys are
138
+ // exactly the agent kinds.
134
139
  const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
135
140
  (kind) => args[kind]
136
141
  );
@@ -64,6 +64,9 @@ import { prepareProject } from "../prepare.ts";
64
64
 
65
65
  const ADAPTERS = ["vercel", "node", "netlify", "cloudflare"] as const;
66
66
 
67
+ const isAdapter = (value: string): value is (typeof ADAPTERS)[number] =>
68
+ ADAPTERS.some((adapter) => adapter === value);
69
+
67
70
  const BUDGET_JS = "budget-js";
68
71
  const BUDGET_CSS = "budget-css";
69
72
 
@@ -295,14 +298,13 @@ const emitVercelNegotiation = async (
295
298
  if (!existsSync(configPath)) {
296
299
  return;
297
300
  }
298
- const overrides: Record<string, string> = {
299
- ...(hasApiCatalog(config)
300
- ? { [API_CATALOG_PATH.slice(1)]: API_CATALOG_TYPE }
301
- : {}),
302
- ...(config.ai.webBotAuth.keys.length > 0
303
- ? { [SIGNATURES_DIRECTORY_PATH.slice(1)]: SIGNATURES_DIRECTORY_TYPE }
304
- : {}),
305
- };
301
+ const overrides: Record<string, string> = {};
302
+ if (hasApiCatalog(config)) {
303
+ overrides[API_CATALOG_PATH.slice(1)] = API_CATALOG_TYPE;
304
+ }
305
+ if (config.ai.webBotAuth.keys.length > 0) {
306
+ overrides[SIGNATURES_DIRECTORY_PATH.slice(1)] = SIGNATURES_DIRECTORY_TYPE;
307
+ }
306
308
  // The homepage rewrite serves `/index.md` from the static layer, so its
307
309
  // `x-markdown-tokens` estimate has to ride the routing config; the runtime
308
310
  // endpoint stamps it on dev/server-rendered responses itself.
@@ -754,7 +756,7 @@ export const buildCommand = defineCommand({
754
756
  logger.error(`Invalid --output "${args.output}" (use static | server).`);
755
757
  process.exit(1);
756
758
  }
757
- if (args.adapter && !ADAPTERS.includes(args.adapter as never)) {
759
+ if (args.adapter && !isAdapter(args.adapter)) {
758
760
  logger.error(
759
761
  `Invalid --adapter "${args.adapter}" (use ${ADAPTERS.join(" | ")}).`
760
762
  );
@@ -765,8 +767,12 @@ export const buildCommand = defineCommand({
765
767
  const project = await prepareProject({
766
768
  mode: "build",
767
769
  overrides: {
770
+ // SAFETY: an invalid --adapter exited above; a set flag is an ADAPTERS
771
+ // member.
768
772
  adapter: args.adapter as (typeof ADAPTERS)[number] | undefined,
769
773
  base: args.base,
774
+ // SAFETY: an invalid --output exited above; a set flag is static or
775
+ // server.
770
776
  output: args.output as "server" | "static" | undefined,
771
777
  },
772
778
  preview: args.preview,
@@ -185,6 +185,8 @@ export const devCommand = defineCommand({
185
185
  reportDiagnostics(next.diagnostics, root);
186
186
  showBlumeErrorOverlay(next.diagnostics);
187
187
  } catch (error) {
188
+ // SAFETY: regeneration failures come from the generator and Astro's
189
+ // server API, which raise Error instances; only the message is shown.
188
190
  logger.error(`Regeneration failed: ${(error as Error).message}`);
189
191
  }
190
192
  }, 80);
@@ -23,6 +23,8 @@ const FALLBACK_NODE_RANGE = ">=22.12.0";
23
23
  * doctor can never drift from what the package actually declares. */
24
24
  const supportedNodeRange = (): string => {
25
25
  try {
26
+ // SAFETY: this parses blume's own package.json; the optional fields cover
27
+ // an `engines` block going missing, and a bad read falls to the catch.
26
28
  const pkg = JSON.parse(
27
29
  readFileSync(join(packageRoot(), "package.json"), "utf-8")
28
30
  ) as { engines?: { node?: string } };
@@ -52,6 +52,9 @@ const launchAgentCode = async (
52
52
  try {
53
53
  return await launchAgent(AGENTS[agent].bin, prompt);
54
54
  } catch (error) {
55
+ // SAFETY: only the `code` tag is inspected; a spawn failure throws an
56
+ // ErrnoException, and any other thrown value fails the comparison and
57
+ // rethrows unchanged.
55
58
  if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
56
59
  throw error;
57
60
  }
@@ -76,9 +79,7 @@ interface EvalFlags {
76
79
  }
77
80
 
78
81
  /** Validate the flag surface, exiting with a message on the first offense. */
79
- const parseFlags = (
80
- args: EvalFlags
81
- ): { agent: AgentKind; threshold: number; timeoutS: number } => {
82
+ const parseFlags = (args: EvalFlags) => {
82
83
  if (!isAgentKind(args.agent)) {
83
84
  logger.error(`Invalid --agent "${args.agent}" (use claude | codex).`);
84
85
  process.exit(1);
@@ -237,6 +238,9 @@ export const evalCommand = defineCommand({
237
238
  logger.error(error.diagnostic.message);
238
239
  process.exit(1);
239
240
  }
241
+ // SAFETY: only the `code` tag is inspected; a spawn failure throws an
242
+ // ErrnoException, and any other thrown value fails the comparison and
243
+ // falls through to the internal-error report.
240
244
  if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
241
245
  notInstalled(agent);
242
246
  }
@@ -16,11 +16,7 @@ import {
16
16
  TEMPLATES,
17
17
  validateContentDir,
18
18
  } from "../init/scaffold.ts";
19
- import type {
20
- InitAnswers,
21
- PackageManager,
22
- Template,
23
- } from "../init/scaffold.ts";
19
+ import type { InitAnswers } from "../init/scaffold.ts";
24
20
  import { logger } from "../log.ts";
25
21
 
26
22
  /**
@@ -45,6 +41,8 @@ const ejectScaffold = async (
45
41
  const steps = [...cd, commands.install, commands.dev];
46
42
  logger.box(`Next steps:\n\n ${steps.join("\n ")}\n`);
47
43
  } catch (error) {
44
+ // SAFETY: eject and the script rewrite throw Error instances; only the
45
+ // message is surfaced in the fallback hint.
48
46
  logger.warn(
49
47
  `Scaffolded, but eject needs the project's dependencies installed to load blume.config.ts: ${(error as Error).message}`
50
48
  );
@@ -93,15 +91,17 @@ export const initCommand = defineCommand({
93
91
  async run({ args }) {
94
92
  const cwd = process.cwd();
95
93
 
96
- const template = args.template as Template | undefined;
97
- if (template !== undefined && !TEMPLATES.includes(template)) {
94
+ const template = TEMPLATES.find((candidate) => candidate === args.template);
95
+ if (args.template !== undefined && template === undefined) {
98
96
  logger.error(
99
97
  `Unknown template "${args.template}" (use ${TEMPLATES.join(" | ")}).`
100
98
  );
101
99
  process.exit(1);
102
100
  }
103
- const pm = args["package-manager"] as PackageManager | undefined;
104
- if (pm !== undefined && !PACKAGE_MANAGERS.includes(pm)) {
101
+ const pm = PACKAGE_MANAGERS.find(
102
+ (candidate) => candidate === args["package-manager"]
103
+ );
104
+ if (args["package-manager"] !== undefined && pm === undefined) {
105
105
  logger.error(
106
106
  `Unknown package manager "${args["package-manager"]}" (use ${PACKAGE_MANAGERS.join(" | ")}).`
107
107
  );
@@ -23,6 +23,9 @@ export const mcpStdioCommand = defineCommand({
23
23
  // must go to stderr or the MCP client chokes on the stray line.
24
24
  let data: McpData;
25
25
  try {
26
+ // SAFETY: the snapshot is written by `blume eval` itself as
27
+ // JSON.stringify of an McpData; a hand-mangled file fails the parse and
28
+ // lands in the catch below.
26
29
  data = JSON.parse(await readFile(args.data, "utf-8")) as McpData;
27
30
  } catch (error) {
28
31
  const detail = error instanceof Error ? error.message : String(error);
@@ -47,10 +47,17 @@ interface TranslateFlags {
47
47
  timeout?: string;
48
48
  }
49
49
 
50
+ /** The validated flag surface `parseFlags` hands the command body. */
51
+ interface ParsedTranslateFlags {
52
+ agent: AgentKind | undefined;
53
+ concurrency: number;
54
+ timeoutS: number;
55
+ }
56
+
50
57
  /** Validate the flag surface, exiting with a message on the first offense. */
51
- const parseFlags = (
52
- args: TranslateFlags
53
- ): { agent: AgentKind | undefined; concurrency: number; timeoutS: number } => {
58
+ const parseFlags = (args: TranslateFlags): ParsedTranslateFlags => {
59
+ // SAFETY: Object.keys of the AGENTS table returns exactly its declared
60
+ // AgentKind keys.
54
61
  const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
55
62
  (kind) => args[kind]
56
63
  );
@@ -227,6 +234,8 @@ export const translateCommand = defineCommand({
227
234
  return;
228
235
  }
229
236
 
237
+ // SAFETY: parseFlags exits unless --check was passed or exactly one
238
+ // agent flag was; on this non-check path the agent is therefore set.
230
239
  const kind = agent as AgentKind;
231
240
  process.stderr.write(
232
241
  `${translateHeaderLine(workList.items.length, workList.targetLocales.length, kind)}\n\n`
@@ -290,6 +299,8 @@ export const translateCommand = defineCommand({
290
299
  logger.error(error.diagnostic.message);
291
300
  process.exit(1);
292
301
  }
302
+ // SAFETY: an ENOENT from spawning the agent CLI is an ErrnoException;
303
+ // any other thrown shape reads `code` as undefined and falls through.
293
304
  if ((error as NodeJS.ErrnoException)?.code === "ENOENT" && agent) {
294
305
  notInstalled(agent);
295
306
  }
@@ -0,0 +1,85 @@
1
+ import { defineCommand } from "citty";
2
+
3
+ import { loadConfig } from "../../core/config.ts";
4
+ import { BlumeError } from "../../core/diagnostics.ts";
5
+ import { CutError, cutVersion } from "../../core/version-cut.ts";
6
+ import { reportInternalError } from "../internal-error.ts";
7
+ import { logger } from "../log.ts";
8
+
9
+ export const versionCommand = defineCommand({
10
+ args: {
11
+ force: {
12
+ description: "Overwrite an existing snapshot directory.",
13
+ type: "boolean",
14
+ },
15
+ id: {
16
+ description: 'Version id to cut (e.g. "v1.0").',
17
+ required: false,
18
+ type: "positional",
19
+ },
20
+ },
21
+ meta: {
22
+ description: "Freeze the current docs as an archived version.",
23
+ name: "version",
24
+ },
25
+ async run({ args }) {
26
+ const root = process.cwd();
27
+
28
+ if (!args.id) {
29
+ const { config } = await loadConfig(root);
30
+ if (!config.versions) {
31
+ logger.info(
32
+ "Versioning is not configured. Cut the first version with `blume version <id>` (e.g. `blume version v1.0`)."
33
+ );
34
+ return;
35
+ }
36
+ const { current, archived } = config.versions;
37
+ process.stdout.write(
38
+ ` ${current.label} (current)${current.badge ? ` — ${current.badge}` : ""}\n`
39
+ );
40
+ for (const version of archived) {
41
+ process.stdout.write(
42
+ ` ${version.label ?? version.id} — ${version.id}/\n`
43
+ );
44
+ }
45
+ return;
46
+ }
47
+
48
+ try {
49
+ const result = await cutVersion(root, args.id, { force: args.force });
50
+ logger.success(
51
+ `Snapshot ${result.dir} (${result.copied} file(s) copied)`
52
+ );
53
+ const totalRewrites = result.rewritten.reduce(
54
+ (sum, entry) => sum + entry.count,
55
+ 0
56
+ );
57
+ if (totalRewrites > 0) {
58
+ logger.info(
59
+ `Rewrote root-absolute links in ${result.rewritten.length} page(s) (${totalRewrites} line(s)).`
60
+ );
61
+ }
62
+ if (result.configUpdated) {
63
+ logger.success(
64
+ `Added "${args.id}" to versions.archived in blume.config.ts`
65
+ );
66
+ } else if (result.configSnippet) {
67
+ logger.info(result.configSnippet);
68
+ }
69
+ logger.info(
70
+ "Archived versions are frozen — future edits belong in the live tree. Restart `blume dev` to pick up the snapshot."
71
+ );
72
+ } catch (error) {
73
+ if (error instanceof CutError) {
74
+ logger.error(error.message);
75
+ process.exit(1);
76
+ }
77
+ if (error instanceof BlumeError) {
78
+ logger.error(error.diagnostic.message);
79
+ process.exit(1);
80
+ }
81
+ reportInternalError(error);
82
+ process.exit(1);
83
+ }
84
+ },
85
+ });