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 { BASE_STYLE, 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() : BASE_STYLE; const EMAIL_ENABLED = process.env.GERBEUR_SMTPS_URL?.trim() ? "on" : "off"; const FEDERATION_INCOMING = process.env.GERBEUR_FEDERATION_INCOMING?.trim() === "0" ? "off" : "on"; const FEDERATION_OUTGOING = process.env.GERBEUR_FEDERATION_OUTGOING?.trim() === "0" ? "off" : "on"; // 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); }, }; } // Each dev instance (dev/instances.ts) runs a Vite server of its own; sharing // one dependency cache, two of them would rewrite it under each other. const INSTANCE = process.env.GERBEUR_INSTANCE; export default defineConfig({ cacheDir: INSTANCE ? `node_modules/.vite/${INSTANCE}` : undefined, define: { "import.meta.env.VITE_EMOJIBASE_URL": JSON.stringify(EMOJIBASE_URL), }, server: { port: 3000, watch: { ignored: ["**/api/**"], }, // Another instance may know this one by its pages' address, and reads its // metadata there (resolveIssuer in api/remote/gerbeur-instance.ts), which // the API serves; in production, whatever serves the pages passes it on. proxy: { "/.well-known": `${process.env.VITE_API_PROTOCOL || "http"}://${ process.env.VITE_API_HOSTNAME || "localhost" }:${process.env.VITE_API_PORT || "8000"}`, }, }, 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) .replaceAll("__FEDERATION_INCOMING__", FEDERATION_INCOMING) .replaceAll("__FEDERATION_OUTGOING__", FEDERATION_OUTGOING) : html, }, }, emojibase(), precompress(), lingui(), react({ plugins: [["@lingui/swc-plugin", {}]], }), ], });