blume 1.5.0 → 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 +32 -0
- package/README.md +16 -12
- package/dist/cli/index.js +449 -135
- package/dist/cli/index.js.map +24 -23
- package/dist/types/ai/ask-context.d.ts +78 -0
- package/dist/types/core/config-input.d.ts +54 -2
- package/dist/types/core/data.d.ts +19 -2
- 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/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/api-reference.mdx +67 -5
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/ai.mdx +35 -0
- package/docs/configuration/index.mdx +14 -2
- package/docs/configuration/search.mdx +4 -4
- package/docs/configuration/theming.mdx +4 -2
- package/docs/reference/cli.mdx +2 -2
- 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 +172 -18
- package/src/astro/templates.ts +89 -15
- 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/Fonts.astro +23 -3
- 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 +79 -5
- package/src/components/layout/ReferenceLayout.astro +12 -9
- package/src/components/layout/RootLayout.astro +153 -121
- 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 +67 -1
- package/src/core/content-assets.ts +66 -15
- package/src/core/data.ts +16 -2
- package/src/core/last-modified.ts +76 -2
- 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/project-graph.ts +11 -0
- package/src/core/schema.ts +60 -1
- 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/deploy/vercel-negotiation.ts +34 -14
- package/src/og/card.ts +3 -1
- 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 +15 -2
- package/src/theme/fonts.ts +75 -3
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,6 +700,11 @@ ${userConfigSetup}export default defineConfig({
|
|
|
694
700
|
},
|
|
695
701
|
},
|
|
696
702
|
devToolbar: { enabled: false },
|
|
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.
|
|
707
|
+
prefetch: { prefetchAll: true },
|
|
697
708
|
vite: {
|
|
698
709
|
plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
|
|
699
710
|
// Everything hydration can reach must be part of the dev dep optimizer's
|
|
@@ -887,17 +898,30 @@ export const collections = { docs${options.staged ? ", staged" : ""} };
|
|
|
887
898
|
const ASK_FALLBACK_PROMPT =
|
|
888
899
|
"You are a helpful documentation assistant. Answer using the project's documentation.";
|
|
889
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
|
+
|
|
890
909
|
/**
|
|
891
910
|
* Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`).
|
|
892
|
-
*
|
|
911
|
+
*
|
|
912
|
+
* `options.instructions` (the `ai.ask.instructions` config) is appended to the
|
|
893
913
|
* built-in prompt on every path: the grounded prompt via `createAskContext`,
|
|
894
|
-
* 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.
|
|
895
918
|
*/
|
|
896
919
|
export const askEndpointTemplate = (
|
|
897
920
|
backend: AskBackend,
|
|
898
921
|
grounded: boolean,
|
|
899
|
-
|
|
922
|
+
options?: AskEndpointOptions
|
|
900
923
|
): string => {
|
|
924
|
+
const instructions = options?.instructions;
|
|
901
925
|
const fallbackPrompt = instructions
|
|
902
926
|
? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
|
|
903
927
|
: ASK_FALLBACK_PROMPT;
|
|
@@ -933,9 +957,15 @@ export const askEndpointTemplate = (
|
|
|
933
957
|
'import { createAskContext } from "blume/ai/ask-context.ts";',
|
|
934
958
|
'import askData from "../../generated/ask-data.json";'
|
|
935
959
|
);
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
:
|
|
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(", ")} }` : "";
|
|
939
969
|
setup += `\nconst ground = createAskContext(askData${groundOptions});\n`;
|
|
940
970
|
}
|
|
941
971
|
// Validate the client-supplied body and cap its size. The endpoint is
|
|
@@ -1099,7 +1129,7 @@ const SEARCH_BASE_IMPORT =
|
|
|
1099
1129
|
/**
|
|
1100
1130
|
* A client that loads a static `blume-search.json` index (Orama, FlexSearch).
|
|
1101
1131
|
* `locale` (Orama only) is the site's `i18n.defaultLocale`, which selects a
|
|
1102
|
-
* word-segmenting tokenizer for
|
|
1132
|
+
* word-segmenting tokenizer for every non-Latin script.
|
|
1103
1133
|
*/
|
|
1104
1134
|
const staticSearchClient = (module: string, locale?: string): string =>
|
|
1105
1135
|
`${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
|
|
@@ -1410,6 +1440,31 @@ export const ALL: APIRoute = ({ request }) => handler(request);
|
|
|
1410
1440
|
`;
|
|
1411
1441
|
};
|
|
1412
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
|
+
|
|
1413
1468
|
/** Generate a prerendered endpoint that serves a fixed JSON payload. */
|
|
1414
1469
|
export const staticJsonEndpointTemplate = <Payload extends object>(
|
|
1415
1470
|
payload: Payload
|
|
@@ -1454,7 +1509,8 @@ export function GET({ props }: { props: { section: string } }) {
|
|
|
1454
1509
|
/** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
|
|
1455
1510
|
export const ogEndpointTemplate = (
|
|
1456
1511
|
customRoutes: OgCustomRoute[] = [],
|
|
1457
|
-
og: { families?: OgFontFamilies; fonts?: OgFont[] } = {}
|
|
1512
|
+
og: { families?: OgFontFamilies; fonts?: OgFont[] } = {},
|
|
1513
|
+
includeChangelog = false
|
|
1458
1514
|
): string =>
|
|
1459
1515
|
`// Generated by Blume. Do not edit.
|
|
1460
1516
|
import { renderOgImage } from "blume/og";
|
|
@@ -1493,6 +1549,13 @@ export function getStaticPaths() {
|
|
|
1493
1549
|
}
|
|
1494
1550
|
for (const route of data.routes) {
|
|
1495
1551
|
add(route.path === "/" ? "index" : route.path.slice(1), route.title);
|
|
1552
|
+
}${
|
|
1553
|
+
includeChangelog
|
|
1554
|
+
? `
|
|
1555
|
+
// The generated changelog index is not a content route, so it needs its own
|
|
1556
|
+
// card. Added last: a custom page or content route owning /changelog wins.
|
|
1557
|
+
add("changelog", data.ui.changelog?.title ?? "Changelog");`
|
|
1558
|
+
: ""
|
|
1496
1559
|
}
|
|
1497
1560
|
return paths;
|
|
1498
1561
|
}
|
|
@@ -1795,10 +1858,9 @@ const contentLocale =
|
|
|
1795
1858
|
const contentDir = i18n
|
|
1796
1859
|
? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
|
|
1797
1860
|
: "ltr";
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
};
|
|
1861
|
+
// The root route keeps its trailing slash (\`https://site/\`) so canonical and
|
|
1862
|
+
// hreflang URLs byte-match the sitemap's <loc> for the home page.
|
|
1863
|
+
const absolute = (path: string) => base + withBase(path);
|
|
1802
1864
|
|
|
1803
1865
|
// An archived page defaults its canonical to the same page in the latest docs
|
|
1804
1866
|
// when that page still exists — search engines treat the live page as
|
|
@@ -1809,7 +1871,7 @@ const canonical =
|
|
|
1809
1871
|
(archived && archived.canonical === "latest" && latestVersionAlt && base
|
|
1810
1872
|
? absolute(latestVersionAlt.path)
|
|
1811
1873
|
: base
|
|
1812
|
-
? \`\${base}\${basedRoute === "/" ? "" : encodeURI(basedRoute)}\`
|
|
1874
|
+
? \`\${base}\${basedRoute === "/" ? "/" : encodeURI(basedRoute)}\`
|
|
1813
1875
|
: null);
|
|
1814
1876
|
const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
|
|
1815
1877
|
|
|
@@ -1944,6 +2006,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1944
2006
|
feedback={data.config.feedback}
|
|
1945
2007
|
exportPdf={${options.exportPdf}}
|
|
1946
2008
|
exportEpub={${options.exportEpub}}
|
|
2009
|
+
openInChat={data.config.openInChat}
|
|
1947
2010
|
feeds={data.feeds}
|
|
1948
2011
|
discovery={data.config.discovery}
|
|
1949
2012
|
siteUrl={data.config.site}
|
|
@@ -2122,6 +2185,12 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
|
2122
2185
|
const basedRoute = withBase("/changelog");
|
|
2123
2186
|
const canonical = base ? base + basedRoute : null;
|
|
2124
2187
|
|
|
2188
|
+
// The generated OG card for this route (the /og endpoint emits it alongside
|
|
2189
|
+
// the content-route cards), absolutized like the catch-all's so crawlers get
|
|
2190
|
+
// a full URL when the site is known.
|
|
2191
|
+
const ogPath = data.config.og.enabled ? withBase("/og/changelog.png") : null;
|
|
2192
|
+
const ogImage = ogPath && base ? base + ogPath : ogPath;
|
|
2193
|
+
|
|
2125
2194
|
// The page chrome (h1, title, description) comes from the same translatable
|
|
2126
2195
|
// \`changelog\` group as the reveal button; optional chaining tolerates a
|
|
2127
2196
|
// not-yet-regenerated data snapshot from before these keys existed.
|
|
@@ -2129,7 +2198,10 @@ const changelogTitle = data.ui.changelog?.title ?? "Changelog";
|
|
|
2129
2198
|
const changelogDescription =
|
|
2130
2199
|
data.ui.changelog?.description ??
|
|
2131
2200
|
"Product updates, new features, and fixes from every release.";
|
|
2132
|
-
|
|
2201
|
+
// The layout suffixes "- {site title}" itself, so the page title is just the
|
|
2202
|
+
// changelog's own name — prefixing the site title too would double it
|
|
2203
|
+
// ("Acme Changelog - Acme").
|
|
2204
|
+
const pageTitle = changelogTitle;
|
|
2133
2205
|
|
|
2134
2206
|
const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
2135
2207
|
---
|
|
@@ -2161,11 +2233,13 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
2161
2233
|
fontCssVars={data.fontCssVars}
|
|
2162
2234
|
searchEnabled={data.config.search.enabled}
|
|
2163
2235
|
indexable={true}
|
|
2164
|
-
ogImage={
|
|
2236
|
+
ogImage={ogImage}
|
|
2237
|
+
ogGenerated={Boolean(ogImage)}
|
|
2165
2238
|
x={data.config.x}
|
|
2166
2239
|
canonical={canonical}
|
|
2167
2240
|
exportPdf={${options.exportPdf}}
|
|
2168
2241
|
exportEpub={${options.exportEpub}}
|
|
2242
|
+
openInChat={data.config.openInChat}
|
|
2169
2243
|
feeds={data.feeds}
|
|
2170
2244
|
discovery={data.config.discovery}
|
|
2171
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 {
|
|
@@ -110,6 +110,9 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
|
|
|
110
110
|
}
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
+
// Client-router swaps replace the body with fresh server-rendered entries
|
|
114
|
+
// (duplicate ids and all), so the dedupe has to run again for each page.
|
|
115
|
+
document.addEventListener("astro:after-swap", dedupeUpdateIds);
|
|
113
116
|
if (document.readyState === "loading") {
|
|
114
117
|
document.addEventListener("DOMContentLoaded", dedupeUpdateIds, {
|
|
115
118
|
once: true,
|
|
@@ -44,12 +44,18 @@ const icons = {
|
|
|
44
44
|
};
|
|
45
45
|
---
|
|
46
46
|
|
|
47
|
+
{/* transition:persist carries the island — and with it the conversation,
|
|
48
|
+
the draft input, and the open panel — across client-router navigations
|
|
49
|
+
instead of remounting it empty on every page. The island re-portals its
|
|
50
|
+
panel and re-stamps the body push attribute itself on astro:after-swap
|
|
51
|
+
(see ask-ai.tsx), since the swap replaces <body> wholesale. */}
|
|
47
52
|
<AskAI
|
|
48
53
|
client:load
|
|
49
54
|
endpoint={endpoint}
|
|
50
55
|
icons={icons}
|
|
51
56
|
strings={strings}
|
|
52
57
|
suggestions={items}
|
|
58
|
+
transition:persist="blume-ask"
|
|
53
59
|
/>
|
|
54
60
|
|
|
55
61
|
<style is:global>
|
|
@@ -144,7 +144,6 @@ const AskAI = ({
|
|
|
144
144
|
// Merge per key (not `strings ?? …`) so a dictionary from a stale snapshot
|
|
145
145
|
// that predates newer keys still resolves every label to its English default.
|
|
146
146
|
const t = { ...DEFAULT_ASK, ...strings };
|
|
147
|
-
const [mounted, setMounted] = useState(false);
|
|
148
147
|
const [open, setOpen] = useState(false);
|
|
149
148
|
const [input, setInput] = useState("");
|
|
150
149
|
// The streaming client — request shaping, optimistic assistant bubble,
|
|
@@ -164,10 +163,25 @@ const AskAI = ({
|
|
|
164
163
|
// Where focus came from when the panel opened, restored on close.
|
|
165
164
|
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
166
165
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
166
|
+
// Where the panel portals to — the CURRENT document.body, held as state.
|
|
167
|
+
// Null until mount (guards SSR), then refreshed on every client-router swap:
|
|
168
|
+
// the island rides across navigations via transition:persist, but each swap
|
|
169
|
+
// installs a NEW <body>, discarding the portaled panel with the old one and
|
|
170
|
+
// resetting the `data-blume-ask` push attribute to the incoming page's
|
|
171
|
+
// server-rendered set. Reading document.body inline in render would NOT
|
|
172
|
+
// recover from that — it isn't a reactive value, so the memoized portal
|
|
173
|
+
// keeps its stale (detached) container. State identity is what re-anchors
|
|
174
|
+
// the portal and re-runs the body-scoped effects below.
|
|
175
|
+
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
// The initial null→body flip is deliberate (there is no body during SSR);
|
|
178
|
+
// it is the same one-time post-mount cascade the old `mounted` flag had.
|
|
179
|
+
// oxlint-disable-next-line react/react-compiler -- deliberate post-mount portal-target initialization
|
|
180
|
+
setPortalTarget(document.body);
|
|
181
|
+
const onSwap = () => setPortalTarget(document.body);
|
|
182
|
+
document.addEventListener("astro:after-swap", onSwap);
|
|
183
|
+
return () => document.removeEventListener("astro:after-swap", onSwap);
|
|
184
|
+
}, []);
|
|
171
185
|
|
|
172
186
|
// The search modal forwards its query so "Ask AI: <query>" carries straight in.
|
|
173
187
|
useEffect(() => {
|
|
@@ -239,6 +253,18 @@ const AskAI = ({
|
|
|
239
253
|
};
|
|
240
254
|
}, [open]);
|
|
241
255
|
|
|
256
|
+
// Re-stamp the push attribute after a swap while the panel is open — the new
|
|
257
|
+
// body arrives without it. Deliberately separate from the effect above: a
|
|
258
|
+
// navigation must not re-run the focus handling and yank focus out of the
|
|
259
|
+
// page the reader just moved to.
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
// document.body (not portalTarget) so the compiler doesn't flag a state
|
|
262
|
+
// mutation; by the time this runs for a swap, they are the same element.
|
|
263
|
+
if (open && portalTarget) {
|
|
264
|
+
document.body.dataset.blumeAsk = "open";
|
|
265
|
+
}
|
|
266
|
+
}, [open, portalTarget]);
|
|
267
|
+
|
|
242
268
|
// Below the desktop dock breakpoint the open panel is a full-width overlay,
|
|
243
269
|
// so Tab must not escape into the page it covers: every other child of
|
|
244
270
|
// <body> (the panel portals to body) turns inert until close. The desktop
|
|
@@ -299,12 +325,16 @@ const AskAI = ({
|
|
|
299
325
|
media.removeEventListener("change", apply);
|
|
300
326
|
release();
|
|
301
327
|
};
|
|
302
|
-
|
|
328
|
+
// portalTarget: each swap installs a new <body>, so the sweep and its
|
|
329
|
+
// observer must re-run against the new children (the old ones are
|
|
330
|
+
// detached).
|
|
331
|
+
}, [open, portalTarget]);
|
|
303
332
|
|
|
304
|
-
// Keep the newest message in view as it streams in
|
|
333
|
+
// Keep the newest message in view as it streams in — and after a swap, when
|
|
334
|
+
// the re-portaled panel's scroll container is reborn at the top.
|
|
305
335
|
useEffect(() => {
|
|
306
336
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
|
307
|
-
}, [messages]);
|
|
337
|
+
}, [messages, portalTarget]);
|
|
308
338
|
|
|
309
339
|
const runQuestion = (raw: string) => {
|
|
310
340
|
const question = raw.trim();
|
|
@@ -501,7 +531,7 @@ const AskAI = ({
|
|
|
501
531
|
>
|
|
502
532
|
<Glyph path={icons.chat} size={18} />
|
|
503
533
|
</button>
|
|
504
|
-
{
|
|
534
|
+
{portalTarget && createPortal(panel, portalTarget)}
|
|
505
535
|
</>
|
|
506
536
|
);
|
|
507
537
|
};
|
|
@@ -33,8 +33,16 @@ const vercelEnabled = enabled && analytics?.vercel === true;
|
|
|
33
33
|
const POSTHOG_LOADER =
|
|
34
34
|
'!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);';
|
|
35
35
|
const posthog = enabled ? analytics?.posthog : undefined;
|
|
36
|
+
// PostHog's loader only captures a pageview per real page load, but the client
|
|
37
|
+
// router turns link clicks into in-place swaps — capture those too, keyed off
|
|
38
|
+
// `astro:page-load` with a pathname guard so the initial load (which the
|
|
39
|
+
// loader already counted) and same-page hash moves aren't double-counted.
|
|
40
|
+
// Vercel Web Analytics and GA4's enhanced measurement track history changes
|
|
41
|
+
// on their own.
|
|
42
|
+
const POSTHOG_SPA_PAGEVIEWS =
|
|
43
|
+
'document.addEventListener("astro:page-load",function(){var p=window.__blumePhPath;window.__blumePhPath=location.pathname;if(p!==undefined&&p!==location.pathname){posthog.capture("$pageview");}});';
|
|
36
44
|
const posthogSnippet = posthog
|
|
37
|
-
? `${POSTHOG_LOADER}posthog.init(${JSON.stringify(posthog.key)},{api_host:${JSON.stringify(posthog.host ?? "https://us.i.posthog.com")}})
|
|
45
|
+
? `${POSTHOG_LOADER}posthog.init(${JSON.stringify(posthog.key)},{api_host:${JSON.stringify(posthog.host ?? "https://us.i.posthog.com")}});${POSTHOG_SPA_PAGEVIEWS}`
|
|
38
46
|
: null;
|
|
39
47
|
|
|
40
48
|
// Custom scripts: any other provider. Each is either external (`src`) or inline
|
|
@@ -1,23 +1,44 @@
|
|
|
1
1
|
---
|
|
2
|
+
import type { BlumeFavicon } from "../../core/data.ts";
|
|
2
3
|
import { withBase } from "../islands/base-path.ts";
|
|
3
4
|
// Emits the icon <link>s for the document <head>: the favicon (rel="icon") and,
|
|
4
5
|
// when the project ships one, the Apple touch icon (rel="apple-touch-icon").
|
|
5
6
|
// Each href is either a URL to a file the project ships in public/, or a data
|
|
6
7
|
// URI (a root-level icon, or the bundled Blume favicon default) — both resolved
|
|
7
8
|
// by the generator's resolveFavicon / resolveAppleIcon.
|
|
9
|
+
//
|
|
10
|
+
// When the favicon has a dark variant, three links cover every consumer class:
|
|
11
|
+
// an unconditional light link first (bots and services that take the first
|
|
12
|
+
// rel="icon" and ignore `media`, and UAs that honor `media` but match neither
|
|
13
|
+
// color-scheme query), then the media-gated pair. Browsers that honor `media`
|
|
14
|
+
// take the last matching link — the dark link in dark mode, the trailing light
|
|
15
|
+
// link in light mode — and browsers that ignore `media` but take the last link
|
|
16
|
+
// also land on light, the safe default against light browser chrome.
|
|
8
17
|
interface Props {
|
|
9
|
-
favicon?:
|
|
10
|
-
|
|
11
|
-
type?: string;
|
|
12
|
-
} | null;
|
|
13
|
-
appleIcon?: {
|
|
14
|
-
href: string;
|
|
15
|
-
type?: string;
|
|
16
|
-
} | null;
|
|
18
|
+
favicon?: BlumeFavicon | null;
|
|
19
|
+
appleIcon?: BlumeFavicon | null;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
const { appleIcon, favicon } = Astro.props;
|
|
20
23
|
---
|
|
21
24
|
|
|
22
25
|
{favicon && <link href={withBase(favicon.href)} rel="icon" type={favicon.type} />}
|
|
26
|
+
{
|
|
27
|
+
favicon?.dark && (
|
|
28
|
+
<>
|
|
29
|
+
<link
|
|
30
|
+
href={withBase(favicon.dark.href)}
|
|
31
|
+
media="(prefers-color-scheme: dark)"
|
|
32
|
+
rel="icon"
|
|
33
|
+
type={favicon.dark.type}
|
|
34
|
+
/>
|
|
35
|
+
<link
|
|
36
|
+
href={withBase(favicon.href)}
|
|
37
|
+
media="(prefers-color-scheme: light)"
|
|
38
|
+
rel="icon"
|
|
39
|
+
type={favicon.type}
|
|
40
|
+
/>
|
|
41
|
+
</>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
23
44
|
{appleIcon && <link href={withBase(appleIcon.href)} rel="apple-touch-icon" />}
|
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
---
|
|
2
2
|
// Emits the optimized @font-face declarations + preload links for each
|
|
3
3
|
// configured font (Astro's Fonts API). Renders nothing when no fonts are set;
|
|
4
|
-
// the CSS variables match the astro.config `fonts:` entries.
|
|
4
|
+
// the CSS variables match the astro.config `fonts:` entries. Preloads are
|
|
5
|
+
// narrowed to the weights above-the-fold text renders in (see
|
|
6
|
+
// `theme/fonts.ts`); a bare string entry — an older generated template — keeps
|
|
7
|
+
// the previous preload-everything behavior.
|
|
5
8
|
import { Font } from "astro:assets";
|
|
9
|
+
import type { FontHead } from "../../theme/fonts.ts";
|
|
6
10
|
|
|
7
11
|
interface Props {
|
|
8
|
-
cssVars: string[];
|
|
12
|
+
cssVars: (string | FontHead)[];
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
const { cssVars } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const entries = cssVars.map((value) =>
|
|
18
|
+
typeof value === "string"
|
|
19
|
+
? { cssVariable: value, preload: true as const }
|
|
20
|
+
: {
|
|
21
|
+
cssVariable: value.cssVariable,
|
|
22
|
+
preload: value.preloadWeights.map((weight) => ({
|
|
23
|
+
style: "normal" as const,
|
|
24
|
+
weight,
|
|
25
|
+
})),
|
|
26
|
+
}
|
|
27
|
+
);
|
|
12
28
|
---
|
|
13
29
|
|
|
14
|
-
{
|
|
30
|
+
{
|
|
31
|
+
entries.map((entry) => (
|
|
32
|
+
<Font cssVariable={entry.cssVariable} preload={entry.preload} />
|
|
33
|
+
))
|
|
34
|
+
}
|
|
@@ -14,7 +14,7 @@ import { GITHUB_MARK } from "../github-mark.ts";
|
|
|
14
14
|
import Icon from "../Icon.astro";
|
|
15
15
|
import LanguageSwitcher from "./LanguageSwitcher.astro";
|
|
16
16
|
import Logo from "./Logo.astro";
|
|
17
|
-
import {
|
|
17
|
+
import { currentTabForRoute } from "./nav-utils.ts";
|
|
18
18
|
import NavSelector from "./NavSelector.astro";
|
|
19
19
|
import { resolveSlot } from "./overrides.ts";
|
|
20
20
|
import Search from "./Search.astro";
|
|
@@ -108,7 +108,7 @@ const SearchSlot = resolveSlot(layout.Search, Search);
|
|
|
108
108
|
// PageLayout) it's a tabs-only drawer the layout renders — so the button is also
|
|
109
109
|
// needed whenever there are tabs to reveal.
|
|
110
110
|
const showNavToggle = hasDrawer && (hasSidebar || navigation.tabs.length > 0);
|
|
111
|
-
const activeTab =
|
|
111
|
+
const activeTab = currentTabForRoute(navigation.tabs, route, navigation.root);
|
|
112
112
|
// Where the header's inline tab bar appears. With a sidebar it shares the `md`
|
|
113
113
|
// breakpoint with the docs drawer; without one, the tabs-only drawer is the sole
|
|
114
114
|
// mobile nav below `lg`, so the inline tabs wait until `lg` to avoid duplicating
|
|
@@ -6,7 +6,7 @@ import { withBase } from "../islands/base-path.ts";
|
|
|
6
6
|
// <details>/<summary> like the language switcher.
|
|
7
7
|
import type { NavSelector } from "../../core/types.ts";
|
|
8
8
|
import Icon from "../Icon.astro";
|
|
9
|
-
import { isUnderPath } from "
|
|
9
|
+
import { isUnderPath } from "../../core/navigation.ts";
|
|
10
10
|
|
|
11
11
|
interface Props {
|
|
12
12
|
selector: NavSelector;
|