blume 2.2.1 → 2.2.3

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 (135) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/{chunk-080mfch6.js → chunk-0wt5amrk.js} +15 -15
  3. package/dist/cli/{chunk-k45sq16q.js → chunk-0xyedh7k.js} +39 -23
  4. package/dist/cli/{chunk-k45sq16q.js.map → chunk-0xyedh7k.js.map} +3 -3
  5. package/dist/cli/{chunk-f260jdrn.js → chunk-0ynr0fr7.js} +5 -5
  6. package/dist/cli/{chunk-e1n41w9m.js → chunk-1ay3hzn0.js} +3 -3
  7. package/dist/cli/{chunk-b680sv30.js → chunk-2f6btenb.js} +10 -8
  8. package/dist/cli/chunk-2f6btenb.js.map +13 -0
  9. package/dist/cli/{chunk-gwy55cx3.js → chunk-426rytg6.js} +2 -2
  10. package/dist/cli/{chunk-9h8mh2tk.js → chunk-5f96yq2c.js} +1 -1
  11. package/dist/cli/{chunk-x60e15st.js → chunk-5xq12jz7.js} +17 -17
  12. package/dist/cli/{chunk-x7bh6d9j.js → chunk-6xag1c83.js} +4 -4
  13. package/dist/cli/{chunk-487b95qy.js → chunk-88q28zav.js} +41 -25
  14. package/dist/cli/{chunk-487b95qy.js.map → chunk-88q28zav.js.map} +3 -3
  15. package/dist/cli/{chunk-yg8ckc3g.js → chunk-88zkpg89.js} +2 -2
  16. package/dist/cli/{chunk-ppa6mg4d.js → chunk-8fm3b4vm.js} +6 -6
  17. package/dist/cli/{chunk-rtvgmg2j.js → chunk-8h2t2vyj.js} +2 -2
  18. package/dist/cli/{chunk-2ssh60qh.js → chunk-982qfeny.js} +6 -6
  19. package/dist/cli/{chunk-t2h8pwy1.js → chunk-9emfspcp.js} +4 -4
  20. package/dist/cli/{chunk-9jqmq5hv.js → chunk-akj2p7k1.js} +6 -6
  21. package/dist/cli/{chunk-beb0z983.js → chunk-b664x65a.js} +16 -16
  22. package/dist/cli/{chunk-kmswjxj9.js → chunk-b7n58hgj.js} +3 -3
  23. package/dist/cli/{chunk-fnxgtjeq.js → chunk-c0qddrxr.js} +1 -1
  24. package/dist/cli/{chunk-p834rkcn.js → chunk-g62vnvaw.js} +5 -5
  25. package/dist/cli/{chunk-zxd5jrgx.js → chunk-gc7knd2s.js} +76 -34
  26. package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-gc7knd2s.js.map} +4 -4
  27. package/dist/cli/{chunk-r5nm9w0c.js → chunk-hwjspffr.js} +105 -36
  28. package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-hwjspffr.js.map} +11 -10
  29. package/dist/cli/{chunk-40vk8kgf.js → chunk-p4fe7tax.js} +7 -7
  30. package/dist/cli/{chunk-34wap3ed.js → chunk-psg2za5z.js} +3 -3
  31. package/dist/cli/{chunk-hktq4j5r.js → chunk-pxp1y6yz.js} +16 -10
  32. package/dist/cli/{chunk-hktq4j5r.js.map → chunk-pxp1y6yz.js.map} +4 -4
  33. package/dist/cli/{chunk-qcyxkj44.js → chunk-qax1aekz.js} +43 -17
  34. package/dist/cli/chunk-qax1aekz.js.map +15 -0
  35. package/dist/cli/{chunk-h0qwdbem.js → chunk-r5ajrw1g.js} +2 -2
  36. package/dist/cli/{chunk-q1wwrxzw.js → chunk-rc2qk3qx.js} +3 -3
  37. package/dist/cli/chunk-rc2qk3qx.js.map +10 -0
  38. package/dist/cli/{chunk-69rjf786.js → chunk-ry2ymbxf.js} +5 -5
  39. package/dist/cli/{chunk-gxhcf2hs.js → chunk-t43g7v22.js} +2 -2
  40. package/dist/cli/{chunk-ydqzkhjn.js → chunk-x0jqyq1g.js} +6 -6
  41. package/dist/cli/{chunk-aafhwx74.js → chunk-x25s4gph.js} +1 -1
  42. package/dist/cli/{chunk-nqna8dzp.js → chunk-xkx216rd.js} +17 -17
  43. package/dist/cli/{chunk-etckvbrh.js → chunk-xv8nkt56.js} +14 -14
  44. package/dist/cli/{chunk-hn3g2b89.js → chunk-yrmw5n2b.js} +9 -9
  45. package/dist/cli/{chunk-4zxh25hc.js → chunk-zgca0vxs.js} +2 -2
  46. package/dist/cli/{chunk-c5bjxq8v.js → chunk-zj6bcx0n.js} +2 -2
  47. package/dist/cli/{chunk-1ch3ct3r.js → chunk-zjfwzrvd.js} +14 -14
  48. package/dist/cli/{chunk-j4nfgn2j.js → chunk-znwhf37a.js} +1 -1
  49. package/dist/cli/index.js +18 -18
  50. package/dist/types/ai/ask-context.d.ts +8 -0
  51. package/dist/types/ai/link-text.d.ts +5 -0
  52. package/dist/types/ai/relative-links.d.ts +26 -6
  53. package/dist/types/core/config-input.d.ts +35 -1
  54. package/dist/types/core/data.d.ts +14 -0
  55. package/dist/types/core/last-modified.d.ts +33 -18
  56. package/dist/types/core/links.d.ts +16 -0
  57. package/dist/types/core/schema.d.ts +16 -1
  58. package/dist/types/core/types.d.ts +10 -0
  59. package/dist/types/deploy/sitemap.d.ts +9 -0
  60. package/dist/types/search/documents.d.ts +3 -1
  61. package/dist/types/search/fold.d.ts +33 -0
  62. package/dist/types/search/orama-index.d.ts +4 -4
  63. package/dist/types/seo/jsonld.d.ts +33 -0
  64. package/docs/configuration/search.mdx +2 -2
  65. package/docs/content/frontmatter.mdx +1 -1
  66. package/docs/content/i18n.mdx +1 -1
  67. package/docs/content/sources/remote-mdx.mdx +1 -1
  68. package/docs/discoverability/open-graph.mdx +16 -1
  69. package/docs/discoverability/sitemap-and-robots.mdx +12 -0
  70. package/docs/discoverability/structured-data.mdx +44 -0
  71. package/package.json +1 -1
  72. package/src/ai/ask-context.ts +52 -14
  73. package/src/ai/ask-data.ts +4 -1
  74. package/src/ai/ask-tools.ts +17 -5
  75. package/src/ai/link-text.ts +6 -0
  76. package/src/ai/llms.ts +1 -7
  77. package/src/ai/markdown.ts +8 -4
  78. package/src/ai/relative-links.ts +155 -48
  79. package/src/ai/site-skill.ts +1 -4
  80. package/src/astro/generate.ts +7 -0
  81. package/src/astro/templates.ts +36 -20
  82. package/src/components/layout/PageActions.astro +45 -18
  83. package/src/components/layout/PageLayout.astro +25 -12
  84. package/src/components/layout/RootLayout.astro +8 -1
  85. package/src/components/layout/locale-redirect.ts +38 -19
  86. package/src/components/layout/search/types.ts +12 -4
  87. package/src/core/config-input.ts +34 -1
  88. package/src/core/config.ts +5 -2
  89. package/src/core/data.ts +14 -0
  90. package/src/core/last-modified.ts +80 -34
  91. package/src/core/links.ts +43 -2
  92. package/src/core/manifest.ts +2 -0
  93. package/src/core/project-graph.ts +83 -43
  94. package/src/core/schema.ts +47 -9
  95. package/src/core/types.ts +10 -0
  96. package/src/deploy/sitemap.ts +127 -49
  97. package/src/openapi/proxy.ts +5 -1
  98. package/src/search/documents.ts +34 -11
  99. package/src/search/fold.ts +175 -0
  100. package/src/search/orama-index.ts +40 -49
  101. package/src/seo/jsonld.ts +100 -23
  102. package/dist/cli/chunk-b680sv30.js.map +0 -13
  103. package/dist/cli/chunk-q1wwrxzw.js.map +0 -10
  104. package/dist/cli/chunk-qcyxkj44.js.map +0 -14
  105. /package/dist/cli/{chunk-080mfch6.js.map → chunk-0wt5amrk.js.map} +0 -0
  106. /package/dist/cli/{chunk-f260jdrn.js.map → chunk-0ynr0fr7.js.map} +0 -0
  107. /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-1ay3hzn0.js.map} +0 -0
  108. /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-426rytg6.js.map} +0 -0
  109. /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-5f96yq2c.js.map} +0 -0
  110. /package/dist/cli/{chunk-x60e15st.js.map → chunk-5xq12jz7.js.map} +0 -0
  111. /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6xag1c83.js.map} +0 -0
  112. /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-88zkpg89.js.map} +0 -0
  113. /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-8fm3b4vm.js.map} +0 -0
  114. /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-8h2t2vyj.js.map} +0 -0
  115. /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-982qfeny.js.map} +0 -0
  116. /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-9emfspcp.js.map} +0 -0
  117. /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-akj2p7k1.js.map} +0 -0
  118. /package/dist/cli/{chunk-beb0z983.js.map → chunk-b664x65a.js.map} +0 -0
  119. /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-b7n58hgj.js.map} +0 -0
  120. /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-c0qddrxr.js.map} +0 -0
  121. /package/dist/cli/{chunk-p834rkcn.js.map → chunk-g62vnvaw.js.map} +0 -0
  122. /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-p4fe7tax.js.map} +0 -0
  123. /package/dist/cli/{chunk-34wap3ed.js.map → chunk-psg2za5z.js.map} +0 -0
  124. /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-r5ajrw1g.js.map} +0 -0
  125. /package/dist/cli/{chunk-69rjf786.js.map → chunk-ry2ymbxf.js.map} +0 -0
  126. /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-t43g7v22.js.map} +0 -0
  127. /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-x0jqyq1g.js.map} +0 -0
  128. /package/dist/cli/{chunk-aafhwx74.js.map → chunk-x25s4gph.js.map} +0 -0
  129. /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-xkx216rd.js.map} +0 -0
  130. /package/dist/cli/{chunk-etckvbrh.js.map → chunk-xv8nkt56.js.map} +0 -0
  131. /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-yrmw5n2b.js.map} +0 -0
  132. /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-zgca0vxs.js.map} +0 -0
  133. /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-zj6bcx0n.js.map} +0 -0
  134. /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-zjfwzrvd.js.map} +0 -0
  135. /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-znwhf37a.js.map} +0 -0
@@ -11,12 +11,15 @@ import { localePrefix } from "../core/i18n.ts";
11
11
  import {
12
12
  buildFileRouteIndex,
13
13
  isIndexFileName,
14
+ resolveRawRelativeHref,
14
15
  resolveRelativeHref,
15
16
  resolveRootFileHref,
17
+ routeOfLinkedEntry,
16
18
  routeOfLinkedFile,
17
19
  } from "../core/links.ts";
18
- import type { RelativeLinkBase } from "../core/links.ts";
20
+ import type { FileRouteIndex, RelativeLinkBase } from "../core/links.ts";
19
21
  import { localizeHref, servesRoute } from "../core/locale-links.ts";
22
+ import type { LocalizeHrefOptions } from "../core/locale-links.ts";
20
23
  import type { BlumeProject } from "../core/project-graph.ts";
21
24
  import { resolveDocsCollection } from "../core/sources/collection.ts";
22
25
  import { extractLinks } from "../core/sources/normalize.ts";
@@ -28,7 +31,9 @@ import { extractLinks } from "../core/sources/normalize.ts";
28
31
  * link lands on `/install`; the rendered page rewrites it to the route it
29
32
  * means (`markdown/relative-links.ts`), and the Markdown an agent reads gets
30
33
  * the same rewrite from the same resolver (`resolveRelativeHref`), so both
31
- * point at one page. Root-relative links (`[x](/guide)`,
34
+ * point at one page. A raw HTML `<a href>` ships as written, so a relative
35
+ * one lands where the browser takes it from the page's URL
36
+ * (`resolveRawRelativeHref`). Root-relative links (`[x](/guide)`,
32
37
  * `<Card href="/guide">`, `![logo](/logo.png)`) get what the rendered page
33
38
  * gives them too (`markdown/base-links.ts`,
34
39
  * `components/content/base-href.ts`): a page link the `deployment.base` +
@@ -40,10 +45,17 @@ import { extractLinks } from "../core/sources/normalize.ts";
40
45
  * (`resolveRootFileHref`). Inline links, images, component
41
46
  * `href`s, and reference definitions are rewritten; fenced and inline code,
42
47
  * relative images and asset links, and external URLs are left as written.
48
+ * The assistant's snapshot takes the links that name a page by its path or
49
+ * file alone, as the routes it keys pages by (`assistant`).
43
50
  */
44
51
 
45
52
  /** The page a Markdown text was written for. */
46
53
  export interface LinkedPage {
54
+ /**
55
+ * A staged page's path under its staging dir, which its links resolve from
56
+ * when it has no `sourcePath` (a remote or CMS source).
57
+ */
58
+ entryId?: string;
47
59
  route: string;
48
60
  sourcePath?: string;
49
61
  }
@@ -51,6 +63,20 @@ export interface LinkedPage {
51
63
  /** Rewrite a page's relative page links to the routes they mean. */
52
64
  export type RelativeLinkRewriter = (text: string, page: LinkedPage) => string;
53
65
 
66
+ /** Who reads the rewritten links. */
67
+ export interface RelativeLinkOptions {
68
+ /**
69
+ * The assistant's snapshot, rather than a page an agent fetches by URL.
70
+ * Only a link that names a page by its relative path or its content file
71
+ * changes, to the route the snapshot keys that page by: without the
72
+ * `deployment.base`, since the chat panel mounts every link it renders
73
+ * under it, and a locale's fallback copy as the page it renders, since the
74
+ * snapshot holds that page instead. Root page links and images stay as
75
+ * written.
76
+ */
77
+ assistant?: boolean;
78
+ }
79
+
54
80
  /**
55
81
  * Whether a text could hold a relative page link at all: an inline link, a
56
82
  * reference definition, or a component `href` whose target isn't a URL with
@@ -123,16 +149,79 @@ const collectSplices = (
123
149
  return splices;
124
150
  };
125
151
 
152
+ /** What a project's links resolve through, whoever reads them. */
153
+ interface LinkIndex {
154
+ fileRoutes: FileRouteIndex;
155
+ localeByRoute: Map<string, string>;
156
+ navPathBySource: Map<string | undefined, string>;
157
+ routes: Set<string>;
158
+ }
159
+
160
+ // The mirrors, llms-full.txt and the assistant's snapshot each build a
161
+ // rewriter for the same project, so the lookups behind them are built once.
162
+ const linkIndexes = new WeakMap<BlumeProject, LinkIndex>();
163
+
164
+ /**
165
+ * The file → route lookup a `.md`/`.mdx` link resolves through, and the
166
+ * routes a link can land on. A file shared by every locale publishes once
167
+ * per locale, and the default locale's route stands for it; fallback copies
168
+ * render another locale's file, so they never stand for one.
169
+ */
170
+ const linkIndex = (project: BlumeProject): LinkIndex => {
171
+ let index = linkIndexes.get(project);
172
+ if (!index) {
173
+ const { config, graph, manifest } = project;
174
+ index = {
175
+ fileRoutes: buildFileRouteIndex(graph.pages, config.i18n ?? null),
176
+ localeByRoute: new Map(
177
+ manifest.routes.map((route) => [route.path, route.locale])
178
+ ),
179
+ navPathBySource: new Map(
180
+ graph.pages.map((page) => [page.sourcePath, page.navPath])
181
+ ),
182
+ routes: new Set(manifest.routes.map((route) => route.path)),
183
+ };
184
+ linkIndexes.set(project, index);
185
+ }
186
+ return index;
187
+ };
188
+
189
+ /**
190
+ * A resolved route as the assistant's snapshot keys it. A locale's fallback
191
+ * copy (`/fr/guides/install` with no French page there) renders another
192
+ * locale's page, which the snapshot holds in its place, so the route becomes
193
+ * that page's; every other route is kept, `?query#hash` and all.
194
+ */
195
+ const snapshotRoute = (project: BlumeProject): ((route: string) => string) => {
196
+ const pageRoutes = new Map(
197
+ project.graph.pages.map((page) => [page.id, page.route])
198
+ );
199
+ const rendered = new Map<string, string>();
200
+ for (const route of project.manifest.routes) {
201
+ const page = route.fallback ? pageRoutes.get(route.id) : undefined;
202
+ if (page !== undefined) {
203
+ rendered.set(route.path, page);
204
+ }
205
+ }
206
+ return (route) => {
207
+ const suffixAt = route.search(/[?#]/u);
208
+ const path = suffixAt === -1 ? route : route.slice(0, suffixAt);
209
+ const page = rendered.get(path);
210
+ return page === undefined ? route : `${page}${route.slice(path.length)}`;
211
+ };
212
+ };
213
+
126
214
  /**
127
- * Build the rewriter for a project: the file → route lookup a `.md`/`.mdx`
128
- * link resolves through is built once. A file shared by every locale
129
- * publishes once per locale, and the default locale's route stands for it;
130
- * fallback copies render another locale's file, so they never stand for one.
215
+ * Build the rewriter for a project, for an agent reading a page by URL or,
216
+ * with `assistant`, for the assistant's snapshot.
131
217
  */
132
218
  export const relativeLinkRewriter = (
133
- project: BlumeProject
219
+ project: BlumeProject,
220
+ options: RelativeLinkOptions = {}
134
221
  ): RelativeLinkRewriter => {
135
222
  const { basePath, deployment, i18n } = project.config;
223
+ // Links are read under the real deployment base whoever reads them, so a
224
+ // root link to a content file that carries it is still recognized.
136
225
  const deployBase = normalizeBasePath(deployment.options.base);
137
226
  const contentRoot = resolveDocsCollection(
138
227
  project.config,
@@ -141,17 +230,38 @@ export const relativeLinkRewriter = (
141
230
  const localeTokens = i18n
142
231
  ? ["$", ...i18n.locales.map((locale) => locale.code)]
143
232
  : [];
144
- const fileRoutes = buildFileRouteIndex(project.graph.pages, i18n ?? null);
145
- const navPathBySource = new Map(
146
- project.graph.pages.map((graphPage) => [
147
- graphPage.sourcePath,
148
- graphPage.navPath,
149
- ])
150
- );
151
- const routes = new Set(project.manifest.routes.map((route) => route.path));
152
- const localeByRoute = new Map(
153
- project.manifest.routes.map((route) => [route.path, route.locale])
154
- );
233
+ const { fileRoutes, localeByRoute, navPathBySource, routes } =
234
+ linkIndex(project);
235
+ const hasRoute = (path: string): boolean => routes.has(path);
236
+
237
+ // Where a resolved route lands for its reader. A route Blume serves takes
238
+ // the deployment base unconditionally — even over a route that starts with
239
+ // the base's name — except in the assistant's snapshot.
240
+ const land = options.assistant
241
+ ? snapshotRoute(project)
242
+ : (route: string): string => mountBasePath(deployBase, route);
243
+
244
+ /**
245
+ * How a page's root-relative links change, if they do: when the site is
246
+ * mounted under a prefix, or the page reads in a prefixed locale, whose
247
+ * copy of a route they move to. The assistant's snapshot keeps them as
248
+ * written.
249
+ */
250
+ const rootLinksOf = (
251
+ route: string
252
+ ): { localize?: LocalizeHrefOptions } | undefined => {
253
+ if (options.assistant) {
254
+ return undefined;
255
+ }
256
+ const locale = localeByRoute.get(route);
257
+ const localize =
258
+ i18n && locale !== undefined && localePrefix(locale, i18n) !== ""
259
+ ? { basePath, deployBase, i18n, locale, routes }
260
+ : undefined;
261
+ return deployBase !== "" || basePath !== "" || localize !== undefined
262
+ ? { localize }
263
+ : undefined;
264
+ };
155
265
 
156
266
  /**
157
267
  * A page's relative-link resolver; `undefined` when the page has no file
@@ -160,43 +270,36 @@ export const relativeLinkRewriter = (
160
270
  const relativeRoute = (
161
271
  page: LinkedPage
162
272
  ): ((target: string) => string | undefined) | undefined => {
163
- const { sourcePath } = page;
164
- if (!sourcePath) {
273
+ const { entryId, sourcePath } = page;
274
+ const file = sourcePath ?? entryId;
275
+ if (!file) {
165
276
  return;
166
277
  }
167
278
  const from: RelativeLinkBase = {
168
- isIndex: isIndexFileName(basename(sourcePath), localeTokens),
279
+ isIndex: isIndexFileName(basename(file), localeTokens),
169
280
  route: page.route,
170
281
  };
171
- const navPath = navPathBySource.get(sourcePath) ?? basename(sourcePath);
172
- const resolveFile = (path: string): string | undefined =>
173
- routeOfLinkedFile(fileRoutes, { navPath, sourcePath }, path);
174
- // A resolved route is one Blume serves, so the deployment base goes on
175
- // unconditionally — even over a route that starts with the base's name.
176
- return (target) => {
177
- const route = resolveRelativeHref(target, from, resolveFile, (path) =>
178
- routes.has(path)
179
- );
180
- return route === undefined ? undefined : mountBasePath(deployBase, route);
181
- };
282
+ // A staged page (a remote or CMS source) has no file on disk: it
283
+ // resolves a sibling by its entry id, as `blume validate` does.
284
+ let resolveFile: (path: string) => string | undefined;
285
+ if (sourcePath) {
286
+ const navPath = navPathBySource.get(sourcePath) ?? basename(sourcePath);
287
+ resolveFile = (path) =>
288
+ routeOfLinkedFile(fileRoutes, { navPath, sourcePath }, path);
289
+ } else {
290
+ resolveFile = (path) => routeOfLinkedEntry(fileRoutes, file, path);
291
+ }
292
+ return (target) => resolveRelativeHref(target, from, resolveFile, hasRoute);
182
293
  };
183
294
 
184
295
  /** Whether a page is served at a based, fragment-less path. */
185
296
  const servesPage = (route: string): boolean => servesRoute(routes, route);
186
297
 
187
298
  return (text, page) => {
188
- const locale = localeByRoute.get(page.route);
189
- // The page's locale moves root links only when it has a URL prefix.
190
- const localize =
191
- i18n && locale !== undefined && localePrefix(locale, i18n) !== ""
192
- ? { basePath, deployBase, i18n, locale, routes }
193
- : undefined;
194
- // A root-relative link only changes when the site is mounted under a
195
- // prefix or the page reads in a prefixed locale.
196
- const rootLinks =
197
- deployBase !== "" || basePath !== "" || localize !== undefined;
299
+ const rootLinks = rootLinksOf(page.route);
300
+ const localize = rootLinks?.localize;
198
301
  if (
199
- !(page.sourcePath || rootLinks) ||
302
+ !(page.sourcePath || page.entryId || rootLinks) ||
200
303
  !(
201
304
  (rootLinks ? MAYBE_PAGE_LINK : MAYBE_RELATIVE).test(text) ||
202
305
  MAYBE_ROOT_FILE.test(text)
@@ -230,20 +333,24 @@ export const relativeLinkRewriter = (
230
333
  if (route === undefined) {
231
334
  return undefined;
232
335
  }
233
- const based = mountBasePath(deployBase, route);
234
- return localize ? localizeHref(based, localize) : based;
336
+ const landed = land(route);
337
+ return localize ? localizeHref(landed, localize) : landed;
235
338
  };
236
339
  const routed = (target: string, raw: boolean): string | undefined => {
237
340
  if (!isInternalPath(target)) {
238
- return relative?.(target);
341
+ const route = raw
342
+ ? resolveRawRelativeHref(target, page.route, hasRoute)
343
+ : relative?.(target);
344
+ return route === undefined ? undefined : land(route);
239
345
  }
240
- return (raw ? undefined : rootFile(target)) ?? rooted(target);
346
+ const file = raw ? undefined : rootFile(target);
347
+ return file ?? (rootLinks ? rooted(target) : undefined);
241
348
  };
242
349
  // An image is always a file: `public/` (or a generated asset endpoint),
243
350
  // served under the deployment base but never `basePath`. A relative one
244
351
  // is left to `core/content-assets.ts`.
245
352
  const imaged = (target: string): string | undefined => {
246
- const based = withBasePath(deployBase, target);
353
+ const based = rootLinks ? withBasePath(deployBase, target) : target;
247
354
  return based === target ? undefined : based;
248
355
  };
249
356
 
@@ -14,6 +14,7 @@ import type { NavNode, PageRecord } from "../core/types.ts";
14
14
  import { resolveReferences } from "../openapi/references.ts";
15
15
  import { docsPhrase } from "./ai-catalog.ts";
16
16
  import { API_PAGES_PATH } from "./api/paths.ts";
17
+ import { linkText } from "./link-text.ts";
17
18
  import { asciiSlugify } from "./mcp/discovery.ts";
18
19
  import {
19
20
  SKILL_DESCRIPTION_MAX,
@@ -54,10 +55,6 @@ export const siteSkillName = (project: BlumeProject): string => {
54
55
  return name.slice(0, NAME_MAX).replace(/-+$/u, "");
55
56
  };
56
57
 
57
- /** A title as Markdown link text (brackets escaped, as in llms.txt). */
58
- const linkText = (title: string): string =>
59
- title.replaceAll(/[\\[\]]/gu, String.raw`\$&`);
60
-
61
58
  /**
62
59
  * Current-version, default-locale pages worth listing: not API operations,
63
60
  * and not changelog entries, which the changelog index covers in one line.
@@ -88,6 +88,7 @@ import type { Diagnostic, Navigation } from "../core/types.ts";
88
88
  import { getBlumeVersion } from "../core/version.ts";
89
89
  import { servedFiles } from "../deploy/generated-files.ts";
90
90
  import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
91
+ import { hreflangAlternates } from "../deploy/sitemap.ts";
91
92
  import {
92
93
  languageIconCss,
93
94
  languageIconSlugsIn,
@@ -1204,6 +1205,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1204
1205
  i18n?.defaultLocale
1205
1206
  );
1206
1207
 
1208
+ const hreflang = hreflangAlternates(project);
1209
+
1207
1210
  const navigationByLocale = i18n
1208
1211
  ? Object.fromEntries(
1209
1212
  i18n.locales.map(({ code }) => [
@@ -1273,6 +1276,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1273
1276
  }
1274
1277
  : null,
1275
1278
  imageZoom: config.markdown.imageZoom,
1279
+ jsonLd: config.seo.jsonLd,
1276
1280
  logo,
1277
1281
  mcp: config.agents.mcp.enabled
1278
1282
  ? {
@@ -1293,6 +1297,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1293
1297
  og: {
1294
1298
  description: resolveOgDescription(config),
1295
1299
  enabled: config.seo.og.enabled ?? false,
1300
+ image: config.seo.og.image,
1296
1301
  logo: ogLogo,
1297
1302
  palette: config.seo.og.palette,
1298
1303
  site: resolveOgSite(config),
@@ -1353,12 +1358,14 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1353
1358
  entryId: route.entryId,
1354
1359
  fallback: route.fallback ?? false,
1355
1360
  hidden: route.hidden,
1361
+ hreflang: hreflang.get(route.path) ?? [],
1356
1362
  id: route.id,
1357
1363
  indexable: route.indexable,
1358
1364
  lastModified: route.lastModified ?? null,
1359
1365
  locale: route.locale,
1360
1366
  monolingual: route.monolingual ?? false,
1361
1367
  path: route.path,
1368
+ published: route.published ?? null,
1362
1369
  title: route.title,
1363
1370
  version: route.version,
1364
1371
  versionAlternates: route.versionAlternates,
@@ -2512,6 +2512,7 @@ import RootLayout from "blume/components/layout/RootLayout.astro";
2512
2512
  import { withBase, withMountedBase } from "blume/components/islands/base-path.ts";
2513
2513
  import { mountBasePath, stripBasePath } from "blume/core/base-path.ts";
2514
2514
  import { routeSetFor, servesRoute } from "blume/core/locale-links.ts";
2515
+ import { resolveLocalizable } from "blume/core/localizable.ts";
2515
2516
  import { resolveSlot } from "blume/components/layout/overrides.ts";
2516
2517
  ${componentImports}
2517
2518
  import data from "blume:data";
@@ -2536,8 +2537,10 @@ export function getStaticPaths() {
2536
2537
  editUrl: route.editUrl,
2537
2538
  entryId: route.entryId,
2538
2539
  fallback: route.fallback,
2540
+ hreflang: route.hreflang,
2539
2541
  indexable: route.indexable,
2540
2542
  lastModified: route.lastModified,
2543
+ published: route.published,
2541
2544
  locale: route.locale,
2542
2545
  monolingual: route.monolingual,
2543
2546
  route: route.path,
@@ -2548,7 +2551,7 @@ export function getStaticPaths() {
2548
2551
  }));
2549
2552
  }
2550
2553
 
2551
- const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback, monolingual, version, versionAlternates } = Astro.props;
2554
+ const { entryId, collection, route, title, indexable, editUrl, lastModified, published, locale, alternates, hreflang, fallback, monolingual, version, versionAlternates } = Astro.props;
2552
2555
  const entry = await getEntry(collection as CollectionKey, entryId);
2553
2556
  if (!entry) {
2554
2557
  return new Response(null, { status: 404 });
@@ -2598,19 +2601,23 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
2598
2601
  const ogPath = data.config.og.enabled
2599
2602
  ? encodeURI(\`/og/\${route === "/" ? "index" : route.slice(1)}.png\`)
2600
2603
  : null;
2601
- // Absolute URLs also carry the deployment base (the page is served under it):
2602
- // \`site + base + path\`. Only absolutize root-relative paths: \`seo.image\` may be
2604
+ // Root-relative paths carry the deployment base (the page is served under it),
2605
+ // and the site too once it's known: \`site + base + path\`. \`seo.image\` may be
2603
2606
  // an external URL, which passes through verbatim (mirrors PageLayout). An
2604
- // authored \`seo.image\` keeps a base written by hand; the generated card is
2605
- // always mounted under it.
2607
+ // authored image keeps a base written by hand; the generated card is always
2608
+ // mounted under it.
2606
2609
  const absoluteOg = (path: string, based: (path: string) => string) =>
2607
- base && path.startsWith("/") ? \`\${base}\${based(path)}\` : path;
2608
- const ogImage = seo.image
2609
- ? absoluteOg(seo.image, withBase)
2610
+ path.startsWith("/") ? \`\${base ?? ""}\${based(path)}\` : path;
2611
+ // The page's own image, else the site's default for its locale.
2612
+ const selectedOgImage =
2613
+ seo.image ??
2614
+ (data.config.og.image && resolveLocalizable(data.config.og.image, locale, data.config.i18n?.defaultLocale));
2615
+ const ogImage = selectedOgImage
2616
+ ? absoluteOg(selectedOgImage, withBase)
2610
2617
  : ogPath && absoluteOg(ogPath, withMountedBase);
2611
- // Blume's generated card has known dimensions the layout can declare; a user's
2612
- // \`seo.image\` could be any size or format, so it gets none.
2613
- const ogGenerated = !seo.image && Boolean(ogPath);
2618
+ // Blume's generated card has known dimensions the layout can declare; an
2619
+ // authored image could be any size or format, so it gets none.
2620
+ const ogGenerated = !selectedOgImage && Boolean(ogPath);
2614
2621
 
2615
2622
  // X attribution: the site's account, plus a creator the page can claim for
2616
2623
  // itself (a guest post crediting its own author) over the configured default.
@@ -2706,11 +2713,12 @@ const canonical =
2706
2713
  : null);
2707
2714
  const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
2708
2715
 
2716
+ // hreflang names only indexable translations (see \`hreflangAlternates\`).
2709
2717
  const localeAlternates =
2710
2718
  i18n && base
2711
- ? (alternates ?? []).map((alt) => ({ hreflang: alt.locale, href: absolute(alt.path) }))
2719
+ ? (hreflang ?? []).map((alt) => ({ hreflang: alt.locale, href: absolute(alt.path) }))
2712
2720
  : [];
2713
- const defaultAlt = i18n ? (alternates ?? []).find((alt) => alt.locale === i18n.defaultLocale) : null;
2721
+ const defaultAlt = i18n ? (hreflang ?? []).find((alt) => alt.locale === i18n.defaultLocale) : null;
2714
2722
  const xDefault = defaultAlt && base ? absolute(defaultAlt.path) : null;
2715
2723
 
2716
2724
  // \`route\` arrives with \`basePath\` already applied, so the locale segment it
@@ -2874,7 +2882,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2874
2882
  pageType={frontmatter.type}
2875
2883
  apiRail={remarkPluginFrontmatter?.${API_RAIL_KEY} === true}
2876
2884
  pageMode={frontmatter.mode}
2877
- published={frontmatter.date ?? frontmatter.changelog?.date ?? null}
2885
+ published={frontmatter.date ?? frontmatter.changelog?.date ?? published}
2886
+ authors={frontmatter.authors}
2878
2887
  lastModified={lastModified}
2879
2888
  noindex={effectiveNoindex}
2880
2889
  structuredDataEnabled={data.config.structuredData}
@@ -2922,8 +2931,9 @@ export const changelogIndexTemplate = (options: {
2922
2931
  // Generated by Blume. Do not edit.
2923
2932
  import RootLayout from "blume/components/layout/RootLayout.astro";
2924
2933
  import Changelog from "blume/components/content/Changelog.astro";
2925
- import { withMountedBase } from "blume/components/islands/base-path.ts";
2934
+ import { withBase, withMountedBase } from "blume/components/islands/base-path.ts";
2926
2935
  import { resolveSlot } from "blume/components/layout/overrides.ts";
2936
+ import { resolveLocalizable } from "blume/core/localizable.ts";
2927
2937
  import { layoutOverrides } from "../generated/components.ts";
2928
2938
  import data from "blume:data";
2929
2939
 
@@ -2945,11 +2955,17 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
2945
2955
  const basedRoute = withMountedBase("/changelog");
2946
2956
  const canonical = base ? base + basedRoute : null;
2947
2957
 
2948
- // The generated OG card for this route (the /og endpoint emits it alongside
2949
- // the content-route cards), absolutized like the catch-all's so crawlers get
2950
- // a full URL when the site is known.
2958
+ // The site's default image, else the generated OG card for this route (the /og
2959
+ // endpoint emits it alongside the content-route cards), absolutized like the
2960
+ // catch-all's so crawlers get a full URL when the site is known.
2961
+ const defaultOgImage =
2962
+ data.config.og.image && resolveLocalizable(data.config.og.image, htmlLang, i18n?.defaultLocale);
2951
2963
  const ogPath = data.config.og.enabled ? withMountedBase("/og/changelog.png") : null;
2952
- const ogImage = ogPath && base ? base + ogPath : ogPath;
2964
+ const ogImage = defaultOgImage
2965
+ ? defaultOgImage.startsWith("/")
2966
+ ? (base ?? "") + withBase(defaultOgImage)
2967
+ : defaultOgImage
2968
+ : ogPath && base ? base + ogPath : ogPath;
2953
2969
 
2954
2970
  // The page chrome (h1, title, description) comes from the translatable
2955
2971
  // \`changelog\` group, which carries the \`changelog\` config's title and
@@ -2997,7 +3013,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2997
3013
  searchEnabled={data.config.search.enabled}
2998
3014
  indexable={true}
2999
3015
  ogImage={ogImage}
3000
- ogGenerated={Boolean(ogImage)}
3016
+ ogGenerated={!defaultOgImage && Boolean(ogImage)}
3001
3017
  x={data.config.x}
3002
3018
  canonical={canonical}
3003
3019
  exportPdf={${options.exportPdf}}
@@ -115,11 +115,17 @@ const rowClass =
115
115
  "flex w-full items-center gap-2.5 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:text-foreground";
116
116
 
117
117
  // Items inside the floating popovers keep menu affordances (padding + hover
118
- // background).
118
+ // background). Their icons sit in a box one line tall, so a label that wraps
119
+ // keeps them beside its first line.
119
120
  const menuRowClass =
120
- "flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground";
121
+ "flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground";
122
+ const menuIconClass = "flex h-lh shrink-0 items-center";
123
+ // At most out to the rail's edge, past its padding (`--blume-rail-inset`, set
124
+ // by the layout), less 2px so the border never lands on the edge: the rail
125
+ // scrolls, so it clips anything wider. Longer labels wrap instead. The minimum
126
+ // width gives way to that cap in a narrower rail.
121
127
  const panelClass =
122
- "absolute top-full right-2 z-50 mt-1 w-max min-w-[14rem] max-w-[22rem] rounded-blume border border-border bg-background p-1 shadow-xl";
128
+ "absolute top-full end-0 z-50 mt-1 w-max [--blume-menu-room:calc(100%+var(--blume-rail-inset,1rem)-2px)] min-w-[min(14rem,var(--blume-menu-room))] max-w-(--blume-menu-room) wrap-anywhere rounded-blume border border-border bg-background p-1 shadow-xl";
123
129
  ---
124
130
 
125
131
  <div
@@ -172,13 +178,17 @@ const panelClass =
172
178
  <div class={panelClass} data-blume-menu>
173
179
  {exportPdf && (
174
180
  <button class={menuRowClass} data-blume-export-pdf type="button">
175
- <Icon name="file" size={16} />
181
+ <span class={menuIconClass}>
182
+ <Icon name="file" size={16} />
183
+ </span>
176
184
  <span class="flex-1">{a.exportPdf}</span>
177
185
  </button>
178
186
  )}
179
187
  {exportEpub && (
180
188
  <button class={menuRowClass} data-blume-export-epub type="button">
181
- <Icon name="book-open" size={16} />
189
+ <span class={menuIconClass}>
190
+ <Icon name="book-open" size={16} />
191
+ </span>
182
192
  <span class="flex-1" data-blume-epub-label>
183
193
  {a.exportEpub}
184
194
  </span>
@@ -214,9 +224,11 @@ const panelClass =
214
224
  data-open-in={provider.key}
215
225
  {...NEW_TAB_ATTRS}
216
226
  >
217
- <span set:html={LOGOS[provider.key]} />
227
+ <span class={menuIconClass} set:html={LOGOS[provider.key]} />
218
228
  <span class="flex-1">{provider.label}</span>
219
- <Icon name="external-link" size={13} />
229
+ <span class={menuIconClass}>
230
+ <Icon name="external-link" size={13} />
231
+ </span>
220
232
  </a>
221
233
  </>
222
234
  ))}
@@ -241,7 +253,9 @@ const panelClass =
241
253
  </summary>
242
254
  <div class={panelClass} data-blume-menu>
243
255
  <button class={menuRowClass} data-mcp-copy-url type="button">
244
- <Icon name="link" size={16} />
256
+ <span class={menuIconClass}>
257
+ <Icon name="link" size={16} />
258
+ </span>
245
259
  <span class="flex-1" data-mcp-url-label>
246
260
  {a.copyServerUrl}
247
261
  </span>
@@ -255,7 +269,7 @@ const panelClass =
255
269
  data-mcp-copy-claude
256
270
  type="button"
257
271
  >
258
- <span set:html={LOGOS.claude} />
272
+ <span class={menuIconClass} set:html={LOGOS.claude} />
259
273
  <span class="flex-1" data-mcp-claude-label>
260
274
  {a.copyClaudeCode}
261
275
  </span>
@@ -263,7 +277,7 @@ const panelClass =
263
277
  )}
264
278
  {client === "codex" && (
265
279
  <button class={menuRowClass} data-mcp-copy-codex type="button">
266
- <span set:html={LOGOS.codex} />
280
+ <span class={menuIconClass} set:html={LOGOS.codex} />
267
281
  <span class="flex-1" data-mcp-codex-label>
268
282
  {a.copyCodex}
269
283
  </span>
@@ -271,16 +285,22 @@ const panelClass =
271
285
  )}
272
286
  {client === "cursor" && (
273
287
  <a class={menuRowClass} data-mcp-cursor rel="noreferrer">
274
- <span set:html={LOGOS.cursor} />
288
+ <span class={menuIconClass} set:html={LOGOS.cursor} />
275
289
  <span class="flex-1">{a.addToCursor}</span>
276
- <Icon name="external-link" size={13} />
290
+ <span class={menuIconClass}>
291
+ <Icon name="external-link" size={13} />
292
+ </span>
277
293
  </a>
278
294
  )}
279
295
  {client === "vscode" && (
280
296
  <a class={menuRowClass} data-mcp-vscode rel="noreferrer">
281
- <Icon name="code" size={16} />
297
+ <span class={menuIconClass}>
298
+ <Icon name="code" size={16} />
299
+ </span>
282
300
  <span class="flex-1">{a.addToVscode}</span>
283
- <Icon name="external-link" size={13} />
301
+ <span class={menuIconClass}>
302
+ <Icon name="external-link" size={13} />
303
+ </span>
284
304
  </a>
285
305
  )}
286
306
  {!isBuiltInMcpClient(client) && (
@@ -289,7 +309,9 @@ const panelClass =
289
309
  data-mcp-copy-command={client.command}
290
310
  type="button"
291
311
  >
292
- <Icon name={client.icon ?? "terminal"} size={16} />
312
+ <span class={menuIconClass}>
313
+ <Icon name={client.icon ?? "terminal"} size={16} />
314
+ </span>
293
315
  <span class="flex-1" data-mcp-command-label>
294
316
  {client.label}
295
317
  </span>
@@ -442,10 +464,15 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
442
464
  return;
443
465
  }
444
466
  const rect = summary.getBoundingClientRect();
467
+ // The rail scrolls, so it clips the menu at its own edges: its top sits
468
+ // under the sticky header, below the viewport's.
469
+ const room = details
470
+ .closest("[data-blume-toc]")
471
+ ?.getBoundingClientRect() ?? { bottom: window.innerHeight, top: 0 };
445
472
  const margin = 8;
446
473
  const flipUp =
447
- rect.bottom + menu.offsetHeight + margin > window.innerHeight &&
448
- rect.top - menu.offsetHeight - margin > 0;
474
+ rect.bottom + menu.offsetHeight + margin > room.bottom &&
475
+ rect.top - menu.offsetHeight - margin > room.top;
449
476
  menu.classList.toggle("top-full", !flipUp);
450
477
  menu.classList.toggle("mt-1", !flipUp);
451
478
  menu.classList.toggle("bottom-full", flipUp);
@@ -513,7 +540,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
513
540
  }
514
541
  // The action dropdowns are native <details>. Make them behave like a proper
515
542
  // menu: only one open at a time, and flip above the trigger when opening
516
- // downward would run past the viewport bottom.
543
+ // downward would run past the rail's bottom and there's room above.
517
544
  const dropdowns = [...root.querySelectorAll<HTMLDetailsElement>("details")];
518
545
  for (const details of dropdowns) {
519
546
  details.addEventListener("toggle", () => {