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..0886263ae --- /dev/null +++ b/scripts/relative-css-asset-urls.mjs @@ -0,0 +1,82 @@ +/** + * 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); +// 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(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'); + +if (!fs.existsSync(assetsDir)) { + console.error(`[css-assets] ${assetsDir} not found — run this after \`remix build\`.`); + process.exit(1); +} + +let rewritten = 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); + 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; + 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 ')}\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` +);