Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,206 @@
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
Params,
} from "react-router";
import { expect } from "vitest";
import type { z } from "zod";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { actAs } from "~/db/seed/core/actAs";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { SESSION_KEY } from "~/features/auth/core/authenticator.server";
import { authSessionStorage } from "~/features/auth/core/session.server";
import {
getUserFromRequest,
userAsyncLocalStorage,
} from "~/features/auth/core/user-context.server";
import { logger } from "./logger";
/**
* The user a wrapped action/loader call runs as: one of the pinned seed users,
* or any user's id — scenario tests' users are participants and staff the test
* itself created, not fixed ids.
*/
export type TestUser = "admin" | "regular" | number;
export function arrayContainsSameItems<T>(arr1: T[], arr2: T[]) {
return (
arr1.length === arr2.length && arr1.every((item) => arr2.includes(item))
);
}
/**
* Runs `fn` inside the user AsyncLocalStorage store so that repository functions
* resolving the actor via `actorId()` / `actorIdOrNull()` see the user as the acting
* one. Use in direct repository unit tests, which run outside a request.
*
* An id is all it takes: repositories read the actor solely through `actorId()`.
*/
export function withUserId<T>(id: number, fn: () => T): T {
return actAs(id, fn);
}
/**
* Runs `fn` inside a user AsyncLocalStorage store with no acting user, mirroring an
* anonymous visitor's request. Repository functions resolving the actor via
* `actorIdOrNull()` then see `null`, as they would inside a real request context.
*/
export function withNoUser<T>(fn: () => T): T {
return userAsyncLocalStorage.run({ user: undefined }, fn);
}
/**
* Wraps an action function to provide a strongly-typed, reusable handler for executing actions
* in unit tests as if it was a normal function. The returned function allows you to pass
* parameters that match the schema defined by the action, and it simulates a request with
* authentication headers based on the provided user type.
*
* @example
* import { someAction } from "../actions/some.action.server";
*
* const someAction = wrappedAction<typeof someActionSchema>({ action });
*/
export function wrappedAction<T extends z.ZodTypeAny>({
action,
/** Is this action submitted as json (via SendouForm) */
isJsonSubmission = false,
}: {
action: (args: ActionFunctionArgs) => any;
isJsonSubmission?: boolean;
}) {
return async (
args: z.infer<T>,
{ user, params = {} }: { user?: TestUser; params?: Params<string> } = {},
) => {
const body = isJsonSubmission
? JSON.stringify(args)
: new URLSearchParams(args as any);
const request = new Request("http://app.com/path", {
method: "POST",
body,
headers: [
...(await authHeader(user)),
[
"Content-Type",
isJsonSubmission
? "application/json"
: "application/x-www-form-urlencoded",
],
],
});
const userFromRequest = await getUserFromRequest(
request,
new URL(request.url),
);
return userAsyncLocalStorage.run({ user: userFromRequest }, async () => {
try {
const response = await action({
request,
context: {} as any,
params,
pattern: "",
url: new URL(request.url),
});
return response;
} catch (thrown) {
// we only log errors in vitest for failed tests so this is okay (more context)
logger.error("Error in wrappedAction:", thrown);
if (thrown instanceof Response) {
// it was a redirect
if (thrown.status === 302) return thrown;
throw new Error(`Response thrown with status code: ${thrown.status}`);
}
throw thrown;
}
});
};
}
export function wrappedLoader<T>({
loader,
}: {
loader: (args: LoaderFunctionArgs) => any;
}) {
return async ({
user,
params = {},
url = "/path",
}: {
user?: TestUser;
params?: Params<string>;
/** Path with its search params, built with the route's search params definition. */
url?: string;
} = {}) => {
const request = new Request(new URL(url, "http://app.com"), {
method: "GET",
headers: [
...(await authHeader(user)),
["Content-Type", "application/x-www-form-urlencoded"],
],
});
const userFromRequest = await getUserFromRequest(
request,
new URL(request.url),
);
return userAsyncLocalStorage.run({ user: userFromRequest }, async () => {
try {
const data = await loader({
request,
params,
context: {} as any,
pattern: "",
url: new URL(request.url),
});
return data as T;
} catch (thrown) {
if (thrown instanceof Response) {
throw new Error(`Response thrown with status code: ${thrown.status}`);
}
throw thrown;
}
});
};
}
/**
* Asserts that the given response errored out (with a toast message, via `errorToastIfFalsy(cond)` call)
*
* @param response - The HTTP response object to check.
* @param message - Optional. The expected error toast message shown to the user.
*/
export function assertResponseErrored(response: Response, message?: string) {
if (!response) {
throw new Error(`Expected a Response, got: ${response}`);
}
expect(response.headers.get("Location")).toContain("?__error=");
if (message) {
expect(response.headers.get("Location")).toContain(message);
}
}
async function authHeader(user?: TestUser): Promise<[string, string][]> {
if (user === undefined) return [];
const session = await authSessionStorage.getSession();
session.set(SESSION_KEY, testUserId(user));
return [["Cookie", await authSessionStorage.commitSession(session)]];
}
function testUserId(user: Exclude<TestUser, undefined>): number {
if (typeof user === "number") return user;
return user === "admin" ? ADMIN_ID : REGULAR_USER_TEST_ID;
}

View File

@@ -0,0 +1,25 @@
import type { z } from "zod";
/** `_action` literals of an action schema (union or single branch). */
export type ActionsOf<TSchema extends z.ZodTypeAny> =
z.infer<TSchema> extends {
_action: infer TAction extends string;
}
? TAction
: never;
/** Non-`_action` fields of the schema branch matching the given action, in parsed types. */
export type FieldsOf<
TSchema extends z.ZodTypeAny,
TAction extends string,
> = Omit<Extract<z.infer<TSchema>, { _action: TAction }>, "_action">;
/** Serializes a parsed field value into its form data representation. */
export function serializeFieldValue(value: unknown) {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "vitest";
import { diff, flatZip, mostPopularArrayElement } from "./arrays";
describe("diff", () => {
test.each([
[
[1, 2, 3],
[2, 3, 4, 4],
[4, 4],
],
[[1, 2, 3], [], []],
[[], [1, 2, 3], [1, 2, 3]],
[
[1, 2, 2, 3],
[2, 2, 3, 3, 4],
[3, 4],
],
[[1, 2, 3], [1, 2, 3], []],
])("%j vs %j leaves %j", (arr1, arr2, expected) => {
expect(diff(arr1, arr2)).toEqual(expected);
});
test("does not overflow the stack for very large counts", () => {
const arr2 = new Array(200_000).fill(1);
expect(diff([], arr2)).toHaveLength(200_000);
});
});
describe("mostPopularArrayElement", () => {
test.each([
[[1, 2, 2, 3, 3, 3, 4], 3],
[["a", "b", "b", "c", "a", "b"], "b"],
// the first of the tied elements wins
[[1, 2, 2, 1], 1],
[["only"], "only"],
[[], null],
] as [(string | number)[], string | number | null][])(
"%j is most popularly %j",
(arr, expected) => {
expect(mostPopularArrayElement(arr)).toBe(expected);
},
);
});
describe("flatZip", () => {
test.each([
[
[1, 2, 3],
["a", "b", "c"],
[1, "a", 2, "b", 3, "c"],
],
[
[1, 2],
["a", "b", "c"],
[1, "a", 2, "b", "c"],
],
[
[1, 2, 3, 4],
["a", "b"],
[1, "a", 2, "b", 3, 4],
],
[[], ["a", "b"], ["a", "b"]],
[[1, 2], [], [1, 2]],
[[], [], []],
[[1], ["a"], [1, "a"]],
])("zips %j and %j into %j", (arr1, arr2, expected) => {
expect(flatZip(arr1, arr2)).toEqual(expected);
});
});

View File

@@ -0,0 +1,110 @@
// TODO: when more examples of permissions profile difference between
// this implementation and one that takes arrays
// (not all arrays need to necessarily run but they need to be defined)
export function allTruthy(arr: unknown[]) {
return arr.every(Boolean);
}
export function normalizeFormFieldArray(
value: undefined | null | string | string[],
): string[] {
return value == null ? [] : typeof value === "string" ? [value] : value;
}
export function nullFilledArray(size: number): null[] {
return new Array(size).fill(null);
}
/**
* Calculates the average of an array of numbers. If the array is empty, returns null.
*
* @param values - An array of numbers to calculate the average of.
* @returns The average of the numbers in the array, or null if the array is empty.
*/
export function nullifyingAvg(values: number[]) {
if (values.length === 0) return null;
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
}
function countElements<T>(arr: T[]): Map<T, number> {
const counts = new Map<T, number>();
for (const element of arr) {
const count = counts.get(element) ?? 0;
counts.set(element, count + 1);
}
return counts;
}
/** Returns list of elements that are in arr2 but not in arr1. Supports duplicates */
export function diff<T extends string | number>(arr1: T[], arr2: T[]): T[] {
const arr1Counts = countElements(arr1);
const arr2Counts = countElements(arr2);
const diff = new Map<T, number>();
for (const [element, count] of arr2Counts) {
const diffCount = Math.max(count - (arr1Counts.get(element) ?? 0), 0);
diff.set(element, diffCount);
}
const result: T[] = [];
for (const [element, count] of diff) {
for (let i = 0; i < count; i++) {
result.push(element);
}
}
return result;
}
export function mostPopularArrayElement<T>(arr: T[]): T | null {
if (arr.length === 0) return null;
const counts = countElements(arr);
let mostPopularElement: T | null = null;
let maxCount = 0;
for (const [element, count] of counts) {
if (count > maxCount) {
maxCount = count;
mostPopularElement = element;
}
}
return mostPopularElement;
}
/**
* Safely zips two arrays together by alternating elements. If arrays have different lengths,
* zips as much as possible, then appends remaining elements from the longer array.
*
* @param arr1 - The first array
* @param arr2 - The second array
* @returns Alternating elements from both arrays: [arr1[0], arr2[0], arr1[1], arr2[1], ...]
* followed by any remaining elements from the longer array
*
* @example
* zipSafe([1, 2], ['a', 'b']) // [1, 'a', 2, 'b']
* zipSafe([1, 2], ['a', 'b', 'c']) // [1, 'a', 2, 'b', 'c']
* zipSafe([1, 2, 3], ['a', 'b']) // [1, 'a', 2, 'b', 3]
*/
export function flatZip<T, U>(arr1: T[], arr2: U[]): Array<T | U> {
const result: Array<T | U> = [];
const minLength = Math.min(arr1.length, arr2.length);
for (let i = 0; i < minLength; i++) {
result.push(arr1[i], arr2[i]);
}
if (arr1.length > minLength) {
result.push(...arr1.slice(minLength));
} else if (arr2.length > minLength) {
result.push(...arr2.slice(minLength));
}
return result;
}

View File

@@ -0,0 +1,33 @@
import type { CacheEntry } from "@epic-web/cachified";
import { ServerConfig } from "~/config.server";
import { LRUCache } from "~/modules/cache";
declare global {
// This preserves the LRU cache during development
var __lruCache: LRUCache<string, CacheEntry<unknown>> | undefined;
}
// biome-ignore lint/suspicious/noAssignInExpressions: trick to only create one
export const cache = (global.__lruCache = global.__lruCache
? global.__lruCache
: new LRUCache<string, CacheEntry<unknown>>({ max: 5000 }));
export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms);
export function syncCached<T>(key: string, getFreshValue: () => T) {
if (cache.has(key)) {
return cache.get(key) as T;
}
const value = getFreshValue();
cache.set(key, value as any);
return value;
}
export const IN_MILLISECONDS = {
HALF_HOUR: 30 * 60 * 1000,
ONE_HOUR: 60 * 60 * 1000,
TWO_HOURS: 2 * 60 * 60 * 1000,
TWO_DAYS: 2 * 24 * 60 * 60 * 1000,
};

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from "vitest";
import { compressToBase64, decompressFromBase64 } from "./compression";
describe("compressToBase64 & decompressFromBase64", () => {
test("round-trips a string", () => {
const value = JSON.stringify({ name: "trophy", mesh: [1, 2, 3] });
expect(decompressFromBase64(compressToBase64(value))).toBe(value);
});
test("decompresses raw deflate produced by another implementation", () => {
const compressed = "q1bKS8xNVbJSKinKL8ioVNJRyk0tzlCyijbUMdIxjq0FAA==";
expect(decompressFromBase64(compressed)).toBe(
JSON.stringify({ name: "trophy", mesh: [1, 2, 3] }),
);
});
test("round-trips unicode content", () => {
const value = "tröphy \u{1f3c6} テスト";
expect(decompressFromBase64(compressToBase64(value))).toBe(value);
});
test("round-trips with the url safe alphabet", () => {
const value = "a".repeat(1000) + JSON.stringify({ b: [4, 5, 6] });
const compressed = compressToBase64(value, { urlSafe: true });
expect(compressed).not.toMatch(/[+/=]/);
expect(decompressFromBase64(compressed)).toBe(value);
});
test("returns null for corrupt input", () => {
expect(decompressFromBase64("!!!not base64!!!")).toBeNull();
expect(decompressFromBase64(btoa("not deflate data"))).toBeNull();
expect(decompressFromBase64("")).toBeNull();
});
test("returns null for truncated input", () => {
const compressed = compressToBase64("some longer input to compress");
expect(decompressFromBase64(compressed.slice(0, 4))).toBeNull();
});
test("returns null for a value inflating past maxDecompressedBytes", () => {
const bomb = compressToBase64("a".repeat(10 * 1024 * 1024));
expect(bomb.length).toBeLessThan(64 * 1024);
expect(
decompressFromBase64(bomb, { maxDecompressedBytes: 64 * 1024 }),
).toBeNull();
});
test("allows a value exactly at maxDecompressedBytes", () => {
const value = "a".repeat(1024);
expect(
decompressFromBase64(compressToBase64(value), {
maxDecompressedBytes: 1024,
}),
).toBe(value);
});
});

View File

@@ -0,0 +1,76 @@
import { deflateSync, Inflate, strToU8 } from "fflate";
/**
* Compresses a string with raw deflate and encodes the result as base64.
* With `urlSafe` the output uses the URL-safe base64 alphabet without padding.
*/
export function compressToBase64(
value: string,
options?: { urlSafe?: boolean },
) {
const bytes = deflateSync(strToU8(value), { level: 9 });
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const base64 = btoa(binary);
if (!options?.urlSafe) return base64;
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* Decompresses a base64 encoded (standard or URL-safe alphabet) raw deflate
* string. Returns `null` if the input is corrupt or, when
* `maxDecompressedBytes` is given, if it inflates past that limit (a
* decompression bomb guard for attacker controlled input).
*/
export function decompressFromBase64(
compressed: string,
options?: { maxDecompressedBytes?: number },
) {
const maxDecompressedBytes =
options?.maxDecompressedBytes ?? Number.POSITIVE_INFINITY;
try {
const base64 = compressed.replace(/-/g, "+").replace(/_/g, "/");
const chunks: Array<Uint8Array> = [];
let decompressedBytes = 0;
const inflator = new Inflate((chunk) => {
decompressedBytes += chunk.length;
if (decompressedBytes > maxDecompressedBytes) {
throw new Error("Decompressed value over the maximum size");
}
chunks.push(chunk);
});
inflator.push(
Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)),
true,
);
const value = new TextDecoder().decode(concatChunks(chunks));
if (!value) return null;
return value;
} catch {
return null;
}
}
function concatChunks(chunks: Array<Uint8Array>) {
const totalBytes = chunks.reduce((total, chunk) => total + chunk.length, 0);
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}

View File

@@ -0,0 +1,86 @@
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
getDateAtNextFullHour,
weekNumberToDate,
weekNumberToDateRange,
} from "./dates";
describe("getDateAtNextFullHour", () => {
test("returns a date sitting exactly on a full hour (no leftover minutes/seconds/milliseconds)", () => {
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 30, 0, 500));
expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0));
});
test("advances to the next hour when only seconds/milliseconds are past the hour", () => {
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 30, 0));
expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0));
});
test("keeps the same hour when already exactly on a full hour", () => {
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 0, 0));
expect(result).toEqual(new Date(2024, 0, 1, 14, 0, 0, 0));
});
});
describe("weekNumberToDate", () => {
// Force a timezone west of UTC so the assertion is deterministic regardless
// of where the test happens to run (the bug only manifests west of UTC).
const originalTimezone = process.env.TZ;
beforeAll(() => {
process.env.TZ = "America/Los_Angeles";
});
afterAll(() => {
process.env.TZ = originalTimezone;
});
test("returns the Monday of the ISO week regardless of server timezone", () => {
// ISO week 1 of 2024 starts on Monday 2024-01-01
const start = weekNumberToDate({ week: 1, year: 2024 });
expect(start.toISOString().slice(0, 10)).toBe("2024-01-01");
});
test("returns the Sunday of the ISO week regardless of server timezone", () => {
// ISO week 1 of 2024 ends on Sunday 2024-01-07
const end = weekNumberToDate({ week: 1, year: 2024, position: "end" });
expect(end.toISOString().slice(0, 10)).toBe("2024-01-07");
});
});
describe("weekNumberToDateRange", () => {
// Force a timezone west of UTC observing DST so the assertion is deterministic
// regardless of where the test happens to run.
const originalTimezone = process.env.TZ;
beforeAll(() => {
process.env.TZ = "America/New_York";
});
afterAll(() => {
process.env.TZ = originalTimezone;
});
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
test("spans exactly seven days even when the week contains a DST transition", () => {
// US spring-forward 2025 happened on Sunday 2025-03-09, which falls inside
// ISO week 10 of 2025 (Mon 2025-03-03 .. Mon 2025-03-10).
const { startTime, endTime } = weekNumberToDateRange({
week: 10,
year: 2025,
});
expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS);
});
test("spans exactly seven days for an ordinary week", () => {
const { startTime, endTime } = weekNumberToDateRange({
week: 20,
year: 2025,
});
expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS);
});
});

View File

@@ -0,0 +1,238 @@
import {
CalendarDate,
CalendarDateTime,
parseDate,
} from "@internationalized/date";
import type { Locale } from "date-fns";
import { formatDistanceToNow as dateFnsFormatDistanceToNow } from "date-fns";
import { enUS } from "date-fns/locale/en-US";
import type { MonthYear } from "~/features/plus-voting/core";
import type { LanguageCode } from "~/modules/i18n/config";
import { logger } from "./logger";
import type { DayMonthYear } from "./zod";
// en-US ships with date-fns core as the default locale, so it costs no extra bytes
const LOCALE_LOADERS: Record<LanguageCode, () => Promise<Locale>> = {
da: () => import("date-fns/locale/da").then((module) => module.da),
de: () => import("date-fns/locale/de").then((module) => module.de),
en: () => Promise.resolve(enUS),
"es-ES": () => import("date-fns/locale/es").then((module) => module.es),
"es-US": () => import("date-fns/locale/es").then((module) => module.es),
"fr-CA": () => import("date-fns/locale/fr-CA").then((module) => module.frCA),
"fr-EU": () => import("date-fns/locale/fr").then((module) => module.fr),
he: () => import("date-fns/locale/he").then((module) => module.he),
it: () => import("date-fns/locale/it").then((module) => module.it),
ja: () => import("date-fns/locale/ja").then((module) => module.ja),
ko: () => import("date-fns/locale/ko").then((module) => module.ko),
nl: () => import("date-fns/locale/nl").then((module) => module.nl),
pl: () => import("date-fns/locale/pl").then((module) => module.pl),
"pt-BR": () => import("date-fns/locale/pt-BR").then((module) => module.ptBR),
ru: () => import("date-fns/locale/ru").then((module) => module.ru),
zh: () => import("date-fns/locale/zh-CN").then((module) => module.zhCN),
};
const loadedLocales = new Map<LanguageCode, Locale>();
/**
* Loads the date-fns locale for the given language into the in-memory cache
* used by {@link formatDistanceToNow}. Load failures are logged and result in
* an English fallback instead of rejecting.
*/
export async function loadDateFnsLocale(language: LanguageCode) {
if (loadedLocales.has(language)) return;
const loader = LOCALE_LOADERS[language];
if (!loader) return;
try {
loadedLocales.set(language, await loader());
} catch (error) {
logger.warn(
`Failed to load date-fns locale for language ${language}`,
error,
);
}
}
/** Loads every date-fns locale into the cache (meant for the server where bundle size does not matter). */
export function loadAllDateFnsLocales() {
return Promise.all(
(Object.keys(LOCALE_LOADERS) as LanguageCode[]).map(loadDateFnsLocale),
);
}
/**
* Formats how long ago / until the given date in the given language. The
* language's date-fns locale must be loaded first via
* {@link loadDateFnsLocale}; otherwise falls back to English.
*/
export function formatDistanceToNow(
date: Parameters<typeof dateFnsFormatDistanceToNow>[0],
options: Omit<
NonNullable<Parameters<typeof dateFnsFormatDistanceToNow>[1]>,
"locale"
> & { language: LanguageCode },
) {
return dateFnsFormatDistanceToNow(date, {
...options,
locale: loadedLocales.get(options.language) ?? enUS,
});
}
export function databaseTimestampToDate(timestamp: number) {
return new Date(databaseTimestampToJavascriptTimestamp(timestamp));
}
export function databaseTimestampToJavascriptTimestamp(timestamp: number) {
return timestamp * 1000;
}
export function dateToDatabaseTimestamp(date: Date) {
return Math.floor(date.getTime() / 1000);
}
export function databaseTimestampNow() {
return dateToDatabaseTimestamp(new Date());
}
/**
* Converts a date represented by day, month, and year into a JavaScript Date object, noon UTC.
*/
export function dayMonthYearToDate({ day, month, year }: DayMonthYear) {
return new Date(Date.UTC(year, month, day, 12));
}
/**
* Converts a JavaScript Date object into a CalendarDateTime object (used by react-aria-components).
*/
export function dateToDateValue(date: Date) {
return new CalendarDateTime(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
);
}
/**
* Converts a JavaScript Date object into a CalendarDate object (used by react-aria-components for date-only pickers).
*/
export function dateToCalendarDate(date: Date) {
return new CalendarDate(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
);
}
/**
* Converts a date represented by day, month, and year into a DateValue object (used by react-aria-components), noon UTC.
*/
export function dayMonthYearToDateValue({ day, month, year }: DayMonthYear) {
const isoString = dateToYYYYMMDD(new Date(Date.UTC(year, month, day, 12)));
return parseDate(isoString);
}
/**
* Converts a date represented by day, month, and year into a database timestamp, noon UTC.
*/
export function dayMonthYearToDatabaseTimestamp(args: DayMonthYear) {
return dateToDatabaseTimestamp(dayMonthYearToDate(args));
}
// https://stackoverflow.com/a/71336659
export function weekNumberToDate({
week,
year,
position = "start",
}: {
week: number;
year: number;
/** start = Date of Monday, end = Date of Sunday */
position?: "start" | "end";
}) {
const result = new Date(Date.UTC(year, 0, 4));
result.setUTCDate(
result.getUTCDate() - (result.getUTCDay() || 7) + 1 + 7 * (week - 1),
);
if (position === "end") {
result.setUTCDate(result.getUTCDate() + 6);
}
return result;
}
/**
* Returns the UTC date range covering an ISO week: the Monday that starts the
* week and the Monday that starts the following week (a 7-day span). Uses UTC
* date arithmetic so the span is exactly 7×24h regardless of the server's
* timezone or any DST transition that falls inside the week.
*/
export function weekNumberToDateRange({
week,
year,
}: {
week: number;
year: number;
}) {
const startTime = weekNumberToDate({ week, year });
const endTime = new Date(startTime);
endTime.setUTCDate(endTime.getUTCDate() + 7);
return { startTime, endTime };
}
/**
* Checks if a date is valid or not.
*
* Returns:
* - True if date is valid
* - False otherwise
*/
export function isValidDate(date: Date) {
return !Number.isNaN(date.getTime());
}
export function getDateAtNextFullHour(date: Date) {
const copiedDate = new Date(date.getTime());
if (
date.getMinutes() > 0 ||
date.getSeconds() > 0 ||
date.getMilliseconds() > 0
) {
copiedDate.setHours(date.getHours() + 1);
copiedDate.setMinutes(0);
}
copiedDate.setSeconds(0);
copiedDate.setMilliseconds(0);
return copiedDate;
}
export function dateToYYYYMMDD(date: Date) {
return date.toISOString().split("T")[0];
}
// same as datesOfMonth but contains null at the start to start with monday
export function nullPaddedDatesOfMonth({ month, year }: MonthYear) {
const dates = datesOfMonth({ month, year });
const firstDay = dates[0].getUTCDay();
const nulls = Array.from(
{ length: firstDay === 0 ? 6 : firstDay - 1 },
() => null,
);
return [...nulls, ...dates];
}
function datesOfMonth({ month, year }: MonthYear) {
const dates = [];
const date = new Date(Date.UTC(year, month, 1));
while (date.getUTCMonth() === month) {
dates.push(new Date(date));
date.setUTCDate(date.getUTCDate() + 1);
}
return dates;
}

View File

@@ -0,0 +1,7 @@
// import.meta.env is undefined when Playwright bundles test code, so we need to
// check if it exists before accessing it. There the flag comes from process.env
// instead (set by playwright.config.ts), as app modules run in the test process too.
export const IS_E2E_TEST_RUN =
(typeof import.meta.env !== "undefined" &&
import.meta.env.VITE_E2E_TEST_RUN === "true") ||
(typeof process !== "undefined" && process.env.VITE_E2E_TEST_RUN === "true");

View File

@@ -0,0 +1,20 @@
export class LimitReachedError extends Error {
constructor(message: string) {
super(message);
this.name = "LimitReachedError";
}
}
export class ConcurrentModificationError extends Error {
constructor(message: string) {
super(message);
this.name = "ConcurrentModificationError";
}
}
export class DuplicateEntryError extends Error {
constructor(message: string) {
super(message);
this.name = "DuplicateEntryError";
}
}

View File

@@ -0,0 +1,20 @@
import { logger } from "./logger";
// https://stackoverflow.com/a/50101022
export async function fetchWithTimeout(
input: RequestInfo | URL,
init?: RequestInit | undefined,
timeout = 5000,
) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
logger.error("Fetch timed out");
}, timeout);
const response = await fetch(input, { signal: controller.signal, ...init });
clearTimeout(timeoutId);
return response;
}

View File

@@ -0,0 +1,8 @@
declare const __GIT_COMMIT__: string;
/**
* Commit the running bundle was built from. Inlined at build time, meaning the
* client and the server report their own build's commit. Empty string outside
* of deployed builds (e.g. local development).
*/
export const GIT_COMMIT = __GIT_COMMIT__;

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { countryCodeToTranslatedName } from "./i18n";
describe("countryCodeToTranslatedName()", () => {
test("returns the translated country name for a valid code", () => {
const result = countryCodeToTranslatedName({
countryCode: "FI",
language: "fi",
});
expect(result).toBe("Suomi");
});
test("returns the country name in english if the code contains a dash", () => {
const result = countryCodeToTranslatedName({
countryCode: "GB-WLS",
language: "fi",
});
expect(result).toBe("Wales");
});
test("returns the country code as is for unknown country", () => {
const result = countryCodeToTranslatedName({
countryCode: "UNKNOWN",
language: "en",
});
expect(result).toBe("UNKNOWN");
});
test("defaults to english for unknown language", () => {
const result = countryCodeToTranslatedName({
countryCode: "FI",
language: "unknown",
});
expect(result).toBe("Finland");
});
});

View File

@@ -0,0 +1,135 @@
import type { LanguageCode } from "~/modules/i18n/config";
import type { Namespace } from "~/modules/i18n/resources.server";
import { logger } from "./logger";
import { assertType } from "./types";
// note: cannot get from resources.server.ts directly, because that is a server-only file
const ALL_NAMESPACES = [
"common",
"analyzer",
"badges",
"builds",
"calendar",
"contributions",
"faq",
"forms",
"game-badges",
"game-misc",
"gear",
"user",
"weapons",
"scrims",
"tournament",
"team",
"tier-list-maker",
"vods",
"art",
"q",
"lfg",
"org",
"front",
"friends",
"settings",
"trophies",
"params",
"welcome",
] as const;
assertType<Namespace, (typeof ALL_NAMESPACES)[number]>();
assertType<(typeof ALL_NAMESPACES)[number], Namespace>();
export function allI18nNamespaces() {
return [...ALL_NAMESPACES];
}
/**
* Returns the localized display name for a given ISO country code using the specified language. If the country code is unknown or the function fails for othe reason, returns the country code itself as a fallback.
*
* @example
* ```typescript
* function CountryNameComponent() {
* const { i18n } = useTranslation();
* const countryName = countryCodeToTranslatedName({
* countryCode: "FI",
* language: i18n.language,
* }); // "Suomi" in Finnish
* }
* ```
*/
export function countryCodeToTranslatedName({
countryCode,
language,
}: {
countryCode: string;
language: string;
}) {
if (countryCode === "GB-WLS") return "Wales";
if (countryCode === "GB-SCT") return "Scotland";
if (countryCode === "GB-NIR") return "Northern Ireland";
if (countryCode === "GB-ENG") return "England";
try {
return (
new Intl.DisplayNames([language], { type: "region" }).of(countryCode) ??
countryCode
);
} catch (e) {
logger.error(
`Error getting display name for country code "${countryCode}":`,
e,
);
return countryCode; // fallback to the code itself if display name fails
}
}
/**
* Ordinal placement suffixes per language, keyed by the CLDR ordinal plural
* category given by `Intl.PluralRules(language, { type: "ordinal" })`. A leading
* `^` marks a suffix that should render as superscript. Languages without a
* written ordinal suffix use explicit `null` – rendering no suffix is preferred
* over borrowing the English one.
*/
const ORDINAL_SUFFIXES: Record<
LanguageCode,
Partial<Record<Intl.LDMLPluralRule, string>> | null
> = {
da: null,
he: null,
nl: null,
en: { one: "^st", two: "^nd", few: "^rd", other: "^th" },
"es-ES": { other: "º" },
"es-US": { other: "^o" },
"fr-CA": { one: "^er", other: "^ème" },
"fr-EU": { one: "^er", other: "^ème" },
de: { other: "^." },
it: { many: "^o", other: "^o" },
ja: { other: "位" },
ko: { other: "^등" },
pl: { other: "^." },
"pt-BR": { other: "^º" },
ru: { other: "^ое" },
zh: { other: "名" },
};
const pluralRulesCache = new Map<string, Intl.PluralRules>();
/**
* Returns the localized ordinal suffix for a placement number in the given
* language (e.g. `"^st"` for `1` in English). A leading `^` marks a suffix the
* caller should render as superscript. Returns an empty string for languages
* without a written ordinal suffix.
*/
export function ordinalSuffix(placement: number, language: string): string {
const category = ordinalPluralRules(language).select(placement);
return ORDINAL_SUFFIXES[language as LanguageCode]?.[category] ?? "";
}
function ordinalPluralRules(language: string): Intl.PluralRules {
let rules = pluralRulesCache.get(language);
if (!rules) {
rules = new Intl.PluralRules(language, { type: "ordinal" });
pluralRulesCache.set(language, rules);
}
return rules;
}

View File

@@ -0,0 +1,10 @@
import { nanoid } from "nanoid";
export const SHORT_NANOID_LENGTH = 10;
/**
* Generates a short, unique identifier string (wraps nanoid using a smaller length than the default).
*/
export function shortNanoid() {
return nanoid(SHORT_NANOID_LENGTH);
}

View File

@@ -0,0 +1,12 @@
// adapted from https://github.com/alexreardon/tiny-invariant, changed to show errors in production as well
export default function invariant(
condition: any,
message?: string,
): asserts condition {
if (condition) return;
const addition = message ? `: ${message}` : "";
throw new Error(`Invariant failed${addition}`);
}

View File

@@ -0,0 +1,581 @@
import {
type AliasedRawBuilder,
type ColumnType,
type Expression,
type ExpressionBuilder,
type RawBuilder,
sql,
} from "kysely";
import type {
jsonArrayFrom as sqliteJsonArrayFrom,
jsonBuildObject as sqliteJsonBuildObject,
jsonObjectFrom as sqliteJsonObjectFrom,
} from "kysely/helpers/sqlite";
import { Config } from "~/config";
import {
jsonValuedNode,
jsonValuedSelection,
selectionOutputName,
} from "~/db/json-selections";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { IS_E2E_TEST_RUN } from "./e2e";
import { safeNumberParse } from "./number";
/**
* Base query selecting the user matching a URL identifier, which can be their user id, their Discord
* id or their custom URL. Extend it with the columns the caller needs.
*/
export function userByIdentifierQuery(identifier: string) {
return db
.selectFrom("User")
.select("User.id")
.where((eb) => {
// we don't want to parse discord id's as numbers (length = 18)
const parsedId =
identifier.length < 10 ? safeNumberParse(identifier) : null;
if (parsedId) {
return eb("User.id", "=", parsedId);
}
if (/^\d+$/.test(identifier)) {
return eb("User.discordId", "=", identifier);
}
return eb("User.customUrl", "=", identifier);
});
}
/**
* SQLite expression extracting a Splatoon player's overall peak XP from the denormalized `peakXp`
* JSON column. `"SplatoonPlayer"` must be in scope at the call site.
*/
export function peakXpOverallSql<T extends number | null = number | null>() {
return sql<T>`"SplatoonPlayer"."peakXp" ->> '$.overall'`;
}
type CommonUserSelectOptions = {
alias?: string;
prefix?: string;
idAs?: string;
/** For tournament scoped queries: `username` resolves to {@link tournamentUsername}. */
inTournament?: boolean;
};
type UserTableAlias<O> = O extends { alias: infer A extends string }
? A
: "User";
type PrefixedUserColumn<O, C extends string> = O extends {
prefix: infer P extends string;
}
? `${P}${Capitalize<C>}`
: C;
type UserIdColumn<O> = O extends { idAs: infer I extends string }
? I
: PrefixedUserColumn<O, "id">;
type CommonUserSelectResult<O> = readonly [
`${UserTableAlias<O>}.id as ${UserIdColumn<O>}`,
`${UserTableAlias<O>}.username as ${PrefixedUserColumn<O, "username">}`,
`${UserTableAlias<O>}.discordId as ${PrefixedUserColumn<O, "discordId">}`,
`${UserTableAlias<O>}.discordAvatar as ${PrefixedUserColumn<O, "discordAvatar">}`,
`${UserTableAlias<O>}.customUrl as ${PrefixedUserColumn<O, "customUrl">}`,
AliasedRawBuilder<string | null, PrefixedUserColumn<O, "customAvatarUrl">>,
];
/**
* Select list for the fields shared by every user representation across the app. Includes
* `customAvatarUrl`, the full URL of the user's supporter custom avatar (resolved from
* `User.customAvatarImgId`), or `null` when they have none. By default reads from `"User"` which
* must be in scope at the call site; pass `alias` when the table is joined under another name
* (`alias: "LinkedUser"`), `prefix` to prefix every output column (`prefix: "sender"` →
* `senderId`, `senderUsername`, ...), `idAs` to rename only the id column (`idAs: "userId"`) and
* `inTournament` to resolve `username` via {@link tournamentUsername}.
*/
export function commonUserSelect<const O extends CommonUserSelectOptions>(
eb: ExpressionBuilder<DB, any>,
options?: O,
): CommonUserSelectResult<O> {
const alias = options?.alias ?? "User";
const prefix = options?.prefix;
const outputName = (column: string) =>
prefix ? `${prefix}${column[0].toUpperCase()}${column.slice(1)}` : column;
const idName = options?.idAs ?? outputName("id");
return [
`${alias}.id as ${idName}`,
options?.inTournament
? tournamentUsername(alias).as(outputName("username"))
: `${alias}.username as ${outputName("username")}`,
`${alias}.discordId as ${outputName("discordId")}`,
`${alias}.discordAvatar as ${outputName("discordAvatar")}`,
`${alias}.customUrl as ${outputName("customUrl")}`,
customAvatarUrl(eb, alias).as(outputName("customAvatarUrl")),
] as unknown as CommonUserSelectResult<O>;
}
/**
* SQL expression resolving to the full URL of a user's supporter custom avatar (from
* `User.customAvatarImgId`), or `null` when they have none. Alias it
* (`.as("customAvatarUrl")`) when selecting it directly. Pass `alias` when the `User` table is
* joined under another name. Prefer {@link commonUserSelect} / {@link commonUserJsonObject};
* reach for this only when those don't fit (e.g. a hand-built `jsonBuildObject`).
*/
export function customAvatarUrl(
eb: ExpressionBuilder<DB, any>,
alias = "User",
) {
return concatUserSubmittedImagePrefix(
eb
.selectFrom("UserSubmittedImage")
.select("UserSubmittedImage.url")
.whereRef(
"UserSubmittedImage.id",
"=",
sql.ref(`${alias}.customAvatarImgId`),
)
.$asScalar(),
).$castTo<string | null>();
}
export type CommonUser = Pick<
Tables["User"],
"id" | "username" | "discordId" | "discordAvatar" | "customUrl"
> & { customAvatarUrl: string | null };
/** Represents User joined with PlusTier table */
export type UserWithPlusTier = Tables["User"] & {
plusTier: Tables["PlusTier"]["tier"] | null;
};
const userChatNameHueRaw = sql<
string | null
>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme" ->> '--_chat-h', null)`;
export const userChatNameHue = userChatNameHueRaw.as("chatNameHue");
/**
* The {@link CommonUser} fields as a plain record of Kysely expressions, for spreading into a
* hand-built `jsonBuildObject` alongside extra fields. Prefer {@link commonUserJsonObject} when the
* common fields are the whole object.
*/
export function commonUserObjectFields(eb: ExpressionBuilder<Tables, "User">) {
return {
id: eb.ref("User.id"),
username: eb.ref("User.username"),
discordId: eb.ref("User.discordId"),
discordAvatar: eb.ref("User.discordAvatar"),
customUrl: eb.ref("User.customUrl"),
customAvatarUrl: customAvatarUrl(eb),
};
}
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
return jsonBuildObject(commonUserObjectFields(eb));
}
type ExtractedExpressionTypes<E extends Record<string, Expression<unknown>>> = {
[K in keyof E]: E[K] extends Expression<infer T> ? T : never;
};
/**
* `json_group_array` aggregate building one object per member row from the {@link CommonUser}
* fields plus `extras`. `"User"` must be in scope at the call site. Alias it (`.as("members")`)
* when selecting.
*/
export function commonUserMembersAgg<
E extends Record<string, Expression<unknown>>,
>(eb: ExpressionBuilder<DB, any>, extras: E) {
return eb.fn
.agg("json_group_array", [
jsonBuildObject({
...commonUserObjectFields(
eb as unknown as ExpressionBuilder<Tables, "User">,
),
...extras,
}),
])
.$castTo<Array<CommonUser & ExtractedExpressionTypes<E>>>();
}
const USER_SUBMITTED_IMAGE_ROOT =
(process.env.NODE_ENV === "development" && !Config.prodMode) ||
IS_E2E_TEST_RUN ||
process.env.NODE_ENV === "test"
? "http://127.0.0.1:9000/sendou"
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
/**
* Constructs a SQL expression that returns the full URL for a tournament's logo.
* If the tournament has a custom logo (via avatarImgId), returns that logo's URL.
* Otherwise, returns null.
*
* @returns A SQL expression that concatenates the image root URL with either the custom logo URL or default logo
*/
export function tournamentLogoOrNull(
eb: ExpressionBuilder<Tables, "CalendarEvent">,
) {
return eb.fn<string | null>("iif", [
eb("CalendarEvent.avatarImgId", "is not", null),
eb.fn<string>("concat", [
sql.lit(`${USER_SUBMITTED_IMAGE_ROOT}/`),
eb
.selectFrom("UnvalidatedUserSubmittedImage")
.select(["UnvalidatedUserSubmittedImage.url"])
.whereRef(
"CalendarEvent.avatarImgId",
"=",
"UnvalidatedUserSubmittedImage.id",
),
]),
sql`null`,
]);
}
/**
* Constructs a SQL expression that returns the full URL for a tournament's logo.
* If the tournament has a custom logo (via avatarImgId), returns that logo's URL.
* Otherwise, falls back to the default tournament logo.
*
* @returns A SQL expression that concatenates the image root URL with either the custom logo URL or default logo
*/
export function tournamentLogoWithDefault(
eb: ExpressionBuilder<Tables, "CalendarEvent">,
) {
return concatUserSubmittedImagePrefix(
eb.fn.coalesce(
eb
.selectFrom("UnvalidatedUserSubmittedImage")
.select("UnvalidatedUserSubmittedImage.url")
.whereRef(
"CalendarEvent.avatarImgId",
"=",
"UnvalidatedUserSubmittedImage.id",
)
.$asScalar(),
sql.lit(Config.tournamentDefaultLogo),
),
);
}
/**
* Subquery resolving to the event's earliest `CalendarEventDate` start time, or `null` when it has
* no dates. Correlates on `"CalendarEvent"."id"`. Alias it `.as("startTime")` when selecting it
* directly. Can also be passed to `orderBy` as is.
*/
export function calendarEventStartTime(
eb: ExpressionBuilder<Tables, "CalendarEvent">,
) {
return eb
.selectFrom("CalendarEventDate")
.select((eb2) => eb2.fn.min<number>("startsAt").as("startsAt"))
.whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id");
}
/**
* Subquery counting a tournament's non-placeholder teams. Correlates on `"Tournament"."id"`.
* Alias it `.as("teamsCount")` when selecting it directly.
*/
export function tournamentTeamCount(
eb: ExpressionBuilder<Tables, "Tournament">,
) {
return eb
.selectFrom("TournamentTeam")
.select((eb2) => eb2.fn.countAll<number>().as("count"))
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
.where("TournamentTeam.isPlaceholder", "=", 0);
}
/** Expression resolving to whether any of a tournament's brackets has been started. */
function tournamentHasStarted(eb: ExpressionBuilder<DB, "Tournament">) {
return eb.exists(
eb
.selectFrom("TournamentStage")
.select("TournamentStage.id")
.whereRef("TournamentStage.tournamentId", "=", "Tournament.id"),
);
}
/**
* Subquery resolving to the non-placeholder teams of a tournament that are still relevant to it:
* every registered team as long as no bracket has been started, only the checked in ones after
* that. Mirrors how the tournament page itself resolves its teams, so keep the two in sync.
* Correlates on `"Tournament"."id"`. Has no select of its own, so extend it with the aggregate the
* caller needs. A team can have several check in rows, so aggregate with `.distinct()`, e.g.
* `.select(({ fn }) => fn.count("TournamentTeam.id").distinct().as("count"))`.
*/
function tournamentCheckedInTeams(eb: ExpressionBuilder<DB, "Tournament">) {
return eb
.selectFrom("TournamentTeam")
.leftJoin(
"TournamentTeamCheckIn",
"TournamentTeamCheckIn.tournamentTeamId",
"TournamentTeam.id",
)
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
.where("TournamentTeam.isPlaceholder", "=", 0)
.where((eb2) =>
eb2.or([
eb2("TournamentTeamCheckIn.checkedInAt", "is not", null),
eb2.not(tournamentHasStarted(eb)),
]),
);
}
/**
* Subquery counting the teams of {@link tournamentCheckedInTeams}. Correlates on
* `"Tournament"."id"`. Alias it `.as("teamsCount")` when selecting it directly.
*/
export function tournamentTeamsCount(eb: ExpressionBuilder<DB, "Tournament">) {
return tournamentCheckedInTeams(eb).select(({ fn }) => [
fn.count<number>("TournamentTeam.id").distinct().as("count"),
]);
}
/**
* Expression resolving to a tournament's participant count: rostered players of the teams from
* {@link tournamentCheckedInTeams} while the tournament is still ongoing, players who actually got
* a result once it has been finalized. Correlates on `"Tournament"."id"`. Alias it
* `.as("membersCount")` when selecting it directly.
*/
export function tournamentMembersCount(
eb: ExpressionBuilder<DB, "Tournament">,
) {
return eb
.case()
.when("Tournament.isFinalized", "=", 1)
.then(
eb
.selectFrom("TournamentResult")
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
.select(({ fn }) => [
fn.count<number>("TournamentResult.userId").distinct().as("count"),
]),
)
.else(
tournamentCheckedInTeams(eb)
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.select(({ fn }) => [
fn
.count<number>("TournamentTeamMember.userId")
.distinct()
.as("count"),
]),
)
.end();
}
/**
* Grouped subquery picking each user's (`by: "userId"`) or team's (`by: "identifier"`) latest
* Skill row of a season: `latestId` plus that row's `ordinal`, `matchesCount` and the `by`
* column. Wrap it with `.selectFrom(latestSkillPerSeason(...).as("Latest"))`; extra `.where`s
* compose before aliasing.
*/
export function latestSkillPerSeason<By extends "userId" | "identifier">({
season,
by,
}: {
season: number;
by: By;
}) {
// The latest row per user/team is picked via SQLite's bare column rule: with a `max()`
// aggregate the other selected columns come from the row that produced the max.
// A self-join against a `max(id)` subquery is avoided because it lets the planner
// pick a nested-loop plan when it misjudges the season's row count (e.g. a freshly
// started season whose stats are dwarfed by older seasons), which made this query
// take ~12s. This form is plan-stable regardless of stats: a single grouped scan of
// the `skill_season_user_id_leaderboard` / `skill_season_identifier_leaderboard`
// covering index, no temp b-tree per partition.
return db
.selectFrom("Skill")
.select(({ fn }) => [
fn.max("Skill.id").as("latestId"),
"Skill.ordinal" as const,
"Skill.matchesCount" as const,
`Skill.${by}` as `Skill.${By}`,
])
.where("Skill.season", "=", season)
.where(`Skill.${by}`, "is not", null)
.groupBy(`Skill.${by}`);
}
/**
* Predicate for `Skill` rows of the user that represent a played set: either a SendouQ match or
* a ranked tournament the user has a result in. Filters out e.g. skills of tournament teams the
* user dropped from before results. `"Skill"` must be in scope at the call site.
*/
export function skillCountsAsSeasonSet(
eb: ExpressionBuilder<DB, "Skill">,
userId: number,
) {
return eb.or([
eb("Skill.groupMatchId", "is not", null),
eb.exists(
eb
.selectFrom("TournamentResult")
.select("TournamentResult.userId")
.whereRef("TournamentResult.tournamentId", "=", "Skill.tournamentId")
.where("TournamentResult.userId", "=", userId),
),
]);
}
/** Concats the file name (a bit misleadingly called `url` in the DB schema) with the root URL, giving the full URL for the image */
export function concatUserSubmittedImagePrefix<T extends string | null>(
expr: Expression<T>,
) {
// null-propagating || instead of iif(expr is not null, concat(...), null)
// so a correlated subquery passed as expr is evaluated only once per row
return sql<T extends null ? string | null : string>`(${sql.lit(
`${USER_SUBMITTED_IMAGE_ROOT}/`,
)} || ${expr})`;
}
export type JSONColumnTypeNullable<
SelectType extends object | string | number | null,
> = ColumnType<SelectType | null, string | null, string | null>;
const TEN_STAR_CASE = sql<number>`case when "TenStarWeapon"."weaponSplId" is not null then 1 else 0 end`;
/** Match profile weapons (from UserWeaponPool) with TenStarWeapon join. Correlates on "User"."id". */
export function matchProfileWeapons(eb: ExpressionBuilder<DB, any>) {
return jsonArrayFrom(
eb
.selectFrom("UserWeaponPool")
.leftJoin("TenStarWeapon", (join) =>
join
.onRef("TenStarWeapon.userId", "=", "UserWeaponPool.userId")
.onRef(
"TenStarWeapon.weaponSplId",
"=",
"UserWeaponPool.weaponSplId",
),
)
.select([
"UserWeaponPool.weaponSplId",
"UserWeaponPool.isFavorite",
TEN_STAR_CASE.as("isTenStar"),
])
.whereRef("UserWeaponPool.userId", "=", "User.id")
.orderBy("UserWeaponPool.sortOrder", "asc"),
);
}
/** User profile weapons (from UserWeapon) with TenStarWeapon join. Correlates on "User"."id". */
export function userProfileWeapons(eb: ExpressionBuilder<DB, any>) {
return jsonArrayFrom(
eb
.selectFrom("UserWeapon")
.leftJoin("TenStarWeapon", (join) =>
join
.onRef("TenStarWeapon.userId", "=", "UserWeapon.userId")
.onRef("TenStarWeapon.weaponSplId", "=", "UserWeapon.weaponSplId"),
)
.select([
"UserWeapon.weaponSplId",
"UserWeapon.isFavorite",
TEN_STAR_CASE.as("isTenStar"),
])
.whereRef("UserWeapon.userId", "=", "User.id")
.orderBy("UserWeapon.order", "asc"),
);
}
/**
* The name a user is shown under inside tournaments: the name organizers have given them
* (`User.tournamentName`) falling back to their `username`. Alias it (`.as("username")`) when
* selecting it directly. Prefer `commonUserSelect(eb, { inTournament: true })`; reach for this
* only when the query doesn't select the common user fields.
*/
export function tournamentUsername(alias = "User") {
return sql<string>`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref(
`${alias}.username`,
)})`;
}
type SelectQueryBuilderExpression<O> = Parameters<
typeof sqliteJsonArrayFrom<O>
>[0];
/**
* Drop-in replacement for kysely's sqlite `jsonArrayFrom`. Emits the same query, except
* JSON-valued selections (per {@link jsonValuedSelection}: JSON columns, nested json helpers) get
* `json(...)` applied at the `json_object` argument position. SQLite's JSON subtype never
* survives a subquery boundary, so without the re-tag such values would be embedded as
* strings; the dialect parses each result column exactly once and relies on documents
* arriving fully nested. Always use this over the kysely one.
*/
export function jsonArrayFrom<O>(
expr: SelectQueryBuilderExpression<O>,
): ReturnType<typeof sqliteJsonArrayFrom<O>> {
return sql`(select coalesce(json_group_array(json_object(${sql.join(
jsonObjectArgs(expr, "agg"),
)})), '[]') from ${expr} as agg)` as ReturnType<
typeof sqliteJsonArrayFrom<O>
>;
}
/** Drop-in replacement for kysely's sqlite `jsonObjectFrom`, see {@link jsonArrayFrom}. */
export function jsonObjectFrom<O>(
expr: SelectQueryBuilderExpression<O>,
): ReturnType<typeof sqliteJsonObjectFrom<O>> {
return sql`(select json_object(${sql.join(
jsonObjectArgs(expr, "obj"),
)}) from ${expr} as obj)` as ReturnType<typeof sqliteJsonObjectFrom<O>>;
}
/** Drop-in replacement for kysely's sqlite `jsonBuildObject`, see {@link jsonArrayFrom}. */
export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
obj: O,
): ReturnType<typeof sqliteJsonBuildObject<O>> {
return sql`json_object(${sql.join(
Object.keys(obj).flatMap((key) => [
sql.lit(key),
jsonValuedNode(obj[key].toOperationNode())
? sql`json(${obj[key]})`
: obj[key],
]),
)})` as ReturnType<typeof sqliteJsonBuildObject<O>>;
}
/**
* Re-tags a JSON-valued expression with SQLite's `json()` so it stays a nested document
* (instead of an escaped string) inside {@link jsonBuildObject}/{@link jsonArrayFrom}.
* Only needed for expressions the helpers can not recognize as JSON on their own, e.g. a
* raw `IIF(...)` over a JSON column.
*/
export function asJson<T>(expr: Expression<T>): RawBuilder<T> {
return sql<T>`json(${expr})`;
}
function jsonObjectArgs(
expr: SelectQueryBuilderExpression<unknown>,
table: string,
) {
const args: Expression<unknown>[] = [];
for (const { selection } of expr.toOperationNode().selections ?? []) {
const name = selectionOutputName(selection);
if (!name) {
throw new Error(
"jsonArrayFrom and jsonObjectFrom can only handle explicit selections. selectAll() is not allowed in the subquery.",
);
}
const ref = sql.ref(`${table}.${name}`);
args.push(
sql.lit(name),
jsonValuedSelection(selection) ? sql`json(${ref})` : ref,
);
}
return args;
}

View File

@@ -0,0 +1,44 @@
/** biome-ignore-all lint/suspicious/noConsole: stub file to enable different solution later */
import { getSessionId as getClientSessionId } from "./session-id";
declare global {
var __getServerSessionId: (() => string | undefined) | undefined;
}
function getSessionIdForLog(): string {
if (typeof window !== "undefined") {
return getClientSessionId();
}
return globalThis.__getServerSessionId?.() ?? "no-session";
}
let infoLogsSilenced = false;
function formatLog(...args: unknown[]) {
const sessionId = getSessionIdForLog();
return [`[${sessionId}]`, ...args];
}
export const logger = {
info: (...args: unknown[]) => {
if (infoLogsSilenced) return;
console.log(...formatLog(...args));
},
error: (...args: unknown[]) => console.error(...formatLog(...args)),
warn: (...args: unknown[]) => console.warn(...formatLog(...args)),
debug: (...args: unknown[]) => {
if (process.env.NODE_ENV === "production") return;
console.debug(...formatLog(...args));
},
};
/** Runs `fn` without its `logger.info` output, warnings and errors still logging. */
export async function withoutInfoLogs<T>(fn: () => Promise<T>): Promise<T> {
infoLogsSilenced = true;
try {
return await fn();
} finally {
infoLogsSilenced = false;
}
}

View File

@@ -0,0 +1,80 @@
import * as R from "remeda";
/**
* Rounds a number to a specified number of decimal places.
*
* @example
* ```typescript
* roundToNDecimalPlaces(3.14159); // returns 3.14
* roundToNDecimalPlaces(3.14159, 3); // returns 3.142
* roundToNDecimalPlaces(2.5, 0); // returns 3
* ```
*/
export function roundToNDecimalPlaces(num: number, n = 2) {
return Number((Math.round(num * 10 ** n) / 10 ** n).toFixed(n));
}
/**
* Truncates a number to a specified number of decimal places without rounding.
*
* @example
* ```typescript
* cutToNDecimalPlaces(3.9999, 2); // returns 3.99
* cutToNDecimalPlaces(3.12, 1); // returns 3.1
* cutToNDecimalPlaces(100, 2); // returns 100
* cutToNDecimalPlaces(3.0001, 2); // returns 3
* ```
*/
export function cutToNDecimalPlaces(num: number, n = 2) {
const multiplier = 10 ** n;
// Round away floating point representation error (e.g. 0.29 * 100 = 28.999...) before truncating
const scaled = Number((num * multiplier).toFixed(8));
const truncatedNum = Math.trunc(scaled) / multiplier;
const result = truncatedNum.toFixed(n);
return Number(n > 0 ? result.replace(/\.?0+$/, "") : result);
}
/**
* Calculates the average (arithmetic mean) of an array of numbers.
* Returns 0 if the array is empty.
*
* @example
* ```typescript
* averageArray([2, 4, 6, 8]); // returns 5
* averageArray([-2, -4, -6, -8]); // returns -5
* averageArray([10, -10, 20, -20]); // returns 0
* averageArray([42]); // returns 42
* averageArray([]); // returns 0
* ```
*/
export function averageArray(arr: number[]) {
if (arr.length === 0) return 0;
return R.sum(arr) / arr.length;
}
/**
* Safely parses a string into a number, returning `null` if the input is `null`,
* empty, or not a valid number.
*
* Trims whitespace from the input before parsing. If the trimmed string is empty
* or cannot be converted to a valid number, returns `null`.
*
* @example
* ```typescript
* safeNumberParse("42"); // returns 42
* safeNumberParse(" 3.14 "); // returns 3.14
* safeNumberParse(""); // returns null
* safeNumberParse("abc"); // returns null
* safeNumberParse(null); // returns null
* ```
*/
export function safeNumberParse(value: string | null) {
if (value === null) return null;
const trimmed = value.trim();
if (trimmed === "") return null;
const result = Number(trimmed);
return Number.isNaN(result) ? null : result;
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import {
averageArray,
cutToNDecimalPlaces,
roundToNDecimalPlaces,
safeNumberParse,
} from "./number";
describe("roundToNDecimalPlaces()", () => {
test.each([
[1.234, 1.23],
[1.235, 1.24],
[1.2, 1.2],
[1, 1],
])("rounds %d to %d with the default 2 decimal places", (input, expected) => {
expect(roundToNDecimalPlaces(input)).toBe(expected);
});
test.each([
[1.6, 0, 2],
[1.4, 0, 1],
[2.5, 0, 3],
[1.23456, 3, 1.235],
[1.23444, 3, 1.234],
[-1.2345, 2, -1.23],
[-1.2355, 2, -1.24],
[0, 2, 0],
[0, 0, 0],
[123456.789, 1, 123456.8],
[123456.789, 0, 123457],
])("rounds %d to %d decimal places as %d", (input, decimals, expected) => {
expect(roundToNDecimalPlaces(input, decimals)).toBe(expected);
});
});
describe("cutToNDecimalPlaces()", () => {
test.each([
[3.9999, 2, 3.99],
[3.12, 1, 3.1],
[100, 2, 100],
[3.0001, 2, 3],
[0.29, 2, 0.29],
// values whose binary representation is just below the decimal shown
[2.32, 2, 2.32],
[-0.29, 2, -0.29],
])("cuts %d to %d decimal places as %d", (input, decimals, expected) => {
expect(cutToNDecimalPlaces(input, decimals)).toBe(expected);
});
});
describe("averageArray()", () => {
test.each([
[[2, 4, 6, 8], 5],
[[-2, -4, -6, -8], -5],
[[10, -10, 20, -20], 0],
[[42], 42],
[[], 0],
])("averages %j to %d", (input, expected) => {
expect(averageArray(input)).toBe(expected);
});
});
describe("safeNumberParse()", () => {
test.each([
["42", 42],
["3.14", 3.14],
[" 7 ", 7],
["-123", -123],
["0", 0],
["abc", null],
["", null],
[" ", null],
[null, null],
])("parses %j as %j", (input, expected) => {
expect(safeNumberParse(input)).toBe(expected);
});
});

View File

@@ -0,0 +1,339 @@
import type { z } from "zod";
import type { CustomTheme } from "~/db/tables-json";
import type { themeInputSchema } from "~/utils/zod";
export type ThemeInput = z.infer<typeof themeInputSchema>;
interface Lab {
L: number;
a: number;
b: number;
}
interface RGB {
r: number;
g: number;
b: number;
}
interface LC {
L: number;
C: number;
}
function oklab_to_linear_srgb(c: Lab): RGB {
const l_ = c.L + 0.3963377774 * c.a + 0.2158037573 * c.b;
const m_ = c.L - 0.1055613458 * c.a - 0.0638541728 * c.b;
const s_ = c.L - 0.0894841775 * c.a - 1.291485548 * c.b;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
return {
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
};
}
function compute_max_saturation(a: number, b: number): number {
let k0: number;
let k1: number;
let k2: number;
let k3: number;
let k4: number;
let wl: number;
let wm: number;
let ws: number;
if (-1.88170328 * a - 0.80936493 * b > 1) {
k0 = +1.19086277;
k1 = +1.76576728;
k2 = +0.59662641;
k3 = +0.75515197;
k4 = +0.56771245;
wl = +4.0767416621;
wm = -3.3077115913;
ws = +0.2309699292;
} else if (1.81444104 * a - 1.19445276 * b > 1) {
k0 = +0.73956515;
k1 = -0.45954404;
k2 = +0.08285427;
k3 = +0.1254107;
k4 = +0.14503204;
wl = -1.2684380046;
wm = +2.6097574011;
ws = -0.3413193965;
} else {
k0 = +1.35733652;
k1 = -0.00915799;
k2 = -1.1513021;
k3 = -0.50559606;
k4 = +0.00692167;
wl = -0.0041960863;
wm = -0.7034186147;
ws = +1.707614701;
}
let S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
const k_l = +0.3963377774 * a + 0.2158037573 * b;
const k_m = -0.1055613458 * a - 0.0638541728 * b;
const k_s = -0.0894841775 * a - 1.291485548 * b;
{
const l_ = 1 + S * k_l;
const m_ = 1 + S * k_m;
const s_ = 1 + S * k_s;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
const l_dS = 3 * k_l * l_ * l_;
const m_dS = 3 * k_m * m_ * m_;
const s_dS = 3 * k_s * s_ * s_;
const l_dS2 = 6 * k_l * k_l * l_;
const m_dS2 = 6 * k_m * k_m * m_;
const s_dS2 = 6 * k_s * k_s * s_;
const f = wl * l + wm * m + ws * s;
const f1 = wl * l_dS + wm * m_dS + ws * s_dS;
const f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2;
S = S - (f * f1) / (f1 * f1 - 0.5 * f * f2);
}
return S;
}
function find_cusp(a: number, b: number): LC {
const S_cusp = compute_max_saturation(a, b);
const rgb_at_max = oklab_to_linear_srgb({
L: 1,
a: S_cusp * a,
b: S_cusp * b,
});
const L_cusp = Math.cbrt(
1 / Math.max(rgb_at_max.r, rgb_at_max.g, rgb_at_max.b),
);
const C_cusp = L_cusp * S_cusp;
return { L: L_cusp, C: C_cusp };
}
function find_gamut_intersection(
a: number,
b: number,
L1: number,
C1: number,
L0: number,
): number {
const cusp = find_cusp(a, b);
let t: number;
if ((L1 - L0) * cusp.C - (cusp.L - L0) * C1 <= 0) {
t = (cusp.C * L0) / (C1 * cusp.L + cusp.C * (L0 - L1));
} else {
t = (cusp.C * (L0 - 1)) / (C1 * (cusp.L - 1) + cusp.C * (L0 - L1));
{
const dL = L1 - L0;
const dC = C1;
const k_l = +0.3963377774 * a + 0.2158037573 * b;
const k_m = -0.1055613458 * a - 0.0638541728 * b;
const k_s = -0.0894841775 * a - 1.291485548 * b;
const l_dt = dL + dC * k_l;
const m_dt = dL + dC * k_m;
const s_dt = dL + dC * k_s;
{
const L = L0 * (1 - t) + t * L1;
const C = t * C1;
const l_ = L + C * k_l;
const m_ = L + C * k_m;
const s_ = L + C * k_s;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
const ldt = 3 * l_dt * l_ * l_;
const mdt = 3 * m_dt * m_ * m_;
const sdt = 3 * s_dt * s_ * s_;
const ldt2 = 6 * l_dt * l_dt * l_;
const mdt2 = 6 * m_dt * m_dt * m_;
const sdt2 = 6 * s_dt * s_dt * s_;
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1;
const r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt;
const r2 =
4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2;
const u_r = r1 / (r1 * r1 - 0.5 * r * r2);
let t_r = -r * u_r;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1;
const g1 =
-1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt;
const g2 =
-1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2;
const u_g = g1 / (g1 * g1 - 0.5 * g * g2);
let t_g = -g * u_g;
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s - 1;
const b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.707614701 * sdt;
const b2 =
-0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.707614701 * sdt2;
const u_b = b1 / (b1 * b1 - 0.5 * b * b2);
let t_b = -b * u_b;
t_r = u_r >= 0 ? t_r : Number.MAX_VALUE;
t_g = u_g >= 0 ? t_g : Number.MAX_VALUE;
t_b = u_b >= 0 ? t_b : Number.MAX_VALUE;
t += Math.min(t_r, Math.min(t_g, t_b));
}
}
}
return t;
}
function clamp(x: number, min: number, max: number): number {
return x < min ? min : x > max ? max : x;
}
function maximum_chroma_for_lh(L: number, h: number): number {
const a = Math.cos(h);
const b = Math.sin(h);
const L0 = clamp(L, 0, 1);
const t = find_gamut_intersection(a, b, L, 1, L0);
return t;
}
// These are the lightness values used in vars.css
// Any changes here NEED to be reflected in vars.css as well.
const BASE_LIGHTNESS_VALUES = [
1.0, // --base-c-0
0.95, // --base-c-1
0.9, // --base-c-2
0.64, // --base-c-3
0.46, // --base-c-4
0.32, // --base-c-5
0.25, // --base-c-6
0.17, // --base-c-7
] as const;
const ACCENT_LIGHTNESS_VALUES = [
0.26, // --acc-c-0: dark mode low
0.52, // --acc-c-1: dark mode mid
0.83, // --acc-c-2: dark mode high
0.88, // --acc-c-3: light mode low
0.53, // --acc-c-4: light mode mid
0.32, // --acc-c-5: light mode high
] as const;
export const BASE_CHROMA_MULTIPLIERS = [
0.01, // --base-c-0
0.49, // --base-c-1
0.62, // --base-c-2
1.4, // --base-c-3
1.29, // --base-c-4
1.36, // --base-c-5
1.29, // --base-c-6
0.67, // --base-c-7
] as const;
export const ACCENT_CHROMA_MULTIPLIERS = [
0.38, // --acc-c-0
1.11, // --acc-c-1
0.34, // --acc-c-2
0.25, // --acc-c-3
1.09, // --acc-c-4
0.56, // --acc-c-5
] as const;
function clampChromaForColor(
lightness: number,
desiredChroma: number,
hueRadians: number,
): number {
const maxChroma = maximum_chroma_for_lh(lightness, hueRadians);
return Math.min(desiredChroma, maxChroma);
}
export function clampThemeToGamut(input: ThemeInput): CustomTheme {
const baseHueRadians = input.baseHue * (Math.PI / 180);
const accentHueRadians = input.accentHue * (Math.PI / 180);
const clampedBaseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => {
const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index];
return clampChromaForColor(lightness, desiredChroma, baseHueRadians);
});
const clampedAccentChromas = ACCENT_LIGHTNESS_VALUES.map(
(lightness, index) => {
const desiredChroma =
input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index];
return clampChromaForColor(lightness, desiredChroma, accentHueRadians);
},
);
const secondaryHue = (input.accentHue + 180) % 360;
const secondaryHueRadians = secondaryHue * (Math.PI / 180);
const clampedSecondaryChromas = ACCENT_LIGHTNESS_VALUES.map(
(lightness, index) => {
const desiredChroma =
input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index];
return clampChromaForColor(lightness, desiredChroma, secondaryHueRadians);
},
);
return {
"--_base-h": input.baseHue,
"--_base-c-0": clampedBaseChromas[0],
"--_base-c-1": clampedBaseChromas[1],
"--_base-c-2": clampedBaseChromas[2],
"--_base-c-3": clampedBaseChromas[3],
"--_base-c-4": clampedBaseChromas[4],
"--_base-c-5": clampedBaseChromas[5],
"--_base-c-6": clampedBaseChromas[6],
"--_base-c-7": clampedBaseChromas[7],
"--_acc-h": input.accentHue,
"--_acc-c-0": clampedAccentChromas[0],
"--_acc-c-1": clampedAccentChromas[1],
"--_acc-c-2": clampedAccentChromas[2],
"--_acc-c-3": clampedAccentChromas[3],
"--_acc-c-4": clampedAccentChromas[4],
"--_acc-c-5": clampedAccentChromas[5],
"--_second-h": secondaryHue,
"--_second-c-0": clampedSecondaryChromas[0],
"--_second-c-1": clampedSecondaryChromas[1],
"--_second-c-2": clampedSecondaryChromas[2],
"--_second-c-3": clampedSecondaryChromas[3],
"--_second-c-4": clampedSecondaryChromas[4],
"--_second-c-5": clampedSecondaryChromas[5],
"--_chat-h": input.chatHue,
"--_radius-box": input.radiusBox,
"--_radius-field": input.radiusField,
"--_radius-selector": input.radiusSelector,
"--_border-width": input.borderWidth,
"--_size-field": input.sizeField,
"--_size-selector": input.sizeSelector,
"--_size-spacing": input.sizeSpacing,
};
}

View File

@@ -0,0 +1,142 @@
import { describe, expect, test } from "vitest";
import { seededRandom } from "./random";
describe("seededRandom", () => {
describe("random", () => {
test("produces same values for same seed", () => {
const rng1 = seededRandom("test-seed");
const rng2 = seededRandom("test-seed");
expect(rng1.random()).toBe(rng2.random());
expect(rng1.random()).toBe(rng2.random());
expect(rng1.random()).toBe(rng2.random());
});
test("produces different values for different seeds", () => {
const rng1 = seededRandom("seed-1");
const rng2 = seededRandom("seed-2");
expect(rng1.random()).not.toBe(rng2.random());
});
test("returns values between 0 and 1 by default", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.random();
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(1);
}
});
test("returns values between lo and hi when both provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.random(5, 10);
expect(value).toBeGreaterThanOrEqual(5);
expect(value).toBeLessThan(10);
}
});
test("returns values between 0 and hi when only hi provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.random(5);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(5);
}
});
});
describe("randomInteger", () => {
test("produces same values for same seed", () => {
const rng1 = seededRandom("test-seed");
const rng2 = seededRandom("test-seed");
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
});
test("produces different values for different seeds", () => {
const rng1 = seededRandom("seed-1");
const rng2 = seededRandom("seed-2");
expect(rng1.randomInteger(100)).not.toBe(rng2.randomInteger(100));
});
test("returns integers between 0 and hi when only hi provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.randomInteger(10);
expect(Number.isInteger(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(10);
}
});
test("returns integers between lo and hi when both provided", () => {
const rng = seededRandom("test");
for (let i = 0; i < 100; i++) {
const value = rng.randomInteger(5, 10);
expect(Number.isInteger(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(5);
expect(value).toBeLessThan(10);
}
});
});
describe("seededShuffle", () => {
test("produces same shuffle for same seed", () => {
const array = [1, 2, 3, 4, 5];
const rng1 = seededRandom("test-seed");
const rng2 = seededRandom("test-seed");
expect(rng1.seededShuffle(array)).toEqual(rng2.seededShuffle(array));
});
test("produces different shuffles for different seeds", () => {
const array = [1, 2, 3, 4, 5];
const rng1 = seededRandom("seed-1");
const rng2 = seededRandom("seed-2");
expect(rng1.seededShuffle(array)).not.toEqual(rng2.seededShuffle(array));
});
test("does not mutate original array", () => {
const array = [1, 2, 3, 4, 5];
const original = [...array];
const rng = seededRandom("test");
rng.seededShuffle(array);
expect(array).toEqual(original);
});
test("returns array with same elements", () => {
const array = [1, 2, 3, 4, 5];
const rng = seededRandom("test");
const shuffled = rng.seededShuffle(array);
expect(shuffled.sort()).toEqual(array.sort());
});
test("handles empty array", () => {
const array: number[] = [];
const rng = seededRandom("test");
const shuffled = rng.seededShuffle(array);
expect(shuffled).toEqual([]);
});
test("handles single element array", () => {
const array = [1];
const rng = seededRandom("test");
const shuffled = rng.seededShuffle(array);
expect(shuffled).toEqual([1]);
});
});
});

View File

@@ -0,0 +1,78 @@
function cyrb128(str: string) {
let h1 = 1779033703;
let h2 = 3144134277;
let h3 = 1013904242;
let h4 = 2773480762;
// biome-ignore lint/suspicious/noImplicitAnyLet: biome migration
for (let i = 0, k; i < str.length; i++) {
k = str.charCodeAt(i);
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
}
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
return [
(h1 ^ h2 ^ h3 ^ h4) >>> 0,
(h2 ^ h1) >>> 0,
(h3 ^ h1) >>> 0,
(h4 ^ h1) >>> 0,
];
}
function mulberry32(a: number) {
return () => {
// biome-ignore lint/suspicious/noAssignInExpressions: biome migration
// biome-ignore lint/style/noParameterAssign: biome migration
let t = (a += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Creates a seeded pseudo-random number generator that produces consistent results for the same seed.
* Uses mulberry32 algorithm with cyrb128 hash function for string-to-number conversion.
*
* @param seed - String seed value (e.g., "2025-1-8" for daily rotation)
* @returns Object with random number generation methods:
* - `random(lo?, hi?)` - Returns random float between lo (inclusive) and hi (exclusive)
* - `randomInteger(lo, hi?)` - Returns random integer between lo (inclusive) and hi (exclusive)
* - `seededShuffle(array)` - Returns shuffled copy of array using seeded Fisher-Yates algorithm
*
* @example
* const { seededShuffle } = seededRandom("2025-1-8");
* const shuffled = seededShuffle([1, 2, 3, 4, 5]);
*/
export const seededRandom = (seed: string) => {
const rng = mulberry32(cyrb128(seed)[0]);
const random = (lo?: number, hi?: number, defaultHi = 1) => {
const actualLo = hi === undefined ? 0 : (lo ?? 0);
const actualHi = hi === undefined ? (lo ?? defaultHi) : hi;
return rng() * (actualHi - actualLo) + actualLo;
};
const randomInteger = (lo: number, hi?: number) =>
Math.floor(random(lo, hi, 2));
const seededShuffle = <T>(o: T[]) => {
const a = o.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = randomInteger(i + 1);
const x = a[i];
a[i] = a[j]!;
a[j] = x!;
}
return a;
};
return { random, randomInteger, seededShuffle };
};

View File

@@ -0,0 +1,114 @@
import { describe, expect, test } from "vitest";
import { paginate } from "./remix.server";
const buildUrl = (url: string) => new URL(url);
const captureRedirect = (fn: () => void) => {
try {
fn();
} catch (thrown) {
if (thrown instanceof Response) return thrown;
throw thrown;
}
return null;
};
describe("paginate()", () => {
test("returns the page count rounded up", () => {
const result = paginate({
url: buildUrl("https://sendou.ink/vods?page=1"),
page: 1,
pageSize: 10,
totalCount: 41,
});
expect(result).toEqual({ currentPage: 1, pagesCount: 5 });
});
test("does not redirect when page is within bounds", () => {
const response = captureRedirect(() =>
paginate({
url: buildUrl("https://sendou.ink/vods?page=2"),
page: 2,
pageSize: 10,
totalCount: 50,
}),
);
expect(response).toBeNull();
});
test("does not redirect when page equals pagesCount", () => {
const response = captureRedirect(() =>
paginate({
url: buildUrl("https://sendou.ink/vods?page=5"),
page: 5,
pageSize: 10,
totalCount: 50,
}),
);
expect(response).toBeNull();
});
test("redirects to last page when page exceeds pagesCount", () => {
const response = captureRedirect(() =>
paginate({
url: buildUrl("https://sendou.ink/vods?page=99"),
page: 99,
pageSize: 10,
totalCount: 50,
}),
);
expect(response).not.toBeNull();
expect(response?.headers.get("Location")).toBe("/vods?page=5");
});
test("preserves other search params when redirecting", () => {
const response = captureRedirect(() =>
paginate({
url: buildUrl(
"https://sendou.ink/vods?type=TOURNAMENT&page=99&mode=SZ",
),
page: 99,
pageSize: 10,
totalCount: 25,
}),
);
const location = response?.headers.get("Location");
expect(location).not.toBeNull();
const locationUrl = new URL(location!, "https://sendou.ink");
expect(locationUrl.pathname).toBe("/vods");
// biome-ignore-start lint/plugin: asserting on the raw redirect URL is the point of the test
expect(locationUrl.searchParams.get("page")).toBe("3");
expect(locationUrl.searchParams.get("type")).toBe("TOURNAMENT");
expect(locationUrl.searchParams.get("mode")).toBe("SZ");
// biome-ignore-end lint/plugin: asserting on the raw redirect URL is the point of the test
});
test("stays on page 1 when there are no results", () => {
const result = paginate({
url: buildUrl("https://sendou.ink/vods?page=1"),
page: 1,
pageSize: 10,
totalCount: 0,
});
expect(result).toEqual({ currentPage: 1, pagesCount: 1 });
});
test("redirects to page 1 when there are no results and page exceeds 1", () => {
const response = captureRedirect(() =>
paginate({
url: buildUrl("https://sendou.ink/vods?page=4"),
page: 4,
pageSize: 10,
totalCount: 0,
}),
);
expect(response?.headers.get("Location")).toBe("/vods?page=1");
});
});

View File

@@ -0,0 +1,249 @@
import type { Namespace, TFunction } from "i18next";
import type { Params, UIMatch } from "react-router";
import { data, redirect } from "react-router";
import type { z } from "zod";
import type { navItems } from "~/components/layout/nav-items";
import { ServerConfig } from "~/config.server";
import type { Ok, Result } from "~/utils/result";
import { logger } from "./logger";
import { currentRequestPathname } from "./request-context.server";
export function notFoundIfNullish<T>(value: T | null | undefined): T {
if (value === null || value === undefined) {
throw new Response(null, { status: 404 });
}
return value;
}
export function unauthorizedIfFalsy<T>(value: T | null | undefined): T {
if (!value) throw new Response(null, { status: 401 });
return value;
}
/** Throws a HTTP 403 (Forbidden) response, ending execution of the loader/action early */
export function forbidden() {
throw new Response(null, { status: 403 });
}
export function badRequestIfFalsy<T>(value: T | null | undefined): T {
if (!value) {
throw new Response(null, { status: 400 });
}
return value;
}
/**
* Resolves the pagination state of a loader whose current page comes from the
* `page` search param. `pagesCount` is at minimum 1 so empty result sets stay
* on page 1.
*
* If the requested `page` exceeds `pagesCount`, throws a redirect to the last
* available page (preserving other search params).
*/
export function paginate({
url,
page,
pageSize,
totalCount,
}: {
url: URL;
page: number;
pageSize: number;
totalCount: number;
}): { currentPage: number; pagesCount: number } {
const pagesCount = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > pagesCount) {
const searchParams = new URLSearchParams(url.searchParams);
searchParams.set("page", String(pagesCount));
throw redirect(`${url.pathname}?${searchParams.toString()}`);
}
return { currentPage: page, pagesCount };
}
/**
* Parse formData of a request with the given schema. Throws HTTP 400 response if fails.
*
* When using SendouForm, use parseFormData from /app/form/parse.server.ts instead.
* */
export async function parseRequestPayload<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): Promise<z.infer<T>> {
const formDataObj =
request.headers.get("Content-Type") === "application/json"
? await request.json()
: formDataToObject(await request.formData());
try {
return await schema.parseAsync(formDataObj);
} catch (e) {
logger.error("Error parsing request payload", e);
throw errorToastRedirect("Validation failed");
}
}
/** Parse params with the given schema. Throws HTTP 404 response if fails. */
export function parseParams<T extends z.ZodTypeAny>({
params,
schema,
}: {
params: Params<string>;
schema: T;
}): z.infer<T> {
const parsed = schema.safeParse(params);
if (!parsed.success) {
throw new Response(null, { status: 404 });
}
return parsed.data;
}
/** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */
export async function parseBody<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): Promise<z.infer<T>> {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) {
throw new Response(null, { status: 400 });
}
return parsed.data;
}
export function formDataToObject(formData: FormData) {
const result: Record<string, string | string[]> = {};
for (const [key, value] of formData.entries()) {
const newValue = String(value);
const existingValue = result[key];
if (Array.isArray(existingValue)) {
existingValue.push(newValue);
} else if (typeof existingValue === "string") {
result[key] = [existingValue, newValue];
} else {
result[key] = newValue;
}
}
return result;
}
const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
/** Some endpoints can only be accessed with an auth token. Used by Lohi bot and cron jobs. */
export function canAccessLohiEndpoint(request: Request) {
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === ServerConfig.lohiToken;
}
export function errorToastRedirect(message: string) {
return redirect(`${currentRequestPathname() ?? ""}?__error=${message}`);
}
/** Asserts condition is truthy. Throws a redirect triggering an error toast with given message otherwise. */
export function errorToastIfFalsy(
condition: any,
message: string,
): asserts condition {
if (condition) return;
throw errorToastRedirect(message);
}
/**
* To be used in loader or action function. Asserts that the provided `Result` value is an `Ok` variant.
*
* If the value is an `Err`, shows an error toast to the user with the error message. The function will stop execution by throwing a redirect meaning it is safe to operate on the value after this function call.
*/
export function errorToastIfErr<T, E extends string>(
value: Result<T, E>,
): asserts value is Ok<T> {
if (!value.ok) {
throw errorToastRedirect(value.error);
}
}
/** Throws a redirect triggering an error toast with given message. */
export function errorToast(message: string) {
throw errorToastRedirect(message);
}
export function successToast(message: string) {
return redirect(`${currentRequestPathname() ?? ""}?__success=${message}`);
}
export function successToastWithRedirect({
message,
url,
}: {
message: string;
url: string;
}) {
return redirect(`${url}?__success=${message}`);
}
export type Breadcrumb =
| {
imgPath: string;
type: "IMAGE";
href: string;
text?: string;
/** Seed for the identicon shown if `imgPath` fails to load. */
identiconInput?: string;
}
| { text: string; type: "TEXT"; href: string };
/**
* Our custom type for route handles - the keys are defined by us or
* libraries that parse them.
*
* Can be set per route using `export const handle: SendouRouteHandle = { };`
* Can be accessed for all currently active routes via the `useMatches()` hook.
*/
export type SendouRouteHandle = {
/** The i18n translation files used for this route, via remix-i18next */
i18n?: Namespace;
/**
* A function that returns the breadcrumb text that should be displayed in
* the <Breadcrumb> component
*/
breadcrumb?: (args: {
match: UIMatch;
t: TFunction<"common", undefined>;
}) => Breadcrumb | Array<Breadcrumb> | undefined;
/** The name of a navItem that is active on this route. See nav-items.ts */
navItemName?: (typeof navItems)[number]["name"];
/**
* When `true`, the shared `<Main>` rendered by a parent layout (e.g. the
* tournament layout) fills the whole content area instead of the page
* max-width, while the page content stays centered at the normal width.
* Lets a descendant (e.g. the bracket) break out and grow wider than the
* page when it needs to.
*/
mainBreakout?: boolean;
};
/** Caches the loader response with "private" Cache-Control meaning that CDN won't cache the response.
* To be used when the response is different for each user. This is especially useful when the response
* is prefetched on link hover.
*/
export function privatelyCachedJson<T>(dataValue: T) {
return data(dataValue, {
headers: { "Cache-Control": "private, max-age=5" },
});
}

View File

@@ -0,0 +1,41 @@
import type { Location } from "react-router";
import { describe, expect, test } from "vitest";
import { metaTags } from "./remix";
import { COMMON_PREVIEW_IMAGE } from "./urls";
const location = { pathname: "/to/1/brackets" } as Location;
const contentOf = (tags: ReturnType<typeof metaTags>, property: string) =>
tags.find((tag) => "property" in tag && tag.property === property)?.content;
describe("metaTags()", () => {
test("uses the common preview image when no image given", () => {
const tags = metaTags({ title: "sendou.ink", location });
expect(contentOf(tags, "og:image")).toBe(COMMON_PREVIEW_IMAGE);
});
test("uses the given image url", () => {
const tags = metaTags({
title: "sendou.ink",
location,
image: { url: "https://cdn.example.com/img/preview.png" },
});
expect(contentOf(tags, "og:image")).toBe(
"https://cdn.example.com/img/preview.png",
);
});
test("resolves og:url from the location pathname", () => {
const tags = metaTags({ title: "sendou.ink", location });
expect(contentOf(tags, "og:url")).toBe("https://sendou.ink/to/1/brackets");
});
test("prefers the url override over the location pathname", () => {
const tags = metaTags({ title: "sendou.ink", location, url: "/to/1" });
expect(contentOf(tags, "og:url")).toBe("https://sendou.ink/to/1");
});
});

View File

@@ -0,0 +1,115 @@
import type {
Location,
ShouldRevalidateFunctionArgs,
useLoaderData,
} from "react-router";
import { truncateBySentence } from "./strings";
import { COMMON_PREVIEW_IMAGE } from "./urls";
export function isRevalidation(args: ShouldRevalidateFunctionArgs) {
return (
args.defaultShouldRevalidate &&
args.nextUrl.href === args.currentUrl.href &&
!args.formMethod
);
}
// https://remix.run/docs/en/main/start/future-flags#serializefrom
export type SerializeFrom<T> = ReturnType<typeof useLoaderData<T>>;
interface OpenGraphArgs {
/** Title as shown by the browser in the tab etc. Appended with "| sendou.ink"*/
title: string;
/** Title as shown when shared on Bluesky, Discord etc. Also used in search results. If omitted, "title" is used instead. */
ogTitle?: string;
/** Brief description of the page's contents used by search engines and social media sharing. If the description is over 300 characters long it is automatically truncated. */
description?: string;
location: Location;
/** Optionally override location pathname. */
url?: string;
image?: {
/** Absolute URL of the image. */
url: string;
dimensions?: {
width: number;
height: number;
};
};
}
const ROOT_URL = "https://sendou.ink";
export function metaTitle(args: Pick<OpenGraphArgs, "title" | "ogTitle">) {
return [
{
title:
args.title === "sendou.ink" ? args.title : `${args.title} | sendou.ink`,
},
{
property: "og:title",
content: args.ogTitle ?? args.title,
},
];
}
export function metaTags(args: OpenGraphArgs) {
const truncatedDescription = args.description
? truncateBySentence(args.description, 300)
: null;
const result = [
...metaTitle(args),
args.description
? {
name: "description",
content: truncatedDescription,
}
: null,
args.description
? {
property: "og:description",
content: truncatedDescription,
}
: null,
{
property: "og:site_name",
content: "sendou.ink",
},
{
property: "og:type",
content: "website",
},
{
property: "og:url",
content: `${ROOT_URL}${args.url ?? args.location.pathname}`,
},
{
property: "og:image",
content: args.image?.url ?? COMMON_PREVIEW_IMAGE,
},
].filter((val) => val !== null);
if (!args.image) {
result.push({
property: "og:image:width",
content: "1920",
});
result.push({
property: "og:image:height",
content: "1080",
});
} else if (args.image.dimensions) {
result.push({
property: "og:image:width",
content: String(args.image.dimensions.width),
});
result.push({
property: "og:image:height",
content: String(args.image.dimensions.height),
});
}
return result;
}

View File

@@ -0,0 +1,16 @@
import { runWithRequestContext } from "./request-context.server";
type MiddlewareArgs = {
request: Request;
url: URL;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
// TODO: this is only needed for our current hacky toast setup, once a proper one in place this middleware can be deleted
export const requestContextMiddleware: MiddlewareFn = ({ url }, next) =>
runWithRequestContext({ url }, () => next());

View File

@@ -0,0 +1,26 @@
import { AsyncLocalStorage } from "node:async_hooks";
// TODO: this is only needed for our current hacky toast setup, once a proper one in place this middleware can be deleted
interface RequestContext {
/** Normalized request URL, as provided to middleware in framework mode
* (single-fetch `.data` suffix and internal search params removed). */
url: URL;
}
const requestContextAsyncLocalStorage = new AsyncLocalStorage<RequestContext>();
/** Runs `fn` with the given request context available to server-side helpers
* (e.g. toast redirects) that don't otherwise receive the request. */
export function runWithRequestContext<T>(
context: RequestContext,
fn: () => T,
): T {
return requestContextAsyncLocalStorage.run(context, fn);
}
/** Normalized pathname of the current request, or `undefined` outside a request
* context. Used to build absolute redirects from helpers lacking the request. */
export function currentRequestPathname(): string | undefined {
return requestContextAsyncLocalStorage.getStore()?.url.pathname;
}

View File

@@ -0,0 +1,41 @@
export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
/** Discriminated union representing either a success (`Ok`) or a failure (`Err`). Narrow with the `ok` property. */
export type Result<T, E> = Ok<T> | Err<E>;
/** Creates an `Ok` variant of `Result` holding the success value. */
export function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
/** Creates an `Err` variant of `Result` holding the error value. */
export function err<E>(error: E): Err<E> {
return { ok: false, error };
}
/** Returns the success value, or the given default value if the result is an `Err`. */
export function unwrapOr<T, D>(
result: Result<T, unknown>,
defaultValue: D,
): T | D {
return result.ok ? result.value : defaultValue;
}
/** Returns the success value. Throws if the result is an `Err`. */
export function unwrap<T>(result: Result<T, unknown>): T {
if (!result.ok) {
throw new Error(`Expected Ok, got Err: ${JSON.stringify(result.error)}`);
}
return result.value;
}
/** Returns the error value. Throws if the result is an `Ok`. */
export function unwrapErr<E>(result: Result<unknown, E>): E {
if (result.ok) {
throw new Error("Expected Err, got Ok");
}
return result.error;
}

View File

@@ -0,0 +1,15 @@
import { customAlphabet } from "nanoid";
const nanoid = customAlphabet(
// avoid 1/I and 0/O
"23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
);
let sessionId: string | undefined;
export function getSessionId(): string {
if (!sessionId) {
sessionId = nanoid(10);
}
return sessionId;
}

View File

@@ -0,0 +1,24 @@
import type { DBBoolean } from "~/db/tables";
/** Converts a JS boolean to the 0/1 representation SQLite stores booleans as. */
export function toDBBoolean(value: boolean): DBBoolean {
return value ? 1 : 0;
}
export function errorIsSqliteForeignKeyConstraintFailure(
error: unknown,
): error is Error {
return (
error instanceof Error &&
error?.message?.includes("FOREIGN KEY constraint failed")
);
}
export function errorIsSqliteUniqueConstraintFailure(
error: unknown,
): error is Error {
return (
error instanceof Error &&
error?.message?.includes("UNIQUE constraint failed")
);
}

View File

@@ -0,0 +1,124 @@
import { describe, expect, test } from "vitest";
import {
pathnameFromPotentialURL,
removeMarkdown,
truncateBySentence,
} from "./strings";
describe("pathnameFromPotentialURL()", () => {
test("Resolves path name from valid URL", () => {
expect(pathnameFromPotentialURL("https://bsky.app/sendouc")).toBe(
"sendouc",
);
});
test("Returns string as is if not URL", () => {
expect(pathnameFromPotentialURL("sendouc")).toBe("sendouc");
});
test("Strips trailing slash from URL path", () => {
expect(pathnameFromPotentialURL("https://discord.gg/FW4dKrY/")).toBe(
"FW4dKrY",
);
});
test("Resolves a scheme-less URL paste to the path", () => {
// otherwise the generated discordUrl becomes https://discord.gg/discord.gg/FW4dKrY
expect(pathnameFromPotentialURL("discord.gg/FW4dKrY")).toBe("FW4dKrY");
});
});
describe("truncateBySentence()", () => {
test("Truncates text by sentence within max length", () => {
const text = "This is the first sentence. This is the second sentence.";
expect(truncateBySentence(text, 30)).toBe("This is the first sentence.");
});
test("Returns original text if no sentences fit within max length", () => {
const text = "This is a very long sentence that exceeds the max length.";
expect(truncateBySentence(text, 10)).toBe("This is a");
});
test("Returns original text if it is shorter than max length", () => {
const text = "Short text.";
expect(truncateBySentence(text, 50)).toBe("Short text.");
});
test("Handles no senteces", () => {
const text = "One two three four five six seven eight nine ten";
expect(truncateBySentence(text, 10)).toBe("One two th");
});
test("Truncates text by sentence with newline characters", () => {
const text = "This is the first sentence\nThis is the second sentence";
expect(truncateBySentence(text, 30)).toBe("This is the first sentence");
});
test("Handles text with multiple newline characters", () => {
const text = "First line\nSecond line\nThird line";
expect(truncateBySentence(text, 20)).toBe("First line");
});
test("Does not treat a period inside a time like 18.00 as a sentence end", () => {
const text =
"Doors at 18.00, we will be playing five rounds of swiss followed by a top cut.";
expect(truncateBySentence(text, 40)).not.toBe("Doors at 18.");
});
test("Does not return a tiny fraction of the budget when a long sentence follows a short one", () => {
const text = `Hi. ${"x".repeat(400)}. Everyone is welcome.`;
expect(truncateBySentence(text, 300)).not.toBe("Hi.");
});
});
describe("removeMarkdown()", () => {
test("Decodes &nbsp; entities and collapses runs", () => {
const text = "&nbsp;&nbsp;&nbsp;&nbsp; Global Gauntlet is an event";
expect(removeMarkdown(text)).toBe("Global Gauntlet is an event");
});
test("Decodes common named HTML entities", () => {
expect(removeMarkdown("Tom &amp; Jerry &lt;3 &quot;hi&quot;")).toBe(
'Tom & Jerry <3 "hi"',
);
});
test("Decodes numeric HTML entities", () => {
expect(removeMarkdown("caf&#233; &#x26; tea")).toBe("café & tea");
});
test("Does not throw on an out of range numeric entity", () => {
// e.g. an organizer writing a hex color code in the description
expect(() => removeMarkdown("background: &#xFFFFFF; here")).not.toThrow();
});
test("Leaves unknown named entities untouched", () => {
expect(removeMarkdown("AT&amp;T &fakeentity; rules")).toBe(
"AT&T &fakeentity; rules",
);
});
test("Strips HTML tags and markdown emphasis", () => {
expect(removeMarkdown("<p>Hello **world**!</p>")).toBe("Hello world!");
});
test("Keeps the link text of inline links", () => {
expect(
removeMarkdown("Check out [the site](https://example.com) today"),
).toBe("Check out the site today");
});
test("Keeps non-header # characters", () => {
expect(removeMarkdown("Showdown #1 starts now")).toBe(
"Showdown #1 starts now",
);
});
test("Leaves space-flanked asterisks intact instead of mangling them", () => {
expect(removeMarkdown("** bold text **")).toBe("** bold text **");
});
test("Strips emphasis with inner spaces", () => {
expect(removeMarkdown("*a b c*")).toBe("a b c");
});
});

View File

@@ -0,0 +1,168 @@
import type { GearType } from "~/modules/in-game-lists/types";
import { assertUnreachable } from "./types";
export function inGameNameWithoutDiscriminator(inGameName: string) {
return inGameName.split("#")[0];
}
export const rawSensToString = (sens: number) =>
`${sens > 0 ? "+" : ""}${sens / 10}`;
type WithStart<
S extends string,
Start extends string,
> = S extends `${Start}${infer Rest}` ? `${Start}${Rest}` : never;
export function startsWith<S extends string, Start extends string>(
str: S,
start: Start,
// @ts-expect-error TS 4.9 upgrade
): str is WithStart<S, Start> {
return str.startsWith(start);
}
type Split<S extends string, Sep extends string> = string extends S
? string[]
: S extends ""
? []
: S extends `${infer T}${Sep}${infer U}`
? [T, ...Split<U, Sep>]
: [S];
export function split<S extends string, Sep extends string>(
str: S,
seperator: Sep,
) {
return str.split(seperator) as Split<S, Sep>;
}
export function gearTypeToInitial(gearType: GearType) {
switch (gearType) {
case "HEAD":
return "H";
case "CLOTHES":
return "C";
case "SHOES":
return "S";
default:
assertUnreachable(gearType);
}
}
export function pathnameFromPotentialURL(maybeUrl: string) {
const parsed = safeParseUrl(maybeUrl);
if (parsed) return stripEdgeSlashes(parsed.pathname);
// handle a URL pasted without a protocol, e.g. "discord.gg/FW4dKrY"
const parsedWithProtocol = safeParseUrl(`https://${maybeUrl}`);
const pathname = parsedWithProtocol
? stripEdgeSlashes(parsedWithProtocol.pathname)
: "";
return pathname || maybeUrl;
}
function safeParseUrl(value: string) {
try {
return new URL(value);
} catch {
return null;
}
}
function stripEdgeSlashes(pathname: string) {
return pathname.replace(/^\/+|\/+$/g, "");
}
export function truncateBySentence(value: string, max: number) {
if (value.length <= max) {
return value;
}
// a sentence only ends at a terminator followed by whitespace, so that
// e.g. "18.00" does not split in the middle
const sentences = value.match(/[\s\S]+?(?:[.!?](?=\s|$)|\n|$)/g) || [];
let result = "";
for (const sentence of sentences) {
if ((result + sentence).length > max) {
break;
}
result += sentence;
}
// when cutting at a sentence boundary would leave most of the budget
// unused, a mid-sentence cut that fills it is more informative
if (result.length < max / 2) {
return value.slice(0, max).trim();
}
return result.trim();
}
// based on https://github.com/zuchka/remove-markdown
const NAMED_HTML_ENTITIES: Record<string, string> = {
nbsp: " ",
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
};
export function removeMarkdown(value: string) {
const htmlReplaceRegex = /<[^>]*>/g;
return (
value
// Remove HTML tags
.replace(htmlReplaceRegex, "")
// Decode named HTML entities (e.g. &nbsp;, &amp;)
.replace(/&([a-zA-Z]+);/g, (match, name: string) => {
const replacement = NAMED_HTML_ENTITIES[name.toLowerCase()];
return replacement ?? match;
})
// Decode numeric HTML entities (e.g. &#160; or &#xA0;)
.replace(/&#(x?[0-9a-fA-F]+);/g, (_, code: string) => {
const codePoint = code.startsWith("x")
? Number.parseInt(code.slice(1), 16)
: Number.parseInt(code, 10);
const isValidCodePoint =
Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff;
return isValidCodePoint ? String.fromCodePoint(codePoint) : "";
})
// Remove setext-style headers
.replace(/^[=-]{2,}\s*$/g, "")
// Remove footnotes?
.replace(/\[\^.+?\](: .*?$)?/g, "")
.replace(/\s{0,2}\[.*?\]: .*?$/g, "")
// Remove images
.replace(/!\[(.*?)\][[(].*?[\])]/g, "")
// Remove inline links
.replace(/\[([^\]]*?)\][[(].*?[\])]/g, "$1")
// Remove blockquotes
.replace(/^(\n)?\s{0,3}>\s?/gm, "$1")
// Remove reference-style links?
.replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "")
// Remove headers
.replace(/^\s{0,3}#{1,6}\s*/gm, "")
// Remove * emphasis
.replace(/(\*+)([^\s*])(.*?[^\s*])??\1/g, "$2$3")
// Remove _ emphasis. Unlike *, _ emphasis gets rendered only if
// 1. Either there is a whitespace character before opening _ and after closing _.
// 2. Or _ is at the start/end of the string.
.replace(/(^|\W)([_]+)(\S)(.*?\S)??\2($|\W)/g, "$1$3$4$5")
// Remove code blocks
.replace(/(`{3,})(.*?)\1/gm, "$2")
// Remove inline code
.replace(/`(.+?)`/g, "$1")
// // Replace two or more newlines with exactly two? Not entirely sure this belongs here...
// .replace(/\n{2,}/g, '\n\n')
// // Remove newlines in a paragraph
// .replace(/(\S+)\n\s*(\S+)/g, '$1 $2')
// Replace strike through
.replace(/~(.*?)~/g, "$1")
// Collapse runs of whitespace (e.g. from decoded &nbsp; or stripped tags)
.replace(/[ \t ]{2,}/g, " ")
.trim()
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
import { ADJECTIVES, SUBTITLES_PLURALIZED } from "./team-name-data";
/** Generates a random team name by combining a Splatoon 3 title adjective with a pluralized subtitle e.g. "Prestigious Heat Haters" */
export function randomTeamName() {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const subtitle =
SUBTITLES_PLURALIZED[
Math.floor(Math.random() * SUBTITLES_PLURALIZED.length)
];
return `${adjective} ${subtitle}`;
}

View File

@@ -0,0 +1,30 @@
/**
* Asserts that a code path is unreachable by accepting a value of type `never`.
* This function is useful for exhaustive checks in switch statements or discriminated unions.
* If called, it throws an error with a message containing the unexpected value.
*
* @param x - The value that should never occur (of type `never`).
* @throws {Error} Throws an error indicating an unexpected value was encountered.
*/
export function assertUnreachable(x: never): never {
throw new Error(
`Didn't expect to get here. Unexpected value: ${JSON.stringify(x)}`,
);
}
/** @link https://stackoverflow.com/a/69413184 */
export const assertType = <A, _B extends A>() => {};
export type Unpacked<T> = T extends (infer U)[]
? U
: T extends (...args: unknown[]) => infer U
? U
: T extends Promise<infer U>
? U
: T;
export type Nullish<T> = T | null | undefined;
export type Unwrapped<T extends (...args: any) => any> = Unpacked<
Awaited<ReturnType<T>>
>;

View File

@@ -0,0 +1,40 @@
import type {
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import weaponTranslations from "../../locales/en/weapons.json";
import { mySlugify } from "./urls";
function buildSlugToIdMap<T extends number>(prefix: string): Record<string, T> {
return Object.fromEntries(
Object.entries(weaponTranslations)
.filter(([id]) => id.startsWith(`${prefix}_`))
.map(([id, name]) => [
mySlugify(name),
Number(id.replace(`${prefix}_`, "")) as T,
]),
) as Record<string, T>;
}
const SLUG_TO_WEAPON_ID = buildSlugToIdMap<MainWeaponId>("MAIN");
const SLUG_TO_SUB_WEAPON_ID = buildSlugToIdMap<SubWeaponId>("SUB");
const SLUG_TO_SPECIAL_WEAPON_ID = buildSlugToIdMap<SpecialWeaponId>("SPECIAL");
export function weaponNameSlugToId(slug?: string) {
if (!slug) return null;
return SLUG_TO_WEAPON_ID[slug.toLowerCase()] ?? null;
}
export function subWeaponNameSlugToId(slug?: string) {
if (!slug) return null;
return SLUG_TO_SUB_WEAPON_ID[slug.toLowerCase()] ?? null;
}
export function specialWeaponNameSlugToId(slug?: string) {
if (!slug) return null;
return SLUG_TO_SPECIAL_WEAPON_ID[slug.toLowerCase()] ?? null;
}

View File

@@ -0,0 +1,419 @@
import slugify from "slugify";
import { Config } from "~/config";
import type { Tables } from "~/db/tables";
import type { Preference } from "~/db/tables-json";
import type { AuthErrorCode } from "~/features/auth/core/errors";
import type {
StageBackgroundStyle,
StageWaterLevel,
} from "~/features/map-planner/plans-types";
import type { TierName } from "~/features/mmr/mmr-constants";
import type {
AbilityWithUnknown,
BrandId,
GearType,
MainWeaponId,
ModeShort,
ModeShortWithSpecial,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import type { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
export const discordAvatarUrl = ({
discordId,
discordAvatar,
size,
}: {
discordId: string;
discordAvatar: string;
size: "lg" | "sm";
}) =>
`https://cdn.discordapp.com/avatars/${discordId}/${
discordAvatar
}.webp${size === "lg" ? "?size=240" : "?size=80"}`;
/**
* Resolves the avatar image url of an user, preferring their custom avatar over
* the Discord one. Returns undefined if the user has neither.
*/
export const resolveAvatarUrl = ({
customAvatarUrl,
discordId,
discordAvatar,
size,
}: {
customAvatarUrl?: string | null;
discordId: string;
discordAvatar?: string | null;
size: "lg" | "sm";
}) => {
if (customAvatarUrl) return customAvatarUrl;
if (discordAvatar) {
return discordAvatarUrl({ discordId, discordAvatar, size });
}
return undefined;
};
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
export const BADGES_DOC_LINK =
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/badges.md";
export const API_DOC_LINK =
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/dev/api.md";
export const CREATING_TOURNAMENT_DOC_LINK =
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/tournament-creation.md";
export const PLUS_SERVER_DISCORD_URL = "https://discord.gg/FW4dKrY";
export const SENDOU_INK_DISCORD_URL = "https://discord.gg/sendou";
export const SENDOU_INK_PATREON_URL = "https://patreon.com/sendou";
export const NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL =
"https://en-americas-support.nintendo.com/app/answers/detail/a_id/63454";
export const PATREON_HOW_TO_CONNECT_DISCORD_URL =
"https://support.patreon.com/hc/en-us/articles/212052266-How-do-I-connect-Discord-to-Patreon-Patron-";
export const SENDOU_INK_GITHUB_URL = "https://github.com/sendou-ink/sendou.ink";
export const GITHUB_CONTRIBUTORS_URL =
"https://github.com/sendou-ink/sendou.ink/graphs/contributors";
export const ipLabsMaps = (pool: string) =>
`https://maps.iplabs.ink/?3&pool=${pool}`;
export const SPLATOON_3_INK = "https://splatoon3.ink/";
export const RHODESMAS_FREESOUND_PROFILE_URL =
"https://freesound.org/people/rhodesmas/";
export const SPR_INFO_URL =
"https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf";
export const SPLATOON3_INK_SCHEDULES_URL =
"https://splatoon3.ink/data/schedules.json";
export const PICOCAD2_WEB_VIEWER_URL =
"https://picocad2-web-viewer.hfcred.workers.dev/";
export const bskyUrl = (accountName: string) =>
`https://bsky.app/profile/${accountName}`;
export const twitchUrl = (accountName: string) =>
`https://twitch.tv/${accountName}`;
export const youtubeUrl = (channelId: string) =>
`https://youtube.com/channel/${channelId}`;
export const LOG_IN_URL = "/auth";
export const LOG_OUT_URL = "/auth/logout";
export const ADMIN_PAGE = "/admin";
export const API_PAGE = "/api";
export const ARTICLES_MAIN_PAGE = "/a";
export const FAQ_PAGE = "/faq";
export const WELCOME_PAGE = "/welcome";
export const SUPPORT_PAGE = "/support";
export const CONTRIBUTIONS_PAGE = "/contributions";
export const BADGES_PAGE = "/badges";
export const TROPHIES_PAGE = "/trophies";
export const NEW_TROPHY_PAGE = "/trophies/new";
export const BUILDS_PAGE = "/builds";
export const TEAM_SEARCH_PAGE = "/t";
export const NEW_TEAM_PAGE = "/t/new";
export const CALENDAR_PAGE = "/calendar";
export const CALENDAR_NEW_PAGE = "/calendar/new";
export const TOURNAMENT_NEW_PAGE = "/calendar/new?tournament=true";
export const ORGANIZATION_NEW_PAGE = "/org/new";
export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
export const SEED_URL = "/seed";
export const PLANNER_URL = "/plans";
export const MAPS_URL = "/maps";
export const TIER_LIST_MAKER_URL = "/tier-list-maker";
export const ANALYZER_URL = "/analyzer";
export const COMP_ANALYZER_URL = "/comp-analyzer";
export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator";
export const SCANNER_PAGE = "/scanner";
export const VODS_PAGE = "/vods";
export const LEADERBOARDS_PAGE = "/leaderboards";
export const LINKS_PAGE = "/links";
export const SENDOUQ_PAGE = "/q";
export const SENDOUQ_RULES_PAGE = "/q/rules";
export const SENDOUQ_INFO_PAGE = "/q/info";
export const MATCH_PROFILE_PAGE = "/settings?tab=match-profile";
export const SENDOUQ_PREPARING_PAGE = "/q/preparing";
export const SENDOUQ_LOOKING_PAGE = "/q/looking";
export const SENDOUQ_READY_PAGE = "/q/ready";
export const SENDOUQ_LOOKING_PREVIEW_PAGE = "/q/looking?preview=true";
export const SENDOUQ_STREAMS_PAGE = "/q/streams";
export const TIERS_PAGE = "/tiers";
export const SUSPENDED_PAGE = "/suspended";
export const LFG_PAGE = "/lfg";
export const EVENTS_PAGE = "/events";
export const FRIENDS_PAGE = "/friends";
export const SETTINGS_PAGE = "/settings";
export const LUTI_PAGE = "/luti";
export const PLUS_VOTING_PAGE = "/plus/voting";
const STATIC_ASSETS_URL = Config.staticAssetsUrl;
export const BLANK_IMAGE_URL = `${STATIC_ASSETS_URL}/img/blank.gif`;
export const COMMON_PREVIEW_IMAGE = `${STATIC_ASSETS_URL}/img/layout/common-preview.png`;
export const ERROR_GIRL_IMAGE_PATH = `${STATIC_ASSETS_URL}/img/layout/error-girl`;
export const SENDOU_LOVE_EMOJI_PATH = `${STATIC_ASSETS_URL}/img/layout/sendou_love`;
export const FIRST_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/first.svg`;
export const SECOND_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/second.svg`;
export const THIRD_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/third.svg`;
export const WELCOME_HERO_IMAGE_PATH = `${STATIC_ASSETS_URL}/img/welcome-hero.webp`;
export const APP_ICON_URL = `${STATIC_ASSETS_URL}/img/app-icon.png`;
export const pwaSplashScreenImageUrl = (fileName: string) =>
`${STATIC_ASSETS_URL}/img/splash-screens/${fileName}`;
export const soundPath = (fileName: string) =>
`${STATIC_ASSETS_URL}/sounds/${fileName}.wav`;
export const GET_FRIENDS_FOR_ADDING_ROUTE = "/friends-for-adding";
export const PATRONS_LIST_ROUTE = "/patrons-list";
export const LAYOUT_DATA_ROUTE = "/api/layout";
export const NOTIFICATIONS_URL = "/notifications";
export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen";
export const NOTIFICATIONS_SUBSCRIBE_ROUTE = "/notifications/subscribe";
export const NOTIFICATIONS_DATA_ROUTE = "/api/notifications";
export const userCardFriendshipPage = (userId: number) =>
`/user-card/${userId}/friendship`;
export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;
export const userReportPage = (userId: number) => `/user-report/${userId}`;
export const trophyPage = (trophyId: number) => `${TROPHIES_PAGE}/${trophyId}`;
export const trophyWinsPage = (args: { trophyId: number; userId: number }) =>
`${TROPHIES_PAGE}/${args.trophyId}/wins/${args.userId}`;
export const trophyTournamentsPage = (trophyId: number) =>
`${TROPHIES_PAGE}/${trophyId}/tournaments`;
export interface UserLinkArgs {
discordId: Tables["User"]["discordId"];
customUrl?: Tables["User"]["customUrl"];
}
export const userPage = (user: UserLinkArgs) =>
`/u/${user.customUrl ?? user.discordId}`;
export const userEditProfilePage = (user: UserLinkArgs) =>
`${userPage(user)}/edit`;
export const userBuildsPage = (user: UserLinkArgs) =>
`${userPage(user)}/builds`;
export const userResultsPage = (user: UserLinkArgs) =>
`${userPage(user)}/results`;
export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`;
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
`${userResultsPage(user)}/highlights`;
export const userAdminPage = (user: UserLinkArgs) => `${userPage(user)}/admin`;
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
export const editTeamPage = (customUrl: string) =>
`${teamPage(customUrl)}/edit`;
export const manageTeamRosterPage = (customUrl: string) =>
`${teamPage(customUrl)}/roster`;
export const authErrorUrl = (errorCode: AuthErrorCode) =>
`/?authError=${errorCode}`;
export const impersonateUrl = (idToLogInAs: number) =>
`/auth/impersonate?id=${idToLogInAs}`;
export const badgePage = (badgeId: number) => `${BADGES_PAGE}/${badgeId}`;
export const weaponBuildPage = (weaponSlug: string) =>
`${BUILDS_PAGE}/${weaponSlug}`;
export const weaponBuildStatsPage = (weaponSlug: string) =>
`${weaponBuildPage(weaponSlug)}/stats`;
export const weaponBuildPopularPage = (weaponSlug: string) =>
`${weaponBuildPage(weaponSlug)}/popular`;
export const weaponParamsPage = (weaponSlug: string) => `/params/${weaponSlug}`;
export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`;
export const calendarReportWinnersPage = (eventId: number) =>
`/calendar/${eventId}/report-winners`;
export const tournamentPage = (tournamentId: number) => `/to/${tournamentId}`;
export const tournamentTeamsPage = (tournamentId: number) =>
`/to/${tournamentId}/teams`;
export const tournamentTeamPage = ({
tournamentId,
tournamentTeamId,
}: {
tournamentId: number;
tournamentTeamId: number;
}) => `/to/${tournamentId}/teams/${tournamentTeamId}`;
export const tournamentTeamCompsPage = ({
tournamentId,
tournamentTeamId,
}: {
tournamentId: number;
tournamentTeamId: number;
}) => `${tournamentTeamPage({ tournamentId, tournamentTeamId })}/comps`;
export const tournamentInfoPage = (tournamentId: number) =>
`/to/${tournamentId}/info`;
export const tournamentRegisterPage = (tournamentId: number) =>
`/to/${tournamentId}/register`;
export const tournamentRulesPage = (tournamentId: number) =>
`/to/${tournamentId}/rules`;
export const tournamentAdminPage = (tournamentId: number) =>
`/to/${tournamentId}/admin`;
export const tournamentAdminRegistrationPage = (tournamentId: number) =>
`${tournamentAdminPage(tournamentId)}/registration`;
export const tournamentAdminRegistrationEditPage = (
tournamentId: number,
tournamentTeamId: number,
) => `${tournamentAdminRegistrationPage(tournamentId)}/${tournamentTeamId}`;
export const tournamentDivisionsPage = (tournamentId: number) =>
`/to/${tournamentId}/divisions`;
export const tournamentResultsPage = (tournamentId: number) =>
`/to/${tournamentId}/results`;
export const tournamentMatchPage = ({
tournamentId,
matchId,
}: {
tournamentId: number;
matchId: number;
}) => `/to/${tournamentId}/matches/${matchId}`;
export const tournamentSubsPage = (tournamentId: number) => {
return `/to/${tournamentId}/looking`;
};
export const tournamentStreamsPage = (tournamentId: number) => {
return `/to/${tournamentId}/streams`;
};
export const sendouQMatchPage = (id: Tables["GroupMatch"]["id"]) => {
return `${SENDOUQ_PAGE}/match/${id}`;
};
export const scrimsPage = () => {
return "/scrims";
};
export const scrimPage = (id: number) => {
return `${scrimsPage()}/${id}`;
};
export const newScrimPostPage = () => {
return "/scrims/new";
};
export const newAssociationsPage = () => {
return "/associations/new";
};
export const articlePage = (slug: string) => `${ARTICLES_MAIN_PAGE}/${slug}`;
export const vodVideoPage = (videoId: number) => `${VODS_PAGE}/${videoId}`;
export const badgeUrl = ({
code,
extension,
}: {
code: Tables["Badge"]["code"];
extension?: "gif";
}) => `${STATIC_ASSETS_URL}/badges/${code}${extension ? `.${extension}` : ""}`;
export const gameBadgeUrl = (id: string) =>
`${STATIC_ASSETS_URL}/img/badges/${id}.avif`;
export const articlePreviewUrl = (slug: string) =>
`${STATIC_ASSETS_URL}/img/article-previews/${slug}.png`;
export const navIconUrl = (navItem: string) =>
`${STATIC_ASSETS_URL}/img/layout/${navItem}`;
export const gearImageUrl = (gearType: GearType, gearSplId: number) =>
`${STATIC_ASSETS_URL}/img/gear/${gearType.toLowerCase()}/${gearSplId}`;
export const weaponCategoryUrl = (
category: (typeof weaponCategories)[number]["name"],
) => `${STATIC_ASSETS_URL}/img/weapon-categories/${category}`;
export const mainWeaponImageUrl = (mainWeaponSplId: MainWeaponId) =>
`${STATIC_ASSETS_URL}/img/main-weapons/${mainWeaponSplId}`;
export const mainWeaponVariantImageUrl = (
mainWeaponSplId: MainWeaponId,
variant: "launched",
) =>
`${STATIC_ASSETS_URL}/img/main-weapons/variants/${mainWeaponSplId}-${variant}`;
export const outlinedMainWeaponImageUrl = (mainWeaponSplId: MainWeaponId) =>
`${STATIC_ASSETS_URL}/img/main-weapons-outlined/${mainWeaponSplId}`;
export const outlinedFiveStarMainWeaponImageUrl = (
mainWeaponSplId: MainWeaponId,
) => `${STATIC_ASSETS_URL}/img/main-weapons-outlined-2/${mainWeaponSplId}`;
export const outlinedTenStarMainWeaponImageUrl = (
mainWeaponSplId: MainWeaponId,
) => `${STATIC_ASSETS_URL}/img/main-weapons-outlined-3/${mainWeaponSplId}`;
export const subWeaponImageUrl = (subWeaponSplId: SubWeaponId) =>
`${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`;
export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) =>
`${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`;
export const specialWeaponVariantImageUrl = (
specialWeaponSplId: SpecialWeaponId,
variant: "weakpoints",
) =>
`${STATIC_ASSETS_URL}/img/special-weapons/variants/${specialWeaponSplId}-${variant}`;
export const abilityImageUrl = (ability: AbilityWithUnknown) =>
`${STATIC_ASSETS_URL}/img/abilities/${ability}`;
export const brandImageUrl = (brand: BrandId) =>
`${STATIC_ASSETS_URL}/img/brands/${brand}`;
export const modeImageUrl = (mode: ModeShortWithSpecial) =>
`${STATIC_ASSETS_URL}/img/modes/${mode}`;
export const stageImageUrl = (stageId: StageId) =>
`${STATIC_ASSETS_URL}/img/stages/${stageId}`;
export const stageBannerImageUrl = (stageId: StageId) =>
`${STATIC_ASSETS_URL}/img/stage-banners/${stageId}.avif`;
export const tierImageUrl = (tier: TierName | "CALCULATING") =>
`${STATIC_ASSETS_URL}/img/tiers/${tier === "CALCULATING" ? "unranked" : tier.toLowerCase()}`;
export const controllerImageUrl = (controller: string) =>
`${STATIC_ASSETS_URL}/img/controllers/${controller}.avif`;
export const preferenceEmojiUrl = (preference?: Preference) => {
const emoji =
preference === "PREFER"
? "grin"
: preference === "AVOID"
? "unamused"
: "no-mouth";
return `${STATIC_ASSETS_URL}/img/emoji/${emoji}.svg`;
};
export const TIER_PLUS_URL = `${STATIC_ASSETS_URL}/img/tiers/plus`;
export const winnersImageUrl = ({
season,
placement,
}: {
season: number;
placement: number;
}) => `${STATIC_ASSETS_URL}/img/winners/${season}/${placement}`;
export const sqHeaderGuyImageUrl = (season: number) =>
`${STATIC_ASSETS_URL}/img/sq-header/${season}`;
export const stageMinimapImageUrlWithEnding = ({
stageId,
mode,
style,
waterLevel,
}: {
stageId: StageId;
mode: ModeShort;
style: StageBackgroundStyle;
waterLevel?: StageWaterLevel;
}) =>
`${STATIC_ASSETS_URL}/planner-maps/${stageId}-${mode}-${style}${
waterLevel === "down" ? "-DOWN" : ""
}.png`;
export function resolveBaseUrl(url: string) {
return new URL(url).host;
}
export const mySlugify = (name: string) => {
return slugify(name, {
lower: true,
strict: true,
});
};
export const isCustomUrl = (value: string) => {
return Number.isNaN(Number(value));
};
export function vodUrl(vod: {
platformVideoId: string;
timestampSeconds: number;
}) {
return `https://www.twitch.tv/videos/${vod.platformVideoId}?t=${vod.timestampSeconds}s`;
}

View File

@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { queryToUserIdentifier, userDiscordIdIsAged } from "./users";
describe("queryToUserIdentifier()", () => {
test("returns null if no match", () => {
expect(queryToUserIdentifier("foo")).toBe(null);
});
test("gets custom url from url", () => {
expect(queryToUserIdentifier("https://sendou.ink/u/sendou")).toEqual({
customUrl: "sendou",
});
});
test("gets discord id from url", () => {
expect(
queryToUserIdentifier("https://sendou.ink/u/79237403620945920"),
).toEqual({
discordId: "79237403620945920",
});
});
test("gets custom url from url (without https://)", () => {
expect(queryToUserIdentifier("sendou.ink/u/sendou")).toEqual({
customUrl: "sendou",
});
});
test("gets discord id", () => {
expect(queryToUserIdentifier("79237403620945920")).toEqual({
discordId: "79237403620945920",
});
});
test("gets id", () => {
expect(queryToUserIdentifier("1")).toEqual({
id: 1,
});
});
test("gets id from url", () => {
expect(queryToUserIdentifier("https://sendou.ink/u/42")).toEqual({
id: 42,
});
});
test("gets custom url from url with trailing slash", () => {
expect(queryToUserIdentifier("https://sendou.ink/u/sendou/")).toEqual({
customUrl: "sendou",
});
});
test("gets custom url from profile sub-page url", () => {
expect(queryToUserIdentifier("https://sendou.ink/u/sendou/builds")).toEqual(
{
customUrl: "sendou",
},
);
});
test("gets discord id from url with query string", () => {
expect(
queryToUserIdentifier(
"https://sendou.ink/u/79237403620945920?utm_source=discord",
),
).toEqual({
discordId: "79237403620945920",
});
});
});
describe("userDiscordIdIsAged()", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2023-11-25T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
test("returns false if discord id is not aged", () => {
expect(userDiscordIdIsAged({ discordId: "1177730652641181871" })).toBe(
false,
);
});
test("returns true if discord id is aged", () => {
expect(userDiscordIdIsAged({ discordId: "79237403620945920" })).toBe(true);
});
test("return false if discord id missing", () => {
expect(userDiscordIdIsAged({ discordId: "" })).toBe(false);
});
test("return false if discord id too short", () => {
expect(userDiscordIdIsAged({ discordId: "1234" })).toBe(false);
});
});

View File

@@ -0,0 +1,69 @@
import { logger } from "./logger";
import { isCustomUrl } from "./urls";
const longUrlRegExp = /(https:\/\/)?sendou\.ink\/u\/([^/?#]+)/;
const shortUrlRegExp = /(https:\/\/)?snd\.ink\/([^/?#]+)/;
const DISCORD_ID_MIN_LENGTH = 17;
export function queryToUserIdentifier(
query: string,
): { id: number } | { discordId: string } | { customUrl: string } | null {
const longUrlMatch = query.match(longUrlRegExp);
const shortUrlMatch = query.match(shortUrlRegExp);
if (longUrlMatch || shortUrlMatch) {
const [, , identifier] = (longUrlMatch ?? shortUrlMatch)!;
if (isCustomUrl(identifier)) {
return { customUrl: identifier };
}
if (identifier.length >= DISCORD_ID_MIN_LENGTH) {
return { discordId: identifier };
}
return { id: Number(identifier) };
}
// = it's numeric
if (!isCustomUrl(query)) {
if (query.length >= DISCORD_ID_MIN_LENGTH) {
return { discordId: query };
}
return { id: Number(query) };
}
return null;
}
// snowflake logic from https://github.dev/vegeta897/snow-stamp/blob/main/src/util.js
const DISCORD_EPOCH = 1420070400000;
// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided
export function convertSnowflakeToDate(snowflake: string) {
// Convert snowflake to BigInt to extract timestamp bits
// https://discord.com/developers/docs/reference#snowflakes
const milliseconds = BigInt(snowflake) >> 22n;
return new Date(Number(milliseconds) + DISCORD_EPOCH);
}
const AGED_CRITERIA = 1000 * 60 * 60 * 24 * 30 * 3; // 3 months
export function userDiscordIdIsAged(user: { discordId: string }) {
if (!user.discordId || user.discordId.length < DISCORD_ID_MIN_LENGTH) {
logger.error("Invalid or missing discord id", {
discordId: user.discordId,
});
return false;
}
const timestamp = convertSnowflakeToDate(user.discordId).getTime();
return Date.now() - timestamp > AGED_CRITERIA;
}
export function accountCreatedInTheLastSixMonths(discordId: string) {
const timestamp = convertSnowflakeToDate(discordId).getTime();
return Date.now() - timestamp < 1000 * 60 * 60 * 24 * 30 * 6;
}

View File

@@ -0,0 +1,179 @@
import { describe, expect, test } from "vitest";
import {
actuallyNonEmptyStringOrNull,
hasZalgo,
hexCodeWithoutAlpha,
normalizeFriendCode,
timeString,
} from "./zod";
describe("normalizeFriendCode", () => {
test("returns well formatted friend code as is", () => {
expect(normalizeFriendCode("1234-5678-9012")).toBe("1234-5678-9012");
});
test("handles no dashes", () => {
expect(normalizeFriendCode("123456789012")).toBe("1234-5678-9012");
});
test("handles SW-suffix", () => {
expect(normalizeFriendCode("SW-1234-5678-9012")).toBe("1234-5678-9012");
});
test("handles a mix", () => {
expect(normalizeFriendCode("SW-1234-56789012")).toBe("1234-5678-9012");
});
});
describe("hasZalgo", () => {
test("returns true for text containing Zalgo characters", () => {
expect(hasZalgo("z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ")).toBe(true);
});
test("returns false for text without Zalgo characters", () => {
expect(hasZalgo("normal text")).toBe(false);
});
test("returns false for an empty string", () => {
expect(hasZalgo("")).toBe(false);
});
test("returns false for text with special but non-Zalgo characters", () => {
expect(hasZalgo("!@#$%^&*()")).toBe(false);
});
test("accepts japanese characters", () => {
expect(hasZalgo("こんにちは")).toBe(false);
});
test("returns a stable result when called repeatedly with the same input", () => {
const withCombiningMark = "á"; // "á" as base letter + single combining accent
expect(hasZalgo(withCombiningMark)).toBe(true);
expect(hasZalgo(withCombiningMark)).toBe(true);
});
});
describe("actuallyNonEmptyStringOrNull", () => {
test("returns null for an empty string", () => {
expect(actuallyNonEmptyStringOrNull("")).toBeNull();
});
test("returns null for a string with only spaces", () => {
expect(actuallyNonEmptyStringOrNull(" ")).toBeNull();
});
test("returns trimmed string for a string with visible characters and spaces", () => {
expect(actuallyNonEmptyStringOrNull(" hello world ")).toBe("hello world");
});
test("removes invisible characters and trims", () => {
expect(actuallyNonEmptyStringOrNull("​​​​test​​​​")).toBe("test");
});
test("returns original value if not a string", () => {
expect(actuallyNonEmptyStringOrNull(123)).toBe(123);
expect(actuallyNonEmptyStringOrNull(null)).toBe(null);
expect(actuallyNonEmptyStringOrNull(undefined)).toBe(undefined);
expect(actuallyNonEmptyStringOrNull({})).toEqual({});
});
test("returns null for a string with only zero width spaces", () => {
expect(actuallyNonEmptyStringOrNull("​​​​​​​​​​")).toBeNull();
});
test("returns null for a string with only tag space emoji", () => {
expect(actuallyNonEmptyStringOrNull("󠀠󠀠󠀠󠀠󠀠")).toBeNull();
});
test("returns null for a string with only Hangul Filler", () => {
expect(actuallyNonEmptyStringOrNull("\u3164")).toBeNull();
expect(actuallyNonEmptyStringOrNull("ㅤㅤㅤ")).toBeNull();
});
test("returns null for other invisible characters", () => {
expect(actuallyNonEmptyStringOrNull("\u115F")).toBeNull();
expect(actuallyNonEmptyStringOrNull("\u1160")).toBeNull();
expect(actuallyNonEmptyStringOrNull("\uFEFF")).toBeNull();
expect(actuallyNonEmptyStringOrNull("\u2060")).toBeNull();
});
test("returns null for a string with only soft hyphens", () => {
expect(actuallyNonEmptyStringOrNull("\u00AD")).toBeNull();
expect(actuallyNonEmptyStringOrNull("\u00AD\u00AD\u00AD")).toBeNull();
});
test("returns null for a string with only braille blanks", () => {
expect(actuallyNonEmptyStringOrNull("\u2800")).toBeNull();
expect(actuallyNonEmptyStringOrNull("\u2800\u2800\u2800\u2800")).toBeNull();
});
test("returns null for a string with only variation selectors", () => {
expect(actuallyNonEmptyStringOrNull("\ufe0e")).toBeNull();
expect(actuallyNonEmptyStringOrNull("\ufe0e\ufe0e")).toBeNull();
});
});
describe("hexCodeWithoutAlpha", () => {
test("accepts valid 3 and 6 digit hex colors", () => {
expect(hexCodeWithoutAlpha.safeParse("#fff").success).toBe(true);
expect(hexCodeWithoutAlpha.safeParse("#FFF").success).toBe(true);
expect(hexCodeWithoutAlpha.safeParse("#abc").success).toBe(true);
expect(hexCodeWithoutAlpha.safeParse("#ffffff").success).toBe(true);
expect(hexCodeWithoutAlpha.safeParse("#a1b2c3").success).toBe(true);
});
test("rejects strings that are not valid hex colors", () => {
expect(hexCodeWithoutAlpha.safeParse("#fff99").success).toBe(false);
expect(hexCodeWithoutAlpha.safeParse("#abc12").success).toBe(false);
expect(hexCodeWithoutAlpha.safeParse("#12345").success).toBe(false);
expect(hexCodeWithoutAlpha.safeParse("#ffffff99").success).toBe(false);
});
test("rejects alpha (4 and 8 digit) hex colors", () => {
expect(hexCodeWithoutAlpha.safeParse("#ffff").success).toBe(false);
expect(hexCodeWithoutAlpha.safeParse("#ffffffff").success).toBe(false);
});
});
describe("timeString", () => {
test("accepts valid time in HH:MM format", () => {
expect(timeString.safeParse("00:00").success).toBe(true);
expect(timeString.safeParse("12:30").success).toBe(true);
expect(timeString.safeParse("23:59").success).toBe(true);
});
test("accepts times with leading zeros", () => {
expect(timeString.safeParse("01:05").success).toBe(true);
expect(timeString.safeParse("09:00").success).toBe(true);
});
test("rejects invalid hour values", () => {
expect(timeString.safeParse("24:00").success).toBe(false);
expect(timeString.safeParse("25:30").success).toBe(false);
expect(timeString.safeParse("99:00").success).toBe(false);
});
test("rejects invalid minute values", () => {
expect(timeString.safeParse("12:60").success).toBe(false);
expect(timeString.safeParse("12:99").success).toBe(false);
});
test("rejects malformed time strings", () => {
expect(timeString.safeParse("1:30").success).toBe(false);
expect(timeString.safeParse("12:3").success).toBe(false);
expect(timeString.safeParse("12-30").success).toBe(false);
expect(timeString.safeParse("1230").success).toBe(false);
expect(timeString.safeParse("12:30:00").success).toBe(false);
});
test("rejects non-string values", () => {
expect(timeString.safeParse(1230).success).toBe(false);
expect(timeString.safeParse(null).success).toBe(false);
expect(timeString.safeParse(undefined).success).toBe(false);
});
test("rejects empty string", () => {
expect(timeString.safeParse("").success).toBe(false);
});
});

View File

@@ -0,0 +1,450 @@
import type { ZodType } from "zod";
import { z } from "zod";
import {
abilities,
type abilitiesShort,
} from "~/modules/in-game-lists/abilities";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import { SHORT_NANOID_LENGTH } from "./id";
import type { Unpacked } from "./types";
import { assertType } from "./types";
export const id = z.coerce.number({ message: "Required" }).int().positive();
export const idObject = z.object({
id,
});
export const inviteCode = z.string().length(SHORT_NANOID_LENGTH);
export const nonEmptyString = z.string().trim().min(1, {
message: "Required",
});
// matches #RGB and #RRGGBB only (no alpha) https://stackoverflow.com/a/1636354
const hexCodeWithoutAlphaRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const hexCodeWithoutAlpha = z.string().regex(hexCodeWithoutAlphaRegex);
export const THEME_INPUT_LIMITS = {
BASE_HUE_MIN: 0,
BASE_HUE_MAX: 360,
BASE_CHROMA_MIN: 0,
BASE_CHROMA_MAX: 0.1,
ACCENT_HUE_MIN: 0,
ACCENT_HUE_MAX: 360,
ACCENT_CHROMA_MIN: 0,
ACCENT_CHROMA_MAX: 0.3,
RADIUS_MIN: 0,
RADIUS_MAX: 5,
RADIUS_STEP: 1,
BORDER_WIDTH_MIN: 0.5,
BORDER_WIDTH_MAX: 2,
BORDER_WIDTH_STEP: 0.5,
SIZE_MIN: 0.9,
SIZE_MAX: 1.1,
SIZE_STEP: 0.05,
} as const;
function isValidStep(value: number, min: number, step: number) {
const diff = value - min;
const steps = Math.round(diff / step);
return Math.abs(diff - steps * step) < 0.0001;
}
export const themeInputSchema = z.object({
baseHue: z
.number()
.min(THEME_INPUT_LIMITS.BASE_HUE_MIN)
.max(THEME_INPUT_LIMITS.BASE_HUE_MAX),
baseChroma: z
.number()
.min(THEME_INPUT_LIMITS.BASE_CHROMA_MIN)
.max(THEME_INPUT_LIMITS.BASE_CHROMA_MAX),
accentHue: z
.number()
.min(THEME_INPUT_LIMITS.ACCENT_HUE_MIN)
.max(THEME_INPUT_LIMITS.ACCENT_HUE_MAX),
accentChroma: z
.number()
.min(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN)
.max(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX),
chatHue: z
.number()
.min(THEME_INPUT_LIMITS.BASE_HUE_MIN)
.max(THEME_INPUT_LIMITS.BASE_HUE_MAX)
.nullable(),
radiusBox: z
.number()
.int()
.min(THEME_INPUT_LIMITS.RADIUS_MIN)
.max(THEME_INPUT_LIMITS.RADIUS_MAX),
radiusField: z
.number()
.int()
.min(THEME_INPUT_LIMITS.RADIUS_MIN)
.max(THEME_INPUT_LIMITS.RADIUS_MAX),
radiusSelector: z
.number()
.int()
.min(THEME_INPUT_LIMITS.RADIUS_MIN)
.max(THEME_INPUT_LIMITS.RADIUS_MAX),
borderWidth: z
.number()
.min(THEME_INPUT_LIMITS.BORDER_WIDTH_MIN)
.max(THEME_INPUT_LIMITS.BORDER_WIDTH_MAX)
.refine(
(val) =>
isValidStep(
val,
THEME_INPUT_LIMITS.BORDER_WIDTH_MIN,
THEME_INPUT_LIMITS.BORDER_WIDTH_STEP,
),
{ message: "Must be a valid step increment" },
),
sizeField: z
.number()
.min(THEME_INPUT_LIMITS.SIZE_MIN)
.max(THEME_INPUT_LIMITS.SIZE_MAX)
.refine(
(val) =>
isValidStep(
val,
THEME_INPUT_LIMITS.SIZE_MIN,
THEME_INPUT_LIMITS.SIZE_STEP,
),
{ message: "Must be a valid step increment" },
),
sizeSelector: z
.number()
.min(THEME_INPUT_LIMITS.SIZE_MIN)
.max(THEME_INPUT_LIMITS.SIZE_MAX)
.refine(
(val) =>
isValidStep(
val,
THEME_INPUT_LIMITS.SIZE_MIN,
THEME_INPUT_LIMITS.SIZE_STEP,
),
{ message: "Must be a valid step increment" },
),
sizeSpacing: z
.number()
.min(THEME_INPUT_LIMITS.SIZE_MIN)
.max(THEME_INPUT_LIMITS.SIZE_MAX)
.refine(
(val) =>
isValidStep(
val,
THEME_INPUT_LIMITS.SIZE_MIN,
THEME_INPUT_LIMITS.SIZE_STEP,
),
{ message: "Must be a valid step increment" },
),
});
const timeStringRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
export const timeString = z.string().regex(timeStringRegex);
const abilityNameToType = (val: string) =>
abilities.find((ability) => ability.name === val)?.type;
export const headMainSlotAbility = z
.string()
.refine(
(val) =>
["STACKABLE", "HEAD_MAIN_ONLY"].includes(abilityNameToType(val) as any),
{ message: "forms:errors.required" },
);
export const clothesMainSlotAbility = z
.string()
.refine(
(val) =>
["STACKABLE", "CLOTHES_MAIN_ONLY"].includes(
abilityNameToType(val) as any,
),
{ message: "forms:errors.required" },
);
export const shoesMainSlotAbility = z
.string()
.refine(
(val) =>
["STACKABLE", "SHOES_MAIN_ONLY"].includes(abilityNameToType(val) as any),
{ message: "forms:errors.required" },
);
export const stackableAbility = z
.string()
.refine((val) => abilityNameToType(val) === "STACKABLE", {
message: "forms:errors.required",
});
export const normalizeFriendCode = (value: string) => {
const onlyNumbers = value.replace(/\D/g, "");
const withDashes = onlyNumbers
.split(/(\d{4})/)
.filter(Boolean)
.join("-");
return withDashes;
};
export const ability = z.enum([
"ISM",
"ISS",
"IRU",
"RSU",
"SSU",
"SCU",
"SS",
"SPU",
"QR",
"QSJ",
"BRU",
"RES",
"SRU",
"IA",
"OG",
"LDE",
"T",
"CB",
"NS",
"H",
"TI",
"RP",
"AD",
"SJ",
"OS",
"DR",
]);
// keep in-game-lists and the zod enum in sync
assertType<z.infer<typeof ability>, Unpacked<typeof abilitiesShort>>();
export const weaponSplId = z.preprocess(
actualNumber,
numericEnum(mainWeaponIds),
);
export const subWeaponId = numericEnum(subWeaponIds);
export const specialWeaponId = numericEnum(specialWeaponIds);
export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]);
export const modeShortWithSpecial = z.enum([
"TW",
"SZ",
"TC",
"RM",
"CB",
"SR",
"TB",
]);
export const gamesShortSchema = z.enum(["S1", "S2", "S3"]);
export const stageId = z.preprocess(actualNumber, numericEnum(stageIds));
export function processMany(
...processFuncs: Array<(value: unknown) => unknown>
) {
return (value: unknown) => {
let result = value;
for (const processFunc of processFuncs) {
result = processFunc(result);
}
return result;
};
}
export function safeJSONParse(value: unknown): unknown {
try {
if (typeof value !== "string") return value;
return JSON.parse(value);
} catch {
return undefined;
}
}
const EMPTY_CHARACTERS = [
"\u00AD",
"\u200B",
"\u200C",
"\u200D",
"\u200E",
"\u200F",
"󠀠",
"\u2800",
"\u3164",
"\u115F",
"\u1160",
"\uFEFF",
"\u2060",
"[\\uFE00-\\uFE0F]",
];
const EMPTY_CHARACTERS_REGEX = new RegExp(EMPTY_CHARACTERS.join("|"), "g");
const zalgoRe = /%CC%/;
export const hasZalgo = (txt: string) => zalgoRe.test(encodeURIComponent(txt));
/** Non-empty string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */
export const safeStringSchema = ({ min, max }: { min?: number; max: number }) =>
z.preprocess(
actuallyNonEmptyStringOrNull, // if this returns null, none of the checks below will run because it's not a string
z
.string()
.min(min ?? 0)
.max(max)
.refine((text) => !hasZalgo(text), {
message: "Includes not allowed characters.",
}),
);
/** Nullable string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */
export const safeNullableStringSchema = ({
min,
max,
}: {
min?: number;
max: number;
}) =>
z.preprocess(
processMany(undefinedToNull, actuallyNonEmptyStringOrNull),
z
.string()
.min(min ?? 0)
.max(max)
.nullable()
.refine(
(text) => {
if (typeof text !== "string") return true;
return !hasZalgo(text);
},
{
message: "Includes not allowed characters.",
},
),
);
/**
* Processes the input value and returns a non-empty string with invisible characters cleaned out or null.
*/
export function actuallyNonEmptyStringOrNull(value: unknown) {
if (typeof value !== "string") return value;
const trimmed = value.replace(EMPTY_CHARACTERS_REGEX, "").trim();
return trimmed === "" ? null : trimmed;
}
export function falsyToNull(value: unknown): unknown {
if (value) return value;
return null;
}
export function nullLiteraltoNull(value: unknown): unknown {
if (value === "null") return null;
return value;
}
function undefinedToNull(value: unknown): unknown {
if (value === undefined) return null;
return value;
}
export function actualNumber(value: unknown) {
if (value === "") return undefined;
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
export function date(value: unknown) {
if (typeof value === "string" || typeof value === "number") {
const valueAsNumber = Number(value);
return new Date(Number.isNaN(valueAsNumber) ? value : valueAsNumber);
}
return value;
}
export function noDuplicates(arr: (number | string)[]) {
return new Set(arr).size === arr.length;
}
export function filterOutNullishMembers(value: unknown) {
if (!Array.isArray(value)) return value;
return value.filter((member) => member !== null && member !== undefined);
}
export function removeDuplicates(value: unknown) {
if (!Array.isArray(value)) return value;
return Array.from(new Set(value));
}
export function emptyArrayToNull(value: unknown) {
if (Array.isArray(value) && value.length === 0) return null;
return value;
}
export function checkboxValueToBoolean(value: unknown) {
if (!value) return false;
if (typeof value !== "string") {
throw new Error("Expected string checkbox value");
}
return value === "on";
}
export const _action = <T extends string>(value: T) =>
z.preprocess(deduplicate, z.literal(value));
// Fix bug at least in Safari 15 where SubmitButton value might get sent twice
export function deduplicate(value: unknown) {
if (Array.isArray(value)) {
const [one, two, ...rest] = value;
if (rest.length > 0) return value;
if (one !== two) return value;
return one;
}
return value;
}
// https://github.com/colinhacks/zod/issues/1118#issuecomment-1235065111
export function numericEnum<TValues extends readonly number[]>(
values: TValues,
) {
return z.number().superRefine((val, ctx) => {
if (!values.includes(val)) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_value,
input: val,
values: [...values],
message: `Expected one of: ${values.join(", ")}, received ${val}`,
});
}
}) as ZodType<TValues[number]>;
}
export const dayMonthYear = z.object({
day: z.coerce.number().int().min(1).max(31),
month: z.coerce.number().int().min(0).max(11),
year: z.coerce.number().int().min(2015).max(2100),
});
export type DayMonthYear = z.infer<typeof dayMonthYear>;