Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
38 changes: 38 additions & 0 deletions app/links.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 },
];
15 changes: 14 additions & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -62,6 +63,12 @@ export const meta: V2_MetaFunction<typeof loader> = ({ 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 <link>
* 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 <body>, which @myst-theme/site renders as
Expand All @@ -81,7 +88,7 @@ export const meta: V2_MetaFunction<typeof loader> = ({ 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}
Expand All @@ -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' },
Expand Down
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions scripts/relative-css-asset-urls.mjs
Original file line number Diff line number Diff line change
@@ -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/<file>` — 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 <base>/build/_assets/x.css -> <base>/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`
);
4 changes: 3 additions & 1 deletion styles/app.css
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
7 changes: 6 additions & 1 deletion tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 8 additions & 1 deletion tests/visual/fouc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <link>, 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"
Expand Down
Loading