Default languages with env & langcodes typed (#94)

This commit is contained in:
David
2022-02-24 19:53:31 +01:00
committed by GitHub
parent 5e4db73a9f
commit 5856776785
15 changed files with 89 additions and 50 deletions

View File

@@ -23,4 +23,7 @@ ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1 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

View File

@@ -53,6 +53,8 @@ services:
environment: environment:
- site_domain=lingva.ml - site_domain=lingva.ml
- dark_theme=false - dark_theme=false
- default_source_lang=auto
- default_target_lang=en
ports: ports:
- 3000:3000 - 3000:3000
``` ```
@@ -60,7 +62,7 @@ services:
#### Docker Run #### Docker Run
```bash ```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 ### Vercel

View File

@@ -9,7 +9,7 @@ import { useHotkeys } from "react-hotkeys-hook";
import { CustomHead, LangSelect, TranslationArea } from "@components"; import { CustomHead, LangSelect, TranslationArea } from "@components";
import { useToastOnLoad } from "@hooks"; import { useToastOnLoad } from "@hooks";
import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate"; 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 langReducer, { Actions, initialState } from "@utils/reducer";
import { localGetItem, localSetItem } from "@utils/storage"; import { localGetItem, localSetItem } from "@utils/storage";
@@ -46,18 +46,22 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
}, [isLoading, source, target, home, initial]); }, [isLoading, source, target, home, initial]);
useEffect(() => { useEffect(() => {
if (home) if (home) {
const localSource = localGetItem("source");
const localTarget = localGetItem("target");
return dispatch({ return dispatch({
type: Actions.SET_ALL, type: Actions.SET_ALL,
payload: { payload: {
state: { state: {
...initialState, ...initialState,
source: localGetItem("source") || initialState.source, source: isValid(localSource) ? localSource : initialState.source,
target: localGetItem("target") || initialState.target, target: isValid(localTarget) ? localTarget : initialState.target,
isLoading: false isLoading: false
} }
} }
}); });
}
if (!initial) if (!initial)
return; 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 textScrape = await googleScrape(source, target, query);
const [sourceAudio, targetAudio] = await Promise.all([ const [sourceAudio, targetAudio] = await Promise.all([

View File

@@ -2,7 +2,7 @@ import { ApolloServer, gql, IResolvers, ApolloError, UserInputError } from "apol
import { NextApiHandler } from "next"; import { NextApiHandler } from "next";
import NextCors from "nextjs-cors"; import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "@utils/translate"; import { googleScrape, textToSpeechScrape } from "@utils/translate";
import { retrieveFromType, getName } from "@utils/language"; import { retrieveFromType, getName, isValid } from "@utils/language";
export const typeDefs = gql` export const typeDefs = gql`
enum LangType { enum LangType {
@@ -33,6 +33,10 @@ export const resolvers: IResolvers = {
Query: { Query: {
translation(_, args) { translation(_, args) {
const { source, target, query } = args; const { source, target, query } = args;
if (!isValid(source) || !isValid(target))
throw new UserInputError("Invalid language code");
return { return {
source: { source: {
lang: { lang: {
@@ -49,6 +53,10 @@ export const resolvers: IResolvers = {
}, },
audio(_, args) { audio(_, args) {
const { lang, query } = args; const { lang, query } = args;
if (!isValid(lang))
throw new UserInputError("Invalid language code");
return { return {
lang: { lang: {
code: lang code: lang
@@ -87,13 +95,7 @@ export const resolvers: IResolvers = {
Language: { Language: {
name(parent) { name(parent) {
const { code, name } = parent; const { code, name } = parent;
if (name) return name || getName(code);
return name;
const newName = getName(code);
if (!newName)
throw new UserInputError("Invalid language code");
return newName;
} }
} }
}; };

View File

@@ -1,6 +1,7 @@
import { NextApiHandler } from "next"; import { NextApiHandler } from "next";
import NextCors from "nextjs-cors"; import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "@utils/translate"; import { googleScrape, textToSpeechScrape } from "@utils/translate";
import { isValid } from "@utils/language";
type Data = { type Data = {
translation: string, translation: string,
@@ -34,6 +35,9 @@ const handler: NextApiHandler<Data> = async (req, res) => {
const [source, target, query] = slug; const [source, target, query] = slug;
if (!isValid(target))
return res.status(400).json({ error: "Invalid target language" });
if (source === "audio") { if (source === "audio") {
const audio = await textToSpeechScrape(target, query); const audio = await textToSpeechScrape(target, query);
return audio return audio
@@ -41,6 +45,9 @@ const handler: NextApiHandler<Data> = async (req, res) => {
: res.status(500).json({ error: "An error occurred while retrieving the audio" }); : 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); const textScrape = await googleScrape(source, target, query);
if ("errorMsg" in textScrape) if ("errorMsg" in textScrape)

View File

@@ -1,10 +1,10 @@
import { NextApiHandler } from "next"; import { NextApiHandler } from "next";
import NextCors from "nextjs-cors"; import NextCors from "nextjs-cors";
import { retrieveFromType } from "@utils/language"; import { retrieveFromType, LangCode } from "@utils/language";
type Data = { type Data = {
languages: { languages: {
code: string, code: LangCode,
name: string name: string
}[] }[]
} | { } | {

View File

@@ -12,8 +12,8 @@ beforeEach(() => {
}); });
describe("getStaticProps", () => { describe("getStaticProps", () => {
const source = faker.random.locale(); const source = "es";
const target = faker.random.locale(); const target = "ca";
const query = faker.random.words(); const query = faker.random.words();
it("returns home on empty params", async () => { it("returns home on empty params", async () => {

View File

@@ -50,7 +50,7 @@ it("returns translation triggering fetch", async () => {
}); });
it("returns audio triggering fetch", async () => { it("returns audio triggering fetch", async () => {
const lang = faker.random.locale(); const lang = "es";
const text = faker.random.words(); const text = faker.random.words();
resolveFetchWith({ status: 200 }); 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 () => { it("returns null and throws on audio error", async () => {
const lang = faker.random.locale(); const lang = "es";
const text = faker.random.words(); const text = faker.random.words();
fetchMock.mockRejectOnce(); 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 () => { 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 text = faker.random.words();
const { errors: queryErrors } = await query({ const { errors: queryErrors } = await query({

View File

@@ -7,8 +7,8 @@ beforeEach(() => {
fetchMock.resetMocks(); fetchMock.resetMocks();
}); });
const source = faker.random.locale(); const source = "es";
const target = faker.random.locale(); const target = "ca";
const query = faker.random.words(); const query = faker.random.words();
const slug = [source, target, query]; const slug = [source, target, query];

View File

@@ -13,7 +13,7 @@ describe("replaceBoth", () => {
langType: LangType langType: LangType
) => ( ) => (
Object.entries(checkObj[langType]).forEach(([code, replacement]) => { 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); expect(res[langType]).toBe(replacement);
}) })
); );

View File

@@ -17,13 +17,13 @@ it("changes a field value", () => {
it("changes all fields", () => { it("changes all fields", () => {
const query = faker.random.words(); const query = faker.random.words();
const state = { const state = {
source: faker.random.locale(), source: "zh",
target: faker.random.locale(), target: "zh_HANT",
query, query,
delayedQuery: query, delayedQuery: query,
translation: faker.random.words(), translation: faker.random.words(),
isLoading: faker.datatype.boolean() isLoading: faker.datatype.boolean()
}; } as const;
const res = langReducer(initialState, { const res = langReducer(initialState, {
type: Actions.SET_ALL, type: Actions.SET_ALL,
@@ -37,7 +37,7 @@ it("switches target on source change", () => {
...initialState, ...initialState,
source: "es", source: "es",
target: "ca" target: "ca"
}; } as const;
const res = langReducer(state, { const res = langReducer(state, {
type: Actions.SET_FIELD, type: Actions.SET_FIELD,
@@ -57,7 +57,7 @@ it("switches the languages & the translations", () => {
target: "ca", target: "ca",
query: faker.random.words(), query: faker.random.words(),
translation: faker.random.words() translation: faker.random.words()
}; } as const;
const res = langReducer(state, { type: Actions.SWITCH_LANGS }); const res = langReducer(state, { type: Actions.SWITCH_LANGS });
expect(res).toStrictEqual({ expect(res).toStrictEqual({
@@ -75,7 +75,7 @@ it("resets the source while switching if they're the same", () => {
...initialState, ...initialState,
source: "eo", source: "eo",
target: "eo" target: "eo"
}; } as const;
const res = langReducer(state, { type: Actions.SWITCH_LANGS }); const res = langReducer(state, { type: Actions.SWITCH_LANGS });
expect(res.source).toStrictEqual(initialState.source); expect(res.source).toStrictEqual(initialState.source);

View File

@@ -2,8 +2,8 @@ import { htmlRes, resolveFetchWith } from "@tests/commonUtils";
import faker from "faker"; import faker from "faker";
import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate"; import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate";
const source = faker.random.locale(); const source = "es";
const target = faker.random.locale(); const target = "ca";
const query = faker.random.words(); const query = faker.random.words();
describe("googleScrape", () => { describe("googleScrape", () => {

View File

@@ -1,6 +1,8 @@
import languagesJson from "./languages.json"; import languagesJson from "./languages.json";
const { languages, exceptions, mappings } = languagesJson; const { languages, exceptions, mappings } = languagesJson;
export type LangCode = keyof typeof languages;
const checkTypes = { const checkTypes = {
exception: exceptions, exception: exceptions,
mapping: mappings mapping: mappings
@@ -20,10 +22,10 @@ const isKeyOf = <T extends object>(obj: T) => (key: keyof any): key is keyof T =
export function replaceBoth( export function replaceBoth(
checkType: CheckType, checkType: CheckType,
langs: { langs: {
[key in LangType]: string [key in LangType]: LangCode
} }
): { ): {
[key in LangType]: string [key in LangType]: LangCode
} { } {
const [source, target] = langTypes.map(langType => { const [source, target] = langTypes.map(langType => {
const object = checkTypes[checkType][langType]; const object = checkTypes[checkType][langType];
@@ -33,8 +35,8 @@ export function replaceBoth(
return { source, target }; return { source, target };
} }
export function retrieveFromType(type?: LangType): [string, string][] { export function retrieveFromType(type?: LangType) {
const langEntries = Object.entries(languages); const langEntries = Object.entries(languages) as [LangCode, string][];
if (!type) if (!type)
return langEntries; return langEntries;
@@ -43,6 +45,10 @@ export function retrieveFromType(type?: LangType): [string, string][] {
)); ));
} }
export function getName(code: string): string | null { export function isValid(code: string | null | undefined): code is LangCode {
return isKeyOf(languages)(code) ? languages[code] : null; return !!code && isKeyOf(languages)(code);
}
export function getName(code: string): string | null {
return isValid(code) ? languages[code] : null;
} }

View File

@@ -1,16 +1,26 @@
import { replaceBoth } from "./language"; import { replaceBoth, isValid, LangCode } from "./language";
export const initialState = { const defaultSourceLang = process.env["NEXT_PUBLIC_DEFAULT_SOURCE_LANG"];
source: "auto", const defaultTargetLang = process.env["NEXT_PUBLIC_DEFAULT_TARGET_LANG"];
target: "en",
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: "", query: "",
delayedQuery: "", delayedQuery: "",
translation: "", translation: "",
isLoading: true isLoading: true
} }
type State = typeof initialState;
export enum Actions { export enum Actions {
SET_FIELD, SET_FIELD,
SET_ALL, SET_ALL,
@@ -32,7 +42,7 @@ type Action = {
type: Actions.SWITCH_LANGS 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", { const { source, target } = replaceBoth("exception", {
source: state.target, source: state.target,
target: state.source target: state.source

View File

@@ -1,10 +1,10 @@
import UserAgent from "user-agents"; import UserAgent from "user-agents";
import cheerio from "cheerio"; import cheerio from "cheerio";
import { replaceBoth } from "./language"; import { replaceBoth, LangCode } from "./language";
export async function googleScrape( export async function googleScrape(
source: string, source: LangCode,
target: string, target: LangCode,
query: string query: string
): Promise<{ ): Promise<{
translationRes: string translationRes: string
@@ -57,8 +57,8 @@ export function extractSlug(slug: string[]): {
} }
} }
export async function textToSpeechScrape(lang: string, text: string) { export async function textToSpeechScrape(lang: LangCode, text: string) {
const { target: parsedLang } = replaceBoth("mapping", { source: "", target: lang }); const { target: parsedLang } = replaceBoth("mapping", { source: "auto", target: lang });
const lastSpace = text.lastIndexOf(" ", 200); const lastSpace = text.lastIndexOf(" ", 200);
const slicedText = text.slice(0, text.length > 200 && lastSpace !== -1 ? lastSpace : 200); const slicedText = text.slice(0, text.length > 200 && lastSpace !== -1 ? lastSpace : 200);