blume 2.2.2 → 2.2.4

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 (145) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/THIRD-PARTY-NOTICES.md +51 -0
  3. package/dist/cli/{chunk-kmswjxj9.js → chunk-0b1a8xqz.js} +3 -3
  4. package/dist/cli/{chunk-rtvgmg2j.js → chunk-0qx6vwwf.js} +2 -2
  5. package/dist/cli/{chunk-qcyxkj44.js → chunk-3ps63r91.js} +43 -17
  6. package/dist/cli/chunk-3ps63r91.js.map +15 -0
  7. package/dist/cli/{chunk-b680sv30.js → chunk-402xaaxn.js} +10 -8
  8. package/dist/cli/chunk-402xaaxn.js.map +13 -0
  9. package/dist/cli/{chunk-k45sq16q.js → chunk-49xa4yf2.js} +39 -23
  10. package/dist/cli/{chunk-k45sq16q.js.map → chunk-49xa4yf2.js.map} +3 -3
  11. package/dist/cli/{chunk-j4nfgn2j.js → chunk-51wr1wre.js} +1 -1
  12. package/dist/cli/{chunk-9jqmq5hv.js → chunk-531qbwn1.js} +6 -6
  13. package/dist/cli/{chunk-r5nm9w0c.js → chunk-5bp65pv4.js} +107 -37
  14. package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-5bp65pv4.js.map} +11 -10
  15. package/dist/cli/{chunk-yg8ckc3g.js → chunk-6nvcjzts.js} +2 -2
  16. package/dist/cli/{chunk-x7bh6d9j.js → chunk-6v5w8vzd.js} +4 -4
  17. package/dist/cli/{chunk-f260jdrn.js → chunk-8cshtem6.js} +5 -5
  18. package/dist/cli/{chunk-ydqzkhjn.js → chunk-8h5ceyk0.js} +6 -6
  19. package/dist/cli/{chunk-e1n41w9m.js → chunk-8v90n67z.js} +3 -3
  20. package/dist/cli/{chunk-etckvbrh.js → chunk-98me5yas.js} +14 -14
  21. package/dist/cli/{chunk-hktq4j5r.js → chunk-9k7gg4ga.js} +16 -10
  22. package/dist/cli/{chunk-hktq4j5r.js.map → chunk-9k7gg4ga.js.map} +5 -5
  23. package/dist/cli/{chunk-34wap3ed.js → chunk-9qmc57zw.js} +3 -3
  24. package/dist/cli/{chunk-beb0z983.js → chunk-a1xmzxk2.js} +16 -16
  25. package/dist/cli/{chunk-gwy55cx3.js → chunk-ajw85dgf.js} +2 -2
  26. package/dist/cli/{chunk-69rjf786.js → chunk-ansqj08e.js} +5 -5
  27. package/dist/cli/{chunk-q1wwrxzw.js → chunk-b0vwhmdf.js} +3 -3
  28. package/dist/cli/chunk-b0vwhmdf.js.map +10 -0
  29. package/dist/cli/{chunk-nqna8dzp.js → chunk-b5gztt4j.js} +17 -17
  30. package/dist/cli/{chunk-fnxgtjeq.js → chunk-bc4jz2m4.js} +1 -1
  31. package/dist/cli/{chunk-aafhwx74.js → chunk-d4h1y312.js} +1 -1
  32. package/dist/cli/{chunk-080mfch6.js → chunk-et7zwqkg.js} +15 -15
  33. package/dist/cli/{chunk-487b95qy.js → chunk-f29q3j13.js} +41 -25
  34. package/dist/cli/{chunk-487b95qy.js.map → chunk-f29q3j13.js.map} +3 -3
  35. package/dist/cli/{chunk-c5bjxq8v.js → chunk-f72388jn.js} +2 -2
  36. package/dist/cli/{chunk-t2h8pwy1.js → chunk-g0hpwbyx.js} +4 -4
  37. package/dist/cli/{chunk-40vk8kgf.js → chunk-j102xmnk.js} +7 -7
  38. package/dist/cli/{chunk-ppa6mg4d.js → chunk-m54bz9ng.js} +6 -6
  39. package/dist/cli/{chunk-h0qwdbem.js → chunk-mt6njy68.js} +2 -2
  40. package/dist/cli/{chunk-2ssh60qh.js → chunk-n091sgam.js} +6 -6
  41. package/dist/cli/{chunk-1ch3ct3r.js → chunk-p33bf2nb.js} +14 -14
  42. package/dist/cli/{chunk-4zxh25hc.js → chunk-saxq5jjr.js} +2 -2
  43. package/dist/cli/{chunk-x60e15st.js → chunk-sbt1s5j2.js} +17 -17
  44. package/dist/cli/{chunk-gxhcf2hs.js → chunk-tckd6qwv.js} +2 -2
  45. package/dist/cli/{chunk-p834rkcn.js → chunk-tf4134kk.js} +5 -5
  46. package/dist/cli/{chunk-zxd5jrgx.js → chunk-vxdjss7f.js} +76 -34
  47. package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-vxdjss7f.js.map} +4 -4
  48. package/dist/cli/{chunk-9h8mh2tk.js → chunk-x2pgg22t.js} +1 -1
  49. package/dist/cli/{chunk-hn3g2b89.js → chunk-xhrwrzrh.js} +9 -9
  50. package/dist/cli/index.js +18 -18
  51. package/dist/types/ai/ask-context.d.ts +8 -0
  52. package/dist/types/ai/link-text.d.ts +5 -0
  53. package/dist/types/ai/relative-links.d.ts +26 -6
  54. package/dist/types/core/config-input.d.ts +41 -1
  55. package/dist/types/core/data.d.ts +14 -0
  56. package/dist/types/core/last-modified.d.ts +33 -18
  57. package/dist/types/core/links.d.ts +16 -0
  58. package/dist/types/core/schema.d.ts +17 -1
  59. package/dist/types/core/types.d.ts +10 -0
  60. package/dist/types/deploy/sitemap.d.ts +9 -0
  61. package/dist/types/search/documents.d.ts +3 -1
  62. package/dist/types/search/fold.d.ts +33 -0
  63. package/dist/types/search/orama-index.d.ts +4 -4
  64. package/dist/types/seo/jsonld.d.ts +33 -0
  65. package/docs/configuration/search.mdx +2 -2
  66. package/docs/configuration/theming.mdx +13 -1
  67. package/docs/content/frontmatter.mdx +1 -1
  68. package/docs/content/i18n.mdx +1 -1
  69. package/docs/content/sources/remote-mdx.mdx +1 -1
  70. package/docs/discoverability/open-graph.mdx +16 -1
  71. package/docs/discoverability/sitemap-and-robots.mdx +12 -0
  72. package/docs/discoverability/structured-data.mdx +44 -0
  73. package/package.json +4 -3
  74. package/skills/blume-migrate/references/docus.md +1 -2
  75. package/skills/blume-migrate/references/docusaurus.md +1 -1
  76. package/skills/blume-migrate/references/vitepress.md +2 -2
  77. package/skills/blume-migrate/references/vuepress.md +1 -1
  78. package/src/ai/ask-context.ts +52 -14
  79. package/src/ai/ask-data.ts +4 -1
  80. package/src/ai/ask-tools.ts +17 -5
  81. package/src/ai/link-text.ts +6 -0
  82. package/src/ai/llms.ts +1 -7
  83. package/src/ai/markdown.ts +8 -4
  84. package/src/ai/relative-links.ts +155 -48
  85. package/src/ai/site-skill.ts +1 -4
  86. package/src/astro/generate.ts +7 -0
  87. package/src/astro/templates.ts +36 -20
  88. package/src/components/layout/Header.astro +18 -10
  89. package/src/components/layout/PageActions.astro +1 -0
  90. package/src/components/layout/PageLayout.astro +31 -13
  91. package/src/components/layout/ReferenceLayout.astro +6 -1
  92. package/src/components/layout/RootLayout.astro +13 -1
  93. package/src/components/layout/head-scripts.ts +5 -1
  94. package/src/components/layout/locale-redirect.ts +38 -19
  95. package/src/components/layout/search/types.ts +12 -4
  96. package/src/core/config-input.ts +40 -1
  97. package/src/core/config.ts +5 -2
  98. package/src/core/data.ts +14 -0
  99. package/src/core/last-modified.ts +80 -34
  100. package/src/core/links.ts +43 -2
  101. package/src/core/manifest.ts +2 -0
  102. package/src/core/project-graph.ts +83 -43
  103. package/src/core/schema.ts +48 -9
  104. package/src/core/types.ts +10 -0
  105. package/src/deploy/sitemap.ts +127 -49
  106. package/src/search/documents.ts +34 -11
  107. package/src/search/fold.ts +175 -0
  108. package/src/search/orama-index.ts +40 -49
  109. package/src/seo/jsonld.ts +100 -23
  110. package/src/theme/chrome-icons.ts +2 -1
  111. package/src/theme/entry.ts +4 -1
  112. package/dist/cli/chunk-b680sv30.js.map +0 -13
  113. package/dist/cli/chunk-q1wwrxzw.js.map +0 -10
  114. package/dist/cli/chunk-qcyxkj44.js.map +0 -14
  115. /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-0b1a8xqz.js.map} +0 -0
  116. /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-0qx6vwwf.js.map} +0 -0
  117. /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-51wr1wre.js.map} +0 -0
  118. /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-531qbwn1.js.map} +0 -0
  119. /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-6nvcjzts.js.map} +0 -0
  120. /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6v5w8vzd.js.map} +0 -0
  121. /package/dist/cli/{chunk-f260jdrn.js.map → chunk-8cshtem6.js.map} +0 -0
  122. /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-8h5ceyk0.js.map} +0 -0
  123. /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-8v90n67z.js.map} +0 -0
  124. /package/dist/cli/{chunk-etckvbrh.js.map → chunk-98me5yas.js.map} +0 -0
  125. /package/dist/cli/{chunk-34wap3ed.js.map → chunk-9qmc57zw.js.map} +0 -0
  126. /package/dist/cli/{chunk-beb0z983.js.map → chunk-a1xmzxk2.js.map} +0 -0
  127. /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-ajw85dgf.js.map} +0 -0
  128. /package/dist/cli/{chunk-69rjf786.js.map → chunk-ansqj08e.js.map} +0 -0
  129. /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-b5gztt4j.js.map} +0 -0
  130. /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-bc4jz2m4.js.map} +0 -0
  131. /package/dist/cli/{chunk-aafhwx74.js.map → chunk-d4h1y312.js.map} +0 -0
  132. /package/dist/cli/{chunk-080mfch6.js.map → chunk-et7zwqkg.js.map} +0 -0
  133. /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-f72388jn.js.map} +0 -0
  134. /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-g0hpwbyx.js.map} +0 -0
  135. /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-j102xmnk.js.map} +0 -0
  136. /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-m54bz9ng.js.map} +0 -0
  137. /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-mt6njy68.js.map} +0 -0
  138. /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-n091sgam.js.map} +0 -0
  139. /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-p33bf2nb.js.map} +0 -0
  140. /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-saxq5jjr.js.map} +0 -0
  141. /package/dist/cli/{chunk-x60e15st.js.map → chunk-sbt1s5j2.js.map} +0 -0
  142. /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-tckd6qwv.js.map} +0 -0
  143. /package/dist/cli/{chunk-p834rkcn.js.map → chunk-tf4134kk.js.map} +0 -0
  144. /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-x2pgg22t.js.map} +0 -0
  145. /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-xhrwrzrh.js.map} +0 -0
@@ -43,6 +43,7 @@ import {
43
43
  OG_IMAGE_WIDTH,
44
44
  } from "../../og/dimensions.ts";
45
45
  import { buildStructuredData, toIso } from "../../seo/jsonld.ts";
46
+ import type { AuthorInput } from "../../seo/jsonld.ts";
46
47
  import { normalizeXHandle } from "../../seo/x-handle.ts";
47
48
  import { withBase, withMountedBase } from "../islands/base-path.ts";
48
49
  import { routeSetFor } from "../../core/locale-links.ts";
@@ -196,6 +197,8 @@ interface Props {
196
197
  /** The page's `mode` frontmatter: what it shows around its content (see `core/page-modes.ts`). */
197
198
  pageMode?: PageMode;
198
199
  published?: string | Date | null;
200
+ /** The page's front matter `authors`, credited in its JSON-LD. */
201
+ authors?: AuthorInput | AuthorInput[];
199
202
  lastModified?: string | null;
200
203
  noindex?: boolean;
201
204
  structuredDataEnabled?: boolean;
@@ -312,6 +315,7 @@ const {
312
315
  apiRail = false,
313
316
  pageMode,
314
317
  published,
318
+ authors,
315
319
  lastModified,
316
320
  noindex,
317
321
  structuredDataEnabled,
@@ -534,11 +538,14 @@ const structuredData =
534
538
  structuredDataEnabled === false || noindex
535
539
  ? null
536
540
  : buildStructuredData({
541
+ authors,
537
542
  base: import.meta.env.BASE_URL,
538
543
  breadcrumbs: crumbs,
539
544
  description,
540
545
  homeRoute: navigation.root,
541
546
  identity: data.config.identity,
547
+ jsonLd: data.config.jsonLd,
548
+ image: ogImage,
542
549
  locale,
543
550
  modified: lastModified,
544
551
  pageType,
@@ -685,7 +692,12 @@ const localeRedirect = localeTargets
685
692
  <script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
686
693
  )
687
694
  }
688
- <script data-mode={themeMode} is:inline set:html={THEME_INIT_SCRIPT} />
695
+ <script
696
+ data-fixed={data.config.theme.toggle ? undefined : ""}
697
+ data-mode={themeMode}
698
+ is:inline
699
+ set:html={THEME_INIT_SCRIPT}
700
+ />
689
701
  {consent && <ConsentHead consent={consent} />}
690
702
  <Analytics analytics={analytics} />
691
703
  </head>
@@ -45,12 +45,16 @@
45
45
  * after a swap the theme the `before-swap` stamp carried over stands, so a
46
46
  * toggle the reader made still holds for the rest of their visit.
47
47
  *
48
+ * `data-fixed` (present when `theme.toggle` is off) skips the stored
49
+ * preference, so a choice saved before the toggle was removed can't hold the
50
+ * page in a mode the site no longer offers.
51
+ *
48
52
  * Paper is white, so a dark page prints light: `beforeprint` switches the root
49
53
  * to the light theme (every token, Shiki color, and `dark:` utility follows
50
54
  * it) and `afterprint` puts the dark theme back. The listeners sit on
51
55
  * `window`, so the one registration outlives client-router swaps.
52
56
  */
53
- export const THEME_INIT_SCRIPT = `(()=>{const m=document.currentScript?.dataset.mode??"system";const apply=()=>{const r=document.documentElement;let s=null;let blocked=false;try{s=localStorage.getItem("blume-theme");}catch{blocked=true;}const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";r.dataset.theme=s??(blocked&&r.dataset.theme?r.dataset.theme:m==="system"?sys:m);};apply();document.addEventListener("astro:before-swap",(e)=>{const t=document.documentElement.dataset.theme;if(t){e.newDocument.documentElement.dataset.theme=t;}});document.addEventListener("astro:after-swap",apply);let printed=null;addEventListener("beforeprint",()=>{const r=document.documentElement;if(r.dataset.theme==="dark"){printed="dark";r.dataset.theme="light";}});addEventListener("afterprint",()=>{if(printed){document.documentElement.dataset.theme=printed;printed=null;}});})();`;
57
+ export const THEME_INIT_SCRIPT = `(()=>{const ds=document.currentScript?.dataset;const m=ds?.mode??"system";const fixed=ds?.fixed!==undefined;const apply=()=>{const r=document.documentElement;let s=null;let blocked=false;try{s=fixed?null:localStorage.getItem("blume-theme");}catch{blocked=true;}const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";r.dataset.theme=s??(blocked&&r.dataset.theme?r.dataset.theme:m==="system"?sys:m);};apply();document.addEventListener("astro:before-swap",(e)=>{const t=document.documentElement.dataset.theme;if(t){e.newDocument.documentElement.dataset.theme=t;}});document.addEventListener("astro:after-swap",apply);let printed=null;addEventListener("beforeprint",()=>{const r=document.documentElement;if(r.dataset.theme==="dark"){printed="dark";r.dataset.theme="light";}});addEventListener("afterprint",()=>{if(printed){document.documentElement.dataset.theme=printed;printed=null;}});})();`;
54
58
 
55
59
  /**
56
60
  * Keep an embedded Scalar reference on Blume's theme. Scalar picks its own
@@ -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) {
@@ -500,6 +500,12 @@ export interface ThemeConfig {
500
500
  mode?: "system" | "light" | "dark";
501
501
  /** Corner radius scale. Defaults to `md`. */
502
502
  radius?: RadiusScale;
503
+ /**
504
+ * Show the header's light/dark toggle. Defaults to `true`. `false` hides it
505
+ * and ignores any choice a reader saved earlier, so every page renders in
506
+ * `mode` (`system` follows the reader's OS setting).
507
+ */
508
+ toggle?: boolean;
503
509
  }
504
510
 
505
511
  // ---------------------------------------------------------------------------
@@ -1099,6 +1105,12 @@ export interface OgPaletteConfig {
1099
1105
 
1100
1106
  /** Per-page Open Graph image generation. */
1101
1107
  export interface OgConfig {
1108
+ /**
1109
+ * Default social image for pages without their own `seo.image`, in place of
1110
+ * the generated card (which then defaults off): one path or URL, or one per
1111
+ * locale, like any localizable label.
1112
+ */
1113
+ image?: string | Record<string, string>;
1102
1114
  /**
1103
1115
  * Card subtitle. Defaults to the site description; a string overrides it,
1104
1116
  * `false` renders the card without one.
@@ -1223,6 +1235,28 @@ export interface SoftwareConfig {
1223
1235
 
1224
1236
  /** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
1225
1237
  export interface SeoConfig {
1238
+ /**
1239
+ * Structured-data entities shared with a parent site (`example.com/docs`),
1240
+ * referenced by absolute `@id` instead of defined again.
1241
+ */
1242
+ jsonLd?: {
1243
+ /** The WebSite's `@id`, which every page names as `isPartOf`. */
1244
+ websiteId?: string;
1245
+ /** The Organization's `@id`; `organization` still adds its details. */
1246
+ organizationId?: string;
1247
+ /** Credited on pages without their own `authors`. */
1248
+ author?: { "@id": string };
1249
+ /** Every page's publisher, over the Organization. */
1250
+ publisher?: { "@id": string };
1251
+ /** Emit the WebSite node. Defaults to true unless `websiteId` is set. */
1252
+ website?: boolean;
1253
+ };
1254
+ /**
1255
+ * Date pages without a front matter `date` by the git commit that added
1256
+ * their source file, following exact renames. Defaults to `false`.
1257
+ */
1258
+ datePublished?: false | "git";
1259
+
1226
1260
  /**
1227
1261
  * Meta tags written into every page's head, name to content: site
1228
1262
  * verification tokens, `theme-color`, an app banner, and anything else
@@ -1262,7 +1296,12 @@ export interface SeoConfig {
1262
1296
  /** RSS/Atom feeds. */
1263
1297
  rss?: RssConfig;
1264
1298
  /** Generate sitemap.xml (requires `deployment.site`). Defaults to `true`. */
1265
- sitemap?: boolean;
1299
+ sitemap?:
1300
+ | boolean
1301
+ | {
1302
+ /** List each page's translations as hreflang links. Defaults to `false`. */
1303
+ alternates?: boolean;
1304
+ };
1266
1305
  /**
1267
1306
  * The documented product, added to the homepage's JSON-LD as a
1268
1307
  * `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,