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
package/src/og/card.ts CHANGED
@@ -6,7 +6,7 @@ import type { RenderOptions } from "takumi-js";
6
6
  import { container, googleFonts, image, text } from "takumi-js/helpers";
7
7
  import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
8
8
 
9
- import { ACCENTS } from "../theme/palette.ts";
9
+ import { ACCENTS, isAccentPreset } from "../theme/palette.ts";
10
10
  import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
11
11
 
12
12
  /** A local font file registered with the OG card renderer, read at build. */
@@ -61,10 +61,10 @@ export interface OgFontFamilies {
61
61
  // shows (a separate hand-synced hex palette used to drift: the card's "blue"
62
62
  // was Tailwind's, not Blume's). Anything else is handed to Takumi as-is, and
63
63
  // a genuinely malformed value fails the build with a parse error naming it.
64
- // `hasOwn` keeps a preset name like "constructor" from resolving up the
65
- // prototype chain.
64
+ // `isAccentPreset` keeps a preset name like "constructor" from resolving up
65
+ // the prototype chain.
66
66
  const resolveAccent = (accent: string): string =>
67
- Object.hasOwn(ACCENTS, accent) ? (ACCENTS[accent] as string) : accent;
67
+ isAccentPreset(accent) ? ACCENTS[accent] : accent;
68
68
 
69
69
  export interface OgCardPalette {
70
70
  accent?: string;
@@ -153,6 +153,9 @@ const loadFonts = (
153
153
  const key = JSON.stringify(fonts);
154
154
  let pending = fontSubsetCache.get(key);
155
155
  if (!pending) {
156
+ // SAFETY: OgFont's weight strings are documented as variable ranges like
157
+ // "100..900" (GoogleFontFamily's WeightRange); Takumi validates the value
158
+ // at fetch time and fails the build naming a malformed one.
156
159
  pending = googleFonts(fonts as GoogleFontFamily[]);
157
160
  fontSubsetCache.set(key, pending);
158
161
  }
@@ -165,13 +168,29 @@ const loadFonts = (
165
168
  * registers each file once across a build's per-page renders; a missing file
166
169
  * rejects at first use, failing the build with the path in the cause.
167
170
  */
168
- const localFontLoader = (font: OgLocalFont) => ({
169
- data: () => readFile(font.src),
170
- key: font.src,
171
- name: font.name,
172
- ...(font.weight === undefined ? {} : { weight: font.weight }),
173
- ...(font.style === undefined ? {} : { style: font.style }),
174
- });
171
+ /** A lazily-read local font file, in the shape `render` accepts for `fonts`. */
172
+ interface LocalFontSource {
173
+ data: () => Promise<Buffer>;
174
+ key: string;
175
+ name: string;
176
+ weight?: number;
177
+ style?: "normal" | "italic";
178
+ }
179
+
180
+ const localFontLoader = (font: OgLocalFont): LocalFontSource => {
181
+ const loader: LocalFontSource = {
182
+ data: () => readFile(font.src),
183
+ key: font.src,
184
+ name: font.name,
185
+ };
186
+ if (font.weight !== undefined) {
187
+ loader.weight = font.weight;
188
+ }
189
+ if (font.style !== undefined) {
190
+ loader.style = font.style;
191
+ }
192
+ return loader;
193
+ };
175
194
 
176
195
  // Light neutral scale mirrored from the docs homepage theme tokens:
177
196
  // FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
package/src/og/derive.ts CHANGED
@@ -13,7 +13,6 @@ import type {
13
13
  FontsConfig,
14
14
  FontValue,
15
15
  LocalFontConfig,
16
- RemoteFontConfig,
17
16
  } from "../theme/fonts.ts";
18
17
  import { GOOGLE_FONTS, isFontSlug } from "../theme/fonts.ts";
19
18
  import type { OgFont, OgFontFamilies, OgLocalFont } from "./card.ts";
@@ -31,6 +30,23 @@ const CARD_WEIGHTS = [400, 600];
31
30
  const absoluteSrc = (root: string, src: string): string =>
32
31
  isAbsolute(src) ? src : join(root, src);
33
32
 
33
+ /** A concrete numeric face weight (as opposed to a variable-range string). */
34
+ const isNumericWeight = (
35
+ weight: number | string | undefined
36
+ ): weight is number => typeof weight === "number";
37
+
38
+ /** A variable-range weight spec string, e.g. `"100..900"`. */
39
+ const isRangeWeight = (weight: number | string | undefined): weight is string =>
40
+ typeof weight === "string";
41
+
42
+ /** A theme role configured as a font slug / family-name string. */
43
+ const isFontName = (value: FontValue): value is string =>
44
+ typeof value === "string";
45
+
46
+ /** An OG font entry that reads a local file (as opposed to a Google family). */
47
+ const isLocalOgFont = (font: OgFont): font is OgLocalFont =>
48
+ typeof font !== "string" && "src" in font;
49
+
34
50
  /**
35
51
  * The weight spec to fetch for a derived Google family: the declared weights
36
52
  * the card uses, the declared numeric weights otherwise, a lone variable
@@ -39,9 +55,7 @@ const absoluteSrc = (root: string, src: string): string =>
39
55
  const googleWeights = (
40
56
  weights: (number | string)[]
41
57
  ): number[] | string | undefined => {
42
- const numbers = weights.filter(
43
- (weight): weight is number => typeof weight === "number"
44
- );
58
+ const numbers = weights.filter(isNumericWeight);
45
59
  const used = numbers.filter((weight) => CARD_WEIGHTS.includes(weight));
46
60
  if (used.length > 0) {
47
61
  return used;
@@ -50,7 +64,7 @@ const googleWeights = (
50
64
  return numbers;
51
65
  }
52
66
  const [first] = weights;
53
- return weights.length === 1 && typeof first === "string" ? first : undefined;
67
+ return weights.length === 1 && isRangeWeight(first) ? first : undefined;
54
68
  };
55
69
 
56
70
  const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
@@ -60,15 +74,19 @@ const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
60
74
 
61
75
  /** Per-variant local entries for the renderer (paths made absolute). */
62
76
  const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
63
- font.variants.map((variant) => ({
64
- name: font.name,
65
- src: absoluteSrc(root, variant.src),
66
- ...(typeof variant.weight === "number" ? { weight: variant.weight } : {}),
77
+ font.variants.map((variant) => {
78
+ const entry: OgLocalFont = {
79
+ name: font.name,
80
+ src: absoluteSrc(root, variant.src),
81
+ };
82
+ const withWeight: OgLocalFont = isNumericWeight(variant.weight)
83
+ ? { ...entry, weight: variant.weight }
84
+ : entry;
67
85
  // Takumi's per-face style is normal/italic; oblique falls back to the file.
68
- ...(variant.style === "normal" || variant.style === "italic"
69
- ? { style: variant.style }
70
- : {}),
71
- }));
86
+ return variant.style === "normal" || variant.style === "italic"
87
+ ? { ...withWeight, style: variant.style }
88
+ : withWeight;
89
+ });
72
90
 
73
91
  /**
74
92
  * The card fonts for one theme role, or null when the role can't flow into
@@ -76,7 +94,7 @@ const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
76
94
  * `googleFonts` only speaks Google's css2 endpoint).
77
95
  */
78
96
  const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
79
- if (typeof value === "string") {
97
+ if (isFontName(value)) {
80
98
  if (!isFontSlug(value)) {
81
99
  return null;
82
100
  }
@@ -86,16 +104,15 @@ const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
86
104
  if ("variants" in value) {
87
105
  return localOgFonts(value, root);
88
106
  }
89
- const remote = value as RemoteFontConfig;
90
- if ((remote.provider ?? "google") !== "google") {
107
+ if ((value.provider ?? "google") !== "google") {
91
108
  return null;
92
109
  }
93
- return [googleOgFont(remote.name, remote.weights ?? CARD_WEIGHTS)];
110
+ return [googleOgFont(value.name, value.weights ?? CARD_WEIGHTS)];
94
111
  };
95
112
 
96
113
  /** The family name a theme role registers under. */
97
114
  const roleFamily = (value: FontValue): string | null => {
98
- if (typeof value === "string") {
115
+ if (isFontName(value)) {
99
116
  return isFontSlug(value) ? GOOGLE_FONTS[value].family : null;
100
117
  }
101
118
  return value.name;
@@ -136,18 +153,17 @@ export const deriveOgFonts = (
136
153
  }
137
154
  }
138
155
 
139
- return {
140
- fonts: derived,
141
- ...(families.title || families.body ? { families } : {}),
142
- };
156
+ const result: DerivedOgFonts = { fonts: derived };
157
+ if (families.title || families.body) {
158
+ result.families = families;
159
+ }
160
+ return result;
143
161
  };
144
162
 
145
163
  /** Explicit `seo.og.fonts` with local `src` paths resolved to absolute. */
146
164
  export const resolveOgFontSources = (fonts: OgFont[], root: string): OgFont[] =>
147
165
  fonts.map((font) =>
148
- typeof font !== "string" && "src" in font
149
- ? { ...font, src: absoluteSrc(root, font.src) }
150
- : font
166
+ isLocalOgFont(font) ? { ...font, src: absoluteSrc(root, font.src) } : font
151
167
  );
152
168
 
153
169
  /**
@@ -185,14 +201,14 @@ export const missingFontFiles = (
185
201
  ): string[] => {
186
202
  const sources: string[] = [];
187
203
  for (const value of Object.values(options.themeFonts ?? {})) {
188
- if (typeof value !== "string" && "variants" in value) {
204
+ if (!isFontName(value) && "variants" in value) {
189
205
  sources.push(
190
206
  ...value.variants.map((variant) => absoluteSrc(root, variant.src))
191
207
  );
192
208
  }
193
209
  }
194
210
  for (const font of options.ogFonts) {
195
- if (typeof font !== "string" && "src" in font) {
211
+ if (isLocalOgFont(font)) {
196
212
  sources.push(absoluteSrc(root, font.src));
197
213
  }
198
214
  }
@@ -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
+ };