Skip to content
6 changes: 4 additions & 2 deletions frontend/src/ts/commandline/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import * as FPSCounter from "../elements/fps-counter";
import { migrateConfig } from "../utils/config";
import { PartialConfigSchema } from "@monkeytype/contracts/schemas/configs";
import { Command, CommandsSubgroup } from "./types";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";

const layoutsPromise = JSONData.getLayoutsList();
layoutsPromise
Expand Down Expand Up @@ -362,8 +363,9 @@ export const commands: CommandsSubgroup = {
exec: async ({ input }): Promise<void> => {
if (input === undefined || input === "") return;
try {
const parsedConfig = PartialConfigSchema.strip().parse(
JSON.parse(input)
const parsedConfig = parseJsonWithSchema(
input,
PartialConfigSchema.strip()
);
await UpdateConfig.apply(migrateConfig(parsedConfig));
UpdateConfig.saveFullConfigToLocalStorage();
Expand Down
21 changes: 15 additions & 6 deletions frontend/src/ts/controllers/analytics-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import {
} from "firebase/analytics";
import { app as firebaseApp } from "../firebase";
import { createErrorMessage } from "../utils/misc";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
import { z } from "zod";

let analytics: AnalyticsType;

type AcceptedCookies = {
security: boolean;
analytics: boolean;
};
const AcceptedCookiesSchema = z.object({
security: z.boolean(),
analytics: z.boolean(),
});

type AcceptedCookies = z.infer<typeof AcceptedCookiesSchema>;

export async function log(
eventName: string,
Expand All @@ -26,9 +30,14 @@ export async function log(
}

const lsString = localStorage.getItem("acceptedCookies");
let acceptedCookies;
let acceptedCookies: AcceptedCookies | null;
if (lsString !== undefined && lsString !== null && lsString !== "") {
acceptedCookies = JSON.parse(lsString) as AcceptedCookies;
try {
acceptedCookies = parseJsonWithSchema(lsString, AcceptedCookiesSchema);
} catch (e) {
console.error("Failed to parse accepted cookies:", e);
acceptedCookies = null;
}
} else {
acceptedCookies = null;
}
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/ts/event-handlers/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { isAuthenticated } from "../firebase";
import * as Notifications from "../elements/notifications";
import * as EditResultTagsModal from "../modals/edit-result-tags";
import * as AddFilterPresetModal from "../modals/new-filter-preset";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
import { z } from "zod";

const accountPage = document.querySelector("#pageAccount") as HTMLElement;

Expand Down Expand Up @@ -36,12 +38,15 @@ $(accountPage).on("click", ".editProfileButton", () => {
EditProfileModal.show();
});

const TagsArraySchema = z.array(z.string());

$(accountPage).on("click", ".group.history .resultEditTagsButton", (e) => {
const resultid = $(e.target).attr("data-result-id");
const tags = $(e.target).attr("data-tags");

EditResultTagsModal.show(
resultid ?? "",
JSON.parse(tags ?? "[]") as string[],
parseJsonWithSchema(tags ?? "[]", TagsArraySchema),
"accountPage"
);
});
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/ts/modals/import-export-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as UpdateConfig from "../config";
import * as Notifications from "../elements/notifications";
import AnimatedModal from "../utils/animated-modal";
import { migrateConfig } from "../utils/config";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";

type State = {
mode: "import" | "export";
Expand Down Expand Up @@ -47,8 +48,9 @@ const modal = new AnimatedModal({
return;
}
try {
const parsedConfig = PartialConfigSchema.strip().parse(
JSON.parse(state.value)
const parsedConfig = parseJsonWithSchema(
state.value,
PartialConfigSchema.strip()
);
await UpdateConfig.apply(migrateConfig(parsedConfig));
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/ts/test/custom-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const customTextLongLS = new LocalStorageWithSchema({
fallback: {},
});

const CustomTextSettingsSchema = z.object({
export const CustomTextSettingsSchema = z.object({
text: z.array(z.string()),
mode: CustomTextModeSchema,
limit: z.object({ value: z.number(), mode: CustomTextLimitModeSchema }),
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/ts/test/wikipedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as Loader from "../elements/loader";
import * as Misc from "../utils/misc";
import * as Strings from "../utils/strings";
import * as JSONData from "../utils/json-data";
import { z } from "zod";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";

export async function getTLD(
languageGroup: JSONData.LanguageGroup
Expand Down Expand Up @@ -241,6 +243,18 @@ type SectionObject = {
author: string;
};

// Section Schema
const SectionSchema = z.object({
query: z.object({
pages: z.record(
z.string(),
z.object({
extract: z.string(),
})
),
}),
});

export async function getSection(language: string): Promise<JSONData.Section> {
// console.log("Getting section");
Loader.show();
Expand Down Expand Up @@ -285,10 +299,17 @@ export async function getSection(language: string): Promise<JSONData.Section> {
sectionReq.onload = (): void => {
if (sectionReq.readyState === 4) {
if (sectionReq.status === 200) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
let sectionText = JSON.parse(sectionReq.responseText).query.pages[
pageid.toString()
].extract as string;
const parsedResponse = parseJsonWithSchema(
sectionReq.responseText,
SectionSchema
);
const page = parsedResponse.query.pages[pageid.toString()];
if (!page) {
Loader.hide();
rej("Page not found");
return;
}
let sectionText = page.extract;

// Converting to one paragraph
sectionText = sectionText.replace(/<\/p><p>+/g, " ");
Expand Down
44 changes: 29 additions & 15 deletions frontend/src/ts/utils/url-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ import * as Loader from "../elements/loader";
import * as AccountButton from "../elements/account-button";
import { restart as restartTest } from "../test/test-logic";
import * as ChallengeController from "../controllers/challenge-controller";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
import {
DifficultySchema,
Mode2Schema,
ModeSchema,
} from "@monkeytype/contracts/schemas/shared";
import {
CustomBackgroundFilter,
CustomBackgroundFilterSchema,
CustomBackgroundSize,
CustomBackgroundSizeSchema,
CustomThemeColors,
CustomThemeColorsSchema,
Difficulty,
} from "@monkeytype/contracts/schemas/configs";
import { z } from "zod";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
Expand Down Expand Up @@ -129,24 +132,35 @@ export function loadCustomThemeFromUrl(getOverride?: string): void {
}
}

type SharedTestSettings = [
Mode | null,
Mode2<Mode> | null,
CustomText.CustomTextData | null,
boolean | null,
boolean | null,
string | null,
Difficulty | null,
string | null
];
const TestSettingsSchema = z.tuple([
ModeSchema.nullable(),
Mode2Schema.nullable(),
CustomText.CustomTextSettingsSchema.nullable(),
z.boolean().nullable(), //punctuation
z.boolean().nullable(), //numbers
z.string().nullable(), //language
DifficultySchema.nullable(),
z.string().nullable(), //funbox
]);

type SharedTestSettings = z.infer<typeof TestSettingsSchema>;

export function loadTestSettingsFromUrl(getOverride?: string): void {
const getValue = Misc.findGetParameter("testSettings", getOverride);
if (getValue === null) return;

const de = JSON.parse(
decompressFromURI(getValue) ?? ""
) as SharedTestSettings;
let de: SharedTestSettings;
try {
const decompressed = decompressFromURI(getValue) ?? "";
de = parseJsonWithSchema(decompressed, TestSettingsSchema);
} catch (e) {
console.error("Failed to parse test settings:", e);
Notifications.add(
"Failed to load test settings from URL: " + (e as Error).message,
0
);
return;
}

const applied: Record<string, string> = {};

Expand Down
12 changes: 8 additions & 4 deletions packages/util/src/json.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { ZodError, ZodIssue, ZodSchema } from "zod";
import { z, ZodError, ZodIssue } from "zod";

/**
* Parse a JSON string into an object and validate it against a schema
* @param json JSON string
* @param schema Zod schema to validate the JSON against
* @returns The parsed JSON object
*/
export function parseWithSchema<T>(json: string, schema: ZodSchema<T>): T {
export function parseWithSchema<T extends z.ZodTypeAny>(
json: string,
schema: T
): z.infer<T> {
try {
const jsonParsed = JSON.parse(json) as unknown;
const zodParsed = schema.parse(jsonParsed);
return zodParsed;
// hits is fine to ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return schema.parse(jsonParsed) as z.infer<T>;
} catch (error) {
if (error instanceof ZodError) {
throw new Error(error.issues.map(prettyErrorMessage).join("\n"));
Expand Down