Skip to content

fix: stop inlining fonts into the minified CSS - #8314

Open
sqmyou wants to merge 1 commit into
ether:developfrom
sqmyou:fix/8268-font-urls
Open

sqmyou wants to merge 1 commit into
ether:developfrom
sqmyou:fix/8268-font-urls

Conversation

@sqmyou

@sqmyou sqmyou commented Oct 7, 2026 •

Copy link
Copy Markdown

Fixes #8268

What and why

compressCSS() bundles each stylesheet with esbuild using dataurl loaders for .ttf, .otf, .woff, .woff2 and .eot. Every url() pointing at a font was therefore embedded in the CSS as base64.

For the pad page that meant static/css/pad.css was about 1.7 MB, because it pulls in the editor fonts (Montserrat, OpenDyslexic, Roboto, Roboto Mono, Quicksand, Alegreya) and the icon font in three formats. Every visitor had to download all of it — including the formats their browser never uses — before the pad could render, and again after each upgrade because the ?v= cache key changes.

This marks the font URLs external instead, so the browser fetches only the faces it needs, lazily, and caches them separately from the CSS. Images are still inlined exactly as before.

before after
static/css/pad.css 1,733,220 bytes 61,002 bytes
inlined font data URLs 18 0
external font URLs 0 18

Why a plugin

esbuild has no declarative way to keep CSS url()s external. I tried the obvious options first and each fails:

  • external: ['.ttf', ...] — the external option only applies to imports, not CSS url().
  • loader: {'.ttf': 'file'} — requires an output path and returns separate files.
  • external: ['*/font/*'] — works for the plain paths but misses the icon font, whose URLs carry query strings (fontawesome-etherpad.woff?2).
  • external: ['*.ttf', '*.woff?2', ...] — esbuild allows only a single * wildcard, so these cannot match.

An onResolve plugin matching the font extensions (including the query-string variants) handles all of them.

The fonts are referenced from the CSS by relative path, so the emitted URLs resolve to the same files as before and no server-side changes are needed.

Tests

src/tests/backend/specs/cssFontInlining.ts asserts that no font data URLs remain, that the output stays small, that each @font-face source is still present as an external URL, and that every such URL resolves to a real file.

I confirmed the test fails if the plugin is removed, so it is a genuine regression test rather than a tautology.

Verification I ran locally:

  • full backend suite: 1823 passing, 0 failing
  • tsc --noEmit: clean
  • end-to-end against a running server: GET /static/css/pad.css returns 61,002 bytes with 0 inlined fonts; the resolved font URLs return 200 with content-type: font/otf, including the query-string icon font
  • the test passes when run against this exact branch, freshly cloned

Note on lint

pnpm run lint fails on a clean develop checkout in this environment (ESLint 10.12.0 cannot find eslint.config.*; the repo still uses .eslintrc.cjs). This is unrelated to the change — it reproduces with my edits stashed.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Keep font URLs external in minified CSS

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Keep fonts out of bundled CSS so browsers fetch and cache only the faces they need.
• Preserve image inlining and font URLs, including query-string icon-font references.
• Add regression tests for CSS size, external references, and font file existence.
Diagram

graph TD
  A["Pad CSS"] --> B["esbuild bundle"] --> C{"Asset URL?"}
  C -->|font| D["External font URL"] --> F["Browser font fetch"]
  C -->|image| E["Inlined image data"]
Loading
High-Level Assessment

The targeted esbuild resolution plugin fits the existing bundling pipeline: it preserves relative font URLs, including query-string variants, without changing static-file serving or image inlining. Declarative external patterns and file loaders were considered but do not cover these URLs without additional output handling.

Files changed (2) +152 / -5

Bug fix (1) +26 / -5
MinifyWorker.tsPreserve font URLs during CSS bundling +26/-5

Preserve font URLs during CSS bundling

• Adds an esbuild resolution plugin that marks TTF, OTF, WOFF, WOFF2, and EOT references external, including URLs with query strings. Removes font data-URL loaders while retaining image inlining.

src/node/utils/MinifyWorker.ts

Tests (1) +126 / -0
cssFontInlining.tsTest bundled CSS font references +126/-0

Test bundled CSS font references

• Adds regression tests for the absence of inlined font data, a bounded stylesheet size, and preservation of editor and query-string icon-font URLs. Checks that emitted external font URLs resolve to existing files.

src/tests/backend/specs/cssFontInlining.ts

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can route each severity your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

compressCSS() bundled every stylesheet with esbuild using `dataurl` loaders
for .ttf/.otf/.woff/.woff2/.eot, so every `url()` pointing at a font was
embedded as base64. For the pad page that made `static/css/pad.css` ~1.7 MB,
because it pulls in the editor fonts (Montserrat, OpenDyslexic, Roboto,
Roboto Mono, Quicksand, Alegreya) and the icon font in three formats. Every
visitor had to download all of it — including the formats their browser never
uses — before the pad could render, and again after each upgrade because the
`?v=` cache key changes.

Mark the font URLs external instead, via an esbuild onResolve plugin, so the
browser fetches only the faces it needs, lazily, and caches them separately
from the CSS. Images are still inlined as before.

esbuild offers no declarative way to do this: the `external` option applies
only to imports, the `file` loader requires an output path, and external globs
accept a single wildcard so they cannot match the query-string icon-font URLs
such as `fontawesome-etherpad.woff?2`. The plugin matches those too.

The fonts are referenced from the CSS by relative path, so the emitted URLs
resolve to the same files as before and no server-side changes are needed.

The regression test asserts that no font data URLs remain in the bundled
stylesheet, that the output stays small, that each @font-face source is still
present as an external URL, and that every such URL resolves to a real file.
It fails if the plugin is removed.

Fixes ether#8268
@sqmyou
sqmyou force-pushed the fix/8268-font-urls branch from a0f0014 to cb6affc Compare October 7, 2026 17:58

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pad.css is 1.7 MB because the CSS minifier inlines every font as base64

1 participant