blume 1.1.3 → 1.2.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 (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1473 -149
  4. package/dist/cli/index.js.map +47 -36
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +3 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +48 -14
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +119 -48
  26. package/src/astro/templates.ts +173 -37
  27. package/src/audit/checks/duplicates.ts +15 -6
  28. package/src/audit/checks/indexability.ts +11 -2
  29. package/src/audit/checks/network.ts +22 -8
  30. package/src/audit/checks/sitemap.ts +42 -16
  31. package/src/audit/redirects.ts +12 -1
  32. package/src/audit/run.ts +13 -3
  33. package/src/audit/url.ts +21 -2
  34. package/src/cli/commands/audit.ts +21 -6
  35. package/src/cli/commands/dev.ts +19 -2
  36. package/src/cli/commands/eval.ts +291 -0
  37. package/src/cli/commands/init.ts +9 -4
  38. package/src/cli/commands/mcp-stdio.ts +36 -0
  39. package/src/cli/index.ts +4 -0
  40. package/src/cli/required-secrets.ts +1 -1
  41. package/src/components/content/AccordionItem.astro +2 -2
  42. package/src/components/content/Frame.astro +4 -1
  43. package/src/components/content/Prompt.astro +4 -1
  44. package/src/components/content/Tooltip.astro +4 -1
  45. package/src/components/content/TreeFolder.astro +1 -2
  46. package/src/components/content/Update.astro +45 -0
  47. package/src/components/islands/AskAI.astro +9 -2
  48. package/src/components/islands/ask-ai.tsx +23 -4
  49. package/src/components/islands/hooks.ts +48 -15
  50. package/src/components/layout/NavTree.astro +37 -19
  51. package/src/components/layout/ReferenceLayout.astro +4 -0
  52. package/src/components/layout/RootLayout.astro +14 -3
  53. package/src/components/layout/Search.astro +5 -1
  54. package/src/components/layout/head-scripts.ts +22 -5
  55. package/src/components/openapi/SchemaProperty.astro +3 -3
  56. package/src/core/config-input.ts +18 -0
  57. package/src/core/config.ts +4 -0
  58. package/src/core/data.ts +3 -0
  59. package/src/core/deployment-env.ts +7 -2
  60. package/src/core/graph.ts +8 -1
  61. package/src/core/i18n.ts +10 -2
  62. package/src/core/navigation.ts +16 -5
  63. package/src/core/schema.ts +51 -4
  64. package/src/core/server-features.ts +1 -1
  65. package/src/core/sources/normalize.ts +69 -8
  66. package/src/core/sources/notion.ts +4 -2
  67. package/src/core/sources/sanity.ts +5 -3
  68. package/src/core/types.ts +5 -3
  69. package/src/eval/agents.ts +340 -0
  70. package/src/eval/findings.ts +103 -0
  71. package/src/eval/prompts.ts +78 -0
  72. package/src/eval/report.ts +214 -0
  73. package/src/eval/run.ts +290 -0
  74. package/src/eval/schema.ts +124 -0
  75. package/src/markdown/code-title.ts +7 -1
  76. package/src/openapi/model.ts +31 -2
  77. package/src/openapi/references.ts +23 -2
  78. package/src/openapi/render-mdx.ts +39 -11
  79. package/src/openapi/scalar.ts +1 -0
  80. package/src/openapi/source.ts +11 -4
  81. package/src/registry/eject.ts +23 -1
  82. package/src/search/build.ts +4 -3
@@ -139,7 +139,12 @@ export interface UseAskAI {
139
139
  reset: () => void;
140
140
  }
141
141
 
142
- const ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
142
+ const DEFAULT_ASK_ENDPOINT = joinBase(import.meta.env.BASE_URL, "api/ask");
143
+
144
+ export interface UseAskAIOptions {
145
+ /** Existing Ask AI endpoint; defaults to Blume's generated `/api/ask`. */
146
+ endpoint?: string;
147
+ }
143
148
 
144
149
  /** Shown as the assistant's answer when the request fails or throws. */
145
150
  const ASK_ERROR = "Something went wrong answering that. Please try again.";
@@ -152,9 +157,17 @@ const currentPath = (): string =>
152
157
  * Stream answers from the Ask AI endpoint. Mirrors the built-in Ask AI island so
153
158
  * a custom chat UI shares the same grounded, page-aware backend.
154
159
  */
155
- export const useAskAI = (): UseAskAI => {
160
+ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
161
+ const endpoint = options.endpoint ?? DEFAULT_ASK_ENDPOINT;
156
162
  const [messages, setMessages] = useState<AskMessage[]>([]);
157
163
  const [loading, setLoading] = useState(false);
164
+ // The stream writes into the conversation via state updates, so `reset()`
165
+ // mid-answer must revoke the in-flight request's right to write — otherwise
166
+ // its next chunk re-appends the assistant bubble onto the emptied list, and
167
+ // its error path resurrects the entire pre-reset history. Mirrors the
168
+ // built-in island's generation/abort guard.
169
+ const generation = useRef(0);
170
+ const abortRef = useRef<AbortController | null>(null);
158
171
 
159
172
  // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
160
173
  // preserves a stable `ask` identity for consumers that depend on it. With the
@@ -166,6 +179,11 @@ export const useAskAI = (): UseAskAI => {
166
179
  if (!trimmed || loading) {
167
180
  return;
168
181
  }
182
+ generation.current += 1;
183
+ const { current } = generation;
184
+ const controller = new AbortController();
185
+ abortRef.current = controller;
186
+ const live = () => current === generation.current;
169
187
  const history: AskMessage[] = [
170
188
  ...messages,
171
189
  { content: trimmed, role: "user" },
@@ -174,37 +192,40 @@ export const useAskAI = (): UseAskAI => {
174
192
  setMessages([...history, assistant]);
175
193
  setLoading(true);
176
194
  try {
177
- const response = await fetch(ASK_ENDPOINT, {
195
+ const response = await fetch(endpoint, {
178
196
  body: JSON.stringify({
179
197
  messages: history,
180
198
  page: { path: currentPath() },
181
199
  }),
182
200
  headers: { "content-type": "application/json" },
183
201
  method: "POST",
202
+ signal: controller.signal,
184
203
  });
185
204
  if (!response.ok) {
186
205
  // An error body (JSON, HTML error page) must not stream in as the
187
206
  // assistant's answer.
188
- assistant.content = ASK_ERROR;
189
- setMessages([...history, { ...assistant }]);
207
+ if (live()) {
208
+ assistant.content = ASK_ERROR;
209
+ setMessages([...history, { ...assistant }]);
210
+ }
190
211
  return;
191
212
  }
192
213
  const reader = response.body?.getReader();
193
214
  const decoder = new TextDecoder();
194
215
  if (reader) {
195
216
  let done = false;
196
- while (!done) {
217
+ while (!done && live()) {
197
218
  // oxlint-disable-next-line no-await-in-loop, react-doctor/async-await-in-loop -- sequential stream consumption; iterations are not independent
198
219
  const chunk = await reader.read();
199
220
  ({ done } = chunk);
200
- if (chunk.value) {
221
+ if (chunk.value && live()) {
201
222
  // Streaming mode: a multi-byte UTF-8 sequence split across
202
223
  // chunks must not flush as U+FFFD garbage.
203
224
  assistant.content += decoder.decode(chunk.value, {
204
225
  stream: true,
205
226
  });
206
- setMessages((current) => [
207
- ...current.slice(0, -1),
227
+ setMessages((currentMessages) => [
228
+ ...currentMessages.slice(0, -1),
208
229
  { ...assistant },
209
230
  ]);
210
231
  }
@@ -212,20 +233,32 @@ export const useAskAI = (): UseAskAI => {
212
233
  }
213
234
  } catch {
214
235
  // A thrown fetch (offline, DNS failure, CORS) must not strand the
215
- // pre-appended empty assistant message as a stuck placeholder.
216
- assistant.content = ASK_ERROR;
217
- setMessages([...history, { ...assistant }]);
236
+ // pre-appended empty assistant message as a stuck placeholder. A
237
+ // reset's abort lands here too — the guard keeps it silent.
238
+ if (live()) {
239
+ assistant.content = ASK_ERROR;
240
+ setMessages([...history, { ...assistant }]);
241
+ }
218
242
  } finally {
219
- setLoading(false);
243
+ if (live()) {
244
+ setLoading(false);
245
+ }
220
246
  }
221
247
  },
222
- [loading, messages]
248
+ [endpoint, loading, messages]
223
249
  );
224
250
 
225
251
  // Retained for the compiler-off opt-out path (`react: { compiler: false }`):
226
252
  // keeps a stable `reset` identity. With the compiler on it's redundant but inert.
227
253
  // oxlint-disable-next-line react-doctor/react-compiler-no-manual-memoization -- see above
228
- const reset = useCallback(() => setMessages([]), []);
254
+ const reset = useCallback(() => {
255
+ generation.current += 1;
256
+ abortRef.current?.abort();
257
+ abortRef.current = null;
258
+ setMessages([]);
259
+ // The in-flight `ask`'s finally is now stale and won't clear this.
260
+ setLoading(false);
261
+ }, []);
229
262
 
230
263
  return { ask, loading, messages, reset };
231
264
  };
@@ -129,29 +129,43 @@ const initialId =
129
129
  </div>
130
130
  {panels.map((panel) => (
131
131
  <div data-nav-panel={panel.id} hidden={panel.id !== initialId}>
132
- <div class="mb-3 flex items-center gap-1.5">
133
- <button
134
- aria-label={n.back}
135
- class="-ml-1 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
136
- data-nav-back={panel.parentId}
137
- type="button"
138
- >
139
- <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
140
- </button>
141
- {panel.route ? (
132
+ {/* The title is the only sidebar link to the section's own page, so
133
+ a routed panel keeps it as a link; without a route the whole row
134
+ becomes the back button. Either way every part of the row is
135
+ interactive. */}
136
+ {panel.route ? (
137
+ <div class="mb-3 flex items-center gap-0.5">
138
+ <button
139
+ aria-label={n.back}
140
+ class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
141
+ data-nav-back={panel.parentId}
142
+ type="button"
143
+ >
144
+ <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
145
+ </button>
142
146
  <a
143
147
  aria-current={panel.route === currentRoute ? "page" : undefined}
144
- class="flex-1 truncate font-semibold text-foreground text-sm hover:underline"
148
+ class="flex-1 truncate rounded px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
145
149
  href={withBase(panel.route)}
146
150
  >
147
151
  {panel.label}
148
152
  </a>
149
- ) : (
150
- <span class="flex-1 truncate font-semibold text-foreground text-sm">
151
- {panel.label}
152
- </span>
153
- )}
154
- </div>
153
+ </div>
154
+ ) : (
155
+ <button
156
+ aria-label={`${n.back}: ${panel.label}`}
157
+ class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
158
+ data-nav-back={panel.parentId}
159
+ type="button"
160
+ >
161
+ <Icon
162
+ class="shrink-0 text-muted-foreground rtl:-scale-x-100"
163
+ name="arrow-left"
164
+ size={16}
165
+ />
166
+ <span class="flex-1 truncate">{panel.label}</span>
167
+ </button>
168
+ )}
155
169
  <Self
156
170
  currentRoute={currentRoute}
157
171
  depth={1}
@@ -236,7 +250,7 @@ const initialId =
236
250
  const open = active || item.collapsed === false;
237
251
  return (
238
252
  <li class={spacing}>
239
- <details class="group" open={open}>
253
+ <details open={open}>
240
254
  <summary class="flex cursor-pointer list-none items-center gap-1.5 rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden">
241
255
  {item.route ? (
242
256
  <a
@@ -273,7 +287,11 @@ const initialId =
273
287
  )}
274
288
  </>
275
289
  )}
276
- <span class="shrink-0 text-muted-foreground transition-transform group-open:rotate-90">
290
+ {/* Scope the rotation to this group's own `details` — the
291
+ `group-open` variant matches any open ancestor `.group`,
292
+ so nested chevrons rotated while their own group stayed
293
+ closed. */}
294
+ <span class="shrink-0 text-muted-foreground transition-transform [details[open]>summary_&]:rotate-90">
277
295
  <Icon name="chevron-right" size={13} />
278
296
  </span>
279
297
  </summary>
@@ -53,6 +53,8 @@ interface Props {
53
53
  fontCssVars?: string[];
54
54
  searchEnabled: boolean;
55
55
  pageTitle: string;
56
+ /** Keep the reference route out of crawler indexes. */
57
+ noindex?: boolean;
56
58
  /** Active locale code for `<html lang>` (defaults to `en`). */
57
59
  locale?: string;
58
60
  /** Text direction for `<html dir>` (defaults to `ltr`). */
@@ -74,6 +76,7 @@ const {
74
76
  fontCssVars,
75
77
  searchEnabled,
76
78
  pageTitle,
79
+ noindex = false,
77
80
  locale = "en",
78
81
  dir = "ltr",
79
82
  ui,
@@ -89,6 +92,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
89
92
  <head>
90
93
  <meta charset="utf-8" />
91
94
  <meta name="viewport" content="width=device-width, initial-scale=1" />
95
+ {noindex && <meta name="robots" content="noindex" />}
92
96
  <title>{pageTitle}</title>
93
97
  <Favicon favicon={favicon} appleIcon={appleIcon} />
94
98
  <Fonts cssVars={fontCssVars ?? []} />
@@ -25,7 +25,11 @@ import Breadcrumbs from "./Breadcrumbs.astro";
25
25
  import Empty from "./Empty.astro";
26
26
  import Favicon from "./Favicon.astro";
27
27
  import Fonts from "./Fonts.astro";
28
- import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
28
+ import {
29
+ BANNER_INIT_SCRIPT,
30
+ SIDEBAR_SCROLL_INIT_SCRIPT,
31
+ THEME_INIT_SCRIPT,
32
+ } from "./head-scripts.ts";
29
33
  import Header from "./Header.astro";
30
34
  import Icon from "../Icon.astro";
31
35
  import {
@@ -355,7 +359,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
355
359
  ---
356
360
 
357
361
  <!doctype html>
358
- <html dir={dir} lang={locale}>
362
+ <html data-pagefind-ignore={indexable ? undefined : "all"} dir={dir} lang={locale}>
359
363
  <head>
360
364
  <meta charset="utf-8" />
361
365
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -558,7 +562,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
558
562
  </nav>
559
563
  )
560
564
  }
561
- <nav>
565
+ <nav data-blume-nav-tree>
562
566
  {
563
567
  MobileNavSlot ? (
564
568
  <>
@@ -587,6 +591,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
587
591
  }
588
592
  </nav>
589
593
  </aside>
594
+ <script is:inline set:html={SIDEBAR_SCROLL_INIT_SCRIPT} />
590
595
  <main class="px-6 pt-6 pb-10 lg:px-8 xl:px-10" id="blume-content">
591
596
  <BreadcrumbsSlot
592
597
  crumbs={crumbs}
@@ -896,6 +901,12 @@ const bannerKey = banner?.dismissible ? banner.key : null;
896
901
  };
897
902
 
898
903
  for (const image of zoomTargets) {
904
+ // An image that is itself a link navigates on click — binding zoom
905
+ // to it would flash a zoom overlay in the instant before navigation
906
+ // and advertise (via the cursor) a zoom that never happens.
907
+ if (image.closest("a")) {
908
+ continue;
909
+ }
899
910
  image.classList.add("cursor-zoom-in");
900
911
  image.addEventListener("click", () => openZoom(image));
901
912
  }
@@ -325,7 +325,11 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
325
325
  document.addEventListener("keydown", (event) => {
326
326
  if (
327
327
  (event.key === "k" || event.key === "K") &&
328
- (event.metaKey || event.ctrlKey)
328
+ (event.metaKey || event.ctrlKey) &&
329
+ // Ctrl+Shift+K is Firefox's web console; a shifted or alted chord
330
+ // belongs to the browser, not the search dialog.
331
+ !event.shiftKey &&
332
+ !event.altKey
329
333
  ) {
330
334
  // ⌘K toggles, mirroring the Ask AI panel's ⌘I: pressing it with
331
335
  // the dialog open must close it, not re-showModal an open dialog
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Pre-paint inline scripts shared by the document layouts (`RootLayout`,
3
- * `PageLayout`, `ReferenceLayout`). They run synchronously in `<head>`, before
4
- * first paint, so the page never flashes the wrong theme or a since-dismissed
5
- * banner. Kept in one place so the layouts can't drift on this timing-critical
6
- * logic.
3
+ * `PageLayout`, `ReferenceLayout`). They run synchronously in `<head>`, or
4
+ * immediately after the markup they act on before that content paints, so the
5
+ * page never flashes the wrong theme, a since-dismissed banner, or a sidebar
6
+ * scrolled away from the current page. Kept in one place so the layouts can't
7
+ * drift on this timing-critical logic.
7
8
  *
8
- * Both are constants, never built by interpolating config into source text: the
9
+ * All are constants, never built by interpolating config into source text: any
9
10
  * values they need ride in as `data-*` attributes on the script tag and are read
10
11
  * back through `document.currentScript`. Baking a config string into JS — even
11
12
  * via `JSON.stringify` — is code construction, and JSON escaping does not cover
@@ -27,3 +28,19 @@ export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.
27
28
  * Reads `data-key` — the banner's dismissal key.
28
29
  */
29
30
  export const BANNER_INIT_SCRIPT = `(()=>{const k=document.currentScript?.dataset.key;if(k&&localStorage.getItem("blume-banner:"+k))document.documentElement.setAttribute("data-blume-banner-hidden","");})();`;
31
+
32
+ /**
33
+ * Center the current page's sidebar link before the sidebar paints. Every
34
+ * navigation is a full page load, and the sidebar is its own scroll container,
35
+ * so without this it is reborn scrolled to the top on every click — on a long
36
+ * sidebar the viewport visibly jumps away from the link you just clicked.
37
+ *
38
+ * Runs inline immediately after the sidebar `<aside>` (not in `<head>`: it
39
+ * needs that markup parsed). The lookup is scoped to the page tree
40
+ * (`data-blume-nav-tree`) because the drawer also holds the mobile tabs list,
41
+ * whose active tab is `aria-current` too. `getClientRects()` skips links that
42
+ * aren't rendered — `hidden` drill-in panels and breakpoint-hidden duplicates —
43
+ * and the script no-ops when the active link is already inside the visible
44
+ * scroll area, so a short sidebar never moves.
45
+ */
46
+ export const SIDEBAR_SCROLL_INIT_SCRIPT = `(()=>{const n=document.querySelector("[data-blume-nav-drawer]");const s=n&&(n.querySelector("[data-blume-nav-tree]")||n);if(!s)return;let l=null;for(const a of s.querySelectorAll('a[aria-current="page"]')){if(a.getClientRects().length){l=a;break;}}if(!l)return;const r=n.getBoundingClientRect();const t=l.getBoundingClientRect();if(t.top>=r.top&&t.bottom<=r.bottom)return;n.scrollTop+=t.top-r.top-(n.clientHeight-t.height)/2;})();`;
@@ -99,10 +99,10 @@ const expandable =
99
99
  }
100
100
  {
101
101
  expandable && (
102
- <details class="group mt-2" open={expandAll}>
102
+ <details class="mt-2" open={expandAll}>
103
103
  <summary class="cursor-pointer select-none text-accent text-xs hover:underline">
104
- <span class="group-open:hidden">Show properties</span>
105
- <span class="hidden group-open:inline">Hide properties</span>
104
+ <span class="[details[open]>summary_&]:hidden">Show properties</span>
105
+ <span class="hidden [details[open]>summary_&]:inline">Hide properties</span>
106
106
  </summary>
107
107
  <div class="mt-2 border-border border-l pl-4">
108
108
  <SchemaTable
@@ -1,3 +1,4 @@
1
+ import type { AstroIntegration } from "astro";
1
2
  import type { z } from "zod";
2
3
 
3
4
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
@@ -290,6 +291,15 @@ export interface NavTabItem {
290
291
 
291
292
  /** A top-level tab in the header, optionally opening a dropdown of items. */
292
293
  export interface NavTab {
294
+ /**
295
+ * Where the tab links to, when that differs from `path`. `path` scopes the
296
+ * sidebar section and matches the active tab; without `href`, a section whose
297
+ * `path` isn't itself a page falls back to the section's first page, or keeps
298
+ * `path` when the section has no linkable page at all. Set this to send
299
+ * readers somewhere else — e.g. a generated `/changelog` index, or a custom
300
+ * `.astro` landing page, neither of which is part of the content tree.
301
+ */
302
+ href?: string;
293
303
  /** Lucide icon name shown beside the label. */
294
304
  icon?: string;
295
305
  /** Dropdown items; omit for a plain link tab. */
@@ -522,6 +532,12 @@ export interface AskConfig {
522
532
  baseUrl?: string;
523
533
  /** Turn Ask AI on. Defaults to `false`. */
524
534
  enabled?: boolean;
535
+ /**
536
+ * Existing Ask AI endpoint to call instead of generating one. This keeps a
537
+ * Blume site static while an API backend owns retrieval, model access, rate
538
+ * limiting, and streaming. Accepts an absolute URL or root-relative path.
539
+ */
540
+ endpoint?: string;
525
541
  /** Model id to use. Defaults to `openai/gpt-5.5`. */
526
542
  model?: string;
527
543
  /** Which backend routes the request. Defaults to `gateway`. */
@@ -1115,6 +1131,8 @@ export interface BlumeConfig {
1115
1131
  github?: GithubConfig;
1116
1132
  /** Internationalization (opt-in multi-locale). */
1117
1133
  i18n?: I18nConfig;
1134
+ /** Astro integrations appended after Blume's built-ins, in declaration order. */
1135
+ integrations?: AstroIntegration[];
1118
1136
  /** "Last updated" timestamps from git history or frontmatter. Defaults to `false`. */
1119
1137
  lastModified?: LastModifiedConfig;
1120
1138
  /** Site logo / brand mark. */
@@ -84,6 +84,10 @@ import type { Diagnostic } from "./types.ts";
84
84
  * - `analytics` — PostHog, Vercel, or arbitrary `scripts` (Plausible, Fathom,
85
85
  * GA, …).
86
86
  *
87
+ * **Astro**
88
+ * - `integrations` — Astro integrations appended after Blume's built-ins, in
89
+ * declaration order. Install and maintain each integration in the site.
90
+ *
87
91
  * **Deployment & i18n**
88
92
  * - `deployment` — `site` URL (needed for absolute links, sitemaps, and OG),
89
93
  * `adapter` (`vercel`/`node`/`netlify`/`cloudflare`), `output`
package/src/core/data.ts CHANGED
@@ -95,6 +95,7 @@ export interface BlumeDataConfig {
95
95
  appleIcon: BlumeFavicon | null;
96
96
  /** Ask AI empty-state suggestions, or `null` when Ask AI is off. */
97
97
  ask: {
98
+ endpoint: string | null;
98
99
  suggestions: NonNullable<ResolvedConfig["ai"]["ask"]>["suggestions"];
99
100
  } | null;
100
101
  banner: BlumeBanner | null;
@@ -104,6 +105,8 @@ export interface BlumeDataConfig {
104
105
  codeThemes: ResolvedConfig["markdown"]["codeBlocks"]["theme"];
105
106
  /** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
106
107
  codeWrap: boolean;
108
+ /** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
109
+ dateFormat: ResolvedConfig["dateFormat"];
107
110
  description: string | undefined;
108
111
  favicon: BlumeFavicon;
109
112
  feedback: boolean;
@@ -30,12 +30,17 @@ const PLATFORMS: Platform[] = [
30
30
  {
31
31
  adapter: "vercel",
32
32
  detect: (env) => Boolean(env.VERCEL),
33
- site: (env) => toUrl(env.VERCEL_PROJECT_PRODUCTION_URL ?? env.VERCEL_URL),
33
+ // Fall through per *resolved* value, not per variable — a platform can set
34
+ // a var to the empty string, which `??` on the raw values treats as
35
+ // present, dead-ending the chain and silently losing the site URL.
36
+ site: (env) =>
37
+ toUrl(env.VERCEL_PROJECT_PRODUCTION_URL) ?? toUrl(env.VERCEL_URL),
34
38
  },
35
39
  {
36
40
  adapter: "netlify",
37
41
  detect: (env) => Boolean(env.NETLIFY),
38
- site: (env) => toUrl(env.URL ?? env.DEPLOY_PRIME_URL ?? env.DEPLOY_URL),
42
+ site: (env) =>
43
+ toUrl(env.URL) ?? toUrl(env.DEPLOY_PRIME_URL) ?? toUrl(env.DEPLOY_URL),
39
44
  },
40
45
  {
41
46
  adapter: "cloudflare",
package/src/core/graph.ts CHANGED
@@ -97,6 +97,7 @@ const buildLocaleNavigation = (
97
97
  path.startsWith("/") ? localizeRoute(path, code, i18n) : path;
98
98
  const tabs = options.navigation.tabs?.map((tab) => ({
99
99
  ...tab,
100
+ ...(tab.href ? { href: localizePath(tab.href) } : {}),
100
101
  items: tab.items?.map((item) => ({
101
102
  ...item,
102
103
  path: localizePath(item.path),
@@ -116,7 +117,13 @@ const buildLocaleNavigation = (
116
117
  basePath: options.basePath ?? "",
117
118
  diagnostics,
118
119
  display: options.navigation.sidebar.display,
119
- featured: options.navigation.featured,
120
+ // Internal featured hrefs are localized like tab paths — a pinned
121
+ // `/changelog` link rendered on `/fr/…` pages must stay inside the
122
+ // reader's locale, not kick them back to the default one.
123
+ featured: options.navigation.featured?.map((link) => ({
124
+ ...link,
125
+ href: localizePath(link.href),
126
+ })),
120
127
  folderMeta: options.folderMeta,
121
128
  // The localized tree root ("/" for the hidden default, "/fr" otherwise):
122
129
  // the tab pointing here spans the whole tree and must not be treated as a
package/src/core/i18n.ts CHANGED
@@ -105,11 +105,19 @@ export const localePlacement = (
105
105
  ): { navPath: string; locales: string[] } => {
106
106
  const base = rel.slice(0, rel.length - ext.length);
107
107
 
108
- // Shared `$` file: the same content in every locale.
108
+ // Shared `$` file: the same content in every locale. A shared file placed
109
+ // inside a locale directory (`fr/changelog.$.mdx`) still sheds that
110
+ // directory from its nav path — otherwise every locale's record would route
111
+ // under `/fr/…`, nesting the default locale inside the French namespace and
112
+ // the French copy at `/fr/fr/…`.
109
113
  if (base.endsWith(".$")) {
114
+ const shared = `${base.slice(0, -2)}${ext}`;
110
115
  return {
111
116
  locales: i18n.locales.map((locale) => locale.code),
112
- navPath: `${base.slice(0, -2)}${ext}`,
117
+ navPath:
118
+ i18n.parser === "dir"
119
+ ? detectLocale(shared.split("/"), i18n).rest.join("/")
120
+ : shared,
113
121
  };
114
122
  }
115
123
 
@@ -473,9 +473,13 @@ const normalizeRef = (ref: string): string => {
473
473
  return "/";
474
474
  }
475
475
  const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
476
- const trimmed = withSlash.endsWith("/index")
477
- ? withSlash.slice(0, -"/index".length)
478
- : withSlash;
476
+ // Routes are stored slashless (`/guides`, not `/guides/`); a hand-written
477
+ // `"guides/"` ref must still find its page instead of being silently
478
+ // dropped from the sidebar.
479
+ const noTrailing = withSlash.replace(/\/+$/u, "");
480
+ const trimmed = noTrailing.endsWith("/index")
481
+ ? noTrailing.slice(0, -"/index".length)
482
+ : noTrailing;
479
483
  // "/index" trims to "" — that's the root, not an empty route.
480
484
  return trimmed === "" ? "/" : trimmed;
481
485
  };
@@ -609,10 +613,16 @@ const resolveTabHref = (sidebar: NavNode[], path: string): string => {
609
613
  return walk(sidebar) ? path : (first ?? path);
610
614
  };
611
615
 
612
- /** Attach a resolved `href` to each tab whose section has no index page. */
616
+ /**
617
+ * Attach a resolved `href` to each tab whose section has no index page. An
618
+ * author-declared `href` is the tab's stated target, so it's kept as-is —
619
+ * resolution only fills in the tabs that didn't declare one. That's what lets a
620
+ * tab point at a route outside the content tree (a generated `/changelog`
621
+ * index, a custom `.astro` page), which resolution can't see.
622
+ */
613
623
  const withTabHrefs = (tabs: NavTab[], sidebar: NavNode[]): NavTab[] =>
614
624
  tabs.map((tab) => {
615
- const href = resolveTabHref(sidebar, tab.path);
625
+ const href = tab.href ?? resolveTabHref(sidebar, tab.path);
616
626
  return href === tab.path ? tab : { ...tab, href };
617
627
  });
618
628
 
@@ -684,6 +694,7 @@ export const buildNavigation = (
684
694
  const tabs = basePath
685
695
  ? (options.tabs ?? []).map((tab) => ({
686
696
  ...tab,
697
+ ...(tab.href ? { href: withBasePath(basePath, tab.href) } : {}),
687
698
  items: tab.items?.map(rebasePath),
688
699
  path: withBasePath(basePath, tab.path),
689
700
  }))