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
|
@@ -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" />}
|
|
@@ -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;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
3
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
|
+
import { openInChatProviders } from "../../core/open-in-chat.ts";
|
|
5
|
+
import type { OpenInChatProvider } from "../../core/open-in-chat.ts";
|
|
4
6
|
import { GITHUB_MARK } from "../github-mark.ts";
|
|
5
7
|
import Icon from "../Icon.astro";
|
|
6
8
|
|
|
@@ -15,12 +17,25 @@ interface Props {
|
|
|
15
17
|
mcpUrl?: string | null;
|
|
16
18
|
/** Display name of the MCP server (defaults to the site title). */
|
|
17
19
|
mcpName?: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* "Open in chat" providers to list, in order; an empty list hides the
|
|
22
|
+
* action, and omitting the prop shows every provider.
|
|
23
|
+
*/
|
|
24
|
+
openInChat?: readonly OpenInChatProvider[];
|
|
18
25
|
/** Localized action labels. */
|
|
19
26
|
strings?: UIStrings["actions"];
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
const {
|
|
23
|
-
|
|
29
|
+
const {
|
|
30
|
+
route,
|
|
31
|
+
editUrl,
|
|
32
|
+
exportPdf,
|
|
33
|
+
exportEpub,
|
|
34
|
+
mcpUrl,
|
|
35
|
+
mcpName,
|
|
36
|
+
openInChat,
|
|
37
|
+
strings,
|
|
38
|
+
} = Astro.props;
|
|
24
39
|
// Merge over the English defaults so a label missing from a translation (or from
|
|
25
40
|
// a not-yet-regenerated snapshot) still renders instead of coming out blank.
|
|
26
41
|
const a = { ...EN_UI.actions, ...strings };
|
|
@@ -44,20 +59,28 @@ const LOGOS: Record<string, string> = {
|
|
|
44
59
|
v0: '<svg class="size-4 shrink-0" fill="currentColor" viewBox="0 0 147 70" xmlns="http://www.w3.org/2000/svg"><path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z"/><path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z"/></svg>',
|
|
45
60
|
};
|
|
46
61
|
|
|
62
|
+
/** Display names for the "Open in chat" providers. */
|
|
63
|
+
const PROVIDER_NAMES: Record<OpenInChatProvider, string> = {
|
|
64
|
+
chatgpt: "ChatGPT",
|
|
65
|
+
claude: "Claude",
|
|
66
|
+
cursor: "Cursor",
|
|
67
|
+
scira: "Scira",
|
|
68
|
+
t3: "T3 Chat",
|
|
69
|
+
v0: "v0",
|
|
70
|
+
};
|
|
71
|
+
|
|
47
72
|
// Brand names stay verbatim; the surrounding "Open in" phrase localizes via
|
|
48
|
-
// the `actions.openIn` template.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
{ key: "t3", name: "T3 Chat" },
|
|
54
|
-
{ key: "scira", name: "Scira" },
|
|
55
|
-
{ key: "cursor", name: "Cursor" },
|
|
56
|
-
].map((provider) => ({
|
|
57
|
-
...provider,
|
|
58
|
-
label: a.openIn.replace("{name}", provider.name),
|
|
73
|
+
// the `actions.openIn` template. `ai.openInChat` picks and orders the list;
|
|
74
|
+
// an empty list hides the action entirely.
|
|
75
|
+
const providers = (openInChat ?? openInChatProviders).map((key) => ({
|
|
76
|
+
key,
|
|
77
|
+
label: a.openIn.replace("{name}", PROVIDER_NAMES[key]),
|
|
59
78
|
}));
|
|
60
79
|
|
|
80
|
+
// The default list opens with v0 and separates it from the chat assistants;
|
|
81
|
+
// keep that separator only while a configured list preserves v0 first.
|
|
82
|
+
const chatSeparator = providers.length > 1 && providers[0]?.key === "v0";
|
|
83
|
+
|
|
61
84
|
// Top-level actions match the ToC links: flat, no background/padding, color
|
|
62
85
|
// shift on hover only.
|
|
63
86
|
const rowClass =
|
|
@@ -134,41 +157,45 @@ const menuRowClass =
|
|
|
134
157
|
)
|
|
135
158
|
}
|
|
136
159
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
class=
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
160
|
+
{
|
|
161
|
+
providers.length > 0 && (
|
|
162
|
+
<details class="group relative">
|
|
163
|
+
<summary
|
|
164
|
+
class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
|
|
165
|
+
>
|
|
166
|
+
<Icon name="external-link" size={16} />
|
|
167
|
+
{a.openInChat}
|
|
168
|
+
<Icon
|
|
169
|
+
class="ms-auto transition-transform group-open:rotate-180"
|
|
170
|
+
name="chevron-down"
|
|
171
|
+
size={14}
|
|
172
|
+
/>
|
|
173
|
+
</summary>
|
|
174
|
+
<div
|
|
175
|
+
class="absolute top-full right-2 z-50 mt-1 w-max min-w-[14rem] max-w-[22rem] rounded-blume border border-border bg-background p-1 shadow-xl"
|
|
176
|
+
data-blume-menu
|
|
177
|
+
>
|
|
178
|
+
{providers.map((provider, index) => (
|
|
179
|
+
<>
|
|
180
|
+
{index === 1 && chatSeparator && (
|
|
181
|
+
<hr class="my-1 border-border border-t" />
|
|
182
|
+
)}
|
|
183
|
+
<a
|
|
184
|
+
class={menuRowClass}
|
|
185
|
+
data-open-in={provider.key}
|
|
186
|
+
rel="noreferrer"
|
|
187
|
+
target="_blank"
|
|
188
|
+
>
|
|
189
|
+
<span set:html={LOGOS[provider.key]} />
|
|
190
|
+
<span class="flex-1">{provider.label}</span>
|
|
191
|
+
<Icon name="external-link" size={13} />
|
|
192
|
+
</a>
|
|
193
|
+
</>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
</details>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
172
199
|
|
|
173
200
|
{
|
|
174
201
|
mcpUrl && (
|
|
@@ -285,28 +312,51 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
285
312
|
return clone.innerHTML;
|
|
286
313
|
};
|
|
287
314
|
|
|
288
|
-
const
|
|
289
|
-
|
|
315
|
+
const placeMenu = (details: HTMLDetailsElement) => {
|
|
316
|
+
const menu = details.querySelector<HTMLElement>("[data-blume-menu]");
|
|
317
|
+
const summary = details.querySelector("summary");
|
|
318
|
+
if (!(menu && summary)) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const rect = summary.getBoundingClientRect();
|
|
322
|
+
const margin = 8;
|
|
323
|
+
const flipUp =
|
|
324
|
+
rect.bottom + menu.offsetHeight + margin > window.innerHeight &&
|
|
325
|
+
rect.top - menu.offsetHeight - margin > 0;
|
|
326
|
+
menu.classList.toggle("top-full", !flipUp);
|
|
327
|
+
menu.classList.toggle("mt-1", !flipUp);
|
|
328
|
+
menu.classList.toggle("bottom-full", flipUp);
|
|
329
|
+
menu.classList.toggle("mb-1", flipUp);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// rAF-coalesced so a live resize drag re-reads layout once per frame, not
|
|
333
|
+
// once per event, while the open menu still tracks the viewport. Registered
|
|
334
|
+
// once at module scope (this bundle runs once per real page load) and
|
|
335
|
+
// querying live, because client-router swaps replace the menu elements.
|
|
336
|
+
window.addEventListener(
|
|
337
|
+
"resize",
|
|
338
|
+
rafThrottle(() => {
|
|
339
|
+
const open = document.querySelector<HTMLDetailsElement>(
|
|
340
|
+
"[data-blume-page-actions] details[open]"
|
|
341
|
+
);
|
|
342
|
+
if (open) {
|
|
343
|
+
placeMenu(open);
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Per-page setup, run on the initial load and again after every
|
|
349
|
+
// client-router swap: the swap rebuilds the actions menu from
|
|
350
|
+
// server-rendered markup, so every handler below binds to fresh elements.
|
|
351
|
+
const initPageActions = () => {
|
|
352
|
+
const root = document.querySelector("[data-blume-page-actions]");
|
|
353
|
+
if (!root) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
290
356
|
// The action dropdowns are native <details>. Make them behave like a proper
|
|
291
357
|
// menu: only one open at a time, and flip above the trigger when opening
|
|
292
358
|
// downward would run past the viewport bottom.
|
|
293
359
|
const dropdowns = [...root.querySelectorAll<HTMLDetailsElement>("details")];
|
|
294
|
-
const placeMenu = (details: HTMLDetailsElement) => {
|
|
295
|
-
const menu = details.querySelector<HTMLElement>("[data-blume-menu]");
|
|
296
|
-
const summary = details.querySelector("summary");
|
|
297
|
-
if (!(menu && summary)) {
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
const rect = summary.getBoundingClientRect();
|
|
301
|
-
const margin = 8;
|
|
302
|
-
const flipUp =
|
|
303
|
-
rect.bottom + menu.offsetHeight + margin > window.innerHeight &&
|
|
304
|
-
rect.top - menu.offsetHeight - margin > 0;
|
|
305
|
-
menu.classList.toggle("top-full", !flipUp);
|
|
306
|
-
menu.classList.toggle("mt-1", !flipUp);
|
|
307
|
-
menu.classList.toggle("bottom-full", flipUp);
|
|
308
|
-
menu.classList.toggle("mb-1", flipUp);
|
|
309
|
-
};
|
|
310
360
|
for (const details of dropdowns) {
|
|
311
361
|
details.addEventListener("toggle", () => {
|
|
312
362
|
if (!details.open) {
|
|
@@ -320,17 +370,6 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
320
370
|
placeMenu(details);
|
|
321
371
|
});
|
|
322
372
|
}
|
|
323
|
-
// rAF-coalesced so a live resize drag re-reads layout once per frame,
|
|
324
|
-
// not once per event, while the open menu still tracks the viewport.
|
|
325
|
-
window.addEventListener(
|
|
326
|
-
"resize",
|
|
327
|
-
rafThrottle(() => {
|
|
328
|
-
const open = dropdowns.find((details) => details.open);
|
|
329
|
-
if (open) {
|
|
330
|
-
placeMenu(open);
|
|
331
|
-
}
|
|
332
|
-
})
|
|
333
|
-
);
|
|
334
373
|
|
|
335
374
|
// `data-md` is the base-less logical route; the raw-markdown endpoint is a
|
|
336
375
|
// generated page route, so it's served under the deployment base like any
|
|
@@ -491,5 +530,8 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
491
530
|
}
|
|
492
531
|
}
|
|
493
532
|
});
|
|
494
|
-
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
initPageActions();
|
|
536
|
+
document.addEventListener("astro:after-swap", initPageActions);
|
|
495
537
|
</script>
|
|
@@ -42,8 +42,14 @@ const buttonClass =
|
|
|
42
42
|
<script>
|
|
43
43
|
import { track } from "./analytics-client.ts";
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// Run on the initial load and again after every client-router swap, which
|
|
46
|
+
// rebuilds the rating widget from server-rendered markup — the handlers
|
|
47
|
+
// below always bind to the freshly swapped-in elements.
|
|
48
|
+
const initFeedback = () => {
|
|
49
|
+
const root = document.querySelector("[data-blume-page-feedback]");
|
|
50
|
+
if (!root) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
47
53
|
const actions = root.querySelector("[data-feedback-actions]");
|
|
48
54
|
const thanks = root.querySelector("[data-feedback-thanks]");
|
|
49
55
|
for (const button of root.querySelectorAll("[data-feedback-value]")) {
|
|
@@ -57,5 +63,8 @@ const buttonClass =
|
|
|
57
63
|
thanks?.classList.remove("hidden");
|
|
58
64
|
});
|
|
59
65
|
}
|
|
60
|
-
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
initFeedback();
|
|
69
|
+
document.addEventListener("astro:after-swap", initFeedback);
|
|
61
70
|
</script>
|
|
@@ -35,13 +35,14 @@ import { buildStructuredData } from "../../seo/jsonld.ts";
|
|
|
35
35
|
import { normalizeXHandle } from "../../seo/x-handle.ts";
|
|
36
36
|
import { withBase } from "../islands/base-path.ts";
|
|
37
37
|
import "blume:theme";
|
|
38
|
+
import { ClientRouter } from "astro:transitions";
|
|
38
39
|
import Analytics from "./Analytics.astro";
|
|
39
40
|
import Banner from "./Banner.astro";
|
|
40
41
|
import Favicon from "./Favicon.astro";
|
|
41
42
|
import Fonts from "./Fonts.astro";
|
|
42
43
|
import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
43
44
|
import Header from "./Header.astro";
|
|
44
|
-
import {
|
|
45
|
+
import { currentTabForRoute } from "./nav-utils.ts";
|
|
45
46
|
import WebMcp from "./WebMcp.astro";
|
|
46
47
|
|
|
47
48
|
interface Props {
|
|
@@ -160,7 +161,7 @@ const searchLocale =
|
|
|
160
161
|
const pageTitle = page?.title ?? site.title;
|
|
161
162
|
const description = page?.description ?? site.description;
|
|
162
163
|
const route = page?.route ?? "/";
|
|
163
|
-
const activeTab =
|
|
164
|
+
const activeTab = currentTabForRoute(navigation.tabs, route, navigation.root);
|
|
164
165
|
|
|
165
166
|
// Derive canonical + og:image from the site URL the same way the catch-all does
|
|
166
167
|
// for content pages, so a custom page gets both for free. The matching OG card
|
|
@@ -230,6 +231,10 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
230
231
|
<head>
|
|
231
232
|
<meta charset="utf-8" />
|
|
232
233
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
234
|
+
{/* Client-side navigation, matching RootLayout: same-origin link clicks
|
|
235
|
+
swap the DOM in place instead of tearing the document down, so no
|
|
236
|
+
browser paints a blank frame between pages. */}
|
|
237
|
+
<ClientRouter />
|
|
233
238
|
<title>{pageTitle}</title>
|
|
234
239
|
<Favicon appleIcon={appleIcon} favicon={favicon} />
|
|
235
240
|
<Fonts cssVars={fontCssVars ?? []} />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import "blume:theme";
|
|
3
|
+
import type { BlumeFavicon } from "../../core/data.ts";
|
|
3
4
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
4
5
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
5
6
|
import type { FontHead } from "../../theme/fonts.ts";
|
|
@@ -15,6 +16,13 @@ import Header from "./Header.astro";
|
|
|
15
16
|
// A minimal shell for the Scalar API/AsyncAPI reference: Blume's banner + navbar
|
|
16
17
|
// on top, then a full-height region the reference mounts into. Unlike RootLayout
|
|
17
18
|
// it has no docs sidebar/TOC/prose column, so the reference renders edge to edge.
|
|
19
|
+
//
|
|
20
|
+
// Deliberately NOT wired into the client router (`<ClientRouter />`): Scalar is
|
|
21
|
+
// a SPA whose mount script runs once per real page load, so a client-side swap
|
|
22
|
+
// back onto this page would leave the mount region empty (Astro never re-runs a
|
|
23
|
+
// script it has already executed). Without the router's marker meta, the docs
|
|
24
|
+
// pages' router falls back to a normal full load when navigating here — exactly
|
|
25
|
+
// what a fresh Scalar mount needs.
|
|
18
26
|
interface Props {
|
|
19
27
|
site: { title: string; description?: string };
|
|
20
28
|
logo?: {
|
|
@@ -25,14 +33,8 @@ interface Props {
|
|
|
25
33
|
href: string;
|
|
26
34
|
text?: string;
|
|
27
35
|
} | null;
|
|
28
|
-
favicon?:
|
|
29
|
-
|
|
30
|
-
type?: string;
|
|
31
|
-
} | null;
|
|
32
|
-
appleIcon?: {
|
|
33
|
-
href: string;
|
|
34
|
-
type?: string;
|
|
35
|
-
} | null;
|
|
36
|
+
favicon?: BlumeFavicon | null;
|
|
37
|
+
appleIcon?: BlumeFavicon | null;
|
|
36
38
|
banner?: {
|
|
37
39
|
content: string;
|
|
38
40
|
link?: { text: string; href: string };
|