blume 0.7.0 → 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 (168) 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 +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  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 +9 -0
  9. package/dist/types/core/config-input.d.ts +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
@@ -17,12 +17,15 @@ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
17
17
  *
18
18
  * `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
19
19
  * subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
20
- * project root survives the relocation. `netlify` owns its whole `.netlify`
21
- * dir. `node` and `cloudflare` emit into `dist/` (already at the project root),
22
- * so they are absent here and need no relocation.
20
+ * project root survives the relocation. `netlify` writes a Frameworks API tree
21
+ * at `.netlify/v1` (its `.netlify/build` sibling is only the intermediate SSR
22
+ * bundle, already traced into `v1/functions`); only `v1` is moved, so the
23
+ * `.netlify/state.json` written by `netlify link` survives too. `node` and
24
+ * `cloudflare` emit into `dist/` (already at the project root), so they are
25
+ * absent here and need no relocation.
23
26
  */
24
27
  export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
25
- netlify: ".netlify",
28
+ netlify: ".netlify/v1",
26
29
  vercel: ".vercel/output",
27
30
  };
28
31
 
@@ -30,7 +33,10 @@ export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
30
33
  * Directory whose contents the deploy platform serves as static files. Build
31
34
  * artifacts (robots.txt, sitemap.xml, llms.txt, …) must be written here to be
32
35
  * served. For a Vercel server build that is the adapter's
33
- * `.vercel/output/static`; every other build serves `dist/`.
36
+ * `.vercel/output/static`; for a Node server build it is Astro's
37
+ * `build.client` dir (`dist/client/`), the only directory the standalone
38
+ * server's static handler reads from. Netlify publishes `dist/` itself and
39
+ * Cloudflare serves the `outDir` root, so every other build serves `dist/`.
34
40
  */
35
41
  export const deployStaticDir = (
36
42
  config: ResolvedConfig,
@@ -40,7 +46,11 @@ export const deployStaticDir = (
40
46
  if (output === "server" && adapter === "vercel") {
41
47
  return join(context.root, ".vercel", "output", "static");
42
48
  }
43
- return context.distDir ?? join(context.root, "dist");
49
+ const dist = context.distDir ?? join(context.root, "dist");
50
+ if (output === "server" && adapter === "node") {
51
+ return join(dist, "client");
52
+ }
53
+ return dist;
44
54
  };
45
55
 
46
56
  /** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
@@ -76,7 +86,7 @@ export const surfaceAdapterOutput = async (
76
86
  await cp(from, to, { recursive: true });
77
87
  await rm(from, { force: true, recursive: true });
78
88
  // The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
79
- // never the moved sub-path — Vercel's own `.vercel/project.json` lives there
80
- // too and must also be ignored.
89
+ // never the moved sub-path — the platform's own state (`.vercel/project.json`,
90
+ // `.netlify/state.json`) lives there too and must also be ignored.
81
91
  return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
82
92
  };
@@ -34,14 +34,19 @@ export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
34
34
  .map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
35
35
  .join("\n")}\n`;
36
36
 
37
- /** `vercel.json` contents with a `redirects` array (permanent = 301/308). */
37
+ /**
38
+ * `vercel.json` contents with a `redirects` array. Uses `statusCode` (Vercel's
39
+ * alternative to the boolean `permanent`) so the configured code ships exactly:
40
+ * `permanent` would silently coerce a 301 to 308 and a 302 to 307, diverging
41
+ * from the `_redirects` file, which preserves exact codes.
42
+ */
38
43
  export const buildVercelConfig = (redirects: Redirect[]): string =>
39
44
  `${JSON.stringify(
40
45
  {
41
46
  redirects: redirects.map((redirect) => ({
42
47
  destination: redirect.to,
43
- permanent: redirect.status === 301 || redirect.status === 308,
44
48
  source: redirect.from,
49
+ statusCode: redirect.status,
45
50
  })),
46
51
  },
47
52
  null,
@@ -1,7 +1,21 @@
1
+ import {
2
+ customStaticRoutes,
3
+ discoverPagesSync,
4
+ hasGeneratedChangelog,
5
+ } from "../astro/pages.ts";
1
6
  import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
2
7
  import type { BlumeProject } from "../core/project-graph.ts";
3
8
  import { escapeXml } from "./xml.ts";
4
9
 
10
+ /**
11
+ * Astro's reserved error routes. A user-authored override (`pages/404.astro`,
12
+ * `pages/500.astro`, or a `404.md` content page — see `writeNotFoundPage` in
13
+ * `astro/generate.ts`) is neither dynamic nor private, so it would otherwise
14
+ * be emitted — but error pages aren't crawlable destinations and must stay out
15
+ * of the sitemap.
16
+ */
17
+ const ERROR_ROUTES = new Set(["/404", "/500"]);
18
+
5
19
  /** A `<lastmod>` element (W3C date) when the page has a valid modified date. */
6
20
  const lastmodTag = (value: string | undefined): string => {
7
21
  if (!value) {
@@ -14,9 +28,11 @@ const lastmodTag = (value: string | undefined): string => {
14
28
  };
15
29
 
16
30
  /**
17
- * Build a sitemap.xml from the route manifest. Returns null when the sitemap is
18
- * disabled or no `site` is configured (absolute URLs are required for a valid
19
- * sitemap). Drafts, hidden, and `noindex` pages are excluded.
31
+ * Build a sitemap.xml from the route manifest plus the routes the manifest
32
+ * can't see: custom `.astro` pages (most importantly a custom landing `/`) and
33
+ * the generated `/changelog` index. Returns null when the sitemap is disabled
34
+ * or no `site` is configured (absolute URLs are required for a valid sitemap).
35
+ * Drafts, hidden, and `noindex` pages are excluded.
20
36
  */
21
37
  export const buildSitemap = (project: BlumeProject): string | null => {
22
38
  const { site } = project.config.deployment;
@@ -27,19 +43,45 @@ export const buildSitemap = (project: BlumeProject): string | null => {
27
43
  const base = site.replace(/\/$/u, "");
28
44
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
29
45
  const deployBase = normalizeBasePath(project.config.deployment.base);
46
+ const seen = new Set<string>();
30
47
  const urls: string[] = [];
31
- for (const page of project.graph.pages) {
32
- if (page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex) {
33
- continue;
34
- }
48
+ const pushUrl = (route: string, lastModified?: string): void => {
35
49
  // `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
36
50
  // then escape XML metacharacters (notably `&`) so a route like
37
51
  // `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
38
52
  // rejected.
39
- const loc = escapeXml(
40
- encodeURI(`${base}${withBasePath(deployBase, page.route)}`)
41
- );
42
- urls.push(` <url><loc>${loc}</loc>${lastmodTag(page.lastModified)}</url>`);
53
+ const loc = escapeXml(encodeURI(`${base}${route}`));
54
+ if (seen.has(loc)) {
55
+ return;
56
+ }
57
+ seen.add(loc);
58
+ urls.push(` <url><loc>${loc}</loc>${lastmodTag(lastModified)}</url>`);
59
+ };
60
+ for (const page of project.graph.pages) {
61
+ if (
62
+ page.meta.draft ||
63
+ page.meta.sidebar.hidden ||
64
+ page.meta.seo.noindex ||
65
+ ERROR_ROUTES.has(page.route)
66
+ ) {
67
+ continue;
68
+ }
69
+ pushUrl(withBasePath(deployBase, page.route), page.lastModified);
70
+ }
71
+ // Custom `.astro` pages and the generated changelog index mount outside
72
+ // `basePath` (they're injected at their pattern — see `blumeIntegration`), so
73
+ // only the deployment base layers onto their URLs.
74
+ const userPages = project.context.pagesRoot
75
+ ? discoverPagesSync(project.context.pagesRoot)
76
+ : [];
77
+ const extraRoutes = customStaticRoutes(userPages).filter(
78
+ (route) => !ERROR_ROUTES.has(route)
79
+ );
80
+ if (hasGeneratedChangelog(project, userPages)) {
81
+ extraRoutes.push("/changelog");
82
+ }
83
+ for (const route of extraRoutes) {
84
+ pushUrl(withBasePath(deployBase, route));
43
85
  }
44
86
  urls.sort();
45
87
 
package/src/index.ts CHANGED
@@ -1,3 +1,8 @@
1
+ export type {
2
+ ComponentMarkdown,
3
+ ComponentMarkdownChild,
4
+ ComponentMarkdownContext,
5
+ } from "./ai/component-markdown.ts";
1
6
  export { defineConfig } from "./core/config.ts";
2
7
  export type {
3
8
  BlumeBanner,
@@ -1,4 +1,4 @@
1
- import { isInternalPath, withBasePath } from "../core/base-path.ts";
1
+ import { isInternalPath, withComposedBasePath } from "../core/base-path.ts";
2
2
  import type { MdastNode } from "./mdast.ts";
3
3
 
4
4
  interface UrlNode extends MdastNode {
@@ -27,13 +27,15 @@ const ASSET_PATH = /\.[a-z0-9]+$/iu;
27
27
  const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
28
28
 
29
29
  /**
30
- * Satteri MDAST plugin that prepends the site-wide `basePath` to root-relative
31
- * internal page links (`[x](/guide)` -> `/docs/guide`), so authors write links
32
- * as if mounted at root. Idempotent (via `withBasePath`) and inert for external
33
- * URLs, fragments, relative paths, images, and asset links. Only constructed
34
- * when a base is set (see `markdown/index.ts`).
30
+ * Satteri MDAST plugin that prepends the served-URL base — `deployment.base`
31
+ * layered over the site-wide `basePath` to root-relative internal page links
32
+ * (`[x](/guide)` -> `/base/docs/guide`), so authors write links as if mounted
33
+ * at root. Idempotent per layer (via `withComposedBasePath`, so a hand-written
34
+ * `/docs/x` isn't double-prefixed) and inert for external URLs, fragments,
35
+ * relative paths, images, and asset links. Only constructed when a base is set
36
+ * (see `markdown/index.ts`).
35
37
  */
36
- export const baseLinksPlugin = (basePath: string) => {
38
+ export const baseLinksPlugin = (deployBase: string, basePath: string) => {
37
39
  const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
38
40
  const { url } = node;
39
41
  if (
@@ -41,7 +43,7 @@ export const baseLinksPlugin = (basePath: string) => {
41
43
  isInternalPath(url) &&
42
44
  !ASSET_PATH.test(pathOf(url))
43
45
  ) {
44
- const next = withBasePath(basePath, url);
46
+ const next = withComposedBasePath(deployBase, basePath, url);
45
47
  if (next !== url) {
46
48
  ctx.setProperty(node, "url", next);
47
49
  }
@@ -217,17 +217,29 @@ export interface BlumeMarkdownOptions {
217
217
  * links as if mounted at root.
218
218
  */
219
219
  basePath?: string;
220
+ /**
221
+ * Astro's `deployment.base` subdirectory (`""` or `/seg`), layered on top of
222
+ * `basePath` when links are rewritten. Kept separate so a hand-written
223
+ * `basePath` link isn't double-prefixed (see `withComposedBasePath`).
224
+ */
225
+ deployBase?: string;
220
226
  }
221
227
 
222
228
  /**
223
229
  * MDAST plugins that apply to both `.md` and `.mdx`. Currently just the
224
- * base-path link rewrite, added only when a `basePath` is configured.
230
+ * base-path link rewrite, added only when a `basePath` or `deployBase` is
231
+ * configured.
225
232
  */
226
233
  const blumeSharedMdastPlugins = (
227
234
  options: BlumeMarkdownOptions
228
235
  ): MdastPlugin[] =>
229
- options.basePath
230
- ? [baseLinksPlugin(options.basePath) as unknown as MdastPlugin]
236
+ options.basePath || options.deployBase
237
+ ? [
238
+ baseLinksPlugin(
239
+ options.deployBase ?? "",
240
+ options.basePath ?? ""
241
+ ) as unknown as MdastPlugin,
242
+ ]
231
243
  : [];
232
244
 
233
245
  /** Sätteri processor for plain `.md`, with Blume's curated feature set. */
@@ -104,8 +104,13 @@ export const inlineCodeHighlightPlugin = (
104
104
  type: "element",
105
105
  };
106
106
  } catch {
107
- // Unknown language or load failure: leave the code unhighlighted
108
- // (falling through returns undefined, so the node is left as-is).
107
+ // Unknown language or load failure: still strip the marker — the
108
+ // literal `{:lang}` must not ship in the page and fall back to
109
+ // plain, unhighlighted inline code.
110
+ return {
111
+ ...node,
112
+ children: [{ type: "text", value: parsed.code }],
113
+ };
109
114
  }
110
115
  },
111
116
  },
@@ -149,10 +149,16 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
149
149
  return `${manager} dlx ${args}`;
150
150
  }
151
151
  case "ci": {
152
- // `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
153
- return manager === "npm"
154
- ? "npm ci"
155
- : `${manager} install --frozen-lockfile`;
152
+ // `npm ci` maps to a frozen, lockfile-faithful install elsewhere. Yarn
153
+ // Berry's flag is `--immutable` (`--frozen-lockfile` was removed in
154
+ // Yarn 4), matching the Berry-only `yarn dlx` the `exec` case emits.
155
+ if (manager === "npm") {
156
+ return "npm ci";
157
+ }
158
+ if (manager === "yarn") {
159
+ return "yarn install --immutable";
160
+ }
161
+ return `${manager} install --frozen-lockfile`;
156
162
  }
157
163
  case "remove": {
158
164
  if (manager === "npm") {
@@ -104,12 +104,13 @@ const isOperation = (value: unknown): value is OperationObject =>
104
104
  /**
105
105
  * Flatten a 3.1 document into a route-mapped operation list and its ordered
106
106
  * tags. Operations inherit the first tag they declare; keys are de-duplicated so
107
- * a repeated `operationId` still yields distinct routes.
107
+ * a repeated `operationId` still yields distinct routes. `warnings` reports
108
+ * anything skipped (a `$ref` path item), so missing operations aren't silent.
108
109
  */
109
110
  export const extractOperations = (
110
111
  document: ApiDocument,
111
112
  baseRoute: string
112
- ): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
113
+ ): { operations: ApiOperationRef[]; tags: ApiTagRef[]; warnings: string[] } => {
113
114
  const operations: ApiOperationRef[] = [];
114
115
  const tagOrder: string[] = [];
115
116
  const tagsSeen = new Set<string>();
@@ -117,10 +118,17 @@ export const extractOperations = (
117
118
  (document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
118
119
  );
119
120
  const seen = new Set<string>();
121
+ const warnings: string[] = [];
120
122
 
121
123
  for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
122
124
  const item = rawItem as PathItemObject | undefined;
123
- if (!item || "$ref" in item) {
125
+ if (!item) {
126
+ continue;
127
+ }
128
+ if ("$ref" in item) {
129
+ warnings.push(
130
+ `Path "${path}" is a $ref to a shared path item; referenced path items are not resolved, so its operations are missing from the reference. Inline the path item under "paths" to render it.`
131
+ );
124
132
  continue;
125
133
  }
126
134
  for (const method of HTTP_METHODS) {
@@ -161,7 +169,7 @@ export const extractOperations = (
161
169
  slug: slugify(name) || "operations",
162
170
  }));
163
171
 
164
- return { operations, tags };
172
+ return { operations, tags, warnings };
165
173
  };
166
174
 
167
175
  /** Resolve the operation object for a ref out of its document. */
@@ -47,6 +47,19 @@ export interface ParsedSpec {
47
47
  warnings: string[];
48
48
  }
49
49
 
50
+ /**
51
+ * The spec was read successfully but its contents aren't an OpenAPI document
52
+ * (an empty file, or YAML that parses to a scalar or list — say, a README
53
+ * pointed at by mistake). Kept distinct from read/fetch failures so callers
54
+ * can suggest fixing the file instead of checking reachability.
55
+ */
56
+ export class InvalidSpecError extends Error {
57
+ constructor(message: string) {
58
+ super(message);
59
+ this.name = "InvalidSpecError";
60
+ }
61
+ }
62
+
50
63
  /** Where and whether to cache a remote spec's text between runs. */
51
64
  export interface SpecFetchOptions {
52
65
  /** Dir for a last-good on-disk copy of a remote spec (offline fallback). */
@@ -235,5 +248,13 @@ export const parseSpec = async (
235
248
  const { text, warnings } = await readSpecText(spec, root, options);
236
249
  const normalized = normalize(text);
237
250
  const { specification } = upgrade(normalized);
251
+ // `normalize` yields undefined for anything that isn't a YAML/JSON mapping
252
+ // (empty file, scalar, list) and `upgrade(undefined)` yields a null
253
+ // specification — reject it here so the renderer never sees a non-document.
254
+ if (specification === null || typeof specification !== "object") {
255
+ throw new InvalidSpecError(
256
+ `${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
257
+ );
258
+ }
238
259
  return { document: specification as ApiDocument, warnings };
239
260
  };
@@ -31,6 +31,12 @@ export interface ReferenceSource {
31
31
  slug: string;
32
32
  /** Normalized route the reference mounts at, e.g. `/reference`. */
33
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;
34
40
  label: string;
35
41
  /** Local path or `http(s)` URL, verbatim from config. */
36
42
  spec: string;
@@ -38,6 +44,11 @@ export interface ReferenceSource {
38
44
  theme?: string;
39
45
  /** Display options carried through to the Blume renderer. */
40
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[];
41
52
  }
42
53
 
43
54
  const NON_SLUG = /[^a-z0-9]+/gu;
@@ -78,7 +89,8 @@ const referencesFor = (
78
89
  block: Block,
79
90
  defaultLabel: string,
80
91
  renderer: ReferenceRenderer,
81
- display: ReferenceDisplay
92
+ display: ReferenceDisplay,
93
+ basePath: string
82
94
  ): ReferenceSource[] => {
83
95
  if (!block.enabled) {
84
96
  return [];
@@ -102,6 +114,7 @@ const referencesFor = (
102
114
  }
103
115
 
104
116
  return {
117
+ basePath,
105
118
  display,
106
119
  kind,
107
120
  label,
@@ -131,9 +144,17 @@ export const resolveReferences = (
131
144
  {
132
145
  codeSamples: config.openapi.codeSamples,
133
146
  expandSchemas: config.openapi.expandSchemas,
134
- }
147
+ },
148
+ config.basePath
149
+ ),
150
+ ...referencesFor(
151
+ "asyncapi",
152
+ config.asyncapi,
153
+ "Events",
154
+ "scalar",
155
+ NO_DISPLAY,
156
+ config.basePath
135
157
  ),
136
- ...referencesFor("asyncapi", config.asyncapi, "Events", "scalar", NO_DISPLAY),
137
158
  ];
138
159
 
139
160
  /** Nav tabs (header links) for every reference, regardless of renderer. */
@@ -152,19 +173,24 @@ export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
152
173
  /**
153
174
  * Accept one resolved reference into the deduped Blume-rendered set, or return
154
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.
155
178
  */
156
179
  const blumeReferenceOf = (
157
180
  ref: ReferenceSource,
158
- seen: Set<string>,
181
+ seen: Map<string, ReferenceSource>,
159
182
  usedSlugs: Set<string>
160
183
  ): ReferenceSource | null => {
161
184
  if (ref.kind !== "openapi" || ref.renderer !== "blume") {
162
185
  return null;
163
186
  }
164
- if (seen.has(ref.route)) {
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
+ );
165
192
  return null;
166
193
  }
167
- seen.add(ref.route);
168
194
  // Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
169
195
  // yield `api-v1`). The slug keys the `blume:openapi` data module, so a
170
196
  // collision would let one spec silently overwrite the other while the
@@ -176,12 +202,16 @@ const blumeReferenceOf = (
176
202
  n += 1;
177
203
  }
178
204
  usedSlugs.add(slug);
179
- return slug === ref.slug ? ref : { ...ref, 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;
180
210
  };
181
211
 
182
212
  /** Blume-rendered OpenAPI references, deduped by route (first wins). */
183
213
  export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
184
- const seen = new Set<string>();
214
+ const seen = new Map<string, ReferenceSource>();
185
215
  const usedSlugs = new Set<string>();
186
216
  const result: ReferenceSource[] = [];
187
217
  for (const ref of resolveReferences(config)) {
@@ -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) {