diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d7a16da..026842ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it is asserted against the DOM ([#121](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/121)). +### Changed +- Source Sans 3 is now self-hosted instead of `@import`ed from + `fonts.googleapis.com`. Google Fonts is blocked in mainland China, a + significant share of the QuantEcon readership, and unlike the CDN stylesheets + removed in 2.3.0 this one is the *body* font on every page rather than the + maths on some of them. It also had the worst possible shape for a + critical-path request: a CSS `@import`, discovered only after `app.css` had + downloaded and parsed, so the browser could not preload it and the chain ran + `app.css` → Google's CSS → `fonts.gstatic.com` woff2 across two extra origins. + The font now ships from `@fontsource-variable/source-sans-3` through the same + Remix import route the KaTeX CSS uses, so its 14 `.woff2` files are served + from the site's own origin. The family is declared as `Source Sans 3 + Variable`, so `tailwind.config.js` and the inlined critical CSS in + `app/root.tsx` name it that way too, with plain `Source Sans 3` kept next in + the stack for a locally installed copy. Rendered text is unchanged — the + visual suite passes against untouched baselines + ([#131](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/131)). + ### Fixed - Code cells nested inside a directive (`{exercise}`, `{solution}`, `{note}`, …) are now registered with the kernel, so their run button works and their @@ -56,6 +74,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/app/links.ts b/app/links.ts index 3a47004ce..018f43220 100644 --- a/app/links.ts +++ b/app/links.ts @@ -1,5 +1,10 @@ import type { HtmlLinkDescriptor } from '@remix-run/react'; import katexCss from 'katex/dist/katex.min.css'; +// Explicit `.css` subpaths, not the bare specifier: the package ships its own +// `index.d.css.ts` (`export {}`), which would win over Remix's +// `declare module "*.css"` and fail `npm run compile`. +import sourceSans3Css from '@fontsource-variable/source-sans-3/index.css'; +import sourceSans3ItalicCss from '@fontsource-variable/source-sans-3/wght-italic.css'; /** * Self-hosted KaTeX stylesheet. @@ -30,3 +35,36 @@ export const KatexCSS: HtmlLinkDescriptor = { rel: 'stylesheet', href: katexCss, }; + +/** + * Self-hosted Source Sans 3 (variable), replacing the + * `@import url('https://fonts.googleapis.com/css2?family=Source+Sans+3…')` that + * used to open `styles/app.css`. + * + * Same two reasons as KaTeX above, both sharper here. Google Fonts is blocked + * in mainland China, and this is the *body* font on every page rather than the + * maths on some of them. And an `@import` is the worst shape a critical-path + * request can have: it is discovered only once `app.css` has downloaded and + * parsed, so it cannot be preloaded, and the chain ran app.css → Google's CSS → + * gstatic woff2 across two extra origins. + * + * The import lives here rather than in `styles/app.css` because Tailwind does + * not rebase `url()` inside an `@import`ed stylesheet — the font paths would be + * emitted relative to the Tailwind *output* file and 404. Imported from a + * module, Remix's esbuild pass rewrites them and emits the woff2 files into + * `public/build/_assets/`, the same route KaTeX's fonts take. (Those emitted + * URLs are then made stylesheet-relative by `scripts/relative-css-asset-urls.mjs`, + * without which they resolve only under `myst start` and not in static builds.) + * + * Two stylesheets, not one: the package splits upright from italic, and lecture + * prose uses both. Without the italic faces the browser synthesises an oblique + * from the upright, which measures wider and shifts the layout. + * + * The family is declared as `Source Sans 3 Variable`, which is why + * `tailwind.config.js` and the inlined `CRITICAL_CSS` in `app/root.tsx` name it + * that way too — those three have to stay in step. + */ +export const SourceSans3CSS: HtmlLinkDescriptor[] = [ + { rel: 'stylesheet', href: sourceSans3Css }, + { rel: 'stylesheet', href: sourceSans3ItalicCss }, +]; diff --git a/app/root.tsx b/app/root.tsx index 18261ad8d..4e4b4aaf7 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,6 +1,7 @@ import type { LinksFunction, V2_MetaFunction, LoaderFunction } from '@remix-run/node'; import tailwind from '~/styles/app.css'; import thebeCoreCss from 'thebe-core/dist/lib/thebe-core.css'; +import { SourceSans3CSS } from '~/links'; import { getConfig } from '~/backend/loaders.server'; import type { SiteLoader } from '@myst-theme/common'; import { @@ -62,6 +63,12 @@ export const meta: V2_MetaFunction = ({ data }) => { * * Keep the values in sync with their sources of truth: * - font stack: tailwind.config.js -> theme.extend.fontFamily.sans + * The `@font-face` rules for "Source Sans 3 Variable" are + * self-hosted via app/links.ts, so they arrive in a + * and are NOT available at this first paint. The + * `sans-serif` tail is what renders here and the webfont + * swaps in once that stylesheet lands — as it did with the + * Google Fonts @import this replaced. * - grid columns: tailwind.config.js -> theme.extend.gridTemplateColumns * (`simple-sm` / `simple-xl`), applied by `.simple-center-grid` * - dark bg: matches the page , which @myst-theme/site renders as @@ -81,7 +88,7 @@ export const meta: V2_MetaFunction = ({ data }) => { * the panel does not push the article down while it waits. */ const CRITICAL_CSS = ` -:where(html){font-family:"Source Sans 3",sans-serif} +:where(html){font-family:"Source Sans 3 Variable","Source Sans 3",sans-serif} :where(body){margin:0;background-color:#fff} :where(.dark body){background-color:#1c1917} :where([hidden],.hidden){display:none} @@ -97,6 +104,12 @@ export const links: LinksFunction = () => { rel: 'icon', href: '/favicon.ico', }, + // Self-hosted Source Sans 3 (see app/links.ts). Declared on the *root* + // route rather than the two page routes like KatexCSS, because root's + // links() are the only ones that also apply when the root ErrorBoundary + // renders — a 404, or the missing-site response thrown below — and the body + // font has to be right on those pages too. + ...SourceSans3CSS, { rel: 'stylesheet', href: tailwind }, { rel: 'stylesheet', href: thebeCoreCss }, { rel: 'stylesheet', href: '/myst-theme.css' }, diff --git a/package-lock.json b/package-lock.json index e46dfd0a4..8470a6ff5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.3.0", "hasInstallScript": true, "dependencies": { + "@fontsource-variable/source-sans-3": "^5.3.0", "@myst-theme/common": "^1.3.0", "@myst-theme/icons": "^1.3.0", "@myst-theme/jupyter": "^1.3.0", @@ -46,7 +47,7 @@ "@vercel/node": "^2.15.1", "concurrently": "^9.1.2", "patch-package": "^8.0.0", - "prettier": "*", + "prettier": "latest", "tailwindcss": "^3.4.17", "typescript": "~5.9.0" }, @@ -2895,6 +2896,15 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@fontsource-variable/source-sans-3": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/source-sans-3/-/source-sans-3-5.3.0.tgz", + "integrity": "sha512-dpi0GZk7EQe2tYpg6Q0Fx0OmUgnuGu+0rHPgtXqtKhglYmJuP7jZ12Mu1uN+NfRaJRY1Emn8AQJEWzmoZ4wa9g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fortawesome/fontawesome-free": { "version": "5.15.4", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz", diff --git a/package.json b/package.json index db59b7598..3a102de24 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", @@ -23,6 +23,7 @@ "test:fouc": "playwright test --project=webkit-fouc" }, "dependencies": { + "@fontsource-variable/source-sans-3": "^5.3.0", "@myst-theme/common": "^1.3.0", "@myst-theme/icons": "^1.3.0", "@myst-theme/jupyter": "^1.3.0", 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` +); diff --git a/styles/app.css b/styles/app.css index 23921c15f..7267002fe 100644 --- a/styles/app.css +++ b/styles/app.css @@ -1,4 +1,6 @@ -@import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,200..900;1,200..900&display=swap'); +/* Source Sans 3 is self-hosted and imported from app/links.ts, not @import-ed + here: Tailwind does not rebase url() inside an @import-ed stylesheet, so the + font paths would resolve relative to this file's *output* and 404. */ @import '@myst-theme/styles'; @import './lists.css'; @import './mpl-widget.css'; diff --git a/tailwind.config.js b/tailwind.config.js index 3ad99a63c..b48d96e0e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -66,7 +66,12 @@ module.exports = { 'qeborder-blue': 'rgb(0 114 188)', }, fontFamily: { - sans: ['"Source Sans 3"', 'sans-serif'], + // "Source Sans 3 Variable" is the family name declared by + // @fontsource-variable/source-sans-3, self-hosted via app/links.ts. + // Plain "Source Sans 3" comes next so a locally installed copy is used + // while the webfont swaps in, or if it fails to load. Must stay in step + // with CRITICAL_CSS in app/root.tsx. + sans: ['"Source Sans 3 Variable"', '"Source Sans 3"', 'sans-serif'], }, keyframes: { slideDownAndFade: { diff --git a/tests/visual/fouc.spec.ts b/tests/visual/fouc.spec.ts index 5e50a620a..e999da775 100644 --- a/tests/visual/fouc.spec.ts +++ b/tests/visual/fouc.spec.ts @@ -108,7 +108,14 @@ test.describe("FOUC guard (WebKit) — inline critical CSS styles the first pain expect(state.appliedExternal).toBe(false); // The reported FOUC symptoms must be absent on first paint: expect(state.gridDisplay).toBe("grid"); // grid not collapsed to block - expect(state.bodyFont).toMatch(/Source Sans 3/); // sans, not the serif default + // Head of the stack, not just a substring: "Source Sans 3 Variable" (the + // family name of the self-hosted webfont) contains "Source Sans 3", so the + // looser regex would keep passing if CRITICAL_CSS and tailwind.config.js + // drifted apart. This reads the *declared* stack — the @font-face rules + // live in a , which this test aborts, so what actually paints is the + // `sans-serif` tail. That is the point: the guard is about sans-vs-serif, + // not about the webfont having arrived. + expect(state.bodyFont).toMatch(/^["']?Source Sans 3 Variable["']?\s*,/); expect( state.sidebarRight, "`.qe-contents-sidebar` not found — the hook the critical CSS targets was renamed or removed"