This commit is contained in:
57
frontend/vite-plugins/env-config.ts
Normal file
57
frontend/vite-plugins/env-config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Plugin } from "vite";
|
||||
import { EnvConfig } from "virtual:env-config";
|
||||
|
||||
const virtualModuleId = "virtual:env-config";
|
||||
const resolvedVirtualModuleId = "\0" + virtualModuleId;
|
||||
|
||||
function fallback(value: string | undefined | null, fallback: string): string {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function envConfig(options: {
|
||||
isDevelopment: boolean;
|
||||
clientVersion: string;
|
||||
env: Record<string, string>;
|
||||
}): Plugin {
|
||||
return {
|
||||
name: "virtual-env-config",
|
||||
resolveId(id) {
|
||||
if (id === virtualModuleId) return resolvedVirtualModuleId;
|
||||
return;
|
||||
},
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
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)};
|
||||
`;
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
78
frontend/vite-plugins/font-preview.ts
Normal file
78
frontend/vite-plugins/font-preview.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Plugin } from "vite";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import subsetFont from "subset-font";
|
||||
import { Fonts } from "../src/ts/constants/fonts";
|
||||
import { KnownFontName } from "@monkeytype/schemas/fonts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function generateSubset(
|
||||
source: string,
|
||||
target: string,
|
||||
includedCharacters: string,
|
||||
): Promise<void> {
|
||||
const font = fs.readFileSync(source);
|
||||
const subset = await subsetFont(font, includedCharacters, {
|
||||
targetFormat: "woff2",
|
||||
});
|
||||
fs.writeFileSync(target, subset);
|
||||
}
|
||||
|
||||
async function generatePreviewFonts(debug: boolean = false): Promise<void> {
|
||||
const srcDir = __dirname + "/../static/webfonts";
|
||||
const targetDir = __dirname + "/../static/webfonts-preview";
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
for (const name of Object.keys(Fonts)) {
|
||||
const font = Fonts[name as KnownFontName];
|
||||
if (font.systemFont) continue;
|
||||
|
||||
const includedCharacters =
|
||||
(font.display ?? name.replaceAll("_", " ")) + "Fontfamily";
|
||||
|
||||
const fileName = font.fileName;
|
||||
|
||||
await generateSubset(
|
||||
srcDir + "/" + fileName,
|
||||
targetDir + "/" + fileName,
|
||||
includedCharacters,
|
||||
);
|
||||
if (debug) {
|
||||
console.log(
|
||||
`Processing ${name} with file ${fileName} to display "${includedCharacters}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a preview file from each font in `static/webfonts` into `static/webfonts-preview`.
|
||||
* A preview file only contains the characters needed to show the preview.
|
||||
* @returns
|
||||
*/
|
||||
export function fontPreview(): Plugin {
|
||||
return {
|
||||
name: "vite-plugin-webfonts-preview",
|
||||
apply: "build",
|
||||
async buildStart() {
|
||||
const start = performance.now();
|
||||
console.log("\nCreating webfonts preview...");
|
||||
|
||||
await generatePreviewFonts();
|
||||
|
||||
const end = performance.now();
|
||||
console.log(
|
||||
`Creating webfonts preview took ${Math.round(end - start)} ms`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//detect if we run this as a main
|
||||
if (import.meta.url.endsWith(process.argv[1] as string)) {
|
||||
void generatePreviewFonts(true);
|
||||
}
|
||||
207
frontend/vite-plugins/fontawesome-subset.ts
Normal file
207
frontend/vite-plugins/fontawesome-subset.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Plugin } from "vite";
|
||||
import * as fs from "fs";
|
||||
import { createRequire } from "module";
|
||||
import * as path from "path";
|
||||
import { fontawesomeSubset as createFontawesomeSubset } from "fontawesome-subset";
|
||||
|
||||
function parseIcons(iconSet: string): string[] {
|
||||
const require = createRequire(import.meta.url);
|
||||
const path = require.resolve(
|
||||
`@fortawesome/fontawesome-free/js/${iconSet}.js`,
|
||||
);
|
||||
const file: string | null = fs.readFileSync(path).toString();
|
||||
|
||||
return file
|
||||
?.match(/"(.*)": \[.*\],/g)
|
||||
?.map((it) => it.substring(1, it.indexOf(":") - 1)) as string[];
|
||||
}
|
||||
|
||||
type FontawesomeConfig = {
|
||||
/* used regular icons without `fa-` prefix*/
|
||||
regular: string[];
|
||||
/* used solid icons without `fa-` prefix*/
|
||||
solid: string[];
|
||||
/* used brands icons without `fa-` prefix*/
|
||||
brands: string[];
|
||||
};
|
||||
|
||||
type FileObject = { name: string; isDirectory: boolean };
|
||||
|
||||
const iconSet = {
|
||||
solid: parseIcons("solid"),
|
||||
regular: parseIcons("regular"),
|
||||
brands: parseIcons("brands"),
|
||||
};
|
||||
/**
|
||||
* Map containing reserved classes by module
|
||||
*/
|
||||
const modules2 = {
|
||||
animated: ["spin", "pulse"],
|
||||
"bordererd-pulled": ["border", "pull-left", "pull-right"],
|
||||
"fixed-width": ["fw"],
|
||||
larger: [
|
||||
"lg",
|
||||
"xs",
|
||||
"sm",
|
||||
"1x",
|
||||
"2x",
|
||||
"3x",
|
||||
"4x",
|
||||
"5x",
|
||||
"6x",
|
||||
"7x",
|
||||
"8x",
|
||||
"9x",
|
||||
"10x",
|
||||
],
|
||||
"rotated-flipped": [
|
||||
"rotate-90",
|
||||
"rotate-180",
|
||||
"rotate-270",
|
||||
"flip-horizontal",
|
||||
"flip-vertical",
|
||||
"flip-both",
|
||||
],
|
||||
stacked: ["stack", "stack-1x", "stack-2x", "inverse"],
|
||||
};
|
||||
|
||||
function toFileAndDir(dir: string, file: string): FileObject {
|
||||
const name = path.join(dir, file);
|
||||
return { name, isDirectory: fs.statSync(name).isDirectory() };
|
||||
}
|
||||
|
||||
function findAllFiles(
|
||||
dir: string,
|
||||
filter: (filename: string) => boolean = (_it): boolean => true,
|
||||
): string[] {
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.map((it) => toFileAndDir(dir, it))
|
||||
.filter((file) => file.isDirectory || filter(file.name));
|
||||
|
||||
const out: string[] = [];
|
||||
for (const file of files) {
|
||||
if (file.isDirectory) {
|
||||
out.push(...findAllFiles(file.name, filter));
|
||||
} else {
|
||||
out.push(file.name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect used fontawesome icons in the directories `src/**` and `static/**{.html|.css}`
|
||||
* @param {boolean} debug - Enable debug output
|
||||
* @returns {FontawesomeConfig} - used icons
|
||||
*/
|
||||
function getFontawesomeConfig(debug = false): FontawesomeConfig {
|
||||
const time = Date.now();
|
||||
const srcFiles = findAllFiles(
|
||||
"./src",
|
||||
(filename) =>
|
||||
!filename.endsWith("fontawesome-5.scss") &&
|
||||
!filename.endsWith("fontawesome-6.scss"), //ignore our own css
|
||||
);
|
||||
const staticFiles = findAllFiles(
|
||||
"./static",
|
||||
(filename) => filename.endsWith(".html") || filename.endsWith(".css"),
|
||||
);
|
||||
|
||||
const allFiles = [...srcFiles, ...staticFiles];
|
||||
const usedClassesSet: Set<string> = new Set();
|
||||
|
||||
const regex = /\bfa-[a-z0-9-]+\b/g;
|
||||
|
||||
for (const file of allFiles) {
|
||||
const fileContent = fs.readFileSync("./" + file).toString();
|
||||
const matches = fileContent.match(regex);
|
||||
|
||||
if (matches) {
|
||||
matches.forEach((match) => {
|
||||
const [icon] = match.split(" ");
|
||||
usedClassesSet.add((icon as string).substring(3));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const usedClasses = [...usedClassesSet].sort();
|
||||
const allModuleClasses = new Set(Object.values(modules2).flatMap((it) => it));
|
||||
const icons = usedClasses.filter((it) => !allModuleClasses.has(it));
|
||||
|
||||
const solid = icons.filter((it) => iconSet.solid.includes(it));
|
||||
const regular = icons.filter((it) => iconSet.regular.includes(it));
|
||||
const brands = usedClasses.filter((it) => iconSet.brands.includes(it));
|
||||
|
||||
const leftOvers = icons.filter(
|
||||
(it) =>
|
||||
!(solid.includes(it) || regular.includes(it) || brands.includes(it)),
|
||||
);
|
||||
if (leftOvers.length !== 0) {
|
||||
throw new Error(
|
||||
"Fontawesome failed with unknown icons: " + leftOvers.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.debug(
|
||||
"Make sure fontawesome modules are active: ",
|
||||
Object.entries(modules2)
|
||||
.filter((it) => usedClasses.filter((c) => it[1].includes(c)).length > 0)
|
||||
.map((it) => it[0])
|
||||
.filter((it) => it !== "brands")
|
||||
.join(", "),
|
||||
);
|
||||
|
||||
console.debug(
|
||||
"Here is your config: \n",
|
||||
JSON.stringify({
|
||||
regular,
|
||||
solid,
|
||||
brands,
|
||||
}),
|
||||
);
|
||||
console.debug("Detected fontawesome classes in", Date.now() - time, "ms");
|
||||
}
|
||||
|
||||
return {
|
||||
regular,
|
||||
solid,
|
||||
brands,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect fontawesome icons used by the application and creates subset font files only containing the used icons.
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export function fontawesomeSubset(): Plugin {
|
||||
return {
|
||||
name: "vite-plugin-fontawesome-subset",
|
||||
apply: "build",
|
||||
async buildStart() {
|
||||
const start = performance.now();
|
||||
console.log("\nCreating fontawesome subset...");
|
||||
|
||||
const fontawesomeClasses = getFontawesomeConfig();
|
||||
await createFontawesomeSubset(
|
||||
fontawesomeClasses,
|
||||
"src/webfonts-generated",
|
||||
{
|
||||
targetFormats: ["woff2"],
|
||||
},
|
||||
);
|
||||
|
||||
const end = performance.now();
|
||||
console.log(
|
||||
`Creating fontawesome subset took ${Math.round(end - start)} ms`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//detect if we run this as a main
|
||||
if (import.meta.url.endsWith(process.argv[1] as string)) {
|
||||
getFontawesomeConfig(true);
|
||||
}
|
||||
48
frontend/vite-plugins/inject-preload.ts
Normal file
48
frontend/vite-plugins/inject-preload.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { IndexHtmlTransformContext, Plugin } from "vite";
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import UnpluginInjectPreload from "unplugin-inject-preload/vite";
|
||||
import { basename } from "node:path";
|
||||
export function injectPreload(): Plugin {
|
||||
const base = UnpluginInjectPreload({
|
||||
files: [
|
||||
{
|
||||
outputMatch: /css\/.*\.css$/,
|
||||
attributes: {
|
||||
as: "style",
|
||||
type: "text/css",
|
||||
rel: "preload",
|
||||
crossorigin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
outputMatch: /.*\.woff2$/,
|
||||
attributes: {
|
||||
as: "font",
|
||||
type: "font/woff2",
|
||||
rel: "preload",
|
||||
crossorigin: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
injectTo: "head-prepend",
|
||||
}) as {
|
||||
name: string;
|
||||
vite: {
|
||||
transformIndexHtml: {
|
||||
handler: (html: string, ctx: IndexHtmlTransformContext) => string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
name: base.name,
|
||||
...base.vite,
|
||||
transformIndexHtml(html, ctx) {
|
||||
//only add preload to the index.html file
|
||||
if (basename(ctx.filename) !== "index.html") {
|
||||
return html;
|
||||
}
|
||||
return base.vite.transformIndexHtml.handler(html, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
63
frontend/vite-plugins/language-hashes.ts
Normal file
63
frontend/vite-plugins/language-hashes.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Plugin } from "vite";
|
||||
import { readdirSync, readFileSync } from "fs";
|
||||
import { TextEncoder } from "util";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
const virtualModuleId = "virtual:language-hashes";
|
||||
const resolvedVirtualModuleId = "\0" + virtualModuleId;
|
||||
|
||||
function calcHash(file: string): string {
|
||||
const currentLanguage = JSON.stringify(
|
||||
JSON.parse(readFileSync("./static/languages/" + file).toString()),
|
||||
null,
|
||||
0,
|
||||
);
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(currentLanguage);
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
function getHashes(): Record<string, string> {
|
||||
const start = performance.now();
|
||||
|
||||
console.log("\nHashing languages...");
|
||||
|
||||
const hashes = Object.fromEntries(
|
||||
readdirSync("./static/languages").map((file) => {
|
||||
return [file.slice(0, -5), calcHash(file)];
|
||||
}),
|
||||
);
|
||||
|
||||
const end = performance.now();
|
||||
|
||||
console.log(`Creating language hashes took ${Math.round(end - start)} ms`);
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
export function languageHashes(options?: { skip: boolean }): Plugin {
|
||||
return {
|
||||
name: "virtual-language-hashes",
|
||||
resolveId(id) {
|
||||
if (id === virtualModuleId) return resolvedVirtualModuleId;
|
||||
return;
|
||||
},
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
if (options?.skip) {
|
||||
console.log("Skipping language hashing.");
|
||||
}
|
||||
|
||||
const hashes: Record<string, string> = options?.skip ? {} : getHashes();
|
||||
return `
|
||||
export const languageHashes = ${JSON.stringify(hashes)};
|
||||
`;
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url.endsWith(process.argv[1] as string)) {
|
||||
console.log(JSON.stringify(getHashes(), null, 4));
|
||||
}
|
||||
76
frontend/vite-plugins/minify-json.ts
Normal file
76
frontend/vite-plugins/minify-json.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Plugin } from "vite";
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Minifies all json files in the `dist` directory
|
||||
* @returns
|
||||
*/
|
||||
export function minifyJson(): Plugin {
|
||||
return {
|
||||
name: "minify-json",
|
||||
apply: "build",
|
||||
generateBundle() {
|
||||
let totalOriginalSize = 0;
|
||||
let totalMinifiedSize = 0;
|
||||
|
||||
const minifyJsonFiles = (dir: string): void => {
|
||||
readdirSync(dir).forEach((file) => {
|
||||
const sourcePath = path.join(dir, file);
|
||||
const stat = statSync(sourcePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
minifyJsonFiles(sourcePath);
|
||||
} else if (path.extname(file) === ".json") {
|
||||
const originalContent = readFileSync(sourcePath, "utf8");
|
||||
const originalSize = Buffer.byteLength(originalContent, "utf8");
|
||||
const minifiedContent = JSON.stringify(JSON.parse(originalContent));
|
||||
const minifiedSize = Buffer.byteLength(minifiedContent, "utf8");
|
||||
|
||||
totalOriginalSize += originalSize;
|
||||
totalMinifiedSize += minifiedSize;
|
||||
|
||||
writeFileSync(sourcePath, minifiedContent);
|
||||
|
||||
/*
|
||||
const savings =
|
||||
((originalSize - minifiedSize) / originalSize) * 100;
|
||||
console.log(
|
||||
`\x1b[0m \x1b[36m${sourcePath}\x1b[0m | ` +
|
||||
`\x1b[90mOriginal: ${originalSize} bytes\x1b[0m | ` +
|
||||
`\x1b[90mMinified: ${minifiedSize} bytes\x1b[0m | ` +
|
||||
`\x1b[32mSavings: ${savings.toFixed(2)}%\x1b[0m`
|
||||
);
|
||||
*/
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// console.log("\n\x1b[1mMinifying JSON files...\x1b[0m\n");
|
||||
const start = performance.now();
|
||||
|
||||
minifyJsonFiles("./dist");
|
||||
|
||||
const end = performance.now();
|
||||
const totalSavings =
|
||||
((totalOriginalSize - totalMinifiedSize) / totalOriginalSize) * 100;
|
||||
|
||||
console.log(
|
||||
`\n\n\x1b[1mJSON Minification Summary:\x1b[0m\n` +
|
||||
` \x1b[90mTotal original size: ${(
|
||||
totalOriginalSize /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2)} mB\x1b[0m\n` +
|
||||
` \x1b[90mTotal minified size: ${(
|
||||
totalMinifiedSize /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2)} mB\x1b[0m\n` +
|
||||
` \x1b[32mTotal savings: ${totalSavings.toFixed(
|
||||
2,
|
||||
)}%\x1b[0m took ${Math.round(end - start)} ms\n`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
314
frontend/vite-plugins/oxlint-checker.ts
Normal file
314
frontend/vite-plugins/oxlint-checker.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { Plugin, ViteDevServer, normalizePath } from "vite";
|
||||
import { spawn, execSync, ChildProcess } from "child_process";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export type OxlintCheckerOptions = {
|
||||
/** Debounce delay in milliseconds before running lint after file changes. @default 125 */
|
||||
debounceDelay?: number;
|
||||
/** Run type-aware checks (slower but more thorough). @default true */
|
||||
typeAware?: boolean;
|
||||
/** Show browser overlay with lint status. @default true */
|
||||
overlay?: boolean;
|
||||
/** File extensions to watch for changes. @default ['.ts', '.tsx', '.js', '.jsx'] */
|
||||
extensions?: string[];
|
||||
};
|
||||
|
||||
type LintResult = {
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
running: boolean;
|
||||
hadIssues: boolean;
|
||||
typeAware?: boolean;
|
||||
};
|
||||
|
||||
const OXLINT_SUMMARY_REGEX = /Found (\d+) warnings? and (\d+) errors?/;
|
||||
|
||||
export function oxlintChecker(options: OxlintCheckerOptions = {}): Plugin {
|
||||
const {
|
||||
debounceDelay = 125,
|
||||
typeAware = true,
|
||||
overlay = true,
|
||||
extensions = [".ts", ".tsx", ".js", ".jsx"],
|
||||
} = options;
|
||||
|
||||
let currentProcess: ChildProcess | null = null;
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
let server: ViteDevServer | null = null;
|
||||
let isProduction = false;
|
||||
let currentRunId = 0;
|
||||
let lastLintResult: LintResult = {
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
running: false,
|
||||
hadIssues: false,
|
||||
};
|
||||
|
||||
const killCurrentProcess = (): boolean => {
|
||||
if ((currentProcess && !currentProcess.killed) || currentProcess !== null) {
|
||||
currentProcess.kill();
|
||||
currentProcess = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clearDebounceTimer = (): void => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseLintOutput = (
|
||||
output: string,
|
||||
): Pick<LintResult, "errorCount" | "warningCount"> => {
|
||||
const summaryMatch = OXLINT_SUMMARY_REGEX.exec(output);
|
||||
if (summaryMatch?.[1] !== undefined && summaryMatch?.[2] !== undefined) {
|
||||
return {
|
||||
warningCount: parseInt(summaryMatch[1], 10),
|
||||
errorCount: parseInt(summaryMatch[2], 10),
|
||||
};
|
||||
}
|
||||
return { errorCount: 0, warningCount: 0 };
|
||||
};
|
||||
|
||||
const sendLintResult = (result: Partial<LintResult>): void => {
|
||||
const previousHadIssues = lastLintResult.hadIssues;
|
||||
|
||||
const payload: LintResult = {
|
||||
errorCount: result.errorCount ?? lastLintResult.errorCount,
|
||||
warningCount: result.warningCount ?? lastLintResult.warningCount,
|
||||
running: result.running ?? false,
|
||||
hadIssues: previousHadIssues,
|
||||
typeAware: result.typeAware,
|
||||
};
|
||||
|
||||
// Only update hadIssues when we have actual lint results (not just running status)
|
||||
if (result.running === false) {
|
||||
const currentHasIssues =
|
||||
(result.errorCount ?? 0) > 0 || (result.warningCount ?? 0) > 0;
|
||||
lastLintResult = { ...payload, hadIssues: currentHasIssues };
|
||||
} else {
|
||||
// Keep hadIssues unchanged when just updating running status
|
||||
lastLintResult = { ...payload, hadIssues: previousHadIssues };
|
||||
}
|
||||
|
||||
if (server) {
|
||||
server.ws.send("vite-plugin-oxlint", payload);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs an oxlint process with the given arguments and captures its combined output.
|
||||
*
|
||||
* This function is responsible for managing the lifecycle of the current lint process:
|
||||
* - It spawns a new child process via `npx oxlint . ...args`.
|
||||
* - It assigns the spawned process to the shared {@link currentProcess} variable so that
|
||||
* other parts of the plugin can cancel or track the active lint run.
|
||||
* - On process termination (either "error" or "close"), it clears {@link currentProcess}
|
||||
* if it still refers to this child, avoiding interference with any newer process that
|
||||
* may have started in the meantime.
|
||||
*
|
||||
* @param args Additional command-line arguments to pass to `oxlint`.
|
||||
* @returns A promise that resolves with the process exit code (or `null` if
|
||||
* the process exited due to a signal) and the full stdout/stderr output
|
||||
* produced by the lint run.
|
||||
*/
|
||||
const runLintProcess = async (
|
||||
args: string[],
|
||||
): Promise<{ code: number | null; output: string }> => {
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn("npx", ["oxlint", ".", ...args], {
|
||||
cwd: process.cwd(),
|
||||
shell: true,
|
||||
env: { ...process.env, FORCE_COLOR: "3" },
|
||||
});
|
||||
|
||||
currentProcess = childProcess;
|
||||
let output = "";
|
||||
|
||||
childProcess.stdout?.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on("error", (error: Error) => {
|
||||
output += `\nError: ${error.message}`;
|
||||
if (currentProcess === childProcess) {
|
||||
currentProcess = null;
|
||||
}
|
||||
resolve({ code: 1, output });
|
||||
});
|
||||
|
||||
childProcess.on("close", (code: number | null) => {
|
||||
if (currentProcess === childProcess) {
|
||||
currentProcess = null;
|
||||
}
|
||||
resolve({ code, output });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const runOxlint = async (): Promise<void> => {
|
||||
const wasKilled = killCurrentProcess();
|
||||
const runId = ++currentRunId;
|
||||
|
||||
console.log(
|
||||
wasKilled
|
||||
? "\x1b[36mRunning oxlint...\x1b[0m \x1b[90m(killed previous process)\x1b[0m"
|
||||
: "\x1b[36mRunning oxlint...\x1b[0m",
|
||||
);
|
||||
|
||||
sendLintResult({ running: true });
|
||||
|
||||
// First pass: fast oxlint without type checking
|
||||
const { code, output } = await runLintProcess([]);
|
||||
|
||||
// Check if we were superseded by a newer run
|
||||
if (runId !== currentRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (output) {
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
// If first pass had errors, send them immediately (fast-fail)
|
||||
if (code !== 0) {
|
||||
const counts = parseLintOutput(output);
|
||||
if (counts.errorCount > 0 || counts.warningCount > 0) {
|
||||
sendLintResult({ ...counts, running: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Run type-aware check if enabled
|
||||
if (!typeAware) {
|
||||
sendLintResult({ errorCount: 0, warningCount: 0, running: false });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\x1b[36mRunning type-aware checks...\x1b[0m");
|
||||
sendLintResult({ running: true, typeAware: true });
|
||||
const typeResult = await runLintProcess(["--type-check", "--type-aware"]);
|
||||
|
||||
// Check if we were superseded by a newer run
|
||||
if (runId !== currentRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeResult.output) {
|
||||
console.log(typeResult.output);
|
||||
}
|
||||
|
||||
const counts =
|
||||
typeResult.code !== 0
|
||||
? parseLintOutput(typeResult.output)
|
||||
: { errorCount: 0, warningCount: 0 };
|
||||
sendLintResult({ ...counts, running: false });
|
||||
};
|
||||
|
||||
const debouncedLint = (): void => {
|
||||
clearDebounceTimer();
|
||||
sendLintResult({ running: true });
|
||||
debounceTimer = setTimeout(() => void runOxlint(), debounceDelay);
|
||||
};
|
||||
|
||||
return {
|
||||
name: "vite-plugin-oxlint-checker",
|
||||
|
||||
config(_, { mode }) {
|
||||
isProduction = mode === "production";
|
||||
},
|
||||
|
||||
configureServer(devServer: ViteDevServer) {
|
||||
server = devServer;
|
||||
|
||||
// Send current lint status to new clients on connection
|
||||
devServer.ws.on("connection", () => {
|
||||
devServer.ws.send("vite-plugin-oxlint", lastLintResult);
|
||||
});
|
||||
|
||||
// Run initial lint
|
||||
void runOxlint();
|
||||
|
||||
// Listen for file changes
|
||||
devServer.watcher.on("change", (file: string) => {
|
||||
// Only lint on relevant file changes
|
||||
if (extensions.some((ext) => file.endsWith(ext))) {
|
||||
debouncedLint();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
transformIndexHtml() {
|
||||
if (isProduction || !overlay) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Inject import to the overlay module (actual .ts file processed by Vite)
|
||||
const overlayPath = normalizePath(
|
||||
fileURLToPath(new URL("./oxlint-overlay.ts", import.meta.url)),
|
||||
);
|
||||
return [
|
||||
{
|
||||
tag: "script",
|
||||
attrs: {
|
||||
type: "module",
|
||||
src: `/@fs${overlayPath}`,
|
||||
},
|
||||
injectTo: "body-prepend",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
buildStart() {
|
||||
// Only run during production builds, not dev server startup
|
||||
if (!isProduction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run oxlint synchronously during build
|
||||
console.log("\n\x1b[1mRunning oxlint...\x1b[0m");
|
||||
|
||||
try {
|
||||
const commands = ["npx oxlint ."];
|
||||
if (typeAware) {
|
||||
commands.push("npx oxlint . --type-aware --type-check");
|
||||
}
|
||||
|
||||
const output = execSync(commands.join(" && "), {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, FORCE_COLOR: "3" },
|
||||
});
|
||||
|
||||
if (output) {
|
||||
console.log(output);
|
||||
}
|
||||
console.log(` \x1b[32m✓ No linting issues found\x1b[0m\n`);
|
||||
} catch (error) {
|
||||
// execSync throws on non-zero exit code (linting errors found)
|
||||
if (error instanceof Error && "stdout" in error) {
|
||||
const execError = error as Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
if (execError.stdout !== undefined) console.log(execError.stdout);
|
||||
if (execError.stderr !== undefined) console.error(execError.stderr);
|
||||
}
|
||||
console.error("\n\x1b[31mBuild aborted due to linting errors\x1b[0m\n");
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
|
||||
closeBundle() {
|
||||
// Cleanup on server close
|
||||
killCurrentProcess();
|
||||
clearDebounceTimer();
|
||||
},
|
||||
};
|
||||
}
|
||||
126
frontend/vite-plugins/oxlint-overlay.ts
Normal file
126
frontend/vite-plugins/oxlint-overlay.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// Oxlint overlay client-side code
|
||||
let overlay: HTMLDivElement | null = null;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function createOverlay(): HTMLDivElement {
|
||||
if (overlay) return overlay;
|
||||
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "oxlint-error-overlay";
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: #323437;
|
||||
color: #e4dec8ff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
`;
|
||||
|
||||
overlay.addEventListener("mouseenter", () => {
|
||||
if (overlay) overlay.style.opacity = "0.5";
|
||||
});
|
||||
|
||||
overlay.addEventListener("mouseleave", () => {
|
||||
if (overlay) overlay.style.opacity = "1";
|
||||
});
|
||||
|
||||
overlay.addEventListener("click", () => {
|
||||
if (overlay) overlay.style.display = "none";
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function updateOverlay(data: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
running?: boolean;
|
||||
hadIssues?: boolean;
|
||||
typeAware?: boolean;
|
||||
}): void {
|
||||
const overlayEl = createOverlay();
|
||||
|
||||
// Clear any pending hide timeout
|
||||
if (hideTimeout !== null) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
|
||||
// Show running icon if linting is running and there were issues before
|
||||
if (data.running) {
|
||||
if (data.hadIssues) {
|
||||
const message = data.typeAware ? "checking type aware..." : "checking...";
|
||||
overlayEl.innerHTML = `
|
||||
<span style="font-size: 18px;">⏳</span>
|
||||
<span>oxlint: ${message}</span>
|
||||
`;
|
||||
overlayEl.style.display = "flex";
|
||||
overlayEl.style.color = "#e4dec8ff";
|
||||
} else {
|
||||
overlayEl.style.display = "none";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { errorCount = 0, warningCount = 0, hadIssues = false } = data;
|
||||
const total = errorCount + warningCount;
|
||||
|
||||
if (total > 0) {
|
||||
overlayEl.innerHTML = `
|
||||
<span style="font-size: 18px;">🚨</span>
|
||||
<span>oxlint: ${errorCount} error${errorCount !== 1 ? "s" : ""}, ${warningCount} warning${warningCount !== 1 ? "s" : ""}</span>
|
||||
`;
|
||||
overlayEl.style.display = "flex";
|
||||
|
||||
if (errorCount > 0) {
|
||||
overlayEl.style.color = "#e4dec8ff";
|
||||
}
|
||||
} else {
|
||||
// Only show success if the previous lint had issues
|
||||
if (hadIssues) {
|
||||
overlayEl.innerHTML = `
|
||||
<span style="font-size: 18px;">✅</span>
|
||||
<span>oxlint: ok</span>
|
||||
`;
|
||||
overlayEl.style.display = "flex";
|
||||
overlayEl.style.color = "#e4dec8ff";
|
||||
|
||||
// Hide after 3 seconds
|
||||
hideTimeout = setTimeout(() => {
|
||||
overlayEl.style.display = "none";
|
||||
hideTimeout = null;
|
||||
}, 3000);
|
||||
} else {
|
||||
// Two good lints in a row - don't show anything
|
||||
overlayEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize overlay on load
|
||||
createOverlay();
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on(
|
||||
"vite-plugin-oxlint",
|
||||
(data: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
running?: boolean;
|
||||
hadIssues?: boolean;
|
||||
}) => {
|
||||
updateOverlay(data);
|
||||
},
|
||||
);
|
||||
}
|
||||
20
frontend/vite-plugins/version-file.ts
Normal file
20
frontend/vite-plugins/version-file.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Plugin } from "vite";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
export function versionFile(options: { clientVersion: string }): Plugin {
|
||||
return {
|
||||
name: "generate-version-json",
|
||||
apply: "build",
|
||||
|
||||
closeBundle() {
|
||||
const distPath = path.resolve("./dist");
|
||||
if (!existsSync(distPath)) {
|
||||
mkdirSync(distPath, { recursive: true });
|
||||
}
|
||||
|
||||
const versionJson = JSON.stringify({ version: options.clientVersion });
|
||||
const versionPath = path.resolve(distPath, "version.json");
|
||||
writeFileSync(versionPath, versionJson);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user