perf: self-host the Source Sans 3 webfont - #140
Conversation
|
There was a problem hiding this comment.
Pull request overview
This PR self-hosts the Source Sans 3 font (variable + italic) so the body font no longer depends on Google Fonts and avoids an @import-driven critical-path request chain. It integrates the font via the existing Remix/esbuild “import CSS → emit assets” flow used for KaTeX, and updates the font-family stack in Tailwind + inline critical CSS accordingly.
Changes:
- Add
@fontsource-variable/source-sans-3and serve its upright/italic CSS fromapp/links.ts, applied from the root route. - Remove the Google Fonts
@importfromstyles/app.cssand update font-family stack usages (tailwind.config.js,CRITICAL_CSS, FOUC guard test). - Document the change in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 6 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/visual/fouc.spec.ts | Tightens the FOUC guard to check the updated font stack. |
| tailwind.config.js | Updates theme.extend.fontFamily.sans to include "Source Sans 3 Variable". |
| styles/app.css | Removes Google Fonts @import and documents why imports must come from Remix, not CSS. |
| package.json | Adds @fontsource-variable/source-sans-3 dependency. |
| package-lock.json | Locks the new font package and updates lockfile metadata. |
| CHANGELOG.md | Records the switch from Google Fonts to self-hosted Source Sans 3. |
| app/root.tsx | Uses the new font stack in CRITICAL_CSS and adds root-level <link>s for the font CSS. |
| app/links.ts | Adds SourceSans3CSS link descriptors for upright + italic stylesheets. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Note on CI coverage while this is stacked: Locally, against a |
The comment claimed the assertion checked the head of the declared stack, but an unanchored regex matches anywhere in it — so the guard would still pass with "Source Sans 3 Variable" demoted behind another family, which is the drift it exists to catch. Raised by Copilot on #140. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ve them
Remix rewrites every `url()` in a bundled stylesheet to
`${publicPath}_assets/<file>` — absolute, because `publicPath` is also
how it loads JS chunks and so cannot itself be relative. Under
`myst start` that resolves, since `template/server.js` mounts
`public/build` at exactly that path. A static `myst build --html` has no
such route, and mystmd's asset rewriter only touches `.html`, `.js` and
`.json`, never `.css` — so the path inside the stylesheet kept pointing
at a directory the output does not contain.
The effect: the KaTeX stylesheet self-hosted in #125 loads in a static
build, and all 60 of its font references 404. Maths then renders with
system fallback glyphs — the degradation that change set out to prevent,
now on every statically built site rather than only where jsdelivr is
blocked. Verified against a real `myst build --html` of the visual
fixture: the `<link>` is rewritten to `/build/_assets/…` and loads, no
`myst_assets_folder/` directory exists in the output, and the fonts sit
in `build/_assets/` unreferenced.
The stylesheets and the files they reference are emitted into the same
`_assets/` directory in both layouts, so a reference relative to the
stylesheet resolves in either — and under a `baseurl` too, where the
absolute path was equally wrong and would have broken the per-PR preview
deployments.
Adds a post-build step that rewrites those references and then checks
every rewritten target exists beside its stylesheet, so a wrong
assumption fails the build rather than shipping silent 404s. On the same
fixture the static build now resolves 60 of 60.
Only the KaTeX stylesheet is affected today; `app.css` and
`thebe-core.css` emit no `url()` references at all.
Closes #138
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two points from Copilot's review of #139. The script wrote each rewritten stylesheet as it went and only failed afterwards, so a missing target left the build output half-corrected — confusing to debug, and worse to inherit if a later step ever ran despite the non-zero exit. Rewrites are now buffered and applied only once every reference has been checked; the failure path says "nothing written". The assets directory was also hardcoded as public/build/_assets while remix.config.prod.js already declares assetsBuildDirectory. Both that and publicPath now come from the config, so the script cannot drift out of step with where the build actually puts things. Verified by seeding a reference to a file that does not exist: the script reports it, exits 1, and the stylesheet is left exactly as it was. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
36845c7 to
49a2e69
Compare
`styles/app.css` opened with a CSS `@import` to fonts.googleapis.com. Google Fonts is blocked in mainland China, a significant share of the QuantEcon readership, and unlike the CDN stylesheets dropped in 2.3.0 this one is the body font on every page rather than the maths on some of them — when it fails, the lectures render in the system sans throughout. It was also the worst possible shape for a critical-path request. An `@import` is discovered only once app.css has downloaded and parsed, so the browser cannot preload it, and the chain ran app.css -> Google's CSS -> gstatic woff2 across two extra origins before any text could paint in the intended face. The font now ships from `@fontsource-variable/source-sans-3`, imported from `app/links.ts` — the module #125 introduced for KaTeX — so esbuild rewrites the `url()`s and emits the 14 woff2 files alongside every other bundled asset. It cannot be `@import`ed from `styles/app.css` instead: Tailwind does not rebase `url()` inside an imported stylesheet, so the paths would resolve against the Tailwind output file and 404. The links are declared on the root route rather than the two page routes like `KatexCSS`, because root's are the only ones that also apply when the root ErrorBoundary renders — the body font has to be right on a 404 too. The package declares the family as "Source Sans 3 Variable", so tailwind.config.js and the inlined CRITICAL_CSS name it that way as well, keeping plain "Source Sans 3" next in the stack for a locally installed copy. The FOUC guard is tightened to match the head of the stack rather than a substring, since "Source Sans 3 Variable" contains "Source Sans 3" and the looser regex would have kept passing if those two declarations ever drifted apart. Verified end to end on a real `myst build --html`: no absolute asset URLs remain in any stylesheet, all 74 references resolve, and the 14 woff2 files are present in the output. Rendered text is unchanged — the full visual suite passes against untouched baselines. Closes #131 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The comment claimed the assertion checked the head of the declared stack, but an unanchored regex matches anywhere in it — so the guard would still pass with "Source Sans 3 Variable" demoted behind another family, which is the drift it exists to catch. Raised by Copilot on #140. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1be9cbd to
17ea92a
Compare
|
@DrDrij merging this. |
…hosting (#148) * test: refresh linux mobile-chrome baselines after Source Sans 3 self-hosting The @fontsource-variable build of Source Sans 3 has marginally different glyph metrics from the Google-served static font it replaced in #140, so a couple of lines wrap one word differently at the mobile viewport width and all five linux mobile-chrome snapshots drift past the diff budget. Desktop snapshots are unaffected. #140 never got a visual-regression run before merge: as a PR stacked on fix/relative-css-asset-urls it only triggered the preview job, and the base retarget after #139 merged does not fire a synchronize event. Baselines to be refreshed via /update-snapshots on this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: refresh CI visual snapshots (linux baselines) * ci: nudge — snapshot-bot pushes do not trigger CI --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Drops the last third-party origin from the critical path: Source Sans 3 is now served from the site's own origin.
Note
Stacked on #139. Based on
fix/relative-css-asset-urls, notmain. Without that fix the emitted font URLs are absolute/myst_assets_folder/…paths that resolve undermyst startbut 404 inmyst build --html— so the font would fail silently in exactly the mode the lectures deploy in. Review or merge #139 first; GitHub will retarget this tomainautomatically.Why this one matters more than the CDN stylesheets already removed
styles/app.cssopened with a CSS@importtofonts.googleapis.com. Two things make it a worse problem than the jsdelivr stylesheets #124/#125 dealt with:@importis the worst shape a critical-path request can have. It is discovered only onceapp.csshas downloaded and parsed, so the browser cannot preload it, and the chain ranapp.css→ Google's CSS →fonts.gstatic.comwoff2 across two extra origins before any text could paint in the intended face.What changed
The font ships from
@fontsource-variable/source-sans-3, imported fromapp/links.ts— the module #125 introduced for KaTeX — so esbuild rewrites theurl()s and emits the 14.woff2files alongside every other bundled asset.It deliberately is not
@imported fromstyles/app.css: Tailwind does not rebaseurl()inside an imported stylesheet, so the paths would resolve against the Tailwind output file and 404.Two stylesheets are imported, upright and italic. Lecture prose uses both, and without the italic faces the browser synthesises an oblique from the upright, which measures wider and shifts the layout.
The links are declared on the root route rather than the two page routes like
KatexCSS, because root'slinks()are the only ones that also apply when the root ErrorBoundary renders — the body font should be right on a 404 too.The package declares the family as
Source Sans 3 Variable, sotailwind.config.jsand the inlinedCRITICAL_CSSinapp/root.tsxname it that way as well, keeping plainSource Sans 3next in the stack for a locally installed copy andsans-serifas the tail.First paint is unaffected
Worth being explicit, since this touches the critical CSS the #123 FOUC guard protects. The
@font-facerules live in a<link>, so they are not available on the first painted frame — and they were not before either, since the Google@importsat insideapp.css. Thesans-seriftail is what paints in both cases; the webfont swaps in when the stylesheet lands. Nothing about the FOUC guard's premise changes.The guard itself is tightened: it asserted
/Source Sans 3/, and"Source Sans 3 Variable"contains that string, so the old regex would have kept passing even ifCRITICAL_CSSandtailwind.config.jsdrifted apart. It now matches the head of the stack.Verification
Static build gate first, since that is the mode that decides the mechanism — a real
myst build --htmlof the fixture againstmake build-theme:myst_assets_folderURLs left in any stylesheetgoogleapisreferences in the built CSSSource Sans 3 Variable,Source Sans 3,sans-serifThen:
npm run compileclean ·npm run test:fouc2 passed · full visual suite 19 passed, 0 failed with every baseline untouched — which is the evidence that rendered text is unchanged, i.e. fontsource's build is metric-identical to what Google was serving.Closes #131
🤖 Generated with Claude Code