blume 0.6.7 → 0.8.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 (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -1,3 +1,4 @@
1
+ import { withBasePath } from "../core/base-path.ts";
1
2
  import type { ResolvedConfig } from "../core/schema.ts";
2
3
  import type { NavTab } from "../core/types.ts";
3
4
 
@@ -30,6 +31,12 @@ export interface ReferenceSource {
30
31
  slug: string;
31
32
  /** Normalized route the reference mounts at, e.g. `/reference`. */
32
33
  route: string;
34
+ /**
35
+ * Site-wide `basePath` the rendered pages are mounted under (`""` when
36
+ * none). Kept separate from `route` — the content pipeline applies it to
37
+ * staged entries itself — so consumers prefix only the URLs they emit.
38
+ */
39
+ basePath: string;
33
40
  label: string;
34
41
  /** Local path or `http(s)` URL, verbatim from config. */
35
42
  spec: string;
@@ -37,6 +44,11 @@ export interface ReferenceSource {
37
44
  theme?: string;
38
45
  /** Display options carried through to the Blume renderer. */
39
46
  display: ReferenceDisplay;
47
+ /**
48
+ * Warnings recorded while deduping — another source's route collided with
49
+ * this one and was dropped. Surfaced as diagnostics when the source loads.
50
+ */
51
+ collisions?: string[];
40
52
  }
41
53
 
42
54
  const NON_SLUG = /[^a-z0-9]+/gu;
@@ -77,7 +89,8 @@ const referencesFor = (
77
89
  block: Block,
78
90
  defaultLabel: string,
79
91
  renderer: ReferenceRenderer,
80
- display: ReferenceDisplay
92
+ display: ReferenceDisplay,
93
+ basePath: string
81
94
  ): ReferenceSource[] => {
82
95
  if (!block.enabled) {
83
96
  return [];
@@ -101,6 +114,7 @@ const referencesFor = (
101
114
  }
102
115
 
103
116
  return {
117
+ basePath,
104
118
  display,
105
119
  kind,
106
120
  label,
@@ -130,41 +144,81 @@ export const resolveReferences = (
130
144
  {
131
145
  codeSamples: config.openapi.codeSamples,
132
146
  expandSchemas: config.openapi.expandSchemas,
133
- }
147
+ },
148
+ config.basePath
149
+ ),
150
+ ...referencesFor(
151
+ "asyncapi",
152
+ config.asyncapi,
153
+ "Events",
154
+ "scalar",
155
+ NO_DISPLAY,
156
+ config.basePath
134
157
  ),
135
- ...referencesFor("asyncapi", config.asyncapi, "Events", "scalar", NO_DISPLAY),
136
158
  ];
137
159
 
138
160
  /** Nav tabs (header links) for every reference, regardless of renderer. */
139
161
  export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
140
162
  resolveReferences(config).map((ref) => ({
141
163
  label: ref.label,
142
- path: ref.route,
164
+ // Blume-rendered operation pages flow through the content pipeline and are
165
+ // mounted under `basePath`, so their tab must be too. Scalar references are
166
+ // a single embedded page injected at the raw `route`, left root-anchored.
167
+ path:
168
+ ref.renderer === "blume"
169
+ ? withBasePath(config.basePath, ref.route)
170
+ : ref.route,
143
171
  }));
144
172
 
173
+ /**
174
+ * Accept one resolved reference into the deduped Blume-rendered set, or return
175
+ * null to skip it. Mutates `seen`/`usedSlugs` so repeated routes/slugs collapse.
176
+ * A dropped route collision is recorded on the kept reference (mirroring the
177
+ * Scalar path's warning) — losing a whole spec's pages must not be silent.
178
+ */
179
+ const blumeReferenceOf = (
180
+ ref: ReferenceSource,
181
+ seen: Map<string, ReferenceSource>,
182
+ usedSlugs: Set<string>
183
+ ): ReferenceSource | null => {
184
+ if (ref.kind !== "openapi" || ref.renderer !== "blume") {
185
+ return null;
186
+ }
187
+ const kept = seen.get(ref.route);
188
+ if (kept) {
189
+ (kept.collisions ??= []).push(
190
+ `Two API reference sources resolve to ${ref.route}; keeping the first.`
191
+ );
192
+ return null;
193
+ }
194
+ // Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
195
+ // yield `api-v1`). The slug keys the `blume:openapi` data module, so a
196
+ // collision would let one spec silently overwrite the other while the
197
+ // loser's pages still point at the shared key — disambiguate.
198
+ let { slug } = ref;
199
+ let n = 2;
200
+ while (usedSlugs.has(slug)) {
201
+ slug = `${ref.slug}-${n}`;
202
+ n += 1;
203
+ }
204
+ usedSlugs.add(slug);
205
+ const accepted = slug === ref.slug ? ref : { ...ref, slug };
206
+ // Keep the accepted object (not the original) so a later collision's warning
207
+ // lands on the reference the caller actually receives.
208
+ seen.set(ref.route, accepted);
209
+ return accepted;
210
+ };
211
+
145
212
  /** Blume-rendered OpenAPI references, deduped by route (first wins). */
146
213
  export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
147
- const seen = new Set<string>();
214
+ const seen = new Map<string, ReferenceSource>();
148
215
  const usedSlugs = new Set<string>();
149
216
  const result: ReferenceSource[] = [];
150
217
  for (const ref of resolveReferences(config)) {
151
- if (ref.kind !== "openapi" || ref.renderer !== "blume") {
152
- continue;
153
- }
154
- if (seen.has(ref.route)) {
155
- continue;
156
- }
157
- seen.add(ref.route);
158
- // Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
159
- // yield `api-v1`). The slug keys the `blume:openapi` data module, so a
160
- // collision would let one spec silently overwrite the other while the
161
- // loser's pages still point at the shared key — disambiguate.
162
- let { slug } = ref;
163
- for (let n = 2; usedSlugs.has(slug); n += 1) {
164
- slug = `${ref.slug}-${n}`;
218
+ const accepted = blumeReferenceOf(ref, seen, usedSlugs);
219
+ if (accepted) {
220
+ result.push(accepted);
165
221
  }
166
- usedSlugs.add(slug);
167
- result.push(slug === ref.slug ? ref : { ...ref, slug });
168
222
  }
169
223
  return result;
170
224
  };
@@ -119,17 +119,22 @@ export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
119
119
  });
120
120
  }
121
121
  }
122
- const tagSections = sections
123
- .filter((tag) =>
124
- operations.some((operation) => operation.tagSlug === tag.slug)
125
- )
126
- .map((tag) =>
122
+ const tagSections: string[] = [];
123
+ for (const tag of sections) {
124
+ if (!operations.some((operation) => operation.tagSlug === tag.slug)) {
125
+ continue;
126
+ }
127
+ const description = tag.description.trim()
128
+ ? [mdxSafe(tag.description.trim())]
129
+ : [];
130
+ tagSections.push(
127
131
  [
128
132
  `## ${mdxSafe(tag.name)}`,
129
- ...(tag.description.trim() ? [mdxSafe(tag.description.trim())] : []),
133
+ ...description,
130
134
  `<ApiTagOperations source="${spec.slug}" tag="${tag.slug}" />`,
131
135
  ].join("\n\n")
132
136
  );
137
+ }
133
138
  return {
134
139
  body: [
135
140
  withDescription(
@@ -87,6 +87,35 @@ const specConfiguration = async (
87
87
  }
88
88
  };
89
89
 
90
+ /**
91
+ * Accept one resolved reference into the deduped Scalar set, or return null to
92
+ * skip it (recording a warning for a route collision). Mutates `seen`.
93
+ */
94
+ const acceptScalarReference = (
95
+ ref: ReferenceSource,
96
+ seen: Set<string>,
97
+ contentRoutes: ReadonlySet<string>,
98
+ warnings: string[]
99
+ ): ReferenceSource | null => {
100
+ if (ref.renderer !== "scalar") {
101
+ return null;
102
+ }
103
+ if (seen.has(ref.route)) {
104
+ warnings.push(
105
+ `Two API reference sources resolve to ${ref.route}; keeping the first.`
106
+ );
107
+ return null;
108
+ }
109
+ if (contentRoutes.has(ref.route)) {
110
+ warnings.push(
111
+ `API reference route ${ref.route} collides with a content page; skipping the reference there.`
112
+ );
113
+ return null;
114
+ }
115
+ seen.add(ref.route);
116
+ return ref;
117
+ };
118
+
90
119
  /**
91
120
  * Build the Scalar reference page(s) for the project. Only Scalar-rendered
92
121
  * references are emitted here (Blume-rendered OpenAPI is staged content). Reads
@@ -105,23 +134,10 @@ export const buildReferenceFiles = async (options: {
105
134
  const seen = new Set<string>();
106
135
  const accepted: ReferenceSource[] = [];
107
136
  for (const ref of resolveReferences(config)) {
108
- if (ref.renderer !== "scalar") {
109
- continue;
110
- }
111
- if (seen.has(ref.route)) {
112
- warnings.push(
113
- `Two API reference sources resolve to ${ref.route}; keeping the first.`
114
- );
115
- continue;
116
- }
117
- if (contentRoutes.has(ref.route)) {
118
- warnings.push(
119
- `API reference route ${ref.route} collides with a content page; skipping the reference there.`
120
- );
121
- continue;
137
+ const next = acceptScalarReference(ref, seen, contentRoutes, warnings);
138
+ if (next) {
139
+ accepted.push(next);
122
140
  }
123
- seen.add(ref.route);
124
- accepted.push(ref);
125
141
  }
126
142
 
127
143
  const built = await Promise.all(
@@ -1,3 +1,4 @@
1
+ import { withBasePath } from "../core/base-path.ts";
1
2
  import matter from "../core/frontmatter.ts";
2
3
  import { hashText } from "../core/sources/cache.ts";
3
4
  import type {
@@ -9,7 +10,7 @@ import type {
9
10
  import type { Diagnostic } from "../core/types.ts";
10
11
  import { extractOperations } from "./model.ts";
11
12
  import type { ApiOperationRef, ApiSpecData, OpenApiData } from "./model.ts";
12
- import { parseSpec } from "./parse.ts";
13
+ import { InvalidSpecError, parseSpec } from "./parse.ts";
13
14
  import type { ReferenceSource } from "./references.ts";
14
15
  import { operationMdx, overviewMdx } from "./render-mdx.ts";
15
16
  import type { RenderedPage } from "./render-mdx.ts";
@@ -90,7 +91,11 @@ export const openApiSource = (
90
91
  ctx.projectRoot,
91
92
  { cacheDir: ctx.cacheDir, refresh: ctx.refresh }
92
93
  );
93
- const { operations, tags } = extractOperations(document, reference.route);
94
+ const {
95
+ operations,
96
+ tags,
97
+ warnings: extractWarnings,
98
+ } = extractOperations(document, reference.route);
94
99
  const info = document.info ?? { title: reference.label, version: "" };
95
100
  const spec: ApiSpecData = {
96
101
  codeSamples: reference.display.codeSamples,
@@ -98,8 +103,18 @@ export const openApiSource = (
98
103
  document,
99
104
  expandSchemas: reference.display.expandSchemas,
100
105
  label: reference.label,
106
+ // Operation pages flow through the content pipeline, which mounts them
107
+ // under the site-wide `basePath` (staged entry refs below stay
108
+ // base-less); serialize the served route so components link to the
109
+ // pages' real URLs.
101
110
  operations: Object.fromEntries(
102
- operations.map((operation) => [operation.key, operation])
111
+ operations.map((operation) => [
112
+ operation.key,
113
+ {
114
+ ...operation,
115
+ route: withBasePath(reference.basePath, operation.route),
116
+ },
117
+ ])
103
118
  ),
104
119
  route: reference.route,
105
120
  slug: reference.slug,
@@ -108,11 +123,32 @@ export const openApiSource = (
108
123
  version: info.version ?? "",
109
124
  };
110
125
  return {
111
- diagnostics: warnings.map((message) => ({
112
- code: "BLUME_OPENAPI_STALE",
113
- message,
114
- severity: "warning" as const,
115
- })),
126
+ diagnostics: [
127
+ ...warnings.map((message) => ({
128
+ code: "BLUME_OPENAPI_STALE",
129
+ message,
130
+ severity: "warning" as const,
131
+ })),
132
+ ...extractWarnings.map((message) => ({
133
+ code: "BLUME_OPENAPI_REF_PATH_ITEM",
134
+ message: `In OpenAPI spec "${reference.spec}": ${message}`,
135
+ severity: "warning" as const,
136
+ })),
137
+ // A document with no operations (say, a config file that happens to
138
+ // parse as YAML) would otherwise build a nav tab onto an empty
139
+ // reference with no hint why.
140
+ ...(operations.length === 0
141
+ ? [
142
+ {
143
+ code: "BLUME_OPENAPI_EMPTY",
144
+ message: `OpenAPI spec "${reference.spec}" for ${reference.route} declares no operations; its API reference is empty.`,
145
+ severity: "warning" as const,
146
+ suggestion:
147
+ "Check the spec points at an OpenAPI document with operations under `paths`.",
148
+ },
149
+ ]
150
+ : []),
151
+ ],
116
152
  entries: specEntries(spec, operations),
117
153
  slug: reference.slug,
118
154
  spec,
@@ -125,8 +161,12 @@ export const openApiSource = (
125
161
  // so fail loudly in build (blocks under --strict) while staying a warning
126
162
  // in dev so offline work still runs.
127
163
  severity: ctx.mode === "build" ? "error" : "warning",
164
+ // A readable-but-invalid file is a content problem, not a network one;
165
+ // only point at reachability for actual fetch/read failures.
128
166
  suggestion:
129
- "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
167
+ error instanceof InvalidSpecError
168
+ ? "Point the spec at an OpenAPI document (a YAML or JSON file with an object at the top level)."
169
+ : "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
130
170
  };
131
171
  }
132
172
  };
@@ -134,7 +174,16 @@ export const openApiSource = (
134
174
  const load = async (): Promise<SourceLoadResult> => {
135
175
  const results = await Promise.all(references.map(loadReference));
136
176
  const entries: SourceEntry[] = [];
137
- const diagnostics: Diagnostic[] = [];
177
+ // Route collisions recorded while deduping (see `blumeReferences`): a
178
+ // dropped source loses a whole spec's pages, so warn even when the kept
179
+ // spec loads cleanly.
180
+ const diagnostics: Diagnostic[] = references.flatMap((reference) =>
181
+ (reference.collisions ?? []).map((message) => ({
182
+ code: "BLUME_OPENAPI_ROUTE_COLLISION",
183
+ message,
184
+ severity: "warning" as const,
185
+ }))
186
+ );
138
187
  const data: OpenApiData = {};
139
188
  for (const result of results) {
140
189
  if ("severity" in result) {