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,23 +1,14 @@
1
1
  import { extname, relative } from "pathe";
2
- import { glob } from "tinyglobby";
2
+ import { glob, globSync } from "tinyglobby";
3
3
 
4
+ import type { BlumeProject } from "../core/project-graph.ts";
4
5
  import type { BlumePageRoute } from "./integration.ts";
5
6
 
6
- /**
7
- * Discover user `.astro` pages and map them to route patterns. Files keep their
8
- * original location; only the route pattern is derived (index -> parent,
9
- * dynamic `[param]` segments preserved).
10
- */
11
- export const discoverPages = async (
12
- pagesRoot: string
13
- ): Promise<BlumePageRoute[]> => {
14
- const files = await glob(["**/*.astro"], {
15
- absolute: true,
16
- cwd: pagesRoot,
17
- onlyFiles: true,
18
- });
19
- files.sort();
7
+ const PAGE_GLOB = ["**/*.astro"];
20
8
 
9
+ /** Map discovered page files to routes; shared by the async/sync discoverers. */
10
+ const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
11
+ files.sort();
21
12
  return files.map((file) => {
22
13
  const rel = relative(pagesRoot, file);
23
14
  const withoutExt = rel.slice(0, rel.length - extname(rel).length);
@@ -32,6 +23,26 @@ export const discoverPages = async (
32
23
  });
33
24
  };
34
25
 
26
+ /**
27
+ * Discover user `.astro` pages and map them to route patterns. Files keep their
28
+ * original location; only the route pattern is derived (index -> parent,
29
+ * dynamic `[param]` segments preserved).
30
+ */
31
+ export const discoverPages = async (
32
+ pagesRoot: string
33
+ ): Promise<BlumePageRoute[]> =>
34
+ toPageRoutes(
35
+ pagesRoot,
36
+ await glob(PAGE_GLOB, { absolute: true, cwd: pagesRoot, onlyFiles: true })
37
+ );
38
+
39
+ /** {@link discoverPages} for synchronous callers (e.g. the sitemap builder). */
40
+ export const discoverPagesSync = (pagesRoot: string): BlumePageRoute[] =>
41
+ toPageRoutes(
42
+ pagesRoot,
43
+ globSync(PAGE_GLOB, { absolute: true, cwd: pagesRoot, onlyFiles: true })
44
+ );
45
+
35
46
  /**
36
47
  * Whether the project already owns `route` — through a custom `.astro` page
37
48
  * (injected, so matched on `pattern`) or a content page (matched on `route`).
@@ -57,6 +68,59 @@ export interface OgCustomRoute {
57
68
  /** Skip private (`_partial`, `.well-known`) and Astro dynamic (`[param]`) parts. */
58
69
  const PRIVATE_SEGMENT = /^[._]/u;
59
70
 
71
+ /** Segments of a static, shareable page pattern; null for dynamic/private ones. */
72
+ const staticSegments = (pattern: string): string[] | null => {
73
+ const segments = pattern.split("/").filter(Boolean);
74
+ return segments.some(
75
+ (part) => PRIVATE_SEGMENT.test(part) || part.includes("[")
76
+ )
77
+ ? null
78
+ : segments;
79
+ };
80
+
81
+ /**
82
+ * The static routes served by custom `.astro` pages — the same filtering as
83
+ * {@link customOgRoutes}, but yielding the routes themselves. Feeds the route
84
+ * sets that must know every servable page beyond the content graph (the link
85
+ * checker, the sitemap); dynamic (`[param]`) and private segments are skipped
86
+ * because their concrete URLs can't be enumerated statically.
87
+ */
88
+ export const customStaticRoutes = (pages: { pattern: string }[]): string[] => {
89
+ const routes = new Set<string>();
90
+ for (const { pattern } of pages) {
91
+ const segments = staticSegments(pattern);
92
+ if (segments !== null) {
93
+ routes.add(segments.length === 0 ? "/" : `/${segments.join("/")}`);
94
+ }
95
+ }
96
+ return [...routes];
97
+ };
98
+
99
+ /**
100
+ * Whether the generated `/changelog` index route exists for this project —
101
+ * `generate.ts` (which writes the page) and the sitemap/link validator all
102
+ * share this check: there are visible `type: changelog` entries — or a
103
+ * release-backed changelog source, whose route must resolve even when a fetch
104
+ * fails — and no user content or custom page already owns `/changelog`.
105
+ */
106
+ export const hasGeneratedChangelog = (
107
+ project: BlumeProject,
108
+ userPages: { pattern: string }[]
109
+ ): boolean => {
110
+ const hasChangelog = project.graph.pages.some(
111
+ (page) =>
112
+ page.contentType === "changelog" &&
113
+ !(page.meta.draft || page.meta.sidebar.hidden)
114
+ );
115
+ const hasChangelogSource = (project.config.content.sources ?? []).some(
116
+ (source) => source.type === "github-releases"
117
+ );
118
+ return (
119
+ (hasChangelog || hasChangelogSource) &&
120
+ !routeIsTaken(userPages, project.graph.pages, "/changelog")
121
+ );
122
+ };
123
+
60
124
  const humanizeSegment = (segment: string): string =>
61
125
  segment
62
126
  .split(/[-_]/u)
@@ -83,10 +147,8 @@ export const customOgRoutes = (
83
147
  // Extracted so the skip paths become early `return`s (one `continue` budget
84
148
  // per loop under the lint rule) instead of `continue` statements.
85
149
  const collectRoute = (pattern: string): void => {
86
- const segments = pattern.split("/").filter(Boolean);
87
- if (
88
- segments.some((part) => PRIVATE_SEGMENT.test(part) || part.includes("["))
89
- ) {
150
+ const segments = staticSegments(pattern);
151
+ if (segments === null) {
90
152
  return;
91
153
  }
92
154
  const slug = segments.length === 0 ? "index" : segments.join("/");
@@ -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;
@@ -329,15 +342,18 @@ export const astroConfigTemplate = (options: {
329
342
  "transformerTwoslash({ explicitTrigger: true }), ";
330
343
 
331
344
  // Content links are rewritten to their real served URL: the `deployment.base`
332
- // subdirectory (Astro doesn't rewrite `<a href>`) plus the site-wide
333
- // `basePath` baked into routes. The link checker validates the base-less
334
- // authored path against `basePath` routes separately.
335
- const contentLinkBase = normalizeBasePath(deployment.base) + config.basePath;
345
+ // subdirectory (Astro doesn't rewrite `<a href>`) layered over the site-wide
346
+ // `basePath` baked into routes. The layers are passed separately so a
347
+ // hand-written `basePath` link (`/docs/x`) isn't double-prefixed (see
348
+ // `withComposedBasePath`). The link checker validates the base-less authored
349
+ // path against `basePath` routes separately.
350
+ const deployBase = normalizeBasePath(deployment.base);
336
351
 
337
352
  const integrations = [
338
353
  `mdx({ processor: blumeMdxProcessor(${JSON.stringify({
339
- basePath: contentLinkBase,
354
+ basePath: config.basePath,
340
355
  codeThemes: config.markdown.codeBlocks.theme,
356
+ deployBase,
341
357
  headingAnchors: config.markdown.headingAnchors,
342
358
  })}) })`,
343
359
  ];
@@ -371,8 +387,9 @@ export default defineConfig({
371
387
  integrations: [${integrations.join(", ")}],
372
388
  markdown: {
373
389
  processor: blumeMarkdownProcessor(${JSON.stringify({
374
- basePath: contentLinkBase,
390
+ basePath: config.basePath,
375
391
  codeThemes: config.markdown.codeBlocks.theme,
392
+ deployBase,
376
393
  headingAnchors: config.markdown.headingAnchors,
377
394
  })}),
378
395
  shikiConfig: {
@@ -615,6 +632,31 @@ export const askEndpointTemplate = (
615
632
  content: m.content,
616
633
  role: m.role,
617
634
  }));`;
635
+ // `streamText` returns synchronously and defers provider/auth/network errors
636
+ // to stream consumption, so the handler's try/catch never sees them: without
637
+ // these the client gets a 200 whose stream aborts mid-flight and nothing is
638
+ // logged server-side. A missing credential is rejected up front as a real
639
+ // 500; everything else is at least logged via `onError`.
640
+ const keyCheck =
641
+ backend.kind === "gateway"
642
+ ? ` // The AI Gateway authenticates with an API key or Vercel's OIDC token.
643
+ if (!(process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN)) {
644
+ return new Response(
645
+ "Ask AI is not configured: set AI_GATEWAY_API_KEY (or deploy on Vercel with OIDC).",
646
+ { status: 500 }
647
+ );
648
+ }`
649
+ : ` if (!process.env[${JSON.stringify(backend.apiKeyEnv)}]) {
650
+ return new Response(
651
+ ${JSON.stringify(`Ask AI is not configured: set ${backend.apiKeyEnv}.`)},
652
+ { status: 500 }
653
+ );
654
+ }`;
655
+ // Provider errors surface mid-stream, after the 200 is committed; this is
656
+ // the only place they can be observed server-side.
657
+ const onError = ` onError({ error }) {
658
+ console.error("Ask AI provider error:", error);
659
+ },`;
618
660
  const stream = grounded
619
661
  ? ` const system =
620
662
  (await ground(messages, body.page)) ??
@@ -623,15 +665,18 @@ export const askEndpointTemplate = (
623
665
  model: ${modelExpr},
624
666
  system,
625
667
  messages,
668
+ ${onError}
626
669
  });`
627
670
  : ` const result = streamText({
628
671
  model: ${modelExpr},
629
672
  system:
630
673
  "You are a helpful documentation assistant. Answer using the project's documentation.",
631
674
  messages,
675
+ ${onError}
632
676
  });`;
633
677
  const handler = `export const POST: APIRoute = async ({ request }) => {
634
678
  ${validate}
679
+ ${keyCheck}
635
680
  try {
636
681
  ${stream}
637
682
  return result.toTextStreamResponse();
@@ -804,9 +849,13 @@ export const POST: APIRoute = async ({ request }) => {
804
849
 
805
850
  /**
806
851
  * Generate the raw-Markdown endpoints (`[...slug].md.ts` and `[...slug].mdx.ts`).
807
- * Each route's source is served verbatim so `/<route>.md` returns plain Markdown.
852
+ * Both read `raw-markdown.json`, whose entries hold the verbatim source (`mdx`)
853
+ * plus a component-downleveled variant (`md`) when the page uses components:
854
+ * `/<route>.mdx` serves the source exactly as written, `/<route>.md` serves
855
+ * plain Markdown with `<TypeTable>`-style components converted for consumers
856
+ * that can't interpret JSX.
808
857
  */
809
- export const rawMarkdownEndpointTemplate = (): string =>
858
+ export const rawMarkdownEndpointTemplate = (kind: "md" | "mdx"): string =>
810
859
  `// Generated by Blume. Do not edit.
811
860
  import raw from "../generated/raw-markdown.json";
812
861
 
@@ -820,7 +869,10 @@ export function getStaticPaths() {
820
869
  }
821
870
 
822
871
  export function GET({ props }) {
823
- return new Response(raw[props.route] ?? "", {
872
+ const entry = raw[props.route];
873
+ return new Response(entry ? ${
874
+ kind === "md" ? "(entry.md ?? entry.mdx)" : "entry.mdx"
875
+ } : "", {
824
876
  headers: { "Content-Type": "text/markdown; charset=utf-8" },
825
877
  });
826
878
  }
@@ -984,12 +1036,24 @@ import data from ${JSON.stringify(options.dataImport)};
984
1036
  export const prerender = true;
985
1037
 
986
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";
987
1049
  ---
988
1050
 
989
1051
  <ReferenceLayout
990
1052
  analytics={data.config.analytics}
991
1053
  banner={data.config.banner}
1054
+ dir={dir}
992
1055
  fontCssVars={data.fontCssVars}
1056
+ locale={htmlLang}
993
1057
  logo={data.config.logo}
994
1058
  favicon={data.config.favicon}
995
1059
  appleIcon={data.config.appleIcon}
@@ -999,6 +1063,7 @@ const configuration = ${JSON.stringify(options.configuration, null, 2)};
999
1063
  searchEnabled={data.config.search.enabled}
1000
1064
  site={{ title: data.config.title, description: data.config.description }}
1001
1065
  themeMode={data.config.theme.mode}
1066
+ ui={data.ui}
1002
1067
  >
1003
1068
  <ScalarComponent configuration={configuration} renderMode="client" />
1004
1069
  </ReferenceLayout>
@@ -1162,6 +1227,13 @@ const ogRel = seo.image ?? ogPath;
1162
1227
  // an external URL, which passes through verbatim (mirrors PageLayout).
1163
1228
  const ogImage =
1164
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 } : {}) };
1165
1237
 
1166
1238
  const basedRoute = withBase(route);
1167
1239
  const canonical =
@@ -1258,6 +1330,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1258
1330
  searchEnabled={data.config.search.enabled}
1259
1331
  indexable={indexable}
1260
1332
  ogImage={ogImage}
1333
+ ogGenerated={ogGenerated}
1334
+ x={x}
1261
1335
  canonical={canonical}
1262
1336
  editUrl={editUrl}
1263
1337
  feedback={data.config.feedback}
@@ -1301,7 +1375,7 @@ export const changelogIndexTemplate = (options: {
1301
1375
  ? '\n <AskAI slot="ask" strings={data.ui.ask} suggestions={data.config.ask?.suggestions ?? []} />'
1302
1376
  : "";
1303
1377
  const clientData = options.needsReact
1304
- ? '\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 } }}'
1305
1379
  : "";
1306
1380
  // Staged sources (e.g. GitHub Releases) render through a parallel collection,
1307
1381
  // so fold them in alongside filesystem entries when one exists.
@@ -1314,6 +1388,7 @@ export const changelogIndexTemplate = (options: {
1314
1388
  import { getCollection, render } from "astro:content";
1315
1389
  import RootLayout from "blume/components/layout/RootLayout.astro";
1316
1390
  import Update from "blume/components/content/Update.astro";
1391
+ import { withBase } from "blume/components/islands/base-path.ts";
1317
1392
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1318
1393
  import { layoutOverrides } from "../generated/components.ts";
1319
1394
  ${askImport}import data from "../generated/data.json";
@@ -1394,6 +1469,20 @@ const items = await Promise.all(
1394
1469
  })
1395
1470
  );
1396
1471
 
1472
+ // Repeated labels slug to the same id (e.g. two entries with neither a title
1473
+ // nor a version both falling back to "update"); suffix the later ones -2, -3,
1474
+ // ... so every heading deep-links to its own entry. The first keeps the plain
1475
+ // slug, and the rendered ids stay in lockstep with the \`headings\` list below.
1476
+ const seenIds = new Set();
1477
+ for (const item of items) {
1478
+ let uniqueId = item.id;
1479
+ for (let n = 2; seenIds.has(uniqueId); n += 1) {
1480
+ uniqueId = item.id + "-" + n;
1481
+ }
1482
+ seenIds.add(uniqueId);
1483
+ item.id = uniqueId;
1484
+ }
1485
+
1397
1486
  // A changelog is semver-paginated only when every visible release parses as
1398
1487
  // semver and they span more than one major line. Older majors then collapse
1399
1488
  // into groups the reader reveals one at a time; otherwise the timeline is flat.
@@ -1414,7 +1503,29 @@ const headings = items.map((item) => ({
1414
1503
  }));
1415
1504
 
1416
1505
  const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1417
- const canonical = base ? base + "/changelog" : null;
1506
+ // The canonical URL carries the deployment base (the page is served under it),
1507
+ // matching how the catch-all canonicalizes via \`withBase(route)\`.
1508
+ const basedRoute = withBase("/changelog");
1509
+ const canonical = base ? base + basedRoute : null;
1510
+
1511
+ // The changelog is an unlocalized route, so its chrome renders in the default
1512
+ // locale's dictionary and direction (\`data.ui\` is the default locale's resolved
1513
+ // dictionary), mirroring the catch-all's locale wiring.
1514
+ const i18n = data.config.i18n;
1515
+ const localeMeta = i18n
1516
+ ? i18n.locales.find((l) => l.code === i18n.defaultLocale)
1517
+ : null;
1518
+ const dir = localeMeta?.dir ?? "ltr";
1519
+ const htmlLang = i18n ? i18n.defaultLocale : "en";
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;
1418
1529
 
1419
1530
  const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1420
1531
  ---
@@ -1431,9 +1542,12 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1431
1542
  imageZoom={data.config.imageZoom}
1432
1543
  codeWrap={data.config.codeWrap}
1433
1544
  navigation={data.navigation}
1545
+ locale={htmlLang}
1546
+ dir={dir}
1547
+ ui={data.ui}
1434
1548
  page={{
1435
- title: data.config.title + " changelog",
1436
- description: "Product updates and release notes.",
1549
+ title: pageTitle,
1550
+ description: changelogDescription,
1437
1551
  route: "/changelog",
1438
1552
  }}
1439
1553
  headings={headings}
@@ -1444,6 +1558,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1444
1558
  searchEnabled={data.config.search.enabled}
1445
1559
  indexable={true}
1446
1560
  ogImage={null}
1561
+ x={data.config.x}
1447
1562
  canonical={canonical}
1448
1563
  askEnabled={${options.askEnabled}}
1449
1564
  exportPdf={${options.exportPdf}}
@@ -1453,12 +1568,15 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1453
1568
  noindex={false}
1454
1569
  structuredDataEnabled={data.config.structuredData}
1455
1570
  >${askSlot}
1456
- <h1>Changelog</h1>
1571
+ <h1>{changelogTitle}</h1>
1457
1572
  {
1458
1573
  items.length === 0 ? (
1459
1574
  <p>No changelog entries yet.</p>
1460
1575
  ) : paginate ? (
1461
- <blume-changelog class="not-prose mt-8 block">
1576
+ <blume-changelog
1577
+ class="not-prose mt-8 block"
1578
+ data-i18n-more={data.ui.changelog?.showReleases}
1579
+ >
1462
1580
  {majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
1463
1581
  <Update description={date} href={href} id={id} label={label} tags={tags}>
1464
1582
  <Content />
@@ -1517,11 +1635,22 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1517
1635
  export const notFoundPageTemplate = (): string => `---
1518
1636
  // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
1519
1637
  import PageLayout from "blume/components/layout/PageLayout.astro";
1638
+ import { withBase } from "blume/components/islands/base-path.ts";
1520
1639
  import data from "../generated/data.json";
1521
1640
 
1522
1641
  export const prerender = true;
1523
1642
 
1524
1643
  const nf = data.ui.notFound;
1644
+
1645
+ // The 404 page is an unlocalized route, so its chrome renders in the default
1646
+ // locale's dictionary and direction (\`data.ui\` is the default locale's resolved
1647
+ // dictionary), mirroring the catch-all's locale wiring.
1648
+ const i18n = data.config.i18n;
1649
+ const localeMeta = i18n
1650
+ ? i18n.locales.find((l) => l.code === i18n.defaultLocale)
1651
+ : null;
1652
+ const dir = localeMeta?.dir ?? "ltr";
1653
+ const htmlLang = i18n ? i18n.defaultLocale : "en";
1525
1654
  ---
1526
1655
 
1527
1656
  <PageLayout
@@ -1536,6 +1665,8 @@ const nf = data.ui.notFound;
1536
1665
  themeMode={data.config.theme.mode}
1537
1666
  fontCssVars={data.fontCssVars}
1538
1667
  searchEnabled={data.config.search.enabled}
1668
+ locale={htmlLang}
1669
+ dir={dir}
1539
1670
  ui={data.ui}
1540
1671
  noindex={true}
1541
1672
  >
@@ -1547,7 +1678,7 @@ const nf = data.ui.notFound;
1547
1678
  <p class="text-muted-foreground">{nf.description}</p>
1548
1679
  <a
1549
1680
  class="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-foreground"
1550
- href="/">{nf.home}</a
1681
+ href={withBase("/")}>{nf.home}</a
1551
1682
  >
1552
1683
  </div>
1553
1684
  </PageLayout>
@@ -15,6 +15,14 @@ declare module "blume:search-client" {
15
15
  export const createSearch: () => Fn | Promise<Fn>;
16
16
  }
17
17
 
18
+ declare module "blume:data" {
19
+ /** The generated per-project data snapshot (see `core/data.ts`). */
20
+ // biome-ignore lint/style/useImportType: ambient module must stay a global script
21
+ // oxlint-disable-next-line typescript/consistent-type-imports
22
+ const data: import("./core/data.ts").BlumeData;
23
+ export default data;
24
+ }
25
+
18
26
  // Package-only shim so `components/props.ts` can extract `.astro` prop types with
19
27
  // `ComponentProps<typeof import("./X.astro").default>` under the package's own
20
28
  // `tsc` (where the Astro TS plugin isn't active). Not shipped in `dist/types`, so