132 lines
6.5 KiB
Markdown
132 lines
6.5 KiB
Markdown
---
|
||
id: 003-self-host-fonts-89466b
|
||
title: Self-host the three web fonts and drop the render-blocking Google Fonts CSS
|
||
created: 2026-09-29
|
||
depends_on: [002-compress-and-etag-shell-bd459c]
|
||
est_files: 5
|
||
---
|
||
|
||
# 003 — Self-host the three web fonts
|
||
|
||
## Objective
|
||
|
||
`frontend/index.html` loads a render-blocking stylesheet from `fonts.googleapis.com`
|
||
(3 families, 10 weights), which costs two extra origins (DNS+TLS each) before first
|
||
paint and only works offline through the SW's `ytplayer-fonts` runtime cache. After
|
||
this plan the fonts are files under `frontend/fonts/`, declared in
|
||
`frontend/fonts/fonts.css`, precached with the shell, and the display face is
|
||
preloaded. Visual result must be identical.
|
||
|
||
## Context the executor must NOT rediscover
|
||
|
||
`frontend/index.html:20-26` today:
|
||
|
||
```html
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link
|
||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700;12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||
rel="stylesheet"
|
||
/>
|
||
<link rel="stylesheet" href="styles.css" />
|
||
```
|
||
|
||
CSS tokens (`frontend/styles.css:27-29`) reference the family names
|
||
`"Bricolage Grotesque"`, `"Hanken Grotesk"`, `"JetBrains Mono"` — keep those names.
|
||
|
||
`frontend/sw.js:55-71` — the `SHELL` array (precache list). Every new shell file must be
|
||
listed there or it will be missing offline. `frontend/sw.js:165` keeps a runtime rule for
|
||
the Google hosts — leave it (harmless, and old clients may still request them).
|
||
|
||
Google serves one variable `woff2` per family per subset when asked with a modern UA.
|
||
`fitLyricLines` already re-fits on `document.fonts` load (CLAUDE.md), so swap is safe.
|
||
|
||
## Steps
|
||
|
||
1. Create `scripts/fetch-fonts.js` (Node ≥18, no deps):
|
||
```js
|
||
// Downloads the app's Google fonts once (latin + latin-ext subsets) into
|
||
// frontend/fonts/ and writes frontend/fonts/fonts.css pointing at them.
|
||
// Re-run only when the font list changes.
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const CSS_URL = 'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600..800&family=Hanken+Grotesk:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap';
|
||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
|
||
const OUT = path.join(__dirname, '..', 'frontend', 'fonts');
|
||
(async () => {
|
||
fs.mkdirSync(OUT, { recursive: true });
|
||
const css = await (await fetch(CSS_URL, { headers: { 'User-Agent': UA } })).text();
|
||
// parts[k] ends with the "/* <subset> */" comment of the block in parts[k+1].
|
||
const parts = css.split('@font-face');
|
||
let out = '/* Generated by scripts/fetch-fonts.js — do not edit by hand. */\n';
|
||
let n = 0;
|
||
for (let k = 1; k < parts.length; k++) {
|
||
const subset = (parts[k - 1].match(/\/\*\s*([\w-]+)\s*\*\/\s*$/) || [])[1];
|
||
const b = parts[k].slice(0, parts[k].indexOf('}') + 1); // just this block
|
||
if (subset !== 'latin' && subset !== 'latin-ext') continue;
|
||
const url = (b.match(/url\((https:[^)]+\.woff2)\)/) || [])[1];
|
||
const fam = (b.match(/font-family:\s*'([^']+)'/) || [])[1];
|
||
if (!url || !fam) continue;
|
||
const file = `${fam.replace(/\s+/g, '')}-${subset}.woff2`;
|
||
const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
|
||
fs.writeFileSync(path.join(OUT, file), buf);
|
||
out += '@font-face' + b.replace(url, file).trimEnd() + '\n';
|
||
n++;
|
||
}
|
||
fs.writeFileSync(path.join(OUT, 'fonts.css'), out);
|
||
console.log(`wrote ${n} font files + fonts.css`);
|
||
})().catch((e) => { console.error(e); process.exit(1); });
|
||
```
|
||
Run it: `node scripts/fetch-fonts.js`. Expect `wrote 6 font files + fonts.css`
|
||
(3 families × 2 subsets). If the network is blocked, STOP and report — do not hand-write fonts.
|
||
2. Open `frontend/fonts/fonts.css` and confirm every block has `font-display: swap;`; if a
|
||
block lacks it, add it.
|
||
3. `frontend/index.html` — replace the 5 lines from `<link rel="preconnect" href="https://fonts.googleapis.com" />`
|
||
through the closing `/>` of the Google stylesheet link with:
|
||
```html
|
||
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
|
||
<link rel="stylesheet" href="fonts/fonts.css" />
|
||
```
|
||
(Use the exact filenames the script produced — check with `ls frontend/fonts`.)
|
||
4. `frontend/sw.js` `SHELL` array — after `'/styles.css',` add `'/fonts/fonts.css',` and one
|
||
`'/fonts/<file>.woff2',` line per generated woff2 file.
|
||
5. `frontend/index.html` line 16 — the Content-Security-Policy `<meta>` has
|
||
`font-src https://fonts.gstatic.com data:` which would BLOCK self-hosted fonts. Change that part to
|
||
`font-src 'self' https://fonts.gstatic.com data:` (leave the rest of the policy unchanged;
|
||
`style-src` already allows `'self'`). `frontend/admin.html` has no Google Fonts link — leave it alone.
|
||
6. `package.json` scripts — add `"fetch-fonts": "node scripts/fetch-fonts.js"`.
|
||
|
||
## Out of scope / do NOT touch
|
||
|
||
- Do not rename the font families or edit `styles.css`.
|
||
- Do not remove the fonts rule in `sw.js` fetch handler or `UTILITY_CACHES`.
|
||
- Do not subset further (e.g. glyph-level subsetting) — out of scope.
|
||
|
||
## Verification
|
||
|
||
```bash
|
||
cd /home/user/ytplayer && ls -la frontend/fonts
|
||
grep -c 'href="https://fonts.googleapis' frontend/index.html
|
||
grep -c "font-src 'self'" frontend/index.html
|
||
for f in $(ls frontend/fonts/*.woff2); do grep -c "/fonts/$(basename $f)" frontend/sw.js; done
|
||
node --test frontend/*.test.js 2>&1 | tail -3
|
||
cd server && [ -e public ] || ln -s ../frontend public; PORT=3998 bun server.js >/tmp/ytp003.log 2>&1 & SRV=$!; sleep 4
|
||
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:3998/fonts/fonts.css
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3998/fonts/$(ls ../frontend/fonts | grep woff2 | head -1)
|
||
kill $SRV; true
|
||
```
|
||
|
||
Expected: 6 `.woff2` + `fonts.css`; google link grep `0`; CSP grep `1`; each sw.js grep `1`; tests `fail 0`;
|
||
`200 text/css…` and `200`.
|
||
|
||
## Report format (executor: follow exactly)
|
||
|
||
Output ONLY the following, no other prose:
|
||
|
||
1. `git diff --stat` plus unified diff of text files (not the woff2 binaries).
|
||
2. Raw output of the Verification commands.
|
||
3. `Findings:` — max 10 lines.
|
||
|
||
Do not commit. Do not push. Do not touch files outside the Steps.
|