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.
Files changed (126) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +13784 -10579
  3. package/dist/cli/index.js.map +93 -61
  4. package/dist/types/core/config-input.d.ts +87 -8
  5. package/dist/types/core/data.d.ts +21 -0
  6. package/dist/types/core/deployment-env.d.ts +6 -0
  7. package/dist/types/core/diagnostics.d.ts +23 -0
  8. package/dist/types/core/i18n-ui.d.ts +140 -140
  9. package/dist/types/core/schema.d.ts +549 -370
  10. package/dist/types/core/sources/types.d.ts +3 -1
  11. package/dist/types/core/standard-schema.d.ts +41 -0
  12. package/dist/types/core/types.d.ts +23 -0
  13. package/dist/types/og/card.d.ts +63 -0
  14. package/dist/types/og/dimensions.d.ts +12 -0
  15. package/dist/types/openapi/references.d.ts +12 -7
  16. package/docs/01-quickstart.mdx +1 -1
  17. package/docs/02-deployment.mdx +9 -1
  18. package/docs/advanced/api-reference.mdx +22 -3
  19. package/docs/advanced/changelog.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +1 -1
  22. package/docs/configuration/customization.mdx +1 -1
  23. package/docs/configuration/export.mdx +1 -1
  24. package/docs/configuration/index.mdx +21 -1
  25. package/docs/configuration/search.mdx +28 -1
  26. package/docs/configuration/seo.mdx +40 -2
  27. package/docs/configuration/theming.mdx +1 -1
  28. package/docs/content/components.mdx +15 -2
  29. package/docs/content/index.mdx +1 -1
  30. package/docs/content/meta.mdx +1 -1
  31. package/docs/content/navigation.mdx +11 -1
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +116 -4
  34. package/docs/reference/cli.mdx +79 -1
  35. package/docs/reference/frontmatter.mdx +29 -1
  36. package/package.json +3 -3
  37. package/skills/blume-migrate/SKILL.md +170 -0
  38. package/skills/blume-migrate/assets/oxfmt@0.55.0.patch +20 -0
  39. package/skills/blume-migrate/references/docusaurus.md +95 -0
  40. package/skills/blume-migrate/references/fumadocs.md +95 -0
  41. package/skills/blume-migrate/references/mintlify.md +156 -0
  42. package/skills/blume-migrate/references/monorepo.md +224 -0
  43. package/skills/blume-migrate/references/nextra.md +76 -0
  44. package/skills/blume-migrate/references/starlight.md +116 -0
  45. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +478 -0
  46. package/src/ai/llms.ts +15 -0
  47. package/src/astro/adapter-root.ts +70 -0
  48. package/src/astro/component-slots.ts +3 -2
  49. package/src/astro/generate.ts +132 -42
  50. package/src/astro/index.ts +1 -0
  51. package/src/astro/pages.ts +18 -3
  52. package/src/astro/templates.ts +158 -56
  53. package/src/audit/agent.ts +114 -0
  54. package/src/audit/catalog.ts +826 -0
  55. package/src/audit/checks/assets.ts +177 -0
  56. package/src/audit/checks/content.ts +231 -0
  57. package/src/audit/checks/duplicates.ts +131 -0
  58. package/src/audit/checks/i18n.ts +246 -0
  59. package/src/audit/checks/indexability.ts +213 -0
  60. package/src/audit/checks/links.ts +223 -0
  61. package/src/audit/checks/llms.ts +135 -0
  62. package/src/audit/checks/network.ts +272 -0
  63. package/src/audit/checks/og-image.ts +113 -0
  64. package/src/audit/checks/redirects.ts +87 -0
  65. package/src/audit/checks/robots.ts +114 -0
  66. package/src/audit/checks/sitemap.ts +229 -0
  67. package/src/audit/checks/social.ts +238 -0
  68. package/src/audit/crawl.ts +259 -0
  69. package/src/audit/graph.ts +74 -0
  70. package/src/audit/html.ts +54 -0
  71. package/src/audit/image-size.ts +63 -0
  72. package/src/audit/locate.ts +33 -0
  73. package/src/audit/redirects.ts +74 -0
  74. package/src/audit/report.ts +278 -0
  75. package/src/audit/run.ts +198 -0
  76. package/src/audit/snapshot.ts +189 -0
  77. package/src/audit/types.ts +214 -0
  78. package/src/audit/url.ts +103 -0
  79. package/src/cli/commands/audit.ts +205 -0
  80. package/src/cli/commands/build.ts +51 -12
  81. package/src/cli/index.ts +2 -0
  82. package/src/components/content/Callout.astro +8 -2
  83. package/src/components/content/Prompt.astro +25 -13
  84. package/src/components/content/Tabs.astro +98 -15
  85. package/src/components/layout/Breadcrumbs.astro +1 -1
  86. package/src/components/layout/Header.astro +5 -8
  87. package/src/components/layout/Logo.astro +13 -1
  88. package/src/components/layout/PageFeedback.astro +2 -2
  89. package/src/components/layout/PageLayout.astro +9 -9
  90. package/src/components/layout/Pagination.astro +7 -7
  91. package/src/components/layout/RootLayout.astro +9 -11
  92. package/src/components/layout/Search.astro +36 -7
  93. package/src/components/layout/TableOfContents.astro +1 -1
  94. package/src/components/layout/nav-utils.ts +9 -7
  95. package/src/components/openapi/Authorization.astro +80 -0
  96. package/src/components/openapi/Operation.astro +19 -1
  97. package/src/components/openapi/ParametersTable.astro +1 -1
  98. package/src/components/openapi/security.ts +201 -0
  99. package/src/components/openapi/snippets.ts +42 -13
  100. package/src/core/config-input.ts +94 -8
  101. package/src/core/data.ts +18 -2
  102. package/src/core/deployment-env.ts +9 -0
  103. package/src/core/diagnostics.ts +59 -12
  104. package/src/core/links.ts +2 -91
  105. package/src/core/nav-diagnostics.ts +48 -4
  106. package/src/core/navigation.ts +55 -13
  107. package/src/core/probe.ts +136 -0
  108. package/src/core/project-graph.ts +8 -0
  109. package/src/core/schema.ts +100 -1
  110. package/src/core/sources/normalize.ts +198 -25
  111. package/src/core/sources/types.ts +3 -1
  112. package/src/core/sources/watch.ts +5 -0
  113. package/src/core/standard-schema.ts +54 -0
  114. package/src/core/types.ts +23 -0
  115. package/src/deploy/adapter-output.ts +27 -15
  116. package/src/deploy/headers.ts +66 -0
  117. package/src/deploy/redirects.ts +49 -9
  118. package/src/markdown/index.ts +2 -0
  119. package/src/markdown/language-icon.ts +2 -1
  120. package/src/markdown/table-wrap.ts +43 -0
  121. package/src/og/card.ts +128 -36
  122. package/src/og/index.ts +1 -1
  123. package/src/og/logo.ts +21 -0
  124. package/src/openapi/references.ts +19 -16
  125. package/src/search/popular.ts +33 -0
  126. 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
- <span data-blume-prompt-copy-label>Copy prompt</span>
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
- const label = copy.querySelector<HTMLElement>(
114
- "[data-blume-prompt-copy-label]"
115
- );
116
- if (!label) {
117
- return;
118
- }
119
- // Remember the real label once — capturing at click time would
120
- // capture "Copied" on a double-click and stick until reload.
121
- label.dataset.blumeLabel ??= label.textContent ?? "Copy prompt";
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="not-prose my-6 block overflow-hidden rounded-blume border border-border bg-background"
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={dropdown ? "true" : "false"}
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 class:list={[dropdown ? "" : "overflow-x-auto"]}>
56
+ <div
57
+ class:list={[
58
+ inline ? "not-prose" : "",
59
+ useDropdown ? "" : "overflow-x-auto",
60
+ ]}
61
+ >
32
62
  <div
33
63
  class:list={[
34
- dropdown || borderBottom ? "border-border border-b" : "",
35
- dropdown ? "p-3" : "flex gap-4 px-3",
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={dropdown ? undefined : "tablist"}
68
+ role={useDropdown ? undefined : "tablist"}
39
69
  >
40
70
  </div>
41
71
  </div>
42
- <div data-blume-tab-content>
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
- window.addEventListener("hashchange", this.#hashChange);
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
- window.addEventListener("hashchange", this.#hashChange);
209
+ this.#addUrlListener();
177
210
  }
178
211
 
179
212
  disconnectedCallback() {
180
213
  document.removeEventListener(SYNC_EVENT, this.#sync);
181
- window.removeEventListener("hashchange", this.#hashChange);
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 && this.#hashEnabled()) {
205
- history.replaceState(null, "", `#${encodeURIComponent(id)}`);
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
- if (this.#activateByHash()) {
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
- #hashChange = () => {
289
- this.#activateByHash();
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-[42rem]",
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 { isUnderPath } from "./nav-utils.ts";
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 alt={logoAlt} class="h-5 w-auto" src={withBase(logoLight)} />
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-full border border-border px-4 py-2 text-foreground text-sm transition-colors hover:border-foreground";
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-[42rem] items-center justify-between gap-4 border-border border-t pt-6"
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 { isUnderPath } from "./nav-utils.ts";
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
- {resolvedCanonical && <link href={resolvedCanonical} rel="canonical" />}
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-[42rem] justify-between gap-4 border-border border-t pt-6 max-md:flex-col"
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-full border border-border px-4 py-3 text-foreground transition-colors hover:border-foreground max-md:max-w-full"
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-full border border-border px-4 py-3 text-end text-foreground transition-colors hover:border-foreground max-md:max-w-full"
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-[42rem]";
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
- {canonical && <link rel="canonical" href={canonical} />}
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-[42rem] text-muted-foreground text-sm">
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 } = Astro.props;
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
- // Pages shown in the empty state, before the user has typed anything.
28
- const popular = navigation ? flattenPages(navigation.sidebar).slice(0, 6) : [];
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="m-auto h-[min(480px,90vh)] 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"
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-[42rem] rounded-blume border border-border xl:hidden">
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`, mirroring the header's
84
- * active-tab highlight. The root tab (`/`) is skipped — it spans everything and
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 activeTab = (tabs: NavTab[], route: string): NavTab | null => {
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 (tab.path === "/" || !isUnderPath(route, tab.path)) {
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 = activeTab(tabs, route);
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);