blume 0.8.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 (80) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/cli/index.js +373 -28
  3. package/dist/cli/index.js.map +51 -50
  4. package/dist/types/core/config-input.d.ts +11 -0
  5. package/dist/types/core/data.d.ts +5 -0
  6. package/dist/types/core/i18n-ui.d.ts +50 -0
  7. package/dist/types/core/schema.d.ts +77 -1
  8. package/dist/types/seo/x-handle.d.ts +12 -0
  9. package/docs/advanced/api-reference.mdx +1 -1
  10. package/docs/advanced/changelog.mdx +1 -1
  11. package/docs/configuration/export.mdx +1 -1
  12. package/docs/configuration/seo.mdx +33 -3
  13. package/docs/content/components.mdx +1 -1
  14. package/docs/content/syntax.mdx +2 -2
  15. package/docs/index.mdx +1 -1
  16. package/docs/reference/cli.mdx +1 -1
  17. package/docs/reference/frontmatter.mdx +1 -1
  18. package/package.json +9 -1
  19. package/src/ai/mcp/tools.ts +1 -1
  20. package/src/astro/generate.ts +1 -0
  21. package/src/astro/templates.ts +51 -7
  22. package/src/cli/commands/build.ts +25 -8
  23. package/src/cli/internal-error.ts +9 -4
  24. package/src/cli/prepare.ts +3 -2
  25. package/src/components/Icon.astro +2 -1
  26. package/src/components/islands/ask-ai.tsx +30 -5
  27. package/src/components/layout/Breadcrumbs.astro +11 -2
  28. package/src/components/layout/NavTree.astro +2 -2
  29. package/src/components/layout/PageLayout.astro +40 -1
  30. package/src/components/layout/Pagination.astro +10 -4
  31. package/src/components/layout/ReferenceLayout.astro +12 -1
  32. package/src/components/layout/RootLayout.astro +75 -3
  33. package/src/components/layout/Search.astro +23 -5
  34. package/src/core/config-input.ts +11 -0
  35. package/src/core/data.ts +2 -0
  36. package/src/core/frontmatter.ts +7 -0
  37. package/src/core/i18n-ui.ts +9 -0
  38. package/src/core/i18n.ts +16 -8
  39. package/src/core/schema.ts +35 -0
  40. package/src/core/ui-packs/ar.ts +7 -0
  41. package/src/core/ui-packs/bg.ts +7 -0
  42. package/src/core/ui-packs/bn.ts +7 -0
  43. package/src/core/ui-packs/ca.ts +7 -0
  44. package/src/core/ui-packs/cs.ts +7 -0
  45. package/src/core/ui-packs/da.ts +7 -0
  46. package/src/core/ui-packs/de.ts +7 -0
  47. package/src/core/ui-packs/el.ts +7 -0
  48. package/src/core/ui-packs/es.ts +7 -0
  49. package/src/core/ui-packs/fa.ts +7 -0
  50. package/src/core/ui-packs/fi.ts +7 -0
  51. package/src/core/ui-packs/fr.ts +7 -0
  52. package/src/core/ui-packs/he.ts +7 -0
  53. package/src/core/ui-packs/hi.ts +7 -0
  54. package/src/core/ui-packs/hr.ts +7 -0
  55. package/src/core/ui-packs/hu.ts +7 -0
  56. package/src/core/ui-packs/id.ts +7 -0
  57. package/src/core/ui-packs/it.ts +7 -0
  58. package/src/core/ui-packs/ja.ts +7 -0
  59. package/src/core/ui-packs/ko.ts +7 -0
  60. package/src/core/ui-packs/nl.ts +7 -0
  61. package/src/core/ui-packs/no.ts +7 -0
  62. package/src/core/ui-packs/pl.ts +7 -0
  63. package/src/core/ui-packs/pt-br.ts +7 -0
  64. package/src/core/ui-packs/pt.ts +7 -0
  65. package/src/core/ui-packs/ro.ts +7 -0
  66. package/src/core/ui-packs/ru.ts +7 -0
  67. package/src/core/ui-packs/sk.ts +7 -0
  68. package/src/core/ui-packs/sr.ts +7 -0
  69. package/src/core/ui-packs/sv.ts +7 -0
  70. package/src/core/ui-packs/th.ts +7 -0
  71. package/src/core/ui-packs/tr.ts +7 -0
  72. package/src/core/ui-packs/uk.ts +7 -0
  73. package/src/core/ui-packs/vi.ts +7 -0
  74. package/src/core/ui-packs/zh-tw.ts +7 -0
  75. package/src/core/ui-packs/zh.ts +7 -0
  76. package/src/og/card.ts +4 -2
  77. package/src/og/dimensions.ts +12 -0
  78. package/src/openapi/render-mdx.ts +62 -1
  79. package/src/seo/jsonld.ts +7 -1
  80. package/src/seo/x-handle.ts +18 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "0.8.0",
3
+ "version": "1.0.0",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -128,6 +128,8 @@
128
128
  "@ai-sdk/openai-compatible": "^1.0.41",
129
129
  "@astrojs/cloudflare": "^14.0.0",
130
130
  "@astrojs/netlify": "^8.0.0",
131
+ "@astrojs/svelte": "^9.0.0",
132
+ "@astrojs/vue": "^7.0.0",
131
133
  "@mixedbread/sdk": "^0.76.0",
132
134
  "@notionhq/client": "^2.2.15",
133
135
  "@openrouter/ai-sdk-provider": "^1.5.4",
@@ -147,6 +149,12 @@
147
149
  "@astrojs/netlify": {
148
150
  "optional": true
149
151
  },
152
+ "@astrojs/svelte": {
153
+ "optional": true
154
+ },
155
+ "@astrojs/vue": {
156
+ "optional": true
157
+ },
150
158
  "@mixedbread/sdk": {
151
159
  "optional": true
152
160
  },
@@ -26,7 +26,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
26
26
  {
27
27
  annotations: READ_ONLY,
28
28
  description:
29
- "Fetch a single documentation page as its original Markdown source (frontmatter included). Pass a route from `search_docs` or `list_pages`, e.g. `/guides/install`.",
29
+ "Fetch a single documentation page as agent-optimized Markdown (frontmatter included, components downleveled to plain Markdown). Pass a route from `search_docs` or `list_pages`, e.g. `/guides/install`.",
30
30
  name: "get_page",
31
31
  title: "Get page Markdown",
32
32
  },
@@ -881,6 +881,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
881
881
  theme: config.theme,
882
882
  title: config.title,
883
883
  toc: config.toc,
884
+ x: config.seo.x,
884
885
  },
885
886
  feeds: buildRssFeeds(project).map((feed) => ({
886
887
  href: feed.path,
@@ -187,18 +187,31 @@ const renderUserAliases = (
187
187
  const astroOutDir = (context: ProjectContext): string =>
188
188
  context.distDir ?? `${context.root}/dist`;
189
189
 
190
+ /**
191
+ * Excludes Vite's pre-bundled dep cache from @vitejs/plugin-react. Astro's
192
+ * react() replaces the plugin's default `/node_modules/` exclude with just
193
+ * `/\.astro$/`, so without this Babel re-parses every optimized dep chunk
194
+ * served from `.vite/deps` — a 500KB+ vendor bundle per chunk, re-done on each
195
+ * re-optimization. A blanket `/node_modules/` exclude would instead switch the
196
+ * React Compiler off for Blume's own components in published installs (they
197
+ * resolve under `node_modules/blume/src`, and exclude beats include in the
198
+ * plugin's filter), so only the pre-bundle cache is excluded.
199
+ */
200
+ const REACT_EXCLUDE = String.raw`exclude: [/\/node_modules\/\.vite\//]`;
201
+
190
202
  /**
191
203
  * The `react()` integration call. When `compilerPath` is set (the resolved
192
204
  * absolute path to `babel-plugin-react-compiler`), react() carries the compiler
193
205
  * as the first babel plugin — an absolute path, because @vitejs/plugin-react
194
206
  * resolves babel plugins from the *project* root, not `.blume/`, so a bare
195
207
  * specifier wouldn't resolve in a user project. `target: "19"` matches Blume's
196
- * React pin. `null`/`undefined` (compiler off or unresolvable) emits bare react().
208
+ * React pin. `null`/`undefined` (compiler off or unresolvable) omits the babel
209
+ * block. Both variants carry the pre-bundle exclude above.
197
210
  */
198
211
  const reactIntegration = (compilerPath: string | null | undefined): string =>
199
212
  compilerPath
200
- ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] } })`
201
- : "react()";
213
+ ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] }, ${REACT_EXCLUDE} })`
214
+ : `react({ ${REACT_EXCLUDE} })`;
202
215
 
203
216
  export const astroConfigTemplate = (options: {
204
217
  context: ProjectContext;
@@ -1023,12 +1036,24 @@ import data from ${JSON.stringify(options.dataImport)};
1023
1036
  export const prerender = true;
1024
1037
 
1025
1038
  const configuration = ${JSON.stringify(options.configuration, null, 2)};
1039
+
1040
+ // The reference is an unlocalized route, so its chrome renders in the default
1041
+ // locale's language and direction (\`data.ui\` is the default locale's resolved
1042
+ // dictionary), mirroring the changelog index's locale wiring.
1043
+ const i18n = data.config.i18n;
1044
+ const localeMeta = i18n
1045
+ ? i18n.locales.find((l) => l.code === i18n.defaultLocale)
1046
+ : null;
1047
+ const dir = localeMeta?.dir ?? "ltr";
1048
+ const htmlLang = i18n ? i18n.defaultLocale : "en";
1026
1049
  ---
1027
1050
 
1028
1051
  <ReferenceLayout
1029
1052
  analytics={data.config.analytics}
1030
1053
  banner={data.config.banner}
1054
+ dir={dir}
1031
1055
  fontCssVars={data.fontCssVars}
1056
+ locale={htmlLang}
1032
1057
  logo={data.config.logo}
1033
1058
  favicon={data.config.favicon}
1034
1059
  appleIcon={data.config.appleIcon}
@@ -1202,6 +1227,13 @@ const ogRel = seo.image ?? ogPath;
1202
1227
  // an external URL, which passes through verbatim (mirrors PageLayout).
1203
1228
  const ogImage =
1204
1229
  ogRel && base && ogRel.startsWith("/") ? \`\${base}\${withBase(ogRel)}\` : ogRel;
1230
+ // Blume's generated card has known dimensions the layout can declare; a user's
1231
+ // \`seo.image\` could be any size or format, so it gets none.
1232
+ const ogGenerated = !seo.image && Boolean(ogPath);
1233
+
1234
+ // X attribution: the site's account, plus a creator the page can claim for
1235
+ // itself (a guest post crediting its own author) over the configured default.
1236
+ const x = { ...data.config.x, ...(seo.x?.creator ? { creator: seo.x.creator } : {}) };
1205
1237
 
1206
1238
  const basedRoute = withBase(route);
1207
1239
  const canonical =
@@ -1298,6 +1330,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1298
1330
  searchEnabled={data.config.search.enabled}
1299
1331
  indexable={indexable}
1300
1332
  ogImage={ogImage}
1333
+ ogGenerated={ogGenerated}
1334
+ x={x}
1301
1335
  canonical={canonical}
1302
1336
  editUrl={editUrl}
1303
1337
  feedback={data.config.feedback}
@@ -1341,7 +1375,7 @@ export const changelogIndexTemplate = (options: {
1341
1375
  ? '\n <AskAI slot="ask" strings={data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
1342
1376
  : "";
1343
1377
  const clientData = options.needsReact
1344
- ? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: data.config.title + " changelog" } }}'
1378
+ ? '\n clientData={{ config: data.config, navigation: data.navigation, page: { route: "/changelog", title: pageTitle } }}'
1345
1379
  : "";
1346
1380
  // Staged sources (e.g. GitHub Releases) render through a parallel collection,
1347
1381
  // so fold them in alongside filesystem entries when one exists.
@@ -1484,6 +1518,15 @@ const localeMeta = i18n
1484
1518
  const dir = localeMeta?.dir ?? "ltr";
1485
1519
  const htmlLang = i18n ? i18n.defaultLocale : "en";
1486
1520
 
1521
+ // The page chrome (h1, title, description) comes from the same translatable
1522
+ // \`changelog\` group as the reveal button; optional chaining tolerates a
1523
+ // not-yet-regenerated data snapshot from before these keys existed.
1524
+ const changelogTitle = data.ui.changelog?.title ?? "Changelog";
1525
+ const changelogDescription =
1526
+ data.ui.changelog?.description ??
1527
+ "Product updates, new features, and fixes from every release.";
1528
+ const pageTitle = data.config.title + " " + changelogTitle;
1529
+
1487
1530
  const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1488
1531
  ---
1489
1532
 
@@ -1503,8 +1546,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1503
1546
  dir={dir}
1504
1547
  ui={data.ui}
1505
1548
  page={{
1506
- title: data.config.title + " changelog",
1507
- description: "Product updates and release notes.",
1549
+ title: pageTitle,
1550
+ description: changelogDescription,
1508
1551
  route: "/changelog",
1509
1552
  }}
1510
1553
  headings={headings}
@@ -1515,6 +1558,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1515
1558
  searchEnabled={data.config.search.enabled}
1516
1559
  indexable={true}
1517
1560
  ogImage={null}
1561
+ x={data.config.x}
1518
1562
  canonical={canonical}
1519
1563
  askEnabled={${options.askEnabled}}
1520
1564
  exportPdf={${options.exportPdf}}
@@ -1524,7 +1568,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1524
1568
  noindex={false}
1525
1569
  structuredDataEnabled={data.config.structuredData}
1526
1570
  >${askSlot}
1527
- <h1>Changelog</h1>
1571
+ <h1>{changelogTitle}</h1>
1528
1572
  {
1529
1573
  items.length === 0 ? (
1530
1574
  <p>No changelog entries yet.</p>
@@ -64,7 +64,10 @@ const validateBudgetFlags = (args: BudgetArgs): void => {
64
64
  /**
65
65
  * Emit platform redirect files for a static build (adapters wire redirects
66
66
  * natively). Always writes the manifest; writes `_redirects`/`vercel.json` only
67
- * when the user hasn't shipped one via public/.
67
+ * when the user hasn't shipped one via public/. Note that Vercel's
68
+ * git-integration builds read `vercel.json` from the repository root only —
69
+ * the copy emitted here takes effect when the dist folder itself is deployed
70
+ * directly via the Vercel CLI.
68
71
  */
69
72
  const emitRedirectFiles = async (
70
73
  config: ResolvedConfig,
@@ -209,6 +212,22 @@ export const runClientAssetChecks = async (
209
212
  }
210
213
  };
211
214
 
215
+ /**
216
+ * Root of an isolated build's output. The runtime-local `dist/`, except for a
217
+ * Vercel server build, whose deploy bundle lands at `<runtime>/.vercel/output`
218
+ * and is never surfaced to the project root.
219
+ */
220
+ export const isolatedOutputDir = (
221
+ config: ResolvedConfig,
222
+ context: ProjectContext
223
+ ): string => {
224
+ const { adapter, output } = config.deployment;
225
+ if (output === "server" && adapter === "vercel") {
226
+ return join(context.outDir, ".vercel", "output");
227
+ }
228
+ return context.distDir ?? join(context.outDir, "dist");
229
+ };
230
+
212
231
  /**
213
232
  * Directory holding an isolated build's client `_astro/` assets. Mirrors
214
233
  * `deployStaticDir`, except that an isolated build never surfaces the adapter
@@ -221,14 +240,14 @@ export const isolatedStaticDir = (
221
240
  context: ProjectContext
222
241
  ): string => {
223
242
  const { adapter, output } = config.deployment;
243
+ const outputDir = isolatedOutputDir(config, context);
224
244
  if (output === "server" && adapter === "vercel") {
225
- return join(context.outDir, ".vercel", "output", "static");
245
+ return join(outputDir, "static");
226
246
  }
227
- const dist = context.distDir ?? join(context.outDir, "dist");
228
247
  if (output === "server" && adapter === "node") {
229
- return join(dist, "client");
248
+ return join(outputDir, "client");
230
249
  }
231
- return dist;
250
+ return outputDir;
232
251
  };
233
252
 
234
253
  /**
@@ -442,8 +461,6 @@ export const buildCommand = defineCommand({
442
461
  root: project.context.outDir,
443
462
  });
444
463
 
445
- const distDir = project.context.distDir ?? join(root, "dist");
446
-
447
464
  // An isolated build is a throwaway verify: it only needs to confirm the site
448
465
  // compiles and renders. Skip the network post-steps (search sync) and
449
466
  // deploy artifacts (index/llms/sitemap/robots/redirects) that only matter
@@ -457,7 +474,7 @@ export const buildCommand = defineCommand({
457
474
  args
458
475
  );
459
476
  logger.success(
460
- `Isolated build OK — output at ${distDir} (not published).`
477
+ `Isolated build OK — output at ${isolatedOutputDir(project.config, project.context)} (not published).`
461
478
  );
462
479
  return;
463
480
  }
@@ -8,9 +8,14 @@ const RESET = `${ESC}[0m`;
8
8
 
9
9
  const ISSUES_URL = "https://github.com/haydenbleasel/blume/issues";
10
10
 
11
- // Absolute paths into the hidden generated runtime (`…/.blume/…`), including any
12
- // trailing `:line:col`, stopping at whitespace or a closing paren.
13
- const BLUME_FRAME = /(?<abs>\/[^\s()]*\/\.blume\/[^\s()]*)/gu;
11
+ // Absolute paths into the hidden generated runtime — POSIX (`…/.blume/…`) or
12
+ // Windows drive-letter (`C:\…\.blume\…`) — including any trailing `:line:col`,
13
+ // stopping at whitespace or a closing paren.
14
+ const BLUME_FRAME =
15
+ /(?<abs>(?:\/[^\s()]*\/|[A-Za-z]:\\[^\s()]*\\)\.blume[/\\][^\s()]*)/gu;
16
+
17
+ // The separator immediately before `.blume/` (or `.blume\`) in a matched path.
18
+ const BLUME_MARKER = /[/\\]\.blume[/\\]/u;
14
19
 
15
20
  /**
16
21
  * Rewrite `.blume/` frames in a stack so the generated runtime reads clearly:
@@ -22,7 +27,7 @@ const BLUME_FRAME = /(?<abs>\/[^\s()]*\/\.blume\/[^\s()]*)/gu;
22
27
  */
23
28
  export const remapBlumeStack = (stack: string): string =>
24
29
  stack.replaceAll(BLUME_FRAME, (match) => {
25
- const marker = match.indexOf("/.blume/");
30
+ const marker = match.search(BLUME_MARKER);
26
31
  return `${match.slice(marker + 1)} (generated)`;
27
32
  });
28
33
 
@@ -35,8 +35,9 @@ export interface PrepareOptions {
35
35
  export const prepareProject = async (
36
36
  options: PrepareOptions
37
37
  ): Promise<BlumeProject> => {
38
- // Honor a `--root` that differs from cwd: remote sources read env during the
39
- // scan below, and `loadEnvFiles` is a no-op for vars already set (cwd load).
38
+ // Load `.env` files from the project root before the scan: remote sources
39
+ // read their tokens from `process.env` during `scanProject`, and
40
+ // `loadEnvFiles` never overrides variables that are already set.
40
41
  loadEnvFiles(options.root);
41
42
 
42
43
  let project: BlumeProject;
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { resolveIcon } from "../theme/icons.ts";
3
+ import { withBase } from "./islands/base-path.ts";
3
4
 
4
5
  interface Props {
5
6
  name?: unknown;
@@ -69,7 +70,7 @@ const style = color ? `color:${color}` : undefined;
69
70
  aria-hidden={label ? undefined : "true"}
70
71
  class={resolvedClass}
71
72
  height={size}
72
- src={imageSrc}
73
+ src={withBase(imageSrc)}
73
74
  width={size}
74
75
  />
75
76
  ) : (
@@ -154,6 +154,13 @@ const AskAI = ({
154
154
  const triggerRef = useRef<HTMLButtonElement>(null);
155
155
  // Where focus came from when the panel opened, restored on close.
156
156
  const returnFocusRef = useRef<HTMLElement | null>(null);
157
+ // The stream writes into the conversation via functional updates, so "Clear
158
+ // conversation" mid-answer must revoke the in-flight stream's right to write
159
+ // — otherwise its next chunk re-appends the assistant bubble onto the
160
+ // emptied list as an orphaned answer. Clearing bumps the generation (stale
161
+ // streams stop writing) and aborts the request (the stream stops arriving).
162
+ const abortRef = useRef<AbortController | null>(null);
163
+ const generationRef = useRef(0);
157
164
 
158
165
  // Portal target (document.body) only exists after mount; guards SSR. The
159
166
  // one-time false→true flip is deliberate, so the initial `false` is required.
@@ -239,6 +246,9 @@ const AskAI = ({
239
246
  setMessages([...history, assistant]);
240
247
  setInput("");
241
248
  setBusy(true);
249
+ const generation = generationRef.current;
250
+ const controller = new AbortController();
251
+ abortRef.current = controller;
242
252
 
243
253
  try {
244
254
  const response = await fetch(ASK_ENDPOINT, {
@@ -248,6 +258,7 @@ const AskAI = ({
248
258
  }),
249
259
  headers: { "content-type": "application/json" },
250
260
  method: "POST",
261
+ signal: controller.signal,
251
262
  });
252
263
  // A 4xx/5xx still has a body; without this guard its error text would be
253
264
  // decoded and shown as the assistant's answer instead of the error notice.
@@ -266,18 +277,32 @@ const AskAI = ({
266
277
  // must not flush as U+FFFD garbage.
267
278
  // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
268
279
  assistant.content += decoder.decode(chunk.value, { stream: true });
269
- setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
280
+ if (generationRef.current === generation) {
281
+ setMessages((current) => [
282
+ ...current.slice(0, -1),
283
+ { ...assistant },
284
+ ]);
285
+ }
270
286
  }
271
287
  }
272
288
  } catch {
273
- // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
274
- assistant.content = t.error;
275
- setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
289
+ // A cleared (aborted) stream must not resurrect its bubble as an error.
290
+ if (generationRef.current === generation) {
291
+ // oxlint-disable-next-line react/react-compiler -- local streaming accumulator, spread into state below
292
+ assistant.content = t.error;
293
+ setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
294
+ }
276
295
  } finally {
277
296
  setBusy(false);
278
297
  }
279
298
  };
280
299
 
300
+ const clearConversation = () => {
301
+ generationRef.current += 1;
302
+ abortRef.current?.abort();
303
+ setMessages([]);
304
+ };
305
+
281
306
  const onSubmit = (event: FormEvent) => {
282
307
  event.preventDefault();
283
308
  void runQuestion(input);
@@ -332,7 +357,7 @@ const AskAI = ({
332
357
  aria-label={t.clear}
333
358
  className={ICON_BUTTON_CLASS}
334
359
  disabled={!hasMessages}
335
- onClick={() => setMessages([])}
360
+ onClick={clearConversation}
336
361
  type="button"
337
362
  >
338
363
  <Glyph path={icons.clear} />
@@ -1,15 +1,24 @@
1
1
  ---
2
+ import { EN_UI } from "../../core/i18n-ui.ts";
3
+ import type { UIStrings } from "../../core/i18n-ui.ts";
2
4
  import { withBase } from "../islands/base-path.ts";
3
5
  import type { Crumb } from "./nav-utils.ts";
4
6
 
5
7
  interface Props {
6
8
  /** Full breadcrumb trail from the site root to the current page. */
7
9
  crumbs: Crumb[];
10
+ /** Localized nav labels (the landmark's `aria-label`); English when omitted. */
11
+ strings?: UIStrings["nav"];
8
12
  /** Left-align full width (for the wide API layout) instead of the prose measure. */
9
13
  wide?: boolean;
10
14
  }
11
15
 
12
- const { crumbs, wide = false } = Astro.props;
16
+ const { crumbs, strings, wide = false } = Astro.props;
17
+
18
+ // Merge over the English defaults so a label missing from a translation (or
19
+ // from a not-yet-regenerated snapshot) still renders instead of coming out
20
+ // blank — the PageActions pattern.
21
+ const n = { ...EN_UI.nav, ...strings };
13
22
 
14
23
  // The built-in shows a single "eyebrow" crumb — the parent group — rather than
15
24
  // the whole trail. An override receives the complete list and can render more.
@@ -19,7 +28,7 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
19
28
  {
20
29
  eyebrowCrumb && (
21
30
  <nav
22
- aria-label="Breadcrumb"
31
+ aria-label={n.breadcrumb}
23
32
  class:list={[
24
33
  "mb-2 text-muted-foreground text-sm",
25
34
  wide ? "max-w-none" : "mx-auto max-w-[42rem]",
@@ -136,7 +136,7 @@ const initialId =
136
136
  data-nav-back={panel.parentId}
137
137
  type="button"
138
138
  >
139
- <Icon name="arrow-left" size={16} />
139
+ <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
140
140
  </button>
141
141
  {panel.route ? (
142
142
  <a
@@ -219,7 +219,7 @@ const initialId =
219
219
  <span class="flex-1 truncate">{item.label}</span>
220
220
  {item.badge && <span class={badgeClassFor(item.badge)}>{item.badge}</span>}
221
221
  <Icon
222
- class="shrink-0 text-muted-foreground"
222
+ class="shrink-0 text-muted-foreground rtl:-scale-x-100"
223
223
  name="chevron-right"
224
224
  size={14}
225
225
  />
@@ -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",
@@ -147,6 +159,15 @@ const resolvedOgImage = ogImage
147
159
  : ogEnabled && siteBase
148
160
  ? `${siteBase}${withBase(`/og/${ogSlug}.png`)}`
149
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);
150
171
 
151
172
  const initialThemeScript = themeInitScript(themeMode);
152
173
  const bannerScript = banner?.dismissible
@@ -166,17 +187,35 @@ const bannerScript = banner?.dismissible
166
187
  {noindex && <meta content="noindex" name="robots" />}
167
188
  {description && <meta content={description} name="description" />}
168
189
  <meta content="website" property="og:type" />
190
+ <meta content={site.title} property="og:site_name" />
169
191
  <meta content={pageTitle} property="og:title" />
170
192
  {description && <meta content={description} property="og:description" />}
193
+ {resolvedCanonical && <meta content={resolvedCanonical} property="og:url" />}
171
194
  {
172
195
  resolvedOgImage && (
173
196
  <>
174
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
+ )}
175
209
  <meta content={resolvedOgImage} name="twitter:image" />
176
- <meta content="summary_large_image" name="twitter:card" />
210
+ {ogGenerated && <meta content={pageTitle} name="twitter:image:alt" />}
177
211
  </>
178
212
  )
179
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" />}
180
219
  {bannerScript && <script is:inline set:html={bannerScript} />}
181
220
  <script is:inline set:html={initialThemeScript} />
182
221
  <Analytics analytics={analytics} />
@@ -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>
@@ -52,6 +52,10 @@ interface Props {
52
52
  fontCssVars?: string[];
53
53
  searchEnabled: boolean;
54
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";
55
59
  /** Resolved UI dictionary; English baseline when omitted. */
56
60
  ui?: UIStrings;
57
61
  }
@@ -69,6 +73,8 @@ const {
69
73
  fontCssVars,
70
74
  searchEnabled,
71
75
  pageTitle,
76
+ locale = "en",
77
+ dir = "ltr",
72
78
  ui,
73
79
  } = Astro.props;
74
80
 
@@ -85,7 +91,7 @@ const bannerScript = banner?.dismissible
85
91
  ---
86
92
 
87
93
  <!doctype html>
88
- <html lang="en">
94
+ <html dir={dir} lang={locale}>
89
95
  <head>
90
96
  <meta charset="utf-8" />
91
97
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -97,6 +103,10 @@ const bannerScript = banner?.dismissible
97
103
  <Analytics analytics={analytics} />
98
104
  </head>
99
105
  <body class="bg-background font-sans text-foreground antialiased">
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
+ >
100
110
  <Banner banner={banner} strings={strings.banner} />
101
111
  <Header
102
112
  hasDrawer={false}
@@ -109,6 +119,7 @@ const bannerScript = banner?.dismissible
109
119
  />
110
120
  <div
111
121
  class="h-[calc(100dvh-4rem)] overflow-auto border-border border-t [&>[data-scalar-client]]:h-full"
122
+ id="blume-content"
112
123
  >
113
124
  <slot />
114
125
  </div>