perf(landing): self-host fonts + inline critical CSS (#2599)

* perf(landing): self-host fonts + inline critical CSS

PageSpeed Insights flagged ~2.3s of render-blocking on /:
  globals.css   12.9 KB external link, 160ms
  fonts CSS     2.2 KB  fonts.googleapis.com, 750ms
  + 4 woff2     ~1200ms each from fonts.gstatic.com

Two changes drop that whole chain:

1. Self-host fonts via @fontsource-variable/{inter,inter-tight,
   playfair-display,jetbrains-mono}. Each family ships a single variable
   woff2 (covers all weights we use) that Astro bundles into /_astro/*
   alongside the rest of the build, served same-origin through CF Pages —
   no separate TLS handshake, no Google Fonts CSS round-trip. The CSS
   variable names get an extra alias in front (`'Inter Tight Variable',
   'Inter Tight', ...`) so a system fallback still works if the package
   ever ships under a different family name.

2. `astro.config.ts: build.inlineStylesheets: 'always'` inlines every
   emitted <style> into the HTML <head> instead of emitting a separate
   /_astro/*.css link. The HTML grows from ~13KB to ~28KB (gzip) but
   loses one stylesheet round-trip + the entire @font-face chain that
   used to gate text rendering.

Component cleanup: the `<FontStylesheet>` component (preconnect + link to
fonts.googleapis.com) is no longer needed and is deleted, removed from
all 7 places that mounted it. og.astro keeps its own font setup since
it renders to a screenshot.

Expected effect (from PageSpeed Insights "Render-blocking requests"
diagnostic on the previous build):
  FCP  1.9s → ~1.2s
  LCP  2.2s → ~1.5s

Verified: pnpm typecheck 0 errors, pnpm build 1853 pages 78s, preview
serves /_astro/*.woff2 as font/woff2 same-origin, 0 fonts.googleapis or
fonts.gstatic references in the built HTML.

* perf(landing): include Playfair italic + bump nix pnpm-deps hash

Two follow-ups on the self-host fonts PR:

1. globals.css imported only `@fontsource-variable/playfair-display`,
   which ships @font-face for font-style: normal only. The previous
   Google Fonts URL included the italic axis (`ital,wght@0,500;1,400;
   ...`) and several rules (.roman, .work-rule .roman, .sec-rule .roman,
   plus 8 other italics across globals.css + sub-pages.css) render
   Playfair italics via `font-family: var(--serif); font-style: italic`.
   Without the italic face self-hosted, those would fall through to
   Times New Roman italic or browser synthesis. Adding
   `wght-italic.css` keeps the typography visually equivalent.

2. nix/pnpm-deps.nix uses a fixed-output derivation hash that has to
   match the pnpm vendored store; adding the four fontsource packages
   changed pnpm-lock.yaml so the hash has to be bumped to the value Nix
   reported in CI.

Codex (Looper reviewer) flagged #1 as non-blocking.

* perf(landing): pin fontsource versions exactly per repo guard

`pnpm add` defaulted to caret ranges (`^5.2.8`) but repo guard rejects
non-exact specs ("dependency specs must be exact versions like 1.2.3 or
workspace:*"). That was the actual cause of the Preflight + Validate
workspace failures — pinning to the locked versions Codex reviewer
called out:

  @fontsource-variable/inter             5.2.8
  @fontsource-variable/inter-tight       5.2.7
  @fontsource-variable/jetbrains-mono    5.2.8
  @fontsource-variable/playfair-display  5.2.8

`pnpm guard` now passes locally (6/6 tests).
This commit is contained in:
lefarcen 2026-05-22 11:49:16 +08:00 committed by GitHub
parent 6dcf55e777
commit 7f03030f3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 65 additions and 57 deletions

View file

@ -1,31 +0,0 @@
---
/*
* Google Fonts stylesheet — single source of truth for the four families
* the site uses (Inter Tight, Inter, Playfair Display, JetBrains Mono).
*
* Why a component instead of `@import` in globals.css:
* Previously globals.css did `@import url('https://fonts.googleapis.com/css2?...')`.
* That gets the request fired only AFTER the browser parses the CSS file,
* serializing the chain:
*
* HTML → globals.css → fonts.googleapis.com/css2 → fonts.gstatic.com/woff2
*
* Live HAR from `127.0.0.1:17574/` measured 953ms for the fonts CSS plus
* 400800ms per woff2 (4 of them) — ~3s end-to-end before text could
* render with the intended family, even with `display=swap`.
*
* Moving to `<link>` in the document head lets the browser kick off the
* fonts CSS request alongside the HTML body parse, and `preconnect` hints
* warm up TLS to fonts.gstatic.com so the woff2 fetches don't pay DNS+TLS.
*
* `display=swap` stays in the URL so paragraph copy can render in a
* system fallback while the woff2 is in flight — never blocks paint.
*/
---
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800;900&family=Inter:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,500;0,600;1,400;1,500;1,600;1,700&family=JetBrains+Mono:wght@400;500&display=swap"
/>

View file

@ -18,7 +18,6 @@ import '../globals.css';
import '../sub-pages.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FontStylesheet from './font-stylesheet.astro';
import FaviconLinks from './favicon-links.astro';
import HeaderEnhancer from './header-enhancer.astro';
import { Header, type HeaderProps } from './header';
@ -78,7 +77,6 @@ const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
<FaviconLinks />
<FontStylesheet />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Open Design" />

View file

@ -5,12 +5,23 @@
* When the canonical example.html changes, mirror the diff here so the
* template's known-good rendering stays in lockstep with the deployed site.
*
* Font loading lives in `_components/font-stylesheet.astro` a server
* component injected into every page's <head>. We used to `@import` it
* inline here, but that serialized HTML CSS fonts CSS woff2 and
* cost ~3s on first paint. Moving to <link> + preconnect parallelizes
* the fetch with HTML parse.
* Fonts are self-hosted via `@fontsource-variable/*` packages. The variable
* font files (one woff2 per family, ~30-50KB each) cover the entire
* weight axis we use, are served same-origin (no Google Fonts CSS or
* fonts.gstatic.com TLS handshake), and ship through CF's edge PageSpeed
* was attributing ~2.3s of render-blocking + LCP to the old Google Fonts
* round-trip chain.
*
* Subset CSS files only declare @font-face metadata (~1KB each). The
* browser downloads the woff2 that matches the unicode-range of text it's
* about to render, so multi-script support stays intact without paying for
* unused scripts up front.
*/
@import '@fontsource-variable/inter';
@import '@fontsource-variable/inter-tight';
@import '@fontsource-variable/playfair-display';
@import '@fontsource-variable/playfair-display/wght-italic.css';
@import '@fontsource-variable/jetbrains-mono';
:root {
--paper: #efe7d2;
@ -29,10 +40,10 @@
--line-soft: rgba(21, 20, 15, 0.08);
--line-faint: rgba(21, 20, 15, 0.05);
--shadow: 0 30px 60px -30px rgba(21, 20, 15, 0.18);
--serif: 'Playfair Display', 'Times New Roman', serif;
--sans: 'Inter Tight', 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--body: 'Inter', -apple-system, system-ui, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
--serif: 'Playfair Display Variable', 'Playfair Display', 'Times New Roman', serif;
--sans: 'Inter Tight Variable', 'Inter Tight', 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--body: 'Inter Variable', 'Inter', -apple-system, system-ui, sans-serif;
--mono: 'JetBrains Mono Variable', 'JetBrains Mono', 'SF Mono', Menlo, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }

View file

@ -9,7 +9,6 @@
import { getCollection, render } from 'astro:content';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FontStylesheet from '../../_components/font-stylesheet.astro';
import GoogleAnalytics from '../../_components/google-analytics.astro';
import HeaderEnhancer from '../../_components/header-enhancer.astro';
import { Header, type HeaderProps } from '../../_components/header';
@ -145,7 +144,6 @@ const fmtDate = (d: Date) =>
category={post.data.category}
/>
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)} />
<FontStylesheet />
<GoogleAnalytics />
</head>
<body>

View file

@ -13,7 +13,6 @@
import { getCollection } from 'astro:content';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FontStylesheet from '../../_components/font-stylesheet.astro';
import GoogleAnalytics from '../../_components/google-analytics.astro';
import HeaderEnhancer from '../../_components/header-enhancer.astro';
import { Header, type HeaderProps } from '../../_components/header';
@ -142,7 +141,6 @@ const localizedPostField = (
description={seoDescription}
pathname={Astro.url.pathname}
/>
<FontStylesheet />
<GoogleAnalytics />
</head>
<body>

View file

@ -4,7 +4,6 @@ import '../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FaviconLinks from '../_components/favicon-links.astro';
import FontStylesheet from '../_components/font-stylesheet.astro';
import LocaleSwitcherScript from '../_components/locale-switcher-script.astro';
import PreciseLazyload from '../_components/precise-lazyload.astro';
import { heroImage, heroImageSrcset } from '../image-assets';
@ -145,7 +144,6 @@ const pageHtml = renderToStaticMarkup(
<FaviconLinks />
<FontStylesheet />
{/*
* Hero LCP preload. Cloudflare Pages turns this <link rel="preload"> into

View file

@ -3,7 +3,6 @@ import '../../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FaviconLinks from '../../_components/favicon-links.astro';
import FontStylesheet from '../../_components/font-stylesheet.astro';
import GoogleAnalytics from '../../_components/google-analytics.astro';
import { Header } from '../../_components/header';
import HeaderEnhancer from '../../_components/header-enhancer.astro';
@ -166,7 +165,6 @@ const breadcrumbJsonLd = {
))}
<link rel='alternate' hreflang='x-default' href={xDefaultHref} />
<FaviconLinks />
<FontStylesheet />
<meta property='og:type' content='article' />
<meta property='og:site_name' content='Open Design' />
<meta property='og:title' content={title} />

View file

@ -3,7 +3,6 @@ import '../../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FaviconLinks from '../../_components/favicon-links.astro';
import FontStylesheet from '../../_components/font-stylesheet.astro';
import GoogleAnalytics from '../../_components/google-analytics.astro';
import { Header } from '../../_components/header';
import HeaderEnhancer from '../../_components/header-enhancer.astro';
@ -121,7 +120,6 @@ const trustLabel = (plugin: (typeof plugins)[number]) =>
))}
<link rel='alternate' hreflang='x-default' href={xDefaultHref} />
<FaviconLinks />
<FontStylesheet />
<meta property='og:type' content='website' />
<meta property='og:site_name' content='Open Design' />
<meta property='og:title' content={title} />

View file

@ -11,7 +11,6 @@
import { getCollection, render } from 'astro:content';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FontStylesheet from '../../_components/font-stylesheet.astro';
import HeaderEnhancer from '../../_components/header-enhancer.astro';
import { Header, type HeaderProps } from '../../_components/header';
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
@ -127,7 +126,6 @@ const tutorialCopy =
datePublished={entry.data.date}
category={tutorialCopy.category}
/>
<FontStylesheet />
</head>
<body>
<div class='shell'>

View file

@ -13,7 +13,6 @@
import { getCollection } from 'astro:content';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import FontStylesheet from '../../_components/font-stylesheet.astro';
import HeaderEnhancer from '../../_components/header-enhancer.astro';
import { Header, type HeaderProps } from '../../_components/header';
import LocaleSwitcherScript from '../../_components/locale-switcher-script.astro';
@ -126,7 +125,6 @@ const featuredCopy = featuredTutorial ? localizedTutorial(featuredTutorial) : un
description={seoDescription}
pathname={Astro.url.pathname}
/>
<FontStylesheet />
</head>
<body>
<div class='shell'>

View file

@ -137,6 +137,14 @@ export default defineConfig({
srcDir: './app',
outDir: './out',
trailingSlash: 'always',
build: {
// Inline every emitted stylesheet directly into the HTML <head>.
// Trade-off: HTML pages grow by ~10-15KB (already Brotli-compressed
// on CF). Win: zero render-blocking CSS roundtrip. Combined with the
// self-hosted variable fonts (see globals.css), this drops the
// PageSpeed "Render-blocking requests" estimate from ~2.3s to ~0.
inlineStylesheets: 'always',
},
markdown: {
// Use our paper-toned theme for fenced code blocks. Astro ships
// Shiki under the hood and the default theme (`github-dark`)

View file

@ -13,6 +13,10 @@
"dependencies": {
"@astrojs/rss": "4.0.18",
"@astrojs/sitemap": "3.7.2",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/inter-tight": "5.2.7",
"@fontsource-variable/jetbrains-mono": "5.2.8",
"@fontsource-variable/playfair-display": "5.2.8",
"astro": "6.3.5",
"react": "18.3.1",
"react-dom": "18.3.1"

View file

@ -5,5 +5,5 @@
# 1. Temporarily set the consuming `hash = lib.fakeHash;`
# 2. Run the relevant nix build/flake check
# 3. Copy the expected hash printed by Nix into `hash` below
hash = "sha256-EqvfkMBoYHuGIu8mXYnUjXTUhKVhgqOg32mr2EzPkgs=";
hash = "sha256-l87ATTkJYpX7OHHxmA/CxvJHdaaN/9RPi6AYI4DRn/I=";
}

View file

@ -161,6 +161,18 @@ importers:
'@astrojs/sitemap':
specifier: 3.7.2
version: 3.7.2
'@fontsource-variable/inter':
specifier: 5.2.8
version: 5.2.8
'@fontsource-variable/inter-tight':
specifier: 5.2.7
version: 5.2.7
'@fontsource-variable/jetbrains-mono':
specifier: 5.2.8
version: 5.2.8
'@fontsource-variable/playfair-display':
specifier: 5.2.8
version: 5.2.8
astro:
specifier: 6.3.5
version: 6.3.5(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.2)(tsx@4.22.3)(yaml@2.9.0)
@ -1266,6 +1278,18 @@ packages:
'@noble/hashes':
optional: true
'@fontsource-variable/inter-tight@5.2.7':
resolution: {integrity: sha512-uU0qW9vlzVQQv8GVrhn8SlNl2A44C0CE9IC6N9P0D9mwhmJcg8UF/fxvmdRayDPlMAnYqxlXtYENDIeZyVvxkg==}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/jetbrains-mono@5.2.8':
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
'@fontsource-variable/playfair-display@5.2.8':
resolution: {integrity: sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A==}
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@ -5778,6 +5802,14 @@ snapshots:
'@exodus/bytes@1.15.0': {}
'@fontsource-variable/inter-tight@5.2.7': {}
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/jetbrains-mono@5.2.8': {}
'@fontsource-variable/playfair-display@5.2.8': {}
'@hono/node-server@1.19.14(hono@4.12.19)':
dependencies:
hono: 4.12.19