This commit is contained in:
3
packages/.vscode/settings.json
vendored
Normal file
3
packages/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"oxc.fmt.configPath": "../.oxfmtrc-editor.json"
|
||||
}
|
||||
7
packages/contracts/.oxlintrc.json
Normal file
7
packages/contracts/.oxlintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "dist", ".turbo"],
|
||||
"extends": [
|
||||
"../oxlint-config/index.jsonc"
|
||||
// "@monkeytype/oxlint-config"
|
||||
]
|
||||
}
|
||||
7
packages/contracts/__test__/tsconfig.json
Normal file
7
packages/contracts/__test__/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
|
||||
}
|
||||
45
packages/contracts/__test__/validation/validation.spec.ts
Normal file
45
packages/contracts/__test__/validation/validation.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
packages/contracts/package.json
Normal file
42
packages/contracts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
142
packages/contracts/src/admin.ts
Normal file
142
packages/contracts/src/admin.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
110
packages/contracts/src/ape-keys.ts
Normal file
110
packages/contracts/src/ape-keys.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
69
packages/contracts/src/configs.ts
Normal file
69
packages/contracts/src/configs.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
102
packages/contracts/src/configuration.ts
Normal file
102
packages/contracts/src/configuration.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
136
packages/contracts/src/connections.ts
Normal file
136
packages/contracts/src/connections.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
79
packages/contracts/src/dev.ts
Normal file
79
packages/contracts/src/dev.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
41
packages/contracts/src/index.ts
Normal file
41
packages/contracts/src/index.ts
Normal 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";
|
||||
260
packages/contracts/src/leaderboards.ts
Normal file
260
packages/contracts/src/leaderboards.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
98
packages/contracts/src/presets.ts
Normal file
98
packages/contracts/src/presets.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
34
packages/contracts/src/psas.ts
Normal file
34
packages/contracts/src/psas.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
70
packages/contracts/src/public.ts
Normal file
70
packages/contracts/src/public.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
217
packages/contracts/src/quotes.ts
Normal file
217
packages/contracts/src/quotes.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
411
packages/contracts/src/rate-limit/index.ts
Normal file
411
packages/contracts/src/rate-limit/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
27
packages/contracts/src/require-configuration/index.ts
Normal file
27
packages/contracts/src/require-configuration/index.ts
Normal 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;
|
||||
};
|
||||
204
packages/contracts/src/results.ts
Normal file
204
packages/contracts/src/results.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
965
packages/contracts/src/users.ts
Normal file
965
packages/contracts/src/users.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
148
packages/contracts/src/util/api.ts
Normal file
148
packages/contracts/src/util/api.ts
Normal 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;
|
||||
};
|
||||
60
packages/contracts/src/webhooks.ts
Normal file
60
packages/contracts/src/webhooks.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
10
packages/contracts/tsconfig.json
Normal file
10
packages/contracts/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"target": "ES6"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
3
packages/contracts/tsup.config.js
Normal file
3
packages/contracts/tsup.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { extendConfig } from "@monkeytype/tsup-config";
|
||||
|
||||
export default extendConfig();
|
||||
10
packages/contracts/vitest.config.ts
Normal file
10
packages/contracts/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
coverage: {
|
||||
include: ["**/*.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
7
packages/funbox/.oxlintrc.json
Normal file
7
packages/funbox/.oxlintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "dist", ".turbo"],
|
||||
"extends": [
|
||||
"../oxlint-config/index.jsonc"
|
||||
// "@monkeytype/oxlint-config"
|
||||
]
|
||||
}
|
||||
7
packages/funbox/__test__/tsconfig.json
Normal file
7
packages/funbox/__test__/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
|
||||
}
|
||||
160
packages/funbox/__test__/validation.spec.ts
Normal file
160
packages/funbox/__test__/validation.spec.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
packages/funbox/package.json
Normal file
34
packages/funbox/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
packages/funbox/src/index.ts
Normal file
15
packages/funbox/src/index.ts
Normal 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
528
packages/funbox/src/list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
39
packages/funbox/src/types.ts
Normal file
39
packages/funbox/src/types.ts
Normal 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[];
|
||||
};
|
||||
250
packages/funbox/src/validation.ts
Normal file
250
packages/funbox/src/validation.ts
Normal 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
|
||||
);
|
||||
}
|
||||
13
packages/funbox/tsconfig.json
Normal file
13
packages/funbox/tsconfig.json
Normal 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"]
|
||||
}
|
||||
3
packages/funbox/tsup.config.js
Normal file
3
packages/funbox/tsup.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { extendConfig } from "@monkeytype/tsup-config";
|
||||
|
||||
export default extendConfig(() => ({ entry: ["src/index.ts"] }));
|
||||
10
packages/funbox/vitest.config.ts
Normal file
10
packages/funbox/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
coverage: {
|
||||
include: ["**/*.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
19
packages/oxlint-config/index.jsonc
Normal file
19
packages/oxlint-config/index.jsonc
Normal 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",
|
||||
],
|
||||
}
|
||||
95
packages/oxlint-config/overrides.jsonc
Normal file
95
packages/oxlint-config/overrides.jsonc
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
11
packages/oxlint-config/package.json
Normal file
11
packages/oxlint-config/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@monkeytype/oxlint-config",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oxlint/plugins": "1.43.0"
|
||||
}
|
||||
}
|
||||
33
packages/oxlint-config/plugin.jsonc
Normal file
33
packages/oxlint-config/plugin.jsonc
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
295
packages/oxlint-config/plugins/monkeytype-rules.js
Normal file
295
packages/oxlint-config/plugins/monkeytype-rules.js
Normal 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;
|
||||
9
packages/oxlint-config/rules/consider.jsonc
Normal file
9
packages/oxlint-config/rules/consider.jsonc
Normal 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",
|
||||
},
|
||||
}
|
||||
16
packages/oxlint-config/rules/disabled.jsonc
Normal file
16
packages/oxlint-config/rules/disabled.jsonc
Normal 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",
|
||||
},
|
||||
}
|
||||
88
packages/oxlint-config/rules/enabled.jsonc
Normal file
88
packages/oxlint-config/rules/enabled.jsonc
Normal 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",
|
||||
},
|
||||
}
|
||||
44
packages/oxlint-config/rules/jsx.jsonc
Normal file
44
packages/oxlint-config/rules/jsx.jsonc
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
21
packages/oxlint-config/rules/ts-consider.jsonc
Normal file
21
packages/oxlint-config/rules/ts-consider.jsonc
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
13
packages/oxlint-config/rules/ts-disabled.jsonc
Normal file
13
packages/oxlint-config/rules/ts-disabled.jsonc
Normal 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",
|
||||
},
|
||||
}
|
||||
64
packages/oxlint-config/rules/ts-enabled.jsonc
Normal file
64
packages/oxlint-config/rules/ts-enabled.jsonc
Normal 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",
|
||||
},
|
||||
}
|
||||
7
packages/release/.oxlintrc.json
Normal file
7
packages/release/.oxlintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "dist", ".turbo"],
|
||||
"extends": [
|
||||
"../oxlint-config/index.jsonc"
|
||||
// "@monkeytype/oxlint-config"
|
||||
]
|
||||
}
|
||||
21
packages/release/bin/deployBackend.sh
Executable file
21
packages/release/bin/deployBackend.sh
Executable 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
|
||||
23
packages/release/bin/purgeCfCache.sh
Executable file
23
packages/release/bin/purgeCfCache.sh
Executable 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
|
||||
6
packages/release/example.env
Normal file
6
packages/release/example.env
Normal 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
|
||||
26
packages/release/package.json
Normal file
26
packages/release/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
399
packages/release/src/buildChangelog.js
Normal file
399
packages/release/src/buildChangelog.js
Normal 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();
|
||||
60
packages/release/src/buildContributors.js
Normal file
60
packages/release/src/buildContributors.js
Normal 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
376
packages/release/src/index.js
Executable 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();
|
||||
7
packages/schemas/.oxlintrc.json
Normal file
7
packages/schemas/.oxlintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "dist", ".turbo"],
|
||||
"extends": [
|
||||
"../oxlint-config/index.jsonc"
|
||||
// "@monkeytype/oxlint-config"
|
||||
]
|
||||
}
|
||||
83
packages/schemas/__tests__/config.spec.ts
Normal file
83
packages/schemas/__tests__/config.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
7
packages/schemas/__tests__/tsconfig.json
Normal file
7
packages/schemas/__tests__/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"]
|
||||
}
|
||||
56
packages/schemas/__tests__/util.spec.ts
Normal file
56
packages/schemas/__tests__/util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
38
packages/schemas/package.json
Normal file
38
packages/schemas/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
packages/schemas/src/ape-keys.ts
Normal file
19
packages/schemas/src/ape-keys.ts
Normal 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>;
|
||||
52
packages/schemas/src/challenges.ts
Normal file
52
packages/schemas/src/challenges.ts
Normal 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>;
|
||||
518
packages/schemas/src/configs.ts
Normal file
518
packages/schemas/src/configs.ts
Normal 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>;
|
||||
131
packages/schemas/src/configuration.ts
Normal file
131
packages/schemas/src/configuration.ts
Normal 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>;
|
||||
24
packages/schemas/src/connections.ts
Normal file
24
packages/schemas/src/connections.ts
Normal 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>;
|
||||
61
packages/schemas/src/fonts.ts
Normal file
61
packages/schemas/src/fonts.ts
Normal 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>;
|
||||
466
packages/schemas/src/languages.ts
Normal file
466
packages/schemas/src/languages.ts
Normal 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>;
|
||||
308
packages/schemas/src/layouts.ts
Normal file
308
packages/schemas/src/layouts.ts
Normal 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>;
|
||||
66
packages/schemas/src/leaderboards.ts
Normal file
66
packages/schemas/src/leaderboards.ts
Normal 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>;
|
||||
51
packages/schemas/src/presets.ts
Normal file
51
packages/schemas/src/presets.ts
Normal 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>;
|
||||
12
packages/schemas/src/psas.ts
Normal file
12
packages/schemas/src/psas.ts
Normal 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>;
|
||||
15
packages/schemas/src/public.ts
Normal file
15
packages/schemas/src/public.ts
Normal 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>;
|
||||
71
packages/schemas/src/quotes.ts
Normal file
71
packages/schemas/src/quotes.ts
Normal 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>;
|
||||
182
packages/schemas/src/results.ts
Normal file
182
packages/schemas/src/results.ts
Normal 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>;
|
||||
83
packages/schemas/src/shared.ts
Normal file
83
packages/schemas/src/shared.ts
Normal 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";
|
||||
197
packages/schemas/src/themes.ts
Normal file
197
packages/schemas/src/themes.ts
Normal 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"),
|
||||
},
|
||||
);
|
||||
389
packages/schemas/src/users.ts
Normal file
389
packages/schemas/src/users.ts
Normal 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>;
|
||||
66
packages/schemas/src/util.ts
Normal file
66
packages/schemas/src/util.ts
Normal 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"),
|
||||
});
|
||||
}
|
||||
42
packages/schemas/src/validation/homoglyphs.ts
Normal file
42
packages/schemas/src/validation/homoglyphs.ts
Normal 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;
|
||||
}
|
||||
442
packages/schemas/src/validation/validation.ts
Normal file
442
packages/schemas/src/validation/validation.ts
Normal 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 };
|
||||
10
packages/schemas/tsconfig.json
Normal file
10
packages/schemas/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@monkeytype/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"target": "ES6"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
3
packages/schemas/tsup.config.js
Normal file
3
packages/schemas/tsup.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { extendConfig } from "@monkeytype/tsup-config";
|
||||
|
||||
export default extendConfig();
|
||||
10
packages/schemas/vitest.config.ts
Normal file
10
packages/schemas/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
coverage: {
|
||||
include: ["**/*.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
7
packages/tsup-config/.oxlintrc.json
Normal file
7
packages/tsup-config/.oxlintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "dist", ".turbo"],
|
||||
"extends": [
|
||||
"../oxlint-config/index.jsonc"
|
||||
// "@monkeytype/oxlint-config"
|
||||
]
|
||||
}
|
||||
27
packages/tsup-config/package.json
Normal file
27
packages/tsup-config/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
packages/tsup-config/src/index.ts
Normal file
21
packages/tsup-config/src/index.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
13
packages/tsup-config/tsconfig.json
Normal file
13
packages/tsup-config/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
packages/tsup-config/tsup.config.js
Normal file
10
packages/tsup-config/tsup.config.js
Normal 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,
|
||||
}));
|
||||
34
packages/typescript-config/base.json
Normal file
34
packages/typescript-config/base.json
Normal 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
|
||||
}
|
||||
}
|
||||
4
packages/typescript-config/package.json
Normal file
4
packages/typescript-config/package.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "@monkeytype/typescript-config",
|
||||
"private": true
|
||||
}
|
||||
7
packages/util/.oxlintrc.json
Normal file
7
packages/util/.oxlintrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "dist", ".turbo"],
|
||||
"extends": [
|
||||
"../oxlint-config/index.jsonc"
|
||||
// "@monkeytype/oxlint-config"
|
||||
]
|
||||
}
|
||||
49
packages/util/__test__/arrays.spec.ts
Normal file
49
packages/util/__test__/arrays.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
223
packages/util/__test__/date-and-time.spec.ts
Normal file
223
packages/util/__test__/date-and-time.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/util/__test__/json.spec.ts
Normal file
147
packages/util/__test__/json.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
157
packages/util/__test__/numbers.spec.ts
Normal file
157
packages/util/__test__/numbers.spec.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
packages/util/__test__/predicates.spec.ts
Normal file
41
packages/util/__test__/predicates.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
packages/util/__test__/strings.spec.ts
Normal file
14
packages/util/__test__/strings.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
92
packages/util/__test__/trycatch.spec.ts
Normal file
92
packages/util/__test__/trycatch.spec.ts
Normal 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
Reference in New Issue
Block a user