blume 0.7.0 → 1.0.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 (185) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +1852 -380
  5. package/dist/cli/index.js.map +98 -91
  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 +9 -0
  9. package/dist/types/core/config-input.d.ts +47 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +7 -0
  12. package/dist/types/core/i18n-ui.d.ts +526 -132
  13. package/dist/types/core/schema.d.ts +293 -146
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/dist/types/seo/x-handle.d.ts +12 -0
  17. package/docs/01-quickstart.mdx +5 -2
  18. package/docs/02-deployment.mdx +8 -8
  19. package/docs/03-faq.mdx +46 -16
  20. package/docs/advanced/api-reference.mdx +1 -1
  21. package/docs/advanced/changelog.mdx +1 -1
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/skills.mdx +1 -1
  24. package/docs/configuration/ai.mdx +49 -10
  25. package/docs/configuration/customization.mdx +11 -0
  26. package/docs/configuration/export.mdx +1 -1
  27. package/docs/configuration/index.mdx +27 -3
  28. package/docs/configuration/seo.mdx +35 -5
  29. package/docs/content/components.mdx +2 -2
  30. package/docs/content/i18n.mdx +1 -1
  31. package/docs/content/navigation.mdx +3 -3
  32. package/docs/content/sources.mdx +1 -1
  33. package/docs/content/syntax.mdx +6 -4
  34. package/docs/index.mdx +2 -2
  35. package/docs/reference/cli.mdx +9 -7
  36. package/docs/reference/frontmatter.mdx +1 -1
  37. package/package.json +22 -4
  38. package/skills/blume/SKILL.md +5 -3
  39. package/skills/blume-update-docs/SKILL.md +3 -2
  40. package/src/ai/agent-readability.ts +9 -8
  41. package/src/ai/ask-context.ts +7 -2
  42. package/src/ai/ask-data.ts +3 -0
  43. package/src/ai/component-markdown.ts +461 -0
  44. package/src/ai/llms.ts +135 -26
  45. package/src/ai/markdown.ts +35 -6
  46. package/src/ai/mcp/data.ts +25 -4
  47. package/src/ai/mcp/discovery.ts +10 -3
  48. package/src/ai/mcp/server.ts +21 -7
  49. package/src/ai/mcp/tools.ts +1 -1
  50. package/src/ai/visibility.ts +74 -0
  51. package/src/astro/component-slots.ts +11 -1
  52. package/src/astro/generate.ts +77 -45
  53. package/src/astro/integration.ts +1 -1
  54. package/src/astro/markdown-negotiation.ts +1 -1
  55. package/src/astro/pages.ts +81 -19
  56. package/src/astro/templates.ts +150 -19
  57. package/src/blume-modules.d.ts +8 -0
  58. package/src/cli/commands/build.ts +120 -23
  59. package/src/cli/commands/check.ts +1 -1
  60. package/src/cli/commands/dev.ts +26 -5
  61. package/src/cli/commands/eject.ts +47 -19
  62. package/src/cli/commands/init.ts +120 -180
  63. package/src/cli/commands/preview.ts +4 -1
  64. package/src/cli/commands/validate.ts +43 -2
  65. package/src/cli/dev-lock.ts +8 -4
  66. package/src/cli/eject-scripts.ts +72 -0
  67. package/src/cli/env.ts +15 -5
  68. package/src/cli/init/questions.ts +158 -0
  69. package/src/cli/init/scaffold.ts +380 -0
  70. package/src/cli/internal-error.ts +9 -4
  71. package/src/cli/prepare.ts +3 -2
  72. package/src/components/Icon.astro +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/Step.astro +10 -1
  77. package/src/components/content/Tabs.astro +15 -3
  78. package/src/components/content/Tile.astro +2 -1
  79. package/src/components/content/Tooltip.astro +3 -1
  80. package/src/components/content/Update.astro +9 -2
  81. package/src/components/content/auto-type-table.ts +7 -1
  82. package/src/components/content/base-href.ts +33 -0
  83. package/src/components/content/changelog-element.ts +9 -2
  84. package/src/components/content/mermaid-element.ts +7 -2
  85. package/src/components/islands/AskAI.astro +5 -2
  86. package/src/components/islands/ask-ai.tsx +86 -11
  87. package/src/components/islands/hooks.ts +28 -8
  88. package/src/components/layout/Banner.astro +10 -2
  89. package/src/components/layout/Breadcrumbs.astro +11 -2
  90. package/src/components/layout/Header.astro +13 -4
  91. package/src/components/layout/Logo.astro +11 -3
  92. package/src/components/layout/NavTree.astro +19 -5
  93. package/src/components/layout/PageActions.astro +25 -10
  94. package/src/components/layout/PageLayout.astro +85 -9
  95. package/src/components/layout/Pagination.astro +10 -4
  96. package/src/components/layout/ReferenceLayout.astro +20 -2
  97. package/src/components/layout/RootLayout.astro +142 -12
  98. package/src/components/layout/Search.astro +117 -27
  99. package/src/components/layout/search/algolia.ts +11 -2
  100. package/src/components/layout/search/endpoint.ts +11 -5
  101. package/src/components/layout/search/orama-cloud.ts +8 -2
  102. package/src/components/layout/search/types.ts +5 -1
  103. package/src/components/layout/search/typesense.ts +4 -1
  104. package/src/components/layout/toc-element.ts +1 -1
  105. package/src/components/openapi/ApiTagOperations.astro +2 -1
  106. package/src/components/openapi/Operation.astro +47 -40
  107. package/src/components/openapi/RequestPanel.astro +1 -1
  108. package/src/components/openapi/helpers.ts +71 -3
  109. package/src/components/openapi/panel.ts +1 -1
  110. package/src/core/base-path.ts +24 -0
  111. package/src/core/builtin-tags.ts +2 -0
  112. package/src/core/config-input.ts +48 -2
  113. package/src/core/config.ts +3 -2
  114. package/src/core/data.ts +4 -0
  115. package/src/core/frontmatter.ts +7 -0
  116. package/src/core/graph.ts +15 -5
  117. package/src/core/i18n-ui.ts +54 -0
  118. package/src/core/i18n.ts +16 -8
  119. package/src/core/last-modified.ts +13 -6
  120. package/src/core/links.ts +32 -8
  121. package/src/core/navigation.ts +29 -4
  122. package/src/core/package-json.ts +17 -2
  123. package/src/core/project-graph.ts +15 -6
  124. package/src/core/schema.ts +71 -2
  125. package/src/core/sources/assets.ts +6 -1
  126. package/src/core/sources/filesystem.ts +4 -0
  127. package/src/core/sources/mdx-remote.ts +23 -14
  128. package/src/core/sources/normalize.ts +152 -50
  129. package/src/core/sources/notion.ts +8 -8
  130. package/src/core/ui-packs/ar.ts +8 -0
  131. package/src/core/ui-packs/bg.ts +8 -0
  132. package/src/core/ui-packs/bn.ts +8 -0
  133. package/src/core/ui-packs/ca.ts +8 -0
  134. package/src/core/ui-packs/cs.ts +8 -0
  135. package/src/core/ui-packs/da.ts +8 -0
  136. package/src/core/ui-packs/de.ts +8 -0
  137. package/src/core/ui-packs/el.ts +8 -0
  138. package/src/core/ui-packs/es.ts +8 -0
  139. package/src/core/ui-packs/fa.ts +8 -0
  140. package/src/core/ui-packs/fi.ts +8 -0
  141. package/src/core/ui-packs/fr.ts +9 -1
  142. package/src/core/ui-packs/he.ts +8 -0
  143. package/src/core/ui-packs/hi.ts +8 -0
  144. package/src/core/ui-packs/hr.ts +8 -0
  145. package/src/core/ui-packs/hu.ts +8 -0
  146. package/src/core/ui-packs/id.ts +8 -0
  147. package/src/core/ui-packs/it.ts +8 -0
  148. package/src/core/ui-packs/ja.ts +8 -0
  149. package/src/core/ui-packs/ko.ts +8 -0
  150. package/src/core/ui-packs/nl.ts +8 -0
  151. package/src/core/ui-packs/no.ts +8 -0
  152. package/src/core/ui-packs/pl.ts +8 -0
  153. package/src/core/ui-packs/pt-br.ts +8 -0
  154. package/src/core/ui-packs/pt.ts +8 -0
  155. package/src/core/ui-packs/ro.ts +8 -0
  156. package/src/core/ui-packs/ru.ts +8 -0
  157. package/src/core/ui-packs/sk.ts +8 -0
  158. package/src/core/ui-packs/sr.ts +8 -0
  159. package/src/core/ui-packs/sv.ts +8 -0
  160. package/src/core/ui-packs/th.ts +8 -0
  161. package/src/core/ui-packs/tr.ts +8 -0
  162. package/src/core/ui-packs/uk.ts +8 -0
  163. package/src/core/ui-packs/vi.ts +8 -0
  164. package/src/core/ui-packs/zh-tw.ts +8 -0
  165. package/src/core/ui-packs/zh.ts +8 -0
  166. package/src/deploy/adapter-output.ts +18 -8
  167. package/src/deploy/redirects.ts +7 -2
  168. package/src/deploy/sitemap.ts +53 -11
  169. package/src/index.ts +5 -0
  170. package/src/markdown/base-links.ts +10 -8
  171. package/src/markdown/index.ts +15 -3
  172. package/src/markdown/inline-code.ts +7 -2
  173. package/src/markdown/package-commands.ts +10 -4
  174. package/src/og/card.ts +4 -2
  175. package/src/og/dimensions.ts +12 -0
  176. package/src/openapi/model.ts +12 -4
  177. package/src/openapi/parse.ts +21 -0
  178. package/src/openapi/references.ts +38 -8
  179. package/src/openapi/render-mdx.ts +62 -1
  180. package/src/openapi/source.ts +59 -10
  181. package/src/registry/eject.ts +184 -12
  182. package/src/registry/registry.ts +0 -3
  183. package/src/search/documents.ts +34 -2
  184. package/src/seo/jsonld.ts +20 -13
  185. package/src/seo/x-handle.ts +18 -0
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import { withBase } from "../islands/base-path.ts";
3
3
  import type { ComponentOverride } from "../../core/define-components.ts";
4
+ import { EN_UI } from "../../core/i18n-ui.ts";
4
5
  import type { UIStrings } from "../../core/i18n-ui.ts";
5
6
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
6
7
  import { GITHUB_MARK } from "../github-mark.ts";
@@ -37,6 +38,8 @@ interface Props {
37
38
  hasDrawer?: boolean;
38
39
  searchStrings?: UIStrings["search"];
39
40
  switcherStrings?: UIStrings["languageSwitcher"];
41
+ /** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
42
+ navStrings?: UIStrings["nav"];
40
43
  localeSwitch?: LocaleSwitchOption[];
41
44
  /** Active locale for per-language search filtering. */
42
45
  searchLocale?: string;
@@ -59,11 +62,17 @@ const {
59
62
  hasDrawer = true,
60
63
  searchStrings,
61
64
  switcherStrings,
65
+ navStrings,
62
66
  localeSwitch,
63
67
  searchLocale,
64
68
  layout = {},
65
69
  } = Astro.props;
66
70
 
71
+ // Merge over the English defaults so a label missing from a translation (or
72
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
73
+ // blank — the PageActions pattern.
74
+ const n = { ...EN_UI.nav, ...navStrings };
75
+
67
76
  const LogoSlot = resolveSlot(layout.Logo, Logo);
68
77
  const SearchSlot = resolveSlot(layout.Search, Search);
69
78
 
@@ -109,7 +118,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
109
118
  {
110
119
  showNavToggle && (
111
120
  <button
112
- aria-label="Toggle navigation"
121
+ aria-label={n.toggleNavigation}
113
122
  class={`${iconButton} lg:hidden`}
114
123
  data-blume-nav-toggle
115
124
  type="button"
@@ -130,7 +139,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
130
139
  }
131
140
  {
132
141
  navigation.tabs.length > 0 && (
133
- <nav aria-label="Sections" class={tabsNavClass}>
142
+ <nav aria-label={n.sections} class={tabsNavClass}>
134
143
  {navigation.tabs.map((tab) => (
135
144
  <a
136
145
  aria-current={
@@ -172,7 +181,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
172
181
  {
173
182
  navigation.repoUrl && (
174
183
  <a
175
- aria-label="GitHub repository"
184
+ aria-label={n.githubRepository}
176
185
  class={iconButton}
177
186
  href={navigation.repoUrl}
178
187
  rel="noreferrer"
@@ -191,7 +200,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
191
200
  )
192
201
  }
193
202
  <button
194
- aria-label="Toggle color theme"
203
+ aria-label={n.toggleTheme}
195
204
  class={iconButton}
196
205
  data-blume-theme-toggle
197
206
  type="button"
@@ -43,11 +43,19 @@ const brandText = logo?.text ?? site.title;
43
43
  !logoSvg &&
44
44
  logoLight &&
45
45
  (logoLight === logoDark ? (
46
- <img alt={logoAlt} class="h-5 w-auto" src={logoLight} />
46
+ <img alt={logoAlt} class="h-5 w-auto" src={withBase(logoLight)} />
47
47
  ) : (
48
48
  <>
49
- <img alt={logoAlt} class="h-5 w-auto dark:hidden" src={logoLight} />
50
- <img alt={logoAlt} class="hidden h-5 w-auto dark:block" src={logoDark} />
49
+ <img
50
+ alt={logoAlt}
51
+ class="h-5 w-auto dark:hidden"
52
+ src={withBase(logoLight)}
53
+ />
54
+ <img
55
+ alt={logoAlt}
56
+ class="hidden h-5 w-auto dark:block"
57
+ src={withBase(logoDark)}
58
+ />
51
59
  </>
52
60
  ))
53
61
  }
@@ -1,4 +1,6 @@
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";
4
6
  import { withBase } from "../islands/base-path.ts";
@@ -22,6 +24,8 @@ interface Props {
22
24
  idPrefix?: string;
23
25
  /** True only for the top-level call; renders the `<blume-nav>` panel stack. */
24
26
  root?: boolean;
27
+ /** Localized labels ("Back", the deprecated badge); English when omitted. */
28
+ strings?: UIStrings["nav"];
25
29
  }
26
30
 
27
31
  const {
@@ -30,12 +34,18 @@ const {
30
34
  depth = 0,
31
35
  idPrefix = "n",
32
36
  root = depth === 0,
37
+ strings,
33
38
  } = Astro.props;
34
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
+
35
45
  const badgeBase =
36
46
  "shrink-0 rounded-full px-1.5 py-0.5 font-medium text-[0.65rem] leading-none";
37
47
  const badgeClass = `${badgeBase} bg-muted text-muted-foreground`;
38
- // 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;
39
49
  // every other badge keeps the neutral style.
40
50
  const METHOD_BADGE: Record<string, string> = {
41
51
  DELETE: "bg-red-500/15 text-red-700 dark:text-red-300",
@@ -114,18 +124,19 @@ const initialId =
114
124
  idPrefix={idPrefix}
115
125
  items={items}
116
126
  root={false}
127
+ strings={n}
117
128
  />
118
129
  </div>
119
130
  {panels.map((panel) => (
120
131
  <div data-nav-panel={panel.id} hidden={panel.id !== initialId}>
121
132
  <div class="mb-3 flex items-center gap-1.5">
122
133
  <button
123
- aria-label="Back"
134
+ aria-label={n.back}
124
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"
125
136
  data-nav-back={panel.parentId}
126
137
  type="button"
127
138
  >
128
- <Icon name="arrow-left" size={16} />
139
+ <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
129
140
  </button>
130
141
  {panel.route ? (
131
142
  <a
@@ -147,6 +158,7 @@ const initialId =
147
158
  idPrefix={panel.id}
148
159
  items={panel.children}
149
160
  root={false}
161
+ strings={n}
150
162
  />
151
163
  </div>
152
164
  ))}
@@ -173,7 +185,7 @@ const initialId =
173
185
  <span class="flex-1 truncate">{item.label}</span>
174
186
  {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
175
187
  {item.deprecated && (
176
- <span class={deprecatedClass}>deprecated</span>
188
+ <span class={deprecatedClass}>{n.deprecated}</span>
177
189
  )}
178
190
  </span>
179
191
  </a>
@@ -207,7 +219,7 @@ const initialId =
207
219
  <span class="flex-1 truncate">{item.label}</span>
208
220
  {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
209
221
  <Icon
210
- class="shrink-0 text-muted-foreground"
222
+ class="shrink-0 text-muted-foreground rtl:-scale-x-100"
211
223
  name="chevron-right"
212
224
  size={14}
213
225
  />
@@ -272,6 +284,7 @@ const initialId =
272
284
  idPrefix={id}
273
285
  items={item.children}
274
286
  root={false}
287
+ strings={n}
275
288
  />
276
289
  </div>
277
290
  </details>
@@ -324,6 +337,7 @@ const initialId =
324
337
  idPrefix={id}
325
338
  items={item.children}
326
339
  root={false}
340
+ strings={n}
327
341
  />
328
342
  </div>
329
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
  });
@@ -25,6 +25,12 @@ 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 {
29
+ OG_IMAGE_HEIGHT,
30
+ OG_IMAGE_TYPE,
31
+ OG_IMAGE_WIDTH,
32
+ } from "../../og/dimensions.ts";
33
+ import { normalizeXHandle } from "../../seo/x-handle.ts";
28
34
  import { withBase } from "../islands/base-path.ts";
29
35
  import "blume:theme";
30
36
  import Analytics from "./Analytics.astro";
@@ -64,6 +70,11 @@ interface Props {
64
70
  /** SEO overrides; a marketing page often sets its own canonical/og image. */
65
71
  ogImage?: string | null;
66
72
  canonical?: string | null;
73
+ /**
74
+ * X (Twitter) attribution (`data.config.x`): the site's account and an author
75
+ * handle, emitted as `twitter:site`/`twitter:creator`.
76
+ */
77
+ x?: { creator?: string; handle?: string };
67
78
  noindex?: boolean;
68
79
  /** Active locale + direction for `<html lang>`/`<html dir>`. */
69
80
  locale?: string;
@@ -97,6 +108,7 @@ const {
97
108
  ogEnabled,
98
109
  ogImage,
99
110
  canonical,
111
+ x,
100
112
  noindex,
101
113
  locale = "en",
102
114
  dir = "ltr",
@@ -110,6 +122,10 @@ const clientDataJson = clientData
110
122
  : null;
111
123
 
112
124
  const strings = ui ?? EN_UI;
125
+ // Merge over the English defaults so a label missing from a translation (or
126
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
127
+ // blank — the PageActions pattern.
128
+ const navStrings = { ...EN_UI.nav, ...strings.nav };
113
129
  // Filter search to the active language only when the site is multi-locale.
114
130
  const searchLocale =
115
131
  localeSwitch && localeSwitch.length > 1 ? locale : undefined;
@@ -125,20 +141,33 @@ const ogSlug = route === "/" ? "index" : route.slice(1);
125
141
  // Absolute URLs carry the deployment base too (`site + base + route`), since the
126
142
  // page is served under it. `withBase` handles a root base as a no-op.
127
143
  const basedRoute = withBase(route);
144
+ // The schema accepts a trailing-slash site URL (`https://docs.example.com/`);
145
+ // strip it before joining with the root-relative route so canonical/og URLs
146
+ // don't come out double-slashed — the catch-all strips it the same way.
147
+ const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
128
148
  const resolvedCanonical =
129
149
  canonical ??
130
- (siteUrl ? `${siteUrl}${basedRoute === "/" ? "" : basedRoute}` : null);
150
+ (siteBase ? `${siteBase}${basedRoute === "/" ? "" : basedRoute}` : null);
131
151
  // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
132
152
  // `public/`) is resolved against the site URL so crawlers get an absolute
133
153
  // `og:image`; an already-absolute URL passes through untouched. Otherwise fall
134
154
  // back to the generated OG card for this route.
135
155
  const absolutizeOgImage = (value: string): string =>
136
- value.startsWith("/") && siteUrl ? `${siteUrl}${withBase(value)}` : value;
156
+ value.startsWith("/") && siteBase ? `${siteBase}${withBase(value)}` : value;
137
157
  const resolvedOgImage = ogImage
138
158
  ? absolutizeOgImage(ogImage)
139
- : ogEnabled && siteUrl
140
- ? `${siteUrl}${withBase(`/og/${ogSlug}.png`)}`
159
+ : ogEnabled && siteBase
160
+ ? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
141
161
  : null;
162
+ // Only the generated card has a known size and format, so its dimensions are
163
+ // declared for crawlers; a user-supplied `ogImage` could be any image.
164
+ const ogGenerated = !ogImage && Boolean(resolvedOgImage);
165
+ // A page with no image still gets a card on X — the compact `summary` variant
166
+ // — rather than rendering as a bare link.
167
+ const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
168
+ // A custom page passes these in by hand, so normalize rather than trust the `@`.
169
+ const xSite = normalizeXHandle(x?.handle);
170
+ const xCreator = normalizeXHandle(x?.creator);
142
171
 
143
172
  const initialThemeScript = themeInitScript(themeMode);
144
173
  const bannerScript = banner?.dismissible
@@ -158,17 +187,35 @@ const bannerScript = banner?.dismissible
158
187
  {noindex && <meta content="noindex" name="robots" />}
159
188
  {description && <meta content={description} name="description" />}
160
189
  <meta content="website" property="og:type" />
190
+ <meta content={site.title} property="og:site_name" />
161
191
  <meta content={pageTitle} property="og:title" />
162
192
  {description && <meta content={description} property="og:description" />}
193
+ {resolvedCanonical && <meta content={resolvedCanonical} property="og:url" />}
163
194
  {
164
195
  resolvedOgImage && (
165
196
  <>
166
197
  <meta content={resolvedOgImage} property="og:image" />
198
+ {ogGenerated && (
199
+ <>
200
+ <meta content={OG_IMAGE_TYPE} property="og:image:type" />
201
+ <meta content={String(OG_IMAGE_WIDTH)} property="og:image:width" />
202
+ <meta
203
+ content={String(OG_IMAGE_HEIGHT)}
204
+ property="og:image:height"
205
+ />
206
+ <meta content={pageTitle} property="og:image:alt" />
207
+ </>
208
+ )}
167
209
  <meta content={resolvedOgImage} name="twitter:image" />
168
- <meta content="summary_large_image" name="twitter:card" />
210
+ {ogGenerated && <meta content={pageTitle} name="twitter:image:alt" />}
169
211
  </>
170
212
  )
171
213
  }
214
+ <meta content={twitterCard} name="twitter:card" />
215
+ <meta content={pageTitle} name="twitter:title" />
216
+ {description && <meta content={description} name="twitter:description" />}
217
+ {xSite && <meta content={xSite} name="twitter:site" />}
218
+ {xCreator && <meta content={xCreator} name="twitter:creator" />}
172
219
  {bannerScript && <script is:inline set:html={bannerScript} />}
173
220
  <script is:inline set:html={initialThemeScript} />
174
221
  <Analytics analytics={analytics} />
@@ -178,12 +225,13 @@ const bannerScript = banner?.dismissible
178
225
  class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
179
226
  href="#blume-content">{strings.page.skipToContent}</a
180
227
  >
181
- <Banner banner={banner} />
228
+ <Banner banner={banner} strings={strings.banner} />
182
229
  <Header
183
230
  askEnabled={askEnabled}
184
231
  hasSidebar={false}
185
232
  localeSwitch={localeSwitch}
186
233
  logo={logo}
234
+ navStrings={navStrings}
187
235
  navigation={navigation}
188
236
  route={route}
189
237
  searchEnabled={searchEnabled}
@@ -205,10 +253,11 @@ const bannerScript = banner?.dismissible
205
253
  navigation.tabs.length > 0 && (
206
254
  <>
207
255
  <aside
208
- aria-label="Navigation"
256
+ aria-label={navStrings.navigation}
209
257
  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"
258
+ data-blume-nav-drawer
210
259
  >
211
- <nav aria-label="Sections">
260
+ <nav aria-label={navStrings.sections}>
212
261
  <ul class="m-0 list-none p-0">
213
262
  {navigation.tabs.map((tab) => (
214
263
  <li>
@@ -230,7 +279,7 @@ const bannerScript = banner?.dismissible
230
279
  </nav>
231
280
  </aside>
232
281
  <button
233
- aria-label="Close navigation"
282
+ aria-label={navStrings.closeNavigation}
234
283
  class="fixed inset-0 z-[30] hidden cursor-pointer border-0 bg-black/40 [:where([data-blume-nav-open])_&]:block lg:hidden"
235
284
  data-blume-nav-toggle
236
285
  type="button"
@@ -252,6 +301,33 @@ const bannerScript = banner?.dismissible
252
301
  // Dev-only: friendly hint after a React island hydration mismatch;
253
302
  // tree-shaken out of production builds.
254
303
  import "./hydration-hint.ts";
304
+
305
+ // The closed tabs drawer is only translated off-canvas, so its links
306
+ // would stay in the tab order. Mirror the header's `data-blume-nav-open`
307
+ // toggle into `inert`/`aria-hidden` below `lg` (64rem), matching the
308
+ // breakpoint where the drawer is display-hidden anyway.
309
+ const drawer = document.querySelector<HTMLElement>(
310
+ "[data-blume-nav-drawer]"
311
+ );
312
+ if (drawer) {
313
+ const desktop = window.matchMedia("(min-width: 64rem)");
314
+ const syncDrawer = () => {
315
+ const hidden =
316
+ !desktop.matches &&
317
+ !document.documentElement.hasAttribute("data-blume-nav-open");
318
+ drawer.inert = hidden;
319
+ if (hidden) {
320
+ drawer.setAttribute("aria-hidden", "true");
321
+ } else {
322
+ drawer.removeAttribute("aria-hidden");
323
+ }
324
+ };
325
+ syncDrawer();
326
+ desktop.addEventListener("change", syncDrawer);
327
+ new MutationObserver(syncDrawer).observe(document.documentElement, {
328
+ attributeFilter: ["data-blume-nav-open"],
329
+ });
330
+ }
255
331
  </script>
256
332
  </body>
257
333
  </html>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { withBase } from "../islands/base-path.ts";
3
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
4
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
5
  import type { FlatPage } from "./nav-utils.ts";
5
6
  import Icon from "../Icon.astro";
@@ -9,17 +10,22 @@ interface Props {
9
10
  prev: FlatPage | null;
10
11
  /** Next page in reading order, or `null` at the end. */
11
12
  next: FlatPage | null;
12
- /** Localized page-level strings (`previous`, `next`). */
13
+ /** Localized page-level strings (`previous`, `next`, the landmark label). */
13
14
  strings: UIStrings["page"];
14
15
  }
15
16
 
16
17
  const { prev, next, strings } = Astro.props;
18
+
19
+ // Merge over the English defaults so a label missing from a translation (or
20
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
21
+ // blank — the PageActions pattern.
22
+ const s = { ...EN_UI.page, ...strings };
17
23
  ---
18
24
 
19
25
  {
20
26
  (prev || next) && (
21
27
  <nav
22
- aria-label="Pagination"
28
+ aria-label={s.pagination}
23
29
  class="mx-auto mt-12 flex max-w-[42rem] justify-between gap-4 border-border border-t pt-6 max-md:flex-col"
24
30
  >
25
31
  {prev ? (
@@ -30,7 +36,7 @@ const { prev, next, strings } = Astro.props;
30
36
  <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
31
37
  <span>
32
38
  <span class="block text-muted-foreground text-xs max-md:hidden">
33
- {strings.previous}
39
+ {s.previous}
34
40
  </span>
35
41
  <span class="block font-medium">{prev.label}</span>
36
42
  </span>
@@ -45,7 +51,7 @@ const { prev, next, strings } = Astro.props;
45
51
  >
46
52
  <span>
47
53
  <span class="block text-muted-foreground text-xs max-md:hidden">
48
- {strings.next}
54
+ {s.next}
49
55
  </span>
50
56
  <span class="block font-medium">{next.label}</span>
51
57
  </span>
@@ -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,12 @@ interface Props {
50
52
  fontCssVars?: string[];
51
53
  searchEnabled: boolean;
52
54
  pageTitle: string;
55
+ /** Active locale code for `<html lang>` (defaults to `en`). */
56
+ locale?: string;
57
+ /** Text direction for `<html dir>` (defaults to `ltr`). */
58
+ dir?: "ltr" | "rtl";
59
+ /** Resolved UI dictionary; English baseline when omitted. */
60
+ ui?: UIStrings;
53
61
  }
54
62
 
55
63
  const {
@@ -65,8 +73,13 @@ const {
65
73
  fontCssVars,
66
74
  searchEnabled,
67
75
  pageTitle,
76
+ locale = "en",
77
+ dir = "ltr",
78
+ ui,
68
79
  } = Astro.props;
69
80
 
81
+ const strings = ui ?? EN_UI;
82
+
70
83
  // Set the theme before paint so the navbar never flashes the wrong colors
71
84
  // (mirrors RootLayout's pre-paint script).
72
85
  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);})();`;
@@ -78,7 +91,7 @@ const bannerScript = banner?.dismissible
78
91
  ---
79
92
 
80
93
  <!doctype html>
81
- <html lang="en">
94
+ <html dir={dir} lang={locale}>
82
95
  <head>
83
96
  <meta charset="utf-8" />
84
97
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -90,7 +103,11 @@ const bannerScript = banner?.dismissible
90
103
  <Analytics analytics={analytics} />
91
104
  </head>
92
105
  <body class="bg-background font-sans text-foreground antialiased">
93
- <Banner banner={banner} />
106
+ <a
107
+ class="absolute start-[-999px] top-0 z-[100] bg-accent px-4 py-2 text-accent-foreground focus:start-0"
108
+ href="#blume-content">{strings.page.skipToContent}</a
109
+ >
110
+ <Banner banner={banner} strings={strings.banner} />
94
111
  <Header
95
112
  hasDrawer={false}
96
113
  hasSidebar={false}
@@ -102,6 +119,7 @@ const bannerScript = banner?.dismissible
102
119
  />
103
120
  <div
104
121
  class="h-[calc(100dvh-4rem)] overflow-auto border-border border-t [&>[data-scalar-client]]:h-full"
122
+ id="blume-content"
105
123
  >
106
124
  <slot />
107
125
  </div>