build: combine vite config into a single file (@fehmer) (#7190)

- **build: replace dotenv with vite env variables (@fehmer)**
- **build: combine vite config into a single file (@fehmer)**

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-12-05 19:45:12 +01:00 committed by GitHub
parent b746ef844e
commit 8cce5bfc7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 280 additions and 268 deletions

2
.github/labeler.yml vendored
View file

@ -18,4 +18,4 @@ packages:
- any: ["packages/**/*"]
local dev:
- any: ["**/turbo.json", "**/tsconfig.json", "**/knip.json", "**/.prettierrc", "**/.oxlintrc.json", "**/.eslintrc.cjs", "**/vite.config.dev.js"]
- any: ["**/turbo.json", "**/tsconfig.json", "**/knip.json", "**/.prettierrc", "**/.oxlintrc.json", "**/.eslintrc.cjs"]

View file

@ -58,7 +58,6 @@
"@vitest/coverage-v8": "4.0.8",
"autoprefixer": "10.4.20",
"concurrently": "8.2.2",
"dotenv": "16.4.5",
"eslint": "8.57.1",
"eslint-plugin-compat": "6.0.2",
"firebase-tools": "13.15.1",

View file

@ -15,6 +15,11 @@
"virtual:env-config": ["./src/ts/types/virtual-env-config.d.ts"]
}
},
"include": ["./src/**/*.ts", "./scripts/**/*.ts", "vite-plugins/**/*.ts"],
"include": [
"./src/**/*.ts",
"./scripts/**/*.ts",
"vite-plugins/**/*.ts",
"vite.config.ts"
],
"exclude": ["node_modules", "build", "setup-tests.ts", "**/*.spec.ts"]
}

View file

@ -1,40 +1,14 @@
import { Plugin } from "vite";
import { EnvConfig } from "virtual:env-config";
import { config as dotenvConfig } from "dotenv";
const envFile =
process.env["NODE_ENV"] === "production" ? ".env.production" : ".env";
dotenvConfig({ path: envFile });
const virtualModuleId = "virtual:env-config";
const resolvedVirtualModuleId = "\0" + virtualModuleId;
const developmentConfig: EnvConfig = {
isDevelopment: true,
backendUrl: fallbackEnv("BACKEND_URL", "http://localhost:5005"),
clientVersion: "DEVELOPMENT_CLIENT",
recaptchaSiteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
quickLoginEmail: process.env["QUICK_LOGIN_EMAIL"],
quickLoginPassword: process.env["QUICK_LOGIN_PASSWORD"],
};
const productionConfig: Omit<EnvConfig, "clientVersion"> = {
isDevelopment: false,
backendUrl: fallbackEnv("BACKEND_URL", "https://api.monkeytype.com"),
recaptchaSiteKey: process.env["RECAPTCHA_SITE_KEY"] ?? "",
quickLoginEmail: undefined,
quickLoginPassword: undefined,
};
export function envConfig(
options:
| {
isDevelopment: true;
}
| {
isDevelopment: false;
clientVersion: string;
},
): Plugin {
export function envConfig(options: {
isDevelopment: boolean;
clientVersion: string;
env: Record<string, string>;
}): Plugin {
return {
name: "virtual-env-config",
resolveId(id) {
@ -43,13 +17,31 @@ export function envConfig(
},
load(id) {
if (id === resolvedVirtualModuleId) {
const envConfig = options.isDevelopment
? developmentConfig
: {
...productionConfig,
clientVersion: options.clientVersion,
};
const devConfig: EnvConfig = {
isDevelopment: true,
backendUrl: fallback(
options.env["BACKEND_URL"],
"http://localhost:5005",
),
clientVersion: options.clientVersion,
recaptchaSiteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
quickLoginEmail: options.env["QUICK_LOGIN_EMAIL"],
quickLoginPassword: options.env["QUICK_LOGIN_PASSWORD"],
};
const prodConfig: EnvConfig = {
isDevelopment: false,
backendUrl: fallback(
options.env["BACKEND_URL"],
"https://api.monkeytype.com",
),
recaptchaSiteKey: options.env["RECAPTCHA_SITE_KEY"] ?? "",
quickLoginEmail: undefined,
quickLoginPassword: undefined,
clientVersion: options.clientVersion,
};
const envConfig = options.isDevelopment ? devConfig : prodConfig;
return `
export const envConfig = ${JSON.stringify(envConfig)};
`;
@ -59,8 +51,7 @@ export function envConfig(
};
}
function fallbackEnv(envVariable: string, fallback: string): string {
const value = process.env[envVariable];
function fallback(value: string | undefined | null, fallback: string): string {
if (value === null || value === undefined || value === "") return fallback;
return value;
}

View file

@ -0,0 +1,29 @@
import { Plugin } from "vite";
import MagicString from "magic-string";
export function jqueryInject(): Plugin {
return {
name: "simple-jquery-inject",
async transform(src: string, id: string) {
if (id.endsWith(".ts")) {
//check if file has a jQuery or $() call
if (/(?:jQuery|\$)\([^)]*\)/.test(src)) {
const s = new MagicString(src);
//if file has "use strict"; at the top, add it below that line, if not, add it at the very top
if (src.startsWith(`"use strict";`)) {
s.appendRight(12, `\nimport $ from "jquery";`);
} else {
s.prepend(`import $ from "jquery";`);
}
return {
code: s.toString(),
map: s.generateMap({ hires: true, source: id }),
};
}
}
return;
},
};
}

View file

@ -16,7 +16,7 @@ export function languageHashes(options?: { skip: boolean }): Plugin {
load(id) {
if (id === resolvedVirtualModuleId) {
if (options?.skip) {
console.log("Skipping language hashing in dev environment.");
console.log("Skipping language hashing.");
}
const hashes: Record<string, string> = options?.skip ? {} : getHashes();

View file

@ -1,42 +0,0 @@
import { checker } from "vite-plugin-checker";
import Inspect from "vite-plugin-inspect";
import path from "node:path";
import { getFontsConig } from "./vite.config";
import { envConfig } from "./vite-plugins/env-config";
import { languageHashes } from "./vite-plugins/language-hashes";
/** @type {import("vite").UserConfig} */
export default {
plugins: [
envConfig({ isDevelopment: true }),
languageHashes({ skip: true }),
checker({
typescript: {
tsconfigPath: path.resolve(__dirname, "./tsconfig.json"),
},
oxlint: true,
eslint: {
lintCommand: `eslint "${path.resolve(__dirname, "./src/ts/**/*.ts")}"`,
watchPath: path.resolve(__dirname, "./src/"),
},
overlay: {
initialIsOpen: false,
},
}),
Inspect(),
],
css: {
preprocessorOptions: {
scss: {
additionalData: `
$fontAwesomeOverride:"@fortawesome/fontawesome-free/webfonts";
$previewFontsPath:"webfonts";
$fonts: (${getFontsConig()});
`,
},
},
},
build: {
outDir: "../dist",
},
};

View file

@ -1,108 +0,0 @@
import { defineConfig, mergeConfig } from "vite";
import injectHTML from "vite-plugin-html-inject";
import autoprefixer from "autoprefixer";
import { config as dotenvConfig } from "dotenv";
import PROD_CONFIG from "./vite.config.prod";
import DEV_CONFIG from "./vite.config.dev";
import MagicString from "magic-string";
import { Fonts } from "./src/ts/constants/fonts";
// Load environment variables based on NODE_ENV
const envFile =
process.env.NODE_ENV === "production" ? ".env.production" : ".env";
dotenvConfig({ path: envFile });
/** @type {import("vite").UserConfig} */
const BASE_CONFIG = {
plugins: [
{
name: "simple-jquery-inject",
async transform(src, id) {
if (id.endsWith(".ts")) {
//check if file has a jQuery or $() call
if (/(?:jQuery|\$)\([^)]*\)/.test(src)) {
const s = new MagicString(src);
//if file has "use strict"; at the top, add it below that line, if not, add it at the very top
if (src.startsWith(`"use strict";`)) {
s.appendRight(12, `\nimport $ from "jquery";`);
} else {
s.prepend(`import $ from "jquery";`);
}
return {
code: s.toString(),
map: s.generateMap({ hires: true, source: id }),
};
}
}
},
},
injectHTML(),
],
server: {
open: process.env.SERVER_OPEN !== "false",
port: 3000,
host: process.env.BACKEND_URL !== undefined,
watch: {
//we rebuild the whole contracts package when a file changes
//so we only want to watch one file
ignored: [/.*\/packages\/contracts\/dist\/(?!configs).*/],
},
},
clearScreen: false,
root: "src",
publicDir: "../static",
css: {
devSourcemap: true,
postcss: {
plugins: [autoprefixer({})],
},
},
envDir: "../",
optimizeDeps: {
include: ["jquery"],
exclude: ["@fortawesome/fontawesome-free"],
},
};
export default defineConfig(({ command }) => {
if (command === "build") {
const envFileName =
process.env.NODE_ENV === "production" ? ".env.production" : ".env";
if (process.env.RECAPTCHA_SITE_KEY === undefined) {
throw new Error(`${envFileName}: RECAPTCHA_SITE_KEY is not defined`);
}
if (process.env.SENTRY && process.env.SENTRY_AUTH_TOKEN === undefined) {
throw new Error(`${envFileName}: SENTRY_AUTH_TOKEN is not defined`);
}
return mergeConfig(BASE_CONFIG, PROD_CONFIG);
} else {
return mergeConfig(BASE_CONFIG, DEV_CONFIG);
}
});
/** Enable for font awesome v6 */
/*
function sassList(values) {
return values.map((it) => `"${it}"`).join(",");
}
*/
export function getFontsConig() {
return (
"\n" +
Object.keys(Fonts)
.sort()
.map((name) => {
const config = Fonts[name];
if (config.systemFont === true) return "";
return `"${name.replaceAll("_", " ")}": (
"src": "${config.fileName}",
"weight": ${config.weight ?? 400},
),`;
})
.join("\n") +
"\n"
);
}

View file

@ -1,70 +1,112 @@
import { VitePWA } from "vite-plugin-pwa";
import replace from "vite-plugin-filter-replace";
import {
defineConfig,
loadEnv,
UserConfig,
BuildEnvironmentOptions,
PluginOption,
Plugin,
CSSOptions,
} from "vite";
import path from "node:path";
import injectHTML from "vite-plugin-html-inject";
import childProcess from "child_process";
import { checker } from "vite-plugin-checker";
// eslint-disable-next-line import/no-unresolved
import UnpluginInjectPreload from "unplugin-inject-preload/vite";
import { ViteMinifyPlugin } from "vite-plugin-minify";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { getFontsConig } from "./vite.config";
import autoprefixer from "autoprefixer";
import { Fonts } from "./src/ts/constants/fonts";
import { fontawesomeSubset } from "./vite-plugins/fontawesome-subset";
import { fontPreview } from "./vite-plugins/font-preview";
import { envConfig } from "./vite-plugins/env-config";
import { languageHashes } from "./vite-plugins/language-hashes";
import { minifyJson } from "./vite-plugins/minify-json";
import { versionFile } from "./vite-plugins/version-file";
import { jqueryInject } from "./vite-plugins/jquery-inject";
import { checker } from "vite-plugin-checker";
import Inspect from "vite-plugin-inspect";
import { ViteMinifyPlugin } from "vite-plugin-minify";
import { VitePWA } from "vite-plugin-pwa";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import replace from "vite-plugin-filter-replace";
// eslint-disable-next-line import/no-unresolved
import UnpluginInjectPreload from "unplugin-inject-preload/vite";
import { KnownFontName } from "@monkeytype/schemas/fonts";
function pad(numbers, maxLength, fillString) {
return numbers.map((number) =>
number.toString().padStart(maxLength, fillString),
);
}
export default defineConfig(({ mode }): UserConfig => {
const env = loadEnv(mode, process.cwd(), "");
const useSentry = env["SENTRY"] !== undefined;
const isDevelopment = mode !== "production";
const CLIENT_VERSION = (() => {
const date = new Date();
const versionPrefix = pad(
[date.getFullYear(), date.getMonth() + 1, date.getDate()],
2,
"0",
).join(".");
const versionSuffix = pad([date.getHours(), date.getMinutes()], 2, "0").join(
".",
);
const version = [versionPrefix, versionSuffix].join("_");
try {
const commitHash = childProcess
.execSync("git rev-parse --short HEAD")
.toString();
return `${version}_${commitHash}`.replace(/\n/g, "");
} catch (e) {
return `${version}_unknown-hash`;
if (!isDevelopment) {
if (env["RECAPTCHA_SITE_KEY"] === undefined) {
throw new Error(`${mode}: RECAPTCHA_SITE_KEY is not defined`);
}
if (useSentry && env["SENTRY_AUTH_TOKEN"] === undefined) {
throw new Error(`${mode}: SENTRY_AUTH_TOKEN is not defined`);
}
}
})();
/** Enable for font awesome v6 */
/*
function sassList(values) {
return values.map((it) => `"${it}"`).join(",");
}
*/
return {
plugins: getPlugins({ isDevelopment, useSentry: useSentry, env }),
build: getBuildOptions({ enableSourceMaps: useSentry }),
css: getCssOptions({ isDevelopment }),
server: {
open: env["SERVER_OPEN"] !== "false",
port: 3000,
host: env["BACKEND_URL"] !== undefined,
watch: {
//we rebuild the whole contracts package when a file changes
//so we only want to watch one file
ignored: [/.*\/packages\/contracts\/dist\/(?!configs).*/],
},
},
clearScreen: false,
root: "src",
publicDir: "../static",
optimizeDeps: {
include: ["jquery"],
exclude: ["@fortawesome/fontawesome-free"],
},
};
});
/** @type {import("vite").UserConfig} */
export default {
plugins: [
envConfig({ isDevelopment: false, clientVersion: CLIENT_VERSION }),
languageHashes(),
fontawesomeSubset(),
versionFile({ clientVersion: CLIENT_VERSION }),
fontPreview(),
function getPlugins({
isDevelopment,
env,
useSentry,
}: {
isDevelopment: boolean;
env: Record<string, string>;
useSentry: boolean;
}): PluginOption[] {
const clientVersion = getClientVersion(isDevelopment);
const plugins: PluginOption[] = [
envConfig({ isDevelopment, clientVersion, env }),
languageHashes({ skip: isDevelopment }),
checker({
typescript: {
tsconfigPath: path.resolve(__dirname, "./tsconfig.json"),
},
oxlint: isDevelopment,
eslint: isDevelopment
? {
lintCommand: `eslint "${path.resolve(__dirname, "./src/ts/**/*.ts")}"`,
watchPath: path.resolve(__dirname, "./src/"),
}
: false,
overlay: {
initialIsOpen: false,
},
}),
ViteMinifyPlugin({}),
jqueryInject(),
injectHTML(),
];
const devPlugins: PluginOption[] = [Inspect()];
const prodPlugins: PluginOption[] = [
fontPreview(),
fontawesomeSubset(),
versionFile({ clientVersion }),
ViteMinifyPlugin(),
VitePWA({
// injectRegister: "networkfirst",
injectRegister: null,
@ -118,16 +160,16 @@ export default {
],
},
}),
process.env.SENTRY
? sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
useSentry
? (sentryVitePlugin({
authToken: env["SENTRY_AUTH_TOKEN"],
org: "monkeytype",
project: "frontend",
release: {
name: CLIENT_VERSION,
name: clientVersion,
},
applicationKey: "monkeytype-frontend",
})
}) as Plugin)
: null,
replace([
{
@ -169,9 +211,20 @@ export default {
injectTo: "head-prepend",
}),
minifyJson(),
],
build: {
sourcemap: process.env.SENTRY,
];
return [...plugins, ...(isDevelopment ? devPlugins : prodPlugins)].filter(
(it) => it !== null,
);
}
function getBuildOptions({
enableSourceMaps,
}: {
enableSourceMaps: boolean;
}): BuildEnvironmentOptions {
return {
sourcemap: enableSourceMaps,
emptyOutDir: true,
outDir: "../dist",
assetsInlineLimit: 0, //dont inline small files as data
@ -185,8 +238,8 @@ export default {
404: path.resolve(__dirname, "src/404.html"),
},
output: {
assetFileNames: (assetInfo) => {
let extType = assetInfo.name.split(".").at(1);
assetFileNames: (assetInfo: { name: string }) => {
let extType = assetInfo.name.split(".").at(1) as string;
if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) {
extType = "images";
}
@ -213,26 +266,50 @@ export default {
if (id.includes("node_modules")) {
return "vendor";
}
return;
},
},
},
},
css: {
} as BuildEnvironmentOptions;
}
function getCssOptions({
isDevelopment,
}: {
isDevelopment: boolean;
}): CSSOptions {
return {
devSourcemap: true,
postcss: {
plugins: [
// @ts-expect-error this is fine
autoprefixer({}),
],
},
preprocessorOptions: {
scss: {
additionalData(source, fp) {
if (fp.endsWith("index.scss")) {
/** Enable for font awesome v6 */
/*
const fontawesomeClasses = getFontawesomeConfig();
const fontawesomeClasses = getFontawesomeConfig();
//inject variables into sass context
$fontawesomeBrands: ${sassList(
fontawesomeClasses.brands
)};
$fontawesomeSolid: ${sassList(fontawesomeClasses.solid)};
*/
const fonts = `$fonts: (${getFontsConig()});`;
//inject variables into sass context
$fontawesomeBrands: ${sassList(
fontawesomeClasses.brands
)};
$fontawesomeSolid: ${sassList(fontawesomeClasses.solid)};
*/
const bypassFonts = isDevelopment
? `
$fontAwesomeOverride:"@fortawesome/fontawesome-free/webfonts";
$previewFontsPath:"webfonts";`
: "";
const fonts = `
${bypassFonts}
$fonts: (${getFontsConfig()});
`;
return `
//inject variables into sass context
${fonts}
@ -244,5 +321,66 @@ export default {
},
},
},
},
};
};
}
function getFontsConfig(): string {
return (
"\n" +
Object.keys(Fonts)
.sort()
.map((name: string) => {
const config = Fonts[name as KnownFontName];
if (config.systemFont === true) return "";
return `"${name.replaceAll("_", " ")}": (
"src": "${config.fileName}",
"weight": ${config.weight ?? 400},
),`;
})
.join("\n") +
"\n"
);
}
function pad(
numbers: number[],
maxLength: number,
fillString: string,
): string[] {
return numbers.map((number) =>
number.toString().padStart(maxLength, fillString),
);
}
/** Enable for font awesome v6 */
/*
function sassList(values) {
return values.map((it) => `"${it}"`).join(",");
}
*/
function getClientVersion(isDevelopment: boolean): string {
if (isDevelopment) {
return "DEVELOPMENT_CLIENT";
}
const date = new Date();
const versionPrefix = pad(
[date.getFullYear(), date.getMonth() + 1, date.getDate()],
2,
"0",
).join(".");
const versionSuffix = pad([date.getHours(), date.getMinutes()], 2, "0").join(
".",
);
const version = [versionPrefix, versionSuffix].join("_");
try {
const commitHash = childProcess
.execSync("git rev-parse --short HEAD")
.toString();
return `${version}_${commitHash}`.replace(/\n/g, "");
} catch (e) {
return `${version}_unknown-hash`;
}
}

View file

@ -20,5 +20,8 @@ export default defineConfig({
},
},
plugins: [languageHashes({ skip: true }), envConfig({ isDevelopment: true })],
plugins: [
languageHashes({ skip: true }),
envConfig({ isDevelopment: true, clientVersion: "TESTING", env: {} }),
],
});

3
pnpm-lock.yaml generated
View file

@ -409,9 +409,6 @@ importers:
concurrently:
specifier: 8.2.2
version: 8.2.2
dotenv:
specifier: 16.4.5
version: 16.4.5
eslint:
specifier: 8.57.1
version: 8.57.1