blume 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/cli/index.js +2235 -5942
  2. package/dist/cli/index.js.map +36 -65
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +2 -2
  5. package/dist/types/core/schema.d.ts +58 -453
  6. package/dist/types/core/types.d.ts +1 -7
  7. package/docs/advanced/meta.ts +1 -8
  8. package/docs/advanced/skills.mdx +28 -0
  9. package/docs/configuration/ai.mdx +3 -1
  10. package/docs/configuration/index.mdx +13 -17
  11. package/docs/configuration/theming.mdx +3 -11
  12. package/docs/content/components.mdx +2 -53
  13. package/docs/content/i18n.mdx +1 -5
  14. package/docs/content/sources.mdx +1 -1
  15. package/docs/content/syntax.mdx +1 -1
  16. package/docs/index.mdx +0 -1
  17. package/docs/reference/cli.mdx +12 -13
  18. package/package.json +4 -6
  19. package/skills/blume/SKILL.md +71 -0
  20. package/skills/blume-update-docs/SKILL.md +52 -0
  21. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  22. package/src/ai/mcp/discovery.ts +3 -1
  23. package/src/ai/mcp/server.ts +3 -1
  24. package/src/astro/component-slots.ts +10 -2
  25. package/src/astro/generate.ts +15 -12
  26. package/src/astro/integration.ts +0 -21
  27. package/src/astro/templates.ts +62 -37
  28. package/src/cli/coalesce.ts +43 -0
  29. package/src/cli/commands/dev.ts +30 -22
  30. package/src/cli/commands/validate.ts +0 -2
  31. package/src/cli/dev-lock.ts +4 -2
  32. package/src/cli/index.ts +0 -2
  33. package/src/components/BlumePage.astro +0 -6
  34. package/src/components/Icon.astro +1 -12
  35. package/src/components/content/AccordionItem.astro +3 -6
  36. package/src/components/content/Badge.astro +1 -3
  37. package/src/components/content/Callout.astro +3 -9
  38. package/src/components/content/Card.astro +2 -3
  39. package/src/components/content/ColorItem.astro +8 -5
  40. package/src/components/content/Column.astro +1 -1
  41. package/src/components/content/Prompt.astro +8 -4
  42. package/src/components/content/Step.astro +3 -4
  43. package/src/components/content/Tab.astro +2 -3
  44. package/src/components/content/Tabs.astro +13 -2
  45. package/src/components/content/TypeTable.astro +5 -5
  46. package/src/components/content/Update.astro +1 -1
  47. package/src/components/content/mermaid-element.ts +20 -2
  48. package/src/components/islands/ask-ai.tsx +4 -8
  49. package/src/components/islands/base-path.ts +30 -0
  50. package/src/components/islands/hooks.ts +12 -8
  51. package/src/components/layout/Header.astro +26 -3
  52. package/src/components/layout/Logo.astro +5 -1
  53. package/src/components/layout/NavSelector.astro +1 -1
  54. package/src/components/layout/NavTree.astro +14 -14
  55. package/src/components/layout/PageActions.astro +17 -11
  56. package/src/components/layout/PageLayout.astro +42 -0
  57. package/src/components/layout/ReferenceLayout.astro +1 -0
  58. package/src/components/layout/RootLayout.astro +31 -2
  59. package/src/components/layout/Search.astro +9 -6
  60. package/src/components/layout/nav-utils.ts +9 -4
  61. package/src/components/layout/search/types.ts +16 -5
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/components/openapi/ParametersTable.astro +1 -1
  65. package/src/components/openapi/SchemaProperty.astro +1 -1
  66. package/src/components/openapi/SchemaTable.astro +3 -3
  67. package/src/components/openapi/helpers.ts +17 -8
  68. package/src/components/openapi/snippets.ts +17 -4
  69. package/src/core/builtin-tags.ts +1 -3
  70. package/src/core/config.ts +19 -33
  71. package/src/core/data.ts +2 -2
  72. package/src/core/graph.ts +7 -5
  73. package/src/core/links.ts +5 -19
  74. package/src/core/meta.ts +1 -1
  75. package/src/core/navigation.ts +5 -6
  76. package/src/core/project-graph.ts +0 -5
  77. package/src/core/schema.ts +13 -90
  78. package/src/core/sources/filesystem.ts +19 -1
  79. package/src/core/sources/mdx-remote.ts +20 -4
  80. package/src/core/sources/normalize.ts +28 -6
  81. package/src/core/sources/resolve.ts +0 -13
  82. package/src/core/sources/watch.ts +45 -0
  83. package/src/core/types.ts +1 -8
  84. package/src/markdown/code-title.ts +6 -3
  85. package/src/markdown/package-install.ts +3 -1
  86. package/src/og/card.ts +16 -4
  87. package/src/openapi/parse.ts +197 -14
  88. package/src/openapi/render-mdx.ts +54 -11
  89. package/src/openapi/source.ts +19 -2
  90. package/src/search/sync/orama-cloud.ts +2 -0
  91. package/src/search/sync/typesense.ts +4 -0
  92. package/src/theme/entry.ts +45 -17
  93. package/src/theme/icons.ts +22 -104
  94. package/src/theme/palette.ts +35 -47
  95. package/src/theme/twoslash.ts +6 -1
  96. package/dist/types/core/bridge.d.ts +0 -24
  97. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  98. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  99. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  100. package/docs/advanced/bridge.mdx +0 -76
  101. package/docs/advanced/migrate.mdx +0 -123
  102. package/src/astro/static-assets.ts +0 -117
  103. package/src/cli/commands/migrate.ts +0 -39
  104. package/src/components/content/ApiField.astro +0 -75
  105. package/src/components/content/ParamField.astro +0 -39
  106. package/src/components/content/RequestField.astro +0 -23
  107. package/src/components/content/ResponseField.astro +0 -23
  108. package/src/components/content/Warning.astro +0 -9
  109. package/src/core/assets.ts +0 -31
  110. package/src/core/bridge.ts +0 -102
  111. package/src/core/sources/mintlify.ts +0 -204
  112. package/src/migrate/fumadocs/config.ts +0 -155
  113. package/src/migrate/fumadocs/content.ts +0 -378
  114. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  115. package/src/migrate/fumadocs/groups.ts +0 -237
  116. package/src/migrate/fumadocs/index.ts +0 -340
  117. package/src/migrate/fumadocs/meta.ts +0 -244
  118. package/src/migrate/migrate.ts +0 -53
  119. package/src/migrate/mintlify/assets.ts +0 -46
  120. package/src/migrate/mintlify/config.ts +0 -958
  121. package/src/migrate/mintlify/content.ts +0 -120
  122. package/src/migrate/mintlify/frontmatter.ts +0 -126
  123. package/src/migrate/mintlify/i18n.ts +0 -51
  124. package/src/migrate/mintlify/icons.ts +0 -128
  125. package/src/migrate/mintlify/index.ts +0 -372
  126. package/src/migrate/mintlify/snippets.ts +0 -306
  127. package/src/migrate/mintlify/transform.ts +0 -82
  128. package/src/migrate/nextra/content.ts +0 -46
  129. package/src/migrate/nextra/frontmatter.ts +0 -40
  130. package/src/migrate/nextra/index.ts +0 -374
  131. package/src/migrate/nextra/meta.ts +0 -266
  132. package/src/migrate/shared.ts +0 -728
  133. package/src/migrate/starlight/config.ts +0 -455
  134. package/src/migrate/starlight/content.ts +0 -78
  135. package/src/migrate/starlight/frontmatter.ts +0 -111
  136. package/src/migrate/starlight/i18n.ts +0 -54
  137. package/src/migrate/starlight/index.ts +0 -131
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;
@@ -25,7 +25,9 @@ export interface CodeTitleTransformer {
25
25
  pre: (this: CodeMetaContext, node: PreNode) => void;
26
26
  }
27
27
 
28
- const TITLE_ATTR = /title=(?<quote>["'])(?<title>[^"']*)\k<quote>/u;
28
+ // The body excludes only the delimiting quote, so `title="foo's file.ts"`
29
+ // (an apostrophe inside double quotes) still matches.
30
+ const TITLE_ATTR = /title=(?:"(?<dq>[^"]*)"|'(?<sq>[^']*)')/u;
29
31
  const LINE_NUMBERS = /(?:^|\s)lineNumbers(?=\s|$)/u;
30
32
 
31
33
  const parseTitle = (raw: string | undefined): string | undefined => {
@@ -33,8 +35,9 @@ const parseTitle = (raw: string | undefined): string | undefined => {
33
35
  return undefined;
34
36
  }
35
37
  const explicit = raw.match(TITLE_ATTR);
36
- if (explicit?.groups?.title) {
37
- return explicit.groups.title;
38
+ const attrTitle = explicit?.groups?.dq ?? explicit?.groups?.sq;
39
+ if (attrTitle) {
40
+ return attrTitle;
38
41
  }
39
42
  // The first bare token is the title (```ts blume.config.ts), skipping Shiki
40
43
  // line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
@@ -31,7 +31,9 @@ export const packageInstallPlugin = () => ({
31
31
  node,
32
32
  jsxFlowElement(
33
33
  "Tabs",
34
- [],
34
+ // hash off: clicking "pnpm" in an install block must not rewrite the
35
+ // page hash (clobbering the heading anchor the reader arrived with).
36
+ [jsxAttribute("hash", "false")],
35
37
  PACKAGE_MANAGERS.map((manager) => tabNode(manager, commands[manager]))
36
38
  )
37
39
  );
package/src/og/card.ts CHANGED
@@ -19,10 +19,19 @@ const ACCENT_HEX: Record<string, string> = {
19
19
  teal: "#14b8a6",
20
20
  };
21
21
 
22
+ const HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
23
+
22
24
  // OG rendering uses hex (Takumi's color parser does not accept oklch); named
23
- // presets map to hex, raw hex passes through, anything else falls back.
24
- const resolveAccent = (accent: string): string =>
25
- ACCENT_HEX[accent] ?? (accent.startsWith("#") ? accent : "#3b82f6");
25
+ // presets map to hex, well-formed hex passes through, anything else falls
26
+ // back a malformed hex (`#12345` typo) would throw inside Takumi and fail
27
+ // the build at OG prerender with an opaque native error. `hasOwn` keeps a
28
+ // preset name like "constructor" from resolving up the prototype chain.
29
+ const resolveAccent = (accent: string): string => {
30
+ if (Object.hasOwn(ACCENT_HEX, accent)) {
31
+ return ACCENT_HEX[accent] as string;
32
+ }
33
+ return HEX_COLOR.test(accent) ? accent : "#3b82f6";
34
+ };
26
35
 
27
36
  export interface OgCardOptions {
28
37
  /** Large headline — the page title. */
@@ -75,7 +84,10 @@ export const truncate = (value: string, max: number): string => {
75
84
  // stays within the lockup.
76
85
  const MARK_HEIGHT = 32;
77
86
  const MARK_MAX_WIDTH = 100;
78
- const VIEW_BOX = /viewBox="0 0 (?<w>[\d.]+) (?<h>[\d.]+)"/u;
87
+ // Accept either quote style and a non-zero min-x/min-y; only width/height
88
+ // matter for the aspect ratio. A miss falls back to a square mark.
89
+ const VIEW_BOX =
90
+ /viewBox=(?<q>["'])[\d.-]+[\s,]+[\d.-]+[\s,]+(?<w>[\d.]+)[\s,]+(?<h>[\d.]+)\k<q>/u;
79
91
 
80
92
  // Render the configured logo as the brand mark. A `currentColor` logo carries
81
93
  // no intrinsic color, so it is painted in the foreground to read on the light
@@ -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
  };
@@ -21,8 +21,17 @@ const ENTITIES: Record<string, string> = {
21
21
  "{": "&#123;",
22
22
  "}": "&#125;",
23
23
  };
24
+ // MDX also parses lines starting with `import`/`export` as ESM ("import the
25
+ // SDK…" is common spec prose). Entity-escape the keyword's first letter so the
26
+ // construct can't match; it still renders as the literal word.
27
+ const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
24
28
  const mdxSafe = (text: string): string =>
25
- text.replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char);
29
+ text
30
+ .replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char)
31
+ .replace(
32
+ MDX_ESM_KEYWORD,
33
+ (keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
34
+ );
26
35
 
27
36
  /** Frontmatter + body for one operation or overview page. */
28
37
  export interface RenderedPage {
@@ -64,13 +73,47 @@ export const operationMdx = (
64
73
  };
65
74
  };
66
75
 
67
- export const overviewMdx = (spec: ApiSpecData): RenderedPage => ({
68
- body: withDescription(
69
- spec.description,
70
- `<ApiOverview source="${spec.slug}" />`
71
- ),
72
- data: {
73
- sidebar: { label: "Overview" },
74
- title: spec.title || spec.label,
75
- },
76
- });
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
+ };
@@ -69,6 +69,8 @@ interface LoadedSpec {
69
69
  slug: string;
70
70
  spec: ApiSpecData;
71
71
  entries: SourceEntry[];
72
+ /** Non-fatal notes from the load (e.g. an offline cache fallback). */
73
+ diagnostics: Diagnostic[];
72
74
  }
73
75
 
74
76
  export const openApiSource = (
@@ -81,7 +83,11 @@ export const openApiSource = (
81
83
  reference: ReferenceSource
82
84
  ): Promise<LoadedSpec | Diagnostic> => {
83
85
  try {
84
- const { document } = await parseSpec(reference.spec, ctx.projectRoot);
86
+ const { document, warnings } = await parseSpec(
87
+ reference.spec,
88
+ ctx.projectRoot,
89
+ { cacheDir: ctx.cacheDir, refresh: ctx.refresh }
90
+ );
85
91
  const { operations, tags } = extractOperations(document, reference.route);
86
92
  const info = document.info ?? { title: reference.label, version: "" };
87
93
  const spec: ApiSpecData = {
@@ -100,6 +106,11 @@ export const openApiSource = (
100
106
  version: info.version ?? "",
101
107
  };
102
108
  return {
109
+ diagnostics: warnings.map((message) => ({
110
+ code: "BLUME_OPENAPI_STALE",
111
+ message,
112
+ severity: "warning" as const,
113
+ })),
103
114
  entries: specEntries(spec, operations),
104
115
  slug: reference.slug,
105
116
  spec,
@@ -108,7 +119,12 @@ export const openApiSource = (
108
119
  return {
109
120
  code: "BLUME_OPENAPI_UNAVAILABLE",
110
121
  message: `Could not load OpenAPI spec "${reference.spec}" for ${reference.route} (${(error as Error).message}); its reference pages were skipped.`,
111
- severity: "warning",
122
+ // A configured-but-unloadable spec ships a dead nav tab (a 404 route),
123
+ // so fail loudly in build (blocks under --strict) while staying a warning
124
+ // in dev so offline work still runs.
125
+ severity: ctx.mode === "build" ? "error" : "warning",
126
+ suggestion:
127
+ "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
112
128
  };
113
129
  }
114
130
  };
@@ -125,6 +141,7 @@ export const openApiSource = (
125
141
  }
126
142
  data[result.slug] = result.spec;
127
143
  entries.push(...result.entries);
144
+ diagnostics.push(...result.diagnostics);
128
145
  }
129
146
  parsed = data;
130
147
  return { diagnostics, entries };
@@ -31,6 +31,8 @@ export const syncOramaCloud = async (
31
31
  content: record.content,
32
32
  description: record.description,
33
33
  id: record._id,
34
+ // Carried so an i18n site can filter hosted results per language.
35
+ locale: record.locale,
34
36
  tag: record.tag,
35
37
  title: record.title,
36
38
  url: record.url,
@@ -54,6 +54,9 @@ export const syncTypesense = async (
54
54
  { name: "content", type: "string" },
55
55
  { name: "url", type: "string" },
56
56
  { facet: true, name: "tag", optional: true, type: "string" },
57
+ // Carried as a facet so an i18n site can filter hosted results per
58
+ // language (the SearchRecord contract).
59
+ { facet: true, name: "locale", optional: true, type: "string" },
57
60
  ],
58
61
  name: config.collection,
59
62
  });
@@ -62,6 +65,7 @@ export const syncTypesense = async (
62
65
  content: record.content,
63
66
  description: record.description,
64
67
  id: record._id,
68
+ locale: record.locale,
65
69
  tag: record.tag,
66
70
  title: record.title,
67
71
  url: record.url,
@@ -32,9 +32,6 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
32
32
 
33
33
  :root {
34
34
  --blume-background: oklch(1 0 0);
35
- --blume-background-decoration: none;
36
- --blume-background-decoration-repeat: no-repeat;
37
- --blume-background-decoration-size: auto;
38
35
  --blume-background-image: none;
39
36
  --blume-background-image-repeat: no-repeat;
40
37
  --blume-background-image-size: cover;
@@ -90,9 +87,6 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
90
87
 
91
88
  :root[data-theme="dark"] {
92
89
  --blume-background: oklch(0.085 0 0);
93
- --blume-background-decoration: none;
94
- --blume-background-decoration-repeat: no-repeat;
95
- --blume-background-decoration-size: auto;
96
90
  --blume-background-image: none;
97
91
  --blume-background-image-repeat: no-repeat;
98
92
  --blume-background-image-size: cover;
@@ -134,9 +128,22 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
134
128
  }
135
129
 
136
130
  @layer base {
131
+ /* Nothing refuses to shrink below its intrinsic content width. This global
132
+ min-width reset defuses the classic flex/grid overflow — a long or
133
+ truncating child forcing its container (and the page) past the viewport
134
+ edge — so components don't need per-element min-w-0 overrides. */
135
+ * {
136
+ min-width: 0;
137
+ }
138
+ /* Interactive controls get a pointer cursor unless disabled. */
139
+ button:not(:disabled),
140
+ [role="button"]:not(:disabled) {
141
+ cursor: pointer;
142
+ }
137
143
  html {
138
144
  scroll-behavior: smooth;
139
145
  scroll-padding-top: 4.5rem;
146
+ text-rendering: optimizeLegibility;
140
147
  }
141
148
  /* Headings use the display font (defaults to the body font when unset). */
142
149
  h1,
@@ -153,16 +160,11 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
153
160
  border-radius: 2px;
154
161
  }
155
162
  body {
156
- background-attachment: fixed, fixed;
157
- background-image:
158
- var(--blume-background-image), var(--blume-background-decoration);
159
- background-position: center top, top center;
160
- background-repeat:
161
- var(--blume-background-image-repeat),
162
- var(--blume-background-decoration-repeat);
163
- background-size:
164
- var(--blume-background-image-size),
165
- var(--blume-background-decoration-size);
163
+ background-attachment: fixed;
164
+ background-image: var(--blume-background-image);
165
+ background-position: center top;
166
+ background-repeat: var(--blume-background-image-repeat);
167
+ background-size: var(--blume-background-image-size);
166
168
  }
167
169
  @media (prefers-reduced-motion: reduce) {
168
170
  html {
@@ -298,7 +300,7 @@ ${options.sources.map((source) => `@source "${source}";`).join("\n")}
298
300
  line-height: 1.55;
299
301
  margin: 1.5rem 0;
300
302
  overflow-x: auto;
301
- padding: 1rem 1.25rem;
303
+ padding: 1rem 0;
302
304
  position: relative;
303
305
  }
304
306
 
@@ -395,6 +397,32 @@ blume-diff {
395
397
  padding: 0;
396
398
  }
397
399
 
400
+ /* Long lines scroll inside the code element, not the pre: the pre stays static
401
+ so its absolute header bar (::before) and copy button don't drift with the
402
+ scroll. The pre's horizontal padding lives here so content still scrolls
403
+ edge-to-edge past it. Two contexts opt out: twoslash blocks (popups must
404
+ escape any scroll container — see theme/twoslash.ts), and the API request
405
+ panel, which owns its code layout and keeps the copy control in the panel
406
+ header. Every other component that hosts a code block — Tabs, CodeGroup,
407
+ Steps, Callout, Card, Accordion — is real prose content and keeps the inset,
408
+ even though its chrome wrapper is not-prose. */
409
+ .prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
410
+ display: block;
411
+ overflow-x: auto;
412
+ padding: 0 1.25rem;
413
+ /* The scroller is only as tall as the code, so an overlay scrollbar would
414
+ draw on top of the last line; hide it (wheel/trackpad/keyboard scrolling
415
+ still works). */
416
+ scrollbar-width: none;
417
+ }
418
+
419
+ .prose
420
+ :where(
421
+ pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code
422
+ )::-webkit-scrollbar {
423
+ display: none;
424
+ }
425
+
398
426
  /* Word wrap (markdown.code.wrap): long lines wrap instead of scrolling. The
399
427
  attribute is set on <body> from config; default code keeps \`white-space: pre\`. */
400
428
  [data-blume-code-wrap] pre,