v3: the frontend ships compressed, caches for good, and stops reaching for other people's servers — a review of the bundle's size found the server was the biggest cost. Oak's send() compressed nothing and sent every hashed chunk with max-age=0, so a first visit to the home page moved about 710 KB of JS and CSS where 200 KB would do, and every later one revalidated some thirty files. The build now writes .br and .gz next to every JS, CSS, JSON and SVG file (a precompress plugin, brotli 11), which send() already serves when the browser accepts them; /assets and /emojibase are cached for a year as immutable, index.html is no-cache, and static responses carry Vary: Accept-Encoding. A missing file under either prefix is a 404 rather than index.html with a 200, which an open tab after a deploy used to try to run as a script; the app now reloads on vite:preloadError, at most once a minute. manualChunks sent everything in node_modules to one vendor chunk, so react-hook-form and frimousse loaded with every page though only the lazy form and editor chunks use them, and its markdown rule missed hast, vfile, unified and property-information while every feed loads Markdown through DumpCard anyway; only react, react-dom, scheduler and react-router get a chunk of their own now, the rest is left to the bundler. Up-front JS goes from 133 to 96 KB gzip, the home page from 178 to 160. Fonts: index.html blocked rendering on a Google Fonts stylesheet, opening two more hosts and handing each visitor's IP to Google; they come from @fontsource now, with src/fonts.css declaring only the latin and latin-ext faces (the packages' own CSS declares every subset, which added 6 KB gzip to the stylesheet), so Vite hashes the woff2 files into /assets and a browser still only downloads its theme's font. The families are the variable builds ("Saira Variable" and so on); Fraunces keeps its opsz, SOFT and WONK axes. Emoji: frimousse fetched emojibase-data@latest from jsDelivr, unpinned and third-party; emojibase-data 17.0.0 is a dependency now, its data and messages for each lingui locale are emitted under /emojibase/17.0.0 (served from node_modules in dev), and the picker is pointed there.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 45s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 11:44:08 +00:00
parent bf28e49c34
commit 16b510764c
12 changed files with 330 additions and 14 deletions

View File

@@ -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<Record<string, object>>, 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();

36
deno.lock generated
View File

@@ -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",

View File

@@ -8,9 +8,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png?v=__ICON_VERSION__" />
<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=Fraunces:ital,opsz,wght,SOFT,WONK@0,9..144,300..900,0..100,0..1;1,9..144,300..900,0..100,0..1&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Saira:ital,wght@0,100..900;1,100..900&family=Space+Grotesk:wght@400;700;900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#01152a" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf5e9" />

View File

@@ -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",

View File

@@ -628,6 +628,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
<EmojiPicker.Root
onEmojiSelect={(e) => selectEmoji(e.emoji)}
locale={i18n.locale as EmojiLocale}
emojibaseUrl={import.meta.env.VITE_EMOJIBASE_URL}
skinTone={readSkinTone()}
>
<SkinTonePersist />

159
src/fonts.css Normal file
View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -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);

View File

@@ -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. */

View File

@@ -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);

View File

@@ -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", {}]],