fix: make built stylesheet asset URLs relative so static builds resolve them - #139
Conversation
|
🎭 Visual regression resultsDetails
Skipped testsmobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab |
There was a problem hiding this comment.
Pull request overview
This PR fixes a static-build defect where asset URLs inside Remix-built CSS (e.g., KaTeX font url()s) remain absolute under /myst_assets_folder/_assets/…, causing 404s in myst build --html outputs. It adds a post-build rewrite step to convert those CSS url() references to be stylesheet-relative so they resolve consistently in myst start, static builds, and under baseurl.
Changes:
- Add a post-build Node script to rewrite absolute CSS asset URLs to
./…and validate referenced files exist. - Run the rewrite script automatically as part of
npm run prod:build. - Document the fix and user-facing impact in the Unreleased changelog.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| scripts/relative-css-asset-urls.mjs | New post-build script to rewrite CSS url()s from ${publicPath}_assets/... to ./... and validate targets exist. |
| package.json | Appends the rewrite script to the prod:build pipeline after remix build. |
| CHANGELOG.md | Adds an Unreleased “Fixed” entry describing the static-build CSS asset 404 issue and resolution. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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>
…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
|
@DrDrij merging this. Feel free to open a follow up PR if it needs tweaks. |
* 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>
…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>
…ts/ (#151) The #139 rewriter enumerated a single directory, `public/build/_assets`. Remix also emits route and shared-chunk CSS into the build root, `_shared/` and `routes/`, so those files were never in scope and shipped in v2.3.1 still carrying absolute `/myst_assets_folder/_assets/plotly-*.svg` references — the same defect #138 described, in the files that fix did not cover. Walk the build directory instead, and compute the prefix per stylesheet from its own location: a stylesheet in `_assets/` still gets `./`, one in the build root gets `./_assets/`, and one in `routes/` gets `../_assets/`. The existence guard now resolves from each stylesheet's own directory too — that assumption is what silently failed here, so it should be the thing being checked. Verified against a real `npm run prod:build`: 78 asset URLs rewritten across 7 stylesheets, zero absolute references remain anywhere under `public/build`, and an independent resolver confirms all 78 exist at the paths the stylesheets now name. Re-running the script is a no-op. Refs #150 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Move the [Unreleased] entries under ## [2.4.0] - 2026-09-04 with a headline blockquote, add the compare link, re-point the [Unreleased] compare base at v2.4.0, and bump package.json (npm version 2.4.0 --no-git-tag-version). template.yml is stamped by release.yml at build time. Minor rather than patch: #155 adds the site footer part and back-to-top styling alongside its fixes. The release carries three commits that have been on main since 2026-09-03: - #151 completes the static-build asset fix that v2.3.1 began. #139 covered the stylesheets in _assets/, but Remix also emits route and shared-chunk CSS, and four such files shipped in 2.3.1 still pointing --jp-icon-plotly at an absolute /myst_assets_folder/ URL. A production build now emits zero absolute asset URLs and all 78 references resolve. - #155 brings lecture content typography into line with the Sphinx builds and fixes inline code rendering wrapped in literal backticks — a regression that reached every code span on every page. - #161 bumps fast-uri 3.1.5 to 3.1.7. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the built stylesheets reference their assets relative to themselves, so they resolve in
myst build --htmloutput. Found while scoping #131, and it turns out to be a live defect in what v2.3.0 already shipped.The bug
Remix rewrites every
url()in a bundled stylesheet to${publicPath}_assets/<file>— absolute, becausepublicPathis also how it loads JS chunks and so cannot itself be relative. Undermyst startthat resolves, sincetemplate/server.jsmountspublic/buildat exactly that path.A static build has no such route. mystmd's rewriter fixes the
<link>in the HTML, but only ever rewrites.html,.jsand.json— never.css— so the paths inside the stylesheet keep pointing at a directory the output does not contain.Reproduced against a real
myst build --htmlof this repo's own visual fixture, using a currentmake build-theme:<link>to the KaTeX stylesheet/build/_assets/katex.min-STXY5V2I.css— loadsurl()inside that stylesheet/myst_assets_folder/_assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2myst_assets_folder/in the outputbuild/_assets/, 20 woff2, unreferencedSo the stylesheet loads and every font it asks for 404s, and KaTeX falls back to system glyphs for maths. That is the degradation #125 set out to prevent — except it now applies to every statically built site, not only where jsdelivr is blocked.
The fix
A post-build step rewriting those references to be relative to the stylesheet. The CSS and the files it references are emitted into the same
_assets/directory in every layout, so one form works everywhere:That last row is a second bug fixed in passing: an absolute
/myst_assets_folderignoresbaseurl, so it was also wrong for project-scoped deployments — including this repo's own per-PR GitHub Pages previews.publicPathis read fromremix.config.prod.jsrather than hardcoded, and every rewritten target is checked to exist beside its stylesheet, so a wrong assumption here fails the build instead of shipping silent 404s.Verification
Same fixture, same static build, after the change: 60 of 60 references resolve, 0 missing. The build step reports what it did —
rewrote 60 asset URL(s) in 1 stylesheet(s)— and the bundle carries no absolute asset URLs at all.npm run compileclean ·test:unit13/13 · visual suite 19 passed 0 failed, baselines untouched ·test:fouc2 passed.Scope
Only the KaTeX stylesheet is affected today:
app.cssandthebe-core.cssemit nourl()references (0 matches each). The exposure arrived with #125 — before that, KaTeX came from jsdelivr with absolute CDN font URLs.This also unblocks #131: self-hosting Source Sans 3 through the same
app/links.tsroute would otherwise have put body text on the identical broken path, failing the same silent way.Worth proposing upstream separately that mystmd's static rewriter treat
.csslike.html/.js/.json, but that only helps once consumers upgrade the CLI, so the theme should not depend on it.Closes #138
🤖 Generated with Claude Code