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
|
@@ -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).
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
package/src/theme/entry.ts
CHANGED
|
@@ -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
|
package/src/theme/fonts.ts
CHANGED
|
@@ -352,6 +352,78 @@ export const buildFontsCss = (fonts: FontsConfig): string => {
|
|
|
352
352
|
: "";
|
|
353
353
|
};
|
|
354
354
|
|
|
355
|
-
/**
|
|
356
|
-
|
|
357
|
-
|
|
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
|
+
};
|