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
@@ -0,0 +1,366 @@
1
+ import type { ApiOperationRef, ApiTagRef } from "./model.ts";
2
+ import { operationCollector, operationKey } from "./model.ts";
3
+
4
+ /**
5
+ * Blume's own AsyncAPI model — the second front-end of the API reference
6
+ * pipeline. Specs are normalized to AsyncAPI 3.x at parse time (see
7
+ * `parseAsyncApiSpec` in `parse.ts`), so this module only handles one shape:
8
+ * top-level `operations` with `action: send | receive` pointing at `channels`.
9
+ * Internal `$ref`s are deliberately left in place, mirroring `model.ts` — the
10
+ * components resolve them lazily, which keeps circular schemas serializable
11
+ * and lets type labels keep their `#/components/schemas/<name>` names.
12
+ * Browser-safe: no Node imports (the components import from here).
13
+ */
14
+
15
+ /** The two AsyncAPI 3.x operation actions, from the application's perspective. */
16
+ export const ASYNCAPI_ACTIONS = ["send", "receive"] as const;
17
+
18
+ export type AsyncApiAction = (typeof ASYNCAPI_ACTIONS)[number];
19
+
20
+ /**
21
+ * A value carried by a parsed spec document: JSON-compatible data whose exact
22
+ * position in the document is not modeled (extension fields, bindings,
23
+ * protocol-specific extras). `undefined` covers absent optional members.
24
+ */
25
+ export type AsyncApiSpecValue =
26
+ | string
27
+ | number
28
+ | boolean
29
+ | null
30
+ | undefined
31
+ | AsyncApiSpecValue[]
32
+ | { [key: string]: AsyncApiSpecValue };
33
+
34
+ /** A permissive view of an AsyncAPI reference object. */
35
+ export interface AsyncApiRefLike {
36
+ $ref?: string;
37
+ [key: string]: AsyncApiSpecValue;
38
+ }
39
+
40
+ /** A permissive view of an AsyncAPI 3.x channel — only the fields we render. */
41
+ export interface AsyncApiChannelObject {
42
+ address?: string | null;
43
+ title?: string;
44
+ summary?: string;
45
+ description?: string;
46
+ messages?: Record<string, AsyncApiRefLike>;
47
+ parameters?: Record<string, AsyncApiRefLike>;
48
+ servers?: AsyncApiRefLike[];
49
+ bindings?: Record<string, Record<string, AsyncApiSpecValue>>;
50
+ [key: string]: AsyncApiSpecValue;
51
+ }
52
+
53
+ /** A permissive view of an AsyncAPI 3.x operation — only the fields we render. */
54
+ export interface AsyncApiOperationObject {
55
+ action?: string;
56
+ channel?: AsyncApiRefLike;
57
+ title?: string;
58
+ summary?: string;
59
+ description?: string;
60
+ deprecated?: boolean;
61
+ tags?: { name?: string; description?: string }[];
62
+ security?: AsyncApiRefLike[];
63
+ messages?: AsyncApiRefLike[];
64
+ bindings?: Record<string, Record<string, AsyncApiSpecValue>>;
65
+ [key: string]: AsyncApiSpecValue;
66
+ }
67
+
68
+ /** A permissive view of an AsyncAPI 3.x server object. */
69
+ export interface AsyncApiServerObject {
70
+ host?: string;
71
+ protocol?: string;
72
+ pathname?: string;
73
+ description?: string;
74
+ security?: AsyncApiRefLike[];
75
+ [key: string]: AsyncApiSpecValue;
76
+ }
77
+
78
+ /** A normalized AsyncAPI 3.x document, internal `$ref`s intact. */
79
+ export interface AsyncApiDocument {
80
+ asyncapi?: string;
81
+ info?: {
82
+ title?: string;
83
+ version?: string;
84
+ description?: string;
85
+ tags?: { name?: string; description?: string }[];
86
+ [key: string]: AsyncApiSpecValue;
87
+ };
88
+ defaultContentType?: string;
89
+ servers?: Record<string, AsyncApiServerObject>;
90
+ channels?: Record<string, AsyncApiChannelObject>;
91
+ operations?: Record<string, AsyncApiOperationObject>;
92
+ components?: Record<string, Record<string, AsyncApiSpecValue>>;
93
+ [key: string]: AsyncApiSpecValue;
94
+ }
95
+
96
+ /** Decode a JSON-pointer token: `user~1signedup` -> `user/signedup`. */
97
+ const unescapePointer = (token: string): string =>
98
+ token.replaceAll("~1", "/").replaceAll("~0", "~");
99
+
100
+ const CHANNEL_REF = /^#\/channels\/(?<id>.+)$/u;
101
+
102
+ const isString = (value: AsyncApiSpecValue): value is string =>
103
+ typeof value === "string";
104
+
105
+ /** The channel id an operation's `channel.$ref` points at, if resolvable. */
106
+ export const channelIdOf = (channel?: AsyncApiRefLike): string | undefined => {
107
+ const id = CHANNEL_REF.exec(channel?.$ref ?? "")?.groups?.id;
108
+ return id === undefined ? undefined : unescapePointer(id);
109
+ };
110
+
111
+ /**
112
+ * A channel's display address. AsyncAPI 3.x allows `address: null` (unknown at
113
+ * design time) — fall back to the channel id so the operation still shows
114
+ * where it lives.
115
+ */
116
+ export const channelAddress = (
117
+ channelId: string,
118
+ channel?: AsyncApiChannelObject
119
+ ): string => {
120
+ const address = channel?.address;
121
+ return isString(address) && address !== "" ? address : channelId;
122
+ };
123
+
124
+ const isObject = (
125
+ value: AsyncApiSpecValue
126
+ ): value is Record<string, AsyncApiSpecValue> =>
127
+ typeof value === "object" && value !== null && !Array.isArray(value);
128
+
129
+ const COMPONENT_SECTION_REF =
130
+ /^#\/components\/(?<section>channels|operations)\/(?<name>[^/]+)$/u;
131
+
132
+ /**
133
+ * Inline top-level `channels`/`operations` entries declared as Reference
134
+ * Objects (`{ $ref: "#/components/channels/…" }` — the spec's reuse pattern)
135
+ * by replacing them with their components target. Runs before trait merging
136
+ * so an inlined operation's traits merge exactly like an inline one's.
137
+ * Unresolvable refs stay in place; the extractor reports them.
138
+ */
139
+ const inlineComponentRefs = (document: AsyncApiDocument): void => {
140
+ const maps: [Record<string, AsyncApiSpecValue> | undefined, string][] = [
141
+ [document.channels, "channels"],
142
+ [document.operations, "operations"],
143
+ ];
144
+ for (const [map, section] of maps) {
145
+ if (!isObject(map)) {
146
+ continue;
147
+ }
148
+ const table = document.components?.[section];
149
+ for (const [id, entry] of Object.entries(map)) {
150
+ if (!isObject(entry) || !isString(entry.$ref)) {
151
+ continue;
152
+ }
153
+ const groups = COMPONENT_SECTION_REF.exec(entry.$ref)?.groups;
154
+ if (groups?.section !== section) {
155
+ continue;
156
+ }
157
+ const resolved = isObject(table)
158
+ ? table[unescapePointer(groups.name ?? "")]
159
+ : undefined;
160
+ if (isObject(resolved) && !isString(resolved.$ref)) {
161
+ map[id] = resolved;
162
+ }
163
+ }
164
+ }
165
+ };
166
+
167
+ const TRAIT_REF =
168
+ /^#\/components\/(?<section>operationTraits|messageTraits)\/(?<name>[^/]+)$/u;
169
+
170
+ /**
171
+ * Merge `traits` into their operation/message objects, in declaration order,
172
+ * with the object's own properties taking precedence (the spec's merge rule;
173
+ * applied shallowly, which covers the fields traits carry in practice —
174
+ * bindings, security, tags, headers). Runs once at parse time so the
175
+ * serialized document and every downstream consumer are trait-free; the
176
+ * `traits` key itself is dropped. Unresolvable trait `$ref`s are skipped.
177
+ */
178
+ export const applyAsyncApiTraits = (
179
+ document: AsyncApiDocument
180
+ ): AsyncApiDocument => {
181
+ const resolveTrait = (
182
+ trait: AsyncApiSpecValue
183
+ ): Record<string, AsyncApiSpecValue> | undefined => {
184
+ if (!isObject(trait)) {
185
+ return undefined;
186
+ }
187
+ if (!isString(trait.$ref)) {
188
+ return trait;
189
+ }
190
+ const groups = TRAIT_REF.exec(trait.$ref)?.groups;
191
+ const section = groups?.section
192
+ ? document.components?.[groups.section]
193
+ : undefined;
194
+ const resolved = section?.[groups?.name ?? ""];
195
+ return isObject(resolved) ? resolved : undefined;
196
+ };
197
+
198
+ const mergeTraits = (node: Record<string, AsyncApiSpecValue>): void => {
199
+ const { traits } = node;
200
+ if (!Array.isArray(traits)) {
201
+ return;
202
+ }
203
+ delete node.traits;
204
+ const merged: Record<string, AsyncApiSpecValue> = {};
205
+ for (const trait of traits) {
206
+ Object.assign(merged, resolveTrait(trait));
207
+ }
208
+ for (const [key, value] of Object.entries(merged)) {
209
+ if (!(key in node)) {
210
+ node[key] = value;
211
+ }
212
+ }
213
+ };
214
+
215
+ for (const operation of Object.values(document.operations ?? {})) {
216
+ if (isObject(operation)) {
217
+ mergeTraits(operation);
218
+ }
219
+ }
220
+ const channelMaps = [
221
+ ...Object.values(document.channels ?? {}),
222
+ // Reusable channels under components carry messages too; their traits
223
+ // must merge the same way so a resolved component channel renders alike.
224
+ ...Object.values(document.components?.channels ?? {}),
225
+ ];
226
+ const messageMaps = [
227
+ ...channelMaps.map((channel) =>
228
+ isObject(channel) ? channel.messages : undefined
229
+ ),
230
+ document.components?.messages,
231
+ ];
232
+ for (const messages of messageMaps) {
233
+ if (!isObject(messages)) {
234
+ continue;
235
+ }
236
+ for (const message of Object.values(messages)) {
237
+ // A channel message that is itself a `$ref` resolves to a components
238
+ // message, which this loop also visits — don't merge through the ref.
239
+ if (isObject(message) && !isString(message.$ref)) {
240
+ mergeTraits(message);
241
+ }
242
+ }
243
+ }
244
+ return document;
245
+ };
246
+
247
+ /**
248
+ * The parse-time normalization pass: component channel/operation refs inlined,
249
+ * then traits merged. The serialized document and every downstream consumer
250
+ * see plain channel/operation objects, trait-free.
251
+ */
252
+ export const normalizeAsyncApiDocument = (
253
+ document: AsyncApiDocument
254
+ ): AsyncApiDocument => {
255
+ inlineComponentRefs(document);
256
+ return applyAsyncApiTraits(document);
257
+ };
258
+
259
+ /**
260
+ * Resolve where one operation renders from: its action and its declared
261
+ * channel. A string return is the warning explaining why the operation can't
262
+ * appear in the reference.
263
+ */
264
+ const operationSite = (
265
+ id: string,
266
+ operation: AsyncApiOperationObject,
267
+ channels: Record<string, AsyncApiChannelObject>
268
+ ): { action: AsyncApiAction; channelId: string; address: string } | string => {
269
+ const action = ASYNCAPI_ACTIONS.find((a) => a === operation.action);
270
+ if (!action) {
271
+ return `Operation "${id}" declares no send/receive action; it is missing from the reference.`;
272
+ }
273
+ const channelId = channelIdOf(operation.channel);
274
+ const channel = channelId === undefined ? undefined : channels[channelId];
275
+ // A channel entry that is still a bare `$ref` survived normalization — its
276
+ // components target doesn't exist — so it renders nothing useful either.
277
+ if (channelId === undefined || !isObject(channel) || isString(channel.$ref)) {
278
+ return `Operation "${id}" references a channel that isn't declared under "channels"; it is missing from the reference.`;
279
+ }
280
+ return { action, address: channelAddress(channelId, channel), channelId };
281
+ };
282
+
283
+ /** The route-mapped operations, ordered tags, and skip warnings of one document. */
284
+ export interface AsyncApiOperationCatalog {
285
+ operations: ApiOperationRef[];
286
+ tags: ApiTagRef[];
287
+ warnings: string[];
288
+ }
289
+
290
+ /**
291
+ * Flatten a normalized AsyncAPI 3.x document into a route-mapped operation
292
+ * list and its ordered tags — the AsyncAPI counterpart of `extractOperations`
293
+ * in `model.ts`, built on the same `operationCollector`. Operations group by
294
+ * their first tag; untagged operations fall back to their channel address, so
295
+ * a spec with no tags still gets one sidebar group per channel. Keys come from
296
+ * the operation id (the `operations` map key), which the official 2.x
297
+ * converter synthesizes deterministically (`<channel>.publish` /
298
+ * `<channel>.subscribe`) — so a 2.x spec and its converter-upgraded 3.x form
299
+ * yield identical URLs. An id that slugifies to nothing falls back to
300
+ * action + address, the same scheme `operationKey` uses for method + path.
301
+ */
302
+ export const extractAsyncApiOperations = (
303
+ document: AsyncApiDocument,
304
+ baseRoute: string
305
+ ): AsyncApiOperationCatalog => {
306
+ const warnings: string[] = [];
307
+ const tagMeta = new Map(
308
+ (document.info?.tags ?? [])
309
+ .filter(
310
+ (tag): tag is { name: string; description?: string } =>
311
+ typeof tag?.name === "string"
312
+ )
313
+ .map((tag): [string, string] => [tag.name, tag.description ?? ""])
314
+ );
315
+ const collector = operationCollector(baseRoute, tagMeta);
316
+ const channels = document.channels ?? {};
317
+
318
+ for (const [id, operation] of Object.entries(document.operations ?? {})) {
319
+ if (!isObject(operation)) {
320
+ continue;
321
+ }
322
+ // An operation entry still carrying a `$ref` is one normalization could
323
+ // not inline; name the real problem instead of the missing-action one.
324
+ if (isString(operation.$ref)) {
325
+ warnings.push(
326
+ `Operation "${id}" is a reference that doesn't resolve to a components operation; it is missing from the reference.`
327
+ );
328
+ continue;
329
+ }
330
+ const site = operationSite(id, operation, channels);
331
+ if (isString(site)) {
332
+ warnings.push(site);
333
+ continue;
334
+ }
335
+ const { action, address, channelId } = site;
336
+ const tag = operation.tags?.find(
337
+ (candidate): candidate is { name: string; description?: string } =>
338
+ typeof candidate?.name === "string"
339
+ )?.name;
340
+ collector.add({
341
+ channelId,
342
+ deprecated: operation.deprecated === true,
343
+ description: operation.description ?? "",
344
+ key: operationKey(action, address, id),
345
+ method: action,
346
+ operationId: id,
347
+ path: address,
348
+ summary: operation.title ?? operation.summary ?? "",
349
+ tag: tag ?? address,
350
+ });
351
+ }
352
+
353
+ return { ...collector.finish(), warnings };
354
+ };
355
+
356
+ /** Resolve the operation object for a ref out of its (AsyncAPI) document. */
357
+ export const asyncApiOperationObject = (
358
+ document: AsyncApiDocument,
359
+ ref: ApiOperationRef
360
+ ): AsyncApiOperationObject | undefined => {
361
+ const operation =
362
+ ref.operationId === undefined
363
+ ? undefined
364
+ : document.operations?.[ref.operationId];
365
+ return isObject(operation) ? operation : undefined;
366
+ };
@@ -1,12 +1,18 @@
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";
2
+
3
+ import type { AsyncApiAction, AsyncApiDocument } from "./asyncapi.ts";
4
+ import type { ReferenceKind } from "./references.ts";
5
+ import { slugify } from "./references.ts";
6
+
7
+ // The slug rules live with the reference resolver so operation routes and
8
+ // reference-source tokens can never drift apart; re-exported for existing
9
+ // importers.
10
+ export { slugify } from "./references.ts";
6
11
 
7
12
  /**
8
- * Blume's own OpenAPI model. Specs are parsed and upgraded to 3.1 (see
9
- * `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
10
16
  * JSON-serializable (a fully dereferenced graph can be circular), and the schema
11
17
  * components resolve refs against `document.components.schemas` at render time.
12
18
  * Each operation is flattened into an {@link ApiOperationRef} with a real,
@@ -16,13 +22,6 @@ import type {
16
22
  /** A normalized OpenAPI 3.1 document, internal `$ref`s intact. */
17
23
  export type ApiDocument = Document;
18
24
 
19
- const NON_SLUG = /[^a-z0-9]+/gu;
20
- const SLUG_EDGES = /^-+|-+$/gu;
21
-
22
- /** Lowercase, URL-safe slug: `Add a Pet!` -> `add-a-pet`. */
23
- export const slugify = (text: string): string =>
24
- text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
25
-
26
25
  /** The HTTP methods an OpenAPI path item may declare, in spec order. */
27
26
  export const HTTP_METHODS = [
28
27
  "get",
@@ -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
- * different names onto one value — any two all-non-ASCII tags (`ペット`,
107
- * `注文`) both fall through to the `operations` fallback — and a shared slug
119
+ * different names onto one value — any two punctuation-only tags (`!!!`,
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
  };