Self-host every font instead of loading them from Google (#23859)

Google Fonts logs the IP and user agent of everyone who loads a font
from it. Any page of ours that links to `fonts.googleapis.com` hands our
users (and every self-hoster's users) to a third party for nothing in
return, since we can serve the same bytes ourselves.

After this PR there is no reference to `fonts.googleapis.com`,
`fonts.gstatic.com` or `next/font/google` left in the repo.

## What changed

**twenty-front, PDF export.** `exportBlockNoteEditorToPdf` registered
Inter by URL against `fonts.gstatic.com`, so exporting a note made the
browser fetch three TTFs from Google. The registration turned out to be
unnecessary altogether: `@blocknote/xl-pdf-exporter` already registers
an `Inter` family for its PDF schema, shipped inlined in the package as
a base64 TTF with the same 2849-codepoint coverage. Deleting our
`Font.register` means no font request leaves the browser, with 41 fewer
lines and nothing vendored.

Only weights 400 and 700 were ever used, and 700 already resolved to
blocknote's `Inter18pt-Bold` before this branch, so the custom 500/600
registrations were dead. The only rendering change is body text going
from `Inter` to `Inter18pt`, the same typeface at its 18pt optical size.

**twenty-sdk, OAuth callback page.** The local "you can close this tab"
page linked to Google Fonts, which meant running `twenty auth` phoned
Google from the developer's browser. Replaced with a system font stack;
a transient callback page did not justify a webfont round trip in the
first place.

**twenty-ui, Storybook.** `preview-head.html` loaded Inter from Google.
It now imports `@fontsource/inter` in `preview.tsx`, matching what
twenty-front's Storybook already does.

**twenty-website.** Host Grotesk, Aleo, Azeret Mono and VT323 came
through `next/font/google`. Next self-hosts those at runtime, so this
was not a visitor-facing leak, but the build still had to reach Google,
which makes builds non-hermetic and fails in an air-gapped environment.
The latin subsets are now vendored in `src/fonts/`, next to the Inter
files that were already there, and loaded with `next/font/local`. All
four are OFL 1.1; `src/fonts/README.md` records each file's upstream and
license. Total added weight is ~78 KB, and these are the exact files
Next was downloading at build time anyway.

Host Grotesk and Azeret Mono ship as single variable files, so they are
declared once over their full `wght` axis rather than as one face per
weight.

## Also removed

Both Storybooks pulled `iframeResizer.contentWindow.min.js` from
`cdnjs.cloudflare.com`. Storybook has not needed it since v7 and nothing
in either package references `iframeResizer` or `parentIFrame`, so it
was a third-party script executing in the preview iframe for no reason.
Argos does not screenshot through the manager iframe either:
`@argos-ci/storybook` hooks Vitest browser mode and calls
`server.commands.argosScreenshot`, so Playwright drives the page
directly.

## Verification

Not just typecheck. The interesting parts were tested end to end, which
caught two bugs an earlier revision of this PR had introduced.

**PDF export** — production Vite build, served over HTTP, real Chromium,
exporting through the actual `exportBlockNoteEditorToPdf`, then
extracting the PDF's text back out:

```
Latin heading  Cyrillic: Привет мир  Greek: Ελληνικά κείμενο
Latin-ext: Zażółć gęślą jaźń, Český  Vietnamese: Tiếng Việt

PASS Latin / Cyrillic / Greek / Polish / Czech / Vietnamese
```

Embedded fonts are `Inter18pt-Regular` / `Inter18pt-Bold`, no Helvetica
fallback, zero requests off-origin.

**Website** — built it, audited the build output (12 `@font-face` rules,
all `/_next/static/media/`, weights `300 800` / `100 900` / `300` /
`400` / `400,500,600`, `display: swap` preserved), then loaded it in
Chromium: 136 requests, zero to Google. The deployed preview was checked
too: no Google references in the served HTML or across all 21 CSS
chunks, every font file returns `200 font/woff2` and parses to the
expected family, and the asset hashes match a local build byte for byte.

**Two bugs this caught**, both in earlier commits on this branch, both
now fixed:

1. Registering `@fontsource/inter`'s latin file dropped coverage from
2849 codepoints to 230, silently removing Cyrillic, Greek, Vietnamese
and extended-Latin from every export. fontsource splits Inter into seven
per-script files chosen by `unicode-range`, but `Font.register` binds
one file per weight with no equivalent.
2. Any woff2 aborts the export outright with `RangeError: Offset is
outside the bounds of the DataView`. fontkit parses woff2, but
`@react-pdf`'s subsetter chokes on the transformed `glyf` table.
Confirmed format was the only variable by running identical content
through local TTF, WOFF and WOFF2 files.

Both are moot now that the registration is gone, but they are why this
is worth a careful look rather than a rubber stamp.

## Left alone, but worth knowing about

More third-party calls exist. None are font-related and each is a
separate decision:

- `twenty-website` loads `dotlottie-player.wasm` from **unpkg.com** at
runtime on the homepage, via `@lottiefiles/dotlottie-react`. This is a
live third-party CDN request on every visit, the same class of problem
as the fonts, and looks like a small config change to self-host.
- The halftone studio loads the Draco decoder from `www.gstatic.com`
and, in exported scenes, three.js from `unpkg.com`.
- The partners marketplace fixtures hotlink logos from
`cdn.simpleicons.org` and `upload.wikimedia.org`.
- reCAPTCHA and the Front support chat are config-gated and off unless
an admin configures them, which seems right.
- `APP_REGISTRY_CDN_URL` defaults to `https://unpkg.com`.
- `twenty-front/index.html` points its `og:image` at
`raw.githubusercontent.com`. Only social crawlers fetch it, so this is
cosmetic.
This commit is contained in:
Félix Malfait
2026-08-06 17:58:55 +02:00
committed by GitHub
parent 435bb54b97
commit 8774bf8604
13 changed files with 42 additions and 57 deletions
@@ -1,5 +1,3 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.7/iframeResizer.contentWindow.min.js"></script>
<style type="text/css">
body {
margin: 0;
@@ -3,44 +3,13 @@ import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import { Font, pdf } from '@react-pdf/renderer';
import { pdf } from '@react-pdf/renderer';
import { saveAs } from 'file-saver';
const registerInterFonts = (() => {
let registrationPromise: Promise<void> | null = null;
return () => {
if (!registrationPromise) {
registrationPromise = Promise.resolve().then(() => {
Font.register({
family: 'Inter',
fonts: [
{
src: 'https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf',
fontWeight: 400,
},
{
src: 'https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuI6fMZg.ttf',
fontWeight: 500,
},
{
src: 'https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf',
fontWeight: 600,
},
],
});
});
}
return registrationPromise;
};
})();
export const exportBlockNoteEditorToPdf = async (
parsedBody: PartialBlock[],
filename: string,
) => {
await registerInterFonts();
const editor = BlockNoteEditor.create({
initialContent: parsedBody,
});
@@ -29,9 +29,6 @@ const pageHtml = ({
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} — Twenty</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #fafafa;
@@ -55,7 +52,7 @@ const pageHtml = ({
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg-body);
display: flex;
align-items: center;
@@ -1,10 +1,3 @@
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.7/iframeResizer.contentWindow.min.js"></script>
<style type="text/css">
body {
margin: 0;
@@ -1,3 +1,7 @@
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import { type Preview } from '@storybook/react-vite';
import '@ui/theme-constants/theme-light.css';
import '@ui/theme-constants/theme-dark.css';
+1
View File
@@ -30,6 +30,7 @@
],
"devDependencies": {
"@argos-ci/storybook": "^6.0.6",
"@fontsource/inter": "^5.2.8",
"@prettier/sync": "^0.5.2",
"@size-limit/preset-small-lib": "^12.1.0",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
@@ -1,5 +1,4 @@
import { css } from '@linaria/core';
import { Aleo, Azeret_Mono, Host_Grotesk, VT323 } from 'next/font/google';
import localFont from 'next/font/local';
import { type ReactNode } from 'react';
@@ -14,30 +13,36 @@ import { resolveLocaleParam } from '@/platform/i18n/resolve-locale-param';
import { WEBSITE_LOCALE_LIST } from '@/platform/i18n/website-locale-list';
import { color, fontFamily, tokenCssVariables } from '@/tokens';
const hostGrotesk = Host_Grotesk({
subsets: ['latin'],
weight: ['300', '400', '500', '600'],
// Host Grotesk and Azeret Mono are variable fonts, declared over their full
// wght axis so the browser interpolates every weight the site asks for.
const hostGrotesk = localFont({
src: '../../fonts/host-grotesk-latin-variable.woff2',
weight: '300 800',
style: 'normal',
variable: '--font-sans',
display: 'swap',
});
const aleo = Aleo({
subsets: ['latin'],
weight: ['300'],
const aleo = localFont({
src: '../../fonts/aleo-latin-300.woff2',
weight: '300',
style: 'normal',
variable: '--font-serif',
display: 'swap',
});
const azeretMono = Azeret_Mono({
subsets: ['latin'],
weight: ['300', '500'],
const azeretMono = localFont({
src: '../../fonts/azeret-mono-latin-variable.woff2',
weight: '100 900',
style: 'normal',
variable: '--font-mono',
display: 'swap',
});
const vt323 = VT323({
subsets: ['latin'],
const vt323 = localFont({
src: '../../fonts/vt323-latin-400.woff2',
weight: '400',
style: 'normal',
variable: '--font-retro',
display: 'swap',
});
@@ -0,0 +1,17 @@
# Fonts
Self-hosted so that neither a build nor a page view ever reaches a third-party
font host. Bound to CSS variables through `next/font/local` in
`src/app/[locale]/layout.tsx`.
Every file is the `latin` subset only, matching what the site renders, and every
family is licensed under the SIL Open Font License 1.1.
- `host-grotesk-latin-variable.woff2`: Host Grotesk, variable `wght` 300-800, https://github.com/Element-Type/HostGrotesk
- `azeret-mono-latin-variable.woff2`: Azeret Mono, variable `wght` 100-900, https://github.com/displaay/azeret
- `aleo-latin-300.woff2`: Aleo Light, https://github.com/AlessioLaiso/aleo
- `vt323-latin-400.woff2`: VT323, https://github.com/phoikoi/VT323
- `inter-latin-{400,500,600}.woff2`: Inter (classic v12), https://github.com/rsms/inter
To refresh a file, take it from the family's upstream release rather than from a
font CDN.
+1
View File
@@ -50026,6 +50026,7 @@ __metadata:
dependencies:
"@argos-ci/storybook": "npm:^6.0.6"
"@base-ui/react": "npm:^1.5.0"
"@fontsource/inter": "npm:^5.2.8"
"@monaco-editor/react": "npm:^4.7.0"
"@prettier/sync": "npm:^0.5.2"
"@radix-ui/colors": "npm:^3.0.0"