blume 0.6.7 → 0.8.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 (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -1,6 +1,9 @@
1
1
  ---
2
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
+ import type { UIStrings } from "../../core/i18n-ui.ts";
2
4
  import type { NavNode } from "../../core/types.ts";
3
5
  import Icon from "../Icon.astro";
6
+ import { withBase } from "../islands/base-path.ts";
4
7
  import Self from "./NavTree.astro";
5
8
 
6
9
  interface PagePanel {
@@ -21,6 +24,8 @@ interface Props {
21
24
  idPrefix?: string;
22
25
  /** True only for the top-level call; renders the `<blume-nav>` panel stack. */
23
26
  root?: boolean;
27
+ /** Localized labels ("Back", the deprecated badge); English when omitted. */
28
+ strings?: UIStrings["nav"];
24
29
  }
25
30
 
26
31
  const {
@@ -29,12 +34,18 @@ const {
29
34
  depth = 0,
30
35
  idPrefix = "n",
31
36
  root = depth === 0,
37
+ strings,
32
38
  } = Astro.props;
33
39
 
40
+ // Merge over the English defaults so a label missing from a translation (or
41
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
42
+ // blank — the PageActions pattern.
43
+ const n = { ...EN_UI.nav, ...strings };
44
+
34
45
  const badgeBase =
35
46
  "shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
36
47
  const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
37
- // HTTP-method badges (from an OpenAPI reference's sidebar) are colour-coded;
48
+ // HTTP-method badges (from an OpenAPI reference's sidebar) are color-coded;
38
49
  // every other badge keeps the neutral style.
39
50
  const METHOD_BADGE: Record<string, string> = {
40
51
  DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
@@ -113,13 +124,14 @@ const initialId =
113
124
  idPrefix={idPrefix}
114
125
  items={items}
115
126
  root={false}
127
+ strings={n}
116
128
  />
117
129
  </div>
118
130
  {panels.map((panel) => (
119
131
  <div data-nav-panel={panel.id} hidden={panel.id !== initialId}>
120
132
  <div class="mb-3 flex items-center gap-1.5">
121
133
  <button
122
- aria-label="Back"
134
+ aria-label={n.back}
123
135
  class="-ml-1 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
124
136
  data-nav-back={panel.parentId}
125
137
  type="button"
@@ -130,7 +142,7 @@ const initialId =
130
142
  <a
131
143
  aria-current={panel.route === currentRoute ? "page" : undefined}
132
144
  class="flex-1 truncate font-semibold text-foreground text-sm hover:underline"
133
- href={panel.route}
145
+ href={withBase(panel.route)}
134
146
  >
135
147
  {panel.label}
136
148
  </a>
@@ -146,6 +158,7 @@ const initialId =
146
158
  idPrefix={panel.id}
147
159
  items={panel.children}
148
160
  root={false}
161
+ strings={n}
149
162
  />
150
163
  </div>
151
164
  ))}
@@ -159,7 +172,7 @@ const initialId =
159
172
  <a
160
173
  aria-current={item.route === currentRoute ? "page" : undefined}
161
174
  class="block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:font-medium aria-[current=page]:text-foreground"
162
- href={item.route}
175
+ href={withBase(item.route)}
163
176
  >
164
177
  <span class="flex items-center gap-2">
165
178
  {item.icon && (
@@ -172,7 +185,7 @@ const initialId =
172
185
  <span class="flex-1 truncate">{item.label}</span>
173
186
  {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
174
187
  {item.deprecated && (
175
- <span class={deprecatedClass}>deprecated</span>
188
+ <span class={deprecatedClass}>{n.deprecated}</span>
176
189
  )}
177
190
  </span>
178
191
  </a>
@@ -231,7 +244,7 @@ const initialId =
231
244
  item.route === currentRoute ? "page" : undefined
232
245
  }
233
246
  class="-my-1 flex flex-1 items-center gap-1.5 rounded py-1 transition-colors aria-[current=page]:text-foreground aria-[current=page]:font-semibold"
234
- href={item.route}
247
+ href={withBase(item.route)}
235
248
  >
236
249
  {item.icon && (
237
250
  <Icon
@@ -271,6 +284,7 @@ const initialId =
271
284
  idPrefix={id}
272
285
  items={item.children}
273
286
  root={false}
287
+ strings={n}
274
288
  />
275
289
  </div>
276
290
  </details>
@@ -286,7 +300,7 @@ const initialId =
286
300
  <a
287
301
  aria-current={item.route === currentRoute ? "page" : undefined}
288
302
  class="-ml-1 flex flex-1 items-center gap-1.5 rounded px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted"
289
- href={item.route}
303
+ href={withBase(item.route)}
290
304
  >
291
305
  {item.icon && (
292
306
  <Icon
@@ -323,6 +337,7 @@ const initialId =
323
337
  idPrefix={id}
324
338
  items={item.children}
325
339
  root={false}
340
+ strings={n}
326
341
  />
327
342
  </div>
328
343
  </li>
@@ -68,6 +68,7 @@ const menuRowClass =
68
68
  class="mt-8 space-y-0.5 border-border border-t pt-4"
69
69
  data-blume-page-actions
70
70
  data-i18n-copied={a.copied}
71
+ data-i18n-generating={a.generating}
71
72
  data-mcp-name={mcpName ?? undefined}
72
73
  data-mcp-url={mcpUrl ?? undefined}
73
74
  data-md={mdPath}
@@ -98,7 +99,7 @@ const menuRowClass =
98
99
  class={`${rowClass} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}
99
100
  >
100
101
  <Icon name="download" size={16} />
101
- Export
102
+ {a.export}
102
103
  <Icon
103
104
  class="ms-auto transition-transform group-open:rotate-180"
104
105
  name="chevron-down"
@@ -112,14 +113,14 @@ const menuRowClass =
112
113
  {exportPdf && (
113
114
  <button class={menuRowClass} data-blume-export-pdf type="button">
114
115
  <Icon name="file" size={16} />
115
- <span class="flex-1">Export to PDF</span>
116
+ <span class="flex-1">{a.exportPdf}</span>
116
117
  </button>
117
118
  )}
118
119
  {exportEpub && (
119
120
  <button class={menuRowClass} data-blume-export-epub type="button">
120
121
  <Icon name="book-open" size={16} />
121
122
  <span class="flex-1" data-blume-epub-label>
122
- Export to EPUB
123
+ {a.exportEpub}
123
124
  </span>
124
125
  </button>
125
126
  )}
@@ -218,6 +219,8 @@ const menuRowClass =
218
219
  </div>
219
220
 
220
221
  <script>
222
+ import { prefixBase } from "../islands/base-path.ts";
223
+
221
224
  const CHAT_URLS: Record<string, (q: string) => string> = {
222
225
  chatgpt: (q) => `https://chatgpt.com/?hints=search&prompt=${q}`,
223
226
  claude: (q) => `https://claude.ai/new?q=${q}`,
@@ -317,8 +320,14 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
317
320
  }
318
321
  });
319
322
 
320
- const md = root.getAttribute("data-md") ?? "";
323
+ // `data-md` is the base-less logical route; the raw-markdown endpoint is a
324
+ // generated page route, so it's served under the deployment base like any
325
+ // other page. Prefix at emit time (the Search-island pattern).
326
+ const mdRoute = root.getAttribute("data-md") ?? "";
327
+ const md = prefixBase(import.meta.env.BASE_URL, mdRoute);
321
328
  const copiedLabel = root.getAttribute("data-i18n-copied") || "Copied!";
329
+ const generatingLabel =
330
+ root.getAttribute("data-i18n-generating") || "Generating…";
322
331
  const absolute = new URL(md, location.origin).href;
323
332
  const query = encodeURIComponent(
324
333
  `Read ${absolute} so I can ask you questions about this page.`
@@ -414,6 +423,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
414
423
  ?.addEventListener("click", async () => {
415
424
  try {
416
425
  const response = await fetch(md);
426
+ if (!response.ok) {
427
+ throw new Error(`Fetching ${md} failed (${response.status})`);
428
+ }
417
429
  await navigator.clipboard.writeText(await response.text());
418
430
  if (label) {
419
431
  label.textContent = copiedLabel;
@@ -421,8 +433,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
421
433
  label.textContent = original;
422
434
  }, 1500);
423
435
  }
424
- } catch {
425
- // Clipboard or fetch unavailable; nothing to do.
436
+ } catch (error) {
437
+ // Clipboard or fetch unavailable; don't flash "Copied!" untruthfully.
438
+ console.error("[blume] Copy as Markdown failed", error);
426
439
  }
427
440
  });
428
441
 
@@ -432,6 +445,9 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
432
445
 
433
446
  const epubButton = root.querySelector("[data-blume-export-epub]");
434
447
  const epubLabel = root.querySelector("[data-blume-epub-label]");
448
+ // Captured once — inside the handler a click during a generation would
449
+ // capture and permanently restore "Generating…".
450
+ const epubReset = epubLabel?.textContent ?? "";
435
451
  epubButton?.addEventListener("click", async () => {
436
452
  const article = document.querySelector("#blume-content article");
437
453
  if (!article) {
@@ -439,9 +455,8 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
439
455
  }
440
456
  const heading = document.querySelector("#blume-content h1");
441
457
  const title = heading?.textContent?.trim() || document.title;
442
- const reset = epubLabel?.textContent ?? "Export to EPUB";
443
458
  if (epubLabel) {
444
- epubLabel.textContent = "Generating…";
459
+ epubLabel.textContent = generatingLabel;
445
460
  }
446
461
  try {
447
462
  // Browser bundle: avoids Node built-ins and returns a Blob. Lazy-loaded
@@ -461,7 +476,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
461
476
  ? result
462
477
  : new Blob([result], { type: "application/epub+zip" });
463
478
  const slug =
464
- md
479
+ mdRoute
465
480
  .replace(/\.md$/u, "")
466
481
  .replace(/^\//u, "")
467
482
  .replace(/\//gu, "-") || "index";
@@ -479,7 +494,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
479
494
  console.error("[blume] EPUB export failed", error);
480
495
  } finally {
481
496
  if (epubLabel) {
482
- epubLabel.textContent = reset;
497
+ epubLabel.textContent = epubReset;
483
498
  }
484
499
  }
485
500
  });
@@ -9,7 +9,10 @@ interface Props {
9
9
  }
10
10
 
11
11
  const { strings } = Astro.props;
12
- const f = strings ?? EN_UI.feedback;
12
+ // Merge over the English baseline per key (rather than `strings ?? …`) so a
13
+ // partial — or empty `{}` — strings object still resolves every label to a
14
+ // default, matching the pattern PageActions and Search use.
15
+ const f = { ...EN_UI.feedback, ...strings };
13
16
 
14
17
  const buttonClass =
15
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";
@@ -25,6 +25,7 @@ import type {
25
25
  import { EN_UI } from "../../core/i18n-ui.ts";
26
26
  import type { UIStrings } from "../../core/i18n-ui.ts";
27
27
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
28
+ import { withBase } from "../islands/base-path.ts";
28
29
  import "blume:theme";
29
30
  import Analytics from "./Analytics.astro";
30
31
  import Banner from "./Banner.astro";
@@ -109,6 +110,10 @@ const clientDataJson = clientData
109
110
  : null;
110
111
 
111
112
  const strings = ui ?? EN_UI;
113
+ // Merge over the English defaults so a label missing from a translation (or
114
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
115
+ // blank — the PageActions pattern.
116
+ const navStrings = { ...EN_UI.nav, ...strings.nav };
112
117
  // Filter search to the active language only when the site is multi-locale.
113
118
  const searchLocale =
114
119
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
@@ -121,18 +126,26 @@ const route = page?.route ?? "/";
121
126
  // (`/og/<slug>.png`, `index` for the home) is generated for static custom pages
122
127
  // when `og.enabled`. Explicit props still win.
123
128
  const ogSlug = route === "/" ? "index" : route.slice(1);
129
+ // Absolute URLs carry the deployment base too (`site + base + route`), since the
130
+ // page is served under it. `withBase` handles a root base as a no-op.
131
+ const basedRoute = withBase(route);
132
+ // The schema accepts a trailing-slash site URL (`https://docs.example.com/`);
133
+ // strip it before joining with the root-relative route so canonical/og URLs
134
+ // don't come out double-slashed — the catch-all strips it the same way.
135
+ const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
124
136
  const resolvedCanonical =
125
- canonical ?? (siteUrl ? `${siteUrl}${route === "/" ? "" : route}` : null);
137
+ canonical ??
138
+ (siteBase ? `${siteBase}${basedRoute === "/" ? "" : basedRoute}` : null);
126
139
  // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
127
140
  // `public/`) is resolved against the site URL so crawlers get an absolute
128
141
  // `og:image`; an already-absolute URL passes through untouched. Otherwise fall
129
142
  // back to the generated OG card for this route.
130
143
  const absolutizeOgImage = (value: string): string =>
131
- value.startsWith("/") && siteUrl ? `${siteUrl}${value}` : value;
144
+ value.startsWith("/") && siteBase ? `${siteBase}${withBase(value)}` : value;
132
145
  const resolvedOgImage = ogImage
133
146
  ? absolutizeOgImage(ogImage)
134
- : ogEnabled && siteUrl
135
- ? `${siteUrl}/og/${ogSlug}.png`
147
+ : ogEnabled && siteBase
148
+ ? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
136
149
  : null;
137
150
 
138
151
  const initialThemeScript = themeInitScript(themeMode);
@@ -173,12 +186,13 @@ const bannerScript = banner?.dismissible
173
186
  class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
174
187
  href="#blume-content">{strings.page.skipToContent}</a
175
188
  >
176
- <Banner banner={banner} />
189
+ <Banner banner={banner} strings={strings.banner} />
177
190
  <Header
178
191
  askEnabled={askEnabled}
179
192
  hasSidebar={false}
180
193
  localeSwitch={localeSwitch}
181
194
  logo={logo}
195
+ navStrings={navStrings}
182
196
  navigation={navigation}
183
197
  route={route}
184
198
  searchEnabled={searchEnabled}
@@ -200,10 +214,11 @@ const bannerScript = banner?.dismissible
200
214
  navigation.tabs.length > 0 && (
201
215
  <>
202
216
  <aside
203
- aria-label="Navigation"
217
+ aria-label={navStrings.navigation}
204
218
  class="fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:hidden"
219
+ data-blume-nav-drawer
205
220
  >
206
- <nav aria-label="Sections">
221
+ <nav aria-label={navStrings.sections}>
207
222
  <ul class="m-0 list-none p-0">
208
223
  {navigation.tabs.map((tab) => (
209
224
  <li>
@@ -215,7 +230,7 @@ const bannerScript = banner?.dismissible
215
230
  : undefined
216
231
  }
217
232
  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"
218
- href={tab.path}
233
+ href={withBase(tab.path)}
219
234
  >
220
235
  {tab.label}
221
236
  </a>
@@ -225,7 +240,7 @@ const bannerScript = banner?.dismissible
225
240
  </nav>
226
241
  </aside>
227
242
  <button
228
- aria-label="Close navigation"
243
+ aria-label={navStrings.closeNavigation}
229
244
  class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
230
245
  data-blume-nav-toggle
231
246
  type="button"
@@ -247,6 +262,33 @@ const bannerScript = banner?.dismissible
247
262
  // Dev-only: friendly hint after a React island hydration mismatch;
248
263
  // tree-shaken out of production builds.
249
264
  import "./hydration-hint.ts";
265
+
266
+ // The closed tabs drawer is only translated off-canvas, so its links
267
+ // would stay in the tab order. Mirror the header's `data-blume-nav-open`
268
+ // toggle into `inert`/`aria-hidden` below `lg` (64rem), matching the
269
+ // breakpoint where the drawer is display-hidden anyway.
270
+ const drawer = document.querySelector<HTMLElement>(
271
+ "[data-blume-nav-drawer]"
272
+ );
273
+ if (drawer) {
274
+ const desktop = window.matchMedia("(min-width: 64rem)");
275
+ const syncDrawer = () => {
276
+ const hidden =
277
+ !desktop.matches &&
278
+ !document.documentElement.hasAttribute("data-blume-nav-open");
279
+ drawer.inert = hidden;
280
+ if (hidden) {
281
+ drawer.setAttribute("aria-hidden", "true");
282
+ } else {
283
+ drawer.removeAttribute("aria-hidden");
284
+ }
285
+ };
286
+ syncDrawer();
287
+ desktop.addEventListener("change", syncDrawer);
288
+ new MutationObserver(syncDrawer).observe(document.documentElement, {
289
+ attributeFilter: ["data-blume-nav-open"],
290
+ });
291
+ }
250
292
  </script>
251
293
  </body>
252
294
  </html>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { withBase } from "../islands/base-path.ts";
2
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
3
4
  import type { FlatPage } from "./nav-utils.ts";
4
5
  import Icon from "../Icon.astro";
@@ -24,7 +25,7 @@ const { prev, next, strings } = Astro.props;
24
25
  {prev ? (
25
26
  <a
26
27
  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"
27
- href={prev.route}
28
+ href={withBase(prev.route)}
28
29
  >
29
30
  <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
30
31
  <span>
@@ -40,7 +41,7 @@ const { prev, next, strings } = Astro.props;
40
41
  {next && (
41
42
  <a
42
43
  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"
43
- href={next.route}
44
+ href={withBase(next.route)}
44
45
  >
45
46
  <span>
46
47
  <span class="block text-muted-foreground text-xs max-md:hidden">
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import "blume:theme";
3
+ import { EN_UI } from "../../core/i18n-ui.ts";
4
+ import type { UIStrings } from "../../core/i18n-ui.ts";
3
5
  import type { Navigation } from "../../core/types.ts";
4
6
  import Analytics from "./Analytics.astro";
5
7
  import Banner from "./Banner.astro";
@@ -50,6 +52,8 @@ interface Props {
50
52
  fontCssVars?: string[];
51
53
  searchEnabled: boolean;
52
54
  pageTitle: string;
55
+ /** Resolved UI dictionary; English baseline when omitted. */
56
+ ui?: UIStrings;
53
57
  }
54
58
 
55
59
  const {
@@ -65,8 +69,11 @@ const {
65
69
  fontCssVars,
66
70
  searchEnabled,
67
71
  pageTitle,
72
+ ui,
68
73
  } = Astro.props;
69
74
 
75
+ const strings = ui ?? EN_UI;
76
+
70
77
  // Set the theme before paint so the navbar never flashes the wrong colors
71
78
  // (mirrors RootLayout's pre-paint script).
72
79
  const initialThemeScript = `(()=>{const m=${JSON.stringify(themeMode)};const s=localStorage.getItem("blume-theme");const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=s??(m==="system"?sys:m);})();`;
@@ -90,7 +97,7 @@ const bannerScript = banner?.dismissible
90
97
  <Analytics analytics={analytics} />
91
98
  </head>
92
99
  <body class="bg-background font-sans text-foreground antialiased">
93
- <Banner banner={banner} />
100
+ <Banner banner={banner} strings={strings.banner} />
94
101
  <Header
95
102
  hasDrawer={false}
96
103
  hasSidebar={false}
@@ -10,6 +10,7 @@ import type {
10
10
  import "blume:theme";
11
11
  import type { ComponentOverride } from "../../core/define-components.ts";
12
12
  import { buildStructuredData } from "../../seo/jsonld.ts";
13
+ import { withBase } from "../islands/base-path.ts";
13
14
  import Analytics from "./Analytics.astro";
14
15
  import Banner from "./Banner.astro";
15
16
  import Breadcrumbs from "./Breadcrumbs.astro";
@@ -205,6 +206,12 @@ const PageFooterSlot = resolveSlot(layout.PageFooter, Empty);
205
206
  const FeedbackSlot = resolveSlot(layout.Feedback, PageFeedback);
206
207
 
207
208
  const strings = ui ?? EN_UI;
209
+ // Merge the groups this layout reads directly over the English defaults so a
210
+ // key missing from a translation (or from a not-yet-regenerated snapshot)
211
+ // still renders instead of coming out blank — the PageActions pattern.
212
+ const navStrings = { ...EN_UI.nav, ...strings.nav };
213
+ const actionStrings = { ...EN_UI.actions, ...strings.actions };
214
+ const contentStrings = { ...EN_UI.content, ...strings.content };
208
215
  // Filter search to the active language only when the site is multi-locale.
209
216
  const searchLocale =
210
217
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
@@ -250,7 +257,8 @@ const formattedLastModified =
250
257
 
251
258
  // The hosted MCP server's absolute URL, used by the page-actions install menu.
252
259
  // Needs a configured site to be useful, so the menu is hidden without one.
253
- const mcpUrl = mcp && siteUrl ? new URL(mcp.route, siteUrl).href : null;
260
+ const mcpUrl =
261
+ mcp && siteUrl ? new URL(withBase(mcp.route), siteUrl).href : null;
254
262
 
255
263
  // Scope the sidebar (and the breadcrumbs/pagination derived from it) to the
256
264
  // active tab's section, so a multi-section site drills each tab into its own
@@ -266,6 +274,7 @@ const structuredData =
266
274
  structuredDataEnabled === false || noindex
267
275
  ? null
268
276
  : buildStructuredData({
277
+ base: import.meta.env.BASE_URL,
269
278
  breadcrumbs: crumbs,
270
279
  description,
271
280
  locale,
@@ -318,7 +327,7 @@ const bannerScript = banner?.dismissible
318
327
  {
319
328
  feeds?.map((feed) => (
320
329
  <link
321
- href={feed.href}
330
+ href={withBase(feed.href)}
322
331
  rel="alternate"
323
332
  title={feed.title}
324
333
  type="application/rss+xml"
@@ -342,12 +351,14 @@ const bannerScript = banner?.dismissible
342
351
  class="bg-background font-sans text-foreground antialiased"
343
352
  data-blume-code-wrap={codeWrap ? "" : undefined}
344
353
  data-blume-image-zoom={imageZoom ? "" : undefined}
354
+ data-i18n-copy-code={actionStrings.copyCode}
355
+ data-i18n-diagram-error={contentStrings.diagramError}
345
356
  >
346
357
  <a
347
358
  class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
348
359
  href="#blume-content">{strings.page.skipToContent}</a
349
360
  >
350
- <Banner banner={banner} />
361
+ <Banner banner={banner} strings={strings.banner} />
351
362
  <HeaderSlot
352
363
  askEnabled={askEnabled}
353
364
  layout={layout}
@@ -357,6 +368,7 @@ const bannerScript = banner?.dismissible
357
368
  route={page.route}
358
369
  searchEnabled={searchEnabled}
359
370
  searchLocale={searchLocale}
371
+ navStrings={navStrings}
360
372
  searchStrings={strings.search}
361
373
  site={site}
362
374
  switcherStrings={strings.languageSwitcher}
@@ -368,7 +380,8 @@ const bannerScript = banner?.dismissible
368
380
  data-blume-doc-grid
369
381
  >
370
382
  <aside
371
- aria-label="Primary"
383
+ aria-label={navStrings.primary}
384
+ data-blume-nav-drawer
372
385
  class:list={[
373
386
  "fixed top-[var(--blume-drawer-top,4rem)] start-0 z-[35] h-[calc(100dvh-var(--blume-drawer-top,4rem))] w-64 max-w-[80vw] -translate-x-[105%] overflow-y-auto border-border border-e bg-background px-5 pt-4 pb-6 transition-transform rtl:translate-x-[105%] [:where([data-blume-nav-open])_&]:translate-x-0! lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100dvh-4rem)] lg:w-auto lg:max-w-none lg:translate-x-0! lg:border-e-0 lg:bg-transparent lg:px-4",
374
387
  // A "bare" landing (the changelog index) has no sidebar column on
@@ -382,7 +395,10 @@ const bannerScript = banner?.dismissible
382
395
  // all breakpoints, outside the tab-scoped sidebar so they never change
383
396
  // with the active tab. External hrefs open in a new tab.
384
397
  navigation.featured.length > 0 && (
385
- <nav aria-label="Featured" class="mb-4 border-border border-b pb-4">
398
+ <nav
399
+ aria-label={navStrings.featured}
400
+ class="mb-4 border-border border-b pb-4"
401
+ >
386
402
  <ul class="m-0 list-none p-0">
387
403
  {navigation.featured.map((link) => {
388
404
  const external = /^https?:\/\//u.test(link.href);
@@ -390,7 +406,7 @@ const bannerScript = banner?.dismissible
390
406
  <li>
391
407
  <a
392
408
  class="block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
393
- href={link.href}
409
+ href={withBase(link.href)}
394
410
  rel={external ? "noreferrer" : undefined}
395
411
  target={external ? "_blank" : undefined}
396
412
  >
@@ -428,7 +444,7 @@ const bannerScript = banner?.dismissible
428
444
  // from opening blank.
429
445
  navigation.tabs.length > 0 && (
430
446
  <nav
431
- aria-label="Sections"
447
+ aria-label={navStrings.sections}
432
448
  class:list={[
433
449
  "mb-4 border-border border-b pb-4",
434
450
  sidebar.length > 0 && "md:hidden",
@@ -445,7 +461,7 @@ const bannerScript = banner?.dismissible
445
461
  : undefined
446
462
  }
447
463
  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"
448
- href={tab.path}
464
+ href={withBase(tab.path)}
449
465
  >
450
466
  {tab.label}
451
467
  </a>
@@ -460,14 +476,26 @@ const bannerScript = banner?.dismissible
460
476
  MobileNavSlot ? (
461
477
  <>
462
478
  <div class="lg:hidden">
463
- <MobileNavSlot currentRoute={page.route} items={sidebar} />
479
+ <MobileNavSlot
480
+ currentRoute={page.route}
481
+ items={sidebar}
482
+ strings={navStrings}
483
+ />
464
484
  </div>
465
485
  <div class="hidden lg:block">
466
- <SidebarSlot currentRoute={page.route} items={sidebar} />
486
+ <SidebarSlot
487
+ currentRoute={page.route}
488
+ items={sidebar}
489
+ strings={navStrings}
490
+ />
467
491
  </div>
468
492
  </>
469
493
  ) : (
470
- <SidebarSlot currentRoute={page.route} items={sidebar} />
494
+ <SidebarSlot
495
+ currentRoute={page.route}
496
+ items={sidebar}
497
+ strings={navStrings}
498
+ />
471
499
  )
472
500
  }
473
501
  </nav>
@@ -539,7 +567,7 @@ const bannerScript = banner?.dismissible
539
567
  )
540
568
  }
541
569
  <button
542
- aria-label="Close navigation"
570
+ aria-label={navStrings.closeNavigation}
543
571
  class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
544
572
  data-blume-nav-toggle
545
573
  type="button"
@@ -556,12 +584,45 @@ const bannerScript = banner?.dismissible
556
584
  // Tree-shaken out of production builds.
557
585
  import "./hydration-hint.ts";
558
586
 
587
+ // The closed mobile drawer is only translated off-canvas, so its links
588
+ // would stay in the tab order on every page. Mirror the header's
589
+ // `data-blume-nav-open` toggle into `inert`/`aria-hidden` — but only
590
+ // below `lg` (64rem), where the same element isn't the static sidebar.
591
+ const drawer = document.querySelector<HTMLElement>(
592
+ "[data-blume-nav-drawer]"
593
+ );
594
+ if (drawer) {
595
+ const desktop = window.matchMedia("(min-width: 64rem)");
596
+ const syncDrawer = () => {
597
+ const hidden =
598
+ !desktop.matches &&
599
+ !document.documentElement.hasAttribute("data-blume-nav-open");
600
+ drawer.inert = hidden;
601
+ if (hidden) {
602
+ drawer.setAttribute("aria-hidden", "true");
603
+ } else {
604
+ drawer.removeAttribute("aria-hidden");
605
+ }
606
+ };
607
+ syncDrawer();
608
+ desktop.addEventListener("change", syncDrawer);
609
+ new MutationObserver(syncDrawer).observe(document.documentElement, {
610
+ attributeFilter: ["data-blume-nav-open"],
611
+ });
612
+ }
613
+
559
614
  const svg = (name: string) =>
560
615
  `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icons[name]}</svg>`;
561
616
 
562
617
  const buttonClass =
563
618
  "absolute right-3 inline-flex size-7 items-center justify-center rounded-full bg-transparent text-muted-foreground opacity-70 transition hover:bg-muted hover:text-foreground hover:opacity-100 focus-visible:opacity-100";
564
619
 
620
+ // Localized copy-button label, stamped on <body> by the layout markup
621
+ // (the Search.astro data-attribute channel) since this bundled script
622
+ // can't interpolate server values directly.
623
+ const copyCodeLabel =
624
+ document.body.getAttribute("data-i18n-copy-code") || "Copy code";
625
+
565
626
  const languageLabels: Record<string, string> = {
566
627
  astro: "Astro",
567
628
  bash: "Bash",
@@ -609,7 +670,7 @@ const bannerScript = banner?.dismissible
609
670
  : "top-2";
610
671
  button.className = `${buttonClass} ${topClass}`;
611
672
  button.setAttribute("data-blume-copy", "");
612
- button.setAttribute("aria-label", "Copy code");
673
+ button.setAttribute("aria-label", copyCodeLabel);
613
674
  button.innerHTML = svg("copy");
614
675
  button.addEventListener("click", async () => {
615
676
  const code = pre.querySelector("code");