diff --git a/frontend/__tests__/constants/themes.spec.ts b/frontend/__tests__/constants/themes.spec.ts deleted file mode 100644 index faca64ecf..000000000 --- a/frontend/__tests__/constants/themes.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readdirSync } from "fs"; -import { ThemesList } from "../../src/ts/constants/themes"; - -describe("themes", () => { - it("should not have duplicates", () => { - const duplicates = ThemesList.filter( - (item, index) => ThemesList.indexOf(item) !== index - ); - expect(duplicates).toEqual([]); - }); - it("should have all related css files", () => { - const themeFiles = listThemeFiles(); - - const missingThemeFiles = ThemesList.filter( - (it) => !themeFiles.includes(it.name) - ).map((it) => `fontend/static/themes/${it}.css`); - - expect(missingThemeFiles, "missing theme css files").toEqual([]); - }); - it("should not have additional css files", () => { - const themeFiles = listThemeFiles(); - - const additionalThemeFiles = themeFiles - .filter((it) => !ThemesList.some((theme) => theme.name === it)) - .map((it) => `fontend/static/themes/${it}.css`); - - expect( - additionalThemeFiles, - "additional theme css files not declared in frontend/src/ts/constants/themes.ts" - ).toEqual([]); - }); -}); - -function listThemeFiles() { - return readdirSync(import.meta.dirname + "/../../static/themes").map((it) => - it.substring(0, it.length - 4) - ); -} diff --git a/frontend/scripts/json-validation.ts b/frontend/scripts/json-validation.ts index 67b847526..ac61fb390 100644 --- a/frontend/scripts/json-validation.ts +++ b/frontend/scripts/json-validation.ts @@ -17,341 +17,356 @@ import { ThemesList } from "../src/ts/constants/themes"; const ajv = new Ajv(); -function findDuplicates(words: string[]): string[] { - const wordFrequencies: Record = {}; - const duplicates: string[] = []; +class Problems { + private type: string; + private labels: Record; + private problems: Partial> = {}; - words.forEach((word) => { - wordFrequencies[word] = (wordFrequencies[word] ?? 0) + 1; + constructor(type: string, labels: Record) { + this.type = type; + this.labels = labels; + } - if (wordFrequencies[word] === 2) { - duplicates.push(word); + public add(key: K | T, problem: string): void { + this.problems[key] = [...(this.problems[key] ?? []), problem]; + } + + public hasError(): boolean { + return Object.keys(this.problems).length !== 0; + } + public toString(): string { + if (!this.hasError()) { + return `${this.type} are all \u001b[32mvalid\u001b[0m`; } - }); - return duplicates; + + return ( + `${this.type} are \u001b[31minvalid\u001b[0m\n` + + Object.entries(this.problems) + .map(([key, problems]) => { + let label: string = this.labels[key as T] ?? `${key}`; + + return `${label}:\n ${(problems as string[]) + .map((error) => "\t- " + error) + .join("\n")}`; + }) + .join("\n") + ); + } +} + +function findDuplicates(words: string[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + + for (const word of words) { + if (seen.has(word)) { + duplicates.add(word); + } else { + seen.add(word); + } + } + + return Array.from(duplicates); } async function validateChallenges(): Promise { - return new Promise((resolve, reject) => { - const challengesSchema = { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - display: { type: "string" }, - autoRole: { type: "boolean" }, - type: { type: "string" }, - message: { type: "string" }, - parameters: { - type: "array", - }, - requirements: { - type: "object", - properties: { - wpm: { - type: "object", - properties: { - min: { type: "number" }, - max: { type: "number" }, - exact: { type: "number" }, - }, + const challengesSchema = { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + display: { type: "string" }, + autoRole: { type: "boolean" }, + type: { type: "string" }, + message: { type: "string" }, + parameters: { + type: "array", + }, + requirements: { + type: "object", + properties: { + wpm: { + type: "object", + properties: { + min: { type: "number" }, + max: { type: "number" }, + exact: { type: "number" }, }, - time: { - type: "object", - properties: { - min: { type: "number" }, - max: { type: "number" }, - exact: { type: "number" }, - }, + }, + time: { + type: "object", + properties: { + min: { type: "number" }, + max: { type: "number" }, + exact: { type: "number" }, }, - acc: { - type: "object", - properties: { - min: { type: "number" }, - max: { type: "number" }, - exact: { type: "number" }, - }, + }, + acc: { + type: "object", + properties: { + min: { type: "number" }, + max: { type: "number" }, + exact: { type: "number" }, }, - raw: { - type: "object", - properties: { - min: { type: "number" }, - max: { type: "number" }, - exact: { type: "number" }, - }, + }, + raw: { + type: "object", + properties: { + min: { type: "number" }, + max: { type: "number" }, + exact: { type: "number" }, }, - con: { - type: "object", - properties: { - min: { type: "number" }, - max: { type: "number" }, - exact: { type: "number" }, - }, + }, + con: { + type: "object", + properties: { + min: { type: "number" }, + max: { type: "number" }, + exact: { type: "number" }, }, - config: { - type: "object", - }, - funbox: { - type: "object", - properties: { - exact: { type: "array" }, - }, + }, + config: { + type: "object", + }, + funbox: { + type: "object", + properties: { + exact: { type: "array" }, }, }, }, }, - required: ["name", "display", "type", "parameters"], }, - }; - const challengesData = JSON.parse( - fs.readFileSync("./static/challenges/_list.json", { - encoding: "utf8", - flag: "r", - }) - ) as object; - const challengesValidator = ajv.compile(challengesSchema); - if (challengesValidator(challengesData)) { - console.log("Challenges list JSON schema is \u001b[32mvalid\u001b[0m"); - } else { - console.log("Challenges list JSON schema is \u001b[31minvalid\u001b[0m"); - reject(new Error(challengesValidator?.errors?.[0]?.message)); - } - resolve(); - }); + required: ["name", "display", "type", "parameters"], + }, + }; + const challengesData = JSON.parse( + fs.readFileSync("./static/challenges/_list.json", { + encoding: "utf8", + flag: "r", + }) + ) as object; + const challengesValidator = ajv.compile(challengesSchema); + if (challengesValidator(challengesData)) { + console.log("Challenges list JSON schema is \u001b[32mvalid\u001b[0m"); + } else { + console.log("Challenges list JSON schema is \u001b[31minvalid\u001b[0m"); + throw new Error(challengesValidator?.errors?.[0]?.message); + } } async function validateLayouts(): Promise { - return new Promise((resolve, reject) => { - const problems: Partial> = {}; - const addProblem = (layout: keyof typeof problems, error: string): void => { - problems[layout] = [...(problems[layout] ?? []), error]; - }; - - const charDefinitionSchema = { - type: "array", - minItems: 1, - maxItems: 4, - items: { type: "string", minLength: 1, maxLength: 1 }, - }; - const charDefinitionSchemaRow5 = { - type: "array", - minItems: 1, - maxItems: 2, - items: { type: "string", minLength: 1, maxLength: 1 }, - }; - - const layoutsSchema = { - ansi: { - type: "object", - properties: { - keymapShowTopRow: { type: "boolean" }, - type: { type: "string", pattern: "^ansi$" }, - keys: { - type: "object", - properties: { - row1: { - type: "array", - items: charDefinitionSchema, - minItems: 13, - maxItems: 13, - }, - row2: { - type: "array", - items: charDefinitionSchema, - minItems: 13, - maxItems: 13, - }, - row3: { - type: "array", - items: charDefinitionSchema, - minItems: 11, - maxItems: 11, - }, - row4: { - type: "array", - items: charDefinitionSchema, - minItems: 10, - maxItems: 10, - }, - row5: { - type: "array", - items: charDefinitionSchemaRow5, - minItems: 1, - maxItems: 2, - }, - }, - required: ["row1", "row2", "row3", "row4", "row5"], - }, - }, - required: ["keymapShowTopRow", "type", "keys"], - }, - iso: { - type: "object", - properties: { - keymapShowTopRow: { type: "boolean" }, - type: { type: "string", pattern: "^iso$" }, - keys: { - type: "object", - properties: { - row1: { - type: "array", - items: charDefinitionSchema, - minItems: 13, - maxItems: 13, - }, - row2: { - type: "array", - items: charDefinitionSchema, - minItems: 12, - maxItems: 12, - }, - row3: { - type: "array", - items: charDefinitionSchema, - minItems: 12, - maxItems: 12, - }, - row4: { - type: "array", - items: charDefinitionSchema, - minItems: 11, - maxItems: 11, - }, - row5: { - type: "array", - items: charDefinitionSchemaRow5, - minItems: 1, - maxItems: 2, - }, - }, - required: ["row1", "row2", "row3", "row4", "row5"], - }, - }, - required: ["keymapShowTopRow", "type", "keys"], - }, - }; - - for (let layoutName of LayoutsList) { - let layoutData = undefined; - if (!fs.existsSync(`./static/layouts/${layoutName}.json`)) { - addProblem( - layoutName, - `missing json file frontend/static/layouts/${layoutName}.json` - ); - continue; - } - try { - layoutData = JSON.parse( - fs.readFileSync(`./static/layouts/${layoutName}.json`, "utf-8") - ) as object & { type: "ansi" | "iso" }; - } catch (e) { - addProblem( - layoutName, - `Unable to parse ${e instanceof Error ? e.message : e}` - ); - continue; - } - - if (layoutsSchema[layoutData.type] === undefined) { - addProblem(layoutName, `invalid type: ${layoutData.type}`); - } else { - const layoutsValidator = ajv.compile(layoutsSchema[layoutData.type]); - if (!layoutsValidator(layoutData)) { - addProblem( - layoutName, - layoutsValidator.errors?.[0]?.message ?? "unknown" - ); - } - } - } - - //no files not defined in LayoutsList - const additionalLayoutFiles = fs - .readdirSync("./static/layouts") - .map((it) => it.substring(0, it.length - 5)) - .filter((it) => !LayoutsList.some((layout) => layout === it)) - .map((it) => `frontend/static/layouts/${it}.json`); - if (additionalLayoutFiles.length !== 0) { - problems._additional = additionalLayoutFiles; - } - - if (Object.keys(problems).length === 0) { - console.log(`Layouts are all \u001b[32mvalid\u001b[0m`); - } else { - console.log(`Layouts are \u001b[31minvalid\u001b[0m`); - console.log( - Object.entries(problems) - .map(([layout, problems]) => { - let label = `${layout}.json`; - if (layout === "_additional") - label = - "Additional layout files not declared in frontend/src/ts/constants/layouts.ts"; - - return `${label}:\n ${problems - .map((error) => "\t- " + error) - .join("\n")}`; - }) - .join("\n") - ); - reject(new Error("layouts with errors")); - } - - resolve(); + const problems = new Problems("Layouts", { + _additional: + "Additional layout files not declared in frontend/src/ts/constants/layouts.ts", }); + + const charDefinitionSchema = { + type: "array", + minItems: 1, + maxItems: 4, + items: { type: "string", minLength: 1, maxLength: 1 }, + }; + const charDefinitionSchemaRow5 = { + type: "array", + minItems: 1, + maxItems: 2, + items: { type: "string", minLength: 1, maxLength: 1 }, + }; + + const layoutsSchema = { + ansi: { + type: "object", + properties: { + keymapShowTopRow: { type: "boolean" }, + type: { type: "string", pattern: "^ansi$" }, + keys: { + type: "object", + properties: { + row1: { + type: "array", + items: charDefinitionSchema, + minItems: 13, + maxItems: 13, + }, + row2: { + type: "array", + items: charDefinitionSchema, + minItems: 13, + maxItems: 13, + }, + row3: { + type: "array", + items: charDefinitionSchema, + minItems: 11, + maxItems: 11, + }, + row4: { + type: "array", + items: charDefinitionSchema, + minItems: 10, + maxItems: 10, + }, + row5: { + type: "array", + items: charDefinitionSchemaRow5, + minItems: 1, + maxItems: 2, + }, + }, + required: ["row1", "row2", "row3", "row4", "row5"], + }, + }, + required: ["keymapShowTopRow", "type", "keys"], + }, + iso: { + type: "object", + properties: { + keymapShowTopRow: { type: "boolean" }, + type: { type: "string", pattern: "^iso$" }, + keys: { + type: "object", + properties: { + row1: { + type: "array", + items: charDefinitionSchema, + minItems: 13, + maxItems: 13, + }, + row2: { + type: "array", + items: charDefinitionSchema, + minItems: 12, + maxItems: 12, + }, + row3: { + type: "array", + items: charDefinitionSchema, + minItems: 12, + maxItems: 12, + }, + row4: { + type: "array", + items: charDefinitionSchema, + minItems: 11, + maxItems: 11, + }, + row5: { + type: "array", + items: charDefinitionSchemaRow5, + minItems: 1, + maxItems: 2, + }, + }, + required: ["row1", "row2", "row3", "row4", "row5"], + }, + }, + required: ["keymapShowTopRow", "type", "keys"], + }, + }; + + for (let layoutName of LayoutsList) { + let layoutData = undefined; + if (!fs.existsSync(`./static/layouts/${layoutName}.json`)) { + problems.add( + layoutName, + `missing json file frontend/static/layouts/${layoutName}.json` + ); + continue; + } + try { + layoutData = JSON.parse( + fs.readFileSync(`./static/layouts/${layoutName}.json`, "utf-8") + ) as object & { type: "ansi" | "iso" }; + } catch (e) { + problems.add( + layoutName, + `Unable to parse ${e instanceof Error ? e.message : e}` + ); + continue; + } + + if (layoutsSchema[layoutData.type] === undefined) { + problems.add(layoutName, `invalid type: ${layoutData.type}`); + } else { + const layoutsValidator = ajv.compile(layoutsSchema[layoutData.type]); + if (!layoutsValidator(layoutData)) { + problems.add( + layoutName, + layoutsValidator.errors?.[0]?.message ?? "unknown" + ); + } + } + } + + //no files not defined in LayoutsList + const additionalLayoutFiles = fs + .readdirSync("./static/layouts") + .map((it) => it.substring(0, it.length - 5)) + .filter((it) => !LayoutsList.some((layout) => layout === it)) + .map((it) => `frontend/static/layouts/${it}.json`); + if (additionalLayoutFiles.length !== 0) { + additionalLayoutFiles.forEach((it) => problems.add("_additional", it)); + } + + console.log(problems.toString()); + + if (problems.hasError()) { + throw new Error("layouts with errors"); + } } async function validateQuotes(): Promise { - return new Promise((resolve, reject) => { - //quotes - const quoteSchema = { - type: "object", - properties: { - language: { type: "string" }, - groups: { + const problems = new Problems("Quotes", {}); + + //quotes + const quoteSchema = { + type: "object", + properties: { + language: { type: "string" }, + groups: { + type: "array", + items: { type: "array", items: { - type: "array", - items: { - type: "number", - }, - minItems: 2, - maxItems: 2, - }, - }, - quotes: { - type: "array", - items: { - type: "object", - properties: { - text: { type: "string" }, - source: { type: "string" }, - length: { type: "number" }, - id: { type: "number" }, - }, - required: ["text", "source", "length", "id"], + type: "number", }, + minItems: 2, + maxItems: 2, }, }, - required: ["language", "groups", "quotes"], - }; - const quoteIdsSchema = { - type: "array", - items: { - type: "number", + quotes: { + type: "array", + items: { + type: "object", + properties: { + text: { type: "string" }, + source: { type: "string" }, + length: { type: "number" }, + id: { type: "number" }, + }, + required: ["text", "source", "length", "id"], + }, }, - uniqueItems: true, - }; - let quoteFilesAllGood = true; - let quoteFilesErrors; - let quoteIdsAllGood = true; - let quoteIdsErrors; - let quoteLengthsAllGood = true; - let quoteLengthErrors: string[] = []; - const quotesFiles = fs.readdirSync("./static/quotes/"); - quotesFiles.forEach((quotefilename: string) => { - quotefilename = quotefilename.split(".")[0] as string; - const quoteData = JSON.parse( + }, + required: ["language", "groups", "quotes"], + }; + const quoteIdsSchema = { + type: "array", + items: { + type: "number", + }, + uniqueItems: true, + }; + + const quotesFiles = fs.readdirSync("./static/quotes/"); + for (let quotefilename of quotesFiles) { + quotefilename = quotefilename.split(".")[0] as string; + let quoteData; + + try { + quoteData = JSON.parse( fs.readFileSync(`./static/quotes/${quotefilename}.json`, { encoding: "utf8", flag: "r", @@ -360,229 +375,180 @@ async function validateQuotes(): Promise { language: string; quotes: { id: number; text: string; length: number }[]; }; - if (quoteData.language !== quotefilename) { - quoteFilesAllGood = false; - quoteFilesErrors = "Name is not " + quotefilename; - } - const quoteValidator = ajv.compile(quoteSchema); - if (!quoteValidator(quoteData)) { - console.log( - `Quote ${quotefilename} JSON schema is \u001b[31minvalid\u001b[0m` - ); - quoteFilesAllGood = false; - quoteFilesErrors = - quoteValidator.errors?.[0]?.message + - ` (at static/quotes/${quotefilename}.json)`; - return; - } - const quoteIds = quoteData.quotes.map((quote) => quote.id); - const quoteIdsValidator = ajv.compile(quoteIdsSchema); - if (!quoteIdsValidator(quoteIds)) { - console.log( - `Quote ${quotefilename} IDs are \u001b[31mnot unique\u001b[0m` - ); - quoteIdsAllGood = false; - quoteIdsErrors = - quoteIdsValidator.errors?.[0]?.message + - ` (at static/quotes/${quotefilename}.json)`; - } - const incorrectQuoteLength = quoteData.quotes.filter( - (quote) => quote.text.length !== quote.length + } catch (e) { + problems.add( + quotefilename, + `Unable to parse ${e instanceof Error ? e.message : e}` ); - if (incorrectQuoteLength.length !== 0) { - console.log("Some length fields are \u001b[31mincorrect\u001b[0m"); - incorrectQuoteLength.map((quote) => { - console.log( - `Quote ${quotefilename} ID ${quote.id}: expected length ${quote.text.length}` - ); - }); - quoteFilesAllGood = false; - incorrectQuoteLength.map((quote) => { - quoteLengthErrors.push( - `${quotefilename} ${quote.id} length field is incorrect` - ); - }); - } - }); - if (quoteFilesAllGood) { - console.log(`Quote file JSON schemas are \u001b[32mvalid\u001b[0m`); - } else { - console.log(`Quote file JSON schemas are \u001b[31minvalid\u001b[0m`); - reject(new Error(quoteFilesErrors)); + continue; } - if (quoteIdsAllGood) { - console.log(`Quote IDs are \u001b[32munique\u001b[0m`); - } else { - console.log(`Quote IDs are \u001b[31mnot unique\u001b[0m`); - reject(new Error(quoteIdsErrors)); + if (quoteData.language !== quotefilename) { + problems.add( + quotefilename, + `Name not matching language ${quoteData.language}` + ); } - if (quoteLengthsAllGood) { - console.log(`Quote length fields are \u001b[32mcorrect\u001b[0m`); - } else { - console.log(`Quote length fields are \u001b[31mincorrect\u001b[0m`); - reject(new Error(quoteLengthErrors.join(","))); + const quoteValidator = ajv.compile(quoteSchema); + if (!quoteValidator(quoteData)) { + problems.add( + quotefilename, + quoteValidator.errors?.[0]?.message ?? "unknown" + ); + continue; } - resolve(); - }); + const quoteIds = quoteData.quotes.map((quote) => quote.id); + const quoteIdsValidator = ajv.compile(quoteIdsSchema); + if (!quoteIdsValidator(quoteIds)) { + problems.add( + quotefilename, + `IDs not unique: ${quoteIdsValidator.errors?.[0]?.message}` + ); + } + + quoteData.quotes + .filter((quote) => quote.text.length !== quote.length) + .forEach((quote) => + problems.add( + quotefilename, + `ID ${quote.id}: expected length ${quote.text.length}` + ) + ); + } + console.log(problems.toString()); + + if (problems.hasError()) { + throw new Error("quotes with errors"); + } } async function validateLanguages(): Promise { - return new Promise((resolve, reject) => { - const problems: Partial< - Record - > = {}; + const problems = new Problems( + "Languages", + { + _additional: + "Additional language files not declared in frontend/src/ts/constants/languages.ts", + _groups: + "Problems in LanguageGroups on frontend/src/ts/constants/languages.ts", + } + ); - const addProblem = ( - language: keyof typeof problems, - error: string - ): void => { - problems[language] = [...(problems[language] ?? []), error]; - }; - - //language files - const languageFileSchema = { - type: "object", - properties: { - name: { type: "string" }, - rightToLeft: { type: "boolean" }, - noLazyMode: { type: "boolean" }, - bcp47: { type: "string" }, - words: { + //language files + const languageFileSchema = { + type: "object", + properties: { + name: { type: "string" }, + rightToLeft: { type: "boolean" }, + noLazyMode: { type: "boolean" }, + bcp47: { type: "string" }, + words: { + type: "array", + items: { type: "string", minLength: 1 }, + }, + additionalAccents: { + type: "array", + items: { type: "array", items: { type: "string", minLength: 1 }, - }, - additionalAccents: { - type: "array", - items: { - type: "array", - items: { type: "string", minLength: 1 }, - minItems: 2, - maxItems: 2, - }, + minItems: 2, + maxItems: 2, }, }, - required: ["name", "words"], - }; + }, + required: ["name", "words"], + }; - const duplicatePercentageThreshold = 0.0001; - for (const language of LanguageList) { - let languageFileData; - try { - languageFileData = JSON.parse( - fs.readFileSync(`./static/languages/${language}.json`, { - encoding: "utf8", - flag: "r", - }) - ) as object & { name: string; words: string[] }; - } catch (e) { - addProblem( - language, - `missing json file frontend/static/languages/${language}.json` - ); + const duplicatePercentageThreshold = 0.0001; + for (const language of LanguageList) { + let languageFileData; + try { + languageFileData = JSON.parse( + fs.readFileSync(`./static/languages/${language}.json`, { + encoding: "utf8", + flag: "r", + }) + ) as object & { name: string; words: string[] }; + } catch (e) { + problems.add( + language, + `missing json file frontend/static/languages/${language}.json` + ); - continue; - } - const languageFileValidator = ajv.compile(languageFileSchema); - if (!languageFileValidator(languageFileData)) { - addProblem( - language, - languageFileValidator.errors?.[0]?.message ?? "unknown" - ); - continue; - } - if (languageFileData.name !== language) { - problems[language] = [ - ...(problems[language] ?? []), - "Name is not " + language, - ]; - } - const duplicates = findDuplicates(languageFileData.words); - const duplicatePercentage = - (duplicates.length / languageFileData.words.length) * 100; - if (duplicatePercentage >= duplicatePercentageThreshold) { - addProblem( - language, - `contains ${duplicates.length} (${Math.round( - duplicatePercentage - )}%) duplicates:\n ${duplicates.join(",")}` - ); - } + continue; } - - //no files not defined in LanguageList - const additionalLanguageFiles = fs - .readdirSync("./static/languages") - .map((it) => it.substring(0, it.length - 5)) - .filter((it) => !LanguageList.some((language) => language === it)) - .map((it) => `frontend/static/languages/${it}.json`); - if (additionalLanguageFiles.length !== 0) { - problems._additional = additionalLanguageFiles; + const languageFileValidator = ajv.compile(languageFileSchema); + if (!languageFileValidator(languageFileData)) { + problems.add( + language, + languageFileValidator.errors?.[0]?.message ?? "unknown" + ); + continue; } - - //check groups - const languagesWithMultipleGroups = []; - const groupByLanguage = new Map(); - - for (const group of Object.keys(LanguageGroups)) { - for (const language of LanguageGroups[group] as Language[]) { - if (groupByLanguage.has(language)) { - languagesWithMultipleGroups.push(language); - } - groupByLanguage.set(language, group); - } + if (languageFileData.name !== language) { + problems.add(language, "Name is not " + language); } - if (languagesWithMultipleGroups.length !== 0) { - addProblem( - "_groups", - `languages with multiple groups: ${languagesWithMultipleGroups.join( - ", " - )}` + const duplicates = findDuplicates(languageFileData.words); + const duplicatePercentage = + (duplicates.length / languageFileData.words.length) * 100; + if (duplicatePercentage >= duplicatePercentageThreshold) { + problems.add( + language, + `contains ${duplicates.length} (${Math.round( + duplicatePercentage + )}%) duplicates:\n ${duplicates.join(",")}` ); } + } - const languagesMissingGroup = LanguageList.filter( - (lang) => !groupByLanguage.has(lang) + //no files not defined in LanguageList + fs.readdirSync("./static/languages") + .map((it) => it.substring(0, it.length - 5)) + .filter((it) => !LanguageList.some((language) => language === it)) + .map((it) => `frontend/static/languages/${it}.json`) + .forEach((it) => problems.add("_additional", it)); + + //check groups + const languagesWithMultipleGroups = []; + const groupByLanguage = new Map(); + + for (const group of Object.keys(LanguageGroups)) { + for (const language of LanguageGroups[group] as Language[]) { + if (groupByLanguage.has(language)) { + languagesWithMultipleGroups.push(language); + } + groupByLanguage.set(language, group); + } + } + if (languagesWithMultipleGroups.length !== 0) { + problems.add( + "_groups", + `languages with multiple groups: ${languagesWithMultipleGroups.join( + ", " + )}` ); - if (languagesMissingGroup.length !== 0) { - problems._groups = [ - ...(problems._groups ?? []), - `languages missing group: ${languagesMissingGroup.join(", ")}`, - ]; - } + } - if (Object.keys(problems).length === 0) { - console.log(`Languages are all \u001b[32mvalid\u001b[0m`); - } else { - console.log(`Languages are \u001b[31minvalid\u001b[0m`); - console.log( - Object.entries(problems) - .map(([language, problems]) => { - let label = `${language}.json`; - if (language === "_additional") - label = - "Additional language files not declared in frontend/src/ts/constants/languages.ts"; - else if (language === "_groups") - label = - "Problems in LanguageGroups on frontend/src/ts/constants/languages.ts"; - return `${label}:\n ${problems - .map((error) => "\t- " + error) - .join("\n")}`; - }) - .join("\n") - ); - reject(new Error("languages with errors")); - } + const languagesMissingGroup = LanguageList.filter( + (lang) => !groupByLanguage.has(lang) + ); + if (languagesMissingGroup.length !== 0) { + problems.add( + "_groups", + `languages missing group: ${languagesMissingGroup.join(", ")}` + ); + } - resolve(); - }); + console.log(problems.toString()); + + if (problems.hasError()) { + throw new Error("languages with errors"); + } } async function validateFonts(): Promise { - const problems: Partial> = {}; - - const addProblem = (fontName: keyof typeof problems, error: string): void => { - problems[fontName] = [...(problems[fontName] ?? []), error]; - }; + const problems = new Problems("Fonts", { + _additional: + "Additional font files not declared in frontend/src/ts/constants/fonts.ts", + }); //no missing files const ignoredFonts = new Set([ @@ -599,7 +565,7 @@ async function validateFonts(): Promise { .filter(([_name, config]) => !config.systemFont) .filter(([_name, config]) => !fontFiles.includes(config.fileName as string)) .forEach(([name, config]) => - addProblem( + problems.add( name as KnownFontName, `missing file frontend/static/webfonts/${config.fileName}` ) @@ -612,45 +578,23 @@ async function validateFonts(): Promise { .map(([_name, config]) => config.fileName as string) ); - const additionalFontFiles = fontFiles + fontFiles .filter((name) => !expectedFontFiles.has(name)) - .map((name) => `frontend/static/webfonts/${name}`); + .map((name) => `frontend/static/webfonts/${name}`) + .forEach((file) => problems.add("_additional", file)); - if (additionalFontFiles.length !== 0) { - additionalFontFiles.forEach((file) => addProblem("_additional", file)); - } + console.log(problems.toString()); - if (Object.keys(problems).length === 0) { - console.log(`Fonts are all \u001b[32mvalid\u001b[0m`); - } else { - console.log(`Fonts are \u001b[31minvalid\u001b[0m`); - console.log( - Object.entries(problems) - .map(([fontName, problems]) => { - let label = `${fontName}`; - if (fontName === "_additional") - label = - "Additional font files not declared in frontend/src/ts/constants/fonts.ts"; - - return `${label}:\n ${problems - .map((error) => "\t- " + error) - .join("\n")}`; - }) - .join("\n") - ); + if (problems.hasError()) { throw new Error("layouts with errors"); } } async function validateThemes(): Promise { - const problems: Partial> = {}; - - const addProblem = ( - themeName: keyof typeof problems, - error: string - ): void => { - problems[themeName] = [...(problems[themeName] ?? []), error]; - }; + const problems = new Problems("Themes", { + _additional: + "Additional theme files not declared in frontend/src/ts/constants/themes.ts", + }); //no missing files const themeFiles = fs @@ -659,61 +603,37 @@ async function validateThemes(): Promise { //missing theme files ThemesList.filter((it) => !themeFiles.includes(it.name)).forEach((it) => - addProblem(it.name, `missing file frontend/static/themes/${it.name}.css`) + problems.add(it.name, `missing file frontend/static/themes/${it.name}.css`) ); - //additional font files - const additionalThemeFiles = themeFiles + //additional theme files + themeFiles .filter((it) => !ThemesList.some((theme) => theme.name === it)) - .map((it) => `frontend/static/layouts/${it}.json`); + .map((it) => `frontend/static/themes/${it}.css`) + .forEach((it) => problems.add("_additional", it)); - if (additionalThemeFiles.length !== 0) { - problems._additional = additionalThemeFiles; - } + console.log(problems.toString()); - if (Object.keys(problems).length === 0) { - console.log(`Themes are all \u001b[32mvalid\u001b[0m`); - } else { - console.log(`Themes are \u001b[31minvalid\u001b[0m`); - console.log( - Object.entries(problems) - .map(([fontName, problems]) => { - let label = `${fontName}`; - if (fontName === "_additional") - label = - "Additional font files not declared in frontend/src/ts/constants/fonts.ts"; - - return `${label}:\n ${problems - .map((error) => "\t- " + error) - .join("\n")}`; - }) - .join("\n") - ); + if (problems.hasError()) { throw new Error("themes with errors"); } } +type Validator = () => Promise; + async function main(): Promise { const args = process.argv.slice(2); - // oxlint-disable-next-line prefer-set-has this error doesnt make sense - const flags = args.filter((arg) => arg.startsWith("-")); + const flags = new Set(args.filter((arg) => arg.startsWith("-"))); const keys = args.filter((arg) => !arg.startsWith("-")); - const mainValidators: Record Promise> = { - quotes: validateQuotes, - languages: validateLanguages, - layouts: validateLayouts, - challenges: validateChallenges, - fonts: validateFonts, - themes: validateThemes, - }; - - const validatorsIndex = { - ...Object.fromEntries( - Object.entries(mainValidators).map(([k, v]) => [k, [v]]) - ), - // add arbitrary keys and validator groupings down here + const validators: Record = { + quotes: [validateQuotes], + languages: [validateLanguages], + layouts: [validateLayouts], + challenges: [validateChallenges], + fonts: [validateFonts], + themes: [validateThemes], others: [ validateChallenges, validateLayouts, @@ -723,20 +643,19 @@ async function main(): Promise { }; // flags - const validateAll = - keys.length < 1 || flags.includes("--all") || flags.includes("-a"); + const validateAll = keys.length < 1 || flags.has("--all") || flags.has("-a"); const passWithNoValidators = - flags.includes("--pass-with-no-validators") || flags.includes("-p"); + flags.has("--pass-with-no-validators") || flags.has("-p"); - const tasks = new Set(validateAll ? Object.values(mainValidators) : []); + const tasks: Set = new Set( + validateAll ? Object.values(validators).flat() : [] + ); for (const key of keys) { - if (!Object.keys(validatorsIndex).includes(key)) { + if (!Object.keys(validators).includes(key)) { console.error(`There is no validator for key '${key}'.`); if (!passWithNoValidators) process.exit(1); } else if (!validateAll) { - //@ts-expect-error magic - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument - validatorsIndex[key].forEach((validator) => tasks.add(validator)); + validators[key]?.forEach((validator) => tasks.add(validator)); } } diff --git a/package.json b/package.json index 4256dab0c..fabd435e5 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,10 @@ "pretty-fix-assets": "prettier --write ./frontend/static", "pretty-fix-pkg": "prettier --write ./packages", "lint-json-assets": "cd frontend && eslint \"./static/**/*.json\"", - "validate-json": "cd frontend && pnpm validate-json", - "check-quote-assets": "cd frontend && npm run validate-json quotes", - "check-language-assets": "cd frontend && npm run validate-json languages", - "check-other-assets": "cd frontend && npm run validate-json others", + "validate-json": "turbo validate-json --filter @monkeytype/frontend", + "check-quote-assets": "turbo validate-json --filter @monkeytype/frontend -- quotes", + "check-language-assets": "turbo validate-json --filter @monkeytype/frontend -- languages", + "check-other-assets": "turbo validate-json --filter @monkeytype/frontend -- others", "knip": "knip" }, "engines": { diff --git a/turbo.json b/turbo.json index 18bb2173b..cfb27ed4e 100644 --- a/turbo.json +++ b/turbo.json @@ -32,7 +32,7 @@ "dependsOn": ["build"] }, "@monkeytype/frontend#validate-json": { - "dependsOn": ["^parallel"] + "dependsOn": ["^parallel", "@monkeytype/schemas#build"] }, "@monkeytype/frontend#build": { "dependsOn": ["^build"],