blume 1.7.1 → 1.7.2
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 +8 -0
- package/dist/cli/{chunk-mfm4sjwx.js → chunk-12dxjqk7.js} +32 -43
- package/dist/cli/{chunk-mfm4sjwx.js.map → chunk-12dxjqk7.js.map} +2 -2
- package/dist/cli/{chunk-vyqj481z.js → chunk-196vjxp9.js} +64 -64
- package/dist/cli/chunk-196vjxp9.js.map +13 -0
- package/dist/cli/{chunk-27gtm2ym.js → chunk-2mzebbbz.js} +1 -1
- package/dist/cli/{chunk-s5e5jt53.js → chunk-2z47ypj8.js} +1 -1
- package/dist/cli/{chunk-52cwcqvp.js → chunk-30e87n55.js} +15 -24
- package/dist/cli/{chunk-52cwcqvp.js.map → chunk-30e87n55.js.map} +2 -2
- package/dist/cli/{chunk-4trphnvy.js → chunk-3w7b2vcx.js} +10 -13
- package/dist/cli/{chunk-4trphnvy.js.map → chunk-3w7b2vcx.js.map} +2 -2
- package/dist/cli/{chunk-kmx2mydj.js → chunk-450a7rcr.js} +8 -8
- package/dist/cli/{chunk-kmx2mydj.js.map → chunk-450a7rcr.js.map} +1 -1
- package/dist/cli/{chunk-x1wvw7a8.js → chunk-5n7t497w.js} +129 -131
- package/dist/cli/{chunk-x1wvw7a8.js.map → chunk-5n7t497w.js.map} +4 -4
- package/dist/cli/{chunk-r99hynxh.js → chunk-61j18dwk.js} +36 -9
- package/dist/cli/{chunk-r99hynxh.js.map → chunk-61j18dwk.js.map} +3 -3
- package/dist/cli/{chunk-3r94j3tc.js → chunk-688e0dde.js} +2 -2
- package/dist/cli/{chunk-vxv4x1n8.js → chunk-88by27n5.js} +2 -2
- package/dist/cli/{chunk-h9ekmtz7.js → chunk-8cd8tj54.js} +28 -35
- package/dist/cli/{chunk-h9ekmtz7.js.map → chunk-8cd8tj54.js.map} +2 -2
- package/dist/cli/{chunk-8gnpdsn1.js → chunk-9bkjd11x.js} +2 -2
- package/dist/cli/{chunk-k0v1f8bb.js → chunk-9he6crym.js} +21 -28
- package/dist/cli/{chunk-k0v1f8bb.js.map → chunk-9he6crym.js.map} +2 -2
- package/dist/cli/{chunk-aqjvpd03.js → chunk-aztttvb3.js} +27 -33
- package/dist/cli/{chunk-aqjvpd03.js.map → chunk-aztttvb3.js.map} +2 -2
- package/dist/cli/{chunk-6mq7qkve.js → chunk-cvky9gb2.js} +18 -24
- package/dist/cli/{chunk-6mq7qkve.js.map → chunk-cvky9gb2.js.map} +2 -2
- package/dist/cli/{chunk-90pdhkpm.js → chunk-eevwt1sc.js} +23 -32
- package/dist/cli/{chunk-90pdhkpm.js.map → chunk-eevwt1sc.js.map} +2 -2
- package/dist/cli/{chunk-4ae4f395.js → chunk-ejjx8znq.js} +53 -34
- package/dist/cli/chunk-ejjx8znq.js.map +15 -0
- package/dist/cli/{chunk-8p3xe5jv.js → chunk-exeeb35e.js} +3 -3
- package/dist/cli/{chunk-q56730e0.js → chunk-fmceyezb.js} +41 -50
- package/dist/cli/{chunk-q56730e0.js.map → chunk-fmceyezb.js.map} +2 -2
- package/dist/cli/{chunk-12dzsn9b.js → chunk-hdm2dkd2.js} +72 -77
- package/dist/cli/{chunk-12dzsn9b.js.map → chunk-hdm2dkd2.js.map} +2 -2
- package/dist/cli/{chunk-j5f2wrj5.js → chunk-hs3gbh8p.js} +10 -15
- package/dist/cli/{chunk-j5f2wrj5.js.map → chunk-hs3gbh8p.js.map} +2 -2
- package/dist/cli/{chunk-ywn7t0pb.js → chunk-jbj4qhfw.js} +3 -3
- package/dist/cli/{chunk-ev67ycx0.js → chunk-jq5n4avg.js} +1 -1
- package/dist/cli/{chunk-he2zfgah.js → chunk-mqb2ka8m.js} +22 -30
- package/dist/cli/{chunk-he2zfgah.js.map → chunk-mqb2ka8m.js.map} +2 -2
- package/dist/cli/{chunk-5gfw0q4j.js → chunk-mt76t7dj.js} +26 -34
- package/dist/cli/{chunk-5gfw0q4j.js.map → chunk-mt76t7dj.js.map} +2 -2
- package/dist/cli/{chunk-jtb45atp.js → chunk-n9sra6sy.js} +13 -13
- package/dist/cli/{chunk-jtb45atp.js.map → chunk-n9sra6sy.js.map} +1 -1
- package/dist/cli/{chunk-x66c5yjn.js → chunk-ppfvdcd4.js} +2 -2
- package/dist/cli/{chunk-wd27zjcz.js → chunk-q4rae3bg.js} +1 -1
- package/dist/cli/{chunk-pxj10x8y.js → chunk-ra1v2nc2.js} +1 -1
- package/dist/cli/{chunk-np8dmfb0.js → chunk-t3tj0dgr.js} +26 -33
- package/dist/cli/{chunk-np8dmfb0.js.map → chunk-t3tj0dgr.js.map} +2 -2
- package/dist/cli/{chunk-qvvpnwaz.js → chunk-tqa1s0k8.js} +4 -4
- package/dist/cli/{chunk-cbjnx4s8.js → chunk-vh9w1sgp.js} +1 -1
- package/dist/cli/{chunk-82atea4k.js → chunk-vkrsvbr5.js} +13 -17
- package/dist/cli/{chunk-82atea4k.js.map → chunk-vkrsvbr5.js.map} +2 -2
- package/dist/cli/{chunk-sbdqrjbb.js → chunk-vrfp10qk.js} +1 -1
- package/dist/cli/{chunk-ka5k7cz9.js → chunk-wjt80jps.js} +27 -27
- package/dist/cli/{chunk-ka5k7cz9.js.map → chunk-wjt80jps.js.map} +2 -2
- package/dist/cli/{chunk-pdwg3q9g.js → chunk-xhtpx3ff.js} +21 -30
- package/dist/cli/{chunk-pdwg3q9g.js.map → chunk-xhtpx3ff.js.map} +2 -2
- package/dist/cli/{chunk-5hs6gb7n.js → chunk-yzhm0j9q.js} +1 -1
- package/dist/cli/index.js +397 -34
- package/dist/cli/index.js.map +12 -4
- package/dist/types/core/config-input.d.ts +26 -0
- package/dist/types/core/schema.d.ts +27 -3
- package/docs/configuration/ask-ai.mdx +47 -0
- package/package.json +1 -1
- package/src/ai/cors.ts +87 -0
- package/src/astro/generate.ts +2 -0
- package/src/astro/templates.ts +88 -23
- package/src/components/layout/NavTree.astro +75 -66
- package/src/components/layout/RootLayout.astro +4 -1
- package/src/components/layout/nav-utils.ts +17 -3
- package/src/core/adapter.ts +61 -0
- package/src/core/config-input.ts +26 -0
- package/src/core/schema.ts +56 -0
- package/src/registry/eject.ts +2 -0
- package/dist/cli/chunk-2aj8ddew.js +0 -72
- package/dist/cli/chunk-2aj8ddew.js.map +0 -10
- package/dist/cli/chunk-4ae4f395.js.map +0 -15
- package/dist/cli/chunk-4xyggvgf.js +0 -21
- package/dist/cli/chunk-4xyggvgf.js.map +0 -10
- package/dist/cli/chunk-6kzzpsx8.js +0 -26
- package/dist/cli/chunk-6kzzpsx8.js.map +0 -10
- package/dist/cli/chunk-bcy492zc.js +0 -16
- package/dist/cli/chunk-bcy492zc.js.map +0 -10
- package/dist/cli/chunk-btfr9yvw.js +0 -41
- package/dist/cli/chunk-btfr9yvw.js.map +0 -10
- package/dist/cli/chunk-ey89bjj1.js +0 -209
- package/dist/cli/chunk-ey89bjj1.js.map +0 -11
- package/dist/cli/chunk-vt8fgygt.js +0 -23
- package/dist/cli/chunk-vt8fgygt.js.map +0 -10
- package/dist/cli/chunk-vyqj481z.js.map +0 -13
- /package/dist/cli/{chunk-27gtm2ym.js.map → chunk-2mzebbbz.js.map} +0 -0
- /package/dist/cli/{chunk-s5e5jt53.js.map → chunk-2z47ypj8.js.map} +0 -0
- /package/dist/cli/{chunk-3r94j3tc.js.map → chunk-688e0dde.js.map} +0 -0
- /package/dist/cli/{chunk-vxv4x1n8.js.map → chunk-88by27n5.js.map} +0 -0
- /package/dist/cli/{chunk-8gnpdsn1.js.map → chunk-9bkjd11x.js.map} +0 -0
- /package/dist/cli/{chunk-8p3xe5jv.js.map → chunk-exeeb35e.js.map} +0 -0
- /package/dist/cli/{chunk-ywn7t0pb.js.map → chunk-jbj4qhfw.js.map} +0 -0
- /package/dist/cli/{chunk-ev67ycx0.js.map → chunk-jq5n4avg.js.map} +0 -0
- /package/dist/cli/{chunk-x66c5yjn.js.map → chunk-ppfvdcd4.js.map} +0 -0
- /package/dist/cli/{chunk-wd27zjcz.js.map → chunk-q4rae3bg.js.map} +0 -0
- /package/dist/cli/{chunk-pxj10x8y.js.map → chunk-ra1v2nc2.js.map} +0 -0
- /package/dist/cli/{chunk-qvvpnwaz.js.map → chunk-tqa1s0k8.js.map} +0 -0
- /package/dist/cli/{chunk-cbjnx4s8.js.map → chunk-vh9w1sgp.js.map} +0 -0
- /package/dist/cli/{chunk-sbdqrjbb.js.map → chunk-vrfp10qk.js.map} +0 -0
- /package/dist/cli/{chunk-5hs6gb7n.js.map → chunk-yzhm0j9q.js.map} +0 -0
|
@@ -327,6 +327,11 @@ export type SidebarItemConfig = string | {
|
|
|
327
327
|
root?: string;
|
|
328
328
|
};
|
|
329
329
|
export declare const searchProviders: readonly ["orama", "pagefind", "flexsearch", "algolia", "orama-cloud", "typesense", "mixedbread", "none"];
|
|
330
|
+
/**
|
|
331
|
+
* The `ai.ask.reasoning` levels: the AI SDK's top-level `reasoning` values
|
|
332
|
+
* minus `provider-default`, which is what omitting the field means.
|
|
333
|
+
*/
|
|
334
|
+
export declare const askReasoningLevels: readonly ["none", "minimal", "low", "medium", "high", "xhigh"];
|
|
330
335
|
/** Ask AI backends. `gateway` (default) routes through the Vercel AI Gateway. */
|
|
331
336
|
export declare const askAiProviders: readonly ["gateway", "openrouter", "llmgateway", "inkeep", "openai-compatible"];
|
|
332
337
|
declare const aiConfigSchema: z.ZodObject<{
|
|
@@ -334,6 +339,7 @@ declare const aiConfigSchema: z.ZodObject<{
|
|
|
334
339
|
ask: z.ZodOptional<z.ZodObject<{
|
|
335
340
|
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
336
341
|
baseUrl: z.ZodOptional<z.ZodURL>;
|
|
342
|
+
cors: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodPipe<z.ZodURL, z.ZodTransform<string, string>>]>>>;
|
|
337
343
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
338
344
|
endpoint: z.ZodOptional<z.ZodString>;
|
|
339
345
|
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
@@ -346,6 +352,14 @@ declare const aiConfigSchema: z.ZodObject<{
|
|
|
346
352
|
inkeep: "inkeep";
|
|
347
353
|
"openai-compatible": "openai-compatible";
|
|
348
354
|
}>>;
|
|
355
|
+
reasoning: z.ZodOptional<z.ZodEnum<{
|
|
356
|
+
none: "none";
|
|
357
|
+
minimal: "minimal";
|
|
358
|
+
low: "low";
|
|
359
|
+
medium: "medium";
|
|
360
|
+
high: "high";
|
|
361
|
+
xhigh: "xhigh";
|
|
362
|
+
}>>;
|
|
349
363
|
retrieval: z.ZodOptional<z.ZodObject<{
|
|
350
364
|
contextBudget: z.ZodOptional<z.ZodNumber>;
|
|
351
365
|
excerptChars: z.ZodOptional<z.ZodNumber>;
|
|
@@ -391,6 +405,7 @@ declare const aiConfigSchema: z.ZodObject<{
|
|
|
391
405
|
webmcp: z.ZodDefault<z.ZodBoolean>;
|
|
392
406
|
}, z.core.$strict>;
|
|
393
407
|
export type AskAiProvider = (typeof askAiProviders)[number];
|
|
408
|
+
export type AskReasoning = (typeof askReasoningLevels)[number];
|
|
394
409
|
export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
|
|
395
410
|
export { openInChatProviders } from "./open-in-chat.ts";
|
|
396
411
|
export type { OpenInChatProvider } from "./open-in-chat.ts";
|
|
@@ -505,9 +520,9 @@ declare const contentSignalsSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodBoolean,
|
|
|
505
520
|
declare const dateFormatConfigSchema: z.ZodObject<{
|
|
506
521
|
calendar: z.ZodOptional<z.ZodString>;
|
|
507
522
|
dateStyle: z.ZodOptional<z.ZodEnum<{
|
|
523
|
+
medium: "medium";
|
|
508
524
|
full: "full";
|
|
509
525
|
long: "long";
|
|
510
|
-
medium: "medium";
|
|
511
526
|
short: "short";
|
|
512
527
|
}>>;
|
|
513
528
|
day: z.ZodOptional<z.ZodEnum<{
|
|
@@ -576,6 +591,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
576
591
|
ask: z.ZodOptional<z.ZodObject<{
|
|
577
592
|
apiKeyEnv: z.ZodOptional<z.ZodString>;
|
|
578
593
|
baseUrl: z.ZodOptional<z.ZodURL>;
|
|
594
|
+
cors: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodPipe<z.ZodURL, z.ZodTransform<string, string>>]>>>;
|
|
579
595
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
580
596
|
endpoint: z.ZodOptional<z.ZodString>;
|
|
581
597
|
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
@@ -588,6 +604,14 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
588
604
|
inkeep: "inkeep";
|
|
589
605
|
"openai-compatible": "openai-compatible";
|
|
590
606
|
}>>;
|
|
607
|
+
reasoning: z.ZodOptional<z.ZodEnum<{
|
|
608
|
+
none: "none";
|
|
609
|
+
minimal: "minimal";
|
|
610
|
+
low: "low";
|
|
611
|
+
medium: "medium";
|
|
612
|
+
high: "high";
|
|
613
|
+
xhigh: "xhigh";
|
|
614
|
+
}>>;
|
|
591
615
|
retrieval: z.ZodOptional<z.ZodObject<{
|
|
592
616
|
contextBudget: z.ZodOptional<z.ZodNumber>;
|
|
593
617
|
excerptChars: z.ZodOptional<z.ZodNumber>;
|
|
@@ -773,9 +797,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
773
797
|
dateFormat: z.ZodDefault<z.ZodObject<{
|
|
774
798
|
calendar: z.ZodOptional<z.ZodString>;
|
|
775
799
|
dateStyle: z.ZodOptional<z.ZodEnum<{
|
|
800
|
+
medium: "medium";
|
|
776
801
|
full: "full";
|
|
777
802
|
long: "long";
|
|
778
|
-
medium: "medium";
|
|
779
803
|
short: "short";
|
|
780
804
|
}>>;
|
|
781
805
|
day: z.ZodOptional<z.ZodEnum<{
|
|
@@ -1061,6 +1085,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
1061
1085
|
label: z.ZodString;
|
|
1062
1086
|
}, z.core.$strict>>>;
|
|
1063
1087
|
provider: z.ZodDefault<z.ZodEnum<{
|
|
1088
|
+
none: "none";
|
|
1064
1089
|
algolia: "algolia";
|
|
1065
1090
|
mixedbread: "mixedbread";
|
|
1066
1091
|
orama: "orama";
|
|
@@ -1068,7 +1093,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
1068
1093
|
flexsearch: "flexsearch";
|
|
1069
1094
|
"orama-cloud": "orama-cloud";
|
|
1070
1095
|
typesense: "typesense";
|
|
1071
|
-
none: "none";
|
|
1072
1096
|
}>>;
|
|
1073
1097
|
typesense: z.ZodOptional<z.ZodObject<{
|
|
1074
1098
|
collection: z.ZodString;
|
|
@@ -109,6 +109,37 @@ Blume sends the same `POST` body as its built-in route:
|
|
|
109
109
|
|
|
110
110
|
Return a successful response whose body is a plain UTF-8 text stream. If the endpoint is on another origin, allow the docs origin with CORS: accept `OPTIONS` and `POST`, permit the `content-type` request header, and return the CORS headers on both the preflight and streamed response. With `endpoint` set, Blume generates the chat UI but no server route, grounding snapshot, provider dependency, or provider-secret warning; your backend owns retrieval, authentication, rate limiting, model access, and citations.
|
|
111
111
|
|
|
112
|
+
## Cross-origin callers
|
|
113
|
+
|
|
114
|
+
The generated endpoint answers the in-page assistant on its own origin. To call it from another site as well — a marketing page with an ask box, say — list that site's origin in `cors`:
|
|
115
|
+
|
|
116
|
+
```ts blume.config.ts lineNumbers
|
|
117
|
+
ai: {
|
|
118
|
+
ask: {
|
|
119
|
+
enabled: true,
|
|
120
|
+
cors: ["https://www.example.com"],
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The route then answers the browser's `OPTIONS` preflight and names a listed origin on every response — the streamed answer and the error statuses alike, so the caller can tell a rejected body from a provider failure. Origins that aren't listed get no header and stay subject to the browser's same-origin rule. Each entry is reduced to its origin, so `https://www.example.com/docs/` and `https://www.example.com` mean the same thing. To let any page call the route, list `"*"` instead of origins.
|
|
126
|
+
|
|
127
|
+
The caller sends the same `POST` body the [external endpoint](#external-endpoint) contract describes and reads back the same text stream. Send it as JSON with a `content-type: application/json` header:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const response = await fetch("https://docs.example.com/api/ask", {
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
messages: [{ role: "user", content: "How do I deploy?" }],
|
|
133
|
+
}),
|
|
134
|
+
headers: { "content-type": "application/json" },
|
|
135
|
+
method: "POST",
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The content type matters: Astro's cross-site request check rejects a cross-origin `POST` that has no content type, or a form-like one such as `text/plain`, with a 403 before the route runs, and that response carries no CORS headers, so the browser reports it as a network error rather than a status. The preflight allows whatever request headers the caller asks for, so a fetch wrapper that adds its own headers needs no extra configuration.
|
|
140
|
+
|
|
141
|
+
`cors` only affects the generated route; with an external `endpoint`, CORS is that backend's job, and setting both is a config error. The endpoint stays unauthenticated either way, so the [rate limiting](#rate-limiting) advice applies to cross-origin traffic too.
|
|
142
|
+
|
|
112
143
|
## Server output required
|
|
113
144
|
|
|
114
145
|
Blume's built-in Ask AI backend is a server route (`POST /api/ask`), so it can't run on a static build. Switch to server output and pick an adapter:
|
|
@@ -188,6 +219,22 @@ The values are written into the generated route as-is, so keep secrets in `apiKe
|
|
|
188
219
|
|
|
189
220
|
Keys are read through Astro's [`getSecret()`](https://docs.astro.build/en/guides/environment-variables/#retrieving-secrets-programmatically), so each adapter supplies them its own way: environment variables on Node, Vercel, and Netlify, and the Worker's [bindings](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#environment-variables-and-secrets) on Cloudflare. Enabling Ask AI also turns on React for the in-page island — see [Customization](/docs/configuration/customization#interactive-islands).
|
|
190
221
|
|
|
222
|
+
## Reasoning
|
|
223
|
+
|
|
224
|
+
Reasoning models think before they answer, and how much they do so by default varies by model. For grounded docs Q&A the retrieved excerpts carry the answer, so most of that thinking is latency the reader waits through. `reasoning` sets how much the model reasons: `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, or `"xhigh"`:
|
|
225
|
+
|
|
226
|
+
```ts blume.config.ts lineNumbers
|
|
227
|
+
ai: {
|
|
228
|
+
ask: {
|
|
229
|
+
enabled: true,
|
|
230
|
+
model: "openai/gpt-5.5",
|
|
231
|
+
reasoning: "none",
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The value is sent as the backend's own reasoning-effort control. Through the gateway it travels as the [AI SDK's `reasoning` option](https://ai-sdk.dev/docs/ai-sdk-core/reasoning), which the gateway maps to the model's setting — OpenAI's `reasoning_effort`, for example. On OpenRouter it is sent as `reasoning.effort`, and on LLMGateway or a custom `openai-compatible` endpoint as `reasoning_effort` in the request, so the endpoint has to accept that parameter. The model has to support the level you pick: OpenAI rejects a level a model doesn't offer (`"none"` and `"xhigh"` exist only on some), so check the model's documentation before setting one. Inkeep runs its own QA pipeline and has no reasoning control, so setting `reasoning` with that backend is a config error. Leave it unset to keep the model's default. Like [retrieval size](#retrieval-size), it trades thoroughness for time-to-first-token, and answers stay grounded either way.
|
|
237
|
+
|
|
191
238
|
## Rate limiting
|
|
192
239
|
|
|
193
240
|
The `POST /api/ask` endpoint is **unauthenticated** — it has to be, so the in-page assistant can call it. Blume validates each request — rejecting malformed bodies, capping it to 1–40 messages, and accepting only `user`/`assistant` roles so a caller can't inject their own system prompt and repurpose the route as a general LLM proxy — to bound how much a single call can spend against your model, but it can't stop someone from calling the endpoint repeatedly. If cost abuse is a concern, put the route behind a rate limiter — your host's (e.g. Vercel's) edge rate limiting, a middleware, or your model provider's per-key spend limits.
|
package/package.json
CHANGED
package/src/ai/cors.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS for the generated Ask AI route (`ai.ask.cors`).
|
|
3
|
+
*
|
|
4
|
+
* A browser only lets a page on another origin read a response that names
|
|
5
|
+
* that origin, and a JSON `POST` preflights first. `preflightResponse` answers
|
|
6
|
+
* the `OPTIONS`; `withCors` wraps the `POST` handler so every response it
|
|
7
|
+
* returns — the stream, a 400, a 500 — carries the headers. Wrapping once,
|
|
8
|
+
* rather than stamping each `return`, keeps the next return site added to the
|
|
9
|
+
* handler from shipping an opaque failure for that one status.
|
|
10
|
+
*
|
|
11
|
+
* `allowed` is the `ai.ask.cors` list: origins already reduced to their
|
|
12
|
+
* `scheme://host[:port]` form by the config schema, or the single entry `"*"`
|
|
13
|
+
* to admit every origin.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The `ai.ask.cors` entry that admits every origin. */
|
|
17
|
+
export const ANY_ORIGIN = "*";
|
|
18
|
+
|
|
19
|
+
/** The headers a response carries for a cross-origin caller. */
|
|
20
|
+
export interface CorsHeaders {
|
|
21
|
+
"access-control-allow-origin"?: string;
|
|
22
|
+
vary?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The response headers that name the caller's origin when `allowed` lists it. */
|
|
26
|
+
export const corsHeaders = (
|
|
27
|
+
request: Request,
|
|
28
|
+
allowed: readonly string[]
|
|
29
|
+
): CorsHeaders => {
|
|
30
|
+
if (allowed.includes(ANY_ORIGIN)) {
|
|
31
|
+
// A wildcard answer is the same for every caller, so nothing to vary on.
|
|
32
|
+
return { "access-control-allow-origin": ANY_ORIGIN };
|
|
33
|
+
}
|
|
34
|
+
// `Vary` rides on both branches: the answer depends on `Origin` whether or
|
|
35
|
+
// not it was listed, so a shared cache never hands one origin's response
|
|
36
|
+
// (or the header-less one) to another.
|
|
37
|
+
const origin = request.headers.get("origin");
|
|
38
|
+
return origin && allowed.includes(origin)
|
|
39
|
+
? { "access-control-allow-origin": origin, vary: "origin" }
|
|
40
|
+
: { vary: "origin" };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Answer the browser's `OPTIONS` preflight for the route. */
|
|
44
|
+
export const preflightResponse = (
|
|
45
|
+
request: Request,
|
|
46
|
+
allowed: readonly string[]
|
|
47
|
+
): Response =>
|
|
48
|
+
new Response(null, {
|
|
49
|
+
headers: {
|
|
50
|
+
...corsHeaders(request, allowed),
|
|
51
|
+
// Reflect whatever the caller's fetch wrapper asks to send, falling back
|
|
52
|
+
// to the JSON POST's own `content-type`; a listed origin shouldn't need
|
|
53
|
+
// an eject to add a header of its own.
|
|
54
|
+
"access-control-allow-headers":
|
|
55
|
+
request.headers.get("access-control-request-headers") ?? "content-type",
|
|
56
|
+
"access-control-allow-methods": "POST",
|
|
57
|
+
"access-control-max-age": "86400",
|
|
58
|
+
},
|
|
59
|
+
status: 204,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The slice of Astro's `APIContext` the wrapped handler reads. */
|
|
63
|
+
interface RequestContext {
|
|
64
|
+
request: Request;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Stamp the CORS headers on every response `handler` returns. */
|
|
68
|
+
export const withCors =
|
|
69
|
+
(
|
|
70
|
+
allowed: readonly string[],
|
|
71
|
+
handler: (context: RequestContext) => Promise<Response> | Response
|
|
72
|
+
): ((context: RequestContext) => Promise<Response>) =>
|
|
73
|
+
async (context) => {
|
|
74
|
+
const response = await handler(context);
|
|
75
|
+
for (const [key, value] of Object.entries(
|
|
76
|
+
corsHeaders(context.request, allowed)
|
|
77
|
+
)) {
|
|
78
|
+
// `Vary` accumulates (the handler may already vary on something), the
|
|
79
|
+
// rest replace.
|
|
80
|
+
if (key === "vary") {
|
|
81
|
+
response.headers.append(key, value);
|
|
82
|
+
} else {
|
|
83
|
+
response.headers.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return response;
|
|
87
|
+
};
|
package/src/astro/generate.ts
CHANGED
|
@@ -1826,7 +1826,9 @@ const writeAskFiles = async (
|
|
|
1826
1826
|
await write(
|
|
1827
1827
|
join(srcDir, "pages", "api", "ask.ts"),
|
|
1828
1828
|
askEndpointTemplate(resolveAskBackend(ask), grounded, {
|
|
1829
|
+
cors: ask.cors,
|
|
1829
1830
|
instructions: ask.instructions,
|
|
1831
|
+
reasoning: ask.reasoning,
|
|
1830
1832
|
retrieval: ask.retrieval,
|
|
1831
1833
|
})
|
|
1832
1834
|
);
|
package/src/astro/templates.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { AskBackend } from "../ai/ask.ts";
|
|
|
9
9
|
import { buildHomeLinkHeader } from "../ai/link-headers.ts";
|
|
10
10
|
import { normalizeBasePath } from "../core/base-path.ts";
|
|
11
11
|
import { TOC_HIDDEN_KEY } from "../core/heading-markers.ts";
|
|
12
|
-
import type { ResolvedConfig } from "../core/schema.ts";
|
|
12
|
+
import type { AskReasoning, ResolvedConfig } from "../core/schema.ts";
|
|
13
13
|
import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
|
|
14
14
|
import { trimChar } from "../core/trim.ts";
|
|
15
15
|
import type { ProjectContext } from "../core/types.ts";
|
|
@@ -1040,12 +1040,58 @@ const ASK_FALLBACK_PROMPT =
|
|
|
1040
1040
|
|
|
1041
1041
|
/** The `ai.ask` values the generated endpoint has to carry with it. */
|
|
1042
1042
|
export interface AskEndpointOptions {
|
|
1043
|
+
/** `ai.ask.cors` — origins allowed to call the route from another site. */
|
|
1044
|
+
cors?: string[];
|
|
1043
1045
|
/** `ai.ask.instructions` — extra system-prompt text. */
|
|
1044
1046
|
instructions?: string;
|
|
1047
|
+
/**
|
|
1048
|
+
* `ai.ask.reasoning` — how much the model reasons before answering, sent
|
|
1049
|
+
* as the backend's own reasoning-effort control.
|
|
1050
|
+
*/
|
|
1051
|
+
reasoning?: AskReasoning;
|
|
1045
1052
|
/** `ai.ask.retrieval` — how much documentation each question carries. */
|
|
1046
1053
|
retrieval?: AskRetrievalOptions;
|
|
1047
1054
|
}
|
|
1048
1055
|
|
|
1056
|
+
/** The pieces `askEndpointTemplate` splices in for `ai.ask.cors`. */
|
|
1057
|
+
interface AskCorsTemplate {
|
|
1058
|
+
/** The route's closing token: `});` when the POST is wrapped, `};` otherwise. */
|
|
1059
|
+
close: string;
|
|
1060
|
+
/** The runtime import, when anything is listed. */
|
|
1061
|
+
imports: string[];
|
|
1062
|
+
/** The opening of `export const POST: APIRoute = `. */
|
|
1063
|
+
open: string;
|
|
1064
|
+
/** The allow list and the `OPTIONS` handler, spliced after the provider setup. */
|
|
1065
|
+
setup: string;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* `ai.ask.cors`: a browser only lets another origin read the stream when the
|
|
1070
|
+
* response names that origin, and a JSON POST preflights first, so the route
|
|
1071
|
+
* answers `OPTIONS` and wraps the `POST` in `withCors`, which stamps a listed
|
|
1072
|
+
* origin on every response — errors included, so a cross-origin caller can
|
|
1073
|
+
* tell a 400 from a 500 — without each `return` having to remember to.
|
|
1074
|
+
* Unlisted origins get no allow header and stay subject to the same-origin
|
|
1075
|
+
* rule. Left out entirely when nothing is listed, so the default route is
|
|
1076
|
+
* unchanged.
|
|
1077
|
+
*/
|
|
1078
|
+
const askCorsTemplate = (cors: readonly string[] = []): AskCorsTemplate =>
|
|
1079
|
+
cors.length > 0
|
|
1080
|
+
? {
|
|
1081
|
+
close: "});",
|
|
1082
|
+
imports: [
|
|
1083
|
+
'import { preflightResponse, withCors } from "blume/ai/cors.ts";',
|
|
1084
|
+
],
|
|
1085
|
+
open: "withCors(ALLOWED_ORIGINS, async ({ request }) => {",
|
|
1086
|
+
setup: `
|
|
1087
|
+
const ALLOWED_ORIGINS = ${JSON.stringify(cors)};
|
|
1088
|
+
|
|
1089
|
+
export const OPTIONS: APIRoute = ({ request }) =>
|
|
1090
|
+
preflightResponse(request, ALLOWED_ORIGINS);
|
|
1091
|
+
`,
|
|
1092
|
+
}
|
|
1093
|
+
: { close: "};", imports: [], open: "async ({ request }) => {", setup: "" };
|
|
1094
|
+
|
|
1049
1095
|
/**
|
|
1050
1096
|
* Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`).
|
|
1051
1097
|
*
|
|
@@ -1053,15 +1099,24 @@ export interface AskEndpointOptions {
|
|
|
1053
1099
|
* built-in prompt on every path: the grounded prompt via `createAskContext`,
|
|
1054
1100
|
* and the plain fallback here. `options.retrieval` (the `ai.ask.retrieval`
|
|
1055
1101
|
* config) is forwarded to `createAskContext` on the grounded path, where it
|
|
1056
|
-
* sizes retrieval.
|
|
1057
|
-
*
|
|
1102
|
+
* sizes retrieval. `options.reasoning` (the `ai.ask.reasoning` config)
|
|
1103
|
+
* reaches the model call on both paths. `options.cors` (the `ai.ask.cors`
|
|
1104
|
+
* config) adds a preflight handler and wraps the `POST` so every response
|
|
1105
|
+
* names a listed origin. All four travel in one options object so a new call
|
|
1106
|
+
* site can't silently drop one of them.
|
|
1058
1107
|
*/
|
|
1059
1108
|
export const askEndpointTemplate = (
|
|
1060
1109
|
backend: AskBackend,
|
|
1061
1110
|
grounded: boolean,
|
|
1062
1111
|
options?: AskEndpointOptions
|
|
1063
1112
|
): string => {
|
|
1064
|
-
const instructions = options
|
|
1113
|
+
const { instructions, reasoning, retrieval } = options ?? {};
|
|
1114
|
+
// `ai.ask.reasoning`. The gateway and OpenAI-compatible providers take it
|
|
1115
|
+
// from `streamText`'s top-level `reasoning` (the gateway maps it to the
|
|
1116
|
+
// model's own control, the OpenAI-compatible provider sends it as
|
|
1117
|
+
// `reasoning_effort`). OpenRouter's provider ignores that call option and
|
|
1118
|
+
// only reads its own model setting, so there the level rides on the model
|
|
1119
|
+
// as `reasoning.effort`. Omitted keeps the provider default on every path.
|
|
1065
1120
|
const fallbackPrompt = instructions
|
|
1066
1121
|
? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
|
|
1067
1122
|
: ASK_FALLBACK_PROMPT;
|
|
@@ -1097,7 +1152,10 @@ export const askEndpointTemplate = (
|
|
|
1097
1152
|
setup = `\nconst openrouter = createOpenRouter({
|
|
1098
1153
|
apiKey: getSecret(${JSON.stringify(backend.apiKeyEnv)}),${headersField}
|
|
1099
1154
|
});\n`;
|
|
1100
|
-
|
|
1155
|
+
const settings = reasoning
|
|
1156
|
+
? `, { reasoning: { effort: ${JSON.stringify(reasoning)} } }`
|
|
1157
|
+
: "";
|
|
1158
|
+
modelExpr = `openrouter(${JSON.stringify(backend.model)}${settings})`;
|
|
1101
1159
|
} else if (backend.kind === "openai-compatible") {
|
|
1102
1160
|
imports.push(
|
|
1103
1161
|
'import { createOpenAICompatible } from "@ai-sdk/openai-compatible";'
|
|
@@ -1120,13 +1178,15 @@ export const askEndpointTemplate = (
|
|
|
1120
1178
|
if (instructions) {
|
|
1121
1179
|
groundFields.push(`instructions: ${JSON.stringify(instructions)}`);
|
|
1122
1180
|
}
|
|
1123
|
-
if (
|
|
1124
|
-
groundFields.push(`retrieval: ${JSON.stringify(
|
|
1181
|
+
if (retrieval) {
|
|
1182
|
+
groundFields.push(`retrieval: ${JSON.stringify(retrieval)}`);
|
|
1125
1183
|
}
|
|
1126
1184
|
const groundOptions =
|
|
1127
1185
|
groundFields.length > 0 ? `, { ${groundFields.join(", ")} }` : "";
|
|
1128
1186
|
setup += `\nconst ground = createAskContext(askData${groundOptions});\n`;
|
|
1129
1187
|
}
|
|
1188
|
+
const cors = askCorsTemplate(options?.cors);
|
|
1189
|
+
imports.push(...cors.imports);
|
|
1130
1190
|
// Validate the client-supplied body and cap its size. The endpoint is
|
|
1131
1191
|
// unauthenticated, so bounding message count/length limits how much a caller
|
|
1132
1192
|
// can spend against the model per request, and restricting roles to
|
|
@@ -1183,24 +1243,29 @@ export const askEndpointTemplate = (
|
|
|
1183
1243
|
const onError = ` onError({ error }) {
|
|
1184
1244
|
console.error("Ask AI provider error:", error);
|
|
1185
1245
|
},`;
|
|
1246
|
+
// The `streamText` argument list, built once so the grounded and plain
|
|
1247
|
+
// paths can't drift: they differ only in where the instructions come from.
|
|
1248
|
+
const streamFields = [
|
|
1249
|
+
`model: ${modelExpr}`,
|
|
1250
|
+
grounded
|
|
1251
|
+
? "instructions"
|
|
1252
|
+
: `instructions:\n ${JSON.stringify(fallbackPrompt)}`,
|
|
1253
|
+
"messages",
|
|
1254
|
+
];
|
|
1255
|
+
if (reasoning && backend.kind !== "openrouter") {
|
|
1256
|
+
streamFields.push(`reasoning: ${JSON.stringify(reasoning)}`);
|
|
1257
|
+
}
|
|
1258
|
+
const call = ` const result = streamText({
|
|
1259
|
+
${streamFields.join(",\n ")},
|
|
1260
|
+
${onError}
|
|
1261
|
+
});`;
|
|
1186
1262
|
const stream = grounded
|
|
1187
1263
|
? ` const instructions =
|
|
1188
1264
|
(await ground(messages, body.page)) ??
|
|
1189
1265
|
${JSON.stringify(fallbackPrompt)};
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
messages,
|
|
1194
|
-
${onError}
|
|
1195
|
-
});`
|
|
1196
|
-
: ` const result = streamText({
|
|
1197
|
-
model: ${modelExpr},
|
|
1198
|
-
instructions:
|
|
1199
|
-
${JSON.stringify(fallbackPrompt)},
|
|
1200
|
-
messages,
|
|
1201
|
-
${onError}
|
|
1202
|
-
});`;
|
|
1203
|
-
const handler = `export const POST: APIRoute = async ({ request }) => {
|
|
1266
|
+
${call}`
|
|
1267
|
+
: call;
|
|
1268
|
+
const handler = `export const POST: APIRoute = ${cors.open}
|
|
1204
1269
|
${validate}
|
|
1205
1270
|
${keyCheck}
|
|
1206
1271
|
try {
|
|
@@ -1209,12 +1274,12 @@ ${stream}
|
|
|
1209
1274
|
} catch {
|
|
1210
1275
|
return new Response("Failed to generate a response.", { status: 500 });
|
|
1211
1276
|
}
|
|
1212
|
-
}
|
|
1277
|
+
${cors.close}`;
|
|
1213
1278
|
return `// Generated by Blume. Do not edit.
|
|
1214
1279
|
${imports.join("\n")}
|
|
1215
1280
|
|
|
1216
1281
|
export const prerender = false;
|
|
1217
|
-
${setup}
|
|
1282
|
+
${setup}${cors.setup}
|
|
1218
1283
|
${handler}
|
|
1219
1284
|
`;
|
|
1220
1285
|
};
|
|
@@ -35,7 +35,10 @@ interface Props {
|
|
|
35
35
|
/**
|
|
36
36
|
* Stable ids for the full sidebar's groups (`navGroupIds`), so panel and
|
|
37
37
|
* fragment ids match across the scoped views the layout renders. Absent,
|
|
38
|
-
* ids fall back to positions within this render.
|
|
38
|
+
* ids fall back to positions within this render. A group missing from the
|
|
39
|
+
* map exists only in this render — a container the tab scoping rebuilt
|
|
40
|
+
* without a nested tab section — so no fragment can serve it: it renders
|
|
41
|
+
* in full instead of being deferred.
|
|
39
42
|
*/
|
|
40
43
|
ids?: Map<NavNode, string>;
|
|
41
44
|
/**
|
|
@@ -63,9 +66,12 @@ const {
|
|
|
63
66
|
const idOf = (node: NavNode, positional: string): string =>
|
|
64
67
|
ids?.get(node) ?? positional;
|
|
65
68
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The fragment a group's section loads from, when sections are deferred and
|
|
71
|
+
* the group is one the fragments know (in `ids`, or `ids` is absent).
|
|
72
|
+
*/
|
|
73
|
+
const fragmentFor = (node: NavNode, id: string): string | undefined =>
|
|
74
|
+
fragmentBase && (ids?.has(node) ?? true) ? `${fragmentBase}/${id}` : undefined;
|
|
69
75
|
|
|
70
76
|
// Merge over the English defaults so a label missing from a translation (or
|
|
71
77
|
// from a not-yet-regenerated snapshot) still renders instead of coming out
|
|
@@ -150,66 +156,56 @@ const initialId =
|
|
|
150
156
|
strings={n}
|
|
151
157
|
/>
|
|
152
158
|
</div>
|
|
153
|
-
{panels.map((panel) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
159
|
+
{panels.map((panel) => {
|
|
160
|
+
const src = panel.active ? undefined : fragmentFor(panel.node, panel.id);
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
data-nav-depth={panel.depth}
|
|
164
|
+
data-nav-panel={panel.id}
|
|
165
|
+
data-nav-src={src}
|
|
166
|
+
hidden={panel.id !== initialId}
|
|
167
|
+
>
|
|
168
|
+
{/* The title is the only sidebar link to the section's own page, so
|
|
169
|
+
a routed panel keeps it as a link; without a route the whole row
|
|
170
|
+
becomes the back button. Either way every part of the row is
|
|
171
|
+
interactive. */}
|
|
172
|
+
{panel.route ? (
|
|
173
|
+
<div class="mb-3 flex items-center gap-0.5">
|
|
174
|
+
<button
|
|
175
|
+
aria-label={n.back}
|
|
176
|
+
class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded-[0.65rem] px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
177
|
+
data-nav-back={panel.parentId}
|
|
178
|
+
type="button"
|
|
179
|
+
>
|
|
180
|
+
<Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
|
|
181
|
+
</button>
|
|
182
|
+
<a
|
|
183
|
+
aria-current={panel.route === currentRoute ? "page" : undefined}
|
|
184
|
+
class="flex-1 truncate rounded-[0.65rem] px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
|
|
185
|
+
href={withBase(panel.route)}
|
|
186
|
+
>
|
|
187
|
+
{panel.label}
|
|
188
|
+
</a>
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
166
191
|
<button
|
|
167
|
-
aria-label={n.back}
|
|
168
|
-
class="-ml-1 flex
|
|
192
|
+
aria-label={`${n.back}: ${panel.label}`}
|
|
193
|
+
class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded-[0.65rem] p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
|
|
169
194
|
data-nav-back={panel.parentId}
|
|
170
195
|
type="button"
|
|
171
196
|
>
|
|
172
|
-
<Icon
|
|
197
|
+
<Icon
|
|
198
|
+
class="shrink-0 text-muted-foreground rtl:-scale-x-100"
|
|
199
|
+
name="arrow-left"
|
|
200
|
+
size={16}
|
|
201
|
+
/>
|
|
202
|
+
<span class="flex-1 truncate">{panel.label}</span>
|
|
173
203
|
</button>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
{panel.label}
|
|
180
|
-
</a>
|
|
181
|
-
</div>
|
|
182
|
-
) : (
|
|
183
|
-
<button
|
|
184
|
-
aria-label={`${n.back}: ${panel.label}`}
|
|
185
|
-
class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded-[0.65rem] p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
|
|
186
|
-
data-nav-back={panel.parentId}
|
|
187
|
-
type="button"
|
|
188
|
-
>
|
|
189
|
-
<Icon
|
|
190
|
-
class="shrink-0 text-muted-foreground rtl:-scale-x-100"
|
|
191
|
-
name="arrow-left"
|
|
192
|
-
size={16}
|
|
193
|
-
/>
|
|
194
|
-
<span class="flex-1 truncate">{panel.label}</span>
|
|
195
|
-
</button>
|
|
196
|
-
)}
|
|
197
|
-
{/* An inactive panel's contents are deferred (fetched into this
|
|
198
|
-
element on drill-in) when fragments are on; otherwise the
|
|
199
|
-
build-time cache renders them once. */}
|
|
200
|
-
{panel.active ? (
|
|
201
|
-
<Self
|
|
202
|
-
currentRoute={currentRoute}
|
|
203
|
-
depth={1}
|
|
204
|
-
fragmentBase={fragmentBase}
|
|
205
|
-
idPrefix={panel.id}
|
|
206
|
-
ids={ids}
|
|
207
|
-
items={panel.children}
|
|
208
|
-
root={false}
|
|
209
|
-
strings={n}
|
|
210
|
-
/>
|
|
211
|
-
) : fragmentBase ? null : (
|
|
212
|
-
<NavTreeCache node={panel.node} variant={`${panel.id}|${n.back}|${n.deprecated}`}>
|
|
204
|
+
)}
|
|
205
|
+
{/* An inactive panel's contents are deferred (fetched into this
|
|
206
|
+
element on drill-in) when it has a fragment; otherwise the
|
|
207
|
+
build-time cache renders them once. */}
|
|
208
|
+
{panel.active ? (
|
|
213
209
|
<Self
|
|
214
210
|
currentRoute={currentRoute}
|
|
215
211
|
depth={1}
|
|
@@ -220,10 +216,23 @@ const initialId =
|
|
|
220
216
|
root={false}
|
|
221
217
|
strings={n}
|
|
222
218
|
/>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
) : src ? null : (
|
|
220
|
+
<NavTreeCache node={panel.node} variant={`${panel.id}|${n.back}|${n.deprecated}`}>
|
|
221
|
+
<Self
|
|
222
|
+
currentRoute={currentRoute}
|
|
223
|
+
depth={1}
|
|
224
|
+
fragmentBase={fragmentBase}
|
|
225
|
+
idPrefix={panel.id}
|
|
226
|
+
ids={ids}
|
|
227
|
+
items={panel.children}
|
|
228
|
+
root={false}
|
|
229
|
+
strings={n}
|
|
230
|
+
/>
|
|
231
|
+
</NavTreeCache>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
227
236
|
</blume-nav>
|
|
228
237
|
) : (
|
|
229
238
|
<ul class="m-0 list-none space-y-px p-0">
|
|
@@ -362,11 +371,11 @@ const initialId =
|
|
|
362
371
|
</summary>
|
|
363
372
|
<div
|
|
364
373
|
class="space-y-0.5 border-border border-l pl-3"
|
|
365
|
-
data-nav-src={open ? undefined : fragmentFor(id)}
|
|
374
|
+
data-nav-src={open ? undefined : fragmentFor(item, id)}
|
|
366
375
|
>
|
|
367
376
|
{/* A closed group's children are deferred (fetched into this
|
|
368
|
-
element on first open) when
|
|
369
|
-
{!open &&
|
|
377
|
+
element on first open) when it has a fragment. */}
|
|
378
|
+
{!open && fragmentFor(item, id) ? null : active ? (
|
|
370
379
|
<Self
|
|
371
380
|
currentRoute={currentRoute}
|
|
372
381
|
depth={depth + 1}
|
|
@@ -407,7 +407,10 @@ const sidebar = sidebarForRoute(
|
|
|
407
407
|
navigation.root
|
|
408
408
|
);
|
|
409
409
|
// Stable group ids over the full tree, so the scoped view above names its
|
|
410
|
-
// panels and deferred fragments the same way every other page does.
|
|
410
|
+
// panels and deferred fragments the same way every other page does. A
|
|
411
|
+
// container the scoping rebuilt is not in the map, and NavTree renders it in
|
|
412
|
+
// full rather than deferring it to a fragment that would render the full
|
|
413
|
+
// tree's version.
|
|
411
414
|
const navIds = navGroupIds(navigation.sidebar);
|
|
412
415
|
const activeTab = currentTabForRoute(
|
|
413
416
|
navigation.tabs,
|