blume 0.6.7 → 0.7.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 (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. package/src/theme/entry.ts +85 -20
package/src/deploy/rss.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
1
2
  import type { BlumeProject } from "../core/project-graph.ts";
2
3
  import type { PageRecord } from "../core/types.ts";
3
4
  import { escapeXml } from "./xml.ts";
@@ -52,6 +53,11 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
52
53
  return [];
53
54
  }
54
55
  const base = site.replace(/\/$/u, "");
56
+ // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
57
+ // The feed's own `link`/self URL points at the docs root under that base, while
58
+ // `path` stays base-less (it's also the on-disk output location).
59
+ const deployBase = normalizeBasePath(config.deployment.base);
60
+ const rootLink = `${base}${deployBase}`;
55
61
 
56
62
  const feeds: RssFeed[] = [];
57
63
  for (const type of rss.types) {
@@ -70,7 +76,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
70
76
  description: page.description,
71
77
  // Encode like the sitemap does: a route with spaces or non-ASCII
72
78
  // must still yield a valid <link>/<guid> URL after XML decoding.
73
- link: encodeURI(`${base}${page.route}`),
79
+ link: encodeURI(`${base}${withBasePath(deployBase, page.route)}`),
74
80
  title: page.title,
75
81
  }))
76
82
  .toSorted((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0))
@@ -79,7 +85,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
79
85
  feeds.push({
80
86
  description: config.description,
81
87
  items,
82
- link: base,
88
+ link: rootLink,
83
89
  path: `/${type}/rss.xml`,
84
90
  title: `${config.title} — ${capitalize(type)}`,
85
91
  type,
@@ -105,11 +111,12 @@ const renderItem = (item: RssItem): string => {
105
111
 
106
112
  /** Serialize a resolved feed into an RSS 2.0 XML document. */
107
113
  export const renderRssFeed = (feed: RssFeed): string => {
114
+ const feedSelfHref = `${feed.link}${feed.path}`;
108
115
  const channel = [
109
116
  ` <title>${escapeXml(feed.title)}</title>`,
110
117
  ` <link>${escapeXml(feed.link)}</link>`,
111
118
  ` <description>${escapeXml(feed.description ?? feed.title)}</description>`,
112
- ` <atom:link href="${escapeXml(`${feed.link}${feed.path}`)}" rel="self" type="application/rss+xml" />`,
119
+ ` <atom:link href="${escapeXml(feedSelfHref)}" rel="self" type="application/rss+xml" />`,
113
120
  ];
114
121
  const items = feed.items.map(renderItem).join("\n");
115
122
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -1,3 +1,4 @@
1
+ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
1
2
  import type { BlumeProject } from "../core/project-graph.ts";
2
3
  import { escapeXml } from "./xml.ts";
3
4
 
@@ -24,20 +25,23 @@ export const buildSitemap = (project: BlumeProject): string | null => {
24
25
  }
25
26
 
26
27
  const base = site.replace(/\/$/u, "");
27
- const urls = project.graph.pages
28
- .filter(
29
- (page) =>
30
- !(page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex)
31
- )
28
+ // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
29
+ const deployBase = normalizeBasePath(project.config.deployment.base);
30
+ 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
+ }
32
35
  // `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
33
36
  // then escape XML metacharacters (notably `&`) so a route like
34
37
  // `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
35
38
  // rejected.
36
- .map(
37
- (page) =>
38
- ` <url><loc>${escapeXml(encodeURI(`${base}${page.route}`))}</loc>${lastmodTag(page.lastModified)}</url>`
39
- )
40
- .toSorted();
39
+ const loc = escapeXml(
40
+ encodeURI(`${base}${withBasePath(deployBase, page.route)}`)
41
+ );
42
+ urls.push(` <url><loc>${loc}</loc>${lastmodTag(page.lastModified)}</url>`);
43
+ }
44
+ urls.sort();
41
45
 
42
46
  return `<?xml version="1.0" encoding="UTF-8"?>
43
47
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@@ -0,0 +1,58 @@
1
+ import { isInternalPath, withBasePath } from "../core/base-path.ts";
2
+ import type { MdastNode } from "./mdast.ts";
3
+
4
+ interface UrlNode extends MdastNode {
5
+ url?: string | null;
6
+ }
7
+
8
+ /**
9
+ * The slice of Satteri's MDAST visitor context this plugin needs. Nodes are
10
+ * read-only (the tree compiles to an op-stream), so a URL edit is recorded via
11
+ * `setProperty`, not by mutating the node object.
12
+ */
13
+ interface MdastUrlContext {
14
+ setProperty: (node: unknown, key: "url", value: string) => void;
15
+ }
16
+
17
+ /**
18
+ * A path whose final segment carries a file extension (`/spec.pdf`, `/logo.svg`)
19
+ * — treated as a public asset, which Blume serves from `public/` at the site
20
+ * root and does *not* move under `basePath`. Bare page links (`/guide`) have no
21
+ * extension. The rare dotted route (`/releases/v1.0`) is left un-based here; the
22
+ * build-time link checker still resolves it against the route set.
23
+ */
24
+ const ASSET_PATH = /\.[a-z0-9]+$/iu;
25
+
26
+ /** Strip any `#fragment`/`?query` so only the path is extension-tested. */
27
+ const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
28
+
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`).
35
+ */
36
+ export const baseLinksPlugin = (basePath: string) => {
37
+ const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
38
+ const { url } = node;
39
+ if (
40
+ typeof url === "string" &&
41
+ isInternalPath(url) &&
42
+ !ASSET_PATH.test(pathOf(url))
43
+ ) {
44
+ const next = withBasePath(basePath, url);
45
+ if (next !== url) {
46
+ ctx.setProperty(node, "url", next);
47
+ }
48
+ }
49
+ };
50
+ // `link` covers inline links; `definition` covers reference-style link
51
+ // definitions (`[x]: /guide`). `image` is intentionally excluded — images are
52
+ // public assets served at the site root, unaffected by `basePath`.
53
+ return {
54
+ definition: rebase,
55
+ link: rebase,
56
+ name: "blume-base-links",
57
+ };
58
+ };
@@ -37,6 +37,16 @@ const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
37
37
  const withoutQuotedAttrs = (raw: string): string =>
38
38
  raw.replace(QUOTED_ATTR, " ");
39
39
 
40
+ // The first bare token is the title (```ts blume.config.ts): a non-empty token
41
+ // that isn't a Shiki line range (`{1,3-5}`), a `key=value` attr, or a reserved
42
+ // `lineNumbers`/`twoslash` keyword.
43
+ const isTitleToken = (token: string): boolean => {
44
+ if (token.length === 0 || token.startsWith("{") || token.includes("=")) {
45
+ return false;
46
+ }
47
+ return token !== "lineNumbers" && token !== "twoslash";
48
+ };
49
+
40
50
  const parseTitle = (raw: string | undefined): string | undefined => {
41
51
  if (!raw) {
42
52
  return undefined;
@@ -46,20 +56,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
46
56
  if (attrTitle) {
47
57
  return attrTitle;
48
58
  }
49
- // The first bare token is the title (```ts blume.config.ts), skipping Shiki
50
- // line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
51
- // and `twoslash` keywords.
52
- return withoutQuotedAttrs(raw)
53
- .trim()
54
- .split(/\s+/u)
55
- .find(
56
- (token) =>
57
- token.length > 0 &&
58
- token !== "lineNumbers" &&
59
- token !== "twoslash" &&
60
- !token.startsWith("{") &&
61
- !token.includes("=")
62
- );
59
+ return withoutQuotedAttrs(raw).trim().split(/\s+/u).find(isTitleToken);
63
60
  };
64
61
 
65
62
  const hasLineNumbers = (raw: string | undefined): boolean =>
@@ -8,6 +8,7 @@ import {
8
8
  } from "@shikijs/transformers";
9
9
  import { codeToHtml } from "shiki";
10
10
 
11
+ import { baseLinksPlugin } from "./base-links.ts";
11
12
  import { codeTitleTransformer } from "./code-title.ts";
12
13
  import { directiveToCalloutPlugin } from "./directives.ts";
13
14
  import { headingAnchorPlugin } from "./heading-anchors.ts";
@@ -16,6 +17,8 @@ import { languageIconTransformer } from "./language-icon.ts";
16
17
  import { mathPlugin } from "./math.ts";
17
18
  import { mermaidPlugin } from "./mermaid.ts";
18
19
  import { packageInstallPlugin } from "./package-install.ts";
20
+ import { DEFAULT_CODE_THEMES } from "./themes.ts";
21
+ import type { CodeThemes } from "./themes.ts";
19
22
 
20
23
  /** A Shiki transformer, derived from the upstream factories' return type. */
21
24
  type ShikiTransformer = ReturnType<typeof transformerNotationDiff>;
@@ -54,7 +57,7 @@ type HastPlugin = NonNullable<
54
57
  */
55
58
  const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
56
59
  const plugins: HastPlugin[] = [
57
- inlineCodeHighlightPlugin() as unknown as HastPlugin,
60
+ inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
58
61
  ];
59
62
  if (options.headingAnchors !== false) {
60
63
  plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
@@ -99,13 +102,6 @@ export const blumeShikiTransformers = (
99
102
  return transformers;
100
103
  };
101
104
 
102
- /**
103
- * The light/dark Shiki themes Blume highlights with. Kept in lockstep with the
104
- * generated Astro config's `shikiConfig.themes` so code highlighted outside the
105
- * Markdown pipeline (via {@link highlightCode}) matches fenced code exactly.
106
- */
107
- const CODE_THEMES = { dark: "github-dark", light: "github-light" } as const;
108
-
109
105
  const escapeHtml = (value: string): string =>
110
106
  value
111
107
  .replaceAll("&", "&amp;")
@@ -147,6 +143,11 @@ const languageAttrTransformer = (lang: string): ShikiTransformer =>
147
143
  export interface HighlightCodeOptions extends BlumeShikiOptions {
148
144
  /** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
149
145
  className?: string;
146
+ /**
147
+ * Light/dark Shiki themes (`markdown.codeBlocks.theme`). Defaults to the same
148
+ * github pair fenced code uses, so out-of-pipeline code stays in lockstep.
149
+ */
150
+ themes?: CodeThemes;
150
151
  /** Header title (a filename), matching a fence's `title="..."` meta. */
151
152
  title?: string;
152
153
  }
@@ -176,7 +177,7 @@ export const highlightCode = async (
176
177
  meta: options.title
177
178
  ? { __raw: `title="${options.title.replaceAll('"', "")}"` }
178
179
  : undefined,
179
- themes: CODE_THEMES,
180
+ themes: options.themes ?? DEFAULT_CODE_THEMES,
180
181
  transformers: [
181
182
  ...blumeShikiTransformers({ icons: options.icons }),
182
183
  astroCodeClassTransformer(options.className),
@@ -200,18 +201,41 @@ const FEATURES = { subscript: true, superscript: true };
200
201
 
201
202
  /** Options shared by both processors. */
202
203
  export interface BlumeMarkdownOptions {
204
+ /**
205
+ * Light/dark Shiki themes for inline `` `code`{:lang} `` highlighting
206
+ * (`markdown.codeBlocks.theme`). Defaults to the github pair fenced code uses.
207
+ */
208
+ codeThemes?: CodeThemes;
203
209
  /**
204
210
  * Wrap `<h2>`–`<h6>` in self-linking anchors (`markdown.headingAnchors`).
205
211
  * On unless explicitly `false`.
206
212
  */
207
213
  headingAnchors?: boolean;
214
+ /**
215
+ * Site-wide route mount point (`""` or `/seg`). When set, root-relative
216
+ * internal page links in content are rewritten under it, so authors write
217
+ * links as if mounted at root.
218
+ */
219
+ basePath?: string;
208
220
  }
209
221
 
222
+ /**
223
+ * MDAST plugins that apply to both `.md` and `.mdx`. Currently just the
224
+ * base-path link rewrite, added only when a `basePath` is configured.
225
+ */
226
+ const blumeSharedMdastPlugins = (
227
+ options: BlumeMarkdownOptions
228
+ ): MdastPlugin[] =>
229
+ options.basePath
230
+ ? [baseLinksPlugin(options.basePath) as unknown as MdastPlugin]
231
+ : [];
232
+
210
233
  /** Sätteri processor for plain `.md`, with Blume's curated feature set. */
211
234
  export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
212
235
  satteri({
213
236
  features: { ...FEATURES },
214
237
  hastPlugins: blumeHastPlugins(options),
238
+ mdastPlugins: blumeSharedMdastPlugins(options),
215
239
  });
216
240
 
217
241
  export type BlumeMdxOptions = BlumeMarkdownOptions;
@@ -246,5 +270,6 @@ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
246
270
  directiveToCalloutPlugin(),
247
271
  mermaidPlugin(),
248
272
  mathPlugin(),
273
+ ...blumeSharedMdastPlugins(options),
249
274
  ] as unknown as MdastPlugin[],
250
275
  });
@@ -12,6 +12,9 @@
12
12
  * added to the pipeline at all).
13
13
  */
14
14
 
15
+ import { DEFAULT_CODE_THEMES } from "./themes.ts";
16
+ import type { CodeThemes } from "./themes.ts";
17
+
15
18
  /** A minimal hast node (avoids a hast type dependency). */
16
19
  interface HastNode {
17
20
  children?: HastNode[];
@@ -72,7 +75,9 @@ const loadHighlighter = async (): Promise<InlineHighlighter> => {
72
75
  };
73
76
 
74
77
  /** Build the plugin. Highlights inline `` `code{:lang}` `` snippets. */
75
- export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
78
+ export const inlineCodeHighlightPlugin = (
79
+ themes: CodeThemes = DEFAULT_CODE_THEMES
80
+ ): InlineCodePlugin => ({
76
81
  element: {
77
82
  filter: ["code"],
78
83
  async visit(node, ctx) {
@@ -90,7 +95,7 @@ export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
90
95
  defaultColor: false,
91
96
  lang: parsed.lang,
92
97
  structure: "inline",
93
- themes: { dark: "github-dark", light: "github-light" },
98
+ themes,
94
99
  });
95
100
  return {
96
101
  children: root.children,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The light/dark Shiki themes Blume highlights code with. Every Shiki surface —
3
+ * fenced code (the generated Astro `shikiConfig.themes`), inline `` `code`{:lang} ``,
4
+ * out-of-pipeline `highlightCode`, and `<Diff>` — resolves to the same pair so a
5
+ * project's `markdown.codeBlocks.theme` shifts them all in lockstep. This is the
6
+ * single home for the github fallback used when nothing is configured.
7
+ */
8
+
9
+ /**
10
+ * A light/dark Shiki theme pair (`markdown.codeBlocks.theme`). A `type` (not an
11
+ * `interface`) so it keeps the implicit index signature Shiki's `themes`
12
+ * parameter (`Partial<Record<string, …>>`) expects.
13
+ */
14
+ // oxlint-disable-next-line typescript/consistent-type-definitions -- interface loses the implicit index signature Shiki's `themes` param needs
15
+ export type CodeThemes = {
16
+ dark: string;
17
+ light: string;
18
+ };
19
+
20
+ /** The default pair, used when `markdown.codeBlocks.theme` is unset. */
21
+ export const DEFAULT_CODE_THEMES: CodeThemes = {
22
+ dark: "github-dark",
23
+ light: "github-light",
24
+ };
@@ -112,6 +112,7 @@ export const extractOperations = (
112
112
  ): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
113
113
  const operations: ApiOperationRef[] = [];
114
114
  const tagOrder: string[] = [];
115
+ const tagsSeen = new Set<string>();
115
116
  const tagMeta = new Map(
116
117
  (document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
117
118
  );
@@ -129,7 +130,8 @@ export const extractOperations = (
129
130
  }
130
131
  const tag = operation.tags?.[0] ?? UNTAGGED;
131
132
  const tagSlug = slugify(tag) || "operations";
132
- if (!tagOrder.includes(tag)) {
133
+ if (!tagsSeen.has(tag)) {
134
+ tagsSeen.add(tag);
133
135
  tagOrder.push(tag);
134
136
  }
135
137
  let key = operationKey(method, path, operation.operationId);
@@ -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
 
@@ -139,32 +140,55 @@ export const resolveReferences = (
139
140
  export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
140
141
  resolveReferences(config).map((ref) => ({
141
142
  label: ref.label,
142
- path: ref.route,
143
+ // Blume-rendered operation pages flow through the content pipeline and are
144
+ // mounted under `basePath`, so their tab must be too. Scalar references are
145
+ // a single embedded page injected at the raw `route`, left root-anchored.
146
+ path:
147
+ ref.renderer === "blume"
148
+ ? withBasePath(config.basePath, ref.route)
149
+ : ref.route,
143
150
  }));
144
151
 
152
+ /**
153
+ * Accept one resolved reference into the deduped Blume-rendered set, or return
154
+ * null to skip it. Mutates `seen`/`usedSlugs` so repeated routes/slugs collapse.
155
+ */
156
+ const blumeReferenceOf = (
157
+ ref: ReferenceSource,
158
+ seen: Set<string>,
159
+ usedSlugs: Set<string>
160
+ ): ReferenceSource | null => {
161
+ if (ref.kind !== "openapi" || ref.renderer !== "blume") {
162
+ return null;
163
+ }
164
+ if (seen.has(ref.route)) {
165
+ return null;
166
+ }
167
+ seen.add(ref.route);
168
+ // Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
169
+ // yield `api-v1`). The slug keys the `blume:openapi` data module, so a
170
+ // collision would let one spec silently overwrite the other while the
171
+ // loser's pages still point at the shared key — disambiguate.
172
+ let { slug } = ref;
173
+ let n = 2;
174
+ while (usedSlugs.has(slug)) {
175
+ slug = `${ref.slug}-${n}`;
176
+ n += 1;
177
+ }
178
+ usedSlugs.add(slug);
179
+ return slug === ref.slug ? ref : { ...ref, slug };
180
+ };
181
+
145
182
  /** Blume-rendered OpenAPI references, deduped by route (first wins). */
146
183
  export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
147
184
  const seen = new Set<string>();
148
185
  const usedSlugs = new Set<string>();
149
186
  const result: ReferenceSource[] = [];
150
187
  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}`;
188
+ const accepted = blumeReferenceOf(ref, seen, usedSlugs);
189
+ if (accepted) {
190
+ result.push(accepted);
165
191
  }
166
- usedSlugs.add(slug);
167
- result.push(slug === ref.slug ? ref : { ...ref, slug });
168
192
  }
169
193
  return result;
170
194
  };
@@ -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(