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
@@ -65,6 +65,7 @@ export const LOCALE_REDIRECT_SCRIPT = `(()=>{const s=document.currentScript;if(!
65
65
  interface ClosestTarget {
66
66
  closest: (selector: string) => {
67
67
  getAttribute: (name: string) => string | null;
68
+ setAttribute: (name: string, value: string) => void;
68
69
  } | null;
69
70
  }
70
71
 
@@ -75,33 +76,51 @@ interface Click {
75
76
 
76
77
  /** What {@link rememberLocaleChoice} listens on: the document. */
77
78
  export interface ChoiceTarget {
78
- addEventListener: (type: "click", listener: (event: Click) => void) => void;
79
+ addEventListener: (
80
+ type: "click",
81
+ listener: (event: Click) => void,
82
+ options: { capture: true }
83
+ ) => void;
84
+ location: { readonly hash: string };
79
85
  }
80
86
 
81
87
  const hasClosest = (target: Click["target"]): target is ClosestTarget =>
82
88
  target !== null && "closest" in target;
83
89
 
84
- /** The code of the switcher link a click landed on, if any. */
85
- const chosenCode = (target: Click["target"]): string | null | undefined =>
86
- hasClosest(target)
87
- ? target.closest("a[data-blume-locale-choice]")?.getAttribute("hreflang")
88
- : null;
90
+ /** The switcher link a click landed on, if any. */
91
+ const chosenLink = (target: Click["target"]) =>
92
+ hasClosest(target) ? target.closest("a[data-blume-locale-choice]") : null;
89
93
 
90
94
  /**
91
95
  * Remember a language picked with the switcher, so routing by browser
92
- * language stops sending the reader elsewhere. One listener on the document
93
- * covers every switcher, on every page the client router swaps in.
96
+ * language stops sending the reader elsewhere, and keep the reader's place:
97
+ * the chosen link carries the current fragment (a heading the translation
98
+ * lacks leaves the reader at the top). One listener on the document covers
99
+ * every switcher, on every page the client router swaps in. It listens in
100
+ * the capture phase: the client router's own listener on the document reads
101
+ * the link's href and navigates, and it's registered first.
94
102
  */
95
103
  export const rememberLocaleChoice = (target: ChoiceTarget): void => {
96
- target.addEventListener("click", (event) => {
97
- const code = chosenCode(event.target);
98
- if (!code) {
99
- return;
100
- }
101
- try {
102
- localStorage.setItem(LOCALE_STORAGE_KEY, code);
103
- } catch {
104
- // Storage can be off; routing then stays off too (see the script).
105
- }
106
- });
104
+ target.addEventListener(
105
+ "click",
106
+ (event) => {
107
+ const link = chosenLink(event.target);
108
+ const code = link?.getAttribute("hreflang");
109
+ if (!(link && code)) {
110
+ return;
111
+ }
112
+ // Rewritten even without a fragment, so one left by an earlier click
113
+ // (one that opened a new tab) doesn't stick.
114
+ const href = link.getAttribute("href");
115
+ if (href !== null) {
116
+ link.setAttribute("href", href.split("#")[0] + target.location.hash);
117
+ }
118
+ try {
119
+ localStorage.setItem(LOCALE_STORAGE_KEY, code);
120
+ } catch {
121
+ // Storage can be off; routing then stays off too (see the script).
122
+ }
123
+ },
124
+ { capture: true }
125
+ );
107
126
  };
@@ -1,5 +1,7 @@
1
1
  import { escape } from "html-escaper";
2
2
 
3
+ import { accentInsensitive } from "../../../search/fold.ts";
4
+
3
5
  /** A single result rendered in the search dialog. */
4
6
  export interface SearchHit {
5
7
  url: string;
@@ -96,16 +98,19 @@ export const SEARCH_LIMIT = 12;
96
98
  */
97
99
  export const RESULT_POOL = 48;
98
100
 
99
- const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
100
101
  const WORD_BREAK = /\s+/u;
101
102
 
102
- /** Split a query into escaped, non-empty search tokens. */
103
+ /**
104
+ * Split a query into non-empty search tokens, each a regex source that
105
+ * matches it whatever accents the text carries: the index folds Prüfung to
106
+ * prufung, so a page found by `prufung` is marked and excerpted there too.
107
+ */
103
108
  const queryTokens = (query: string): string[] =>
104
109
  query
105
110
  .trim()
106
111
  .split(WORD_BREAK)
107
112
  .filter(Boolean)
108
- .map((token) => token.replaceAll(REGEXP_SPECIAL, String.raw`\$&`));
113
+ .map((token) => accentInsensitive(token));
109
114
 
110
115
  /**
111
116
  * Wrap query matches in `<mark>`, HTML-escaping the source text. Matching runs
@@ -159,7 +164,10 @@ export const sanitizeExcerpt = (html: string): string =>
159
164
  })
160
165
  .join("");
161
166
 
162
- /** First index in `text` where any query token matches (case-insensitive). */
167
+ /**
168
+ * First index in `text` where any query token matches, ignoring case and
169
+ * accents.
170
+ */
163
171
  const matchIndex = (text: string, query: string): number => {
164
172
  const tokens = queryTokens(query);
165
173
  if (tokens.length === 0) {
@@ -1099,6 +1099,12 @@ export interface OgPaletteConfig {
1099
1099
 
1100
1100
  /** Per-page Open Graph image generation. */
1101
1101
  export interface OgConfig {
1102
+ /**
1103
+ * Default social image for pages without their own `seo.image`, in place of
1104
+ * the generated card (which then defaults off): one path or URL, or one per
1105
+ * locale, like any localizable label.
1106
+ */
1107
+ image?: string | Record<string, string>;
1102
1108
  /**
1103
1109
  * Card subtitle. Defaults to the site description; a string overrides it,
1104
1110
  * `false` renders the card without one.
@@ -1223,6 +1229,28 @@ export interface SoftwareConfig {
1223
1229
 
1224
1230
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
1225
1231
  export interface SeoConfig {
1232
+ /**
1233
+ * Structured-data entities shared with a parent site (`example.com/docs`),
1234
+ * referenced by absolute `@id` instead of defined again.
1235
+ */
1236
+ jsonLd?: {
1237
+ /** The WebSite's `@id`, which every page names as `isPartOf`. */
1238
+ websiteId?: string;
1239
+ /** The Organization's `@id`; `organization` still adds its details. */
1240
+ organizationId?: string;
1241
+ /** Credited on pages without their own `authors`. */
1242
+ author?: { "@id": string };
1243
+ /** Every page's publisher, over the Organization. */
1244
+ publisher?: { "@id": string };
1245
+ /** Emit the WebSite node. Defaults to true unless `websiteId` is set. */
1246
+ website?: boolean;
1247
+ };
1248
+ /**
1249
+ * Date pages without a front matter `date` by the git commit that added
1250
+ * their source file, following exact renames. Defaults to `false`.
1251
+ */
1252
+ datePublished?: false | "git";
1253
+
1226
1254
  /**
1227
1255
  * Meta tags written into every page's head, name to content: site
1228
1256
  * verification tokens, `theme-color`, an app banner, and anything else
@@ -1262,7 +1290,12 @@ export interface SeoConfig {
1262
1290
  /** RSS/Atom feeds. */
1263
1291
  rss?: RssConfig;
1264
1292
  /** Generate sitemap.xml (requires `deployment.site`). Defaults to `true`. */
1265
- sitemap?: boolean;
1293
+ sitemap?:
1294
+ | boolean
1295
+ | {
1296
+ /** List each page's translations as hreflang links. Defaults to `false`. */
1297
+ alternates?: boolean;
1298
+ };
1266
1299
  /**
1267
1300
  * The documented product, added to the homepage's JSON-LD as a
1268
1301
  * `SoftwareApplication` node. `true` takes every default (name and
@@ -394,8 +394,11 @@ export const loadConfig = async (
394
394
  const site = config.deployment.options.site ?? options.devServerUrl;
395
395
 
396
396
  // OG images need an absolute `og:image`, so they default on once a site URL
397
- // is known and off otherwise. An explicit `seo.og.enabled` always wins.
398
- const ogEnabled = config.seo.og.enabled ?? Boolean(site);
397
+ // is known and off otherwise — and off beside a default `seo.og.image`,
398
+ // which every page shows instead of its card. An explicit `seo.og.enabled`
399
+ // always wins.
400
+ const ogEnabled =
401
+ config.seo.og.enabled ?? (Boolean(site) && !config.seo.og.image);
399
402
 
400
403
  return {
401
404
  config: {
package/src/core/data.ts CHANGED
@@ -100,6 +100,12 @@ export interface BlumeRoute {
100
100
  /** True when this route renders fallback content for a missing translation. */
101
101
  fallback: boolean;
102
102
  hidden: boolean;
103
+ /**
104
+ * The translations the page head links as hreflang alternates: the
105
+ * indexable ones, so a `noindex` or hidden translation is left out (see
106
+ * `hreflangAlternates`).
107
+ */
108
+ hreflang: RouteAlternate[];
103
109
  id: string;
104
110
  /** Whether the page is part of the search index. */
105
111
  indexable: boolean;
@@ -113,6 +119,12 @@ export interface BlumeRoute {
113
119
  */
114
120
  monolingual: boolean;
115
121
  path: string;
122
+ /**
123
+ * The git commit that added the page's source, as an ISO date, when
124
+ * `seo.datePublished` is `"git"` and front matter carries no date; else
125
+ * `null`.
126
+ */
127
+ published: string | null;
116
128
  title: string;
117
129
  /** Resolved docs version (`""` for the current docs). */
118
130
  version: string;
@@ -229,6 +241,7 @@ export interface BlumeDataConfig {
229
241
  * kept out of this snapshot, which pages serialize into HTML.
230
242
  */
231
243
  og: {
244
+ image?: string | Record<string, string>;
232
245
  /**
233
246
  * Site-wide card subtitle: `seo.og.description` (`false` omits it) over
234
247
  * the site description. A page with its own description shows that
@@ -265,6 +278,7 @@ export interface BlumeDataConfig {
265
278
  };
266
279
  /** Deployment site URL, or `null` when none is configured/detected. */
267
280
  site: string | null;
281
+ jsonLd: ResolvedConfig["seo"]["jsonLd"];
268
282
  structuredData: boolean;
269
283
  theme: ResolvedConfig["theme"];
270
284
  title: string;
@@ -40,27 +40,41 @@ export const resolveLastModifiedConfig = (
40
40
  ? { enabled: false, source: "git" }
41
41
  : { enabled: true, source: value };
42
42
 
43
+ /** Each file's git dates, keyed by path. */
44
+ export interface GitDates {
45
+ /** The committer ISO date of the newest commit that changed the file. */
46
+ modified: Map<string, string>;
47
+ /** The committer ISO date of the commit that added the file. */
48
+ published: Map<string, string>;
49
+ }
50
+
43
51
  /**
44
- * Parse `git log --format=%x00%cI --name-status -M100%` output into a map of
45
- * repo-root-relative path → the committer ISO date of the newest commit that
46
- * changed the file. Each commit emits a NUL-prefixed date line followed by a
47
- * status line per path it touched (`M\tpath`, `R100\told\tnew`); since git
48
- * logs newest-first, the first date seen for a path wins. Blank lines are
49
- * ignored.
52
+ * Parse `git log --format=%x00%cI --name-status -M100%` output into each
53
+ * repo-root-relative path's dates. Each commit emits a NUL-prefixed date line
54
+ * followed by a status line per path it touched (`M\tpath`, `R100\told\tnew`);
55
+ * since git logs newest-first, the first date seen for a path is when it last
56
+ * changed, and the add (`A\tpath`) that ends its history is when it was
57
+ * published. Blank lines are ignored.
50
58
  *
51
59
  * A rename (`-M100%` reports only exact ones, where the content is unchanged)
52
60
  * follows the file the way `git log --follow` does for one path: it dates
53
61
  * nothing itself, and the older commits that name the old path date the file
54
62
  * under its current name, so renaming `page.md` to `page.mdx` keeps the page's
55
- * date. A file whose visible history starts at a rename (a shallow clone cut
56
- * off the commits before it) takes the rename's date.
63
+ * dates. A file whose visible history starts at a rename (a shallow clone cut
64
+ * off the commits before it) takes the rename's date. Only exact renames are
65
+ * followed: `--follow`'s default 50% similarity would hand a new page the
66
+ * history of a similar one deleted in the same commit.
57
67
  */
58
- export const parseGitLog = (output: string): Map<string, string> => {
59
- const times = new Map<string, string>();
68
+ export const parseGitLog = (output: string): GitDates => {
69
+ const modified = new Map<string, string>();
70
+ const published = new Map<string, string>();
60
71
  // An older name of a file → its name now, for the commits read after the
61
72
  // rename (older ones).
62
73
  const currentNames = new Map<string, string>();
63
74
  const renamedAt = new Map<string, string>();
75
+ // Files whose add has been read: anything older under the same name was an
76
+ // earlier file at that path, deleted before this one was created.
77
+ const added = new Set<string>();
64
78
  let current: string | null = null;
65
79
  for (const line of output.split("\n")) {
66
80
  if (line.startsWith("\0")) {
@@ -73,21 +87,27 @@ export const parseGitLog = (output: string): Map<string, string> => {
73
87
  continue;
74
88
  }
75
89
  const name = currentNames.get(path) ?? path;
90
+ if (!added.has(name)) {
91
+ published.set(name, current);
92
+ if (status === "A") {
93
+ added.add(name);
94
+ }
95
+ }
76
96
  if (status.startsWith("R")) {
77
97
  currentNames.set(from, name);
78
98
  if (!renamedAt.has(name)) {
79
99
  renamedAt.set(name, current);
80
100
  }
81
- } else if (!times.has(name)) {
82
- times.set(name, current);
101
+ } else if (!modified.has(name)) {
102
+ modified.set(name, current);
83
103
  }
84
104
  }
85
105
  for (const [name, date] of renamedAt) {
86
- if (!times.has(name)) {
87
- times.set(name, date);
106
+ if (!modified.has(name)) {
107
+ modified.set(name, date);
88
108
  }
89
109
  }
90
- return times;
110
+ return { modified, published };
91
111
  };
92
112
 
93
113
  /**
@@ -127,37 +147,38 @@ export const gitRepositoryRoot = (root: string): string | null => {
127
147
  }
128
148
  };
129
149
 
150
+ const noDates = (): GitDates => ({ modified: new Map(), published: new Map() });
151
+
130
152
  /**
131
- * Resolve each source file's last-modified date from git history, keyed by
132
- * absolute source path. Runs a single `git log` over the given content roots
133
- * (each local source's own root, which may diverge from `content.root`),
134
- * following renames within them (see `parseGitLog`; a file moved in from
135
- * outside every root dates from the move), and maps repo-root-relative paths
136
- * back to the given absolute paths
137
- * (monorepo-safe via `rev-parse --show-toplevel`). Returns an empty map if git
138
- * is unavailable or the project isn't a repo — the feature then simply shows
153
+ * Resolve each source file's git dates (see `parseGitLog`), keyed by absolute
154
+ * source path. Runs a single `git log` over the given content roots (each
155
+ * local source's own root, which may diverge from `content.root`), following
156
+ * renames within them (a file moved in from outside every root dates from the
157
+ * move), and maps repo-root-relative paths back to the given absolute paths
158
+ * (monorepo-safe via `rev-parse --show-toplevel`). Returns empty maps if git
159
+ * is unavailable or the project isn't a repo — the features then simply show
139
160
  * no dates.
140
161
  */
141
- export const gitLastModifiedTimes = (
162
+ export const gitFileDates = (
142
163
  root: string,
143
164
  contentRoots: string[],
144
165
  sourcePaths: string[],
145
166
  repositoryRoot?: string | null
146
- ): Map<string, string> => {
167
+ ): GitDates => {
147
168
  // Nothing to date, or nowhere bounded to look — either way, don't pay for a
148
169
  // git scan. Both guards matter: `git log -- ` with no pathspec logs the
149
170
  // entire repository, which is what an all-staged project produces (a staged
150
171
  // source contributes no content root, yet its entries can still carry a
151
172
  // `sourcePath`).
152
173
  if (sourcePaths.length === 0 || contentRoots.length === 0) {
153
- return new Map();
174
+ return noDates();
154
175
  }
155
176
  // The caller that bounded `contentRoots` already resolved the repo root;
156
177
  // reuse it rather than spawning `rev-parse` a second time per scan.
157
178
  const gitRoot =
158
179
  repositoryRoot === undefined ? gitRepositoryRoot(root) : repositoryRoot;
159
180
  if (gitRoot === null) {
160
- return new Map();
181
+ return noDates();
161
182
  }
162
183
  // Git names paths by their real location, so the pathspecs and the paths
163
184
  // read back against the log are compared in that spelling too (see
@@ -185,16 +206,19 @@ export const gitLastModifiedTimes = (
185
206
  { encoding: "utf-8", env: gitEnv(), maxBuffer: 256 * 1024 * 1024 }
186
207
  );
187
208
  const byRepoPath = parseGitLog(output);
188
- const result = new Map<string, string>();
209
+ const result = noDates();
189
210
  for (const sourcePath of sourcePaths) {
190
- const iso = byRepoPath.get(relative(top, realPath(sourcePath)));
191
- if (iso) {
192
- result.set(sourcePath, iso);
211
+ const repoPath = relative(top, realPath(sourcePath));
212
+ for (const key of ["modified", "published"] as const) {
213
+ const iso = byRepoPath[key].get(repoPath);
214
+ if (iso) {
215
+ result[key].set(sourcePath, iso);
216
+ }
193
217
  }
194
218
  }
195
219
  return result;
196
220
  } catch {
197
- return new Map();
221
+ return noDates();
198
222
  }
199
223
  };
200
224
 
@@ -223,6 +247,9 @@ export const isShallowGitRepository = (root: string): boolean => {
223
247
  }
224
248
  };
225
249
 
250
+ const SHALLOW_CLONE_SUGGESTION =
251
+ "Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout.";
252
+
226
253
  /**
227
254
  * A warning for git-derived dates silently missing because the build ran in a
228
255
  * shallow clone — the default on Vercel and `actions/checkout`, where `git log`
@@ -243,8 +270,27 @@ export const lastModifiedShallowWarning = (
243
270
  code: "BLUME_SHALLOW_GIT_HISTORY",
244
271
  message: `lastModified is on, but this build runs in a shallow git clone, so ${undatedCount} page(s) have no git-derived date — their sitemap <lastmod> and "Last updated" stamps are omitted.`,
245
272
  severity: "warning",
246
- suggestion:
247
- "Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout.",
273
+ suggestion: SHALLOW_CLONE_SUGGESTION,
248
274
  },
249
275
  ];
250
276
  };
277
+
278
+ /**
279
+ * The warning for `seo.datePublished: "git"` in a shallow clone, where the
280
+ * oldest commit git can see is where the clone was cut, not where each page
281
+ * began. Git then dates no page rather than every page wrongly. Empty when
282
+ * every page has a front matter date.
283
+ */
284
+ export const datePublishedShallowWarning = (
285
+ undatedCount: number
286
+ ): Diagnostic[] =>
287
+ undatedCount === 0
288
+ ? []
289
+ : [
290
+ {
291
+ code: "BLUME_SHALLOW_GIT_HISTORY",
292
+ message: `seo.datePublished is "git", but this build runs in a shallow git clone, so ${undatedCount} page(s) without a front matter date have no datePublished.`,
293
+ severity: "warning",
294
+ suggestion: SHALLOW_CLONE_SUGGESTION,
295
+ },
296
+ ];
package/src/core/links.ts CHANGED
@@ -249,6 +249,36 @@ export const resolveRelativeHref = (
249
249
  return `${fileRoute ?? toRoute(resolveRelative(from.route, path, from.isIndex))}${suffix}`;
250
250
  };
251
251
 
252
+ /**
253
+ * Where a relative page link in a raw HTML `<a href>` written on `route`
254
+ * lands, with the authored `?query#hash` kept, or `undefined` when `href`
255
+ * isn't a relative page link (see {@link resolveRelativeHref}). The Markdown
256
+ * pipeline leaves a raw element as written, so the browser resolves it
257
+ * against the page's slashless URL, as `blume validate` reads it: its parent
258
+ * directory, even on an index page, and a `.md` target is that path's
259
+ * Markdown copy rather than a file link.
260
+ */
261
+ export const resolveRawRelativeHref = (
262
+ href: string,
263
+ route: string,
264
+ hasRoute: (route: string) => boolean
265
+ ): string | undefined => {
266
+ if (href === "" || NOT_RELATIVE.test(href)) {
267
+ return undefined;
268
+ }
269
+ const suffixAt = href.search(/[?#]/u);
270
+ const path = suffixAt === -1 ? href : href.slice(0, suffixAt);
271
+ const resolved = resolveRelative(route, path, false);
272
+ if (
273
+ FILE_EXT.test(path) &&
274
+ !DOC_EXT.test(path) &&
275
+ !hasRoute(decodePercent(resolved))
276
+ ) {
277
+ return undefined;
278
+ }
279
+ return `${resolved}${suffixAt === -1 ? "" : href.slice(suffixAt)}`;
280
+ };
281
+
252
282
  /**
253
283
  * Where a root-relative link to a content file (`/guide/new.md`, the way
254
284
  * VitePress, Docsify, and TypeDoc write one) lands: the route of the source
@@ -695,6 +725,18 @@ export const routeOfLinkedFile = (
695
725
  index.bySource.get(resolve(dirname(from.sourcePath), path)) ??
696
726
  index.byDefaultNavPath.get(normalize(join(dirname(from.navPath), path)));
697
727
 
728
+ /**
729
+ * The route a file link written on a staged page lands on: the linked
730
+ * entry's, by its path under the staging dir (`entryId`), as the rendered
731
+ * link resolves it.
732
+ */
733
+ export const routeOfLinkedEntry = (
734
+ index: FileRouteIndex,
735
+ entryId: string,
736
+ path: string
737
+ ): string | undefined =>
738
+ index.byEntryId.get(normalize(join(dirname(entryId), path)));
739
+
698
740
  /**
699
741
  * The path a relative link on `page` resolves to — the same reading the
700
742
  * Markdown pipeline rewrites the rendered `href` to (see
@@ -714,8 +756,7 @@ const relativeTarget = (
714
756
  resolveFile = (path) =>
715
757
  routeOfLinkedFile(ctx.fileRoutes, { navPath, sourcePath }, path);
716
758
  } else if (entryId) {
717
- resolveFile = (path) =>
718
- ctx.fileRoutes.byEntryId.get(normalize(join(dirname(entryId), path)));
759
+ resolveFile = (path) => routeOfLinkedEntry(ctx.fileRoutes, entryId, path);
719
760
  }
720
761
  return (
721
762
  resolveRelativeHref(rawPath, base, resolveFile, (route) =>
@@ -136,6 +136,7 @@ const buildFallbackRoutes = (
136
136
  locale: code,
137
137
  monolingual: source.monolingual,
138
138
  path,
139
+ published: source.published,
139
140
  source: source.source,
140
141
  sourcePath: source.sourcePath,
141
142
  title: source.title,
@@ -201,6 +202,7 @@ export const buildManifest = (options: {
201
202
  locale: page.locale,
202
203
  monolingual: page.monolingual,
203
204
  path: page.route,
205
+ published: page.published,
204
206
  source: page.source,
205
207
  sourcePath: page.sourcePath,
206
208
  title: page.title,
@@ -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(