mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
6dcf55e777
commit
7f03030f3f
14 changed files with 65 additions and 57 deletions
|
|
@ -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
|
||||
* 400–800ms 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"
|
||||
/>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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=";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue