1
0
mirror of https://github.com/TheDavidDelta/lingva-translate.git synced 2025-10-06 00:02:52 +02:00

Refactor to external scraper and update dependencies (#113)

This commit is contained in:
David
2022-06-15 23:37:15 +02:00
committed by GitHub
parent ff1ad202ae
commit 274e7f1a4b
49 changed files with 6952 additions and 4414 deletions

View File

@@ -5,7 +5,7 @@ node_js:
os: linux
dist: xenial
dist: focal
services:
- docker
@@ -26,7 +26,7 @@ branches:
script:
- yarn test --ci
- yarn build
- NEXT_PUBLIC_FORCE_DEFAULT_THEME=light yarn build
- yarn start & wait-on http://localhost:3000
- 'if [ "$TRAVIS_PULL_REQUEST" = "false" ];
then

View File

@@ -23,7 +23,8 @@ ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain DEFAULT_DARK_THEME=$dark_theme \
CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain\
NEXT_PUBLIC_FORCE_DEFAULT_THEME=$force_default_theme \
NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
yarn build && yarn start

View File

@@ -2,10 +2,10 @@
<img src="public/logo.svg" width="128" align="right">
[![Travis Build](https://travis-ci.com/TheDavidDelta/lingva-translate.svg?branch=main)](https://travis-ci.com/TheDavidDelta/lingva-translate)
[![Vercel Status](https://img.shields.io/github/deployments/TheDavidDelta/lingva-translate/Production?label=vercel&logo=vercel&color=f5f5f5)](https://lingva.ml/)
[![Travis Build](https://travis-ci.com/thedaviddelta/lingva-translate.svg?branch=main)](https://travis-ci.com/thedaviddelta/lingva-translate)
[![Vercel Status](https://img.shields.io/github/deployments/thedaviddelta/lingva-translate/Production?label=vercel&logo=vercel&color=f5f5f5)](https://lingva.ml/)
[![Cypress Tests](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/qgjdyd&style=flat&logo=cypress)](https://dashboard.cypress.io/projects/qgjdyd/runs)
[![License](https://img.shields.io/github/license/TheDavidDelta/lingva-translate)](./LICENSE)
[![License](https://img.shields.io/github/license/thedaviddelta/lingva-translate)](./LICENSE)
[![Awesome Humane Tech](https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true)](https://github.com/humanetech-community/awesome-humane-tech)
[<img src="https://www.datocms-assets.com/31049/1618983297-powered-by-vercel.svg" alt="Powered by Vercel" height="20">](https://vercel.com?utm_source=lingva-team&utm_campaign=oss)
@@ -14,10 +14,11 @@ Alternative front-end for Google Translate, serving as a Free and Open Source tr
## How does it work?
Inspired by projects like [NewPipe](https://github.com/TeamNewPipe/NewPipe), [Nitter](https://github.com/zedeus/nitter), [Invidious](https://github.com/iv-org/invidious) or [Bibliogram](https://git.sr.ht/~cadence/bibliogram), *Lingva* scrapes through GTranslate and retrieves the translation without using any Google-related service, preventing them from tracking.
Inspired by projects like [NewPipe](https://github.com/TeamNewPipe/NewPipe), [Nitter](https://github.com/zedeus/nitter), [Invidious](https://github.com/iv-org/invidious) or [Bibliogram](https://git.sr.ht/~cadence/bibliogram), *Lingva* scrapes through Google Translate and retrieves the translation without directly accessing any Google-related service, preventing them from tracking.
For this purpose, *Lingva* is built, among others, with the following Open Source resources:
+ [Lingva Scraper](https://github.com/thedaviddelta/lingva-scraper), a Google Translate scraper built and maintained specifically for this project, which obtains all kind of information from this platform.
+ [TypeScript](https://www.typescriptlang.org/), the JavaScript superset, as the language.
+ [React](https://reactjs.org/) as the main front-end framework.
+ [Next.js](https://nextjs.org/) as the complementary React framework, that provides Server-Side Rendering, Static Site Generation or serverless API endpoints.
@@ -33,11 +34,14 @@ As *Lingva* is a [Next.js](https://nextjs.org/) project you can deploy your own
The only requirement is to set an environment variable called `NEXT_PUBLIC_SITE_DOMAIN` with the domain you're deploying the instance under. This is used for the canonical URL and the meta tags.
Optionally, there's another environment variable available called `DEFAULT_DARK_THEME` for selecting dark as the default page theme on the first load. The theme will be light by default unless this variable is set to `true`.
Optionally, there are other environment variables available:
+ `NEXT_PUBLIC_FORCE_DEFAULT_THEME`: Force a certain theme over the system preference set by the user. The accepted values are `light` and `dark`.
+ `NEXT_PUBLIC_DEFAULT_SOURCE_LANG`: Set an initial *source* language instead of the default `auto`.
+ `NEXT_PUBLIC_DEFAULT_TARGET_LANG`: Set an initial *target* language instead of the default `en`.
### Docker
An [official Docker image](https://hub.docker.com/r/thedaviddelta/lingva-translate) is available to ease the deployment using Compose, Kubernetes or similar technologies. Remember to also include the environment variables (simplified to `site_domain` and `dark_theme`) when running the container.
An [official Docker image](https://hub.docker.com/r/thedaviddelta/lingva-translate) is available to ease the deployment using Compose, Kubernetes or similar technologies. Remember to also include the environment variables (simplified to `site_domain`, `force_default_theme`, `default_source_lang` and `default_target_lang`) when running the container.
#### Docker Compose:
@@ -52,7 +56,7 @@ services:
restart: unless-stopped
environment:
- site_domain=lingva.ml
- dark_theme=false
- force_default_theme=light
- default_source_lang=auto
- default_target_lang=en
ports:
@@ -62,14 +66,14 @@ services:
#### Docker Run
```bash
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
docker run -p 3000:3000 -e site_domain=lingva.ml -e force_default_theme=light -e default_source_lang=auto -e default_target_lang=en thedaviddelta/lingva-translate:latest
```
### Vercel
Another easy way is to use the Next.js creators' own platform, [Vercel](https://vercel.com/), where you can deploy it for free with the following button.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2FTheDavidDelta%2Flingva-translate%2Ftree%2Fmain&env=NEXT_PUBLIC_SITE_DOMAIN&envDescription=Your%20domain&utm_source=lingva-team&utm_campaign=oss)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fthedaviddelta%2Flingva-translate%2Ftree%2Fmain&env=NEXT_PUBLIC_SITE_DOMAIN&envDescription=Your%20domain&utm_source=lingva-team&utm_campaign=oss)
## Instances
@@ -79,12 +83,7 @@ These are the currently known *Lingva* instances. Feel free to make a Pull Reque
| Domain | Hosting | SSL Provider |
|:-------------------------------------------------------------------:|:-----------------------------------------:|:--------------------------------------------------------------------------------------------:|
| [lingva.ml](https://lingva.ml/) (Official) | [Vercel](https://vercel.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.ml) |
| [translate.alefvanoon.xyz](https://translate.alefvanoon.xyz) | [Vercel](https://vercel.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.alefvanoon.xyz) |
| [translate.igna.rocks](https://translate.igna.rocks) | [Vercel](https://vercel.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.igna.rocks) |
| [lingva.pussthecat.org](https://lingva.pussthecat.org) | [Hetzner](https://hetzner.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.pussthecat.org) |
| [translate.datatunnel.xyz](https://translate.datatunnel.xyz) | [Hetzner](https://hetzner.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.datatunnel.xyz) |
| [lingva.esmailelbob.xyz](https://lingva.esmailelbob.xyz/) | [Kimsufi](https://kimsufi.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.esmailelbob.xyz) |
| [translate.plausibility.cloud](https://translate.plausibiity.cloud) | [Hetzner](https://hetzner.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.plausibility.cloud) |
| [lingva.lunar.icu](https://lingva.lunar.icu/) | [Lansol](https://lansol.de/) | [Cloudflare](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.lunar.icu) |
## Public APIs
@@ -99,6 +98,7 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs:
```typescript
{
translation: string
info?: TranslationInfo
}
```
@@ -141,6 +141,23 @@ query {
}
text: String!
audio: [Int]!
detected: {
code: String
name: String
}
typo: String
pronunciation: String
definitions: {
type: String
list: {
definition: String
example: String
field: String
synonyms: [String]
}
}
examples: [String]
similar: [String]
}
target: {
lang: {
@@ -149,6 +166,16 @@ query {
}
text: String!
audio: [Int]!
pronunciation: String
extraTranslations: {
type: String
list: {
word: String
article: String
frequency: Int
meanings: [String]
}
}
}
}
audio(lang: String! query: String!) {
@@ -169,9 +196,12 @@ query {
## Related projects
+ [SimplyTranslate](https://sr.ht/~metalune/SimplyTranslate/) - Very simple translation front-end with multi-engine support
+ [Lingva Scraper](https://github.com/thedaviddelta/lingva-scraper) - Google Translate scraper built and maintained specifically for this project
+ [SimplyTranslate](https://codeberg.org/SimpleWeb/SimplyTranslate-Web) - Very simple translation front-end with multi-engine support
+ [LibreTranslate](https://github.com/LibreTranslate/LibreTranslate) - FOSS translation service that uses the open [Argos](https://github.com/argosopentech/argos-translate) engine
+ [Lentil for Android](https://github.com/yaxarat/lingvaandroid) - Unofficial native client for Android that uses Lingva's public API
+ [Arna Translate](https://github.com/MahanRahmati/translate) - Unofficial cross-platform native client that uses Lingva's public API
+ [Translate-UT](https://github.com/walking-octopus/translate-ut) - Unofficial native client for Ubuntu Touch that uses Lingva's public API
## Contributors
@@ -201,5 +231,5 @@ This project follows the [all-contributors](https://github.com/all-contributors/
[![](https://www.gnu.org/graphics/agplv3-with-text-162x68.png)](https://www.gnu.org/licenses/agpl-3.0.html)
Copyright © 2021 [TheDavidDelta](https://github.com/TheDavidDelta) & contributors.
Copyright © 2021 [thedaviddelta](https://github.com/thedaviddelta) & contributors.
This project is [GNU AGPLv3](./LICENSE) licensed.

View File

@@ -5,7 +5,7 @@ import theme from "@theme";
type Props = {
customTitle?: string,
home?: true
home?: boolean
};
const title = "Lingva Translate";

View File

@@ -19,8 +19,8 @@ const Footer: FC<Props> = (props) => (
spacing={[1, null, 2]}
{...props}
>
<Link href="https://github.com/TheDavidDelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
<Text as="span">&#169; 2021 TheDavidDelta & contributors</Text>
<Link href="https://github.com/thedaviddelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
<Text as="span">&#169; 2021 thedaviddelta & contributors</Text>
</Link>
<Text as="span" display={["none", null, "unset"]}>·</Text>
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>

View File

@@ -42,7 +42,7 @@ const Header: FC<Props> = (props) => (
/>
<IconButton
as={Link}
href="https://github.com/TheDavidDelta/lingva-translate"
href="https://github.com/thedaviddelta/lingva-translate"
isExternal={true}
aria-label="GitHub"
icon={<FaGithub />}

View File

@@ -1,14 +1,18 @@
import { FC, ChangeEvent } from "react";
import { Select } from "@chakra-ui/react";
import { LangCode } from "lingva-scraper";
type Props = {
value: string,
onChange: (e: ChangeEvent<any>) => void,
langs: [string, string][],
langs: {
[code in LangCode]: string
},
detectedSource?: LangCode<"source">,
[key: string]: any
};
const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
const LangSelect: FC<Props> = ({ value, onChange, langs, detectedSource, ...props }) => (
<Select
value={value}
onChange={onChange}
@@ -18,8 +22,10 @@ const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
style={{ textAlignLast: "center" }}
{...props}
>
{langs.map(([code, name]) => (
<option value={code} key={code}>{name}</option>
{Object.entries(langs).map(([code, name]) => (
<option value={code} key={code}>
{name}{code === "auto" && !!detectedSource && ` (${langs[detectedSource]})`}
</option>
))}
</Select>
);

View File

@@ -1,4 +1,4 @@
import { FC } from "react";
import { FC, PropsWithChildren } from "react";
import { Flex, VStack, Button, Link, useColorModeValue } from "@chakra-ui/react";
import { Header, Footer } from ".";
@@ -6,7 +6,7 @@ type Props = {
[key: string]: any
};
const Layout: FC<Props> = ({ children, ...props }) => (
const Layout: FC<PropsWithChildren<Props>> = ({ children, ...props }) => (
<>
<Button
as={Link}
@@ -22,7 +22,7 @@ const Layout: FC<Props> = ({ children, ...props }) => (
Skip to content
</Button>
<VStack minH="100vh" spacing={8}>
<VStack minH="100%" spacing={7}>
<Header
bgColor={useColorModeValue("lingva.100", "lingva.900")}
/>

View File

@@ -1,5 +1,17 @@
import { FC } from "react";
import { Box, HStack, Textarea, IconButton, Tooltip, Spinner, TextareaProps, useBreakpointValue, useColorModeValue, useClipboard } from "@chakra-ui/react";
import {
VStack,
HStack,
Text,
Textarea,
IconButton,
Tooltip,
Spinner,
TextareaProps,
useBreakpointValue,
useColorModeValue,
useClipboard
} from "@chakra-ui/react";
import { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa";
import { useAudioFromBuffer } from "@hooks";
@@ -14,20 +26,32 @@ type Props = {
[key: string]: any
};
const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio, canCopy, isLoading, ...props }) => {
const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio, canCopy, isLoading, pronunciation, ...props }) => {
const { hasCopied, onCopy } = useClipboard(value);
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
const spinnerProps = {
size: useBreakpointValue(["lg", null, "xl"]) ?? undefined,
size: useBreakpointValue(["lg", null, "xl"]) ?? "lg",
color: useColorModeValue("lingva.500", "lingva.200"),
emptyColor: useColorModeValue("gray.300", "gray.600")
};
return (
<Box
position="relative"
<VStack
w="full"
h={useBreakpointValue([44, null, 80]) ?? 44}
align="stretch"
spacing={0}
position="relative"
isolation="isolate"
border="1px solid"
borderColor={useColorModeValue("lingva.500", "lingva.200")}
borderRadius="md"
_hover={{
borderColor: useColorModeValue("lingva.800", "lingva.400"),
}}
_readOnly={{
userSelect: "auto"
}}
>
<Textarea
value={value}
@@ -35,54 +59,66 @@ const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio
readOnly={readOnly}
dir="auto"
resize="none"
rows={useBreakpointValue([6, null, 12]) ?? undefined}
size="lg"
variant="ghost"
boxShadow={`inset 0 0 1px ${useColorModeValue("hsl(146 100% 17% / 25%)", "hsl(142 40% 82% / 25%)")}`}
data-gramm_editor={false}
onKeyPress={e => (e.ctrlKey || e.metaKey) && e.key === "Enter" && onSubmit?.()}
flex={1}
bgColor="transparent"
_focus={{
bgColor: useColorModeValue("hsl(0deg 0% 0% / 2.5%)", "hsl(0deg 0% 100% / 2.5%)")
}}
{...props}
/>
<HStack
position="absolute"
bottom={4}
right={4}
// Needed because the textarea stacks over on focus
zIndex={3}
position={!pronunciation ? "absolute" : undefined}
pointerEvents={!pronunciation ? "none" : undefined}
bottom={0}
left={0}
right={0}
>
{canCopy && (
<Tooltip label={hasCopied ? "Copied!" : "Copy to clipboard"}>
<IconButton
aria-label="Copy to clipboard"
icon={hasCopied ? <FaCheck /> : <FaCopy />}
onClick={onCopy}
colorScheme="lingva"
variant="ghost"
disabled={!value}
/>
<HStack justify="space-between" px={5} h={useBreakpointValue([12, null, 14]) ?? 12} w="0px" flex={1}>
<Tooltip label={pronunciation}>
<Text aria-label="Pronunciation" opacity={0.75} whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
{pronunciation}
</Text>
</Tooltip>
)}
<Tooltip label={isAudioPlaying ? "Stop audio" : "Play audio"}>
<IconButton
aria-label={isAudioPlaying ? "Stop audio" : "Play audio"}
icon={isAudioPlaying ? <FaStop /> : <FaPlay />}
onClick={onAudioClick}
colorScheme="lingva"
variant="ghost"
disabled={!audioExists}
/>
</Tooltip>
<HStack pointerEvents="auto">
{canCopy && (
<Tooltip label={hasCopied ? "Copied!" : "Copy to clipboard"}>
<IconButton
aria-label="Copy to clipboard"
icon={hasCopied ? <FaCheck /> : <FaCopy />}
onClick={onCopy}
colorScheme="lingva"
variant="ghost"
disabled={!value}
/>
</Tooltip>
)}
<Tooltip label={isAudioPlaying ? "Stop audio" : "Play audio"}>
<IconButton
aria-label={isAudioPlaying ? "Stop audio" : "Play audio"}
icon={isAudioPlaying ? <FaStop /> : <FaPlay />}
onClick={onAudioClick}
colorScheme="lingva"
variant="ghost"
disabled={!audioExists}
/>
</Tooltip>
</HStack>
</HStack>
</HStack>
{isLoading && <Spinner
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
m="auto"
inset={0}
m="auto !important"
thickness="3px"
label="Loading translation"
{...spinnerProps}
/>}
</Box>
</VStack>
);
};

13
cypress.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'cypress';
export default defineConfig({
defaultCommandTimeout: 10000,
waitForAnimations: true,
retries: 4,
projectId: 'qgjdyd',
e2e: {
setupNodeEvents(on, config) {},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}'
},
});

View File

@@ -1,5 +0,0 @@
{
"baseUrl": "http://localhost:3000",
"defaultCommandTimeout": 10000,
"projectId": "qgjdyd"
}

View File

@@ -1,7 +1,5 @@
/// <reference types="cypress" />
import faker from "faker";
beforeEach(() => {
cy.visit("/");
cy.clearLocalStorage();
@@ -72,7 +70,7 @@ it("switches page on inputs change & goes back correctly", () => {
});
it("switches first loaded page and back and forth on language change", () => {
const query = faker.random.words();
const query = "Texto aleatorio";
cy.visit(`/auto/en/${query}`);
cy.findByRole("button", { name: /switch auto/i })
@@ -134,8 +132,8 @@ it("language switching button is disabled on 'auto', but enables when other", ()
});
it("loads & plays audio correctly", () => {
const query = faker.lorem.words(5);
cy.visit(`/la/en/${query}`);
const query = "No hi havia a València dos amants com nosaltres,\ncar d'amants com nosaltres en són parits ben pocs.";
cy.visit(`/ca/en/${query}`);
const play = "Play audio";
const stop = "Stop audio";

View File

@@ -1,10 +1,5 @@
[
"https://lingva.ml",
"https://translate.alefvanoon.xyz",
"https://translate.igna.rocks",
"https://lingva.pussthecat.org",
"https://translate.datatunnel.xyz",
"https://lingva.esmailelbob.xyz",
"https://translate.plausibility.cloud",
"https://lingva.lunar.icu"
]

View File

@@ -1,24 +1,18 @@
module.exports = {
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest'
const nextJest = require("next/jest");
const createJestConfig = nextJest({
dir: "./"
});
module.exports = createJestConfig({
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^@(components|hooks|mocks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2"
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$',
'^.+\\.module\\.(css|sass|scss)$',
],
testMatch: [
'<rootDir>/**/tests/*/**/*.{js,jsx,ts,tsx}',
'<rootDir>/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
testPathIgnorePatterns: [
'<rootDir>/cypress/'
],
setupFilesAfterEnv: [
"<rootDir>/tests/setupTests.ts"
],
moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
moduleNameMapper: {
"^@(components|hooks|mocks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2"
},
testEnvironment: "jsdom"
}
]
});

2978
mocks/data/audio.json Normal file

File diff suppressed because it is too large Load Diff

287
mocks/data/fullInfo.json Normal file
View File

@@ -0,0 +1,287 @@
{
"detectedSource": "en",
"pronunciation": {
"query": "win"
},
"definitions": [
{
"type": "verb",
"list": [
{
"definition": "be successful or victorious in (a contest or conflict).",
"example": "the Mets have won four games in a row",
"synonyms": [
"come first in",
"finish first in",
"be victorious in",
"triumph in",
"take first prize in",
"achieve success in",
"be successful in",
"prevail in",
"come first",
"finish first",
"be the winner",
"be victorious",
"be the victor",
"carry/win the day",
"carry all before one",
"defeat/overcome the opposition",
"take the honors/crown",
"gain the palm",
"come out ahead",
"come out on top",
"succeed",
"triumph",
"prevail",
"achieve mastery",
"sweep the board",
"make a clean sweep",
"wrap up",
"win out",
"clean up"
]
},
{
"definition": "acquire or secure as a result of a contest, conflict, bet, or other endeavor.",
"example": "there are hundreds of prizes to be won",
"synonyms": [
"secure",
"gain",
"achieve",
"attain",
"earn",
"obtain",
"acquire",
"procure",
"get",
"collect",
"pick up",
"walk away/off with",
"come away with",
"carry off",
"receive",
"land",
"net",
"bag",
"bank",
"pot",
"scoop"
]
}
]
},
{
"type": "noun",
"list": [
{
"definition": "a successful result in a contest, conflict, bet, or other endeavor; a victory.",
"example": "a win against Norway",
"synonyms": [
"victory",
"triumph",
"conquest",
"success",
"game",
"set",
"and match"
]
}
]
}
],
"examples": [
"a <b>win</b> against Norway",
"many lived to <b>win</b> the great cave",
"you will find it difficult to <b>win</b> back their attention",
"a determination to <b>win</b>"
],
"similar": [],
"extraTranslations": [
{
"type": "verb",
"list": [
{
"word": "ganar",
"meanings": [
"win",
"earn",
"gain",
"make",
"get",
"beat"
],
"frequency": 3
},
{
"word": "vencer",
"meanings": [
"overcome",
"beat",
"defeat",
"win",
"conquer",
"expire"
],
"frequency": 2
},
{
"word": "triunfar",
"meanings": [
"succeed",
"triumph",
"win",
"prevail",
"overcome",
"trump"
],
"frequency": 1
},
{
"word": "conseguir",
"meanings": [
"get",
"achieve",
"obtain",
"gain",
"attain",
"win"
],
"frequency": 1
},
{
"word": "lograr",
"meanings": [
"achieve",
"accomplish",
"get",
"attain",
"reach",
"win"
],
"frequency": 1
},
{
"word": "alcanzar",
"meanings": [
"reach",
"achieve",
"attain",
"accomplish",
"hit",
"catch up"
],
"frequency": 1
},
{
"word": "llevarse",
"meanings": [
"get",
"take away",
"win",
"carry away",
"carry off",
"walk away"
],
"frequency": 1
},
{
"word": "tener éxito",
"meanings": [
"succeed",
"be successful",
"win",
"take",
"get on",
"make the grade"
],
"frequency": 1
},
{
"word": "captar",
"meanings": [
"capture",
"catch",
"attract",
"get",
"pick up",
"understand"
],
"frequency": 1
},
{
"word": "arrancar",
"meanings": [
"tear",
"pull",
"pluck",
"tear off",
"pull up",
"extract"
],
"frequency": 1
},
{
"word": "atraerse",
"meanings": [
"win",
"win over",
"win round"
],
"frequency": 1
},
{
"word": "extraer",
"meanings": [
"extract",
"draw",
"pull",
"pull out",
"mine",
"take out"
],
"frequency": 1
}
]
},
{
"type": "noun",
"list": [
{
"word": "triunfo",
"article": "el",
"meanings": [
"triumph",
"win",
"success",
"trump"
],
"frequency": 3
},
{
"word": "victoria",
"article": "la",
"meanings": [
"victory",
"win",
"victoria"
],
"frequency": 3
},
{
"word": "éxito",
"article": "el",
"meanings": [
"success",
"hit",
"achievement",
"accomplishment",
"win",
"triumph"
],
"frequency": 1
}
]
}
]
}

25
mocks/data/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { TranslationInfo, LangCode } from "lingva-scraper";
import fullInfo from "./fullInfo.json";
import simpleInfo from "./simpleInfo.json";
import pronunciationInfo from "./pronunciationInfo.json";
import audio from "./audio.json";
export const fullInfoMock = fullInfo as TranslationInfo;
export const simpleInfoMock = simpleInfo as TranslationInfo;
export const pronunciationInfoMock = pronunciationInfo as TranslationInfo;
export const audioMock = {
query: audio as number[],
translation: audio as number[]
};
export const translationMock = "victoria";
export const initialMock = {
source: "es" as LangCode<"source">,
target: "en" as LangCode<"target">,
query: "hola"
};
export const initialAutoMock = {
source: "auto" as LangCode<"source">,
target: "es" as LangCode<"target">,
query: "win"
};

View File

@@ -0,0 +1,10 @@
{
"pronunciation": {
"query": "Zǎo ān",
"translation": "joh-eun achim"
},
"definitions": [],
"examples": [],
"similar": [],
"extraTranslations": []
}

View File

@@ -0,0 +1,7 @@
{
"pronunciation": {},
"definitions": [],
"examples": [],
"similar": [],
"extraTranslations": []
}

View File

@@ -1,3 +0,0 @@
import Router from "next/router";
export const routerPushMock = jest.spyOn(Router, "push").mockImplementation(async () => true);

31
mocks/next.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { FC, PropsWithChildren } from "react";
import { RouterContext } from "next/dist/shared/lib/router-context";
export const routerMock = {
basePath: "",
pathname: "/",
route: "/",
asPath: "/",
query: {},
push: jest.fn().mockResolvedValue(true),
replace: jest.fn().mockResolvedValue(true),
reload: jest.fn(),
back: jest.fn(),
prefetch: jest.fn().mockResolvedValue(undefined),
beforePopState: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
isFallback: false,
isLocaleDomain: false,
isReady: false,
isPreview: false
};
export const RouterProviderMock: FC<PropsWithChildren> = ({ children }) => (
<RouterContext.Provider value={routerMock}>
{children}
</RouterContext.Provider>
);

40
mocks/renewData.ts Normal file
View File

@@ -0,0 +1,40 @@
import { writeFile } from "node:fs/promises";
import { getTranslationInfo, getAudio, LangCode } from "lingva-scraper";
const handleError = (obj: object | null) => {
if (!obj)
throw new Error();
return obj;
};
const renew = {
fullInfo: () => getTranslationInfo("auto" as LangCode<"source">, "es" as LangCode<"target">, "win").then(handleError),
simpleInfo: () => getTranslationInfo("es" as LangCode<"source">, "en" as LangCode<"target">, "hola").then(handleError),
pronunciationInfo: () => getTranslationInfo("zh" as LangCode<"source">, "ko" as LangCode<"target">, "早安").then(handleError),
audio: () => getAudio("es" as LangCode<"target">, "hola").then(handleError),
};
type DataType = keyof typeof renew;
const save = (json: object, type: DataType) => {
writeFile(
`./mocks/data/${type}.json`,
JSON.stringify(json, null, 2),
"utf-8"
).then(() =>
console.log(`Successfully renewed '${type}'`)
).catch(() =>
console.log(`Error renewing '${type}'`)
)
};
const isKeyOf = <T extends object>(obj: T, key: keyof any): key is keyof T => key in obj;
const arg = process.argv[2];
if (arg && isKeyOf(renew, arg))
renew[arg]().then(json => save(json, arg));
else
Object.entries(renew).forEach(([key, fn]) =>
isKeyOf(renew, key) && fn().then(json => save(json, key))
);

View File

@@ -1,6 +1,7 @@
const withPWA = require("next-pwa");
module.exports = withPWA({
swcMinify: true,
pwa: {
dest: "public"
},

View File

@@ -9,60 +9,55 @@
"start": "next start --port ${PORT-3000}",
"test": "jest",
"cy:open": "cypress open",
"cy:run": "cypress run"
"cy:run": "cypress run",
"renew-mock-data": "ts-node --skip-project mocks/renewData.ts"
},
"dependencies": {
"@chakra-ui/icons": "1.0.15",
"@chakra-ui/react": "1.6.6",
"@chakra-ui/icons": "2.0.2",
"@chakra-ui/react": "2.2.1",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"apollo-server-micro": "^2.25.2",
"cheerio": "^1.0.0-rc.10",
"framer-motion": "^4",
"framer-motion": "^6",
"graphql": "^15.8.0",
"next": "12.1.0",
"lingva-scraper": "1.0.0",
"next": "12.1.6",
"next-pwa": "^5.4.4",
"nextjs-cors": "^2.1.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-hotkeys-hook": "^3.4.4",
"react-icons": "^4.3.1",
"user-agents": "^1.0.937"
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hotkeys-hook": "^3.4.6",
"react-icons": "^4.4.0"
},
"devDependencies": {
"@testing-library/cypress": "^8.0.2",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^13.5.0",
"@types/faker": "^5.5.6",
"@types/jest": "^27.4.1",
"@types/node": "^17.0.21",
"@types/react": "^17.0.39",
"@types/user-agents": "^1.0.2",
"apollo-server-testing": "^2.25.2",
"@testing-library/cypress": "^8.0.3",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.2.0",
"@types/jest": "^28.1.1",
"@types/node": "^17.0.43",
"@types/react": "^18.0.12",
"babel-eslint": "^10.1.0",
"cypress": "^9.5.0",
"eslint": "^8.9.0",
"eslint-config-next": "^12.1.0",
"eslint-config-react-app": "^7.0.0",
"cypress": "^10.1.0",
"eslint": "^8.17.0",
"eslint-config-next": "^12.1.6",
"eslint-plugin-cypress": "^2.12.1",
"faker": "^5.5.3",
"jest": "^27.5.1",
"jest-fetch-mock": "^3.0.3",
"node-mocks-http": "^1.11.0",
"typescript": "^4.5.5",
"jest": "^28.1.1",
"jest-environment-jsdom": "^28.1.1",
"ts-node": "^10.8.1",
"typescript": "^4.7.3",
"wait-on": "^6.0.1"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"plugin:cypress/recommended",
"next"
"next",
"plugin:cypress/recommended"
],
"overrides": [
{
"files": ["cypress/integration/*.ts"],
"files": [
"cypress/integration/*.ts"
],
"rules": {
"testing-library/await-async-query": "off",
"testing-library/prefer-screen-queries": "off"

View File

@@ -1,125 +1,191 @@
import { useEffect, useReducer, useCallback, FC, ChangeEvent } from "react";
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import Router from "next/router";
import { useCallback, useEffect, useReducer } from "react";
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import { Stack, VStack, HStack, IconButton } from "@chakra-ui/react";
import {
getTranslationInfo,
getTranslationText,
getAudio,
languageList,
LanguageType,
replaceExceptedCode,
isValidCode,
TranslationInfo,
LangCode
} from "lingva-scraper";
import { HStack, IconButton, Stack, VStack } from "@chakra-ui/react";
import { FaExchangeAlt } from "react-icons/fa";
import { HiTranslate } from "react-icons/hi";
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, isValid } from "@utils/language";
import langReducer, { Actions, initialState } from "@utils/reducer";
import { extractSlug } from "@utils/slug";
import langReducer, { Actions, initialState, State } from "@utils/reducer";
import { localGetItem, localSetItem } from "@utils/storage";
const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false });
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
const [{ source, target, query, delayedQuery, translation, isLoading }, dispatch] = useReducer(langReducer, initialState);
export enum ResponseType {
SUCCESS,
ERROR,
HOME
}
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
type Props = {
type: ResponseType.SUCCESS,
translation: string,
info: TranslationInfo | null,
audio: {
query: number[] | null,
translation: number[] | null
},
initial: {
source: LangCode<"source">,
target: LangCode<"target">,
query: string
}
} | {
type: ResponseType.ERROR,
errorMsg: string,
initial: {
source: LangCode<"source">,
target: LangCode<"target">,
query: string
}
} | {
type: ResponseType.HOME
};
const Page: NextPage<Props> = (props) => {
const [
{ source, target, query, delayedQuery, translation, isLoading, pronunciation, audio },
dispatch
] = useReducer(langReducer, initialState);
const router = useRouter();
const setField = useCallback(<T extends keyof State,>(key: T, value: State[T]) => (
dispatch({ type: Actions.SET_FIELD, payload: { key, value }})
), []);
const setAllFields = useCallback((state: State) => (
dispatch({ type: Actions.SET_ALL, payload: { state }})
), []);
const setLanguage = useCallback((type: typeof LanguageType[keyof typeof LanguageType], code: string) => (
dispatch({
type: Actions.SET_FIELD,
payload: {
key: e.target.id,
value: e.target.value
}
});
};
type: type === LanguageType.SOURCE
? Actions.SET_SOURCE
: Actions.SET_TARGET,
payload: { code }
})
), []);
const switchLanguages = useCallback((detectedSource?: LangCode<"source">) => (
dispatch({ type: Actions.SWITCH_LANGS, payload: { detectedSource } })
), []);
const changeRoute = useCallback((customQuery: string) => {
if (isLoading)
if (isLoading || router.isFallback)
return;
if (!customQuery || customQuery === initialState.query)
return;
if (!home && !initial)
return;
if (!home && customQuery === initial.query && source === initial.source && target === initial.target)
if (props.type === ResponseType.SUCCESS && customQuery === props.initial.query
&& source === props.initial.source && target === props.initial.target)
return;
localSetItem("source", source);
localSetItem("target", target);
localSetItem(LanguageType.SOURCE, source);
localSetItem(LanguageType.TARGET, target);
dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
Router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`);
}, [isLoading, source, target, home, initial]);
setField("isLoading", true);
router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`);
}, [isLoading, source, target, props, router, setField]);
useEffect(() => {
if (home) {
const localSource = localGetItem("source");
const localTarget = localGetItem("target");
return dispatch({
type: Actions.SET_ALL,
payload: {
state: {
...initialState,
source: isValid(localSource) ? localSource : initialState.source,
target: isValid(localTarget) ? localTarget : initialState.target,
isLoading: false
}
}
if (router.isFallback)
return;
if (props.type === ResponseType.HOME) {
const localSource = localGetItem(LanguageType.SOURCE);
const localTarget = localGetItem(LanguageType.TARGET);
return setAllFields({
...initialState,
source: isValidCode(localSource, LanguageType.SOURCE)
? localSource
: initialState.source,
target: isValidCode(localTarget, LanguageType.TARGET)
? localTarget
: initialState.target,
isLoading: false
});
}
if (!initial)
return;
if (props.type === ResponseType.ERROR)
return setAllFields({
...initialState,
...props.initial,
delayedQuery: props.initial.query,
isLoading: false
});
dispatch({
type: Actions.SET_ALL,
payload: {
state: {
...initial,
delayedQuery: initial.query,
translation: translationRes,
isLoading: false
}
setAllFields({
...props.initial,
delayedQuery: props.initial.query,
translation: props.translation,
isLoading: false,
pronunciation: props.info?.pronunciation ?? {},
audio: {
query: props.audio.query ?? undefined,
translation: props.audio.translation ?? undefined
}
});
}, [initial, translationRes, home]);
}, [props, router, setAllFields]);
useEffect(() => {
const timeout = setTimeout(() =>
dispatch({ type: Actions.SET_FIELD, payload: { key: "delayedQuery", value: query }}
), 1000);
return () => clearTimeout(timeout);
}, [query]);
const timeoutId = setTimeout(() => setField("delayedQuery", query), 1000);
return () => clearTimeout(timeoutId);
}, [query, setField]);
useEffect(() => {
const handler = (url: string) => {
url === Router.asPath || dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
url === router.asPath || setField("isLoading", true);
if (url !== "/")
return;
dispatch({ type: Actions.SET_FIELD, payload: { key: "source", value: initialState.source }});
localSetItem("source", initialState.source);
dispatch({ type: Actions.SET_FIELD, payload: { key: "target", value: initialState.target }});
localSetItem("target", initialState.target);
setLanguage(LanguageType.SOURCE, initialState.source);
localSetItem(LanguageType.SOURCE, initialState.source);
setLanguage(LanguageType.TARGET, initialState.target);
localSetItem(LanguageType.TARGET, initialState.target);
};
Router.events.on("beforeHistoryChange", handler);
return () => Router.events.off("beforeHistoryChange", handler);
}, []);
const sourceLangs = retrieveFromType("source");
const targetLangs = retrieveFromType("target");
const { source: transLang, target: queryLang } = replaceBoth("exception", { source: target, target: source });
router.events.on("beforeHistoryChange", handler);
return () => router.events.off("beforeHistoryChange", handler);
}, [router, setLanguage, setField]);
useToastOnLoad({
title: "Unexpected error",
description: errorMsg,
status: "error",
updateDeps: initial
title: "Unexpected error",
description: props.type === ResponseType.ERROR ? props.errorMsg : undefined,
updateDeps: props.type === ResponseType.ERROR ? props.initial : undefined
});
const canSwitch = source !== "auto" && !isLoading;
const detectedSource = props.type === ResponseType.SUCCESS ? props.info?.detectedSource : undefined;
const canSwitch = !isLoading && (source !== "auto" || !!detectedSource);
useHotkeys("ctrl+shift+s, command+shift+s, ctrl+shift+f, command+shift+f", () => (
canSwitch && dispatch({ type: Actions.SWITCH_LANGS })
), [canSwitch]);
canSwitch && switchLanguages(detectedSource)
), [canSwitch, detectedSource, switchLanguages]);
// parse existing code with opposite exceptions in order to flatten to the standards
const queryLang = source === "auto" && !!detectedSource
? detectedSource
: replaceExceptedCode(LanguageType.TARGET, source);
const transLang = replaceExceptedCode(LanguageType.SOURCE, target);
return (
<>
<CustomHead home={home} />
<CustomHead home={props.type === ResponseType.HOME} />
<VStack px={[8, null, 24, 40]} w="full">
<HStack px={[1, null, 3, 4]} w="full">
@@ -127,23 +193,24 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
id="source"
aria-label="Source language"
value={source}
onChange={handleChange}
langs={sourceLangs}
detectedSource={detectedSource}
onChange={e => setLanguage(LanguageType.SOURCE, e.target.value)}
langs={languageList.source}
/>
<IconButton
aria-label="Switch languages"
icon={<FaExchangeAlt />}
colorScheme="lingva"
variant="ghost"
onClick={() => dispatch({ type: Actions.SWITCH_LANGS })}
onClick={() => switchLanguages(detectedSource)}
isDisabled={!canSwitch}
/>
<LangSelect
id="target"
aria-label="Target language"
value={target}
onChange={handleChange}
langs={targetLangs}
onChange={e => setLanguage(LanguageType.TARGET, e.target.value)}
langs={languageList.target}
/>
</HStack>
<Stack direction={["column", null, "row"]} w="full">
@@ -152,10 +219,11 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
aria-label="Translation query"
placeholder="Text"
value={query}
onChange={e => isLoading || handleChange(e)}
onSubmit={useCallback(() => changeRoute(query), [query, changeRoute])}
onChange={e => isLoading || setField("query", e.target.value)}
onSubmit={() => changeRoute(query)}
lang={queryLang}
audio={audio?.source}
audio={audio.query}
pronunciation={pronunciation.query}
/>
<Stack direction={["row", null, "column"]} justify="center" spacing={3} px={[2, null, "initial"]}>
<IconButton
@@ -168,8 +236,9 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
w={["full", null, "auto"]}
/>
<AutoTranslateButton
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
isDisabled={isLoading}
// runs on effect update
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
w={["full", null, "auto"]}
/>
</Stack>
@@ -180,9 +249,10 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
value={translation ?? ""}
readOnly={true}
lang={transLang}
audio={audio?.target}
audio={audio.translation}
canCopy={true}
isLoading={isLoading}
pronunciation={pronunciation.translation}
/>
</Stack>
</VStack>
@@ -201,10 +271,12 @@ export const getStaticPaths: GetStaticPaths = async () => ({
fallback: true
});
export const getStaticProps: GetStaticProps = async ({ params }) => {
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (!params?.slug || !Array.isArray(params.slug))
return {
props: { home: true }
props: {
type: ResponseType.HOME
}
};
const { source, target, query } = extractSlug(params.slug);
@@ -220,35 +292,52 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
destination: `/${source ?? "auto"}/${target ?? "en"}/${query}`,
permanent: true
}
}
};
if (!isValid(source) || !isValid(target))
if (!isValidCode(source, LanguageType.SOURCE) || !isValidCode(target, LanguageType.TARGET))
return {
notFound: true
};
const textScrape = await googleScrape(source, target, query);
const initial = { source, target, query };
const [sourceAudio, targetAudio] = await Promise.all([
textToSpeechScrape(source, query),
"translationRes" in textScrape
? textToSpeechScrape(target, textScrape.translationRes)
: null
const translation = await getTranslationText(source, target, query);
if (!translation)
return {
props: {
type: ResponseType.ERROR,
errorMsg: "An error occurred while retrieving the translation",
initial
},
revalidate: 1
};
const info = await getTranslationInfo(source, target, query);
const audioSource = source === "auto" && info?.detectedSource
? info.detectedSource
: source;
const parsedAudioSource = replaceExceptedCode(LanguageType.TARGET, audioSource);
const [audioQuery, audioTranslation] = await Promise.all([
getAudio(parsedAudioSource, query),
getAudio(target, translation)
]);
const audio = {
query: audioQuery,
translation: audioTranslation
};
return {
props: {
...textScrape,
audio: {
source: sourceAudio,
target: targetAudio
},
initial: {
source, target, query
}
type: ResponseType.SUCCESS,
translation,
info,
audio,
initial
},
revalidate: !("errorMsg" in textScrape)
? 2 * 30 * 24 * 60 * 60 // 2 months
: 1
revalidate: 2 * 30 * 24 * 60 * 60 // 2 months
};
};

View File

@@ -1,8 +1,16 @@
import { ApolloServer, gql, IResolvers, ApolloError, UserInputError } from "apollo-server-micro";
import { NextApiHandler } from "next";
import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "@utils/translate";
import { retrieveFromType, getName, isValid } from "@utils/language";
import {
getTranslationInfo,
getTranslationText,
getAudio,
replaceExceptedCode,
isValidCode,
LanguageType,
languageList,
LangCode
} from "lingva-scraper";
export const typeDefs = gql`
enum LangType {
@@ -11,14 +19,32 @@ export const typeDefs = gql`
}
type Query {
translation(source: String="auto" target: String="en" query: String!): Translation!
audio(lang: String! query: String!): Entry!
audio(lang: String! query: String!): AudioEntry!
languages(type: LangType): [Language]!
}
type Translation {
source: Entry!
target: Entry!
source: SourceEntry!
target: TargetEntry!
}
type Entry {
type SourceEntry {
lang: Language!
text: String!
audio: [Int]!
detected: Language
typo: String
pronunciation: String
definitions: [DefinitionsGroup]
examples: [String]
similar: [String]
}
type TargetEntry {
lang: Language!
text: String!
audio: [Int]!
pronunciation: String
extraTranslations: [ExtraTranslationsGroup]
}
type AudioEntry {
lang: Language!
text: String!
audio: [Int]!
@@ -27,34 +53,70 @@ export const typeDefs = gql`
code: String!
name: String!
}
type DefinitionsGroup {
type: String!
list: [DefinitionList]!
}
type DefinitionList {
definition: String!
example: String!
field: String
synonyms: [String]
}
type ExtraTranslationsGroup {
type: String!
list: [ExtraTranslationList]!
}
type ExtraTranslationList {
word: String!
article: String
frequency: Int!
meanings: [String]
}
`;
export const resolvers: IResolvers = {
Query: {
translation(_, args) {
async translation(_, args) {
const { source, target, query } = args;
if (!isValid(source) || !isValid(target))
if (!isValidCode(source, LanguageType.SOURCE) || !isValidCode(target, LanguageType.TARGET))
throw new UserInputError("Invalid language code");
const translation = await getTranslationText(source, target, query);
if (!translation)
throw new ApolloError("An error occurred while retrieving the translation");
const info = await getTranslationInfo(source, target, query);
return {
source: {
lang: {
code: source
},
text: query
text: query,
detected: info?.detectedSource && {
code: info.detectedSource
},
typo: info?.typo,
pronunciation: info?.pronunciation.query,
definitions: info?.definitions,
examples: info?.examples,
similar: info?.similar
},
target: {
lang: {
code: target
}
},
text: translation,
pronunciation: info?.pronunciation.translation,
extraTranslations: info?.extraTranslations
}
};
},
audio(_, args) {
const { lang, query } = args;
if (!isValid(lang))
if (!isValidCode(lang))
throw new UserInputError("Invalid language code");
return {
@@ -66,36 +128,28 @@ export const resolvers: IResolvers = {
},
languages(_, args) {
const { type } = args;
const langEntries = retrieveFromType(type?.toLocaleLowerCase());
const lowerType = type?.toLocaleLowerCase() as typeof LanguageType[keyof typeof LanguageType] | undefined;
const langEntries = Object.entries(languageList[lowerType ?? "all"]);
return langEntries.map(([code, name]) => ({ code, name }));
}
},
Translation: {
async target(parent) {
const { source, target } = parent;
const textScrape = await googleScrape(source.lang.code, target.lang.code, source.text);
if ("errorMsg" in textScrape)
throw new ApolloError(textScrape.errorMsg);
return {
lang: target.lang,
text: textScrape.translationRes
};
...(["SourceEntry", "TargetEntry", "AudioEntry"].reduce((acc, key) => ({
...acc,
[key]: {
async audio(parent) {
const { lang, text } = parent;
const parsedLang = replaceExceptedCode(LanguageType.TARGET, lang.code);
const audio = await getAudio(parsedLang, text);
if (!audio)
throw new ApolloError("An error occurred while retrieving the audio");
return audio;
}
}
},
Entry: {
async audio(parent) {
const { lang, text } = parent;
const audio = await textToSpeechScrape(lang.code, text);
if (!audio)
throw new ApolloError("An error occurred while retrieving the audio");
return audio;
}
},
}), {} as IResolvers)),
Language: {
name(parent) {
const { code, name } = parent;
return name || getName(code);
return name || languageList.all[code as LangCode];
}
}
};

View File

@@ -1,10 +1,10 @@
import { NextApiHandler } from "next";
import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "@utils/translate";
import { isValid } from "@utils/language";
import { getTranslationInfo, getTranslationText, getAudio, isValidCode, LanguageType, TranslationInfo } from "lingva-scraper";
type Data = {
translation: string,
info?: TranslationInfo
} | {
audio: number[]
} | {
@@ -35,24 +35,29 @@ const handler: NextApiHandler<Data> = async (req, res) => {
const [source, target, query] = slug;
if (!isValid(target))
if (!isValidCode(target, LanguageType.TARGET))
return res.status(400).json({ error: "Invalid target language" });
if (source === "audio") {
const audio = await textToSpeechScrape(target, query);
const audio = await getAudio(target, query);
return audio
? res.status(200).json({ audio })
: res.status(500).json({ error: "An error occurred while retrieving the audio" });
}
if (!isValid(source))
if (!isValidCode(source, LanguageType.SOURCE))
return res.status(400).json({ error: "Invalid source language" });
const textScrape = await googleScrape(source, target, query);
const translation = await getTranslationText(source, target, query);
if ("errorMsg" in textScrape)
return res.status(500).json({ error: textScrape.errorMsg });
res.status(200).json({ translation: textScrape.translationRes });
if (!translation)
return res.status(500).json({ error: "An error occurred while retrieving the translation" });
const info = await getTranslationInfo(source, target, query);
return info
? res.status(200).json({ translation, info })
: res.status(200).json({ translation });
}
export default handler;

View File

@@ -1,6 +1,6 @@
import { NextApiHandler } from "next";
import NextCors from "nextjs-cors";
import { retrieveFromType, LangCode } from "@utils/language";
import { languageList, LangCode } from "lingva-scraper";
type Data = {
languages: {
@@ -38,7 +38,7 @@ const handler: NextApiHandler<Data> = async (req, res) => {
if (type !== undefined && type !== "source" && type !== "target")
return res.status(400).json({ error: "Type should be 'source', 'target' or empty" });
const langEntries = retrieveFromType(type);
const langEntries = Object.entries(languageList[type ?? "all"]) as [LangCode, string][];
const languages = langEntries.map(([code, name]) => ({ code, name }));
res.status(200).json({ languages });

View File

@@ -1,9 +1,8 @@
import { render, screen } from "@tests/reactUtils";
import faker from "faker";
import { render, screen } from "./reactUtils";
import CustomError from "@components/CustomError";
const code = faker.datatype.number({ min: 400, max: 599 });
const text = faker.random.words();
const code = Math.random() * 199 + 400;
const text = "Testing fake error";
it("loads the layout correctly", async () => {
render(<CustomError statusCode={code} statusText={text} />);
@@ -16,7 +15,6 @@ it("loads the layout correctly", async () => {
});
it("renders the correct status code & text", () => {
const code = faker.datatype.number({ min: 400, max: 599 });
render(<CustomError statusCode={code} statusText={text} />);
expect(screen.getByText(code)).toBeVisible();

198
tests/Page.test.tsx Normal file
View File

@@ -0,0 +1,198 @@
import { render, screen, waitFor, act } from "./reactUtils";
import userEvent from "@testing-library/user-event";
import { localStorageSetMock } from "@mocks/localStorage";
import { routerMock } from "@mocks/next";
import {
fullInfoMock,
simpleInfoMock,
pronunciationInfoMock,
translationMock,
audioMock,
initialMock,
initialAutoMock
} from "@mocks/data";
import Page, { ResponseType } from "@pages/[[...slug]]";
beforeEach(() => {
routerMock.push.mockReset();
});
it("loads the layout correctly", async () => {
render(<Page type={ResponseType.HOME} />);
expect(screen.getByRole("link", { name: /skip to content/i })).toBeEnabled();
expect(await screen.findByRole("img", { name: /logo/i })).toBeVisible();
expect(screen.getByRole("button", { name: /toggle color mode/i })).toBeEnabled();
expect(screen.getByRole("link", { name: /github/i })).toBeEnabled();
expect(screen.getByText(/\xA9/)).toBeVisible();
});
it("switches the page on translate button click", async () => {
const user = userEvent.setup();
render(<Page type={ResponseType.HOME} />);
const query = screen.getByRole("textbox", { name: /translation query/i });
await waitFor(() => user.type(query, "Random query"));
const translate = screen.getByRole("button", { name: /translate/i });
await waitFor(() => user.click(translate));
expect(routerMock.push).toHaveBeenCalledTimes(1);
expect(screen.getByText(/loading translation/i)).toBeInTheDocument();
});
it("doesn't switch the page if nothing has changed", async () => {
const user = userEvent.setup();
render(<Page type={ResponseType.SUCCESS} initial={initialMock} translation={translationMock} info={simpleInfoMock} audio={audioMock} />);
const translate = screen.getByRole("button", { name: /translate/i });
await waitFor(() => user.click(translate));
expect(routerMock.push).not.toHaveBeenCalled();
expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument();
});
it("stores auto state in localStorage", async () => {
const user = userEvent.setup();
render(<Page type={ResponseType.HOME} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
await waitFor(() => user.click(switchAuto));
expect(localStorageSetMock).toHaveBeenLastCalledWith("isauto", "true");
await waitFor(() => user.click(switchAuto));
expect(localStorageSetMock).toHaveBeenLastCalledWith("isauto", "false");
});
it("switches the page on query change if auto is enabled", async () => {
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<Page type={ResponseType.HOME} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
await waitFor(() => user.click(switchAuto));
const query = screen.getByRole("textbox", { name: /translation query/i });
await waitFor(() => user.type(query, "Random query"));
await waitFor(() => expect(routerMock.push).not.toHaveBeenCalled());
expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
});
await waitFor(() => expect(routerMock.push).toHaveBeenCalledTimes(1));
expect(screen.getByText(/loading translation/i)).toBeInTheDocument();
jest.useRealTimers();
});
it("switches the page on language change if auto is enabled", async () => {
const user = userEvent.setup();
render(<Page type={ResponseType.SUCCESS} translation={translationMock} initial={initialAutoMock} info={simpleInfoMock} audio={audioMock} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
await waitFor(() => user.click(switchAuto));
const source = screen.getByRole("combobox", { name: /source language/i });
const sourceVal = "eo";
await waitFor(() => user.selectOptions(source, sourceVal));
expect(source).toHaveValue(sourceVal);
await waitFor(() => expect(routerMock.push).toHaveBeenCalledTimes(1));
expect(localStorageSetMock).toHaveBeenCalledWith("source", sourceVal);
});
it("doesn't switch the page on language change on the start page", async () => {
const user = userEvent.setup();
render(<Page type={ResponseType.HOME} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
await waitFor(() => user.click(switchAuto));
const source = screen.getByRole("combobox", { name: /source language/i });
const sourceVal = "eo";
await waitFor(() => user.selectOptions(source, sourceVal));
expect(source).toHaveValue(sourceVal);
await waitFor(() => expect(routerMock.push).not.toHaveBeenCalled());
});
it("switches languages & translations", async () => {
const user = userEvent.setup();
render(<Page type={ResponseType.SUCCESS} translation={translationMock} initial={initialMock} info={fullInfoMock} audio={audioMock} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
await waitFor(() => user.click(switchAuto));
const btnSwitch = screen.getByRole("button", { name: /switch languages/i });
await waitFor(() => user.click(btnSwitch));
expect(screen.getByRole("combobox", { name: /source language/i })).toHaveValue(initialMock.target);
expect(screen.getByRole("combobox", { name: /target language/i })).toHaveValue(initialMock.source);
expect(screen.getByRole("textbox", { name: /translation query/i })).toHaveValue(translationMock);
expect(screen.getByRole("textbox", { name: /translation result/i })).toHaveValue(initialMock.query);
await waitFor(() => expect(routerMock.push).toHaveBeenCalledTimes(1));
expect(localStorageSetMock).toHaveBeenLastCalledWith("target", initialMock.source);
});
it("switches auto with detected language", async () => {
const user = userEvent.setup();
render(<Page type={ResponseType.SUCCESS} translation={translationMock} initial={initialAutoMock} info={fullInfoMock} audio={audioMock} />);
const btnSwitch = screen.getByRole("button", { name: /switch languages/i });
await waitFor(() => user.click(btnSwitch));
expect(screen.getByRole("combobox", { name: /source language/i })).toHaveValue(initialAutoMock.target);
expect(screen.getByRole("combobox", { name: /target language/i })).toHaveValue(fullInfoMock.detectedSource);
expect(screen.getByRole("textbox", { name: /translation query/i })).toHaveValue(translationMock);
expect(screen.getByRole("textbox", { name: /translation result/i })).toHaveValue(initialAutoMock.query);
});
it("corrently shows query & translation pronunciations", async () => {
render(<Page type={ResponseType.SUCCESS} translation={translationMock} initial={initialMock} info={pronunciationInfoMock} audio={audioMock} />);
expect(screen.getByRole("combobox", { name: /source language/i })).toHaveValue(initialMock.source);
expect(screen.getByRole("combobox", { name: /target language/i })).toHaveValue(initialMock.target);
expect(screen.getByRole("textbox", { name: /translation query/i })).toHaveValue(initialMock.query);
expect(screen.getByRole("textbox", { name: /translation result/i })).toHaveValue(translationMock);
expect(screen.getByText(pronunciationInfoMock.pronunciation.query!)).toBeVisible();
expect(screen.getByText(pronunciationInfoMock.pronunciation.translation!)).toBeVisible();
});
it("translates & loads initials correctly", async () => {
render(<Page type={ResponseType.SUCCESS} translation={translationMock} initial={initialMock} info={fullInfoMock} audio={audioMock} />);
const source = screen.getByRole("combobox", { name: /source language/i });
expect(source).toHaveValue(initialMock.source);
const target = screen.getByRole("combobox", { name: /target language/i });
expect(target).toHaveValue(initialMock.target);
const query = screen.getByRole("textbox", { name: /translation query/i });
expect(query).toHaveValue(initialMock.query);
const translation = screen.getByRole("textbox", { name: /translation result/i });
expect(translation).toHaveValue(translationMock);
});
it("loads audio & clipboard correctly", async () => {
render(<Page type={ResponseType.SUCCESS} translation={translationMock} initial={initialMock} info={simpleInfoMock} audio={audioMock} />);
const btnsAudio = screen.getAllByRole("button", { name: /play audio/i });
btnsAudio.forEach(btn => expect(btn).toBeVisible());
const btnCopy = screen.getByRole("button", { name: /copy to clipboard/i });
expect(btnCopy).toBeEnabled();
});
it("shows alert correctly on error", async () => {
const errorMsg = "Random error";
render(<Page type={ResponseType.ERROR} initial={initialMock} errorMsg={errorMsg} />);
const alert = screen.getByRole("alert");
await waitFor(() => expect(alert).toBeVisible());
expect(alert).toHaveTextContent(/unexpected error/i);
expect(alert).toHaveTextContent(errorMsg);
});

View File

@@ -1,13 +0,0 @@
import { MockResponseInit } from "jest-fetch-mock";
export const htmlRes = (translation: string, className = "result-container") => `
<div>
<div class=${className}>
${translation}
</div>
</div>
`;
export const resolveFetchWith = (params: string | MockResponseInit) => (
fetchMock.mockResponseOnce(async () => params)
);

View File

@@ -1,242 +0,0 @@
import { render, screen, waitFor } from "@tests/reactUtils";
import { htmlRes, resolveFetchWith } from "@tests/commonUtils";
import userEvent from "@testing-library/user-event";
import faker from "faker";
import Page, { getStaticProps } from "@pages/[[...slug]]";
import { localStorageSetMock } from "@mocks/localStorage";
import { routerPushMock } from "@mocks/next";
beforeEach(() => {
fetchMock.resetMocks();
routerPushMock.mockReset();
});
describe("getStaticProps", () => {
const source = "es";
const target = "ca";
const query = faker.random.words();
it("returns home on empty params", async () => {
expect(await getStaticProps({ params: {} })).toStrictEqual({ props: { home: true } });
});
it("returns not found on >=4 params", async () => {
const slug = [source, target, query, ""];
expect(await getStaticProps({ params: { slug } })).toStrictEqual({ notFound: true });
});
it("redirects on 1 param", async () => {
const slug = [query];
expect(await getStaticProps({ params: { slug } })).toMatchObject({ redirect: expect.any(Object) });
});
it("redirects on 2 params", async () => {
const slug = [target, query];
expect(await getStaticProps({ params: { slug } })).toMatchObject({ redirect: expect.any(Object) });
});
it("returns translation, audio & initial values on 3 params", async () => {
const translationRes = faker.random.words();
resolveFetchWith(htmlRes(translationRes));
const slug = [source, target, query];
expect(await getStaticProps({ params: { slug } })).toStrictEqual({
props: {
translationRes,
audio: {
source: expect.any(Array),
target: expect.any(Array)
},
initial: {
source,
target,
query
}
},
revalidate: expect.any(Number)
});
});
});
describe("Page", () => {
const translationRes = faker.random.words();
const randomAudio = Array.from({ length: 10 }, () => faker.datatype.number(100));
const audio = {
source: randomAudio,
target: randomAudio
};
it("loads the layout correctly", async () => {
render(<Page home={true} />);
expect(screen.getByRole("link", { name: /skip to content/i })).toBeEnabled();
expect(await screen.findByRole("img", { name: /logo/i })).toBeVisible();
expect(screen.getByRole("button", { name: /toggle color mode/i })).toBeEnabled();
expect(screen.getByRole("link", { name: /github/i })).toBeEnabled();
expect(screen.getByText(/\xA9/)).toBeVisible();
});
it("switches the page on translate button click", async () => {
render(<Page home={true} />);
const query = screen.getByRole("textbox", { name: /translation query/i });
userEvent.type(query, faker.random.words());
const translate = screen.getByRole("button", { name: /translate/i });
translate.click();
expect(routerPushMock).toHaveBeenCalledTimes(1);
expect(screen.getByText(/loading translation/i)).toBeInTheDocument();
});
it("doesn't switch the page if nothing has changed", async () => {
const initial = {
source: "ca",
target: "es",
query: faker.random.words()
};
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
const translate = screen.getByRole("button", { name: /translate/i });
translate.click();
expect(routerPushMock).not.toHaveBeenCalled();
expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument();
});
it("stores auto state in localStorage", async () => {
render(<Page home={true} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
switchAuto.click();
await waitFor(() => expect(localStorageSetMock).toHaveBeenLastCalledWith("isauto", "true"));
switchAuto.click();
await waitFor(() => expect(localStorageSetMock).toHaveBeenLastCalledWith("isauto", "false"));
});
it("switches the page on query change if auto is enabled", async () => {
render(<Page home={true} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
switchAuto.click();
const query = screen.getByRole("textbox", { name: /translation query/i });
userEvent.type(query, faker.random.words());
await waitFor(
() => {
expect(routerPushMock).not.toHaveBeenCalled();
expect(screen.queryByText(/loading translation/i)).not.toBeInTheDocument();
},
{ timeout: 250 }
);
await waitFor(
() => {
expect(routerPushMock).toHaveBeenCalledTimes(1);
expect(screen.getByText(/loading translation/i)).toBeInTheDocument();
},
{ timeout: 2500 }
);
});
it("switches the page on language change if auto is enabled", async () => {
const initial = {
source: "auto",
target: "en",
query: faker.random.words()
};
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
switchAuto.click();
const source = screen.getByRole("combobox", { name: /source language/i });
const sourceVal = "eo";
userEvent.selectOptions(source, sourceVal);
expect(source).toHaveValue(sourceVal);
await waitFor(() => expect(routerPushMock).toHaveBeenCalledTimes(1));
expect(localStorageSetMock).toHaveBeenCalledWith("source", sourceVal);
});
it("doesn't switch the page on language change on the start page", async () => {
render(<Page home={true} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
switchAuto.click();
const source = screen.getByRole("combobox", { name: /source language/i });
const sourceVal = "eo";
userEvent.selectOptions(source, sourceVal);
expect(source).toHaveValue(sourceVal);
await waitFor(() => expect(routerPushMock).not.toHaveBeenCalled());
});
it("switches languages & translations", async () => {
const initial = {
source: "es",
target: "ca",
query: faker.random.words()
};
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
const switchAuto = screen.getByRole("button", { name: /switch auto/i });
switchAuto.click();
const btnSwitch = screen.getByRole("button", { name: /switch languages/i });
userEvent.click(btnSwitch);
expect(screen.getByRole("combobox", { name: /source language/i })).toHaveValue(initial.target);
expect(screen.getByRole("combobox", { name: /target language/i })).toHaveValue(initial.source);
expect(screen.getByRole("textbox", { name: /translation query/i })).toHaveValue(translationRes);
expect(screen.getByRole("textbox", { name: /translation result/i })).toHaveValue(initial.query);
await waitFor(() => expect(routerPushMock).toHaveBeenCalledTimes(1));
expect(localStorageSetMock).toHaveBeenLastCalledWith("target", initial.source);
});
it("translates & loads initials correctly", async () => {
const initial = {
source: "ca",
target: "es",
query: faker.random.words()
};
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
const source = screen.getByRole("combobox", { name: /source language/i });
expect(source).toHaveValue(initial.source);
const target = screen.getByRole("combobox", { name: /target language/i });
expect(target).toHaveValue(initial.target);
const query = screen.getByRole("textbox", { name: /translation query/i });
expect(query).toHaveValue(initial.query);
const translation = screen.getByRole("textbox", { name: /translation result/i });
expect(translation).toHaveValue(translationRes);
});
it("loads audio & clipboard correctly", async () => {
const initial = {
source: "eo",
target: "zh",
query: faker.random.words()
};
render(<Page translationRes={translationRes} audio={audio} initial={initial} />);
const btnsAudio = screen.getAllByRole("button", { name: /play audio/i });
btnsAudio.forEach(btn => expect(btn).toBeVisible());
const btnCopy = screen.getByRole("button", { name: /copy to clipboard/i });
expect(btnCopy).toBeEnabled();
});
it("shows alert correctly on error", async () => {
const errorMsg = faker.random.words();
render(<Page errorMsg={errorMsg} />);
const alert = screen.getByRole("alert");
await waitFor(() => expect(alert).toBeVisible());
expect(alert).toHaveTextContent(/unexpected error/i);
expect(alert).toHaveTextContent(errorMsg);
});
});

View File

@@ -1,254 +0,0 @@
import { createTestClient } from "apollo-server-testing";
import { ApolloServer } from "apollo-server-micro";
import faker from "faker";
import { htmlRes, resolveFetchWith } from "@tests/commonUtils";
import { typeDefs, resolvers } from "@pages/api/graphql";
beforeEach(() => {
fetchMock.resetMocks();
});
const { query } = createTestClient(new ApolloServer({ typeDefs, resolvers }));
it("doesn't trigger fetch if neither target nor audio are specified", async () => {
const text = faker.random.words();
const { data } = await query({
query: `
query($text: String!) {
translation(query: $text) {
source {
text
}
}
}
`,
variables: { text }
});
expect(data).toMatchObject({ translation: { source: { text } } });
expect(fetch).not.toHaveBeenCalled();
});
it("returns translation triggering fetch", async () => {
const text = faker.random.words();
const translation = faker.random.words();
resolveFetchWith(htmlRes(translation));
const { data } = await query({
query: `
query($text: String!) {
translation(query: $text) {
target {
text
}
}
}
`,
variables: { text }
});
expect(data).toMatchObject({ translation: { target: { text: translation } } });
expect(fetch).toHaveBeenCalledTimes(1);
});
it("returns audio triggering fetch", async () => {
const lang = "es";
const text = faker.random.words();
resolveFetchWith({ status: 200 });
const { data } = await query({
query: `
query($lang: String! $text: String!) {
audio(lang: $lang query: $text) {
lang {
code
}
text
audio
}
}
`,
variables: { lang, text }
});
expect(data).toMatchObject({ audio: { lang: { code: lang }, text, audio: expect.any(Array) } });
expect(fetch).toHaveBeenCalledTimes(1);
});
it("returns null and throws on translation error", async () => {
const text = faker.random.words();
fetchMock.mockRejectOnce();
const { data, errors } = await query({
query: `
query($text: String!) {
translation(query: $text) {
target {
text
}
}
}
`,
variables: { text }
});
expect(data).toBeNull();
expect(errors).toBeTruthy();
});
it("returns null and throws on audio error", async () => {
const lang = "es";
const text = faker.random.words();
fetchMock.mockRejectOnce();
const { data, errors } = await query({
query: `
query($lang: String! $text: String!) {
audio(lang: $lang query: $text) {
audio
}
}
`,
variables: { lang, text }
});
expect(data).toBeNull();
expect(errors).toBeTruthy();
});
it("keeps a default value for both source and target languages", async () => {
const text = faker.random.words();
const translation = faker.random.words();
resolveFetchWith(htmlRes(translation));
const { data } = await query({
query: `
query($text: String!) {
translation(query: $text) {
source {
lang {
code
name
}
}
target {
lang {
code
name
}
}
}
}
`,
variables: { text }
});
expect(data).toMatchObject({
translation: {
source: {
lang: {
code: "auto",
name: "Detect"
}
},
target: {
lang: {
code: "en",
name: "English"
}
}
}
});
});
it("throws error on empty query in translation", async () => {
const { errors } = await query({
query: `
query {
translation {
source {
lang {
code
}
}
target {
lang {
code
}
}
}
}
`
});
expect(errors).toBeTruthy();
});
it("throws error on empty lang or query in audio", async () => {
const lang = "es";
const text = faker.random.words();
const { errors: queryErrors } = await query({
query: `
query($lang: String!) {
audio(lang: $lang) {
lang {
code
}
text
}
}
`,
variables: { lang }
});
expect(queryErrors).toBeTruthy();
const { errors: langErrors } = await query({
query: `
query($text: String!) {
audio(query: $text) {
lang {
code
}
text
}
}
`,
variables: { text }
});
expect(langErrors).toBeTruthy();
});
it("returns languages on empty type", async () => {
const { data } = await query({
query: `
query {
languages {
code
}
}
`
});
expect(data).toMatchObject({ languages: expect.any(Array) });
});
it("returns languages on 'source' type", async () => {
const { data } = await query({
query: `
query($type: LangType!) {
languages(type: $type) {
code
}
}
`,
variables: { type: "SOURCE" }
});
expect(data).toMatchObject({ languages: expect.any(Array) });
});
it("returns languages on 'target' type", async () => {
const { data } = await query({
query: `
query($type: LangType!) {
languages(type: $type) {
code
}
}
`,
variables: { type: "TARGET" }
});
expect(data).toMatchObject({ languages: expect.any(Array) });
});

View File

@@ -1,95 +0,0 @@
import httpMocks from "node-mocks-http";
import faker from "faker";
import { htmlRes, resolveFetchWith } from "@tests/commonUtils";
import handler from "@pages/api/v1/[[...slug]]";
beforeEach(() => {
fetchMock.resetMocks();
});
const source = "es";
const target = "ca";
const query = faker.random.words();
const slug = [source, target, query];
it("returns 404 on <3 params", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: [source, target] }
});
await handler(req, res);
expect(res.statusCode).toBe(404);
});
it("returns 404 on >3 params", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: [source, target, query, ""] }
});
await handler(req, res);
expect(res.statusCode).toBe(404);
});
it("returns 405 on forbidden method", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "POST",
query: { slug }
});
await handler(req, res);
expect(res.statusCode).toBe(405);
});
it("returns translation on scrapping resolve", async () => {
const translationRes = faker.random.words();
resolveFetchWith(htmlRes(translationRes));
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ translation: translationRes });
});
it("returns 500 on scrapping error", async () => {
fetchMock.mockRejectOnce();
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug }
});
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res._getJSONData()).toStrictEqual({ error: expect.any(String) });
});
it("returns audio on audio request", async () => {
resolveFetchWith({ status: 200 });
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["audio", target, query] }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ audio: expect.any(Array) });
});
it("returns 500 on audio request error", async () => {
fetchMock.mockRejectOnce();
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["audio", target, query] }
});
await handler(req, res);
expect(res.statusCode).toBe(500);
});

View File

@@ -1,65 +0,0 @@
import httpMocks from "node-mocks-http";
import handler from "@pages/api/v1/languages/[[...slug]]";
it("returns 404 on >1 params", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["one", "two"] }
});
await handler(req, res);
expect(res.statusCode).toBe(404);
});
it("returns 405 on forbidden method", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "POST",
query: {}
});
await handler(req, res);
expect(res.statusCode).toBe(405);
});
it("returns 400 on wrong param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["other"] }
});
await handler(req, res);
expect(res.statusCode).toBe(400);
});
it("returns 200 on empty param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: {}
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ languages: expect.any(Array) });
});
it("returns 200 on 'source' param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["source"] }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ languages: expect.any(Array) });
});
it("returns 200 on 'target' param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["target"] }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ languages: expect.any(Array) });
});

View File

@@ -1,8 +1,9 @@
import { FC, ReactElement } from "react";
import { FC, ReactElement, PropsWithChildren } from "react";
import { render, RenderOptions } from "@testing-library/react";
import { ChakraProvider } from "@chakra-ui/react";
import theme from "@theme";
import { Layout } from "@components";
import { RouterProviderMock } from "@mocks/next";
// Jest JSDOM bug
Object.defineProperty(window, 'matchMedia', {
@@ -19,12 +20,14 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
const Providers: FC = ({ children }) => (
<ChakraProvider theme={theme}>
<Layout>
{children}
</Layout>
</ChakraProvider>
const Providers: FC<PropsWithChildren> = ({ children }) => (
<RouterProviderMock>
<ChakraProvider theme={theme}>
<Layout>
{children}
</Layout>
</ChakraProvider>
</RouterProviderMock>
);
const customRender = (

View File

@@ -1,3 +1 @@
import '@testing-library/jest-dom';
import jestFetchMock from 'jest-fetch-mock';
jestFetchMock.enableMocks();

View File

@@ -1,68 +0,0 @@
import faker from "faker";
import { replaceBoth, retrieveFromType, getName, CheckType, LangType } from "@utils/language";
import { languages, exceptions, mappings } from "@utils/languages.json";
describe("replaceBoth", () => {
const testReplacer = (
checkType: CheckType,
checkObj: {
[key in LangType]: {
[key: string]: string
}
},
langType: LangType
) => (
Object.entries(checkObj[langType]).forEach(([code, replacement]) => {
const res = replaceBoth(checkType, { source: "auto", target: "en", [langType]: code })
expect(res[langType]).toBe(replacement);
})
);
it("replaces excepted sources correctly", () => {
testReplacer("exception", exceptions, "source");
});
it("replaces excepted targets correctly", () => {
testReplacer("exception", exceptions, "target");
});
it("replaces mapped sources correctly", () => {
testReplacer("mapping", mappings, "source");
});
it("replaces mapped targets correctly", () => {
testReplacer("mapping", mappings, "target");
});
});
describe("retrieveFromType", () => {
const checkExceptions = (langType: LangType) => (
retrieveFromType(langType).forEach(([code]) => !Object.keys(exceptions).includes(code))
);
it("returns full list on empty type", () => {
expect(retrieveFromType()).toStrictEqual(Object.entries(languages));
});
it("filters source exceptions", () => {
checkExceptions("source");
});
it("filters target exceptions", () => {
checkExceptions("target");
});
});
describe("getName", () => {
it("returns name from valid code", () => {
const langEntries = Object.entries(languages);
const randomEntry = faker.random.arrayElement(langEntries);
const [code, name] = randomEntry;
expect(getName(code)).toEqual(name);
});
it("returns null on wrong code", () => {
const randomCode = faker.random.words();
expect(getName(randomCode)).toBeNull();
});
});

View File

@@ -1,83 +0,0 @@
import faker from "faker";
import langReducer, { Actions, initialState } from "@utils/reducer";
it("changes a field value", () => {
const query = faker.random.words();
const res = langReducer(initialState, {
type: Actions.SET_FIELD,
payload: {
key: "query",
value: query
}
});
expect(res).toStrictEqual({ ...initialState, query });
});
it("changes all fields", () => {
const query = faker.random.words();
const state = {
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,
payload: { state }
});
expect(res).toStrictEqual(state);
});
it("switches target on source change", () => {
const state = {
...initialState,
source: "es",
target: "ca"
} as const;
const res = langReducer(state, {
type: Actions.SET_FIELD,
payload: {
key: "source",
value: state.target
}
});
expect(res.source).toStrictEqual(state.target);
expect(res.target).toStrictEqual(state.source);
});
it("switches the languages & the translations", () => {
const state = {
...initialState,
source: "es",
target: "ca",
query: faker.random.words(),
translation: faker.random.words()
} as const;
const res = langReducer(state, { type: Actions.SWITCH_LANGS });
expect(res).toStrictEqual({
source: state.target,
target: state.source,
query: state.translation,
delayedQuery: state.translation,
translation: state.query,
isLoading: initialState.isLoading
});
});
it("resets the source while switching if they're the same", () => {
const state = {
...initialState,
source: "eo",
target: "eo"
} as const;
const res = langReducer(state, { type: Actions.SWITCH_LANGS });
expect(res.source).toStrictEqual(initialState.source);
expect(res.target).toStrictEqual(state.source);
});

View File

@@ -1,86 +0,0 @@
import { htmlRes, resolveFetchWith } from "@tests/commonUtils";
import faker from "faker";
import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate";
const source = "es";
const target = "ca";
const query = faker.random.words();
describe("googleScrape", () => {
beforeEach(() => {
fetchMock.resetMocks();
});
it("parses html response correctly", async () => {
const translationRes = faker.random.words();
const html = htmlRes(translationRes);
resolveFetchWith(html);
expect(await googleScrape(source, target, query)).toStrictEqual({ translationRes });
});
it("returns correct message on request error", async () => {
const status = faker.datatype.number({ min: 400, max: 499 });
resolveFetchWith({ status });
const res = await googleScrape(source, target, query);
expect("errorMsg" in res && res.errorMsg).toMatch(/retrieving/);
});
it("returns correct message on network error", async () => {
fetchMock.mockRejectOnce();
const res = await googleScrape(source, target, query);
expect("errorMsg" in res && res.errorMsg).toMatch(/retrieving/);
});
it("returns correct message on parsing wrong class", async () => {
const translation = faker.random.words();
const className = "wrong-container";
const html = htmlRes(translation, className);
resolveFetchWith(html);
const res = await googleScrape(source, target, query);
expect("errorMsg" in res && res.errorMsg).toMatch(/parsing/);
});
});
describe("extractSlug", () => {
it("returns 'query' for 1 param", () => {
expect(extractSlug([query])).toStrictEqual({ query });
});
it("returns 'target' & 'query' resp. for 2 params", () => {
expect(extractSlug([target, query])).toStrictEqual({ target, query });
});
it("returns 'source', 'target' & 'query' resp. for 3 param", () => {
expect(extractSlug([source, target, query])).toStrictEqual({ source, target, query });
});
it("returns empty object on 0 or >4 params", () => {
expect(extractSlug([])).toStrictEqual({});
const length = faker.datatype.number({ min: 4, max: 50 });
const array = Array(length).fill("");
expect(extractSlug(array)).toStrictEqual({});
});
});
describe("textToSpeechScrape", () => {
it("returns an array on successful request", async () => {
resolveFetchWith({ status: 200 });
expect(await textToSpeechScrape(target, query)).toEqual(expect.any(Array));
});
it("returns 'null' on request error", async () => {
const status = faker.datatype.number({ min: 400, max: 499 });
resolveFetchWith({ status });
expect(await textToSpeechScrape(target, query)).toBeNull();
});
it("returns 'null' on network error", async () => {
fetchMock.mockRejectOnce();
expect(await textToSpeechScrape(target, query)).toBeNull();
});
});

View File

@@ -1,7 +1,19 @@
import { extendTheme } from "@chakra-ui/react";
import { mode } from "@chakra-ui/theme-tools";
import { mode, StyleFunctionProps } from "@chakra-ui/theme-tools";
const forceDefaultTheme = process.env["NEXT_PUBLIC_FORCE_DEFAULT_THEME"];
export default extendTheme({
styles: {
global: {
"html, body, #__next": {
height: "100%"
},
"#__next": {
isolation: "isolate"
}
}
},
colors: {
lingva: {
50: "#e7f5ed",
@@ -17,26 +29,12 @@ export default extendTheme({
}
},
config: {
initialColorMode: process.env["DEFAULT_DARK_THEME"] === "true" ? "dark" : "light",
useSystemColorMode: false
initialColorMode: forceDefaultTheme === "light" || forceDefaultTheme === "dark" ? forceDefaultTheme : "system"
},
components: {
Textarea: {
variants: {
outline: props => ({
borderColor: mode("lingva.500", "lingva.200")(props),
_hover: {
borderColor: mode("lingva.700", "lingva.400")(props),
},
_readOnly: {
userSelect: "auto"
}
})
}
},
Select: {
variants: {
flushed: props => ({
flushed: (props: StyleFunctionProps) => ({
field: {
borderColor: mode("lingva.500", "lingva.200")(props)
}

View File

@@ -30,6 +30,8 @@
],
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"cypress"
]
}

View File

@@ -1,54 +0,0 @@
import languagesJson from "./languages.json";
const { languages, exceptions, mappings } = languagesJson;
export type LangCode = keyof typeof languages;
const checkTypes = {
exception: exceptions,
mapping: mappings
};
export type CheckType = keyof typeof checkTypes;
const langTypes = [
"source",
"target"
] as const;
export type LangType = typeof langTypes[number];
const isKeyOf = <T extends object>(obj: T) => (key: keyof any): key is keyof T => key in obj;
export function replaceBoth(
checkType: CheckType,
langs: {
[key in LangType]: LangCode
}
): {
[key in LangType]: LangCode
} {
const [source, target] = langTypes.map(langType => {
const object = checkTypes[checkType][langType];
const langCode = langs[langType];
return isKeyOf(object)(langCode) ? object[langCode] : langCode;
});
return { source, target };
}
export function retrieveFromType(type?: LangType) {
const langEntries = Object.entries(languages) as [LangCode, string][];
if (!type)
return langEntries;
return langEntries.filter(([code]) => (
!Object.keys(exceptions[type]).includes(code)
));
}
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;
}

View File

@@ -1,130 +0,0 @@
{
"languages": {
"auto": "Detect",
"af": "Afrikaans",
"sq": "Albanian",
"am": "Amharic",
"ar": "Arabic",
"hy": "Armenian",
"az": "Azerbaijani",
"eu": "Basque",
"be": "Belarusian",
"bn": "Bengali",
"bs": "Bosnian",
"bg": "Bulgarian",
"ca": "Catalan",
"ceb": "Cebuano",
"ny": "Chichewa",
"zh": "Chinese",
"zh_HANT": "Chinese (Traditional)",
"co": "Corsican",
"hr": "Croatian",
"cs": "Czech",
"da": "Danish",
"nl": "Dutch",
"en": "English",
"eo": "Esperanto",
"et": "Estonian",
"tl": "Filipino",
"fi": "Finnish",
"fr": "French",
"fy": "Frisian",
"gl": "Galician",
"ka": "Georgian",
"de": "German",
"el": "Greek",
"gu": "Gujarati",
"ht": "Haitian Creole",
"ha": "Hausa",
"haw": "Hawaiian",
"iw": "Hebrew",
"hi": "Hindi",
"hmn": "Hmong",
"hu": "Hungarian",
"is": "Icelandic",
"ig": "Igbo",
"id": "Indonesian",
"ga": "Irish",
"it": "Italian",
"ja": "Japanese",
"jw": "Javanese",
"kn": "Kannada",
"kk": "Kazakh",
"km": "Khmer",
"rw": "Kinyarwanda",
"ko": "Korean",
"ku": "Kurdish (Kurmanji)",
"ky": "Kyrgyz",
"lo": "Lao",
"la": "Latin",
"lv": "Latvian",
"lt": "Lithuanian",
"lb": "Luxembourgish",
"mk": "Macedonian",
"mg": "Malagasy",
"ms": "Malay",
"ml": "Malayalam",
"mt": "Maltese",
"mi": "Maori",
"mr": "Marathi",
"mn": "Mongolian",
"my": "Myanmar (Burmese)",
"ne": "Nepali",
"no": "Norwegian",
"or": "Odia (Oriya)",
"ps": "Pashto",
"fa": "Persian",
"pl": "Polish",
"pt": "Portuguese",
"pa": "Punjabi",
"ro": "Romanian",
"ru": "Russian",
"sm": "Samoan",
"gd": "Scots Gaelic",
"sr": "Serbian",
"st": "Sesotho",
"sn": "Shona",
"sd": "Sindhi",
"si": "Sinhala",
"sk": "Slovak",
"sl": "Slovenian",
"so": "Somali",
"es": "Spanish",
"su": "Sundanese",
"sw": "Swahili",
"sv": "Swedish",
"tg": "Tajik",
"ta": "Tamil",
"tt": "Tatar",
"te": "Telugu",
"th": "Thai",
"tr": "Turkish",
"tk": "Turkmen",
"uk": "Ukrainian",
"ur": "Urdu",
"ug": "Uyghur",
"uz": "Uzbek",
"vi": "Vietnamese",
"cy": "Welsh",
"xh": "Xhosa",
"yi": "Yiddish",
"yo": "Yoruba",
"zu": "Zulu"
},
"exceptions": {
"source": {
"zh_HANT": "zh"
},
"target": {
"auto": "en"
}
},
"mappings": {
"source": {},
"target": {
"zh": "zh-CN",
"zh_HANT": "zh-TW",
"auto": "en"
}
}
}

View File

@@ -1,37 +1,54 @@
import { replaceBoth, isValid, LangCode } from "./language";
import { replaceExceptedCode, isValidCode, LanguageType, LangCode } from "lingva-scraper";
const defaultSourceLang = process.env["NEXT_PUBLIC_DEFAULT_SOURCE_LANG"];
const defaultTargetLang = process.env["NEXT_PUBLIC_DEFAULT_TARGET_LANG"];
type State = {
source: LangCode,
target: LangCode,
export type State = {
source: LangCode<"source">,
target: LangCode<"target">,
query: string,
delayedQuery: string,
translation: string,
isLoading: boolean
isLoading: boolean,
pronunciation: {
query?: string,
translation?: string
},
audio: {
query?: number[],
translation?: number[]
}
}
export const initialState: State = {
source: isValid(defaultSourceLang) ? defaultSourceLang : "auto",
target: isValid(defaultTargetLang) ? defaultTargetLang : "en",
source: isValidCode(defaultSourceLang, LanguageType.SOURCE) ? defaultSourceLang : "auto",
target: isValidCode(defaultTargetLang, LanguageType.TARGET) ? defaultTargetLang : "en",
query: "",
delayedQuery: "",
translation: "",
isLoading: true
isLoading: true,
pronunciation: {},
audio: {}
}
export enum Actions {
SET_FIELD,
SET_SOURCE,
SET_TARGET,
SET_ALL,
SWITCH_LANGS
}
type Action = {
type Action<T extends keyof State = keyof State> = {
type: Actions.SET_FIELD,
payload: {
key: string,
value: any
key: T,
value: State[T]
}
} | {
type: Actions.SET_SOURCE | Actions.SET_TARGET,
payload: {
code: string
}
} | {
type: Actions.SET_ALL,
@@ -39,36 +56,84 @@ type Action = {
state: State
}
} | {
type: Actions.SWITCH_LANGS
type: Actions.SWITCH_LANGS,
payload: {
detectedSource?: LangCode<"source">
}
}
export default function reducer(state: State, action: Action): State {
const { source, target } = replaceBoth("exception", {
source: state.target,
target: state.source
});
switch (action.type) {
case Actions.SET_FIELD:
case Actions.SET_FIELD: {
const { key, value } = action.payload;
if (key === "source" && value === state.target)
return { ...state, [key]: value, target: target !== value ? target : "eo" };
if (key === "target" && value === state.source)
return { ...state, [key]: value, source };
return { ...state, [key]: value };
case Actions.SET_ALL:
return { ...state, ...action.payload.state };
case Actions.SWITCH_LANGS:
}
case Actions.SET_SOURCE: {
const { code } = action.payload;
if (!isValidCode(code, LanguageType.SOURCE))
return state;
if (code !== state.target)
return { ...state, source: code };
const sourceAsTarget = replaceExceptedCode(LanguageType.TARGET, state.source);
return {
...state,
source: source !== target
? source
: initialState.source,
target,
source: code,
target: sourceAsTarget !== code
? sourceAsTarget
: "eo"
};
}
case Actions.SET_TARGET: {
const { code } = action.payload;
if (!isValidCode(code, LanguageType.TARGET))
return state;
if (code !== state.source)
return { ...state, target: code };
const targetAsSource = replaceExceptedCode(LanguageType.SOURCE, state.target);
return {
...state,
target: code,
source: targetAsSource !== code
? targetAsSource
: "auto"
};
}
case Actions.SET_ALL: {
return { ...state, ...action.payload.state };
}
case Actions.SWITCH_LANGS: {
const { detectedSource } = action.payload;
const newTarget = state.source === "auto" && detectedSource
? detectedSource
: state.source;
const parsedNewTarget = replaceExceptedCode(LanguageType.TARGET, newTarget);
const parsedNewSource = parsedNewTarget === state.target
? initialState.source
: replaceExceptedCode(LanguageType.SOURCE, state.target);
return {
...state,
source: parsedNewSource,
target: parsedNewTarget,
query: state.translation,
delayedQuery: state.translation,
translation: state.query
translation: state.query,
pronunciation: {
query: state.pronunciation.translation,
translation: state.pronunciation.query
},
audio: {
query: state.audio.translation,
translation: state.audio.query
}
};
}
default:
return state;
}

19
utils/slug.ts Normal file
View File

@@ -0,0 +1,19 @@
export const extractSlug = (
slug: string[]
): {
source?: string,
target?: string,
query?: string
} => {
const [p1, p2, p3] = slug;
switch (slug.length) {
case 1:
return { query: p1 };
case 2:
return { target: p1, query: p2 };
case 3:
return { source: p1, target: p2, query: p3 };
default:
return {};
}
};

View File

@@ -1,88 +0,0 @@
import UserAgent from "user-agents";
import cheerio from "cheerio";
import { replaceBoth, LangCode } from "./language";
export async function googleScrape(
source: LangCode,
target: LangCode,
query: string
): Promise<{
translationRes: string
} | {
errorMsg: string
}> {
const parsed = replaceBoth("mapping", { source, target });
const encodedQuery = encodeURIComponent(query);
if (encodedQuery.length > 7500)
return {
errorMsg: "The translation query is too long"
};
const res = await fetch(
`https://translate.google.com/m?sl=${parsed.source}&tl=${parsed.target}&q=${encodedQuery}`,
{
headers: {
"User-Agent": new UserAgent().toString()
}
}
).catch(
() => null
);
if (!res?.ok)
return {
errorMsg: "An error occurred while retrieving the translation"
};
const html = await res.text();
const translationRes = cheerio.load(html)(".result-container").text().trim();
return translationRes && !translationRes.includes("#af-error-page")
? {
translationRes
} : {
errorMsg: "An error occurred while parsing the translation"
};
}
export function extractSlug(slug: string[]): {
source?: string,
target?: string,
query?: string
} {
const [p1, p2, p3] = slug;
switch (slug.length) {
case 1:
return { query: p1 };
case 2:
return { target: p1, query: p2 };
case 3:
return { source: p1, target: p2, query: p3 };
default:
return {};
}
}
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);
const res = await fetch(
`https://translate.google.com/translate_tts?tl=${parsedLang}&q=${encodeURIComponent(slicedText)}&textlen=${slicedText.length}&client=tw-ob`,
{
headers: {
"User-Agent": new UserAgent().toString()
}
}
).catch(
() => null
);
return res?.ok
? res.blob().then(blob => blob.arrayBuffer()).then(buffer => Array.from(new Uint8Array(buffer)))
: null;
}

5618
yarn.lock

File diff suppressed because it is too large Load Diff