adding monkeytype
Some checks failed
Mark Stale PRs / stale (push) Has been cancelled

This commit is contained in:
Benjamin Falch
2026-04-23 13:53:44 +02:00
parent e214a2fd35
commit 2bc741fb78
1930 changed files with 7590652 additions and 0 deletions

3
packages/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"oxc.fmt.configPath": "../.oxfmtrc-editor.json"
}

View File

@@ -0,0 +1,7 @@
{
"ignorePatterns": ["node_modules", "dist", ".turbo"],
"extends": [
"../oxlint-config/index.jsonc"
// "@monkeytype/oxlint-config"
]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import * as Validation from "@monkeytype/schemas/validation/validation";
const containsDisallowedWords = Validation.__testing.containsDisallowedWords;
describe("validation", () => {
it("containsDisallowedWords", () => {
const testCases = [
{
text: "https://www.fuckyou.com",
expected: true,
},
{
text: "fucking_profane",
expected: true,
},
{
text: "fucker",
expected: true,
},
{
text: "Hello world!",
expected: false,
},
{
text: "I fucking hate you",
expected: true,
},
{
text: "I love you",
expected: false,
},
{
text: "\n.fuck!",
expected: true,
},
];
testCases.forEach((testCase) => {
expect(containsDisallowedWords(testCase.text, "substring")).toBe(
testCase.expected,
);
});
});
});

View File

@@ -0,0 +1,42 @@
{
"name": "@monkeytype/contracts",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./*": {
"types": "./src/*.ts",
"import": "./dist/*.mjs",
"require": "./dist/*.js"
}
},
"scripts": {
"dev": "tsup-node --watch",
"build": "npm run madge && tsup-node",
"test": "vitest run",
"madge": " madge --circular --extensions ts ./src",
"ts-check": "tsc --noEmit",
"lint": "oxlint . --type-aware --type-check",
"lint-fast": "oxlint ."
},
"dependencies": {
"@monkeytype/schemas": "workspace:*"
},
"devDependencies": {
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"tsup": "8.4.0",
"typescript": "6.0.2",
"vitest": "4.1.0"
},
"peerDependencies": {
"@ts-rest/core": "3.52.1",
"zod": "3.23.8"
}
}

View File

@@ -0,0 +1,142 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { IdSchema } from "@monkeytype/schemas/util";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
export const ToggleBanRequestSchema = z
.object({
uid: IdSchema,
})
.strict();
export type ToggleBanRequest = z.infer<typeof ToggleBanRequestSchema>;
export const ClearStreakHourOffsetRequestSchema = z
.object({
uid: IdSchema,
})
.strict();
export type ClearStreakHourOffsetRequest = z.infer<
typeof ClearStreakHourOffsetRequestSchema
>;
export const ToggleBanResponseSchema = responseWithData(
z.object({
banned: z.boolean(),
}),
).strict();
export type ToggleBanResponse = z.infer<typeof ToggleBanResponseSchema>;
export const AcceptReportsRequestSchema = z
.object({
reports: z.array(z.object({ reportId: z.string() }).strict()).nonempty(),
})
.strict();
export type AcceptReportsRequest = z.infer<typeof AcceptReportsRequestSchema>;
export const RejectReportsRequestSchema = z
.object({
reports: z
.array(
z
.object({ reportId: z.string(), reason: z.string().optional() })
.strict(),
)
.nonempty(),
})
.strict();
export type RejectReportsRequest = z.infer<typeof RejectReportsRequestSchema>;
export const SendForgotPasswordEmailRequestSchema = z
.object({
email: z.string().email(),
})
.strict();
export type SendForgotPasswordEmailRequest = z.infer<
typeof SendForgotPasswordEmailRequestSchema
>;
const c = initContract();
export const adminContract = c.router(
{
test: {
summary: "test permission",
description: "Check for admin permission for the current user",
method: "GET",
path: "",
responses: {
200: MonkeyResponseSchema,
},
},
toggleBan: {
summary: "toggle user ban",
description: "Ban an unbanned user or unban a banned user.",
method: "POST",
path: "/toggleBan",
body: ToggleBanRequestSchema,
responses: {
200: ToggleBanResponseSchema,
},
},
clearStreakHourOffset: {
summary: "clear streak hour offset",
description: "Clear the streak hour offset for a user",
method: "POST",
path: "/clearStreakHourOffset",
body: ClearStreakHourOffsetRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
acceptReports: {
summary: "accept reports",
description: "Accept one or many reports",
method: "POST",
path: "/report/accept",
body: AcceptReportsRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
rejectReports: {
summary: "reject reports",
description: "Reject one or many reports",
method: "POST",
path: "/report/reject",
body: RejectReportsRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
sendForgotPasswordEmail: {
summary: "send forgot password email",
description: "Send a forgot password email to the given user email",
method: "POST",
path: "/sendForgotPasswordEmail",
body: SendForgotPasswordEmailRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
},
{
pathPrefix: "/admin",
strictStatusCodes: true,
metadata: meta({
openApiTags: "admin",
authenticationOptions: { noCache: true },
rateLimit: "adminLimit",
requirePermission: "admin",
requireConfiguration: {
path: "admin.endpointsEnabled",
invalidMessage: "Admin endpoints are currently disabled.",
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,110 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import {
ApeKeySchema,
ApeKeysSchema,
ApeKeyUserDefinedSchema,
} from "@monkeytype/schemas/ape-keys";
import { IdSchema } from "@monkeytype/schemas/util";
export const GetApeKeyResponseSchema = responseWithData(ApeKeysSchema);
export type GetApeKeyResponse = z.infer<typeof GetApeKeyResponseSchema>;
export const AddApeKeyRequestSchema = ApeKeyUserDefinedSchema;
export type AddApeKeyRequest = z.infer<typeof AddApeKeyRequestSchema>;
export const AddApeKeyResponseSchema = responseWithData(
z.object({
apeKeyId: IdSchema,
apeKey: z.string().base64(),
apeKeyDetails: ApeKeySchema,
}),
);
export type AddApeKeyResponse = z.infer<typeof AddApeKeyResponseSchema>;
export const EditApeKeyRequestSchema = AddApeKeyRequestSchema.partial();
export type EditApeKeyRequest = z.infer<typeof EditApeKeyRequestSchema>;
export const ApeKeyParamsSchema = z.object({
apeKeyId: IdSchema,
});
export type ApeKeyParams = z.infer<typeof ApeKeyParamsSchema>;
const c = initContract();
export const apeKeysContract = c.router(
{
get: {
summary: "get ape keys",
description: "Get ape keys of the current user.",
method: "GET",
path: "",
responses: {
200: GetApeKeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGet",
}),
},
add: {
summary: "add ape key",
description: "Add an ape key for the current user.",
method: "POST",
path: "",
body: AddApeKeyRequestSchema.strict(),
responses: {
200: AddApeKeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGenerate",
}),
},
save: {
summary: "update ape key",
description: "Update an existing ape key for the current user.",
method: "PATCH",
path: "/:apeKeyId",
pathParams: ApeKeyParamsSchema,
body: EditApeKeyRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGenerate",
}),
},
delete: {
summary: "delete ape key",
description: "Delete ape key by id.",
method: "DELETE",
path: "/:apeKeyId",
pathParams: ApeKeyParamsSchema,
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "apeKeysGenerate",
}),
},
},
{
pathPrefix: "/ape-keys",
strictStatusCodes: true,
metadata: meta({
openApiTags: "ape-keys",
requirePermission: "canManageApeKeys",
requireConfiguration: {
path: "apeKeys.endpointsEnabled",
invalidMessage: "ApeKeys are currently disabled.",
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,69 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { PartialConfigSchema } from "@monkeytype/schemas/configs";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithNullableData,
} from "./util/api";
export const GetConfigResponseSchema =
responseWithNullableData(PartialConfigSchema);
export type GetConfigResponse = z.infer<typeof GetConfigResponseSchema>;
const c = initContract();
export const configsContract = c.router(
{
get: {
summary: "get config",
description: "Get config of the current user.",
method: "GET",
path: "",
responses: {
200: GetConfigResponseSchema,
},
metadata: meta({
rateLimit: "configGet",
}),
},
save: {
summary: "update config",
description:
"Update the config of the current user. Only provided values will be updated while the missing values will be unchanged.",
method: "PATCH",
path: "",
body: PartialConfigSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "configUpdate",
}),
},
delete: {
summary: "delete config",
description: "Delete/reset the config for the current user.",
method: "DELETE",
path: "",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "configDelete",
}),
},
},
{
pathPrefix: "/configs",
strictStatusCodes: true,
metadata: meta({
openApiTags: "configs",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,102 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { ConfigurationSchema } from "@monkeytype/schemas/configuration";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
export const GetConfigurationResponseSchema =
responseWithData(ConfigurationSchema);
export type GetConfigurationResponse = z.infer<
typeof GetConfigurationResponseSchema
>;
// marked as deprecated but zod team might reconsider according to https://github.com/colinhacks/zod/issues/2854#issuecomment-3100623150
// oxlint-disable-next-line no-deprecated
export const PartialConfigurationSchema = ConfigurationSchema.deepPartial();
export type PartialConfiguration = z.infer<typeof PartialConfigurationSchema>;
export const PatchConfigurationRequestSchema = z
.object({
configuration: PartialConfigurationSchema.strict(),
})
.strict();
export type PatchConfigurationRequest = z.infer<
typeof PatchConfigurationRequestSchema
>;
export const ConfigurationSchemaResponseSchema = responseWithData(z.object({})); //TODO define schema?
export type ConfigurationSchemaResponse = z.infer<
typeof ConfigurationSchemaResponseSchema
>;
const c = initContract();
export const configurationContract = c.router(
{
get: {
summary: "get configuration",
description: "Get server configuration",
method: "GET",
path: "",
responses: {
200: GetConfigurationResponseSchema,
},
metadata: meta({
authenticationOptions: {
isPublic: true,
},
}),
},
update: {
summary: "update configuration",
description:
"Update the server configuration. Only provided values will be updated while the missing values will be unchanged.",
method: "PATCH",
path: "",
body: PatchConfigurationRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: {
noCache: true,
isPublicOnDev: true,
},
rateLimit: "adminLimit",
requirePermission: "admin",
}),
},
getSchema: {
summary: "get configuration schema",
description: "Get schema definition of the server configuration.",
method: "GET",
path: "/schema",
responses: {
200: ConfigurationSchemaResponseSchema,
},
metadata: meta({
authenticationOptions: {
isPublicOnDev: true,
noCache: true,
},
rateLimit: "adminLimit",
requirePermission: "admin",
}),
},
},
{
pathPrefix: "/configuration",
strictStatusCodes: true,
metadata: meta({
openApiTags: "configuration",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,136 @@
import { initContract } from "@ts-rest/core";
import {
ConnectionSchema,
ConnectionStatusSchema,
ConnectionTypeSchema,
} from "@monkeytype/schemas/connections";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import { IdSchema } from "@monkeytype/schemas/util";
const c = initContract();
export const GetConnectionsResponseSchema = responseWithData(
z.array(ConnectionSchema),
);
export type GetConnectionsResponse = z.infer<
typeof GetConnectionsResponseSchema
>;
export const GetConnectionsQuerySchema = z.object({
status: z
.array(ConnectionStatusSchema)
.or(ConnectionStatusSchema.transform((it) => [it]))
.optional(),
type: z
.array(ConnectionTypeSchema)
.or(ConnectionTypeSchema.transform((it) => [it]))
.optional(),
});
export type GetConnectionsQuery = z.infer<typeof GetConnectionsQuerySchema>;
export const CreateConnectionRequestSchema = ConnectionSchema.pick({
receiverName: true,
});
export type CreateConnectionRequest = z.infer<
typeof CreateConnectionRequestSchema
>;
export const CreateConnectionResponseSchema =
responseWithData(ConnectionSchema);
export type CreateConnectionResponse = z.infer<
typeof CreateConnectionResponseSchema
>;
export const IdPathParamsSchema = z.object({
id: IdSchema,
});
export type IdPathParams = z.infer<typeof IdPathParamsSchema>;
export const UpdateConnectionRequestSchema = z.object({
status: ConnectionStatusSchema.exclude(["pending"]),
});
export type UpdateConnectionRequest = z.infer<
typeof UpdateConnectionRequestSchema
>;
export const connectionsContract = c.router(
{
get: {
summary: "get connections",
description: "Get connections of the current user",
method: "GET",
path: "/",
query: GetConnectionsQuerySchema.strict(),
responses: {
200: GetConnectionsResponseSchema,
},
metadata: meta({
rateLimit: "connectionGet",
}),
},
create: {
summary: "create connection",
description: "Request a connection to a user ",
method: "POST",
path: "/",
body: CreateConnectionRequestSchema.strict(),
responses: {
200: CreateConnectionResponseSchema,
404: MonkeyResponseSchema.describe("ReceiverUid unknown"),
409: MonkeyResponseSchema.describe(
"Duplicate connection, blocked or max connections reached",
),
},
metadata: meta({
rateLimit: "connectionCreate",
}),
},
delete: {
summary: "delete connection",
description: "Delete a connection",
method: "DELETE",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionDelete",
}),
},
update: {
summary: "update connection",
description: "Update a connection status",
method: "PATCH",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: UpdateConnectionRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionUpdate",
}),
},
},
{
pathPrefix: "/connections",
strictStatusCodes: true,
metadata: meta({
openApiTags: "connections",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Connections are not available at this time.",
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,79 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import { IdSchema } from "@monkeytype/schemas/util";
export const GenerateDataRequestSchema = z.object({
username: z.string(),
createUser: z
.boolean()
.optional()
.describe(
"If `true` create user with <username>@example.com and password `password`. If false user has to exist.",
),
firstTestTimestamp: z.number().int().nonnegative().optional(),
lastTestTimestamp: z.number().int().nonnegative().optional(),
minTestsPerDay: z.number().int().nonnegative().optional(),
maxTestsPerDay: z.number().int().nonnegative().optional(),
});
export type GenerateDataRequest = z.infer<typeof GenerateDataRequestSchema>;
export const GenerateDataResponseSchema = responseWithData(
z.object({
uid: IdSchema,
email: z.string().email(),
}),
);
export type GenerateDataResponse = z.infer<typeof GenerateDataResponseSchema>;
export const AddDebugInboxItemRequestSchema = z.object({
rewardType: z.enum(["xp", "badge", "none"]),
});
export type AddDebugInboxItemRequest = z.infer<
typeof AddDebugInboxItemRequestSchema
>;
const c = initContract();
export const devContract = c.router(
{
generateData: {
summary: "generate data",
description: "Generate test results for the given user.",
method: "POST",
path: "/generateData",
body: GenerateDataRequestSchema.strict(),
responses: {
200: GenerateDataResponseSchema,
},
},
addDebugInboxItem: {
summary: "add debug inbox item",
description: "Add a debug inbox item with optional reward.",
method: "POST",
path: "/addDebugInboxItem",
body: AddDebugInboxItemRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
openApiTags: "development",
}),
},
},
{
pathPrefix: "/dev",
strictStatusCodes: true,
metadata: meta({
openApiTags: "development",
authenticationOptions: {
isPublicOnDev: true,
},
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,41 @@
import { initContract } from "@ts-rest/core";
import { adminContract } from "./admin";
import { apeKeysContract } from "./ape-keys";
import { configsContract } from "./configs";
import { presetsContract } from "./presets";
import { psasContract } from "./psas";
import { publicContract } from "./public";
import { leaderboardsContract } from "./leaderboards";
import { resultsContract } from "./results";
import { configurationContract } from "./configuration";
import { devContract } from "./dev";
import { usersContract } from "./users";
import { quotesContract } from "./quotes";
import { webhooksContract } from "./webhooks";
import { connectionsContract } from "./connections";
const c = initContract();
export const contract = c.router({
admin: adminContract,
apeKeys: apeKeysContract,
configs: configsContract,
presets: presetsContract,
psas: psasContract,
public: publicContract,
leaderboards: leaderboardsContract,
results: resultsContract,
configuration: configurationContract,
dev: devContract,
users: usersContract,
quotes: quotesContract,
webhooks: webhooksContract,
connections: connectionsContract,
});
/**
* Whenever there is a breaking change with old frontend clients increase this number.
* This will inform the frontend to refresh.
*/
export const COMPATIBILITY_CHECK = 4;
export const COMPATIBILITY_CHECK_HEADER = "X-Compatibility-Check";

View File

@@ -0,0 +1,260 @@
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyClientError,
responseWithData,
responseWithNullableData,
} from "./util/api";
import {
LeaderboardEntrySchema,
XpLeaderboardEntrySchema,
} from "@monkeytype/schemas/leaderboards";
import { Mode2Schema, ModeSchema } from "@monkeytype/schemas/shared";
import { initContract } from "@ts-rest/core";
import { LanguageSchema } from "@monkeytype/schemas/languages";
const LanguageAndModeQuerySchema = z.object({
language: LanguageSchema,
mode: ModeSchema,
mode2: Mode2Schema,
});
const PaginationQuerySchema = z.object({
page: z.number().int().safe().nonnegative().default(0),
pageSize: z.number().int().safe().positive().min(10).max(200).default(50),
});
export type PaginationQuery = z.infer<typeof PaginationQuerySchema>;
const FriendsOnlyQuerySchema = z.object({
friendsOnly: z
.boolean()
.optional()
.describe("include only users from your friends list, defaults to false."),
});
export type FriendsOnlyQuery = z.infer<typeof FriendsOnlyQuerySchema>;
const LeaderboardResponseSchema = z.object({
count: z.number().int().nonnegative(),
pageSize: z.number().int().positive(),
});
//--------------------------------------------------------------------------
export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge(
PaginationQuerySchema,
).merge(FriendsOnlyQuerySchema);
export type GetLeaderboardQuery = z.infer<typeof GetLeaderboardQuerySchema>;
export const GetLeaderboardResponseSchema = responseWithData(
LeaderboardResponseSchema.extend({
entries: z.array(LeaderboardEntrySchema),
}),
);
export type GetLeaderboardResponse = z.infer<
typeof GetLeaderboardResponseSchema
>;
//--------------------------------------------------------------------------
export const GetLeaderboardRankQuerySchema = LanguageAndModeQuerySchema.merge(
FriendsOnlyQuerySchema,
);
export type GetLeaderboardRankQuery = z.infer<
typeof GetLeaderboardRankQuerySchema
>;
export const GetLeaderboardRankResponseSchema = responseWithNullableData(
LeaderboardEntrySchema,
);
export type GetLeaderboardRankResponse = z.infer<
typeof GetLeaderboardRankResponseSchema
>;
//--------------------------------------------------------------------------
export const DailyLeaderboardQuerySchema = LanguageAndModeQuerySchema.extend({
daysBefore: z.literal(1).optional(),
}).merge(FriendsOnlyQuerySchema);
export type DailyLeaderboardQuery = z.infer<typeof DailyLeaderboardQuerySchema>;
export const GetDailyLeaderboardQuerySchema = DailyLeaderboardQuerySchema.merge(
PaginationQuerySchema,
);
export type GetDailyLeaderboardQuery = z.infer<
typeof GetDailyLeaderboardQuerySchema
>;
export const GetDailyLeaderboardResponseSchema = responseWithData(
LeaderboardResponseSchema.extend({
entries: z.array(LeaderboardEntrySchema),
minWpm: z.number().nonnegative(),
}),
);
export type GetDailyLeaderboardResponse = z.infer<
typeof GetDailyLeaderboardResponseSchema
>;
//--------------------------------------------------------------------------
export const GetDailyLeaderboardRankQuerySchema = DailyLeaderboardQuerySchema;
export type GetDailyLeaderboardRankQuery = z.infer<
typeof GetDailyLeaderboardRankQuerySchema
>;
export const GetLeaderboardDailyRankResponseSchema = responseWithNullableData(
LeaderboardEntrySchema,
);
export type GetLeaderboardDailyRankResponse = z.infer<
typeof GetLeaderboardDailyRankResponseSchema
>;
//--------------------------------------------------------------------------
const WeeklyXpLeaderboardQuerySchema = z
.object({
weeksBefore: z.literal(1).optional(),
})
.merge(FriendsOnlyQuerySchema);
export const GetWeeklyXpLeaderboardQuerySchema =
WeeklyXpLeaderboardQuerySchema.merge(PaginationQuerySchema);
export type GetWeeklyXpLeaderboardQuery = z.infer<
typeof GetWeeklyXpLeaderboardQuerySchema
>;
export const GetWeeklyXpLeaderboardResponseSchema = responseWithData(
LeaderboardResponseSchema.extend({
entries: z.array(XpLeaderboardEntrySchema),
}),
);
export type GetWeeklyXpLeaderboardResponse = z.infer<
typeof GetWeeklyXpLeaderboardResponseSchema
>;
//--------------------------------------------------------------------------
export const GetWeeklyXpLeaderboardRankQuerySchema =
WeeklyXpLeaderboardQuerySchema;
export type GetWeeklyXpLeaderboardRankQuery = z.infer<
typeof GetWeeklyXpLeaderboardRankQuerySchema
>;
export const GetWeeklyXpLeaderboardRankResponseSchema =
responseWithNullableData(XpLeaderboardEntrySchema);
export type GetWeeklyXpLeaderboardRankResponse = z.infer<
typeof GetWeeklyXpLeaderboardRankResponseSchema
>;
//--------------------------------------------------------------------------
const c = initContract();
export const leaderboardsContract = c.router(
{
get: {
summary: "get leaderboard",
description: "Get all-time leaderboard.",
method: "GET",
path: "",
query: GetLeaderboardQuerySchema.strict(),
responses: {
200: GetLeaderboardResponseSchema,
404: MonkeyClientError,
},
metadata: meta({
authenticationOptions: { isPublic: true },
}),
},
getRank: {
summary: "get leaderboard rank",
description:
"Get the rank of the current user on the all-time leaderboard",
method: "GET",
path: "/rank",
query: GetLeaderboardRankQuerySchema.strict(),
responses: {
200: GetLeaderboardRankResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
}),
},
getDaily: {
summary: "get daily leaderboard",
description: "Get daily leaderboard.",
method: "GET",
path: "/daily",
query: GetDailyLeaderboardQuerySchema.strict(),
responses: {
200: GetDailyLeaderboardResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
requireConfiguration: {
path: "dailyLeaderboards.enabled",
invalidMessage: "Daily leaderboards are not available at this time.",
},
}),
},
getDailyRank: {
summary: "get daily leaderboard rank",
description: "Get the rank of the current user on the daily leaderboard",
method: "GET",
path: "/daily/rank",
query: GetDailyLeaderboardRankQuerySchema.strict(),
responses: {
200: GetLeaderboardDailyRankResponseSchema,
},
metadata: meta({
requireConfiguration: {
path: "dailyLeaderboards.enabled",
invalidMessage: "Daily leaderboards are not available at this time.",
},
}),
},
getWeeklyXp: {
summary: "get weekly xp leaderboard",
description: "Get weekly xp leaderboard",
method: "GET",
path: "/xp/weekly",
query: GetWeeklyXpLeaderboardQuerySchema.strict(),
responses: {
200: GetWeeklyXpLeaderboardResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
requireConfiguration: {
path: "leaderboards.weeklyXp.enabled",
invalidMessage:
"Weekly XP leaderboards are not available at this time.",
},
}),
},
getWeeklyXpRank: {
summary: "get weekly xp leaderboard rank",
description:
"Get the rank of the current user on the weekly xp leaderboard",
method: "GET",
path: "/xp/weekly/rank",
query: GetWeeklyXpLeaderboardRankQuerySchema.strict(),
responses: {
200: GetWeeklyXpLeaderboardRankResponseSchema,
},
metadata: meta({
requireConfiguration: {
path: "leaderboards.weeklyXp.enabled",
invalidMessage:
"Weekly XP leaderboards are not available at this time.",
},
}),
},
},
{
pathPrefix: "/leaderboards",
strictStatusCodes: true,
metadata: meta({
openApiTags: "leaderboards",
rateLimit: "leaderboardsGet",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,98 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import {
EditPresetRequestSchema,
PresetSchema,
} from "@monkeytype/schemas/presets";
import { IdSchema } from "@monkeytype/schemas/util";
export const GetPresetResponseSchema = responseWithData(z.array(PresetSchema));
export type GetPresetResponse = z.infer<typeof GetPresetResponseSchema>;
export const AddPresetRequestSchema = PresetSchema.omit({ _id: true });
export type AddPresetRequest = z.infer<typeof AddPresetRequestSchema>;
export const AddPresetResponseSchemna = responseWithData(
z.object({ presetId: IdSchema }),
);
export type AddPresetResponse = z.infer<typeof AddPresetResponseSchemna>;
export const DeletePresetsParamsSchema = z.object({
presetId: IdSchema,
});
export type DeletePresetsParams = z.infer<typeof DeletePresetsParamsSchema>;
const c = initContract();
export const presetsContract = c.router(
{
get: {
summary: "get presets",
description: "Get presets of the current user.",
method: "GET",
path: "",
responses: {
200: GetPresetResponseSchema,
},
metadata: meta({
rateLimit: "presetsGet",
}),
},
add: {
summary: "add preset",
description: "Add a new preset for the current user.",
method: "POST",
path: "",
body: AddPresetRequestSchema.strict(),
responses: {
200: AddPresetResponseSchemna,
},
metadata: meta({
rateLimit: "presetsAdd",
}),
},
save: {
summary: "update preset",
description: "Update an existing preset for the current user.",
method: "PATCH",
path: "",
body: EditPresetRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "presetsEdit",
}),
},
delete: {
summary: "delete preset",
description: "Delete preset by id.",
method: "DELETE",
path: "/:presetId",
pathParams: DeletePresetsParamsSchema,
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "presetsRemove",
}),
},
},
{
pathPrefix: "/presets",
strictStatusCodes: true,
metadata: meta({
openApiTags: "presets",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,34 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { PSASchema } from "@monkeytype/schemas/psas";
import { CommonResponses, meta, responseWithData } from "./util/api";
export const GetPsaResponseSchema = responseWithData(z.array(PSASchema));
export type GetPsaResponse = z.infer<typeof GetPsaResponseSchema>;
const c = initContract();
export const psasContract = c.router(
{
get: {
summary: "get psas",
description: "Get list of public service announcements",
method: "GET",
path: "",
responses: {
200: GetPsaResponseSchema,
},
},
},
{
pathPrefix: "/psas",
strictStatusCodes: true,
metadata: meta({
openApiTags: "psas",
authenticationOptions: {
isPublic: true,
},
rateLimit: "psaGet",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,70 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { CommonResponses, meta, responseWithData } from "./util/api";
import {
SpeedHistogramSchema,
TypingStatsSchema,
} from "@monkeytype/schemas/public";
import { Mode2Schema, ModeSchema } from "@monkeytype/schemas/shared";
import { LanguageSchema } from "@monkeytype/schemas/languages";
export const GetSpeedHistogramQuerySchema = z
.object({
language: LanguageSchema,
mode: ModeSchema,
mode2: Mode2Schema,
})
.strict();
export type GetSpeedHistogramQuery = z.infer<
typeof GetSpeedHistogramQuerySchema
>;
export const GetSpeedHistogramResponseSchema =
responseWithData(SpeedHistogramSchema);
export type GetSpeedHistogramResponse = z.infer<
typeof GetSpeedHistogramResponseSchema
>;
export const GetTypingStatsResponseSchema = responseWithData(TypingStatsSchema);
export type GetTypingStatsResponse = z.infer<
typeof GetTypingStatsResponseSchema
>;
const c = initContract();
export const publicContract = c.router(
{
getSpeedHistogram: {
summary: "get speed histogram",
description:
"get number of users personal bests grouped by wpm level (multiples of ten)",
method: "GET",
path: "/speedHistogram",
query: GetSpeedHistogramQuerySchema,
responses: {
200: GetSpeedHistogramResponseSchema,
},
},
getTypingStats: {
summary: "get typing stats",
description: "get number of tests and time users spend typing.",
method: "GET",
path: "/typingStats",
responses: {
200: GetTypingStatsResponseSchema,
},
},
},
{
pathPrefix: "/public",
strictStatusCodes: true,
metadata: meta({
openApiTags: "public",
authenticationOptions: {
isPublic: true,
},
rateLimit: "publicStatsGet",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,217 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
responseWithNullableData,
} from "./util/api";
import {
ApproveQuoteSchema,
QuoteIdSchema,
QuoteRatingSchema,
QuoteReportReasonSchema,
QuoteSchema,
} from "@monkeytype/schemas/quotes";
import { IdSchema, NullableStringSchema } from "@monkeytype/schemas/util";
import { LanguageSchema } from "@monkeytype/schemas/languages";
export const GetQuotesResponseSchema = responseWithData(z.array(QuoteSchema));
export type GetQuotesResponse = z.infer<typeof GetQuotesResponseSchema>;
export const IsSubmissionEnabledResponseSchema = responseWithData(
z.object({
isEnabled: z.boolean(),
}),
);
export type IsSubmissionEnabledResponse = z.infer<
typeof IsSubmissionEnabledResponseSchema
>;
export const AddQuoteRequestSchema = z.object({
text: z.string().min(60),
source: z.string(),
language: LanguageSchema,
captcha: z.string(), //we don't generate the captcha so there should be no validation
});
export type AddQuoteRequest = z.infer<typeof AddQuoteRequestSchema>;
export const ApproveQuoteRequestSchema = z.object({
quoteId: IdSchema,
editText: NullableStringSchema,
editSource: NullableStringSchema,
});
export type ApproveQuoteRequest = z.infer<typeof ApproveQuoteRequestSchema>;
export const ApproveQuoteResponseSchema = responseWithData(ApproveQuoteSchema);
export type ApproveQuoteResponse = z.infer<typeof ApproveQuoteResponseSchema>;
export const RejectQuoteRequestSchema = z.object({
quoteId: IdSchema,
});
export type RejectQuoteRequest = z.infer<typeof RejectQuoteRequestSchema>;
export const GetQuoteRatingQuerySchema = z.object({
quoteId: QuoteIdSchema,
language: LanguageSchema,
});
export type GetQuoteRatingQuery = z.infer<typeof GetQuoteRatingQuerySchema>;
export const GetQuoteRatingResponseSchema =
responseWithNullableData(QuoteRatingSchema);
export type GetQuoteRatingResponse = z.infer<
typeof GetQuoteRatingResponseSchema
>;
export const AddQuoteRatingRequestSchema = z.object({
quoteId: QuoteIdSchema,
language: LanguageSchema,
rating: z.number().int().min(1).max(5),
});
export type AddQuoteRatingRequest = z.infer<typeof AddQuoteRatingRequestSchema>;
export const ReportQuoteRequestSchema = z.object({
quoteId: QuoteIdSchema,
quoteLanguage: LanguageSchema,
reason: QuoteReportReasonSchema,
comment: z
.string()
.regex(/^([.]|[^/<>])+$/)
.max(250)
.optional()
.or(z.string().length(0)),
captcha: z.string(), //we don't generate the captcha so there should be no validation
});
export type ReportQuoteRequest = z.infer<typeof ReportQuoteRequestSchema>;
const c = initContract();
export const quotesContract = c.router(
{
get: {
summary: "get quote submissions",
description: "Get list of quote submissions",
method: "GET",
path: "",
responses: {
200: GetQuotesResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesGet",
requirePermission: "quoteMod",
}),
},
isSubmissionEnabled: {
summary: "is submission enabled",
description: "Check if submissions are enabled.",
method: "GET",
path: "/isSubmissionEnabled",
responses: {
200: IsSubmissionEnabledResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "newQuotesIsSubmissionEnabled",
}),
},
add: {
summary: "submit quote",
description: "Add a quote submission",
method: "POST",
path: "",
body: AddQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesAdd",
requireConfiguration: {
path: "quotes.submissionsEnabled",
invalidMessage:
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up.",
},
}),
},
approveSubmission: {
summary: "submit quote",
description: "Add a quote submission",
method: "POST",
path: "/approve",
body: ApproveQuoteRequestSchema.strict(),
responses: {
200: ApproveQuoteResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesAction",
requirePermission: "quoteMod",
}),
},
rejectSubmission: {
summary: "reject quote",
description: "Reject a quote submission",
method: "POST",
path: "/reject",
body: RejectQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "newQuotesAction",
requirePermission: "quoteMod",
}),
},
getRating: {
summary: "get rating",
description: "Get quote rating",
method: "GET",
path: "/rating",
query: GetQuoteRatingQuerySchema.strict(),
responses: {
200: GetQuoteRatingResponseSchema,
},
metadata: meta({
rateLimit: "quoteRatingsGet",
}),
},
addRating: {
summary: "add rating",
description: "Add a quote rating",
method: "POST",
path: "/rating",
body: AddQuoteRatingRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteRatingsSubmit",
}),
},
report: {
summary: "report quote",
description: "Report a quote",
method: "POST",
path: "/report",
body: ReportQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteReportSubmit",
requirePermission: "canReport",
requireConfiguration: {
path: "quotes.reporting.enabled",
invalidMessage: "Quote reporting is unavailable.",
},
}),
},
},
{
pathPrefix: "/quotes",
strictStatusCodes: true,
metadata: meta({
openApiTags: "quotes",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,411 @@
export type Window = "second" | "minute" | "hour" | "day" | number;
export type RateLimitOptions = {
/** Timeframe or time in milliseconds */
window: Window;
/** Max request within the given window */
max: number;
};
export const limits = {
defaultApeRateLimit: {
window: "minute",
max: 30,
},
adminLimit: {
window: 5000,
max: 1,
},
// Config Routing
configUpdate: {
window: "hour",
max: 500,
},
configGet: {
window: "hour",
max: 120,
},
configDelete: {
window: "hour",
max: 120,
},
// Leaderboards Routing
leaderboardsGet: {
window: "hour",
max: 500,
},
// New Quotes Routing
newQuotesGet: {
window: "hour",
max: 500,
},
newQuotesIsSubmissionEnabled: {
window: "minute",
max: 60,
},
newQuotesAdd: {
window: "hour",
max: 60,
},
newQuotesAction: {
window: "hour",
max: 500,
},
// Quote Ratings Routing
quoteRatingsGet: {
window: "hour",
max: 500,
},
quoteRatingsSubmit: {
window: "hour",
max: 500,
},
// Quote reporting
quoteReportSubmit: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
// Quote favorites
quoteFavoriteGet: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
quoteFavoritePost: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
quoteFavoriteDelete: {
window: 30 * 60 * 1000, // 30 min
max: 50,
},
// Presets Routing
presetsGet: {
window: "hour",
max: 60,
},
presetsAdd: {
window: "hour",
max: 60,
},
presetsRemove: {
window: "hour",
max: 60,
},
presetsEdit: {
window: "hour",
max: 60,
},
// PSA (Public Service Announcement) Routing
psaGet: {
window: "minute",
max: 60,
},
// get public speed stats
publicStatsGet: {
window: "minute",
max: 60,
},
// Results Routing
resultsGet: {
window: "hour",
max: 60,
},
// Results Routing
resultsGetApe: {
window: "day",
max: 30,
},
// Result by id
resultByIdGet: {
window: "hour",
max: 300,
},
// Result by id
resultByIdGetApe: {
window: "hour",
max: 60,
},
resultsAdd: {
window: "hour",
max: 300,
},
resultsTagsUpdate: {
window: "hour",
max: 100,
},
resultsDeleteAll: {
window: "hour",
max: 10,
},
resultsLeaderboardGet: {
window: "hour",
max: 60,
},
resultsLeaderboardQualificationGet: {
window: "hour",
max: 60,
},
// Users Routing
userGet: {
window: "hour",
max: 60,
},
setStreakHourOffset: {
window: "hour",
max: 5,
},
userSignup: {
window: "day",
max: 2,
},
userDelete: {
window: "day",
max: 3,
},
userReset: {
window: "day",
max: 3,
},
userCheckName: {
window: "minute",
max: 60,
},
userUpdateName: {
window: "day",
max: 3,
},
userUpdateLBMemory: {
window: "minute",
max: 60,
},
userUpdateEmail: {
window: "hour",
max: 60,
},
userClearPB: {
window: "hour",
max: 60,
},
userOptOutOfLeaderboards: {
window: "hour",
max: 10,
},
userCustomFilterAdd: {
window: "hour",
max: 60,
},
userCustomFilterRemove: {
window: "hour",
max: 60,
},
userTagsGet: {
window: "hour",
max: 60,
},
userTagsRemove: {
window: "hour",
max: 30,
},
userTagsClearPB: {
window: "hour",
max: 60,
},
userTagsEdit: {
window: "hour",
max: 30,
},
userTagsAdd: {
window: "hour",
max: 30,
},
userCustomThemeGet: {
window: "hour",
max: 30,
},
userCustomThemeAdd: {
window: "hour",
max: 30,
},
userCustomThemeRemove: {
window: "hour",
max: 30,
},
userCustomThemeEdit: {
window: "hour",
max: 30,
},
userDiscordLink: {
window: "hour",
max: 15,
},
userDiscordUnlink: {
window: "hour",
max: 15,
},
userRequestVerificationEmail: {
window: 15 * 60 * 1000, //15 minutes
max: 1,
},
userForgotPasswordEmail: {
window: "minute",
max: 1,
},
userRevokeAllTokens: {
window: "hour",
max: 10,
},
userProfileGet: {
window: "hour",
max: 100,
},
userProfileUpdate: {
window: "hour",
max: 60,
},
userMailGet: {
window: "hour",
max: 60,
},
userMailUpdate: {
window: "hour",
max: 60,
},
userTestActivity: {
window: "hour",
max: 60,
},
userCurrentTestActivity: {
window: "hour",
max: 60,
},
userStreak: {
window: "hour",
max: 60,
},
userFriendGet: {
window: "hour",
max: 60,
},
// ApeKeys Routing
apeKeysGet: {
window: "hour",
max: 120,
},
apeKeysGenerate: {
window: "hour",
max: 15,
},
webhookLimit: {
window: "second",
max: 1,
},
connectionGet: {
window: "hour",
max: 60,
},
connectionCreate: {
window: "hour",
max: 60,
},
connectionDelete: {
window: "hour",
max: 60,
},
connectionUpdate: {
window: "hour",
max: 60,
},
} satisfies Record<string, RateLimitOptions>;
export type RateLimiterId = keyof typeof limits;
export type RateLimitIds = {
/** rate limiter options for non-apeKey requests */
normal: RateLimiterId;
/** Rate limiter options for apeKey requests */
apeKey: RateLimiterId;
};
export function getLimits(limit: RateLimiterId | RateLimitIds): {
limiter: RateLimitOptions;
apeKeyLimiter?: RateLimitOptions;
} {
const isApeKeyLimiter = typeof limit === "object";
const limiter = isApeKeyLimiter ? limit.normal : limit;
const apeLimiter = isApeKeyLimiter ? limit.apeKey : undefined;
return {
limiter: limits[limiter],
apeKeyLimiter: apeLimiter !== undefined ? limits[apeLimiter] : undefined,
};
}

View File

@@ -0,0 +1,27 @@
import { Configuration } from "@monkeytype/schemas/configuration";
type BooleanPaths<T, P extends string = ""> = {
[K in keyof T]: T[K] extends boolean
? P extends ""
? K
: `${P}.${Extract<K, string | number>}`
: T[K] extends object
? `${P}.${Extract<K, string | number>}` extends infer D
? D extends string
? BooleanPaths<T[K], D>
: never
: never
: never;
}[keyof T];
// Helper type to remove leading dot
type RemoveLeadingDot<T> = T extends `.${infer U}` ? U : T;
export type ConfigurationPath = RemoveLeadingDot<BooleanPaths<Configuration>>;
export type RequireConfiguration = {
/** path to the configuration, needs to be a boolean value */
path: ConfigurationPath;
/** message of the ErrorResponse in case the value is `false` */
invalidMessage?: string;
};

View File

@@ -0,0 +1,204 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyClientError,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import {
CompletedEventSchema,
PostResultResponseSchema,
ResultMinifiedSchema,
ResultSchema,
} from "@monkeytype/schemas/results";
import { IdSchema } from "@monkeytype/schemas/util";
export const GetResultsQuerySchema = z.object({
onOrAfterTimestamp: z
.number()
.int()
.min(1589428800000)
.optional()
.describe(
"Timestamp of the earliest result to fetch. If omitted the most recent results are fetched.",
),
offset: z
.number()
.int()
.nonnegative()
.optional()
.describe("Offset of the item at which to begin the response."),
limit: z
.number()
.int()
.nonnegative()
.max(1000)
.optional()
.describe("Limit results to the given amount."),
});
export type GetResultsQuery = z.infer<typeof GetResultsQuerySchema>;
export const GetResultsResponseSchema = responseWithData(
z.array(ResultMinifiedSchema),
);
export type GetResultsResponse = z.infer<typeof GetResultsResponseSchema>;
export const GetResultByIdPathSchema = z.object({
resultId: IdSchema,
});
export type GetResultByIdPath = z.infer<typeof GetResultByIdPathSchema>;
export const GetResultByIdResponseSchema = responseWithData(ResultSchema);
export type GetResultByIdResponse = z.infer<typeof GetResultByIdResponseSchema>;
export const AddResultRequestSchema = z.object({
result: CompletedEventSchema,
});
export type AddResultRequest = z.infer<typeof AddResultRequestSchema>;
export const AddResultResponseSchema = responseWithData(
PostResultResponseSchema,
);
export type AddResultResponse = z.infer<typeof AddResultResponseSchema>;
export const UpdateResultTagsRequestSchema = z.object({
tagIds: z.array(IdSchema),
resultId: IdSchema,
});
export type UpdateResultTagsRequest = z.infer<
typeof UpdateResultTagsRequestSchema
>;
export const UpdateResultTagsResponseSchema = responseWithData(
z.object({
tagPbs: z.array(IdSchema),
}),
);
export type UpdateResultTagsResponse = z.infer<
typeof UpdateResultTagsResponseSchema
>;
export const GetLastResultResponseSchema = responseWithData(ResultSchema);
export type GetLastResultResponse = z.infer<typeof GetLastResultResponseSchema>;
const c = initContract();
export const resultsContract = c.router(
{
get: {
summary: "get results",
description: "Gets up to 1000 results",
method: "GET",
path: "",
query: GetResultsQuerySchema.strict(),
responses: {
200: GetResultsResponseSchema,
},
metadata: meta({
authenticationOptions: {
acceptApeKeys: true,
},
rateLimit: {
normal: "resultsGet",
apeKey: "resultsGetApe",
},
}),
},
getById: {
summary: "get result by id",
description: "Get result by id",
method: "GET",
path: "/id/:resultId",
pathParams: GetResultByIdPathSchema,
responses: {
200: GetResultByIdResponseSchema,
},
metadata: meta({
authenticationOptions: {
acceptApeKeys: true,
},
rateLimit: {
normal: "resultByIdGet",
apeKey: "resultByIdGetApe",
},
}),
},
add: {
summary: "add result",
description: "Add a test result for the current user",
method: "POST",
path: "",
body: AddResultRequestSchema.strict(),
responses: {
200: AddResultResponseSchema,
460: MonkeyClientError.describe("Test too short"),
461: MonkeyClientError.describe("Result hash invalid"),
462: MonkeyClientError.describe("Result spacing invalid"),
463: MonkeyClientError.describe("Result data invalid"),
464: MonkeyClientError.describe("Missing key data"),
465: MonkeyClientError.describe("Bot detected"),
466: MonkeyClientError.describe("Duplicate result"),
},
metadata: meta({
rateLimit: "resultsAdd",
requireConfiguration: {
path: "results.savingEnabled",
invalidMessage: "Results are not being saved at this time.",
},
}),
},
updateTags: {
summary: "update result tags",
description: "Labels a result with the specified tags",
method: "PATCH",
path: "/tags",
body: UpdateResultTagsRequestSchema.strict(),
responses: {
200: UpdateResultTagsResponseSchema,
},
metadata: meta({
rateLimit: "resultsTagsUpdate",
}),
},
deleteAll: {
summary: "delete all results",
description: "Delete all results for the current user",
method: "DELETE",
path: "",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: {
requireFreshToken: true,
},
rateLimit: "resultsDeleteAll",
}),
},
getLast: {
summary: "get last result",
description: "Gets a user's last saved result",
path: "/last",
method: "GET",
responses: {
200: GetLastResultResponseSchema,
},
metadata: meta({
authenticationOptions: {
acceptApeKeys: true,
},
rateLimit: "resultsGet",
}),
},
},
{
pathPrefix: "/results",
strictStatusCodes: true,
metadata: meta({
openApiTags: "results",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,965 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyClientError,
MonkeyResponseSchema,
responseWithData,
responseWithNullableData,
} from "./util/api";
import {
CountByYearAndDaySchema,
CustomThemeNameSchema,
CustomThemeSchema,
FavoriteQuotesSchema,
MonkeyMailSchema,
ResultFiltersSchema,
StreakHourOffsetSchema,
TagNameSchema,
TestActivitySchema,
UserProfileDetailsSchema,
UserProfileSchema,
ReportUserReasonSchema,
UserSchema,
UserStreakSchema,
UserTagSchema,
UserEmailSchema,
UserNameSchema,
FriendSchema,
} from "@monkeytype/schemas/users";
import {
Mode2Schema,
ModeSchema,
PersonalBestSchema,
} from "@monkeytype/schemas/shared";
import { IdSchema, StringNumberSchema } from "@monkeytype/schemas/util";
import { LanguageSchema } from "@monkeytype/schemas/languages";
import { CustomThemeColorsSchema } from "@monkeytype/schemas/configs";
export const GetUserResponseSchema = responseWithData(
UserSchema.extend({
inboxUnreadSize: z.number().int().nonnegative(),
}),
);
export type GetUserResponse = z.infer<typeof GetUserResponseSchema>;
export const CreateUserRequestSchema = z.object({
email: UserEmailSchema.optional(),
name: UserNameSchema,
uid: z.string().optional(), //defined by firebase, no validation should be applied
captcha: z.string(), //defined by google recaptcha, no validation should be applied
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const CheckNamePathParametersSchema = z.object({
name: UserNameSchema,
});
export type CheckNamePathParameters = z.infer<
typeof CheckNamePathParametersSchema
>;
export const CheckNameResponseSchema = responseWithData(
z.object({
available: z.boolean(),
}),
);
export type CheckNameResponse = z.infer<typeof CheckNameResponseSchema>;
export const UpdateUserNameRequestSchema = z.object({
name: UserNameSchema,
});
export type UpdateUserNameRequest = z.infer<typeof UpdateUserNameRequestSchema>;
export const UpdateLeaderboardMemoryRequestSchema = z.object({
mode: ModeSchema,
mode2: Mode2Schema,
language: LanguageSchema,
rank: z.number().int().nonnegative(),
});
export type UpdateLeaderboardMemoryRequest = z.infer<
typeof UpdateLeaderboardMemoryRequestSchema
>;
export const UpdateEmailRequestSchema = z.object({
newEmail: UserEmailSchema,
previousEmail: UserEmailSchema,
});
export type UpdateEmailRequest = z.infer<typeof UpdateEmailRequestSchema>;
export const UpdatePasswordRequestSchema = z.object({
newPassword: z.string().min(6),
});
export type UpdatePasswordRequest = z.infer<typeof UpdatePasswordRequestSchema>;
export const GetPersonalBestsQuerySchema = z.object({
mode: ModeSchema,
mode2: Mode2Schema.optional(),
});
export type GetPersonalBestsQuery = z.infer<typeof GetPersonalBestsQuerySchema>;
export const GetPersonalBestsResponseSchema =
responseWithNullableData(PersonalBestSchema);
export type GetPersonalBestsResponse = z.infer<
typeof GetPersonalBestsResponseSchema
>;
export const AddResultFilterPresetRequestSchema = ResultFiltersSchema;
export type AddResultFilterPresetRequest = z.infer<
typeof AddResultFilterPresetRequestSchema
>;
export const AddResultFilterPresetResponseSchema = responseWithData(
IdSchema.describe("Id of the created result filter preset"),
);
export type AddResultFilterPresetResponse = z.infer<
typeof AddResultFilterPresetResponseSchema
>;
export const RemoveResultFilterPresetPathParamsSchema = z.object({
presetId: IdSchema,
});
export type RemoveResultFilterPresetPathParams = z.infer<
typeof RemoveResultFilterPresetPathParamsSchema
>;
export const GetTagsResponseSchema = responseWithData(z.array(UserTagSchema));
export type GetTagsResponse = z.infer<typeof GetTagsResponseSchema>;
export const AddTagRequestSchema = z.object({
tagName: TagNameSchema,
});
export type AddTagRequest = z.infer<typeof AddTagRequestSchema>;
export const AddTagResponseSchema = responseWithData(UserTagSchema);
export type AddTagResponse = z.infer<typeof AddTagResponseSchema>;
export const EditTagRequestSchema = z.object({
tagId: IdSchema,
newName: TagNameSchema,
});
export type EditTagRequest = z.infer<typeof EditTagRequestSchema>;
export const TagIdPathParamsSchema = z.object({
tagId: IdSchema,
});
export type TagIdPathParams = z.infer<typeof TagIdPathParamsSchema>;
export const GetCustomThemesResponseSchema = responseWithData(
z.array(CustomThemeSchema),
);
export type GetCustomThemesResponse = z.infer<
typeof GetCustomThemesResponseSchema
>;
export const AddCustomThemeRequestSchema = z.object({
name: CustomThemeNameSchema,
colors: CustomThemeColorsSchema,
});
export type AddCustomThemeRequest = z.infer<typeof AddCustomThemeRequestSchema>;
export const AddCustomThemeResponseSchema = responseWithData(
CustomThemeSchema.pick({ _id: true, name: true }),
);
export type AddCustomThemeResponse = z.infer<
typeof AddCustomThemeResponseSchema
>;
export const DeleteCustomThemeRequestSchema = z.object({
themeId: IdSchema,
});
export type DeleteCustomThemeRequest = z.infer<
typeof DeleteCustomThemeRequestSchema
>;
export const EditCustomThemeRequstSchema = z.object({
themeId: IdSchema,
theme: CustomThemeSchema.pick({ name: true, colors: true }),
});
export type EditCustomThemeRequst = z.infer<typeof EditCustomThemeRequstSchema>;
export const GetDiscordOauthLinkResponseSchema = responseWithData(
z.object({
url: z.string().url(),
}),
);
export type GetDiscordOauthLinkResponse = z.infer<
typeof GetDiscordOauthLinkResponseSchema
>;
export const LinkDiscordRequestSchema = z.object({
tokenType: z.string(),
accessToken: z.string(),
state: z.string().length(20),
});
export type LinkDiscordRequest = z.infer<typeof LinkDiscordRequestSchema>;
export const LinkDiscordResponseSchema = responseWithData(
UserSchema.pick({ discordId: true, discordAvatar: true }),
);
export type LinkDiscordResponse = z.infer<typeof LinkDiscordResponseSchema>;
export const GetStatsResponseSchema = responseWithData(
UserSchema.pick({
completedTests: true,
startedTests: true,
timeTyping: true,
}),
);
export type GetStatsResponse = z.infer<typeof GetStatsResponseSchema>;
export const SetStreakHourOffsetRequestSchema = z.object({
hourOffset: StreakHourOffsetSchema,
});
export type SetStreakHourOffsetRequest = z.infer<
typeof SetStreakHourOffsetRequestSchema
>;
export const GetFavoriteQuotesResponseSchema =
responseWithData(FavoriteQuotesSchema);
export type GetFavoriteQuotesResponse = z.infer<
typeof GetFavoriteQuotesResponseSchema
>;
export const AddFavoriteQuoteRequestSchema = z.object({
language: LanguageSchema,
quoteId: StringNumberSchema,
});
export type AddFavoriteQuoteRequest = z.infer<
typeof AddFavoriteQuoteRequestSchema
>;
export const RemoveFavoriteQuoteRequestSchema = z.object({
language: LanguageSchema,
quoteId: StringNumberSchema,
});
export type RemoveFavoriteQuoteRequest = z.infer<
typeof RemoveFavoriteQuoteRequestSchema
>;
export const GetProfilePathParamsSchema = z.object({
uidOrName: z.string(),
});
export type GetProfilePathParams = z.infer<typeof GetProfilePathParamsSchema>;
//TODO test?!
export const GetProfileQuerySchema = z.object({
isUid: z
.string()
.length(0)
.transform((it) => it === "")
.or(z.boolean())
.default(false),
});
export type GetProfileQuery = z.infer<typeof GetProfileQuerySchema>;
export const GetProfileResponseSchema = responseWithData(UserProfileSchema);
export type GetProfileResponse = z.infer<typeof GetProfileResponseSchema>;
export const UpdateUserProfileRequestSchema = UserProfileDetailsSchema.extend({
selectedBadgeId: z
.number()
.int()
.nonnegative()
.optional()
.or(z.literal(-1).describe("no badge selected")), //TODO remove the -1, use optional?
});
export type UpdateUserProfileRequest = z.infer<
typeof UpdateUserProfileRequestSchema
>;
export const UpdateUserProfileResponseSchema = responseWithData(
UserProfileDetailsSchema,
);
export type UpdateUserProfileResponse = z.infer<
typeof UpdateUserProfileResponseSchema
>;
export const GetUserInboxResponseSchema = responseWithData(
z.object({
inbox: z.array(MonkeyMailSchema),
maxMail: z.number().int(),
}),
);
export type GetUserInboxResponse = z.infer<typeof GetUserInboxResponseSchema>;
export const UpdateUserInboxRequestSchema = z.object({
mailIdsToDelete: z.array(z.string().uuid()).min(1).optional(),
mailIdsToMarkRead: z.array(z.string().uuid()).min(1).optional(),
});
export type UpdateUserInboxRequest = z.infer<
typeof UpdateUserInboxRequestSchema
>;
export const ReportUserRequestSchema = z.object({
uid: z.string(),
reason: ReportUserReasonSchema,
comment: z
.string()
.regex(/^([.]|[^/<>])+$/)
.max(250)
.optional()
.or(z.string().length(0)),
captcha: z.string(), //we don't generate the captcha so there should be no validation
});
export type ReportUserRequest = z.infer<typeof ReportUserRequestSchema>;
export const ForgotPasswordEmailRequestSchema = z.object({
captcha: z.string(),
email: UserEmailSchema,
});
export type ForgotPasswordEmailRequest = z.infer<
typeof ForgotPasswordEmailRequestSchema
>;
export const GetTestActivityResponseSchema = responseWithNullableData(
CountByYearAndDaySchema,
);
export type GetTestActivityResponse = z.infer<
typeof GetTestActivityResponseSchema
>;
export const GetCurrentTestActivityResponseSchema =
responseWithNullableData(TestActivitySchema);
export type GetCurrentTestActivityResponse = z.infer<
typeof GetCurrentTestActivityResponseSchema
>;
export const GetStreakResponseSchema =
responseWithNullableData(UserStreakSchema);
export type GetStreakResponse = z.infer<typeof GetStreakResponseSchema>;
export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema));
export type GetFriendsResponse = z.infer<typeof GetFriendsResponseSchema>;
const c = initContract();
export const usersContract = c.router(
{
get: {
summary: "get user",
description: "Get a user's data.",
method: "GET",
path: "",
responses: {
200: GetUserResponseSchema,
},
metadata: meta({
rateLimit: "userGet",
}),
},
create: {
summary: "create user",
description: "Creates a new user",
method: "POST",
path: "/signup",
body: CreateUserRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userSignup",
requireConfiguration: {
path: "users.signUp",
invalidMessage: "Sign up is temporarily disabled",
},
}),
},
getNameAvailability: {
summary: "check name",
description: "Checks to see if a username is available",
method: "GET",
path: "/checkName/:name",
pathParams: CheckNamePathParametersSchema.strict(),
responses: {
200: CheckNameResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userCheckName",
}),
},
delete: {
summary: "delete user",
description: "Deletes a user's account",
method: "DELETE",
path: "",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userDelete",
}),
},
reset: {
summary: "reset user",
description: "Completely resets a user's account to a blank state",
method: "PATCH",
path: "/reset",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userReset",
}),
},
updateName: {
summary: "update username",
description: "Updates a user's name",
method: "PATCH",
path: "/name",
body: UpdateUserNameRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userUpdateName",
}),
},
updateLeaderboardMemory: {
summary: "update lbMemory",
description: "Updates a user's cached leaderboard state",
method: "PATCH",
path: "/leaderboardMemory",
body: UpdateLeaderboardMemoryRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userUpdateLBMemory",
}),
},
updateEmail: {
summary: "update email",
description: "Updates a user's email",
method: "PATCH",
path: "/email",
body: UpdateEmailRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userUpdateEmail",
}),
},
updatePassword: {
summary: "update password",
description: "Updates a user's password",
method: "PATCH",
path: "/password",
body: UpdatePasswordRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userUpdateEmail",
}),
},
getPersonalBests: {
summary: "get personal bests",
description: "Get user's personal bests",
method: "GET",
path: "/personalBests",
query: GetPersonalBestsQuerySchema.strict(),
responses: {
200: GetPersonalBestsResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userGet",
}),
},
deletePersonalBests: {
summary: "delete personal bests",
description: "Deletes a user's personal bests",
method: "DELETE",
path: "/personalBests",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userClearPB",
}),
},
optOutOfLeaderboards: {
summary: "leaderboards opt out",
description: "Opt out of the leaderboards",
method: "POST",
path: "/optOutOfLeaderboards",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true },
rateLimit: "userOptOutOfLeaderboards",
}),
},
addResultFilterPreset: {
summary: "add result filter preset",
description: "Add a result filter preset",
method: "POST",
path: "/resultFilterPresets",
body: AddResultFilterPresetRequestSchema.strict(),
responses: {
200: AddResultFilterPresetResponseSchema,
},
metadata: meta({
rateLimit: "userCustomFilterAdd",
requireConfiguration: {
path: "results.filterPresets.enabled",
invalidMessage:
"Result filter presets are not available at this time.",
},
}),
},
removeResultFilterPreset: {
summary: "remove result filter preset",
description: "Remove a result filter preset",
method: "DELETE",
path: "/resultFilterPresets/:presetId",
pathParams: RemoveResultFilterPresetPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userCustomFilterRemove",
requireConfiguration: {
path: "results.filterPresets.enabled",
invalidMessage:
"Result filter presets are not available at this time.",
},
}),
},
getTags: {
summary: "get tags",
description: "Get the users tags",
method: "GET",
path: "/tags",
responses: {
200: GetTagsResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userTagsGet",
}),
},
createTag: {
summary: "add tag",
description: "Add a tag for the current user",
method: "POST",
path: "/tags",
body: AddTagRequestSchema.strict(),
responses: {
200: AddTagResponseSchema,
},
metadata: meta({
rateLimit: "userTagsAdd",
}),
},
editTag: {
summary: "edit tag",
description: "Edit a tag",
method: "PATCH",
path: "/tags",
body: EditTagRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userTagsEdit",
}),
},
deleteTag: {
summary: "delete tag",
description: "Delete a tag",
method: "DELETE",
path: "/tags/:tagId",
pathParams: TagIdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userTagsRemove",
}),
},
deleteTagPersonalBest: {
summary: "delete tag PBs",
description: "Delete personal bests of a tag",
method: "DELETE",
path: "/tags/:tagId/personalBest",
pathParams: TagIdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userTagsClearPB",
}),
},
getCustomThemes: {
summary: "get custom themes",
description: "Get custom themes for the current user",
method: "GET",
path: "/customThemes",
responses: {
200: GetCustomThemesResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeGet",
}),
},
addCustomTheme: {
summary: "add custom themes",
description: "Add a custom theme for the current user",
method: "POST",
path: "/customThemes",
body: AddCustomThemeRequestSchema.strict(),
responses: {
200: AddCustomThemeResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeAdd",
}),
},
deleteCustomTheme: {
summary: "delete custom themes",
description: "Delete a custom theme",
method: "DELETE",
path: "/customThemes",
body: DeleteCustomThemeRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeRemove",
}),
},
editCustomTheme: {
summary: "edit custom themes",
description: "Edit a custom theme",
method: "PATCH",
path: "/customThemes",
body: EditCustomThemeRequstSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userCustomThemeEdit",
}),
},
getDiscordOAuth: {
summary: "discord oauth",
description: "Start OAuth authentication with discord",
method: "GET",
path: "/discord/oauth",
responses: {
200: GetDiscordOauthLinkResponseSchema,
},
metadata: meta({
rateLimit: "userDiscordLink",
requireConfiguration: {
path: "users.discordIntegration.enabled",
invalidMessage: "Discord integration is not available at this time",
},
}),
},
linkDiscord: {
summary: "link with discord",
description: "Links a user's account with a discord account",
method: "POST",
path: "/discord/link",
body: LinkDiscordRequestSchema.strict(),
responses: {
200: LinkDiscordResponseSchema,
},
metadata: meta({
rateLimit: "userDiscordLink",
requireConfiguration: {
path: "users.discordIntegration.enabled",
invalidMessage: "Discord integration is not available at this time",
},
}),
},
unlinkDiscord: {
summary: "unlink discord",
description: "Unlinks a user's account with a discord account",
method: "POST",
path: "/discord/unlink",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userDiscordUnlink",
}),
},
getStats: {
summary: "get stats",
description: "Gets a user's typing stats data",
method: "GET",
path: "/stats",
responses: {
200: GetStatsResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userGet",
}),
},
setStreakHourOffset: {
summary: "set streak hour offset",
description: "Sets a user's streak hour offset",
method: "POST",
path: "/setStreakHourOffset",
body: SetStreakHourOffsetRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "setStreakHourOffset",
}),
},
getFavoriteQuotes: {
summary: "get favorite quotes",
description: "Gets a user's favorite quotes",
method: "GET",
path: "/favoriteQuotes",
responses: {
200: GetFavoriteQuotesResponseSchema,
},
metadata: meta({
rateLimit: "quoteFavoriteGet",
}),
},
addQuoteToFavorites: {
summary: "add favorite quotes",
description: "Add a quote to the user's favorite quotes",
method: "POST",
path: "/favoriteQuotes",
body: AddFavoriteQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteFavoritePost",
}),
},
removeQuoteFromFavorites: {
summary: "remove favorite quotes",
description: "Remove a quote to the user's favorite quotes",
method: "DELETE",
path: "/favoriteQuotes",
body: RemoveFavoriteQuoteRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteFavoriteDelete",
}),
},
getProfile: {
summary: "get profile",
description: "Gets a user's profile",
method: "GET",
path: "/:uidOrName/profile",
pathParams: GetProfilePathParamsSchema.strict(),
query: GetProfileQuerySchema.strict(),
responses: {
200: GetProfileResponseSchema,
404: MonkeyClientError.describe("User not found"),
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userProfileGet",
requireConfiguration: {
path: "users.profiles.enabled",
invalidMessage: "Profiles are not available at this time",
},
}),
},
updateProfile: {
summary: "update profile",
description: "Update a user's profile",
method: "PATCH",
path: "/profile",
body: UpdateUserProfileRequestSchema.strict(),
responses: {
200: UpdateUserProfileResponseSchema,
},
metadata: meta({
rateLimit: "userProfileUpdate",
requireConfiguration: {
path: "users.profiles.enabled",
invalidMessage: "Profiles are not available at this time",
},
}),
},
getInbox: {
summary: "get inbox",
description: "Gets the user's inbox",
method: "GET",
path: "/inbox",
responses: {
200: GetUserInboxResponseSchema,
},
metadata: meta({
rateLimit: "userMailGet",
requireConfiguration: {
path: "users.inbox.enabled",
invalidMessage: "Your inbox is not available at this time.",
},
}),
},
updateInbox: {
summary: "update inbox",
description: "Updates the user's inbox",
method: "PATCH",
body: UpdateUserInboxRequestSchema.strict(),
path: "/inbox",
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "userMailUpdate",
requireConfiguration: {
path: "users.inbox.enabled",
invalidMessage: "Your inbox is not available at this time.",
},
}),
},
report: {
summary: "report user",
description: "Report a user",
method: "POST",
path: "/report",
body: ReportUserRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "quoteReportSubmit",
requirePermission: "canReport",
requireConfiguration: {
path: "quotes.reporting.enabled",
invalidMessage: "User reporting is unavailable.",
},
}),
},
verificationEmail: {
summary: "send verification email",
description: "Send a verification email",
method: "GET",
path: "/verificationEmail",
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { noCache: true },
rateLimit: "userRequestVerificationEmail",
}),
},
forgotPasswordEmail: {
summary: "send forgot password email",
description: "Send a forgot password email",
method: "POST",
path: "/forgotPasswordEmail",
body: ForgotPasswordEmailRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userForgotPasswordEmail",
}),
},
revokeAllTokens: {
summary: "revoke all tokens",
description: "Revoke all tokens for the current user.",
method: "POST",
path: "/revokeAllTokens",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { requireFreshToken: true, noCache: true },
rateLimit: "userRevokeAllTokens",
}),
},
getTestActivity: {
summary: "get test activity",
description: "Get user's test activity",
method: "GET",
path: "/testActivity",
responses: {
200: GetTestActivityResponseSchema,
},
metadata: meta({
rateLimit: "userTestActivity",
}),
},
getCurrentTestActivity: {
summary: "get current test activity",
description:
"Get test activity for the last up to 372 days for the current user ",
method: "GET",
path: "/currentTestActivity",
responses: {
200: GetCurrentTestActivityResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userCurrentTestActivity",
}),
},
getStreak: {
summary: "get streak",
description: "Get user's streak data",
method: "GET",
path: "/streak",
responses: {
200: GetStreakResponseSchema,
},
metadata: meta({
authenticationOptions: { acceptApeKeys: true },
rateLimit: "userStreak",
}),
},
getFriends: {
summary: "get friends",
description: "get friends list",
method: "GET",
path: "/friends",
responses: {
200: GetFriendsResponseSchema,
},
metadata: meta({
rateLimit: "userFriendGet",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Connections are not available at this time.",
},
}),
},
},
{
pathPrefix: "/users",
strictStatusCodes: true,
metadata: meta({
openApiTags: "users",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,148 @@
import { z, ZodSchema } from "zod";
import { RateLimitIds, RateLimiterId } from "../rate-limit";
import { RequireConfiguration } from "../require-configuration";
export type OpenApiTag =
| "configs"
| "presets"
| "ape-keys"
| "admin"
| "psas"
| "public"
| "leaderboards"
| "results"
| "configuration"
| "development"
| "users"
| "quotes"
| "webhooks"
| "connections";
export type PermissionId =
| "quoteMod"
| "canReport"
| "canManageApeKeys"
| "admin";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */
authenticationOptions?: RequestAuthenticationOptions;
openApiTags?: OpenApiTag | OpenApiTag[];
/** RateLimitId or RateLimitIds.
* Only specifying RateLimiterId will use a default limiter with 30 requests/minute for ApeKey requests.
*/
rateLimit?: RateLimiterId | RateLimitIds;
/** Role/Rples needed to access the endpoint*/
requirePermission?: PermissionId | PermissionId[];
/** Endpoint is only available if configuration allows it */
requireConfiguration?: RequireConfiguration | RequireConfiguration[];
};
/**
*
* @param metadata Ensure the type of metadata is `EndpointMetadata`.
* Ts-rest does not allow to specify the type of `metadata`.
* @returns
*/
export function meta(metadata: EndpointMetadata): EndpointMetadata {
return metadata;
}
export type RequestAuthenticationOptions = {
/** Endpoint is accessible without any authentication. If `false` bearer authentication is required. */
isPublic?: boolean;
/** Endpoint is accessible with ape key authentication in _addition_ to the bearer authentication. */
acceptApeKeys?: boolean;
/** Endpoint requires an authentication token which is younger than one minute. */
requireFreshToken?: boolean;
noCache?: boolean;
/** Allow unauthenticated requests on dev */
isPublicOnDev?: boolean;
/** Endpoint is a webhook only to be called by Github */
isGithubWebhook?: boolean;
};
export const MonkeyResponseSchema = z.object({
message: z.string(),
});
export type MonkeyResponseType = z.infer<typeof MonkeyResponseSchema>;
export const MonkeyValidationErrorSchema = MonkeyResponseSchema.extend({
validationErrors: z.array(z.string()),
});
export type MonkeyValidationError = z.infer<typeof MonkeyValidationErrorSchema>;
export const MonkeyClientError = MonkeyResponseSchema;
export type MonkeyClientErrorType = z.infer<typeof MonkeyClientError>;
export const MonkeyServerError = MonkeyClientError.extend({
errorId: z.string(),
uid: z.string().optional(),
});
export type MonkeyServerErrorType = z.infer<typeof MonkeyServerError>;
export function responseWithNullableData<T extends ZodSchema>(
dataSchema: T,
): z.ZodObject<
z.objectUtil.extendShape<
typeof MonkeyResponseSchema.shape,
{
data: z.ZodNullable<T>;
}
>
> {
return MonkeyResponseSchema.extend({
data: dataSchema.nullable(),
});
}
export function responseWithData<T extends ZodSchema>(
dataSchema: T,
): z.ZodObject<
z.objectUtil.extendShape<
typeof MonkeyResponseSchema.shape,
{
data: T;
}
>
> {
return MonkeyResponseSchema.extend({
data: dataSchema,
});
}
export const CommonResponses = {
400: MonkeyClientError.describe("Generic client error"),
401: MonkeyClientError.describe(
"Authentication required but not provided or invalid",
),
403: MonkeyClientError.describe("Operation not permitted"),
422: MonkeyValidationErrorSchema.describe("Request validation failed"),
429: MonkeyClientError.describe("Rate limit exceeded"),
470: MonkeyClientError.describe("Invalid ApeKey"),
471: MonkeyClientError.describe("ApeKey is inactive"),
472: MonkeyClientError.describe("ApeKey is malformed"),
479: MonkeyClientError.describe("ApeKey rate limit exceeded"),
500: MonkeyServerError.describe("Generic server error"),
503: MonkeyServerError.describe(
"Endpoint disabled or server is under maintenance",
),
};
export type CommonResponsesType =
| {
status: 400 | 401 | 403 | 429 | 470 | 471 | 472 | 479;
body: MonkeyClientErrorType;
}
| {
status: 422;
body: MonkeyValidationError;
}
| {
status: 500 | 503;
body: MonkeyServerErrorType;
};

View File

@@ -0,0 +1,60 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { PSASchema } from "@monkeytype/schemas/psas";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
/**
*Schema: https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=published#release
We only specify the values we read and don't validate any other values.
*/
export const PostGithubReleaseRequestSchema = z.object({
action: z.literal("published").or(z.string()),
release: z
.object({
id: z.string().or(z.number().transform(String)), //we use string, github defines this as a number.
})
.optional(),
});
export type PostGithubReleaseRequest = z.infer<
typeof PostGithubReleaseRequestSchema
>;
export const GetPsaResponseSchema = responseWithData(z.array(PSASchema));
export type GetPsaResponse = z.infer<typeof GetPsaResponseSchema>;
const c = initContract();
export const webhooksContract = c.router(
{
postGithubRelease: {
summary: "Github release",
description: "Announce github release.",
method: "POST",
path: "/githubRelease",
body: PostGithubReleaseRequestSchema, //don't use strict
headers: z.object({
"x-hub-signature-256": z.string(),
}),
responses: {
200: MonkeyResponseSchema,
},
},
},
{
pathPrefix: "/webhooks",
strictStatusCodes: true,
metadata: meta({
openApiTags: "webhooks",
authenticationOptions: {
isGithubWebhook: true,
},
rateLimit: "webhookLimit",
}),
commonResponses: CommonResponses,
},
);

View File

@@ -0,0 +1,10 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "ES6"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
import { extendConfig } from "@monkeytype/tsup-config";
export default extendConfig();

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
coverage: {
include: ["**/*.ts"],
},
},
});

View File

@@ -0,0 +1,7 @@
{
"ignorePatterns": ["node_modules", "dist", ".turbo"],
"extends": [
"../oxlint-config/index.jsonc"
// "@monkeytype/oxlint-config"
]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
}

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import * as List from "../src/list";
import * as Validation from "../src/validation";
import { FunboxMetadata } from "../src/types";
describe("validation", () => {
describe("checkCompatibility", () => {
const getFunboxMock = vi.spyOn(List, "getFunbox");
beforeEach(() => {
getFunboxMock.mockClear();
});
it("should pass without funboxNames", () => {
//WHEN / THEN
expect(Validation.checkCompatibility([])).toBe(true);
});
it("should fail for undefined funboxNames", () => {
//GIVEN
getFunboxMock.mockReturnValueOnce([
{
name: "plus_one",
} as FunboxMetadata,
undefined as unknown as FunboxMetadata,
]);
//WHEN / THEN
expect(Validation.checkCompatibility(["plus_one", "plus_two"])).toBe(
false,
);
});
it("should fail for undefined withFunbox param", () => {
//GIVEN
getFunboxMock
.mockReturnValueOnce([])
.mockReturnValue([undefined as unknown as FunboxMetadata]);
//WHEN / THEN
expect(
Validation.checkCompatibility(["plus_one", "plus_two"], "plus_three"),
).toBe(false);
});
it("should check for optional withFunbox param ", () => {
//GIVEN
getFunboxMock
.mockReturnValueOnce([
{
name: "plus_one",
cssModifications: ["body", "main"],
} as FunboxMetadata,
{
name: "plus_two",
} as FunboxMetadata,
])
.mockReturnValueOnce([
{
name: "plus_three",
cssModifications: ["main", "typingTest"],
} as FunboxMetadata,
]);
//WHEN
const result = Validation.checkCompatibility(
["plus_one", "plus_two"],
"plus_three",
);
//THEN
expect(result).toBe(false);
expect(getFunboxMock).toHaveBeenNthCalledWith(1, [
"plus_one",
"plus_two",
]);
expect(getFunboxMock).toHaveBeenNthCalledWith(2, "plus_three");
});
it("should reject two funboxes modifying the same css element", () => {
//GIVEN
getFunboxMock.mockReturnValueOnce([
{
name: "plus_one",
cssModifications: ["body", "main"],
} as FunboxMetadata,
{
name: "plus_two",
cssModifications: ["main", "typingTest"],
} as FunboxMetadata,
]);
//WHEN / THEN
expect(Validation.checkCompatibility(["plus_one", "plus_two"])).toBe(
false,
);
});
it("should allow two funboxes modifying different css elements", () => {
//GIVEN
getFunboxMock.mockReturnValueOnce([
{
name: "plus_one",
cssModifications: ["body", "main"],
} as FunboxMetadata,
{
name: "plus_two",
cssModifications: ["words"],
} as FunboxMetadata,
]);
//WHEN / THEN
expect(Validation.checkCompatibility(["plus_one", "plus_two"])).toBe(
true,
);
});
describe("should validate two funboxes modifying the wordset", () => {
const testCases = [
{
firstFunction: "withWords",
secondFunction: "withWords",
compatible: false,
},
{
firstFunction: "withWords",
secondFunction: "getWord",
compatible: false,
},
{
firstFunction: "getWord",
secondFunction: "pullSection",
compatible: false,
},
];
it.for(testCases)(
`expect $firstFunction and $secondFunction to be compatible $compatible`,
({ firstFunction, secondFunction, compatible }) => {
//GIVEN
getFunboxMock.mockReturnValueOnce([
{
name: "plus_one",
frontendFunctions: [firstFunction],
} as FunboxMetadata,
{
name: "plus_two",
frontendFunctions: [secondFunction],
} as FunboxMetadata,
]);
//WHEN / THEN
expect(Validation.checkCompatibility(["plus_one", "plus_two"])).toBe(
compatible,
);
},
);
});
});
});

View File

@@ -0,0 +1,34 @@
{
"name": "@monkeytype/funbox",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"dev": "tsup-node --watch",
"build": "npm run madge && tsup-node",
"test": "vitest run",
"madge": " madge --circular --extensions ts ./src",
"ts-check": "tsc --noEmit",
"lint": "oxlint . --type-aware --type-check",
"lint-fast": "oxlint ."
},
"dependencies": {
"@monkeytype/schemas": "workspace:*",
"@monkeytype/util": "workspace:*"
},
"devDependencies": {
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"tsup": "8.4.0",
"typescript": "6.0.2",
"vitest": "4.1.0"
}
}

View File

@@ -0,0 +1,15 @@
import { FunboxName } from "@monkeytype/schemas/configs";
import { getList, getFunbox, getObject, getFunboxNames } from "./list";
import { FunboxMetadata, FunboxProperty } from "./types";
import { checkCompatibility, checkForcedConfig } from "./validation";
export type { FunboxMetadata, FunboxProperty };
export { checkCompatibility, checkForcedConfig, getFunbox, getFunboxNames };
export function getAllFunboxes(): FunboxMetadata[] {
return getList();
}
export function getFunboxObject(): Record<FunboxName, FunboxMetadata> {
return getObject();
}

528
packages/funbox/src/list.ts Normal file
View File

@@ -0,0 +1,528 @@
import { FunboxName } from "@monkeytype/schemas/configs";
import { FunboxMetadata } from "./types";
const list: Record<FunboxName, FunboxMetadata> = {
"58008": {
description: "A special mode for accountants.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "noLetters"],
frontendForcedConfig: {
numbers: [false],
},
frontendFunctions: [
"getWord",
"punctuateWord",
"rememberSettings",
"getEmulatedChar",
],
name: "58008",
alias: "numbers",
},
mirror: {
name: "mirror",
description: "Everything is mirrored!",
properties: ["hasCssFile"],
canGetPb: true,
difficultyLevel: 3,
cssModifications: ["main"],
},
upside_down: {
name: "upside_down",
description: "Everything is upside down!",
properties: ["hasCssFile"],
canGetPb: true,
difficultyLevel: 3,
cssModifications: ["main"],
},
nausea: {
name: "nausea",
description: "I think I'm gonna be sick.",
canGetPb: true,
difficultyLevel: 2,
properties: ["hasCssFile", "ignoreReducedMotion"],
cssModifications: ["typingTest"],
},
round_round_baby: {
name: "round_round_baby",
description:
"...right round, like a record baby. Right, round round round.",
canGetPb: true,
difficultyLevel: 3,
properties: ["hasCssFile", "ignoreReducedMotion"],
cssModifications: ["typingTest"],
},
simon_says: {
name: "simon_says",
description: "Type what simon says.",
canGetPb: true,
difficultyLevel: 1,
properties: ["hasCssFile", "changesWordsVisibility", "usesLayout"],
frontendForcedConfig: {
highlightMode: ["letter", "off"],
},
frontendFunctions: ["applyConfig", "rememberSettings"],
},
tts: {
canGetPb: true,
difficultyLevel: 1,
properties: ["hasCssFile", "changesWordsVisibility", "speaks"],
frontendForcedConfig: {
highlightMode: ["letter", "off"],
},
frontendFunctions: ["applyConfig", "rememberSettings", "toggleScript"],
name: "tts",
description: "Listen closely.",
cssModifications: ["words"],
},
choo_choo: {
canGetPb: true,
difficultyLevel: 2,
properties: [
"hasCssFile",
"noLigatures",
"conflictsWithSymmetricChars",
"ignoreReducedMotion",
],
name: "choo_choo",
description: "All the letters are spinning!",
cssModifications: ["words"],
},
arrows: {
description: "Play it on a pad!",
canGetPb: false,
difficultyLevel: 1,
properties: [
"ignoresLanguage",
"ignoresLayout",
"nospace",
"noLetters",
"symmetricChars",
],
frontendForcedConfig: {
punctuation: [false],
numbers: [false],
highlightMode: ["letter", "off"],
},
frontendFunctions: [
"getWord",
"rememberSettings",
"getEmulatedChar",
"isCharCorrect",
"getWordHtml",
],
name: "arrows",
},
rAnDoMcAsE: {
description: "raNdomIze ThE CApitaLizatIon Of EveRY LeTtEr.",
canGetPb: false,
difficultyLevel: 2,
properties: ["changesCapitalisation"],
frontendFunctions: ["alterText"],
name: "rAnDoMcAsE",
},
sPoNgEcAsE: {
description: "I kInDa LiKe HoW iNeFfIcIeNt QwErTy Is.",
canGetPb: false,
difficultyLevel: 2,
properties: ["changesCapitalisation"],
frontendFunctions: ["alterText"],
name: "sPoNgEcAsE",
},
capitals: {
description: "Capitalize Every Word.",
canGetPb: false,
difficultyLevel: 1,
properties: ["changesCapitalisation"],
frontendFunctions: ["alterText"],
name: "capitals",
},
layout_mirror: {
description: "Mirror the keyboard layout",
canGetPb: true,
difficultyLevel: 3,
properties: ["changesLayout"],
frontendFunctions: ["applyConfig", "rememberSettings"],
name: "layout_mirror",
},
layoutfluid: {
description:
"Switch between layouts specified below proportionately to the length of the test.",
canGetPb: true,
difficultyLevel: 1,
properties: ["changesLayout", "noInfiniteDuration"],
frontendFunctions: [
"applyConfig",
"rememberSettings",
"handleSpace",
"getResultContent",
],
name: "layoutfluid",
},
earthquake: {
description: "Everybody get down! The words are shaking!",
canGetPb: true,
difficultyLevel: 1,
properties: ["hasCssFile", "noLigatures", "ignoreReducedMotion"],
name: "earthquake",
cssModifications: ["words"],
},
space_balls: {
description: "In a galaxy far far away.",
canGetPb: true,
difficultyLevel: 0,
properties: ["hasCssFile", "ignoreReducedMotion"],
name: "space_balls",
cssModifications: ["body"],
},
gibberish: {
description: "Anvbuefl dizzs eoos alsb?",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "unspeakable"],
frontendFunctions: ["getWord"],
name: "gibberish",
},
ascii: {
description: "Where was the ampersand again?. Only ASCII characters.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "noLetters", "unspeakable"],
frontendForcedConfig: {
punctuation: [false],
numbers: [false],
},
frontendFunctions: ["getWord"],
name: "ascii",
},
specials: {
description: "!@#$%^&*. Only special characters.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "noLetters", "unspeakable"],
frontendForcedConfig: {
punctuation: [false],
numbers: [false],
},
frontendFunctions: ["getWord"],
name: "specials",
},
plus_one: {
description: "Only one future word is visible.",
canGetPb: true,
difficultyLevel: 0,
properties: ["changesWordsVisibility", "toPush:2", "noInfiniteDuration"],
name: "plus_one",
},
plus_zero: {
description: "React quickly! Only the current word is visible.",
canGetPb: true,
difficultyLevel: 1,
properties: ["changesWordsVisibility", "toPush:1", "noInfiniteDuration"],
name: "plus_zero",
},
plus_two: {
description: "Only two future words are visible.",
canGetPb: true,
difficultyLevel: 0,
properties: ["changesWordsVisibility", "toPush:3", "noInfiniteDuration"],
name: "plus_two",
},
plus_three: {
description: "Only three future words are visible.",
canGetPb: true,
difficultyLevel: 0,
properties: ["changesWordsVisibility", "toPush:4", "noInfiniteDuration"],
name: "plus_three",
},
read_ahead_easy: {
description: "Only the current word is invisible.",
canGetPb: true,
difficultyLevel: 1,
properties: ["changesWordsVisibility", "hasCssFile"],
frontendForcedConfig: {
highlightMode: ["letter", "off"],
},
frontendFunctions: ["rememberSettings", "handleKeydown"],
name: "read_ahead_easy",
},
read_ahead: {
description: "Current and the next word are invisible!",
canGetPb: true,
difficultyLevel: 2,
properties: ["changesWordsVisibility", "hasCssFile"],
frontendForcedConfig: {
highlightMode: ["letter", "off"],
},
frontendFunctions: ["rememberSettings", "handleKeydown"],
name: "read_ahead",
},
read_ahead_hard: {
description: "Current and the next two words are invisible!",
canGetPb: true,
difficultyLevel: 3,
properties: ["changesWordsVisibility", "hasCssFile"],
frontendForcedConfig: {
highlightMode: ["letter", "off"],
},
frontendFunctions: ["rememberSettings", "handleKeydown"],
name: "read_ahead_hard",
},
memory: {
description: "Test your memory. Remember the words and type them blind.",
canGetPb: true,
difficultyLevel: 3,
properties: ["changesWordsVisibility", "noInfiniteDuration"],
frontendForcedConfig: {
mode: ["words", "quote", "custom"],
},
frontendFunctions: ["applyConfig", "rememberSettings", "start", "restart"],
name: "memory",
},
nospace: {
description: "Whoneedsspacesanyway?",
canGetPb: false,
difficultyLevel: 0,
properties: ["nospace"],
frontendForcedConfig: {
highlightMode: ["letter", "off"],
},
frontendFunctions: ["rememberSettings"],
name: "nospace",
},
poetry: {
description: "Practice typing some beautiful prose.",
canGetPb: false,
difficultyLevel: 0,
properties: ["noInfiniteDuration", "ignoresLanguage"],
frontendForcedConfig: {
punctuation: [false],
numbers: [false],
},
frontendFunctions: ["pullSection"],
name: "poetry",
},
wikipedia: {
description: "Practice typing wikipedia sections.",
canGetPb: false,
difficultyLevel: 0,
properties: ["noInfiniteDuration", "ignoresLanguage"],
frontendForcedConfig: {
punctuation: [false],
numbers: [false],
},
frontendFunctions: ["pullSection"],
name: "wikipedia",
},
weakspot: {
description: "Focus on slow and mistyped letters.",
canGetPb: false,
difficultyLevel: 0,
properties: ["changesWordsFrequency"],
frontendFunctions: ["getWord"],
name: "weakspot",
},
pseudolang: {
description: "Nonsense words that look like the current language.",
canGetPb: false,
difficultyLevel: 0,
properties: ["unspeakable", "ignoresLanguage"],
frontendFunctions: ["withWords"],
name: "pseudolang",
},
IPv4: {
alias: "network",
description: "For sysadmins.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "noLetters"],
frontendForcedConfig: {
numbers: [false],
},
frontendFunctions: ["getWord", "punctuateWord", "rememberSettings"],
name: "IPv4",
},
IPv6: {
alias: "network",
description: "For sysadmins with a long beard.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "noLetters"],
frontendForcedConfig: {
numbers: [false],
},
frontendFunctions: ["getWord", "punctuateWord", "rememberSettings"],
name: "IPv6",
},
binary: {
description:
"01000010 01100101 01100101 01110000 00100000 01100010 01101111 01101111 01110000 00101110",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "noLetters"],
frontendForcedConfig: {
numbers: [false],
punctuation: [false],
},
frontendFunctions: ["getWord"],
name: "binary",
},
hexadecimal: {
description:
"0x38 0x20 0x74 0x69 0x6D 0x65 0x73 0x20 0x6D 0x6F 0x72 0x65 0x20 0x62 0x6F 0x6F 0x70 0x21",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "noLetters"],
frontendForcedConfig: {
numbers: [false],
},
frontendFunctions: ["getWord", "punctuateWord", "rememberSettings"],
name: "hexadecimal",
},
zipf: {
description:
"Words are generated according to Zipf's law. (not all languages will produce Zipfy results, use with caution)",
canGetPb: false,
difficultyLevel: 0,
properties: ["changesWordsFrequency"],
frontendFunctions: ["getWordsFrequencyMode"],
name: "zipf",
},
morse: {
description: "-.../././.--./ -.../---/---/.--./-.-.--/ ",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "noLetters", "nospace"],
frontendFunctions: ["alterText"],
name: "morse",
},
crt: {
description: "Go back to the 1980s",
canGetPb: true,
difficultyLevel: 0,
properties: ["hasCssFile", "noLigatures"],
frontendFunctions: ["applyGlobalCSS", "clearGlobal"],
name: "crt",
cssModifications: ["body"],
},
backwards: {
description: "...sdrawkcab epyt ot yrt woN",
name: "backwards",
properties: [
"hasCssFile",
"conflictsWithSymmetricChars",
"wordOrder:reverse",
"reverseDirection",
],
canGetPb: true,
frontendFunctions: ["alterText"],
difficultyLevel: 3,
cssModifications: ["words"],
},
ddoouubblleedd: {
description: "TTyyppee eevveerryytthhiinngg ttwwiiccee..",
canGetPb: true,
difficultyLevel: 1,
properties: ["noLigatures"],
frontendFunctions: ["alterText"],
name: "ddoouubblleedd",
},
instant_messaging: {
description: "Who needs shift anyway?",
canGetPb: false,
difficultyLevel: 0,
properties: ["changesCapitalisation"],
frontendFunctions: ["alterText"],
name: "instant_messaging",
},
underscore_spaces: {
description: "Underscores_are_better.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage", "ignoresLayout", "nospace"],
frontendFunctions: ["alterText"],
name: "underscore_spaces",
},
ALL_CAPS: {
description: "WHY ARE WE SHOUTING?",
canGetPb: false,
difficultyLevel: 1,
properties: ["changesCapitalisation"],
frontendFunctions: ["alterText"],
name: "ALL_CAPS",
},
polyglot: {
description: "Use words from multiple languages in a single test.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage"],
frontendFunctions: ["withWords"],
name: "polyglot",
},
asl: {
description: "Practice american sign language.",
canGetPb: true,
difficultyLevel: 1,
properties: ["hasCssFile", "noLigatures"],
name: "asl",
cssModifications: ["words"],
},
rot13: {
description: "Vg znl abg or frpher, ohg vg vf sha gb glcr!",
canGetPb: true,
difficultyLevel: 1,
properties: [],
frontendFunctions: ["alterText"],
name: "rot13",
},
no_quit: {
description: "You can't restart the test.",
canGetPb: true,
difficultyLevel: 0,
name: "no_quit",
},
};
export function getObject(): Record<FunboxName, FunboxMetadata> {
return list;
}
export function getFunboxNames(): FunboxName[] {
return Object.keys(list) as FunboxName[];
}
export function getList(): FunboxMetadata[] {
const out: FunboxMetadata[] = [];
for (const name of getFunboxNames()) {
out.push(list[name]);
}
return out;
}
export function getFunbox(name: FunboxName): FunboxMetadata;
export function getFunbox(names: FunboxName[]): FunboxMetadata[];
export function getFunbox(
nameOrNames: FunboxName | FunboxName[],
): FunboxMetadata | FunboxMetadata[] {
if (nameOrNames === undefined) return [];
if (Array.isArray(nameOrNames)) {
const out = nameOrNames.map((name) => getObject()[name]);
//@ts-expect-error sanity check
if (out.includes(undefined)) {
throw new Error(
"One of the funboxes is invalid: " + nameOrNames.toString(),
);
}
return out;
} else {
const out = getObject()[nameOrNames];
if (out === undefined) {
throw new Error("Invalid funbox name: " + nameOrNames);
}
return out;
}
}

View File

@@ -0,0 +1,39 @@
import { FunboxName } from "@monkeytype/schemas/configs";
export type FunboxForcedConfig = Record<string, string[] | boolean[]>;
export type FunboxProperty =
| "hasCssFile"
| "ignoresLanguage"
| "ignoresLayout"
| "noLetters"
| "changesLayout"
| "usesLayout"
| "nospace"
| "changesWordsVisibility"
| "changesWordsFrequency"
| "changesCapitalisation"
| "conflictsWithSymmetricChars"
| "symmetricChars"
| "speaks"
| "unspeakable"
| "noInfiniteDuration"
| "noLigatures"
| `toPush:${number}`
| "wordOrder:reverse"
| "reverseDirection"
| "ignoreReducedMotion";
type FunboxCSSModification = "typingTest" | "words" | "body" | "main";
export type FunboxMetadata = {
name: FunboxName;
alias?: string;
description: string;
properties?: FunboxProperty[];
frontendForcedConfig?: FunboxForcedConfig;
frontendFunctions?: string[];
difficultyLevel: number;
canGetPb: boolean;
cssModifications?: FunboxCSSModification[];
};

View File

@@ -0,0 +1,250 @@
import { intersect } from "@monkeytype/util/arrays";
import { FunboxForcedConfig, FunboxMetadata } from "./types";
import { getFunbox } from "./list";
import { ConfigValue, FunboxName } from "@monkeytype/schemas/configs";
import { safeNumber } from "@monkeytype/util/numbers";
export function checkForcedConfig(
key: string,
value: ConfigValue,
funboxes: FunboxMetadata[],
): {
result: boolean;
forcedConfigs?: ConfigValue[];
} {
if (funboxes.length === 0) {
return { result: true };
}
if (key === "words" || key === "time") {
if (value === 0) {
const fb = funboxes.filter((f) =>
f.properties?.includes("noInfiniteDuration"),
);
if (fb.length > 0) {
return {
result: false,
forcedConfigs: [key === "words" ? 10 : 15],
};
} else {
return { result: true };
}
} else {
return { result: true };
}
} else {
const forcedConfigs: Record<string, ConfigValue[]> = {};
// collect all forced configs
for (const fb of funboxes) {
if (fb.frontendForcedConfig) {
//push keys to forcedConfigs, if they don't exist. if they do, intersect the values
for (const forcedKey in fb.frontendForcedConfig) {
if (forcedConfigs[forcedKey] === undefined) {
forcedConfigs[forcedKey] = fb.frontendForcedConfig[
forcedKey
] as ConfigValue[];
} else {
forcedConfigs[forcedKey] = intersect(
forcedConfigs[forcedKey],
fb.frontendForcedConfig[forcedKey] as ConfigValue[],
true,
);
}
}
}
}
//check if the key is in forcedConfigs, if it is check the value, if its not, return true
if (forcedConfigs[key] === undefined) {
return { result: true };
} else {
if (forcedConfigs[key]?.length === 0) {
throw new Error("No intersection of forced configs");
}
return {
result: (forcedConfigs[key] ?? []).includes(value),
forcedConfigs: forcedConfigs[key],
};
}
}
}
export function checkCompatibility(
funboxNames: FunboxName[],
withFunbox?: FunboxName,
): boolean {
if (funboxNames.length === 0) return true;
let funboxesToCheck: FunboxMetadata[];
try {
funboxesToCheck = getFunbox(funboxNames);
if (withFunbox !== undefined) {
const toAdd = getFunbox(withFunbox);
funboxesToCheck = funboxesToCheck.concat(toAdd);
}
} catch (error) {
console.error(
"Error when getting funboxes for a compatibility check:",
error,
);
return false;
}
const allFunboxesAreValid = funboxesToCheck.every((f) => f !== undefined);
if (!allFunboxesAreValid) return false;
const oneWordModifierMax =
funboxesToCheck.filter(
(f) =>
f.frontendFunctions?.includes("getWord") === true ||
f.frontendFunctions?.includes("pullSection") === true ||
f.frontendFunctions?.includes("withWords") === true,
).length <= 1;
const oneWordOrderMax =
funboxesToCheck.filter(
(f) =>
f.properties?.find((fp) => fp.startsWith("wordOrder")) !== undefined,
).length <= 1;
const layoutUsability =
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "changesLayout"),
).length === 0 ||
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "ignoresLayout" || fp === "usesLayout"),
).length === 0;
const oneNospaceOrToPushMax =
funboxesToCheck.filter(
(f) =>
f.properties?.find(
(fp) => fp === "nospace" || fp.startsWith("toPush"),
) !== undefined,
).length <= 1;
const oneChangesWordsVisibilityMax =
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "changesWordsVisibility"),
).length <= 1;
const oneFrequencyChangesMax =
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "changesWordsFrequency"),
).length <= 1;
const noFrequencyChangesConflicts =
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "changesWordsFrequency"),
).length === 0 ||
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "ignoresLanguage"),
).length === 0;
const capitalisationChangePosibility =
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "noLetters"),
).length === 0 ||
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "changesCapitalisation"),
).length === 0;
const noConflictsWithSymmetricChars =
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "conflictsWithSymmetricChars"),
).length === 0 ||
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "symmetricChars"),
).length === 0;
const oneCanSpeakMax =
funboxesToCheck.filter((f) => f.properties?.find((fp) => fp === "speaks"))
.length <= 1;
const hasLanguageToSpeakAndNoUnspeakable =
funboxesToCheck.filter((f) => f.properties?.find((fp) => fp === "speaks"))
.length === 0 ||
(funboxesToCheck.filter((f) => f.properties?.find((fp) => fp === "speaks"))
.length === 1 &&
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "unspeakable"),
).length === 0) ||
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "ignoresLanguage"),
).length === 0;
const oneToPushOrPullSectionMax =
funboxesToCheck.filter(
(f) =>
f.properties?.find((fp) => fp.startsWith("toPush:")) !== undefined ||
f.frontendFunctions?.includes("pullSection"),
).length <= 1;
const onePunctuateWordMax =
funboxesToCheck.filter((f) =>
f.frontendFunctions?.includes("punctuateWord"),
).length <= 1;
const oneGetEmulatedCharMax =
funboxesToCheck.filter((f) =>
f.frontendFunctions?.includes("getEmulatedChar"),
).length <= 1;
const oneCharCheckerMax =
funboxesToCheck.filter((f) =>
f.frontendFunctions?.includes("isCharCorrect"),
).length <= 1;
const oneCharReplacerMax =
funboxesToCheck.filter((f) => f.frontendFunctions?.includes("getWordHtml"))
.length <= 1;
const oneChangesCapitalisationMax =
funboxesToCheck.filter((f) =>
f.properties?.find((fp) => fp === "changesCapitalisation"),
).length <= 1;
const oneCssModificationPerElement = Object.values(
funboxesToCheck
.map((f) => f.cssModifications)
.filter((f) => f !== undefined)
.flat()
.reduce<Record<string, number>>((counts, cssModification) => {
counts[cssModification] =
(safeNumber(counts[cssModification]) ?? 0) + 1;
return counts;
}, {}),
).every((c) => c <= 1);
const allowedConfig = {} as FunboxForcedConfig;
let noConfigConflicts = true;
for (const f of funboxesToCheck) {
if (!f.frontendForcedConfig) continue;
for (const key in f.frontendForcedConfig) {
if (allowedConfig[key]) {
if (
intersect<string | boolean>(
allowedConfig[key],
f.frontendForcedConfig[key] as string[] | boolean[],
true,
).length === 0
) {
noConfigConflicts = false;
break;
}
} else {
allowedConfig[key] = f.frontendForcedConfig[key] as
| string[]
| boolean[];
}
}
}
return (
oneWordModifierMax &&
layoutUsability &&
oneNospaceOrToPushMax &&
oneChangesWordsVisibilityMax &&
oneFrequencyChangesMax &&
noFrequencyChangesConflicts &&
capitalisationChangePosibility &&
noConflictsWithSymmetricChars &&
oneCanSpeakMax &&
hasLanguageToSpeakAndNoUnspeakable &&
oneToPushOrPullSectionMax &&
onePunctuateWordMax &&
oneGetEmulatedCharMax &&
oneCharCheckerMax &&
oneCharReplacerMax &&
oneChangesCapitalisationMax &&
oneCssModificationPerElement &&
noConfigConflicts &&
oneWordOrderMax
);
}

View File

@@ -0,0 +1,13 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"moduleResolution": "Bundler",
"module": "ES6",
"target": "ES2015",
"lib": ["es2019", "dom"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
import { extendConfig } from "@monkeytype/tsup-config";
export default extendConfig(() => ({ entry: ["src/index.ts"] }));

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
coverage: {
include: ["**/*.ts"],
},
},
});

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "error",
},
"plugins": ["typescript", "unicorn", "oxc", "import", "node", "promise"],
"extends": [
"./rules/enabled.jsonc",
"./rules/ts-enabled.jsonc",
"./rules/disabled.jsonc",
"./rules/ts-disabled.jsonc",
"./rules/consider.jsonc",
"./rules/ts-consider.jsonc",
"./rules/jsx.jsonc",
"./overrides.jsonc",
],
}

View File

@@ -0,0 +1,95 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"overrides": [
{
"files": ["*.js", "*.cjs"],
"rules": {
"typescript/strict-boolean-expressions": "off",
"typescript/await-thenable": "off",
"typescript/no-array-delete": "off",
"typescript/no-base-to-string": "off",
"typescript/no-confusing-void-expression": "off",
"typescript/no-duplicate-type-constituents": "off",
"typescript/no-floating-promises": "off",
"typescript/no-for-in-array": "off",
"typescript/no-implied-eval": "off",
"typescript/no-meaningless-void-operator": "off",
"typescript/no-misused-promises": "off",
"typescript/no-misused-spread": "off",
"typescript/no-mixed-enums": "off",
"typescript/no-redundant-type-constituents": "off",
"typescript/no-unnecessary-boolean-literal-compare": "off",
"typescript/no-unnecessary-template-expression": "off",
"typescript/no-unnecessary-type-arguments": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unsafe-argument": "off",
"typescript/no-unsafe-assignment": "off",
"typescript/no-unsafe-call": "off",
"typescript/no-unsafe-enum-comparison": "off",
"typescript/no-unsafe-member-access": "off",
"typescript/no-unsafe-return": "off",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-unsafe-unary-minus": "off",
"typescript/non-nullable-type-assertion-style": "off",
"typescript/only-throw-error": "off",
"typescript/prefer-promise-reject-errors": "off",
"typescript/prefer-reduce-type-parameter": "off",
"typescript/prefer-return-this-type": "off",
"typescript/promise-function-async": "off",
"typescript/related-getter-setter-pairs": "off",
"typescript/require-array-sort-compare": "off",
"typescript/require-await": "off",
"typescript/restrict-plus-operands": "off",
"typescript/restrict-template-expressions": "off",
"typescript/return-await": "off",
"typescript/switch-exhaustiveness-check": "off",
"typescript/unbound-method": "off",
"typescript/use-unknown-in-catch-callback-variable": "off",
},
},
{
"files": ["__tests__/**"],
"plugins": [
"typescript",
"unicorn",
"oxc",
"import",
"node",
"promise",
"jest",
"vitest",
],
"rules": {
"no-explicit-any": "off", //155
"ban-ts-comment": "off", //9
"typescript/no-unsafe-assignment": "off", //386
"typescript/no-unsafe-member-access": "off", //133
"typescript/no-unsafe-argument": "off", //96
"typescript/await-thenable": "off", //41
"typescript/no-confusing-void-expression": "off", //30
"typescript/promise-function-async": "off", //19
"typescript/no-floating-promises": "off", //16
"typescript/no-unsafe-call": "off", //5
"consistent-type-definitions": "off", //5
"explicit-function-return-type": "off", //4
"eqeqeq": "off", //2
"typescript/no-unsafe-return": "off", //2
"no-empty-object-type": "off", //2
"typescript/strict-boolean-expressions": "off", //1
"typescript/no-unnecessary-type-assertion": "off", //1
"typescript/strict-void-return": "off",
"prefer-nullish-coalescing": "off",
"no-shadow": "off",
"no-non-null-assertion": "off",
},
},
{
"files": ["private/script.js"],
"rules": {
"no-shadow": "off",
},
},
],
}

View File

@@ -0,0 +1,11 @@
{
"name": "@monkeytype/oxlint-config",
"private": true,
"type": "module",
"exports": {
".": "./index.json"
},
"devDependencies": {
"@oxlint/plugins": "1.43.0"
}
}

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off",
},
"jsPlugins": ["./plugins/monkeytype-rules.js"],
"rules": {
"monkeytype-rules/no-testing-access": "error",
"monkeytype-rules/no-mixed-nullish-coalescing": "error",
},
"overrides": [
{
"files": ["__tests__/**/*.ts"],
"rules": {
"monkeytype-rules/no-testing-access": "off",
},
},
{
"files": ["**/*.tsx"],
"rules": {
"monkeytype-rules/prefer-arrow-in-component": "error",
"monkeytype-rules/one-component-per-file": "error",
"monkeytype-rules/component-pascal-case": "error",
},
},
],
}

View File

@@ -0,0 +1,295 @@
import { defineRule } from "@oxlint/plugins";
/**
* Walk a function body looking for a ReturnStatement whose argument is
* JSXElement or JSXFragment. Only traverses control flow nodes — does NOT
* recurse into call arguments or JSX attribute values, preventing false
* positives on functions that pass JSX as a prop/argument. Stops at nested
* function boundaries so inner helpers returning JSX don't count.
*/
function containsJSXReturn(node) {
if (!node || typeof node !== "object" || !node.type) return false;
// Stop at nested function boundaries
if (
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "ArrowFunctionExpression"
) {
return false;
}
// Arrow with concise body: const Foo = () => <div />
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
// return <...>
if (node.type === "ReturnStatement") {
return (
node.argument?.type === "JSXElement" ||
node.argument?.type === "JSXFragment"
);
}
// Only recurse through control flow / block nodes, not into expressions
const CONTROL_FLOW_KEYS = {
BlockStatement: ["body"],
Program: ["body"],
IfStatement: ["consequent", "alternate"],
SwitchStatement: ["cases"],
SwitchCase: ["consequent"],
TryStatement: ["block", "handler", "finalizer"],
CatchClause: ["body"],
WhileStatement: ["body"],
DoWhileStatement: ["body"],
ForStatement: ["body"],
ForInStatement: ["body"],
ForOfStatement: ["body"],
LabeledStatement: ["body"],
};
const keys = CONTROL_FLOW_KEYS[node.type];
if (!keys) return false;
for (const key of keys) {
const child = node[key];
if (!child) continue;
if (Array.isArray(child)) {
for (const item of child) {
if (containsJSXReturn(item)) return true;
}
} else if (containsJSXReturn(child)) {
return true;
}
}
return false;
}
const plugin = {
meta: {
name: "monkeytype-rules",
},
rules: {
"no-testing-access": defineRule({
createOnce(context) {
return {
MemberExpression(node) {
if (node.property?.name === "__testing") {
context.report({
node,
message: "__testing should only be accessed in test files.",
});
}
},
};
},
}),
"prefer-arrow-in-component": defineRule({
meta: {
hasSuggestions: true,
},
createOnce(context) {
const getComponentAncestor = (node) => {
let current = node.parent;
while (current) {
// function Foo() { return <...> }
if (
current.type === "FunctionDeclaration" &&
containsJSXReturn(current.body)
) {
return current.id?.name ?? "component";
}
// const Foo = () => { return <...> } or const Foo = function() { return <...> }
if (
(current.type === "ArrowFunctionExpression" ||
current.type === "FunctionExpression") &&
containsJSXReturn(current.body ?? current) &&
current.parent?.type === "VariableDeclarator"
) {
return current.parent.id?.name ?? "component";
}
current = current.parent;
}
return null;
};
return {
FunctionDeclaration(node) {
const componentName = getComponentAncestor(node);
if (componentName && node.id) {
const fnName = node.id.name;
context.report({
node,
message: `\`${fnName}\` should be a const arrow function`,
suggest: [
{
desc: `Convert to const arrow function (note: removes hoisting)`,
fix(fixer) {
const fullText = context.sourceCode.getText(node);
const nodeStart = node.range?.[0] ?? node.start;
const afterName =
(node.id.range?.[1] ?? node.id.end) - nodeStart;
const bodyStart =
(node.body.range?.[0] ?? node.body.start) - nodeStart;
const paramsAndReturn = fullText
.slice(afterName, bodyStart)
.trimEnd();
const body = fullText.slice(bodyStart);
const asyncPrefix = node.async ? "async " : "";
return fixer.replaceText(
node,
`const ${fnName} = ${asyncPrefix}${paramsAndReturn} => ${body}`,
);
},
},
],
});
}
},
};
},
}),
"one-component-per-file": defineRule({
createOnce(context) {
let exportedComponents;
return {
before() {
exportedComponents = [];
},
ExportNamedDeclaration(node) {
// export function Foo() { return <...> }
if (
node.declaration?.type === "FunctionDeclaration" &&
node.declaration.id?.name &&
containsJSXReturn(node.declaration.body)
) {
exportedComponents.push({
name: node.declaration.id.name,
node,
});
return;
}
// export const Foo = () => <...> or export const Foo = function() { return <...> }
if (node.declaration?.type === "VariableDeclaration") {
for (const decl of node.declaration.declarations) {
if (
decl.id?.name &&
(decl.init?.type === "ArrowFunctionExpression" ||
decl.init?.type === "FunctionExpression") &&
containsJSXReturn(decl.init.body ?? decl.init)
) {
exportedComponents.push({ name: decl.id.name, node });
}
}
}
},
"Program:exit"() {
if (exportedComponents.length > 1) {
for (const { name, node } of exportedComponents.slice(1)) {
context.report({
node,
message: `Only one exported component per file. Move \`${name}\` to its own file.`,
});
}
}
},
};
},
}),
"no-mixed-nullish-coalescing": defineRule({
createOnce(context) {
/**
* Returns true for expression node types that, when combined with ??
* without explicit parentheses, create confusing/ambiguous precedence.
* Excluded: UnaryExpression (clearly bound to its operand),
* LogicalExpression with ?? (same operator, unambiguous).
*/
const isParenthesized = (node, source) => {
// OXC strips ParenthesizedExpression from the AST before visiting,
// so check the raw source surrounding the node's range instead.
const start = node.range?.[0] ?? node.start;
const end = node.range?.[1] ?? node.end;
return source[start - 1] === "(" && source[end] === ")";
};
const isMixedOperatorNode = (node, source) => {
if (isParenthesized(node, source)) return false;
return (
node.type === "BinaryExpression" ||
(node.type === "LogicalExpression" && node.operator !== "??") ||
node.type === "ConditionalExpression"
);
};
return {
LogicalExpression(node) {
if (node.operator !== "??") return;
const source = context.sourceCode.getText();
if (isMixedOperatorNode(node.left, source)) {
context.report({
node: node.left,
message:
"Nullish coalescing (`??`) mixed with other operators without explicit parentheses. Extract to a helper variable or wrap in parentheses for clarity.",
});
}
if (isMixedOperatorNode(node.right, source)) {
context.report({
node: node.right,
message:
"Nullish coalescing (`??`) mixed with other operators without explicit parentheses. Extract to a helper variable or wrap in parentheses for clarity.",
});
}
},
};
},
}),
"component-pascal-case": defineRule({
createOnce(context) {
const isPascalCase = (name) => /^[A-Z][a-zA-Z0-9]*$/.test(name);
return {
FunctionDeclaration(node) {
const isTopLevel =
node.parent?.type === "Program" ||
node.parent?.type === "ExportNamedDeclaration";
if (!isTopLevel || !node.id) return;
const name = node.id.name;
if (!isPascalCase(name) && containsJSXReturn(node.body)) {
context.report({
node: node.id,
message: `Component \`${name}\` should be PascalCase.`,
});
}
},
VariableDeclarator(node) {
const isTopLevel =
node.parent?.parent?.type === "Program" ||
node.parent?.parent?.type === "ExportNamedDeclaration";
if (
!isTopLevel ||
node.id?.type !== "Identifier" ||
(node.init?.type !== "ArrowFunctionExpression" &&
node.init?.type !== "FunctionExpression")
) {
return;
}
const name = node.id.name;
if (!isPascalCase(name) && containsJSXReturn(node.init)) {
context.report({
node: node.id,
message: `Component \`${name}\` should be PascalCase.`,
});
}
},
};
},
}),
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"rules": {
"no-array-for-each": "off",
"no-nested-ternary": "off",
"no-array-sort": "off",
"preserve-caught-error": "off",
},
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"rules": {
"no-await-in-loop": "off",
"consistent-function-scoping": "off",
"prefer-add-event-listener": "off",
"no-new": "off",
"no-new-array": "off",
"no-useless-spread": "off",
"no-async-endpoint-handlers": "off",
"no-this-alias": "off",
"no-unassigned-import": "off",
"no-array-reverse": "off", // disabled for compatibility
"no-map-spread": "off",
},
}

View File

@@ -0,0 +1,88 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"rules": {
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^(_|e|event)",
"caughtErrorsIgnorePattern": "^(_|e|error)",
"varsIgnorePattern": "^_",
"fix": {
"imports": "safe-fix",
},
},
],
"no-var": "error",
"no-non-null-assertion": "error",
"no-non-null-asserted-nullish-coalescing": "error",
"no-explicit-any": "error",
"no-empty-object-type": "error",
"explicit-function-return-type": [
"error",
{
"allowExpressions": true,
},
],
"no-unused-expressions": [
"error",
{
"allowTernary": true,
},
],
"no-unsafe-function-type": "error",
"prefer-for-of": "error",
"consistent-type-definitions": ["error", "type"],
"no-var-requires": "error",
"no-named-as-default": "error",
"no-named-as-default-member": "error",
"no-array-constructor": "error",
"no-dynamic-delete": "error",
"no-extraneous-class": "error",
"no-require-imports": "error",
"no-empty-function": "error",
"no-redeclare": "error",
"no-empty": [
"error",
{
"allowEmptyCatch": true,
},
],
"no-self-compare": "error",
"no-throw-literal": "error",
"no-constant-condition": "error",
"no-constant-binary-expression": "error",
"no-unnecessary-type-constraint": "error",
"no-useless-constructor": "error",
"prefer-literal-enum-member": "error",
"prefer-namespace-keyword": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"no-duplicates": "error",
"no-case-declarations": "error",
"no-fallthrough": "error",
"no-inner-declarations": "error",
"no-prototype-builtins": "error",
"no-regex-spaces": "error",
"typescript/no-namespace": "error",
"eqeqeq": "error",
"ban-ts-comment": "error",
"no-unassigned-vars": "error",
"max-depth": [
"error",
{
"max": 5,
},
],
"always-return": [
"error",
{
"ignoreLastCallback": true,
},
],
"unicorn/prefer-includes": "error",
"unicorn/prefer-structured-clone": "error",
"curly": ["error", "multi-line", "consistent"],
"no-sequences": "error",
"import/no-cycle": "error",
},
}

View File

@@ -0,0 +1,44 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"overrides": [
{
"plugins": [
"typescript",
"unicorn",
"oxc",
"import",
"node",
"promise",
"react",
],
"files": ["src/**/*.tsx"],
"rules": {
"react/react-in-jsx-scope": "off",
"react/button-has-type": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/jsx-props-no-spread-multi": "error",
"react/void-dom-elements-no-children": "error",
"react/no-unknown-property": [
"error",
{
"ignore": [
"autocomplete",
"class",
"classList",
"innerHTML",
"onScrollEnd",
"router-link",
],
},
],
"react/jsx-no-comment-textnodes": "error",
"react/style-prop-object": "error",
"react/checked-requires-onchange-or-readonly": "error",
"react/jsx-no-useless-fragment": "error",
"react/no-unescaped-entities": "error",
"react/jsx-pascal-case": "error",
},
},
],
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"rules": {
//936, no options on this one. super strict, it doesnt allow casting to a narrower type
"typescript/no-unsafe-type-assertion": "off",
//224 errors, very easy to fix.
// adds unnecessary promise overhead and pushing the function to the microtask queue, creating a delay
// all though performance impact probably minimal
// anything that needs to be absolutely as fast as possible should not be async (if not using await)
"typescript/require-await": "off",
//388, when allowing numbers only 27, when also allowing arrays 12
// could be nice to avoid some weird things showing up in templated strings
"typescript/restrict-template-expressions": [
"off",
{
"allowNumber": true,
"allowArray": true,
},
],
},
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"rules": {
"typescript/non-nullable-type-assertion-style": "off",
"typescript/switch-exhaustiveness-check": "off",
"typescript/unbound-method": "off",
"typescript/prefer-promise-reject-errors": "off",
"typescript/no-redundant-type-constituents": "off",
"typescript/require-array-sort-compare": "off",
//unnecessary, might aswell keep template strings in case a string might be added in the future
"typescript/no-unnecessary-template-expression": "off",
},
}

View File

@@ -0,0 +1,64 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"rules": {
"typescript/strict-boolean-expressions": [
"error",
{ "allowNullableBoolean": true },
],
"typescript/only-throw-error": "error",
"typescript/no-unsafe-member-access": "error",
"typescript/no-unsafe-call": "error",
"typescript/no-unsafe-argument": "error",
"typescript/no-unsafe-assignment": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-confusing-void-expression": [
"error",
{ "ignoreArrowShorthand": true },
],
"typescript/no-misused-promises": [
"error",
{
"checksVoidReturn": false,
},
],
"typescript/promise-function-async": "error",
"typescript/no-floating-promises": "error",
"typescript/no-array-delete": "error",
"typescript/no-base-to-string": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-for-in-array": "error",
"typescript/no-implied-eval": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-mixed-enums": "error",
"typescript/no-unnecessary-boolean-literal-compare": "error",
"typescript/no-unsafe-enum-comparison": "error",
"typescript/no-unsafe-return": "error",
"typescript/no-unsafe-unary-minus": "error",
"typescript/prefer-reduce-type-parameter": "error",
"typescript/prefer-return-this-type": "error",
"typescript/related-getter-setter-pairs": "error",
//todo: consider "always" or "in-try-catch"
"typescript/return-await": ["error", "error-handling-correctness-only"],
"typescript/use-unknown-in-catch-callback-variable": "error",
"typescript/await-thenable": "error",
"typescript/no-unnecessary-type-arguments": "error",
"typescript/restrict-plus-operands": [
"error",
{
"allowNumberAndString": true,
},
],
"typescript/no-deprecated": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-nullish-coalescing": "error",
"typescript/no-invalid-void-type": "error",
"typescript/unified-signatures": "error",
"typescript/parameter-properties": "error",
"typescript/dot-notation": "error",
"typescript/no-useless-default-assignment": "error",
"typescript/prefer-string-starts-ends-with": "error",
"typescript/prefer-regexp-exec": "error",
"typescript/prefer-find": "error",
"typescript/consistent-type-exports": "error",
},
}

View File

@@ -0,0 +1,7 @@
{
"ignorePatterns": ["node_modules", "dist", ".turbo"],
"extends": [
"../oxlint-config/index.jsonc"
// "@monkeytype/oxlint-config"
]
}

View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Determine the directory of the script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source the .env file from the parent directory of the script's directory
source "$SCRIPT_DIR/../.env"
echo "Running $BE_SCRIPT_PATH on $BE_HOST with user $BE_USER"
# Connect to SSH and execute remote script
ssh_output=$(ssh "$BE_USER@$BE_HOST" "$BE_SCRIPT_PATH")
# Capture the exit code of the SSH command
exit_code=$?
# Print the output
echo "$ssh_output"
# Forward the exit code of the remote script
exit $exit_code

View File

@@ -0,0 +1,23 @@
#!/bin/bash
# Determine the directory of the script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source the .env file from the parent directory of the script's directory
source "$SCRIPT_DIR/../.env"
echo "Purging Cloudflare cache for zone $CF_ZONE_ID"
response=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_KEY" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}')
success=$(echo "$response" | grep -o '"success"\s*:\s*true')
if [ "$success" ]; then
echo "Cache purged successfully."
else
echo "Cache purge failed."
echo "Response:"
echo "$response"
fi

View File

@@ -0,0 +1,6 @@
CF_ZONE_ID= #cloudflare zone id
CF_API_KEY= #cloudflare api key
BE_HOST= #backend host
BE_USER= #backend user
BE_SCRIPT_PATH= #backend deploy script path

View File

@@ -0,0 +1,26 @@
{
"name": "@monkeytype/release",
"private": true,
"bin": {
"monkeytype-release": "./src/index.js"
},
"type": "module",
"scripts": {
"dev": "nodemon --watch src --exec \"node ./src/index.js --dry\"",
"dev-hotfix": "nodemon --watch src --exec \"node ./src/index.js --dry --hotfix\"",
"dev-changelog": "nodemon ./src/buildChangelog.js",
"lint": "oxlint . --type-aware --type-check",
"lint-fast": "oxlint .",
"purge-cf-cache": "./bin/purgeCfCache.sh"
},
"dependencies": {
"@octokit/rest": "22.0.1",
"dotenv": "16.4.5",
"readline-sync": "1.4.10"
},
"devDependencies": {
"nodemon": "3.1.14",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0"
}
}

View File

@@ -0,0 +1,399 @@
import { exec } from "child_process";
// const stream = conventionalChangelog(
// {
// preset: {
// name: "conventionalcommits",
// types: [
// { type: "feat", section: "Features" },
// { type: "impr", section: "Improvements" },
// { type: "fix", section: "Fixes" },
// ],
// },
// },
// undefined,
// undefined,
// undefined,
// {
// headerPartial: "",
// }
// );
// let log = "";
// for await (const chunk of stream) {
// log += chunk;
// }
// log = log.replace(/^\*/gm, "-");
// console.log(log);
// console.log(header + log + footer);
//i might come back to the approach below at some point
const lineDelimiter =
"thisismylinedelimiterthatwilldefinitelynotappearintheactualcommitmessage";
const logDelimiter =
"thisismylogdelimiterthatwilldefinitelynotappearintheactualcommitmessage";
async function getLog() {
function execPromise(command) {
return new Promise((resolve, reject) => {
exec(command, (err, stdout, _stderr) => {
if (err) {
reject(err);
} else {
resolve(stdout);
}
});
});
}
return execPromise(`git describe --tags --abbrev=0 HEAD^`).then((lastTag) =>
execPromise(
`git log --oneline ${lastTag.trim()}..HEAD --pretty="format:${lineDelimiter}%H${logDelimiter}%h${logDelimiter}%s${logDelimiter}%b"`,
),
);
}
function itemIsAddingQuotes(item) {
const typeIsImprovement = item.type === "impr";
const scopeIsQuote = item.scope?.includes("quote");
const messageAdds = item.message.startsWith("add");
const messageQuotes =
item.message.endsWith("quote") === true || item.message.endsWith("quotes");
return typeIsImprovement && scopeIsQuote && messageAdds && messageQuotes;
}
function itemIsAddressingQuoteReports(item) {
const scopeIsQuote =
item.scope?.includes("quote") === true ||
item.scope?.includes("quotes") === true;
const messageReport =
item.message.includes("report") === true ||
item.message.includes("reports") === true;
return scopeIsQuote && messageReport;
}
const titles = {
feat: "Features",
impr: "Improvements",
fix: "Fixes",
};
function getPrLink(pr) {
const prNum = pr.replace("#", "");
return `[#${prNum}](https://github.com/monkeytypegame/monkeytype/pull/${prNum})`;
}
function getCommitLink(hash, longHash) {
return `[${hash}](https://github.com/monkeytypegame/monkeytype/commit/${longHash})`;
}
function buildItems(items, mergeTypeAndScope = false) {
let ret = "";
for (let item of items) {
let scope = item.scope ? `**${item.scope}:** ` : "";
if (mergeTypeAndScope) {
scope = `**${item.type}${item.scope ? `(${item.scope})` : ""}:** `;
}
const usernames =
item.usernames.length > 0 ? ` (${item.usernames.join(", ")})` : "";
const pr =
item.prs.length > 0
? ` (${item.prs.map((p) => getPrLink(p)).join(", ")})`
: "";
const hash = ` (${item.hashes
.map((h) => getCommitLink(h.short, h.full))
.join(", ")})`;
ret += `- ${scope}${item.message}${usernames}${pr}${hash}\n`;
}
return ret;
}
function buildSection(type, allItems) {
let ret = `### ${titles[type]}\n\n`;
const items = allItems.filter(
(item) => item.type === type && !item.body.includes("!nuf"),
);
if (items.length === 0) {
return "";
}
return (ret += buildItems(items));
}
function buildFooter(logs) {
let out =
"\n### Nerd stuff\n\nThese changes will not be visible to users, but are included for completeness and to credit contributors.\n\n";
const featLogs = logs.filter(
(item) => item.type === "feat" && item.body.includes("!nuf"),
);
const imprLogs = logs.filter(
(item) => item.type === "impr" && item.body.includes("!nuf"),
);
const fixLogs = logs.filter(
(item) => item.type === "fix" && item.body.includes("!nuf"),
);
const styleLogs = logs.filter((item) => item.type === "style");
const docLogs = logs.filter((item) => item.type === "docs");
const refactorLogs = logs.filter((item) => item.type === "refactor");
const perfLogs = logs.filter((item) => item.type === "perf");
const ciLogs = logs.filter((item) => item.type === "ci");
const testLogs = logs.filter((item) => item.type === "test");
const buildLogs = logs.filter((item) => item.type === "build");
const choreLogs = logs.filter((item) => item.type === "chore");
const allOtherLogs = [
...featLogs,
...imprLogs,
...fixLogs,
...styleLogs,
...docLogs,
...refactorLogs,
...perfLogs,
...ciLogs,
...testLogs,
...buildLogs,
...choreLogs,
];
//remove dupes based on hash
const uniqueOtherLogs = allOtherLogs.filter(
(item, index, self) =>
index === self.findIndex((t) => t.hashes[0].full === item.hashes[0].full),
);
// console.log(uniqueOtherLogs);
out += buildItems(uniqueOtherLogs, true);
return out;
}
// function buildFooter(logs) {
// const styleLogs = logs.filter((item) => item.type === "style");
// const docLogs = logs.filter((item) => item.type === "docs");
// const refactorLogs = logs.filter((item) => item.type === "refactor");
// const perfLogs = logs.filter((item) => item.type === "perf");
// const ciLogs = logs.filter((item) => item.type === "ci");
// const testLogs = logs.filter((item) => item.type === "test");
// const buildLogs = logs.filter((item) => item.type === "build");
// const otherStrings = [];
// if (styleLogs.length > 0) {
// otherStrings.push("style");
// }
// if (docLogs.length > 0) {
// otherStrings.push("documentation");
// }
// if (refactorLogs.length > 0) {
// otherStrings.push("refactoring");
// }
// if (perfLogs.length > 0) {
// otherStrings.push("performance");
// }
// if (ciLogs.length > 0) {
// otherStrings.push("CI");
// }
// if (testLogs.length > 0) {
// otherStrings.push("testing");
// }
// if (buildLogs.length > 0) {
// otherStrings.push("build");
// }
// if (otherStrings.length === 0) {
// return "";
// }
// //build a string where otherStrings are joined by commas and the last one is joined by "and"
// const finalString =
// otherStrings.length > 1
// ? otherStrings.slice(0, -1).join(", ") + " and " + otherStrings.slice(-1)
// : otherStrings[0];
// return `\n### Other\n\n- Various ${finalString} changes`;
// }
function convertStringToLog(logString) {
let log = [];
for (let line of logString) {
if (line === "" || line === "\r" || line === "\n") continue;
// console.log(line);
//split line based on the format: d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 style: adjusted testConfig and modesNotice.
//use regex to split
// const [_, hash, shortHash, fullMessage] = line.split(
// /(\w{40}) (\w{9,10}) (.*)/
// );
const [hash, shortHash, title, body] = line
.split(logDelimiter)
.map((s) => s.trim());
// console.log({
// hash,
// shortHash,
// title,
// body,
// });
//split message using regex based on fix(language): spelling mistakes in Nepali wordlist and quotes (sapradhan) (#4528)
//scope is optional, username is optional, pr number is optional
const [_, type, scope, message, username, pr] = title.split(
/^(\w+)(?:\(([^)]+)\))?:\s+(.+?)(?:\s*\((@[^)]+)\))?(?:\s+\((#[^)]+)\))?$/,
);
const usernames = username ? username.split(", ") : [];
const prs = pr ? pr.split(", ") : [];
if (type && message) {
log.push({
hashes: [
{
short: shortHash,
full: hash,
},
],
type,
scope,
message,
usernames: usernames ?? [],
prs: prs ?? [],
body: body ?? "",
});
} else {
// console.log({ hash, shortHash, title, body });
// console.warn("skipping line due to invalid format: " + line);
}
}
return log;
}
const header =
"Thank you to all the contributors who made this release possible!";
async function main() {
let logString = await getLog();
logString = logString.split(lineDelimiter);
//test commits
// const logString = [
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 build: add new feature (miodec, someone) (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 build(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 chore: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 chore(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 ci: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 ci(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 docs: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 docs(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 feat: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 feat(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 impr: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 impr(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 fix: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 fix(score): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 perf: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 perf(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 refactor: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 refactor(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 revert: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 revert(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 style: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 style(scope): add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 test: add new feature (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c106 d2739e4f1 test(scope): add new feature (#1234)",
// ];
//test commits
// logString = [
// "d2739e4f193137db4d86450f0d50b3489d75c101 d2739e4f1 fix: add new fix (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c102 d2739e4f1 fix(nuf something): add new fix nuf (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c103 d2739e4f1 test: add new test (#1234)",
// "d2739e4f193137db4d86450f0d50b3489d75c104 d2739e4f1 test(blah): add new test blah (#1234)",
// ];
let log = convertStringToLog(logString);
const contributorCount = log.flatMap((l) => {
const filtered = l.usernames.filter((u) => {
const lowerCased = u.toLowerCase();
return (
lowerCased !== "monkeytype-bot" &&
lowerCased !== "dependabot" &&
lowerCased !== "miodec"
);
});
return filtered;
}).length;
let quoteAddCommits = log.filter((item) => itemIsAddingQuotes(item));
log = log.filter((item) => !itemIsAddingQuotes(item));
let quoteReportCommits = log.filter((item) =>
itemIsAddressingQuoteReports(item),
);
log = log.filter((item) => !itemIsAddressingQuoteReports(item));
if (quoteAddCommits.length > 0) {
log.push({
hashes: quoteAddCommits.flatMap((item) => item.hashes),
type: "impr",
scope: "quotes",
message: "add quotes in various languages",
usernames: quoteAddCommits.flatMap((item) => item.usernames),
prs: quoteAddCommits.flatMap((item) => item.prs),
body: "",
});
}
if (quoteReportCommits.length > 0) {
log.push({
hashes: quoteReportCommits.flatMap((item) => item.hashes),
type: "fix",
scope: "quotes",
message: "update or remove quotes reported by users",
usernames: quoteReportCommits.flatMap((item) => item.usernames),
prs: quoteReportCommits.flatMap((item) => item.prs),
body: "",
});
}
let final = "";
if (contributorCount > 0) {
final += header + "\n\n\n";
}
const sections = [];
for (const type of Object.keys(titles)) {
const section = buildSection(type, log);
if (section) {
sections.push(section);
}
}
final += sections.join("\n\n");
const footer = buildFooter(log);
if (footer) {
final += "\n" + footer;
}
console.log(final);
}
main();

View File

@@ -0,0 +1,60 @@
import dotenv from "dotenv";
dotenv.config();
const OWNER = "monkeytypegame";
const REPO = "monkeytype";
const EXCLUDED = new Set(["monkeytypegeorge", "miodec"]);
async function getContributors(page) {
console.log("Getting contributors from page " + page);
const res = await fetch(
`https://api.github.com/repos/${OWNER}/${REPO}/contributors?anon=1&per_page=100&page=${page}`,
{
method: "GET",
headers: {
"User-Agent": "monkeytypegame release script",
...(process.env.GITHUB_TOKEN && {
Authorization: `token ${process.env.GITHUB_TOKEN}`,
}),
},
},
);
return res.json();
}
async function main() {
let total = [];
let page = 1;
let lastCount = 1;
while (lastCount > 0) {
const data = await getContributors(page);
const contributors = data.map((c) => ({
name: c.login ?? c.name,
contributions: c.contributions,
}));
lastCount = contributors.length;
page++;
total.push(...contributors);
}
total = total
.filter(
(c) => !EXCLUDED.has(c.name?.toLowerCase()) && !c.name?.includes("[bot]"),
)
.sort((a, b) => b.contributions - a.contributions);
// dedupe
const seen = new Set();
total = total.filter((c) => {
if (seen.has(c.name)) return false;
seen.add(c.name);
return true;
});
console.log(JSON.stringify(total.map((c) => c.name)));
}
main();

376
packages/release/src/index.js Executable file
View File

@@ -0,0 +1,376 @@
import { Octokit } from "@octokit/rest";
import { execSync } from "child_process";
import dotenv from "dotenv";
import fs, { readFileSync } from "fs";
import readlineSync from "readline-sync";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
dotenv.config();
const args = new Set(process.argv.slice(2));
const isFrontend = args.has("--fe");
const noDeploy = args.has("--no-deploy");
const isBackend = args.has("--be");
const isDryRun = args.has("--dry");
const noSyncCheck = args.has("--no-sync-check");
const hotfix = args.has("--hotfix");
const previewFe = args.has("--preview-fe");
const PROJECT_ROOT = path.resolve(__dirname, "../../../");
const runCommand = (command, force) => {
if (isDryRun && !force) {
console.log(`[Dry Run] Command: ${command}`);
return "[Dry Run] Command executed.";
} else {
try {
const output = execSync(command, { stdio: "pipe" }).toString();
return output;
} catch (error) {
console.error(`Error executing command ${command}`);
console.error(error.output.toString());
process.exit(1);
}
}
return undefined;
};
const runProjectRootCommand = (command, force) => {
if (isDryRun && !force) {
console.log(`[Dry Run] Command: ${command}`);
return "[Dry Run] Command executed.";
} else {
try {
const output = execSync(`cd ${PROJECT_ROOT} && ${command}`, {
stdio: "pipe",
}).toString();
return output;
} catch (error) {
console.error(`Error executing command ${command}`);
console.error(error);
process.exit(1);
}
}
return undefined;
};
const checkBranchSync = () => {
console.log("Checking if local branch is master...");
const currentBranch = runProjectRootCommand(
"git branch --show-current",
).trim();
if (currentBranch !== "master") {
console.error(
"Local branch is not master. Please checkout the master branch.",
);
process.exit(1);
}
console.log("Checking if local master branch is in sync with origin...");
if (noSyncCheck) {
console.log("Skipping sync check.");
} else if (isDryRun) {
console.log("[Dry Run] Checking sync...");
} else {
try {
// Fetch the latest changes from the remote repository
runProjectRootCommand("git fetch origin");
// Get the commit hashes of the local and remote master branches
const localMaster = runProjectRootCommand("git rev-parse master").trim();
const remoteMaster = runProjectRootCommand(
"git rev-parse origin/master",
).trim();
if (localMaster !== remoteMaster) {
console.error(
"Local master branch is not in sync with origin. Please pull the latest changes before proceeding.",
);
process.exit(1);
}
} catch (error) {
console.error("Error checking branch sync status.");
console.error(error);
process.exit(1);
}
}
};
const getCurrentVersion = () => {
console.log("Getting current version...");
const rootPackageJson = JSON.parse(
readFileSync(`${PROJECT_ROOT}/package.json`, "utf-8"),
);
return rootPackageJson.version;
};
const incrementVersion = (currentVersion) => {
console.log("Incrementing version...");
const now = new Date();
const year = Number(now.getFullYear().toString().slice(-2));
const start = new Date(now.getFullYear(), 0, 1);
const week = Math.ceil(((now - start) / 86400000 + start.getDay() + 1) / 7);
const [prevYear, prevWeek, minor] = currentVersion.split(".").map(Number);
let newMinor = minor + 1;
if (year !== prevYear || week !== prevWeek) {
newMinor = 0;
}
const v = `v${year}.${week}.${newMinor}`;
return v;
};
const updatePackage = (newVersion) => {
console.log("Updating package.json...");
if (isDryRun) {
console.log(`[Dry Run] Updated package.json to version ${newVersion}`);
return;
}
const packagePath = `${PROJECT_ROOT}/package.json`;
// Read the package.json file
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
// Update the version field
packageJson.version = newVersion.replace("v", "");
// Write the updated JSON back to package.json
fs.writeFileSync(
packagePath,
JSON.stringify(packageJson, null, 2) + "\n",
"utf8",
);
console.log(`Updated package.json to version ${newVersion}`);
};
const checkUncommittedChanges = () => {
console.log("Checking uncommitted changes...");
const status = execSync("git status --porcelain").toString().trim();
if (isDryRun) {
console.log("[Dry Run] Checking uncommitted changes...");
} else if (status) {
console.error(
"You have uncommitted changes. Please commit or stash them before proceeding.",
);
process.exit(1);
}
};
const installDependencies = () => {
console.log("Installing dependencies...");
if (isDryRun) {
console.log("[Dry Run] Dependencies would be installed.");
} else {
runProjectRootCommand("pnpm i");
}
};
const buildProject = () => {
console.log("Building project...");
if (isFrontend && !isBackend) {
runProjectRootCommand(
"NODE_ENV=production SENTRY=1 npx turbo lint test check-assets build --filter @monkeytype/frontend --force",
);
} else if (isBackend && !isFrontend) {
runProjectRootCommand(
"NODE_ENV=production SENTRY=1 npx turbo lint test build --filter @monkeytype/backend --force",
);
} else {
runProjectRootCommand(
"NODE_ENV=production SENTRY=1 npx turbo lint test check-assets build --force",
);
}
};
const deployBackend = () => {
console.log("Deploying backend...");
const p = path.resolve(__dirname, "../bin/deployBackend.sh");
runCommand(`sh ${p}`);
};
const deployFrontend = () => {
console.log("Deploying frontend...");
runProjectRootCommand(
"cd frontend && npx firebase deploy -P live --only hosting",
);
};
const purgeCache = () => {
console.log("Purging Cloudflare cache...");
const p = path.resolve(__dirname, "../bin/purgeCfCache.sh");
runCommand(`sh ${p}`);
};
const generateChangelog = async () => {
console.log("Generating changelog...");
const p = path.resolve(__dirname, "./buildChangelog.js");
const changelog = runCommand(`node ${p}`, true);
return changelog;
};
const generateContributors = () => {
console.log("Generating contributors list...");
try {
const p = path.resolve(__dirname, "./buildContributors.js");
let contributors = runCommand(`node ${p}`, true);
contributors = JSON.parse(
contributors.replaceAll("\n", "").replace(/^.*?\[/, "["),
);
fs.writeFileSync(
`${PROJECT_ROOT}/frontend/static/contributors.json`,
JSON.stringify(contributors, null, 2),
"utf8",
);
console.log("Contributors list updated.");
} catch (e) {
console.error("Failed to generate contributors list.");
console.error(e);
}
};
const createCommitAndTag = (version) => {
console.log("Creating commit and tag... Pushing to Github...");
runCommand(`git add .`);
runCommand(`git commit -m "chore: release ${version}" --no-verify`);
runCommand(`git tag ${version}`);
runCommand(`git push origin master --tags --no-verify`);
};
const createGithubRelease = async (version, changelogContent) => {
console.log("Creating GitHub release...");
if (isDryRun) {
console.log(
`[Dry Run] Sent release request to GitHub for version ${version}`,
);
} else {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const { owner, repo } = {
owner: "monkeytypegame",
repo: "monkeytype",
};
await octokit.repos.createRelease({
owner,
repo,
tag_name: version,
name: `${version}`,
body: changelogContent,
});
}
};
const main = async () => {
if (previewFe) {
console.log(`Starting frontend preview deployment process...`);
installDependencies();
runProjectRootCommand(
"NODE_ENV=production npx turbo lint test check-assets build --filter @monkeytype/frontend --force",
);
const name = readlineSync.question(
"Enter preview channel name (default: preview): ",
);
let channelName = name.trim();
if (channelName === "") {
channelName = "preview";
}
const expirationTime = readlineSync.question(
"Enter expiration time (e.g., 2h, default: 1d): ",
);
let expires = expirationTime.trim();
if (expires === "") {
expires = "1d";
}
console.log(
`Deploying frontend preview to channel "${channelName}" with expiration "${expires}"...`,
);
const result = runProjectRootCommand(
`cd frontend && npx firebase hosting:channel:deploy ${channelName} -P live --expires ${expires}`,
);
console.log(result);
console.log("Frontend preview deployed successfully.");
process.exit(0);
}
console.log(`Starting ${hotfix ? "hotfix" : "release"} process...`);
if (!hotfix) checkBranchSync();
checkUncommittedChanges();
installDependencies();
let changelogContent;
let newVersion;
if (!hotfix) {
changelogContent = await generateChangelog();
console.log(changelogContent);
if (!readlineSync.keyInYN("Changelog looks good?")) {
console.log("Exiting.");
process.exit(1);
}
const currentVersion = getCurrentVersion();
newVersion = incrementVersion(currentVersion);
console.log(`New version: ${newVersion}`);
}
buildProject();
if (!hotfix && !readlineSync.keyInYN(`Ready to release ${newVersion}?`)) {
console.log("Exiting.");
process.exit(1);
}
if (!noDeploy && (isBackend || (!isFrontend && !isBackend))) {
deployBackend();
}
if (!noDeploy && (isFrontend || (!isFrontend && !isBackend))) {
deployFrontend();
}
if (!noDeploy) purgeCache();
if (!hotfix) {
generateContributors();
updatePackage(newVersion);
createCommitAndTag(newVersion);
try {
await createGithubRelease(newVersion, changelogContent);
} catch (e) {
console.error(`Failed to create release on GitHub: ${e}`);
console.log("Please create the release manually.");
}
}
if (hotfix) {
console.log("Hotfix completed successfully.");
} else {
console.log(`Release ${newVersion} completed successfully.`);
}
process.exit(0);
};
main();

View File

@@ -0,0 +1,7 @@
{
"ignorePatterns": ["node_modules", "dist", ".turbo"],
"extends": [
"../oxlint-config/index.jsonc"
// "@monkeytype/oxlint-config"
]
}

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import { CustomBackgroundSchema } from "@monkeytype/schemas/configs";
describe("config schema", () => {
describe("CustomBackgroundSchema", () => {
it.for([
{
name: "http",
input: `http://example.com/path/image.png`,
},
{
name: "https",
input: `https://example.com/path/image.png`,
},
{
name: "png",
input: `https://example.com/path/image.png`,
},
{
name: "gif",
input: `https://example.com/path/image.gif?width=5`,
},
{
name: "jpeg",
input: `https://example.com/path/image.jpeg`,
},
{
name: "jpg",
input: `https://example.com/path/image.jpg`,
},
{
name: "tiff",
input: `https://example.com/path/image.tiff`,
expectedError: "Unsupported image format",
},
{
name: "non-url",
input: `test`,
expectedError: "Needs to be an URI",
},
{
name: "single quotes",
input: `https://example.com/404.jpg?q=alert('1')`,
expectedError: "May not contain quotes",
},
{
name: "double quotes",
input: `https://example.com/404.jpg?q=alert("1")`,
expectedError: "May not contain quotes",
},
{
name: "back tick",
input: `https://example.com/404.jpg?q=alert(\`1\`)`,
expectedError: "May not contain quotes",
},
{
name: "javascript url",
input: `javascript:alert('asdf');//https://example.com/img.jpg`,
expectedError: "Unsupported protocol",
},
{
name: "data url",
input: `data:image/gif;base64,data`,
expectedError: "Unsupported protocol",
},
{
name: "long url",
input: `https://example.com/path/image.jpeg?q=${new Array(2048)
.fill("x")
.join()}`,
expectedError: "URL is too long",
},
])(`$name`, ({ input, expectedError }) => {
const parsed = CustomBackgroundSchema.safeParse(input);
if (expectedError !== undefined) {
expect(parsed.success).toEqual(false);
expect(parsed.error?.issues[0]?.message).toEqual(expectedError);
} else {
expect(parsed.success).toEqual(true);
}
});
});
});

View File

@@ -0,0 +1,7 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { nameWithSeparators, slug } from "../src/util";
describe("Schema Validation Tests", () => {
describe("nameWithSeparators", () => {
const schema = nameWithSeparators();
it("accepts valid names", () => {
expect(schema.safeParse("valid_name").success).toBe(true);
expect(schema.safeParse("valid-name").success).toBe(true);
expect(schema.safeParse("valid123").success).toBe(true);
expect(schema.safeParse("Valid_Name-Check").success).toBe(true);
});
it("rejects leading/trailing separators", () => {
expect(schema.safeParse("_invalid").success).toBe(false);
expect(schema.safeParse("invalid-").success).toBe(false);
});
it("rejects consecutive separators", () => {
expect(schema.safeParse("inv__alid").success).toBe(false);
expect(schema.safeParse("inv--alid").success).toBe(false);
expect(schema.safeParse("inv-_alid").success).toBe(false);
});
it("rejects dots", () => {
expect(schema.safeParse("invalid.dot").success).toBe(false);
expect(schema.safeParse(".invalid").success).toBe(false);
});
});
describe("slug", () => {
const schema = slug();
it("accepts valid slugs", () => {
expect(schema.safeParse("valid-slug.123_test").success).toBe(true);
expect(schema.safeParse("valid.dots").success).toBe(true);
expect(schema.safeParse("_leading_underscore_is_fine").success).toBe(
true,
);
expect(schema.safeParse("-leading_hyphen_is_fine").success).toBe(true);
expect(schema.safeParse("trailing_is_fine_in_slug_").success).toBe(true);
});
it("rejects leading dots", () => {
expect(schema.safeParse(".invalid").success).toBe(false);
});
it("rejects invalid characters", () => {
expect(schema.safeParse("invalid,comma").success).toBe(false);
expect(schema.safeParse(",invalid").success).toBe(false);
expect(schema.safeParse("invalid space").success).toBe(false);
expect(schema.safeParse("invalid#hash").success).toBe(false);
});
});
});

View File

@@ -0,0 +1,38 @@
{
"name": "@monkeytype/schemas",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./*": {
"types": "./src/*.ts",
"import": "./dist/*.mjs",
"require": "./dist/*.js"
}
},
"scripts": {
"dev": "tsup-node --watch",
"build": "npm run madge && tsup-node",
"test": "vitest run",
"madge": " madge --circular --extensions ts ./src",
"ts-check": "tsc --noEmit",
"lint": "oxlint . --type-aware --type-check",
"lint-fast": "oxlint ."
},
"devDependencies": {
"@monkeytype/tsup-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"madge": "8.0.0",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"tsup": "8.4.0",
"typescript": "6.0.2",
"vitest": "4.1.0"
},
"peerDependencies": {
"zod": "3.23.8"
}
}

View File

@@ -0,0 +1,19 @@
import { z } from "zod";
import { IdSchema, slug } from "./util";
export const ApeKeyNameSchema = slug().max(20);
export const ApeKeyUserDefinedSchema = z.object({
name: ApeKeyNameSchema,
enabled: z.boolean(),
});
export const ApeKeySchema = ApeKeyUserDefinedSchema.extend({
createdOn: z.number().min(0),
modifiedOn: z.number().min(0),
lastUsedOn: z.number().min(0).or(z.literal(-1)),
});
export type ApeKey = z.infer<typeof ApeKeySchema>;
export const ApeKeysSchema = z.record(IdSchema, ApeKeySchema);
export type ApeKeys = z.infer<typeof ApeKeysSchema>;

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
import { FunboxNameSchema, PartialConfigSchema } from "./configs";
const MinRequiredNumber = z.object({ min: z.number() }).strict();
const MaxRequiredNumber = z.object({ max: z.number() }).strict();
const ExactRequiredNumber = z.object({ exact: z.number() }).strict();
export const ChallengeSchema = z
.object({
name: z.string(),
display: z.string(),
autoRole: z.boolean().optional(),
type: z.enum([
"customTime",
"customWords",
"customText",
"script",
"accuracy",
"funbox",
"other",
]),
message: z.string().optional(),
parameters: z.array(
z
.string()
.or(z.null())
.or(z.number())
.or(z.boolean())
.or(z.array(FunboxNameSchema)),
),
requirements: z
.object({
wpm: ExactRequiredNumber.or(MinRequiredNumber),
acc: ExactRequiredNumber.or(MinRequiredNumber),
afk: MaxRequiredNumber,
time: MinRequiredNumber,
funbox: z
.object({
exact: z.array(FunboxNameSchema),
})
.partial(),
raw: ExactRequiredNumber,
con: ExactRequiredNumber,
config: PartialConfigSchema,
})
.partial()
.strict()
.optional(),
})
.strict();
export type Challenge = z.infer<typeof ChallengeSchema>;

View File

@@ -0,0 +1,518 @@
import { z, ZodSchema } from "zod";
import * as Shared from "./shared";
import * as Themes from "./themes";
import * as Layouts from "./layouts";
import { LanguageSchema } from "./languages";
import { FontNameSchema } from "./fonts";
export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]);
export type SmoothCaret = z.infer<typeof SmoothCaretSchema>;
export const QuickRestartSchema = z.enum(["off", "esc", "tab", "enter"]);
export type QuickRestart = z.infer<typeof QuickRestartSchema>;
export const QuoteLengthSchema = z.union([
z.literal(-3),
z.literal(-2),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
]);
export type QuoteLength = z.infer<typeof QuoteLengthSchema>;
export const QuoteLengthConfigSchema = z
.array(QuoteLengthSchema)
.describe(
[
"|value|description|\n|-|-|",
"|-3|Favorite quotes|",
"|-2|Quote search|",
"|0|Short quotes|",
"|1|Medium quotes|",
"|2|Long quotes|",
"|3|Thicc quotes|",
].join("\n"),
);
export type QuoteLengthConfig = z.infer<typeof QuoteLengthConfigSchema>;
export const CaretStyleSchema = z.enum([
"off",
"default",
"block",
"outline",
"underline",
"carrot",
"banana",
"monkey",
]);
export type CaretStyle = z.infer<typeof CaretStyleSchema>;
export const ConfidenceModeSchema = z.enum(["off", "on", "max"]);
export type ConfidenceMode = z.infer<typeof ConfidenceModeSchema>;
export const IndicateTyposSchema = z.enum(["off", "below", "replace", "both"]);
export type IndicateTypos = z.infer<typeof IndicateTyposSchema>;
export const CompositionDisplaySchema = z.enum(["off", "below", "replace"]);
export type CompositionDisplay = z.infer<typeof CompositionDisplaySchema>;
export const TimerStyleSchema = z.enum([
"off",
"bar",
"text",
"mini",
"flash_text",
"flash_mini",
]);
export type TimerStyle = z.infer<typeof TimerStyleSchema>;
export const LiveSpeedAccBurstStyleSchema = z.enum(["off", "text", "mini"]);
export type LiveSpeedAccBurstStyle = z.infer<
typeof LiveSpeedAccBurstStyleSchema
>;
export const RandomThemeSchema = z.enum([
"off",
"on",
"fav",
"light",
"dark",
"custom",
"auto",
]);
export type RandomTheme = z.infer<typeof RandomThemeSchema>;
export const TimerColorSchema = z.enum(["black", "sub", "text", "main"]);
export type TimerColor = z.infer<typeof TimerColorSchema>;
export const TimerOpacitySchema = z.enum(["0.25", "0.5", "0.75", "1"]);
export type TimerOpacity = z.infer<typeof TimerOpacitySchema>;
export const StopOnErrorSchema = z.enum(["off", "word", "letter"]);
export type StopOnError = z.infer<typeof StopOnErrorSchema>;
export const KeymapModeSchema = z.enum(["off", "static", "react", "next"]);
export type KeymapMode = z.infer<typeof KeymapModeSchema>;
export const KeymapStyleSchema = z.enum([
"staggered",
"alice",
"matrix",
"split",
"split_matrix",
"steno",
"steno_matrix",
]);
export type KeymapStyle = z.infer<typeof KeymapStyleSchema>;
export const KeymapLegendStyleSchema = z.enum([
"lowercase",
"uppercase",
"blank",
"dynamic",
]);
export type KeymapLegendStyle = z.infer<typeof KeymapLegendStyleSchema>;
export const KeymapShowTopRowSchema = z.enum(["always", "layout", "never"]);
export type KeymapShowTopRow = z.infer<typeof KeymapShowTopRowSchema>;
export const KeymapSizeSchema = z.number().min(0.5).max(3.5).step(0.1);
export type KeymapSize = z.infer<typeof KeymapSizeSchema>;
export const SingleListCommandLineSchema = z.enum(["manual", "on"]);
export type SingleListCommandLine = z.infer<typeof SingleListCommandLineSchema>;
export const PlaySoundOnErrorSchema = z.enum(["off", "1", "2", "3", "4"]);
export type PlaySoundOnError = z.infer<typeof PlaySoundOnErrorSchema>;
export const PlaySoundOnClickSchema = z.enum([
"off",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
]);
export type PlaySoundOnClick = z.infer<typeof PlaySoundOnClickSchema>;
export const SoundVolumeSchema = z.number().min(0).max(1);
export type SoundVolume = z.infer<typeof SoundVolumeSchema>;
export const PaceCaretSchema = z.enum([
"off",
"average",
"pb",
"tagPb",
"last",
"custom",
"daily",
]);
export type PaceCaret = z.infer<typeof PaceCaretSchema>;
export const AccountChartSchema = z.tuple([
z.enum(["on", "off"]),
z.enum(["on", "off"]),
z.enum(["on", "off"]),
z.enum(["on", "off"]),
]);
export type AccountChart = z.infer<typeof AccountChartSchema>;
export const MinimumWordsPerMinuteSchema = z.enum(["off", "custom"]);
export type MinimumWordsPerMinute = z.infer<typeof MinimumWordsPerMinuteSchema>;
export const HighlightModeSchema = z.enum([
"off",
"letter",
"word",
"next_word",
"next_two_words",
"next_three_words",
]);
export type HighlightMode = z.infer<typeof HighlightModeSchema>;
export const TypedEffectSchema = z.enum(["keep", "hide", "fade", "dots"]);
export type TypedEffect = z.infer<typeof TypedEffectSchema>;
export const TapeModeSchema = z.enum(["off", "letter", "word"]);
export type TapeMode = z.infer<typeof TapeModeSchema>;
export const TapeMarginSchema = z.number().min(10).max(90);
export type TapeMargin = z.infer<typeof TapeMarginSchema>;
export const TypingSpeedUnitSchema = z.enum([
"wpm",
"cpm",
"wps",
"cps",
"wph",
]);
export type TypingSpeedUnit = z.infer<typeof TypingSpeedUnitSchema>;
export const AdsSchema = z.enum(["off", "result", "on", "sellout"]);
export type Ads = z.infer<typeof AdsSchema>;
export const MinimumAccuracySchema = z.enum(["off", "custom"]);
export type MinimumAccuracy = z.infer<typeof MinimumAccuracySchema>;
export const RepeatQuotesSchema = z.enum(["off", "typing"]);
export type RepeatQuotes = z.infer<typeof RepeatQuotesSchema>;
export const OppositeShiftModeSchema = z.enum(["off", "on", "keymap"]);
export type OppositeShiftMode = z.infer<typeof OppositeShiftModeSchema>;
export const CustomBackgroundSizeSchema = z.enum(["cover", "contain", "max"]);
export type CustomBackgroundSize = z.infer<typeof CustomBackgroundSizeSchema>;
export const CustomBackgroundFilterSchema = z.tuple([
z.number(),
z.number(),
z.number(),
z.number(),
]);
export type CustomBackgroundFilter = z.infer<
typeof CustomBackgroundFilterSchema
>;
export const CustomLayoutFluidSchema = z
.array(Layouts.LayoutNameSchema)
.min(2)
.max(15);
export type CustomLayoutFluid = z.infer<typeof CustomLayoutFluidSchema>;
export const CustomPolyglotSchema = z.array(LanguageSchema).min(2);
export type CustomPolyglot = z.infer<typeof CustomPolyglotSchema>;
export const MonkeyPowerLevelSchema = z.enum(["off", "1", "2", "3", "4"]);
export type MonkeyPowerLevel = z.infer<typeof MonkeyPowerLevelSchema>;
export const MinimumBurstSchema = z.enum(["off", "fixed", "flex"]);
export type MinimumBurst = z.infer<typeof MinimumBurstSchema>;
export const ShowAverageSchema = z.enum(["off", "speed", "acc", "both"]);
export type ShowAverage = z.infer<typeof ShowAverageSchema>;
export const ShowPbSchema = z.boolean();
export type ShowPb = z.infer<typeof ShowPbSchema>;
export const ColorHexValueSchema = z.string().regex(/^#([\da-f]{3}){1,2}$/i);
export type ColorHexValue = z.infer<typeof ColorHexValueSchema>;
export const DifficultySchema = Shared.DifficultySchema;
export type Difficulty = Shared.Difficulty;
export const CustomThemeColorsSchema = z.tuple([
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
ColorHexValueSchema,
]);
export type CustomThemeColors = z.infer<typeof CustomThemeColorsSchema>;
export const ThemeNameSchema = Themes.ThemeNameSchema;
export type ThemeName = z.infer<typeof ThemeNameSchema>;
export const FavThemesSchema = z.array(ThemeNameSchema);
export type FavThemes = z.infer<typeof FavThemesSchema>;
export const FunboxNameSchema = z.enum([
"58008",
"mirror",
"upside_down",
"nausea",
"round_round_baby",
"simon_says",
"tts",
"choo_choo",
"arrows",
"rAnDoMcAsE",
"sPoNgEcAsE",
"capitals",
"layout_mirror",
"layoutfluid",
"earthquake",
"space_balls",
"gibberish",
"ascii",
"specials",
"plus_one",
"plus_zero",
"plus_two",
"plus_three",
"read_ahead_easy",
"read_ahead",
"read_ahead_hard",
"memory",
"nospace",
"poetry",
"wikipedia",
"weakspot",
"pseudolang",
"IPv4",
"IPv6",
"binary",
"hexadecimal",
"zipf",
"morse",
"crt",
"backwards",
"ddoouubblleedd",
"instant_messaging",
"underscore_spaces",
"ALL_CAPS",
"polyglot",
"asl",
"rot13",
"no_quit",
]);
export type FunboxName = z.infer<typeof FunboxNameSchema>;
export const FunboxSchema = z.array(FunboxNameSchema).max(15);
export type Funbox = z.infer<typeof FunboxSchema>;
export const PaceCaretCustomSpeedSchema = z.number().nonnegative();
export type PaceCaretCustomSpeed = z.infer<typeof PaceCaretCustomSpeedSchema>;
export const MinWpmCustomSpeedSchema = z.number().nonnegative();
export type MinWpmCustomSpeed = z.infer<typeof MinWpmCustomSpeedSchema>;
export const MinimumAccuracyCustomSchema = z.number().nonnegative().max(100);
export type MinimumAccuracyCustom = z.infer<typeof MinimumAccuracyCustomSchema>;
export const MinimumBurstCustomSpeedSchema = z.number().nonnegative();
export type MinimumBurstCustomSpeed = z.infer<
typeof MinimumBurstCustomSpeedSchema
>;
export const TimeConfigSchema = z.number().int().nonnegative();
export type TimeConfig = z.infer<typeof TimeConfigSchema>;
export const WordCountSchema = z.number().int().nonnegative();
export type WordCount = z.infer<typeof WordCountSchema>;
export const KeymapLayoutSchema = z
.literal("overrideSync")
.or(Layouts.LayoutNameSchema);
export type KeymapLayout = z.infer<typeof KeymapLayoutSchema>;
export const LayoutSchema = z.literal("default").or(Layouts.LayoutNameSchema);
export type Layout = z.infer<typeof LayoutSchema>;
export const FontSizeSchema = z.number().positive();
export type FontSize = z.infer<typeof FontSizeSchema>;
export const MaxLineWidthSchema = z.number().min(20).max(1000).or(z.literal(0));
export type MaxLineWidth = z.infer<typeof MaxLineWidthSchema>;
export const CustomBackgroundSchema = z
.string()
.url("Needs to be an URI")
.regex(/^(https|http):\/\/.*/, "Unsupported protocol")
.regex(/^[^`'"]*$/, "May not contain quotes")
.regex(/.+(\.png|\.gif|\.jpeg|\.jpg|\.webp)/gi, "Unsupported image format")
.max(2048, "URL is too long")
.or(z.literal(""));
export type CustomBackground = z.infer<typeof CustomBackgroundSchema>;
export const PlayTimeWarningSchema = z
.enum(["off", "1", "3", "5", "10"])
.describe(
"How many seconds before the end of the test to play a warning sound.",
);
export type PlayTimeWarning = z.infer<typeof PlayTimeWarningSchema>;
export const ConfigSchema = z
.object({
// test
punctuation: z.boolean(),
numbers: z.boolean(),
words: WordCountSchema,
time: TimeConfigSchema,
mode: Shared.ModeSchema,
quoteLength: QuoteLengthConfigSchema,
language: LanguageSchema,
burstHeatmap: z.boolean(),
// behavior
difficulty: DifficultySchema,
quickRestart: QuickRestartSchema,
repeatQuotes: RepeatQuotesSchema,
resultSaving: z.boolean(),
blindMode: z.boolean(),
alwaysShowWordsHistory: z.boolean(),
singleListCommandLine: SingleListCommandLineSchema,
minWpm: MinimumWordsPerMinuteSchema,
minWpmCustomSpeed: MinWpmCustomSpeedSchema,
minAcc: MinimumAccuracySchema,
minAccCustom: MinimumAccuracyCustomSchema,
minBurst: MinimumBurstSchema,
minBurstCustomSpeed: MinimumBurstCustomSpeedSchema,
britishEnglish: z.boolean(),
funbox: FunboxSchema,
customLayoutfluid: CustomLayoutFluidSchema,
customPolyglot: CustomPolyglotSchema,
// input
freedomMode: z.boolean(),
strictSpace: z.boolean(),
oppositeShiftMode: OppositeShiftModeSchema,
stopOnError: StopOnErrorSchema,
confidenceMode: ConfidenceModeSchema,
quickEnd: z.boolean(),
indicateTypos: IndicateTyposSchema,
compositionDisplay: CompositionDisplaySchema,
hideExtraLetters: z.boolean(),
lazyMode: z.boolean(),
layout: LayoutSchema,
codeUnindentOnBackspace: z.boolean(),
// sound
soundVolume: SoundVolumeSchema,
playSoundOnClick: PlaySoundOnClickSchema,
playSoundOnError: PlaySoundOnErrorSchema,
playTimeWarning: PlayTimeWarningSchema,
// caret
smoothCaret: SmoothCaretSchema,
caretStyle: CaretStyleSchema,
paceCaret: PaceCaretSchema,
paceCaretCustomSpeed: PaceCaretCustomSpeedSchema,
paceCaretStyle: CaretStyleSchema,
repeatedPace: z.boolean(),
// appearance
timerStyle: TimerStyleSchema,
liveSpeedStyle: LiveSpeedAccBurstStyleSchema,
liveAccStyle: LiveSpeedAccBurstStyleSchema,
liveBurstStyle: LiveSpeedAccBurstStyleSchema,
timerColor: TimerColorSchema,
timerOpacity: TimerOpacitySchema,
highlightMode: HighlightModeSchema,
typedEffect: TypedEffectSchema,
tapeMode: TapeModeSchema,
tapeMargin: TapeMarginSchema,
smoothLineScroll: z.boolean(),
showAllLines: z.boolean(),
alwaysShowDecimalPlaces: z.boolean(),
typingSpeedUnit: TypingSpeedUnitSchema,
startGraphsAtZero: z.boolean(),
maxLineWidth: MaxLineWidthSchema,
fontSize: FontSizeSchema,
fontFamily: FontNameSchema,
keymapMode: KeymapModeSchema,
keymapLayout: KeymapLayoutSchema,
keymapStyle: KeymapStyleSchema,
keymapLegendStyle: KeymapLegendStyleSchema,
keymapShowTopRow: KeymapShowTopRowSchema,
keymapSize: KeymapSizeSchema,
// theme
flipTestColors: z.boolean(),
colorfulMode: z.boolean(),
customBackground: CustomBackgroundSchema,
customBackgroundSize: CustomBackgroundSizeSchema,
customBackgroundFilter: CustomBackgroundFilterSchema,
autoSwitchTheme: z.boolean(),
themeLight: ThemeNameSchema,
themeDark: ThemeNameSchema,
randomTheme: RandomThemeSchema,
favThemes: FavThemesSchema,
theme: ThemeNameSchema,
customTheme: z.boolean(),
customThemeColors: CustomThemeColorsSchema,
// hide elements
showKeyTips: z.boolean(),
showOutOfFocusWarning: z.boolean(),
capsLockWarning: z.boolean(),
showAverage: ShowAverageSchema,
showPb: ShowPbSchema,
// other (hidden)
accountChart: AccountChartSchema,
monkey: z.boolean(),
monkeyPowerLevel: MonkeyPowerLevelSchema,
// ads
ads: AdsSchema,
} satisfies Record<string, ZodSchema>)
.strict();
export type Config = z.infer<typeof ConfigSchema>;
export const ConfigKeySchema = ConfigSchema.keyof();
export type ConfigKey = z.infer<typeof ConfigKeySchema>;
export type ConfigValue = Config[keyof Config];
export const PartialConfigSchema = ConfigSchema.partial();
export type PartialConfig = z.infer<typeof PartialConfigSchema>;
export const ConfigGroupNameSchema = z.enum([
"test",
"behavior",
"input",
"sound",
"caret",
"appearance",
"theme",
"hideElements",
"hidden",
"ads",
]);
export type ConfigGroupName = z.infer<typeof ConfigGroupNameSchema>;

View File

@@ -0,0 +1,131 @@
import { z } from "zod";
/* ValidModeRuleSchema allows complex rules like `"mode2": "(15|60)"`. We don't want a strict validation here. */
export const ValidModeRuleSchema = z
.object({
language: z.string(),
mode: z.string(),
mode2: z.string(),
})
.strict();
export type ValidModeRule = z.infer<typeof ValidModeRuleSchema>;
export const RewardBracketSchema = z
.object({
minRank: z.number().int().nonnegative(),
maxRank: z.number().int().nonnegative(),
minReward: z.number().int().nonnegative(),
maxReward: z.number().int().nonnegative(),
})
.strict();
export type RewardBracket = z.infer<typeof RewardBracketSchema>;
export const ConfigurationSchema = z.object({
maintenance: z.boolean(),
dev: z.object({
responseSlowdownMs: z.number().int().nonnegative(),
}),
quotes: z.object({
reporting: z.object({
enabled: z.boolean(),
maxReports: z.number().int().nonnegative(),
contentReportLimit: z.number().int().nonnegative(),
}),
submissionsEnabled: z.boolean(),
maxFavorites: z.number().int().nonnegative(),
}),
results: z.object({
savingEnabled: z.boolean(),
objectHashCheckEnabled: z.boolean(),
filterPresets: z.object({
enabled: z.boolean(),
maxPresetsPerUser: z.number().int().nonnegative(),
}),
limits: z.object({
regularUser: z.number().int().nonnegative(),
premiumUser: z.number().int().nonnegative(),
}),
maxBatchSize: z.number().int().nonnegative(),
}),
users: z.object({
signUp: z.boolean(),
lastHashesCheck: z.object({
enabled: z.boolean(),
maxHashes: z.number().int().nonnegative(),
}),
autoBan: z.object({
enabled: z.boolean(),
maxCount: z.number().int().nonnegative(),
maxHours: z.number().int().nonnegative(),
}),
profiles: z.object({
enabled: z.boolean(),
}),
discordIntegration: z.object({
enabled: z.boolean(),
}),
xp: z.object({
enabled: z.boolean(),
funboxBonus: z.number(),
gainMultiplier: z.number(),
maxDailyBonus: z.number(),
minDailyBonus: z.number(),
streak: z.object({
enabled: z.boolean(),
maxStreakDays: z.number().nonnegative(),
maxStreakMultiplier: z.number(),
}),
}),
inbox: z.object({
enabled: z.boolean(),
maxMail: z.number().int().nonnegative(),
}),
premium: z.object({
enabled: z.boolean(),
}),
}),
admin: z.object({
endpointsEnabled: z.boolean(),
}),
apeKeys: z.object({
endpointsEnabled: z.boolean(),
acceptKeys: z.boolean(),
maxKeysPerUser: z.number().int().nonnegative(),
apeKeyBytes: z.number().int().nonnegative(),
apeKeySaltRounds: z.number().int().nonnegative(),
}),
rateLimiting: z.object({
badAuthentication: z.object({
enabled: z.boolean(),
penalty: z.number(),
flaggedStatusCodes: z.array(z.number().int().nonnegative()),
}),
}),
dailyLeaderboards: z.object({
enabled: z.boolean(),
leaderboardExpirationTimeInDays: z.number().nonnegative(),
maxResults: z.number().int().nonnegative(),
validModeRules: z.array(ValidModeRuleSchema),
scheduleRewardsModeRules: z.array(ValidModeRuleSchema),
topResultsToAnnounce: z.number().int().positive(), // This should never be 0. Setting to zero will announce all results.
xpRewardBrackets: z.array(RewardBracketSchema),
}),
leaderboards: z.object({
minTimeTyping: z
.number()
.min(0)
.describe(
"Minimum typing time (in seconds) the user needs to get on a leaderboard",
),
weeklyXp: z.object({
enabled: z.boolean(),
expirationTimeInDays: z.number().nonnegative(),
xpRewardBrackets: z.array(RewardBracketSchema),
}),
}),
connections: z.object({
enabled: z.boolean(),
maxPerUser: z.number().int().nonnegative(),
}),
});
export type Configuration = z.infer<typeof ConfigurationSchema>;

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
import { IdSchema } from "./util";
export const ConnectionStatusSchema = z.enum([
"pending",
"accepted",
"blocked",
]);
export type ConnectionStatus = z.infer<typeof ConnectionStatusSchema>;
export const ConnectionTypeSchema = z.enum(["incoming", "outgoing"]);
export type ConnectionType = z.infer<typeof ConnectionTypeSchema>;
export const ConnectionSchema = z.object({
_id: IdSchema,
initiatorUid: IdSchema,
initiatorName: z.string(),
receiverUid: IdSchema,
receiverName: z.string(),
lastModified: z.number().int().nonnegative(),
status: ConnectionStatusSchema,
});
export type Connection = z.infer<typeof ConnectionSchema>;

View File

@@ -0,0 +1,61 @@
import { z } from "zod";
import { customEnumErrorHandler } from "./util";
const KnownFontNameSchema = z.enum(
[
"Roboto_Mono",
"Noto_Naskh_Arabic",
"Source_Code_Pro",
"IBM_Plex_Sans",
"Inconsolata",
"Fira_Code",
"JetBrains_Mono",
"Roboto",
"Montserrat",
"Titillium_Web",
"Lexend_Deca",
"Comic_Sans_MS",
"Oxygen",
"Nunito",
"Itim",
"Courier",
"Comfortaa",
"Coming_Soon",
"Atkinson_Hyperlegible",
"Lato",
"Lalezar",
"Boon",
"Open_Dyslexic",
"Ubuntu",
"Ubuntu_Mono",
"Georgia",
"Cascadia_Mono",
"IBM_Plex_Mono",
"Overpass_Mono",
"Hack",
"CommitMono",
"Mononoki",
"Parkinsans",
"Geist",
"Sarabun",
"Kanit",
"Geist_Mono",
"Iosevka",
"Proto",
"Adwaita_Mono",
"Inter_Tight",
"Space_Grotesk",
],
{
errorMap: customEnumErrorHandler("Must be a known font family"),
},
);
export type KnownFontName = z.infer<typeof KnownFontNameSchema>;
export const FontNameSchema = KnownFontNameSchema.or(
z
.string()
.max(50)
.regex(/^[a-zA-Z0-9_\-+.]+$/),
);
export type FontName = z.infer<typeof FontNameSchema>;

View File

@@ -0,0 +1,466 @@
import { z } from "zod";
import { customEnumErrorHandler } from "./util";
export const LanguageSchema = z.enum(
[
"english",
"english_1k",
"english_5k",
"english_10k",
"english_25k",
"english_450k",
"english_commonly_misspelled",
"english_contractions",
"english_doubleletter",
"english_shakespearean",
"english_old",
"english_medical",
"spanish",
"spanish_1k",
"spanish_10k",
"spanish_650k",
"french",
"french_1k",
"french_2k",
"french_10k",
"french_600k",
"french_bitoduc",
"nepali",
"nepali_1k",
"nepali_romanized",
"sanskrit",
"sanskrit_roman",
"santali",
"azerbaijani",
"azerbaijani_1k",
"arabic",
"arabic_10k",
"arabic_egypt",
"arabic_egypt_1k",
"arabic_morocco",
"malagasy",
"malagasy_1k",
"malay",
"malay_1k",
"mongolian",
"mongolian_10k",
"kannada",
"korean",
"korean_1k",
"korean_5k",
"khmer",
"chinese_simplified",
"chinese_simplified_1k",
"chinese_simplified_5k",
"chinese_simplified_10k",
"chinese_simplified_50k",
"chinese_traditional",
"chinese_traditional_1k",
"chinese_traditional_5k",
"chinese_traditional_10k",
"chinese_traditional_50k",
"russian",
"russian_1k",
"russian_5k",
"russian_10k",
"russian_25k",
"russian_50k",
"russian_375k",
"russian_contractions",
"russian_contractions_1k",
"russian_abbreviations",
"ukrainian",
"ukrainian_1k",
"ukrainian_10k",
"ukrainian_50k",
"ukrainian_endings",
"ukrainian_latynka",
"ukrainian_latynka_1k",
"ukrainian_latynka_10k",
"ukrainian_latynka_50k",
"ukrainian_latynka_endings",
"portuguese",
"portuguese_acentos_e_cedilha",
"portuguese_1k",
"portuguese_3k",
"portuguese_5k",
"portuguese_320k",
"portuguese_550k",
"indonesian",
"indonesian_1k",
"indonesian_10k",
"kurdish_central",
"kurdish_central_2k",
"kurdish_central_4k",
"german",
"german_1k",
"german_10k",
"german_250k",
"swiss_german",
"swiss_german_1k",
"swiss_german_2k",
"afrikaans",
"afrikaans_1k",
"afrikaans_10k",
"georgian",
"tamil",
"tamil_1k",
"tanglish",
"tamil_old",
"telugu",
"telugu_1k",
"greek",
"greek_1k",
"greek_5k",
"greek_10k",
"greek_25k",
"greeklish",
"greeklish_1k",
"greeklish_5k",
"greeklish_10k",
"greeklish_25k",
"turkish",
"turkish_1k",
"turkish_5k",
"irish",
"irish_1k",
"italian",
"italian_1k",
"italian_7k",
"italian_60k",
"italian_280k",
"friulian",
"latin",
"galician",
"thai",
"thai_1k",
"thai_5k",
"thai_10k",
"thai_20k",
"thai_50k",
"thai_60k",
"polish",
"polish_2k",
"polish_5k",
"polish_10k",
"polish_20k",
"polish_40k",
"polish_200k",
"czech",
"czech_1k",
"czech_10k",
"slovak",
"slovak_1k",
"slovak_10k",
"slovenian",
"slovenian_1k",
"slovenian_5k",
"croatian",
"croatian_1k",
"dutch",
"dutch_1k",
"dutch_10k",
"filipino",
"filipino_1k",
"danish",
"danish_1k",
"danish_10k",
"hungarian",
"hungarian_1k",
"hungarian_2k",
"norwegian_bokmal",
"norwegian_bokmal_1k",
"norwegian_bokmal_5k",
"norwegian_bokmal_10k",
"norwegian_bokmal_150k",
"norwegian_bokmal_600k",
"norwegian_nynorsk",
"norwegian_nynorsk_1k",
"norwegian_nynorsk_5k",
"norwegian_nynorsk_10k",
"norwegian_nynorsk_100k",
"norwegian_nynorsk_400k",
"hebrew",
"hebrew_1k",
"hebrew_5k",
"hebrew_10k",
"icelandic",
"icelandic_1k",
"romanian",
"romanian_1k",
"romanian_5k",
"romanian_10k",
"romanian_25k",
"romanian_50k",
"romanian_100k",
"romanian_200k",
"lorem_ipsum",
"finnish",
"finnish_1k",
"finnish_10k",
"estonian",
"estonian_1k",
"estonian_5k",
"estonian_10k",
"udmurt",
"welsh",
"welsh_1k",
"persian",
"persian_1k",
"persian_5k",
"persian_20k",
"persian_romanized",
"marathi",
"kazakh",
"kazakh_1k",
"vietnamese",
"vietnamese_1k",
"vietnamese_5k",
"jyutping",
"pinyin",
"pinyin_1k",
"pinyin_10k",
"hausa",
"hausa_1k",
"swedish",
"swedish_1k",
"swedish_diacritics",
"serbian_latin",
"serbian_latin_10k",
"serbian",
"serbian_10k",
"yoruba_1k",
"swahili_1k",
"maori_1k",
"catalan",
"catalan_1k",
"lojban_gismu",
"lojban_cmavo",
"lithuanian",
"lithuanian_1k",
"lithuanian_3k",
"bulgarian",
"bulgarian_1k",
"bulgarian_latin",
"bulgarian_latin_1k",
"bangla",
"bangla_letters",
"bangla_10k",
"bosnian",
"bosnian_4k",
"toki_pona",
"toki_pona_ku_suli",
"toki_pona_ku_lili",
"esperanto",
"esperanto_1k",
"esperanto_10k",
"esperanto_25k",
"esperanto_36k",
"esperanto_x_sistemo",
"esperanto_x_sistemo_1k",
"esperanto_x_sistemo_10k",
"esperanto_x_sistemo_25k",
"esperanto_x_sistemo_36k",
"esperanto_h_sistemo",
"esperanto_h_sistemo_1k",
"esperanto_h_sistemo_10k",
"esperanto_h_sistemo_25k",
"esperanto_h_sistemo_36k",
"kyrgyz",
"kyrgyz_1k",
"urdu",
"urdu_1k",
"urdu_5k",
"urdu_roman",
"urdish",
"albanian",
"albanian_1k",
"shona",
"shona_1k",
"armenian",
"armenian_1k",
"armenian_western",
"armenian_western_1k",
"myanmar_burmese",
"japanese_hiragana",
"japanese_katakana",
"japanese_romaji",
"japanese_romaji_1k",
"sinhala",
"latvian",
"latvian_1k",
"maltese",
"maltese_1k",
"twitch_emotes",
"git",
"pig_latin",
"hindi",
"hindi_1k",
"hinglish",
"gujarati",
"gujarati_1k",
"macedonian",
"macedonian_1k",
"macedonian_10k",
"macedonian_75k",
"belarusian",
"belarusian_1k",
"belarusian_5k",
"belarusian_10k",
"belarusian_25k",
"belarusian_50k",
"belarusian_100k",
"belarusian_lacinka",
"belarusian_lacinka_1k",
"tatar",
"tatar_1k",
"tatar_5k",
"tatar_9k",
"tatar_crimean",
"tatar_crimean_1k",
"tatar_crimean_5k",
"tatar_crimean_10k",
"tatar_crimean_15k",
"tatar_crimean_cyrillic",
"tatar_crimean_cyrillic_1k",
"tatar_crimean_cyrillic_5k",
"tatar_crimean_cyrillic_10k",
"tatar_crimean_cyrillic_15k",
"uzbek",
"uzbek_1k",
"uzbek_70k",
"malayalam",
"amharic",
"amharic_1k",
"amharic_5k",
"oromo",
"oromo_1k",
"oromo_5k",
"wordle",
"league_of_legends",
"wordle_1k",
"typing_of_the_dead",
"yiddish",
"frisian",
"frisian_1k",
"pashto",
"euskera",
"klingon",
"klingon_1k",
"quenya",
"occitan",
"occitan_1k",
"occitan_2k",
"occitan_5k",
"occitan_10k",
"bashkir",
"zulu",
"kabyle",
"kabyle_1k",
"kabyle_2k",
"kabyle_5k",
"kabyle_10k",
"hawaiian",
"hawaiian_1k",
"code_python",
"code_python_1k",
"code_python_2k",
"code_python_5k",
"code_fsharp",
"code_c",
"code_csharp",
"code_css",
"code_c++",
"code_dart",
"code_brainfck",
"code_javascript",
"code_javascript_1k",
"code_javascript_react",
"code_jule",
"code_julia",
"code_haskell",
"code_html",
"code_nim",
"code_nix",
"code_pascal",
"code_java",
"code_kotlin",
"code_go",
"code_rockstar",
"code_rust",
"code_ruby",
"code_r",
"code_r_2k",
"code_swift",
"code_scala",
"code_bash",
"code_powershell",
"code_lua",
"code_luau",
"code_latex",
"code_typst",
"code_matlab",
"code_sql",
"code_perl",
"code_php",
"code_vim",
"code_vimscript",
"code_opencl",
"code_visual_basic",
"code_arduino",
"code_systemverilog",
"code_elixir",
"code_gleam",
"code_zig",
"code_gdscript",
"code_gdscript_2",
"code_assembly",
"code_v",
"code_ook",
"code_typescript",
"code_ocaml",
"code_odin",
"xhosa",
"xhosa_3k",
"tibetan",
"tibetan_1k",
"code_cobol",
"code_clojure",
"code_common_lisp",
"code_erlang",
"docker_file",
"code_fortran",
"viossa",
"viossa_njutro",
"code_abap",
"code_abap_1k",
"code_yoptascript",
"code_cuda",
"kinyarwanda",
"pokemon_1k",
"kokanu",
"likanu",
],
{
errorMap: customEnumErrorHandler("Must be a supported language"),
},
);
export type Language = z.infer<typeof LanguageSchema>;
export const LanguageObjectSchema = z
.object({
name: LanguageSchema,
rightToLeft: z.boolean().optional(),
noLazyMode: z.boolean().optional(),
ligatures: z.boolean().optional(),
orderedByFrequency: z.boolean().optional(),
words: z.array(z.string()).min(1),
additionalAccents: z
.array(z.tuple([z.string().min(1), z.string().min(1)]))
.optional(),
bcp47: z.string().optional(),
originalPunctuation: z.boolean().optional(),
})
.strict();
export type LanguageObject = z.infer<typeof LanguageObjectSchema>;

View File

@@ -0,0 +1,308 @@
import { z } from "zod";
import { customEnumErrorHandler } from "./util";
export const LayoutNameSchema = z.enum(
[
"qwerty",
"dvorak",
"colemak",
"colemak_angle",
"colemak_wide",
"colemak_dh",
"colemak_dh_iso",
"colemak_dh_wide",
"colemak_dh_iso_wide",
"colemak_dhk",
"colemak_dh_matrix",
"colemak_dhk_iso",
"colemak_dhv",
"qwertz",
"swiss_german",
"swiss_french",
"workman",
"prog_workman",
"turkish_q",
"turkish_f",
"turkish_e",
"MTGAP_ASRT",
"norman",
"halmak",
"QGMLWB",
"QGMLWY",
"qwpr",
"uk_qwerty",
"spanish_qwerty",
"italian_qwerty",
"latam_qwerty",
"prog_dvorak",
"prog_dvorak_prime",
"german_dvorak",
"german_dvorak_imp",
"spanish_dvorak",
"swedish_colemak",
"swedish_dvorak",
"dvorak_L",
"dvorak_R",
"dvorak_fr",
"azerty",
"azerty_AFNOR",
"bepo",
"bepo_AFNOR",
"alpha",
"handsdown",
"hungarian",
"handsdown_alt",
"handsdown_promethium",
"handsdown_neu",
"handsdown_neu_inverted",
"typehack",
"MTGAP",
"MTGAP_full",
"ina",
"soul",
"niro",
"mongolian",
"JCUKEN",
"statica_3x5",
"Vestnik",
"Diktor",
"Diktor_VoronovMod",
"Redaktor",
"JUIYAF",
"Zubachev",
"ISRT",
"ISRT_Angle",
"colemak_Qix",
"colemak_Qi",
"colemaQ",
"colemaQ_F",
"engram",
"engrammer",
"semimak",
"semimak_jq",
"semimak_jqc",
"canary",
"canary_matrix",
"japanese_hiragana",
"boo",
"boo_mangle",
"APT",
"APT_angle",
"middlemak",
"middlemak-nh",
"hindi_inscript",
"thai_kedmanee",
"thai_pattachote",
"thai_manoonchai",
"persian_standard",
"persian_farsi",
"arabic_101",
"arabic_102",
"arabic_mac",
"hebrew",
"urdu_phonetic",
"brasileiro_nativo",
"Foalmak",
"quartz",
"arensito",
"ARTS",
"beakl_15",
"beakl_19",
"beakl_19_bis",
"capewell_dvorak",
"colman",
"heart",
"klauser",
"oneproduct",
"pine",
"pine_v4",
"real",
"rolll",
"stndc",
"three",
"uciea",
"asset",
"dwarf",
"flaw",
"whorf",
"whorf6",
"whorfmax",
"whorfmax_ortho",
"sertain",
"ctgap",
"octa8",
"polish_programmers",
"bulgarian",
"bulgarian_phonetic_traditional",
"belarusian",
"ukrainian",
"russian",
"neo",
"bone",
"AdNW",
"mine",
"noted",
"koy",
"3l",
"korean",
"ekverto_b",
"nerps",
"sturdy_angle_ansi",
"sturdy_angle_iso",
"sturdy_ortho",
"ABNT2",
"HiYou",
"xenia",
"xenia_alt",
"burmese",
"gallium",
"gallium_angle",
"gallium_v2",
"gallium_v2_matrix",
"gallium_nl",
"maya",
"gallaya_angle_ansi",
"gallaya_angle_iso",
"gallaya_matrix",
"nila",
"minimak_4k",
"minimak_8k",
"minimak_12k",
"optimot",
"norwegian_qwerty",
"portuguese_pt_qwerty_iso",
"portuguese_pt_qwerty_ansi",
"swedish_qwerty",
"danish_qwerty",
"noctum",
"graphite",
"graphite_angle",
"graphite_angle_vc",
"graphite_angle_kp",
"graphite_matrix",
"macedonian",
"UGJRMV",
"pashto",
"ORNATE",
"estonian",
"stronk",
"dhorf",
"gust",
"recurva",
"seht-drai",
"ints",
"rollla",
"wreathy",
"saiga",
"saiga-e",
"krai",
"mir",
"ergol",
"cascade",
"vylet",
"hyperroll",
"romak",
"scythe",
"inqwerted",
"rain",
"night",
"night_stic",
"whix2",
"haruka",
"kuntum",
"anishtro",
"Kuntem",
"kuntem-jq",
"BEAKL_Zi",
"snorkle",
"MALTRON",
"PRSTEN",
"RSTHD",
"dusk",
"zenith",
"focal",
"panini",
"panini_wide",
"ergopti",
"sword",
"opy",
"tarmak_1",
"tarmak_2",
"tarmak_3",
"tarmak_4",
"rulemak",
"persian_farsi_colemak",
"persian_standard_colemak",
"ergo_split46",
"tamil99",
"Gralmak",
"GralmakS",
"vitrimak",
"miligram",
],
{
errorMap: customEnumErrorHandler("Must be a supported layout"),
},
);
export type LayoutName = z.infer<typeof LayoutNameSchema>;
const charDefinitionSchema = z.array(z.string().length(1)).min(1).max(4);
const row5CharDefinitionSchema = z.array(z.string().length(1)).min(1).max(4);
const commonLayoutSchema = z
.object({
keymapShowTopRow: z.boolean(),
matrixShowRightColumn: z.boolean().optional(),
})
.strict();
const ansiLayoutSchema = commonLayoutSchema
.extend({
type: z.literal("ansi"),
keys: z
.object({
row1: z.array(charDefinitionSchema).length(13),
row2: z.array(charDefinitionSchema).length(13),
row3: z.array(charDefinitionSchema).length(11),
row4: z.array(charDefinitionSchema).length(10),
row5: z.array(row5CharDefinitionSchema).min(1).max(2),
})
.strict(),
})
.strict();
const isoLayoutSchema = commonLayoutSchema
.extend({
type: z.literal("iso"),
keys: z
.object({
row1: z.array(charDefinitionSchema).length(13),
row2: z.array(charDefinitionSchema).length(12),
row3: z.array(charDefinitionSchema).length(12),
row4: z.array(charDefinitionSchema).length(11),
row5: z.array(row5CharDefinitionSchema).min(1).max(2),
})
.strict(),
})
.strict();
const matrixLayoutSchema = commonLayoutSchema
.extend({
type: z.literal("matrix"),
keys: z
.object({
row1: z.array(charDefinitionSchema).length(0),
row2: z.array(charDefinitionSchema).length(10),
row3: z.array(charDefinitionSchema).length(10),
row4: z.array(charDefinitionSchema).length(4),
row5: z.array(row5CharDefinitionSchema).length(0),
})
.strict(),
})
.strict();
export const LayoutObjectSchema = ansiLayoutSchema
.or(isoLayoutSchema)
.or(matrixLayoutSchema);
export type LayoutObject = z.infer<typeof LayoutObjectSchema>;

View File

@@ -0,0 +1,66 @@
import { z } from "zod";
const FriendsRankSchema = z
.number()
.nonnegative()
.int()
.optional()
.describe("only available on friendsOnly leaderboard");
export const LeaderboardEntrySchema = z.object({
wpm: z.number().nonnegative(),
acc: z.number().nonnegative().min(0).max(100),
timestamp: z.number().int().nonnegative(),
raw: z.number().nonnegative(),
consistency: z.number().nonnegative().optional(),
uid: z.string(),
name: z.string(),
discordId: z.string().optional(),
discordAvatar: z.string().optional(),
rank: z.number().nonnegative().int(),
friendsRank: FriendsRankSchema,
badgeId: z.number().int().optional(),
isPremium: z.boolean().optional(),
});
export type LeaderboardEntry = z.infer<typeof LeaderboardEntrySchema>;
export const RedisDailyLeaderboardEntrySchema = LeaderboardEntrySchema.omit({
rank: true,
friendsRank: true,
});
export type RedisDailyLeaderboardEntry = z.infer<
typeof RedisDailyLeaderboardEntrySchema
>;
export const RedisXpLeaderboardEntrySchema = z.object({
uid: z.string(),
name: z.string(),
lastActivityTimestamp: z.number().int().nonnegative(),
timeTypedSeconds: z.number().nonnegative(),
// optionals
// discordId: z.string().optional(),
discordId: z //todo remove once weekly leaderboards reset twice and remove null values
.string()
.optional()
.or(z.null().transform((_val) => undefined)),
discordAvatar: z.string().optional(),
badgeId: z.number().int().optional(),
isPremium: z.boolean().optional(),
});
export type RedisXpLeaderboardEntry = z.infer<
typeof RedisXpLeaderboardEntrySchema
>;
export const RedisXpLeaderboardScoreSchema = z.number().int().nonnegative();
export type RedisXpLeaderboardScore = z.infer<
typeof RedisXpLeaderboardScoreSchema
>;
export const XpLeaderboardEntrySchema = RedisXpLeaderboardEntrySchema.extend({
//based on another redis collection
totalXp: RedisXpLeaderboardScoreSchema,
// dynamically added when generating response on the backend
rank: z.number().nonnegative().int(),
friendsRank: FriendsRankSchema,
});
export type XpLeaderboardEntry = z.infer<typeof XpLeaderboardEntrySchema>;

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import { IdSchema, nameWithSeparators, TagSchema } from "./util";
import {
ConfigGroupName,
ConfigGroupNameSchema,
PartialConfigSchema,
} from "./configs";
export const PresetNameSchema = nameWithSeparators().max(16);
export type PresetName = z.infer<typeof PresetNameSchema>;
export const PresetTypeSchema = z.enum(["full", "partial"]);
export type PresetType = z.infer<typeof PresetTypeSchema>;
const PresetSettingsGroupsSchema = z
.array(ConfigGroupNameSchema)
.min(1)
.superRefine((settingList, ctx) => {
ConfigGroupNameSchema.options.forEach(
(presetSettingGroup: ConfigGroupName) => {
const duplicateElemExits: boolean =
settingList.filter(
(settingGroup: ConfigGroupName) =>
settingGroup === presetSettingGroup,
).length > 1;
if (duplicateElemExits) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `No duplicates allowed.`,
});
}
},
);
});
export const PresetSchema = z.object({
_id: IdSchema,
name: PresetNameSchema,
settingGroups: PresetSettingsGroupsSchema.nullable().optional(),
config: PartialConfigSchema.extend({
tags: z.array(TagSchema).optional(),
}),
});
export type Preset = z.infer<typeof PresetSchema>;
export const EditPresetRequestSchema = PresetSchema.partial({
config: true,
settingGroups: true,
});
export type EditPresetRequest = z.infer<typeof EditPresetRequestSchema>;

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
import { IdSchema } from "./util";
export const PSASchema = z.object({
_id: IdSchema,
message: z.string(),
date: z.number().int().min(0).optional(),
level: z.number().int().optional(),
sticky: z.boolean().optional(),
});
export type PSA = z.infer<typeof PSASchema>;

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
import { StringNumberSchema } from "./util";
export const SpeedHistogramSchema = z.record(
StringNumberSchema,
z.number().int(),
);
export type SpeedHistogram = z.infer<typeof SpeedHistogramSchema>;
export const TypingStatsSchema = z.object({
timeTyping: z.number().nonnegative(),
testsCompleted: z.number().int().nonnegative(),
testsStarted: z.number().int().nonnegative(),
});
export type TypingStats = z.infer<typeof TypingStatsSchema>;

View File

@@ -0,0 +1,71 @@
import { z } from "zod";
import { IdSchema } from "./util";
import { LanguageSchema } from "./languages";
export const QuoteIdSchema = z
.number()
.int()
.nonnegative()
.or(z.string().regex(/^\d+$/).transform(Number));
export type QuoteId = z.infer<typeof QuoteIdSchema>;
export const ApproveQuoteSchema = z.object({
id: QuoteIdSchema,
text: z.string(),
source: z.string(),
length: z.number().int().positive(),
approvedBy: z.string().describe("The approvers name"),
});
export type ApproveQuote = z.infer<typeof ApproveQuoteSchema>;
export const QuoteSchema = z.object({
_id: IdSchema,
text: z.string(),
source: z.string(),
language: LanguageSchema,
submittedBy: IdSchema.describe("uid of the submitter"),
timestamp: z.number().int().nonnegative(),
approved: z.boolean(),
});
export type Quote = z.infer<typeof QuoteSchema>;
export const QuoteRatingSchema = z.object({
_id: IdSchema,
language: LanguageSchema,
quoteId: QuoteIdSchema,
average: z.number().nonnegative(),
ratings: z.number().int().nonnegative(),
totalRating: z.number().nonnegative(),
});
export type QuoteRating = z.infer<typeof QuoteRatingSchema>;
export const QuoteReportReasonSchema = z.enum([
"Grammatical error",
"Duplicate quote",
"Inappropriate content",
"Low quality content",
"Incorrect source",
]);
export type QuoteReportReason = z.infer<typeof QuoteReportReasonSchema>;
export const QuoteDataQuoteSchema = z
.object({
id: z.number(),
text: z.string(),
britishText: z.string().optional(),
source: z.string(),
length: z.number(),
approvedBy: z.string().optional(),
})
.strict();
export type QuoteDataQuote = z.infer<typeof QuoteDataQuoteSchema>;
export const QuoteDataSchema = z
.object({
language: LanguageSchema,
groups: z.array(z.tuple([z.number(), z.number()])).length(4),
quotes: z.array(QuoteDataQuoteSchema),
})
.strict();
export type QuoteData = z.infer<typeof QuoteDataSchema>;

View File

@@ -0,0 +1,182 @@
import { z } from "zod";
import {
CustomTextLimitModeSchema,
CustomTextModeSchema,
IdSchema,
PercentageSchema,
token,
WpmSchema,
} from "./util";
import { LanguageSchema } from "./languages";
import { Mode, Mode2, Mode2Schema, ModeSchema } from "./shared";
import { DifficultySchema, FunboxSchema } from "./configs";
export const IncompleteTestSchema = z.object({
acc: PercentageSchema,
seconds: z.number().nonnegative(),
});
export type IncompleteTest = z.infer<typeof IncompleteTestSchema>;
export const OldChartDataSchema = z.object({
wpm: z.array(z.number().nonnegative()).max(122),
raw: z.array(z.number().int().nonnegative()).max(122),
err: z.array(z.number().nonnegative()).max(122),
});
export type OldChartData = z.infer<typeof OldChartDataSchema>;
export const ChartDataSchema = z.object({
wpm: z.array(z.number().nonnegative()).max(122),
burst: z.array(z.number().int().nonnegative()).max(122),
err: z.array(z.number().nonnegative()).max(122),
});
export type ChartData = z.infer<typeof ChartDataSchema>;
export const KeyStatsSchema = z.object({
average: z.number().nonnegative(),
sd: z.number().nonnegative(),
});
export type KeyStats = z.infer<typeof KeyStatsSchema>;
export const CompletedEventCustomTextSchema = z.object({
textLen: z.number().int().nonnegative(),
mode: CustomTextModeSchema,
pipeDelimiter: z.boolean(),
limit: z.object({
mode: CustomTextLimitModeSchema,
value: z.number().nonnegative(),
}),
});
export type CompletedEventCustomText = z.infer<
typeof CompletedEventCustomTextSchema
>;
export const CustomTextSettingsSchema = CompletedEventCustomTextSchema.omit({
textLen: true,
}).extend({
text: z.array(z.string()).min(1),
});
export type CustomTextSettings = z.infer<typeof CustomTextSettingsSchema>;
export const CharStatsSchema = z.tuple([
z.number().int().nonnegative(),
z.number().int().nonnegative(),
z.number().int().nonnegative(),
z.number().int().nonnegative(),
]);
export type CharStats = z.infer<typeof CharStatsSchema>;
const ResultBaseSchema = z.object({
wpm: WpmSchema,
rawWpm: WpmSchema,
charStats: CharStatsSchema,
acc: PercentageSchema.min(50),
mode: ModeSchema,
mode2: Mode2Schema,
quoteLength: z.number().int().nonnegative().max(3).optional(),
timestamp: z.number().int().nonnegative(),
testDuration: z.number().min(1),
consistency: PercentageSchema,
keyConsistency: PercentageSchema,
chartData: ChartDataSchema.or(z.literal("toolong")),
uid: IdSchema,
//required on POST but optional in the database and might be removed to save space
restartCount: z.number().int().nonnegative().optional(),
incompleteTestSeconds: z.number().nonnegative().optional(),
afkDuration: z.number().nonnegative().optional(),
tags: z.array(IdSchema).optional(),
bailedOut: z.boolean().optional(),
blindMode: z.boolean().optional(),
lazyMode: z.boolean().optional(),
funbox: FunboxSchema.optional(),
language: LanguageSchema.optional(),
difficulty: DifficultySchema.optional(),
numbers: z.boolean().optional(),
punctuation: z.boolean().optional(),
});
export const ResultSchema = ResultBaseSchema.extend({
_id: IdSchema,
keySpacingStats: KeyStatsSchema.optional(),
keyDurationStats: KeyStatsSchema.optional(),
name: z.string(),
isPb: z.boolean().optional(), //true or undefined
});
export type Result<M extends Mode> = Omit<
z.infer<typeof ResultSchema>,
"mode" | "mode2"
> & {
mode: M;
mode2: Mode2<M>;
};
export const ResultMinifiedSchema = ResultSchema.omit({
name: true,
keySpacingStats: true,
keyDurationStats: true,
chartData: true,
});
export type ResultMinified = z.infer<typeof ResultMinifiedSchema>;
export const CompletedEventSchema = ResultBaseSchema.required({
restartCount: true,
incompleteTestSeconds: true,
afkDuration: true,
tags: true,
bailedOut: true,
blindMode: true,
lazyMode: true,
funbox: true,
language: true,
difficulty: true,
numbers: true,
punctuation: true,
})
.extend({
charTotal: z.number().int().nonnegative(),
challenge: token().max(100).optional(),
customText: CompletedEventCustomTextSchema.optional(),
hash: token().max(100),
keyDuration: z.array(z.number().nonnegative()).or(z.literal("toolong")),
keySpacing: z.array(z.number().nonnegative()).or(z.literal("toolong")),
keyOverlap: z.number().nonnegative(),
lastKeyToEnd: z.number().nonnegative(),
startToFirstKey: z.number().nonnegative(),
wpmConsistency: PercentageSchema,
stopOnLetter: z.boolean(),
incompleteTests: z.array(IncompleteTestSchema),
})
.strict();
export type CompletedEvent = z.infer<typeof CompletedEventSchema>;
export const XpBreakdownSchema = z.object({
base: z.number().int().optional(),
fullAccuracy: z.number().int().optional(),
quote: z.number().int().optional(),
corrected: z.number().int().optional(),
punctuation: z.number().int().optional(),
numbers: z.number().int().optional(),
funbox: z.number().int().optional(),
streak: z.number().int().optional(),
incomplete: z.number().int().optional(),
daily: z.number().int().optional(),
accPenalty: z.number().int().optional(),
configMultiplier: z.number().int().optional(),
});
export type XpBreakdown = z.infer<typeof XpBreakdownSchema>;
export const PostResultResponseSchema = z.object({
insertedId: IdSchema,
isPb: z.boolean(),
tagPbs: z.array(IdSchema),
dailyLeaderboardRank: z.number().int().nonnegative().optional(),
weeklyXpLeaderboardRank: z.number().int().nonnegative().optional(),
xp: z.number().int().nonnegative(),
dailyXpBonus: z.boolean(),
xpBreakdown: XpBreakdownSchema,
streak: z.number().int().nonnegative(),
});
export type PostResultResponse = z.infer<typeof PostResultResponseSchema>;

View File

@@ -0,0 +1,83 @@
import { literal, z } from "zod";
import { StringNumberSchema } from "./util";
import { LanguageSchema } from "./languages";
//used by config and shared
export const DifficultySchema = z.enum(["normal", "expert", "master"]);
export type Difficulty = z.infer<typeof DifficultySchema>;
//used by user and config
export const PersonalBestSchema = z.object({
acc: z.number().nonnegative().max(100),
consistency: z.number().nonnegative().max(100),
difficulty: DifficultySchema,
lazyMode: z.boolean().optional(),
language: LanguageSchema,
punctuation: z.boolean().optional(),
numbers: z.boolean().optional(),
raw: z.number().nonnegative(),
wpm: z.number().nonnegative(),
timestamp: z.number().nonnegative(),
});
export type PersonalBest = z.infer<typeof PersonalBestSchema>;
//used by user and config
export const PersonalBestsSchema = z.object({
time: z.record(
StringNumberSchema.describe("Number of seconds as string"),
z.array(PersonalBestSchema),
),
words: z.record(
StringNumberSchema.describe("Number of words as string"),
z.array(PersonalBestSchema),
),
quote: z.record(StringNumberSchema, z.array(PersonalBestSchema)),
custom: z.record(z.literal("custom"), z.array(PersonalBestSchema)),
zen: z.record(z.literal("zen"), z.array(PersonalBestSchema)),
});
export type PersonalBests = z.infer<typeof PersonalBestsSchema>;
export const DefaultWordsModeSchema = z.union([
z.literal("10"),
z.literal("25"),
z.literal("50"),
z.literal("100"),
]);
export const DefaultTimeModeSchema = z.union([
z.literal("15"),
z.literal("30"),
z.literal("60"),
z.literal("120"),
]);
export const QuoteLengthSchema = z.union([
z.literal("short"),
z.literal("medium"),
z.literal("long"),
z.literal("thicc"),
]);
// // Step 1: Define the schema for specific string values "10" and "25"
// const SpecificKeySchema = z.union([z.literal("10"), z.literal("25")]);
// // Step 2: Use this schema as the key schema for another object
// export const ExampleSchema = z.record(SpecificKeySchema, z.string());
// //used by user, config, public
export const ModeSchema = PersonalBestsSchema.keyof();
export type Mode = z.infer<typeof ModeSchema>;
export const Mode2Schema = z.union(
[StringNumberSchema, literal("zen"), literal("custom")],
{
errorMap: () => ({
message: 'Needs to be either a number, "zen" or "custom".',
}),
},
);
export type Mode2<M extends Mode> = M extends M
? keyof PersonalBests[M]
: never;
export type Mode2Custom<M extends Mode> = Mode2<M> | "custom";

View File

@@ -0,0 +1,197 @@
import { z } from "zod";
import { customEnumErrorHandler } from "./util";
export const ThemeNameSchema = z.enum(
[
"8008",
"80s_after_dark",
"9009",
"aether",
"alduin",
"alpine",
"anti_hero",
"arch",
"aurora",
"beach",
"bento",
"bingsu",
"bliss",
"blue_dolphin",
"blueberry_dark",
"blueberry_light",
"botanical",
"bouquet",
"breeze",
"bushido",
"cafe",
"camping",
"carbon",
"catppuccin",
"chaos_theory",
"cheesecake",
"cherry_blossom",
"comfy",
"copper",
"creamsicle",
"cy_red",
"cyberspace",
"dark",
"dark_magic_girl",
"dark_note",
"darling",
"deku",
"desert_oasis",
"dev",
"diner",
"dino",
"discord",
"dmg",
"dollar",
"dots",
"dracula",
"drowning",
"dualshot",
"earthsong",
"everblush",
"evil_eye",
"ez_mode",
"fire",
"fledgling",
"fleuriste",
"floret",
"froyo",
"frozen_llama",
"fruit_chew",
"fundamentals",
"future_funk",
"github",
"godspeed",
"graen",
"grand_prix",
"grape",
"gruvbox_dark",
"gruvbox_light",
"hammerhead",
"hanok",
"hedge",
"honey",
"horizon",
"husqy",
"iceberg_dark",
"iceberg_light",
"incognito",
"ishtar",
"iv_clover",
"iv_spade",
"joker",
"laser",
"lavender",
"leather",
"lil_dragon",
"lilac_mist",
"lime",
"luna",
"macroblank",
"magic_girl",
"mashu",
"matcha_moccha",
"material",
"matrix",
"menthol",
"metaverse",
"metropolis",
"mexican",
"miami",
"miami_nights",
"midnight",
"milkshake",
"mint",
"mizu",
"modern_dolch",
"modern_dolch_light",
"modern_ink",
"monokai",
"moonlight",
"mountain",
"mr_sleeves",
"ms_cupcakes",
"muted",
"nautilus",
"nebula",
"night_runner",
"nord",
"nord_light",
"norse",
"oblivion",
"olive",
"olivia",
"onedark",
"our_theme",
"paper",
"passion_fruit",
"pastel",
"peach_blossom",
"peaches",
"phantom",
"pink_lemonade",
"pulse",
"purpleish",
"rainbow_trail",
"red_dragon",
"red_samurai",
"repose_dark",
"repose_light",
"retro",
"retrocast",
"rgb",
"rose_pine",
"rose_pine_dawn",
"rose_pine_moon",
"rudy",
"ryujinscales",
"serika",
"serika_dark",
"sewing_tin",
"sewing_tin_light",
"shadow",
"shoko",
"slambook",
"snes",
"soaring_skies",
"solarized_dark",
"solarized_light",
"solarized_osaka",
"sonokai",
"stealth",
"strawberry",
"striker",
"suisei",
"sunset",
"superuser",
"sweden",
"tangerine",
"taro",
"terminal",
"terra",
"terrazzo",
"terror_below",
"tiramisu",
"trackday",
"trance",
"tron_orange",
"vaporwave",
"vesper",
"vesper_light",
"viridescent",
"voc",
"vscode",
"watermelon",
"wavez",
"witch_girl",
"pale_nimbus",
"spiderman",
],
{
errorMap: customEnumErrorHandler("Must be a known theme"),
},
);

View File

@@ -0,0 +1,389 @@
import { z, ZodEffects, ZodOptional, ZodString } from "zod";
import { IdSchema, nameWithSeparators, slug, StringNumberSchema } from "./util";
import { LanguageSchema } from "./languages";
import {
ModeSchema,
Mode2Schema,
PersonalBestsSchema,
DefaultWordsModeSchema,
DefaultTimeModeSchema,
QuoteLengthSchema,
DifficultySchema,
PersonalBestSchema,
} from "./shared";
import { CustomThemeColorsSchema, FunboxNameSchema } from "./configs";
import { doesNotContainDisallowedWords } from "./validation/validation";
import { ConnectionSchema } from "./connections";
const NoneFilterSchema = z.literal("none");
export const ResultFiltersSchema = z.object({
_id: IdSchema,
name: slug().max(16),
pb: z
.object({
no: z.boolean(),
yes: z.boolean(),
})
.strict(),
difficulty: z.record(DifficultySchema, z.boolean()),
mode: z.record(ModeSchema, z.boolean()),
words: z.record(DefaultWordsModeSchema.or(z.literal("custom")), z.boolean()),
time: z.record(DefaultTimeModeSchema.or(z.literal("custom")), z.boolean()),
quoteLength: z.record(QuoteLengthSchema, z.boolean()),
punctuation: z
.object({
on: z.boolean(),
off: z.boolean(),
})
.strict(),
numbers: z
.object({
on: z.boolean(),
off: z.boolean(),
})
.strict(),
date: z
.object({
last_day: z.boolean(),
last_week: z.boolean(),
last_month: z.boolean(),
last_3months: z.boolean(),
all: z.boolean(),
})
.strict(),
tags: z.record(IdSchema.or(NoneFilterSchema), z.boolean()),
language: z.record(LanguageSchema, z.boolean()),
funbox: z.record(FunboxNameSchema.or(NoneFilterSchema), z.boolean()),
});
export type ResultFilters = z.infer<typeof ResultFiltersSchema>;
export const StreakHourOffsetSchema = z.number().min(-11).max(12).step(0.5);
export type StreakHourOffset = z.infer<typeof StreakHourOffsetSchema>;
export const UserStreakSchema = z
.object({
lastResultTimestamp: z.number().int().nonnegative(),
length: z.number().int().nonnegative(),
maxLength: z.number().int().nonnegative(),
hourOffset: StreakHourOffsetSchema.optional(),
})
.strict();
export type UserStreak = z.infer<typeof UserStreakSchema>;
export const TagNameSchema = nameWithSeparators().max(16);
export type TagName = z.infer<typeof TagNameSchema>;
export const UserTagSchema = z
.object({
_id: IdSchema,
name: TagNameSchema,
personalBests: PersonalBestsSchema,
})
.strict();
export type UserTag = z.infer<typeof UserTagSchema>;
function profileDetailsBase(
schema: ZodString,
): ZodEffects<ZodOptional<ZodEffects<ZodString>>> {
return doesNotContainDisallowedWords("word", schema)
.optional()
.transform((value) => (value === null ? undefined : value));
}
export const TwitterProfileSchema = profileDetailsBase(slug().max(20)).or(
z.literal(""),
);
export const GithubProfileSchema = profileDetailsBase(slug().max(39)).or(
z.literal(""),
);
export const WebsiteSchema = profileDetailsBase(
z.string().url().max(200).startsWith("https://"),
).or(z.literal(""));
export const UserProfileDetailsSchema = z
.object({
bio: profileDetailsBase(z.string().max(250)).or(z.literal("")),
keyboard: profileDetailsBase(z.string().max(75)).or(z.literal("")),
socialProfiles: z
.object({
twitter: TwitterProfileSchema,
github: GithubProfileSchema,
website: WebsiteSchema,
})
.strict()
.optional(),
showActivityOnPublicProfile: z.boolean().optional(),
})
.strict();
export type UserProfileDetails = z.infer<typeof UserProfileDetailsSchema>;
export const CustomThemeNameSchema = nameWithSeparators().max(16);
export type CustomThemeName = z.infer<typeof CustomThemeNameSchema>;
export const CustomThemeSchema = z
.object({
_id: IdSchema,
name: CustomThemeNameSchema,
colors: CustomThemeColorsSchema,
})
.strict();
export type CustomTheme = z.infer<typeof CustomThemeSchema>;
export const PremiumInfoSchema = z.object({
startTimestamp: z.number().int().nonnegative(),
expirationTimestamp: z
.number()
.int()
.nonnegative()
.or(z.literal(-1).describe("lifetime premium")),
});
export type PremiumInfo = z.infer<typeof PremiumInfoSchema>;
export const UserQuoteRatingsSchema = z.record(
LanguageSchema,
z.record(
StringNumberSchema.describe("quoteId as string"),
z.number().nonnegative(),
),
);
export type UserQuoteRatings = z.infer<typeof UserQuoteRatingsSchema>;
export const UserLbMemorySchema = z.record(
ModeSchema,
z.record(
Mode2Schema,
z.record(LanguageSchema, z.number().int().nonnegative()),
),
);
export type UserLbMemory = z.infer<typeof UserLbMemorySchema>;
export const RankAndCountSchema = z.object({
rank: z.number().int().nonnegative().optional(),
count: z.number().int().nonnegative(),
});
export type RankAndCount = z.infer<typeof RankAndCountSchema>;
export const AllTimeLbsSchema = z.object({
time: z.record(
Mode2Schema,
z.record(LanguageSchema, RankAndCountSchema.optional()),
),
});
export type AllTimeLbs = z.infer<typeof AllTimeLbsSchema>;
export const BadgeSchema = z
.object({
id: z.number().int().nonnegative(),
selected: z.boolean().optional(),
})
.strict();
export type Badge = z.infer<typeof BadgeSchema>;
export const UserInventorySchema = z
.object({
badges: z.array(BadgeSchema),
})
.strict();
export type UserInventory = z.infer<typeof UserInventorySchema>;
export const QuoteModSchema = z
.boolean()
.describe("Admin for all languages if true")
.or(LanguageSchema.describe("Admin for the given language"));
export type QuoteMod = z.infer<typeof QuoteModSchema>;
export const TestActivitySchema = z
.object({
testsByDays: z
.array(z.number().int().nonnegative().or(z.null()))
.describe(
"Number of tests by day. Last element of the array is on the date `lastDay`. `null` means no tests on that day.",
),
lastDay: z
.number()
.int()
.nonnegative()
.describe("Timestamp of the last day included in the test activity"),
})
.strict();
export type TestActivity = z.infer<typeof TestActivitySchema>;
export const CountByYearAndDaySchema = z.record(
StringNumberSchema.describe("year"),
z.array(
z
.number()
.int()
.nonnegative()
.nullable()
.describe(
"number of tests, position in the array is the day of the year",
),
),
);
export type CountByYearAndDay = z.infer<typeof CountByYearAndDaySchema>;
//Record<language, array with quoteIds as string
export const FavoriteQuotesSchema = z.record(
LanguageSchema,
z.array(StringNumberSchema),
);
export type FavoriteQuotes = z.infer<typeof FavoriteQuotesSchema>;
export const UserEmailSchema = z.string().email();
export const UserNameSchema = doesNotContainDisallowedWords(
"substring",
slug().min(1).max(16),
);
export const UserSchema = z.object({
name: UserNameSchema,
email: UserEmailSchema,
uid: z.string(), //defined by firebase, no validation should be applied
addedAt: z.number().int().nonnegative(),
personalBests: PersonalBestsSchema,
lastReultHashes: z.array(z.string()).optional(), //TODO: fix typo (it's in the db too)
completedTests: z.number().int().nonnegative().optional(),
startedTests: z.number().int().nonnegative().optional(),
timeTyping: z
.number()
.nonnegative()
.optional()
.describe("time typing in seconds"),
streak: UserStreakSchema.optional(),
xp: z.number().int().nonnegative().optional(),
discordId: z.string().optional(),
discordAvatar: z.string().optional(),
tags: z.array(UserTagSchema).optional(),
profileDetails: UserProfileDetailsSchema.optional(),
customThemes: z.array(CustomThemeSchema).optional(),
premium: PremiumInfoSchema.optional(),
isPremium: z.boolean().optional(),
quoteRatings: UserQuoteRatingsSchema.optional(),
favoriteQuotes: FavoriteQuotesSchema.optional(),
lbMemory: UserLbMemorySchema.optional(),
allTimeLbs: AllTimeLbsSchema,
inventory: UserInventorySchema.optional(),
banned: z.boolean().optional(),
lbOptOut: z.boolean().optional(),
verified: z.boolean().optional(),
needsToChangeName: z.boolean().optional(),
quoteMod: QuoteModSchema.optional(),
resultFilterPresets: z.array(ResultFiltersSchema).optional(),
testActivity: TestActivitySchema.optional(),
});
export type User = z.infer<typeof UserSchema>;
export type ResultFiltersGroup = keyof ResultFilters;
export type ResultFiltersGroupItem<T extends ResultFiltersGroup> =
keyof ResultFilters[T];
export const TypingStatsSchema = z.object({
completedTests: z.number().int().nonnegative().optional(),
startedTests: z.number().int().nonnegative().optional(),
timeTyping: z.number().int().nonnegative().optional(),
});
export type TypingStats = z.infer<typeof TypingStatsSchema>;
export const UserProfileSchema = UserSchema.pick({
uid: true,
name: true,
banned: true,
addedAt: true,
discordId: true,
discordAvatar: true,
xp: true,
lbOptOut: true,
isPremium: true,
inventory: true,
allTimeLbs: true,
testActivity: true,
})
.extend({
typingStats: TypingStatsSchema,
personalBests: PersonalBestsSchema.pick({ time: true, words: true }),
streak: z.number().int().nonnegative(),
maxStreak: z.number().int().nonnegative(),
details: UserProfileDetailsSchema,
})
.partial({
//omitted for banned users
inventory: true,
details: true,
allTimeLbs: true,
uid: true,
});
export type UserProfile = z.infer<typeof UserProfileSchema>;
export const RewardTypeSchema = z.enum(["xp", "badge"]);
export type RewardType = z.infer<typeof RewardTypeSchema>;
export const XpRewardSchema = z.object({
type: z.literal(RewardTypeSchema.enum.xp),
item: z.number().int(),
});
export type XpReward = z.infer<typeof XpRewardSchema>;
export const BadgeRewardSchema = z.object({
type: z.literal(RewardTypeSchema.enum.badge),
item: BadgeSchema,
});
export type BadgeReward = z.infer<typeof BadgeRewardSchema>;
export const AllRewardsSchema = XpRewardSchema.or(BadgeRewardSchema);
export type AllRewards = z.infer<typeof AllRewardsSchema>;
export const MonkeyMailSchema = z.object({
id: IdSchema,
subject: z.string(),
body: z.string(),
timestamp: z.number().int().nonnegative(),
read: z.boolean(),
rewards: z.array(AllRewardsSchema),
});
export type MonkeyMail = z.infer<typeof MonkeyMailSchema>;
export const ReportUserReasonSchema = z.enum([
"Inappropriate name",
"Inappropriate bio",
"Inappropriate social links",
"Suspected cheating",
]);
export type ReportUserReason = z.infer<typeof ReportUserReasonSchema>;
export const PasswordSchema = z
.string()
.min(8, { message: "must be at least 8 characters" })
.max(64, { message: "must be at most 64 characters" })
.regex(/[A-Z]/, { message: "must contain at least one capital letter" })
.regex(/[\d]/, { message: "must contain at least one number" })
.regex(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, {
message: "must contain at least one special character",
});
export type Password = z.infer<typeof PasswordSchema>;
export const FriendSchema = UserSchema.pick({
uid: true,
name: true,
discordId: true,
discordAvatar: true,
startedTests: true,
completedTests: true,
timeTyping: true,
xp: true,
banned: true,
lbOptOut: true,
})
.extend({
connectionId: IdSchema.optional(),
top15: PersonalBestSchema.optional(),
top60: PersonalBestSchema.optional(),
badgeId: z.number().int().optional(),
isPremium: z.boolean().optional(),
streak: UserStreakSchema.pick({ length: true, maxLength: true }),
})
.merge(ConnectionSchema.pick({ lastModified: true }).partial());
export type Friend = z.infer<typeof FriendSchema>;

View File

@@ -0,0 +1,66 @@
import { z, ZodErrorMap, ZodString } from "zod";
export const StringNumberSchema = z
.string()
.regex(
/^\d+$/,
'Needs to be a number or a number represented as a string e.g. "10".',
)
.or(z.number().transform(String));
export type StringNumber = z.infer<typeof StringNumberSchema>;
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);
export const slug = (): ZodString =>
z
.string()
.regex(
/^[0-9a-zA-Z_.-]+$/,
"Only letters, numbers, underscores, dots and hyphens allowed",
)
.regex(/^[^.].*$/, "Cannot start with a dot");
export const nameWithSeparators = (): ZodString =>
z
.string()
.regex(
/^[0-9a-zA-Z_-]+$/,
"Only letters, numbers, underscores and hyphens allowed",
)
.regex(
/^[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+)*$/,
"Separators cannot be at the start or end, or appear multiple times in a row",
);
export const IdSchema = token();
export type Id = z.infer<typeof IdSchema>;
export const TagSchema = token().max(50);
export type Tag = z.infer<typeof TagSchema>;
export const NullableStringSchema = z
.string()
.nullable()
.optional()
.transform((value) => value ?? undefined);
export type NullableString = z.infer<typeof NullableStringSchema>;
export const PercentageSchema = z.number().nonnegative().max(100);
export type Percentage = z.infer<typeof PercentageSchema>;
export const WpmSchema = z.number().nonnegative().max(420);
export type Wpm = z.infer<typeof WpmSchema>;
export const CustomTextModeSchema = z.enum(["repeat", "random", "shuffle"]);
export type CustomTextMode = z.infer<typeof CustomTextModeSchema>;
export const CustomTextLimitModeSchema = z.enum(["word", "time", "section"]);
export type CustomTextLimitMode = z.infer<typeof CustomTextLimitModeSchema>;
export function customEnumErrorHandler(message: string): ZodErrorMap {
return (issue, _ctx) => ({
message:
issue.code === "invalid_enum_value"
? `Invalid enum value. ${message}`
: (issue.message ?? "Required"),
});
}

View File

@@ -0,0 +1,42 @@
const obj = {
a: ["\u0430", "\u00e0", "\u00e1", "\u1ea1", "\u0105"],
c: ["\u0441", "\u0188", "\u010b"],
d: ["\u0501", "\u0257"],
e: ["\u0435", "\u1eb9", "\u0117", "\u0117", "\u00e9", "\u00e8"],
g: ["\u0121"],
h: ["\u04bb"],
i: ["\u0456", "\u00ed", "\u00ec", "\u00ef"],
j: ["\u0458", "\u029d"],
k: ["\u03ba"],
l: ["\u04cf", "\u1e37"],
n: ["\u0578"],
o: [
"\u043e",
"\u03bf",
"\u0585",
"\u022f",
"\u1ecd",
"\u1ecf",
"\u01a1",
"\u00f6",
"\u00f3",
"\u00f2",
],
p: ["\u0440"],
q: ["\u0566"],
s: ["\u0282"],
u: ["\u03c5", "\u057d", "\u00fc", "\u00fa", "\u00f9"],
v: ["\u03bd", "\u0475"],
x: ["\u0445", "\u04b3"],
y: ["\u0443", "\u00fd"],
z: ["\u0290", "\u017c"],
};
export function replaceHomoglyphs(str: string): string {
for (const key in obj) {
obj[key as keyof typeof obj].forEach((value) => {
str = str.replace(value, key);
});
}
return str;
}

View File

@@ -0,0 +1,442 @@
import { replaceHomoglyphs } from "./homoglyphs";
import { ZodEffects, ZodString } from "zod";
// Sorry for the bad words
const disallowedWords = [
"miodec",
"bitly",
"niqqa",
"niqqer",
"ni99a",
"ni99er",
"niggas",
"niga",
"niger",
"retard",
"ahole",
"anus",
"ash0le",
"asholes",
"assh0le",
"assh0lez",
"asshole",
"assholes",
"assholz",
"asswipe",
"azzhole",
"bassterds",
"bastard",
"bastards",
"bastardz",
"basterds",
"basterdz",
"biatch",
"bitch",
"bitches",
"blow job",
"boffing",
"butthole",
"buttwipe",
"c0ck",
"c0cks",
"c0k",
"carpet muncher",
"cawk",
"cawks",
"clit",
"cnts",
"cntz",
"cock",
"cockhead",
"cock-head",
"cocks",
"cocksucker",
"cock-sucker",
"crap",
"cum",
"cunt",
"cunts",
"cuntz",
"dick",
"dild0",
"dild0s",
"dildo",
"dildos",
"dilld0",
"dilld0s",
"dominatricks",
"dominatrics",
"dominatrix",
"dyke",
"enema",
"f u c k",
"f u c k e r",
"fag",
"fag1t",
"faget",
"fagg1t",
"faggit",
"faggot",
"fagit",
"fags",
"fagz",
"faigs",
"flipping the bird",
"fudge packer",
"fukah",
"fuken",
"fuker",
"fukin",
"fukk",
"fukkah",
"fukken",
"fukker",
"fukkin",
"g00k",
"gayboy",
"gaygirl",
"gayz",
"god-damned",
"h00r",
"h0ar",
"h0re",
"jackoff",
"japs",
"jerk-off",
"jisim",
"jiss",
"jizm",
"jizz",
"knob",
"knobs",
"knobz",
"kunt",
"kunts",
"kuntz",
"lezzian",
"lipshits",
"lipshitz",
"masochist",
"masokist",
"massterbait",
"masstrbait",
"masstrbate",
"masterbaiter",
"masterbate",
"masterbates",
"n1gr",
"nastt",
"nigger;",
"nigur;",
"niiger;",
"niigr;",
"orafis",
"orgasim;",
"orgasm",
"orgasum",
"oriface",
"orifice",
"orifiss",
"peeenus",
"peeenusss",
"peenus",
"peinus",
"pen1s",
"penas",
"penis",
"penis-breath",
"penus",
"penuus",
"phuc",
"phuck",
"phuk",
"phuker",
"phukker",
"poonani",
"pr1c",
"pr1ck",
"pr1k",
"puss",
"pussee",
"pussy",
"puuke",
"puuker",
"qweers",
"qweerz",
"qweir",
"recktum",
"rectum",
"retard",
"sadist",
"scank",
"schlong",
"semen",
"sex",
"sexy",
"sh!t",
"sh1t",
"sh1ter",
"sh1ts",
"sh1tter",
"sh1tz",
"shit",
"shits",
"shitter",
"shitty",
"shity",
"shitz",
"shyt",
"shyte",
"shytty",
"shyty",
"skanck",
"skank",
"skankee",
"skankey",
"skanks",
"skanky",
"slut",
"sluts",
"slutty",
"slutz",
"son-of-a-bitch",
"turd",
"va1jina",
"vag1na",
"vagiina",
"vagina",
"vaj1na",
"vajina",
"vullva",
"vulva",
"wh00r",
"wh0re",
"whore",
"xrated",
"b!+ch",
"bitch",
"blowjob",
"clit",
"arschloch",
"shit",
"asshole",
"b!tch",
"b17ch",
"b1tch",
"bastard",
"bi+ch",
"boiolas",
"buceta",
"c0ck",
"cawk",
"chink",
"cipa",
"clits",
"cum",
"cunt",
"dildo",
"dirsa",
"ejakulate",
"fatass",
"fcuk",
"fux0r",
"hoer",
"hore",
"jism",
"kawk",
"l3itch",
"l3i+ch",
"masturbate",
"masterbat",
"masterbat3",
"motherfucker",
"s.o.b.",
"mofo",
"nazi",
"nigga",
"nigger",
"nutsack",
"phuck",
"pimpis",
"pusse",
"pussy",
"scrotum",
"sh!t",
"shemale",
"shi+",
"sh!+",
"slut",
"smut",
"teets",
"tits",
"boobs",
"b00bs",
"teez",
"testical",
"testicle",
"titt",
"w00se",
"jackoff",
"wank",
"whoar",
"whore",
"dyke",
"fuck",
"shit",
"amcik",
"andskota",
"arse",
"assrammer",
"ayir",
"bi7ch",
"bitch",
"bollock",
"breasts",
"butt-pirate",
"cabron",
"cazzo",
"chraa",
"chuj",
"cunt",
"daygo",
"dego",
"dick",
"dike",
"dupa",
"dziwka",
"ejackulate",
"ekrem",
"enculer",
"fag",
"fanculo",
"fanny",
"feces",
"felcher",
"ficken",
"flikker",
"foreskin",
"fotze",
"futkretzn",
"gook",
"guiena",
"h4x0r",
"helvete",
"hoer",
"honkey",
"huevon",
"injun",
"jizz",
"kanker",
"klootzak",
"kraut",
"knulle",
"kuksuger",
"kurac",
"kurwa",
"kyrpa",
"lesbo",
"mamhoon",
"masturbat",
"merd",
"mibun",
"monkleigh",
"mouliewop",
"muie",
"mulkku",
"muschi",
"nazis",
"nepesaurio",
"nigger",
"orospu",
"paska",
"perse",
"picka",
"pierdol",
"pillu",
"pimmel",
"piss",
"pizda",
"poontsee",
"porn",
"p0rn",
"pr0n",
"preteen",
"pula",
"pule",
"puta",
"puto",
"qahbeh",
"queef",
"rautenberg",
"schaffer",
"scheiss",
"schlampe",
"schmuck",
"sh!t",
"sharmuta",
"sharmute",
"skurwysyn",
"sphencter",
"spierdalaj",
"splooge",
"suka",
"b00b",
"testicle",
"titt",
"twat",
"vittu",
"wank",
"wetback",
"wichser",
"zabourah",
];
function sanitizeString(str: string | undefined): string | undefined {
if (str === undefined || str === "") {
return str;
}
return str
.replace(/[\u0300-\u036F]/g, "")
.trim()
.replace(/\n{3,}/g, "\n\n")
.replace(/\s{3,}/g, " ");
}
function containsDisallowedWords(
text: string,
mode: "word" | "substring",
): boolean {
const normalizedText = text
.toLowerCase()
.split(/[.,"/#!?$%^&*;:{}=\-_`~()\s\n]+/g)
.map((str) => {
return replaceHomoglyphs(sanitizeString(str) ?? "");
});
const hasDisallowedWords = disallowedWords.some((disallowedWord) => {
return normalizedText.some((word) => {
return mode === "word"
? word.startsWith(disallowedWord)
: word.includes(disallowedWord);
});
});
return hasDisallowedWords;
}
export function doesNotContainDisallowedWords(
mode: "word" | "substring",
schema: ZodString,
): ZodEffects<ZodString> {
return schema.refine(
(val) => {
return !containsDisallowedWords(val, mode);
},
(val) => ({
message: `Disallowed word detected. Please remove it. If you believe this is a mistake, please contact us (${val}).`,
}),
);
}
export const __testing = { containsDisallowedWords };

View File

@@ -0,0 +1,10 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "ES6"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
import { extendConfig } from "@monkeytype/tsup-config";
export default extendConfig();

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
coverage: {
include: ["**/*.ts"],
},
},
});

View File

@@ -0,0 +1,7 @@
{
"ignorePatterns": ["node_modules", "dist", ".turbo"],
"extends": [
"../oxlint-config/index.jsonc"
// "@monkeytype/oxlint-config"
]
}

View File

@@ -0,0 +1,27 @@
{
"name": "@monkeytype/tsup-config",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"dev": "tsup-node --watch",
"build": "tsup-node",
"ts-check": "tsc --noEmit",
"lint": "oxlint . --type-aware --type-check",
"lint-fast": "oxlint ."
},
"devDependencies": {
"@monkeytype/typescript-config": "workspace:*",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"typescript": "6.0.2"
},
"peerDependencies": {
"tsup": "8.4.0"
}
}

View File

@@ -0,0 +1,21 @@
import { defineConfig, Options } from "tsup";
export function extendConfig(
customizer?: (options: Options) => Options,
): (options: Options) => unknown {
return (options) => {
const overrideOptions = customizer?.(options);
const config: Options = {
entry: ["src/**/*.ts"],
splitting: false,
sourcemap: true,
clean: !(options.watch === true || options.watch === "true"),
format: ["cjs", "esm"],
dts: false,
minify: true,
...overrideOptions,
};
return defineConfig(config);
};
}

View File

@@ -0,0 +1,13 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"target": "ES2015",
"lib": ["es2016"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "tsup";
export default defineConfig((_options) => ({
entry: ["src/index.ts"],
splitting: false,
sourcemap: false,
clear: !_options?.watch,
format: ["cjs", "esm"],
dts: false,
}));

View File

@@ -0,0 +1,34 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"types": ["node"],
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"moduleResolution": "NodeNext",
"module": "NodeNext",
"isolatedModules": true,
"moduleDetection": "force",
"strict": true,
"newLine": "lf",
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"exactOptionalPropertyTypes": false,
"noImplicitOverride": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false
}
}

View File

@@ -0,0 +1,4 @@
{
"name": "@monkeytype/typescript-config",
"private": true
}

View File

@@ -0,0 +1,7 @@
{
"ignorePatterns": ["node_modules", "dist", ".turbo"],
"extends": [
"../oxlint-config/index.jsonc"
// "@monkeytype/oxlint-config"
]
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import * as Arrays from "../src/arrays";
describe("arrays", () => {
it("intersect", () => {
const testCases = [
{
a: [1],
b: [2],
removeDuplicates: false,
expected: [],
},
{
a: [1],
b: [1],
removeDuplicates: false,
expected: [1],
},
{
a: [1, 1],
b: [1],
removeDuplicates: true,
expected: [1],
},
{
a: [1, 1],
b: [1],
removeDuplicates: false,
expected: [1, 1],
},
{
a: [1],
b: [1, 2, 3],
removeDuplicates: false,
expected: [1],
},
{
a: [1, 1],
b: [1, 2, 3],
removeDuplicates: true,
expected: [1],
},
];
testCases.forEach(({ a, b, removeDuplicates, expected }) => {
expect(Arrays.intersect(a, b, removeDuplicates)).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,223 @@
import { describe, it, expect, afterAll, vi } from "vitest";
import * as DateAndTime from "../src/date-and-time";
describe("date-and-time", () => {
afterAll(() => {
vi.useRealTimers();
});
it("getCurrentDayTimestamp", () => {
vi.useFakeTimers();
vi.setSystemTime(1652743381);
const currentDay = DateAndTime.getCurrentDayTimestamp();
expect(currentDay).toBe(1641600000);
});
it("getStartOfWeekTimestamp", () => {
const testCases = [
{
input: 1662400184017, // Mon Sep 05 2022 17:49:44 GMT+0000
expected: 1662336000000, // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: 1559771456000, // Wed Jun 05 2019 21:50:56 GMT+0000
expected: 1559520000000, // Mon Jun 03 2019 00:00:00 GMT+0000
},
{
input: 1465163456000, // Sun Jun 05 2016 21:50:56 GMT+0000
expected: 1464566400000, // Mon May 30 2016 00:00:00 GMT+0000
},
{
input: 1491515456000, // Thu Apr 06 2017 21:50:56 GMT+0000
expected: 1491177600000, // Mon Apr 03 2017 00:00:00 GMT+0000
},
{
input: 1462507200000, // Fri May 06 2016 04:00:00 GMT+0000
expected: 1462147200000, // Mon May 02 2016 00:00:00 GMT+0000
},
{
input: 1231218000000, // Tue Jan 06 2009 05:00:00 GMT+0000,
expected: 1231113600000, // Mon Jan 05 2009 00:00:00 GMT+0000
},
{
input: 1709420681000, // Sat Mar 02 2024 23:04:41 GMT+0000
expected: 1708905600000, // Mon Feb 26 2024 00:00:00 GMT+0000
},
];
testCases.forEach(({ input, expected }) => {
expect(DateAndTime.getStartOfWeekTimestamp(input)).toEqual(expected);
});
});
it("getCurrentWeekTimestamp", () => {
Date.now = vi.fn(() => 825289481000); // Sun Feb 25 1996 23:04:41 GMT+0000
const currentWeek = DateAndTime.getCurrentWeekTimestamp();
expect(currentWeek).toBe(824688000000); // Mon Feb 19 1996 00:00:00 GMT+0000
});
it("getStartOfDayTimestamp", () => {
const testCases = [
{
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: 0,
expected: new Date("2023/06/16 00:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: 1,
expected: new Date("2023/06/16 01:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: -1,
expected: new Date("2023/06/15 23:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: -4,
expected: new Date("2023/06/15 20:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: 4,
expected: new Date("2023/06/16 04:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: new Date("2023/06/17 03:00 UTC").getTime(),
offset: 4,
expected: new Date("2023/06/16 04:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: 3,
expected: new Date("2023/06/16 03:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
{
input: new Date("2023/06/17 01:00 UTC").getTime(),
offset: 3,
expected: new Date("2023/06/16 03:00 UTC").getTime(), // Mon Sep 05 2022 00:00:00 GMT+0000
},
];
testCases.forEach(({ input, offset, expected }) => {
expect(
DateAndTime.getStartOfDayTimestamp(input, offset * 3600000),
).toEqual(expected);
});
});
it("isToday", () => {
const testCases = [
{
now: new Date("2023/06/16 15:00 UTC").getTime(),
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: 0,
expected: true,
},
{
now: new Date("2023/06/16 15:00 UTC").getTime(),
input: new Date("2023/06/17 1:00 UTC").getTime(),
offset: 0,
expected: false,
},
{
now: new Date("2023/06/16 15:00 UTC").getTime(),
input: new Date("2023/06/16 01:00 UTC").getTime(),
offset: 1,
expected: true,
},
{
now: new Date("2023/06/16 15:00 UTC").getTime(),
input: new Date("2023/06/17 01:00 UTC").getTime(),
offset: 2,
expected: true,
},
{
now: new Date("2023/06/16 15:00 UTC").getTime(),
input: new Date("2023/06/16 01:00 UTC").getTime(),
offset: 2,
expected: false,
},
{
now: new Date("2023/06/17 01:00 UTC").getTime(),
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: 2,
expected: true,
},
{
now: new Date("2023/06/17 01:00 UTC").getTime(),
input: new Date("2023/06/17 02:00 UTC").getTime(),
offset: 2,
expected: false,
},
];
testCases.forEach(({ now, input, offset, expected }) => {
Date.now = vi.fn(() => now);
expect(DateAndTime.isToday(input, offset)).toEqual(expected);
});
});
it("isYesterday", () => {
const testCases = [
{
now: new Date("2023/06/15 15:00 UTC").getTime(),
input: new Date("2023/06/14 15:00 UTC").getTime(),
offset: 0,
expected: true,
},
{
now: new Date("2023/06/15 15:00 UTC").getTime(),
input: new Date("2023/06/15 15:00 UTC").getTime(),
offset: 0,
expected: false,
},
{
now: new Date("2023/06/15 15:00 UTC").getTime(),
input: new Date("2023/06/16 15:00 UTC").getTime(),
offset: 0,
expected: false,
},
{
now: new Date("2023/06/15 15:00 UTC").getTime(),
input: new Date("2023/06/13 15:00 UTC").getTime(),
offset: 0,
expected: false,
},
{
now: new Date("2023/06/16 02:00 UTC").getTime(),
input: new Date("2023/06/15 02:00 UTC").getTime(),
offset: 4,
expected: true,
},
{
now: new Date("2023/06/16 02:00 UTC").getTime(),
input: new Date("2023/06/16 01:00 UTC").getTime(),
offset: 4,
expected: false,
},
{
now: new Date("2023/06/16 02:00 UTC").getTime(),
input: new Date("2023/06/15 22:00 UTC").getTime(),
offset: 4,
expected: false,
},
{
now: new Date("2023/06/16 04:00 UTC").getTime(),
input: new Date("2023/06/16 03:00 UTC").getTime(),
offset: 4,
expected: true,
},
{
now: new Date("2023/06/16 14:00 UTC").getTime(),
input: new Date("2023/06/16 12:00 UTC").getTime(),
offset: -11,
expected: true,
},
];
testCases.forEach(({ now, input, offset, expected }) => {
Date.now = vi.fn(() => now);
expect(DateAndTime.isYesterday(input, offset)).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,147 @@
import { describe, it, expect } from "vitest";
import { parseWithSchema } from "../src/json";
import { z } from "zod";
describe("json", () => {
describe("parseWithSchema", () => {
const schema = z.object({
test: z.boolean().optional(),
name: z.string(),
nested: z.object({ foo: z.string() }).strict().optional(),
});
it("should throw with invalid json", () => {
expect(() => parseWithSchema("blah", schema)).toThrow(
new Error(
`Invalid JSON: Unexpected token 'b', "blah" is not valid JSON`,
),
);
});
it("should parse", () => {
const json = `{
"test":true,
"name":"bob",
"unknown":"unknown",
"nested":{
"foo":"bar"
}
}`;
expect(parseWithSchema(json, schema)).toStrictEqual({
test: true,
name: "bob",
nested: { foo: "bar" },
});
});
it("should throw with invalid schema", () => {
const json = `{
"test":"yes",
"nested":{
"foo":1
}
}`;
expect(() => parseWithSchema(json, schema)).toThrow(
new Error(
`JSON does not match schema: "test" expected boolean, received string, "name" required, "nested.foo" expected string, received number`,
),
);
});
it("should migrate if valid json", () => {
const json = `{
"name": 1
}`;
const result = parseWithSchema(json, schema, {
migrate: () => {
return {
name: "migrated",
test: false,
};
},
});
expect(result).toStrictEqual({
name: "migrated",
test: false,
});
});
it("should revert to fallback if invalid json", () => {
const json = `blah`;
const result = parseWithSchema(json, schema, {
fallback: {
name: "migrated",
test: false,
},
});
expect(result).toStrictEqual({
name: "migrated",
test: false,
});
});
it("should throw if migration fails", () => {
const json = `{
"name": 1
}`;
expect(() => {
parseWithSchema(json, schema, {
//@ts-expect-error need to test migration failure
migrate: () => {
return {
name: null,
test: "Hi",
};
},
});
}).toThrow(
new Error(
`Migrated value does not match schema: "test" expected boolean, received string, "name" expected string, received null`,
),
);
});
it("should revert to fallback if migration fails", () => {
const json = `{
"name": 1
}`;
const result = parseWithSchema(json, schema, {
fallback: {
name: "fallback",
test: false,
},
//@ts-expect-error need to test migration failure
migrate: () => {
return {
name: null,
test: "Hi",
};
},
});
expect(result).toStrictEqual({
name: "fallback",
test: false,
});
});
it("migrate function should receive value", () => {
const json = `{
"test":"test"
}`;
const result = parseWithSchema(json, schema, {
migrate: (value) => {
expect(value).toEqual({ test: "test" });
return {
name: "valid",
};
},
});
expect(result).toStrictEqual({
name: "valid",
});
});
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from "vitest";
import * as Numbers from "../src/numbers";
describe("numbers", () => {
describe("roundTo1", () => {
it("should correctly round", () => {
const tests = [
{
in: 0.0,
out: 0,
},
{
in: 0.01,
out: 0.0,
},
{
in: 0.09,
out: 0.1,
},
{
in: 0.123,
out: 0.1,
},
{
in: 0.456,
out: 0.5,
},
{
in: 0.789,
out: 0.8,
},
];
tests.forEach((test) => {
expect(Numbers.roundTo1(test.in)).toBe(test.out);
});
});
it("mapRange", () => {
const testCases = [
{
input: {
value: 123,
inMin: 0,
inMax: 200,
outMin: 0,
outMax: 1000,
clamp: false,
},
expected: 615,
},
{
input: {
value: 123,
inMin: 0,
inMax: 200,
outMin: 1000,
outMax: 0,
clamp: false,
},
expected: 385,
},
{
input: {
value: 10001,
inMin: 0,
inMax: 10000,
outMin: 0,
outMax: 1000,
clamp: false,
},
expected: 1000.1,
},
{
input: {
value: 10001,
inMin: 0,
inMax: 10000,
outMin: 0,
outMax: 1000,
clamp: true,
},
expected: 1000,
},
];
testCases.forEach(({ input, expected }) => {
expect(
Numbers.mapRange(
input.value,
input.inMin,
input.inMax,
input.outMin,
input.outMax,
input.clamp,
),
).toEqual(expected);
});
});
});
describe("isSafeNumber", () => {
describe("should correctly identify safe numbers", () => {
const testCases = [
//safe
{ input: 0, expected: true },
{ input: 1, expected: true },
{ input: -1, expected: true },
{ input: 0.5, expected: true },
{ input: -0.5, expected: true },
//not safe
{ input: NaN, expected: false },
{ input: Infinity, expected: false },
{ input: -Infinity, expected: false },
{ input: "string", expected: false },
{ input: null, expected: false },
{ input: undefined, expected: false },
{ input: true, expected: false },
{ input: false, expected: false },
];
it.for(testCases)(
"should return $expected for $input",
({ input, expected }) => {
expect(Numbers.isSafeNumber(input)).toEqual(expected);
},
);
});
});
describe("safeNumber", () => {
describe("should correctly identify safe numbers", () => {
const testCases = [
//safe
{ input: 0, expected: 0 },
{ input: 1, expected: 1 },
{ input: -1, expected: -1 },
{ input: 0.5, expected: 0.5 },
{ input: -0.5, expected: -0.5 },
//not safe
{ input: NaN, expected: undefined },
{ input: Infinity, expected: undefined },
{ input: -Infinity, expected: undefined },
{ input: "string", expected: undefined },
{ input: null, expected: undefined },
{ input: undefined, expected: undefined },
{ input: true, expected: undefined },
{ input: false, expected: undefined },
];
it.for(testCases)(
"should return $expected for $input",
({ input, expected }) => {
expect(Numbers.safeNumber(input as number)).toEqual(expected);
},
);
});
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { not } from "../src/predicates";
describe("predicates", () => {
describe("not", () => {
it("should not a simple boolean function", () => {
const isTrue = (): boolean => true;
const isFalse = not(isTrue);
expect(isFalse()).toBe(false);
});
it("should not a numeric predicate", () => {
const isPositive = (num: number): boolean => num > 0;
const isNotPositive = not(isPositive);
expect(isNotPositive(-5)).toBe(true);
expect(isNotPositive(10)).toBe(false);
});
it("should not a predicate taking multiple arguments", () => {
const containsLetter = (
str1: string,
str2: string,
letter: string,
): boolean => str1.includes(letter) || str2.includes(letter);
const doesNotContainLetter = not(containsLetter);
expect(doesNotContainLetter("hello", "world", "x")).toBe(true);
expect(doesNotContainLetter("apple", "banana", "a")).toBe(false);
});
it("should preserve type safety", () => {
const isEven = (num: number): boolean => num % 2 === 0;
const isOdd = not(isEven);
expect(isOdd(3)).toBe(true);
expect(isOdd(4)).toBe(false);
});
});
});

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { kebabToCamelCase } from "../src/strings";
describe("strings", () => {
describe("kebabToCamelCase", () => {
it("should convert kebab case to camel case", () => {
expect(kebabToCamelCase("hello-world")).toEqual("helloWorld");
expect(kebabToCamelCase("helloWorld")).toEqual("helloWorld");
expect(
kebabToCamelCase("one-two-three-four-five-six-seven-eight-nine-ten"),
).toEqual("oneTwoThreeFourFiveSixSevenEightNineTen");
});
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { tryCatch, tryCatchSync } from "../src/trycatch";
describe("tryCatch", () => {
it("should return data on successful promise resolution", async () => {
const result = await tryCatch(Promise.resolve("success"));
expect(result.data).toBe("success");
expect(result.error).toBeNull();
});
it("should return error on promise rejection", async () => {
const testError = new Error("test error");
const result = await tryCatch(Promise.reject(testError));
expect(result.data).toBeNull();
expect(result.error).toBe(testError);
});
it("should handle custom error types", async () => {
class CustomError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
}
const customError = new CustomError("custom error", "E123");
const result = await tryCatch<string, CustomError>(
Promise.reject(customError),
);
expect(result.data).toBeNull();
expect(result.error).toBe(customError);
expect(result.error?.code).toBe("E123");
});
it("should handle exceptions in async functions", async () => {
const testError = new Error("test error");
const fn = async (): Promise<void> => {
throw testError;
};
const result = await tryCatch(fn());
expect(result.data).toBeNull();
expect(result.error).toBe(testError);
});
});
describe("tryCatchSync", () => {
it("should return data on successful function execution", () => {
const result = tryCatchSync(() => "success");
expect(result.data).toBe("success");
expect(result.error).toBeNull();
});
it("should return error when function throws", () => {
const testError = new Error("test error");
const result = tryCatchSync(() => {
throw testError;
});
expect(result.data).toBeNull();
expect(result.error).toBe(testError);
});
it("should handle complex data structures", () => {
const complexData = {
foo: "bar",
numbers: [1, 2, 3],
nested: { value: true },
};
const result = tryCatchSync(() => complexData);
expect(result.data).toEqual(complexData);
expect(result.error).toBeNull();
});
it("should handle custom error types", () => {
class CustomError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
}
const customError = new CustomError("custom error", "E123");
const result = tryCatchSync<string, CustomError>(() => {
throw customError;
});
expect(result.data).toBeNull();
expect(result.error).toBe(customError);
expect(result.error?.code).toBe("E123");
});
});

Some files were not shown because too many files have changed in this diff Show More