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.
@@ -36,6 +36,7 @@ export interface VercelRoute {
36
36
  has?: { key?: string; type: string; value?: string }[];
37
37
  headers?: Record<string, string>;
38
38
  src?: string;
39
+ status?: number;
39
40
  }
40
41
 
41
42
  /** Whether a parsed route field is a real string (the config is raw JSON). */
@@ -163,6 +164,21 @@ export const buildNegotiationRoutes = (
163
164
  /** The `src` of the injected homepage `Link` header route. */
164
165
  const HOME_SRC = "^/$";
165
166
 
167
+ /**
168
+ * Permanent redirect from any trailing-slash URL to its slashless twin, so
169
+ * `/docs/` and `/docs` don't serve as duplicate URLs (canonicals, sitemap, and
170
+ * hreflang all use the slashless form; the root `/` is untouched — `.+`
171
+ * requires a non-empty path). Spliced into the main phase before `handle:
172
+ * "filesystem"`, after the Markdown rewrites, so an agent's `Accept:
173
+ * text/markdown` request on a slashed URL still rewrites without the extra
174
+ * hop. Vercel carries the query string over to the `Location` target itself.
175
+ */
176
+ export const TRAILING_SLASH_REDIRECT: VercelRoute = {
177
+ headers: { Location: "/$1" },
178
+ src: "^/(.+)/$",
179
+ status: 308,
180
+ };
181
+
166
182
  /**
167
183
  * Whether a route is one this module previously injected, so re-injection
168
184
  * replaces rather than duplicates. Rewrites are identified by their `accept`
@@ -183,7 +199,9 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
183
199
  (route.continue === true &&
184
200
  isString(route.headers?.link) &&
185
201
  route.src === HOME_SRC &&
186
- Object.keys(route).length === 3);
202
+ Object.keys(route).length === 3) ||
203
+ (route.status === TRAILING_SLASH_REDIRECT.status &&
204
+ route.src === TRAILING_SLASH_REDIRECT.src);
187
205
 
188
206
  /**
189
207
  * Splice the negotiation routes into a Build Output `config.json`, plus — when
@@ -193,10 +211,12 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
193
211
  * rides on the prerendered homepage response. `contentTypeOverrides` maps static-dir
194
212
  * relative paths to media types via the Build Output `overrides` field — the
195
213
  * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
196
- * signature directory). Returns the updated JSON text (tab-indented, like the
197
- * adapter's own output), or `null` when there is nothing to do or nowhere
198
- * safe to do it: nothing to inject, an unparsable config, no `routes` array,
199
- * or no `handle: "filesystem"` marker to anchor the splice.
214
+ * signature directory). The trailing-slash 308 redirect is always spliced in
215
+ * alongside, so slashed duplicates of every page collapse onto the canonical
216
+ * slashless URL. Returns the updated JSON text (tab-indented, like the
217
+ * adapter's own output), or `null` when there is nowhere safe to splice: an
218
+ * unparsable config, no `routes` array, or no `handle: "filesystem"` marker
219
+ * to anchor the splice.
200
220
  */
201
221
  export const injectNegotiationRoutes = (
202
222
  configText: string,
@@ -206,13 +226,6 @@ export const injectNegotiationRoutes = (
206
226
  homeTokens?: number
207
227
  ): string | null => {
208
228
  const overrideEntries = Object.entries(contentTypeOverrides ?? {});
209
- if (
210
- routePaths.length === 0 &&
211
- !homeLinkHeader &&
212
- overrideEntries.length === 0
213
- ) {
214
- return null;
215
- }
216
229
  let config: {
217
230
  overrides?: Record<string, { contentType?: string; path?: string }>;
218
231
  routes?: VercelRoute[];
@@ -250,8 +263,15 @@ export const injectNegotiationRoutes = (
250
263
  }
251
264
  // Headers first: `continue` routes accumulate, so a request the rewrite
252
265
  // route then terminates (Markdown negotiation on the homepage) still carries
253
- // the Link header.
254
- routes.splice(filesystemIndex, 0, ...headerRoutes, ...rewriteRoutes);
266
+ // the Link header. The trailing-slash redirect goes last so a slashed URL's
267
+ // Markdown negotiation still rewrites directly instead of bouncing.
268
+ routes.splice(
269
+ filesystemIndex,
270
+ 0,
271
+ ...headerRoutes,
272
+ ...rewriteRoutes,
273
+ TRAILING_SLASH_REDIRECT
274
+ );
255
275
  config.routes = routes;
256
276
  return `${JSON.stringify(config, null, "\t")}\n`;
257
277
  };
package/src/og/card.ts CHANGED
@@ -340,7 +340,9 @@ export const renderOgImage = async (
340
340
  color: foreground,
341
341
  fontSize: titleSize(options.title),
342
342
  fontWeight: 600,
343
- letterSpacing: "-0.03em",
343
+ // Matches the theme's heading tracking (entry.ts h1-h6 rule), tuned
344
+ // for Inter since the display default dropped Inter Tight.
345
+ letterSpacing: "-0.05em",
344
346
  lineHeight: 1.05,
345
347
  maxWidth: 1010,
346
348
  textWrap: "balance",
@@ -179,7 +179,11 @@ ${THEME_MAPPING}
179
179
  scroll-padding-top: 4.5rem;
180
180
  text-rendering: optimizeLegibility;
181
181
  }
182
- /* Headings use the display font (defaults to the body font when unset). */
182
+ /* Headings use the display font (defaults to the body font when unset).
183
+ The tightened tracking is part of the theme, not the font: display-tuned
184
+ families bake it into their metrics, but a text family promoted to
185
+ headings (including the Inter default) reads loose without it. -0.05em
186
+ was matched visually against Inter Tight, the previous display default. */
183
187
  h1,
184
188
  h2,
185
189
  h3,
@@ -187,6 +191,7 @@ ${THEME_MAPPING}
187
191
  h5,
188
192
  h6 {
189
193
  font-family: var(--font-display);
194
+ letter-spacing: -0.05em;
190
195
  }
191
196
  :focus-visible {
192
197
  outline: 2px solid var(--blume-accent);
@@ -207,6 +212,22 @@ ${THEME_MAPPING}
207
212
  }
208
213
  }
209
214
 
215
+ /* Same-origin navigations are full document loads (no client router); opting
216
+ into cross-document view transitions has the browser crossfade between the
217
+ old and new page instead of hard-swapping, in browsers that support it.
218
+ Pairs with the prefetch option in the generated Astro config. */
219
+ @view-transition {
220
+ navigation: auto;
221
+ }
222
+
223
+ @media (prefers-reduced-motion: reduce) {
224
+ ::view-transition-group(*),
225
+ ::view-transition-old(*),
226
+ ::view-transition-new(*) {
227
+ animation: none !important;
228
+ }
229
+ }
230
+
210
231
  /* Code reads left-to-right regardless of page direction; only the surrounding
211
232
  chrome mirrors for RTL. Inline code is isolated so LTR identifiers don't
212
233
  disturb the bidi flow of right-to-left prose. */
@@ -242,9 +263,10 @@ ${THEME_MAPPING}
242
263
  line-height: 1.7;
243
264
  }
244
265
 
266
+ /* No letter-spacing here: prose headings inherit the base h1-h6 rule's
267
+ display tracking, same as headings outside the prose column. */
245
268
  .prose :where(h1, h2, h3, h4) {
246
269
  font-weight: 500;
247
- letter-spacing: 0;
248
270
  }
249
271
 
250
272
  /* A heading can carry one long unbreakable token — an OpenAPI operation's title
@@ -352,6 +352,78 @@ export const buildFontsCss = (fonts: FontsConfig): string => {
352
352
  : "";
353
353
  };
354
354
 
355
- /** The CSS variables to feed Astro's `<Font>` component in the document head. */
356
- export const configuredCssVars = (fonts: FontsConfig): string[] =>
357
- buildFontEntries(fonts).map((entry) => entry.cssVariable);
355
+ /**
356
+ * Weights worth preloading per role — the faces above-the-fold text actually
357
+ * renders in: body copy and UI chrome at 400/500, headings at 500/600, code at
358
+ * 400. Every other face still loads on demand through its `@font-face` rule
359
+ * (and `font-display: swap` never blocks text on it), so preloading the long
360
+ * tail only competes with the critical CSS for bandwidth and pushes LCP out.
361
+ */
362
+ const PRELOAD_WEIGHTS = {
363
+ body: [400, 500],
364
+ display: [500, 600],
365
+ mono: [400],
366
+ } satisfies Record<FontSlot, number[]>;
367
+
368
+ /** One `<Font>` render in the head: its CSS variable + weights to preload. */
369
+ export interface FontHead {
370
+ cssVariable: string;
371
+ preloadWeights: number[];
372
+ }
373
+
374
+ /** The weights an entry's faces declare (`undefined` = inferred from files). */
375
+ const entryWeights = (entry: FontEntry): (number | string | undefined)[] =>
376
+ entry.kind === "remote"
377
+ ? entry.weights
378
+ : entry.variants.map((variant) => variant.weight);
379
+
380
+ /**
381
+ * The role's preferred preload weights, narrowed to faces the family loads.
382
+ * Variable ranges (`"100..900"`) and weight-inferred local files can serve any
383
+ * weight, so they keep the preferred list; a family whose numeric weights miss
384
+ * the preferred ones entirely preloads all of its faces instead — those are
385
+ * what its text renders in.
386
+ */
387
+ const preloadWeightsFor = (slot: FontSlot, entry: FontEntry): number[] => {
388
+ const preferred = PRELOAD_WEIGHTS[slot];
389
+ const weights = entryWeights(entry);
390
+ const numeric = weights.filter(
391
+ (weight): weight is number => typeof weight === "number"
392
+ );
393
+ const hits = preferred.filter((weight) => numeric.includes(weight));
394
+ if (hits.length > 0) {
395
+ return hits;
396
+ }
397
+ return numeric.length === weights.length ? numeric : preferred;
398
+ };
399
+
400
+ /**
401
+ * The fonts to feed Astro's `<Font>` component in the document head, deduped
402
+ * by CSS variable with preload weights unioned across the roles that share a
403
+ * family (so `display` and `body` both set to Inter preload 400/500/600 once).
404
+ */
405
+ export const configuredFonts = (fonts: FontsConfig): FontHead[] => {
406
+ if (!fonts) {
407
+ return [];
408
+ }
409
+ const heads = new Map<string, Set<number>>();
410
+ for (const slot of SLOTS) {
411
+ const value = fonts[slot];
412
+ if (value === undefined) {
413
+ continue;
414
+ }
415
+ const entry = resolveFontValue(slot, value);
416
+ if (!entry) {
417
+ continue;
418
+ }
419
+ const weights = heads.get(entry.cssVariable) ?? new Set();
420
+ for (const weight of preloadWeightsFor(slot, entry)) {
421
+ weights.add(weight);
422
+ }
423
+ heads.set(entry.cssVariable, weights);
424
+ }
425
+ return [...heads].map(([cssVariable, weights]) => ({
426
+ cssVariable,
427
+ preloadWeights: [...weights].toSorted((a, b) => a - b),
428
+ }));
429
+ };