Files
gerbeur/vite.config.ts
khannurien 89014639cb v4: settings are stored on the server, starting with the style, the color scheme and the digest switches — every setting lived in the browser's localStorage, so it didn't follow an account to another browser and the server couldn't read it, which the digests need (whether to send each, and in which style). user_settings (migration 0019) holds one row per user who changed anything, the defaults otherwise, and GET/PATCH /api/users/me/settings read and change it (settings-service, model/settings, validators/settings, shared/settings). AuthProvider reads the settings into the session alongside the user, on mount and on focus, so a change made elsewhere arrives; useSettings changes them at once and puts them back if the server refuses. ThemeProvider now sits inside AuthProvider: a signed-in user's choice comes from their settings and is mirrored to localStorage for index.html's first-paint script, a visitor's stays in the browser, and an account with no choice yet takes the one this browser had, once. Until anyone picks a style, the instance's GERBEUR_DEFAULT_STYLE applies, written into index.html's default-style tag along with email-enabled (src/config/instance.ts), and a browser no longer stores the default as if it had been chosen, so changing the instance's style reaches everyone who never picked one. The settings tab gets an Email section with the weekly digest and monthly recap switches, disabled with a note when the server has no SMTP. The style and scheme names moved to shared/theme.ts in the previous commit; the default feed tab and sounds stay in the browser for now. 223 backend tests (new: settings defaults, patches, isolation, refusals and cascade; index.html's new tags) and 658 frontend tests (new: useSettings, the account's theme winning and a browser's carried over, settings read into the session, the digest switches) pass, and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:20:57 +00:00

145 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { isStyleName } from "./shared/theme.ts";
const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚";
const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" ? "native" : "embed";
const DEFAULT_STYLE = isStyleName(process.env.GERBEUR_DEFAULT_STYLE?.trim()) ? process.env.GERBEUR_DEFAULT_STYLE!.trim() : "smooth";
const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off";
// Cache-busting token for the favicon URLs (see index.html). Changes when the
// emoji changes, so browsers don't keep serving a stale cached icon. Mirrors
// Twemoji's filename convention (kept trivial here to avoid the frontend build
// depending on backend code; the canonical version lives in api/lib/site-icons.ts).
function emojiToCodepoint(emoji: string): string {
const cleaned = emoji.includes("‍") ? emoji : emoji.replace(/️/g, "");
return [...cleaned].map((ch) => ch.codePointAt(0)!.toString(16)).join("-");
}
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: {
ignored: ["**/api/**"],
},
},
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 (/node_modules\/(react|react-dom|scheduler|react-router)\//.test(id)) {
return "vendor";
}
},
},
},
},
plugins: [
{
// In dev the API server isn't serving index.html, so replace the placeholders here.
// In production the Oak server does the replacement at runtime (see api/lib/static.ts).
name: "inject-site-name",
transformIndexHtml: {
order: "pre",
handler: (html, ctx) =>
ctx.server
? html
.replaceAll("__SITE_NAME__", SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
.replaceAll("__EMAIL_ENABLED__", EMAIL_ENABLED)
: html,
},
},
emojibase(),
precompress(),
lingui(),
react({
plugins: [["@lingui/swc-plugin", {}]],
}),
],
});