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
@@ -1,4 +1,21 @@
1
- import type { ComponentOverride } from "../../core/define-components.ts";
1
+ import type {
2
+ ComponentOverride,
3
+ IslandDescriptor,
4
+ } from "../../core/define-components.ts";
5
+
6
+ /** A leftover path string an override resolved to (see `resolveSlot`). */
7
+ const isPathString = (override: ComponentOverride): override is string =>
8
+ typeof override === "string";
9
+
10
+ /** An `IslandDescriptor` whose `component` is actually present. */
11
+ const isResolvedIsland = (
12
+ override: ComponentOverride
13
+ ): override is IslandDescriptor =>
14
+ typeof override === "object" &&
15
+ override !== null &&
16
+ "component" in override &&
17
+ override.component !== undefined &&
18
+ override.component !== null;
2
19
 
3
20
  /**
4
21
  * Resolve a layout-slot override to the component Astro should render, falling
@@ -15,20 +32,16 @@ export const resolveSlot = <T>(
15
32
  override: ComponentOverride | undefined,
16
33
  fallback: T
17
34
  ): T => {
18
- if (
19
- override === undefined ||
20
- override === null ||
21
- typeof override === "string"
22
- ) {
35
+ if (override === undefined || override === null || isPathString(override)) {
23
36
  return fallback;
24
37
  }
25
- if (
26
- typeof override === "object" &&
27
- "component" in override &&
28
- override.component !== undefined &&
29
- override.component !== null
30
- ) {
38
+ if (isResolvedIsland(override)) {
39
+ // SAFETY: `ComponentReference` is untyped (`unknown`); the generated
40
+ // components map stores real components for this slot, so the descriptor's
41
+ // component is renderable as the slot's component type.
31
42
  return override.component as T;
32
43
  }
44
+ // SAFETY: same untyped `ComponentReference` — a bare value here is the
45
+ // imported component the config referenced for this slot.
33
46
  return override as T;
34
47
  };
@@ -8,6 +8,7 @@ interface AlgoliaRecord {
8
8
  title: string;
9
9
  description?: string;
10
10
  content?: string;
11
+ version?: string;
11
12
  }
12
13
 
13
14
  /**
@@ -27,15 +28,24 @@ export const createSearch = (opts: {
27
28
  hitsPerPage: SEARCH_LIMIT,
28
29
  indexName: opts.indexName,
29
30
  query,
30
- // The sync uploads `locale` on every record so an i18n site can
31
- // scope hosted results to the active language.
32
- ...(options?.locale && {
33
- facetFilters: [`locale:${options.locale}`],
34
- }),
31
+ // The sync uploads `locale` and `version` on every record so a
32
+ // site can scope hosted results to the active language and the
33
+ // viewed docs version (the current docs upload as "current").
34
+ ...(() => {
35
+ const facetFilters = [
36
+ ...(options?.locale ? [`locale:${options.locale}`] : []),
37
+ ...(options?.version === undefined
38
+ ? []
39
+ : [`version:${options.version || "current"}`]),
40
+ ];
41
+ return facetFilters.length > 0 ? { facetFilters } : {};
42
+ })(),
35
43
  },
36
44
  ],
37
45
  });
38
46
  const [first] = results;
47
+ // SAFETY: the build-time sync uploads every record in the AlgoliaRecord
48
+ // shape, so hits returned by that index carry those fields.
39
49
  const records =
40
50
  first && "hits" in first ? (first.hits as AlgoliaRecord[]) : [];
41
51
  const hits = records.map((record) => ({
@@ -46,6 +56,9 @@ export const createSearch = (opts: {
46
56
  ),
47
57
  title: highlight(record.title, query),
48
58
  url: record.url,
59
+ // Records store the current docs' version as "current" (hosted backends
60
+ // treat empty facet values unreliably); the hit contract uses "".
61
+ version: record.version === "current" ? "" : record.version,
49
62
  }));
50
63
  return { hits, sections: [] };
51
64
  };
@@ -18,6 +18,9 @@ export const createSearch =
18
18
  if (!response.ok) {
19
19
  return { hits: [], sections: [] };
20
20
  }
21
+ // SAFETY: the endpoint is Blume-generated (`search-endpoint` template) and
22
+ // responds with the SearchHit list it built; title/excerpt are still
23
+ // escaped below before the dialog injects them as HTML.
21
24
  const records = (await response.json()) as SearchHit[];
22
25
  const hits = records.slice(0, SEARCH_LIMIT).map((hit) => ({
23
26
  ...hit,
@@ -3,6 +3,16 @@ import { Document } from "flexsearch";
3
3
  import { buildResult, RESULT_POOL } from "./types.ts";
4
4
  import type { IndexedDocument, SearchFn } from "./types.ts";
5
5
 
6
+ /**
7
+ * The fields FlexSearch indexes. An anonymous alias of the indexed fields:
8
+ * unlike the `IndexedDocument` interface it satisfies FlexSearch's
9
+ * index-signature `DocumentData` constraint structurally.
10
+ */
11
+ type SearchDocument = Pick<
12
+ IndexedDocument,
13
+ "content" | "description" | "route" | "title"
14
+ >;
15
+
6
16
  /**
7
17
  * FlexSearch: reuse the same static `blume-search.json` Orama ships, but build
8
18
  * a FlexSearch document index in the browser. Keyless; works in dev and build.
@@ -13,17 +23,17 @@ export const createSearch = async (opts: {
13
23
  indexUrl: string;
14
24
  }): Promise<SearchFn> => {
15
25
  const response = await fetch(opts.indexUrl);
26
+ // SAFETY: `blume-search.json` is generated by our own search indexer, which
27
+ // writes exactly this document shape.
16
28
  const documents = (await response.json()) as IndexedDocument[];
17
29
  const byRoute = new Map(documents.map((doc) => [doc.route, doc]));
18
30
 
19
- const index = new Document({
31
+ const index = new Document<SearchDocument>({
20
32
  document: { id: "route", index: ["title", "description", "content"] },
21
33
  tokenize: "forward",
22
34
  });
23
35
  for (const doc of documents) {
24
- // FlexSearch's `DocumentData` is an index-signature type; our concrete
25
- // record satisfies it structurally but TS needs the cast.
26
- index.add(doc as unknown as Record<string, string>);
36
+ index.add(doc);
27
37
  }
28
38
 
29
39
  // FlexSearch's in-memory search is synchronous, so the SearchFn resolves
@@ -40,9 +50,15 @@ export const createSearch = async (opts: {
40
50
  }
41
51
  seen.add(route);
42
52
  const doc = byRoute.get(route);
43
- // Filter to the active locale (when one is requested) before shaping,
44
- // so section counts and results stay within the language.
45
- if (doc && (!options?.locale || doc.locale === options.locale)) {
53
+ // Filter to the active locale and version (when requested) before
54
+ // shaping, so section counts and results stay within scope. `""` is
55
+ // the current version, so version presence is tested explicitly.
56
+ if (
57
+ doc &&
58
+ (!options?.locale || doc.locale === options.locale) &&
59
+ (options?.version === undefined ||
60
+ (doc.version ?? "") === options.version)
61
+ ) {
46
62
  matched.push(doc);
47
63
  }
48
64
  }
@@ -31,7 +31,7 @@ export const createSearch = (opts: {
31
31
  ...(options?.locale && { where: { locale: options.locale } }),
32
32
  });
33
33
  const hits = (results?.hits ?? []).map((hit) => {
34
- const doc = hit.document as unknown as OramaCloudRecord;
34
+ const doc: OramaCloudRecord = hit.document;
35
35
  return {
36
36
  content: doc.content ?? "",
37
37
  excerpt: highlight(
@@ -19,13 +19,16 @@ export const createSearch = async (opts: {
19
19
  locale?: string;
20
20
  }): Promise<SearchFn> => {
21
21
  const response = await fetch(opts.indexUrl);
22
+ // SAFETY: `blume-search.json` is generated by our own indexer
23
+ // (`buildSearchDocuments`), which writes exactly this document shape.
22
24
  const documents = (await response.json()) as IndexedDocument[];
23
25
  const db = await buildOramaIndex(documents, opts.locale);
24
26
 
25
27
  return async (query, options) => {
26
28
  const docs = await queryOramaIndex(db, query, RESULT_POOL, {
27
29
  locale: options?.locale,
30
+ version: options?.version,
28
31
  });
29
- return buildResult(docs as IndexedDocument[], query, options?.section);
32
+ return buildResult(docs, query, options?.section);
30
33
  };
31
34
  };
@@ -1,4 +1,4 @@
1
- import { highlight, SEARCH_LIMIT } from "./types.ts";
1
+ import { highlight, sanitizeExcerpt, SEARCH_LIMIT } from "./types.ts";
2
2
  import type { SearchFn } from "./types.ts";
3
3
 
4
4
  interface PagefindResult {
@@ -23,21 +23,24 @@ export const createSearch = async (opts: {
23
23
  }): Promise<SearchFn> => {
24
24
  // The pagefind bundle lives in the built site (not node_modules) and is
25
25
  // resolved at runtime by URL — it can't be a static, code-splittable path.
26
+ // SAFETY: the URL points at the `pagefind.js` module our own build emitted,
27
+ // whose export contract (`search()`) is fixed by pagefind.
26
28
  // oxlint-disable-next-line react-doctor/no-dynamic-import-path
27
29
  const pagefind = (await import(
28
30
  /* @vite-ignore */
29
31
  opts.url
30
32
  )) as PagefindModule;
31
- // Pagefind builds its own marked-up excerpt; we keep that and only highlight
32
- // the title. It carries no section/breadcrumb data, so pills stay hidden and
33
- // the preview pane falls back to the excerpt.
33
+ // Pagefind builds its own marked-up excerpt; we keep its `<mark>` highlights
34
+ // (dropping any other markup the excerpt is rendered via innerHTML) and
35
+ // only highlight the title ourselves. It carries no section/breadcrumb data,
36
+ // so pills stay hidden and the preview pane falls back to the excerpt.
34
37
  return async (query) => {
35
38
  const response = await pagefind.search(query);
36
39
  const docs = await Promise.all(
37
40
  response.results.slice(0, SEARCH_LIMIT).map((result) => result.data())
38
41
  );
39
42
  const hits = docs.map((doc) => ({
40
- excerpt: doc.excerpt,
43
+ excerpt: sanitizeExcerpt(doc.excerpt),
41
44
  title: highlight(doc.meta?.title ?? doc.url, query),
42
45
  url: doc.url,
43
46
  }));
@@ -13,6 +13,11 @@ export interface SearchHit {
13
13
  section?: string;
14
14
  /** Plain-text page content, used to render the preview pane. */
15
15
  content?: string;
16
+ /**
17
+ * Docs version the hit belongs to (`""` = current). Local indexes and the
18
+ * hosted Algolia/Typesense adapters set it; other providers leave it unset.
19
+ */
20
+ version?: string;
16
21
  }
17
22
 
18
23
  /** A category pill with its result count. */
@@ -30,7 +35,12 @@ export interface SearchResult {
30
35
  /** A configured query function — the common contract every provider returns. */
31
36
  export type SearchFn = (
32
37
  query: string,
33
- options?: { section?: string; locale?: string }
38
+ options?: {
39
+ section?: string;
40
+ locale?: string;
41
+ /** Docs version to scope to (`""` = current); omitted disables it. */
42
+ version?: string;
43
+ }
34
44
  ) => Promise<SearchResult>;
35
45
 
36
46
  /** A document in the client-loaded `blume-search.json` index. */
@@ -42,6 +52,7 @@ export interface IndexedDocument {
42
52
  breadcrumb?: string[];
43
53
  section?: string;
44
54
  locale?: string;
55
+ version?: string;
45
56
  }
46
57
 
47
58
  /** Max results surfaced in the dialog. */
@@ -84,6 +95,38 @@ export const highlight = (text: string, query: string): string => {
84
95
  .join("");
85
96
  };
86
97
 
98
+ // Either a tag-shaped run — an opening `<` with a letter or `/` after it,
99
+ // through the closing `>` (or end of string for an unterminated tag) — or a
100
+ // lone `<`. A run can't span a later `<` (`[^<>]`), so between the two
101
+ // alternatives every `<` in the input lands inside a captured run.
102
+ const ANGLE_RUN = /(?<run><\/?[a-z][^<>]*>?|<)/iu;
103
+ const BARE_MARK = /^<\/?mark>$/iu;
104
+
105
+ /**
106
+ * Reduce provider-supplied excerpt markup to the `<mark>` highlighting the
107
+ * dialog expects. Remote excerpts (Pagefind's index, hosted engines) are
108
+ * rendered via `innerHTML`, so the output alphabet is pinned: bare
109
+ * `<mark>`/`</mark>` tags (attributes make even a mark untrusted), text, and
110
+ * entities. Tag-shaped runs are dropped; every other `<` is escaped, which
111
+ * renders identically but can't be parsed as markup (`<!--` would otherwise
112
+ * open a comment in `innerHTML` and swallow the rest of the excerpt). Split on
113
+ * runs covering every `<` rather than deleting tags in place: a deletion can
114
+ * splice the text around it into a fresh tag (`<<b>script>` → `<script>`),
115
+ * while here no `<` survives outside a run, so the only ones emitted are the
116
+ * bare mark tags. String-level on purpose: this also runs under DOM-less
117
+ * tests, where DOMPurify/DOMParser don't exist.
118
+ */
119
+ export const sanitizeExcerpt = (html: string): string =>
120
+ html
121
+ .split(ANGLE_RUN)
122
+ .map((part, index) => {
123
+ if (index % 2 === 0 || BARE_MARK.test(part)) {
124
+ return part;
125
+ }
126
+ return part === "<" ? "&lt;" : "";
127
+ })
128
+ .join("");
129
+
87
130
  /** First index in `text` where any query token matches (case-insensitive). */
88
131
  const matchIndex = (text: string, query: string): number => {
89
132
  const tokens = queryTokens(query);
@@ -161,6 +204,7 @@ export const buildResult = (
161
204
  section: doc.section ?? "",
162
205
  title: highlight(doc.title, query),
163
206
  url: doc.route,
207
+ version: doc.version,
164
208
  }));
165
209
  return { hits, sections };
166
210
  };
@@ -8,6 +8,7 @@ interface TypesenseRecord extends Record<string, unknown> {
8
8
  title: string;
9
9
  description?: string;
10
10
  content?: string;
11
+ version?: string;
11
12
  }
12
13
 
13
14
  /**
@@ -40,9 +41,20 @@ export const createSearch = (opts: {
40
41
  per_page: SEARCH_LIMIT,
41
42
  q: query,
42
43
  query_by: "title,description,content",
43
- // The sync marks `locale` as a facet so an i18n site can scope
44
- // hosted results to the active language.
45
- ...(options?.locale && { filter_by: `locale:=${options.locale}` }),
44
+ // The sync marks `locale` and `version` as facets so hosted results
45
+ // scope to the active language and the viewed docs version (the
46
+ // current docs upload as "current").
47
+ ...(() => {
48
+ const clauses = [
49
+ ...(options?.locale ? [`locale:=${options.locale}`] : []),
50
+ ...(options?.version === undefined
51
+ ? []
52
+ : [`version:=${options.version || "current"}`]),
53
+ ];
54
+ return clauses.length > 0
55
+ ? { filter_by: clauses.join(" && ") }
56
+ : {};
57
+ })(),
46
58
  },
47
59
  {}
48
60
  );
@@ -56,6 +68,10 @@ export const createSearch = (opts: {
56
68
  ),
57
69
  title: highlight(doc.title, query),
58
70
  url: doc.url,
71
+ // Records store the current docs' version as "current" (hosted
72
+ // backends treat empty facet values unreliably); the hit contract
73
+ // uses "".
74
+ version: doc.version === "current" ? "" : doc.version,
59
75
  };
60
76
  });
61
77
  return { hits, sections: [] };
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import specs from "blume:openapi";
3
3
 
4
+ import type { AsyncApiDocument } from "../../openapi/asyncapi.ts";
5
+
4
6
  // The spec-level metadata block (version + base URLs) at the top of an API
5
7
  // overview page. The tag sections that follow are emitted by `overviewMdx` as
6
8
  // markdown headings plus `<ApiTagOperations>` lists, so they land in the
@@ -11,8 +13,32 @@ interface Props {
11
13
 
12
14
  const { source } = Astro.props;
13
15
  const spec = specs[source];
14
- const servers =
15
- ((spec?.document ?? {}) as { servers?: { url?: string }[] }).servers ?? [];
16
+
17
+ // OpenAPI declares `servers` as an array of URLs; AsyncAPI as a named map of
18
+ // host/protocol/pathname. Both flatten into one list of address chips.
19
+ const addresses: string[] = [];
20
+ if (spec?.kind === "asyncapi") {
21
+ const servers = (spec.document as AsyncApiDocument).servers ?? {};
22
+ for (const server of Object.values(servers)) {
23
+ if (server?.host) {
24
+ addresses.push(
25
+ `${server.protocol ? `${server.protocol}://` : ""}${server.host}${server.pathname ?? ""}`
26
+ );
27
+ }
28
+ }
29
+ } else {
30
+ // Hand-written specs sometimes declare `servers` as a bare object; degrade
31
+ // to no address chips instead of throwing mid-build.
32
+ const declared = ((spec?.document ?? {}) as { servers?: { url?: string }[] })
33
+ .servers;
34
+ const servers = Array.isArray(declared) ? declared : [];
35
+ for (const server of servers) {
36
+ if (server.url) {
37
+ addresses.push(server.url);
38
+ }
39
+ }
40
+ }
41
+ const addressLabel = spec?.kind === "asyncapi" ? "Servers" : "Base URL";
16
42
  ---
17
43
 
18
44
  {
@@ -23,12 +49,12 @@ const servers =
23
49
  Version {spec.version}
24
50
  </div>
25
51
  )}
26
- {servers.length > 0 && (
52
+ {addresses.length > 0 && (
27
53
  <div class="not-prose mb-8 flex flex-wrap items-center gap-2">
28
- <span class="text-muted-foreground text-xs">Base URL</span>
29
- {servers.map((server) => (
54
+ <span class="text-muted-foreground text-xs">{addressLabel}</span>
55
+ {addresses.map((address) => (
30
56
  <code class="rounded bg-muted px-2 py-0.5 text-foreground text-xs">
31
- {server.url}
57
+ {address}
32
58
  </code>
33
59
  ))}
34
60
  </div>
@@ -0,0 +1,237 @@
1
+ ---
2
+ import data from "blume:data";
3
+ import specs from "blume:openapi";
4
+
5
+ import type { AsyncApiDocument } from "../../openapi/asyncapi.ts";
6
+ import { asyncApiOperationObject } from "../../openapi/asyncapi.ts";
7
+ import { highlightCode } from "../../markdown/index.ts";
8
+ import {
9
+ asyncApiSecurityEntries,
10
+ bindingGroups,
11
+ channelParameters,
12
+ channelServers,
13
+ messageLabel,
14
+ operationMessages,
15
+ payloadSchema,
16
+ protocolOf,
17
+ schemaOf,
18
+ } from "./async.ts";
19
+ import type { MessageSample } from "./async-snippets.ts";
20
+ import { asyncSampleLanguages } from "./async-snippets.ts";
21
+ import Authorization from "./Authorization.astro";
22
+ import Bindings from "./Bindings.astro";
23
+ import { exampleValue, type SchemaLike, toJson } from "./helpers.ts";
24
+ import MethodBadge from "./MethodBadge.astro";
25
+ import PanelTabs from "./PanelTabs.astro";
26
+ import ParametersTable from "./ParametersTable.astro";
27
+ import SchemaTable from "./SchemaTable.astro";
28
+ import type { SecuritySchemeLike } from "./security.ts";
29
+ import { resolveAsyncApiSecurity } from "./security.ts";
30
+
31
+ /**
32
+ * The AsyncAPI front-end of the operation page: message payloads instead of
33
+ * request/response, channel parameters instead of path/query, protocol
34
+ * bindings, and binding-aware samples in the right rail. Everything below the
35
+ * dispatch — schema tables, parameter rows, authorization, the tabbed panels —
36
+ * is the same component set the OpenAPI operation renders.
37
+ */
38
+ interface Props {
39
+ source: string;
40
+ id: string;
41
+ }
42
+
43
+ const { source, id } = Astro.props;
44
+ const spec = specs[source];
45
+ const ref = spec?.operations[id];
46
+ const document = (spec?.document ?? {}) as AsyncApiDocument;
47
+ const operation = ref ? asyncApiOperationObject(document, ref) : undefined;
48
+ const channel = ref?.channelId
49
+ ? document.channels?.[ref.channelId]
50
+ : undefined;
51
+
52
+ const schemas = (document.components?.schemas ?? {}) as Record<
53
+ string,
54
+ SchemaLike
55
+ >;
56
+ const servers = channelServers(channel, document);
57
+ const security = resolveAsyncApiSecurity(
58
+ asyncApiSecurityEntries(operation, servers),
59
+ document.components?.securitySchemes as
60
+ | Record<string, SecuritySchemeLike>
61
+ | undefined
62
+ );
63
+ const parameters = channelParameters(channel, document);
64
+ const messages = operation ? operationMessages(operation, channel, document) : [];
65
+ const protocol = protocolOf(operation, channel, servers);
66
+
67
+ /** Declared example first, else a sampled value from the payload schema. */
68
+ const exampleOf = (message: (typeof messages)[number]["message"]): unknown =>
69
+ message.examples?.[0]?.payload ?? exampleValue(payloadSchema(message), schemas);
70
+
71
+ const messagePanels = await Promise.all(
72
+ messages.map(async (named, index) => {
73
+ const example = exampleOf(named.message);
74
+ return {
75
+ html:
76
+ example === undefined || example === null
77
+ ? null
78
+ : await highlightCode(toJson(example), "json", {
79
+ icons: false,
80
+ themes: data.config.codeThemes,
81
+ }),
82
+ key: `message-${index}`,
83
+ label: messageLabel(named),
84
+ text: named.message.summary || "No example payload.",
85
+ };
86
+ })
87
+ );
88
+
89
+ const sample: MessageSample | null = ref
90
+ ? {
91
+ action: ref.method === "send" ? "send" : "receive",
92
+ address: ref.path,
93
+ payload: messages.length > 0 ? exampleOf(messages[0].message) : undefined,
94
+ server: servers[0],
95
+ }
96
+ : null;
97
+ const languages = asyncSampleLanguages(spec?.codeSamples ?? [], protocol);
98
+ const samplePanels = sample
99
+ ? await Promise.all(
100
+ languages.map(async (language) => ({
101
+ html: await highlightCode(language.build(sample), language.lang, {
102
+ icons: false,
103
+ themes: data.config.codeThemes,
104
+ }),
105
+ key: language.id,
106
+ label: language.label,
107
+ }))
108
+ )
109
+ : [];
110
+
111
+ const operationBindings = bindingGroups(operation?.bindings);
112
+ const channelBindings = bindingGroups(channel?.bindings);
113
+ ---
114
+
115
+ {
116
+ !(spec && ref && operation) ? (
117
+ <div class="text-muted-foreground">This API operation could not be found.</div>
118
+ ) : (
119
+ <div class="not-prose">
120
+ <div class="mb-6 flex flex-wrap items-center gap-3">
121
+ <MethodBadge method={ref.method} />
122
+ <code class="break-all font-mono text-foreground text-sm">
123
+ {ref.path}
124
+ </code>
125
+ {ref.deprecated && (
126
+ <span class="font-medium text-[0.625rem] text-orange-600 uppercase tracking-wide dark:text-orange-400">
127
+ deprecated
128
+ </span>
129
+ )}
130
+ </div>
131
+ <div class="grid grid-cols-1 items-start gap-x-10 gap-y-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,28rem)]">
132
+ <div>
133
+ <Authorization security={security} />
134
+ <ParametersTable parameters={parameters} schemas={schemas} />
135
+ {messages.map((named) => {
136
+ const payload = payloadSchema(named.message);
137
+ // Headers may be multi-format too; undefined with `headers`
138
+ // present means an inline-unrenderable schema format.
139
+ const headers = schemaOf(named.message.headers);
140
+ const messageBindings = bindingGroups(named.message.bindings);
141
+ return (
142
+ <section class="mt-6">
143
+ <div
144
+ aria-level="2"
145
+ class="mb-2 font-semibold text-foreground text-sm"
146
+ role="heading"
147
+ >
148
+ {messages.length > 1
149
+ ? `Message: ${messageLabel(named)}`
150
+ : "Message"}
151
+ </div>
152
+ {named.message.description && (
153
+ <p class="mb-2 text-muted-foreground text-sm">
154
+ {named.message.description}
155
+ </p>
156
+ )}
157
+ {named.message.contentType && (
158
+ <div class="mb-2 text-muted-foreground text-xs">
159
+ <code class="rounded bg-muted px-1 py-0.5">
160
+ {named.message.contentType}
161
+ </code>
162
+ </div>
163
+ )}
164
+ {payload && (
165
+ <div class="not-prose rounded-blume border border-border px-4 py-3">
166
+ <SchemaTable
167
+ expandAll={spec.expandSchemas}
168
+ schema={payload}
169
+ schemas={schemas}
170
+ />
171
+ </div>
172
+ )}
173
+ {/* Payload is optional on a message — only a payload that
174
+ failed to unwrap earns the can't-render note. */}
175
+ {named.message.payload !== undefined && !payload && (
176
+ <p class="text-muted-foreground text-sm">
177
+ The payload uses a schema format this reference can't
178
+ render inline.
179
+ </p>
180
+ )}
181
+ {named.message.headers && (
182
+ <div class="mt-3">
183
+ <div class="mb-1 font-medium text-muted-foreground text-xs uppercase tracking-wide">
184
+ Headers
185
+ </div>
186
+ {headers ? (
187
+ <div class="not-prose rounded-blume border border-border px-4 py-3">
188
+ <SchemaTable
189
+ expandAll={spec.expandSchemas}
190
+ schema={headers}
191
+ schemas={schemas}
192
+ />
193
+ </div>
194
+ ) : (
195
+ <p class="text-muted-foreground text-sm">
196
+ The headers use a schema format this reference can't
197
+ render inline.
198
+ </p>
199
+ )}
200
+ </div>
201
+ )}
202
+ {messageBindings.length > 0 && (
203
+ <Bindings
204
+ expandAll={spec.expandSchemas}
205
+ groups={messageBindings}
206
+ schemas={schemas}
207
+ title="Message bindings"
208
+ />
209
+ )}
210
+ </section>
211
+ );
212
+ })}
213
+ <Bindings
214
+ expandAll={spec.expandSchemas}
215
+ groups={operationBindings}
216
+ schemas={schemas}
217
+ title="Operation bindings"
218
+ />
219
+ <Bindings
220
+ expandAll={spec.expandSchemas}
221
+ groups={channelBindings}
222
+ schemas={schemas}
223
+ title="Channel bindings"
224
+ />
225
+ </div>
226
+ {(samplePanels.length > 0 || messagePanels.length > 0) && (
227
+ <div class="xl:sticky xl:top-24 xl:self-start">
228
+ <div class="not-prose flex flex-col gap-6">
229
+ <PanelTabs copy heading="Example" panels={samplePanels} />
230
+ <PanelTabs heading="Message" panels={messagePanels} />
231
+ </div>
232
+ </div>
233
+ )}
234
+ </div>
235
+ </div>
236
+ )
237
+ }