blume 1.4.3 → 1.5.1

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 (204) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +1784 -633
  4. package/dist/cli/index.js.map +111 -106
  5. package/dist/types/ai/component-markdown.d.ts +14 -4
  6. package/dist/types/core/config-input.d.ts +80 -28
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +19 -3
  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 +112 -15
  12. package/dist/types/core/sources/types.d.ts +3 -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 +6 -5
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/dist/types/theme/fonts.d.ts +11 -2
  19. package/docs/advanced/api-reference.mdx +8 -6
  20. package/docs/advanced/custom-pages.mdx +5 -1
  21. package/docs/configuration/index.mdx +1 -1
  22. package/docs/configuration/search.mdx +2 -0
  23. package/docs/configuration/seo.mdx +1 -1
  24. package/docs/configuration/theming.mdx +4 -2
  25. package/docs/content/i18n.mdx +1 -1
  26. package/docs/content/meta.mdx +2 -1
  27. package/docs/content/meta.ts +1 -0
  28. package/docs/content/navigation.mdx +35 -1
  29. package/docs/content/versioning.mdx +106 -0
  30. package/docs/reference/cli.mdx +2 -1
  31. package/docs/reference/frontmatter.mdx +3 -0
  32. package/package.json +3 -1
  33. package/skills/blume-migrate/SKILL.md +2 -2
  34. package/skills/blume-migrate/references/docusaurus.md +1 -1
  35. package/skills/blume-migrate/references/fumadocs.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +1 -1
  37. package/src/ai/agent-readability.ts +37 -10
  38. package/src/ai/ask-context.ts +5 -1
  39. package/src/ai/ask.ts +10 -1
  40. package/src/ai/component-markdown.ts +80 -43
  41. package/src/ai/llms.ts +40 -16
  42. package/src/ai/mcp/data.ts +48 -12
  43. package/src/ai/mcp/discovery.ts +28 -11
  44. package/src/ai/mcp/server.ts +183 -38
  45. package/src/ai/mcp/tools.ts +3 -3
  46. package/src/ai/skills.ts +32 -9
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +6 -2
  50. package/src/astro/generate.ts +64 -34
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +16 -9
  53. package/src/astro/templates.ts +181 -40
  54. package/src/audit/agent.ts +2 -2
  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 +37 -9
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +5 -2
  64. package/src/audit/snapshot.ts +2 -4
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +9 -4
  68. package/src/cli/commands/build.ts +15 -9
  69. package/src/cli/commands/dev.ts +2 -0
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eval.ts +7 -3
  72. package/src/cli/commands/init.ts +9 -9
  73. package/src/cli/commands/mcp-stdio.ts +3 -0
  74. package/src/cli/commands/translate.ts +14 -3
  75. package/src/cli/commands/version.ts +85 -0
  76. package/src/cli/dev-lock.ts +31 -10
  77. package/src/cli/eject-scripts.ts +17 -2
  78. package/src/cli/index.ts +2 -0
  79. package/src/cli/init/questions.ts +1 -1
  80. package/src/cli/init/scaffold.ts +22 -15
  81. package/src/cli/internal-error.ts +1 -0
  82. package/src/components/content/auto-type-table.ts +3 -0
  83. package/src/components/content/diff.ts +9 -5
  84. package/src/components/content/github-info.ts +2 -0
  85. package/src/components/islands/ask-ai.tsx +33 -25
  86. package/src/components/islands/hooks.ts +5 -1
  87. package/src/components/islands/webmcp.ts +49 -12
  88. package/src/components/layout/Fonts.astro +23 -3
  89. package/src/components/layout/Header.astro +25 -1
  90. package/src/components/layout/NavSelector.astro +11 -2
  91. package/src/components/layout/NavTree.astro +4 -2
  92. package/src/components/layout/PageLayout.astro +72 -3
  93. package/src/components/layout/ReferenceLayout.astro +2 -1
  94. package/src/components/layout/RootLayout.astro +20 -1
  95. package/src/components/layout/Search.astro +77 -13
  96. package/src/components/layout/VersionBanner.astro +39 -0
  97. package/src/components/layout/analytics-client.ts +8 -5
  98. package/src/components/layout/hydration-hint.ts +1 -1
  99. package/src/components/layout/nav-utils.ts +1 -4
  100. package/src/components/layout/overrides.ts +25 -12
  101. package/src/components/layout/search/algolia.ts +18 -5
  102. package/src/components/layout/search/endpoint.ts +3 -0
  103. package/src/components/layout/search/flexsearch.ts +23 -7
  104. package/src/components/layout/search/orama-cloud.ts +1 -1
  105. package/src/components/layout/search/orama.ts +4 -1
  106. package/src/components/layout/search/pagefind.ts +2 -0
  107. package/src/components/layout/search/types.ts +13 -1
  108. package/src/components/layout/search/typesense.ts +19 -3
  109. package/src/components/openapi/ApiOverview.astro +32 -6
  110. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  111. package/src/components/openapi/Bindings.astro +89 -0
  112. package/src/components/openapi/MethodBadge.astro +3 -0
  113. package/src/components/openapi/Operation.astro +7 -2
  114. package/src/components/openapi/PanelTabs.astro +131 -0
  115. package/src/components/openapi/ParametersTable.astro +2 -0
  116. package/src/components/openapi/RequestPanel.astro +12 -119
  117. package/src/components/openapi/async-snippets.ts +174 -0
  118. package/src/components/openapi/async.ts +348 -0
  119. package/src/components/openapi/helpers.ts +52 -20
  120. package/src/components/openapi/security.ts +102 -29
  121. package/src/components/openapi/snippets.ts +11 -11
  122. package/src/core/component-overrides.ts +28 -23
  123. package/src/core/config-input.ts +89 -28
  124. package/src/core/config.ts +20 -7
  125. package/src/core/content.ts +3 -1
  126. package/src/core/data.ts +19 -3
  127. package/src/core/define-components.ts +5 -0
  128. package/src/core/diagnostics.ts +46 -38
  129. package/src/core/frontmatter.ts +33 -7
  130. package/src/core/graph.ts +137 -53
  131. package/src/core/i18n-ui.ts +15 -0
  132. package/src/core/i18n.ts +16 -8
  133. package/src/core/last-modified.ts +49 -0
  134. package/src/core/load-module.ts +1 -0
  135. package/src/core/manifest.ts +92 -3
  136. package/src/core/meta.ts +44 -14
  137. package/src/core/nav-diagnostics.ts +3 -3
  138. package/src/core/navigation.ts +247 -67
  139. package/src/core/project-graph.ts +26 -3
  140. package/src/core/schema.ts +214 -68
  141. package/src/core/sources/assets.ts +2 -0
  142. package/src/core/sources/cache.ts +6 -0
  143. package/src/core/sources/github-releases.ts +39 -31
  144. package/src/core/sources/mdx-remote.ts +4 -0
  145. package/src/core/sources/normalize.ts +67 -20
  146. package/src/core/sources/notion.ts +49 -17
  147. package/src/core/sources/portable-text.ts +32 -11
  148. package/src/core/sources/sanity.ts +68 -14
  149. package/src/core/sources/types.ts +4 -0
  150. package/src/core/sources/watch.ts +1 -1
  151. package/src/core/standard-schema.ts +9 -3
  152. package/src/core/text-width.ts +26 -0
  153. package/src/core/tsconfig-aliases.ts +9 -5
  154. package/src/core/types.ts +45 -2
  155. package/src/core/ui-packs/index.ts +9 -1
  156. package/src/core/version-cut.ts +301 -0
  157. package/src/core/version.ts +2 -0
  158. package/src/core/versions.ts +170 -0
  159. package/src/deploy/adapter-output.ts +5 -2
  160. package/src/deploy/cloudflare-negotiation.ts +25 -10
  161. package/src/deploy/sitemap.ts +33 -1
  162. package/src/deploy/vercel-negotiation.ts +45 -18
  163. package/src/eval/report.ts +4 -4
  164. package/src/eval/run.ts +2 -2
  165. package/src/eval/schema.ts +1 -1
  166. package/src/markdown/base-links.ts +6 -6
  167. package/src/markdown/directives.ts +7 -1
  168. package/src/markdown/heading-anchors.ts +17 -6
  169. package/src/markdown/index.ts +73 -24
  170. package/src/markdown/inline-code.ts +14 -2
  171. package/src/markdown/language-icon.ts +6 -2
  172. package/src/markdown/mdast.ts +18 -4
  173. package/src/markdown/package-commands.ts +6 -8
  174. package/src/markdown/table-wrap.ts +4 -1
  175. package/src/markdown/twoslash.ts +2 -0
  176. package/src/og/card.ts +33 -12
  177. package/src/og/derive.ts +43 -27
  178. package/src/openapi/asyncapi.ts +366 -0
  179. package/src/openapi/model.ts +126 -57
  180. package/src/openapi/parse.ts +97 -5
  181. package/src/openapi/references.ts +12 -10
  182. package/src/openapi/render-mdx.ts +73 -34
  183. package/src/openapi/scalar.ts +6 -8
  184. package/src/openapi/source.ts +98 -28
  185. package/src/registry/eject.ts +7 -2
  186. package/src/search/documents.ts +25 -5
  187. package/src/search/facets.ts +7 -5
  188. package/src/search/orama-index.ts +66 -20
  189. package/src/search/popular.ts +10 -5
  190. package/src/search/providers.ts +2 -2
  191. package/src/search/sync/index.ts +2 -0
  192. package/src/search/sync/typesense.ts +4 -2
  193. package/src/seo/jsonld.ts +24 -6
  194. package/src/seo/x-handle.ts +8 -3
  195. package/src/theme/chrome-icons.ts +7 -2
  196. package/src/theme/entry.ts +24 -2
  197. package/src/theme/fonts.ts +83 -7
  198. package/src/theme/icons.ts +4 -2
  199. package/src/theme/palette.ts +22 -14
  200. package/src/translate/meta.ts +15 -6
  201. package/src/translate/report.ts +9 -5
  202. package/src/translate/run.ts +10 -4
  203. package/src/translate/validate.ts +52 -17
  204. package/src/translate/work-list.ts +0 -0
@@ -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
  };
@@ -23,6 +23,8 @@ 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 */
@@ -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. */
@@ -193,6 +204,7 @@ export const buildResult = (
193
204
  section: doc.section ?? "",
194
205
  title: highlight(doc.title, query),
195
206
  url: doc.route,
207
+ version: doc.version,
196
208
  }));
197
209
  return { hits, sections };
198
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
+ }
@@ -0,0 +1,89 @@
1
+ ---
2
+ import type { BindingGroup } from "./async.ts";
3
+ import type { SchemaLike } from "./helpers.ts";
4
+ import SchemaTable from "./SchemaTable.astro";
5
+
6
+ /**
7
+ * Protocol binding fields as a key/value list, one group per protocol
8
+ * (`kafka`, `ws`, `mqtt`, …). Schema-shaped values — the ws binding's `query`
9
+ * and `headers` — render as nested schema tables; everything else as code.
10
+ */
11
+ interface Props {
12
+ title: string;
13
+ groups: BindingGroup[];
14
+ schemas: Record<string, SchemaLike>;
15
+ expandAll?: boolean;
16
+ }
17
+
18
+ const { title, groups, schemas, expandAll = false } = Astro.props;
19
+
20
+ // A bare `type` key is not enough — binding sub-objects carry protocol enums
21
+ // there (the AMQP exchange's `type: "topic"`), so only a JSON-Schema type
22
+ // value counts.
23
+ const SCHEMA_TYPES = new Set([
24
+ "array",
25
+ "boolean",
26
+ "integer",
27
+ "null",
28
+ "number",
29
+ "object",
30
+ "string",
31
+ ]);
32
+
33
+ const isSchemaish = (value: unknown): value is SchemaLike => {
34
+ if (typeof value !== "object" || value === null) {
35
+ return false;
36
+ }
37
+ if ("properties" in value || "$ref" in value) {
38
+ return true;
39
+ }
40
+ const { type } = value as { type?: unknown };
41
+ return typeof type === "string" && SCHEMA_TYPES.has(type);
42
+ };
43
+ ---
44
+
45
+ {
46
+ groups.length > 0 && (
47
+ <section class="mt-6">
48
+ <div
49
+ aria-level="2"
50
+ class="mb-2 font-semibold text-foreground text-sm"
51
+ role="heading"
52
+ >
53
+ {title}
54
+ </div>
55
+ {groups.map((group) => (
56
+ <div class="not-prose mb-3 rounded-blume border border-border px-4 last:mb-0">
57
+ <div class="flex items-baseline gap-2 border-border py-3">
58
+ <code class="font-mono font-semibold text-foreground text-sm">
59
+ {group.protocol}
60
+ </code>
61
+ </div>
62
+ {group.rows.map((row) => (
63
+ <div class="border-border border-t py-3">
64
+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
65
+ <code class="font-mono text-foreground text-sm">{row.name}</code>
66
+ {!isSchemaish(row.value) && (
67
+ <code class="rounded bg-muted px-1 py-0.5 text-foreground text-xs">
68
+ {typeof row.value === "string"
69
+ ? row.value
70
+ : JSON.stringify(row.value)}
71
+ </code>
72
+ )}
73
+ </div>
74
+ {isSchemaish(row.value) && (
75
+ <div class="mt-2">
76
+ <SchemaTable
77
+ expandAll={expandAll}
78
+ schema={row.value}
79
+ schemas={schemas}
80
+ />
81
+ </div>
82
+ )}
83
+ </div>
84
+ ))}
85
+ </div>
86
+ ))}
87
+ </section>
88
+ )
89
+ }
@@ -15,6 +15,9 @@ const COLORS: Record<string, string> = {
15
15
  PATCH: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
16
16
  POST: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
17
17
  PUT: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
18
+ // AsyncAPI actions.
19
+ RECEIVE: "bg-teal-500/15 text-teal-700 dark:text-teal-300",
20
+ SEND: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
18
21
  };
19
22
  const color = COLORS[upper] ?? "bg-muted text-muted-foreground";
20
23
  ---
@@ -14,6 +14,7 @@ import {
14
14
  type SecuritySchemeLike,
15
15
  } from "./security.ts";
16
16
  import { buildRequestSample, sampleLanguages } from "./snippets.ts";
17
+ import AsyncApiOperation from "./AsyncApiOperation.astro";
17
18
  import Authorization from "./Authorization.astro";
18
19
  import MethodBadge from "./MethodBadge.astro";
19
20
  import ParametersTable from "./ParametersTable.astro";
@@ -57,6 +58,9 @@ interface FullOperation {
57
58
  const { source, id } = Astro.props;
58
59
  const spec = specs[source];
59
60
  const ref = spec?.operations[id];
61
+ // The AsyncAPI front-end renders its own body; the lookups below are
62
+ // OpenAPI-shaped (paths, request/response) and resolve to nothing for it.
63
+ const isAsyncApi = spec?.kind === "asyncapi";
60
64
 
61
65
  const doc = (spec?.document ?? {}) as {
62
66
  paths?: Record<
@@ -113,9 +117,10 @@ const sample =
113
117
  : null;
114
118
  const languages = sampleLanguages(spec?.codeSamples ?? []);
115
119
  ---
116
-
117
120
  {
118
- !(spec && ref && operation) ? (
121
+ isAsyncApi ? (
122
+ <AsyncApiOperation id={id} source={source} />
123
+ ) : !(spec && ref && operation) ? (
119
124
  <div class="text-muted-foreground">This API operation could not be found.</div>
120
125
  ) : (
121
126
  <div class="not-prose">