perf: self-host KaTeX and jupyter-matplotlib stylesheets - #125
Merged
Conversation
Both were render-blocking <link>s pointing at cdn.jsdelivr.net. The motivation is reach, not milliseconds. jsdelivr is intermittently unreachable from mainland China, which is a significant share of the QuantEcon readership. When it is blocked the maths markup still renders but arrives completely unstyled — fractions, radicals and matrices collapse into run-together text — so the lectures are at their least readable exactly where the maths matters most. Serving both from the site's own origin removes that dependency. Dropping a third origin from the critical path (DNS + TLS before first paint) is a secondary benefit. - KaTeX: imported from the `katex` package so Remix fingerprints it and emits the 60 referenced font files under the usual `/myst_assets_folder/_assets/` path, alongside every other bundled asset. Browsers still fetch only the few faces a page's glyphs need. This also retires the upstream `KatexCSS` export from @myst-theme/site, which pins 0.15.2 while this repo resolves 0.16.x. - jupyter-matplotlib: 316 bytes, vendored into the Tailwind bundle via styles/mpl-widget.css, so it costs no request at all. Kept rather than deleted — no current lecture renders ipympl output, but the theme is shared across lecture repos and any of them may enable it. `katex` becomes a direct dependency instead of a hoisted transitive one. The existing security override is rewritten as `"katex": "$katex"` so it defers to that direct version, preserving the >=0.16.21 floor from #104.
Contributor
|
Contributor
🎭 Visual regression resultsDetails
Skipped testsmobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab |
Resolves the predicted adjacent-lines conflict with the Font Awesome removal (#124) in the links() array: both explanatory comments are kept, both third-party CDN <link> entries are dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mmcky
added a commit
that referenced
this pull request
Aug 20, 2026
…#132) release.yml builds a release's notes from that version's changelog section, so entries in the wrong place are silently dropped from the next release: - The git-history entry (#83, merged 2026-08-19) sat inside the already-tagged [2.2.0] (2026-07-16) section. Moved to [Unreleased] and given its missing PR link. - The two CDN-removal changes had no entries at all. Added under Changed (#125, self-hosted KaTeX + vendored jupyter-matplotlib) and Removed (#124, Font Awesome). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 20, 2026
mmcky
added a commit
that referenced
this pull request
Aug 24, 2026
…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>
mmcky
added a commit
that referenced
this pull request
Aug 24, 2026
`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>
mmcky
added a commit
that referenced
this pull request
Aug 24, 2026
…ve them (#139) * fix: make built stylesheet asset URLs relative so static builds resolve 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> * fix: validate all asset references before writing any stylesheet 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mmcky
added a commit
that referenced
this pull request
Aug 24, 2026
* fix: make built stylesheet asset URLs relative so static builds resolve 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>
* fix: validate all asset references before writing any stylesheet
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>
* perf: self-host the Source Sans 3 webfont
`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>
* test: anchor the FOUC font assertion to the head of the stack
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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Both the KaTeX and jupyter-matplotlib stylesheets were render-blocking
<link>s pointing atcdn.jsdelivr.net. This serves them from the site's own origin instead.Why — reach, not milliseconds
jsdelivr is intermittently unreachable from mainland China, which is a significant share of the QuantEcon readership.
The failure mode matters here. When the CDN is blocked the maths markup still renders — MyST has already converted it at build time — but it arrives with no stylesheet. KaTeX depends entirely on CSS for layout, so fractions lose their bar and stack vertically, radicals lose their overbar, matrices collapse into run-together text, and superscripts sit inline. The lectures degrade worst exactly where the maths matters most, and they do so silently: nothing errors, the page just becomes wrong.
Serving from the site's own origin removes that dependency. Dropping a third origin from the critical path (a DNS lookup and TLS handshake before first paint) is a secondary benefit.
What changed
KaTeX.
katexis already present in the tree, so this adds no new package — it becomes a direct dependency instead of a hoisted transitive one ofmyst-transforms/mermaid. A localapp/links.tsreplaces theKatexCSSexport from@myst-theme/site, which is the thing that pointed at the CDN. The two routes that consumed it now import from~/links; no upstream patch is needed.Remix fingerprints the import and emits the stylesheet plus its 60 referenced font files into
public/build/_assets/, served at/myst_assets_folder/— the samepublicPathevery other bundled asset already uses, so no new path handling is introduced. Verified against a realremix build: all 60 fonts emitted and everyurl()correctly rewritten. Browsers still fetch only the handful of faces a page's glyphs actually need, not all twenty.This also retires a version skew: the upstream export pins KaTeX 0.15.2 while this repo resolves 0.16.x. Worth being precise — that skew is not currently causing breakage. The two stylesheets are functionally equivalent for the pages checked (248 vs 250 rules, identical nine
@font-facefamilies), so this is hygiene rather than a bug fix.jupyter-matplotlib. 316 bytes, vendored verbatim into the Tailwind bundle as
styles/mpl-widget.cssand pulled in bystyles/app.css, so it now costs no request at all. It is kept rather than deleted: no current lecture renders ipympl output, but this theme is shared across lecture repos and any of them may enable%matplotlib widget. The one!importantin that file is upstream's, preserved deliberately rather than silently "improved".Removing only one of the two would have achieved little, since both sat on the same jsdelivr origin — the DNS and TLS cost is per origin, not per file.
The dependency change worth a second look
npm rejects an override that conflicts with a direct dependency of the same name, and
katexalready had a security override from #104. The fix is npm's own idiom:$katexmakes the override defer to the direct version, so the>=0.16.21floor still applies to every transitive consumer. Lockfile churn is a single line.Related, not included
styles/app.cssstill opens with an@importtofonts.googleapis.comfor Source Sans 3. That is the same class of problem and arguably a larger one — Google Fonts is blocked in mainland China, it affects body text on every page rather than just maths, and as a CSS@importit is discovered only afterapp.cssparses, forming a serial request chain that cannot be preloaded.Deliberately left out of this PR, and it is a larger change than a one-line swap. Scoping notes for whoever picks it up:
url()inside an@imported stylesheet (verified experimentally), so@import '@fontsource-variable/source-sans-3'instyles/app.csswould emit font paths relative to the output file and 404. It needs the Remix import route this PR uses for KaTeX, where esbuild rewrites the URLs and emits the files.@fontsource-variable/source-sans-3declares the family asSource Sans 3 Variable, sotailwind.config.jsand the inlined critical CSS inapp/root.tsxboth need the name updated — otherwise the first paint falls back to system sans and the FOUC returns.Because it touches
app/root.tsx, it will conflict with this PR and with #123, so it is best done after both land.Note for merge order
This and the Font Awesome removal (#124) both delete adjacent entries from the same
links()array, so whichever merges second will show a trivial conflict. The resolution is to keep both explanatory comments and drop both<link>entries.