blume 0.5.3 → 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 +2985 -6883
- package/dist/cli/index.js.map +27 -58
- 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 +2 -0
- package/docs/configuration/index.mdx +13 -17
- package/docs/configuration/theming.mdx +2 -10
- package/docs/content/components.mdx +2 -53
- package/docs/content/i18n.mdx +0 -4
- 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/astro/generate.ts +15 -12
- package/src/astro/integration.ts +0 -21
- package/src/astro/templates.ts +9 -16
- package/src/cli/commands/dev.ts +0 -6
- package/src/cli/commands/validate.ts +0 -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 +2 -2
- package/src/components/content/Column.astro +1 -1
- package/src/components/content/Prompt.astro +1 -1
- package/src/components/content/Step.astro +3 -4
- package/src/components/content/Tab.astro +2 -3
- package/src/components/content/TypeTable.astro +5 -5
- package/src/components/content/Update.astro +1 -1
- 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/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 +5 -5
- package/src/components/layout/nav-utils.ts +9 -4
- package/src/components/openapi/ApiOverview.astro +4 -50
- package/src/components/openapi/ApiTagOperations.astro +42 -0
- package/src/core/builtin-tags.ts +1 -3
- package/src/core/config.ts +5 -28
- package/src/core/data.ts +2 -2
- package/src/core/graph.ts +1 -4
- package/src/core/links.ts +5 -19
- package/src/core/meta.ts +1 -1
- package/src/core/navigation.ts +0 -5
- package/src/core/project-graph.ts +0 -5
- package/src/core/schema.ts +13 -90
- package/src/core/sources/resolve.ts +0 -13
- package/src/core/sources/watch.ts +3 -2
- package/src/core/types.ts +1 -8
- package/src/openapi/parse.ts +197 -14
- package/src/openapi/render-mdx.ts +44 -10
- package/src/openapi/source.ts +19 -2
- package/src/theme/entry.ts +45 -17
- package/src/theme/icons.ts +18 -109
- package/src/theme/palette.ts +4 -37
- package/src/theme/twoslash.ts +6 -1
- package/dist/types/core/bridge.d.ts +0 -24
- package/dist/types/core/package-json.d.ts +0 -12
- 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/dist/types/migrate/shared.d.ts +0 -153
- package/docs/advanced/bridge.mdx +0 -76
- package/docs/advanced/migrate.mdx +0 -124
- package/src/astro/static-assets.ts +0 -124
- 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 -190
- package/src/migrate/fumadocs/config.ts +0 -155
- package/src/migrate/fumadocs/content.ts +0 -376
- package/src/migrate/fumadocs/frontmatter.ts +0 -18
- package/src/migrate/fumadocs/groups.ts +0 -237
- package/src/migrate/fumadocs/index.ts +0 -355
- 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 -954
- 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 -459
- package/src/migrate/mintlify/snippets.ts +0 -315
- 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 -389
- package/src/migrate/nextra/meta.ts +0 -266
- package/src/migrate/shared.ts +0 -801
- package/src/migrate/starlight/config.ts +0 -455
- package/src/migrate/starlight/content.ts +0 -75
- package/src/migrate/starlight/frontmatter.ts +0 -111
- package/src/migrate/starlight/i18n.ts +0 -54
- package/src/migrate/starlight/index.ts +0 -131
package/src/core/navigation.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
SidebarItemConfig,
|
|
7
7
|
} from "./schema.ts";
|
|
8
8
|
import type {
|
|
9
|
-
NavChromeVariant,
|
|
10
9
|
NavNode,
|
|
11
10
|
Navigation,
|
|
12
11
|
NavSelector,
|
|
@@ -346,7 +345,6 @@ const buildConfigSidebar = (
|
|
|
346
345
|
export const buildNavigation = (
|
|
347
346
|
pages: PageRecord[],
|
|
348
347
|
options: {
|
|
349
|
-
chromeVariants?: NavChromeVariant[];
|
|
350
348
|
folderMeta: Map<string, FolderMeta>;
|
|
351
349
|
selectors?: NavSelector[];
|
|
352
350
|
tabs?: NavTab[];
|
|
@@ -363,7 +361,6 @@ export const buildNavigation = (
|
|
|
363
361
|
sharedFolderMeta?: Map<string, FolderMeta>;
|
|
364
362
|
}
|
|
365
363
|
): Navigation => {
|
|
366
|
-
const chromeVariants = options.chromeVariants ?? [];
|
|
367
364
|
const selectors = options.selectors ?? [];
|
|
368
365
|
const tabs = options.tabs ?? [];
|
|
369
366
|
const metaPrefix = options.metaPrefix ?? "";
|
|
@@ -377,7 +374,6 @@ export const buildNavigation = (
|
|
|
377
374
|
|
|
378
375
|
if (options.sidebar) {
|
|
379
376
|
return {
|
|
380
|
-
chromeVariants,
|
|
381
377
|
selectors,
|
|
382
378
|
sidebar: buildConfigSidebar(options.sidebar, byRoute),
|
|
383
379
|
tabs,
|
|
@@ -385,7 +381,6 @@ export const buildNavigation = (
|
|
|
385
381
|
}
|
|
386
382
|
|
|
387
383
|
return {
|
|
388
|
-
chromeVariants,
|
|
389
384
|
selectors,
|
|
390
385
|
sidebar: buildFileSystemSidebar(
|
|
391
386
|
pages,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { loadConfig } from "./config.ts";
|
|
2
|
-
import type { ConfigBridge } from "./config.ts";
|
|
3
2
|
import { buildContentGraph } from "./graph.ts";
|
|
4
3
|
import { i18nDiagnostics } from "./i18n.ts";
|
|
5
4
|
import {
|
|
@@ -69,8 +68,6 @@ export interface BlumeProject {
|
|
|
69
68
|
diagnostics: Diagnostic[];
|
|
70
69
|
/** The instantiated content sources, for lazy entry reads (search/AI/raw). */
|
|
71
70
|
sources: ContentSource[];
|
|
72
|
-
/** Set when serving a foreign docs config (e.g. Mintlify) without migrating. */
|
|
73
|
-
bridge: ConfigBridge | null;
|
|
74
71
|
}
|
|
75
72
|
|
|
76
73
|
/**
|
|
@@ -97,7 +94,6 @@ export const scanProject = async (
|
|
|
97
94
|
const configResult = await loadConfig(root, {
|
|
98
95
|
devServerUrl: options.devServerUrl,
|
|
99
96
|
});
|
|
100
|
-
const { bridge } = configResult;
|
|
101
97
|
const config = applyConfigOverrides(configResult.config, options.overrides);
|
|
102
98
|
const context = resolveProjectContext(root, config, {
|
|
103
99
|
runtimeDir: options.runtimeDir,
|
|
@@ -179,7 +175,6 @@ export const scanProject = async (
|
|
|
179
175
|
const i18nWarnings = config.i18n ? i18nDiagnostics(pages, config.i18n) : [];
|
|
180
176
|
|
|
181
177
|
return {
|
|
182
|
-
bridge,
|
|
183
178
|
config,
|
|
184
179
|
context,
|
|
185
180
|
diagnostics: [
|
package/src/core/schema.ts
CHANGED
|
@@ -99,25 +99,15 @@ const pageMetaBaseSchema = z
|
|
|
99
99
|
deprecated: z.boolean().default(false),
|
|
100
100
|
description: z.string().optional(),
|
|
101
101
|
draft: z.boolean().default(false),
|
|
102
|
-
groups: z.union([z.string(), z.array(z.string())]).optional(),
|
|
103
102
|
hidden: z.boolean().default(false),
|
|
104
|
-
hideApiMarker: z.boolean().default(false),
|
|
105
|
-
hideFooterPagination: z.boolean().optional(),
|
|
106
103
|
icon: iconName.optional(),
|
|
107
|
-
iconType: z.string().optional(),
|
|
108
|
-
keywords: z.array(z.string()).optional(),
|
|
109
104
|
/** Overrides the git-derived last-modified date when `lastModified` is on. */
|
|
110
105
|
lastModified: dateSchema.optional(),
|
|
111
|
-
mode: z.string().optional(),
|
|
112
106
|
noindex: z.boolean().default(false),
|
|
113
|
-
public: z.boolean().optional(),
|
|
114
|
-
rss: z.boolean().optional(),
|
|
115
107
|
search: searchMetaSchema.default({}),
|
|
116
108
|
seo: seoMetaSchema.default({}),
|
|
117
109
|
sidebar: sidebarMetaSchema.default({}),
|
|
118
|
-
sidebarTitle: z.string().optional(),
|
|
119
110
|
slug: z.string().optional(),
|
|
120
|
-
tag: z.string().optional(),
|
|
121
111
|
title: z.string().optional(),
|
|
122
112
|
type: z.string().default("doc"),
|
|
123
113
|
})
|
|
@@ -160,45 +150,41 @@ export type FolderMeta = z.infer<typeof folderMetaSchema>;
|
|
|
160
150
|
// Project config (blume.config.ts)
|
|
161
151
|
// ---------------------------------------------------------------------------
|
|
162
152
|
|
|
163
|
-
|
|
153
|
+
/** The logo mark: a single image path/URL, or light/dark variants with alt text. */
|
|
154
|
+
const logoImageSchema = z.union([
|
|
164
155
|
z.string(),
|
|
165
156
|
z
|
|
166
157
|
.object({
|
|
167
158
|
alt: z.string().optional(),
|
|
168
159
|
dark: z.string().optional(),
|
|
169
|
-
href: z.string().optional(),
|
|
170
160
|
light: z.string().optional(),
|
|
171
161
|
})
|
|
172
162
|
.strict(),
|
|
173
163
|
]);
|
|
174
164
|
|
|
175
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Site logo. A bare string is the image shorthand. The object form splits the
|
|
167
|
+
* brand into an optional `image` mark and optional wordmark `text` so a site can
|
|
168
|
+
* show an image-only logo (a mark with the wordmark baked in), a text-only logo,
|
|
169
|
+
* or both. Omit `text` to fall back to the site title; set `text: ""` to render
|
|
170
|
+
* the mark alone. `href` overrides the brand link (defaults to `/`).
|
|
171
|
+
*/
|
|
172
|
+
const logoConfigSchema = z.union([
|
|
176
173
|
z.string(),
|
|
177
174
|
z
|
|
178
175
|
.object({
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
href: z.string().optional(),
|
|
177
|
+
image: logoImageSchema.optional(),
|
|
178
|
+
text: z.string().optional(),
|
|
181
179
|
})
|
|
182
180
|
.strict(),
|
|
183
181
|
]);
|
|
184
182
|
|
|
185
|
-
const bannerColorSchema = z
|
|
186
|
-
.object({
|
|
187
|
-
dark: z.string().optional(),
|
|
188
|
-
light: z.string().optional(),
|
|
189
|
-
})
|
|
190
|
-
.strict()
|
|
191
|
-
.refine((value) => value.dark !== undefined || value.light !== undefined, {
|
|
192
|
-
message: "Banner color requires at least one of light or dark.",
|
|
193
|
-
});
|
|
194
|
-
|
|
195
183
|
/** Site-wide announcement banner: a string, or text with an optional link. */
|
|
196
184
|
const bannerConfigSchema = z.union([
|
|
197
185
|
z.string(),
|
|
198
186
|
z
|
|
199
187
|
.object({
|
|
200
|
-
/** Background color override (Mintlify compatibility). */
|
|
201
|
-
color: bannerColorSchema.optional(),
|
|
202
188
|
content: z.string(),
|
|
203
189
|
/** Show a dismiss button; the choice is remembered per visitor. */
|
|
204
190
|
dismissible: z.boolean().default(false),
|
|
@@ -208,8 +194,6 @@ const bannerConfigSchema = z.union([
|
|
|
208
194
|
.object({ href: z.string(), text: z.string() })
|
|
209
195
|
.strict()
|
|
210
196
|
.optional(),
|
|
211
|
-
/** Tone (Mintlify compatibility). */
|
|
212
|
-
type: z.enum(["info", "warning", "critical"]).optional(),
|
|
213
197
|
})
|
|
214
198
|
.strict(),
|
|
215
199
|
]);
|
|
@@ -329,30 +313,6 @@ const githubReleasesSourceSchema = z
|
|
|
329
313
|
})
|
|
330
314
|
.strict();
|
|
331
315
|
|
|
332
|
-
/**
|
|
333
|
-
* In-place Mintlify content (`docs.json` + MDX). Powers "bridge mode": Blume
|
|
334
|
-
* reads an unconverted Mintlify project, transforming each page to Blume MDX at
|
|
335
|
-
* scan time. Injected automatically by `loadConfig` when a `docs.json` is found
|
|
336
|
-
* and no `blume.config.*` exists; can also be configured explicitly.
|
|
337
|
-
*/
|
|
338
|
-
const mintlifySourceSchema = z
|
|
339
|
-
.object({
|
|
340
|
-
/** Absolute path of the `docs.json`/`mint.json`, watched for changes in dev. */
|
|
341
|
-
configFile: z.string().optional(),
|
|
342
|
-
/** Patterns excluded from page discovery (Mintlify defaults are merged in). */
|
|
343
|
-
exclude: z.array(z.string()).default([]),
|
|
344
|
-
/** Glob patterns for Mintlify content files. */
|
|
345
|
-
include: z.array(z.string()).default(["**/*.{md,mdx}"]),
|
|
346
|
-
/** Namespaces the source's routes under `/<prefix>/`. */
|
|
347
|
-
prefix: z.string().optional(),
|
|
348
|
-
/** Content root, absolute or relative to the project root (Mintlify: `.`). */
|
|
349
|
-
root: z.string().default("."),
|
|
350
|
-
type: z.literal("mintlify"),
|
|
351
|
-
/** `docs.json` variables, inlined into content (`{{name}}`) at scan time. */
|
|
352
|
-
variables: z.record(z.string(), z.string()).default({}),
|
|
353
|
-
})
|
|
354
|
-
.strict();
|
|
355
|
-
|
|
356
316
|
/**
|
|
357
317
|
* A user-provided `ContentSource` instance, passed straight through from
|
|
358
318
|
* `blume.config.ts`. This is the extension point that lets adapters with custom
|
|
@@ -377,7 +337,6 @@ const contentSourceSchema = z.discriminatedUnion("type", [
|
|
|
377
337
|
githubReleasesSourceSchema,
|
|
378
338
|
sanitySourceSchema,
|
|
379
339
|
notionSourceSchema,
|
|
380
|
-
mintlifySourceSchema,
|
|
381
340
|
customSourceSchema,
|
|
382
341
|
]);
|
|
383
342
|
|
|
@@ -386,13 +345,6 @@ export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
|
|
|
386
345
|
|
|
387
346
|
const contentConfigSchema = z
|
|
388
347
|
.object({
|
|
389
|
-
/**
|
|
390
|
-
* Extra top-level directories (relative to the project root) served as
|
|
391
|
-
* static assets at the site root, alongside `public/`. Lets projects keep
|
|
392
|
-
* root-served asset folders in place — e.g. a Mintlify migration keeps
|
|
393
|
-
* `images/` where it is instead of relocating it under `public/`.
|
|
394
|
-
*/
|
|
395
|
-
assets: z.array(z.string()).default([]),
|
|
396
348
|
defaultType: z.string().default("doc"),
|
|
397
349
|
exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
|
|
398
350
|
include: z.array(z.string()).default(["**/*.{md,mdx}"]),
|
|
@@ -483,10 +435,6 @@ const sidebarItemSchema: z.ZodType<SidebarItemConfig> = z.lazy(() =>
|
|
|
483
435
|
])
|
|
484
436
|
);
|
|
485
437
|
|
|
486
|
-
const variablesConfigSchema = z
|
|
487
|
-
.record(z.string().regex(/^[A-Za-z0-9-]+$/u), z.string())
|
|
488
|
-
.default({});
|
|
489
|
-
|
|
490
438
|
/** A curated Google Font slug (see `theme/fonts.ts`). */
|
|
491
439
|
const fontSlug = z.string().refine(isFontSlug, (value) => ({
|
|
492
440
|
message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}.`,
|
|
@@ -499,7 +447,6 @@ const themeConfigSchema = z
|
|
|
499
447
|
action: z.string().optional(),
|
|
500
448
|
background: z.string().optional(),
|
|
501
449
|
backgroundDark: z.string().optional(),
|
|
502
|
-
backgroundDecoration: z.enum(["gradient", "grid", "windows"]).optional(),
|
|
503
450
|
backgroundImage: z.string().optional(),
|
|
504
451
|
backgroundImageDark: z.string().optional(),
|
|
505
452
|
fonts: z
|
|
@@ -584,7 +531,6 @@ const searchConfigSchema = z
|
|
|
584
531
|
.default({}),
|
|
585
532
|
mixedbread: mixedbreadSearchSchema.optional(),
|
|
586
533
|
oramaCloud: oramaCloudSearchSchema.optional(),
|
|
587
|
-
prompt: z.string().optional(),
|
|
588
534
|
provider: z.enum(searchProviders).default("orama"),
|
|
589
535
|
typesense: typesenseSearchSchema.optional(),
|
|
590
536
|
})
|
|
@@ -644,16 +590,8 @@ const aiConfigSchema = z
|
|
|
644
590
|
})
|
|
645
591
|
.strict();
|
|
646
592
|
|
|
647
|
-
const chromeVariantSchema = z
|
|
648
|
-
.object({
|
|
649
|
-
banner: bannerConfigSchema.optional(),
|
|
650
|
-
path: z.string(),
|
|
651
|
-
})
|
|
652
|
-
.strict();
|
|
653
|
-
|
|
654
593
|
const navigationConfigSchema = z
|
|
655
594
|
.object({
|
|
656
|
-
chromeVariants: z.array(chromeVariantSchema).default([]),
|
|
657
595
|
/** Show a GitHub repo link in the header (requires `github` configured). */
|
|
658
596
|
repo: z.boolean().default(true),
|
|
659
597
|
selectors: z.array(navSelectorSchema).default([]),
|
|
@@ -824,7 +762,6 @@ const rssConfigSchema = z
|
|
|
824
762
|
/** Discoverability features: OG images, feeds, sitemap, structured data. */
|
|
825
763
|
const seoConfigSchema = z
|
|
826
764
|
.object({
|
|
827
|
-
metatags: z.record(z.string(), z.string()).default({}),
|
|
828
765
|
og: ogConfigSchema.default({}),
|
|
829
766
|
/** Generate robots.txt (with a Sitemap reference when available). */
|
|
830
767
|
robots: z.boolean().default(true),
|
|
@@ -1001,17 +938,6 @@ const tocConfigSchema = z
|
|
|
1001
938
|
};
|
|
1002
939
|
});
|
|
1003
940
|
|
|
1004
|
-
/**
|
|
1005
|
-
* Which icon library bare `icon` names resolve against (mirrors Mintlify's
|
|
1006
|
-
* `icons.library`). Names can always opt into a specific set with an explicit
|
|
1007
|
-
* `prefix:name` (`lucide:rocket`, `fa6-brands:github`) regardless of this.
|
|
1008
|
-
*/
|
|
1009
|
-
const iconsConfigSchema = z
|
|
1010
|
-
.object({
|
|
1011
|
-
library: z.enum(["lucide", "fontawesome", "tabler"]).default("lucide"),
|
|
1012
|
-
})
|
|
1013
|
-
.strict();
|
|
1014
|
-
|
|
1015
941
|
export const blumeConfigSchema = z
|
|
1016
942
|
.object({
|
|
1017
943
|
ai: aiConfigSchema.default({}),
|
|
@@ -1036,11 +962,9 @@ export const blumeConfigSchema = z
|
|
|
1036
962
|
*/
|
|
1037
963
|
examples: z.string().default("examples"),
|
|
1038
964
|
export: exportConfigSchema.default(false),
|
|
1039
|
-
favicon: faviconConfigSchema.optional(),
|
|
1040
965
|
feedback: z.boolean().default(true),
|
|
1041
966
|
github: githubConfigSchema.optional(),
|
|
1042
967
|
i18n: i18nConfigSchema.optional(),
|
|
1043
|
-
icons: iconsConfigSchema.default({}),
|
|
1044
968
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1045
969
|
logo: logoConfigSchema.optional(),
|
|
1046
970
|
markdown: markdownConfigSchema.default({}),
|
|
@@ -1053,7 +977,6 @@ export const blumeConfigSchema = z
|
|
|
1053
977
|
theme: themeConfigSchema.default({}),
|
|
1054
978
|
title: z.string().default("Documentation"),
|
|
1055
979
|
toc: tocConfigSchema,
|
|
1056
|
-
variables: variablesConfigSchema,
|
|
1057
980
|
})
|
|
1058
981
|
.strict();
|
|
1059
982
|
|
|
@@ -7,7 +7,6 @@ import type { ProjectContext } from "../types.ts";
|
|
|
7
7
|
import { filesystemSource } from "./filesystem.ts";
|
|
8
8
|
import { githubReleasesSource } from "./github-releases.ts";
|
|
9
9
|
import { mdxRemoteSource } from "./mdx-remote.ts";
|
|
10
|
-
import { mintlifySource } from "./mintlify.ts";
|
|
11
10
|
import { notionSource } from "./notion.ts";
|
|
12
11
|
import { sanitySource } from "./sanity.ts";
|
|
13
12
|
import type { ContentSource, SourceContext } from "./types.ts";
|
|
@@ -64,18 +63,6 @@ const buildSource = (
|
|
|
64
63
|
root: def.root,
|
|
65
64
|
});
|
|
66
65
|
}
|
|
67
|
-
if (def.type === "mintlify") {
|
|
68
|
-
return mintlifySource({
|
|
69
|
-
configFile: def.configFile,
|
|
70
|
-
exclude: def.exclude,
|
|
71
|
-
include: def.include,
|
|
72
|
-
name,
|
|
73
|
-
prefix: def.prefix,
|
|
74
|
-
projectRoot: context.root,
|
|
75
|
-
root: def.root,
|
|
76
|
-
variables: def.variables,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
66
|
if (def.type === "custom") {
|
|
80
67
|
// A user-provided instance manages its own context/caching; we only ensure
|
|
81
68
|
// its name is unique across the project for id namespacing.
|
|
@@ -2,8 +2,9 @@ import type { WatchListener } from "node:fs";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Directory segments a recursive dev watcher must never react to. When a
|
|
5
|
-
* source's content root is the project root — a
|
|
6
|
-
*
|
|
5
|
+
* source's content root is the project root — a `.`-rooted layout, or an
|
|
6
|
+
* all-staged project (openapi/notion/github-releases/…) with no filesystem
|
|
7
|
+
* source — a naive recursive `fs.watch` also sees Blume's own `.blume/`
|
|
7
8
|
* output, which the dev server rewrites on every render (e.g.
|
|
8
9
|
* `.blume/.astro/data-store.json`). Left unfiltered, each such write re-triggers
|
|
9
10
|
* a rescan + runtime regeneration whose writes land back under `.blume/` and
|
package/src/core/types.ts
CHANGED
|
@@ -173,24 +173,17 @@ export interface NavSelectorItem {
|
|
|
173
173
|
tag?: string;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
/** Top-level
|
|
176
|
+
/** Top-level partition selectors (products, versions, languages). */
|
|
177
177
|
export interface NavSelector {
|
|
178
178
|
label: string;
|
|
179
179
|
kind: "dropdown" | "language" | "product" | "version";
|
|
180
180
|
items: NavSelectorItem[];
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
/** Chrome overrides used when the current route belongs to a nav partition. */
|
|
184
|
-
export interface NavChromeVariant {
|
|
185
|
-
path: string;
|
|
186
|
-
banner?: ResolvedConfig["banner"];
|
|
187
|
-
}
|
|
188
|
-
|
|
189
183
|
/** The complete navigation model derived from the content graph. */
|
|
190
184
|
export interface Navigation {
|
|
191
185
|
tabs: NavTab[];
|
|
192
186
|
selectors: NavSelector[];
|
|
193
|
-
chromeVariants: NavChromeVariant[];
|
|
194
187
|
sidebar: NavNode[];
|
|
195
188
|
/** Repo URL for the header link, or null when hidden (`navigation.repo`). */
|
|
196
189
|
repoUrl?: string | null;
|
package/src/openapi/parse.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
3
|
|
|
3
4
|
import { normalize, upgrade } from "@scalar/openapi-parser";
|
|
4
5
|
import { isAbsolute, join } from "pathe";
|
|
5
6
|
|
|
7
|
+
import { hashText } from "../core/sources/cache.ts";
|
|
6
8
|
import type { ApiDocument } from "./model.ts";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -10,39 +12,220 @@ import type { ApiDocument } from "./model.ts";
|
|
|
10
12
|
* (`@scalar/openapi-parser`) to read a spec (YAML or JSON), then upgrade Swagger
|
|
11
13
|
* 2.0 / OpenAPI 3.0 documents to 3.1 so the renderer only handles one shape.
|
|
12
14
|
* Internal `$ref`s are deliberately left in place (see `model.ts`).
|
|
15
|
+
*
|
|
16
|
+
* Remote (`http(s)`) specs are fetched defensively — bounded per attempt, retried
|
|
17
|
+
* on transient failures, proxy-aware, and cached on disk — mirroring the
|
|
18
|
+
* resilience the external link checker (`core/links.ts`) and the Notion source
|
|
19
|
+
* (`core/sources/notion.ts`) already have. A bare `fetch` is the classic "curl
|
|
20
|
+
* works but the build doesn't" gap: it ignores `*_PROXY`, has no timeout, and
|
|
21
|
+
* dies on a single cold-start blip.
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
const URL_SPEC = /^https?:\/\//u;
|
|
16
25
|
|
|
26
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
27
|
+
const MAX_ATTEMPTS = 3;
|
|
28
|
+
const BASE_BACKOFF_MS = 500;
|
|
29
|
+
const SECOND_MS = 1000;
|
|
30
|
+
// Worth another try: request timeout, too-early, rate-limited, and the 5xx range.
|
|
31
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
32
|
+
const USER_AGENT = "blume (+https://github.com/haydenbleasel/blume)";
|
|
33
|
+
const PROXY_ENV_VARS = [
|
|
34
|
+
"HTTP_PROXY",
|
|
35
|
+
"http_proxy",
|
|
36
|
+
"HTTPS_PROXY",
|
|
37
|
+
"https_proxy",
|
|
38
|
+
"ALL_PROXY",
|
|
39
|
+
"all_proxy",
|
|
40
|
+
];
|
|
41
|
+
|
|
17
42
|
export interface ParsedSpec {
|
|
18
43
|
document: ApiDocument;
|
|
19
44
|
warnings: string[];
|
|
20
45
|
}
|
|
21
46
|
|
|
47
|
+
/** Where and whether to cache a remote spec's text between runs. */
|
|
48
|
+
export interface SpecFetchOptions {
|
|
49
|
+
/** Dir for a last-good on-disk copy of a remote spec (offline fallback). */
|
|
50
|
+
cacheDir?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Re-fetch even when a cached copy exists. Builds/sync refresh; dev is
|
|
53
|
+
* cache-first for fast, offline-tolerant restarts (see `SourceContext`).
|
|
54
|
+
*/
|
|
55
|
+
refresh?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Route Node's global `fetch` through an HTTP(S) proxy the first time a remote
|
|
60
|
+
* spec is fetched with one configured. Node's built-in `fetch` ignores `*_PROXY`
|
|
61
|
+
* on its own; undici's env proxy agent, installed on the shared global-dispatcher
|
|
62
|
+
* symbol, wires it in without replacing `fetch` (so tests can still stub it).
|
|
63
|
+
* Best-effort and lazy: no proxy env means no undici import at all, and an
|
|
64
|
+
* unavailable undici just leaves the direct connection in place.
|
|
65
|
+
*/
|
|
66
|
+
let proxyInstalled = false;
|
|
67
|
+
const ensureProxyDispatcher = async (): Promise<void> => {
|
|
68
|
+
// Only memoize a successful install: with no proxy configured we cheaply
|
|
69
|
+
// re-check each time, so a proxy set later in the process still takes effect.
|
|
70
|
+
if (proxyInstalled || !PROXY_ENV_VARS.some((name) => process.env[name])) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
|
|
75
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
76
|
+
proxyInstalled = true;
|
|
77
|
+
} catch {
|
|
78
|
+
// No proxy support available; fall back to a direct connection.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** `Retry-After` in ms when the server sent a sane one, else undefined. */
|
|
83
|
+
const retryAfterMs = (response: Response): number | undefined => {
|
|
84
|
+
const header = response.headers.get("retry-after");
|
|
85
|
+
const seconds = header ? Number(header) : Number.NaN;
|
|
86
|
+
return Number.isFinite(seconds) && seconds > 0
|
|
87
|
+
? seconds * SECOND_MS
|
|
88
|
+
: undefined;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** One fetch attempt, normalized: the body text, or a (maybe-retryable) error. */
|
|
92
|
+
type Attempt =
|
|
93
|
+
| { text: string }
|
|
94
|
+
| { error: Error; retryable: boolean; retryAfter?: number };
|
|
95
|
+
|
|
96
|
+
const attemptFetch = async (spec: string): Promise<Attempt> => {
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(spec, {
|
|
99
|
+
headers: {
|
|
100
|
+
accept: "application/json, application/yaml, text/yaml, */*",
|
|
101
|
+
"user-agent": USER_AGENT,
|
|
102
|
+
},
|
|
103
|
+
redirect: "follow",
|
|
104
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
105
|
+
});
|
|
106
|
+
if (response.ok) {
|
|
107
|
+
return { text: await response.text() };
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
error: new Error(`${spec} -> ${response.status} ${response.statusText}`),
|
|
111
|
+
retryAfter: retryAfterMs(response),
|
|
112
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// Network error, DNS/TLS failure, or an aborted (timed-out) request — all
|
|
116
|
+
// transient by nature, so worth a retry.
|
|
117
|
+
return {
|
|
118
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
119
|
+
retryable: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Fetch a remote spec's text, retrying transient failures with backoff. */
|
|
125
|
+
const fetchSpecText = async (spec: string): Promise<string> => {
|
|
126
|
+
await ensureProxyDispatcher();
|
|
127
|
+
let last: Attempt = {
|
|
128
|
+
error: new Error(`Could not fetch ${spec}`),
|
|
129
|
+
retryable: false,
|
|
130
|
+
};
|
|
131
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
|
132
|
+
// oxlint-disable-next-line no-await-in-loop -- sequential retry attempts
|
|
133
|
+
last = await attemptFetch(spec);
|
|
134
|
+
if ("text" in last) {
|
|
135
|
+
return last.text;
|
|
136
|
+
}
|
|
137
|
+
if (!last.retryable || attempt === MAX_ATTEMPTS - 1) {
|
|
138
|
+
throw last.error;
|
|
139
|
+
}
|
|
140
|
+
// oxlint-disable-next-line no-await-in-loop -- back off before retrying
|
|
141
|
+
await sleep(last.retryAfter ?? BASE_BACKOFF_MS * 2 ** attempt);
|
|
142
|
+
}
|
|
143
|
+
throw last.error;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const cacheFileFor = (cacheDir: string, spec: string): string =>
|
|
147
|
+
join(cacheDir, `spec-${hashText(spec)}.cache`);
|
|
148
|
+
|
|
149
|
+
const readCache = async (file: string): Promise<string | undefined> => {
|
|
150
|
+
try {
|
|
151
|
+
return await readFile(file, "utf-8");
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const writeCache = async (
|
|
158
|
+
dir: string,
|
|
159
|
+
file: string,
|
|
160
|
+
text: string
|
|
161
|
+
): Promise<void> => {
|
|
162
|
+
try {
|
|
163
|
+
await mkdir(dir, { recursive: true });
|
|
164
|
+
await writeFile(file, text, "utf-8");
|
|
165
|
+
} catch {
|
|
166
|
+
// Best-effort cache; a write failure must not fail the load.
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
22
170
|
/** Read a spec's raw text from an `http(s)` URL or a local (project-relative) path. */
|
|
23
|
-
const readSpecText = async (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
171
|
+
const readSpecText = async (
|
|
172
|
+
spec: string,
|
|
173
|
+
root: string,
|
|
174
|
+
options: SpecFetchOptions
|
|
175
|
+
): Promise<{ text: string; warnings: string[] }> => {
|
|
176
|
+
if (!URL_SPEC.test(spec)) {
|
|
177
|
+
const absolute = isAbsolute(spec) ? spec : join(root, spec);
|
|
178
|
+
return { text: await readFile(absolute, "utf-8"), warnings: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cacheFile = options.cacheDir
|
|
182
|
+
? cacheFileFor(options.cacheDir, spec)
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
// Cache-first in dev: serve the last-good snapshot without touching the network.
|
|
186
|
+
if (cacheFile && options.refresh === false) {
|
|
187
|
+
const cached = await readCache(cacheFile);
|
|
188
|
+
if (cached !== undefined) {
|
|
189
|
+
return { text: cached, warnings: [] };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const text = await fetchSpecText(spec);
|
|
195
|
+
if (options.cacheDir && cacheFile) {
|
|
196
|
+
await writeCache(options.cacheDir, cacheFile, text);
|
|
197
|
+
}
|
|
198
|
+
return { text, warnings: [] };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// A transient outage falls back to the last good fetch, with a warning.
|
|
201
|
+
if (cacheFile) {
|
|
202
|
+
const cached = await readCache(cacheFile);
|
|
203
|
+
if (cached !== undefined) {
|
|
204
|
+
return {
|
|
205
|
+
text: cached,
|
|
206
|
+
warnings: [
|
|
207
|
+
`Could not fetch ${spec} (${(error as Error).message}); using the last cached copy.`,
|
|
208
|
+
],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
28
211
|
}
|
|
29
|
-
|
|
212
|
+
throw error;
|
|
30
213
|
}
|
|
31
|
-
const absolute = isAbsolute(spec) ? spec : join(root, spec);
|
|
32
|
-
return await readFile(absolute, "utf-8");
|
|
33
214
|
};
|
|
34
215
|
|
|
35
216
|
/**
|
|
36
217
|
* Read, normalize, and upgrade a spec to an OpenAPI 3.1 document. Throws when the
|
|
37
|
-
* spec can't be read; callers turn that into a source
|
|
38
|
-
*
|
|
218
|
+
* spec can't be read and no cache is available; callers turn that into a source
|
|
219
|
+
* diagnostic (an error in build, a warning in dev) rather than a hard failure so
|
|
220
|
+
* a broken spec doesn't take down the whole build.
|
|
39
221
|
*/
|
|
40
222
|
export const parseSpec = async (
|
|
41
223
|
spec: string,
|
|
42
|
-
root: string
|
|
224
|
+
root: string,
|
|
225
|
+
options: SpecFetchOptions = {}
|
|
43
226
|
): Promise<ParsedSpec> => {
|
|
44
|
-
const text = await readSpecText(spec, root);
|
|
227
|
+
const { text, warnings } = await readSpecText(spec, root, options);
|
|
45
228
|
const normalized = normalize(text);
|
|
46
229
|
const { specification } = upgrade(normalized);
|
|
47
|
-
return { document: specification as ApiDocument, warnings
|
|
230
|
+
return { document: specification as ApiDocument, warnings };
|
|
48
231
|
};
|
|
@@ -73,13 +73,47 @@ export const operationMdx = (
|
|
|
73
73
|
};
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
export const overviewMdx = (spec: ApiSpecData): RenderedPage =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
|
|
77
|
+
// Tag sections: declared tags in spec order, then any tag an operation
|
|
78
|
+
// references that isn't declared under `tags`. The section headings are
|
|
79
|
+
// emitted as real markdown `##` (not markup inside a component) so the
|
|
80
|
+
// markdown pipeline gives them ids, permalink anchors, and table-of-contents
|
|
81
|
+
// entries; only the operation-link list defers to a component.
|
|
82
|
+
const operations = Object.values(spec.operations);
|
|
83
|
+
const sections = [...spec.tags];
|
|
84
|
+
const known = new Set(spec.tags.map((tag) => tag.slug));
|
|
85
|
+
for (const operation of operations) {
|
|
86
|
+
if (!known.has(operation.tagSlug)) {
|
|
87
|
+
known.add(operation.tagSlug);
|
|
88
|
+
sections.push({
|
|
89
|
+
description: "",
|
|
90
|
+
name: operation.tag,
|
|
91
|
+
slug: operation.tagSlug,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const tagSections = sections
|
|
96
|
+
.filter((tag) =>
|
|
97
|
+
operations.some((operation) => operation.tagSlug === tag.slug)
|
|
98
|
+
)
|
|
99
|
+
.map((tag) =>
|
|
100
|
+
[
|
|
101
|
+
`## ${mdxSafe(tag.name)}`,
|
|
102
|
+
...(tag.description.trim() ? [mdxSafe(tag.description.trim())] : []),
|
|
103
|
+
`<ApiTagOperations source="${spec.slug}" tag="${tag.slug}" />`,
|
|
104
|
+
].join("\n\n")
|
|
105
|
+
);
|
|
106
|
+
return {
|
|
107
|
+
body: [
|
|
108
|
+
withDescription(
|
|
109
|
+
spec.description,
|
|
110
|
+
`<ApiOverview source="${spec.slug}" />`
|
|
111
|
+
),
|
|
112
|
+
...tagSections,
|
|
113
|
+
].join("\n\n"),
|
|
114
|
+
data: {
|
|
115
|
+
sidebar: { label: "Overview" },
|
|
116
|
+
title: spec.title || spec.label,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|