blume 0.5.2 → 0.5.4

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 (137) hide show
  1. package/dist/cli/index.js +2235 -5942
  2. package/dist/cli/index.js.map +36 -65
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +2 -2
  5. package/dist/types/core/schema.d.ts +58 -453
  6. package/dist/types/core/types.d.ts +1 -7
  7. package/docs/advanced/meta.ts +1 -8
  8. package/docs/advanced/skills.mdx +28 -0
  9. package/docs/configuration/ai.mdx +3 -1
  10. package/docs/configuration/index.mdx +13 -17
  11. package/docs/configuration/theming.mdx +3 -11
  12. package/docs/content/components.mdx +2 -53
  13. package/docs/content/i18n.mdx +1 -5
  14. package/docs/content/sources.mdx +1 -1
  15. package/docs/content/syntax.mdx +1 -1
  16. package/docs/index.mdx +0 -1
  17. package/docs/reference/cli.mdx +12 -13
  18. package/package.json +4 -6
  19. package/skills/blume/SKILL.md +71 -0
  20. package/skills/blume-update-docs/SKILL.md +52 -0
  21. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  22. package/src/ai/mcp/discovery.ts +3 -1
  23. package/src/ai/mcp/server.ts +3 -1
  24. package/src/astro/component-slots.ts +10 -2
  25. package/src/astro/generate.ts +15 -12
  26. package/src/astro/integration.ts +0 -21
  27. package/src/astro/templates.ts +62 -37
  28. package/src/cli/coalesce.ts +43 -0
  29. package/src/cli/commands/dev.ts +30 -22
  30. package/src/cli/commands/validate.ts +0 -2
  31. package/src/cli/dev-lock.ts +4 -2
  32. package/src/cli/index.ts +0 -2
  33. package/src/components/BlumePage.astro +0 -6
  34. package/src/components/Icon.astro +1 -12
  35. package/src/components/content/AccordionItem.astro +3 -6
  36. package/src/components/content/Badge.astro +1 -3
  37. package/src/components/content/Callout.astro +3 -9
  38. package/src/components/content/Card.astro +2 -3
  39. package/src/components/content/ColorItem.astro +8 -5
  40. package/src/components/content/Column.astro +1 -1
  41. package/src/components/content/Prompt.astro +8 -4
  42. package/src/components/content/Step.astro +3 -4
  43. package/src/components/content/Tab.astro +2 -3
  44. package/src/components/content/Tabs.astro +13 -2
  45. package/src/components/content/TypeTable.astro +5 -5
  46. package/src/components/content/Update.astro +1 -1
  47. package/src/components/content/mermaid-element.ts +20 -2
  48. package/src/components/islands/ask-ai.tsx +4 -8
  49. package/src/components/islands/base-path.ts +30 -0
  50. package/src/components/islands/hooks.ts +12 -8
  51. package/src/components/layout/Header.astro +26 -3
  52. package/src/components/layout/Logo.astro +5 -1
  53. package/src/components/layout/NavSelector.astro +1 -1
  54. package/src/components/layout/NavTree.astro +14 -14
  55. package/src/components/layout/PageActions.astro +17 -11
  56. package/src/components/layout/PageLayout.astro +42 -0
  57. package/src/components/layout/ReferenceLayout.astro +1 -0
  58. package/src/components/layout/RootLayout.astro +31 -2
  59. package/src/components/layout/Search.astro +9 -6
  60. package/src/components/layout/nav-utils.ts +9 -4
  61. package/src/components/layout/search/types.ts +16 -5
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/components/openapi/ParametersTable.astro +1 -1
  65. package/src/components/openapi/SchemaProperty.astro +1 -1
  66. package/src/components/openapi/SchemaTable.astro +3 -3
  67. package/src/components/openapi/helpers.ts +17 -8
  68. package/src/components/openapi/snippets.ts +17 -4
  69. package/src/core/builtin-tags.ts +1 -3
  70. package/src/core/config.ts +19 -33
  71. package/src/core/data.ts +2 -2
  72. package/src/core/graph.ts +7 -5
  73. package/src/core/links.ts +5 -19
  74. package/src/core/meta.ts +1 -1
  75. package/src/core/navigation.ts +5 -6
  76. package/src/core/project-graph.ts +0 -5
  77. package/src/core/schema.ts +13 -90
  78. package/src/core/sources/filesystem.ts +19 -1
  79. package/src/core/sources/mdx-remote.ts +20 -4
  80. package/src/core/sources/normalize.ts +28 -6
  81. package/src/core/sources/resolve.ts +0 -13
  82. package/src/core/sources/watch.ts +45 -0
  83. package/src/core/types.ts +1 -8
  84. package/src/markdown/code-title.ts +6 -3
  85. package/src/markdown/package-install.ts +3 -1
  86. package/src/og/card.ts +16 -4
  87. package/src/openapi/parse.ts +197 -14
  88. package/src/openapi/render-mdx.ts +54 -11
  89. package/src/openapi/source.ts +19 -2
  90. package/src/search/sync/orama-cloud.ts +2 -0
  91. package/src/search/sync/typesense.ts +4 -0
  92. package/src/theme/entry.ts +45 -17
  93. package/src/theme/icons.ts +22 -104
  94. package/src/theme/palette.ts +35 -47
  95. package/src/theme/twoslash.ts +6 -1
  96. package/dist/types/core/bridge.d.ts +0 -24
  97. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  98. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  99. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  100. package/docs/advanced/bridge.mdx +0 -76
  101. package/docs/advanced/migrate.mdx +0 -123
  102. package/src/astro/static-assets.ts +0 -117
  103. package/src/cli/commands/migrate.ts +0 -39
  104. package/src/components/content/ApiField.astro +0 -75
  105. package/src/components/content/ParamField.astro +0 -39
  106. package/src/components/content/RequestField.astro +0 -23
  107. package/src/components/content/ResponseField.astro +0 -23
  108. package/src/components/content/Warning.astro +0 -9
  109. package/src/core/assets.ts +0 -31
  110. package/src/core/bridge.ts +0 -102
  111. package/src/core/sources/mintlify.ts +0 -204
  112. package/src/migrate/fumadocs/config.ts +0 -155
  113. package/src/migrate/fumadocs/content.ts +0 -378
  114. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  115. package/src/migrate/fumadocs/groups.ts +0 -237
  116. package/src/migrate/fumadocs/index.ts +0 -340
  117. package/src/migrate/fumadocs/meta.ts +0 -244
  118. package/src/migrate/migrate.ts +0 -53
  119. package/src/migrate/mintlify/assets.ts +0 -46
  120. package/src/migrate/mintlify/config.ts +0 -958
  121. package/src/migrate/mintlify/content.ts +0 -120
  122. package/src/migrate/mintlify/frontmatter.ts +0 -126
  123. package/src/migrate/mintlify/i18n.ts +0 -51
  124. package/src/migrate/mintlify/icons.ts +0 -128
  125. package/src/migrate/mintlify/index.ts +0 -372
  126. package/src/migrate/mintlify/snippets.ts +0 -306
  127. package/src/migrate/mintlify/transform.ts +0 -82
  128. package/src/migrate/nextra/content.ts +0 -46
  129. package/src/migrate/nextra/frontmatter.ts +0 -40
  130. package/src/migrate/nextra/index.ts +0 -374
  131. package/src/migrate/nextra/meta.ts +0 -266
  132. package/src/migrate/shared.ts +0 -728
  133. package/src/migrate/starlight/config.ts +0 -455
  134. package/src/migrate/starlight/content.ts +0 -78
  135. package/src/migrate/starlight/frontmatter.ts +0 -111
  136. package/src/migrate/starlight/i18n.ts +0 -54
  137. package/src/migrate/starlight/index.ts +0 -131
@@ -190,6 +190,48 @@ const bannerScript = banner?.dismissible
190
190
  </Header>
191
191
  <main id="blume-content"><slot /></main>
192
192
  <slot name="footer" />
193
+ {
194
+ // Mobile nav for chrome-only pages: the header's inline tab bar is hidden
195
+ // below `lg` and there's no sidebar drawer, so surface the tabs in a
196
+ // slide-in drawer instead. It reuses the header's `data-blume-nav-toggle`
197
+ // machinery (drawer top, scroll lock, resize-close) — the hamburger button
198
+ // lives in the header; this is the panel it opens.
199
+ navigation.tabs.length > 0 && (
200
+ <>
201
+ <aside
202
+ aria-label="Navigation"
203
+ class="fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:hidden"
204
+ >
205
+ <nav aria-label="Sections">
206
+ <ul class="m-0 list-none p-0">
207
+ {navigation.tabs.map((tab) => (
208
+ <li>
209
+ <a
210
+ aria-current={
211
+ route === tab.path ||
212
+ (tab.path !== "/" && route.startsWith(tab.path))
213
+ ? "page"
214
+ : undefined
215
+ }
216
+ class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
217
+ href={tab.path}
218
+ >
219
+ {tab.label}
220
+ </a>
221
+ </li>
222
+ ))}
223
+ </ul>
224
+ </nav>
225
+ </aside>
226
+ <button
227
+ aria-label="Close navigation"
228
+ class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
229
+ data-blume-nav-toggle
230
+ type="button"
231
+ />
232
+ </>
233
+ )
234
+ }
193
235
  {
194
236
  clientDataJson && (
195
237
  <script
@@ -18,6 +18,7 @@ interface Props {
18
18
  dark?: string;
19
19
  alt: string;
20
20
  href: string;
21
+ text?: string;
21
22
  } | null;
22
23
  favicon?: {
23
24
  href: string;
@@ -39,6 +39,7 @@ interface Props {
39
39
  dark?: string;
40
40
  alt: string;
41
41
  href: string;
42
+ text?: string;
42
43
  } | null;
43
44
  favicon?: {
44
45
  href: string;
@@ -344,8 +345,36 @@ const bannerScript = banner?.dismissible
344
345
  <div class:list={["mx-auto grid grid-cols-1 items-start", gridClass]}>
345
346
  <aside
346
347
  aria-label="Primary"
347
- class="fixed top-16 start-0 z-[35] h-[calc(100dvh-4rem)] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:z-auto lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4"
348
+ class="fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4"
348
349
  >
350
+ {
351
+ // The header's tab bar is hidden below `md`, so the drawer is the only
352
+ // way to move between sections on mobile: list the tabs above the
353
+ // page tree, separated by a divider. Hidden from `md` up, where the
354
+ // header tabs take over.
355
+ navigation.tabs.length > 0 && (
356
+ <nav aria-label="Sections" class="mb-4 border-border border-b pb-4 md:hidden">
357
+ <ul class="m-0 list-none p-0">
358
+ {navigation.tabs.map((tab) => (
359
+ <li>
360
+ <a
361
+ aria-current={
362
+ page.route === tab.path ||
363
+ (tab.path !== "/" && page.route.startsWith(tab.path))
364
+ ? "page"
365
+ : undefined
366
+ }
367
+ class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
368
+ href={tab.path}
369
+ >
370
+ {tab.label}
371
+ </a>
372
+ </li>
373
+ ))}
374
+ </ul>
375
+ </nav>
376
+ )
377
+ }
349
378
  <nav>
350
379
  {
351
380
  MobileNavSlot ? (
@@ -363,7 +392,7 @@ const bannerScript = banner?.dismissible
363
392
  }
364
393
  </nav>
365
394
  </aside>
366
- <main class="min-w-0 px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
395
+ <main class="px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
367
396
  <BreadcrumbsSlot crumbs={crumbs} wide={isApiOperation} />
368
397
  <TableOfContentsSlot
369
398
  headings={tocHeadings}
@@ -36,12 +36,12 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
36
36
  >
37
37
  <button
38
38
  aria-label={s.button}
39
- class="inline-flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground sm:min-w-48"
39
+ class="inline-flex h-9 cursor-pointer items-center gap-2 rounded-full border border-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground sm:min-w-48"
40
40
  data-blume-search-open
41
41
  type="button"
42
42
  >
43
43
  <Icon name="search" size={16} />
44
- <span class="min-w-0 flex-1 text-start max-sm:hidden">{s.button}</span>
44
+ <span class="flex-1 text-start max-sm:hidden">{s.button}</span>
45
45
  <kbd class="font-mono text-[0.7rem] max-sm:hidden" data-blume-search-kbd
46
46
  >⌘K</kbd
47
47
  >
@@ -296,10 +296,13 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
296
296
  this.input.focus();
297
297
  this.input.select();
298
298
  if (!this.loaded) {
299
- this.loaded = true;
300
299
  try {
301
300
  const { createSearch } = await import("blume:search-client");
302
301
  this.searchFn = await createSearch();
302
+ // Only latch on success — a transient failure (flaky network
303
+ // fetching the index) must retry on the next open, not disable
304
+ // search until a full page reload.
305
+ this.loaded = true;
303
306
  } catch {
304
307
  this.searchFn = null;
305
308
  }
@@ -456,7 +459,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
456
459
  : "Ask AI";
457
460
  el.innerHTML = `
458
461
  <span class="mt-0.5 shrink-0 text-accent">${svg("sparkles")}</span>
459
- <span class="min-w-0 flex-1">
462
+ <span class="flex-1">
460
463
  <span class="block truncate font-normal text-foreground text-sm">${title}</span>
461
464
  <span class="block truncate text-muted-foreground text-sm">Get an instant answer from AI</span>
462
465
  </span>`;
@@ -476,7 +479,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
476
479
  : "";
477
480
  el.innerHTML = `
478
481
  <span class="mt-0.5 shrink-0 text-muted-foreground">${svg("file")}</span>
479
- <span class="min-w-0 flex-1">
482
+ <span class="flex-1">
480
483
  <span class="block truncate font-normal text-foreground text-sm">${hit.title}</span>
481
484
  ${excerpt}
482
485
  </span>`;
@@ -491,7 +494,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
491
494
  el.className = ROW_CLASS;
492
495
  el.innerHTML = `
493
496
  <span class="mt-0.5 shrink-0 text-muted-foreground">${svg("file")}</span>
494
- <span class="min-w-0 flex-1">
497
+ <span class="flex-1">
495
498
  <span class="block truncate font-normal text-foreground text-sm">${escapeHtml(label)}</span>
496
499
  </span>`;
497
500
  const item: Selectable = { el, kind: "link", url };
@@ -161,9 +161,14 @@ const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
161
161
  * Adapters / API / AI tabs) drills each tab into its own pages instead of one
162
162
  * global tree, the way Fumadocs' root folders do. On a route under no tab (or
163
163
  * the root `/` tab), the tab-owned groups are hidden so the root sidebar shows
164
- * only pages that don't belong to a tab. Falls back to the full sidebar when a
165
- * matched tab maps to no group, or when hiding the tab sections would blank the
166
- * sidebar, so a route is never left empty.
164
+ * only pages that don't belong to a tab.
165
+ *
166
+ * When a matched tab owns no sidebar group a standalone page like the
167
+ * generated changelog timeline (`/changelog`), or a tab whose source produced
168
+ * no pages — the sidebar is empty. It must not fall back to the full tree: that
169
+ * would leak every *other* tab's section (e.g. the OpenAPI operations) onto the
170
+ * page. On a route under no tab, hiding the tab sections falls back to the full
171
+ * sidebar only when it would otherwise blank, so an un-tabbed route stays full.
167
172
  */
168
173
  export const sidebarForRoute = (
169
174
  sidebar: NavNode[],
@@ -172,7 +177,7 @@ export const sidebarForRoute = (
172
177
  ): NavNode[] => {
173
178
  const tab = activeTab(tabs, route);
174
179
  if (tab) {
175
- return sectionChildren(sidebar, tab.path) ?? sidebar;
180
+ return sectionChildren(sidebar, tab.path) ?? [];
176
181
  }
177
182
  const scoped = withoutTabSections(sidebar, tabs);
178
183
  return scoped.length > 0 ? scoped : sidebar;
@@ -74,15 +74,26 @@ const queryTokens = (query: string): string[] =>
74
74
  .filter(Boolean)
75
75
  .map((token) => token.replaceAll(REGEXP_SPECIAL, String.raw`\$&`));
76
76
 
77
- /** Wrap query matches in `<mark>`, after HTML-escaping the source text. */
77
+ /**
78
+ * Wrap query matches in `<mark>`, HTML-escaping the source text. Matching runs
79
+ * on the *raw* text and escaping on each segment — matching after escaping
80
+ * would let a query like "amp" or "lt" mark the inside of an entity produced
81
+ * from the source (`&amp;` in "a & b"), corrupting the rendered excerpt.
82
+ */
78
83
  export const highlight = (text: string, query: string): string => {
79
- const escaped = escapeHtml(text);
80
84
  const tokens = queryTokens(query);
81
85
  if (tokens.length === 0) {
82
- return escaped;
86
+ return escapeHtml(text);
83
87
  }
84
- const pattern = new RegExp(`(?<match>${tokens.join("|")})`, "giu");
85
- return escaped.replaceAll(pattern, "<mark>$<match></mark>");
88
+ const pattern = new RegExp(`(${tokens.join("|")})`, "giu");
89
+ return text
90
+ .split(pattern)
91
+ .map((segment, index) =>
92
+ index % 2 === 1
93
+ ? `<mark>${escapeHtml(segment)}</mark>`
94
+ : escapeHtml(segment)
95
+ )
96
+ .join("");
86
97
  };
87
98
 
88
99
  /** First index in `text` where any query token matches (case-insensitive). */
@@ -1,28 +1,18 @@
1
1
  ---
2
2
  import specs from "blume:openapi";
3
- import MethodBadge from "./MethodBadge.astro";
4
3
 
4
+ // The spec-level metadata block (version + base URLs) at the top of an API
5
+ // overview page. The tag sections that follow are emitted by `overviewMdx` as
6
+ // markdown headings plus `<ApiTagOperations>` lists, so they land in the
7
+ // table of contents.
5
8
  interface Props {
6
9
  source: string;
7
10
  }
8
11
 
9
12
  const { source } = Astro.props;
10
13
  const spec = specs[source];
11
- const operations = Object.values(spec?.operations ?? {});
12
14
  const servers =
13
15
  ((spec?.document ?? {}) as { servers?: { url?: string }[] }).servers ?? [];
14
-
15
- // Tags in declared order, then any operation tag not listed under `tags`.
16
- const declaredSlugs = new Set(spec?.tags.map((tag) => tag.slug));
17
- const extraSlugs = new Set<string>();
18
- const extraTags: { slug: string; name: string; description: string }[] = [];
19
- for (const operation of operations) {
20
- if (!(declaredSlugs.has(operation.tagSlug) || extraSlugs.has(operation.tagSlug))) {
21
- extraSlugs.add(operation.tagSlug);
22
- extraTags.push({ description: "", name: operation.tag, slug: operation.tagSlug });
23
- }
24
- }
25
- const sections = [...(spec?.tags ?? []), ...extraTags];
26
16
  ---
27
17
 
28
18
  {
@@ -43,42 +33,6 @@ const sections = [...(spec?.tags ?? []), ...extraTags];
43
33
  ))}
44
34
  </div>
45
35
  )}
46
- {sections.map((tag) => {
47
- const ops = operations.filter(
48
- (operation) => operation.tagSlug === tag.slug
49
- );
50
- if (ops.length === 0) {
51
- return null;
52
- }
53
- return (
54
- <section class="mb-8">
55
- <h2 class="mb-1 font-semibold text-foreground text-xl">
56
- {tag.name}
57
- </h2>
58
- {tag.description && (
59
- <div class="mb-3 text-muted-foreground text-sm" set:text={tag.description} />
60
- )}
61
- <ul class="not-prose flex list-none flex-col gap-2 p-0">
62
- {ops.map((operation) => (
63
- <li>
64
- <a
65
- class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
66
- href={operation.route}
67
- >
68
- <MethodBadge method={operation.method} />
69
- <span class="font-medium text-foreground text-sm">
70
- {operation.summary || operation.path}
71
- </span>
72
- <code class="ml-auto hidden text-muted-foreground text-xs sm:inline">
73
- {operation.path}
74
- </code>
75
- </a>
76
- </li>
77
- ))}
78
- </ul>
79
- </section>
80
- );
81
- })}
82
36
  </div>
83
37
  )
84
38
  }
@@ -0,0 +1,42 @@
1
+ ---
2
+ import specs from "blume:openapi";
3
+ import MethodBadge from "./MethodBadge.astro";
4
+
5
+ // The operation-link list for one tag section of an API overview page. The
6
+ // section heading itself is emitted by `overviewMdx` as a markdown `##` — so it
7
+ // flows into the table of contents — and this component renders only the list
8
+ // of operations beneath it.
9
+ interface Props {
10
+ source: string;
11
+ /** Tag slug (`ApiOperationRef.tagSlug`) selecting this section's operations. */
12
+ tag: string;
13
+ }
14
+
15
+ const { source, tag } = Astro.props;
16
+ const operations = Object.values(specs[source]?.operations ?? {}).filter(
17
+ (operation) => operation.tagSlug === tag
18
+ );
19
+ ---
20
+
21
+ {
22
+ operations.length > 0 && (
23
+ <ul class="not-prose my-4 flex list-none flex-col gap-2 p-0">
24
+ {operations.map((operation) => (
25
+ <li>
26
+ <a
27
+ class="flex items-center gap-3 rounded-blume border border-border p-3 text-inherit no-underline! transition-colors hover:border-accent hover:bg-muted hover:no-underline!"
28
+ href={operation.route}
29
+ >
30
+ <MethodBadge method={operation.method} />
31
+ <span class="font-medium text-foreground text-sm">
32
+ {operation.summary || operation.path}
33
+ </span>
34
+ <code class="ml-auto hidden text-muted-foreground text-xs sm:inline">
35
+ {operation.path}
36
+ </code>
37
+ </a>
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ )
42
+ }
@@ -48,7 +48,7 @@ const groups = SECTIONS.map((section) => ({
48
48
  <div class="not-prose rounded-blume border border-border px-4">
49
49
  {group.items.map((param) => {
50
50
  const resolved = resolveSchema(schemas, param.schema ?? {});
51
- const type = param.schema ? typeLabel(param.schema, schemas) : "string";
51
+ const type = param.schema ? typeLabel(param.schema) : "string";
52
52
  const limits = constraints(resolved);
53
53
  const enumValues = Array.isArray(resolved.enum) ? resolved.enum : null;
54
54
  return (
@@ -31,7 +31,7 @@ const refLabel = typeof schema.$ref === "string" ? refName(schema.$ref) : null;
31
31
  const circular = refLabel !== null && seen.includes(refLabel);
32
32
  const resolved = resolveSchema(schemas, schema);
33
33
 
34
- const type = typeLabel(schema, schemas);
34
+ const type = typeLabel(schema);
35
35
  const description = resolved.description ?? schema.description ?? "";
36
36
  const deprecated = resolved.deprecated === true;
37
37
  const nullable = isNullable(resolved);
@@ -45,7 +45,7 @@ const { properties, required } = circular
45
45
  ) : isArray ? (
46
46
  <div>
47
47
  <div class="mb-2 text-muted-foreground text-xs">
48
- Array of <code class="text-foreground">{typeLabel(items ?? {}, schemas)}</code>
48
+ Array of <code class="text-foreground">{typeLabel(items ?? {})}</code>
49
49
  </div>
50
50
  {items && (
51
51
  <Astro.self schema={items} schemas={schemas} seen={nextSeen} expandAll={expandAll} />
@@ -59,7 +59,7 @@ const { properties, required } = circular
59
59
  {branches.map((branch, index) => (
60
60
  <div class="rounded-blume border border-border p-3">
61
61
  <div class="mb-2 font-medium text-foreground text-xs">
62
- {typeLabel(branch, schemas) || `Option ${index + 1}`}
62
+ {typeLabel(branch) || `Option ${index + 1}`}
63
63
  </div>
64
64
  <Astro.self schema={branch} schemas={schemas} seen={nextSeen} expandAll={expandAll} />
65
65
  </div>
@@ -80,7 +80,7 @@ const { properties, required } = circular
80
80
  </div>
81
81
  ) : (
82
82
  <div class="text-muted-foreground text-sm">
83
- <code class="text-foreground">{typeLabel(resolved, schemas)}</code>
83
+ <code class="text-foreground">{typeLabel(resolved)}</code>
84
84
  </div>
85
85
  )
86
86
  }
@@ -69,17 +69,18 @@ const nonNullTypes = (type: string | string[] | undefined): string[] => {
69
69
  return (Array.isArray(type) ? type : [type]).filter((t) => t !== "null");
70
70
  };
71
71
 
72
- /** A short, human-readable type label for a schema row. */
73
- export const typeLabel = (
74
- schema: SchemaLike,
75
- schemas: Record<string, SchemaLike>
76
- ): string => {
72
+ /**
73
+ * A short, human-readable type label for a schema row. `$ref`s label by name
74
+ * (`Pet`, `Pet[]`) without resolving — which also means circular refs through
75
+ * array items can't recurse forever.
76
+ */
77
+ export const typeLabel = (schema: SchemaLike): string => {
77
78
  if (typeof schema.$ref === "string") {
78
79
  return refName(schema.$ref);
79
80
  }
80
81
  if (schema.oneOf || schema.anyOf) {
81
82
  const branches = schema.oneOf ?? schema.anyOf ?? [];
82
- const labels = branches.map((branch) => typeLabel(branch, schemas));
83
+ const labels = branches.map((branch) => typeLabel(branch));
83
84
  return [...new Set(labels)].join(" | ") || "any";
84
85
  }
85
86
  if (schema.allOf) {
@@ -87,8 +88,7 @@ export const typeLabel = (
87
88
  }
88
89
  const types = nonNullTypes(schema.type);
89
90
  if (types.includes("array")) {
90
- const item = resolveSchema(schemas, schema.items);
91
- return `${typeLabel(item, schemas)}[]`;
91
+ return `${typeLabel(schema.items ?? {})}[]`;
92
92
  }
93
93
  const base = types[0] ?? (schema.properties ? "object" : "any");
94
94
  return schema.format ? `${base}<${schema.format}>` : base;
@@ -136,8 +136,17 @@ export const objectProperties = (
136
136
  ): { properties: [string, SchemaLike][]; required: Set<string> } => {
137
137
  const properties = new Map<string, SchemaLike>();
138
138
  const required = new Set<string>();
139
+ // Cycles can only enter through `$ref`s (inline JSON can't self-nest), so
140
+ // tracking visited refs is enough to stop circular allOf chains recursing.
141
+ const seen = new Set<string>();
139
142
 
140
143
  const collect = (node: SchemaLike): void => {
144
+ if (typeof node.$ref === "string") {
145
+ if (seen.has(node.$ref)) {
146
+ return;
147
+ }
148
+ seen.add(node.$ref);
149
+ }
141
150
  const resolved = resolveSchema(schemas, node);
142
151
  for (const name of resolved.required ?? []) {
143
152
  required.add(name);
@@ -115,7 +115,9 @@ const curlSnippet = (sample: RequestSample): string => {
115
115
  ...headerLines(sample.headers, (key, value) => ` -H "${key}: ${value}"`),
116
116
  ];
117
117
  if (sample.body) {
118
- lines.push(` -d '${sample.body}'`);
118
+ // Close-quote/escaped-quote/reopen: the POSIX way to put a literal ' in a
119
+ // single-quoted string, so an example like "it's" doesn't break the shell.
120
+ lines.push(` -d '${sample.body.replaceAll("'", String.raw`'\''`)}'`);
119
121
  }
120
122
  return lines.join(" \\\n");
121
123
  };
@@ -137,12 +139,23 @@ const fetchSnippet = (sample: RequestSample): string => {
137
139
  )}\n});`;
138
140
  };
139
141
 
142
+ // Split-with-capture: odd segments are JSON string literals, kept verbatim so
143
+ // a string *value* containing the words true/false/null isn't rewritten.
144
+ const JSON_STRING = /(?<literal>"(?:\\.|[^"\\])*")/gu;
145
+
140
146
  /** Turn a JSON literal into an equivalent Python literal (`true` -> `True`). */
141
147
  const toPython = (json: string): string =>
142
148
  json
143
- .replaceAll(/\btrue\b/gu, "True")
144
- .replaceAll(/\bfalse\b/gu, "False")
145
- .replaceAll(/\bnull\b/gu, "None");
149
+ .split(JSON_STRING)
150
+ .map((part, index) =>
151
+ index % 2 === 1
152
+ ? part
153
+ : part
154
+ .replaceAll(/\btrue\b/gu, "True")
155
+ .replaceAll(/\bfalse\b/gu, "False")
156
+ .replaceAll(/\bnull\b/gu, "None")
157
+ )
158
+ .join("");
146
159
 
147
160
  const pythonSnippet = (sample: RequestSample): string => {
148
161
  const args = [` "${sample.url}"`];
@@ -8,6 +8,7 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
8
8
  "Accordion",
9
9
  "AccordionItem",
10
10
  "ApiOverview",
11
+ "ApiTagOperations",
11
12
  "AutoTypeTable",
12
13
  "Badge",
13
14
  "Callout",
@@ -28,10 +29,7 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
28
29
  "Math",
29
30
  "Operation",
30
31
  "Panel",
31
- "ParamField",
32
32
  "Prompt",
33
- "RequestField",
34
- "ResponseField",
35
33
  "Step",
36
34
  "Steps",
37
35
  "Tab",
@@ -1,7 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
- import { detectMintlifyBridge } from "./bridge.ts";
4
- import type { BridgeDetection } from "./bridge.ts";
5
3
  import { applyDeploymentEnv } from "./deployment-env.ts";
6
4
  import { BlumeError, diagnosticsFromZod } from "./diagnostics.ts";
7
5
  import { createModuleLoader } from "./load-module.ts";
@@ -16,20 +14,11 @@ import type { Diagnostic } from "./types.ts";
16
14
  */
17
15
  export const defineConfig = (config: BlumeConfig): BlumeConfig => config;
18
16
 
19
- /** Bridge mode info: a foreign docs tool Blume is serving without migrating. */
20
- export interface ConfigBridge {
21
- tool: "mintlify";
22
- /** Absolute path of the foreign config file (`docs.json`/`mint.json`). */
23
- configFile: string;
24
- }
25
-
26
17
  /** Result of loading + validating a project config. */
27
18
  export interface ConfigLoadResult {
28
19
  config: ResolvedConfig;
29
20
  /** Absolute path of the config file used, or null when defaults were used. */
30
21
  configFile: string | null;
31
- /** Set when a foreign docs config (e.g. Mintlify) is being bridged. */
32
- bridge: ConfigBridge | null;
33
22
  diagnostics: Diagnostic[];
34
23
  }
35
24
 
@@ -50,9 +39,6 @@ export const loadConfig = async (
50
39
  ): Promise<ConfigLoadResult> => {
51
40
  const configFile = findConfigFile(root);
52
41
 
53
- // With no Blume config, a Mintlify `docs.json` activates bridge mode: serve
54
- // the unconverted project by synthesizing config + a `mintlify` content source.
55
- let bridge: BridgeDetection | null = null;
56
42
  let raw: unknown = {};
57
43
  if (configFile) {
58
44
  try {
@@ -65,33 +51,36 @@ export const loadConfig = async (
65
51
  severity: "error",
66
52
  });
67
53
  }
68
- } else {
69
- bridge = await detectMintlifyBridge(root);
70
- if (bridge) {
71
- ({ raw } = bridge);
72
- }
73
54
  }
74
55
 
75
- const sourceFile = bridge?.configFile ?? configFile;
76
56
  const parsed = blumeConfigSchema.safeParse(raw ?? {});
77
57
  if (!parsed.success) {
78
58
  // Read the raw config text (when on disk) so errors carry a line/column.
79
59
  const source =
80
- sourceFile && existsSync(sourceFile)
81
- ? readFileSync(sourceFile, "utf-8")
60
+ configFile && existsSync(configFile)
61
+ ? readFileSync(configFile, "utf-8")
82
62
  : undefined;
83
63
  const diagnostics = diagnosticsFromZod(parsed.error, {
84
64
  code: "BLUME_CONFIG_INVALID",
85
- file: sourceFile ?? undefined,
65
+ file: configFile ?? undefined,
86
66
  source,
87
67
  });
68
+ const [first, ...rest] = diagnostics;
69
+ const primary = first ?? {
70
+ code: "BLUME_CONFIG_INVALID",
71
+ file: configFile ?? undefined,
72
+ message: "Invalid Blume config.",
73
+ severity: "error" as const,
74
+ };
75
+ // Surface every issue in one failing run — reporting only the first turns
76
+ // a three-mistake config into three fix-rerun-fail loops.
88
77
  throw new BlumeError(
89
- diagnostics[0] ?? {
90
- code: "BLUME_CONFIG_INVALID",
91
- file: sourceFile ?? undefined,
92
- message: "Invalid Blume config.",
93
- severity: "error",
94
- }
78
+ rest.length > 0
79
+ ? {
80
+ ...primary,
81
+ message: `${primary.message}\n${rest.length} more config issue(s):\n${rest.map((d) => ` - ${d.message}`).join("\n")}`,
82
+ }
83
+ : primary
95
84
  );
96
85
  }
97
86
 
@@ -106,15 +95,12 @@ export const loadConfig = async (
106
95
  const ogEnabled = config.seo.og.enabled ?? Boolean(site);
107
96
 
108
97
  return {
109
- bridge: bridge
110
- ? { configFile: bridge.configFile, tool: bridge.tool }
111
- : null,
112
98
  config: {
113
99
  ...config,
114
100
  deployment: { ...config.deployment, site },
115
101
  seo: { ...config.seo, og: { ...config.seo.og, enabled: ogEnabled } },
116
102
  },
117
- configFile: sourceFile,
103
+ configFile,
118
104
  diagnostics: [],
119
105
  };
120
106
  };
package/src/core/data.ts CHANGED
@@ -17,6 +17,8 @@ export interface BlumeLogo {
17
17
  dark?: string;
18
18
  alt: string;
19
19
  href: string;
20
+ /** Wordmark text beside the mark; `undefined` falls back to the site title. */
21
+ text?: string;
20
22
  }
21
23
 
22
24
  /** A favicon or apple-touch-icon: a link href plus an optional MIME type. */
@@ -93,8 +95,6 @@ export interface BlumeDataConfig {
93
95
  favicon: BlumeFavicon;
94
96
  feedback: boolean;
95
97
  i18n: BlumeDataI18n | null;
96
- /** Default icon library for bare `icon` names. */
97
- icons: ResolvedConfig["icons"];
98
98
  /** `markdown.imageZoom`: click-to-zoom content images. */
99
99
  imageZoom: boolean;
100
100
  logo: BlumeLogo | null;