Skip to content

perf: self-host KaTeX and jupyter-matplotlib stylesheets - #125

Merged
mmcky merged 2 commits into
mainfrom
perf/self-host-katex
Aug 20, 2026
Merged

perf: self-host KaTeX and jupyter-matplotlib stylesheets#125
mmcky merged 2 commits into
mainfrom
perf/self-host-katex

Conversation

@DrDrij

@DrDrij DrDrij commented Aug 18, 2026

Copy link
Copy Markdown
Member

Both the KaTeX and jupyter-matplotlib stylesheets were render-blocking <link>s pointing at cdn.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. katex is already present in the tree, so this adds no new package — it becomes a direct dependency instead of a hoisted transitive one of myst-transforms / mermaid. A local app/links.ts replaces the KatexCSS export 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 same publicPath every other bundled asset already uses, so no new path handling is introduced. Verified against a real remix build: all 60 fonts emitted and every url() 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-face families), so this is hygiene rather than a bug fix.

jupyter-matplotlib. 316 bytes, vendored verbatim into the Tailwind bundle as styles/mpl-widget.css and pulled in by styles/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 !important in 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 katex already had a security override from #104. The fix is npm's own idiom:

"dependencies": { "katex": "^0.16.21" },
"overrides":    { "katex": "$katex" }

$katex makes the override defer to the direct version, so the >=0.16.21 floor still applies to every transitive consumer. Lockfile churn is a single line.

Related, not included

styles/app.css still opens with an @import to fonts.googleapis.com for 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 @import it is discovered only after app.css parses, 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:

  • Tailwind does not rebase url() inside an @imported stylesheet (verified experimentally), so @import '@fontsource-variable/source-sans-3' in styles/app.css would 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-3 declares the family as Source Sans 3 Variable, so tailwind.config.js and the inlined critical CSS in app/root.tsx both need the name updated — otherwise the first paint falls back to system sans and the FOUC returns.
  • The variable package is nonetheless the right choice: the theme uses seven distinct font weights plus italics, which the static package would ship as roughly fourteen files per unicode subset.

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.

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.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-20 03:15 UTC

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  17 passed
skipped  3 skipped

Details

stats  20 tests across 1 suite
duration  37 seconds
commit  571383c

Skipped tests

mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle-absent-without-thebe

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
mmcky merged commit 545b038 into main Aug 20, 2026
4 checks passed
@mmcky
mmcky deleted the perf/self-host-katex branch August 20, 2026 03:15
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants