blume 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/dist/cli/index.js +13784 -10579
- package/dist/cli/index.js.map +93 -61
- package/dist/types/core/config-input.d.ts +87 -8
- package/dist/types/core/data.d.ts +21 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +140 -140
- package/dist/types/core/schema.d.ts +549 -370
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +23 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/dist/types/openapi/references.d.ts +12 -7
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +22 -3
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +1 -1
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +40 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +15 -2
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +11 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +116 -4
- package/docs/reference/cli.mdx +79 -1
- package/docs/reference/frontmatter.mdx +29 -1
- package/package.json +3 -3
- package/skills/blume-migrate/SKILL.md +170 -0
- package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
- package/skills/blume-migrate/references/docusaurus.md +95 -0
- package/skills/blume-migrate/references/fumadocs.md +95 -0
- package/skills/blume-migrate/references/mintlify.md +156 -0
- package/skills/blume-migrate/references/monorepo.md +224 -0
- package/skills/blume-migrate/references/nextra.md +76 -0
- package/skills/blume-migrate/references/starlight.md +116 -0
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
- package/src/ai/llms.ts +15 -0
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/component-slots.ts +3 -2
- package/src/astro/generate.ts +132 -42
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +18 -3
- package/src/astro/templates.ts +158 -56
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +135 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +51 -12
- package/src/cli/index.ts +2 -0
- package/src/components/content/Callout.astro +8 -2
- package/src/components/content/Prompt.astro +25 -13
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +5 -8
- package/src/components/layout/Logo.astro +13 -1
- package/src/components/layout/PageFeedback.astro +2 -2
- package/src/components/layout/PageLayout.astro +9 -9
- package/src/components/layout/Pagination.astro +7 -7
- package/src/components/layout/RootLayout.astro +9 -11
- package/src/components/layout/Search.astro +36 -7
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/layout/nav-utils.ts +9 -7
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +94 -8
- package/src/core/data.ts +18 -2
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +59 -12
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/navigation.ts +55 -13
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +8 -0
- package/src/core/schema.ts +100 -1
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/sources/watch.ts +5 -0
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +23 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/markdown/index.ts +2 -0
- package/src/markdown/language-icon.ts +2 -1
- package/src/markdown/table-wrap.ts +43 -0
- package/src/og/card.ts +128 -36
- package/src/og/index.ts +1 -1
- package/src/og/logo.ts +21 -0
- package/src/openapi/references.ts +19 -16
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +56 -6
|
@@ -59,7 +59,15 @@ const secondaryButton =
|
|
|
59
59
|
data-blume-prompt-copy
|
|
60
60
|
type="button"
|
|
61
61
|
>
|
|
62
|
-
|
|
62
|
+
{/* Both labels share one grid cell so the button is always sized
|
|
63
|
+
to the wider ("Copy prompt") — swapping to "Copied" on copy
|
|
64
|
+
can't resize it and shift the description beside it. */}
|
|
65
|
+
<span class="grid *:col-start-1 *:row-start-1 *:text-center">
|
|
66
|
+
<span data-blume-prompt-copy-idle>Copy prompt</span>
|
|
67
|
+
<span aria-hidden="true" class="invisible" data-blume-prompt-copy-done>
|
|
68
|
+
Copied
|
|
69
|
+
</span>
|
|
70
|
+
</span>
|
|
63
71
|
</button>
|
|
64
72
|
)}
|
|
65
73
|
{enabledActions.has("cursor") && (
|
|
@@ -99,6 +107,13 @@ const secondaryButton =
|
|
|
99
107
|
const copy = this.querySelector<HTMLButtonElement>(
|
|
100
108
|
"[data-blume-prompt-copy]"
|
|
101
109
|
);
|
|
110
|
+
const idle = copy?.querySelector<HTMLElement>(
|
|
111
|
+
"[data-blume-prompt-copy-idle]"
|
|
112
|
+
);
|
|
113
|
+
const done = copy?.querySelector<HTMLElement>(
|
|
114
|
+
"[data-blume-prompt-copy-done]"
|
|
115
|
+
);
|
|
116
|
+
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
102
117
|
copy?.addEventListener("click", async () => {
|
|
103
118
|
const text = promptText();
|
|
104
119
|
if (!text) {
|
|
@@ -110,18 +125,15 @@ const secondaryButton =
|
|
|
110
125
|
return;
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
label.textContent = "Copied";
|
|
123
|
-
setTimeout(() => {
|
|
124
|
-
label.textContent = label.dataset.blumeLabel ?? "Copy prompt";
|
|
128
|
+
// Toggle visibility rather than swapping text: both labels occupy the
|
|
129
|
+
// same grid cell, so the button width never changes. Clearing the timer
|
|
130
|
+
// keeps the "Copied" state 1.5s past the last of several rapid clicks.
|
|
131
|
+
clearTimeout(resetTimer);
|
|
132
|
+
idle?.classList.add("invisible");
|
|
133
|
+
done?.classList.remove("invisible");
|
|
134
|
+
resetTimer = setTimeout(() => {
|
|
135
|
+
idle?.classList.remove("invisible");
|
|
136
|
+
done?.classList.add("invisible");
|
|
125
137
|
}, 1500);
|
|
126
138
|
});
|
|
127
139
|
}
|
|
@@ -4,6 +4,20 @@ interface Props {
|
|
|
4
4
|
defaultTabIndex?: number;
|
|
5
5
|
dropdown?: boolean;
|
|
6
6
|
hash?: boolean | "false" | "true";
|
|
7
|
+
/**
|
|
8
|
+
* Render borderless — a tab strip on a full-width rule with the content
|
|
9
|
+
* flowing beneath as prose — instead of the default bordered box. Reads as
|
|
10
|
+
* page-level navigation rather than an isolated widget; pair with `param` for
|
|
11
|
+
* shareable, multiple-per-page tabs.
|
|
12
|
+
*/
|
|
13
|
+
inline?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Sync the active tab to this URL query-param key instead of the hash. Each
|
|
16
|
+
* group owns its own param, so several `Tabs` can share a page and every
|
|
17
|
+
* selection is deep-linkable (`?install=windows`). Give each group a unique
|
|
18
|
+
* `param`; it takes precedence over `hash`.
|
|
19
|
+
*/
|
|
20
|
+
param?: string;
|
|
7
21
|
sync?: boolean;
|
|
8
22
|
}
|
|
9
23
|
|
|
@@ -12,34 +26,53 @@ const {
|
|
|
12
26
|
defaultTabIndex = 0,
|
|
13
27
|
dropdown = false,
|
|
14
28
|
hash = true,
|
|
29
|
+
inline = false,
|
|
30
|
+
param,
|
|
15
31
|
sync = true,
|
|
16
32
|
} = Astro.props;
|
|
17
33
|
|
|
18
34
|
// MDX string attributes (`hash="false"` from generated markup) must read as
|
|
19
35
|
// their boolean meaning — the string "false" is truthy.
|
|
20
36
|
const hashEnabled = String(hash) !== "false";
|
|
37
|
+
// The dropdown (select) affordance belongs to the boxed layout; inline always
|
|
38
|
+
// renders a tab strip.
|
|
39
|
+
const useDropdown = dropdown && !inline;
|
|
21
40
|
---
|
|
22
41
|
|
|
23
42
|
<blume-tabs
|
|
24
|
-
class=
|
|
43
|
+
class:list={[
|
|
44
|
+
"my-6 block",
|
|
45
|
+
inline
|
|
46
|
+
? ""
|
|
47
|
+
: "not-prose overflow-hidden rounded-blume border border-border bg-background",
|
|
48
|
+
]}
|
|
25
49
|
data-border-bottom={borderBottom ? "true" : "false"}
|
|
26
50
|
data-default-tab-index={defaultTabIndex}
|
|
27
|
-
data-dropdown={
|
|
51
|
+
data-dropdown={useDropdown ? "true" : "false"}
|
|
28
52
|
data-hash={hashEnabled ? "true" : "false"}
|
|
53
|
+
data-param={param}
|
|
29
54
|
data-sync={sync ? "true" : "false"}
|
|
30
55
|
>
|
|
31
|
-
<div
|
|
56
|
+
<div
|
|
57
|
+
class:list={[
|
|
58
|
+
inline ? "not-prose" : "",
|
|
59
|
+
useDropdown ? "" : "overflow-x-auto",
|
|
60
|
+
]}
|
|
61
|
+
>
|
|
32
62
|
<div
|
|
33
63
|
class:list={[
|
|
34
|
-
|
|
35
|
-
|
|
64
|
+
useDropdown || borderBottom ? "border-border border-b" : "",
|
|
65
|
+
useDropdown ? "p-3" : inline ? "flex gap-4" : "flex gap-4 px-3",
|
|
36
66
|
]}
|
|
37
67
|
data-blume-tablist
|
|
38
|
-
role={
|
|
68
|
+
role={useDropdown ? undefined : "tablist"}
|
|
39
69
|
>
|
|
40
70
|
</div>
|
|
41
71
|
</div>
|
|
42
|
-
<div
|
|
72
|
+
<div
|
|
73
|
+
class:list={[inline ? "pt-4 [&>[data-blume-tab-panel]]:p-0!" : ""]}
|
|
74
|
+
data-blume-tab-content
|
|
75
|
+
>
|
|
43
76
|
<slot />
|
|
44
77
|
</div>
|
|
45
78
|
</blume-tabs>
|
|
@@ -114,7 +147,7 @@ const hashEnabled = String(hash) !== "false";
|
|
|
114
147
|
if (this.#syncEnabled()) {
|
|
115
148
|
document.addEventListener(SYNC_EVENT, this.#sync);
|
|
116
149
|
}
|
|
117
|
-
|
|
150
|
+
this.#addUrlListener();
|
|
118
151
|
return;
|
|
119
152
|
}
|
|
120
153
|
this.dataset.blumeTabsReady = "true";
|
|
@@ -173,12 +206,13 @@ const hashEnabled = String(hash) !== "false";
|
|
|
173
206
|
if (this.#syncEnabled()) {
|
|
174
207
|
document.addEventListener(SYNC_EVENT, this.#sync);
|
|
175
208
|
}
|
|
176
|
-
|
|
209
|
+
this.#addUrlListener();
|
|
177
210
|
}
|
|
178
211
|
|
|
179
212
|
disconnectedCallback() {
|
|
180
213
|
document.removeEventListener(SYNC_EVENT, this.#sync);
|
|
181
|
-
window.removeEventListener("hashchange", this.#
|
|
214
|
+
window.removeEventListener("hashchange", this.#urlChange);
|
|
215
|
+
window.removeEventListener("popstate", this.#urlChange);
|
|
182
216
|
}
|
|
183
217
|
|
|
184
218
|
activate(index: number, sync: boolean, updateHash: boolean) {
|
|
@@ -201,8 +235,18 @@ const hashEnabled = String(hash) !== "false";
|
|
|
201
235
|
this.#select.value = String(index);
|
|
202
236
|
}
|
|
203
237
|
const id = panelId(activePanel);
|
|
204
|
-
if (updateHash && id
|
|
205
|
-
|
|
238
|
+
if (updateHash && id) {
|
|
239
|
+
const param = this.#paramName();
|
|
240
|
+
if (param) {
|
|
241
|
+
// Query-param mode: write only this group's key, so several groups on
|
|
242
|
+
// one page each deep-link independently. replaceState (not push) keeps
|
|
243
|
+
// the shareable URL current without stacking a history entry per click.
|
|
244
|
+
const url = new URL(window.location.href);
|
|
245
|
+
url.searchParams.set(param, id);
|
|
246
|
+
history.replaceState(history.state, "", url);
|
|
247
|
+
} else if (this.#hashEnabled()) {
|
|
248
|
+
history.replaceState(null, "", `#${encodeURIComponent(id)}`);
|
|
249
|
+
}
|
|
206
250
|
}
|
|
207
251
|
if (sync && this.#syncEnabled()) {
|
|
208
252
|
document.dispatchEvent(
|
|
@@ -239,8 +283,29 @@ const hashEnabled = String(hash) !== "false";
|
|
|
239
283
|
return true;
|
|
240
284
|
}
|
|
241
285
|
|
|
286
|
+
#activateByParam() {
|
|
287
|
+
const param = this.#paramName();
|
|
288
|
+
if (!param) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
const value = new URL(window.location.href).searchParams.get(param);
|
|
292
|
+
if (!value) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
const index = this.#panels.findIndex((panel) => panelId(panel) === value);
|
|
296
|
+
if (index === -1) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
this.activate(index, false, false);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
242
304
|
#initialIndex() {
|
|
243
|
-
|
|
305
|
+
const activated = this.#paramName()
|
|
306
|
+
? this.#activateByParam()
|
|
307
|
+
: this.#activateByHash();
|
|
308
|
+
if (activated) {
|
|
244
309
|
return this.#panels.findIndex((panel) => !panel.classList.contains("hidden"));
|
|
245
310
|
}
|
|
246
311
|
return boundedIndex(
|
|
@@ -278,6 +343,20 @@ const hashEnabled = String(hash) !== "false";
|
|
|
278
343
|
return this.dataset.hash !== "false";
|
|
279
344
|
}
|
|
280
345
|
|
|
346
|
+
#paramName() {
|
|
347
|
+
return this.dataset.param || null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Query-param changes don't fire `hashchange`; they surface through history
|
|
351
|
+
// navigation (`popstate`). Listen to whichever channel this group syncs on.
|
|
352
|
+
#addUrlListener() {
|
|
353
|
+
if (this.#paramName()) {
|
|
354
|
+
window.addEventListener("popstate", this.#urlChange);
|
|
355
|
+
} else {
|
|
356
|
+
window.addEventListener("hashchange", this.#urlChange);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
281
360
|
#sync = (event: Event) => {
|
|
282
361
|
const title = (event as CustomEvent<{ title?: string }>).detail?.title;
|
|
283
362
|
if (title) {
|
|
@@ -285,8 +364,12 @@ const hashEnabled = String(hash) !== "false";
|
|
|
285
364
|
}
|
|
286
365
|
};
|
|
287
366
|
|
|
288
|
-
#
|
|
289
|
-
this.#
|
|
367
|
+
#urlChange = () => {
|
|
368
|
+
if (this.#paramName()) {
|
|
369
|
+
this.#activateByParam();
|
|
370
|
+
} else {
|
|
371
|
+
this.#activateByHash();
|
|
372
|
+
}
|
|
290
373
|
};
|
|
291
374
|
}
|
|
292
375
|
|
|
@@ -31,7 +31,7 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
|
|
|
31
31
|
aria-label={n.breadcrumb}
|
|
32
32
|
class:list={[
|
|
33
33
|
"mb-2 text-muted-foreground text-sm",
|
|
34
|
-
wide ? "max-w-none" : "mx-auto max-w-
|
|
34
|
+
wide ? "max-w-none" : "mx-auto max-w-content",
|
|
35
35
|
]}
|
|
36
36
|
>
|
|
37
37
|
{eyebrowCrumb.route ? (
|
|
@@ -10,7 +10,7 @@ import { GITHUB_MARK } from "../github-mark.ts";
|
|
|
10
10
|
import Icon from "../Icon.astro";
|
|
11
11
|
import LanguageSwitcher from "./LanguageSwitcher.astro";
|
|
12
12
|
import Logo from "./Logo.astro";
|
|
13
|
-
import {
|
|
13
|
+
import { activeTabForRoute } from "./nav-utils.ts";
|
|
14
14
|
import NavSelector from "./NavSelector.astro";
|
|
15
15
|
import { resolveSlot } from "./overrides.ts";
|
|
16
16
|
import Search from "./Search.astro";
|
|
@@ -94,6 +94,7 @@ const SearchSlot = resolveSlot(layout.Search, Search);
|
|
|
94
94
|
// PageLayout) it's a tabs-only drawer the layout renders — so the button is also
|
|
95
95
|
// needed whenever there are tabs to reveal.
|
|
96
96
|
const showNavToggle = hasDrawer && (hasSidebar || navigation.tabs.length > 0);
|
|
97
|
+
const activeTab = activeTabForRoute(navigation.tabs, route);
|
|
97
98
|
// Where the header's inline tab bar appears. With a sidebar it shares the `md`
|
|
98
99
|
// breakpoint with the docs drawer; without one, the tabs-only drawer is the sole
|
|
99
100
|
// mobile nav below `lg`, so the inline tabs wait until `lg` to avoid duplicating
|
|
@@ -155,14 +156,9 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
155
156
|
<nav aria-label={n.sections} class={tabsNavClass}>
|
|
156
157
|
{navigation.tabs.map((tab) => (
|
|
157
158
|
<a
|
|
158
|
-
aria-current={
|
|
159
|
-
route === tab.path ||
|
|
160
|
-
(tab.path !== "/" && isUnderPath(route, tab.path))
|
|
161
|
-
? "page"
|
|
162
|
-
: undefined
|
|
163
|
-
}
|
|
159
|
+
aria-current={tab === activeTab ? "page" : undefined}
|
|
164
160
|
class="rounded-full px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:text-foreground"
|
|
165
|
-
href={withBase(tab.path)}
|
|
161
|
+
href={withBase(tab.href ?? tab.path)}
|
|
166
162
|
>
|
|
167
163
|
{tab.label}
|
|
168
164
|
</a>
|
|
@@ -186,6 +182,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
186
182
|
askEnabled={askEnabled}
|
|
187
183
|
locale={searchLocale}
|
|
188
184
|
navigation={navigation}
|
|
185
|
+
popularPages={data.config.search.popular}
|
|
189
186
|
strings={searchStrings}
|
|
190
187
|
/>
|
|
191
188
|
)
|
|
@@ -18,6 +18,8 @@ const { site, logo } = Astro.props;
|
|
|
18
18
|
const logoSvg = logo?.svg;
|
|
19
19
|
const logoLight = logo?.light;
|
|
20
20
|
const logoDark = logo?.dark ?? logo?.light;
|
|
21
|
+
const logoLightDimensions = logo?.dimensions?.light;
|
|
22
|
+
const logoDarkDimensions = logo?.dimensions?.dark ?? logo?.dimensions?.light;
|
|
21
23
|
const logoAlt = logo?.alt ?? "";
|
|
22
24
|
const brandHref = logo?.href ?? "/";
|
|
23
25
|
// Wordmark beside the mark: the configured `logo.text`, or the site title when
|
|
@@ -43,18 +45,28 @@ const brandText = logo?.text ?? site.title;
|
|
|
43
45
|
!logoSvg &&
|
|
44
46
|
logoLight &&
|
|
45
47
|
(logoLight === logoDark ? (
|
|
46
|
-
<img
|
|
48
|
+
<img
|
|
49
|
+
alt={logoAlt}
|
|
50
|
+
class="h-5 w-auto"
|
|
51
|
+
height={logoLightDimensions?.height}
|
|
52
|
+
src={withBase(logoLight)}
|
|
53
|
+
width={logoLightDimensions?.width}
|
|
54
|
+
/>
|
|
47
55
|
) : (
|
|
48
56
|
<>
|
|
49
57
|
<img
|
|
50
58
|
alt={logoAlt}
|
|
51
59
|
class="h-5 w-auto dark:hidden"
|
|
60
|
+
height={logoLightDimensions?.height}
|
|
52
61
|
src={withBase(logoLight)}
|
|
62
|
+
width={logoLightDimensions?.width}
|
|
53
63
|
/>
|
|
54
64
|
<img
|
|
55
65
|
alt={logoAlt}
|
|
56
66
|
class="hidden h-5 w-auto dark:block"
|
|
67
|
+
height={logoDarkDimensions?.height}
|
|
57
68
|
src={withBase(logoDark)}
|
|
69
|
+
width={logoDarkDimensions?.width}
|
|
58
70
|
/>
|
|
59
71
|
</>
|
|
60
72
|
))
|
|
@@ -15,12 +15,12 @@ const { strings } = Astro.props;
|
|
|
15
15
|
const f = { ...EN_UI.feedback, ...strings };
|
|
16
16
|
|
|
17
17
|
const buttonClass =
|
|
18
|
-
"inline-flex items-center gap-2 rounded-
|
|
18
|
+
"inline-flex items-center gap-2 rounded-blume border border-border px-4 py-2 text-foreground text-sm transition-colors hover:border-foreground";
|
|
19
19
|
---
|
|
20
20
|
|
|
21
21
|
<section
|
|
22
22
|
aria-label={f.question}
|
|
23
|
-
class="mx-auto mt-12 flex max-w-
|
|
23
|
+
class="mx-auto mt-12 flex max-w-content items-center justify-between gap-4 border-border border-t pt-6"
|
|
24
24
|
data-blume-page-feedback
|
|
25
25
|
>
|
|
26
26
|
<p class="text-muted-foreground text-sm">{f.question}</p>
|
|
@@ -39,7 +39,7 @@ import Favicon from "./Favicon.astro";
|
|
|
39
39
|
import Fonts from "./Fonts.astro";
|
|
40
40
|
import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
41
41
|
import Header from "./Header.astro";
|
|
42
|
-
import {
|
|
42
|
+
import { activeTabForRoute } from "./nav-utils.ts";
|
|
43
43
|
|
|
44
44
|
interface Props {
|
|
45
45
|
site: { title: string; description?: string };
|
|
@@ -137,6 +137,7 @@ const searchLocale =
|
|
|
137
137
|
const pageTitle = page?.title ?? site.title;
|
|
138
138
|
const description = page?.description ?? site.description;
|
|
139
139
|
const route = page?.route ?? "/";
|
|
140
|
+
const activeTab = activeTabForRoute(navigation.tabs, route);
|
|
140
141
|
|
|
141
142
|
// Derive canonical + og:image from the site URL the same way the catch-all does
|
|
142
143
|
// for content pages, so a custom page gets both for free. The matching OG card
|
|
@@ -185,7 +186,11 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
185
186
|
<title>{pageTitle}</title>
|
|
186
187
|
<Favicon appleIcon={appleIcon} favicon={favicon} />
|
|
187
188
|
<Fonts cssVars={fontCssVars ?? []} />
|
|
188
|
-
{
|
|
189
|
+
{/* Google warns against pairing a canonical with noindex — the canonical
|
|
190
|
+
says "index that URL", the robots meta says "distrust this page". */}
|
|
191
|
+
{resolvedCanonical && !noindex && (
|
|
192
|
+
<link href={resolvedCanonical} rel="canonical" />
|
|
193
|
+
)}
|
|
189
194
|
{noindex && <meta content="noindex" name="robots" />}
|
|
190
195
|
{description && <meta content={description} name="description" />}
|
|
191
196
|
<meta content="website" property="og:type" />
|
|
@@ -267,14 +272,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
267
272
|
{navigation.tabs.map((tab) => (
|
|
268
273
|
<li>
|
|
269
274
|
<a
|
|
270
|
-
aria-current={
|
|
271
|
-
route === tab.path ||
|
|
272
|
-
(tab.path !== "/" && isUnderPath(route, tab.path))
|
|
273
|
-
? "page"
|
|
274
|
-
: undefined
|
|
275
|
-
}
|
|
275
|
+
aria-current={tab === activeTab ? "page" : undefined}
|
|
276
276
|
class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
|
|
277
|
-
href={withBase(tab.path)}
|
|
277
|
+
href={withBase(tab.href ?? tab.path)}
|
|
278
278
|
>
|
|
279
279
|
{tab.label}
|
|
280
280
|
</a>
|
|
@@ -26,19 +26,19 @@ const s = { ...EN_UI.page, ...strings };
|
|
|
26
26
|
(prev || next) && (
|
|
27
27
|
<nav
|
|
28
28
|
aria-label={s.pagination}
|
|
29
|
-
class="mx-auto mt-12 flex max-w-
|
|
29
|
+
class="mx-auto mt-12 flex max-w-content justify-between gap-4 border-border border-t pt-6 max-md:flex-col"
|
|
30
30
|
>
|
|
31
31
|
{prev ? (
|
|
32
32
|
<a
|
|
33
|
-
class="flex max-w-[48%] flex-1 items-center gap-2 rounded-
|
|
33
|
+
class="flex max-w-[48%] flex-1 items-center gap-2 rounded-blume border border-border px-4 py-3 text-foreground transition-colors hover:border-foreground max-md:max-w-full"
|
|
34
34
|
href={withBase(prev.route)}
|
|
35
35
|
>
|
|
36
36
|
<Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
|
|
37
|
-
<span>
|
|
37
|
+
<span class="min-w-0">
|
|
38
38
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
39
39
|
{s.previous}
|
|
40
40
|
</span>
|
|
41
|
-
<span class="block font-medium">{prev.label}</span>
|
|
41
|
+
<span class="block truncate font-medium">{prev.label}</span>
|
|
42
42
|
</span>
|
|
43
43
|
</a>
|
|
44
44
|
) : (
|
|
@@ -46,14 +46,14 @@ const s = { ...EN_UI.page, ...strings };
|
|
|
46
46
|
)}
|
|
47
47
|
{next && (
|
|
48
48
|
<a
|
|
49
|
-
class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-
|
|
49
|
+
class="ms-auto flex max-w-[48%] flex-1 items-center justify-end gap-2 rounded-blume border border-border px-4 py-3 text-end text-foreground transition-colors hover:border-foreground max-md:max-w-full"
|
|
50
50
|
href={withBase(next.route)}
|
|
51
51
|
>
|
|
52
|
-
<span>
|
|
52
|
+
<span class="min-w-0">
|
|
53
53
|
<span class="block text-muted-foreground text-xs max-md:hidden">
|
|
54
54
|
{s.next}
|
|
55
55
|
</span>
|
|
56
|
-
<span class="block font-medium">{next.label}</span>
|
|
56
|
+
<span class="block truncate font-medium">{next.label}</span>
|
|
57
57
|
</span>
|
|
58
58
|
<Icon class="rtl:-scale-x-100" name="arrow-right" size={16} />
|
|
59
59
|
</a>
|
|
@@ -27,10 +27,10 @@ import { BANNER_INIT_SCRIPT, THEME_INIT_SCRIPT } from "./head-scripts.ts";
|
|
|
27
27
|
import Header from "./Header.astro";
|
|
28
28
|
import Icon from "../Icon.astro";
|
|
29
29
|
import {
|
|
30
|
+
activeTabForRoute,
|
|
30
31
|
findBreadcrumbs,
|
|
31
32
|
flattenPages,
|
|
32
33
|
getPagination,
|
|
33
|
-
isUnderPath,
|
|
34
34
|
sidebarForRoute,
|
|
35
35
|
} from "./nav-utils.ts";
|
|
36
36
|
import NavTree from "./NavTree.astro";
|
|
@@ -261,7 +261,7 @@ if (isBare) {
|
|
|
261
261
|
} else if (isApiOperation) {
|
|
262
262
|
gridClass = "lg:grid-cols-[17.5rem_minmax(0,1fr)]";
|
|
263
263
|
}
|
|
264
|
-
let articleClass = "prose mx-auto max-w-
|
|
264
|
+
let articleClass = "prose mx-auto max-w-content";
|
|
265
265
|
if (isBare) {
|
|
266
266
|
articleClass = "prose mx-auto max-w-[54rem]";
|
|
267
267
|
} else if (isApiOperation) {
|
|
@@ -307,6 +307,7 @@ const mcpUrl =
|
|
|
307
307
|
// active tab's section, so a multi-section site drills each tab into its own
|
|
308
308
|
// pages. Without tabs — or on a route under none — this is the full sidebar.
|
|
309
309
|
const sidebar = sidebarForRoute(navigation.sidebar, navigation.tabs, page.route);
|
|
310
|
+
const activeTab = activeTabForRoute(navigation.tabs, page.route);
|
|
310
311
|
const crumbs = findBreadcrumbs(sidebar, page.route);
|
|
311
312
|
const { prev, next } = getPagination(flattenPages(sidebar), page.route);
|
|
312
313
|
|
|
@@ -344,7 +345,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
344
345
|
<title>{pageTitle}</title>
|
|
345
346
|
<Favicon favicon={favicon} appleIcon={appleIcon} />
|
|
346
347
|
<Fonts cssVars={fontCssVars ?? []} />
|
|
347
|
-
{
|
|
348
|
+
{/* Google warns against pairing a canonical with noindex — the canonical
|
|
349
|
+
says "index that URL", the robots meta says "distrust this page". */}
|
|
350
|
+
{canonical && !noindex && <link rel="canonical" href={canonical} />}
|
|
348
351
|
{
|
|
349
352
|
localeAlternates?.map((alt) => (
|
|
350
353
|
<link href={alt.href} hreflang={alt.hreflang} rel="alternate" />
|
|
@@ -526,14 +529,9 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
526
529
|
{navigation.tabs.map((tab) => (
|
|
527
530
|
<li>
|
|
528
531
|
<a
|
|
529
|
-
aria-current={
|
|
530
|
-
page.route === tab.path ||
|
|
531
|
-
(tab.path !== "/" && isUnderPath(page.route, tab.path))
|
|
532
|
-
? "page"
|
|
533
|
-
: undefined
|
|
534
|
-
}
|
|
532
|
+
aria-current={tab === activeTab ? "page" : undefined}
|
|
535
533
|
class="block rounded-[0.65rem] px-2.5 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
|
|
536
|
-
href={withBase(tab.path)}
|
|
534
|
+
href={withBase(tab.href ?? tab.path)}
|
|
537
535
|
>
|
|
538
536
|
{tab.label}
|
|
539
537
|
</a>
|
|
@@ -596,7 +594,7 @@ const bannerKey = banner?.dismissible ? banner.key : null;
|
|
|
596
594
|
route={page.route}
|
|
597
595
|
/>
|
|
598
596
|
{formattedLastModified && (
|
|
599
|
-
<p class="mx-auto mt-10 max-w-
|
|
597
|
+
<p class="mx-auto mt-10 max-w-content text-muted-foreground text-sm">
|
|
600
598
|
{strings.page.lastUpdated} {formattedLastModified}
|
|
601
599
|
</p>
|
|
602
600
|
)}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
3
3
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
4
4
|
import type { Navigation } from "../../core/types.ts";
|
|
5
|
+
import { resolveIcon } from "../../theme/icons.ts";
|
|
5
6
|
import Icon from "../Icon.astro";
|
|
6
7
|
import { flattenPages } from "./nav-utils.ts";
|
|
7
8
|
|
|
@@ -13,19 +14,43 @@ import { flattenPages } from "./nav-utils.ts";
|
|
|
13
14
|
interface Props {
|
|
14
15
|
askEnabled?: boolean;
|
|
15
16
|
navigation?: Navigation;
|
|
17
|
+
/** Curated empty-state pages; falls back to the first sidebar pages when empty or omitted. */
|
|
18
|
+
popularPages?: { icon?: string; label: string; route: string }[];
|
|
16
19
|
strings?: UIStrings["search"];
|
|
17
20
|
/** Active locale to filter results to; omitted disables locale filtering. */
|
|
18
21
|
locale?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
const { askEnabled = false, navigation, strings, locale } =
|
|
24
|
+
const { askEnabled = false, navigation, popularPages, strings, locale } =
|
|
25
|
+
Astro.props;
|
|
22
26
|
// Merge over the English baseline per key (rather than `strings ?? …`) so a
|
|
23
27
|
// partial — or empty `{}` — strings object still resolves every label to a
|
|
24
28
|
// default, matching the pattern PageActions uses for its own dictionary.
|
|
25
29
|
const s = { ...EN_UI.search, ...strings };
|
|
26
30
|
|
|
27
|
-
//
|
|
28
|
-
|
|
31
|
+
// Icons resolve to inline SVG here (`theme/icons.ts` is a server-only module —
|
|
32
|
+
// far too large to ship to the browser), so the island gets ready-to-render
|
|
33
|
+
// markup rather than a name it can't resolve. Same approach as AskAI.astro.
|
|
34
|
+
// A name outside the set falls through to the island's own file glyph.
|
|
35
|
+
const iconSvg = (name: string | undefined): string | undefined => {
|
|
36
|
+
const resolved = name ? resolveIcon(name) : null;
|
|
37
|
+
return resolved
|
|
38
|
+
? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${resolved.body}</svg>`
|
|
39
|
+
: undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Pages shown in the empty state, before the user has typed anything. Only
|
|
43
|
+
// curated entries carry an icon; sidebar-derived rows keep the file glyph.
|
|
44
|
+
const popular =
|
|
45
|
+
popularPages && popularPages.length > 0
|
|
46
|
+
? popularPages.map((page) => ({
|
|
47
|
+
icon: iconSvg(page.icon),
|
|
48
|
+
label: page.label,
|
|
49
|
+
route: page.route,
|
|
50
|
+
}))
|
|
51
|
+
: navigation
|
|
52
|
+
? flattenPages(navigation.sidebar).slice(0, 6)
|
|
53
|
+
: [];
|
|
29
54
|
|
|
30
55
|
const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
31
56
|
---
|
|
@@ -58,7 +83,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
58
83
|
|
|
59
84
|
<dialog
|
|
60
85
|
aria-label={s.label}
|
|
61
|
-
class="
|
|
86
|
+
class="mx-auto mt-[8vh] mb-auto h-[min(480px,90dvh)] w-[min(62.5rem,94vw)] flex-col overflow-hidden rounded-blume border border-border bg-background/80 p-0 text-foreground shadow-2xl outline-none backdrop-blur-xl backdrop:bg-black/30 open:flex sm:mt-auto"
|
|
62
87
|
data-blume-search-dialog
|
|
63
88
|
>
|
|
64
89
|
<div
|
|
@@ -166,6 +191,8 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
166
191
|
}
|
|
167
192
|
|
|
168
193
|
interface PopularPage {
|
|
194
|
+
/** Pre-resolved inline SVG from the server; absent falls back to `file`. */
|
|
195
|
+
icon?: string;
|
|
169
196
|
label: string;
|
|
170
197
|
route: string;
|
|
171
198
|
}
|
|
@@ -501,7 +528,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
501
528
|
if (this.popular.length > 0) {
|
|
502
529
|
const group = this.addGroup(this.popularMsg);
|
|
503
530
|
for (const page of this.popular) {
|
|
504
|
-
const item = this.createLinkRow(page.route, page.label);
|
|
531
|
+
const item = this.createLinkRow(page.route, page.label, page.icon);
|
|
505
532
|
group.appendChild(item.el);
|
|
506
533
|
this.selectables.push(item);
|
|
507
534
|
}
|
|
@@ -590,13 +617,15 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
|
|
|
590
617
|
return item;
|
|
591
618
|
}
|
|
592
619
|
|
|
593
|
-
createLinkRow(url: string, label: string): Selectable {
|
|
620
|
+
createLinkRow(url: string, label: string, icon?: string): Selectable {
|
|
594
621
|
const el = document.createElement("a");
|
|
595
622
|
const href = prefixBase(import.meta.env.BASE_URL, url);
|
|
596
623
|
el.href = href;
|
|
597
624
|
el.className = ROW_CLASS;
|
|
625
|
+
// `icon` is server-resolved markup from the bundled icon set, not
|
|
626
|
+
// author HTML — the label still goes through `escapeHtml`.
|
|
598
627
|
el.innerHTML = `
|
|
599
|
-
<span class="mt-0.5 shrink-0 text-muted-foreground">${svg("file")}</span>
|
|
628
|
+
<span class="mt-0.5 shrink-0 text-muted-foreground">${icon ?? svg("file")}</span>
|
|
600
629
|
<span class="flex-1">
|
|
601
630
|
<span class="block truncate font-normal text-foreground text-sm">${escapeHtml(label)}</span>
|
|
602
631
|
</span>`;
|
|
@@ -19,7 +19,7 @@ const { headings, title, variant } = Astro.props;
|
|
|
19
19
|
|
|
20
20
|
{
|
|
21
21
|
headings.length > 0 && variant === "mobile" && (
|
|
22
|
-
<details class="group mx-auto mb-6 max-w-
|
|
22
|
+
<details class="group mx-auto mb-6 max-w-content rounded-blume border border-border xl:hidden">
|
|
23
23
|
<summary class="flex cursor-pointer list-none items-center justify-between gap-2 px-4 py-3 font-medium text-foreground text-sm [&::-webkit-details-marker]:hidden">
|
|
24
24
|
<span>{title}</span>
|
|
25
25
|
<Icon
|
|
@@ -80,14 +80,16 @@ export const isUnderPath = (route: string, base: string): boolean =>
|
|
|
80
80
|
base === "/" || route === base || route.startsWith(`${base}/`);
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* The tab whose `path` is the longest prefix of `route
|
|
84
|
-
*
|
|
85
|
-
* so never scopes the sidebar.
|
|
83
|
+
* The tab whose `path` is the longest prefix of `route`. The root tab (`/`)
|
|
84
|
+
* acts as the fallback when no more specific tab matches.
|
|
86
85
|
*/
|
|
87
|
-
const
|
|
86
|
+
export const activeTabForRoute = (
|
|
87
|
+
tabs: NavTab[],
|
|
88
|
+
route: string
|
|
89
|
+
): NavTab | null => {
|
|
88
90
|
let match: NavTab | null = null;
|
|
89
91
|
for (const tab of tabs) {
|
|
90
|
-
if (
|
|
92
|
+
if (!isUnderPath(route, tab.path)) {
|
|
91
93
|
continue;
|
|
92
94
|
}
|
|
93
95
|
if (!match || tab.path.length > match.path.length) {
|
|
@@ -187,8 +189,8 @@ export const sidebarForRoute = (
|
|
|
187
189
|
tabs: NavTab[],
|
|
188
190
|
route: string
|
|
189
191
|
): NavNode[] => {
|
|
190
|
-
const tab =
|
|
191
|
-
if (tab) {
|
|
192
|
+
const tab = activeTabForRoute(tabs, route);
|
|
193
|
+
if (tab && tab.path !== "/") {
|
|
192
194
|
return sectionChildren(sidebar, tab.path) ?? [];
|
|
193
195
|
}
|
|
194
196
|
const scoped = withoutTabSections(sidebar, tabs);
|