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
@@ -6,9 +6,20 @@ import { enrichDiagnostic } from "../core/diagnostics.ts";
6
6
  import type { Diagnostic } from "../core/types.ts";
7
7
  import { markdownVariantUrl, prefersMarkdown } from "./markdown-negotiation.ts";
8
8
 
9
+ /** The `{ type: "error" }` payload Vite's browser overlay renders. */
10
+ interface OverlayErrorPayload {
11
+ err: {
12
+ id?: string;
13
+ message: string;
14
+ plugin: string;
15
+ stack: string;
16
+ };
17
+ type: "error";
18
+ }
19
+
9
20
  /** The dev server's HMR channel — either `.ws` (Vite ≤5) or `.hot` (Vite 6+). */
10
21
  interface OverlayChannel {
11
- send: (payload: unknown) => void;
22
+ send: (payload: OverlayErrorPayload) => void;
12
23
  }
13
24
  interface OverlayServer {
14
25
  hot?: OverlayChannel;
@@ -164,7 +175,7 @@ export const blumeIntegration = (
164
175
  "astro:server:setup": ({ server }) => {
165
176
  // Keep a handle on the dev server so Blume diagnostics can be pushed to
166
177
  // its browser error overlay (see `showBlumeErrorOverlay`).
167
- overlayServer = server as unknown as OverlayServer;
178
+ overlayServer = server;
168
179
  // Prepend so the rewrite happens before Astro's own request handler,
169
180
  // letting the rewritten URL resolve to the `.md` endpoint.
170
181
  server.middlewares.stack.unshift({
@@ -40,13 +40,20 @@ const VALID_MODES = new Set<IslandClientMode>([
40
40
  "visible",
41
41
  ]);
42
42
 
43
+ /** Whether a declared client mode is one Astro's directives support. */
44
+ const isClientMode = (mode: string): mode is IslandClientMode =>
45
+ // SAFETY: `Set.has` only compares identity at runtime; the assertion widens
46
+ // the lookup key so the narrower-typed set accepts it, and the `has` result
47
+ // is exactly the predicate being claimed.
48
+ VALID_MODES.has(mode as IslandClientMode);
49
+
43
50
  /** Island extensions mapped to the Astro renderer that handles them. */
44
- const FRAMEWORK_BY_EXT: Record<string, IslandFramework> = {
45
- jsx: "react",
46
- svelte: "svelte",
47
- tsx: "react",
48
- vue: "vue",
49
- };
51
+ const FRAMEWORK_BY_EXT = new Map<string, IslandFramework>([
52
+ ["jsx", "react"],
53
+ ["svelte", "svelte"],
54
+ ["tsx", "react"],
55
+ ["vue", "vue"],
56
+ ]);
50
57
 
51
58
  // Captures the extension so we can both strip it from the name and pick the
52
59
  // framework. Kept in sync with the glob below.
@@ -66,13 +73,13 @@ export const readClientMode = (
66
73
  if (!mode) {
67
74
  return DEFAULT_CLIENT;
68
75
  }
69
- if (!VALID_MODES.has(mode as IslandClientMode)) {
76
+ if (!isClientMode(mode)) {
70
77
  warnings.push(
71
78
  `Island "${file}" declares an unknown client mode "${mode}"; defaulting to "${DEFAULT_CLIENT}". Use "load", "idle", "visible", or "only".`
72
79
  );
73
80
  return DEFAULT_CLIENT;
74
81
  }
75
- return mode as IslandClientMode;
82
+ return mode;
76
83
  };
77
84
 
78
85
  /**
@@ -109,7 +116,7 @@ export const discoverIslands = async (
109
116
  const collectIsland = (file: string, source: string): void => {
110
117
  const base = basename(file);
111
118
  const ext = base.match(ISLAND_FILE)?.groups?.ext;
112
- const framework = ext ? FRAMEWORK_BY_EXT[ext] : undefined;
119
+ const framework = ext ? FRAMEWORK_BY_EXT.get(ext) : undefined;
113
120
  if (!framework) {
114
121
  return;
115
122
  }
@@ -37,6 +37,8 @@ const hasWorkspacesField = (pkgPath: string): boolean => {
37
37
  return false;
38
38
  }
39
39
  try {
40
+ // SAFETY: parsed from the user's own package.json; only the presence of a
41
+ // `workspaces` field is read, so this loose shape is all the cast claims.
40
42
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
41
43
  workspaces?: unknown;
42
44
  };
@@ -71,16 +73,19 @@ const findWorkspaceRoot = (start: string): string => {
71
73
  }
72
74
  };
73
75
 
74
- const ADAPTER_IMPORTS: Record<string, string> = {
76
+ type DeploymentAdapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
77
+
78
+ const ADAPTER_IMPORTS = {
75
79
  cloudflare: "@astrojs/cloudflare",
76
80
  netlify: "@astrojs/netlify",
77
81
  node: "@astrojs/node",
78
82
  vercel: "@astrojs/vercel",
79
- };
83
+ } satisfies Record<DeploymentAdapter, string>;
80
84
 
81
- const ADAPTER_OPTIONS: Record<string, string> = {
82
- node: '{ mode: "standalone" }',
83
- };
85
+ /** Adapter constructor arguments, for the adapters that need any. */
86
+ const ADAPTER_OPTIONS = new Map<DeploymentAdapter, string>([
87
+ ["node", '{ mode: "standalone" }'],
88
+ ]);
84
89
 
85
90
  const WRANGLER_CONFIG_FILES = [
86
91
  "wrangler.jsonc",
@@ -169,8 +174,8 @@ export const runtimeDependencies = (options: {
169
174
  deps.push("@astrojs/svelte");
170
175
  }
171
176
  // The Scalar integration is only declared for a Scalar-rendered reference
172
- // (the `renderer: "scalar"` fallback, or AsyncAPI). Blume-rendered OpenAPI
173
- // parses at generate time and needs no runtime Scalar dependency.
177
+ // (the `renderer: "scalar"` opt-out on either block). Blume-rendered
178
+ // references parse at generate time and need no runtime Scalar dependency.
174
179
  if (hasScalarReferences(config)) {
175
180
  deps.push("@scalar/astro");
176
181
  }
@@ -370,6 +375,12 @@ const renderImageOption = (config: ResolvedConfig): string =>
370
375
  ? `\n image: ${JSON.stringify(config.image)},`
371
376
  : "";
372
377
 
378
+ /** What `resolveOptimizeDeps` feeds the generated `optimizeDeps` block. */
379
+ interface OptimizeDepsConfig {
380
+ optimizeDepsEntries: string[];
381
+ optimizeDepsInclude: string[];
382
+ }
383
+
373
384
  /**
374
385
  * Startup-scan entry points and forced includes for the dev dep optimizer:
375
386
  * the Vite root is the generated runtime, so user pages, convention islands,
@@ -383,7 +394,7 @@ const resolveOptimizeDeps = (options: {
383
394
  context: ProjectContext;
384
395
  needsReact: boolean;
385
396
  reactCompilerPath: string | null | undefined;
386
- }): { optimizeDepsEntries: string[]; optimizeDepsInclude: string[] } => {
397
+ }): OptimizeDepsConfig => {
387
398
  const { context } = options;
388
399
  const optimizeDepsEntries = [
389
400
  ...(context.pagesRoot ? [`${context.pagesRoot}/**/*.astro`] : []),
@@ -474,7 +485,7 @@ export const astroConfigTemplate = (options: {
474
485
  if (deployment.adapter === "cloudflare") {
475
486
  return resolveCloudflareAdapterArgs(context);
476
487
  }
477
- return ADAPTER_OPTIONS[deployment.adapter] ?? "";
488
+ return ADAPTER_OPTIONS.get(deployment.adapter) ?? "";
478
489
  })();
479
490
  // Vercel resolves its Build Output tree and its `@vercel/nft` dependency
480
491
  // trace against the Astro root, which for Blume is the hidden `.blume`
@@ -547,19 +558,23 @@ export const astroConfigTemplate = (options: {
547
558
  )}, fallbacks: ${JSON.stringify(
548
559
  font.fallbacks
549
560
  )}, options: { variants: ${JSON.stringify(
550
- font.variants.map((variant) => ({
551
- ...(variant.weight === undefined
552
- ? {}
553
- : { weight: variant.weight }),
554
- ...(variant.style === undefined
555
- ? {}
556
- : { style: variant.style }),
557
- src: [
558
- isAbsolute(variant.src)
559
- ? variant.src
560
- : join(context.root, variant.src),
561
- ],
562
- }))
561
+ font.variants.map((variant) => {
562
+ const face: Pick<typeof variant, "style" | "weight"> = {};
563
+ if (variant.weight !== undefined) {
564
+ face.weight = variant.weight;
565
+ }
566
+ if (variant.style !== undefined) {
567
+ face.style = variant.style;
568
+ }
569
+ return {
570
+ ...face,
571
+ src: [
572
+ isAbsolute(variant.src)
573
+ ? variant.src
574
+ : join(context.root, variant.src),
575
+ ],
576
+ };
577
+ })
563
578
  )} } }`
564
579
  : `{ provider: fontProviders.${font.provider}(), name: ${JSON.stringify(
565
580
  font.name
@@ -1095,10 +1110,17 @@ export const createSearch = () => create({ indexUrl${
1095
1110
  } });
1096
1111
  `;
1097
1112
 
1113
+ /**
1114
+ * Public credential fields baked into a hosted provider's generated client
1115
+ * (Algolia/Orama Cloud/Typesense config values from `search.*`, all plain
1116
+ * strings or numbers; `JSON.stringify` drops the absent ones).
1117
+ */
1118
+ type HostedSearchCredentials = Record<string, string | number | undefined>;
1119
+
1098
1120
  /** A client that passes public credentials straight to the provider SDK. */
1099
1121
  const hostedSearchClient = (
1100
1122
  module: string,
1101
- options: Record<string, unknown>
1123
+ options: HostedSearchCredentials
1102
1124
  ): string =>
1103
1125
  `${SEARCH_CLIENT_HEADER}${searchClientImport(module)}
1104
1126
  export const createSearch = () => create(${JSON.stringify(options)});
@@ -1107,7 +1129,7 @@ export const createSearch = () => create(${JSON.stringify(options)});
1107
1129
  /** Build the per-provider config object the hosted client is created with. */
1108
1130
  const hostedSearchOptions = (
1109
1131
  search: ResolvedConfig["search"]
1110
- ): { module: string; options: Record<string, unknown> } | null => {
1132
+ ): { module: string; options: HostedSearchCredentials } | null => {
1111
1133
  switch (search.provider) {
1112
1134
  case "algolia": {
1113
1135
  return { module: "algolia", options: { ...search.algolia } };
@@ -1389,7 +1411,9 @@ export const ALL: APIRoute = ({ request }) => handler(request);
1389
1411
  };
1390
1412
 
1391
1413
  /** Generate a prerendered endpoint that serves a fixed JSON payload. */
1392
- export const staticJsonEndpointTemplate = (payload: unknown): string =>
1414
+ export const staticJsonEndpointTemplate = <Payload extends object>(
1415
+ payload: Payload
1416
+ ): string =>
1393
1417
  `// Generated by Blume. Do not edit.
1394
1418
  export const prerender = true;
1395
1419
 
@@ -1510,8 +1534,9 @@ export async function GET({ props }: { props: { title: string } }) {
1510
1534
  * live inside our shell. `dataImport` is the route-depth-aware relative path to
1511
1535
  * the generated data module the layout reads.
1512
1536
  */
1513
- export const scalarReferenceTemplate = (options: {
1514
- configuration: Record<string, unknown>;
1537
+ export const scalarReferenceTemplate = <Configuration extends object>(options: {
1538
+ /** Scalar options forwarded verbatim (spec/theme config plus the author's `scalar` escape hatch). */
1539
+ configuration: Configuration;
1515
1540
  dataImport: string;
1516
1541
  noindex?: boolean;
1517
1542
  route: string;
@@ -1687,11 +1712,13 @@ export function getStaticPaths() {
1687
1712
  locale: route.locale,
1688
1713
  route: route.path,
1689
1714
  title: route.title,
1715
+ version: route.version,
1716
+ versionAlternates: route.versionAlternates,
1690
1717
  },
1691
1718
  }));
1692
1719
  }
1693
1720
 
1694
- const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback } = Astro.props;
1721
+ const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback, version, versionAlternates } = Astro.props;
1695
1722
  const entry = await getEntry(collection as CollectionKey, entryId);
1696
1723
  if (!entry) {
1697
1724
  return new Response(null, { status: 404 });
@@ -1723,9 +1750,6 @@ const ogGenerated = !seo.image && Boolean(ogPath);
1723
1750
  const x = { ...data.config.x, ...(seo.x?.creator ? { creator: seo.x.creator } : {}) };
1724
1751
 
1725
1752
  const basedRoute = withBase(route);
1726
- const canonical =
1727
- seo.canonical ??
1728
- (base ? \`\${base}\${basedRoute === "/" ? "" : encodeURI(basedRoute)}\` : null);
1729
1753
 
1730
1754
  // Locale resolution. With i18n on, pick the active locale's nav + dictionary,
1731
1755
  // build hreflang alternates, and derive the language-switcher targets.
@@ -1746,7 +1770,20 @@ const stripLocale = (path: string, codeArg: string) => {
1746
1770
  return prefix && path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path;
1747
1771
  };
1748
1772
 
1749
- const navigation = i18n ? (data.navigationByLocale[locale] ?? data.navigation) : data.navigation;
1773
+ // Version resolution. An archived page renders its snapshot's navigation tree,
1774
+ // points its canonical at the latest equivalent (unless configured otherwise),
1775
+ // and shows the old-version notice.
1776
+ const versionsConfig = data.config.versions;
1777
+ const archived = versionsConfig && version
1778
+ ? (versionsConfig.archived.find((v) => v.id === version) ?? null)
1779
+ : null;
1780
+ const latestVersionAlt = (versionAlternates ?? []).find((alt) => alt.version === "");
1781
+
1782
+ const navigation = version
1783
+ ? (data.navigationByVersion[version]?.[i18n ? locale : ""] ?? data.navigation)
1784
+ : i18n
1785
+ ? (data.navigationByLocale[locale] ?? data.navigation)
1786
+ : data.navigation;
1750
1787
  const ui = i18n ? (data.uiByLocale[locale] ?? data.ui) : data.ui;
1751
1788
  const localeMeta = i18n ? i18n.locales.find((l) => l.code === locale) : null;
1752
1789
  const dir = localeMeta?.dir ?? "ltr";
@@ -1763,6 +1800,19 @@ const absolute = (path: string) => {
1763
1800
  return base + (p === "/" ? "" : p);
1764
1801
  };
1765
1802
 
1803
+ // An archived page defaults its canonical to the same page in the latest docs
1804
+ // when that page still exists — search engines treat the live page as
1805
+ // authoritative without deindexing version-only content. A page's own
1806
+ // \`seo.canonical\` always wins, and \`canonical: "self"\` keeps the default.
1807
+ const canonical =
1808
+ seo.canonical ??
1809
+ (archived && archived.canonical === "latest" && latestVersionAlt && base
1810
+ ? absolute(latestVersionAlt.path)
1811
+ : base
1812
+ ? \`\${base}\${basedRoute === "/" ? "" : encodeURI(basedRoute)}\`
1813
+ : null);
1814
+ const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
1815
+
1766
1816
  const localeAlternates =
1767
1817
  i18n && base
1768
1818
  ? (alternates ?? []).map((alt) => ({ hreflang: alt.locale, href: absolute(alt.path) }))
@@ -1785,6 +1835,72 @@ const localeSwitch = i18n
1785
1835
  })
1786
1836
  : [];
1787
1837
 
1838
+ // Version switcher + old-version notice. The switcher auto-populates from the
1839
+ // versions config as a \`kind: "version"\` selector; a user-declared version
1840
+ // selector in \`navigation.selectors\` suppresses it (theirs renders instead).
1841
+ // Fallback version roots compose like real routes — \`{basePath}/{locale?}/{id}\`
1842
+ // (manifest \`versionAlternates\` paths arrive with the base already applied).
1843
+ const versionRootFor = (id: string) => {
1844
+ const logical = id ? \`/\${id}\` : "/";
1845
+ const localized = i18n ? localizeRoute(logical, locale) : logical;
1846
+ const mount = data.config.basePath;
1847
+ if (!mount) {
1848
+ return localized;
1849
+ }
1850
+ return localized === "/" ? mount : \`\${mount}\${localized}\`;
1851
+ };
1852
+ const samePageSwitch = versionsConfig
1853
+ ? versionsConfig.switcher.redirect === "same-page"
1854
+ : true;
1855
+ const userHasVersionSelector = navigation.selectors.some(
1856
+ (selector) => selector.kind === "version"
1857
+ );
1858
+ const versionSelector =
1859
+ versionsConfig && !userHasVersionSelector
1860
+ ? {
1861
+ items: [
1862
+ {
1863
+ id: "",
1864
+ label: versionsConfig.current.label,
1865
+ tag: versionsConfig.current.badge,
1866
+ },
1867
+ ...versionsConfig.archived.map((v) => ({
1868
+ id: v.id,
1869
+ label: v.label ?? v.id,
1870
+ tag: undefined,
1871
+ })),
1872
+ ].map((entry) => {
1873
+ const alt = (versionAlternates ?? []).find(
1874
+ (a) => a.version === entry.id
1875
+ );
1876
+ return {
1877
+ label: entry.label,
1878
+ path: samePageSwitch && alt ? alt.path : versionRootFor(entry.id),
1879
+ ...(entry.tag ? { tag: entry.tag } : {}),
1880
+ };
1881
+ }),
1882
+ kind: "version" as const,
1883
+ label: ui.versions.switcher,
1884
+ }
1885
+ : null;
1886
+
1887
+ const versionNotice =
1888
+ archived && archived.banner !== false
1889
+ ? {
1890
+ latestHref: latestVersionAlt
1891
+ ? latestVersionAlt.path
1892
+ : versionRootFor(""),
1893
+ latestLabel: ui.versions.latest,
1894
+ message:
1895
+ typeof archived.banner === "string"
1896
+ ? archived.banner
1897
+ : ui.versions.notice.replace(
1898
+ "{version}",
1899
+ archived.label ?? archived.id
1900
+ ),
1901
+ }
1902
+ : null;
1903
+
1788
1904
  // The whole page shell is overridable via \`layout.Layout\`; it receives the same
1789
1905
  // props as the built-in RootLayout, plus the \`layout\` map for its inner slots.
1790
1906
  const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
@@ -1809,6 +1925,9 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1809
1925
  localeAlternates={localeAlternates}
1810
1926
  xDefault={xDefault}
1811
1927
  localeSwitch={localeSwitch}
1928
+ versionSelector={versionSelector}
1929
+ versionNotice={versionNotice}
1930
+ searchVersion={versionsConfig ? version : null}
1812
1931
  page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
1813
1932
  headings={headings}
1814
1933
  toc={data.config.toc}
@@ -1831,7 +1950,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1831
1950
  pageType={frontmatter.type}
1832
1951
  published={frontmatter.date ?? frontmatter.changelog?.date ?? null}
1833
1952
  lastModified={lastModified}
1834
- noindex={seo.noindex}
1953
+ noindex={effectiveNoindex}
1835
1954
  structuredDataEnabled={data.config.structuredData}
1836
1955
  >
1837
1956
  <h1>{title}</h1>
@@ -19,7 +19,7 @@ export interface AgentCli {
19
19
 
20
20
  export type AgentKind = "claude" | "codex";
21
21
 
22
- export const AGENTS: Record<AgentKind, AgentCli> = {
22
+ export const AGENTS = {
23
23
  claude: {
24
24
  bin: "claude",
25
25
  install: "npm install -g @anthropic-ai/claude-code",
@@ -30,7 +30,7 @@ export const AGENTS: Record<AgentKind, AgentCli> = {
30
30
  install: "npm install -g @openai/codex",
31
31
  name: "Codex",
32
32
  },
33
- };
33
+ } satisfies Record<AgentKind, AgentCli>;
34
34
 
35
35
  /**
36
36
  * Write the full JSON report where the agent can read it. A file rather than
@@ -1,3 +1,5 @@
1
+ import stringWidth from "string-width";
2
+
1
3
  import matter from "../../core/frontmatter.ts";
2
4
  import type { Diagnostic } from "../../core/types.ts";
3
5
  import { finding } from "../catalog.ts";
@@ -5,6 +7,17 @@ import { pageSite } from "../locate.ts";
5
7
  import { ERROR_ROUTES } from "../types.ts";
6
8
  import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
7
9
 
10
+ // Extraction trims only the ends; interior runs of whitespace (a <title>
11
+ // authored across indented source lines) survive. A search engine collapses
12
+ // them to single spaces before rendering, so the width is measured on the
13
+ // collapsed text — source formatting must not move a page across a threshold.
14
+ const WHITESPACE = /\s+/gu;
15
+
16
+ /** Whether front matter `date` arrived in one of YAML's two date spellings. */
17
+ const isDateValue = (
18
+ value: string | Date | undefined
19
+ ): value is string | Date => typeof value === "string" || value instanceof Date;
20
+
8
21
  /**
9
22
  * The `date` a source file's front matter declares, when it parses to a real
10
23
  * date. YAML hands back a `Date` for an unquoted `2026-01-01` and a string for
@@ -14,8 +27,8 @@ import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
14
27
  */
15
28
  const frontmatterDate = (source: string): Date | null => {
16
29
  try {
17
- const { date } = matter(source).data as { date?: unknown };
18
- if (typeof date !== "string" && !(date instanceof Date)) {
30
+ const { date } = matter(source).data;
31
+ if (!isDateValue(date)) {
19
32
  return null;
20
33
  }
21
34
  const parsed = date instanceof Date ? date : new Date(date);
@@ -52,13 +65,16 @@ const titleChecks = (
52
65
  )
53
66
  );
54
67
  }
55
- if (title.length > titleMax || title.length < titleMin) {
56
- const direction = title.length > titleMax ? "long" : "short";
68
+ // Columns, not characters: a search engine truncates by the space the text
69
+ // takes up. See {@link AuditThresholds}.
70
+ const width = stringWidth(title.replaceAll(WHITESPACE, " "));
71
+ if (width > titleMax || width < titleMin) {
72
+ const direction = width > titleMax ? "long" : "short";
57
73
  found.push(
58
74
  finding(
59
75
  "BLUME_AUDIT_TITLE_LENGTH",
60
76
  pageSite(context, page, ["title"]),
61
- `Title is ${title.length} characters — too ${direction} (aim for ${titleMin}–${titleMax}).`
77
+ `Title renders ${width} columns wide — too ${direction} (aim for ${titleMin}–${titleMax}).`
62
78
  )
63
79
  );
64
80
  }
@@ -99,16 +115,15 @@ const descriptionChecks = (
99
115
  )
100
116
  );
101
117
  }
102
- if (
103
- description.length > descriptionMax ||
104
- description.length < descriptionMin
105
- ) {
106
- const direction = description.length > descriptionMax ? "long" : "short";
118
+ // Columns, not characters. See {@link AuditThresholds}.
119
+ const width = stringWidth(description.replaceAll(WHITESPACE, " "));
120
+ if (width > descriptionMax || width < descriptionMin) {
121
+ const direction = width > descriptionMax ? "long" : "short";
107
122
  found.push(
108
123
  finding(
109
124
  "BLUME_AUDIT_DESCRIPTION_LENGTH",
110
125
  pageSite(context, page, ["description"]),
111
- `Meta description is ${description.length} characters — too ${direction} (aim for ${descriptionMin}–${descriptionMax}).`
126
+ `Meta description renders ${width} columns wide — too ${direction} (aim for ${descriptionMin}–${descriptionMax}).`
112
127
  )
113
128
  );
114
129
  }
@@ -68,6 +68,9 @@ const fetchDohJson = async (
68
68
  if (!response.ok) {
69
69
  return null;
70
70
  }
71
+ // SAFETY: the resolver answered a dns-json query, whose response schema
72
+ // DohResponse models with every field optional; a malformed body that
73
+ // fails to parse rejects into the catch below.
71
74
  return (await response.json()) as DohResponse;
72
75
  } catch {
73
76
  return null;
@@ -101,13 +101,31 @@ const canonicalChecks = (
101
101
  )
102
102
  );
103
103
  } else if (context.byUrl.has(target)) {
104
- found.push(
105
- finding(
106
- "BLUME_AUDIT_CANONICAL_NOT_SELF",
107
- pageSite(context, page, ["seo", "canonical"]),
108
- `Page declares ${target} as its canonical, so it will not be indexed itself.`
109
- )
104
+ // An archived-version page pointing at its latest equivalent is Blume's
105
+ // own default (versions.archived[].canonical: "latest"), not a defect —
106
+ // the live page is the authoritative one by design.
107
+ const version = page.route?.version;
108
+ const archived = version
109
+ ? context.project.config.versions?.archived.find(
110
+ (entry) => entry.id === version
111
+ )
112
+ : undefined;
113
+ const latestAlternate = page.route?.versionAlternates.find(
114
+ (alternate) => alternate.version === ""
110
115
  );
116
+ const intentional =
117
+ archived?.canonical === "latest" &&
118
+ latestAlternate !== undefined &&
119
+ normalizePath(latestAlternate.path) === target;
120
+ if (!intentional) {
121
+ found.push(
122
+ finding(
123
+ "BLUME_AUDIT_CANONICAL_NOT_SELF",
124
+ pageSite(context, page, ["seo", "canonical"]),
125
+ `Page declares ${target} as its canonical, so it will not be indexed itself.`
126
+ )
127
+ );
128
+ }
111
129
  } else {
112
130
  found.push(
113
131
  finding(
@@ -6,12 +6,17 @@ import { ERROR_ROUTES } from "../types.ts";
6
6
  import type { AuditContext, CheckModule } from "../types.ts";
7
7
  import { normalizePath, siteOrigin } from "../url.ts";
8
8
 
9
+ /** The object form of `ai.llmsTxt`. The schema always emits it, but hand-built
10
+ * audit contexts (tests, partial configs) may still carry the raw boolean. */
11
+ const isLlmsToggleObject = (
12
+ value: boolean | { enabled: boolean; openapi: boolean } | undefined
13
+ ): value is { enabled: boolean; openapi: boolean } =>
14
+ typeof value === "object" && value !== null;
15
+
9
16
  /** The `ai.llmsTxt` config normalized to what the checks need. */
10
- const llmsConfig = (
11
- context: AuditContext
12
- ): { enabled: boolean; openapi: boolean } => {
17
+ const llmsConfig = (context: AuditContext) => {
13
18
  const value = context.project.config.ai?.llmsTxt;
14
- if (typeof value === "object" && value !== null) {
19
+ if (isLlmsToggleObject(value)) {
15
20
  return { enabled: value.enabled, openapi: value.openapi };
16
21
  }
17
22
  return { enabled: value !== false, openapi: true };
@@ -252,6 +252,8 @@ export const externalChecks: CheckModule = {
252
252
  if (!result) {
253
253
  continue;
254
254
  }
255
+ // SAFETY: every `linkers` entry is created with its linking page and only
256
+ // ever appended to, so the array is never empty.
255
257
  const site = pageSite(context, pages[0] as PageSnapshot);
256
258
 
257
259
  const grade = gradeExternal(result);
@@ -89,6 +89,18 @@ export const socialChecks: CheckModule = {
89
89
  tier: "static",
90
90
  };
91
91
 
92
+ /** The JSON-LD keywords the audit reads off a parsed block. */
93
+ interface JsonLdNode {
94
+ "@context"?: unknown;
95
+ "@graph"?: unknown;
96
+ "@type"?: unknown;
97
+ }
98
+
99
+ /** Whether one parsed JSON-LD value is an object the keyword checks can read. */
100
+ const isJsonLdNode = (
101
+ value: PageSnapshot["jsonld"][number]
102
+ ): value is JsonLdNode => typeof value === "object" && value !== null;
103
+
92
104
  /**
93
105
  * What's missing from one JSON-LD block.
94
106
  *
@@ -98,28 +110,24 @@ export const socialChecks: CheckModule = {
98
110
  * and each entry carries its own `@type` — so demanding `@type` on the root, or
99
111
  * `@context` on each entry, would flag perfectly valid structured data.
100
112
  */
101
- const jsonLdProblems = (node: unknown): string[] => {
102
- if (typeof node !== "object" || node === null) {
113
+ const jsonLdProblems = (node: PageSnapshot["jsonld"][number]): string[] => {
114
+ if (!isJsonLdNode(node)) {
103
115
  return ["it is not an object"];
104
116
  }
105
- const record = node as Record<string, unknown>;
106
117
  const problems: string[] = [];
107
- if (!record["@context"]) {
118
+ if (!node["@context"]) {
108
119
  problems.push("@context");
109
120
  }
110
121
 
111
- const graph = record["@graph"];
122
+ const graph = node["@graph"];
112
123
  if (Array.isArray(graph)) {
113
124
  const untyped = graph.filter(
114
- (entry) =>
115
- typeof entry !== "object" ||
116
- entry === null ||
117
- !(entry as Record<string, unknown>)["@type"]
125
+ (entry) => !isJsonLdNode(entry) || !entry["@type"]
118
126
  ).length;
119
127
  if (untyped > 0) {
120
128
  problems.push(`@type on ${untyped} of its ${graph.length} @graph nodes`);
121
129
  }
122
- } else if (!record["@type"]) {
130
+ } else if (!node["@type"]) {
123
131
  problems.push("@type");
124
132
  }
125
133