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
@@ -14,8 +14,10 @@ import { buildContentGraph } from "./graph.ts";
14
14
  import { i18nDiagnostics } from "./i18n.ts";
15
15
  import { expandIncludes, hasIncludeStatements } from "./includes.ts";
16
16
  import {
17
- gitLastModifiedTimes,
17
+ datePublishedShallowWarning,
18
+ gitFileDates,
18
19
  gitRepositoryRoot,
20
+ isShallowGitRepository,
19
21
  lastModifiedShallowWarning,
20
22
  realPath,
21
23
  resolveLastModifiedConfig,
@@ -301,6 +303,82 @@ const gitContentRoots = (
301
303
  .filter((dir) => isWithin(top, realPath(dir)));
302
304
  };
303
305
 
306
+ /** Whether a page's front matter dates its publication itself. */
307
+ const hasPublishedDate = (page: PageRecord): boolean =>
308
+ Boolean(page.meta.date ?? page.meta.changelog?.date);
309
+
310
+ /**
311
+ * Fill in each page's git dates — "last updated" under `lastModified: "git"`,
312
+ * published under `seo.datePublished: "git"` — from one `git log`, and warn
313
+ * when a shallow clone leaves pages undated. Frontmatter always wins; git
314
+ * applies to filesystem entries, other sources supply dates on the entry.
315
+ */
316
+ const applyGitDates = (
317
+ pages: PageRecord[],
318
+ sources: readonly ContentSource[],
319
+ root: string,
320
+ config: ResolvedConfig
321
+ ): Diagnostic[] => {
322
+ const lastModified = resolveLastModifiedConfig(config.lastModified);
323
+ const gitModified = lastModified.enabled && lastModified.source === "git";
324
+ const gitPublished = config.seo.datePublished === "git";
325
+ if (!(gitModified || gitPublished)) {
326
+ return [];
327
+ }
328
+ const fsPaths = pages
329
+ .map((page) => page.sourcePath)
330
+ .filter((path): path is string => path !== undefined);
331
+ // The git pathspecs must cover where the pages actually live: each local
332
+ // source's own on-disk root — a filesystem source's `root`, or a staged
333
+ // local source's tree (an Obsidian vault) — which diverges from the global
334
+ // `content.root`.
335
+ const gitRoot = gitRepositoryRoot(root);
336
+ const contentRoots = gitContentRoots(sources, gitRoot);
337
+ const gitDates = gitFileDates(root, contentRoots, fsPaths, gitRoot);
338
+ // Only pages the log could have dated count toward the shallow-clone
339
+ // warnings — a page outside every covered root stays undated no matter how
340
+ // deep the clone is, and the warning's suggested fix cannot help it.
341
+ const datable = pages.filter(
342
+ (page) =>
343
+ page.sourcePath &&
344
+ contentRoots.some((dir) => isWithin(dir, page.sourcePath ?? ""))
345
+ );
346
+ const warnings: Diagnostic[] = [];
347
+ if (gitModified) {
348
+ for (const page of pages) {
349
+ if (!page.lastModified && page.sourcePath) {
350
+ page.lastModified = gitDates.modified.get(page.sourcePath);
351
+ }
352
+ }
353
+ // A shallow CI clone (Vercel, actions/checkout) silently drops most dates;
354
+ // surface that instead of letting production diverge from local builds.
355
+ warnings.push(
356
+ ...lastModifiedShallowWarning(
357
+ root,
358
+ datable.filter((page) => !page.lastModified).length
359
+ )
360
+ );
361
+ }
362
+ if (gitPublished) {
363
+ // A shallow clone's oldest commit is where it was cut, not where a page
364
+ // began, so it dates nothing rather than every page wrongly.
365
+ const shallow = isShallowGitRepository(root);
366
+ for (const page of pages) {
367
+ if (!(shallow || hasPublishedDate(page)) && page.sourcePath) {
368
+ page.published = gitDates.published.get(page.sourcePath);
369
+ }
370
+ }
371
+ if (shallow) {
372
+ warnings.push(
373
+ ...datePublishedShallowWarning(
374
+ datable.filter((page) => !hasPublishedDate(page)).length
375
+ )
376
+ );
377
+ }
378
+ }
379
+ return warnings;
380
+ };
381
+
304
382
  /**
305
383
  * Run the full core pipeline for a project root: load config, resolve paths,
306
384
  * discover content and folder meta, build the graph, and assemble the manifest.
@@ -681,47 +759,9 @@ export const scanProject = async (
681
759
  ? allPages.filter((page) => !page.meta.draft)
682
760
  : allPages;
683
761
 
684
- // Resolve "last updated" dates before the graph is built so the manifest
685
- // (which shares these page objects) picks them up. Frontmatter always wins;
686
- // git applies to filesystem entries, other sources supply dates on the entry.
687
- const lastModified = resolveLastModifiedConfig(config.lastModified);
688
- const lastModifiedWarnings: Diagnostic[] = [];
689
- if (lastModified.enabled && lastModified.source === "git") {
690
- const fsPaths = pages
691
- .map((page) => page.sourcePath)
692
- .filter((path): path is string => path !== undefined);
693
- // The git pathspecs must cover where the pages actually live: each local
694
- // source's own on-disk root — a filesystem source's `root`, or a staged
695
- // local source's tree (an Obsidian vault) — which diverges from the global
696
- // `content.root`.
697
- const gitRoot = gitRepositoryRoot(context.root);
698
- const contentRoots = gitContentRoots(sources, gitRoot);
699
- const gitTimes = gitLastModifiedTimes(
700
- context.root,
701
- contentRoots,
702
- fsPaths,
703
- gitRoot
704
- );
705
- for (const page of pages) {
706
- if (!page.lastModified && page.sourcePath) {
707
- page.lastModified = gitTimes.get(page.sourcePath);
708
- }
709
- }
710
- // A shallow CI clone (Vercel, actions/checkout) silently drops most dates;
711
- // surface that instead of letting production diverge from local builds.
712
- // Only pages the log could have dated count — a page outside every covered
713
- // root stays undated no matter how deep the clone is, and the warning's
714
- // suggested fix cannot help it.
715
- const undated = pages.filter(
716
- (page) =>
717
- page.sourcePath &&
718
- !page.lastModified &&
719
- contentRoots.some((dir) => isWithin(dir, page.sourcePath ?? ""))
720
- ).length;
721
- lastModifiedWarnings.push(
722
- ...lastModifiedShallowWarning(context.root, undated)
723
- );
724
- }
762
+ // Resolve "last updated" and publication dates before the graph is built so
763
+ // the manifest (which shares these page objects) picks them up.
764
+ const gitDateWarnings = applyGitDates(pages, sources, context.root, config);
725
765
 
726
766
  // Custom `.astro` pages and the generated changelog index aren't content
727
767
  // pages, so name them for navigation: a `/changelog` tab should open the
@@ -791,7 +831,7 @@ export const scanProject = async (
791
831
  ...graph.diagnostics,
792
832
  ...i18nWarnings,
793
833
  ...versionWarnings,
794
- ...lastModifiedWarnings,
834
+ ...gitDateWarnings,
795
835
  ...svgWarnings,
796
836
  ...publicOpenApiDiagnostics(config, context.root),
797
837
  ...redirectPageDiagnostics(
@@ -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.
@@ -196,7 +196,11 @@ const followUpstream = async (args: {
196
196
  signal: AbortSignal;
197
197
  url: URL;
198
198
  }): Promise<Response> => {
199
- const response = await args.fetchImpl(args.url, {
199
+ // Called bare, never as `args.fetchImpl(...)`: that would run the global
200
+ // `fetch` with `this` set to `args`, which Node allows and workerd rejects
201
+ // as an "Illegal invocation".
202
+ const { fetchImpl } = args;
203
+ const response = await fetchImpl(args.url, {
200
204
  body: args.body,
201
205
  headers: args.headers,
202
206
  method: args.method,
@@ -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;