blume 1.5.1 → 1.5.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 +17 -0
- package/dist/cli/index.js +340 -132
- package/dist/cli/index.js.map +21 -20
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +53 -1
- package/dist/types/core/data.d.ts +16 -0
- package/dist/types/core/open-in-chat.d.ts +9 -0
- package/dist/types/core/schema.d.ts +48 -1
- package/dist/types/core/types.d.ts +10 -3
- package/dist/types/openapi/references.d.ts +9 -0
- package/dist/types/search/orama-index.d.ts +70 -0
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +13 -1
- package/docs/configuration/search.mdx +4 -4
- package/docs/reference/cli.mdx +1 -1
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/ask-context.ts +51 -11
- package/src/ai/mcp/data.ts +3 -2
- package/src/ai/mcp/server.ts +3 -2
- package/src/assets/icon-dark.png +0 -0
- package/src/astro/generate.ts +162 -13
- package/src/astro/templates.ts +63 -11
- package/src/components/content/AccordionItem.astro +4 -0
- package/src/components/content/Update.astro +3 -0
- package/src/components/islands/AskAI.astro +6 -0
- package/src/components/islands/ask-ai.tsx +39 -9
- package/src/components/layout/Analytics.astro +9 -1
- package/src/components/layout/Favicon.astro +29 -8
- package/src/components/layout/Header.astro +2 -2
- package/src/components/layout/NavSelector.astro +1 -1
- package/src/components/layout/PageActions.astro +120 -78
- package/src/components/layout/PageFeedback.astro +12 -3
- package/src/components/layout/PageLayout.astro +7 -2
- package/src/components/layout/ReferenceLayout.astro +10 -8
- package/src/components/layout/RootLayout.astro +151 -120
- package/src/components/layout/Search.astro +41 -26
- package/src/components/layout/drawer-inert.ts +10 -5
- package/src/components/layout/head-scripts.ts +34 -16
- package/src/components/layout/nav-utils.ts +34 -15
- package/src/components/layout/search/orama.ts +3 -2
- package/src/components/openapi/AsyncApiOperation.astro +22 -7
- package/src/components/openapi/MessageComposer.astro +238 -0
- package/src/components/openapi/Operation.astro +26 -12
- package/src/components/openapi/PanelTabs.astro +7 -0
- package/src/components/openapi/Playground.astro +320 -0
- package/src/components/openapi/RequestPanel.astro +1 -0
- package/src/components/openapi/async-snippets.ts +20 -7
- package/src/components/openapi/async.ts +13 -2
- package/src/components/openapi/message-composer.ts +242 -0
- package/src/components/openapi/message-model.ts +108 -0
- package/src/components/openapi/message.ts +153 -0
- package/src/components/openapi/operation-model.ts +260 -0
- package/src/components/openapi/playground-client.ts +486 -0
- package/src/components/openapi/playground-schema.ts +109 -0
- package/src/components/openapi/request.ts +287 -0
- package/src/components/openapi/security.ts +0 -56
- package/src/components/openapi/snippets.ts +23 -136
- package/src/components/openapi/validate-json.ts +144 -0
- package/src/components/openapi/ws-client.ts +194 -0
- package/src/core/config-input.ts +66 -0
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +13 -0
- package/src/core/last-modified.ts +28 -3
- package/src/core/links.ts +30 -4
- package/src/core/navigation.ts +26 -1
- package/src/core/open-in-chat.ts +17 -0
- package/src/core/schema.ts +59 -0
- package/src/core/server-features.ts +11 -0
- package/src/core/sources/normalize.ts +10 -2
- package/src/core/types.ts +10 -3
- package/src/openapi/model.ts +7 -0
- package/src/openapi/proxy.ts +217 -0
- package/src/openapi/references.ts +8 -0
- package/src/openapi/source.ts +13 -0
- package/src/registry/eject.ts +4 -5
- package/src/search/orama-index.ts +109 -36
- package/src/theme/entry.ts +6 -15
|
@@ -67,9 +67,9 @@ search: {
|
|
|
67
67
|
}
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
####
|
|
70
|
+
#### Non-Latin scripts
|
|
71
71
|
|
|
72
|
-
Orama's standard tokenizer
|
|
72
|
+
Orama's standard tokenizer keeps only basic Latin letters, digits and a handful of accented vowels, so text in any other script — Japanese, Chinese, Korean and Thai, but equally Russian, Greek, Hebrew and Hindi — would otherwise produce no matches at all. Blume handles this for you: when [`i18n.defaultLocale`](/docs/content/i18n) resolves to a non-Latin script, the index switches to a word-segmenting tokenizer (built on the browser- and Node-native `Intl.Segmenter`). Declaring your site's language is all it takes:
|
|
73
73
|
|
|
74
74
|
```ts blume.config.ts lineNumbers
|
|
75
75
|
i18n: {
|
|
@@ -78,7 +78,7 @@ i18n: {
|
|
|
78
78
|
}
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
The same tokenizer serves the search dialog, the MCP server's `search_docs` tool, and Ask AI grounding.
|
|
81
|
+
The same tokenizer serves the search dialog, the MCP server's `search_docs` tool, and Ask AI grounding. The script is what decides, not the language name — `az-Cyrl` is segmented while `sr-Latn` is not — and it is the default locale that decides for the whole index: on a mixed-language site every page shares the default locale's tokenizer. With a non-Latin default that's safe, because Latin words survive segmentation intact, so pages in English stay searchable alongside the default language. The reverse doesn't hold: non-Latin translations on a Latin-default site aren't searchable. Latin-script languages that lean heavily on diacritics (Vietnamese, or Serbian in Latin script) also fare worse on the standard tokenizer, which folds only a few accented vowels and splits words on the rest.
|
|
82
82
|
|
|
83
83
|
Japanese and Chinese go one step further. Segmenting alone indexes a compound term as its parts — 資金決済法 as 資金, 決済 and 法 — which lets a page mentioning each part somewhere outrank the page the term is actually about. Han, Hiragana and Katakana are therefore indexed as overlapping character pairs, and queries on those indexes prefer pages carrying a term's pairs together, loosening to any-pair matching when no page carries them all, so typing a whole sentence still returns its closest pages. Korean and Thai keep their segmented words.
|
|
84
84
|
|
|
@@ -86,7 +86,7 @@ Japanese and Chinese go one step further. Segmenting alone indexes a compound te
|
|
|
86
86
|
|
|
87
87
|
A second keyless, client-side option. It reuses the same `/blume-search.json` index Orama ships and builds a [FlexSearch](https://github.com/nextapps-de/flexsearch) document index in the browser. Works in `blume dev` and `blume build`.
|
|
88
88
|
|
|
89
|
-
FlexSearch has no equivalent segmentation hook, so for sites in
|
|
89
|
+
FlexSearch has no equivalent segmentation hook, so for sites in a non-Latin script prefer Orama (the default) or [Pagefind](#pagefind), whose `pagefind_extended` binary indexes a broad set of languages and segments Chinese, Japanese and Korean natively.
|
|
90
90
|
|
|
91
91
|
```ts blume.config.ts lineNumbers
|
|
92
92
|
search: {
|
package/docs/reference/cli.mdx
CHANGED
|
@@ -123,7 +123,7 @@ Without a project `tsconfig.json`, only the generated runtime is checked.
|
|
|
123
123
|
|
|
124
124
|
- **Internal page links** (`/guides/intro`, `./sibling`) must resolve to a real page — broken ones are reported as errors.
|
|
125
125
|
- **Anchor links** (`#section`, `/guides/intro#setup`) must match a heading on the target page — misses are warnings.
|
|
126
|
-
- **Asset links** (`/logo.png`)
|
|
126
|
+
- **Asset links** are checked where the file lives: an absolute path (`/logo.png`) against the `public/` directory, a relative image embed (``) against the page's own folder. A plain link to a relative path still resolves as a site route — only image embeds go through the image pipeline.
|
|
127
127
|
- **External links** are only checked with `--external` (off by default since it requires the network); dead links (404/410/unreachable) are errors, while rate-limited or transient responses (403/429/5xx/timeout) are warnings.
|
|
128
128
|
|
|
129
129
|
## Auditing the built site
|
package/package.json
CHANGED
|
@@ -70,7 +70,7 @@ The single biggest shift for most sources — especially Mintlify — is that **
|
|
|
70
70
|
- **`navigation`:** `tabs`, `selectors`, `featured` (links pinned above the sidebar on every route), `sidebar` (`{ display, items }` — `display` is the global render mode above; `items` is an explicit tree), `repo`. **Avoid an explicit `navigation.sidebar` unless you have to** — lean on the filesystem-derived sidebar. It only works when the file tree roughly matches the intended sidebar layout, so reshape folders to match first; reach for `sidebar.items` only for a shape files genuinely can't express (see "Config-declared nesting" above).
|
|
71
71
|
- **`search`** (Orama default, Pagefind opt-in), **`ai`** (llms.txt, Ask AI, the MCP server), **`openapi`**, **`redirects`**, **`seo`**, **`markdown`**, **`analytics`**, **`deployment`**, **`i18n`**, **`toc`**, **`lastModified`**, **`github`**.
|
|
72
72
|
- **Don't set `deployment.site`.** Blume auto-fills it: the dev server's `localhost` URL in dev, and the deployment URL (`VERCEL_PROJECT_PRODUCTION_URL`/`VERCEL_URL`) on Vercel. Hardcoding it in `blume.config.ts` overrides that auto-detection and pins the wrong absolute URL (canonical links, sitemap, OG, llms.txt) everywhere but the one host you typed — so leave it unset even when a source config had a `url`/`site` field. (Sitemap still generates in production because the deploy URL is present there.)
|
|
73
|
-
- **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }`
|
|
73
|
+
- **Favicon is a filename convention, not config.** Drop `icon`/`favicon.{svg,png,ico}` (and `apple-icon.png`) in the project root or `public/` — Blume auto-detects it. There is **no** `favicon` config field. A source favicon given as `{ light, dark }` maps to a filename pair: copy the light file to a conventional name (e.g. `public/icon.png`) and the dark file to its `-dark` sibling — same directory and extension, `-dark` before the extension (`public/icon-dark.png`). If the two files have different formats, convert one so the extensions match; only an exact sibling of the resolved icon is picked up.
|
|
74
74
|
|
|
75
75
|
The schema is exported from `blume/schema`; the full field reference is in the `docs/configuration/` directory of the installed `blume` package (see "Full documentation" below for how to locate it).
|
|
76
76
|
|
|
@@ -19,7 +19,7 @@ Resolve `$ref` includes first (Mintlify splits config across files). Map only wh
|
|
|
19
19
|
| `name` / `title` | `title` | |
|
|
20
20
|
| `description` | `description` | |
|
|
21
21
|
| `logo` (string or `{ light, dark, href }`) | `logo` (string, or `{ image: { light, dark, alt }, text, href }`) | Mintlify's `light`/`dark` nest under Blume's `image`; ensure files land in `public/`. **If the SVG is local and monochrome** (a solid black or white mark), rewrite its `fill`/`stroke` to `currentColor` and collapse `{ light, dark }` to the string shorthand `logo: "/logo.svg"` — one file that inherits the theme text color and flips with light/dark automatically. If the logo is a **wordmark** (brand name baked in), set `text: ""` so it doesn't render twice beside `title` |
|
|
22
|
-
| `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy
|
|
22
|
+
| `favicon` (string **or `{ light, dark }`**) | **drop the field** — copy the file(s) into `public/` under conventional names (`favicon.svg`/`icon.png`) | Blume auto-detects by filename; there is **no** favicon config field. A `{ light, dark }` source maps to a filename pair: light file → `public/icon.png`, dark file → its `-dark` sibling `public/icon-dark.png` (same directory and extension — convert if the formats differ) |
|
|
23
23
|
| `colors.primary` + `colors.light` | `theme.accent` (`{ light, dark }`) | accent is now **per-mode**: `accent: { light: <colors.primary>, dark: <colors.light> }`. If Mintlify sets only `primary`, collapse to the string shorthand `accent: "<colors.primary>"` (applies to both modes) |
|
|
24
24
|
| `colors.dark` | `theme.action` | |
|
|
25
25
|
| `appearance.default` | `theme.mode` (`light`/`dark`/`system`) | |
|
package/src/ai/ask-context.ts
CHANGED
|
@@ -22,8 +22,8 @@ export interface AskPage {
|
|
|
22
22
|
export interface AskData {
|
|
23
23
|
/**
|
|
24
24
|
* The site's `i18n.defaultLocale`, when i18n is configured. Selects a
|
|
25
|
-
* word-segmenting Orama tokenizer for
|
|
26
|
-
*
|
|
25
|
+
* word-segmenting Orama tokenizer for every non-Latin script, so retrieval
|
|
26
|
+
* can match CJK, Cyrillic, Greek, Hebrew, or Devanagari content.
|
|
27
27
|
*/
|
|
28
28
|
defaultLocale?: string;
|
|
29
29
|
documents: OramaDoc[];
|
|
@@ -36,6 +36,34 @@ const MAX_RESULTS = 6;
|
|
|
36
36
|
const EXCERPT_CHARS = 2000;
|
|
37
37
|
/** Overall cap on injected documentation characters. */
|
|
38
38
|
const CONTEXT_BUDGET = 10_000;
|
|
39
|
+
/**
|
|
40
|
+
* Smallest excerpt worth injecting. A long page pushed under a tiny residual
|
|
41
|
+
* budget would get a full `## Title (/route)` heading over a fragment of a few
|
|
42
|
+
* dozen characters — a section the model is invited to cite but that grounds
|
|
43
|
+
* nothing. Short pages that fit whole are still injected below this floor.
|
|
44
|
+
*/
|
|
45
|
+
const MIN_EXCERPT_CHARS = 200;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How much retrieved documentation a question carries (the `ai.ask.retrieval`
|
|
49
|
+
* config). Every field falls back to the built-in default, so a partial object
|
|
50
|
+
* only changes what it names. Injected characters dominate time-to-first-token
|
|
51
|
+
* on a self-hosted backend, and the three knobs aren't interchangeable: the
|
|
52
|
+
* budget caps the total, `excerptChars` decides how deep into one long page the
|
|
53
|
+
* excerpt reaches, and `maxResults` decides how many pages retrieval adds (the
|
|
54
|
+
* page the reader is viewing is injected on top of them).
|
|
55
|
+
*/
|
|
56
|
+
export interface AskRetrievalOptions {
|
|
57
|
+
/** Overall cap on injected documentation characters. Defaults to `10000`. */
|
|
58
|
+
contextBudget?: number;
|
|
59
|
+
/** Characters kept per injected excerpt. Defaults to `2000`. */
|
|
60
|
+
excerptChars?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Documents retrieved per question. Defaults to `6`. The current page is
|
|
63
|
+
* injected in addition when it isn't among the hits.
|
|
64
|
+
*/
|
|
65
|
+
maxResults?: number;
|
|
66
|
+
}
|
|
39
67
|
/** Chars of lead-in kept before the matched region, for heading/sentence context. */
|
|
40
68
|
const EXCERPT_LEAD = 160;
|
|
41
69
|
|
|
@@ -176,13 +204,14 @@ export const relevantExcerpt = (
|
|
|
176
204
|
return `${prefix}${slice}${suffix}`;
|
|
177
205
|
};
|
|
178
206
|
|
|
179
|
-
|
|
207
|
+
// Case-insensitive matching via regex rather than `indexOf` on a lowercased
|
|
208
|
+
// copy: length-changing case mappings (Turkish İ → "i" + U+0307) would shift
|
|
209
|
+
// every index in the copy, sliding the excerpt window off the match. Terms
|
|
210
|
+
// come from TERM (letters, marks and digits only), so no regex escaping.
|
|
180
211
|
const positions: number[] = [];
|
|
181
212
|
for (const term of queryTerms(query)) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
positions.push(idx);
|
|
185
|
-
idx = lower.indexOf(term, idx + term.length);
|
|
213
|
+
for (const match of trimmed.matchAll(new RegExp(term, "giu"))) {
|
|
214
|
+
positions.push(match.index);
|
|
186
215
|
}
|
|
187
216
|
}
|
|
188
217
|
// No query terms hit this doc — nothing to center on, so keep the head.
|
|
@@ -232,10 +261,13 @@ export const relevantExcerpt = (
|
|
|
232
261
|
* the base instruction rather than replacing it: the base carries the
|
|
233
262
|
* functional contract (answer only from the excerpts, cite pages as Markdown
|
|
234
263
|
* links) that the panel's citation rendering depends on.
|
|
264
|
+
*
|
|
265
|
+
* `options.retrieval` (the `ai.ask.retrieval` config) sizes how much
|
|
266
|
+
* documentation each question carries; omitted fields keep today's defaults.
|
|
235
267
|
*/
|
|
236
268
|
export const createAskContext = (
|
|
237
269
|
data: AskData,
|
|
238
|
-
options?: { instructions?: string }
|
|
270
|
+
options?: { instructions?: string; retrieval?: AskRetrievalOptions }
|
|
239
271
|
): ((
|
|
240
272
|
messages: AskMessage[],
|
|
241
273
|
page?: AskPage
|
|
@@ -250,6 +282,9 @@ export const createAskContext = (
|
|
|
250
282
|
const instruction = options?.instructions
|
|
251
283
|
? `${BASE_INSTRUCTION}\n\n${options.instructions}`
|
|
252
284
|
: BASE_INSTRUCTION;
|
|
285
|
+
const maxResults = options?.retrieval?.maxResults ?? MAX_RESULTS;
|
|
286
|
+
const excerptChars = options?.retrieval?.excerptChars ?? EXCERPT_CHARS;
|
|
287
|
+
const contextBudget = options?.retrieval?.contextBudget ?? CONTEXT_BUDGET;
|
|
253
288
|
|
|
254
289
|
return async (messages, page) => {
|
|
255
290
|
const list = Array.isArray(messages) ? messages : [];
|
|
@@ -263,22 +298,27 @@ export const createAskContext = (
|
|
|
263
298
|
? byRoute.get(normalizeRoute(page.path))
|
|
264
299
|
: undefined;
|
|
265
300
|
const db = await index();
|
|
266
|
-
const hits = await queryOramaIndex(db, query,
|
|
301
|
+
const hits = await queryOramaIndex(db, query, maxResults, {
|
|
267
302
|
locale: current?.locale || undefined,
|
|
268
303
|
});
|
|
269
304
|
|
|
270
305
|
const seen = new Set<string>();
|
|
271
306
|
const sections: string[] = [];
|
|
272
|
-
let budget =
|
|
307
|
+
let budget = contextBudget;
|
|
273
308
|
const push = (doc: OramaDoc, label: string) => {
|
|
274
309
|
if (seen.has(doc.route) || budget <= 0) {
|
|
275
310
|
return;
|
|
276
311
|
}
|
|
312
|
+
// Skip a page that would be cut to a junk fragment: its excerpt is only
|
|
313
|
+
// useful when it either fits whole or gets at least the minimum window.
|
|
314
|
+
if (budget < MIN_EXCERPT_CHARS && doc.content.trim().length > budget) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
277
317
|
seen.add(doc.route);
|
|
278
318
|
const body = relevantExcerpt(
|
|
279
319
|
doc.content,
|
|
280
320
|
query,
|
|
281
|
-
Math.min(
|
|
321
|
+
Math.min(excerptChars, budget)
|
|
282
322
|
);
|
|
283
323
|
budget -= body.length;
|
|
284
324
|
sections.push(`## ${doc.title} (${doc.route})${label}\n${body}`);
|
package/src/ai/mcp/data.ts
CHANGED
|
@@ -37,8 +37,9 @@ export interface McpData {
|
|
|
37
37
|
base: string;
|
|
38
38
|
/**
|
|
39
39
|
* The site's `i18n.defaultLocale`, when i18n is configured. Selects a
|
|
40
|
-
* word-segmenting Orama tokenizer for
|
|
41
|
-
* `search_docs` can match CJK
|
|
40
|
+
* word-segmenting Orama tokenizer for every non-Latin script, so
|
|
41
|
+
* `search_docs` can match CJK, Cyrillic, Greek, Hebrew, or Devanagari
|
|
42
|
+
* content.
|
|
42
43
|
*/
|
|
43
44
|
defaultLocale?: string;
|
|
44
45
|
/**
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -311,8 +311,9 @@ export type OramaIndexProvider = () => Promise<
|
|
|
311
311
|
|
|
312
312
|
/**
|
|
313
313
|
* Memoize the search index so every server built from a snapshot shares it.
|
|
314
|
-
* `locale` is the snapshot's `defaultLocale`, forwarded so
|
|
315
|
-
* (Japanese
|
|
314
|
+
* `locale` is the snapshot's `defaultLocale`, forwarded so non-Latin scripts
|
|
315
|
+
* (Japanese and Chinese, but equally Cyrillic, Greek, Hebrew, Devanagari…)
|
|
316
|
+
* get a word-segmenting tokenizer.
|
|
316
317
|
*/
|
|
317
318
|
export const createIndexProvider = (
|
|
318
319
|
documents: OramaDoc[],
|
|
Binary file
|
package/src/astro/generate.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
|
|
|
50
50
|
import { missingFontFiles, resolveOgFonts } from "../og/derive.ts";
|
|
51
51
|
import type { DerivedOgFonts } from "../og/derive.ts";
|
|
52
52
|
import { resolveOgLogo } from "../og/logo.ts";
|
|
53
|
+
import type { OpenApiData } from "../openapi/model.ts";
|
|
53
54
|
import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
|
|
54
55
|
import { buildReferenceFiles } from "../openapi/scalar.ts";
|
|
55
56
|
import { isOpenApiSource } from "../openapi/source.ts";
|
|
@@ -94,6 +95,7 @@ import {
|
|
|
94
95
|
mixedbreadSearchEndpointTemplate,
|
|
95
96
|
notFoundPageTemplate,
|
|
96
97
|
ogEndpointTemplate,
|
|
98
|
+
playgroundProxyTemplate,
|
|
97
99
|
rawMarkdownEndpointTemplate,
|
|
98
100
|
rssEndpointTemplate,
|
|
99
101
|
runtimeDirWithin,
|
|
@@ -964,6 +966,13 @@ const FAVICON_CANDIDATES = [
|
|
|
964
966
|
"icon.ico",
|
|
965
967
|
];
|
|
966
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Derive the `-dark` sibling of an icon filename (`icon.svg` → `icon-dark.svg`),
|
|
971
|
+
* inserting the suffix before the extension.
|
|
972
|
+
*/
|
|
973
|
+
const darkSibling = (name: string): string =>
|
|
974
|
+
name.replace(/\.(?=[^.]+$)/u, "-dark.");
|
|
975
|
+
|
|
967
976
|
/** `<link type>` MIME for the favicon extensions we recognize. */
|
|
968
977
|
const FAVICON_TYPES = new Map([
|
|
969
978
|
["ico", "image/x-icon"],
|
|
@@ -983,8 +992,19 @@ const faviconType = (name: string): string | undefined => {
|
|
|
983
992
|
const inlineDataUri = (file: string, type: string): string =>
|
|
984
993
|
`data:${type};base64,${readFileSync(file).toString("base64")}`;
|
|
985
994
|
|
|
986
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* The bundled Blume favicon, inlined as a data URI so it needs no public file.
|
|
997
|
+
* The mark is dark, so it ships with a light-on-transparent dark-scheme variant —
|
|
998
|
+
* otherwise it disappears against dark browser chrome.
|
|
999
|
+
*/
|
|
987
1000
|
const defaultFavicon = (): BlumeFavicon => ({
|
|
1001
|
+
dark: {
|
|
1002
|
+
href: inlineDataUri(
|
|
1003
|
+
join(BLUME_SRC, "assets", "icon-dark.png"),
|
|
1004
|
+
"image/png"
|
|
1005
|
+
),
|
|
1006
|
+
type: "image/png",
|
|
1007
|
+
},
|
|
988
1008
|
href: inlineDataUri(join(BLUME_SRC, "assets", "icon.png"), "image/png"),
|
|
989
1009
|
type: "image/png",
|
|
990
1010
|
});
|
|
@@ -1029,16 +1049,52 @@ const resolveIconFile = (
|
|
|
1029
1049
|
|
|
1030
1050
|
/**
|
|
1031
1051
|
* Resolve the site favicon by convention, falling back to the bundled Blume mark
|
|
1032
|
-
* when the project ships no `icon.*`/`favicon.*` file.
|
|
1052
|
+
* when the project ships no `icon.*`/`favicon.*` file. The dark-scheme variant
|
|
1053
|
+
* is anchored to the resolved icon: its `-dark` sibling (e.g. `icon.svg` →
|
|
1054
|
+
* `icon-dark.svg`) in the same directory — never an unrelated `-dark` file, so a
|
|
1055
|
+
* stale or foreign `favicon-dark.*` can't silently pair with a different mark.
|
|
1056
|
+
* A `-dark` file with no light sibling is the site's only mark and is used for
|
|
1057
|
+
* both schemes, mirroring how `resolveLogo` treats a single-variant image.
|
|
1033
1058
|
*/
|
|
1034
|
-
const resolveFavicon = (project: BlumeProject): BlumeFavicon =>
|
|
1035
|
-
|
|
1059
|
+
const resolveFavicon = (project: BlumeProject): BlumeFavicon => {
|
|
1060
|
+
const { root } = project.context;
|
|
1061
|
+
for (const name of FAVICON_CANDIDATES) {
|
|
1062
|
+
if (existsSync(join(root, "public", name))) {
|
|
1063
|
+
const type = faviconType(name);
|
|
1064
|
+
const sibling = darkSibling(name);
|
|
1065
|
+
return existsSync(join(root, "public", sibling))
|
|
1066
|
+
? { dark: { href: `/${sibling}`, type }, href: `/${name}`, type }
|
|
1067
|
+
: { href: `/${name}`, type };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
for (const name of FAVICON_CANDIDATES) {
|
|
1071
|
+
const file = join(root, name);
|
|
1072
|
+
if (existsSync(file)) {
|
|
1073
|
+
const type = faviconType(name);
|
|
1074
|
+
const mime = type ?? "image/x-icon";
|
|
1075
|
+
const siblingFile = join(root, darkSibling(name));
|
|
1076
|
+
return existsSync(siblingFile)
|
|
1077
|
+
? {
|
|
1078
|
+
dark: { href: inlineDataUri(siblingFile, mime), type },
|
|
1079
|
+
href: inlineDataUri(file, mime),
|
|
1080
|
+
type,
|
|
1081
|
+
}
|
|
1082
|
+
: { href: inlineDataUri(file, mime), type };
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return (
|
|
1086
|
+
resolveIconFile(project, FAVICON_CANDIDATES.map(darkSibling)) ??
|
|
1087
|
+
defaultFavicon()
|
|
1088
|
+
);
|
|
1089
|
+
};
|
|
1036
1090
|
|
|
1037
1091
|
/**
|
|
1038
1092
|
* Resolve the Apple touch icon by convention, or null when the project ships
|
|
1039
1093
|
* none (unlike the favicon, there's no bundled default). Note: iOS ignores
|
|
1040
1094
|
* `data:`-URI apple-touch-icons, so a `public/` file (served by URL) is the
|
|
1041
1095
|
* reliable path; a root-level file is still inlined for symmetry with favicons.
|
|
1096
|
+
* Deliberately no `-dark` sibling detection here: iOS ignores `media` on
|
|
1097
|
+
* `apple-touch-icon` links, so a dark variant could never be served.
|
|
1042
1098
|
*/
|
|
1043
1099
|
const resolveAppleIcon = (project: BlumeProject): BlumeFavicon | null =>
|
|
1044
1100
|
resolveIconFile(project, APPLE_ICON_CANDIDATES);
|
|
@@ -1231,6 +1287,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1231
1287
|
palette: config.seo.og.palette,
|
|
1232
1288
|
site: resolveOgSite(config),
|
|
1233
1289
|
},
|
|
1290
|
+
openInChat: config.ai.openInChat,
|
|
1234
1291
|
repoUrl,
|
|
1235
1292
|
search: {
|
|
1236
1293
|
enabled: config.search.provider !== "none",
|
|
@@ -1394,6 +1451,76 @@ const writeMcpFiles = async (
|
|
|
1394
1451
|
]);
|
|
1395
1452
|
};
|
|
1396
1453
|
|
|
1454
|
+
/**
|
|
1455
|
+
* Decide whether to generate the playground's built-in CORS proxy endpoint.
|
|
1456
|
+
* Only the Blume renderer's playground with `proxy: true` needs it — a proxy
|
|
1457
|
+
* URL string points at an external service, and `false` sends requests
|
|
1458
|
+
* directly. Injected at `/_api-proxy` (rather than written under `pages/`)
|
|
1459
|
+
* because Astro treats `_`-prefixed page files as private; the endpoint's own
|
|
1460
|
+
* `prerender = false` export wins over the injection default.
|
|
1461
|
+
*/
|
|
1462
|
+
const planPlaygroundProxy = (config: ResolvedConfig, srcDir: string) => ({
|
|
1463
|
+
enabled:
|
|
1464
|
+
config.openapi.enabled &&
|
|
1465
|
+
config.openapi.renderer === "blume" &&
|
|
1466
|
+
config.openapi.playground.enabled &&
|
|
1467
|
+
config.openapi.playground.proxy === true,
|
|
1468
|
+
entrypoint: join(srcDir, "blume-openapi", "api-proxy.ts"),
|
|
1469
|
+
pattern: "/_api-proxy",
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* The origins the built-in proxy is allowed to reach: one per absolute
|
|
1474
|
+
* `servers[].url` across the parsed specs. This is the endpoint's whole trust
|
|
1475
|
+
* boundary — the client sends the target as a query parameter, and a reader's
|
|
1476
|
+
* custom base URL is not a documented server — so it is derived here, at build
|
|
1477
|
+
* time, from the same documents the operation pages render.
|
|
1478
|
+
*
|
|
1479
|
+
* Relative (`/v1`) and templated (`{env}.api.example.com`) server URLs carry no
|
|
1480
|
+
* origin to allow and are skipped; AsyncAPI documents declare `servers` as a
|
|
1481
|
+
* map and contribute nothing (the proxy is OpenAPI-only).
|
|
1482
|
+
*/
|
|
1483
|
+
const specOrigins = (data: OpenApiData): string[] => {
|
|
1484
|
+
const origins = new Set<string>();
|
|
1485
|
+
for (const spec of Object.values(data)) {
|
|
1486
|
+
// SAFETY: `document` is arbitrary parsed JSON; the assertion only names
|
|
1487
|
+
// the optional `servers` shape, and every access below re-checks it —
|
|
1488
|
+
// `Array.isArray(servers)` guards the list and `server.url ?? ""` the url.
|
|
1489
|
+
const { servers } = spec.document as { servers?: { url?: string }[] };
|
|
1490
|
+
for (const server of Array.isArray(servers) ? servers : []) {
|
|
1491
|
+
const url = server.url ?? "";
|
|
1492
|
+
// `new URL("https://{region}.api.example.com")` parses — the braces land
|
|
1493
|
+
// in the hostname — so templated URLs need an explicit check or their
|
|
1494
|
+
// junk literal becomes an allowlist entry no real request can match.
|
|
1495
|
+
if (url.includes("{")) {
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
try {
|
|
1499
|
+
origins.add(new URL(url).origin);
|
|
1500
|
+
} catch {
|
|
1501
|
+
// Not an absolute URL: nothing to allow.
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return [...origins].toSorted();
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* The build-time diagnostic for a proxy whose allowlist came out empty. The
|
|
1510
|
+
* allowlist comes solely from absolute `servers[].url` entries — relative and
|
|
1511
|
+
* templated ones carry no origin — and with none at all the endpoint would
|
|
1512
|
+
* 403 every playground send with nothing pointing the author at the spec.
|
|
1513
|
+
*/
|
|
1514
|
+
const proxyAllowlistWarnings = (
|
|
1515
|
+
enabled: boolean,
|
|
1516
|
+
origins: string[]
|
|
1517
|
+
): string[] =>
|
|
1518
|
+
enabled && origins.length === 0
|
|
1519
|
+
? [
|
|
1520
|
+
"openapi.playground.proxy is enabled, but no spec declares an absolute servers[].url (relative and templated URLs carry no origin), so the proxy's allowlist is empty and it will refuse every request. Add an absolute server URL to the spec, or point playground.proxy at an external proxy URL.",
|
|
1521
|
+
]
|
|
1522
|
+
: [];
|
|
1523
|
+
|
|
1397
1524
|
/**
|
|
1398
1525
|
* Write the Ask AI endpoint and, unless the backend runs its own retrieval
|
|
1399
1526
|
* (Inkeep), the grounding snapshot the endpoint queries at request time. A no-op
|
|
@@ -1417,7 +1544,10 @@ const writeAskFiles = async (
|
|
|
1417
1544
|
}
|
|
1418
1545
|
await write(
|
|
1419
1546
|
join(srcDir, "pages", "api", "ask.ts"),
|
|
1420
|
-
askEndpointTemplate(resolveAskBackend(ask), grounded,
|
|
1547
|
+
askEndpointTemplate(resolveAskBackend(ask), grounded, {
|
|
1548
|
+
instructions: ask.instructions,
|
|
1549
|
+
retrieval: ask.retrieval,
|
|
1550
|
+
})
|
|
1421
1551
|
);
|
|
1422
1552
|
};
|
|
1423
1553
|
|
|
@@ -1635,6 +1765,25 @@ export const generateRuntime = async (
|
|
|
1635
1765
|
const mcp = planMcp(project, srcDir, pages);
|
|
1636
1766
|
pages.push(...mcp.discoveryPages);
|
|
1637
1767
|
|
|
1768
|
+
// The parsed OpenAPI specs behind the `blume:openapi` alias, also the source
|
|
1769
|
+
// of the proxy's origin allowlist below. The source parsed them during the
|
|
1770
|
+
// scan, so reading them here is free.
|
|
1771
|
+
const openApiSource = project.sources.find(isOpenApiSource);
|
|
1772
|
+
const openApiData = openApiSource ? openApiSource.openApiData() : {};
|
|
1773
|
+
|
|
1774
|
+
// The playground's built-in CORS proxy rides the same injection path as the
|
|
1775
|
+
// MCP discovery docs; the endpoint itself opts out of prerendering.
|
|
1776
|
+
const playgroundProxy = planPlaygroundProxy(config, srcDir);
|
|
1777
|
+
if (playgroundProxy.enabled) {
|
|
1778
|
+
pages.push({
|
|
1779
|
+
entrypoint: playgroundProxy.entrypoint,
|
|
1780
|
+
pattern: playgroundProxy.pattern,
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
// Computed once: the endpoint template bakes it in below, and an empty list
|
|
1784
|
+
// is worth a diagnostic — the proxy would refuse every request it gets.
|
|
1785
|
+
const proxyOrigins = specOrigins(openApiData);
|
|
1786
|
+
|
|
1638
1787
|
const hasStaged = staged.size > 0;
|
|
1639
1788
|
// Only emit a project-scanning `docs` collection when a filesystem source
|
|
1640
1789
|
// actually feeds it. An all-staged project (openapi/notion/…) has only staged
|
|
@@ -1780,6 +1929,9 @@ export const generateRuntime = async (
|
|
|
1780
1929
|
),
|
|
1781
1930
|
writeAskFiles(project, srcDir, write),
|
|
1782
1931
|
writeMcpFiles(project, mcp, write),
|
|
1932
|
+
playgroundProxy.enabled
|
|
1933
|
+
? write(playgroundProxy.entrypoint, playgroundProxyTemplate(proxyOrigins))
|
|
1934
|
+
: Promise.resolve(false),
|
|
1783
1935
|
]);
|
|
1784
1936
|
|
|
1785
1937
|
if (config.seo.og.enabled) {
|
|
@@ -1895,6 +2047,7 @@ export const generateRuntime = async (
|
|
|
1895
2047
|
// regenerated each run.
|
|
1896
2048
|
const warnings: string[] = [
|
|
1897
2049
|
...(depsLinkWarning ? [depsLinkWarning] : []),
|
|
2050
|
+
...proxyAllowlistWarnings(playgroundProxy.enabled, proxyOrigins),
|
|
1898
2051
|
...reactCompilerWarnings(config, needsReact, reactCompilerPath),
|
|
1899
2052
|
...mcp.warnings,
|
|
1900
2053
|
...islandDiscovery.warnings,
|
|
@@ -1961,20 +2114,16 @@ export const generateRuntime = async (
|
|
|
1961
2114
|
);
|
|
1962
2115
|
}
|
|
1963
2116
|
|
|
1964
|
-
//
|
|
1965
|
-
//
|
|
1966
|
-
//
|
|
1967
|
-
const openApiSource = project.sources.find(isOpenApiSource);
|
|
2117
|
+
// `openapi.json` (the `blume:openapi` alias) is always written — even as `{}`
|
|
2118
|
+
// — so the alias resolves whether or not a reference is enabled; the specs
|
|
2119
|
+
// were parsed during the scan, so this is just serialization.
|
|
1968
2120
|
// These write to distinct trees and never read one another, so they batch.
|
|
1969
2121
|
// `data.json`/`openapi.json` and the manifest are not "structural" for Astro;
|
|
1970
2122
|
// they hot-reload. `writeStagedContent` owns the `.blume/content` tree (its
|
|
1971
2123
|
// own pruning), outside `.blume/src`, so a removed remote entry doesn't linger.
|
|
1972
2124
|
await Promise.all([
|
|
1973
2125
|
write(join(srcDir, "generated", "data.json"), buildRuntimeData(project)),
|
|
1974
|
-
write(
|
|
1975
|
-
openapiPath,
|
|
1976
|
-
`${JSON.stringify(openApiSource ? openApiSource.openApiData() : {})}\n`
|
|
1977
|
-
),
|
|
2126
|
+
write(openapiPath, `${JSON.stringify(openApiData)}\n`),
|
|
1978
2127
|
write(
|
|
1979
2128
|
join(out, "blume.manifest.json"),
|
|
1980
2129
|
`${JSON.stringify(project.manifest, null, 2)}\n`
|
package/src/astro/templates.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { pathToFileURL } from "node:url";
|
|
|
3
3
|
|
|
4
4
|
import { dirname, isAbsolute, join, relative } from "pathe";
|
|
5
5
|
|
|
6
|
+
import type { AskRetrievalOptions } from "../ai/ask-context.ts";
|
|
6
7
|
import { askBackendRuntimeDep } from "../ai/ask.ts";
|
|
7
8
|
import type { AskBackend } from "../ai/ask.ts";
|
|
8
9
|
import { buildHomeLinkHeader } from "../ai/link-headers.ts";
|
|
@@ -406,6 +407,11 @@ const resolveOptimizeDeps = (options: {
|
|
|
406
407
|
const optimizeDepsInclude = [
|
|
407
408
|
"blume > mermaid",
|
|
408
409
|
"blume > epub-gen-memory/bundle",
|
|
410
|
+
// Astro's own client-router/prefetch virtual modules are deliberately NOT
|
|
411
|
+
// forced in here: they read Vite `define`-injected constants
|
|
412
|
+
// (__PREFETCH_PREFETCH_ALL__ and friends) that a pre-bundled copy loses,
|
|
413
|
+
// throwing ReferenceError on every page. Astro manages their optimization
|
|
414
|
+
// itself, without a mid-session reload.
|
|
409
415
|
...(options.needsReact && options.reactCompilerPath
|
|
410
416
|
? ["react/compiler-runtime"]
|
|
411
417
|
: []),
|
|
@@ -694,10 +700,10 @@ ${userConfigSetup}export default defineConfig({
|
|
|
694
700
|
},
|
|
695
701
|
},
|
|
696
702
|
devToolbar: { enabled: false },
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
// user's intent
|
|
700
|
-
//
|
|
703
|
+
// The layouts render Astro's <ClientRouter />, and its in-place swaps read
|
|
704
|
+
// from the prefetch cache — fetching every link on hover/viewport hides the
|
|
705
|
+
// request latency behind the user's intent, so most navigations swap
|
|
706
|
+
// instantly.
|
|
701
707
|
prefetch: { prefetchAll: true },
|
|
702
708
|
vite: {
|
|
703
709
|
plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
|
|
@@ -892,17 +898,30 @@ export const collections = { docs${options.staged ? ", staged" : ""} };
|
|
|
892
898
|
const ASK_FALLBACK_PROMPT =
|
|
893
899
|
"You are a helpful documentation assistant. Answer using the project's documentation.";
|
|
894
900
|
|
|
901
|
+
/** The `ai.ask` values the generated endpoint has to carry with it. */
|
|
902
|
+
export interface AskEndpointOptions {
|
|
903
|
+
/** `ai.ask.instructions` — extra system-prompt text. */
|
|
904
|
+
instructions?: string;
|
|
905
|
+
/** `ai.ask.retrieval` — how much documentation each question carries. */
|
|
906
|
+
retrieval?: AskRetrievalOptions;
|
|
907
|
+
}
|
|
908
|
+
|
|
895
909
|
/**
|
|
896
910
|
* Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`).
|
|
897
|
-
*
|
|
911
|
+
*
|
|
912
|
+
* `options.instructions` (the `ai.ask.instructions` config) is appended to the
|
|
898
913
|
* built-in prompt on every path: the grounded prompt via `createAskContext`,
|
|
899
|
-
* and the plain fallback here.
|
|
914
|
+
* and the plain fallback here. `options.retrieval` (the `ai.ask.retrieval`
|
|
915
|
+
* config) is forwarded to `createAskContext` on the grounded path, where it
|
|
916
|
+
* sizes retrieval. Both travel in one options object so a new call site can't
|
|
917
|
+
* silently drop one of them.
|
|
900
918
|
*/
|
|
901
919
|
export const askEndpointTemplate = (
|
|
902
920
|
backend: AskBackend,
|
|
903
921
|
grounded: boolean,
|
|
904
|
-
|
|
922
|
+
options?: AskEndpointOptions
|
|
905
923
|
): string => {
|
|
924
|
+
const instructions = options?.instructions;
|
|
906
925
|
const fallbackPrompt = instructions
|
|
907
926
|
? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
|
|
908
927
|
: ASK_FALLBACK_PROMPT;
|
|
@@ -938,9 +957,15 @@ export const askEndpointTemplate = (
|
|
|
938
957
|
'import { createAskContext } from "blume/ai/ask-context.ts";',
|
|
939
958
|
'import askData from "../../generated/ask-data.json";'
|
|
940
959
|
);
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
:
|
|
960
|
+
const groundFields: string[] = [];
|
|
961
|
+
if (instructions) {
|
|
962
|
+
groundFields.push(`instructions: ${JSON.stringify(instructions)}`);
|
|
963
|
+
}
|
|
964
|
+
if (options?.retrieval) {
|
|
965
|
+
groundFields.push(`retrieval: ${JSON.stringify(options.retrieval)}`);
|
|
966
|
+
}
|
|
967
|
+
const groundOptions =
|
|
968
|
+
groundFields.length > 0 ? `, { ${groundFields.join(", ")} }` : "";
|
|
944
969
|
setup += `\nconst ground = createAskContext(askData${groundOptions});\n`;
|
|
945
970
|
}
|
|
946
971
|
// Validate the client-supplied body and cap its size. The endpoint is
|
|
@@ -1104,7 +1129,7 @@ const SEARCH_BASE_IMPORT =
|
|
|
1104
1129
|
/**
|
|
1105
1130
|
* A client that loads a static `blume-search.json` index (Orama, FlexSearch).
|
|
1106
1131
|
* `locale` (Orama only) is the site's `i18n.defaultLocale`, which selects a
|
|
1107
|
-
* word-segmenting tokenizer for
|
|
1132
|
+
* word-segmenting tokenizer for every non-Latin script.
|
|
1108
1133
|
*/
|
|
1109
1134
|
const staticSearchClient = (module: string, locale?: string): string =>
|
|
1110
1135
|
`${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
|
|
@@ -1415,6 +1440,31 @@ export const ALL: APIRoute = ({ request }) => handler(request);
|
|
|
1415
1440
|
`;
|
|
1416
1441
|
};
|
|
1417
1442
|
|
|
1443
|
+
/**
|
|
1444
|
+
* Generate the playground's CORS proxy endpoint
|
|
1445
|
+
* (`.blume/src/blume-openapi/api-proxy.ts`), behind
|
|
1446
|
+
* `openapi.playground.proxy: true`. A thin server-rendered wrapper around the
|
|
1447
|
+
* shipped `createPlaygroundProxyHandler`; injected at `/_api-proxy` rather
|
|
1448
|
+
* than written under `pages/` because Astro treats `_`-prefixed page files as
|
|
1449
|
+
* private.
|
|
1450
|
+
*
|
|
1451
|
+
* `origins` — the origins of the servers the documented specs declare — is
|
|
1452
|
+
* baked in as the handler's allowlist. It cannot come from the request or from
|
|
1453
|
+
* client-side data: that is the whole trust boundary keeping the endpoint from
|
|
1454
|
+
* being an open proxy onto the deployment's own network.
|
|
1455
|
+
*/
|
|
1456
|
+
export const playgroundProxyTemplate = (origins: string[]): string =>
|
|
1457
|
+
`// Generated by Blume. Do not edit.
|
|
1458
|
+
import type { APIRoute } from "astro";
|
|
1459
|
+
import { createPlaygroundProxyHandler } from "blume/openapi/proxy.ts";
|
|
1460
|
+
|
|
1461
|
+
export const prerender = false;
|
|
1462
|
+
|
|
1463
|
+
const handler = createPlaygroundProxyHandler(${JSON.stringify(origins)});
|
|
1464
|
+
|
|
1465
|
+
export const ALL: APIRoute = ({ request }) => handler(request);
|
|
1466
|
+
`;
|
|
1467
|
+
|
|
1418
1468
|
/** Generate a prerendered endpoint that serves a fixed JSON payload. */
|
|
1419
1469
|
export const staticJsonEndpointTemplate = <Payload extends object>(
|
|
1420
1470
|
payload: Payload
|
|
@@ -1956,6 +2006,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1956
2006
|
feedback={data.config.feedback}
|
|
1957
2007
|
exportPdf={${options.exportPdf}}
|
|
1958
2008
|
exportEpub={${options.exportEpub}}
|
|
2009
|
+
openInChat={data.config.openInChat}
|
|
1959
2010
|
feeds={data.feeds}
|
|
1960
2011
|
discovery={data.config.discovery}
|
|
1961
2012
|
siteUrl={data.config.site}
|
|
@@ -2188,6 +2239,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
2188
2239
|
canonical={canonical}
|
|
2189
2240
|
exportPdf={${options.exportPdf}}
|
|
2190
2241
|
exportEpub={${options.exportEpub}}
|
|
2242
|
+
openInChat={data.config.openInChat}
|
|
2191
2243
|
feeds={data.feeds}
|
|
2192
2244
|
discovery={data.config.discovery}
|
|
2193
2245
|
siteUrl={data.config.site}
|
|
@@ -121,6 +121,10 @@ const accordionId = id ?? componentSlug(title);
|
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
window.addEventListener("hashchange", openHashAccordion);
|
|
124
|
+
// Client-router swaps replace the body with fresh server-rendered
|
|
125
|
+
// accordions (duplicate ids and all), so the dedupe + hash-open pass has
|
|
126
|
+
// to run again for each new page.
|
|
127
|
+
document.addEventListener("astro:after-swap", init);
|
|
124
128
|
if (document.readyState === "loading") {
|
|
125
129
|
document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
126
130
|
} else {
|