213 lines
8.2 KiB
TypeScript
213 lines
8.2 KiB
TypeScript
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);
|
||
},
|
||
};
|
||
}
|
||
|
||
// The interface's messages are a chunk of their own per language, which
|
||
// main.tsx imports before rendering anything (src/i18n.ts). Left to that
|
||
// import, the chunk is only asked for once the entry and the framework have
|
||
// loaded and run. This puts a script in the page's head that asks for it at
|
||
// once, beside them, picking the language as `detectLocale` does there: the
|
||
// two must agree, or the wrong language is fetched for nothing.
|
||
function preloadCatalog(): Plugin {
|
||
return {
|
||
name: "preload-catalog",
|
||
apply: "build",
|
||
transformIndexHtml: {
|
||
order: "post",
|
||
handler(_html, ctx) {
|
||
const catalogs: Record<string, string> = {};
|
||
for (const chunk of Object.values(ctx.bundle ?? {})) {
|
||
if (chunk.type !== "chunk") continue;
|
||
const locale = linguiConfig.locales.find((l) =>
|
||
chunk.moduleIds.some((id) => id.split("?")[0].endsWith(`/${l}.po`))
|
||
);
|
||
if (locale) catalogs[locale] = `/${chunk.fileName}`;
|
||
}
|
||
if (Object.keys(catalogs).length === 0) return [];
|
||
return [{
|
||
tag: "script",
|
||
injectTo: "head-prepend",
|
||
children: `(function(){var c=${JSON.stringify(catalogs)},l;` +
|
||
`try{l=localStorage.getItem("locale")}catch(e){}` +
|
||
`if(!c[l])l=navigator.language.indexOf("fr")===0?"fr":"en";` +
|
||
`if(!c[l])return;var k=document.createElement("link");` +
|
||
`k.rel="modulepreload";k.href=c[l];document.head.appendChild(k)})();`,
|
||
}];
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
// 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(),
|
||
preloadCatalog(),
|
||
precompress(),
|
||
lingui(),
|
||
react({
|
||
plugins: [["@lingui/swc-plugin", {}]],
|
||
}),
|
||
],
|
||
});
|