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
@@ -29,15 +29,32 @@ export interface DevLockInfo {
29
29
 
30
30
  const lockPath = (outDir: string): string => join(outDir, "dev.lock");
31
31
 
32
- const isValidPid = (pid: unknown): pid is number =>
32
+ /** What `JSON.parse` can yield for a lock file body. */
33
+ type LockFileValue =
34
+ | string
35
+ | number
36
+ | boolean
37
+ | null
38
+ | LockFileValue[]
39
+ | { [key: string]: LockFileValue };
40
+
41
+ const isValidPid = (pid: LockFileValue | undefined): pid is number =>
33
42
  typeof pid === "number" && Number.isInteger(pid) && pid > 0;
34
43
 
44
+ const isPortNumber = (port: LockFileValue | undefined): port is number =>
45
+ typeof port === "number";
46
+
47
+ const isLockRecord = (
48
+ data: LockFileValue
49
+ ): data is { pid?: LockFileValue; port?: LockFileValue } =>
50
+ typeof data === "object" && data !== null;
51
+
35
52
  /**
36
53
  * Parse a lock file body. Current locks are JSON (`{"pid":123,"port":3001}`);
37
54
  * a bare integer (the pre-port format) still parses as a pid-only lock.
38
55
  */
39
56
  const parseLock = (raw: string): DevLockInfo | null => {
40
- let data: unknown;
57
+ let data: LockFileValue;
41
58
  try {
42
59
  data = JSON.parse(raw.trim());
43
60
  } catch {
@@ -46,10 +63,10 @@ const parseLock = (raw: string): DevLockInfo | null => {
46
63
  if (isValidPid(data)) {
47
64
  return { pid: data };
48
65
  }
49
- if (typeof data === "object" && data !== null) {
50
- const { pid, port } = data as { pid?: unknown; port?: unknown };
66
+ if (isLockRecord(data)) {
67
+ const { pid, port } = data;
51
68
  if (isValidPid(pid)) {
52
- return typeof port === "number" ? { pid, port } : { pid };
69
+ return isPortNumber(port) ? { pid, port } : { pid };
53
70
  }
54
71
  }
55
72
  return null;
@@ -63,6 +80,7 @@ const isProcessAlive = (pid: number): boolean => {
63
80
  } catch (error) {
64
81
  // EPERM means the process exists but belongs to another user — still
65
82
  // live, so the lock must hold (only ESRCH proves it's gone).
83
+ // SAFETY: `process.kill` failures are errno exceptions.
66
84
  return (error as NodeJS.ErrnoException).code === "EPERM";
67
85
  }
68
86
  };
@@ -84,11 +102,13 @@ export const readDevLock = (outDir: string): DevLockInfo | null => {
84
102
  export const isDevLocked = (outDir: string): boolean =>
85
103
  readDevLock(outDir) !== null;
86
104
 
87
- const lockPayload = (port?: number): string =>
88
- JSON.stringify({
89
- pid: process.pid,
90
- ...(port === undefined ? {} : { port }),
91
- });
105
+ const lockPayload = (port?: number): string => {
106
+ const info: DevLockInfo = { pid: process.pid };
107
+ if (port !== undefined) {
108
+ info.port = port;
109
+ }
110
+ return JSON.stringify(info);
111
+ };
92
112
 
93
113
  const writeLock = (outDir: string, port?: number): void => {
94
114
  writeFileSync(lockPath(outDir), lockPayload(port));
@@ -128,6 +148,7 @@ const tryClaimLock = (outDir: string, port?: number): boolean => {
128
148
  writeFileSync(lockPath(outDir), lockPayload(port), { flag: "wx" });
129
149
  return true;
130
150
  } catch (error) {
151
+ // SAFETY: `writeFileSync` failures are errno exceptions.
131
152
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
132
153
  throw error;
133
154
  }
@@ -5,6 +5,21 @@ import { join } from "pathe";
5
5
  import type { ResolvedConfig } from "../core/schema.ts";
6
6
  import { searchProviderMeta } from "../search/providers.ts";
7
7
 
8
+ /** A JSON value, as `JSON.parse` of a manifest can return. */
9
+ type JsonValue =
10
+ | string
11
+ | number
12
+ | boolean
13
+ | null
14
+ | JsonValue[]
15
+ | { [key: string]: JsonValue };
16
+
17
+ /** The slice of package.json the rewrite touches; the rest rides along. */
18
+ interface PackageManifest {
19
+ [key: string]: JsonValue | undefined;
20
+ scripts?: Record<string, string>;
21
+ }
22
+
8
23
  /**
9
24
  * The `blume build`-only artifacts this project's config actually produces, as
10
25
  * notice lines for the eject command. After an eject the build script runs
@@ -55,13 +70,13 @@ export const droppedArtifactNotices = (config: ResolvedConfig): string[] => {
55
70
  */
56
71
  export const updatePackageScripts = async (root: string): Promise<void> => {
57
72
  const pkgPath = join(root, "package.json");
58
- let pkg: Record<string, unknown>;
73
+ let pkg: PackageManifest;
59
74
  try {
60
75
  pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
61
76
  } catch {
62
77
  return;
63
78
  }
64
- const scripts = (pkg.scripts ?? {}) as Record<string, string>;
79
+ const scripts = pkg.scripts ?? {};
65
80
  pkg.scripts = {
66
81
  ...scripts,
67
82
  build: "astro build",
package/src/cli/index.ts CHANGED
@@ -15,6 +15,7 @@ import { previewCommand } from "./commands/preview.ts";
15
15
  import { syncCommand } from "./commands/sync.ts";
16
16
  import { translateCommand } from "./commands/translate.ts";
17
17
  import { validateCommand } from "./commands/validate.ts";
18
+ import { versionCommand } from "./commands/version.ts";
18
19
  import { loadEnvFiles } from "./env.ts";
19
20
  import { reportInternalError } from "./internal-error.ts";
20
21
 
@@ -39,6 +40,7 @@ const main = defineCommand({
39
40
  sync: syncCommand,
40
41
  translate: translateCommand,
41
42
  validate: validateCommand,
43
+ version: versionCommand,
42
44
  },
43
45
  });
44
46
 
@@ -52,7 +52,7 @@ export interface InitFlags {
52
52
  template?: Template;
53
53
  }
54
54
 
55
- const cancelled = (value: unknown): value is symbol =>
55
+ const cancelled = (value: string | string[] | symbol): value is symbol =>
56
56
  typeof value === "symbol";
57
57
 
58
58
  /**
@@ -54,7 +54,7 @@ interface Starter {
54
54
  const page = (title: string, description: string, body: string): string =>
55
55
  `---\ntitle: ${title}\ndescription: ${description}\n---\n\n${body}\n`;
56
56
 
57
- export const STARTERS: Record<Template, Starter> = {
57
+ export const STARTERS = {
58
58
  api: {
59
59
  configExtra: `
60
60
  openapi: {
@@ -135,16 +135,14 @@ export const STARTERS: Record<Template, Starter> = {
135
135
  },
136
136
  ],
137
137
  },
138
- };
138
+ } satisfies Record<Template, Starter>;
139
139
 
140
140
  /**
141
141
  * Install + dev commands to print for the chosen package manager, plus the
142
142
  * prefix that runs a locally installed bin (`exec`, e.g. `npx blume eject`) —
143
143
  * dependency bins aren't on PATH, so a bare `blume …` hint would not run.
144
144
  */
145
- export const commandsFor = (
146
- pm: PackageManager
147
- ): { build: string; dev: string; exec: string; install: string } => ({
145
+ export const commandsFor = (pm: PackageManager) => ({
148
146
  // `bun build` invokes Bun's bundler, not the package.json `build` script —
149
147
  // unlike `bun dev`, the script name is shadowed by a builtin subcommand.
150
148
  build: pm === "npm" || pm === "bun" ? `${pm} run build` : `${pm} build`,
@@ -153,6 +151,9 @@ export const commandsFor = (
153
151
  install: `${pm} install`,
154
152
  });
155
153
 
154
+ const isPackageManager = (value: string): value is PackageManager =>
155
+ PACKAGE_MANAGERS.some((pm) => pm === value);
156
+
156
157
  /**
157
158
  * Derive the package manager from an npm user-agent string (the first
158
159
  * `name/version` token of `npm_config_user_agent`), falling back to npm.
@@ -160,8 +161,8 @@ export const commandsFor = (
160
161
  * runner is the only signal.
161
162
  */
162
163
  export const detectPackageManager = (userAgent?: string): PackageManager => {
163
- const name = userAgent?.split("/")[0] as PackageManager | undefined;
164
- return name !== undefined && PACKAGE_MANAGERS.includes(name) ? name : "npm";
164
+ const name = userAgent?.split("/")[0];
165
+ return name !== undefined && isPackageManager(name) ? name : "npm";
165
166
  };
166
167
 
167
168
  /**
@@ -176,8 +177,8 @@ export const detectProjectPackageManager = async (
176
177
  root: string
177
178
  ): Promise<PackageManager> => {
178
179
  const detected = await detect({ cwd: root });
179
- const name = detected?.name as PackageManager | undefined;
180
- return name !== undefined && PACKAGE_MANAGERS.includes(name)
180
+ const name = detected?.name;
181
+ return name !== undefined && isPackageManager(name)
181
182
  ? name
182
183
  : detectPackageManager(process.env.npm_config_user_agent);
183
184
  };
@@ -216,7 +217,7 @@ const hasRemoteSource = (sources: SourceKind[]): boolean =>
216
217
  * Config snippets for each remote source kind, with placeholder values to
217
218
  * replace and comments naming the env var each source authenticates with.
218
219
  */
219
- const SOURCE_SNIPPETS: Record<Exclude<SourceKind, "filesystem">, string> = {
220
+ const SOURCE_SNIPPETS = {
220
221
  "github-releases": ` // Changelog entries from GitHub Releases. Private repos read
221
222
  // GITHUB_TOKEN from the environment.
222
223
  {
@@ -247,7 +248,7 @@ const SOURCE_SNIPPETS: Record<Exclude<SourceKind, "filesystem">, string> = {
247
248
  query: \`*[_type == "doc"]\`,
248
249
  prefix: "sanity",
249
250
  },`,
250
- };
251
+ } satisfies Record<Exclude<SourceKind, "filesystem">, string>;
251
252
 
252
253
  /**
253
254
  * The `content` block for the generated config, or an empty string when the
@@ -293,10 +294,16 @@ export default defineConfig({
293
294
  `;
294
295
 
295
296
  /** SDK dependencies required by the selected remote sources. */
296
- const extraDepsFor = (sources: SourceKind[]): Record<string, string> => ({
297
- ...(sources.includes("notion") && { "@notionhq/client": "^2.2.15" }),
298
- ...(sources.includes("sanity") && { "@sanity/client": "^7.25.0" }),
299
- });
297
+ const extraDepsFor = (sources: SourceKind[]) => {
298
+ const deps: Record<string, string> = {};
299
+ if (sources.includes("notion")) {
300
+ deps["@notionhq/client"] = "^2.2.15";
301
+ }
302
+ if (sources.includes("sanity")) {
303
+ deps["@sanity/client"] = "^7.25.0";
304
+ }
305
+ return deps;
306
+ };
300
307
 
301
308
  /** Every file `init` should write for the given answers, package.json first. */
302
309
  export const buildPlan = (
@@ -33,6 +33,7 @@ export const remapBlumeStack = (stack: string): string =>
33
33
  * message, a trimmed stack, and an environment dump for bug reports. Callers
34
34
  * exit after this — it doesn't exit itself, so it's testable.
35
35
  */
36
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- last-resort handler for whatever a catch clause caught; anything narrower would force casts at every call site
36
37
  export const reportInternalError = (error: unknown): void => {
37
38
  const err = error instanceof Error ? error : new Error(String(error));
38
39
  const lines = [
@@ -75,6 +75,9 @@ export const extractTypeTable = async (
75
75
  ): Promise<TypeTableProperty[]> => {
76
76
  const { name, path, root = process.cwd(), source } = options;
77
77
  const tsModule = await import("typescript");
78
+ // SAFETY: CJS/ESM interop — the typescript package exposes its API namespace
79
+ // either directly or under `default` depending on the loader; both are the
80
+ // same object, which TypeScriptApi models structurally.
78
81
  const ts = (tsModule.default ?? tsModule) as TypeScriptApi;
79
82
 
80
83
  const compilerOptions: CompilerOptions = {
@@ -62,8 +62,11 @@ const registeredDiffThemeNames = new Set<string>();
62
62
  * shared between both modes must not hand light mode the dark-typed
63
63
  * registration.
64
64
  */
65
+ const isThemeName = (theme: CodeTheme): theme is string =>
66
+ typeof theme === "string";
67
+
65
68
  const diffThemeName = (theme: CodeTheme, mode: "dark" | "light"): string => {
66
- if (typeof theme === "string") {
69
+ if (isThemeName(theme)) {
67
70
  return theme;
68
71
  }
69
72
  const type = theme.type ?? mode;
@@ -90,7 +93,7 @@ const diffThemeName = (theme: CodeTheme, mode: "dark" | "light"): string => {
90
93
  return name;
91
94
  };
92
95
 
93
- const diffThemes = (themes: CodeThemes): { dark: string; light: string } => ({
96
+ const diffThemes = (themes: CodeThemes) => ({
94
97
  dark: diffThemeName(themes.dark, "dark"),
95
98
  light: diffThemeName(themes.light, "light"),
96
99
  });
@@ -113,11 +116,12 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
113
116
  theme = DEFAULT_CODE_THEMES,
114
117
  } = options;
115
118
 
116
- if (patch !== undefined || src !== undefined) {
117
- const text = patch ?? (await readText(src as string, root));
119
+ const patchText =
120
+ patch ?? (src === undefined ? undefined : await readText(src, root));
121
+ if (patchText !== undefined) {
118
122
  const result = await preloadPatchDiff({
119
123
  options: { theme: diffThemes(theme) },
120
- patch: text,
124
+ patch: patchText,
121
125
  });
122
126
  return result.prerenderedHTML;
123
127
  }
@@ -41,6 +41,8 @@ const load = async (
41
41
  return null;
42
42
  }
43
43
 
44
+ // SAFETY: GitHub's `GET /repos/{owner}/{repo}` contract carries these three
45
+ // fields on every 2xx response; a malformed body rejects into `loadSafe`.
44
46
  const data = (await response.json()) as {
45
47
  description: string | null;
46
48
  forks_count: number;
@@ -100,12 +100,21 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
100
100
  const EMPTY_SUGGESTIONS: Suggestion[] = [];
101
101
 
102
102
  // The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
103
- // platform (same detection Search.astro uses for its ⌘K hint). Guarded so the
104
- // island still server-renders, where `navigator` doesn't exist; the hint itself
105
- // only renders client-side, inside the portaled panel.
103
+ // platform (same detection Search.astro uses for its ⌘K hint). Guarded via
104
+ // `globalThis` so the island still server-renders where `navigator` doesn't
105
+ // exist; the hint itself only renders client-side, inside the portaled panel.
106
106
  const IS_APPLE =
107
- typeof navigator !== "undefined" &&
108
- /mac|iphone|ipad|ipod/iu.test(navigator.platform);
107
+ globalThis.navigator !== undefined &&
108
+ /mac|iphone|ipad|ipod/iu.test(globalThis.navigator.platform);
109
+
110
+ // Duck-typed (`closest` presence) rather than `instanceof Element`, which
111
+ // needs a DOM global the test environment doesn't provide.
112
+ const isElementLike = (target: EventTarget | null): target is Element => {
113
+ // SAFETY: the cast only names the probed surface; `closest` is verified to
114
+ // exist before the caller uses it.
115
+ const candidate = target as Partial<Element> | null;
116
+ return typeof candidate?.closest === "function";
117
+ };
109
118
 
110
119
  // Ghost icon button, matching the header's theme toggle and repo link.
111
120
  const TRIGGER_CLASS =
@@ -163,6 +172,8 @@ const AskAI = ({
163
172
  // The search modal forwards its query so "Ask AI: <query>" carries straight in.
164
173
  useEffect(() => {
165
174
  const handler = (event: Event) => {
175
+ // SAFETY: `blume:open-ask-ai` is only ever dispatched as a CustomEvent
176
+ // whose optional detail carries the search query.
166
177
  const query = (event as CustomEvent<{ query?: string }>).detail?.query;
167
178
  if (query) {
168
179
  setInput(query);
@@ -190,11 +201,8 @@ const AskAI = ({
190
201
  // An Escape aimed at a modal surface stacked on top (the search
191
202
  // dialog traps focus inside itself) dismisses that surface only —
192
203
  // this window listener still fires for it, and closing the panel
193
- // underneath too would eat the user's conversation view. Duck-typed
194
- // (`closest` presence) rather than `instanceof Element`, which needs
195
- // a DOM global the test environment doesn't provide.
196
- const target = event.target as Partial<Element> | null;
197
- if (typeof target?.closest === "function" && target.closest("dialog")) {
204
+ // underneath too would eat the user's conversation view.
205
+ if (isElementLike(event.target) && event.target.closest("dialog")) {
198
206
  return;
199
207
  }
200
208
  setOpen(false);
@@ -346,12 +354,12 @@ const AskAI = ({
346
354
  // The closed panel is only translated off-screen; `inert` drops its
347
355
  // buttons/textarea from the tab order and the accessibility tree.
348
356
  inert={!open}
349
- className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-border border-s bg-background shadow-2xl transition-transform duration-200 ease-out ${
357
+ className={`border-border bg-background fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-s shadow-2xl transition-transform duration-200 ease-out ${
350
358
  open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
351
359
  }`}
352
360
  >
353
- <header className="flex h-16 shrink-0 items-center justify-between gap-2 border-border border-b px-4">
354
- <span className="font-semibold text-foreground">{t.title}</span>
361
+ <header className="border-border flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4">
362
+ <span className="text-foreground font-semibold">{t.title}</span>
355
363
  <div className="flex items-center gap-0.5">
356
364
  <button
357
365
  aria-label={t.copy}
@@ -383,7 +391,7 @@ const AskAI = ({
383
391
  </header>
384
392
 
385
393
  <div
386
- className="flex flex-1 flex-col scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto"
394
+ className="scrollbar-thumb-border flex flex-1 scrollbar-thin scrollbar-track-transparent flex-col overflow-y-auto"
387
395
  ref={scrollRef}
388
396
  >
389
397
  {hasMessages ? (
@@ -393,7 +401,7 @@ const AskAI = ({
393
401
  {messages.map((message, index) =>
394
402
  message.role === "user" ? (
395
403
  <div
396
- className="max-w-[85%] self-end whitespace-pre-wrap rounded-blume bg-muted px-3 py-2 text-foreground text-sm"
404
+ className="rounded-blume bg-muted text-foreground max-w-[85%] self-end px-3 py-2 text-sm whitespace-pre-wrap"
397
405
  // oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
398
406
  key={index}
399
407
  >
@@ -412,7 +420,7 @@ const AskAI = ({
412
420
  }}
413
421
  />
414
422
  ) : (
415
- <span className="animate-pulse text-muted-foreground">
423
+ <span className="text-muted-foreground animate-pulse">
416
424
  …
417
425
  </span>
418
426
  )}
@@ -423,18 +431,18 @@ const AskAI = ({
423
431
  ) : (
424
432
  <div className="mt-auto flex flex-col gap-0.5 p-4">
425
433
  {suggestions.length === 0 && (
426
- <p className="px-2 text-muted-foreground text-sm">{t.empty}</p>
434
+ <p className="text-muted-foreground px-2 text-sm">{t.empty}</p>
427
435
  )}
428
436
  {suggestions.map((suggestion) => (
429
437
  <button
430
- className="flex cursor-pointer items-center gap-2.5 rounded-blume px-2 py-2 text-start text-foreground text-sm transition-colors hover:bg-muted"
438
+ className="rounded-blume text-foreground hover:bg-muted flex cursor-pointer items-center gap-2.5 px-2 py-2 text-start text-sm transition-colors"
431
439
  key={suggestion.label}
432
440
  onClick={() => runQuestion(suggestion.label)}
433
441
  type="button"
434
442
  >
435
443
  {suggestion.icon && (
436
444
  <span
437
- className="shrink-0 text-muted-foreground [&_svg]:h-[18px] [&_svg]:w-[18px]"
445
+ className="text-muted-foreground shrink-0 [&_svg]:h-[18px] [&_svg]:w-[18px]"
438
446
  // oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
439
447
  dangerouslySetInnerHTML={{ __html: suggestion.icon }}
440
448
  />
@@ -442,12 +450,12 @@ const AskAI = ({
442
450
  <span>{suggestion.label}</span>
443
451
  </button>
444
452
  ))}
445
- <p className="mt-3 flex items-center gap-1.5 px-2 text-muted-foreground text-sm">
453
+ <p className="text-muted-foreground mt-3 flex items-center gap-1.5 px-2 text-sm">
446
454
  {t.tip}
447
- <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
455
+ <kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
448
456
  {IS_APPLE ? "⌘" : "Ctrl"}
449
457
  </kbd>
450
- <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-xs">
458
+ <kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
451
459
  I
452
460
  </kbd>
453
461
  </p>
@@ -456,12 +464,12 @@ const AskAI = ({
456
464
  </div>
457
465
 
458
466
  <form
459
- className="relative shrink-0 border-border border-t"
467
+ className="border-border relative shrink-0 border-t"
460
468
  onSubmit={onSubmit}
461
469
  >
462
470
  <textarea
463
471
  aria-label={t.label}
464
- className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-foreground text-sm pointer-coarse:text-base outline-none placeholder:text-muted-foreground"
472
+ className="text-foreground placeholder:text-muted-foreground max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-sm outline-none pointer-coarse:text-base"
465
473
  onChange={(event) => setInput(event.target.value)}
466
474
  onKeyDown={onInputKeyDown}
467
475
  placeholder={t.placeholder}
@@ -471,7 +479,7 @@ const AskAI = ({
471
479
  />
472
480
  <button
473
481
  aria-label={t.send}
474
- className="absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-blume bg-foreground text-background transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
482
+ className="rounded-blume bg-foreground text-background absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
475
483
  disabled={busy || input.trim().length === 0}
476
484
  type="submit"
477
485
  >
@@ -29,7 +29,9 @@ const readClientData = (): BlumeClientData | null => {
29
29
  if (cachedData) {
30
30
  return cachedData;
31
31
  }
32
- if (typeof document === "undefined") {
32
+ // `document` is undeclared on the server; probing `globalThis` avoids both
33
+ // the bare-reference ReferenceError and a `typeof` sniff.
34
+ if (!("document" in globalThis)) {
33
35
  return null;
34
36
  }
35
37
  const element = document.querySelector("#blume-client-data");
@@ -37,6 +39,8 @@ const readClientData = (): BlumeClientData | null => {
37
39
  return null;
38
40
  }
39
41
  try {
42
+ // SAFETY: the layout serialized this script tag's JSON from the same
43
+ // `BlumeClientData` snapshot this reads back.
40
44
  cachedData = JSON.parse(element.textContent) as BlumeClientData;
41
45
  return cachedData;
42
46
  } catch {
@@ -16,11 +16,36 @@ interface WebMcpResult {
16
16
  isError?: boolean;
17
17
  }
18
18
 
19
+ /** A JSON value as a WebMCP agent may supply it in a tool call. */
20
+ export type WebMcpJsonValue =
21
+ | boolean
22
+ | number
23
+ | string
24
+ | null
25
+ | WebMcpJsonValue[]
26
+ | { [key: string]: WebMcpJsonValue };
27
+
28
+ /**
29
+ * Tool arguments exactly as the calling agent supplied them. WebMCP doesn't
30
+ * guarantee schema validation, so each tool checks its own fields at runtime.
31
+ */
32
+ export interface WebMcpToolArgs {
33
+ query?: WebMcpJsonValue;
34
+ route?: WebMcpJsonValue;
35
+ }
36
+
37
+ /** The JSON Schema subset these string-argument tools declare. */
38
+ export interface WebMcpInputSchema {
39
+ properties: Record<string, { description: string; type: "string" }>;
40
+ required?: string[];
41
+ type: "object";
42
+ }
43
+
19
44
  export interface WebMcpTool {
20
45
  annotations: { openWorldHint: boolean; readOnlyHint: boolean };
21
46
  description: string;
22
- execute: (input: Record<string, unknown>) => Promise<WebMcpResult>;
23
- inputSchema: Record<string, unknown>;
47
+ execute: (input: WebMcpToolArgs) => Promise<WebMcpResult>;
48
+ inputSchema: WebMcpInputSchema;
24
49
  name: string;
25
50
  }
26
51
 
@@ -31,14 +56,26 @@ export interface WebMcpTool {
31
56
  * individually via `registerTool`.
32
57
  */
33
58
  export interface ModelContext {
34
- provideContext?: (context: { tools: WebMcpTool[] }) => unknown;
35
- registerTool?: (tool: WebMcpTool) => unknown;
59
+ provideContext?: (context: { tools: WebMcpTool[] }) => void;
60
+ registerTool?: (tool: WebMcpTool) => void;
36
61
  }
37
62
 
38
- const text = (value: string, isError = false): WebMcpResult => ({
39
- content: [{ text: value, type: "text" }],
40
- ...(isError ? { isError: true } : {}),
41
- });
63
+ const text = (value: string, isError = false): WebMcpResult => {
64
+ const result: WebMcpResult = { content: [{ text: value, type: "text" }] };
65
+ if (isError) {
66
+ result.isError = true;
67
+ }
68
+ return result;
69
+ };
70
+
71
+ /** Validates an agent-supplied tool argument before it is used as a string. */
72
+ const isString = (value: WebMcpJsonValue | undefined): value is string =>
73
+ typeof value === "string";
74
+
75
+ /** Detects which registration surface a model context actually implements. */
76
+ const isCallable = <T extends (...args: never[]) => void>(
77
+ value: T | undefined
78
+ ): value is T => typeof value === "function";
42
79
 
43
80
  const TAG = /<[^>]*>?/gu;
44
81
 
@@ -89,7 +126,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
89
126
  description:
90
127
  "Full-text search across this documentation site. Returns matching pages with their title, URL, and a short excerpt.",
91
128
  async execute(input) {
92
- const query = typeof input.query === "string" ? input.query : "";
129
+ const query = isString(input.query) ? input.query : "";
93
130
  if (!query.trim()) {
94
131
  return text("Provide a non-empty `query` string.", true);
95
132
  }
@@ -128,7 +165,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
128
165
  description:
129
166
  "Fetch a page of this site as plain Markdown. Pass the page's root-relative route, e.g. `/quickstart`.",
130
167
  async execute(input) {
131
- const route = typeof input.route === "string" ? input.route : "";
168
+ const route = isString(input.route) ? input.route : "";
132
169
  if (!route.startsWith("/")) {
133
170
  return text("Pass a root-relative route, e.g. `/quickstart`.", true);
134
171
  }
@@ -189,11 +226,11 @@ export const registerWebMcpTools = (
189
226
  if (!context) {
190
227
  return false;
191
228
  }
192
- if (typeof context.provideContext === "function") {
229
+ if (isCallable(context.provideContext)) {
193
230
  context.provideContext({ tools });
194
231
  return true;
195
232
  }
196
- if (typeof context.registerTool === "function") {
233
+ if (isCallable(context.registerTool)) {
197
234
  for (const tool of tools) {
198
235
  context.registerTool(tool);
199
236
  }
@@ -5,7 +5,11 @@ import { withBase } from "../islands/base-path.ts";
5
5
  import type { ComponentOverride } from "../../core/define-components.ts";
6
6
  import { EN_UI } from "../../core/i18n-ui.ts";
7
7
  import type { UIStrings } from "../../core/i18n-ui.ts";
8
- import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
8
+ import type {
9
+ LocaleSwitchOption,
10
+ Navigation,
11
+ NavSelector as NavSelectorConfig,
12
+ } from "../../core/types.ts";
9
13
  import { GITHUB_MARK } from "../github-mark.ts";
10
14
  import Icon from "../Icon.astro";
11
15
  import LanguageSwitcher from "./LanguageSwitcher.astro";
@@ -50,8 +54,16 @@ interface Props {
50
54
  /** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
51
55
  navStrings?: UIStrings["nav"];
52
56
  localeSwitch?: LocaleSwitchOption[];
57
+ /**
58
+ * Auto-populated version switcher, rendered ahead of the configured
59
+ * selectors. `null`/absent when versioning is off — or when the user
60
+ * declares their own `kind: "version"` selector, which then owns the UI.
61
+ */
62
+ versionSelector?: NavSelectorConfig | null;
53
63
  /** Active locale for per-language search filtering. */
54
64
  searchLocale?: string;
65
+ /** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
66
+ searchVersion?: string | null;
55
67
  /**
56
68
  * Layout-slot overrides forwarded from the root layout. The header honors
57
69
  * `Logo` and `Search` here so those pieces can be replaced without swapping
@@ -77,7 +89,9 @@ const {
77
89
  switcherStrings,
78
90
  navStrings,
79
91
  localeSwitch,
92
+ versionSelector,
80
93
  searchLocale,
94
+ searchVersion = null,
81
95
  layout = {},
82
96
  } = Astro.props;
83
97
 
@@ -167,6 +181,15 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
167
181
  )
168
182
  }
169
183
  <div class="flex-1"></div>
184
+ {
185
+ /* Right-aligned next to the language switcher: the spacer absorbs its
186
+ width, so pages without a version selector (blog, generated references)
187
+ keep the logo, tabs, and the rest of this cluster in place — no layout
188
+ shift when crossing into the docs. */
189
+ versionSelector && (
190
+ <NavSelector align="end" route={route} selector={versionSelector} />
191
+ )
192
+ }
170
193
  {
171
194
  localeSwitch && localeSwitch.length > 1 && (
172
195
  <LanguageSwitcher
@@ -184,6 +207,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
184
207
  navigation={navigation}
185
208
  popularPages={data.config.search.popular}
186
209
  strings={searchStrings}
210
+ version={searchVersion}
187
211
  />
188
212
  )
189
213
  }