blume 1.5.0 → 1.5.1

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.
@@ -438,7 +438,7 @@ export type FontInput = LiteralUnion<FontSlug> | RemoteFontInput | LocalFontInpu
438
438
  export interface FontsConfig {
439
439
  /** Body / prose font. Defaults to `inter`. */
440
440
  body?: FontInput;
441
- /** Display / heading font. Defaults to `inter-tight`. */
441
+ /** Display / heading font. Defaults to `inter` (shared with the body). */
442
442
  display?: FontInput;
443
443
  /** Monospace / code font. Defaults to `ibm-plex-mono`. */
444
444
  mono?: FontInput;
@@ -1,3 +1,4 @@
1
+ import type { FontHead } from "../theme/fonts.ts";
1
2
  import type { UIStrings } from "./i18n-ui.ts";
2
3
  import type { ResolvedConfig, SearchProvider } from "./schema.ts";
3
4
  import type { Navigation, RouteAlternate, VersionAlternate } from "./types.ts";
@@ -209,8 +210,8 @@ export interface BlumeClientData {
209
210
  export interface BlumeData {
210
211
  config: BlumeDataConfig;
211
212
  feeds: BlumeFeed[];
212
- /** CSS variable names for the configured fonts (Astro `<Font>` integration). */
213
- fontCssVars: string[];
213
+ /** Configured fonts for the head: CSS variable + preload weights per family. */
214
+ fontCssVars: FontHead[];
214
215
  /** Sidebar + tab tree for the default locale. */
215
216
  navigation: Navigation;
216
217
  /** Per-locale navigation trees, keyed by locale code (empty without i18n). */
@@ -203,5 +203,14 @@ export declare const buildFontEntries: (fonts: FontsConfig) => FontEntry[];
203
203
  * config tokens; empty when no fonts are set so defaults stay the system stacks.
204
204
  */
205
205
  export declare const buildFontsCss: (fonts: FontsConfig) => string;
206
- /** The CSS variables to feed Astro's `<Font>` component in the document head. */
207
- export declare const configuredCssVars: (fonts: FontsConfig) => string[];
206
+ /** One `<Font>` render in the head: its CSS variable + weights to preload. */
207
+ export interface FontHead {
208
+ cssVariable: string;
209
+ preloadWeights: number[];
210
+ }
211
+ /**
212
+ * The fonts to feed Astro's `<Font>` component in the document head, deduped
213
+ * by CSS variable with preload weights unioned across the roles that share a
214
+ * family (so `display` and `body` both set to Inter preload 400/500/600 once).
215
+ */
216
+ export declare const configuredFonts: (fonts: FontsConfig) => FontHead[];
@@ -231,13 +231,17 @@ Passing `siteUrl` (and `ogEnabled`) derives the page's `canonical` and a generat
231
231
  siteUrl={config.site}
232
232
  ogEnabled={config.og.enabled}
233
233
  ogImage="/opengraph-image.png"
234
+ ogImageAlt="Acme — the fastest docs"
235
+ ogImageSize={{ width: 1200, height: 630 }}
234
236
  page={{ title: config.title }}
235
237
  >
236
238
  <!-- page content -->
237
239
  </PageLayout>
238
240
  ```
239
241
 
240
- Only this page changes — every other route keeps its generated card — so it's how you give the home page alone a bespoke share image.
242
+ Only this page changes — every other route keeps its generated card — so it's how you give the home page alone a bespoke share image. The generated card declares its size and alt text to crawlers on its own; for your own `ogImage`, pass `ogImageAlt` and `ogImageSize` alongside so the share card gets the same treatment.
243
+
244
+ The page also emits schema.org JSON-LD — the same `WebSite` graph the docs pages carry, so the home page (usually a custom page) isn't the one URL without structured data. Pass `structuredDataEnabled={config.structuredData}` to keep it in sync with the [`structuredData`](/docs/configuration/seo) config, or `structuredDataEnabled={false}` to turn it off for one page.
241
245
 
242
246
  `page.title` is used verbatim as the document title (no `- siteTitle` suffix), since marketing pages usually set their own. To give a custom page the full docs chrome instead — sidebar, TOC, and all — wrap it in `RootLayout`, the layout the generated pages use. Pull the required props straight from `blume:data`:
243
247
 
@@ -289,7 +289,7 @@ lastModified: true,
289
289
  | `{ type: "git" }` | Same as `true`, written explicitly. |
290
290
  | `{ type: "frontmatter" }` | Never run git — use only the `lastModified` frontmatter field. |
291
291
 
292
- The git source reads the most recent commit that touched each file, so it works in any git repository — including monorepos — and needs the repo's history at build time (avoid a shallow `--depth 1` checkout in CI). A page's own `lastModified` frontmatter always wins, which is handy for pinning a date or for files that aren't committed yet:
292
+ The git source reads the most recent commit that touched each file, so it works in any git repository — including monorepos — and needs the repo's history at build time. CI platforms usually check out a shallow clone, which silently drops most dates (the build warns with `BLUME_SHALLOW_GIT_HISTORY` when that happens): on Vercel, set the `VERCEL_DEEP_CLONE=true` environment variable; with `actions/checkout`, set `fetch-depth: 0`. A page's own `lastModified` frontmatter always wins, which is handy for pinning a date or for files that aren't committed yet:
293
293
 
294
294
  ```mdx page.mdx
295
295
  ---
@@ -15,7 +15,7 @@ theme: {
15
15
  radius: "md", // none | sm | md | lg
16
16
  mode: "system", // system | light | dark
17
17
  fonts: { // self-hosted Google Fonts
18
- display: "inter-tight",
18
+ display: "inter",
19
19
  body: "inter",
20
20
  mono: "ibm-plex-mono",
21
21
  },
@@ -57,12 +57,14 @@ A toggle in the header always lets readers switch, and their choice is remembere
57
57
  - **`body`** — body text, UI, and prose
58
58
  - **`mono`** — code blocks and inline code
59
59
 
60
+ Headings get display-grade letter-spacing (`-0.05em`) from the theme itself, so whatever you pick for `display` — including text families like the Inter default — reads correctly at heading sizes instead of depending on tracking built into the font.
61
+
60
62
  Each defaults to a curated Google Font, so Blume looks intentional out of the box:
61
63
 
62
64
  ```ts blume.config.ts lineNumbers
63
65
  theme: {
64
66
  fonts: {
65
- display: "inter-tight", // default
67
+ display: "inter", // default
66
68
  body: "inter", // default
67
69
  mono: "ibm-plex-mono", // default
68
70
  },
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: CLI
3
- description: Every Blume command and flag explained in one place — init, dev, build, preview, add, sync, and eject — along with the options each one accepts.
3
+ description: Every Blume command and flag explained in one place, along with the options each one accepts.
4
4
  ---
5
5
 
6
6
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -61,7 +61,7 @@ import {
61
61
  examplesEntryTemplate,
62
62
  tailwindEntryTemplate,
63
63
  } from "../theme/entry.ts";
64
- import { buildFontsCss, configuredCssVars } from "../theme/fonts.ts";
64
+ import { buildFontsCss, configuredFonts } from "../theme/fonts.ts";
65
65
  import { buildThemeCss } from "../theme/palette.ts";
66
66
  import { twoslashCss } from "../theme/twoslash.ts";
67
67
  import { planComponentSlots } from "./component-slots.ts";
@@ -1255,7 +1255,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1255
1255
  })),
1256
1256
  // CSS variables for Astro's <Font> component; matches the astro.config
1257
1257
  // `fonts:` entries derived from the same theme.fonts config.
1258
- fontCssVars: configuredCssVars(config.theme.fonts),
1258
+ fontCssVars: configuredFonts(config.theme.fonts),
1259
1259
  navigation: withRepoUrl(graph.navigation),
1260
1260
  // Per-locale navigation; the catch-all selects the active locale's tree.
1261
1261
  navigationByLocale,
@@ -1624,6 +1624,11 @@ export const generateRuntime = async (
1624
1624
  // private and filtered out anyway, but the intent is the user's pages.
1625
1625
  const ogRoutes = customOgRoutes(pages, config.title, config.seo.og.titles);
1626
1626
 
1627
+ // Whether the generated `/changelog` index exists — shared by the OG endpoint
1628
+ // (which adds the index's own card) and the page write below. Computed here,
1629
+ // before the MCP discovery pages are appended, on the user's own pages.
1630
+ const changelogIndex = hasGeneratedChangelog(project, pages);
1631
+
1627
1632
  // The hosted MCP server. The `.well-known` discovery docs are injected as
1628
1633
  // prerendered routes alongside user pages; the server endpoint itself is a
1629
1634
  // normal (server-rendered) page written by `writeMcpFiles`.
@@ -1780,12 +1785,12 @@ export const generateRuntime = async (
1780
1785
  if (config.seo.og.enabled) {
1781
1786
  await write(
1782
1787
  join(srcDir, "pages", "og", "[...slug].png.ts"),
1783
- ogEndpointTemplate(ogRoutes, projectOgFonts(project))
1788
+ ogEndpointTemplate(ogRoutes, projectOgFonts(project), changelogIndex)
1784
1789
  );
1785
1790
  }
1786
1791
 
1787
1792
  // Changelog index (`/changelog`), rendered through the Update timeline layout.
1788
- if (hasGeneratedChangelog(project, pages)) {
1793
+ if (changelogIndex) {
1789
1794
  await write(
1790
1795
  join(srcDir, "pages", "changelog.astro"),
1791
1796
  changelogIndexTemplate({
@@ -1906,7 +1911,7 @@ export const generateRuntime = async (
1906
1911
  ...pages.map((page) => page.pattern),
1907
1912
  ...referenceRoutes(config),
1908
1913
  ]);
1909
- if (hasGeneratedChangelog(project, pages)) {
1914
+ if (changelogIndex) {
1910
1915
  navTargetRoutes.add("/changelog");
1911
1916
  }
1912
1917
  // Curated `search.popular` icons live outside the navigation model, so they
@@ -694,6 +694,11 @@ ${userConfigSetup}export default defineConfig({
694
694
  },
695
695
  },
696
696
  devToolbar: { enabled: false },
697
+ // Navigations are full document loads (no client router), so the next page's
698
+ // HTML is fetched on hover/viewport to hide the request latency behind the
699
+ // user's intent. Pairs with the cross-document view-transition rule in the
700
+ // theme sheet, which smooths the swap itself.
701
+ prefetch: { prefetchAll: true },
697
702
  vite: {
698
703
  plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
699
704
  // Everything hydration can reach must be part of the dev dep optimizer's
@@ -1454,7 +1459,8 @@ export function GET({ props }: { props: { section: string } }) {
1454
1459
  /** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
1455
1460
  export const ogEndpointTemplate = (
1456
1461
  customRoutes: OgCustomRoute[] = [],
1457
- og: { families?: OgFontFamilies; fonts?: OgFont[] } = {}
1462
+ og: { families?: OgFontFamilies; fonts?: OgFont[] } = {},
1463
+ includeChangelog = false
1458
1464
  ): string =>
1459
1465
  `// Generated by Blume. Do not edit.
1460
1466
  import { renderOgImage } from "blume/og";
@@ -1493,6 +1499,13 @@ export function getStaticPaths() {
1493
1499
  }
1494
1500
  for (const route of data.routes) {
1495
1501
  add(route.path === "/" ? "index" : route.path.slice(1), route.title);
1502
+ }${
1503
+ includeChangelog
1504
+ ? `
1505
+ // The generated changelog index is not a content route, so it needs its own
1506
+ // card. Added last: a custom page or content route owning /changelog wins.
1507
+ add("changelog", data.ui.changelog?.title ?? "Changelog");`
1508
+ : ""
1496
1509
  }
1497
1510
  return paths;
1498
1511
  }
@@ -1795,10 +1808,9 @@ const contentLocale =
1795
1808
  const contentDir = i18n
1796
1809
  ? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
1797
1810
  : "ltr";
1798
- const absolute = (path: string) => {
1799
- const p = withBase(path);
1800
- return base + (p === "/" ? "" : p);
1801
- };
1811
+ // The root route keeps its trailing slash (\`https://site/\`) so canonical and
1812
+ // hreflang URLs byte-match the sitemap's <loc> for the home page.
1813
+ const absolute = (path: string) => base + withBase(path);
1802
1814
 
1803
1815
  // An archived page defaults its canonical to the same page in the latest docs
1804
1816
  // when that page still exists — search engines treat the live page as
@@ -1809,7 +1821,7 @@ const canonical =
1809
1821
  (archived && archived.canonical === "latest" && latestVersionAlt && base
1810
1822
  ? absolute(latestVersionAlt.path)
1811
1823
  : base
1812
- ? \`\${base}\${basedRoute === "/" ? "" : encodeURI(basedRoute)}\`
1824
+ ? \`\${base}\${basedRoute === "/" ? "/" : encodeURI(basedRoute)}\`
1813
1825
  : null);
1814
1826
  const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
1815
1827
 
@@ -2122,6 +2134,12 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
2122
2134
  const basedRoute = withBase("/changelog");
2123
2135
  const canonical = base ? base + basedRoute : null;
2124
2136
 
2137
+ // The generated OG card for this route (the /og endpoint emits it alongside
2138
+ // the content-route cards), absolutized like the catch-all's so crawlers get
2139
+ // a full URL when the site is known.
2140
+ const ogPath = data.config.og.enabled ? withBase("/og/changelog.png") : null;
2141
+ const ogImage = ogPath && base ? base + ogPath : ogPath;
2142
+
2125
2143
  // The page chrome (h1, title, description) comes from the same translatable
2126
2144
  // \`changelog\` group as the reveal button; optional chaining tolerates a
2127
2145
  // not-yet-regenerated data snapshot from before these keys existed.
@@ -2129,7 +2147,10 @@ const changelogTitle = data.ui.changelog?.title ?? "Changelog";
2129
2147
  const changelogDescription =
2130
2148
  data.ui.changelog?.description ??
2131
2149
  "Product updates, new features, and fixes from every release.";
2132
- const pageTitle = data.config.title + " " + changelogTitle;
2150
+ // The layout suffixes "- {site title}" itself, so the page title is just the
2151
+ // changelog's own name — prefixing the site title too would double it
2152
+ // ("Acme Changelog - Acme").
2153
+ const pageTitle = changelogTitle;
2133
2154
 
2134
2155
  const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2135
2156
  ---
@@ -2161,7 +2182,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2161
2182
  fontCssVars={data.fontCssVars}
2162
2183
  searchEnabled={data.config.search.enabled}
2163
2184
  indexable={true}
2164
- ogImage={null}
2185
+ ogImage={ogImage}
2186
+ ogGenerated={Boolean(ogImage)}
2165
2187
  x={data.config.x}
2166
2188
  canonical={canonical}
2167
2189
  exportPdf={${options.exportPdf}}
@@ -1,14 +1,34 @@
1
1
  ---
2
2
  // Emits the optimized @font-face declarations + preload links for each
3
3
  // configured font (Astro's Fonts API). Renders nothing when no fonts are set;
4
- // the CSS variables match the astro.config `fonts:` entries.
4
+ // the CSS variables match the astro.config `fonts:` entries. Preloads are
5
+ // narrowed to the weights above-the-fold text renders in (see
6
+ // `theme/fonts.ts`); a bare string entry — an older generated template — keeps
7
+ // the previous preload-everything behavior.
5
8
  import { Font } from "astro:assets";
9
+ import type { FontHead } from "../../theme/fonts.ts";
6
10
 
7
11
  interface Props {
8
- cssVars: string[];
12
+ cssVars: (string | FontHead)[];
9
13
  }
10
14
 
11
15
  const { cssVars } = Astro.props;
16
+
17
+ const entries = cssVars.map((value) =>
18
+ typeof value === "string"
19
+ ? { cssVariable: value, preload: true as const }
20
+ : {
21
+ cssVariable: value.cssVariable,
22
+ preload: value.preloadWeights.map((weight) => ({
23
+ style: "normal" as const,
24
+ weight,
25
+ })),
26
+ }
27
+ );
12
28
  ---
13
29
 
14
- {cssVars.map((cssVar) => <Font cssVariable={cssVar} preload />)}
30
+ {
31
+ entries.map((entry) => (
32
+ <Font cssVariable={entry.cssVariable} preload={entry.preload} />
33
+ ))
34
+ }
@@ -24,12 +24,14 @@ import type {
24
24
  } from "../../core/data.ts";
25
25
  import { EN_UI } from "../../core/i18n-ui.ts";
26
26
  import type { UIStrings } from "../../core/i18n-ui.ts";
27
+ import type { FontHead } from "../../theme/fonts.ts";
27
28
  import type { LocaleSwitchOption, Navigation } from "../../core/types.ts";
28
29
  import {
29
30
  OG_IMAGE_HEIGHT,
30
31
  OG_IMAGE_TYPE,
31
32
  OG_IMAGE_WIDTH,
32
33
  } from "../../og/dimensions.ts";
34
+ import { buildStructuredData } from "../../seo/jsonld.ts";
33
35
  import { normalizeXHandle } from "../../seo/x-handle.ts";
34
36
  import { withBase } from "../islands/base-path.ts";
35
37
  import "blume:theme";
@@ -57,7 +59,7 @@ interface Props {
57
59
  */
58
60
  page?: { title?: string; description?: string; route?: string };
59
61
  themeMode: "system" | "light" | "dark";
60
- fontCssVars?: string[];
62
+ fontCssVars?: (string | FontHead)[];
61
63
  searchEnabled: boolean;
62
64
  /**
63
65
  * Opt this page out of the header's Ask AI trigger. Defaults to whether Ask
@@ -75,7 +77,24 @@ interface Props {
75
77
  ogEnabled?: boolean;
76
78
  /** SEO overrides; a marketing page often sets its own canonical/og image. */
77
79
  ogImage?: string | null;
80
+ /**
81
+ * Alt text for a user-supplied `ogImage`, emitted as `og:image:alt` /
82
+ * `twitter:image:alt`. The generated card derives its own from the title.
83
+ */
84
+ ogImageAlt?: string;
85
+ /**
86
+ * Pixel size of a user-supplied `ogImage`, emitted as `og:image:width` /
87
+ * `og:image:height` so crawlers can lay the card out before fetching it.
88
+ * The generated card declares its known size automatically.
89
+ */
90
+ ogImageSize?: { height: number; width: number };
78
91
  canonical?: string | null;
92
+ /**
93
+ * Emit schema.org JSON-LD for this page (`data.config.structuredData`) — a
94
+ * WebSite node, plus an article node on non-root routes. Defaults to on,
95
+ * matching RootLayout.
96
+ */
97
+ structuredDataEnabled?: boolean;
79
98
  /**
80
99
  * X (Twitter) attribution (`data.config.x`): the site's account and an author
81
100
  * handle, emitted as `twitter:site`/`twitter:creator`.
@@ -113,7 +132,10 @@ const {
113
132
  siteUrl,
114
133
  ogEnabled,
115
134
  ogImage,
135
+ ogImageAlt,
136
+ ogImageSize,
116
137
  canonical,
138
+ structuredDataEnabled,
117
139
  x,
118
140
  noindex,
119
141
  locale = "en",
@@ -153,11 +175,12 @@ const basedRoute = withBase(route);
153
175
  // don't come out double-slashed — the catch-all strips it the same way.
154
176
  const siteBase = siteUrl ? siteUrl.replace(/\/$/u, "") : null;
155
177
  // The route-derived part is percent-encoded (the sitemap convention) so a
156
- // Unicode route slug yields a legal URI that byte-matches the sitemap <loc>.
178
+ // Unicode route slug yields a legal URI that byte-matches the sitemap <loc> —
179
+ // including the root, whose <loc> is `https://site/` with the slash.
157
180
  const resolvedCanonical =
158
181
  canonical ??
159
182
  (siteBase
160
- ? `${siteBase}${basedRoute === "/" ? "" : encodeURI(basedRoute)}`
183
+ ? `${siteBase}${basedRoute === "/" ? "/" : encodeURI(basedRoute)}`
161
184
  : null);
162
185
  // An explicit `ogImage` wins. A root-relative path (e.g. an image dropped in
163
186
  // `public/`) is resolved against the site URL so crawlers get an absolute
@@ -180,6 +203,25 @@ const twitterCard = resolvedOgImage ? "summary_large_image" : "summary";
180
203
  const xSite = normalizeXHandle(x?.handle);
181
204
  const xCreator = normalizeXHandle(x?.creator);
182
205
 
206
+ // JSON-LD, mirroring RootLayout: skipped when disabled or noindexed. A custom
207
+ // page has no breadcrumb trail; the home route yields the WebSite node alone.
208
+ const structuredData =
209
+ structuredDataEnabled === false || noindex
210
+ ? null
211
+ : buildStructuredData({
212
+ base: import.meta.env.BASE_URL,
213
+ breadcrumbs: [],
214
+ description,
215
+ locale,
216
+ route,
217
+ siteName: site.title,
218
+ siteUrl: siteUrl ?? null,
219
+ title: pageTitle,
220
+ });
221
+ const structuredDataJson = structuredData
222
+ ? JSON.stringify(structuredData).replaceAll("<", "\\u003c")
223
+ : null;
224
+
183
225
  const bannerKey = banner?.dismissible ? banner.key : null;
184
226
  ---
185
227
 
@@ -218,8 +260,26 @@ const bannerKey = banner?.dismissible ? banner.key : null;
218
260
  <meta content={pageTitle} property="og:image:alt" />
219
261
  </>
220
262
  )}
263
+ {!ogGenerated && ogImageSize && (
264
+ <>
265
+ <meta
266
+ content={String(ogImageSize.width)}
267
+ property="og:image:width"
268
+ />
269
+ <meta
270
+ content={String(ogImageSize.height)}
271
+ property="og:image:height"
272
+ />
273
+ </>
274
+ )}
275
+ {!ogGenerated && ogImageAlt && (
276
+ <meta content={ogImageAlt} property="og:image:alt" />
277
+ )}
221
278
  <meta content={resolvedOgImage} name="twitter:image" />
222
279
  {ogGenerated && <meta content={pageTitle} name="twitter:image:alt" />}
280
+ {!ogGenerated && ogImageAlt && (
281
+ <meta content={ogImageAlt} name="twitter:image:alt" />
282
+ )}
223
283
  </>
224
284
  )
225
285
  }
@@ -228,6 +288,15 @@ const bannerKey = banner?.dismissible ? banner.key : null;
228
288
  {description && <meta content={description} name="twitter:description" />}
229
289
  {xSite && <meta content={xSite} name="twitter:site" />}
230
290
  {xCreator && <meta content={xCreator} name="twitter:creator" />}
291
+ {
292
+ structuredDataJson && (
293
+ <script
294
+ is:inline
295
+ set:html={structuredDataJson}
296
+ type="application/ld+json"
297
+ />
298
+ )
299
+ }
231
300
  {
232
301
  bannerKey && (
233
302
  <script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
@@ -2,6 +2,7 @@
2
2
  import "blume:theme";
3
3
  import { EN_UI } from "../../core/i18n-ui.ts";
4
4
  import type { UIStrings } from "../../core/i18n-ui.ts";
5
+ import type { FontHead } from "../../theme/fonts.ts";
5
6
  import type { Navigation } from "../../core/types.ts";
6
7
  import Analytics from "./Analytics.astro";
7
8
  import WebMcp from "./WebMcp.astro";
@@ -51,7 +52,7 @@ interface Props {
51
52
  navigation: Navigation;
52
53
  route: string;
53
54
  themeMode: "system" | "light" | "dark";
54
- fontCssVars?: string[];
55
+ fontCssVars?: (string | FontHead)[];
55
56
  searchEnabled: boolean;
56
57
  pageTitle: string;
57
58
  /** Keep the reference route out of crawler indexes. */
@@ -11,6 +11,7 @@ import type {
11
11
  NavSelector as NavSelectorType,
12
12
  } from "../../core/types.ts";
13
13
  import "blume:theme";
14
+ import type { FontHead } from "../../theme/fonts.ts";
14
15
  import type { ComponentOverride } from "../../core/define-components.ts";
15
16
  import {
16
17
  OG_IMAGE_HEIGHT,
@@ -90,7 +91,7 @@ interface Props {
90
91
  imageZoom?: boolean;
91
92
  codeWrap?: boolean;
92
93
  themeMode: "system" | "light" | "dark";
93
- fontCssVars?: string[];
94
+ fontCssVars?: (string | FontHead)[];
94
95
  searchEnabled: boolean;
95
96
  indexable: boolean;
96
97
  ogImage?: string | null;
@@ -510,7 +510,7 @@ export type FontInput =
510
510
  export interface FontsConfig {
511
511
  /** Body / prose font. Defaults to `inter`. */
512
512
  body?: FontInput;
513
- /** Display / heading font. Defaults to `inter-tight`. */
513
+ /** Display / heading font. Defaults to `inter` (shared with the body). */
514
514
  display?: FontInput;
515
515
  /** Monospace / code font. Defaults to `ibm-plex-mono`. */
516
516
  mono?: FontInput;
package/src/core/data.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { FontHead } from "../theme/fonts.ts";
1
2
  import type { UIStrings } from "./i18n-ui.ts";
2
3
  import type { ResolvedConfig, SearchProvider } from "./schema.ts";
3
4
  import type { Navigation, RouteAlternate, VersionAlternate } from "./types.ts";
@@ -192,8 +193,8 @@ export interface BlumeClientData {
192
193
  export interface BlumeData {
193
194
  config: BlumeDataConfig;
194
195
  feeds: BlumeFeed[];
195
- /** CSS variable names for the configured fonts (Astro `<Font>` integration). */
196
- fontCssVars: string[];
196
+ /** Configured fonts for the head: CSS variable + preload weights per family. */
197
+ fontCssVars: FontHead[];
197
198
  /** Sidebar + tab tree for the default locale. */
198
199
  navigation: Navigation;
199
200
  /** Per-locale navigation trees, keyed by locale code (empty without i18n). */
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
 
3
3
  import { relative } from "pathe";
4
4
 
5
+ import type { Diagnostic } from "./types.ts";
6
+
5
7
  /** Normalized form of the `lastModified` config. */
6
8
  export interface ResolvedLastModified {
7
9
  enabled: boolean;
@@ -95,3 +97,50 @@ export const gitLastModifiedTimes = (
95
97
  return new Map();
96
98
  }
97
99
  };
100
+
101
+ /**
102
+ * Whether the repository containing `root` is a shallow clone. Returns false
103
+ * when git is unavailable or the project isn't a repo — those cases already
104
+ * yield no dates at all, and the shallow warning would only mislead.
105
+ */
106
+ export const isShallowGitRepository = (root: string): boolean => {
107
+ try {
108
+ return (
109
+ execFileSync(
110
+ // oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
111
+ "git",
112
+ ["-C", root, "rev-parse", "--is-shallow-repository"],
113
+ // stderr silenced: outside a repository the probe fails by design.
114
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
115
+ ).trim() === "true"
116
+ );
117
+ } catch {
118
+ return false;
119
+ }
120
+ };
121
+
122
+ /**
123
+ * A warning for git-derived dates silently missing because the build ran in a
124
+ * shallow clone — the default on Vercel and `actions/checkout`, where `git log`
125
+ * only sees the last few commits, so most pages get no date and the sitemap's
126
+ * `<lastmod>` / "Last updated" stamps quietly disappear in production while
127
+ * working locally. Empty when every page got a date or the clone isn't
128
+ * shallow.
129
+ */
130
+ export const lastModifiedShallowWarning = (
131
+ root: string,
132
+ undatedCount: number
133
+ ): Diagnostic[] => {
134
+ if (undatedCount === 0 || !isShallowGitRepository(root)) {
135
+ return [];
136
+ }
137
+ return [
138
+ {
139
+ code: "BLUME_SHALLOW_GIT_HISTORY",
140
+ 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.`,
141
+ severity: "warning",
142
+ suggestion:
143
+ "Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout.",
144
+ },
145
+ ];
146
+ };
@@ -6,6 +6,7 @@ import { buildContentGraph } from "./graph.ts";
6
6
  import { i18nDiagnostics } from "./i18n.ts";
7
7
  import {
8
8
  gitLastModifiedTimes,
9
+ lastModifiedShallowWarning,
9
10
  resolveLastModifiedConfig,
10
11
  } from "./last-modified.ts";
11
12
  import { buildManifest } from "./manifest.ts";
@@ -294,6 +295,7 @@ export const scanProject = async (
294
295
  // (which shares these page objects) picks them up. Frontmatter always wins;
295
296
  // git applies to filesystem entries, other sources supply dates on the entry.
296
297
  const lastModified = resolveLastModifiedConfig(config.lastModified);
298
+ const lastModifiedWarnings: Diagnostic[] = [];
297
299
  if (lastModified.enabled && lastModified.source === "git") {
298
300
  const fsPaths = pages
299
301
  .map((page) => page.sourcePath)
@@ -310,6 +312,14 @@ export const scanProject = async (
310
312
  page.lastModified = gitTimes.get(page.sourcePath);
311
313
  }
312
314
  }
315
+ // A shallow CI clone (Vercel, actions/checkout) silently drops most dates;
316
+ // surface that instead of letting production diverge from local builds.
317
+ const undated = pages.filter(
318
+ (page) => page.sourcePath && !page.lastModified
319
+ ).length;
320
+ lastModifiedWarnings.push(
321
+ ...lastModifiedShallowWarning(context.root, undated)
322
+ );
313
323
  }
314
324
 
315
325
  const graph = buildContentGraph(pages, {
@@ -339,6 +349,7 @@ export const scanProject = async (
339
349
  ...graph.diagnostics,
340
350
  ...i18nWarnings,
341
351
  ...versionWarnings,
352
+ ...lastModifiedWarnings,
342
353
  ],
343
354
  droppedPages,
344
355
  graph,
@@ -623,7 +623,7 @@ const themeConfigSchema = z.strictObject({
623
623
  fonts: z
624
624
  .strictObject({
625
625
  body: fontValueSchema.default("inter"),
626
- display: fontValueSchema.default("inter-tight"),
626
+ display: fontValueSchema.default("inter"),
627
627
  mono: fontValueSchema.default("ibm-plex-mono"),
628
628
  })
629
629
  .prefault({}),