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.
- package/dist/cli/index.js +2235 -5942
- package/dist/cli/index.js.map +36 -65
- package/dist/types/core/config.d.ts +0 -8
- package/dist/types/core/data.d.ts +2 -2
- package/dist/types/core/schema.d.ts +58 -453
- package/dist/types/core/types.d.ts +1 -7
- package/docs/advanced/meta.ts +1 -8
- package/docs/advanced/skills.mdx +28 -0
- package/docs/configuration/ai.mdx +3 -1
- package/docs/configuration/index.mdx +13 -17
- package/docs/configuration/theming.mdx +3 -11
- package/docs/content/components.mdx +2 -53
- package/docs/content/i18n.mdx +1 -5
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +0 -1
- package/docs/reference/cli.mdx +12 -13
- package/package.json +4 -6
- package/skills/blume/SKILL.md +71 -0
- package/skills/blume-update-docs/SKILL.md +52 -0
- package/skills/blume-update-docs/references/audit-checklist.md +46 -0
- package/src/ai/mcp/discovery.ts +3 -1
- package/src/ai/mcp/server.ts +3 -1
- package/src/astro/component-slots.ts +10 -2
- package/src/astro/generate.ts +15 -12
- package/src/astro/integration.ts +0 -21
- package/src/astro/templates.ts +62 -37
- package/src/cli/coalesce.ts +43 -0
- package/src/cli/commands/dev.ts +30 -22
- package/src/cli/commands/validate.ts +0 -2
- package/src/cli/dev-lock.ts +4 -2
- package/src/cli/index.ts +0 -2
- package/src/components/BlumePage.astro +0 -6
- package/src/components/Icon.astro +1 -12
- package/src/components/content/AccordionItem.astro +3 -6
- package/src/components/content/Badge.astro +1 -3
- package/src/components/content/Callout.astro +3 -9
- package/src/components/content/Card.astro +2 -3
- package/src/components/content/ColorItem.astro +8 -5
- package/src/components/content/Column.astro +1 -1
- package/src/components/content/Prompt.astro +8 -4
- package/src/components/content/Step.astro +3 -4
- package/src/components/content/Tab.astro +2 -3
- package/src/components/content/Tabs.astro +13 -2
- package/src/components/content/TypeTable.astro +5 -5
- package/src/components/content/Update.astro +1 -1
- package/src/components/content/mermaid-element.ts +20 -2
- package/src/components/islands/ask-ai.tsx +4 -8
- package/src/components/islands/base-path.ts +30 -0
- package/src/components/islands/hooks.ts +12 -8
- package/src/components/layout/Header.astro +26 -3
- package/src/components/layout/Logo.astro +5 -1
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/NavTree.astro +14 -14
- package/src/components/layout/PageActions.astro +17 -11
- package/src/components/layout/PageLayout.astro +42 -0
- package/src/components/layout/ReferenceLayout.astro +1 -0
- package/src/components/layout/RootLayout.astro +31 -2
- package/src/components/layout/Search.astro +9 -6
- package/src/components/layout/nav-utils.ts +9 -4
- package/src/components/layout/search/types.ts +16 -5
- package/src/components/openapi/ApiOverview.astro +4 -50
- package/src/components/openapi/ApiTagOperations.astro +42 -0
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/SchemaProperty.astro +1 -1
- package/src/components/openapi/SchemaTable.astro +3 -3
- package/src/components/openapi/helpers.ts +17 -8
- package/src/components/openapi/snippets.ts +17 -4
- package/src/core/builtin-tags.ts +1 -3
- package/src/core/config.ts +19 -33
- package/src/core/data.ts +2 -2
- package/src/core/graph.ts +7 -5
- package/src/core/links.ts +5 -19
- package/src/core/meta.ts +1 -1
- package/src/core/navigation.ts +5 -6
- package/src/core/project-graph.ts +0 -5
- package/src/core/schema.ts +13 -90
- package/src/core/sources/filesystem.ts +19 -1
- package/src/core/sources/mdx-remote.ts +20 -4
- package/src/core/sources/normalize.ts +28 -6
- package/src/core/sources/resolve.ts +0 -13
- package/src/core/sources/watch.ts +45 -0
- package/src/core/types.ts +1 -8
- package/src/markdown/code-title.ts +6 -3
- package/src/markdown/package-install.ts +3 -1
- package/src/og/card.ts +16 -4
- package/src/openapi/parse.ts +197 -14
- package/src/openapi/render-mdx.ts +54 -11
- package/src/openapi/source.ts +19 -2
- package/src/search/sync/orama-cloud.ts +2 -0
- package/src/search/sync/typesense.ts +4 -0
- package/src/theme/entry.ts +45 -17
- package/src/theme/icons.ts +22 -104
- package/src/theme/palette.ts +35 -47
- package/src/theme/twoslash.ts +6 -1
- package/dist/types/core/bridge.d.ts +0 -24
- package/dist/types/migrate/mintlify/assets.d.ts +0 -8
- package/dist/types/migrate/mintlify/config.d.ts +0 -16
- package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
- package/docs/advanced/bridge.mdx +0 -76
- package/docs/advanced/migrate.mdx +0 -123
- package/src/astro/static-assets.ts +0 -117
- package/src/cli/commands/migrate.ts +0 -39
- package/src/components/content/ApiField.astro +0 -75
- package/src/components/content/ParamField.astro +0 -39
- package/src/components/content/RequestField.astro +0 -23
- package/src/components/content/ResponseField.astro +0 -23
- package/src/components/content/Warning.astro +0 -9
- package/src/core/assets.ts +0 -31
- package/src/core/bridge.ts +0 -102
- package/src/core/sources/mintlify.ts +0 -204
- package/src/migrate/fumadocs/config.ts +0 -155
- package/src/migrate/fumadocs/content.ts +0 -378
- package/src/migrate/fumadocs/frontmatter.ts +0 -18
- package/src/migrate/fumadocs/groups.ts +0 -237
- package/src/migrate/fumadocs/index.ts +0 -340
- package/src/migrate/fumadocs/meta.ts +0 -244
- package/src/migrate/migrate.ts +0 -53
- package/src/migrate/mintlify/assets.ts +0 -46
- package/src/migrate/mintlify/config.ts +0 -958
- package/src/migrate/mintlify/content.ts +0 -120
- package/src/migrate/mintlify/frontmatter.ts +0 -126
- package/src/migrate/mintlify/i18n.ts +0 -51
- package/src/migrate/mintlify/icons.ts +0 -128
- package/src/migrate/mintlify/index.ts +0 -372
- package/src/migrate/mintlify/snippets.ts +0 -306
- package/src/migrate/mintlify/transform.ts +0 -82
- package/src/migrate/nextra/content.ts +0 -46
- package/src/migrate/nextra/frontmatter.ts +0 -40
- package/src/migrate/nextra/index.ts +0 -374
- package/src/migrate/nextra/meta.ts +0 -266
- package/src/migrate/shared.ts +0 -728
- package/src/migrate/starlight/config.ts +0 -455
- package/src/migrate/starlight/content.ts +0 -78
- package/src/migrate/starlight/frontmatter.ts +0 -111
- package/src/migrate/starlight/i18n.ts +0 -54
- 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
|
|
@@ -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-
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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.
|
|
165
|
-
*
|
|
166
|
-
* sidebar
|
|
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) ??
|
|
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
|
-
/**
|
|
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 (`&` 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
|
|
86
|
+
return escapeHtml(text);
|
|
83
87
|
}
|
|
84
|
-
const pattern = new RegExp(`(
|
|
85
|
-
return
|
|
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
|
|
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
|
|
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 ?? {}
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
144
|
-
.
|
|
145
|
-
|
|
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}"`];
|
package/src/core/builtin-tags.ts
CHANGED
|
@@ -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",
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
-
? readFileSync(
|
|
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:
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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;
|