Skip to content

fix: make built stylesheet asset URLs relative so static builds resolve them - #139

Merged
mmcky merged 2 commits into
mainfrom
fix/relative-css-asset-urls
Aug 24, 2026
Merged

fix: make built stylesheet asset URLs relative so static builds resolve them#139
mmcky merged 2 commits into
mainfrom
fix/relative-css-asset-urls

Conversation

@mmcky

@mmcky mmcky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Makes the built stylesheets reference their assets relative to themselves, so they resolve in myst build --html output. 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, 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 build has no such route. mystmd's rewriter fixes the <link> in the HTML, but only ever rewrites .html, .js and .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 --html of this repo's own visual fixture, using a current make build-theme:

check before
<link> to the KaTeX stylesheet rewritten to /build/_assets/katex.min-STXY5V2I.css — loads
url() inside that stylesheet still /myst_assets_folder/_assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2
myst_assets_folder/ in the output absent
fonts on disk build/_assets/, 20 woff2, unreferenced
font references that resolve 0 of 60

So 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:

myst start     /myst_assets_folder/_assets/x.css  ->  /myst_assets_folder/_assets/font.woff2
static build   /build/_assets/x.css               ->  /build/_assets/font.woff2
under baseurl  <base>/build/_assets/x.css         ->  <base>/build/_assets/font.woff2

That last row is a second bug fixed in passing: an absolute /myst_assets_folder ignores baseurl, so it was also wrong for project-scoped deployments — including this repo's own per-PR GitHub Pages previews.

publicPath is read from remix.config.prod.js rather 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 compile clean · test:unit 13/13 · visual suite 19 passed 0 failed, baselines untouched · test:fouc 2 passed.

Scope

Only the KaTeX stylesheet is affected today: app.css and thebe-core.css emit no url() 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.ts route 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 .css like .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

Copilot AI lite review requested due to automatic review settings August 20, 2026 05:50
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-24 23:19 UTC

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  17 passed
skipped  3 skipped

Details

stats  20 tests across 1 suite
duration  36.7 seconds
commit  49a2e69

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/relative-css-asset-urls.mjs
Comment thread scripts/relative-css-asset-urls.mjs Outdated
mmcky added a commit that referenced this pull request Aug 20, 2026
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>
@mmcky
mmcky requested a balanced review from Copilot August 24, 2026 22:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

mmcky and others added 2 commits August 25, 2026 09:10
…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>
@mmcky
mmcky force-pushed the fix/relative-css-asset-urls branch from 36845c7 to 49a2e69 Compare August 24, 2026 23:10
@mmcky

mmcky commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@DrDrij merging this. Feel free to open a follow up PR if it needs tweaks.

@mmcky
mmcky merged commit 7732b9b into main Aug 24, 2026
4 checks passed
@mmcky
mmcky deleted the fix/relative-css-asset-urls branch August 24, 2026 23:19
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>
mmcky added a commit that referenced this pull request Aug 24, 2026
…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>
mmcky added a commit that referenced this pull request Aug 26, 2026
…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>
mmcky added a commit that referenced this pull request Sep 4, 2026
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>
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.

Self-hosted stylesheet assets 404 in static builds: url() points at /myst_assets_folder, which myst build --html never creates

2 participants