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
@@ -4,6 +4,7 @@ import { isAbsolute, join } from "pathe";
4
4
 
5
5
  import { scalarReferenceTemplate } from "../astro/templates.ts";
6
6
  import type { ResolvedConfig } from "../core/schema.ts";
7
+ import { trimChar } from "../core/trim.ts";
7
8
  import { resolveAccent, resolveRadius } from "../theme/palette.ts";
8
9
  import { resolveReferences } from "./references.ts";
9
10
  import type { ReferenceSource } from "./references.ts";
@@ -24,17 +25,14 @@ export interface ReferenceFile {
24
25
  }
25
26
 
26
27
  const URL_SPEC = /^https?:\/\//u;
27
- const ROUTE_EDGES = /^\/+|\/+$/gu;
28
28
 
29
29
  /** The `src/pages`-relative file path for a reference route. */
30
30
  const referencePagePath = (route: string): string => {
31
- const segments = route.replace(ROUTE_EDGES, "");
31
+ const segments = trimChar(route, "/");
32
32
  return `${segments === "" ? "index" : segments}.astro`;
33
33
  };
34
34
 
35
- const darkModeConfig = (
36
- mode: ResolvedConfig["theme"]["mode"]
37
- ): Record<string, boolean> => {
35
+ const darkModeConfig = (mode: ResolvedConfig["theme"]["mode"]) => {
38
36
  if (mode === "dark") {
39
37
  return { darkMode: true };
40
38
  }
@@ -51,10 +49,7 @@ const darkModeConfig = (
51
49
  * `customCss`. Scalar re-injects `customCss` after its bundled theme, so these
52
50
  * variables reliably override the defaults. Best-effort, not pixel-exact.
53
51
  */
54
- const themeConfiguration = (
55
- config: ResolvedConfig,
56
- override?: string
57
- ): Record<string, unknown> => {
52
+ const themeConfiguration = (config: ResolvedConfig, override?: string) => {
58
53
  if (override) {
59
54
  return { theme: override };
60
55
  }
@@ -70,7 +65,10 @@ const themeConfiguration = (
70
65
  const specConfiguration = async (
71
66
  spec: string,
72
67
  root: string
73
- ): Promise<{ config: Record<string, unknown>; warning?: string }> => {
68
+ ): Promise<{
69
+ config: { content: string } | { url: string };
70
+ warning?: string;
71
+ }> => {
74
72
  if (URL_SPEC.test(spec)) {
75
73
  return { config: { url: spec } };
76
74
  }
@@ -1,5 +1,6 @@
1
1
  import { withBasePath } from "../core/base-path.ts";
2
2
  import matter from "../core/frontmatter.ts";
3
+ import type { FolderMeta } from "../core/schema.ts";
3
4
  import { hashText } from "../core/sources/cache.ts";
4
5
  import type {
5
6
  ContentSource,
@@ -8,19 +9,30 @@ import type {
8
9
  SourceLoadResult,
9
10
  } from "../core/sources/types.ts";
10
11
  import type { Diagnostic } from "../core/types.ts";
12
+ import { extractAsyncApiOperations } from "./asyncapi.ts";
13
+ import type { AsyncApiDocument } from "./asyncapi.ts";
11
14
  import { extractOperations } from "./model.ts";
12
- import type { ApiOperationRef, ApiSpecData, OpenApiData } from "./model.ts";
13
- import { InvalidSpecError, parseSpec } from "./parse.ts";
15
+ import type {
16
+ ApiDocument,
17
+ ApiOperationRef,
18
+ ApiSpecData,
19
+ ApiTagRef,
20
+ OpenApiData,
21
+ } from "./model.ts";
22
+ import { InvalidSpecError, parseAsyncApiSpec, parseSpec } from "./parse.ts";
14
23
  import type { ReferenceSource } from "./references.ts";
15
24
  import { operationMdx, overviewMdx } from "./render-mdx.ts";
16
25
  import type { RenderedPage } from "./render-mdx.ts";
17
26
 
18
27
  /**
19
- * The staged content source behind Blume's own OpenAPI renderer. Each configured
20
- * spec is parsed once here, then lowered into one MDX page per operation plus an
21
- * overview page — so operations become first-class Blume pages (real routes,
22
- * sidebar, search, i18n, OG) and the parsed documents are handed to the
23
- * generated `blume:openapi` module for the UI components to render.
28
+ * The staged content source behind Blume's own API reference renderer (OpenAPI
29
+ * and AsyncAPI alike). Each configured spec is parsed once here, then lowered
30
+ * into one MDX page per operation plus an overview page — so operations become
31
+ * first-class Blume pages (real routes, sidebar, search, i18n, OG) and the
32
+ * parsed documents are handed to the generated `blume:openapi` module for the
33
+ * UI components to render. The source keeps its historical `openapi` name for
34
+ * both kinds — downstream consumers (`ai.llmsTxt.openapi`, the llms noindex
35
+ * exemption) key on it as "the generated API reference source".
24
36
  */
25
37
 
26
38
  /** A content source that also exposes the specs it parsed during `load()`. */
@@ -34,7 +46,7 @@ export interface OpenApiContentSource extends ContentSource {
34
46
  export const isOpenApiSource = (
35
47
  source: ContentSource
36
48
  ): source is OpenApiContentSource =>
37
- (source as Partial<OpenApiContentSource>).kind === "openapi-source";
49
+ "kind" in source && source.kind === "openapi-source";
38
50
 
39
51
  /** Route (`/reference/pet/add-pet`) to a staged content ref, without extension. */
40
52
  const routeToRef = (route: string): string => route.replace(/^\/+/u, "");
@@ -43,7 +55,9 @@ const toEntry = (rendered: RenderedPage, ref: string): SourceEntry => {
43
55
  const raw = matter.stringify(`${rendered.body}\n`, rendered.data);
44
56
  return {
45
57
  body: { format: "mdx", text: rendered.body },
46
- data: rendered.data,
58
+ // Spread so the named frontmatter shape satisfies the open metadata
59
+ // dictionary every source entry carries.
60
+ data: { ...rendered.data },
47
61
  hash: hashText(raw),
48
62
  raw,
49
63
  ref,
@@ -75,14 +89,81 @@ const specEntries = (
75
89
  return entries;
76
90
  };
77
91
 
92
+ /**
93
+ * Label each tag's sidebar group with the spec's own tag name. The group label
94
+ * is otherwise re-humanized from the tag's route slug (split on hyphens,
95
+ * title-cased), which mangles authored casing and symbols — `OAuth2` →
96
+ * "Oauth2", `Größe` → "Größe" only by luck of the slug. Keys are the tag
97
+ * directories under the reference route, the same group paths `meta.ts` files
98
+ * use, so user-authored meta still overrides these.
99
+ */
100
+ const tagFolderMeta = (
101
+ spec: ApiSpecData,
102
+ tags: { slug: string; name: string }[]
103
+ ): Record<string, FolderMeta> => {
104
+ const base = routeToRef(spec.route);
105
+ return Object.fromEntries(
106
+ tags.map((tag) => [
107
+ base ? `${base}/${tag.slug}` : tag.slug,
108
+ { title: tag.name },
109
+ ])
110
+ );
111
+ };
112
+
78
113
  interface LoadedSpec {
79
114
  slug: string;
80
115
  spec: ApiSpecData;
81
116
  entries: SourceEntry[];
117
+ /** Sidebar-group labels for the spec's tag directories. */
118
+ folderMeta: Record<string, FolderMeta>;
82
119
  /** Non-fatal notes from the load (e.g. an offline cache fallback). */
83
120
  diagnostics: Diagnostic[];
84
121
  }
85
122
 
123
+ /** One parsed spec, whichever front-end read it — the kind dispatch seam. */
124
+ interface ParsedReference {
125
+ document: ApiDocument | AsyncApiDocument;
126
+ warnings: string[];
127
+ operations: ApiOperationRef[];
128
+ tags: ApiTagRef[];
129
+ extractWarnings: string[];
130
+ }
131
+
132
+ const parseReference = async (
133
+ reference: ReferenceSource,
134
+ ctx: SourceContext
135
+ ): Promise<ParsedReference> => {
136
+ const options = { cacheDir: ctx.cacheDir, refresh: ctx.refresh };
137
+ if (reference.kind === "asyncapi") {
138
+ const { document, warnings } = await parseAsyncApiSpec(
139
+ reference.spec,
140
+ ctx.projectRoot,
141
+ options
142
+ );
143
+ const extracted = extractAsyncApiOperations(document, reference.route);
144
+ return {
145
+ document,
146
+ extractWarnings: extracted.warnings,
147
+ operations: extracted.operations,
148
+ tags: extracted.tags,
149
+ warnings,
150
+ };
151
+ }
152
+ const { document, warnings } = await parseSpec(
153
+ reference.spec,
154
+ ctx.projectRoot,
155
+ options
156
+ );
157
+ const extracted = extractOperations(document, reference.route);
158
+ return {
159
+ document,
160
+ extractWarnings: extracted.warnings,
161
+ operations: extracted.operations,
162
+ tags: extracted.tags,
163
+ warnings,
164
+ };
165
+ };
166
+
86
167
  export const openApiSource = (
87
168
  references: ReferenceSource[],
88
169
  ctx: SourceContext
@@ -92,23 +173,21 @@ export const openApiSource = (
92
173
  const loadReference = async (
93
174
  reference: ReferenceSource
94
175
  ): Promise<LoadedSpec | Diagnostic> => {
176
+ // Human label and diagnostic-code prefix for the spec's kind, so an
177
+ // AsyncAPI failure never reads as an OpenAPI one.
178
+ const kindLabel = reference.kind === "asyncapi" ? "AsyncAPI" : "OpenAPI";
179
+ const codePrefix =
180
+ reference.kind === "asyncapi" ? "BLUME_ASYNCAPI" : "BLUME_OPENAPI";
95
181
  try {
96
- const { document, warnings } = await parseSpec(
97
- reference.spec,
98
- ctx.projectRoot,
99
- { cacheDir: ctx.cacheDir, refresh: ctx.refresh }
100
- );
101
- const {
102
- operations,
103
- tags,
104
- warnings: extractWarnings,
105
- } = extractOperations(document, reference.route);
182
+ const { document, warnings, operations, tags, extractWarnings } =
183
+ await parseReference(reference, ctx);
106
184
  const info = document.info ?? { title: reference.label, version: "" };
107
185
  const spec: ApiSpecData = {
108
186
  codeSamples: reference.display.codeSamples,
109
187
  description: info.description ?? "",
110
188
  document,
111
189
  expandSchemas: reference.display.expandSchemas,
190
+ kind: reference.kind,
112
191
  label: reference.label,
113
192
  // Operation pages flow through the content pipeline, which mounts them
114
193
  // under the site-wide `basePath` (staged entry refs below stay
@@ -132,13 +211,24 @@ export const openApiSource = (
132
211
  return {
133
212
  diagnostics: [
134
213
  ...warnings.map((message) => ({
135
- code: "BLUME_OPENAPI_STALE",
214
+ // Parse-level notes: an offline cache fallback for either kind,
215
+ // plus lossy 2.x→3.0 conversion notes for AsyncAPI — hence the
216
+ // broader code on that side (OpenAPI keeps its historical one).
217
+ code:
218
+ reference.kind === "asyncapi"
219
+ ? "BLUME_ASYNCAPI_SPEC_WARNING"
220
+ : "BLUME_OPENAPI_STALE",
136
221
  message,
137
222
  severity: "warning" as const,
138
223
  })),
139
224
  ...extractWarnings.map((message) => ({
140
- code: "BLUME_OPENAPI_REF_PATH_ITEM",
141
- message: `In OpenAPI spec "${reference.spec}": ${message}`,
225
+ // OpenAPI keeps its historical code (the only extract warning it
226
+ // emits is the unresolved $ref path item).
227
+ code:
228
+ reference.kind === "asyncapi"
229
+ ? "BLUME_ASYNCAPI_SKIPPED_OPERATION"
230
+ : "BLUME_OPENAPI_REF_PATH_ITEM",
231
+ message: `In ${kindLabel} spec "${reference.spec}": ${message}`,
142
232
  severity: "warning" as const,
143
233
  })),
144
234
  // A document with no operations (say, a config file that happens to
@@ -147,23 +237,28 @@ export const openApiSource = (
147
237
  ...(operations.length === 0
148
238
  ? [
149
239
  {
150
- code: "BLUME_OPENAPI_EMPTY",
151
- message: `OpenAPI spec "${reference.spec}" for ${reference.route} declares no operations; its API reference is empty.`,
240
+ code: `${codePrefix}_EMPTY`,
241
+ message: `${kindLabel} spec "${reference.spec}" for ${reference.route} declares no operations; its API reference is empty.`,
152
242
  severity: "warning" as const,
153
243
  suggestion:
154
- "Check the spec points at an OpenAPI document with operations under `paths`.",
244
+ reference.kind === "asyncapi"
245
+ ? "Check the spec points at an AsyncAPI document with `channels` and `operations`."
246
+ : "Check the spec points at an OpenAPI document with operations under `paths`.",
155
247
  },
156
248
  ]
157
249
  : []),
158
250
  ],
159
251
  entries: specEntries(spec, operations, reference),
252
+ folderMeta: tagFolderMeta(spec, tags),
160
253
  slug: reference.slug,
161
254
  spec,
162
255
  };
163
256
  } catch (error) {
164
257
  return {
165
- code: "BLUME_OPENAPI_UNAVAILABLE",
166
- message: `Could not load OpenAPI spec "${reference.spec}" for ${reference.route} (${(error as Error).message}); its reference pages were skipped.`,
258
+ code: `${codePrefix}_UNAVAILABLE`,
259
+ // SAFETY: spec loading fails with Error instances (fetch, read, and
260
+ // parse errors alike); only the message is read for the diagnostic.
261
+ message: `Could not load ${kindLabel} spec "${reference.spec}" for ${reference.route} (${(error as Error).message}); its reference pages were skipped.`,
167
262
  // A configured-but-unloadable spec ships a dead nav tab (a 404 route),
168
263
  // so fail loudly in build (blocks under --strict) while staying a warning
169
264
  // in dev so offline work still runs.
@@ -172,7 +267,7 @@ export const openApiSource = (
172
267
  // only point at reachability for actual fetch/read failures.
173
268
  suggestion:
174
269
  error instanceof InvalidSpecError
175
- ? "Point the spec at an OpenAPI document (a YAML or JSON file with an object at the top level)."
270
+ ? `Point the spec at ${reference.kind === "asyncapi" ? "an AsyncAPI" : "an OpenAPI"} document (a YAML or JSON file with an object at the top level).`
176
271
  : "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
177
272
  };
178
273
  }
@@ -192,6 +287,7 @@ export const openApiSource = (
192
287
  }))
193
288
  );
194
289
  const data: OpenApiData = {};
290
+ const folderMeta: Record<string, FolderMeta> = {};
195
291
  for (const result of results) {
196
292
  if ("severity" in result) {
197
293
  diagnostics.push(result);
@@ -199,10 +295,11 @@ export const openApiSource = (
199
295
  }
200
296
  data[result.slug] = result.spec;
201
297
  entries.push(...result.entries);
298
+ Object.assign(folderMeta, result.folderMeta);
202
299
  diagnostics.push(...result.diagnostics);
203
300
  }
204
301
  parsed = data;
205
- return { diagnostics, entries };
302
+ return { diagnostics, entries, folderMeta };
206
303
  };
207
304
 
208
305
  return {
@@ -49,6 +49,7 @@ import { scanProject } from "../core/project-graph.ts";
49
49
  import type { BlumeProject } from "../core/project-graph.ts";
50
50
  import type { ProjectContext } from "../core/types.ts";
51
51
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
52
+ import type { OpenApiData } from "../openapi/model.ts";
52
53
  import { hasScalarReferences } from "../openapi/references.ts";
53
54
  import { buildReferenceFiles } from "../openapi/scalar.ts";
54
55
  import { isOpenApiSource } from "../openapi/source.ts";
@@ -97,7 +98,7 @@ export const blumeSourceGlob = (
97
98
  };
98
99
 
99
100
  /** The `blume:openapi` payload for the ejected app (`{}` when none). */
100
- const ejectOpenApiData = (project: BlumeProject): unknown => {
101
+ const ejectOpenApiData = (project: BlumeProject): OpenApiData => {
101
102
  const source = project.sources.find(isOpenApiSource);
102
103
  return source ? source.openApiData() : {};
103
104
  };
@@ -126,7 +127,11 @@ const askFiles = async (
126
127
  const grounded = ask.provider !== "inkeep";
127
128
  const files = [
128
129
  {
129
- content: askEndpointTemplate(resolveAskBackend(ask), grounded),
130
+ content: askEndpointTemplate(
131
+ resolveAskBackend(ask),
132
+ grounded,
133
+ ask.instructions
134
+ ),
130
135
  path: join(srcDir, "pages", "api", "ask.ts"),
131
136
  },
132
137
  ];
@@ -1,3 +1,8 @@
1
+ import type { Nodes } from "mdast";
2
+ import { fromMarkdown } from "mdast-util-from-markdown";
3
+ import { gfmFromMarkdown } from "mdast-util-gfm";
4
+ import { gfm } from "micromark-extension-gfm";
5
+
1
6
  import { applyAudienceVisibility } from "../ai/visibility.ts";
2
7
  import type { VisibilityAudience } from "../ai/visibility.ts";
3
8
  import matter from "../core/frontmatter.ts";
@@ -21,6 +26,8 @@ export interface SearchDocument {
21
26
  locale: string;
22
27
  /** Resolved page `type` (`doc`, `blog`, a custom `rfc`…), for type filters. */
23
28
  contentType: string;
29
+ /** Docs version (`""` for the current docs), so results scope to the viewed version. */
30
+ version: string;
24
31
  /** Frontmatter `search.tags`, surfaced for hosted-provider faceting. */
25
32
  tags?: string[];
26
33
  /**
@@ -43,51 +50,96 @@ export interface SearchRecord {
43
50
  content: string;
44
51
  /** Locale code, carried as a facet for per-language filtering. */
45
52
  locale: string;
53
+ /**
54
+ * Docs version, carried as a facet for per-version filtering. The current
55
+ * docs upload as `"current"` — hosted backends treat an empty facet value
56
+ * unreliably, so the sentinel stands in for the empty version id.
57
+ */
58
+ version: string;
46
59
  /** Single faceting tag (the first frontmatter tag, when present). */
47
60
  tag?: string;
48
61
  }
49
62
 
50
- const CODE_FENCE = /```[\s\S]*?```/gu;
51
- const INLINE_CODE = /`(?<code>[^`]+)`/gu;
52
63
  // Tag-shaped only: a name (or closing slash/fragment) right after `<`, and no
53
- // newline inside. A bare `<` in prose ("costs < 5 credits") must not swallow
54
- // everything up to some later `>` potentially whole paragraphs.
64
+ // newline inside. Applied *within* html/JSX nodes so their inner prose is
65
+ // kept; the surrounding Markdown is walked as a tree, so a bare `<` in prose
66
+ // ("costs < 5 credits") is ordinary text and never at risk.
55
67
  const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
56
- const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
57
- const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
58
- const HEADING_MARK = /^#{1,6}\s+/gmu;
59
- const MARKDOWN_PUNCT = /[*_~>]+/gu;
60
68
  const WHITESPACE = /\s+/gu;
61
69
 
62
- /** Reduce Markdown/MDX to plain, searchable text. */
63
- const toPlainText = (markdown: string): string => {
64
- const withoutBlocks = markdown
65
- .replaceAll(CODE_FENCE, " ")
66
- .replaceAll(IMAGE, " ")
67
- .replaceAll(LINK, "$<text>");
70
+ // Parents whose children are inline: no separator is inserted after them, or
71
+ // `re*ally*` would index as `re ally`. Every other parent is block-shaped and
72
+ // ends with a space so adjacent paragraphs/headings/cells don't fuse.
73
+ const INLINE_PARENTS = new Set([
74
+ "delete",
75
+ "emphasis",
76
+ "footnoteReference",
77
+ "link",
78
+ "linkReference",
79
+ "strong",
80
+ ]);
68
81
 
69
- // Strip HTML/JSX from the prose, but keep the contents of inline code — an
70
- // angle-bracket span like `<T>` inside `Array<T>` is a type parameter, not a
71
- // tag, and stripping it would drop those tokens from the search index. Split
72
- // on inline-code spans and only run the HTML strip on the text between them.
73
- const pieces: string[] = [];
74
- let cursor = 0;
75
- for (const match of withoutBlocks.matchAll(INLINE_CODE)) {
76
- const start = match.index ?? 0;
77
- pieces.push(
78
- withoutBlocks.slice(cursor, start).replaceAll(HTML_OR_JSX, " "),
79
- match.groups?.code ?? ""
80
- );
81
- cursor = start + match[0].length;
82
+ /** Fold one mdast node into the plain-text accumulator. */
83
+ const collectText = (node: Nodes, out: string[]): void => {
84
+ switch (node.type) {
85
+ // Fenced code is excluded from the plain index (ranking noise) the
86
+ // "markdown" extraction keeps it for Ask AI grounding — and image alt
87
+ // text was never indexed.
88
+ case "code":
89
+ case "image":
90
+ case "imageReference": {
91
+ return;
92
+ }
93
+ // Inline code is kept verbatim — `Array<T>` is a type parameter, not a
94
+ // tag, and its tokens must stay searchable.
95
+ case "inlineCode": {
96
+ out.push(node.value);
97
+ return;
98
+ }
99
+ // A raw-HTML/JSX run. CommonMark parses a block-level `<Callout>` with no
100
+ // blank lines as ONE html node holding all its inner prose, so the node
101
+ // can't just be dropped — strip the tag-shaped runs and keep the text.
102
+ case "html": {
103
+ out.push(node.value.replaceAll(HTML_OR_JSX, " "));
104
+ return;
105
+ }
106
+ case "break": {
107
+ out.push(" ");
108
+ return;
109
+ }
110
+ default: {
111
+ break;
112
+ }
113
+ }
114
+ if ("value" in node) {
115
+ out.push(node.value);
116
+ return;
82
117
  }
83
- pieces.push(withoutBlocks.slice(cursor).replaceAll(HTML_OR_JSX, " "));
118
+ if ("children" in node) {
119
+ for (const child of node.children) {
120
+ collectText(child, out);
121
+ }
122
+ if (!INLINE_PARENTS.has(node.type)) {
123
+ out.push(" ");
124
+ }
125
+ }
126
+ };
84
127
 
85
- return pieces
86
- .join("")
87
- .replaceAll(HEADING_MARK, "")
88
- .replaceAll(MARKDOWN_PUNCT, " ")
89
- .replaceAll(WHITESPACE, " ")
90
- .trim();
128
+ /**
129
+ * Reduce Markdown/MDX to plain, searchable text: parse (GFM included) and walk
130
+ * the tree instead of regex-stripping the source, so reference-style links,
131
+ * autolinks, setext headings, tables, and literal `*`/`~`/`>` in prose all
132
+ * reduce correctly. This feeds the client index *and* every hosted-provider
133
+ * record, so anything lost here is a permanent search-quality loss.
134
+ */
135
+ const toPlainText = (markdown: string): string => {
136
+ const tree = fromMarkdown(markdown, {
137
+ extensions: [gfm()],
138
+ mdastExtensions: [gfmFromMarkdown()],
139
+ });
140
+ const out: string[] = [];
141
+ collectText(tree, out);
142
+ return out.join("").replaceAll(WHITESPACE, " ").trim();
91
143
  };
92
144
 
93
145
  interface Crumbs {
@@ -169,10 +221,17 @@ export const buildSearchDocuments = async (
169
221
  // locale-prefixed routes), so localized pages get the right section/breadcrumb.
170
222
  // Falls back to the single default-locale nav when i18n is off.
171
223
  const byLocale = Object.values(project.graph.navigationByLocale ?? {});
172
- const sidebars =
173
- byLocale.length > 0
224
+ // Archived versions' trees contribute too, so snapshot pages get their own
225
+ // section/breadcrumb instead of falling through to the "Docs" default.
226
+ const byVersion = Object.values(project.graph.navigationByVersion ?? {})
227
+ .flatMap((locales) => Object.values(locales))
228
+ .map((nav) => nav.sidebar);
229
+ const sidebars = [
230
+ ...(byLocale.length > 0
174
231
  ? byLocale.map((nav) => nav.sidebar)
175
- : [project.graph.navigation?.sidebar ?? []];
232
+ : [project.graph.navigation?.sidebar ?? []]),
233
+ ...byVersion,
234
+ ];
176
235
  const crumbs = new Map<string, Crumbs>();
177
236
  for (const sidebar of sidebars) {
178
237
  for (const [route, crumb] of buildCrumbIndex(sidebar)) {
@@ -202,18 +261,22 @@ export const buildSearchDocuments = async (
202
261
  const tags = page?.meta?.search?.tags;
203
262
  const crumb = crumbs.get(route.path);
204
263
  const facets = page ? pageFacets(page, project.config) : undefined;
205
- return {
264
+ const document: SearchDocument = {
206
265
  breadcrumb: crumb?.breadcrumb ?? [],
207
266
  content: body,
208
267
  contentType: route.contentType,
209
268
  description: page?.description ?? "",
210
- ...(facets ? { facets } : {}),
211
269
  locale: route.locale,
212
270
  route: route.path,
213
271
  section: crumb?.section || "Docs",
214
272
  tags: tags && tags.length > 0 ? tags : undefined,
215
273
  title: route.title,
274
+ version: route.version,
216
275
  };
276
+ if (facets) {
277
+ document.facets = facets;
278
+ }
279
+ return document;
217
280
  })
218
281
  );
219
282
  };
@@ -232,4 +295,5 @@ export const toSearchRecords = (documents: SearchDocument[]): SearchRecord[] =>
232
295
  tag: doc.tags?.[0],
233
296
  title: doc.title,
234
297
  url: doc.route,
298
+ version: doc.version || "current",
235
299
  }));
@@ -10,6 +10,12 @@ import type { PageRecord } from "../core/types.ts";
10
10
  * when nothing facets, so the field stays absent from serialized documents
11
11
  * rather than shipping as `{}` on every page.
12
12
  */
13
+ /** Whether a custom frontmatter value stringifies into a usable facet value. */
14
+ const isFacetValue = <T>(value: T): value is T & (string | number | boolean) =>
15
+ typeof value === "string" ||
16
+ typeof value === "number" ||
17
+ typeof value === "boolean";
18
+
13
19
  export const pageFacets = (
14
20
  page: Pick<PageRecord, "contentType" | "custom">,
15
21
  config: ResolvedConfig
@@ -21,11 +27,7 @@ export const pageFacets = (
21
27
  const facets: Record<string, string> = {};
22
28
  for (const key of declared) {
23
29
  const value = page.custom[key];
24
- if (
25
- typeof value === "string" ||
26
- typeof value === "number" ||
27
- typeof value === "boolean"
28
- ) {
30
+ if (isFacetValue(value)) {
29
31
  facets[key] = String(value);
30
32
  }
31
33
  }