From f09f98068233cca3904f2721ba9aebfbf0b073ec Mon Sep 17 00:00:00 2001 From: Matt McKay Date: Wed, 26 Aug 2026 11:38:30 +1000 Subject: [PATCH] fix: rewrite asset URLs in route stylesheets, not just those in _assets/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 18 +++++++++++ scripts/relative-css-asset-urls.mjs | 46 ++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd475e86..bf6f1b854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- The remaining absolute asset URLs in the built stylesheets are now relative + too. [#139](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/139) + rewrote the stylesheets in `_assets/`, but Remix also emits route and + shared-chunk CSS into the build root, `_shared/` and `routes/`, and the + rewriter enumerated a single directory so it never saw them. Four such + stylesheets shipped in 2.3.1 still pointing `--jp-icon-plotly` at an absolute + `/myst_assets_folder/_assets/plotly-*.svg`, which resolves under `myst start` + but 404s in `myst build --html` output and under a `baseurl` — the same defect + [#138](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/138) + described, in the files that fix did not cover. The script now walks the build + directory and computes each stylesheet's prefix from its own location, since + the depth differs (`./_assets/` from the build root, `../_assets/` from + `routes/`), and checks every rewritten target from that stylesheet's own + directory rather than assuming one. A production build now emits **zero** + absolute asset URLs and all 78 references resolve + ([#150](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/150)). + ## [2.3.1] - 2026-08-26 > Headline: corrects two defects in what 2.3.0 shipped. Code cells nested inside diff --git a/scripts/relative-css-asset-urls.mjs b/scripts/relative-css-asset-urls.mjs index 0886263ae..1cdb58f85 100644 --- a/scripts/relative-css-asset-urls.mjs +++ b/scripts/relative-css-asset-urls.mjs @@ -11,9 +11,9 @@ * `.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: + * The referenced files always land in `_assets/`, and the whole build directory + * moves as a unit between 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 @@ -23,7 +23,15 @@ * 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 + * Not every stylesheet sits in `_assets/` (#150): Remix also emits route and + * shared-chunk CSS into the build root, `_shared/` and `routes/`, and those + * reference `_assets/` from a directory above it. So the relative prefix is + * computed per stylesheet from its own location rather than assumed to be + * `./` — the whole tree is walked, and a stylesheet in the build root gets + * `./_assets/` where one in `routes/` gets `../_assets/`. + * + * Every rewritten target is checked to exist at the path the stylesheet now + * names, resolved from that stylesheet's own directory, so a wrong assumption * here fails the build rather than shipping silent 404s. */ import fs from 'node:fs'; @@ -35,7 +43,8 @@ const require = createRequire(import.meta.url); // 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 buildDir = path.resolve(assetsBuildDirectory); +const assetsDir = path.join(buildDir, '_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'); @@ -45,6 +54,22 @@ if (!fs.existsSync(assetsDir)) { process.exit(1); } +/** Every `.css` file under the build directory, at any depth. */ +function* stylesheets(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* stylesheets(full); + else if (entry.name.endsWith('.css')) yield full; + } +} + +/** How this stylesheet must spell `_assets/` to reach it from where it sits. */ +function assetsPrefixFrom(file) { + const rel = path.relative(path.dirname(file), assetsDir).split(path.sep).join('/'); + if (rel === '') return './'; + return rel.startsWith('..') ? `${rel}/` : `./${rel}/`; +} + let rewritten = 0; const missing = []; // Validate everything before writing anything: a partial rewrite would leave @@ -52,14 +77,15 @@ const missing = []; // 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); +for (const file of stylesheets(buildDir)) { const before = fs.readFileSync(file, 'utf8'); - const after = before.replace(URL_RE, 'url($1./'); + const assetsPrefix = assetsPrefixFrom(file); + const after = before.replace(URL_RE, `url($1${assetsPrefix}`); if (after === before) continue; - for (const [, target] of after.matchAll(/url\(\s*['"]?\.\/([^)'"]+)['"]?\s*\)/g)) { - const resolved = path.join(assetsDir, target.split(/[?#]/)[0]); + const name = path.relative(buildDir, file).split(path.sep).join('/'); + for (const [, target] of after.matchAll(/url\(\s*['"]?(\.[^)'"]+)['"]?\s*\)/g)) { + const resolved = path.resolve(path.dirname(file), target.split(/[?#]/)[0]); if (!fs.existsSync(resolved)) missing.push(`${name} -> ${target}`); }