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.
- package/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/dist/cli/index.js +1473 -149
- package/dist/cli/index.js.map +47 -36
- package/dist/types/core/config-input.d.ts +18 -0
- package/dist/types/core/config.d.ts +4 -0
- package/dist/types/core/data.d.ts +3 -0
- package/dist/types/core/schema.d.ts +132 -17
- package/dist/types/core/types.d.ts +5 -3
- package/dist/types/openapi/references.d.ts +6 -0
- package/docs/advanced/api-reference.mdx +27 -0
- package/docs/advanced/changelog.mdx +10 -0
- package/docs/configuration/ai.mdx +38 -2
- package/docs/configuration/customization.mdx +27 -0
- package/docs/configuration/index.mdx +5 -0
- package/docs/content/navigation.mdx +12 -0
- package/docs/reference/cli.mdx +17 -13
- package/docs/reference/eval.mdx +106 -0
- package/docs/reference/meta.ts +1 -1
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +19 -1
- package/src/ai/llms.ts +9 -4
- package/src/ai/mcp/server.ts +48 -14
- package/src/ai/mcp/stdio.ts +35 -0
- package/src/astro/generate.ts +119 -48
- package/src/astro/templates.ts +173 -37
- package/src/audit/checks/duplicates.ts +15 -6
- package/src/audit/checks/indexability.ts +11 -2
- package/src/audit/checks/network.ts +22 -8
- package/src/audit/checks/sitemap.ts +42 -16
- package/src/audit/redirects.ts +12 -1
- package/src/audit/run.ts +13 -3
- package/src/audit/url.ts +21 -2
- package/src/cli/commands/audit.ts +21 -6
- package/src/cli/commands/dev.ts +19 -2
- package/src/cli/commands/eval.ts +291 -0
- package/src/cli/commands/init.ts +9 -4
- package/src/cli/commands/mcp-stdio.ts +36 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/required-secrets.ts +1 -1
- package/src/components/content/AccordionItem.astro +2 -2
- package/src/components/content/Frame.astro +4 -1
- package/src/components/content/Prompt.astro +4 -1
- package/src/components/content/Tooltip.astro +4 -1
- package/src/components/content/TreeFolder.astro +1 -2
- package/src/components/content/Update.astro +45 -0
- package/src/components/islands/AskAI.astro +9 -2
- package/src/components/islands/ask-ai.tsx +23 -4
- package/src/components/islands/hooks.ts +48 -15
- package/src/components/layout/NavTree.astro +37 -19
- package/src/components/layout/ReferenceLayout.astro +4 -0
- package/src/components/layout/RootLayout.astro +14 -3
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/components/openapi/SchemaProperty.astro +3 -3
- package/src/core/config-input.ts +18 -0
- package/src/core/config.ts +4 -0
- package/src/core/data.ts +3 -0
- package/src/core/deployment-env.ts +7 -2
- package/src/core/graph.ts +8 -1
- package/src/core/i18n.ts +10 -2
- package/src/core/navigation.ts +16 -5
- package/src/core/schema.ts +51 -4
- package/src/core/server-features.ts +1 -1
- package/src/core/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/core/types.ts +5 -3
- package/src/eval/agents.ts +340 -0
- package/src/eval/findings.ts +103 -0
- package/src/eval/prompts.ts +78 -0
- package/src/eval/report.ts +214 -0
- package/src/eval/run.ts +290 -0
- package/src/eval/schema.ts +124 -0
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/references.ts +23 -2
- package/src/openapi/render-mdx.ts +39 -11
- package/src/openapi/scalar.ts +1 -0
- package/src/openapi/source.ts +11 -4
- package/src/registry/eject.ts +23 -1
- package/src/search/build.ts +4 -3
|
@@ -139,7 +139,12 @@ export interface UseAskAI {
|
|
|
139
139
|
reset: () => void;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
const
|
|
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(
|
|
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
|
-
|
|
189
|
-
|
|
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((
|
|
207
|
-
...
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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:
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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>`,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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="
|
|
102
|
+
<details class="mt-2" open={expandAll}>
|
|
103
103
|
<summary class="cursor-pointer select-none text-accent text-xs hover:underline">
|
|
104
|
-
<span class="
|
|
105
|
-
<span class="hidden
|
|
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
|
package/src/core/config-input.ts
CHANGED
|
@@ -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. */
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
|
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:
|
|
117
|
+
navPath:
|
|
118
|
+
i18n.parser === "dir"
|
|
119
|
+
? detectLocale(shared.split("/"), i18n).rest.join("/")
|
|
120
|
+
: shared,
|
|
113
121
|
};
|
|
114
122
|
}
|
|
115
123
|
|
package/src/core/navigation.ts
CHANGED
|
@@ -473,9 +473,13 @@ const normalizeRef = (ref: string): string => {
|
|
|
473
473
|
return "/";
|
|
474
474
|
}
|
|
475
475
|
const withSlash = ref.startsWith("/") ? ref : `/${ref}`;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
/**
|
|
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
|
}))
|