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.
Files changed (113) hide show
  1. package/dist/cli/index.js +2985 -6883
  2. package/dist/cli/index.js.map +27 -58
  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 +2 -0
  10. package/docs/configuration/index.mdx +13 -17
  11. package/docs/configuration/theming.mdx +2 -10
  12. package/docs/content/components.mdx +2 -53
  13. package/docs/content/i18n.mdx +0 -4
  14. package/docs/content/syntax.mdx +1 -1
  15. package/docs/index.mdx +0 -1
  16. package/docs/reference/cli.mdx +12 -13
  17. package/package.json +4 -6
  18. package/skills/blume/SKILL.md +71 -0
  19. package/skills/blume-update-docs/SKILL.md +52 -0
  20. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  21. package/src/astro/generate.ts +15 -12
  22. package/src/astro/integration.ts +0 -21
  23. package/src/astro/templates.ts +9 -16
  24. package/src/cli/commands/dev.ts +0 -6
  25. package/src/cli/commands/validate.ts +0 -2
  26. package/src/cli/index.ts +0 -2
  27. package/src/components/BlumePage.astro +0 -6
  28. package/src/components/Icon.astro +1 -12
  29. package/src/components/content/AccordionItem.astro +3 -6
  30. package/src/components/content/Badge.astro +1 -3
  31. package/src/components/content/Callout.astro +3 -9
  32. package/src/components/content/Card.astro +2 -3
  33. package/src/components/content/ColorItem.astro +2 -2
  34. package/src/components/content/Column.astro +1 -1
  35. package/src/components/content/Prompt.astro +1 -1
  36. package/src/components/content/Step.astro +3 -4
  37. package/src/components/content/Tab.astro +2 -3
  38. package/src/components/content/TypeTable.astro +5 -5
  39. package/src/components/content/Update.astro +1 -1
  40. package/src/components/layout/Header.astro +26 -3
  41. package/src/components/layout/Logo.astro +5 -1
  42. package/src/components/layout/NavSelector.astro +1 -1
  43. package/src/components/layout/NavTree.astro +14 -14
  44. package/src/components/layout/PageLayout.astro +42 -0
  45. package/src/components/layout/ReferenceLayout.astro +1 -0
  46. package/src/components/layout/RootLayout.astro +31 -2
  47. package/src/components/layout/Search.astro +5 -5
  48. package/src/components/layout/nav-utils.ts +9 -4
  49. package/src/components/openapi/ApiOverview.astro +4 -50
  50. package/src/components/openapi/ApiTagOperations.astro +42 -0
  51. package/src/core/builtin-tags.ts +1 -3
  52. package/src/core/config.ts +5 -28
  53. package/src/core/data.ts +2 -2
  54. package/src/core/graph.ts +1 -4
  55. package/src/core/links.ts +5 -19
  56. package/src/core/meta.ts +1 -1
  57. package/src/core/navigation.ts +0 -5
  58. package/src/core/project-graph.ts +0 -5
  59. package/src/core/schema.ts +13 -90
  60. package/src/core/sources/resolve.ts +0 -13
  61. package/src/core/sources/watch.ts +3 -2
  62. package/src/core/types.ts +1 -8
  63. package/src/openapi/parse.ts +197 -14
  64. package/src/openapi/render-mdx.ts +44 -10
  65. package/src/openapi/source.ts +19 -2
  66. package/src/theme/entry.ts +45 -17
  67. package/src/theme/icons.ts +18 -109
  68. package/src/theme/palette.ts +4 -37
  69. package/src/theme/twoslash.ts +6 -1
  70. package/dist/types/core/bridge.d.ts +0 -24
  71. package/dist/types/core/package-json.d.ts +0 -12
  72. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  73. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  74. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  75. package/dist/types/migrate/shared.d.ts +0 -153
  76. package/docs/advanced/bridge.mdx +0 -76
  77. package/docs/advanced/migrate.mdx +0 -124
  78. package/src/astro/static-assets.ts +0 -124
  79. package/src/cli/commands/migrate.ts +0 -39
  80. package/src/components/content/ApiField.astro +0 -75
  81. package/src/components/content/ParamField.astro +0 -39
  82. package/src/components/content/RequestField.astro +0 -23
  83. package/src/components/content/ResponseField.astro +0 -23
  84. package/src/components/content/Warning.astro +0 -9
  85. package/src/core/assets.ts +0 -31
  86. package/src/core/bridge.ts +0 -102
  87. package/src/core/sources/mintlify.ts +0 -190
  88. package/src/migrate/fumadocs/config.ts +0 -155
  89. package/src/migrate/fumadocs/content.ts +0 -376
  90. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  91. package/src/migrate/fumadocs/groups.ts +0 -237
  92. package/src/migrate/fumadocs/index.ts +0 -355
  93. package/src/migrate/fumadocs/meta.ts +0 -244
  94. package/src/migrate/migrate.ts +0 -53
  95. package/src/migrate/mintlify/assets.ts +0 -46
  96. package/src/migrate/mintlify/config.ts +0 -954
  97. package/src/migrate/mintlify/content.ts +0 -120
  98. package/src/migrate/mintlify/frontmatter.ts +0 -126
  99. package/src/migrate/mintlify/i18n.ts +0 -51
  100. package/src/migrate/mintlify/icons.ts +0 -128
  101. package/src/migrate/mintlify/index.ts +0 -459
  102. package/src/migrate/mintlify/snippets.ts +0 -315
  103. package/src/migrate/mintlify/transform.ts +0 -82
  104. package/src/migrate/nextra/content.ts +0 -46
  105. package/src/migrate/nextra/frontmatter.ts +0 -40
  106. package/src/migrate/nextra/index.ts +0 -389
  107. package/src/migrate/nextra/meta.ts +0 -266
  108. package/src/migrate/shared.ts +0 -801
  109. package/src/migrate/starlight/config.ts +0 -455
  110. package/src/migrate/starlight/content.ts +0 -75
  111. package/src/migrate/starlight/frontmatter.ts +0 -111
  112. package/src/migrate/starlight/i18n.ts +0 -54
  113. package/src/migrate/starlight/index.ts +0 -131
@@ -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: [
@@ -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
- const logoConfigSchema = z.union([
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
- const faviconConfigSchema = z.union([
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
- dark: z.string().optional(),
180
- light: z.string().optional(),
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 migrated `.`-rooted project or a
6
- * Mintlify bridge a naive recursive `fs.watch` also sees Blume's own `.blume/`
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 Mintlify-style partition selectors. */
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;
@@ -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 (spec: string, root: string): Promise<string> => {
24
- if (URL_SPEC.test(spec)) {
25
- const response = await fetch(spec);
26
- if (!response.ok) {
27
- throw new Error(`${spec} -> ${response.status} ${response.statusText}`);
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
- return await response.text();
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 diagnostic rather than a
38
- * hard failure so a broken spec doesn't take down the whole build.
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
- body: withDescription(
78
- spec.description,
79
- `<ApiOverview source="${spec.slug}" />`
80
- ),
81
- data: {
82
- sidebar: { label: "Overview" },
83
- title: spec.title || spec.label,
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
+ };