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
@@ -32,9 +32,7 @@ const referencePagePath = (route: string): string => {
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
  }
@@ -9,19 +9,30 @@ import type {
9
9
  SourceLoadResult,
10
10
  } from "../core/sources/types.ts";
11
11
  import type { Diagnostic } from "../core/types.ts";
12
+ import { extractAsyncApiOperations } from "./asyncapi.ts";
13
+ import type { AsyncApiDocument } from "./asyncapi.ts";
12
14
  import { extractOperations } from "./model.ts";
13
- import type { ApiOperationRef, ApiSpecData, OpenApiData } from "./model.ts";
14
- 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";
15
23
  import type { ReferenceSource } from "./references.ts";
16
24
  import { operationMdx, overviewMdx } from "./render-mdx.ts";
17
25
  import type { RenderedPage } from "./render-mdx.ts";
18
26
 
19
27
  /**
20
- * The staged content source behind Blume's own OpenAPI renderer. Each configured
21
- * spec is parsed once here, then lowered into one MDX page per operation plus an
22
- * overview page — so operations become first-class Blume pages (real routes,
23
- * sidebar, search, i18n, OG) and the parsed documents are handed to the
24
- * 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".
25
36
  */
26
37
 
27
38
  /** A content source that also exposes the specs it parsed during `load()`. */
@@ -35,7 +46,7 @@ export interface OpenApiContentSource extends ContentSource {
35
46
  export const isOpenApiSource = (
36
47
  source: ContentSource
37
48
  ): source is OpenApiContentSource =>
38
- (source as Partial<OpenApiContentSource>).kind === "openapi-source";
49
+ "kind" in source && source.kind === "openapi-source";
39
50
 
40
51
  /** Route (`/reference/pet/add-pet`) to a staged content ref, without extension. */
41
52
  const routeToRef = (route: string): string => route.replace(/^\/+/u, "");
@@ -44,7 +55,9 @@ const toEntry = (rendered: RenderedPage, ref: string): SourceEntry => {
44
55
  const raw = matter.stringify(`${rendered.body}\n`, rendered.data);
45
56
  return {
46
57
  body: { format: "mdx", text: rendered.body },
47
- data: rendered.data,
58
+ // Spread so the named frontmatter shape satisfies the open metadata
59
+ // dictionary every source entry carries.
60
+ data: { ...rendered.data },
48
61
  hash: hashText(raw),
49
62
  raw,
50
63
  ref,
@@ -107,6 +120,50 @@ interface LoadedSpec {
107
120
  diagnostics: Diagnostic[];
108
121
  }
109
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
+
110
167
  export const openApiSource = (
111
168
  references: ReferenceSource[],
112
169
  ctx: SourceContext
@@ -116,23 +173,21 @@ export const openApiSource = (
116
173
  const loadReference = async (
117
174
  reference: ReferenceSource
118
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";
119
181
  try {
120
- const { document, warnings } = await parseSpec(
121
- reference.spec,
122
- ctx.projectRoot,
123
- { cacheDir: ctx.cacheDir, refresh: ctx.refresh }
124
- );
125
- const {
126
- operations,
127
- tags,
128
- warnings: extractWarnings,
129
- } = extractOperations(document, reference.route);
182
+ const { document, warnings, operations, tags, extractWarnings } =
183
+ await parseReference(reference, ctx);
130
184
  const info = document.info ?? { title: reference.label, version: "" };
131
185
  const spec: ApiSpecData = {
132
186
  codeSamples: reference.display.codeSamples,
133
187
  description: info.description ?? "",
134
188
  document,
135
189
  expandSchemas: reference.display.expandSchemas,
190
+ kind: reference.kind,
136
191
  label: reference.label,
137
192
  // Operation pages flow through the content pipeline, which mounts them
138
193
  // under the site-wide `basePath` (staged entry refs below stay
@@ -156,13 +211,24 @@ export const openApiSource = (
156
211
  return {
157
212
  diagnostics: [
158
213
  ...warnings.map((message) => ({
159
- 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",
160
221
  message,
161
222
  severity: "warning" as const,
162
223
  })),
163
224
  ...extractWarnings.map((message) => ({
164
- code: "BLUME_OPENAPI_REF_PATH_ITEM",
165
- 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}`,
166
232
  severity: "warning" as const,
167
233
  })),
168
234
  // A document with no operations (say, a config file that happens to
@@ -171,11 +237,13 @@ export const openApiSource = (
171
237
  ...(operations.length === 0
172
238
  ? [
173
239
  {
174
- code: "BLUME_OPENAPI_EMPTY",
175
- 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.`,
176
242
  severity: "warning" as const,
177
243
  suggestion:
178
- "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`.",
179
247
  },
180
248
  ]
181
249
  : []),
@@ -187,8 +255,10 @@ export const openApiSource = (
187
255
  };
188
256
  } catch (error) {
189
257
  return {
190
- code: "BLUME_OPENAPI_UNAVAILABLE",
191
- 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.`,
192
262
  // A configured-but-unloadable spec ships a dead nav tab (a 404 route),
193
263
  // so fail loudly in build (blocks under --strict) while staying a warning
194
264
  // in dev so offline work still runs.
@@ -197,7 +267,7 @@ export const openApiSource = (
197
267
  // only point at reachability for actual fetch/read failures.
198
268
  suggestion:
199
269
  error instanceof InvalidSpecError
200
- ? "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).`
201
271
  : "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
202
272
  };
203
273
  }
@@ -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
  ];
@@ -26,6 +26,8 @@ export interface SearchDocument {
26
26
  locale: string;
27
27
  /** Resolved page `type` (`doc`, `blog`, a custom `rfc`…), for type filters. */
28
28
  contentType: string;
29
+ /** Docs version (`""` for the current docs), so results scope to the viewed version. */
30
+ version: string;
29
31
  /** Frontmatter `search.tags`, surfaced for hosted-provider faceting. */
30
32
  tags?: string[];
31
33
  /**
@@ -48,6 +50,12 @@ export interface SearchRecord {
48
50
  content: string;
49
51
  /** Locale code, carried as a facet for per-language filtering. */
50
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;
51
59
  /** Single faceting tag (the first frontmatter tag, when present). */
52
60
  tag?: string;
53
61
  }
@@ -213,10 +221,17 @@ export const buildSearchDocuments = async (
213
221
  // locale-prefixed routes), so localized pages get the right section/breadcrumb.
214
222
  // Falls back to the single default-locale nav when i18n is off.
215
223
  const byLocale = Object.values(project.graph.navigationByLocale ?? {});
216
- const sidebars =
217
- 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
218
231
  ? byLocale.map((nav) => nav.sidebar)
219
- : [project.graph.navigation?.sidebar ?? []];
232
+ : [project.graph.navigation?.sidebar ?? []]),
233
+ ...byVersion,
234
+ ];
220
235
  const crumbs = new Map<string, Crumbs>();
221
236
  for (const sidebar of sidebars) {
222
237
  for (const [route, crumb] of buildCrumbIndex(sidebar)) {
@@ -246,18 +261,22 @@ export const buildSearchDocuments = async (
246
261
  const tags = page?.meta?.search?.tags;
247
262
  const crumb = crumbs.get(route.path);
248
263
  const facets = page ? pageFacets(page, project.config) : undefined;
249
- return {
264
+ const document: SearchDocument = {
250
265
  breadcrumb: crumb?.breadcrumb ?? [],
251
266
  content: body,
252
267
  contentType: route.contentType,
253
268
  description: page?.description ?? "",
254
- ...(facets ? { facets } : {}),
255
269
  locale: route.locale,
256
270
  route: route.path,
257
271
  section: crumb?.section || "Docs",
258
272
  tags: tags && tags.length > 0 ? tags : undefined,
259
273
  title: route.title,
274
+ version: route.version,
260
275
  };
276
+ if (facets) {
277
+ document.facets = facets;
278
+ }
279
+ return document;
261
280
  })
262
281
  );
263
282
  };
@@ -276,4 +295,5 @@ export const toSearchRecords = (documents: SearchDocument[]): SearchRecord[] =>
276
295
  tag: doc.tags?.[0],
277
296
  title: doc.title,
278
297
  url: doc.route,
298
+ version: doc.version || "current",
279
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
  }
@@ -1,5 +1,10 @@
1
1
  import { create, insertMultiple, search } from "@orama/orama";
2
- import type { AnyOrama, Tokenizer } from "@orama/orama";
2
+ import type {
3
+ AnyOrama,
4
+ EnumArrComparisonOperator,
5
+ EnumComparisonOperator,
6
+ Tokenizer,
7
+ } from "@orama/orama";
3
8
 
4
9
  /**
5
10
  * The minimal document shape both the client-side search dialog and the
@@ -13,6 +18,11 @@ export interface OramaDoc {
13
18
  title: string;
14
19
  /** Locale code; indexed as an enum so queries can filter to one language. */
15
20
  locale?: string;
21
+ /**
22
+ * Docs version; indexed as an enum so queries can filter to one version.
23
+ * The current docs carry `""`, which the enum stores and matches exactly.
24
+ */
25
+ version?: string;
16
26
  /** Resolved page `type`; indexed as an enum so queries can filter by type. */
17
27
  contentType?: string;
18
28
  /** Declared facet values (`content.types.<type>.facets`), key → value. */
@@ -35,6 +45,7 @@ const SCHEMA = {
35
45
  locale: "enum",
36
46
  route: "string",
37
47
  title: "string",
48
+ version: "enum",
38
49
  } as const;
39
50
 
40
51
  /** Flatten a facet map to the `key:value` terms the `facetTerms` enum holds. */
@@ -141,12 +152,20 @@ const addBigrams = (run: string, tokens: Set<string>): void => {
141
152
  * either way — whether they stand between segments, as 「クーリング・オフ」
142
153
  * does, or inside one.
143
154
  */
155
+ /**
156
+ * `Intl.Segmenter` is missing on some runtimes even though the lib type
157
+ * declares it, so the constructor's presence is probed before use.
158
+ */
159
+ const hasSegmenter = (
160
+ segmenter: typeof Intl.Segmenter | undefined
161
+ ): segmenter is typeof Intl.Segmenter => typeof segmenter === "function";
162
+
144
163
  const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
145
164
  const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
146
165
  if (!SEGMENTED_LANGUAGES.has(language)) {
147
166
  return;
148
167
  }
149
- if (typeof Intl.Segmenter !== "function") {
168
+ if (!hasSegmenter(Intl.Segmenter)) {
150
169
  return;
151
170
  }
152
171
  const segmenter = new Intl.Segmenter(language, { granularity: "word" });
@@ -217,10 +236,9 @@ export const buildOramaIndex = async (
217
236
  locale?: string
218
237
  ): Promise<AnyOrama> => {
219
238
  const tokenizer = segmentingTokenizer(locale);
220
- const db = create({
221
- schema: SCHEMA,
222
- ...(tokenizer ? { components: { tokenizer } } : {}),
223
- });
239
+ const db = tokenizer
240
+ ? create({ components: { tokenizer }, schema: SCHEMA })
241
+ : create({ schema: SCHEMA });
224
242
  await insertMultiple(
225
243
  db,
226
244
  documents.map((doc) =>
@@ -244,6 +262,23 @@ export interface OramaQueryFilters {
244
262
  facets?: Record<string, string>;
245
263
  /** Keep only documents in this locale. */
246
264
  locale?: string;
265
+ /**
266
+ * Keep only documents of this docs version (`""` is the current docs — a
267
+ * meaningful filter value, so absence alone disables version filtering).
268
+ */
269
+ version?: string;
270
+ }
271
+
272
+ /**
273
+ * The exact-match `where` clause the filters compile to. Orama types `where`
274
+ * openly (any schema property to an operator), mirrored here by the index
275
+ * signature; this module only ever emits the two enum operators.
276
+ */
277
+ interface OramaWhereClause {
278
+ [property: string]:
279
+ | EnumArrComparisonOperator
280
+ | EnumComparisonOperator
281
+ | undefined;
247
282
  }
248
283
 
249
284
  /**
@@ -265,27 +300,38 @@ export const queryOramaIndex = async (
265
300
  filters?: OramaQueryFilters
266
301
  ): Promise<OramaDoc[]> => {
267
302
  const facetTerms = filters?.facets ? toFacetTerms(filters.facets) : [];
268
- const where = {
269
- ...(filters?.locale ? { locale: { eq: filters.locale } } : {}),
270
- ...(filters?.contentTypes && filters.contentTypes.length > 0
271
- ? { contentType: { in: filters.contentTypes } }
272
- : {}),
273
- ...(facetTerms.length > 0
274
- ? { facetTerms: { containsAll: facetTerms } }
275
- : {}),
276
- };
277
- const params = {
303
+ const where: OramaWhereClause = {};
304
+ if (filters?.locale) {
305
+ where.locale = { eq: filters.locale };
306
+ }
307
+ // `""` (the current docs) is a real filter value, so test for presence.
308
+ if (filters?.version !== undefined) {
309
+ where.version = { eq: filters.version };
310
+ }
311
+ if (filters?.contentTypes && filters.contentTypes.length > 0) {
312
+ where.contentType = { in: filters.contentTypes };
313
+ }
314
+ if (facetTerms.length > 0) {
315
+ where.facetTerms = { containsAll: facetTerms };
316
+ }
317
+ const unfiltered = {
278
318
  boost: BOOST,
279
319
  limit,
280
320
  properties: ["title", "description", "content"],
281
321
  term,
282
- ...(Object.keys(where).length > 0 ? { where } : {}),
283
322
  };
323
+ const params =
324
+ Object.keys(where).length > 0 ? { ...unfiltered, where } : unfiltered;
284
325
  const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
326
+ // The result-document generic is OramaDoc because `buildOramaIndex` is the
327
+ // only writer to this database and inserts OramaDoc records (plus the
328
+ // derived `facetTerms`).
285
329
  const strict = bigrammed
286
- ? await search(db, { ...params, threshold: ALL_TOKENS })
330
+ ? await search<AnyOrama, OramaDoc>(db, { ...params, threshold: ALL_TOKENS })
287
331
  : undefined;
288
332
  const found =
289
- strict && strict.hits.length > 0 ? strict : await search(db, params);
290
- return found.hits.map((hit) => hit.document as unknown as OramaDoc);
333
+ strict && strict.hits.length > 0
334
+ ? strict
335
+ : await search<AnyOrama, OramaDoc>(db, params);
336
+ return found.hits.map((hit) => hit.document);
291
337
  };
@@ -26,8 +26,13 @@ export const resolveSearchPopular = (
26
26
  popular: { href: string; icon?: string; label: string }[],
27
27
  basePath: string
28
28
  ): SearchPopularPage[] =>
29
- popular.map(({ href, icon, label }) => ({
30
- ...(icon ? { icon } : {}),
31
- label,
32
- route: withBasePath(basePath, href),
33
- }));
29
+ popular.map(({ href, icon, label }) => {
30
+ const page: SearchPopularPage = {
31
+ label,
32
+ route: withBasePath(basePath, href),
33
+ };
34
+ if (icon) {
35
+ page.icon = icon;
36
+ }
37
+ return page;
38
+ });
@@ -29,7 +29,7 @@ export interface SearchProviderMeta {
29
29
  syncs: boolean;
30
30
  }
31
31
 
32
- export const SEARCH_PROVIDERS: Record<SearchProvider, SearchProviderMeta> = {
32
+ export const SEARCH_PROVIDERS = {
33
33
  algolia: {
34
34
  kind: "hosted",
35
35
  requiresServer: false,
@@ -80,7 +80,7 @@ export const SEARCH_PROVIDERS: Record<SearchProvider, SearchProviderMeta> = {
80
80
  runtimeDeps: ["typesense"],
81
81
  syncs: true,
82
82
  },
83
- };
83
+ } satisfies Record<SearchProvider, SearchProviderMeta>;
84
84
 
85
85
  export const searchProviderMeta = (
86
86
  provider: SearchProvider
@@ -45,6 +45,8 @@ export const syncSearchProvider = async (
45
45
  `Synced ${records.length} record(s) to ${search.provider}`
46
46
  );
47
47
  } catch (error) {
48
+ // SAFETY: the three sync clients surface network/auth failures as Error
49
+ // instances; the message is read only to annotate the skip warning.
48
50
  reporter.warn(`Search sync skipped: ${(error as Error).message}`);
49
51
  }
50
52
  };
@@ -56,9 +56,10 @@ export const syncTypesense = async (
56
56
  { name: "content", type: "string" },
57
57
  { name: "url", type: "string" },
58
58
  { facet: true, name: "tag", optional: true, type: "string" },
59
- // Carried as a facet so an i18n site can filter hosted results per
60
- // language (the SearchRecord contract).
59
+ // Carried as facets so hosted results can filter per language and per
60
+ // docs version (the SearchRecord contract; current docs = "current").
61
61
  { facet: true, name: "locale", optional: true, type: "string" },
62
+ { facet: true, name: "version", optional: true, type: "string" },
62
63
  ],
63
64
  name: config.collection,
64
65
  });
@@ -71,6 +72,7 @@ export const syncTypesense = async (
71
72
  tag: record.tag,
72
73
  title: record.title,
73
74
  url: record.url,
75
+ version: record.version,
74
76
  }));
75
77
  await client
76
78
  .collections(config.collection)
package/src/seo/jsonld.ts CHANGED
@@ -27,10 +27,25 @@ export interface StructuredDataInput {
27
27
  }
28
28
 
29
29
  /** schema.org `@type` for each content type; defaults to TechArticle. */
30
- const ARTICLE_TYPES: Record<string, string> = {
30
+ const ARTICLE_TYPES = {
31
31
  blog: "BlogPosting",
32
32
  changelog: "TechArticle",
33
- };
33
+ } as const;
34
+
35
+ /**
36
+ * `hasOwn` (not a bare index) so a content type named like an
37
+ * `Object.prototype` member can't resolve a function up the prototype chain.
38
+ */
39
+ const isArticleType = (value: string): value is keyof typeof ARTICLE_TYPES =>
40
+ Object.hasOwn(ARTICLE_TYPES, value);
41
+
42
+ /** A value a schema.org node property can hold. */
43
+ type JsonLdValue = string | number | JsonLdValue[] | JsonLdNode;
44
+
45
+ /** A schema.org node: JSON-LD keys to concrete JSON values. */
46
+ export interface JsonLdNode {
47
+ [key: string]: JsonLdValue;
48
+ }
34
49
 
35
50
  const trimSlash = (value: string): string => value.replace(/\/$/u, "");
36
51
 
@@ -59,14 +74,14 @@ export const toIso = (value: DateInput | undefined): string | undefined => {
59
74
  */
60
75
  export const buildStructuredData = (
61
76
  input: StructuredDataInput
62
- ): Record<string, unknown> | null => {
77
+ ): JsonLdNode | null => {
63
78
  const base = input.siteUrl ? trimSlash(input.siteUrl) : null;
64
79
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top
65
80
  // so JSON-LD URLs match the served location.
66
81
  const deployBase = normalizeBasePath(input.base);
67
82
  const pageUrl = absolute(base, withBasePath(deployBase, input.route));
68
83
  const rootUrl = absolute(base, deployBase);
69
- const graph: Record<string, unknown>[] = [];
84
+ const graph: JsonLdNode[] = [];
70
85
 
71
86
  if (base) {
72
87
  graph.push({
@@ -80,9 +95,12 @@ export const buildStructuredData = (
80
95
  // The homepage is fully described by the WebSite node; deeper pages get an
81
96
  // article node plus a breadcrumb trail.
82
97
  if (input.route !== "/") {
83
- const node: Record<string, unknown> = {
98
+ const pageType = input.pageType ?? "";
99
+ const node: JsonLdNode = {
84
100
  "@id": `${pageUrl}#page`,
85
- "@type": ARTICLE_TYPES[input.pageType ?? ""] ?? "TechArticle",
101
+ "@type": isArticleType(pageType)
102
+ ? ARTICLE_TYPES[pageType]
103
+ : "TechArticle",
86
104
  headline: input.title,
87
105
  inLanguage: input.locale || "en",
88
106
  name: input.title,
@@ -1,3 +1,7 @@
1
+ /** Frontmatter defense in depth: only a real string can carry a handle. */
2
+ const isString = <Value>(value: Value): value is Value & string =>
3
+ typeof value === "string";
4
+
1
5
  /**
2
6
  * Normalize an X account to the leading `@` that `twitter:site`/`twitter:creator`
3
7
  * require, so `acme`, `@acme`, and ` @acme ` all land on `@acme`. Empty or
@@ -7,10 +11,11 @@
7
11
  * Astro's collections carry no schema here, so a page's `seo.x.creator` reaches
8
12
  * them as raw frontmatter, and the schema's own transform never runs on it.
9
13
  * (Blume's page pipeline does reject a non-string `creator` before the page is
10
- * built, so `unknown` is defense in depth rather than the expected path.)
14
+ * built, so the string guard is defense in depth rather than the expected
15
+ * path.)
11
16
  */
12
- export const normalizeXHandle = (value: unknown): string | undefined => {
13
- if (typeof value !== "string") {
17
+ export const normalizeXHandle = <Value>(value: Value): string | undefined => {
18
+ if (!isString(value)) {
14
19
  return;
15
20
  }
16
21
  const handle = value.trim().replace(/^@+/u, "");