blume 0.5.3 → 0.6.0

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 (132) hide show
  1. package/dist/cli/index.js +3349 -7024
  2. package/dist/cli/index.js.map +39 -69
  3. package/dist/types/core/config.d.ts +0 -8
  4. package/dist/types/core/data.d.ts +6 -2
  5. package/dist/types/core/i18n-ui.d.ts +50 -0
  6. package/dist/types/core/schema.d.ts +379 -485
  7. package/dist/types/core/types.d.ts +8 -6
  8. package/docs/advanced/meta.ts +1 -8
  9. package/docs/advanced/skills.mdx +28 -0
  10. package/docs/configuration/ai.mdx +58 -0
  11. package/docs/configuration/index.mdx +13 -17
  12. package/docs/configuration/seo.mdx +59 -1
  13. package/docs/configuration/theming.mdx +15 -18
  14. package/docs/content/components.mdx +2 -53
  15. package/docs/content/i18n.mdx +0 -4
  16. package/docs/content/meta.mdx +3 -17
  17. package/docs/content/navigation.mdx +41 -4
  18. package/docs/content/syntax.mdx +1 -1
  19. package/docs/index.mdx +0 -1
  20. package/docs/reference/cli.mdx +12 -13
  21. package/package.json +6 -6
  22. package/skills/blume/SKILL.md +71 -0
  23. package/skills/blume-update-docs/SKILL.md +52 -0
  24. package/skills/blume-update-docs/references/audit-checklist.md +46 -0
  25. package/src/ai/agent-readability.ts +97 -0
  26. package/src/ai/ask-context.ts +131 -8
  27. package/src/ai/ask-data.ts +4 -1
  28. package/src/astro/generate.ts +19 -12
  29. package/src/astro/integration.ts +0 -21
  30. package/src/astro/templates.ts +33 -21
  31. package/src/cli/commands/build.ts +15 -0
  32. package/src/cli/commands/dev.ts +31 -20
  33. package/src/cli/commands/validate.ts +0 -2
  34. package/src/cli/dev-lock.ts +94 -21
  35. package/src/cli/index.ts +0 -2
  36. package/src/components/BlumePage.astro +0 -6
  37. package/src/components/Icon.astro +1 -12
  38. package/src/components/content/AccordionItem.astro +3 -6
  39. package/src/components/content/Badge.astro +1 -3
  40. package/src/components/content/Callout.astro +3 -9
  41. package/src/components/content/Card.astro +2 -3
  42. package/src/components/content/ColorItem.astro +2 -2
  43. package/src/components/content/Column.astro +1 -1
  44. package/src/components/content/GithubInfo.astro +11 -10
  45. package/src/components/content/Prompt.astro +1 -1
  46. package/src/components/content/Step.astro +3 -4
  47. package/src/components/content/Tab.astro +2 -3
  48. package/src/components/content/TypeTable.astro +13 -8
  49. package/src/components/content/Update.astro +1 -1
  50. package/src/components/islands/AskAI.astro +66 -2
  51. package/src/components/islands/ask-ai.tsx +289 -53
  52. package/src/components/layout/Header.astro +27 -4
  53. package/src/components/layout/Logo.astro +5 -1
  54. package/src/components/layout/NavSelector.astro +1 -1
  55. package/src/components/layout/NavTree.astro +15 -15
  56. package/src/components/layout/PageActions.astro +73 -30
  57. package/src/components/layout/PageLayout.astro +42 -0
  58. package/src/components/layout/ReferenceLayout.astro +1 -0
  59. package/src/components/layout/RootLayout.astro +79 -4
  60. package/src/components/layout/Search.astro +5 -5
  61. package/src/components/layout/nav-utils.ts +9 -4
  62. package/src/components/openapi/ApiOverview.astro +4 -50
  63. package/src/components/openapi/ApiTagOperations.astro +42 -0
  64. package/src/core/builtin-tags.ts +1 -3
  65. package/src/core/config.ts +5 -28
  66. package/src/core/data.ts +6 -2
  67. package/src/core/graph.ts +8 -6
  68. package/src/core/i18n-ui.ts +5 -0
  69. package/src/core/links.ts +5 -19
  70. package/src/core/meta.ts +1 -1
  71. package/src/core/nav-diagnostics.ts +7 -0
  72. package/src/core/navigation.ts +38 -17
  73. package/src/core/project-graph.ts +0 -5
  74. package/src/core/schema.ts +133 -95
  75. package/src/core/sources/filesystem.ts +5 -1
  76. package/src/core/sources/resolve.ts +0 -13
  77. package/src/core/sources/watch.ts +43 -11
  78. package/src/core/types.ts +8 -6
  79. package/src/deploy/robots.ts +37 -4
  80. package/src/openapi/parse.ts +197 -14
  81. package/src/openapi/render-mdx.ts +44 -10
  82. package/src/openapi/scalar.ts +1 -1
  83. package/src/openapi/source.ts +19 -2
  84. package/src/search/documents.ts +9 -2
  85. package/src/theme/entry.ts +45 -17
  86. package/src/theme/icons.ts +18 -109
  87. package/src/theme/palette.ts +25 -51
  88. package/src/theme/twoslash.ts +6 -1
  89. package/dist/types/core/bridge.d.ts +0 -24
  90. package/dist/types/core/package-json.d.ts +0 -12
  91. package/dist/types/migrate/mintlify/assets.d.ts +0 -8
  92. package/dist/types/migrate/mintlify/config.d.ts +0 -16
  93. package/dist/types/migrate/mintlify/i18n.d.ts +0 -7
  94. package/dist/types/migrate/shared.d.ts +0 -153
  95. package/docs/advanced/bridge.mdx +0 -76
  96. package/docs/advanced/migrate.mdx +0 -124
  97. package/src/astro/static-assets.ts +0 -124
  98. package/src/cli/commands/migrate.ts +0 -39
  99. package/src/components/content/ApiField.astro +0 -75
  100. package/src/components/content/ParamField.astro +0 -39
  101. package/src/components/content/RequestField.astro +0 -23
  102. package/src/components/content/ResponseField.astro +0 -23
  103. package/src/components/content/Warning.astro +0 -9
  104. package/src/core/assets.ts +0 -31
  105. package/src/core/bridge.ts +0 -102
  106. package/src/core/sources/mintlify.ts +0 -190
  107. package/src/migrate/fumadocs/config.ts +0 -155
  108. package/src/migrate/fumadocs/content.ts +0 -376
  109. package/src/migrate/fumadocs/frontmatter.ts +0 -18
  110. package/src/migrate/fumadocs/groups.ts +0 -237
  111. package/src/migrate/fumadocs/index.ts +0 -355
  112. package/src/migrate/fumadocs/meta.ts +0 -244
  113. package/src/migrate/migrate.ts +0 -53
  114. package/src/migrate/mintlify/assets.ts +0 -46
  115. package/src/migrate/mintlify/config.ts +0 -954
  116. package/src/migrate/mintlify/content.ts +0 -120
  117. package/src/migrate/mintlify/frontmatter.ts +0 -126
  118. package/src/migrate/mintlify/i18n.ts +0 -51
  119. package/src/migrate/mintlify/icons.ts +0 -128
  120. package/src/migrate/mintlify/index.ts +0 -459
  121. package/src/migrate/mintlify/snippets.ts +0 -315
  122. package/src/migrate/mintlify/transform.ts +0 -82
  123. package/src/migrate/nextra/content.ts +0 -46
  124. package/src/migrate/nextra/frontmatter.ts +0 -40
  125. package/src/migrate/nextra/index.ts +0 -389
  126. package/src/migrate/nextra/meta.ts +0 -266
  127. package/src/migrate/shared.ts +0 -801
  128. package/src/migrate/starlight/config.ts +0 -455
  129. package/src/migrate/starlight/content.ts +0 -75
  130. package/src/migrate/starlight/frontmatter.ts +0 -111
  131. package/src/migrate/starlight/i18n.ts +0 -54
  132. package/src/migrate/starlight/index.ts +0 -131
@@ -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
+ };
@@ -61,7 +61,7 @@ const themeConfiguration = (
61
61
  const accent = resolveAccent(config.theme);
62
62
  const radius = resolveRadius(config.theme);
63
63
  return {
64
- customCss: `:root,.light-mode,.dark-mode{--scalar-color-accent:${accent};--scalar-radius:${radius};}`,
64
+ customCss: `:root,.light-mode,.dark-mode{--scalar-color-accent:${accent.light};--scalar-radius:${radius};}.dark-mode{--scalar-color-accent:${accent.dark};}`,
65
65
  ...darkModeConfig(config.theme.mode),
66
66
  };
67
67
  };
@@ -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 };
@@ -114,10 +114,15 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
114
114
  * Pass `includeWhenDisabled` to index pages on their content merits even when
115
115
  * the search provider is `none` — used by the MCP server, which is a separate
116
116
  * feature from on-page search.
117
+ *
118
+ * `content` selects the extraction: `"plain"` (default) strips Markdown to bare
119
+ * searchable text; `"markdown"` keeps the body's Markdown — code blocks, lists,
120
+ * headings — for Ask AI grounding, where fenced examples are often the answer
121
+ * and stripping them makes the model unable to cite content the docs do contain.
117
122
  */
118
123
  export const buildSearchDocuments = async (
119
124
  project: BlumeProject,
120
- options?: { includeWhenDisabled?: boolean }
125
+ options?: { includeWhenDisabled?: boolean; content?: "markdown" | "plain" }
121
126
  ): Promise<SearchDocument[]> => {
122
127
  const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
123
128
 
@@ -148,7 +153,9 @@ export const buildSearchDocuments = async (
148
153
  indexable.map(async (route) => {
149
154
  const page = pageById.get(route.id);
150
155
  const raw = page ? await readEntryText(project, page) : "";
151
- const body = raw ? toPlainText(matter(raw).content) : "";
156
+ const source = raw ? matter(raw).content : "";
157
+ const body =
158
+ options?.content === "markdown" ? source.trim() : toPlainText(source);
152
159
  const tags = page?.meta?.search?.tags;
153
160
  const crumb = crumbs.get(route.path);
154
161
  return {
@@ -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,