Merge branch 'master' into newtribemerge

This commit is contained in:
Miodec 2025-12-20 11:07:51 +01:00
commit aa75bdf051
65 changed files with 3461 additions and 689 deletions

View file

@ -7,6 +7,7 @@
"endOfLine": "lf",
"trailingComma": "all",
"ignorePatterns": [
"pnpm-lock.yaml",
"node_modules",
".turbo",
"dist",

View file

@ -31,7 +31,14 @@ export async function setup(): Promise<void> {
process.env["REDIS_URI"] = redisUrl;
}
export async function teardown(): Promise<void> {
async function stopContainers(): Promise<void> {
await startedMongoContainer?.stop();
await startedRedisContainer?.stop();
}
export async function teardown(): Promise<void> {
await stopContainers();
}
process.on("SIGTERM", stopContainers);
process.on("SIGINT", stopContainers);

View file

@ -77,17 +77,17 @@
"@types/swagger-stats": "0.95.11",
"@types/ua-parser-js": "0.7.36",
"@types/uuid": "10.0.0",
"@vitest/coverage-v8": "4.0.8",
"@vitest/coverage-v8": "4.0.15",
"concurrently": "8.2.2",
"openapi3-ts": "2.0.2",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"readline-sync": "1.4.10",
"supertest": "7.1.4",
"testcontainers": "11.4.0",
"testcontainers": "11.10.0",
"tsx": "4.16.2",
"typescript": "5.9.3",
"vitest": "4.0.8"
"vitest": "4.0.15"
},
"engines": {
"node": "24.11.0 || 22.21.0"

View file

@ -82,7 +82,7 @@
"@types/seedrandom": "3.0.2",
"@types/subset-font": "1.4.3",
"@types/throttle-debounce": "5.0.2",
"@vitest/coverage-v8": "4.0.8",
"@vitest/coverage-v8": "4.0.15",
"autoprefixer": "10.4.20",
"concurrently": "8.2.2",
"eslint": "9.39.1",
@ -93,8 +93,8 @@
"madge": "8.0.0",
"magic-string": "0.30.17",
"normalize.css": "8.0.1",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"postcss": "8.4.31",
"sass": "1.70.0",
"subset-font": "2.3.0",
@ -103,13 +103,12 @@
"unplugin-inject-preload": "3.0.0",
"vite": "7.1.12",
"vite-bundle-visualizer": "1.2.1",
"vite-plugin-checker": "0.11.0",
"vite-plugin-filter-replace": "0.1.14",
"vite-plugin-html-inject": "1.1.2",
"vite-plugin-inspect": "11.3.3",
"vite-plugin-minify": "2.1.0",
"vite-plugin-pwa": "1.1.0",
"vitest": "4.0.8"
"vitest": "4.0.15"
},
"browserslist": [
"defaults",

View file

@ -196,7 +196,7 @@
<div class="buttons">
<button data-config-value="false">off</button>
<button data-config-value="true">
&ensp;
<!-- On is missing on purpose. -->
</button>
</div>

View file

@ -403,6 +403,11 @@ key {
}
}
.userIcon {
display: grid;
border-radius: 0;
}
.loading {
font-size: 0.8em;
line-height: 0.8em;

View file

@ -2209,8 +2209,7 @@ body.darkMode {
}
}
.notificationHistory .list .item {
grid-template-areas: "indicator title" "indicator body";
grid-template-columns: 0.25rem calc(100% - 0.25rem);
grid-template-areas: "indicator title buttons" "indicator body buttons";
.title {
font-size: 0.75rem;
color: var(--sub-color);
@ -2218,6 +2217,9 @@ body.darkMode {
.body {
opacity: 1;
}
.highlight {
color: var(--main-color) !important;
}
}
.accountAlerts {
.title {

View file

@ -50,6 +50,7 @@
.settingsGroup {
display: grid;
gap: 2rem;
overflow: hidden;
&.quickNav {
justify-content: center;
.links {

View file

@ -45,14 +45,11 @@ async function sendVerificationEmail(): Promise<void> {
Loader.show();
qs(".sendVerificationEmail")?.disable();
const result = await Ape.users.verificationEmail();
const response = await Ape.users.verificationEmail();
qs(".sendVerificationEmail")?.enable();
if (result.status !== 200) {
if (response.status !== 200) {
Loader.hide();
Notifications.add(
"Failed to request verification email: " + result.body.message,
-1,
);
Notifications.add("Failed to request verification email", -1, { response });
} else {
Loader.hide();
Notifications.add("Verification email sent", 1);

View file

@ -188,7 +188,12 @@ export const LanguageGroups: Record<string, Language[]> = {
swahili: ["swahili_1k"],
maori: ["maori_1k"],
catalan: ["catalan", "catalan_1k"],
bulgarian: ["bulgarian", "bulgarian_latin"],
bulgarian: [
"bulgarian",
"bulgarian_1k",
"bulgarian_latin",
"bulgarian_latin_1k",
],
bosnian: ["bosnian", "bosnian_4k"],
esperanto: [
"esperanto",

View file

@ -299,7 +299,7 @@ export async function getUserResults(offset?: number): Promise<boolean> {
const response = await Ape.results.get({ query: { offset } });
if (response.status !== 200) {
Notifications.add("Error getting results: " + response.body.message, -1);
Notifications.add("Error getting results", -1, { response });
return false;
}
@ -357,10 +357,7 @@ export async function addCustomTheme(
const response = await Ape.users.addCustomTheme({ body: { ...theme } });
if (response.status !== 200) {
Notifications.add(
"Error adding custom theme: " + response.body.message,
-1,
);
Notifications.add("Error adding custom theme", -1, { response });
return false;
}
@ -400,10 +397,7 @@ export async function editCustomTheme(
body: { themeId, theme: newTheme },
});
if (response.status !== 200) {
Notifications.add(
"Error editing custom theme: " + response.body.message,
-1,
);
Notifications.add("Error editing custom theme", -1, { response });
return false;
}
@ -427,10 +421,7 @@ export async function deleteCustomTheme(themeId: string): Promise<boolean> {
const response = await Ape.users.deleteCustomTheme({ body: { themeId } });
if (response.status !== 200) {
Notifications.add(
"Error deleting custom theme: " + response.body.message,
-1,
);
Notifications.add("Error deleting custom theme", -1, { response });
return false;
}
@ -923,7 +914,7 @@ export async function saveConfig(config: Partial<Config>): Promise<void> {
if (isAuthenticated()) {
const response = await Ape.configs.save({ body: config });
if (response.status !== 200) {
Notifications.add("Failed to save config: " + response.body.message, -1);
Notifications.add("Failed to save config", -1, { response });
}
}
}
@ -932,7 +923,7 @@ export async function resetConfig(): Promise<void> {
if (isAuthenticated()) {
const response = await Ape.configs.delete();
if (response.status !== 200) {
Notifications.add("Failed to reset config: " + response.body.message, -1);
Notifications.add("Failed to reset config", -1, { response });
}
}
}
@ -1055,10 +1046,7 @@ export async function getTestActivityCalendar(
Loader.show();
const response = await Ape.users.getTestActivity();
if (response.status !== 200) {
Notifications.add(
"Error getting test activities: " + response.body.message,
-1,
);
Notifications.add("Error getting test activities", -1, { response });
Loader.hide();
return undefined;
}

View file

@ -27,7 +27,8 @@ const editApeKey = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to update key: " + response.body.message,
message: "Failed to update key",
notificationOptions: { response },
};
}
return {
@ -53,7 +54,8 @@ const deleteApeKeyModal = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to delete key: " + response.body.message,
message: "Failed to delete key",
notificationOptions: { response },
};
}
@ -128,7 +130,8 @@ const generateApeKey = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to generate key: " + response.body.message,
message: "Failed to generate key",
notificationOptions: { response },
};
}
@ -174,7 +177,7 @@ async function getData(): Promise<boolean> {
void update();
return false;
}
Notifications.add("Error getting ape keys: " + response.body.message, -1);
Notifications.add("Error getting ape keys", -1, { response });
return false;
}
@ -261,7 +264,7 @@ async function toggleActiveKey(keyId: string): Promise<void> {
});
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to update key: " + response.body.message, -1);
Notifications.add("Failed to update key", -1, { response });
return;
}
key.enabled = !key.enabled;

View file

@ -24,10 +24,7 @@ async function getData(): Promise<boolean> {
if (response.status !== 200) {
blockedUsers = [];
Notifications.add(
"Error getting blocked users: " + response.body.message,
-1,
);
Notifications.add("Error getting blocked users", -1, { response });
return false;
}

View file

@ -6,7 +6,12 @@ import * as NotificationEvent from "../observables/notification-event";
import * as BadgeController from "../controllers/badge-controller";
import * as Notifications from "../elements/notifications";
import * as ConnectionState from "../states/connection";
import { escapeHTML } from "../utils/misc";
import {
applyReducedMotion,
createErrorMessage,
escapeHTML,
promiseAnimate,
} from "../utils/misc";
import AnimatedModal from "../utils/animated-modal";
import { updateXp as accountPageUpdateProfile } from "./profile";
import { MonkeyMail } from "@monkeytype/schemas/users";
@ -29,10 +34,17 @@ let mailToMarkRead: string[] = [];
let mailToDelete: string[] = [];
type State = {
notifications: { message: string; level: number; customTitle?: string }[];
notifications: {
id: string;
title: string;
message: string;
level: number;
details?: string | object;
}[];
psas: { message: string; level: number }[];
};
let notificationId = 0;
const state: State = {
notifications: [],
psas: [],
@ -289,28 +301,29 @@ function fillNotifications(): void {
} else {
notificationHistoryListEl.empty();
for (const n of state.notifications) {
const { message, level, customTitle } = n;
let title = "Notice";
const { message, level, title } = n;
let levelClass = "sub";
if (level === -1) {
levelClass = "error";
title = "Error";
} else if (level === 1) {
levelClass = "main";
title = "Success";
}
if (customTitle !== undefined) {
title = customTitle;
}
notificationHistoryListEl.prependHtml(`
<div class="item">
<div class="item" data-id="${n.id}">
<div class="indicator ${levelClass}"></div>
<div class="title">${title}</div>
<div class="body">
${escapeHTML(message)}
</div>
<div class="buttons">
${
n.details !== undefined
? `<button class="copyNotification textButton" aria-label="Copy details to clipboard" data-balloon-pos="left"><i class="fas fa-fw fa-clipboard"></i></button>`
: ``
}
</div>
</div>
`);
}
@ -396,15 +409,89 @@ function updateClaimDeleteAllButton(): void {
}
}
async function copyNotificationToClipboard(target: HTMLElement): Promise<void> {
const id = (target as HTMLElement | null)
?.closest(".item")
?.getAttribute("data-id")
?.toString();
if (id === undefined) {
throw new Error("Notification ID is undefined");
}
const notification = state.notifications.find((it) => it.id === id);
if (notification === undefined) return;
const icon = target.querySelector("i") as HTMLElement;
try {
await navigator.clipboard.writeText(
JSON.stringify(
{
title: notification.title,
message: notification.message,
details: notification.details,
},
null,
4,
),
);
const duration = applyReducedMotion(100);
await promiseAnimate(icon, {
scale: [1, 0.8],
opacity: [1, 0],
duration,
});
icon.classList.remove("fa-clipboard");
icon.classList.add("fa-check", "highlight");
await promiseAnimate(icon, {
scale: [0.8, 1],
opacity: [0, 1],
duration,
});
await promiseAnimate(icon, {
scale: [1, 0.8],
opacity: [1, 0],
delay: 3000,
duration,
});
icon.classList.remove("fa-check", "highlight");
icon.classList.add("fa-clipboard");
await promiseAnimate(icon, {
scale: [0.8, 1],
opacity: [0, 1],
duration,
});
} catch (e: unknown) {
const msg = createErrorMessage(e, "Could not copy to clipboard");
Notifications.add(msg, -1);
}
}
qs("header nav .showAlerts")?.on("click", () => {
void show();
});
NotificationEvent.subscribe((message, level, customTitle) => {
NotificationEvent.subscribe((message, level, options) => {
let title = "Notice";
if (level === -1) {
title = "Error";
} else if (level === 1) {
title = "Success";
}
if (options.customTitle !== undefined) {
title = options.customTitle;
}
state.notifications.push({
id: (notificationId++).toString(),
title,
message,
level,
customTitle,
details: options.details,
});
if (state.notifications.length > 25) {
state.notifications.shift();
@ -495,5 +582,11 @@ const modal = new AnimatedModal({
markReadAlert(id);
});
alertsPopupEl
.qs(".notificationHistory .list")
?.onChild("click", ".item .buttons .copyNotification", (e) => {
void copyNotificationToClipboard(e.target as HTMLElement);
});
},
});

View file

@ -8,7 +8,7 @@ import {
} from "@monkeytype/schemas/configs";
import Config, { setConfig } from "../config";
import * as Notifications from "../elements/notifications";
import { ElementWithUtils } from "../utils/dom";
import { DomUtilsEvent, ElementWithUtils } from "../utils/dom";
export type ValidationResult = {
status: "checking" | "success" | "failed" | "warning";
@ -60,7 +60,7 @@ export function createInputEventHandler<T>(
callback: (result: ValidationResult) => void,
validation: Validation<T>,
inputValueConvert?: (val: string) => T,
): (e: Event) => Promise<void> {
): (e: DomUtilsEvent) => Promise<void> {
let callIsValid =
validation.isValid !== undefined
? debounceIfNeeded(

View file

@ -5,6 +5,7 @@ import * as NotificationEvent from "../observables/notification-event";
import { convertRemToPixels } from "../utils/numbers";
import { animate } from "animejs";
import { qsr } from "../utils/dom";
import { CommonResponsesType } from "@monkeytype/contracts/util/api";
const notificationCenter = qsr("#notificationCenter");
const notificationCenterHistory = notificationCenter.qsr(".history");
@ -107,6 +108,7 @@ class Notification {
visibleStickyNotifications++;
updateClearAllButton();
}
notificationCenterHistory.prependHtml(`
<div class="notif ${cls}" id=${this.id} style="opacity: 0;">
<div class="message"><div class="title"><div class="icon">${icon}</div>${title}</div>${this.message}</div>
@ -270,6 +272,8 @@ export type AddNotificationOptions = {
customIcon?: string;
closeCallback?: () => void;
allowHTML?: boolean;
details?: object | string;
response?: CommonResponsesType;
};
export function add(
@ -277,7 +281,24 @@ export function add(
level = 0,
options: AddNotificationOptions = {},
): void {
NotificationEvent.dispatch(message, level, options.customTitle);
let details = options.details;
if (options.response !== undefined) {
details = {
status: options.response.status,
additionalDetails: options.details,
validationErrors:
options.response.status === 422
? options.response.body.validationErrors
: undefined,
};
message = message + ": " + options.response.body.message;
}
NotificationEvent.dispatch(message, level, {
customTitle: options.customTitle,
details,
});
new Notification(
"notification",

View file

@ -1,27 +1,30 @@
import * as ActivePage from "../states/active-page";
import { prefersReducedMotion } from "../utils/misc";
import { qsr } from "../utils/dom";
let visible = false;
const button = qsr(".scrollToTopButton");
export function hide(): void {
$(".scrollToTopButton").addClass("invisible");
button.addClass("invisible");
visible = false;
}
function show(): void {
$(".scrollToTopButton").removeClass("invisible");
button.removeClass("invisible");
visible = true;
}
$(document).on("click", ".scrollToTopButton", () => {
$(".scrollToTopButton").addClass("invisible");
button.on("click", () => {
button.addClass("invisible");
window.scrollTo({
top: 0,
behavior: prefersReducedMotion() ? "instant" : "smooth",
});
});
$(window).on("scroll", () => {
window.addEventListener("scroll", () => {
const page = ActivePage.get();
if (page === "test") return;

View file

@ -32,6 +32,7 @@ async function setup(modalEl: HTMLElement): Promise<void> {
});
Notifications.add("This is a test", -1, {
duration: 0,
details: { test: true, error: "Example error message" },
});
void modal.hide();
});

View file

@ -48,7 +48,7 @@ export function show(action: string, id?: string, name?: string): void {
$("#editPresetModal .modal .text").addClass("hidden");
addCheckBoxes();
presetNameEl ??= new ValidatedHtmlInputElement(
qsr("#editPresetModal .modal input"),
qsr("#editPresetModal .modal input[type=text]"),
{
schema: PresetNameSchema,
},
@ -284,7 +284,7 @@ async function apply(): Promise<void> {
if (response.status !== 200 || response.body.data === null) {
Notifications.add(
"Failed to add preset: " +
"Failed to add preset" +
response.body.message.replace(presetName, propPresetName),
-1,
);
@ -325,7 +325,7 @@ async function apply(): Promise<void> {
});
if (response.status !== 200) {
Notifications.add("Failed to edit preset: " + response.body.message, -1);
Notifications.add("Failed to edit preset", -1, { response });
} else {
Notifications.add("Preset updated", 1);
@ -344,10 +344,7 @@ async function apply(): Promise<void> {
const response = await Ape.presets.delete({ params: { presetId } });
if (response.status !== 200) {
Notifications.add(
"Failed to remove preset: " + response.body.message,
-1,
);
Notifications.add("Failed to remove preset", -1, { response });
} else {
Notifications.add("Preset removed", 1);
snapshotPresets.forEach((preset: SnapshotPreset, index: number) => {

View file

@ -180,7 +180,7 @@ async function updateProfile(): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to update profile: " + response.body.message, -1);
Notifications.add("Failed to update profile", -1, { response });
return;
}

View file

@ -121,10 +121,7 @@ async function save(): Promise<void> {
state.tags = state.tags.filter((el) => el !== undefined);
if (response.status !== 200) {
Notifications.add(
"Failed to update result tags: " + response.body.message,
-1,
);
Notifications.add("Failed to update result tags", -1, { response });
return;
}

View file

@ -37,6 +37,7 @@ const actionModals: Record<Action, SimpleModal> = {
message:
"Failed to add tag: " +
response.body.message.replace(tagName, propTagName),
notificationOptions: { response },
};
}
@ -83,7 +84,8 @@ const actionModals: Record<Action, SimpleModal> = {
if (response.status !== 200) {
return {
status: -1,
message: "Failed to edit tag: " + response.body.message,
message: "Failed to edit tag",
notificationOptions: { response },
};
}
@ -113,7 +115,8 @@ const actionModals: Record<Action, SimpleModal> = {
if (response.status !== 200) {
return {
status: -1,
message: "Failed to remove tag: " + response.body.message,
message: "Failed to remove tag",
notificationOptions: { response },
};
}
@ -143,7 +146,8 @@ const actionModals: Record<Action, SimpleModal> = {
if (response.status !== 200) {
return {
status: -1,
message: "Failed to clear tag pb: " + response.body.message,
message: "Failed to clear tag pb",
notificationOptions: { response },
};
}

View file

@ -98,7 +98,7 @@ async function getQuotes(): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to get new quotes: " + response.body.message, -1);
Notifications.add("Failed to get new quotes", -1, { response });
return;
}
@ -160,7 +160,7 @@ async function approveQuote(index: number, dbid: string): Promise<void> {
if (response.status !== 200) {
resetButtons(index);
quote.find("textarea, input").prop("disabled", false);
Notifications.add("Failed to approve quote: " + response.body.message, -1);
Notifications.add("Failed to approve quote", -1, { response });
return;
}
@ -184,7 +184,7 @@ async function refuseQuote(index: number, dbid: string): Promise<void> {
if (response.status !== 200) {
resetButtons(index);
quote.find("textarea, input").prop("disabled", false);
Notifications.add("Failed to refuse quote: " + response.body.message, -1);
Notifications.add("Failed to refuse quote", -1, { response });
return;
}
@ -218,7 +218,7 @@ async function editQuote(index: number, dbid: string): Promise<void> {
if (response.status !== 200) {
resetButtons(index);
quote.find("textarea, input").prop("disabled", false);
Notifications.add("Failed to approve quote: " + response.body.message, -1);
Notifications.add("Failed to approve quote", -1, { response });
return;
}

View file

@ -60,10 +60,7 @@ export async function getQuoteStats(
Loader.hide();
if (response.status !== 200) {
Notifications.add(
"Failed to get quote ratings: " + response.body.message,
-1,
);
Notifications.add("Failed to get quote ratings", -1, { response });
return;
}
@ -156,10 +153,7 @@ async function submit(): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add(
"Failed to submit quote rating: " + response.body.message,
-1,
);
Notifications.add("Failed to submit quote rating", -1, { response });
return;
}

View file

@ -121,7 +121,7 @@ async function submitReport(): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to report quote: " + response.body.message, -1);
Notifications.add("Failed to report quote", -1, { response });
return;
}

View file

@ -28,6 +28,11 @@ const searchServiceCache: Record<string, SearchService<Quote>> = {};
const pageSize = 100;
let currentPageNumber = 1;
let usingCustomLength = true;
let quotes: Quote[];
async function updateQuotes(): Promise<void> {
({ quotes } = await QuotesController.getQuotes(Config.language));
}
function getSearchService<T>(
language: string,
@ -188,10 +193,61 @@ function buildQuoteSearchResult(
`;
}
function exactSearch(quotes: Quote[], captured: RegExp[]): [Quote[], string[]] {
const matches: Quote[] = [];
const exactSearchQueryTerms: Set<string> = new Set<string>();
for (const quote of quotes) {
const textAndSource = quote.text + quote.source;
const currentMatches = [];
let noMatch = false;
for (const regex of captured) {
const match = textAndSource.match(regex);
if (!match) {
noMatch = true;
break;
}
currentMatches.push(match[0]);
}
if (!noMatch) {
currentMatches.forEach((match) => exactSearchQueryTerms.add(match));
matches.push(quote);
}
}
return [matches, Array.from(exactSearchQueryTerms)];
}
async function updateResults(searchText: string): Promise<void> {
if (!modal.isOpen()) return;
const { quotes } = await QuotesController.getQuotes(Config.language);
if (quotes === undefined) {
({ quotes } = await QuotesController.getQuotes(Config.language));
}
let matches: Quote[] = [];
let matchedQueryTerms: string[] = [];
let exactSearchMatches: Quote[] = [];
let exactSearchMatchedQueryTerms: string[] = [];
const quotationsRegex = /"(.*?)"/g;
const exactSearchQueries = Array.from(searchText.matchAll(quotationsRegex));
const removedSearchText = searchText.replaceAll(quotationsRegex, "");
if (exactSearchQueries[0]) {
const searchQueriesRaw = exactSearchQueries.map(
(query) => new RegExp(query[1] ?? "", "i"),
);
[exactSearchMatches, exactSearchMatchedQueryTerms] = exactSearch(
quotes,
searchQueriesRaw,
);
}
const quoteSearchService = getSearchService<Quote>(
Config.language,
@ -200,8 +256,21 @@ async function updateResults(searchText: string): Promise<void> {
return `${quote.text} ${quote.id} ${quote.source}`;
},
);
const { results: matches, matchedQueryTerms } =
quoteSearchService.query(searchText);
if (exactSearchMatches.length > 0 || removedSearchText === searchText) {
const ids = exactSearchMatches.map((match) => match.id);
({ results: matches, matchedQueryTerms } = quoteSearchService.query(
removedSearchText,
ids,
));
exactSearchMatches.forEach((match) => {
if (!matches.includes(match)) matches.push(match);
});
matchedQueryTerms = [...exactSearchMatchedQueryTerms, ...matchedQueryTerms];
}
const quotesToShow = applyQuoteLengthFilter(
applyQuoteFavFilter(searchText === "" ? quotes : matches),
@ -340,12 +409,7 @@ export async function show(showOptions?: ShowOptions): Promise<void> {
});
},
afterAnimation: async () => {
const quoteSearchInputValue = $(
"#quoteSearchModal input",
).val() as string;
currentPageNumber = 1;
void updateResults(quoteSearchInputValue);
void updateQuotes();
},
});
}

View file

@ -44,7 +44,7 @@ async function submitQuote(): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to submit quote: " + response.body.message, -1);
Notifications.add("Failed to submit quote", -1, { response });
return;
}

View file

@ -288,7 +288,8 @@ list.updateEmail = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to update email: " + response.body.message,
message: "Failed to update email",
notificationOptions: { response },
};
}
@ -499,13 +500,14 @@ list.updateName = new SimpleModal({
};
}
const updateNameResponse = await Ape.users.updateName({
const response = await Ape.users.updateName({
body: { name: newName },
});
if (updateNameResponse.status !== 200) {
if (response.status !== 200) {
return {
status: -1,
message: "Failed to update name: " + updateNameResponse.body.message,
message: "Failed to update name",
notificationOptions: { response },
};
}
@ -598,7 +600,8 @@ list.updatePassword = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to update password: " + response.body.message,
message: "Failed to update password",
notificationOptions: { response },
};
}
@ -699,8 +702,8 @@ list.addPasswordAuth = new SimpleModal({
return {
status: -1,
message:
"Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error: " +
response.body.message,
"Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error",
notificationOptions: { response },
};
}
@ -735,12 +738,13 @@ list.deleteAccount = new SimpleModal({
}
Notifications.add("Deleting all data...", 0);
const usersResponse = await Ape.users.delete();
const response = await Ape.users.delete();
if (usersResponse.status !== 200) {
if (response.status !== 200) {
return {
status: -1,
message: "Failed to delete user data: " + usersResponse.body.message,
message: "Failed to delete user data",
notificationOptions: { response },
};
}
@ -791,7 +795,8 @@ list.resetAccount = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to reset account: " + response.body.message,
message: "Failed to reset account",
notificationOptions: { response },
};
}
@ -837,7 +842,8 @@ list.optOutOfLeaderboards = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to opt out: " + response.body.message,
message: "Failed to opt out",
notificationOptions: { response },
};
}
@ -898,7 +904,8 @@ list.resetPersonalBests = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to reset personal bests: " + response.body.message,
message: "Failed to reset personal bests",
notificationOptions: { response },
};
}
@ -974,7 +981,8 @@ list.revokeAllTokens = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to revoke tokens: " + response.body.message,
message: "Failed to revoke tokens",
notificationOptions: { response },
};
}
@ -1015,7 +1023,8 @@ list.unlinkDiscord = new SimpleModal({
if (response.status !== 200) {
return {
status: -1,
message: "Failed to unlink Discord: " + response.body.message,
message: "Failed to unlink Discord",
notificationOptions: { response },
};
}
@ -1203,7 +1212,7 @@ list.devGenerateData = new SimpleModal({
const span = document.querySelector(
"#devGenerateData_1 + span",
) as HTMLInputElement;
span.innerHTML = `if checked, user will be created with ${target.value}@example.com and password: password`;
span.innerText = `if checked, user will be created with ${target.value}@example.com and password: password`;
return;
},
validation: {

View file

@ -88,10 +88,7 @@ async function apply(): Promise<void> {
});
Loader.hide();
if (response.status !== 200) {
Notifications.add(
"Failed to set streak hour offset: " + response.body.message,
-1,
);
Notifications.add("Failed to set streak hour offset", -1, { response });
} else {
Notifications.add("Streak hour offset set", 1);
const snap = getSnapshot() as Snapshot;

View file

@ -128,7 +128,7 @@ async function submitReport(): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to report user: " + response.body.message, -1);
Notifications.add("Failed to report user", -1, { response });
return;
}

View file

@ -1,7 +1,12 @@
type SubscribeFunction = (
export type NotificationOptions = {
customTitle?: string;
details?: object | string;
};
export type SubscribeFunction = (
message: string,
level: number,
customTitle?: string,
options: NotificationOptions,
) => void;
const subscribers: SubscribeFunction[] = [];
@ -13,11 +18,11 @@ export function subscribe(fn: SubscribeFunction): void {
export function dispatch(
message: string,
level: number,
customTitle?: string,
options: NotificationOptions,
): void {
subscribers.forEach((fn) => {
try {
fn(message, level, customTitle);
fn(message, level, options);
} catch (e) {
console.error("Notification event subscriber threw an error");
console.error(e);

View file

@ -1107,7 +1107,7 @@ $(".pageAccount").on("click", ".miniResultChartButton", async (event) => {
target.removeClass("loading");
if (response.status !== 200) {
Notifications.add("Error fetching result: " + response.body.message, -1);
Notifications.add("Error fetching result", -1, { response });
return;
}

View file

@ -43,7 +43,7 @@ import * as CustomBackgroundPicker from "../elements/settings/custom-background-
import * as CustomFontPicker from "../elements/settings/custom-font-picker";
import * as AuthEvent from "../observables/auth-event";
import * as FpsLimitSection from "../elements/settings/fps-limit-section";
import { qsr } from "../utils/dom";
import { qs, qsr } from "../utils/dom";
let settingsInitialized = false;
@ -763,13 +763,28 @@ function toggleSettingsGroup(groupName: string): void {
//The highlight is repeated/broken when toggling the group
handleHighlightSection(undefined);
const groupEl = $(`.pageSettings .settingsGroup.${groupName}`);
groupEl.stop(true, true).slideToggle(250).toggleClass("slideup");
if (groupEl.hasClass("slideup")) {
const groupEl = qs(`.pageSettings .settingsGroup.${groupName}`);
if (!groupEl?.hasClass("slideup")) {
groupEl?.animate({
height: 0,
duration: 250,
onComplete: () => {
groupEl?.hide();
},
});
groupEl?.addClass("slideup");
$(`.pageSettings .sectionGroupTitle[group=${groupName}]`).addClass(
"rotateIcon",
);
} else {
groupEl?.show();
groupEl?.setStyle({ height: "" });
const height = groupEl.getOffsetHeight();
groupEl?.animate({
height: [0, height],
duration: 250,
});
groupEl?.removeClass("slideup");
$(`.pageSettings .sectionGroupTitle[group=${groupName}]`).removeClass(
"rotateIcon",
);

View file

@ -1,16 +0,0 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
const ls = new LocalStorageWithSchema({
key: "prefersArabicLazyMode",
schema: z.boolean(),
fallback: true,
});
export function get(): boolean {
return ls.get();
}
export function set(value: boolean): void {
ls.set(value);
}

View file

@ -0,0 +1,30 @@
import { z } from "zod";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
const rememberLazyModeLS = new LocalStorageWithSchema({
key: "rememberLazyMode",
schema: z.boolean(),
fallback: false,
});
const arabicLazyModeLS = new LocalStorageWithSchema({
key: "prefersArabicLazyMode",
schema: z.boolean(),
fallback: true,
});
export function getRemember(): boolean {
return rememberLazyModeLS.get();
}
export function setRemember(value: boolean): void {
rememberLazyModeLS.set(value);
}
export function getArabicPref(): boolean {
return arabicLazyModeLS.get();
}
export function setArabicPref(value: boolean): void {
arabicLazyModeLS.set(value);
}

View file

@ -4,8 +4,8 @@ export function get(): number {
return time;
}
export function set(active: number): void {
time = active;
export function set(number: number): void {
time = number;
}
export function increment(): void {

View file

@ -1,7 +1,7 @@
export let leftState = false;
export let rightState = false;
$(document).on("keydown", (e) => {
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.code === "AltLeft") {
leftState = true;
} else if (e.code === "AltRight") {
@ -9,7 +9,7 @@ $(document).on("keydown", (e) => {
}
});
$(document).on("keyup", (e) => {
document.addEventListener("keyup", (e: KeyboardEvent) => {
if (e.code === "AltLeft") {
leftState = false;
} else if (e.code === "AltRight") {

View file

@ -652,6 +652,33 @@ const replacementRules: BritishEnglishReplacements = {
moisturizing: "moisturising",
favoring: "favouring",
marvelous: "marvellous",
hematuria: "haematuria",
hemoptysis: "haemoptysis",
hemorrhoid: "haemorrhoid",
hemorrhagic: "haemorrhagic",
hypercalcemia: "hypercalcaemia",
hyperglycemia: "hyperglycaemia",
hypoglycemia: "hypoglycaemia",
toxemia: "toxaemia",
hypoxemia: "hypoxaemia",
bacteremia: "bacteraemia",
hypernatremia: "hypernatraemia",
hyponatremia: "hyponatraemia",
leukocytosis: "leucocytosis",
leukocyte: "leucocyte",
leukopenia: "leucopenia",
apnea: "apnoea",
bradypnea: "bradypnoea",
tachypnea: "tachypnoea",
orthopnea: "orthopnoea",
ileocecal: "ileocaecal",
metastasize: "metastasise",
lymphedema: "lymphoedema",
neuron: "neurone",
hemianopsia: "hemianopia",
galactorrhea: "galactorrhoea",
nebulizer: "nebuliser",
paresthesia: "paraesthesia",
};
export async function replace(

View file

@ -1,7 +1,8 @@
import Config from "../config";
import * as Misc from "../utils/misc";
import { qsr } from "../utils/dom";
const el = document.querySelector("#capsWarning") as HTMLElement;
const el = qsr("#capsWarning");
export let capsState = false;
@ -9,23 +10,23 @@ let visible = false;
function show(): void {
if (!visible) {
el?.classList.remove("hidden");
el.removeClass("hidden");
visible = true;
}
}
function hide(): void {
if (visible) {
el?.classList.add("hidden");
el.addClass("hidden");
visible = false;
}
}
function update(event: JQuery.KeyDownEvent | JQuery.KeyUpEvent): void {
if (event?.originalEvent?.key === "CapsLock" && capsState !== null) {
function update(event: KeyboardEvent): void {
if (event.key === "CapsLock" && capsState !== null) {
capsState = !capsState;
} else {
const modState = event?.originalEvent?.getModifierState?.("CapsLock");
const modState = event.getModifierState?.("CapsLock");
if (modState !== undefined) {
capsState = modState;
}
@ -40,8 +41,8 @@ function update(event: JQuery.KeyDownEvent | JQuery.KeyUpEvent): void {
} catch {}
}
$(document).on("keyup", update);
document.addEventListener("keyup", update);
$(document).on("keydown", (event) => {
document.addEventListener("keydown", (event) => {
if (Misc.isMac()) update(event);
});

View file

@ -4,7 +4,7 @@ import * as KeyConverter from "../utils/key-converter";
export let leftState = false;
export let rightState = false;
$(document).on("keydown", (e) => {
document.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.code === "ShiftLeft") {
leftState = true;
rightState = false;
@ -14,7 +14,7 @@ $(document).on("keydown", (e) => {
}
});
$(document).on("keyup", (e) => {
document.addEventListener("keyup", (e: KeyboardEvent) => {
if (e.code === "ShiftLeft" || e.code === "ShiftRight") {
leftState = false;
rightState = false;

View file

@ -44,7 +44,7 @@ import * as Tribe from "../tribe/tribe";
import * as TribeTypes from "../tribe/types";
import * as ConnectionState from "../states/connection";
import * as KeymapEvent from "../observables/keymap-event";
import * as ArabicLazyMode from "../states/arabic-lazy-mode";
import * as LazyModeState from "../states/remember-lazy-mode";
import Format from "../utils/format";
import { QuoteLength, QuoteLengthConfig } from "@monkeytype/schemas/configs";
import { Mode } from "@monkeytype/schemas/shared";
@ -75,6 +75,7 @@ import { animate } from "animejs";
import { setInputElementValue } from "../input/input-element";
import { debounce } from "throttle-debounce";
import tribeSocket from "../tribe/tribe-socket";
import * as Time from "../states/time";
let resolve: TribeTypes.ResultResolve = {};
let failReason = "";
@ -108,6 +109,7 @@ export function startTest(now: number): boolean {
Replay.startReplayRecording();
Replay.replayGetWordsList(TestWords.words.list);
TestInput.resetKeypressTimings();
Time.set(0);
TestTimer.clear();
for (const fb of getActiveFunboxesWithFunction("start")) {
@ -356,7 +358,6 @@ export function restart(options = {} as RestartOptions): void {
}
let lastInitError: Error | null = null;
let rememberLazyMode: boolean;
let showedLazyModeNotification: boolean = false;
let testReinitCount = 0;
@ -440,39 +441,43 @@ async function init(): Promise<boolean> {
.some((lang) => !lang.noLazyMode);
if (Config.lazyMode && !anySupportsLazyMode) {
rememberLazyMode = true;
Notifications.add(
"None of the selected polyglot languages support lazy mode.",
0,
{
important: true,
},
);
LazyModeState.setRemember(true);
if (!showedLazyModeNotification) {
Notifications.add(
"None of the selected polyglot languages support lazy mode.",
0,
{
important: true,
},
);
showedLazyModeNotification = true;
}
setConfig("lazyMode", false);
} else if (rememberLazyMode && anySupportsLazyMode) {
setConfig("lazyMode", true, {
nosave: true,
});
} else if (LazyModeState.getRemember() && anySupportsLazyMode) {
setConfig("lazyMode", true);
LazyModeState.setRemember(false);
showedLazyModeNotification = false;
}
} else {
// normal mode
if (Config.lazyMode && !allowLazyMode) {
rememberLazyMode = true;
showedLazyModeNotification = true;
Notifications.add("This language does not support lazy mode.", 0, {
important: true,
});
LazyModeState.setRemember(true);
if (!showedLazyModeNotification) {
Notifications.add("This language does not support lazy mode.", 0, {
important: true,
});
showedLazyModeNotification = true;
}
setConfig("lazyMode", false);
} else if (rememberLazyMode && !language.noLazyMode) {
setConfig("lazyMode", true, {
nosave: true,
});
} else if (LazyModeState.getRemember() && allowLazyMode) {
setConfig("lazyMode", true);
LazyModeState.setRemember(false);
showedLazyModeNotification = false;
}
}
if (!Config.lazyMode && !language.noLazyMode) {
rememberLazyMode = false;
LazyModeState.setRemember(false);
}
if (Config.mode === "custom") {
@ -1413,7 +1418,7 @@ async function saveResult(
response.body.message =
"Looks like your result data is using an incorrect schema. Please refresh the page to download the new update. If the problem persists, please contact support.";
}
Notifications.add("Failed to save result: " + response.body.message, -1);
Notifications.add("Failed to save result", -1, { response });
return;
}
@ -1725,7 +1730,10 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => {
if (ActivePage.get() === "test") {
if (key === "language") {
//automatically enable lazy mode for arabic
if ((newValue as string)?.startsWith("arabic") && ArabicLazyMode.get()) {
if (
(newValue as string)?.startsWith("arabic") &&
LazyModeState.getArabicPref()
) {
setConfig("lazyMode", true, {
nosave: true,
});
@ -1758,13 +1766,7 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => {
}
if (key === "lazyMode" && !nosave) {
if (Config.language.startsWith("arabic")) {
ArabicLazyMode.set(newValue);
}
if (newValue) {
if (!showedLazyModeNotification) {
rememberLazyMode = false;
}
showedLazyModeNotification = false;
LazyModeState.setArabicPref(newValue);
}
}
});

View file

@ -62,7 +62,6 @@ export function enableTimerDebug(): void {
export function clear(): void {
clearLowFpsMode();
Time.set(0);
newTimer.reset();
if (timer !== null) clearTimeout(timer);
}

View file

@ -300,7 +300,7 @@ export class Caret {
if (options.letterIndex >= letters.length) {
side = "afterLetter";
if (Config.blindMode) {
if (Config.blindMode || Config.hideExtraLetters) {
options.letterIndex = wordText?.length - 1;
} else {
options.letterIndex = letters.length - 1;
@ -450,6 +450,9 @@ export class Caret {
let left = 0;
let top = 0;
const tapeOffset =
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
// yes, this is all super verbose, but its easier to maintain and understand
if (isWordRTL) {
let afterLetterCorrection = 0;
@ -475,8 +478,7 @@ export class Caret {
left += options.letter.getOffsetLeft();
left += afterLetterCorrection;
if (this.isMainCaret && lockedMainCaretInTape) {
left +=
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
left += wordsWrapperCache.getOffsetWidth() - tapeOffset;
} else {
left += options.word.getOffsetLeft();
left += options.word.getOffsetWidth();
@ -486,8 +488,7 @@ export class Caret {
left += width * -1;
}
if (this.isMainCaret && lockedMainCaretInTape) {
left +=
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
left += wordsWrapperCache.getOffsetWidth() - tapeOffset;
} else {
left += options.letter.getOffsetLeft();
left += options.word.getOffsetLeft();
@ -508,15 +509,13 @@ export class Caret {
left += options.letter.getOffsetLeft();
left += afterLetterCorrection;
if (this.isMainCaret && lockedMainCaretInTape) {
left +=
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
left += tapeOffset;
} else {
left += options.word.getOffsetLeft();
}
} else if (Config.tapeMode === "letter") {
if (this.isMainCaret && lockedMainCaretInTape) {
left +=
wordsWrapperCache.getOffsetWidth() * (Config.tapeMargin / 100);
left += tapeOffset;
} else {
left += options.letter.getOffsetLeft();
left += options.word.getOffsetLeft();

View file

@ -105,6 +105,12 @@ type ElementWithValue =
| HTMLTextAreaElement
| HTMLSelectElement;
export type DomUtilsEvent<T extends Event = Event> = Omit<T, "currentTarget">;
type DomUtilsEventListenerOrEventListenerObject =
| { (evt: DomUtilsEvent): void }
| { handleEvent(object: DomUtilsEvent): void };
export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
/**
* The native dom element
@ -238,19 +244,19 @@ export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
*/
on<K extends keyof HTMLElementEventMap>(
event: K,
handler: (this: T, ev: HTMLElementEventMap[K]) => void,
handler: (this: T, ev: DomUtilsEvent<HTMLElementEventMap[K]>) => void,
): this;
on(event: string, handler: EventListenerOrEventListenerObject): this;
on(event: string, handler: DomUtilsEventListenerOrEventListenerObject): this;
on(
event: keyof HTMLElementEventMap | string,
handler:
| EventListenerOrEventListenerObject
| ((this: T, ev: Event) => void),
| DomUtilsEventListenerOrEventListenerObject
| ((this: T, ev: DomUtilsEvent) => void),
): this {
// this type was some AI magic but if it works it works
this.native.addEventListener(
event,
handler as EventListenerOrEventListenerObject,
handler as DomUtilsEventListenerOrEventListenerObject,
);
return this;
}
@ -262,19 +268,22 @@ export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
onChild<K extends keyof HTMLElementEventMap>(
event: K,
query: string,
handler: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
handler: (
this: HTMLElement,
ev: DomUtilsEvent<HTMLElementEventMap[K]>,
) => void,
): this;
onChild(
event: string,
query: string,
handler: EventListenerOrEventListenerObject,
handler: DomUtilsEventListenerOrEventListenerObject,
): this;
onChild(
event: keyof HTMLElementEventMap | string,
query: string,
handler:
| EventListenerOrEventListenerObject
| ((this: HTMLElement, ev: Event) => void),
| DomUtilsEventListenerOrEventListenerObject
| ((this: HTMLElement, ev: DomUtilsEvent) => void),
): this {
// this type was some AI magic but if it works it works
this.native.addEventListener(event, (e) => {
@ -671,14 +680,14 @@ export class ElementsWithUtils<
*/
on<K extends keyof HTMLElementEventMap>(
event: K,
handler: (this: T, ev: HTMLElementEventMap[K]) => void,
handler: (this: T, ev: DomUtilsEvent<HTMLElementEventMap[K]>) => void,
): this;
on(event: string, handler: EventListenerOrEventListenerObject): this;
on(event: string, handler: DomUtilsEventListenerOrEventListenerObject): this;
on(
event: keyof HTMLElementEventMap | string,
handler:
| EventListenerOrEventListenerObject
| ((this: T, ev: Event) => void),
| DomUtilsEventListenerOrEventListenerObject
| ((this: T, ev: DomUtilsEvent) => void),
): this {
for (const item of this) {
item.on(event, handler);

View file

@ -14,10 +14,7 @@ export async function syncNotSignedInLastResult(uid: string): Promise<void> {
body: { result: notSignedInLastResult },
});
if (response.status !== 200) {
Notifications.add(
"Failed to save last result: " + response.body.message,
-1,
);
Notifications.add("Failed to save last result", -1, { response });
return;
}

View file

@ -2,7 +2,7 @@ import { stemmer } from "stemmer";
import levenshtein from "damerau-levenshtein";
export type SearchService<T> = {
query: (query: string) => SearchResult<T>;
query: (query: string, ids: number[]) => SearchResult<T>;
};
type SearchServiceOptions = {
@ -110,7 +110,7 @@ export const buildSearchService = <T>(
const tokenSet = Object.keys(reverseIndex);
const query = (searchQuery: string): SearchResult<T> => {
const query = (searchQuery: string, ids: number[]): SearchResult<T> => {
const searchResult: SearchResult<T> = {
results: [],
matchedQueryTerms: [],
@ -155,7 +155,13 @@ export const buildSearchService = <T>(
const scoreForToken = score * idf * termFrequency;
results.set(document.id, currentScore + scoreForToken);
const quote = documents[document.id] as InternalDocument;
if (
ids.length === 0 ||
(quote !== null && quote !== undefined && ids.includes(quote.id))
) {
results.set(document.id, currentScore + scoreForToken);
}
});
normalizedTokenToOriginal[token]?.forEach((originalToken) => {

View file

@ -49,7 +49,7 @@ export async function linkDiscord(hashOverride: string): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to link Discord: " + response.body.message, -1);
Notifications.add("Failed to link Discord", -1, { response });
return;
}

View file

@ -1,5 +1,9 @@
{
"name": "bulgarian",
"rightToLeft": false,
"ligatures": false,
"orderedByFrequency": false,
"bcp47": "bg",
"noLazyMode": true,
"words": [
"а",

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,9 @@
{
"name": "bulgarian_latin",
"rightToLeft": false,
"ligatures": false,
"orderedByFrequency": false,
"bcp47": "bg",
"noLazyMode": true,
"words": [
"a",

File diff suppressed because it is too large Load diff

View file

@ -39111,6 +39111,36 @@
"source": "George Orwell, 1984",
"id": 7731,
"length": 109
},
{
"text": "Commander! You may want to instruct your men to exercise restraint when using explosives... while certainly effective at killing aliens, they also destroy the artifacts we're hoping to recover from the bodies. Just something to consider.",
"source": "XCOM: Enemy Unknown",
"id": 7732,
"length": 237
},
{
"text": "This is unlike anything else we've previously identified. Based on its physical appearance, I would assume this alien doesn't rely on brute strength. I recommend the troops exercise extreme caution, Commander.",
"source": "XCOM: Enemy Unknown",
"id": 7733,
"length": 209
},
{
"text": "Ever since mankind first looked up at the stars, we have wondered what lies beyond. So very few have dared to look inward... The depths of the human mind hold more secrets than we can possibly imagine. How ironic that the means to defeat our enemy comes not through weapons or machines of war, but from within. And if we have succeeded... we will have gained a glimpse of what we are to become. We will have created something... extraordinary.",
"source": "XCOM: Enemy Unknown",
"id": 7734,
"length": 443
},
{
"text": "Incredible! That alien seems to have... taken control of that soldier somehow. All of the advances we've made so far... they would be useless against this type of power.",
"source": "XCOM: Enemy Unknown",
"id": 7735,
"length": 169
},
{
"text": "I have difficulty understanding how such an advanced species could show so little empathy for the lives of other sentient beings... It goes against everything we have ever imagined. The technology is there, but with it comes a callousness we would never have expected. What could have brought them to this...",
"source": "XCOM: Enemy Unknown",
"id": 7736,
"length": 308
}
]
}

View file

@ -0,0 +1,312 @@
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 = output.match(OXLINT_SUMMARY_REGEX);
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;
}
}
// First pass clean - 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(_, { command }) {
isProduction = command === "build";
},
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 (!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 output = execSync(
"npx oxlint . && npx oxlint . --type-aware --type-check",
{
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();
},
};
}

View 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);
},
);
}

View file

@ -19,7 +19,7 @@ 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 { oxlintChecker } from "./vite-plugins/oxlint-checker";
import Inspect from "vite-plugin-inspect";
import { ViteMinifyPlugin } from "vite-plugin-minify";
import { VitePWA } from "vite-plugin-pwa";
@ -81,13 +81,10 @@ function getPlugins({
const plugins: PluginOption[] = [
envConfig({ isDevelopment, clientVersion, env }),
languageHashes({ skip: isDevelopment }),
checker({
oxlint: {
lintCommand: "oxlint . --type-aware --type-check",
},
overlay: {
initialIsOpen: false,
},
oxlintChecker({
debounceDelay: 125,
typeAware: true,
overlay: true,
}),
jqueryInject(),
injectHTML(),

View file

@ -67,18 +67,18 @@
"@commitlint/cli": "17.7.1",
"@commitlint/config-conventional": "19.2.2",
"@monkeytype/release": "workspace:*",
"@vitest/coverage-v8": "4.0.8",
"@vitest/coverage-v8": "4.0.15",
"conventional-changelog": "6.0.0",
"husky": "8.0.1",
"knip": "2.19.2",
"lint-staged": "13.2.3",
"only-allow": "1.2.1",
"oxfmt": "0.18.0",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxfmt": "0.19.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"prettier": "3.7.1",
"turbo": "2.5.6",
"vitest": "4.0.8"
"vitest": "4.0.15"
},
"packageManager": "pnpm@9.6.0",
"engines": {

View file

@ -29,11 +29,11 @@
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"tsup": "8.4.0",
"typescript": "5.9.3",
"vitest": "4.0.8"
"vitest": "4.0.15"
},
"peerDependencies": {
"@ts-rest/core": "3.52.1",

View file

@ -77,6 +77,8 @@ export const MonkeyValidationErrorSchema = MonkeyResponseSchema.extend({
export type MonkeyValidationError = z.infer<typeof MonkeyValidationErrorSchema>;
export const MonkeyClientError = MonkeyResponseSchema;
export type MonkeyClientErrorType = z.infer<typeof MonkeyClientError>;
export const MonkeyServerError = MonkeyClientError.extend({
errorId: z.string(),
uid: z.string().optional(),
@ -130,3 +132,17 @@ export const CommonResponses = {
"Endpoint disabled or server is under maintenance",
),
};
export type CommonResponsesType =
| {
status: 400 | 401 | 403 | 429 | 470 | 471 | 472 | 479;
body: MonkeyClientErrorType;
}
| {
status: 422;
body: MonkeyValidationError;
}
| {
status: 500 | 503;
body: MonkeyServerErrorType;
};

View file

@ -25,10 +25,10 @@
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"tsup": "8.4.0",
"typescript": "5.9.3",
"vitest": "4.0.8"
"vitest": "4.0.15"
}
}

View file

@ -20,7 +20,7 @@
},
"devDependencies": {
"nodemon": "3.1.4",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0"
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2"
}
}

View file

@ -26,11 +26,11 @@
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"tsup": "8.4.0",
"typescript": "5.9.3",
"vitest": "4.0.8"
"vitest": "4.0.15"
},
"peerDependencies": {
"zod": "3.23.8"

View file

@ -235,7 +235,9 @@ export const LanguageSchema = z.enum(
"lithuanian_1k",
"lithuanian_3k",
"bulgarian",
"bulgarian_1k",
"bulgarian_latin",
"bulgarian_latin_1k",
"bangla",
"bangla_letters",
"bangla_10k",

View file

@ -17,8 +17,8 @@
},
"devDependencies": {
"@monkeytype/typescript-config": "workspace:*",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"typescript": "5.9.3"
},
"peerDependencies": {

View file

@ -20,11 +20,11 @@
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.33.0",
"oxlint-tsgolint": "0.9.0",
"oxlint": "1.34.0",
"oxlint-tsgolint": "0.9.2",
"tsup": "8.4.0",
"typescript": "5.9.3",
"vitest": "4.0.8",
"vitest": "4.0.15",
"zod": "3.23.8"
}
}

839
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff