From 16b510764cdb7e3e384f31827fcab662b89d94d4 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 11:44:08 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20frontend=20ships=20compressed,=20ca?= =?UTF-8?q?ches=20for=20good,=20and=20stops=20reaching=20for=20other=20peo?= =?UTF-8?q?ple's=20servers=20=E2=80=94=20a=20review=20of=20the=20bundle's?= =?UTF-8?q?=20size=20found=20the=20server=20was=20the=20biggest=20cost.=20?= =?UTF-8?q?Oak's=20send()=20compressed=20nothing=20and=20sent=20every=20ha?= =?UTF-8?q?shed=20chunk=20with=20max-age=3D0,=20so=20a=20first=20visit=20t?= =?UTF-8?q?o=20the=20home=20page=20moved=20about=20710=20KB=20of=20JS=20an?= =?UTF-8?q?d=20CSS=20where=20200=20KB=20would=20do,=20and=20every=20later?= =?UTF-8?q?=20one=20revalidated=20some=20thirty=20files.=20The=20build=20n?= =?UTF-8?q?ow=20writes=20.br=20and=20.gz=20next=20to=20every=20JS,=20CSS,?= =?UTF-8?q?=20JSON=20and=20SVG=20file=20(a=20precompress=20plugin,=20brotl?= =?UTF-8?q?i=2011),=20which=20send()=20already=20serves=20when=20the=20bro?= =?UTF-8?q?wser=20accepts=20them;=20/assets=20and=20/emojibase=20are=20cac?= =?UTF-8?q?hed=20for=20a=20year=20as=20immutable,=20index.html=20is=20no-c?= =?UTF-8?q?ache,=20and=20static=20responses=20carry=20Vary:=20Accept-Encod?= =?UTF-8?q?ing.=20A=20missing=20file=20under=20either=20prefix=20is=20a=20?= =?UTF-8?q?404=20rather=20than=20index.html=20with=20a=20200,=20which=20an?= =?UTF-8?q?=20open=20tab=20after=20a=20deploy=20used=20to=20try=20to=20run?= =?UTF-8?q?=20as=20a=20script;=20the=20app=20now=20reloads=20on=20vite:pre?= =?UTF-8?q?loadError,=20at=20most=20once=20a=20minute.=20manualChunks=20se?= =?UTF-8?q?nt=20everything=20in=20node=5Fmodules=20to=20one=20vendor=20chu?= =?UTF-8?q?nk,=20so=20react-hook-form=20and=20frimousse=20loaded=20with=20?= =?UTF-8?q?every=20page=20though=20only=20the=20lazy=20form=20and=20editor?= =?UTF-8?q?=20chunks=20use=20them,=20and=20its=20markdown=20rule=20missed?= =?UTF-8?q?=20hast,=20vfile,=20unified=20and=20property-information=20whil?= =?UTF-8?q?e=20every=20feed=20loads=20Markdown=20through=20DumpCard=20anyw?= =?UTF-8?q?ay;=20only=20react,=20react-dom,=20scheduler=20and=20react-rout?= =?UTF-8?q?er=20get=20a=20chunk=20of=20their=20own=20now,=20the=20rest=20i?= =?UTF-8?q?s=20left=20to=20the=20bundler.=20Up-front=20JS=20goes=20from=20?= =?UTF-8?q?133=20to=2096=20KB=20gzip,=20the=20home=20page=20from=20178=20t?= =?UTF-8?q?o=20160.=20Fonts:=20index.html=20blocked=20rendering=20on=20a?= =?UTF-8?q?=20Google=20Fonts=20stylesheet,=20opening=20two=20more=20hosts?= =?UTF-8?q?=20and=20handing=20each=20visitor's=20IP=20to=20Google;=20they?= =?UTF-8?q?=20come=20from=20@fontsource=20now,=20with=20src/fonts.css=20de?= =?UTF-8?q?claring=20only=20the=20latin=20and=20latin-ext=20faces=20(the?= =?UTF-8?q?=20packages'=20own=20CSS=20declares=20every=20subset,=20which?= =?UTF-8?q?=20added=206=20KB=20gzip=20to=20the=20stylesheet),=20so=20Vite?= =?UTF-8?q?=20hashes=20the=20woff2=20files=20into=20/assets=20and=20a=20br?= =?UTF-8?q?owser=20still=20only=20downloads=20its=20theme's=20font.=20The?= =?UTF-8?q?=20families=20are=20the=20variable=20builds=20("Saira=20Variabl?= =?UTF-8?q?e"=20and=20so=20on);=20Fraunces=20keeps=20its=20opsz,=20SOFT=20?= =?UTF-8?q?and=20WONK=20axes.=20Emoji:=20frimousse=20fetched=20emojibase-d?= =?UTF-8?q?ata@latest=20from=20jsDelivr,=20unpinned=20and=20third-party;?= =?UTF-8?q?=20emojibase-data=2017.0.0=20is=20a=20dependency=20now,=20its?= =?UTF-8?q?=20data=20and=20messages=20for=20each=20lingui=20locale=20are?= =?UTF-8?q?=20emitted=20under=20/emojibase/17.0.0=20(served=20from=20node?= =?UTF-8?q?=5Fmodules=20in=20dev),=20and=20the=20picker=20is=20pointed=20t?= =?UTF-8?q?here.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/lib/static.ts | 24 ++++- deno.lock | 36 ++++++++ index.html | 3 - package.json | 6 ++ src/components/TextEditor.tsx | 1 + src/fonts.css | 159 ++++++++++++++++++++++++++++++++++ src/main.tsx | 18 ++++ src/themes/brutalist.css | 2 +- src/themes/kanagawa.css | 2 +- src/themes/melange.css | 4 +- src/themes/smooth.css | 2 +- vite.config.ts | 87 +++++++++++++++++-- 12 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 src/fonts.css diff --git a/api/lib/static.ts b/api/lib/static.ts index 5f63eba7..5158f697 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -16,9 +16,16 @@ async function serveIndexHtml( .replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); context.response.type = "text/html"; + // Always revalidated: it names the hashed chunks of the current build. + context.response.headers.set("Cache-Control", "no-cache"); context.response.body = html; } +// Build output whose URL changes whenever its content does: Vite's hashed +// chunks, and the emoji data under its package version (see vite.config.ts). +const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"]; +const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; + export function routeStaticFilesFrom(staticPaths: string[]) { return async (context: Context>, next: Next) => { const pathname = context.request.url.pathname; @@ -29,15 +36,30 @@ export function routeStaticFilesFrom(staticPaths: string[]) { return; } + const immutable = IMMUTABLE_PREFIXES.some((prefix) => + pathname.startsWith(prefix) + ); for (const path of staticPaths) { try { - await send(context, pathname, { root: path }); + // send() picks a prebuilt .br or .gz sibling when the browser accepts it. + await send(context, pathname, { + root: path, + ...(immutable && { maxage: ONE_YEAR_MS, immutable: true }), + }); + context.response.headers.append("Vary", "Accept-Encoding"); return; } catch { continue; } } + // A chunk from an older build (an open tab after a deploy) must fail as a + // 404, not load index.html as a script; the app reloads on that failure. + if (immutable) { + context.response.status = 404; + return; + } + // SPA fallback: serve index.html so client-side routes work on direct navigation await serveIndexHtml(context, staticPaths[0]); await next(); diff --git a/deno.lock b/deno.lock index ef93f8e7..d2de21a6 100644 --- a/deno.lock +++ b/deno.lock @@ -21,6 +21,11 @@ "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@tajpouria/cors@^1.2.1": "1.2.1", "npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", + "npm:@fontsource-variable/fraunces@5.3.0": "5.3.0", + "npm:@fontsource-variable/jetbrains-mono@5.3.0": "5.3.0", + "npm:@fontsource-variable/saira@5.3.0": "5.3.0", + "npm:@fontsource-variable/space-grotesk@5.3.0": "5.3.0", + "npm:@fontsource/instrument-serif@5.3.0": "5.3.0", "npm:@lingui/cli@*": "6.4.0", "npm:@lingui/cli@6.4.0": "6.4.0", "npm:@lingui/conf@6.4.0": "6.4.0", @@ -35,6 +40,7 @@ "npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17", "npm:@types/react@^19.2.17": "19.2.17", "npm:@vitejs/plugin-react-swc@^4.3.1": "4.3.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0", + "npm:emojibase-data@17.0.0": "17.0.0_emojibase@17.0.0", "npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", "npm:eslint-plugin-react-refresh@~0.5.3": "0.5.3_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", "npm:eslint@*": "10.6.0_jiti@2.7.0", @@ -469,6 +475,21 @@ "levn" ] }, + "@fontsource-variable/fraunces@5.3.0": { + "integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==" + }, + "@fontsource-variable/jetbrains-mono@5.3.0": { + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==" + }, + "@fontsource-variable/saira@5.3.0": { + "integrity": "sha512-C1rm7n1OuEx4Se/SAB/5EeUuCviGFFnYTr5vxuQQm2YIOfgbQWx/P6qqhdXlVCBRm/1UzNWCtDK5jNbVmO6W2w==" + }, + "@fontsource-variable/space-grotesk@5.3.0": { + "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==" + }, + "@fontsource/instrument-serif@5.3.0": { + "integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ==" + }, "@humanfs/core@0.19.2": { "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dependencies": [ @@ -1227,6 +1248,15 @@ "emoji-regex@8.0.0": { "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "emojibase-data@17.0.0_emojibase@17.0.0": { + "integrity": "sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==", + "dependencies": [ + "emojibase" + ] + }, + "emojibase@17.0.0": { + "integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==" + }, "esbuild@0.25.12": { "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "optionalDependencies": [ @@ -2662,6 +2692,11 @@ "packageJson": { "dependencies": [ "npm:@eslint/js@^10.0.1", + "npm:@fontsource-variable/fraunces@5.3.0", + "npm:@fontsource-variable/jetbrains-mono@5.3.0", + "npm:@fontsource-variable/saira@5.3.0", + "npm:@fontsource-variable/space-grotesk@5.3.0", + "npm:@fontsource/instrument-serif@5.3.0", "npm:@lingui/cli@6.4.0", "npm:@lingui/conf@6.4.0", "npm:@lingui/core@6.4.0", @@ -2673,6 +2708,7 @@ "npm:@types/react-dom@^19.2.3", "npm:@types/react@^19.2.17", "npm:@vitejs/plugin-react-swc@^4.3.1", + "npm:emojibase-data@17.0.0", "npm:eslint-plugin-react-hooks@^7.1.1", "npm:eslint-plugin-react-refresh@~0.5.3", "npm:eslint@^10.6.0", diff --git a/index.html b/index.html index ef1496bb..8f346c6f 100644 --- a/index.html +++ b/index.html @@ -8,9 +8,6 @@ - - - diff --git a/package.json b/package.json index fd460fe5..9db42a88 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/fraunces": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/saira": "5.3.0", + "@fontsource-variable/space-grotesk": "5.3.0", + "@fontsource/instrument-serif": "5.3.0", "@lingui/cli": "6.4.0", "@lingui/conf": "6.4.0", "@lingui/core": "6.4.0", @@ -18,6 +23,7 @@ "@types/react": "^19.2.17", "@lingui/swc-plugin": "6.5.0", "@vitejs/plugin-react-swc": "^4.3.1", + "emojibase-data": "17.0.0", "frimousse": "^0.3.0", "jiti": "^2.7.0", "react": "^19.2.7", diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index 7a9e6522..ea709a47 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -628,6 +628,7 @@ export const TextEditor = forwardRef( selectEmoji(e.emoji)} locale={i18n.locale as EmojiLocale} + emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL} skinTone={readSkinTone()} > diff --git a/src/fonts.css b/src/fonts.css new file mode 100644 index 00000000..42ad5115 --- /dev/null +++ b/src/fonts.css @@ -0,0 +1,159 @@ +/* Web fonts, self-hosted from @fontsource: the latin and latin-ext faces of + each package's CSS, with their url()s pointed into the package. Every face + is declared for every theme, but a browser only downloads the files for the + faces the active theme renders with, and only the subsets the text needs. + Vite hashes the .woff2 files into /assets, which is cached for good. */ + +/* smooth (and brutalist's fallback) */ +@font-face { + font-family: 'Saira Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'Saira Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/saira/files/saira-latin-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: 'Saira Variable'; + font-style: italic; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-italic.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'Saira Variable'; + font-style: italic; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/saira/files/saira-latin-wght-italic.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* brutalist */ +@font-face { + font-family: 'Space Grotesk Variable'; + font-style: normal; + font-display: swap; + font-weight: 300 700; + src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-ext-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'Space Grotesk Variable'; + font-style: normal; + font-display: swap; + font-weight: 300 700; + src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* nyt */ +@font-face { + font-family: 'Instrument Serif'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'Instrument Serif'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: 'Instrument Serif'; + font-style: italic; + font-display: swap; + font-weight: 400; + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-italic.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'Instrument Serif'; + font-style: italic; + font-display: swap; + font-weight: 400; + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* kanagawa */ +@font-face { + font-family: 'JetBrains Mono Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: 'JetBrains Mono Variable'; + font-style: italic; + font-display: swap; + font-weight: 100 800; + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'JetBrains Mono Variable'; + font-style: italic; + font-display: swap; + font-weight: 100 800; + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* melange: opsz, SOFT and WONK axes as well as wght */ +@font-face { + font-family: 'Fraunces Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'Fraunces Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} +@font-face { + font-family: 'Fraunces Variable'; + font-style: italic; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: 'Fraunces Variable'; + font-style: italic; + font-display: swap; + font-weight: 100 900; + src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} diff --git a/src/main.tsx b/src/main.tsx index fb8404e5..9f79ab2f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { I18nProvider } from "@lingui/react"; import App from "./App.tsx"; import { i18n, loadCatalog } from "./i18n.ts"; +import "./fonts.css"; import "./tokens.css"; import "./index.css"; import "./themes/smooth.css"; @@ -14,6 +15,23 @@ import "./themes/nyt.css"; import "./themes/kanagawa.css"; import "./themes/melange.css"; +// A deploy renames every chunk, so a tab opened before it fails to load the +// lazy ones it hasn't fetched yet. Reload to pick up the new build, but only +// once a minute, so a chunk that's really missing shows its error instead of +// reloading forever. +window.addEventListener("vite:preloadError", (event) => { + const RELOADED_AT = "chunk-reload-at"; + try { + const last = Number(sessionStorage.getItem(RELOADED_AT)) || 0; + if (Date.now() - last < 60_000) return; + sessionStorage.setItem(RELOADED_AT, String(Date.now())); + } catch { + return; + } + event.preventDefault(); + location.reload(); +}); + await loadCatalog(); createRoot(document.getElementById("root")!).render( diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index 519f9137..223acdad 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -36,7 +36,7 @@ /* ── Typography ──────────────────────────────────────────────────── */ [data-style="brutalist"] { - --font-body: "Space Grotesk", "Saira", sans-serif; + --font-body: "Space Grotesk Variable", "Saira Variable", sans-serif; /* Hard edges everywhere App.css uses the radius scale. */ --radius-xs: 0; diff --git a/src/themes/kanagawa.css b/src/themes/kanagawa.css index c6411d55..441b589a 100644 --- a/src/themes/kanagawa.css +++ b/src/themes/kanagawa.css @@ -13,7 +13,7 @@ /* ── Palette (light-dark: see tokens.css) ─────────────────────────── */ :root[data-style="kanagawa"] { --font-mono: - "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; + "JetBrains Mono Variable", ui-monospace, "SFMono-Regular", Menlo, monospace; --font-body: var(--font-mono); --font-display: var(--font-mono); diff --git a/src/themes/melange.css b/src/themes/melange.css index 62e3df15..c6d10c15 100644 --- a/src/themes/melange.css +++ b/src/themes/melange.css @@ -24,8 +24,8 @@ and everywhere: sturdy, low-contrast, warm at every size. Any rule that touches SOFT or WONK repeats opsz, since font-variation-settings replaces the whole list rather than merging into it. */ - --font-body: "Fraunces", Georgia, serif; - --font-display: "Fraunces", Georgia, serif; + --font-body: "Fraunces Variable", Georgia, serif; + --font-display: "Fraunces Variable", Georgia, serif; --mel-opsz: 14; /* Nothing here has a sharp corner. */ diff --git a/src/themes/smooth.css b/src/themes/smooth.css index af653ba2..206f6dd2 100644 --- a/src/themes/smooth.css +++ b/src/themes/smooth.css @@ -11,7 +11,7 @@ :root:not([data-style]), :root[data-style="smooth"] { - --font-body: "Saira", sans-serif; + --font-body: "Saira Variable", sans-serif; /* Text */ --color-text: light-dark(#0e3a42, #e9eef2); diff --git a/vite.config.ts b/vite.config.ts index 5306f340..02e55e9f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,10 @@ -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react-swc"; import { lingui } from "@lingui/vite-plugin"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { brotliCompressSync, constants as zlib, gzipSync } from "node:zlib"; +import linguiConfig from "./lingui.config.ts"; const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur"; const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚"; @@ -16,7 +20,77 @@ function emojiToCodepoint(emoji: string): string { } const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); +// The emoji picker (frimousse) reads emojibase-data from ${url}/${locale}/{data,messages}.json. +// It is served from here rather than a CDN; the version is in the path so the +// files can be cached for good, and bumping the package moves the URL. +const EMOJIBASE_DIR = resolve("node_modules/emojibase-data"); +const EMOJIBASE_VERSION = JSON.parse(readFileSync(join(EMOJIBASE_DIR, "package.json"), "utf8")).version; +const EMOJIBASE_URL = `/emojibase/${EMOJIBASE_VERSION}`; +const EMOJIBASE_FILES = linguiConfig.locales.flatMap((locale) => [`${locale}/data.json`, `${locale}/messages.json`]); + +function emojibase(): Plugin { + return { + name: "emojibase", + configureServer(server) { + server.middlewares.use(EMOJIBASE_URL, (req, res, next) => { + const file = req.url?.split("?")[0].slice(1) ?? ""; + if (!EMOJIBASE_FILES.includes(file)) return next(); + res.setHeader("Content-Type", "application/json"); + res.end(readFileSync(join(EMOJIBASE_DIR, file))); + }); + }, + generateBundle() { + for (const file of EMOJIBASE_FILES) { + this.emitFile({ + type: "asset", + fileName: `${EMOJIBASE_URL.slice(1)}/${file}`, + source: readFileSync(join(EMOJIBASE_DIR, file)), + }); + } + }, + }; +} + +// Writes .br and .gz next to every compressible file in the build, which Oak's +// send() serves in place of the original when the browser accepts them. The +// server itself compresses nothing. index.html is left out: the server fills in +// its placeholders at runtime (see api/lib/static.ts). +const COMPRESSIBLE = /\.(js|css|json|svg|webmanifest)$/; +function precompress(): Plugin { + let outDir = ""; + return { + name: "precompress", + apply: "build", + configResolved(config) { + outDir = resolve(config.root, config.build.outDir); + }, + closeBundle() { + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (COMPRESSIBLE.test(name)) compress(path); + } + }; + const compress = (path: string) => { + const raw = readFileSync(path); + if (raw.length < 1024) return; + const br = brotliCompressSync(raw, { + params: { [zlib.BROTLI_PARAM_QUALITY]: 11, [zlib.BROTLI_PARAM_SIZE_HINT]: raw.length }, + }); + const gz = gzipSync(raw, { level: 9 }); + if (br.length < raw.length * 0.9) writeFileSync(`${path}.br`, br); + if (gz.length < raw.length * 0.9) writeFileSync(`${path}.gz`, gz); + }; + walk(outDir); + }, + }; +} + export default defineConfig({ + define: { + "import.meta.env.VITE_EMOJIBASE_URL": JSON.stringify(EMOJIBASE_URL), + }, server: { port: 3000, watch: { @@ -26,11 +100,12 @@ export default defineConfig({ build: { rollupOptions: { output: { + // Only the framework, which every page needs and which changes least, gets + // a chunk of its own so it stays cached across deploys. Everything else is + // left to the bundler, so libraries only the lazy chunks use (the emoji + // picker, forms) stay out of the first load. manualChunks(id) { - if (id.includes("react-markdown") || id.includes("remark-gfm") || id.includes("remark-parse") || id.includes("mdast") || id.includes("micromark") || id.includes("unist")) { - return "vendor-markdown"; - } - if (id.includes("node_modules")) { + if (/node_modules\/(react|react-dom|scheduler|react-router)\//.test(id)) { return "vendor"; } }, @@ -54,6 +129,8 @@ export default defineConfig({ : html, }, }, + emojibase(), + precompress(), lingui(), react({ plugins: [["@lingui/swc-plugin", {}]],