From 2432ffbe9bfa112dc087d1cd3e3ce4de902e85f6 Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 20 Aug 2026 15:50:18 +1000 Subject: [PATCH 1/2] fix: make built stylesheet asset URLs relative so static builds resolve them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remix rewrites every `url()` in a bundled stylesheet to `${publicPath}_assets/` — 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 `` 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 --- CHANGELOG.md | 15 ++++++ package.json | 2 +- scripts/relative-css-asset-urls.mjs | 75 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 scripts/relative-css-asset-urls.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d7a16da..26527cc7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unchanged. Note that nested cells now participate in **Run all**, so solution and exercise cells execute along with the rest of the page. ([#117](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/117)) +- Self-hosted stylesheet assets no longer 404 in static builds. Remix rewrites + every `url()` in a bundled stylesheet to an absolute `/myst_assets_folder/…` + path, which resolves under `myst start` — the theme's own server mounts + `public/build` there — but not in `myst build --html` output, where the assets + land under `build/_assets/` and mystmd's rewriter only fixes up `.html`, `.js` + and `.json`, never `.css`. So the KaTeX stylesheet self-hosted in + [#125](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/125) loaded + while all 60 of its font references failed, and maths fell back to system + glyphs on every statically built site — the degradation that change set out to + prevent. The build now rewrites those references to be relative to the + stylesheet, which resolves identically under `myst start`, in a static build, + and under a `baseurl` (where the absolute path was also wrong, affecting the + per-PR preview deployments). Every rewritten target is checked to exist beside + its stylesheet, so a wrong assumption fails the build instead of shipping + silent 404s ([#138](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/138)). ## [2.3.0] - 2026-08-20 diff --git a/package.json b/package.json index db59b7598..6ffaf457b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "clean": "rimraf public/build build api", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "build:thebe": "copy-thebe-assets ./public", - "prod:build": "npm run prod:copy && npm run build:thebe && npm run build:css && remix build", + "prod:build": "npm run prod:copy && npm run build:thebe && npm run build:css && remix build && node scripts/relative-css-asset-urls.mjs", "dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css", "dev": "npm run dev:copy && npm run build:thebe && concurrently \"npm run dev:css\" \"remix dev\"", "start": "npm run build:css && remix dev", diff --git a/scripts/relative-css-asset-urls.mjs b/scripts/relative-css-asset-urls.mjs new file mode 100644 index 000000000..6da3b543a --- /dev/null +++ b/scripts/relative-css-asset-urls.mjs @@ -0,0 +1,75 @@ +/** + * Rewrite absolute asset URLs in the built stylesheets to be relative to the + * stylesheet itself (#138). + * + * Remix rewrites every `url()` in a bundled stylesheet to + * `${publicPath}_assets/` — an absolute path, because `publicPath` is + * also how it loads JS chunks and so cannot itself be relative. Under + * `myst start` that resolves: `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 keeps pointing at a directory the + * output does not contain, and every font 404s. + * + * 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 all of them: + * + * 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 /build/_assets/x.css -> /build/_assets/font.woff2 + * + * The last of those is a bug fixed in passing: an absolute `/myst_assets_folder` + * ignores `baseurl` and breaks on project-scoped deployments such as the + * per-PR GitHub Pages previews. + * + * Every rewritten target is checked to exist on disk, so a wrong assumption + * here fails the build rather than shipping silent 404s. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { publicPath = '/' } = require('../remix.config.prod.js'); + +const assetsDir = path.resolve('public/build/_assets'); +const prefix = `${publicPath.endsWith('/') ? publicPath : `${publicPath}/`}_assets/`; +// url( optional-quote PREFIX file optional-quote ) +const URL_RE = new RegExp(`url\\((\\s*['"]?)${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'); + +if (!fs.existsSync(assetsDir)) { + console.error(`[css-assets] ${assetsDir} not found — run this after \`remix build\`.`); + process.exit(1); +} + +let rewritten = 0; +let filesTouched = 0; +const missing = []; + +for (const name of fs.readdirSync(assetsDir).filter((f) => f.endsWith('.css'))) { + const file = path.join(assetsDir, name); + const before = fs.readFileSync(file, 'utf8'); + const after = before.replace(URL_RE, 'url($1./'); + if (after === before) continue; + + for (const [, target] of after.matchAll(/url\(\s*['"]?\.\/([^)'"]+)['"]?\s*\)/g)) { + const resolved = path.join(assetsDir, target.split(/[?#]/)[0]); + if (!fs.existsSync(resolved)) missing.push(`${name} -> ${target}`); + } + + rewritten += before.split(prefix).length - 1; + filesTouched += 1; + fs.writeFileSync(file, after); +} + +if (missing.length) { + console.error( + `[css-assets] ${missing.length} reference(s) do not exist beside their stylesheet:\n ${missing.join('\n ')}` + ); + process.exit(1); +} + +console.log( + `[css-assets] rewrote ${rewritten} asset URL(s) in ${filesTouched} stylesheet(s) to be stylesheet-relative` +); From 49a2e69b692d062eddbfa5a30ec29cc0e1d0d13c Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Thu, 20 Aug 2026 16:18:42 +1000 Subject: [PATCH 2/2] fix: validate all asset references before writing any stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/relative-css-asset-urls.mjs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/relative-css-asset-urls.mjs b/scripts/relative-css-asset-urls.mjs index 6da3b543a..0886263ae 100644 --- a/scripts/relative-css-asset-urls.mjs +++ b/scripts/relative-css-asset-urls.mjs @@ -31,9 +31,11 @@ import path from 'node:path'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const { publicPath = '/' } = require('../remix.config.prod.js'); +// Both read from the Remix config rather than hardcoded, so this cannot drift +// out of step with where the build actually puts things. +const { publicPath = '/', assetsBuildDirectory = 'public/build' } = require('../remix.config.prod.js'); -const assetsDir = path.resolve('public/build/_assets'); +const assetsDir = path.resolve(assetsBuildDirectory, '_assets'); const prefix = `${publicPath.endsWith('/') ? publicPath : `${publicPath}/`}_assets/`; // url( optional-quote PREFIX file optional-quote ) const URL_RE = new RegExp(`url\\((\\s*['"]?)${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'); @@ -44,8 +46,11 @@ if (!fs.existsSync(assetsDir)) { } let rewritten = 0; -let filesTouched = 0; const missing = []; +// Validate everything before writing anything: a partial rewrite would leave +// the build output in a half-corrected state that is confusing to debug and +// worse to inherit if a later step ever runs despite the failure. +const pending = []; for (const name of fs.readdirSync(assetsDir).filter((f) => f.endsWith('.css'))) { const file = path.join(assetsDir, name); @@ -59,17 +64,19 @@ for (const name of fs.readdirSync(assetsDir).filter((f) => f.endsWith('.css'))) } rewritten += before.split(prefix).length - 1; - filesTouched += 1; - fs.writeFileSync(file, after); + pending.push([file, after]); } if (missing.length) { console.error( - `[css-assets] ${missing.length} reference(s) do not exist beside their stylesheet:\n ${missing.join('\n ')}` + `[css-assets] ${missing.length} reference(s) do not exist beside their stylesheet:\n ${missing.join('\n ')}\n[css-assets] nothing written.` ); process.exit(1); } +for (const [file, contents] of pending) fs.writeFileSync(file, contents); +const filesTouched = pending.length; + console.log( `[css-assets] rewrote ${rewritten} asset URL(s) in ${filesTouched} stylesheet(s) to be stylesheet-relative` );