blume 2.2.2 → 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 (133) hide show
  1. package/CHANGELOG.md +10 -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/PageLayout.astro +25 -12
  83. package/src/components/layout/RootLayout.astro +7 -0
  84. package/src/components/layout/locale-redirect.ts +38 -19
  85. package/src/components/layout/search/types.ts +12 -4
  86. package/src/core/config-input.ts +34 -1
  87. package/src/core/config.ts +5 -2
  88. package/src/core/data.ts +14 -0
  89. package/src/core/last-modified.ts +80 -34
  90. package/src/core/links.ts +43 -2
  91. package/src/core/manifest.ts +2 -0
  92. package/src/core/project-graph.ts +83 -43
  93. package/src/core/schema.ts +47 -9
  94. package/src/core/types.ts +10 -0
  95. package/src/deploy/sitemap.ts +127 -49
  96. package/src/search/documents.ts +34 -11
  97. package/src/search/fold.ts +175 -0
  98. package/src/search/orama-index.ts +40 -49
  99. package/src/seo/jsonld.ts +100 -23
  100. package/dist/cli/chunk-b680sv30.js.map +0 -13
  101. package/dist/cli/chunk-q1wwrxzw.js.map +0 -10
  102. package/dist/cli/chunk-qcyxkj44.js.map +0 -14
  103. /package/dist/cli/{chunk-080mfch6.js.map → chunk-0wt5amrk.js.map} +0 -0
  104. /package/dist/cli/{chunk-f260jdrn.js.map → chunk-0ynr0fr7.js.map} +0 -0
  105. /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-1ay3hzn0.js.map} +0 -0
  106. /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-426rytg6.js.map} +0 -0
  107. /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-5f96yq2c.js.map} +0 -0
  108. /package/dist/cli/{chunk-x60e15st.js.map → chunk-5xq12jz7.js.map} +0 -0
  109. /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6xag1c83.js.map} +0 -0
  110. /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-88zkpg89.js.map} +0 -0
  111. /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-8fm3b4vm.js.map} +0 -0
  112. /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-8h2t2vyj.js.map} +0 -0
  113. /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-982qfeny.js.map} +0 -0
  114. /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-9emfspcp.js.map} +0 -0
  115. /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-akj2p7k1.js.map} +0 -0
  116. /package/dist/cli/{chunk-beb0z983.js.map → chunk-b664x65a.js.map} +0 -0
  117. /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-b7n58hgj.js.map} +0 -0
  118. /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-c0qddrxr.js.map} +0 -0
  119. /package/dist/cli/{chunk-p834rkcn.js.map → chunk-g62vnvaw.js.map} +0 -0
  120. /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-p4fe7tax.js.map} +0 -0
  121. /package/dist/cli/{chunk-34wap3ed.js.map → chunk-psg2za5z.js.map} +0 -0
  122. /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-r5ajrw1g.js.map} +0 -0
  123. /package/dist/cli/{chunk-69rjf786.js.map → chunk-ry2ymbxf.js.map} +0 -0
  124. /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-t43g7v22.js.map} +0 -0
  125. /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-x0jqyq1g.js.map} +0 -0
  126. /package/dist/cli/{chunk-aafhwx74.js.map → chunk-x25s4gph.js.map} +0 -0
  127. /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-xkx216rd.js.map} +0 -0
  128. /package/dist/cli/{chunk-etckvbrh.js.map → chunk-xv8nkt56.js.map} +0 -0
  129. /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-yrmw5n2b.js.map} +0 -0
  130. /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-zgca0vxs.js.map} +0 -0
  131. /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-zj6bcx0n.js.map} +0 -0
  132. /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-zjfwzrvd.js.map} +0 -0
  133. /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}}
@@ -41,6 +41,7 @@ import { normalizeXHandle } from "../../seo/x-handle.ts";
41
41
  import Icon from "../Icon.astro";
42
42
  import { stripBase, withBase, withMountedBase } from "../islands/base-path.ts";
43
43
  import { routeSetFor } from "../../core/locale-links.ts";
44
+ import { resolveLocalizable } from "../../core/localizable.ts";
44
45
  import {
45
46
  browserLanguageTargets,
46
47
  LOCALE_REDIRECT_SCRIPT,
@@ -265,20 +266,30 @@ const resolvedCanonical =
265
266
  (siteBase
266
267
  ? `${siteBase}${basedRoute === "/" ? "/" : encodeURI(basedRoute)}`
267
268
  : null);
268
- // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
269
- // `public/`) is resolved against the site URL so crawlers get an absolute
270
- // `og:image`; an already-absolute URL passes through untouched. Otherwise fall
271
- // back to the generated OG card for this route.
269
+ // An explicit `ogImage` wins, then the site's default image for this locale. A
270
+ // root-relative path (e.g. an image dropped in `public/`) is served under the
271
+ // deployment base and resolved against the site URL so crawlers get an
272
+ // absolute `og:image`; an already-absolute URL passes through untouched.
273
+ // Otherwise fall back to the generated OG card for this route.
272
274
  const absolutizeOgImage = (value: string): string =>
273
- value.startsWith("/") && siteBase ? `${siteBase}${withBase(value)}` : value;
274
- const resolvedOgImage = ogImage
275
- ? absolutizeOgImage(ogImage)
275
+ value.startsWith("/") ? `${siteBase ?? ""}${withBase(value)}` : value;
276
+ const selectedOgImage =
277
+ ogImage ??
278
+ (data.config.og.image &&
279
+ resolveLocalizable(
280
+ data.config.og.image,
281
+ locale,
282
+ data.config.i18n?.defaultLocale
283
+ ));
284
+ const resolvedOgImage = selectedOgImage
285
+ ? absolutizeOgImage(selectedOgImage)
276
286
  : ogEnabled && ogCardGenerated && siteBase
277
287
  ? `${siteBase}${encodeURI(withMountedBase(`/og/${ogSlug}.png`))}`
278
288
  : null;
279
289
  // Only the generated card has a known size and format, so its dimensions are
280
- // declared for crawlers; a user-supplied `ogImage` could be any image.
281
- const ogGenerated = !ogImage && Boolean(resolvedOgImage);
290
+ // declared for crawlers; any other image could be any size. The size and alt
291
+ // props describe the page's own `ogImage`, never the site default.
292
+ const ogGenerated = !selectedOgImage && Boolean(resolvedOgImage);
282
293
  // A page with no image still gets a card on X — the compact `summary` variant
283
294
  // — rather than rendering as a bare link.
284
295
  const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
@@ -297,6 +308,8 @@ const structuredData =
297
308
  breadcrumbs: [],
298
309
  description,
299
310
  identity: data.config.identity,
311
+ jsonLd: data.config.jsonLd,
312
+ image: resolvedOgImage,
300
313
  locale,
301
314
  modified: lastModified,
302
315
  route,
@@ -386,7 +399,7 @@ const localeRedirect = localeTargets
386
399
  <meta content={pageTitle} property="og:image:alt" />
387
400
  </>
388
401
  )}
389
- {!ogGenerated && ogImageSize && (
402
+ {ogImage && ogImageSize && (
390
403
  <>
391
404
  <meta
392
405
  content={String(ogImageSize.width)}
@@ -398,12 +411,12 @@ const localeRedirect = localeTargets
398
411
  />
399
412
  </>
400
413
  )}
401
- {!ogGenerated && ogImageAlt && (
414
+ {ogImage && ogImageAlt && (
402
415
  <meta content={ogImageAlt} property="og:image:alt" />
403
416
  )}
404
417
  <meta content={resolvedOgImage} name="twitter:image" />
405
418
  {ogGenerated && <meta content={pageTitle} name="twitter:image:alt" />}
406
- {!ogGenerated && ogImageAlt && (
419
+ {ogImage && ogImageAlt && (
407
420
  <meta content={ogImageAlt} name="twitter:image:alt" />
408
421
  )}
409
422
  </>
@@ -43,6 +43,7 @@ import {
43
43
  OG_IMAGE_WIDTH,
44
44
  } from "../../og/dimensions.ts";
45
45
  import { buildStructuredData, toIso } from "../../seo/jsonld.ts";
46
+ import type { AuthorInput } from "../../seo/jsonld.ts";
46
47
  import { normalizeXHandle } from "../../seo/x-handle.ts";
47
48
  import { withBase, withMountedBase } from "../islands/base-path.ts";
48
49
  import { routeSetFor } from "../../core/locale-links.ts";
@@ -196,6 +197,8 @@ interface Props {
196
197
  /** The page's `mode` frontmatter: what it shows around its content (see `core/page-modes.ts`). */
197
198
  pageMode?: PageMode;
198
199
  published?: string | Date | null;
200
+ /** The page's front matter `authors`, credited in its JSON-LD. */
201
+ authors?: AuthorInput | AuthorInput[];
199
202
  lastModified?: string | null;
200
203
  noindex?: boolean;
201
204
  structuredDataEnabled?: boolean;
@@ -312,6 +315,7 @@ const {
312
315
  apiRail = false,
313
316
  pageMode,
314
317
  published,
318
+ authors,
315
319
  lastModified,
316
320
  noindex,
317
321
  structuredDataEnabled,
@@ -534,11 +538,14 @@ const structuredData =
534
538
  structuredDataEnabled === false || noindex
535
539
  ? null
536
540
  : buildStructuredData({
541
+ authors,
537
542
  base: import.meta.env.BASE_URL,
538
543
  breadcrumbs: crumbs,
539
544
  description,
540
545
  homeRoute: navigation.root,
541
546
  identity: data.config.identity,
547
+ jsonLd: data.config.jsonLd,
548
+ image: ogImage,
542
549
  locale,
543
550
  modified: lastModified,
544
551
  pageType,