dev/start.ts was checked by hand: musique starting alone on a fresh database, an unknown name refused, a taken port stopping both servers, and Ctrl+C or SIGTERM leaving no server behind. The compose project couldn't be run here (no Docker in the workspace): its YAML parses and resolves as intended, but it is untested; tsc, eslint, deno check, deno fmt, deno lint and doc:lint, which now covers dev/, are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
5.9 KiB
TypeScript
162 lines
5.9 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";
|
||
|
||
// 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/**"],
|
||
},
|
||
},
|
||
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", {}]],
|
||
}),
|
||
],
|
||
});
|