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.
- package/CHANGELOG.md +15 -0
- package/README.md +16 -12
- package/dist/cli/index.js +130 -24
- package/dist/cli/index.js.map +10 -10
- package/dist/types/core/config-input.d.ts +1 -1
- package/dist/types/core/data.d.ts +3 -2
- package/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/index.mdx +1 -1
- package/docs/configuration/theming.mdx +4 -2
- package/docs/reference/cli.mdx +1 -1
- package/package.json +1 -1
- package/src/astro/generate.ts +10 -5
- package/src/astro/templates.ts +30 -8
- package/src/components/layout/Fonts.astro +23 -3
- package/src/components/layout/PageLayout.astro +72 -3
- package/src/components/layout/ReferenceLayout.astro +2 -1
- package/src/components/layout/RootLayout.astro +2 -1
- package/src/core/config-input.ts +1 -1
- package/src/core/data.ts +3 -2
- package/src/core/last-modified.ts +49 -0
- package/src/core/project-graph.ts +11 -0
- package/src/core/schema.ts +1 -1
- package/src/deploy/vercel-negotiation.ts +34 -14
- package/src/og/card.ts +3 -1
- package/src/theme/entry.ts +24 -2
- package/src/theme/fonts.ts +75 -3
|
@@ -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
|
|
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
|
-
/**
|
|
213
|
-
fontCssVars:
|
|
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
|
-
/**
|
|
207
|
-
export
|
|
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
|
|
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
|
|
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
|
|
67
|
+
display: "inter", // default
|
|
66
68
|
body: "inter", // default
|
|
67
69
|
mono: "ibm-plex-mono", // default
|
|
68
70
|
},
|
package/docs/reference/cli.mdx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: CLI
|
|
3
|
-
description: Every Blume command and flag explained in one place
|
|
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
package/src/astro/generate.ts
CHANGED
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
examplesEntryTemplate,
|
|
62
62
|
tailwindEntryTemplate,
|
|
63
63
|
} from "../theme/entry.ts";
|
|
64
|
-
import { buildFontsCss,
|
|
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:
|
|
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 (
|
|
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 (
|
|
1914
|
+
if (changelogIndex) {
|
|
1910
1915
|
navTargetRoutes.add("/changelog");
|
|
1911
1916
|
}
|
|
1912
1917
|
// Curated `search.popular` icons live outside the navigation model, so they
|
package/src/astro/templates.ts
CHANGED
|
@@ -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
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
{
|
|
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;
|
package/src/core/config-input.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
196
|
-
fontCssVars:
|
|
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,
|
package/src/core/schema.ts
CHANGED
|
@@ -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
|
|
626
|
+
display: fontValueSchema.default("inter"),
|
|
627
627
|
mono: fontValueSchema.default("ibm-plex-mono"),
|
|
628
628
|
})
|
|
629
629
|
.prefault({}),
|