Default languages with env & langcodes typed (#94)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}[]
|
}[]
|
||||||
} | {
|
} | {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user