blume 1.4.2 → 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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
package/src/ai/tar.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { gzipSync } from "node:zlib";
2
2
 
3
+ import { createTar } from "nanotar";
4
+
3
5
  /**
4
- * Minimal, dependency-free `.tar.gz` writer for agent-skill archives (POSIX
5
- * ustar). Deterministic by construction — fixed mtime/uid/gid, caller-ordered
6
- * entries, and Node's gzip header carries no timestamp — so a skill's archive
7
- * digest only changes when its content does.
6
+ * `.tar.gz` writer for agent-skill archives, on nanotar's ustar writer.
7
+ * Deterministic by construction — fixed mtime/uid/gid/owner attrs,
8
+ * caller-ordered entries, and Node's gzip header carries no timestamp — so a
9
+ * skill's archive digest only changes when its content does. That holds per
10
+ * machine: the tar bytes are portable, but zlib's compressed stream differs
11
+ * across architectures, so gzip-layer digests are not comparable across
12
+ * platforms.
8
13
  */
9
14
 
10
15
  /** One regular file to archive. Paths are `/`-separated, relative, no `..`. */
@@ -17,67 +22,18 @@ export interface TarEntry {
17
22
  path: string;
18
23
  }
19
24
 
20
- const BLOCK = 512;
21
25
  /** ustar `name` field capacity; skill layouts are shallow, so no `prefix`. */
22
26
  const NAME_MAX = 100;
23
27
 
24
28
  const encoder = new TextEncoder();
25
29
 
26
- /** Write an octal field: zero-padded digits followed by a NUL terminator. */
27
- const octal = (
28
- header: Uint8Array,
29
- offset: number,
30
- length: number,
31
- value: number
32
- ): void => {
33
- const text = value.toString(8).padStart(length - 1, "0");
34
- header.set(encoder.encode(text), offset);
35
- header[offset + length - 1] = 0;
36
- };
37
-
38
- const text = (header: Uint8Array, offset: number, value: string): void => {
39
- header.set(encoder.encode(value), offset);
40
- };
41
-
42
- const fileHeader = (entry: TarEntry): Uint8Array => {
43
- const header = new Uint8Array(BLOCK);
44
- text(header, 0, entry.path);
45
- octal(header, 100, 8, entry.executable ? 0o755 : 0o644);
46
- // uid and gid: root-owned, fixed for determinism.
47
- octal(header, 108, 8, 0);
48
- octal(header, 116, 8, 0);
49
- octal(header, 124, 12, entry.content.byteLength);
50
- // mtime: fixed at the epoch for determinism.
51
- octal(header, 136, 12, 0);
52
- // typeflag "0": regular file.
53
- text(header, 156, "0");
54
- text(header, 257, "ustar");
55
- header[262] = 0;
56
- text(header, 263, "00");
57
- // devmajor and devminor.
58
- octal(header, 329, 8, 0);
59
- octal(header, 337, 8, 0);
60
- // Checksum: computed with the checksum field treated as eight spaces, then
61
- // written as six octal digits, NUL, space (the historical ustar format).
62
- header.fill(0x20, 148, 156);
63
- let sum = 0;
64
- for (const byte of header) {
65
- sum += byte;
66
- }
67
- text(header, 148, sum.toString(8).padStart(6, "0"));
68
- header[154] = 0;
69
- header[155] = 0x20;
70
- return header;
71
- };
72
-
73
30
  /**
74
31
  * Build a gzipped ustar archive of the given files, in the given order. Paths
75
- * longer than the ustar `name` field or escaping the archive root are the
76
- * caller's responsibility to filterthis throws to surface a programming
77
- * error rather than emitting a corrupt archive.
32
+ * longer than the ustar `name` field or escaping the archive root throw to
33
+ * surface a programming errornanotar would silently truncate an oversized
34
+ * name into a corrupt archive, so the validation stays here.
78
35
  */
79
36
  export const buildTarGz = (entries: readonly TarEntry[]): Uint8Array => {
80
- const blocks: Uint8Array[] = [];
81
37
  for (const entry of entries) {
82
38
  if (encoder.encode(entry.path).byteLength > NAME_MAX) {
83
39
  throw new Error(`tar path exceeds ${NAME_MAX} bytes: ${entry.path}`);
@@ -85,20 +41,23 @@ export const buildTarGz = (entries: readonly TarEntry[]): Uint8Array => {
85
41
  if (entry.path.startsWith("/") || entry.path.split("/").includes("..")) {
86
42
  throw new Error(`tar path must be archive-relative: ${entry.path}`);
87
43
  }
88
- blocks.push(fileHeader(entry), entry.content);
89
- const overhang = entry.content.byteLength % BLOCK;
90
- if (overhang > 0) {
91
- blocks.push(new Uint8Array(BLOCK - overhang));
92
- }
93
- }
94
- // End-of-archive marker: two zero blocks.
95
- blocks.push(new Uint8Array(BLOCK * 2));
96
- const total = blocks.reduce((sum, block) => sum + block.byteLength, 0);
97
- const tar = new Uint8Array(total);
98
- let offset = 0;
99
- for (const block of blocks) {
100
- tar.set(block, offset);
101
- offset += block.byteLength;
102
44
  }
45
+ const tar = createTar(
46
+ entries.map((entry) => ({
47
+ // Root-owned, epoch-mtime, empty owner names: every field a rebuild
48
+ // could vary is pinned so the archive bytes are a function of content.
49
+ attrs: {
50
+ gid: 0,
51
+ group: "",
52
+ mode: entry.executable ? "755" : "644",
53
+ mtime: 0,
54
+ uid: 0,
55
+ user: "",
56
+ },
57
+ data: entry.content,
58
+ name: entry.path,
59
+ }))
60
+ );
61
+ // Sync gzip with a pinned level; Node writes no timestamp into the header.
103
62
  return new Uint8Array(gzipSync(tar, { level: 9 }));
104
63
  };
@@ -19,10 +19,10 @@ const visibilityBlock = (audience: VisibilityAudience): RegExp =>
19
19
  "gu"
20
20
  );
21
21
 
22
- const BLOCKS: Record<VisibilityAudience, RegExp> = {
22
+ const BLOCKS = {
23
23
  agents: visibilityBlock("agents"),
24
24
  web: visibilityBlock("web"),
25
- };
25
+ } satisfies Record<VisibilityAudience, RegExp>;
26
26
 
27
27
  /**
28
28
  * Resolve `<Visibility>` blocks for one audience: blocks addressed to the
@@ -77,6 +77,8 @@ const importClause = (variable: string, name: string, path: string): string =>
77
77
 
78
78
  /** A wrapper `.astro` that statically imports a component and hydrates it. */
79
79
  const wrapperContent = (override: NormalizedOverride): string => {
80
+ // SAFETY: the only caller guards `if (!source)` and bails before invoking
81
+ // this, so the override always carries a resolved source here.
80
82
  const { name, path } = override.source as NonNullable<
81
83
  NormalizedOverride["source"]
82
84
  >;
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import pMap from "p-map";
3
4
  import { join, relative } from "pathe";
4
5
  import { glob } from "tinyglobby";
5
6
 
@@ -32,7 +33,11 @@ export interface ExampleDiscovery {
32
33
  }
33
34
 
34
35
  /** Example extensions mapped to the framework that renders them. */
35
- const FRAMEWORK_BY_EXT: Record<string, ExampleFramework> = {
36
+ interface FrameworkByExtension {
37
+ [extension: string]: ExampleFramework;
38
+ }
39
+
40
+ const FRAMEWORK_BY_EXT: FrameworkByExtension = {
36
41
  astro: "astro",
37
42
  jsx: "react",
38
43
  svelte: "svelte",
@@ -48,6 +53,9 @@ const EXAMPLE_FILE = /\.(?<ext>astro|jsx|svelte|tsx|vue)$/u;
48
53
  // Renderable example files when `examples` names a plain directory.
49
54
  const DEFAULT_EXAMPLE_GLOB = "**/*.{astro,jsx,svelte,tsx,vue}";
50
55
 
56
+ /** Ceiling on concurrent example-file reads; unbounded fan-out risks EMFILE. */
57
+ const READ_CONCURRENCY = 16;
58
+
51
59
  // Glob magic that turns `examples` from a plain directory into a pattern. `()`,
52
60
  // `@`, and `+` are excluded so literal path segments (npm scopes, parens) keep
53
61
  // resolving as directories; the extglob leads `*?!` still trigger here.
@@ -58,7 +66,7 @@ const GLOB_MAGIC = /[!*?[\]{}]/u;
58
66
  * discovered files can be keyed relative to that prefix (e.g.
59
67
  * `registry/x/**\/examples/*` → `{ base: "registry/x", rest: "**\/examples/*" }`).
60
68
  */
61
- const splitGlobBase = (pattern: string): { base: string; rest: string } => {
69
+ const splitGlobBase = (pattern: string) => {
62
70
  const segments = pattern.split("/");
63
71
  // Only called when the pattern contains glob magic (see the caller), and `/`
64
72
  // is never magic, so the magic char always lands in a segment — `findIndex`
@@ -102,9 +110,9 @@ export const discoverExamples = async (
102
110
  onlyFiles: true,
103
111
  });
104
112
  const files = matches.toSorted();
105
- const sources = await Promise.all(
106
- files.map((file) => readFile(file, "utf-8"))
107
- );
113
+ const sources = await pMap(files, (file) => readFile(file, "utf-8"), {
114
+ concurrency: READ_CONCURRENCY,
115
+ });
108
116
 
109
117
  const examples: ExampleSpec[] = [];
110
118
  const warnings: string[] = [];
@@ -12,11 +12,13 @@ import {
12
12
  import { createRequire } from "node:module";
13
13
  import { pathToFileURL } from "node:url";
14
14
 
15
+ import { imageSize } from "image-size";
16
+ import pMap from "p-map";
15
17
  import { basename, dirname, join, normalize, relative, resolve } from "pathe";
16
18
  import { glob } from "tinyglobby";
17
19
 
18
20
  import { buildAskData } from "../ai/ask-data.ts";
19
- import { resolveAskBackend } from "../ai/ask.ts";
21
+ import { askBackendRuntimeDep, resolveAskBackend } from "../ai/ask.ts";
20
22
  import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
21
23
  import { buildMcpData } from "../ai/mcp/data.ts";
22
24
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
@@ -487,12 +489,14 @@ export const ensureDepsLink = async (
487
489
  * (`<build.server>/.prerender/`) output — so it fires for exactly that build.
488
490
  * Inert in dev, where there is no build and `writeBundle` never runs.
489
491
  */
490
- export const prerenderDepsPlugin = (
491
- pkgDir: string = packageRoot()
492
- ): {
492
+ export interface PrerenderDepsPlugin {
493
493
  name: string;
494
494
  writeBundle: (options: { dir?: string }) => Promise<void>;
495
- } => ({
495
+ }
496
+
497
+ export const prerenderDepsPlugin = (
498
+ pkgDir: string = packageRoot()
499
+ ): PrerenderDepsPlugin => ({
496
500
  name: "blume:prerender-deps",
497
501
  writeBundle: async (options) => {
498
502
  if (!options.dir || basename(options.dir) !== ".prerender") {
@@ -526,14 +530,16 @@ interface ServerAppResolveContext {
526
530
  * Stripping the spurious `.js` and delegating back to Astro's resolver lets the
527
531
  * reload complete cleanly, so the renamed route resolves without a restart.
528
532
  */
529
- export const serverAppResolvePlugin = (): {
533
+ export interface ServerAppResolvePlugin {
530
534
  enforce: "pre";
531
535
  name: string;
532
536
  resolveId: (
533
537
  this: ServerAppResolveContext,
534
538
  id: string
535
539
  ) => Promise<string | null>;
536
- } => ({
540
+ }
541
+
542
+ export const serverAppResolvePlugin = (): ServerAppResolvePlugin => ({
537
543
  enforce: "pre",
538
544
  name: "blume:server-app-resolve",
539
545
  async resolveId(id) {
@@ -546,20 +552,20 @@ export const serverAppResolvePlugin = (): {
546
552
  });
547
553
 
548
554
  /** Astro integration package each non-React island framework needs installed. */
549
- const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
550
- svelte: "@astrojs/svelte",
551
- vue: "@astrojs/vue",
552
- };
555
+ const ISLAND_FRAMEWORK_DEPS = new Map([
556
+ ["svelte", "@astrojs/svelte"],
557
+ ["vue", "@astrojs/vue"],
558
+ ]);
553
559
 
554
560
  /**
555
561
  * Adapter package the project must install itself for each deployment
556
562
  * platform whose adapter Blume doesn't ship. Node and Vercel ship with Blume,
557
563
  * so they never need this.
558
564
  */
559
- const DEPLOYMENT_ADAPTER_DEPS: Record<string, string> = {
560
- cloudflare: "@astrojs/cloudflare",
561
- netlify: "@astrojs/netlify",
562
- };
565
+ const DEPLOYMENT_ADAPTER_DEPS = new Map([
566
+ ["cloudflare", "@astrojs/cloudflare"],
567
+ ["netlify", "@astrojs/netlify"],
568
+ ]);
563
569
 
564
570
  /**
565
571
  * Warn when a Vue/Svelte island is present but its Astro integration isn't
@@ -572,7 +578,7 @@ const islandFrameworkWarnings = (
572
578
  ): string[] => {
573
579
  const warnings: string[] = [];
574
580
  for (const framework of frameworks) {
575
- const dep = ISLAND_FRAMEWORK_DEPS[framework];
581
+ const dep = ISLAND_FRAMEWORK_DEPS.get(framework);
576
582
  if (dep && !canResolveFrom(root, dep)) {
577
583
  warnings.push(
578
584
  `Islands use ${framework}, which needs "${dep}". Install it (e.g. \`npm install ${dep} ${framework}\`).`
@@ -597,7 +603,7 @@ const deploymentAdapterWarnings = (
597
603
  ): string[] => {
598
604
  const dep =
599
605
  deployment.output === "server" && deployment.adapter
600
- ? DEPLOYMENT_ADAPTER_DEPS[deployment.adapter]
606
+ ? DEPLOYMENT_ADAPTER_DEPS.get(deployment.adapter)
601
607
  : undefined;
602
608
  if (
603
609
  dep &&
@@ -638,6 +644,33 @@ export const searchProviderWarnings = (
638
644
  return warnings;
639
645
  };
640
646
 
647
+ /**
648
+ * Warn when the Ask AI backend's provider SDK is missing. Like search provider
649
+ * SDKs, these are optional peers the project must install (only `gateway`
650
+ * needs nothing beyond the core `ai` package Blume ships) — warn early with
651
+ * the package name rather than let Vite fail to resolve the import opaquely.
652
+ * Same resolution rule as {@link searchProviderWarnings}: available if the
653
+ * project installed it or Blume can resolve it. `pkgDir` is injectable for
654
+ * testing.
655
+ */
656
+ export const askProviderWarnings = (
657
+ ask: ResolvedConfig["ai"]["ask"],
658
+ root: string,
659
+ pkgDir: string = packageRoot()
660
+ ): string[] => {
661
+ // An external `endpoint` means no generated route, so no SDK is imported.
662
+ if (!ask?.enabled || ask.endpoint) {
663
+ return [];
664
+ }
665
+ const dep = askBackendRuntimeDep(ask);
666
+ if (!dep || canResolveFrom(root, dep) || canResolveFrom(pkgDir, dep)) {
667
+ return [];
668
+ }
669
+ return [
670
+ `Ask AI provider "${ask.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`,
671
+ ];
672
+ };
673
+
641
674
  /** Absolute path to the configured `examples.css`, or null when unset. */
642
675
  const examplesCssFile = (
643
676
  root: string,
@@ -708,6 +741,9 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
708
741
  const containsMath = (content: string): boolean =>
709
742
  content.includes("$$") || content.includes("<Math");
710
743
 
744
+ /** Ceiling on concurrent content-file reads; unbounded fan-out risks EMFILE. */
745
+ const READ_CONCURRENCY = 16;
746
+
711
747
  /**
712
748
  * Detect whether the project can render math: block math (`$$…$$`) or an
713
749
  * explicit `<Math>` tag in any local `.md`/`.mdx`, or in staged (non-filesystem)
@@ -726,9 +762,9 @@ export const detectUsesMath = async (
726
762
  ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
727
763
  onlyFiles: true,
728
764
  });
729
- const contents = await Promise.all(
730
- files.map((file) => readOptional(join(root, file)))
731
- );
765
+ const contents = await pMap(files, (file) => readOptional(join(root, file)), {
766
+ concurrency: READ_CONCURRENCY,
767
+ });
732
768
  return [...contents, ...staged].some(containsMath);
733
769
  };
734
770
 
@@ -838,33 +874,24 @@ interface LogoDimensions {
838
874
  width: number;
839
875
  }
840
876
 
841
- const SVG_ROOT = /<svg\b(?<attributes>[^>]*)>/u;
842
- const SVG_WIDTH = /\bwidth\s*=\s*["'](?<value>[^"']+)["']/u;
843
- const SVG_HEIGHT = /\bheight\s*=\s*["'](?<value>[^"']+)["']/u;
844
- const SVG_LENGTH = /^\s*(?<value>[\d.]+)(?:px)?\s*$/u;
845
- const SVG_VIEW_BOX =
846
- /\bviewBox\s*=\s*["'][\d.-]+[\s,]+[\d.-]+[\s,]+(?<width>[\d.]+)[\s,]+(?<height>[\d.]+)["']/u;
847
-
848
- const parseSvgLength = (value: string | undefined): number | undefined => {
849
- const length = Number(value?.match(SVG_LENGTH)?.groups?.value);
850
- return length > 0 ? length : undefined;
851
- };
852
-
853
- /** Read dimensions from an SVG's explicit size or its view box. */
877
+ /**
878
+ * Read dimensions from an SVG's explicit size or its view box. Measured with
879
+ * image-size — the same parser og/card.ts uses for the OG brand mark, so the
880
+ * header and the card can't disagree about one logo — which also tolerates
881
+ * the spellings the old regex missed (unquoted values, `em`/`pt` lengths, a
882
+ * `>` inside another attribute). An SVG with no usable size returns partial
883
+ * dimensions or throws; both collapse to undefined.
884
+ */
854
885
  const svgDimensions = (svg: string | undefined): LogoDimensions | undefined => {
855
- const attributes = svg?.match(SVG_ROOT)?.groups?.attributes;
856
- const width = parseSvgLength(attributes?.match(SVG_WIDTH)?.groups?.value);
857
- const height = parseSvgLength(attributes?.match(SVG_HEIGHT)?.groups?.value);
858
- if (width && height) {
859
- return { height, width };
886
+ if (!svg) {
887
+ return;
888
+ }
889
+ try {
890
+ const { height, width } = imageSize(Buffer.from(svg));
891
+ return height && width ? { height, width } : undefined;
892
+ } catch {
893
+ return undefined;
860
894
  }
861
-
862
- const viewBox = attributes?.match(SVG_VIEW_BOX);
863
- const viewBoxWidth = Number(viewBox?.groups?.width);
864
- const viewBoxHeight = Number(viewBox?.groups?.height);
865
- return viewBoxWidth > 0 && viewBoxHeight > 0
866
- ? { height: viewBoxHeight, width: viewBoxWidth }
867
- : undefined;
868
895
  };
869
896
 
870
897
  /** Read a local SVG logo from the project root or public directory. */
@@ -883,6 +910,10 @@ const readLogoSvg = (
883
910
  return file ? readFileSync(file, "utf-8") : undefined;
884
911
  };
885
912
 
913
+ /** Narrows a config union's string shorthand from its object form. */
914
+ const isStringShorthand = <T>(value: T | string): value is string =>
915
+ typeof value === "string";
916
+
886
917
  /**
887
918
  * Resolve the configured logo. A single SVG is read and inlined so a
888
919
  * `currentColor` logo follows the theme; other images keep their URL for an
@@ -893,12 +924,12 @@ const resolveLogo = (project: BlumeProject): BlumeLogo | null => {
893
924
  if (!logo) {
894
925
  return null;
895
926
  }
896
- const config = typeof logo === "string" ? { image: logo } : logo;
927
+ const config = isStringShorthand(logo) ? { image: logo } : logo;
897
928
  // `text` is passed through verbatim: `undefined` lets the brand fall back to
898
929
  // the site title, `""` renders the mark alone (a logo with the wordmark baked
899
930
  // in).
900
931
  const { href, image: source, text } = config;
901
- const image = typeof source === "string" ? { light: source } : source;
932
+ const image = isStringShorthand(source) ? { light: source } : source;
902
933
  const light = image?.light ?? image?.dark;
903
934
  const dark = image?.dark ?? image?.light;
904
935
  const alt = image?.alt ?? "";
@@ -934,18 +965,18 @@ const FAVICON_CANDIDATES = [
934
965
  ];
935
966
 
936
967
  /** `<link type>` MIME for the favicon extensions we recognize. */
937
- const FAVICON_TYPES: Record<string, string> = {
938
- ico: "image/x-icon",
939
- jpeg: "image/jpeg",
940
- jpg: "image/jpeg",
941
- png: "image/png",
942
- svg: "image/svg+xml",
943
- };
968
+ const FAVICON_TYPES = new Map([
969
+ ["ico", "image/x-icon"],
970
+ ["jpeg", "image/jpeg"],
971
+ ["jpg", "image/jpeg"],
972
+ ["png", "image/png"],
973
+ ["svg", "image/svg+xml"],
974
+ ]);
944
975
 
945
976
  /** Infer the `<link type>` MIME from a filename, when we recognize the extension. */
946
977
  const faviconType = (name: string): string | undefined => {
947
978
  const ext = name.split(".").pop()?.toLowerCase();
948
- return ext ? FAVICON_TYPES[ext] : undefined;
979
+ return ext ? FAVICON_TYPES.get(ext) : undefined;
949
980
  };
950
981
 
951
982
  /** Read a file and encode it as a `data:` URI of the given MIME type. */
@@ -1018,7 +1049,7 @@ const resolveBanner = (config: ResolvedConfig): BlumeBanner | null => {
1018
1049
  if (!banner) {
1019
1050
  return null;
1020
1051
  }
1021
- if (typeof banner === "string") {
1052
+ if (isStringShorthand(banner)) {
1022
1053
  return { content: banner, dismissible: false, key: banner };
1023
1054
  }
1024
1055
  return {
@@ -1211,6 +1242,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1211
1242
  theme: config.theme,
1212
1243
  title: config.title,
1213
1244
  toc: config.toc,
1245
+ versions: config.versions ?? null,
1214
1246
  webmcp: {
1215
1247
  enabled: config.ai.webmcp,
1216
1248
  llms: config.ai.llmsTxt.enabled,
@@ -1227,6 +1259,19 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1227
1259
  navigation: withRepoUrl(graph.navigation),
1228
1260
  // Per-locale navigation; the catch-all selects the active locale's tree.
1229
1261
  navigationByLocale,
1262
+ // Per-archived-version navigation; the catch-all selects by the route's
1263
+ // version, then locale.
1264
+ navigationByVersion: Object.fromEntries(
1265
+ Object.entries(graph.navigationByVersion).map(([id, byLocale]) => [
1266
+ id,
1267
+ Object.fromEntries(
1268
+ Object.entries(byLocale).map(([code, nav]) => [
1269
+ code,
1270
+ withRepoUrl(nav),
1271
+ ])
1272
+ ),
1273
+ ])
1274
+ ),
1230
1275
  routes: manifest.routes.map((route) => ({
1231
1276
  alternates: route.alternates,
1232
1277
  collection: route.collection,
@@ -1241,6 +1286,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1241
1286
  locale: route.locale,
1242
1287
  path: route.path,
1243
1288
  title: route.title,
1289
+ version: route.version,
1290
+ versionAlternates: route.versionAlternates,
1244
1291
  })),
1245
1292
  // Default-locale chrome strings (English baseline when not under i18n).
1246
1293
  ui: defaultUi,
@@ -1370,7 +1417,7 @@ const writeAskFiles = async (
1370
1417
  }
1371
1418
  await write(
1372
1419
  join(srcDir, "pages", "api", "ask.ts"),
1373
- askEndpointTemplate(resolveAskBackend(ask), grounded)
1420
+ askEndpointTemplate(resolveAskBackend(ask), grounded, ask.instructions)
1374
1421
  );
1375
1422
  };
1376
1423
 
@@ -1838,8 +1885,9 @@ export const generateRuntime = async (
1838
1885
  ]);
1839
1886
  }
1840
1887
 
1841
- // API/AsyncAPI reference pages (Scalar). One self-contained page per source,
1842
- // mounted on its configured route and regenerated each run.
1888
+ // Scalar-rendered API/AsyncAPI reference pages (`renderer: "scalar"`). One
1889
+ // self-contained page per source, mounted on its configured route and
1890
+ // regenerated each run.
1843
1891
  const warnings: string[] = [
1844
1892
  ...(depsLinkWarning ? [depsLinkWarning] : []),
1845
1893
  ...reactCompilerWarnings(config, needsReact, reactCompilerPath),
@@ -1878,10 +1926,11 @@ export const generateRuntime = async (
1878
1926
  ...islandDiscovery.islands.map((island) => island.name),
1879
1927
  ...overrideTags,
1880
1928
  ]);
1881
- // Missing-dependency preflights: the search provider's SDK, the deployment
1882
- // adapter's package, and — since React ships with Blume while Vue/Svelte
1883
- // don't — any island framework's Astro integration. Warn early rather than
1884
- // let Vite fail to resolve them opaquely.
1929
+ // Missing-dependency preflights: the search provider's SDK, the Ask AI
1930
+ // backend's provider SDK, the deployment adapter's package, and — since
1931
+ // React ships with Blume while Vue/Svelte don't — any island framework's
1932
+ // Astro integration. Warn early rather than let Vite fail to resolve them
1933
+ // opaquely.
1885
1934
  warnings.push(
1886
1935
  ...validateUsedComponents(
1887
1936
  project.graph.pages,
@@ -1889,6 +1938,7 @@ export const generateRuntime = async (
1889
1938
  new Set(registry.map((item) => item.name))
1890
1939
  ).map(diagnosticWarning),
1891
1940
  ...searchProviderWarnings(config.search.provider, context.root),
1941
+ ...askProviderWarnings(config.ai.ask, context.root),
1892
1942
  ...deploymentAdapterWarnings(config.deployment, context.root),
1893
1943
  ...islandFrameworkWarnings(frameworks, context.root)
1894
1944
  );
@@ -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({
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import pMap from "p-map";
3
4
  import { basename, join } from "pathe";
4
5
  import { glob } from "tinyglobby";
5
6
 
@@ -29,6 +30,9 @@ export interface IslandDiscovery {
29
30
  /** Hydration mode used when an island doesn't declare one. */
30
31
  const DEFAULT_CLIENT: IslandClientMode = "visible";
31
32
 
33
+ /** Ceiling on concurrent island-file reads; unbounded fan-out risks EMFILE. */
34
+ const READ_CONCURRENCY = 16;
35
+
32
36
  const VALID_MODES = new Set<IslandClientMode>([
33
37
  "idle",
34
38
  "load",
@@ -36,13 +40,20 @@ const VALID_MODES = new Set<IslandClientMode>([
36
40
  "visible",
37
41
  ]);
38
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
+
39
50
  /** Island extensions mapped to the Astro renderer that handles them. */
40
- const FRAMEWORK_BY_EXT: Record<string, IslandFramework> = {
41
- jsx: "react",
42
- svelte: "svelte",
43
- tsx: "react",
44
- vue: "vue",
45
- };
51
+ const FRAMEWORK_BY_EXT = new Map<string, IslandFramework>([
52
+ ["jsx", "react"],
53
+ ["svelte", "svelte"],
54
+ ["tsx", "react"],
55
+ ["vue", "vue"],
56
+ ]);
46
57
 
47
58
  // Captures the extension so we can both strip it from the name and pick the
48
59
  // framework. Kept in sync with the glob below.
@@ -62,13 +73,13 @@ export const readClientMode = (
62
73
  if (!mode) {
63
74
  return DEFAULT_CLIENT;
64
75
  }
65
- if (!VALID_MODES.has(mode as IslandClientMode)) {
76
+ if (!isClientMode(mode)) {
66
77
  warnings.push(
67
78
  `Island "${file}" declares an unknown client mode "${mode}"; defaulting to "${DEFAULT_CLIENT}". Use "load", "idle", "visible", or "only".`
68
79
  );
69
80
  return DEFAULT_CLIENT;
70
81
  }
71
- return mode as IslandClientMode;
82
+ return mode;
72
83
  };
73
84
 
74
85
  /**
@@ -92,9 +103,9 @@ export const discoverIslands = async (
92
103
  onlyFiles: true,
93
104
  });
94
105
  const files = matches.toSorted();
95
- const sources = await Promise.all(
96
- files.map((file) => readFile(file, "utf-8"))
97
- );
106
+ const sources = await pMap(files, (file) => readFile(file, "utf-8"), {
107
+ concurrency: READ_CONCURRENCY,
108
+ });
98
109
 
99
110
  const islands: IslandSpec[] = [];
100
111
  const warnings: string[] = [];
@@ -105,7 +116,7 @@ export const discoverIslands = async (
105
116
  const collectIsland = (file: string, source: string): void => {
106
117
  const base = basename(file);
107
118
  const ext = base.match(ISLAND_FILE)?.groups?.ext;
108
- const framework = ext ? FRAMEWORK_BY_EXT[ext] : undefined;
119
+ const framework = ext ? FRAMEWORK_BY_EXT.get(ext) : undefined;
109
120
  if (!framework) {
110
121
  return;
111
122
  }