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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # blume
2
2
 
3
+ ## 1.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ca28fb8: Generate an Open Graph card for the changelog index. The `/changelog` page previously shipped no `og:image` (and fell back to X's compact summary card); it now gets the same generated card every other page has, served at `/og/changelog.png`.
8
+ - ca28fb8: Stop doubling the site title in the changelog index's document title. The page passed "{site title} {Changelog}" to a layout that suffixes "- {site title}" itself, producing titles like "Acme Changelog - Acme"; it now reads "Changelog - Acme".
9
+ - 8db1ecb: The default display font is now Inter, matching the body font — default sites download one text family instead of two (Inter Tight's files alone were ~190 KB per page). To keep the tightened display look, headings now get `letter-spacing: -0.05em` from the theme itself, which also means any font you configure for `display` reads correctly at heading sizes instead of depending on tracking built into the font. Inter Tight remains available as the `inter-tight` slug.
10
+ - ca28fb8: Let a user-supplied `ogImage` on `PageLayout` declare its alt text and pixel size via the new `ogImageAlt` and `ogImageSize` props, emitted as `og:image:alt` / `og:image:width` / `og:image:height` — the metadata Blume's generated cards already declare automatically.
11
+ - ca28fb8: Emit schema.org JSON-LD from `PageLayout`, so custom pages — most importantly a custom home page — carry the same `WebSite` structured-data graph the docs pages do. On by default; pass `structuredDataEnabled={false}` to opt a page out.
12
+ - ca28fb8: Keep the trailing slash on the home page's canonical, `og:url`, and hreflang URLs (`https://site/` instead of `https://site`), so they byte-match the sitemap's `<loc>` for the root route.
13
+ - ca28fb8: Warn when `lastModified` runs in a shallow git clone. CI platforms usually check out limited history, which silently dropped most git-derived dates — sitemap `<lastmod>` and "Last updated" stamps vanished in production while working locally. The build now emits a `BLUME_SHALLOW_GIT_HISTORY` warning pointing at the fix (`VERCEL_DEEP_CLONE=true` on Vercel, `fetch-depth: 0` for actions/checkout).
14
+ - bad025f: Smooth out page navigation: every link now prefetches on hover/viewport, and same-origin navigations opt into cross-document view transitions in supporting browsers, replacing the hard flash between pages with a crossfade. Respects `prefers-reduced-motion`.
15
+ - 8db1ecb: Preload only the font weights above-the-fold text actually renders in (body 400/500, display 500/600, mono 400) instead of every configured face. On a default site this cuts the per-page font preloads from ten files (~260 KB) to a handful (~50 KB), bandwidth that was competing with the critical CSS and pushing out mobile LCP. All other faces still load on demand through their `@font-face` rules with `font-display: swap`.
16
+ - ca28fb8: Redirect trailing-slash URLs to their slashless twins on Vercel with a 308. `/docs/` and `/docs` previously both served 200 as duplicate URLs; the routing config now collapses the slashed form onto the canonical slashless one (the root `/` is untouched).
17
+
3
18
  ## 1.5.0
4
19
 
5
20
  ### Minor Changes
package/README.md CHANGED
@@ -49,18 +49,22 @@ Blume works with any package manager and never requires you to set up Astro or T
49
49
 
50
50
  ## CLI
51
51
 
52
- | Command | Description |
53
- | ------------------ | ----------------------------------------------------- |
54
- | `blume init [dir]` | Scaffold a project (interactive by default). |
55
- | `blume dev` | Start the dev server with hot reload. |
56
- | `blume build` | Build the static (or server) site. |
57
- | `blume preview` | Preview the last build. |
58
- | `blume add <item>` | Install a source component from the registry. |
59
- | `blume sync` | Re-fetch remote content sources and regenerate. |
60
- | `blume eject` | Promote the runtime into a standalone Astro app. |
61
- | `blume check` | Type-check the docs site with `astro check`. |
62
- | `blume validate` | Validate internal, anchor, asset, and external links. |
63
- | `blume doctor` | Diagnose config and content problems. |
52
+ | Command | Description |
53
+ | --- | --- |
54
+ | `blume init [dir]` | Scaffold a project (interactive by default). |
55
+ | `blume dev` | Start the dev server with hot reload. |
56
+ | `blume build` | Build the static (or server) site. |
57
+ | `blume preview` | Preview the last build. |
58
+ | `blume add <item>` | Install a source component from the registry. |
59
+ | `blume sync` | Re-fetch remote content sources and regenerate. |
60
+ | `blume eject` | Promote the runtime into a standalone Astro app. |
61
+ | `blume check` | Type-check the docs site with `astro check`. |
62
+ | `blume validate` | Validate internal, anchor, asset, and external links. |
63
+ | `blume doctor` | Diagnose config and content problems. |
64
+ | `blume audit` | Audit the built site for SEO and health issues. |
65
+ | `blume eval` | Test the docs: an agent answers your questions using only the documentation. |
66
+ | `blume translate` | Translate docs into the configured locales with a local agent CLI. |
67
+ | `blume version [id]` | Freeze the current docs as an archived version (no id lists configured versions). |
64
68
 
65
69
  See the [CLI reference](https://useblume.dev/docs/reference/cli) for every flag.
66
70
 
package/dist/cli/index.js CHANGED
@@ -3607,7 +3607,47 @@ ${lines.join(`
3607
3607
  }
3608
3608
  ` : "";
3609
3609
  };
3610
- var configuredCssVars = (fonts) => buildFontEntries(fonts).map((entry) => entry.cssVariable);
3610
+ var PRELOAD_WEIGHTS = {
3611
+ body: [400, 500],
3612
+ display: [500, 600],
3613
+ mono: [400]
3614
+ };
3615
+ var entryWeights = (entry) => entry.kind === "remote" ? entry.weights : entry.variants.map((variant) => variant.weight);
3616
+ var preloadWeightsFor = (slot, entry) => {
3617
+ const preferred = PRELOAD_WEIGHTS[slot];
3618
+ const weights = entryWeights(entry);
3619
+ const numeric = weights.filter((weight) => typeof weight === "number");
3620
+ const hits = preferred.filter((weight) => numeric.includes(weight));
3621
+ if (hits.length > 0) {
3622
+ return hits;
3623
+ }
3624
+ return numeric.length === weights.length ? numeric : preferred;
3625
+ };
3626
+ var configuredFonts = (fonts) => {
3627
+ if (!fonts) {
3628
+ return [];
3629
+ }
3630
+ const heads = new Map;
3631
+ for (const slot of SLOTS) {
3632
+ const value = fonts[slot];
3633
+ if (value === undefined) {
3634
+ continue;
3635
+ }
3636
+ const entry = resolveFontValue(slot, value);
3637
+ if (!entry) {
3638
+ continue;
3639
+ }
3640
+ const weights = heads.get(entry.cssVariable) ?? new Set;
3641
+ for (const weight of preloadWeightsFor(slot, entry)) {
3642
+ weights.add(weight);
3643
+ }
3644
+ heads.set(entry.cssVariable, weights);
3645
+ }
3646
+ return [...heads].map(([cssVariable, weights]) => ({
3647
+ cssVariable,
3648
+ preloadWeights: [...weights].toSorted((a, b) => a - b)
3649
+ }));
3650
+ };
3611
3651
 
3612
3652
  // src/astro/templates.ts
3613
3653
  var WORKSPACE_MARKERS = [
@@ -3936,6 +3976,11 @@ ${userConfigSetup}export default defineConfig({
3936
3976
  },
3937
3977
  },
3938
3978
  devToolbar: { enabled: false },
3979
+ // Navigations are full document loads (no client router), so the next page's
3980
+ // HTML is fetched on hover/viewport to hide the request latency behind the
3981
+ // user's intent. Pairs with the cross-document view-transition rule in the
3982
+ // theme sheet, which smooths the swap itself.
3983
+ prefetch: { prefetchAll: true },
3939
3984
  vite: {
3940
3985
  plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
3941
3986
  // Everything hydration can reach must be part of the dev dep optimizer's
@@ -4444,7 +4489,7 @@ export function GET({ props }: { props: { section: string } }) {
4444
4489
  });
4445
4490
  }
4446
4491
  `;
4447
- var ogEndpointTemplate = (customRoutes = [], og = {}) => `// Generated by Blume. Do not edit.
4492
+ var ogEndpointTemplate = (customRoutes = [], og = {}, includeChangelog = false) => `// Generated by Blume. Do not edit.
4448
4493
  import { renderOgImage } from "blume/og";
4449
4494
  import type { OgFont, OgFontFamilies } from "blume/og";
4450
4495
  import data from "blume:data";
@@ -4479,7 +4524,10 @@ export function getStaticPaths() {
4479
4524
  }
4480
4525
  for (const route of data.routes) {
4481
4526
  add(route.path === "/" ? "index" : route.path.slice(1), route.title);
4482
- }
4527
+ }${includeChangelog ? `
4528
+ // The generated changelog index is not a content route, so it needs its own
4529
+ // card. Added last: a custom page or content route owning /changelog wins.
4530
+ add("changelog", data.ui.changelog?.title ?? "Changelog");` : ""}
4483
4531
  return paths;
4484
4532
  }
4485
4533
 
@@ -4753,10 +4801,9 @@ const contentLocale =
4753
4801
  const contentDir = i18n
4754
4802
  ? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
4755
4803
  : "ltr";
4756
- const absolute = (path: string) => {
4757
- const p = withBase(path);
4758
- return base + (p === "/" ? "" : p);
4759
- };
4804
+ // The root route keeps its trailing slash (\`https://site/\`) so canonical and
4805
+ // hreflang URLs byte-match the sitemap's <loc> for the home page.
4806
+ const absolute = (path: string) => base + withBase(path);
4760
4807
 
4761
4808
  // An archived page defaults its canonical to the same page in the latest docs
4762
4809
  // when that page still exists — search engines treat the live page as
@@ -4767,7 +4814,7 @@ const canonical =
4767
4814
  (archived && archived.canonical === "latest" && latestVersionAlt && base
4768
4815
  ? absolute(latestVersionAlt.path)
4769
4816
  : base
4770
- ? \`\${base}\${basedRoute === "/" ? "" : encodeURI(basedRoute)}\`
4817
+ ? \`\${base}\${basedRoute === "/" ? "/" : encodeURI(basedRoute)}\`
4771
4818
  : null);
4772
4819
  const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
4773
4820
 
@@ -5061,6 +5108,12 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
5061
5108
  const basedRoute = withBase("/changelog");
5062
5109
  const canonical = base ? base + basedRoute : null;
5063
5110
 
5111
+ // The generated OG card for this route (the /og endpoint emits it alongside
5112
+ // the content-route cards), absolutized like the catch-all's so crawlers get
5113
+ // a full URL when the site is known.
5114
+ const ogPath = data.config.og.enabled ? withBase("/og/changelog.png") : null;
5115
+ const ogImage = ogPath && base ? base + ogPath : ogPath;
5116
+
5064
5117
  // The page chrome (h1, title, description) comes from the same translatable
5065
5118
  // \`changelog\` group as the reveal button; optional chaining tolerates a
5066
5119
  // not-yet-regenerated data snapshot from before these keys existed.
@@ -5068,7 +5121,10 @@ const changelogTitle = data.ui.changelog?.title ?? "Changelog";
5068
5121
  const changelogDescription =
5069
5122
  data.ui.changelog?.description ??
5070
5123
  "Product updates, new features, and fixes from every release.";
5071
- const pageTitle = data.config.title + " " + changelogTitle;
5124
+ // The layout suffixes "- {site title}" itself, so the page title is just the
5125
+ // changelog's own name — prefixing the site title too would double it
5126
+ // ("Acme Changelog - Acme").
5127
+ const pageTitle = changelogTitle;
5072
5128
 
5073
5129
  const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
5074
5130
  ---
@@ -5100,7 +5156,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
5100
5156
  fontCssVars={data.fontCssVars}
5101
5157
  searchEnabled={data.config.search.enabled}
5102
5158
  indexable={true}
5103
- ogImage={null}
5159
+ ogImage={ogImage}
5160
+ ogGenerated={Boolean(ogImage)}
5104
5161
  x={data.config.x}
5105
5162
  canonical={canonical}
5106
5163
  exportPdf={${options.exportPdf}}
@@ -9861,7 +9918,7 @@ var themeConfigSchema = z2.strictObject({
9861
9918
  backgroundImage: perModeValueSchema,
9862
9919
  fonts: z2.strictObject({
9863
9920
  body: fontValueSchema.default("inter"),
9864
- display: fontValueSchema.default("inter-tight"),
9921
+ display: fontValueSchema.default("inter"),
9865
9922
  mono: fontValueSchema.default("ibm-plex-mono")
9866
9923
  }).prefault({}),
9867
9924
  layout: z2.enum(["sidebar"]).default("sidebar"),
@@ -11458,6 +11515,26 @@ var gitLastModifiedTimes = (root, contentRoots, sourcePaths) => {
11458
11515
  return new Map;
11459
11516
  }
11460
11517
  };
11518
+ var isShallowGitRepository = (root) => {
11519
+ try {
11520
+ return execFileSync("git", ["-C", root, "rev-parse", "--is-shallow-repository"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim() === "true";
11521
+ } catch {
11522
+ return false;
11523
+ }
11524
+ };
11525
+ var lastModifiedShallowWarning = (root, undatedCount) => {
11526
+ if (undatedCount === 0 || !isShallowGitRepository(root)) {
11527
+ return [];
11528
+ }
11529
+ return [
11530
+ {
11531
+ code: "BLUME_SHALLOW_GIT_HISTORY",
11532
+ 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.`,
11533
+ severity: "warning",
11534
+ suggestion: "Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout."
11535
+ }
11536
+ ];
11537
+ };
11461
11538
 
11462
11539
  // src/core/manifest.ts
11463
11540
  var versionAlternateKey = (versionKey, locale) => `${versionKey}\x00${locale}`;
@@ -14029,6 +14106,7 @@ var scanProject = async (root, options = {}) => {
14029
14106
  } = normalizeLoadedEntries(loaded, config);
14030
14107
  const pages = mode === "build" && !preview ? allPages.filter((page) => !page.meta.draft) : allPages;
14031
14108
  const lastModified = resolveLastModifiedConfig(config.lastModified);
14109
+ const lastModifiedWarnings = [];
14032
14110
  if (lastModified.enabled && lastModified.source === "git") {
14033
14111
  const fsPaths = pages.map((page) => page.sourcePath).filter((path) => path !== undefined);
14034
14112
  const contentRoots = sources.flatMap((source) => source.staged || !source.contentRoot ? [] : [source.contentRoot]);
@@ -14038,6 +14116,8 @@ var scanProject = async (root, options = {}) => {
14038
14116
  page.lastModified = gitTimes.get(page.sourcePath);
14039
14117
  }
14040
14118
  }
14119
+ const undated = pages.filter((page) => page.sourcePath && !page.lastModified).length;
14120
+ lastModifiedWarnings.push(...lastModifiedShallowWarning(context.root, undated));
14041
14121
  }
14042
14122
  const graph = buildContentGraph(pages, {
14043
14123
  basePath: config.basePath,
@@ -14059,7 +14139,8 @@ var scanProject = async (root, options = {}) => {
14059
14139
  ...entryIdDiagnostics(pages, resolveDocsCollection(config, context).base),
14060
14140
  ...graph.diagnostics,
14061
14141
  ...i18nWarnings,
14062
- ...versionWarnings
14142
+ ...versionWarnings,
14143
+ ...lastModifiedWarnings
14063
14144
  ],
14064
14145
  droppedPages,
14065
14146
  graph,
@@ -15818,12 +15899,14 @@ var buildNegotiationRoutes = (routePaths, homeTokens) => {
15818
15899
  return { headerRoutes, rewriteRoutes };
15819
15900
  };
15820
15901
  var HOME_SRC = "^/$";
15821
- var isNegotiationRoute = (route) => route.has?.some((condition) => condition.value === ACCEPT_MARKDOWN_HEADER_VALUE) === true || route.continue === true && route.headers?.vary === "Accept" && isString6(route.src) && Object.keys(route).length === 3 || route.continue === true && isString6(route.headers?.link) && route.src === HOME_SRC && Object.keys(route).length === 3;
15902
+ var TRAILING_SLASH_REDIRECT = {
15903
+ headers: { Location: "/$1" },
15904
+ src: "^/(.+)/$",
15905
+ status: 308
15906
+ };
15907
+ var isNegotiationRoute = (route) => route.has?.some((condition) => condition.value === ACCEPT_MARKDOWN_HEADER_VALUE) === true || route.continue === true && route.headers?.vary === "Accept" && isString6(route.src) && Object.keys(route).length === 3 || route.continue === true && isString6(route.headers?.link) && route.src === HOME_SRC && Object.keys(route).length === 3 || route.status === TRAILING_SLASH_REDIRECT.status && route.src === TRAILING_SLASH_REDIRECT.src;
15822
15908
  var injectNegotiationRoutes = (configText, routePaths, homeLinkHeader, contentTypeOverrides, homeTokens) => {
15823
15909
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
15824
- if (routePaths.length === 0 && !homeLinkHeader && overrideEntries.length === 0) {
15825
- return null;
15826
- }
15827
15910
  let config;
15828
15911
  try {
15829
15912
  config = JSON.parse(configText);
@@ -15849,7 +15932,7 @@ var injectNegotiationRoutes = (configText, routePaths, homeLinkHeader, contentTy
15849
15932
  src: HOME_SRC
15850
15933
  });
15851
15934
  }
15852
- routes.splice(filesystemIndex, 0, ...headerRoutes, ...rewriteRoutes);
15935
+ routes.splice(filesystemIndex, 0, ...headerRoutes, ...rewriteRoutes, TRAILING_SLASH_REDIRECT);
15853
15936
  config.routes = routes;
15854
15937
  return `${JSON.stringify(config, null, "\t")}
15855
15938
  `;
@@ -17319,7 +17402,11 @@ ${THEME_MAPPING}
17319
17402
  scroll-padding-top: 4.5rem;
17320
17403
  text-rendering: optimizeLegibility;
17321
17404
  }
17322
- /* Headings use the display font (defaults to the body font when unset). */
17405
+ /* Headings use the display font (defaults to the body font when unset).
17406
+ The tightened tracking is part of the theme, not the font: display-tuned
17407
+ families bake it into their metrics, but a text family promoted to
17408
+ headings (including the Inter default) reads loose without it. -0.05em
17409
+ was matched visually against Inter Tight, the previous display default. */
17323
17410
  h1,
17324
17411
  h2,
17325
17412
  h3,
@@ -17327,6 +17414,7 @@ ${THEME_MAPPING}
17327
17414
  h5,
17328
17415
  h6 {
17329
17416
  font-family: var(--font-display);
17417
+ letter-spacing: -0.05em;
17330
17418
  }
17331
17419
  :focus-visible {
17332
17420
  outline: 2px solid var(--blume-accent);
@@ -17347,6 +17435,22 @@ ${THEME_MAPPING}
17347
17435
  }
17348
17436
  }
17349
17437
 
17438
+ /* Same-origin navigations are full document loads (no client router); opting
17439
+ into cross-document view transitions has the browser crossfade between the
17440
+ old and new page instead of hard-swapping, in browsers that support it.
17441
+ Pairs with the prefetch option in the generated Astro config. */
17442
+ @view-transition {
17443
+ navigation: auto;
17444
+ }
17445
+
17446
+ @media (prefers-reduced-motion: reduce) {
17447
+ ::view-transition-group(*),
17448
+ ::view-transition-old(*),
17449
+ ::view-transition-new(*) {
17450
+ animation: none !important;
17451
+ }
17452
+ }
17453
+
17350
17454
  /* Code reads left-to-right regardless of page direction; only the surrounding
17351
17455
  chrome mirrors for RTL. Inline code is isolated so LTR identifiers don't
17352
17456
  disturb the bidi flow of right-to-left prose. */
@@ -17382,9 +17486,10 @@ ${THEME_MAPPING}
17382
17486
  line-height: 1.7;
17383
17487
  }
17384
17488
 
17489
+ /* No letter-spacing here: prose headings inherit the base h1-h6 rule's
17490
+ display tracking, same as headings outside the prose column. */
17385
17491
  .prose :where(h1, h2, h3, h4) {
17386
17492
  font-weight: 500;
17387
- letter-spacing: 0;
17388
17493
  }
17389
17494
 
17390
17495
  /* A heading can carry one long unbreakable token — an OpenAPI operation's title
@@ -18793,7 +18898,7 @@ var buildRuntimeData = (project) => {
18793
18898
  href: feed.path,
18794
18899
  title: feed.title
18795
18900
  })),
18796
- fontCssVars: configuredCssVars(config.theme.fonts),
18901
+ fontCssVars: configuredFonts(config.theme.fonts),
18797
18902
  navigation: withRepoUrl(graph.navigation),
18798
18903
  navigationByLocale,
18799
18904
  navigationByVersion: Object.fromEntries(Object.entries(graph.navigationByVersion).map(([id2, byLocale]) => [
@@ -18987,6 +19092,7 @@ var generateRuntime = async (project) => {
18987
19092
  const needsSvelte = frameworks.has("svelte");
18988
19093
  const reactCompilerPath = resolveReactCompiler(config, needsReact);
18989
19094
  const ogRoutes = customOgRoutes(pages, config.title, config.seo.og.titles);
19095
+ const changelogIndex = hasGeneratedChangelog(project, pages);
18990
19096
  const mcp = planMcp(project, srcDir, pages);
18991
19097
  pages.push(...mcp.discoveryPages);
18992
19098
  const hasStaged = staged.size > 0;
@@ -19056,9 +19162,9 @@ var generateRuntime = async (project) => {
19056
19162
  writeMcpFiles(project, mcp, write)
19057
19163
  ]);
19058
19164
  if (config.seo.og.enabled) {
19059
- await write(join28(srcDir, "pages", "og", "[...slug].png.ts"), ogEndpointTemplate(ogRoutes, projectOgFonts(project)));
19165
+ await write(join28(srcDir, "pages", "og", "[...slug].png.ts"), ogEndpointTemplate(ogRoutes, projectOgFonts(project), changelogIndex));
19060
19166
  }
19061
- if (hasGeneratedChangelog(project, pages)) {
19167
+ if (changelogIndex) {
19062
19168
  await write(join28(srcDir, "pages", "changelog.astro"), changelogIndexTemplate({
19063
19169
  exportEpub,
19064
19170
  exportPdf,
@@ -19120,7 +19226,7 @@ var generateRuntime = async (project) => {
19120
19226
  ...pages.map((page) => page.pattern),
19121
19227
  ...referenceRoutes(config)
19122
19228
  ]);
19123
- if (hasGeneratedChangelog(project, pages)) {
19229
+ if (changelogIndex) {
19124
19230
  navTargetRoutes.add("/changelog");
19125
19231
  }
19126
19232
  warnings.push(...[
@@ -23753,5 +23859,5 @@ process.on("unhandledRejection", (error) => {
23753
23859
  });
23754
23860
  runMain(main);
23755
23861
 
23756
- //# debugId=6377B095BFD3F5AE64756E2164756E21
23862
+ //# debugId=A01DCE54DD00393164756E2164756E21
23757
23863
  //# sourceMappingURL=index.js.map