diff --git a/Dockerfile b/Dockerfile index 4003a01..d17d3db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,7 @@ ENV NODE_ENV production ENV NEXT_TELEMETRY_DISABLED 1 -CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain DEFAULT_DARK_THEME=$dark_theme yarn build && yarn start +CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain DEFAULT_DARK_THEME=$dark_theme \ + NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \ + NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \ + yarn build && yarn start diff --git a/README.md b/README.md index 4d6bd06..68d5003 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ services: environment: - site_domain=lingva.ml - dark_theme=false + - default_source_lang=auto + - default_target_lang=en ports: - 3000:3000 ``` @@ -60,7 +62,7 @@ services: #### Docker Run ```bash -docker run -p 3000:3000 -e site_domain=lingva.ml -e dark_theme=false thedaviddelta/lingva-translate:latest +docker run -p 3000:3000 -e site_domain=lingva.ml -e dark_theme=false -e default_source_lang=auto -e default_target_lang=en thedaviddelta/lingva-translate:latest ``` ### Vercel diff --git a/pages/[[...slug]].tsx b/pages/[[...slug]].tsx index 2c3d459..7824b6a 100644 --- a/pages/[[...slug]].tsx +++ b/pages/[[...slug]].tsx @@ -9,7 +9,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { CustomHead, LangSelect, TranslationArea } from "@components"; import { useToastOnLoad } from "@hooks"; import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate"; -import { retrieveFromType, replaceBoth } from "@utils/language"; +import { retrieveFromType, replaceBoth, isValid } from "@utils/language"; import langReducer, { Actions, initialState } from "@utils/reducer"; import { localGetItem, localSetItem } from "@utils/storage"; @@ -46,18 +46,22 @@ const Page: FC> = ({ home, transl }, [isLoading, source, target, home, initial]); useEffect(() => { - if (home) + if (home) { + const localSource = localGetItem("source"); + const localTarget = localGetItem("target"); return dispatch({ type: Actions.SET_ALL, payload: { state: { ...initialState, - source: localGetItem("source") || initialState.source, - target: localGetItem("target") || initialState.target, + source: isValid(localSource) ? localSource : initialState.source, + target: isValid(localTarget) ? localTarget : initialState.target, isLoading: false } } }); + } + if (!initial) return; @@ -218,6 +222,11 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { } } + if (!isValid(source) || !isValid(target)) + return { + notFound: true + }; + const textScrape = await googleScrape(source, target, query); const [sourceAudio, targetAudio] = await Promise.all([ diff --git a/pages/api/graphql.ts b/pages/api/graphql.ts index 0161608..79fb703 100644 --- a/pages/api/graphql.ts +++ b/pages/api/graphql.ts @@ -2,7 +2,7 @@ import { ApolloServer, gql, IResolvers, ApolloError, UserInputError } from "apol import { NextApiHandler } from "next"; import NextCors from "nextjs-cors"; import { googleScrape, textToSpeechScrape } from "@utils/translate"; -import { retrieveFromType, getName } from "@utils/language"; +import { retrieveFromType, getName, isValid } from "@utils/language"; export const typeDefs = gql` enum LangType { @@ -33,6 +33,10 @@ export const resolvers: IResolvers = { Query: { translation(_, args) { const { source, target, query } = args; + + if (!isValid(source) || !isValid(target)) + throw new UserInputError("Invalid language code"); + return { source: { lang: { @@ -49,6 +53,10 @@ export const resolvers: IResolvers = { }, audio(_, args) { const { lang, query } = args; + + if (!isValid(lang)) + throw new UserInputError("Invalid language code"); + return { lang: { code: lang @@ -87,13 +95,7 @@ export const resolvers: IResolvers = { Language: { name(parent) { const { code, name } = parent; - if (name) - return name; - - const newName = getName(code); - if (!newName) - throw new UserInputError("Invalid language code"); - return newName; + return name || getName(code); } } }; diff --git a/pages/api/v1/[[...slug]].ts b/pages/api/v1/[[...slug]].ts index a9fc236..96b4dc5 100644 --- a/pages/api/v1/[[...slug]].ts +++ b/pages/api/v1/[[...slug]].ts @@ -1,6 +1,7 @@ import { NextApiHandler } from "next"; import NextCors from "nextjs-cors"; import { googleScrape, textToSpeechScrape } from "@utils/translate"; +import { isValid } from "@utils/language"; type Data = { translation: string, @@ -34,6 +35,9 @@ const handler: NextApiHandler = async (req, res) => { const [source, target, query] = slug; + if (!isValid(target)) + return res.status(400).json({ error: "Invalid target language" }); + if (source === "audio") { const audio = await textToSpeechScrape(target, query); return audio @@ -41,6 +45,9 @@ const handler: NextApiHandler = async (req, res) => { : res.status(500).json({ error: "An error occurred while retrieving the audio" }); } + if (!isValid(source)) + return res.status(400).json({ error: "Invalid source language" }); + const textScrape = await googleScrape(source, target, query); if ("errorMsg" in textScrape) diff --git a/pages/api/v1/languages/[[...slug]].ts b/pages/api/v1/languages/[[...slug]].ts index ebc7b3d..b2fa0e4 100644 --- a/pages/api/v1/languages/[[...slug]].ts +++ b/pages/api/v1/languages/[[...slug]].ts @@ -1,10 +1,10 @@ import { NextApiHandler } from "next"; import NextCors from "nextjs-cors"; -import { retrieveFromType } from "@utils/language"; +import { retrieveFromType, LangCode } from "@utils/language"; type Data = { languages: { - code: string, + code: LangCode, name: string }[] } | { diff --git a/tests/pages/[[...slug]].test.tsx b/tests/pages/[[...slug]].test.tsx index fafacd5..7c85bab 100644 --- a/tests/pages/[[...slug]].test.tsx +++ b/tests/pages/[[...slug]].test.tsx @@ -12,8 +12,8 @@ beforeEach(() => { }); describe("getStaticProps", () => { - const source = faker.random.locale(); - const target = faker.random.locale(); + const source = "es"; + const target = "ca"; const query = faker.random.words(); it("returns home on empty params", async () => { diff --git a/tests/pages/api/graphql.test.ts b/tests/pages/api/graphql.test.ts index f0c8859..7e418b4 100644 --- a/tests/pages/api/graphql.test.ts +++ b/tests/pages/api/graphql.test.ts @@ -50,7 +50,7 @@ it("returns translation triggering fetch", async () => { }); it("returns audio triggering fetch", async () => { - const lang = faker.random.locale(); + const lang = "es"; const text = faker.random.words(); resolveFetchWith({ status: 200 }); @@ -93,7 +93,7 @@ it("returns null and throws on translation error", async () => { }); it("returns null and throws on audio error", async () => { - const lang = faker.random.locale(); + const lang = "es"; const text = faker.random.words(); fetchMock.mockRejectOnce(); @@ -178,7 +178,7 @@ it("throws error on empty query in translation", async () => { }); it("throws error on empty lang or query in audio", async () => { - const lang = faker.random.locale(); + const lang = "es"; const text = faker.random.words(); const { errors: queryErrors } = await query({ diff --git a/tests/pages/api/v1/[[...slug]].test.ts b/tests/pages/api/v1/[[...slug]].test.ts index edea1c2..465fb84 100644 --- a/tests/pages/api/v1/[[...slug]].test.ts +++ b/tests/pages/api/v1/[[...slug]].test.ts @@ -7,8 +7,8 @@ beforeEach(() => { fetchMock.resetMocks(); }); -const source = faker.random.locale(); -const target = faker.random.locale(); +const source = "es"; +const target = "ca"; const query = faker.random.words(); const slug = [source, target, query]; diff --git a/tests/utils/language.test.ts b/tests/utils/language.test.ts index 186c817..22bc5ab 100644 --- a/tests/utils/language.test.ts +++ b/tests/utils/language.test.ts @@ -13,7 +13,7 @@ describe("replaceBoth", () => { langType: LangType ) => ( Object.entries(checkObj[langType]).forEach(([code, replacement]) => { - const res = replaceBoth(checkType, { source: "", target: "", [langType]: code }) + const res = replaceBoth(checkType, { source: "auto", target: "en", [langType]: code }) expect(res[langType]).toBe(replacement); }) ); diff --git a/tests/utils/reducer.test.ts b/tests/utils/reducer.test.ts index b09903b..4d73ec0 100644 --- a/tests/utils/reducer.test.ts +++ b/tests/utils/reducer.test.ts @@ -17,13 +17,13 @@ it("changes a field value", () => { it("changes all fields", () => { const query = faker.random.words(); const state = { - source: faker.random.locale(), - target: faker.random.locale(), + source: "zh", + target: "zh_HANT", query, delayedQuery: query, translation: faker.random.words(), isLoading: faker.datatype.boolean() - }; + } as const; const res = langReducer(initialState, { type: Actions.SET_ALL, @@ -37,7 +37,7 @@ it("switches target on source change", () => { ...initialState, source: "es", target: "ca" - }; + } as const; const res = langReducer(state, { type: Actions.SET_FIELD, @@ -57,7 +57,7 @@ it("switches the languages & the translations", () => { target: "ca", query: faker.random.words(), translation: faker.random.words() - }; + } as const; const res = langReducer(state, { type: Actions.SWITCH_LANGS }); expect(res).toStrictEqual({ @@ -75,7 +75,7 @@ it("resets the source while switching if they're the same", () => { ...initialState, source: "eo", target: "eo" - }; + } as const; const res = langReducer(state, { type: Actions.SWITCH_LANGS }); expect(res.source).toStrictEqual(initialState.source); diff --git a/tests/utils/translate.test.ts b/tests/utils/translate.test.ts index a54401d..3dda897 100644 --- a/tests/utils/translate.test.ts +++ b/tests/utils/translate.test.ts @@ -2,8 +2,8 @@ import { htmlRes, resolveFetchWith } from "@tests/commonUtils"; import faker from "faker"; import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate"; -const source = faker.random.locale(); -const target = faker.random.locale(); +const source = "es"; +const target = "ca"; const query = faker.random.words(); describe("googleScrape", () => { diff --git a/utils/language.ts b/utils/language.ts index 01327a7..52426f9 100644 --- a/utils/language.ts +++ b/utils/language.ts @@ -1,6 +1,8 @@ import languagesJson from "./languages.json"; const { languages, exceptions, mappings } = languagesJson; +export type LangCode = keyof typeof languages; + const checkTypes = { exception: exceptions, mapping: mappings @@ -20,10 +22,10 @@ const isKeyOf = (obj: T) => (key: keyof any): key is keyof T = export function replaceBoth( checkType: CheckType, langs: { - [key in LangType]: string + [key in LangType]: LangCode } ): { - [key in LangType]: string + [key in LangType]: LangCode } { const [source, target] = langTypes.map(langType => { const object = checkTypes[checkType][langType]; @@ -33,8 +35,8 @@ export function replaceBoth( return { source, target }; } -export function retrieveFromType(type?: LangType): [string, string][] { - const langEntries = Object.entries(languages); +export function retrieveFromType(type?: LangType) { + const langEntries = Object.entries(languages) as [LangCode, string][]; if (!type) return langEntries; @@ -43,6 +45,10 @@ export function retrieveFromType(type?: LangType): [string, string][] { )); } -export function getName(code: string): string | null { - return isKeyOf(languages)(code) ? languages[code] : null; +export function isValid(code: string | null | undefined): code is LangCode { + return !!code && isKeyOf(languages)(code); +} + +export function getName(code: string): string | null { + return isValid(code) ? languages[code] : null; } diff --git a/utils/reducer.ts b/utils/reducer.ts index c859e36..a8e4647 100644 --- a/utils/reducer.ts +++ b/utils/reducer.ts @@ -1,16 +1,26 @@ -import { replaceBoth } from "./language"; +import { replaceBoth, isValid, LangCode } from "./language"; -export const initialState = { - source: "auto", - target: "en", +const defaultSourceLang = process.env["NEXT_PUBLIC_DEFAULT_SOURCE_LANG"]; +const defaultTargetLang = process.env["NEXT_PUBLIC_DEFAULT_TARGET_LANG"]; + +type State = { + source: LangCode, + target: LangCode, + query: string, + delayedQuery: string, + translation: string, + isLoading: boolean +} + +export const initialState: State = { + source: isValid(defaultSourceLang) ? defaultSourceLang : "auto", + target: isValid(defaultTargetLang) ? defaultTargetLang : "en", query: "", delayedQuery: "", translation: "", isLoading: true } -type State = typeof initialState; - export enum Actions { SET_FIELD, SET_ALL, @@ -32,7 +42,7 @@ type Action = { type: Actions.SWITCH_LANGS } -export default function reducer(state: State, action: Action) { +export default function reducer(state: State, action: Action): State { const { source, target } = replaceBoth("exception", { source: state.target, target: state.source diff --git a/utils/translate.ts b/utils/translate.ts index 4cc73ed..63acdf7 100644 --- a/utils/translate.ts +++ b/utils/translate.ts @@ -1,10 +1,10 @@ import UserAgent from "user-agents"; import cheerio from "cheerio"; -import { replaceBoth } from "./language"; +import { replaceBoth, LangCode } from "./language"; export async function googleScrape( - source: string, - target: string, + source: LangCode, + target: LangCode, query: string ): Promise<{ translationRes: string @@ -57,8 +57,8 @@ export function extractSlug(slug: string[]): { } } -export async function textToSpeechScrape(lang: string, text: string) { - const { target: parsedLang } = replaceBoth("mapping", { source: "", target: lang }); +export async function textToSpeechScrape(lang: LangCode, text: string) { + const { target: parsedLang } = replaceBoth("mapping", { source: "auto", target: lang }); const lastSpace = text.lastIndexOf(" ", 200); const slicedText = text.slice(0, text.length > 200 && lastSpace !== -1 ? lastSpace : 200);