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
@@ -207,10 +207,9 @@ const changelogMetaSchema = z.strictObject({
207
207
 
208
208
  /**
209
209
  * A post author: a bare name/handle, or an object with a name plus optional
210
- * avatar/URL. The object is passthrough so richer author metadata (social
211
- * handles, roles) survives untouched — Blume doesn't render authors yet, so
212
- * this exists to preserve the field (common on blog/changelog pages) rather
213
- * than have a strict scan reject it.
210
+ * avatar/URL. Blume credits the name and URL in the page's JSON-LD; the object
211
+ * is passthrough so richer author metadata (social handles, roles) survives
212
+ * untouched rather than have a strict scan reject it.
214
213
  */
215
214
  const authorSchema = z.union([
216
215
  z.string(),
@@ -254,7 +253,7 @@ const pageMetaBaseSchema = z.strictObject({
254
253
  .optional(),
255
254
  /** How this page's endpoint authenticates, over the site's `api.auth`. */
256
255
  authMethod: z.enum(AUTH_METHODS).optional(),
257
- /** Post author(s) for blog/changelog content; preserved, not yet rendered. */
256
+ /** Post author(s) for blog/changelog content, credited in JSON-LD. */
258
257
  authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
259
258
  changelog: changelogMetaSchema.optional(),
260
259
  /** Publish date for feed-backed content like blog/changelog. */
@@ -1609,8 +1608,9 @@ const ogConfigSchema = z.strictObject({
1609
1608
  /**
1610
1609
  * Generate a per-page Open Graph image. Defaults to on once a deployment
1611
1610
  * site URL is known (set or auto-detected) and off otherwise, since
1612
- * `og:image` must be absolute to be useful to crawlers — resolved in
1613
- * `loadConfig`. An explicit value here always wins.
1611
+ * `og:image` must be absolute to be useful to crawlers, and off when `image`
1612
+ * sets a default every page would show instead — resolved in `loadConfig`.
1613
+ * An explicit value here always wins.
1614
1614
  */
1615
1615
  enabled: z.boolean().optional(),
1616
1616
  /**
@@ -1619,6 +1619,13 @@ const ogConfigSchema = z.strictObject({
1619
1619
  * families are fetched from Google Fonts at build.
1620
1620
  */
1621
1621
  fonts: z.array(ogFontSchema).optional(),
1622
+ /**
1623
+ * Default social image for pages without their own `seo.image`, in place of
1624
+ * the generated card: one path or URL, or one per locale (resolved like any
1625
+ * localizable label). A root-relative path is served under the deployment
1626
+ * base.
1627
+ */
1628
+ image: localizableLabelSchema.optional(),
1622
1629
  /**
1623
1630
  * Local SVG used in the generated card instead of the site logo; `false`
1624
1631
  * renders the card without any brand mark.
@@ -1728,6 +1735,29 @@ type SoftwareResolved = z.output<typeof softwareConfigSchema>;
1728
1735
 
1729
1736
  /** Discoverability features: OG images, feeds, sitemap, structured data. */
1730
1737
  const seoConfigFields = {
1738
+ /**
1739
+ * Date pages without a front matter `date` by the git commit that added
1740
+ * their source file, following exact renames. Off by default.
1741
+ */
1742
+ datePublished: z.union([z.literal(false), z.literal("git")]).default(false),
1743
+ /**
1744
+ * Structured-data entities shared with a parent site (`example.com/docs`),
1745
+ * referenced by absolute `@id` instead of defined again (see `seo/jsonld.ts`).
1746
+ */
1747
+ jsonLd: z
1748
+ .strictObject({
1749
+ /** Credited on pages without their own `authors`. */
1750
+ author: z.strictObject({ "@id": z.url() }).optional(),
1751
+ /** The Organization's `@id`; `seo.organization` still adds its details. */
1752
+ organizationId: z.url().optional(),
1753
+ /** Every page's publisher, over the Organization. */
1754
+ publisher: z.strictObject({ "@id": z.url() }).optional(),
1755
+ /** Emit the WebSite node. Defaults to true unless `websiteId` is set. */
1756
+ website: z.boolean().optional(),
1757
+ /** The WebSite's `@id`, which every page names as `isPartOf`. */
1758
+ websiteId: z.url().optional(),
1759
+ })
1760
+ .prefault({}),
1731
1761
  /**
1732
1762
  * Meta tags written into every page's head, name to content: site
1733
1763
  * verification, `theme-color`, and anything else Blume has no setting for.
@@ -1754,8 +1784,16 @@ const seoConfigFields = {
1754
1784
  /** Generate robots.txt (with a Sitemap reference when available). */
1755
1785
  robots: z.boolean().default(true),
1756
1786
  rss: rssConfigSchema.prefault({}),
1757
- /** Generate sitemap.xml (requires deployment.site). */
1758
- sitemap: z.boolean().default(true),
1787
+ /**
1788
+ * Generate sitemap.xml (requires deployment.site). `{ alternates: true }`
1789
+ * also lists each page's translations as hreflang links.
1790
+ */
1791
+ sitemap: z
1792
+ .union([
1793
+ z.boolean(),
1794
+ z.strictObject({ alternates: z.boolean().default(false) }),
1795
+ ])
1796
+ .default(true),
1759
1797
  /** The documented product, as a homepage `SoftwareApplication` node. */
1760
1798
  software: z
1761
1799
  .union([z.boolean(), softwareConfigSchema])
package/src/core/types.ts CHANGED
@@ -256,6 +256,11 @@ export interface PageRecord {
256
256
  unknownProps?: ElementUse[];
257
257
  /** Resolved "last updated" ISO date, when the feature is enabled. */
258
258
  lastModified?: string;
259
+ /**
260
+ * The git commit that added the source file, as an ISO date, when
261
+ * `seo.datePublished` is `"git"` and front matter carries no `date`.
262
+ */
263
+ published?: string;
259
264
  /** Absolute paths of files this page `<include>`s, transitively. Drives the
260
265
  * dev-server invalidation edge from a partial to the pages that splice it. */
261
266
  includes?: string[];
@@ -484,6 +489,11 @@ export interface RouteManifestEntry {
484
489
  aiExclude?: boolean;
485
490
  /** Resolved "last updated" ISO date, when the feature is enabled. */
486
491
  lastModified?: string;
492
+ /**
493
+ * The git commit that added the source file, as an ISO date, when
494
+ * `seo.datePublished` is `"git"` and front matter carries no `date`.
495
+ */
496
+ published?: string;
487
497
  }
488
498
 
489
499
  /** The generated runtime contract between core and the Astro project. */
@@ -14,6 +14,7 @@ import {
14
14
  import { isHiddenPage } from "../core/hidden-pages.ts";
15
15
  import type { BlumeProject } from "../core/project-graph.ts";
16
16
  import { siteRoot } from "../core/site-url.ts";
17
+ import type { RouteAlternate } from "../core/types.ts";
17
18
 
18
19
  /**
19
20
  * Astro's reserved error routes. A user-authored override (`pages/404.astro`,
@@ -79,13 +80,95 @@ export const describeSitemapFiles = (files: readonly SitemapFile[]): string =>
79
80
  const URLS_PER_FILE = 50_000;
80
81
 
81
82
  const renderUrlset = (
82
- urls: string[]
83
+ urls: string[],
84
+ alternatesEnabled: boolean
83
85
  ): string => `<?xml version="1.0" encoding="UTF-8"?>
84
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
86
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${alternatesEnabled ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ""}>
85
87
  ${urls.join("\n")}
86
88
  </urlset>
87
89
  `;
88
90
 
91
+ /**
92
+ * The content pages crawlers may index under their own URL: drafts, hidden,
93
+ * `noindex` (the page's or its archived version's), and error pages can't, nor
94
+ * can a page whose own `seo.canonical` names another URL.
95
+ */
96
+ const indexablePages = (
97
+ project: BlumeProject
98
+ ): BlumeProject["graph"]["pages"] => {
99
+ const deployBase = normalizeBasePath(project.config.deployment.options.base);
100
+ // A non-empty version always names a configured archived entry — that is
101
+ // the only way detection assigns one.
102
+ const noindexVersions = new Set(
103
+ (project.config.versions?.archived ?? []).flatMap((version) =>
104
+ version.noindex ? [version.id] : []
105
+ )
106
+ );
107
+ return project.graph.pages.filter(
108
+ (page) =>
109
+ !page.meta.draft &&
110
+ !isHiddenPage(page, project.graph) &&
111
+ !page.meta.seo.noindex &&
112
+ !noindexVersions.has(page.version) &&
113
+ !ERROR_ROUTES.has(page.route) &&
114
+ !canonicalElsewhere(
115
+ page.meta.seo.canonical,
116
+ mountBasePath(deployBase, page.route)
117
+ )
118
+ );
119
+ };
120
+
121
+ /**
122
+ * The content pages the sitemap lists: the indexable ones, less archived-version
123
+ * pages whose canonical points at a still-existing latest equivalent —
124
+ * listing a URL whose canonical says "index the other page" invites the
125
+ * noindexed-page-in-sitemap incoherence Docusaurus is known for. A page that
126
+ * exists only in an archived version stays listed (self-canonical), and a
127
+ * page's own `seo.canonical` wins over the version's default, as it does in
128
+ * the page head (see `canonicalElsewhere`).
129
+ */
130
+ const listedPages = (project: BlumeProject): BlumeProject["graph"]["pages"] => {
131
+ const latestVersions = new Set(
132
+ (project.config.versions?.archived ?? []).flatMap((version) =>
133
+ version.canonical === "latest" ? [version.id] : []
134
+ )
135
+ );
136
+ const currentKeys = new Set(
137
+ project.graph.pages.flatMap((page) =>
138
+ page.version === "" ? [`${page.versionKey}\u0000${page.locale}`] : []
139
+ )
140
+ );
141
+ return indexablePages(project).filter(
142
+ (page) =>
143
+ !(
144
+ latestVersions.has(page.version) &&
145
+ !page.meta.seo.canonical &&
146
+ currentKeys.has(`${page.versionKey}\u0000${page.locale}`)
147
+ )
148
+ );
149
+ };
150
+
151
+ /**
152
+ * Each route's hreflang set, keyed by route: its indexable translations. The
153
+ * page head links it, and so does the sitemap under `seo.sitemap.alternates`,
154
+ * so the two never disagree, and neither points crawlers at a page they're
155
+ * told not to index. A page that can't be indexed joins no set — except an
156
+ * i18n fallback copy, which links the translations it stands in for.
157
+ */
158
+ export const hreflangAlternates = (
159
+ project: BlumeProject
160
+ ): Map<string, RouteAlternate[]> => {
161
+ const indexable = new Set(indexablePages(project).map((page) => page.route));
162
+ return new Map(
163
+ project.manifest.routes.map((route) => [
164
+ route.path,
165
+ route.fallback || indexable.has(route.path)
166
+ ? route.alternates.filter((alternate) => indexable.has(alternate.path))
167
+ : [],
168
+ ])
169
+ );
170
+ };
171
+
89
172
  /**
90
173
  * Build the sitemap files from the route manifest plus the routes the manifest
91
174
  * can't see: custom `.astro` pages (most importantly a custom landing `/`) and
@@ -112,43 +195,38 @@ export const buildSitemapFiles = (
112
195
  // top of every one, even a route whose first segment matches it: under base
113
196
  // `/guides`, `guides/setup.md` is served at `/guides/guides/setup`.
114
197
  const deployBase = normalizeBasePath(project.config.deployment.options.base);
115
-
116
- // Archived-version pages leave the sitemap when the version is noindexed,
117
- // or when their canonical points at a still-existing latest equivalent —
118
- // listing a URL whose canonical says "index the other page" invites the
119
- // noindexed-page-in-sitemap incoherence Docusaurus is known for. A page
120
- // that exists only in an archived version stays listed (self-canonical),
121
- // and a page's own `seo.canonical` wins over the version's default, as it
122
- // does in the page head (see `canonicalElsewhere`).
123
- const { versions } = project.config;
124
- const archivedById = new Map(
125
- (versions?.archived ?? []).map((version) => [version.id, version])
126
- );
127
- const currentKeys = versions
128
- ? new Set(
129
- project.graph.pages.flatMap((page) =>
130
- page.version === "" ? [`${page.versionKey}\u0000${page.locale}`] : []
131
- )
132
- )
133
- : null;
134
- const archivedExcluded = (page: (typeof project.graph.pages)[number]) => {
135
- if (!page.version) {
136
- return false;
198
+ const alternatesEnabled =
199
+ project.config.seo.sitemap !== true &&
200
+ project.config.seo.sitemap.alternates;
201
+ const hreflang = alternatesEnabled ? hreflangAlternates(project) : null;
202
+ // A page's translations as `xhtml:link`s, plus `x-default` at the default
203
+ // locale's — the same set the page head links, for pages in 2+ locales.
204
+ const alternateTags = (route: string): string => {
205
+ const alternates = hreflang?.get(route) ?? [];
206
+ if (alternates.length < 2) {
207
+ return "";
137
208
  }
138
- // A non-empty version always names a configured archived entry — that is
139
- // the only way detection assigns one.
140
- const archived = archivedById.get(page.version);
141
- return Boolean(
142
- archived?.noindex ||
143
- (archived?.canonical === "latest" &&
144
- !page.meta.seo.canonical &&
145
- currentKeys?.has(`${page.versionKey}\u0000${page.locale}`))
209
+ const defaultPage = alternates.find(
210
+ (entry) => entry.locale === project.config.i18n?.defaultLocale
146
211
  );
212
+ const links = defaultPage
213
+ ? [...alternates, { ...defaultPage, locale: "x-default" }]
214
+ : alternates;
215
+ return links
216
+ .map(
217
+ (entry) =>
218
+ `<xhtml:link rel="alternate" hreflang="${escapeXml(entry.locale)}" href="${escapeXml(encodeURI(`${base}${mountBasePath(deployBase, entry.path)}`))}"/>`
219
+ )
220
+ .join("");
147
221
  };
148
222
 
149
223
  const seen = new Set<string>();
150
224
  const urls: string[] = [];
151
- const pushUrl = (route: string, lastModified?: string): void => {
225
+ const pushUrl = (
226
+ route: string,
227
+ lastModified?: string,
228
+ alternates = ""
229
+ ): void => {
152
230
  // `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
153
231
  // then escape XML metacharacters (notably `&`) so a route like
154
232
  // `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
@@ -158,21 +236,16 @@ export const buildSitemapFiles = (
158
236
  return;
159
237
  }
160
238
  seen.add(loc);
161
- urls.push(` <url><loc>${loc}</loc>${lastmodTag(lastModified)}</url>`);
239
+ urls.push(
240
+ ` <url><loc>${loc}</loc>${lastmodTag(lastModified)}${alternates}</url>`
241
+ );
162
242
  };
163
- for (const page of project.graph.pages) {
164
- const served = mountBasePath(deployBase, page.route);
165
- if (
166
- page.meta.draft ||
167
- isHiddenPage(page, project.graph) ||
168
- page.meta.seo.noindex ||
169
- ERROR_ROUTES.has(page.route) ||
170
- archivedExcluded(page) ||
171
- canonicalElsewhere(page.meta.seo.canonical, served)
172
- ) {
173
- continue;
174
- }
175
- pushUrl(served, page.lastModified);
243
+ for (const page of listedPages(project)) {
244
+ pushUrl(
245
+ mountBasePath(deployBase, page.route),
246
+ page.lastModified,
247
+ alternateTags(page.route)
248
+ );
176
249
  }
177
250
  // Custom `.astro` pages and the generated changelog index mount outside
178
251
  // `basePath` (they're injected at their pattern — see `blumeIntegration`), so
@@ -192,7 +265,9 @@ export const buildSitemapFiles = (
192
265
  urls.sort();
193
266
 
194
267
  if (urls.length <= URLS_PER_FILE) {
195
- return [{ name: "sitemap.xml", xml: renderUrlset(urls) }];
268
+ return [
269
+ { name: "sitemap.xml", xml: renderUrlset(urls, alternatesEnabled) },
270
+ ];
196
271
  }
197
272
 
198
273
  const chunks: SitemapFile[] = [];
@@ -201,7 +276,10 @@ export const buildSitemapFiles = (
201
276
  const name = `sitemap-${chunks.length + 1}.xml`;
202
277
  chunks.push({
203
278
  name,
204
- xml: renderUrlset(urls.slice(start, start + URLS_PER_FILE)),
279
+ xml: renderUrlset(
280
+ urls.slice(start, start + URLS_PER_FILE),
281
+ alternatesEnabled
282
+ ),
205
283
  });
206
284
  // Chunks sit next to sitemap.xml, so their URLs layer the same deployment
207
285
  // base robots.txt uses for the index.
@@ -11,6 +11,8 @@ import type {
11
11
  DownlevelWalk,
12
12
  MdastNode as DownlevelNode,
13
13
  } from "../ai/component-markdown.ts";
14
+ import { relativeLinkRewriter } from "../ai/relative-links.ts";
15
+ import type { RelativeLinkRewriter } from "../ai/relative-links.ts";
14
16
  import { projectComponentSerializers } from "../ai/serializers.ts";
15
17
  import { applyAudienceVisibility } from "../ai/visibility.ts";
16
18
  import type { VisibilityAudience } from "../ai/visibility.ts";
@@ -399,17 +401,20 @@ interface BuildOptions {
399
401
 
400
402
  /**
401
403
  * Read a page's body and reduce it per the extraction options. The
402
- * `"markdown"` body is agent-facing, so its components are downleveled in the
403
- * source with the serializers every agent surface uses (front matter in scope
404
- * for prop expressions). The plain body is parsed as written and walked, with
405
- * the same serializers applied per component on the way, so the index reads a
406
- * `<Card>` or `<TypeTable>` as the text it shows.
404
+ * `"markdown"` body, the one `rewriteLinks` comes with, is agent-facing: its
405
+ * page links are rewritten, then its components downleveled in the source
406
+ * with the serializers every agent surface uses (front matter in scope for
407
+ * prop expressions), in the order llms-full.txt takes. The plain body is
408
+ * parsed as written and walked, with the same serializers applied per
409
+ * component on the way, so the index reads a `<Card>` or `<TypeTable>` as
410
+ * the text it shows.
407
411
  */
408
412
  const pageBody = async (
409
413
  project: BlumeProject,
410
414
  page: PageRecord | undefined,
411
- options: Pick<BuildOptions, "audience" | "content"> | undefined,
412
- components: Record<string, ComponentMarkdown>
415
+ options: Pick<BuildOptions, "audience"> | undefined,
416
+ components: Record<string, ComponentMarkdown>,
417
+ rewriteLinks: RelativeLinkRewriter | undefined
413
418
  ): Promise<string> => {
414
419
  if (!page) {
415
420
  return "";
@@ -419,8 +424,12 @@ const pageBody = async (
419
424
  parsed.content,
420
425
  options?.audience ?? "web"
421
426
  );
422
- if (options?.content === "markdown") {
423
- return downlevelComponents(visible.trim(), components, parsed.data);
427
+ if (rewriteLinks) {
428
+ return downlevelComponents(
429
+ rewriteLinks(visible, page).trim(),
430
+ components,
431
+ parsed.data
432
+ );
424
433
  }
425
434
  return toPlainText(visible, page.format, {
426
435
  downlevel: {
@@ -460,7 +469,9 @@ const rankingFields = (
460
469
  * same serializers the `.md` mirror, llms-full.txt and MCP `get_page` use: a
461
470
  * page whose body is a `<CardGroup>` of `<Card>`s is prose to a reader and bare
462
471
  * JSX to anything reading the source, and section landing pages are exactly
463
- * that shape.
472
+ * that shape. Its page links point at the pages they mean, as they do there
473
+ * too, as the routes the assistant's snapshot keys those pages by (see
474
+ * `relativeLinkRewriter`'s `assistant`): the model cites the links it reads.
464
475
  *
465
476
  * `audience` resolves `<Visibility>` blocks before extraction: `"web"`
466
477
  * (default) keeps web-only content and drops agents-only blocks — the site
@@ -518,6 +529,12 @@ export const buildSearchDocuments = async (
518
529
  // Built once — `downlevelComponents` rebuilds its registry per call
519
530
  // otherwise.
520
531
  const components = projectComponentSerializers(project);
532
+ // The `"markdown"` body is the assistant's: the model cites the links it
533
+ // reads, so a page link points at the page the snapshot holds.
534
+ const rewriteLinks =
535
+ options?.content === "markdown"
536
+ ? relativeLinkRewriter(project, { assistant: true })
537
+ : undefined;
521
538
 
522
539
  // A page in no sidebar group falls under the "Docs" section, in its own
523
540
  // language: the label is a UI string, resolved once per locale.
@@ -542,7 +559,13 @@ export const buildSearchDocuments = async (
542
559
  return await Promise.all(
543
560
  indexable.map(async (route) => {
544
561
  const page = pageById.get(route.id);
545
- const body = await pageBody(project, page, options, components);
562
+ const body = await pageBody(
563
+ project,
564
+ page,
565
+ options,
566
+ components,
567
+ rewriteLinks
568
+ );
546
569
  const tags = page?.meta?.search?.tags;
547
570
  const crumb = crumbs.get(route.path);
548
571
  const facets = page ? pageFacets(page, project.config) : undefined;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Latin folding, shared by the search index and everything that finds a query
3
+ * in a page's text afterwards: the search dialog's highlights and excerpts,
4
+ * and the assistant's excerpts. The index matches Prüfung for `prufung`, so
5
+ * the rest must too, or a page found by folding shows its opening with
6
+ * nothing marked.
7
+ */
8
+
9
+ /** A Latin letter and the combining marks on it, which NFD splits off. */
10
+ const LATIN_ACCENTS = /(?<letter>\p{Script=Latin})\p{M}+/gu;
11
+
12
+ /** A Latin character outside ASCII, once NFD has split off its accents. */
13
+ const NON_ASCII_LATIN = /(?=\P{ASCII})\p{Script=Latin}/gu;
14
+
15
+ /**
16
+ * Latin letters NFD leaves whole, spelled the way an unaccented query types
17
+ * them. Orama's own diacritics table folds the same letters, though its
18
+ * English splitter cuts words at them first.
19
+ */
20
+ const LATIN_FOLDS = new Map([
21
+ ["ß", "ss"],
22
+ ["æ", "ae"],
23
+ ["œ", "oe"],
24
+ ["ø", "o"],
25
+ ["ł", "l"],
26
+ ["đ", "d"],
27
+ ["ð", "d"],
28
+ ["ħ", "h"],
29
+ ["ı", "i"],
30
+ ["þ", "th"],
31
+ ]);
32
+
33
+ /**
34
+ * U+00AD, the invisible break hint `&shy;` writes inside a long word. It
35
+ * belongs to no word: split there, Prüfungs&shy;ordnung would index as two.
36
+ */
37
+ const SOFT_HYPHEN = /\u00AD/gu;
38
+
39
+ /** `text` without its soft hyphens. */
40
+ export const stripSoftHyphens = (text: string): string =>
41
+ text.replace(SOFT_HYPHEN, "");
42
+
43
+ /**
44
+ * Fold lowercased text's Latin letters to the plain letters an unaccented
45
+ * query types: accents drop (café → cafe), letters NFD leaves whole are
46
+ * spelled out (Łódź → lodz, ışık and IŞIK → isik, Straße → strasse), and
47
+ * compatibility forms unpack (the fi ligature → fi, fullwidth API → api).
48
+ * Soft hyphens go too. Marks on any other script, and marks on no letter
49
+ * (the keycap in 1️⃣), stay.
50
+ */
51
+ export const foldLatin = (lowered: string): string =>
52
+ stripSoftHyphens(lowered)
53
+ .normalize("NFD")
54
+ .replace(
55
+ NON_ASCII_LATIN,
56
+ (letter) =>
57
+ LATIN_FOLDS.get(letter) ?? letter.normalize("NFKD").toLowerCase()
58
+ )
59
+ .replace(LATIN_ACCENTS, "$<letter>");
60
+
61
+ /**
62
+ * A run of Latin letters and the marks on them (lowercase İ is i plus a
63
+ * combining dot), inside a term of any script.
64
+ */
65
+ const LATIN_RUN = /\p{Script=Latin}[\p{Script=Latin}\p{M}]*/gu;
66
+
67
+ /**
68
+ * {@link foldLatin} for the Latin letters of a lowercased term, whatever
69
+ * script the rest of it is in: a segmenter keeps caféनमस्ते together as one
70
+ * term. Only the Latin runs fold. Marks are spelling elsewhere (Thai vowels
71
+ * and tones; the breve that separates Cyrillic й from и), and NFD would split
72
+ * a Hangul syllable into its jamo.
73
+ */
74
+ export const foldLatinTerm = (term: string): string =>
75
+ term.replace(LATIN_RUN, (run) => foldLatin(run));
76
+
77
+ /**
78
+ * The Unicode blocks holding Latin letters outside ASCII: Latin-1 through
79
+ * Latin Extended-B, the phonetic extensions and Latin Extended Additional,
80
+ * superscripts through number forms, Latin Extended-C, -D and -E, the
81
+ * ligatures, and the fullwidth forms.
82
+ */
83
+ const LATIN_BLOCKS = [
84
+ [0x00_80, 0x02_4f],
85
+ [0x1d_00, 0x1e_ff],
86
+ [0x20_70, 0x21_8f],
87
+ [0x2c_60, 0x2c_7f],
88
+ [0xa7_20, 0xa7_ff],
89
+ [0xab_30, 0xab_6f],
90
+ [0xfb_00, 0xfb_06],
91
+ [0xff_21, 0xff_5a],
92
+ ] as const;
93
+
94
+ const LATIN = /^\p{Script=Latin}$/u;
95
+ const PLAIN = /^[a-z]+$/u;
96
+
97
+ let spellings: Map<string, string> | undefined;
98
+
99
+ /**
100
+ * Every Latin character outside ASCII that folds to plain letters, keyed by
101
+ * those letters: `a` → àáâ…, `ss` → ßẞ. Built on first use, not at import.
102
+ */
103
+ const latinSpellings = (): Map<string, string> => {
104
+ if (spellings) {
105
+ return spellings;
106
+ }
107
+ spellings = new Map();
108
+ for (const [first, last] of LATIN_BLOCKS) {
109
+ for (let code = first; code <= last; code += 1) {
110
+ const char = String.fromCodePoint(code);
111
+ const plain = foldLatin(char.toLowerCase());
112
+ if (LATIN.test(char) && PLAIN.test(plain)) {
113
+ spellings.set(plain, `${spellings.get(plain) ?? ""}${char}`);
114
+ }
115
+ }
116
+ }
117
+ return spellings;
118
+ };
119
+
120
+ /** A Latin letter in a query, with the marks a decomposed query puts on it. */
121
+ const QUERY_LETTER = /\p{Script=Latin}\p{M}*/gu;
122
+
123
+ const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
124
+
125
+ /** What may follow a letter inside a word: its accents, or a soft hyphen. */
126
+ const LETTER_TAIL = String.raw`[\p{M}\u00AD]*`;
127
+
128
+ /**
129
+ * The longest plain spelling a single character folds to (ⅷ → viii), so the
130
+ * pattern builder knows how far ahead to look.
131
+ */
132
+ const longestSpelling = (folds: Map<string, string>): number =>
133
+ Math.max(...[...folds.keys()].map((plain) => plain.length));
134
+
135
+ /**
136
+ * A regex source that finds `term` in unfolded text whatever accents, soft
137
+ * hyphens or spelled-out letters its Latin words carry there: `prufung`
138
+ * matches Prüfung, `strasse` matches Straße. Compile it with the `iu` flags.
139
+ * Characters outside Latin match as themselves.
140
+ */
141
+ export const accentInsensitive = (term: string): string => {
142
+ const folds = latinSpellings();
143
+ const longest = longestSpelling(folds);
144
+ const letter = (plain: string): string =>
145
+ `[${plain}${folds.get(plain) ?? ""}]${LETTER_TAIL}`;
146
+ const chars = [
147
+ ...stripSoftHyphens(term.toLowerCase()).replace(QUERY_LETTER, (latin) =>
148
+ foldLatin(latin)
149
+ ),
150
+ ];
151
+ let source = "";
152
+ let index = 0;
153
+ while (index < chars.length) {
154
+ let length = Math.min(longest, chars.length - index);
155
+ // The longest run here that a single character spells out — `ss` for ß.
156
+ while (
157
+ length > 1 &&
158
+ !folds.has(chars.slice(index, index + length).join(""))
159
+ ) {
160
+ length -= 1;
161
+ }
162
+ const run = chars.slice(index, index + length);
163
+ const plain = run.join("");
164
+ if (length > 1) {
165
+ const letters = run.map((char) => letter(char)).join("");
166
+ source += `(?:${letters}|[${folds.get(plain) ?? ""}]${LETTER_TAIL})`;
167
+ } else if (PLAIN.test(plain)) {
168
+ source += letter(plain);
169
+ } else {
170
+ source += plain.replaceAll(REGEXP_SPECIAL, String.raw`\$&`);
171
+ }
172
+ index += length;
173
+ }
174
+ return source;
175
+ };