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
@@ -1,9 +1,7 @@
1
- import type {
2
- Document,
3
- OperationObject,
4
- PathItemObject,
5
- } from "@scalar/openapi-types/3.1";
1
+ import type { Document, OperationObject } from "@scalar/openapi-types/3.1";
6
2
 
3
+ import type { AsyncApiAction, AsyncApiDocument } from "./asyncapi.ts";
4
+ import type { ReferenceKind } from "./references.ts";
7
5
  import { slugify } from "./references.ts";
8
6
 
9
7
  // The slug rules live with the reference resolver so operation routes and
@@ -12,8 +10,9 @@ import { slugify } from "./references.ts";
12
10
  export { slugify } from "./references.ts";
13
11
 
14
12
  /**
15
- * Blume's own OpenAPI model. Specs are parsed and upgraded to 3.1 (see
16
- * `parse.ts`) with internal `$ref`s left intact — the document stays
13
+ * Blume's own API reference model, shared by both spec kinds. OpenAPI specs
14
+ * are parsed and upgraded to 3.1, AsyncAPI specs normalized to 3.x (see
15
+ * `parse.ts`), with internal `$ref`s left intact — the document stays
17
16
  * JSON-serializable (a fully dereferenced graph can be circular), and the schema
18
17
  * components resolve refs against `document.components.schemas` at render time.
19
18
  * Each operation is flattened into an {@link ApiOperationRef} with a real,
@@ -50,22 +49,26 @@ export const operationKey = (
50
49
  return fromId || slugify(`${method}-${path}`);
51
50
  };
52
51
 
53
- /** One operation, flattened out of the paths object and mapped to a route. */
52
+ /** One operation, flattened out of its document and mapped to a route. */
54
53
  export interface ApiOperationRef {
55
54
  /** Stable key, unique within a spec; matches the MDX `<Operation id>`. */
56
55
  key: string;
57
- method: HttpMethod;
58
- /** Templated path, e.g. `/pets/{id}`. */
56
+ /** HTTP method (OpenAPI) or `send`/`receive` action (AsyncAPI). */
57
+ method: HttpMethod | AsyncApiAction;
58
+ /** Templated path, e.g. `/pets/{id}` — or the channel address (AsyncAPI). */
59
59
  path: string;
60
60
  /** Full site route for this operation's page, e.g. `/reference/pet/add-pet`. */
61
61
  route: string;
62
- /** Display tag name (first tag, or `Operations` when untagged). */
62
+ /** Display tag name (first tag; `Operations` or the channel address when untagged). */
63
63
  tag: string;
64
64
  tagSlug: string;
65
65
  summary: string;
66
66
  description: string;
67
+ /** The `operationId` (OpenAPI) or the `operations` map key (AsyncAPI). */
67
68
  operationId?: string;
68
69
  deprecated: boolean;
70
+ /** The channel the operation acts on (AsyncAPI only). */
71
+ channelId?: string;
69
72
  }
70
73
 
71
74
  /** A tag/section, in first-seen order. */
@@ -77,6 +80,8 @@ export interface ApiTagRef {
77
80
 
78
81
  /** Everything the runtime needs for one spec, serialized into `blume:openapi`. */
79
82
  export interface ApiSpecData {
83
+ /** Which front-end parsed the spec (and which components render it). */
84
+ kind: ReferenceKind;
80
85
  /** Unique token used as the `<Operation source>` and the data-module key. */
81
86
  slug: string;
82
87
  /** Base route the spec's operations hang off, e.g. `/reference`. */
@@ -85,7 +90,7 @@ export interface ApiSpecData {
85
90
  title: string;
86
91
  version: string;
87
92
  description: string;
88
- document: ApiDocument;
93
+ document: ApiDocument | AsyncApiDocument;
89
94
  /** Operations keyed by {@link ApiOperationRef.key}. */
90
95
  operations: Record<string, ApiOperationRef>;
91
96
  tags: ApiTagRef[];
@@ -98,17 +103,27 @@ export interface ApiSpecData {
98
103
  /** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
99
104
  export type OpenApiData = Record<string, ApiSpecData>;
100
105
 
101
- const isOperation = (value: unknown): value is OperationObject =>
102
- typeof value === "object" && value !== null;
106
+ // The runtime object check stands guard because the document was parsed from
107
+ // arbitrary YAML/JSON: a spec can put a scalar where the type promises an
108
+ // operation object.
109
+ const isOperation = (
110
+ value: OperationObject | undefined
111
+ ): value is OperationObject => typeof value === "object" && value !== null;
112
+
113
+ /** A declared tag whose `name` really is a string at runtime, type aside. */
114
+ const hasTagName = (tag: SpecTag): tag is SpecTag =>
115
+ typeof tag.name === "string";
103
116
 
104
117
  /**
105
118
  * Assign each distinct tag name a unique slug. `slugify` can collapse
106
119
  * different names onto one value — any two punctuation-only tags (`!!!`,
107
120
  * `???`) both fall through to the `operations` fallback — and a shared slug
108
121
  * silently merges the tags' routes, sidebar groups, and overview sections.
109
- * Collisions gain `-2`, `-3`, … in first-seen order.
122
+ * Collisions gain `-2`, `-3`, … in first-seen order. Shared with the AsyncAPI
123
+ * extractor (`asyncapi.ts`), whose untagged fallback groups are channel
124
+ * addresses.
110
125
  */
111
- const tagSlugger = (): ((name: string) => string) => {
126
+ export const tagSlugger = (): ((name: string) => string) => {
112
127
  const assigned = new Map<string, string>();
113
128
  const taken = new Set<string>();
114
129
  return (name) => {
@@ -127,6 +142,79 @@ const tagSlugger = (): ((name: string) => string) => {
127
142
  };
128
143
  };
129
144
 
145
+ /** An operation before the collector assigns its unique key and route. */
146
+ type CollectedOperation = Omit<ApiOperationRef, "route" | "tagSlug">;
147
+
148
+ /** A document's declared tag entry (`tags[n]`). */
149
+ type SpecTag = NonNullable<ApiDocument["tags"]>[number];
150
+
151
+ /** The flattened output both extractors produce. */
152
+ export interface CollectedOperations {
153
+ operations: ApiOperationRef[];
154
+ tags: ApiTagRef[];
155
+ }
156
+
157
+ /** The collector handle: feed operations in, read the flattened output out. */
158
+ export interface OperationCollector {
159
+ add: (entry: CollectedOperation) => void;
160
+ finish: () => CollectedOperations;
161
+ }
162
+
163
+ /** {@link CollectedOperations} plus anything the extractor had to skip. */
164
+ export interface ExtractedOperations extends CollectedOperations {
165
+ warnings: string[];
166
+ }
167
+
168
+ /**
169
+ * The collector behind both extractors (OpenAPI here, AsyncAPI in
170
+ * `asyncapi.ts`): first-seen tag ordering, key de-duplication (a repeated key
171
+ * gains its method/action as a suffix), and the shared route template — so
172
+ * URL shape and slug rules can never drift between the two spec kinds.
173
+ */
174
+ export const operationCollector = (
175
+ baseRoute: string,
176
+ tagMeta: ReadonlyMap<string, string>
177
+ ): OperationCollector => {
178
+ const operations: ApiOperationRef[] = [];
179
+ const tagOrder: string[] = [];
180
+ const tagsSeen = new Set<string>();
181
+ const seen = new Set<string>();
182
+ const slugForTag = tagSlugger();
183
+
184
+ const add = (entry: CollectedOperation): void => {
185
+ const tagSlug = slugForTag(entry.tag);
186
+ if (!tagsSeen.has(entry.tag)) {
187
+ tagsSeen.add(entry.tag);
188
+ tagOrder.push(entry.tag);
189
+ }
190
+ let { key } = entry;
191
+ while (seen.has(key)) {
192
+ key = `${key}-${entry.method}`;
193
+ }
194
+ seen.add(key);
195
+ operations.push({
196
+ ...entry,
197
+ key,
198
+ // A root-mounted reference (`route: "/"`) must not emit `//tag/key`.
199
+ route: `${baseRoute === "/" ? "" : baseRoute}/${tagSlug}/${key}`,
200
+ tagSlug,
201
+ });
202
+ };
203
+
204
+ const finish = (): CollectedOperations => ({
205
+ operations,
206
+ tags: tagOrder.map((name) => ({
207
+ description: tagMeta.get(name) ?? "",
208
+ name,
209
+ // The same slugger instance, so every tag resolves to the slug its
210
+ // operations were routed under.
211
+ slug: slugForTag(name),
212
+ })),
213
+ });
214
+
215
+ return { add, finish };
216
+ };
217
+
130
218
  /**
131
219
  * Flatten a 3.1 document into a route-mapped operation list and its ordered
132
220
  * tags. Operations inherit the first tag they declare; keys are de-duplicated so
@@ -136,19 +224,17 @@ const tagSlugger = (): ((name: string) => string) => {
136
224
  export const extractOperations = (
137
225
  document: ApiDocument,
138
226
  baseRoute: string
139
- ): { operations: ApiOperationRef[]; tags: ApiTagRef[]; warnings: string[] } => {
140
- const operations: ApiOperationRef[] = [];
141
- const tagOrder: string[] = [];
142
- const tagsSeen = new Set<string>();
227
+ ): ExtractedOperations => {
228
+ const warnings: string[] = [];
143
229
  const tagMeta = new Map(
144
- (document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
230
+ (document.tags ?? [])
231
+ .filter(hasTagName)
232
+ .map((tag) => [tag.name, tag.description ?? ""])
145
233
  );
146
- const seen = new Set<string>();
147
- const warnings: string[] = [];
148
- const slugForTag = tagSlugger();
234
+ const collector = operationCollector(baseRoute, tagMeta);
149
235
 
150
- for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
151
- const item = rawItem as PathItemObject | undefined;
236
+ for (const [path, item] of Object.entries(document.paths ?? {})) {
237
+ // A parsed spec can carry a null path item despite the type; skip it.
152
238
  if (!item) {
153
239
  continue;
154
240
  }
@@ -163,52 +249,35 @@ export const extractOperations = (
163
249
  if (!isOperation(operation)) {
164
250
  continue;
165
251
  }
166
- const tag = operation.tags?.[0] ?? UNTAGGED;
167
- const tagSlug = slugForTag(tag);
168
- if (!tagsSeen.has(tag)) {
169
- tagsSeen.add(tag);
170
- tagOrder.push(tag);
171
- }
172
- let key = operationKey(method, path, operation.operationId);
173
- while (seen.has(key)) {
174
- key = `${key}-${method}`;
175
- }
176
- seen.add(key);
177
- operations.push({
252
+ collector.add({
178
253
  deprecated: operation.deprecated ?? false,
179
254
  description: operation.description ?? "",
180
- key,
255
+ key: operationKey(method, path, operation.operationId),
181
256
  method,
182
257
  operationId: operation.operationId,
183
258
  path,
184
- // A root-mounted reference (`route: "/"`) must not emit `//tag/key`.
185
- route: `${baseRoute === "/" ? "" : baseRoute}/${tagSlug}/${key}`,
186
259
  summary: operation.summary ?? "",
187
- tag,
188
- tagSlug,
260
+ tag: operation.tags?.[0] ?? UNTAGGED,
189
261
  });
190
262
  }
191
263
  }
192
264
 
193
- const tags: ApiTagRef[] = tagOrder.map((name) => ({
194
- description: tagMeta.get(name) ?? "",
195
- name,
196
- // The same slugger instance, so every tag resolves to the slug its
197
- // operations were routed under.
198
- slug: slugForTag(name),
199
- }));
200
-
201
- return { operations, tags, warnings };
265
+ return { ...collector.finish(), warnings };
202
266
  };
203
267
 
204
- /** Resolve the operation object for a ref out of its document. */
268
+ /** Resolve the operation object for a ref out of its (OpenAPI) document. */
205
269
  export const operationObject = (
206
270
  spec: ApiSpecData,
207
271
  ref: ApiOperationRef
208
272
  ): OperationObject | undefined => {
209
- const item = (spec.document.paths?.[ref.path] ?? undefined) as
210
- | PathItemObject
211
- | undefined;
212
- const operation = item?.[ref.method];
273
+ // Only OpenAPI refs carry HTTP methods; the AsyncAPI counterpart is
274
+ // `asyncApiOperationObject` in `asyncapi.ts`.
275
+ const method = HTTP_METHODS.find((candidate) => candidate === ref.method);
276
+ // SAFETY: only OpenAPI specs route their refs through this resolver
277
+ // (AsyncAPI documents go to `asyncApiOperationObject`), so the spec's
278
+ // document is the OpenAPI shape.
279
+ const document = spec.document as ApiDocument;
280
+ const item = document.paths?.[ref.path];
281
+ const operation = method === undefined ? undefined : item?.[method];
213
282
  return isOperation(operation) ? operation : undefined;
214
283
  };
@@ -1,11 +1,15 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
 
4
+ import type { AsyncAPIDocument as ConverterDocument } from "@asyncapi/converter";
5
+ import { convert } from "@asyncapi/converter";
4
6
  import { normalize, upgrade } from "@scalar/openapi-parser";
5
7
  import pRetry, { AbortError } from "p-retry";
6
8
  import { isAbsolute, join } from "pathe";
7
9
 
8
10
  import { hashText } from "../core/sources/cache.ts";
11
+ import type { AsyncApiDocument } from "./asyncapi.ts";
12
+ import { normalizeAsyncApiDocument } from "./asyncapi.ts";
9
13
  import type { ApiDocument } from "./model.ts";
10
14
 
11
15
  /**
@@ -192,6 +196,8 @@ const fetchSpecText = async (spec: string): Promise<string> => {
192
196
  // stacking on it: p-retry's own (capped) delay still runs after this
193
197
  // hook, so only the difference is slept here.
194
198
  onFailedAttempt: async (context) => {
199
+ // SAFETY: every retryable throw above is a RetryableFetchError; any
200
+ // other error reaching this hook reads an absent retryAfter.
195
201
  const { retryAfter } = context.error as RetryableFetchError;
196
202
  if (retryAfter !== undefined && context.retriesLeft > 0) {
197
203
  await sleep(
@@ -265,6 +271,8 @@ const readSpecText = async (
265
271
  if (cacheFile) {
266
272
  const cached = await readCache(cacheFile);
267
273
  if (cached !== undefined) {
274
+ // SAFETY: fetchSpecText throws only Error instances — attemptFetch
275
+ // wraps every non-Error throw in an Error.
268
276
  return {
269
277
  text: cached,
270
278
  warnings: [
@@ -283,6 +291,14 @@ const readSpecText = async (
283
291
  * diagnostic (an error in build, a warning in dev) rather than a hard failure so
284
292
  * a broken spec doesn't take down the whole build.
285
293
  */
294
+ /**
295
+ * A parsed mapping is the only shape the renderer can treat as a document:
296
+ * `normalize` yields undefined for anything that isn't a YAML/JSON mapping
297
+ * (empty file, scalar, list) and `upgrade(undefined)` a null specification.
298
+ */
299
+ const isApiDocument = <Value>(value: Value): value is Value & ApiDocument =>
300
+ typeof value === "object" && value !== null;
301
+
286
302
  export const parseSpec = async (
287
303
  spec: string,
288
304
  root: string,
@@ -291,13 +307,89 @@ export const parseSpec = async (
291
307
  const { text, warnings } = await readSpecText(spec, root, options);
292
308
  const normalized = normalize(text);
293
309
  const { specification } = upgrade(normalized);
294
- // `normalize` yields undefined for anything that isn't a YAML/JSON mapping
295
- // (empty file, scalar, list) and `upgrade(undefined)` yields a null
296
- // specification — reject it here so the renderer never sees a non-document.
297
- if (specification === null || typeof specification !== "object") {
310
+ // Reject a non-mapping here so the renderer never sees a non-document.
311
+ if (!isApiDocument(specification)) {
298
312
  throw new InvalidSpecError(
299
313
  `${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
300
314
  );
301
315
  }
302
- return { document: specification as ApiDocument, warnings };
316
+ return { document: specification, warnings };
317
+ };
318
+
319
+ export interface ParsedAsyncApiSpec {
320
+ document: AsyncApiDocument;
321
+ warnings: string[];
322
+ }
323
+
324
+ /**
325
+ * Read and normalize a spec to an AsyncAPI 3.x document — the AsyncAPI mirror
326
+ * of {@link parseSpec}. 1.x/2.x documents are lifted to 3.0 with the official
327
+ * `@asyncapi/converter` (channels + operations with `send`/`receive` actions),
328
+ * so the extractor and components only ever handle one shape; `$ref`s stay
329
+ * intact, matching the OpenAPI path. Error semantics match `parseSpec`: an
330
+ * unreadable spec throws, a readable non-AsyncAPI document throws
331
+ * {@link InvalidSpecError}, and callers lower both into source diagnostics.
332
+ */
333
+ /**
334
+ * An object carrying a non-empty `asyncapi` version string — the only input
335
+ * the converter and extractor can key on. `normalize` yields undefined for
336
+ * non-mapping input, which fails the object check here.
337
+ */
338
+ const isAsyncApiDocument = <Value>(
339
+ value: Value
340
+ ): value is Value & AsyncApiDocument & { asyncapi: string } =>
341
+ typeof value === "object" &&
342
+ value !== null &&
343
+ "asyncapi" in value &&
344
+ typeof value.asyncapi === "string" &&
345
+ value.asyncapi !== "";
346
+
347
+ export const parseAsyncApiSpec = async (
348
+ spec: string,
349
+ root: string,
350
+ options: SpecFetchOptions = {}
351
+ ): Promise<ParsedAsyncApiSpec> => {
352
+ const { text, warnings } = await readSpecText(spec, root, options);
353
+ const normalized = normalize(text);
354
+ if (!isAsyncApiDocument(normalized)) {
355
+ throw new InvalidSpecError(
356
+ `${spec} is not a valid AsyncAPI document (expected a YAML or JSON object with an \`asyncapi\` version field).`
357
+ );
358
+ }
359
+ const version = normalized.asyncapi;
360
+ let document: AsyncApiDocument = normalized;
361
+ if (!version.startsWith("3.")) {
362
+ // The converter reports lossy conversions (e.g. a 2.x parameter schema
363
+ // that 3.0 can't express) through console.warn — capture those as spec
364
+ // warnings instead of letting them leak into CLI output.
365
+ const captured: string[] = [];
366
+ const originalWarn = console.warn;
367
+ console.warn = (...args: unknown[]) => {
368
+ captured.push(args.map(String).join(" "));
369
+ };
370
+ try {
371
+ // SAFETY: the converter accepts any pre-3.0 AsyncAPI object and returns
372
+ // the 3.0 shape the extractor consumes; the two packages just declare
373
+ // the document type differently.
374
+ document = convert(
375
+ document as ConverterDocument,
376
+ "3.0.0"
377
+ ) as AsyncApiDocument;
378
+ } catch (error) {
379
+ // An unconvertible document (say, an unknown `asyncapi` version) is a
380
+ // content problem, not a network one — same class as a non-document.
381
+ // SAFETY: @asyncapi/converter throws Error instances for bad input.
382
+ throw new InvalidSpecError(
383
+ `${spec} could not be converted to AsyncAPI 3.0 (${(error as Error).message}).`
384
+ );
385
+ } finally {
386
+ console.warn = originalWarn;
387
+ }
388
+ warnings.push(
389
+ ...captured.map(
390
+ (message) => `Converting ${spec} to AsyncAPI 3.0: ${message}`
391
+ )
392
+ );
393
+ }
394
+ return { document: normalizeAsyncApiDocument(document), warnings };
303
395
  };
@@ -54,9 +54,10 @@ export interface ReferenceSource {
54
54
  theme?: string;
55
55
  /**
56
56
  * Arbitrary Scalar config forwarded to `<ScalarComponent>` (Scalar renderer
57
- * only). Takes precedence over Blume's derived spec/theme config.
57
+ * only). Takes precedence over Blume's derived spec/theme config. Typed off
58
+ * the config schema so the two can never drift.
58
59
  */
59
- scalar?: Record<string, unknown>;
60
+ scalar?: ResolvedConfig["openapi"]["scalar"];
60
61
  /** Display options carried through to the Blume renderer. */
61
62
  display: ReferenceDisplay;
62
63
  /**
@@ -174,11 +175,9 @@ const referencesFor = (
174
175
  });
175
176
  };
176
177
 
177
- const NO_DISPLAY: ReferenceDisplay = { codeSamples: [], expandSchemas: false };
178
-
179
178
  /**
180
- * Resolve every enabled reference. OpenAPI honors its `renderer` (Blume's own UI
181
- * by default); AsyncAPI is always rendered by Scalar for now.
179
+ * Resolve every enabled reference. Both blocks honor their `renderer` —
180
+ * Blume's own UI by default, with the embedded Scalar SPA as the opt-out.
182
181
  */
183
182
  export const resolveReferences = (
184
183
  config: ResolvedConfig
@@ -198,8 +197,11 @@ export const resolveReferences = (
198
197
  "asyncapi",
199
198
  config.asyncapi,
200
199
  "Events",
201
- "scalar",
202
- NO_DISPLAY,
200
+ config.asyncapi.renderer,
201
+ {
202
+ codeSamples: config.asyncapi.codeSamples,
203
+ expandSchemas: config.asyncapi.expandSchemas,
204
+ },
203
205
  config.basePath
204
206
  ),
205
207
  ];
@@ -232,7 +234,7 @@ const blumeReferenceOf = (
232
234
  seen: Map<string, ReferenceSource>,
233
235
  usedSlugs: Set<string>
234
236
  ): ReferenceSource | null => {
235
- if (ref.kind !== "openapi" || ref.renderer !== "blume") {
237
+ if (ref.renderer !== "blume") {
236
238
  return null;
237
239
  }
238
240
  const kept = seen.get(ref.route);
@@ -260,7 +262,7 @@ const blumeReferenceOf = (
260
262
  return accepted;
261
263
  };
262
264
 
263
- /** Blume-rendered OpenAPI references, deduped by route (first wins). */
265
+ /** Blume-rendered references (both kinds), deduped by route (first wins). */
264
266
  export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
265
267
  const seen = new Map<string, ReferenceSource>();
266
268
  const usedSlugs = new Set<string>();
@@ -1,7 +1,9 @@
1
1
  import type { Nodes } from "mdast";
2
2
  import { fromMarkdown } from "mdast-util-from-markdown";
3
3
  import { toString as mdastToString } from "mdast-util-to-string";
4
+ import stringWidth from "string-width";
4
5
 
6
+ import { columnsPrefix } from "../core/text-width.ts";
5
7
  import type { ApiOperationRef, ApiSpecData } from "./model.ts";
6
8
  import type { ReferenceSource } from "./references.ts";
7
9
 
@@ -22,18 +24,18 @@ import type { ReferenceSource } from "./references.ts";
22
24
  // deliberately not escaped: it isn't MDX-special on its own, and escaping it
23
25
  // turns a `> Note:` blockquote into literal "&gt; Note:" text.
24
26
  const MDX_UNSAFE = /[<{}]/gu;
25
- const ENTITIES: Record<string, string> = {
26
- "<": "&lt;",
27
- "{": "&#123;",
28
- "}": "&#125;",
29
- };
27
+ const ENTITIES = new Map([
28
+ ["<", "&lt;"],
29
+ ["{", "&#123;"],
30
+ ["}", "&#125;"],
31
+ ]);
30
32
  // MDX also parses lines starting with `import`/`export` as ESM ("import the
31
33
  // SDK…" is common spec prose). Entity-escape the keyword's first letter so the
32
34
  // construct can't match; it still renders as the literal word.
33
35
  const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
34
36
  const escapeProse = (text: string): string =>
35
37
  text
36
- .replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
38
+ .replace(MDX_UNSAFE, (char) => ENTITIES.get(char) ?? char)
37
39
  .replace(
38
40
  MDX_ESM_KEYWORD,
39
41
  (keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
@@ -94,9 +96,23 @@ const mdxSafe = (text: string): string => {
94
96
  return out + escapeProse(text.slice(cursor));
95
97
  };
96
98
 
99
+ /**
100
+ * Frontmatter emitted for one operation or overview page. Boolean flags are
101
+ * assigned only when set, so absent keys stay absent in the staged MDX.
102
+ */
103
+ export interface RenderedPageData {
104
+ ai?: { exclude: boolean };
105
+ deprecated?: boolean;
106
+ search?: { exclude?: boolean; tags?: string[] };
107
+ seo: { description: string; noindex?: boolean };
108
+ sidebar: { badge?: string; label: string };
109
+ title: string;
110
+ type?: string;
111
+ }
112
+
97
113
  /** Frontmatter + body for one operation or overview page. */
98
114
  export interface RenderedPage {
99
- data: Record<string, unknown>;
115
+ data: RenderedPageData;
100
116
  body: string;
101
117
  }
102
118
 
@@ -123,19 +139,19 @@ const plainProse = (markdown: string): string => {
123
139
  return first ? mdastToString(first).replace(WHITESPACE, " ").trim() : "";
124
140
  };
125
141
 
126
- /** Cap `text` at `max` characters, cutting on a word boundary. */
142
+ /** Cap `text` at `max` display columns, cutting on a word boundary. */
127
143
  const clip = (text: string, max: number): string => {
128
144
  if (max <= 0) {
129
145
  return "";
130
146
  }
131
- if (text.length <= max) {
147
+ if (stringWidth(text) <= max) {
132
148
  return text;
133
149
  }
134
- const head = text.slice(0, max - 1);
150
+ const head = columnsPrefix(text, max - 1);
135
151
  const onWordBoundary = head.replace(TRAILING_WORD, "");
136
152
  // One very long token — an endpoint path has no spaces — would be dropped
137
153
  // whole, leaving a stub. Hard-cut it instead of losing it.
138
- return `${onWordBoundary.length >= max / 2 ? onWordBoundary : head}…`;
154
+ return `${stringWidth(onWordBoundary) >= max / 2 ? onWordBoundary : head}…`;
139
155
  };
140
156
 
141
157
  const apiName = (spec: ApiSpecData): string => spec.title || spec.label;
@@ -149,11 +165,14 @@ const operationDescription = (
149
165
  spec: ApiSpecData,
150
166
  operation: ApiOperationRef
151
167
  ): string => {
152
- const endpoint = `${operation.method.toUpperCase()} ${operation.path}`;
153
- const suffix = `Reference for the ${endpoint} endpoint in the ${apiName(spec)} API.`;
168
+ // AsyncAPI operations act on a channel, not an HTTP endpoint.
169
+ const suffix =
170
+ spec.kind === "asyncapi"
171
+ ? `Reference for the ${operation.method} operation on ${operation.path} in the ${apiName(spec)} API.`
172
+ : `Reference for the ${operation.method.toUpperCase()} ${operation.path} endpoint in the ${apiName(spec)} API.`;
154
173
  const prose = clip(
155
174
  plainProse(operation.description || operation.summary),
156
- META_DESCRIPTION_MAX - suffix.length - 1
175
+ META_DESCRIPTION_MAX - stringWidth(suffix) - 1
157
176
  );
158
177
  return clip([prose, suffix].filter(Boolean).join(" "), META_DESCRIPTION_MAX);
159
178
  };
@@ -180,22 +199,35 @@ export const operationMdx = (
180
199
  operation.description.trim() === operation.summary.trim()
181
200
  ? ""
182
201
  : operation.description;
202
+ const flags: Pick<RenderedPageData, "ai" | "deprecated"> = {};
203
+ if (reference?.includeInLlms === false) {
204
+ flags.ai = { exclude: true };
205
+ }
206
+ if (operation.deprecated) {
207
+ flags.deprecated = true;
208
+ }
209
+ const searchFlags: Pick<
210
+ NonNullable<RenderedPageData["search"]>,
211
+ "exclude"
212
+ > = {};
213
+ if (reference?.includeInSearch === false) {
214
+ searchFlags.exclude = true;
215
+ }
216
+ const seo: RenderedPageData["seo"] = {
217
+ description: operationDescription(spec, operation),
218
+ };
219
+ if (reference?.noindex) {
220
+ seo.noindex = true;
221
+ }
183
222
  return {
184
223
  body: withDescription(
185
224
  description,
186
225
  `<Operation source="${spec.slug}" id="${operation.key}" />`
187
226
  ),
188
227
  data: {
189
- ...(reference?.includeInLlms === false ? { ai: { exclude: true } } : {}),
190
- ...(operation.deprecated ? { deprecated: true } : {}),
191
- search: {
192
- ...(reference?.includeInSearch === false ? { exclude: true } : {}),
193
- tags: [operation.tag, method],
194
- },
195
- seo: {
196
- description: operationDescription(spec, operation),
197
- ...(reference?.noindex ? { noindex: true } : {}),
198
- },
228
+ ...flags,
229
+ search: { ...searchFlags, tags: [operation.tag, method] },
230
+ seo,
199
231
  sidebar: { badge: method, label: operation.summary || operation.path },
200
232
  title,
201
233
  // Signals the two-column API layout (request panel instead of the TOC).
@@ -253,6 +285,21 @@ export const overviewMdx = (
253
285
  ].join("\n\n")
254
286
  );
255
287
  }
288
+ const flags: Pick<RenderedPageData, "ai" | "search"> = {};
289
+ if (reference?.includeInLlms === false) {
290
+ flags.ai = { exclude: true };
291
+ }
292
+ if (reference?.includeInSearch === false) {
293
+ flags.search = { exclude: true };
294
+ }
295
+ const seo: RenderedPageData["seo"] = {
296
+ description:
297
+ clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
298
+ `${apiName(spec)} API reference.`,
299
+ };
300
+ if (reference?.noindex) {
301
+ seo.noindex = true;
302
+ }
256
303
  return {
257
304
  body: [
258
305
  withDescription(
@@ -262,16 +309,8 @@ export const overviewMdx = (
262
309
  ...tagSections,
263
310
  ].join("\n\n"),
264
311
  data: {
265
- ...(reference?.includeInLlms === false ? { ai: { exclude: true } } : {}),
266
- ...(reference?.includeInSearch === false
267
- ? { search: { exclude: true } }
268
- : {}),
269
- seo: {
270
- description:
271
- clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
272
- `${apiName(spec)} API reference.`,
273
- ...(reference?.noindex ? { noindex: true } : {}),
274
- },
312
+ ...flags,
313
+ seo,
275
314
  sidebar: { label: "Overview" },
276
315
  title: apiName(spec),
277
316
  },