Refactor to external scraper and update dependencies (#113)
This commit is contained in:
@@ -5,7 +5,7 @@ node_js:
|
|||||||
|
|
||||||
os: linux
|
os: linux
|
||||||
|
|
||||||
dist: xenial
|
dist: focal
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- docker
|
- docker
|
||||||
@@ -26,7 +26,7 @@ branches:
|
|||||||
|
|
||||||
script:
|
script:
|
||||||
- yarn test --ci
|
- yarn test --ci
|
||||||
- yarn build
|
- NEXT_PUBLIC_FORCE_DEFAULT_THEME=light yarn build
|
||||||
- yarn start & wait-on http://localhost:3000
|
- yarn start & wait-on http://localhost:3000
|
||||||
- 'if [ "$TRAVIS_PULL_REQUEST" = "false" ];
|
- 'if [ "$TRAVIS_PULL_REQUEST" = "false" ];
|
||||||
then
|
then
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ ENV NODE_ENV production
|
|||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain DEFAULT_DARK_THEME=$dark_theme \
|
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_SOURCE_LANG=$default_source_lang \
|
||||||
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
|
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
|
||||||
yarn build && yarn start
|
yarn build && yarn start
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
<img src="public/logo.svg" width="128" align="right">
|
<img src="public/logo.svg" width="128" align="right">
|
||||||
|
|
||||||
[](https://travis-ci.com/TheDavidDelta/lingva-translate)
|
[](https://travis-ci.com/thedaviddelta/lingva-translate)
|
||||||
[](https://lingva.ml/)
|
[](https://lingva.ml/)
|
||||||
[](https://dashboard.cypress.io/projects/qgjdyd/runs)
|
[](https://dashboard.cypress.io/projects/qgjdyd/runs)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://github.com/humanetech-community/awesome-humane-tech)
|
[](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)
|
[<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?
|
## 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:
|
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.
|
+ [TypeScript](https://www.typescriptlang.org/), the JavaScript superset, as the language.
|
||||||
+ [React](https://reactjs.org/) as the main front-end framework.
|
+ [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.
|
+ [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.
|
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
|
### 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:
|
#### Docker Compose:
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- site_domain=lingva.ml
|
- site_domain=lingva.ml
|
||||||
- dark_theme=false
|
- force_default_theme=light
|
||||||
- default_source_lang=auto
|
- default_source_lang=auto
|
||||||
- default_target_lang=en
|
- default_target_lang=en
|
||||||
ports:
|
ports:
|
||||||
@@ -62,14 +66,14 @@ services:
|
|||||||
#### Docker Run
|
#### Docker Run
|
||||||
|
|
||||||
```bash
|
```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
|
### 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.
|
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.
|
||||||
|
|
||||||
[](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)
|
[](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
|
## Instances
|
||||||
@@ -79,12 +83,7 @@ These are the currently known *Lingva* instances. Feel free to make a Pull Reque
|
|||||||
| Domain | Hosting | SSL Provider |
|
| 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) |
|
| [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) |
|
| [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) |
|
| [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
|
## Public APIs
|
||||||
@@ -99,6 +98,7 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs:
|
|||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
translation: string
|
translation: string
|
||||||
|
info?: TranslationInfo
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -141,6 +141,23 @@ query {
|
|||||||
}
|
}
|
||||||
text: String!
|
text: String!
|
||||||
audio: [Int]!
|
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: {
|
target: {
|
||||||
lang: {
|
lang: {
|
||||||
@@ -149,6 +166,16 @@ query {
|
|||||||
}
|
}
|
||||||
text: String!
|
text: String!
|
||||||
audio: [Int]!
|
audio: [Int]!
|
||||||
|
pronunciation: String
|
||||||
|
extraTranslations: {
|
||||||
|
type: String
|
||||||
|
list: {
|
||||||
|
word: String
|
||||||
|
article: String
|
||||||
|
frequency: Int
|
||||||
|
meanings: [String]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audio(lang: String! query: String!) {
|
audio(lang: String! query: String!) {
|
||||||
@@ -169,9 +196,12 @@ query {
|
|||||||
|
|
||||||
## Related projects
|
## 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
|
+ [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
|
+ [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
|
## Contributors
|
||||||
@@ -201,5 +231,5 @@ This project follows the [all-contributors](https://github.com/all-contributors/
|
|||||||
|
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0.html)
|
[](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.
|
This project is [GNU AGPLv3](./LICENSE) licensed.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import theme from "@theme";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
customTitle?: string,
|
customTitle?: string,
|
||||||
home?: true
|
home?: boolean
|
||||||
};
|
};
|
||||||
|
|
||||||
const title = "Lingva Translate";
|
const title = "Lingva Translate";
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const Footer: FC<Props> = (props) => (
|
|||||||
spacing={[1, null, 2]}
|
spacing={[1, null, 2]}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Link href="https://github.com/TheDavidDelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
|
<Link href="https://github.com/thedaviddelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
|
||||||
<Text as="span">© 2021 TheDavidDelta & contributors</Text>
|
<Text as="span">© 2021 thedaviddelta & contributors</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
||||||
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>
|
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const Header: FC<Props> = (props) => (
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
as={Link}
|
as={Link}
|
||||||
href="https://github.com/TheDavidDelta/lingva-translate"
|
href="https://github.com/thedaviddelta/lingva-translate"
|
||||||
isExternal={true}
|
isExternal={true}
|
||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
icon={<FaGithub />}
|
icon={<FaGithub />}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { FC, ChangeEvent } from "react";
|
import { FC, ChangeEvent } from "react";
|
||||||
import { Select } from "@chakra-ui/react";
|
import { Select } from "@chakra-ui/react";
|
||||||
|
import { LangCode } from "lingva-scraper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string,
|
value: string,
|
||||||
onChange: (e: ChangeEvent<any>) => void,
|
onChange: (e: ChangeEvent<any>) => void,
|
||||||
langs: [string, string][],
|
langs: {
|
||||||
|
[code in LangCode]: string
|
||||||
|
},
|
||||||
|
detectedSource?: LangCode<"source">,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
};
|
};
|
||||||
|
|
||||||
const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
|
const LangSelect: FC<Props> = ({ value, onChange, langs, detectedSource, ...props }) => (
|
||||||
<Select
|
<Select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -18,8 +22,10 @@ const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
|
|||||||
style={{ textAlignLast: "center" }}
|
style={{ textAlignLast: "center" }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{langs.map(([code, name]) => (
|
{Object.entries(langs).map(([code, name]) => (
|
||||||
<option value={code} key={code}>{name}</option>
|
<option value={code} key={code}>
|
||||||
|
{name}{code === "auto" && !!detectedSource && ` (${langs[detectedSource]})`}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FC } from "react";
|
import { FC, PropsWithChildren } from "react";
|
||||||
import { Flex, VStack, Button, Link, useColorModeValue } from "@chakra-ui/react";
|
import { Flex, VStack, Button, Link, useColorModeValue } from "@chakra-ui/react";
|
||||||
import { Header, Footer } from ".";
|
import { Header, Footer } from ".";
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ type Props = {
|
|||||||
[key: string]: any
|
[key: string]: any
|
||||||
};
|
};
|
||||||
|
|
||||||
const Layout: FC<Props> = ({ children, ...props }) => (
|
const Layout: FC<PropsWithChildren<Props>> = ({ children, ...props }) => (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
as={Link}
|
as={Link}
|
||||||
@@ -22,7 +22,7 @@ const Layout: FC<Props> = ({ children, ...props }) => (
|
|||||||
Skip to content
|
Skip to content
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<VStack minH="100vh" spacing={8}>
|
<VStack minH="100%" spacing={7}>
|
||||||
<Header
|
<Header
|
||||||
bgColor={useColorModeValue("lingva.100", "lingva.900")}
|
bgColor={useColorModeValue("lingva.100", "lingva.900")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { FC } from "react";
|
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 { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa";
|
||||||
import { useAudioFromBuffer } from "@hooks";
|
import { useAudioFromBuffer } from "@hooks";
|
||||||
|
|
||||||
@@ -14,20 +26,32 @@ type Props = {
|
|||||||
[key: string]: any
|
[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 { hasCopied, onCopy } = useClipboard(value);
|
||||||
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
|
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
|
||||||
const spinnerProps = {
|
const spinnerProps = {
|
||||||
size: useBreakpointValue(["lg", null, "xl"]) ?? undefined,
|
size: useBreakpointValue(["lg", null, "xl"]) ?? "lg",
|
||||||
color: useColorModeValue("lingva.500", "lingva.200"),
|
color: useColorModeValue("lingva.500", "lingva.200"),
|
||||||
emptyColor: useColorModeValue("gray.300", "gray.600")
|
emptyColor: useColorModeValue("gray.300", "gray.600")
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<VStack
|
||||||
position="relative"
|
|
||||||
w="full"
|
w="full"
|
||||||
|
h={useBreakpointValue([44, null, 80]) ?? 44}
|
||||||
|
align="stretch"
|
||||||
|
spacing={0}
|
||||||
|
position="relative"
|
||||||
isolation="isolate"
|
isolation="isolate"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={useColorModeValue("lingva.500", "lingva.200")}
|
||||||
|
borderRadius="md"
|
||||||
|
_hover={{
|
||||||
|
borderColor: useColorModeValue("lingva.800", "lingva.400"),
|
||||||
|
}}
|
||||||
|
_readOnly={{
|
||||||
|
userSelect: "auto"
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={value}
|
value={value}
|
||||||
@@ -35,54 +59,66 @@ const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
resize="none"
|
resize="none"
|
||||||
rows={useBreakpointValue([6, null, 12]) ?? undefined}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
|
variant="ghost"
|
||||||
|
boxShadow={`inset 0 0 1px ${useColorModeValue("hsl(146 100% 17% / 25%)", "hsl(142 40% 82% / 25%)")}`}
|
||||||
data-gramm_editor={false}
|
data-gramm_editor={false}
|
||||||
onKeyPress={e => (e.ctrlKey || e.metaKey) && e.key === "Enter" && onSubmit?.()}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<HStack
|
<HStack
|
||||||
position="absolute"
|
position={!pronunciation ? "absolute" : undefined}
|
||||||
bottom={4}
|
pointerEvents={!pronunciation ? "none" : undefined}
|
||||||
right={4}
|
bottom={0}
|
||||||
// Needed because the textarea stacks over on focus
|
left={0}
|
||||||
zIndex={3}
|
right={0}
|
||||||
>
|
>
|
||||||
{canCopy && (
|
<HStack justify="space-between" px={5} h={useBreakpointValue([12, null, 14]) ?? 12} w="0px" flex={1}>
|
||||||
<Tooltip label={hasCopied ? "Copied!" : "Copy to clipboard"}>
|
<Tooltip label={pronunciation}>
|
||||||
<IconButton
|
<Text aria-label="Pronunciation" opacity={0.75} whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
|
||||||
aria-label="Copy to clipboard"
|
{pronunciation}
|
||||||
icon={hasCopied ? <FaCheck /> : <FaCopy />}
|
</Text>
|
||||||
onClick={onCopy}
|
|
||||||
colorScheme="lingva"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!value}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
<HStack pointerEvents="auto">
|
||||||
<Tooltip label={isAudioPlaying ? "Stop audio" : "Play audio"}>
|
{canCopy && (
|
||||||
<IconButton
|
<Tooltip label={hasCopied ? "Copied!" : "Copy to clipboard"}>
|
||||||
aria-label={isAudioPlaying ? "Stop audio" : "Play audio"}
|
<IconButton
|
||||||
icon={isAudioPlaying ? <FaStop /> : <FaPlay />}
|
aria-label="Copy to clipboard"
|
||||||
onClick={onAudioClick}
|
icon={hasCopied ? <FaCheck /> : <FaCopy />}
|
||||||
colorScheme="lingva"
|
onClick={onCopy}
|
||||||
variant="ghost"
|
colorScheme="lingva"
|
||||||
disabled={!audioExists}
|
variant="ghost"
|
||||||
/>
|
disabled={!value}
|
||||||
</Tooltip>
|
/>
|
||||||
|
</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>
|
</HStack>
|
||||||
{isLoading && <Spinner
|
{isLoading && <Spinner
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top="0"
|
inset={0}
|
||||||
bottom="0"
|
m="auto !important"
|
||||||
left="0"
|
|
||||||
right="0"
|
|
||||||
m="auto"
|
|
||||||
thickness="3px"
|
thickness="3px"
|
||||||
label="Loading translation"
|
label="Loading translation"
|
||||||
{...spinnerProps}
|
{...spinnerProps}
|
||||||
/>}
|
/>}
|
||||||
</Box>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
13
cypress.config.ts
Normal file
13
cypress.config.ts
Normal 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}'
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"baseUrl": "http://localhost:3000",
|
|
||||||
"defaultCommandTimeout": 10000,
|
|
||||||
"projectId": "qgjdyd"
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
/// <reference types="cypress" />
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
import faker from "faker";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
cy.clearLocalStorage();
|
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", () => {
|
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.visit(`/auto/en/${query}`);
|
||||||
|
|
||||||
cy.findByRole("button", { name: /switch auto/i })
|
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", () => {
|
it("loads & plays audio correctly", () => {
|
||||||
const query = faker.lorem.words(5);
|
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(`/la/en/${query}`);
|
cy.visit(`/ca/en/${query}`);
|
||||||
|
|
||||||
const play = "Play audio";
|
const play = "Play audio";
|
||||||
const stop = "Stop audio";
|
const stop = "Stop audio";
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
[
|
[
|
||||||
"https://lingva.ml",
|
"https://lingva.ml",
|
||||||
"https://translate.alefvanoon.xyz",
|
|
||||||
"https://translate.igna.rocks",
|
|
||||||
"https://lingva.pussthecat.org",
|
"https://lingva.pussthecat.org",
|
||||||
"https://translate.datatunnel.xyz",
|
|
||||||
"https://lingva.esmailelbob.xyz",
|
|
||||||
"https://translate.plausibility.cloud",
|
|
||||||
"https://lingva.lunar.icu"
|
"https://lingva.lunar.icu"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
module.exports = {
|
const nextJest = require("next/jest");
|
||||||
transform: {
|
|
||||||
'^.+\\.(js|jsx|ts|tsx)$': 'babel-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: [
|
testPathIgnorePatterns: [
|
||||||
'<rootDir>/cypress/'
|
'<rootDir>/cypress/'
|
||||||
],
|
],
|
||||||
setupFilesAfterEnv: [
|
setupFilesAfterEnv: [
|
||||||
"<rootDir>/tests/setupTests.ts"
|
"<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
2978
mocks/data/audio.json
Normal file
File diff suppressed because it is too large
Load Diff
287
mocks/data/fullInfo.json
Normal file
287
mocks/data/fullInfo.json
Normal 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
25
mocks/data/index.ts
Normal 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"
|
||||||
|
};
|
||||||
10
mocks/data/pronunciationInfo.json
Normal file
10
mocks/data/pronunciationInfo.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"pronunciation": {
|
||||||
|
"query": "Zǎo ān",
|
||||||
|
"translation": "joh-eun achim"
|
||||||
|
},
|
||||||
|
"definitions": [],
|
||||||
|
"examples": [],
|
||||||
|
"similar": [],
|
||||||
|
"extraTranslations": []
|
||||||
|
}
|
||||||
7
mocks/data/simpleInfo.json
Normal file
7
mocks/data/simpleInfo.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"pronunciation": {},
|
||||||
|
"definitions": [],
|
||||||
|
"examples": [],
|
||||||
|
"similar": [],
|
||||||
|
"extraTranslations": []
|
||||||
|
}
|
||||||
@@ -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
31
mocks/next.tsx
Normal 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
40
mocks/renewData.ts
Normal 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))
|
||||||
|
);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const withPWA = require("next-pwa");
|
const withPWA = require("next-pwa");
|
||||||
|
|
||||||
module.exports = withPWA({
|
module.exports = withPWA({
|
||||||
|
swcMinify: true,
|
||||||
pwa: {
|
pwa: {
|
||||||
dest: "public"
|
dest: "public"
|
||||||
},
|
},
|
||||||
|
|||||||
65
package.json
65
package.json
@@ -9,60 +9,55 @@
|
|||||||
"start": "next start --port ${PORT-3000}",
|
"start": "next start --port ${PORT-3000}",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"cy:open": "cypress open",
|
"cy:open": "cypress open",
|
||||||
"cy:run": "cypress run"
|
"cy:run": "cypress run",
|
||||||
|
"renew-mock-data": "ts-node --skip-project mocks/renewData.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/icons": "1.0.15",
|
"@chakra-ui/icons": "2.0.2",
|
||||||
"@chakra-ui/react": "1.6.6",
|
"@chakra-ui/react": "2.2.1",
|
||||||
"@emotion/react": "^11",
|
"@emotion/react": "^11",
|
||||||
"@emotion/styled": "^11",
|
"@emotion/styled": "^11",
|
||||||
"apollo-server-micro": "^2.25.2",
|
"apollo-server-micro": "^2.25.2",
|
||||||
"cheerio": "^1.0.0-rc.10",
|
"framer-motion": "^6",
|
||||||
"framer-motion": "^4",
|
|
||||||
"graphql": "^15.8.0",
|
"graphql": "^15.8.0",
|
||||||
"next": "12.1.0",
|
"lingva-scraper": "1.0.0",
|
||||||
|
"next": "12.1.6",
|
||||||
"next-pwa": "^5.4.4",
|
"next-pwa": "^5.4.4",
|
||||||
"nextjs-cors": "^2.1.0",
|
"nextjs-cors": "^2.1.0",
|
||||||
"react": "17.0.2",
|
"react": "18.2.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "18.2.0",
|
||||||
"react-hotkeys-hook": "^3.4.4",
|
"react-hotkeys-hook": "^3.4.6",
|
||||||
"react-icons": "^4.3.1",
|
"react-icons": "^4.4.0"
|
||||||
"user-agents": "^1.0.937"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/cypress": "^8.0.2",
|
"@testing-library/cypress": "^8.0.3",
|
||||||
"@testing-library/jest-dom": "^5.16.2",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react": "^12.1.3",
|
"@testing-library/react": "^13.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^14.2.0",
|
||||||
"@types/faker": "^5.5.6",
|
"@types/jest": "^28.1.1",
|
||||||
"@types/jest": "^27.4.1",
|
"@types/node": "^17.0.43",
|
||||||
"@types/node": "^17.0.21",
|
"@types/react": "^18.0.12",
|
||||||
"@types/react": "^17.0.39",
|
|
||||||
"@types/user-agents": "^1.0.2",
|
|
||||||
"apollo-server-testing": "^2.25.2",
|
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"cypress": "^9.5.0",
|
"cypress": "^10.1.0",
|
||||||
"eslint": "^8.9.0",
|
"eslint": "^8.17.0",
|
||||||
"eslint-config-next": "^12.1.0",
|
"eslint-config-next": "^12.1.6",
|
||||||
"eslint-config-react-app": "^7.0.0",
|
|
||||||
"eslint-plugin-cypress": "^2.12.1",
|
"eslint-plugin-cypress": "^2.12.1",
|
||||||
"faker": "^5.5.3",
|
"jest": "^28.1.1",
|
||||||
"jest": "^27.5.1",
|
"jest-environment-jsdom": "^28.1.1",
|
||||||
"jest-fetch-mock": "^3.0.3",
|
"ts-node": "^10.8.1",
|
||||||
"node-mocks-http": "^1.11.0",
|
"typescript": "^4.7.3",
|
||||||
"typescript": "^4.5.5",
|
|
||||||
"wait-on": "^6.0.1"
|
"wait-on": "^6.0.1"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"react-app",
|
"next",
|
||||||
"react-app/jest",
|
"plugin:cypress/recommended"
|
||||||
"plugin:cypress/recommended",
|
|
||||||
"next"
|
|
||||||
],
|
],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["cypress/integration/*.ts"],
|
"files": [
|
||||||
|
"cypress/integration/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"testing-library/await-async-query": "off",
|
"testing-library/await-async-query": "off",
|
||||||
"testing-library/prefer-screen-queries": "off"
|
"testing-library/prefer-screen-queries": "off"
|
||||||
|
|||||||
@@ -1,125 +1,191 @@
|
|||||||
import { useEffect, useReducer, useCallback, FC, ChangeEvent } from "react";
|
import { useCallback, useEffect, useReducer } from "react";
|
||||||
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
|
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
||||||
import Router from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import dynamic from "next/dynamic";
|
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 { FaExchangeAlt } from "react-icons/fa";
|
||||||
import { HiTranslate } from "react-icons/hi";
|
import { HiTranslate } from "react-icons/hi";
|
||||||
import { useHotkeys } from "react-hotkeys-hook";
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
import { CustomHead, LangSelect, TranslationArea } from "@components";
|
import { CustomHead, LangSelect, TranslationArea } from "@components";
|
||||||
import { useToastOnLoad } from "@hooks";
|
import { useToastOnLoad } from "@hooks";
|
||||||
import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate";
|
import { extractSlug } from "@utils/slug";
|
||||||
import { retrieveFromType, replaceBoth, isValid } from "@utils/language";
|
import langReducer, { Actions, initialState, State } from "@utils/reducer";
|
||||||
import langReducer, { Actions, initialState } from "@utils/reducer";
|
|
||||||
import { localGetItem, localSetItem } from "@utils/storage";
|
import { localGetItem, localSetItem } from "@utils/storage";
|
||||||
|
|
||||||
const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false });
|
const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false });
|
||||||
|
|
||||||
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
|
export enum ResponseType {
|
||||||
const [{ source, target, query, delayedQuery, translation, isLoading }, dispatch] = useReducer(langReducer, initialState);
|
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({
|
dispatch({
|
||||||
type: Actions.SET_FIELD,
|
type: type === LanguageType.SOURCE
|
||||||
payload: {
|
? Actions.SET_SOURCE
|
||||||
key: e.target.id,
|
: Actions.SET_TARGET,
|
||||||
value: e.target.value
|
payload: { code }
|
||||||
}
|
})
|
||||||
});
|
), []);
|
||||||
};
|
|
||||||
|
const switchLanguages = useCallback((detectedSource?: LangCode<"source">) => (
|
||||||
|
dispatch({ type: Actions.SWITCH_LANGS, payload: { detectedSource } })
|
||||||
|
), []);
|
||||||
|
|
||||||
const changeRoute = useCallback((customQuery: string) => {
|
const changeRoute = useCallback((customQuery: string) => {
|
||||||
if (isLoading)
|
if (isLoading || router.isFallback)
|
||||||
return;
|
return;
|
||||||
if (!customQuery || customQuery === initialState.query)
|
if (!customQuery || customQuery === initialState.query)
|
||||||
return;
|
return;
|
||||||
if (!home && !initial)
|
if (props.type === ResponseType.SUCCESS && customQuery === props.initial.query
|
||||||
return;
|
&& source === props.initial.source && target === props.initial.target)
|
||||||
if (!home && customQuery === initial.query && source === initial.source && target === initial.target)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
localSetItem("source", source);
|
localSetItem(LanguageType.SOURCE, source);
|
||||||
localSetItem("target", target);
|
localSetItem(LanguageType.TARGET, target);
|
||||||
|
|
||||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
|
setField("isLoading", true);
|
||||||
Router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`);
|
router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`);
|
||||||
}, [isLoading, source, target, home, initial]);
|
}, [isLoading, source, target, props, router, setField]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (home) {
|
if (router.isFallback)
|
||||||
const localSource = localGetItem("source");
|
return;
|
||||||
const localTarget = localGetItem("target");
|
|
||||||
return dispatch({
|
if (props.type === ResponseType.HOME) {
|
||||||
type: Actions.SET_ALL,
|
const localSource = localGetItem(LanguageType.SOURCE);
|
||||||
payload: {
|
const localTarget = localGetItem(LanguageType.TARGET);
|
||||||
state: {
|
|
||||||
...initialState,
|
return setAllFields({
|
||||||
source: isValid(localSource) ? localSource : initialState.source,
|
...initialState,
|
||||||
target: isValid(localTarget) ? localTarget : initialState.target,
|
source: isValidCode(localSource, LanguageType.SOURCE)
|
||||||
isLoading: false
|
? localSource
|
||||||
}
|
: initialState.source,
|
||||||
}
|
target: isValidCode(localTarget, LanguageType.TARGET)
|
||||||
|
? localTarget
|
||||||
|
: initialState.target,
|
||||||
|
isLoading: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initial)
|
if (props.type === ResponseType.ERROR)
|
||||||
return;
|
return setAllFields({
|
||||||
|
...initialState,
|
||||||
|
...props.initial,
|
||||||
|
delayedQuery: props.initial.query,
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
dispatch({
|
setAllFields({
|
||||||
type: Actions.SET_ALL,
|
...props.initial,
|
||||||
payload: {
|
delayedQuery: props.initial.query,
|
||||||
state: {
|
translation: props.translation,
|
||||||
...initial,
|
isLoading: false,
|
||||||
delayedQuery: initial.query,
|
pronunciation: props.info?.pronunciation ?? {},
|
||||||
translation: translationRes,
|
audio: {
|
||||||
isLoading: false
|
query: props.audio.query ?? undefined,
|
||||||
}
|
translation: props.audio.translation ?? undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [initial, translationRes, home]);
|
}, [props, router, setAllFields]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() =>
|
const timeoutId = setTimeout(() => setField("delayedQuery", query), 1000);
|
||||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "delayedQuery", value: query }}
|
return () => clearTimeout(timeoutId);
|
||||||
), 1000);
|
}, [query, setField]);
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (url: string) => {
|
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 !== "/")
|
if (url !== "/")
|
||||||
return;
|
return;
|
||||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "source", value: initialState.source }});
|
setLanguage(LanguageType.SOURCE, initialState.source);
|
||||||
localSetItem("source", initialState.source);
|
localSetItem(LanguageType.SOURCE, initialState.source);
|
||||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "target", value: initialState.target }});
|
setLanguage(LanguageType.TARGET, initialState.target);
|
||||||
localSetItem("target", initialState.target);
|
localSetItem(LanguageType.TARGET, initialState.target);
|
||||||
};
|
};
|
||||||
Router.events.on("beforeHistoryChange", handler);
|
router.events.on("beforeHistoryChange", handler);
|
||||||
return () => Router.events.off("beforeHistoryChange", handler);
|
return () => router.events.off("beforeHistoryChange", handler);
|
||||||
}, []);
|
}, [router, setLanguage, setField]);
|
||||||
|
|
||||||
const sourceLangs = retrieveFromType("source");
|
|
||||||
const targetLangs = retrieveFromType("target");
|
|
||||||
const { source: transLang, target: queryLang } = replaceBoth("exception", { source: target, target: source });
|
|
||||||
|
|
||||||
useToastOnLoad({
|
useToastOnLoad({
|
||||||
title: "Unexpected error",
|
|
||||||
description: errorMsg,
|
|
||||||
status: "error",
|
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", () => (
|
useHotkeys("ctrl+shift+s, command+shift+s, ctrl+shift+f, command+shift+f", () => (
|
||||||
canSwitch && dispatch({ type: Actions.SWITCH_LANGS })
|
canSwitch && switchLanguages(detectedSource)
|
||||||
), [canSwitch]);
|
), [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomHead home={home} />
|
<CustomHead home={props.type === ResponseType.HOME} />
|
||||||
|
|
||||||
<VStack px={[8, null, 24, 40]} w="full">
|
<VStack px={[8, null, 24, 40]} w="full">
|
||||||
<HStack px={[1, null, 3, 4]} w="full">
|
<HStack px={[1, null, 3, 4]} w="full">
|
||||||
@@ -127,23 +193,24 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
id="source"
|
id="source"
|
||||||
aria-label="Source language"
|
aria-label="Source language"
|
||||||
value={source}
|
value={source}
|
||||||
onChange={handleChange}
|
detectedSource={detectedSource}
|
||||||
langs={sourceLangs}
|
onChange={e => setLanguage(LanguageType.SOURCE, e.target.value)}
|
||||||
|
langs={languageList.source}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="Switch languages"
|
aria-label="Switch languages"
|
||||||
icon={<FaExchangeAlt />}
|
icon={<FaExchangeAlt />}
|
||||||
colorScheme="lingva"
|
colorScheme="lingva"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => dispatch({ type: Actions.SWITCH_LANGS })}
|
onClick={() => switchLanguages(detectedSource)}
|
||||||
isDisabled={!canSwitch}
|
isDisabled={!canSwitch}
|
||||||
/>
|
/>
|
||||||
<LangSelect
|
<LangSelect
|
||||||
id="target"
|
id="target"
|
||||||
aria-label="Target language"
|
aria-label="Target language"
|
||||||
value={target}
|
value={target}
|
||||||
onChange={handleChange}
|
onChange={e => setLanguage(LanguageType.TARGET, e.target.value)}
|
||||||
langs={targetLangs}
|
langs={languageList.target}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Stack direction={["column", null, "row"]} w="full">
|
<Stack direction={["column", null, "row"]} w="full">
|
||||||
@@ -152,10 +219,11 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
aria-label="Translation query"
|
aria-label="Translation query"
|
||||||
placeholder="Text"
|
placeholder="Text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => isLoading || handleChange(e)}
|
onChange={e => isLoading || setField("query", e.target.value)}
|
||||||
onSubmit={useCallback(() => changeRoute(query), [query, changeRoute])}
|
onSubmit={() => changeRoute(query)}
|
||||||
lang={queryLang}
|
lang={queryLang}
|
||||||
audio={audio?.source}
|
audio={audio.query}
|
||||||
|
pronunciation={pronunciation.query}
|
||||||
/>
|
/>
|
||||||
<Stack direction={["row", null, "column"]} justify="center" spacing={3} px={[2, null, "initial"]}>
|
<Stack direction={["row", null, "column"]} justify="center" spacing={3} px={[2, null, "initial"]}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -168,8 +236,9 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
w={["full", null, "auto"]}
|
w={["full", null, "auto"]}
|
||||||
/>
|
/>
|
||||||
<AutoTranslateButton
|
<AutoTranslateButton
|
||||||
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
|
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
|
// runs on effect update
|
||||||
|
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
|
||||||
w={["full", null, "auto"]}
|
w={["full", null, "auto"]}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -180,9 +249,10 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
|||||||
value={translation ?? ""}
|
value={translation ?? ""}
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
lang={transLang}
|
lang={transLang}
|
||||||
audio={audio?.target}
|
audio={audio.translation}
|
||||||
canCopy={true}
|
canCopy={true}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
pronunciation={pronunciation.translation}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -201,10 +271,12 @@ export const getStaticPaths: GetStaticPaths = async () => ({
|
|||||||
fallback: true
|
fallback: true
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
|
||||||
if (!params?.slug || !Array.isArray(params.slug))
|
if (!params?.slug || !Array.isArray(params.slug))
|
||||||
return {
|
return {
|
||||||
props: { home: true }
|
props: {
|
||||||
|
type: ResponseType.HOME
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { source, target, query } = extractSlug(params.slug);
|
const { source, target, query } = extractSlug(params.slug);
|
||||||
@@ -220,35 +292,52 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
|
|||||||
destination: `/${source ?? "auto"}/${target ?? "en"}/${query}`,
|
destination: `/${source ?? "auto"}/${target ?? "en"}/${query}`,
|
||||||
permanent: true
|
permanent: true
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!isValid(source) || !isValid(target))
|
if (!isValidCode(source, LanguageType.SOURCE) || !isValidCode(target, LanguageType.TARGET))
|
||||||
return {
|
return {
|
||||||
notFound: true
|
notFound: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const textScrape = await googleScrape(source, target, query);
|
const initial = { source, target, query };
|
||||||
|
|
||||||
const [sourceAudio, targetAudio] = await Promise.all([
|
const translation = await getTranslationText(source, target, query);
|
||||||
textToSpeechScrape(source, query),
|
|
||||||
"translationRes" in textScrape
|
if (!translation)
|
||||||
? textToSpeechScrape(target, textScrape.translationRes)
|
return {
|
||||||
: null
|
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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
...textScrape,
|
type: ResponseType.SUCCESS,
|
||||||
audio: {
|
translation,
|
||||||
source: sourceAudio,
|
info,
|
||||||
target: targetAudio
|
audio,
|
||||||
},
|
initial
|
||||||
initial: {
|
|
||||||
source, target, query
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
revalidate: !("errorMsg" in textScrape)
|
revalidate: 2 * 30 * 24 * 60 * 60 // 2 months
|
||||||
? 2 * 30 * 24 * 60 * 60 // 2 months
|
|
||||||
: 1
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { ApolloServer, gql, IResolvers, ApolloError, UserInputError } from "apollo-server-micro";
|
import { ApolloServer, gql, IResolvers, ApolloError, UserInputError } from "apollo-server-micro";
|
||||||
import { NextApiHandler } from "next";
|
import { NextApiHandler } from "next";
|
||||||
import NextCors from "nextjs-cors";
|
import NextCors from "nextjs-cors";
|
||||||
import { googleScrape, textToSpeechScrape } from "@utils/translate";
|
import {
|
||||||
import { retrieveFromType, getName, isValid } from "@utils/language";
|
getTranslationInfo,
|
||||||
|
getTranslationText,
|
||||||
|
getAudio,
|
||||||
|
replaceExceptedCode,
|
||||||
|
isValidCode,
|
||||||
|
LanguageType,
|
||||||
|
languageList,
|
||||||
|
LangCode
|
||||||
|
} from "lingva-scraper";
|
||||||
|
|
||||||
export const typeDefs = gql`
|
export const typeDefs = gql`
|
||||||
enum LangType {
|
enum LangType {
|
||||||
@@ -11,14 +19,32 @@ export const typeDefs = gql`
|
|||||||
}
|
}
|
||||||
type Query {
|
type Query {
|
||||||
translation(source: String="auto" target: String="en" query: String!): Translation!
|
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]!
|
languages(type: LangType): [Language]!
|
||||||
}
|
}
|
||||||
type Translation {
|
type Translation {
|
||||||
source: Entry!
|
source: SourceEntry!
|
||||||
target: Entry!
|
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!
|
lang: Language!
|
||||||
text: String!
|
text: String!
|
||||||
audio: [Int]!
|
audio: [Int]!
|
||||||
@@ -27,34 +53,70 @@ export const typeDefs = gql`
|
|||||||
code: String!
|
code: String!
|
||||||
name: 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 = {
|
export const resolvers: IResolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
translation(_, args) {
|
async translation(_, args) {
|
||||||
const { source, target, query } = 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");
|
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 {
|
return {
|
||||||
source: {
|
source: {
|
||||||
lang: {
|
lang: {
|
||||||
code: source
|
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: {
|
target: {
|
||||||
lang: {
|
lang: {
|
||||||
code: target
|
code: target
|
||||||
}
|
},
|
||||||
|
text: translation,
|
||||||
|
pronunciation: info?.pronunciation.translation,
|
||||||
|
extraTranslations: info?.extraTranslations
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
audio(_, args) {
|
audio(_, args) {
|
||||||
const { lang, query } = args;
|
const { lang, query } = args;
|
||||||
|
|
||||||
if (!isValid(lang))
|
if (!isValidCode(lang))
|
||||||
throw new UserInputError("Invalid language code");
|
throw new UserInputError("Invalid language code");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -66,36 +128,28 @@ export const resolvers: IResolvers = {
|
|||||||
},
|
},
|
||||||
languages(_, args) {
|
languages(_, args) {
|
||||||
const { type } = 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 }));
|
return langEntries.map(([code, name]) => ({ code, name }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Translation: {
|
...(["SourceEntry", "TargetEntry", "AudioEntry"].reduce((acc, key) => ({
|
||||||
async target(parent) {
|
...acc,
|
||||||
const { source, target } = parent;
|
[key]: {
|
||||||
const textScrape = await googleScrape(source.lang.code, target.lang.code, source.text);
|
async audio(parent) {
|
||||||
|
const { lang, text } = parent;
|
||||||
if ("errorMsg" in textScrape)
|
const parsedLang = replaceExceptedCode(LanguageType.TARGET, lang.code);
|
||||||
throw new ApolloError(textScrape.errorMsg);
|
const audio = await getAudio(parsedLang, text);
|
||||||
return {
|
if (!audio)
|
||||||
lang: target.lang,
|
throw new ApolloError("An error occurred while retrieving the audio");
|
||||||
text: textScrape.translationRes
|
return audio;
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
},
|
}), {} as IResolvers)),
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Language: {
|
Language: {
|
||||||
name(parent) {
|
name(parent) {
|
||||||
const { code, name } = parent;
|
const { code, name } = parent;
|
||||||
return name || getName(code);
|
return name || languageList.all[code as LangCode];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextApiHandler } from "next";
|
import { NextApiHandler } from "next";
|
||||||
import NextCors from "nextjs-cors";
|
import NextCors from "nextjs-cors";
|
||||||
import { googleScrape, textToSpeechScrape } from "@utils/translate";
|
import { getTranslationInfo, getTranslationText, getAudio, isValidCode, LanguageType, TranslationInfo } from "lingva-scraper";
|
||||||
import { isValid } from "@utils/language";
|
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
translation: string,
|
translation: string,
|
||||||
|
info?: TranslationInfo
|
||||||
} | {
|
} | {
|
||||||
audio: number[]
|
audio: number[]
|
||||||
} | {
|
} | {
|
||||||
@@ -35,24 +35,29 @@ const handler: NextApiHandler<Data> = async (req, res) => {
|
|||||||
|
|
||||||
const [source, target, query] = slug;
|
const [source, target, query] = slug;
|
||||||
|
|
||||||
if (!isValid(target))
|
if (!isValidCode(target, LanguageType.TARGET))
|
||||||
return res.status(400).json({ error: "Invalid target language" });
|
return res.status(400).json({ error: "Invalid target language" });
|
||||||
|
|
||||||
if (source === "audio") {
|
if (source === "audio") {
|
||||||
const audio = await textToSpeechScrape(target, query);
|
const audio = await getAudio(target, query);
|
||||||
return audio
|
return audio
|
||||||
? res.status(200).json({ audio })
|
? res.status(200).json({ audio })
|
||||||
: res.status(500).json({ error: "An error occurred while retrieving the audio" });
|
: res.status(500).json({ error: "An error occurred while retrieving the audio" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValid(source))
|
if (!isValidCode(source, LanguageType.SOURCE))
|
||||||
return res.status(400).json({ error: "Invalid source language" });
|
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)
|
if (!translation)
|
||||||
return res.status(500).json({ error: textScrape.errorMsg });
|
return res.status(500).json({ error: "An error occurred while retrieving the translation" });
|
||||||
res.status(200).json({ translation: textScrape.translationRes });
|
|
||||||
|
const info = await getTranslationInfo(source, target, query);
|
||||||
|
|
||||||
|
return info
|
||||||
|
? res.status(200).json({ translation, info })
|
||||||
|
: res.status(200).json({ translation });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default handler;
|
export default handler;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextApiHandler } from "next";
|
import { NextApiHandler } from "next";
|
||||||
import NextCors from "nextjs-cors";
|
import NextCors from "nextjs-cors";
|
||||||
import { retrieveFromType, LangCode } from "@utils/language";
|
import { languageList, LangCode } from "lingva-scraper";
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
languages: {
|
languages: {
|
||||||
@@ -38,7 +38,7 @@ const handler: NextApiHandler<Data> = async (req, res) => {
|
|||||||
if (type !== undefined && type !== "source" && type !== "target")
|
if (type !== undefined && type !== "source" && type !== "target")
|
||||||
return res.status(400).json({ error: "Type should be 'source', 'target' or empty" });
|
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 }));
|
const languages = langEntries.map(([code, name]) => ({ code, name }));
|
||||||
|
|
||||||
res.status(200).json({ languages });
|
res.status(200).json({ languages });
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { render, screen } from "@tests/reactUtils";
|
import { render, screen } from "./reactUtils";
|
||||||
import faker from "faker";
|
|
||||||
import CustomError from "@components/CustomError";
|
import CustomError from "@components/CustomError";
|
||||||
|
|
||||||
const code = faker.datatype.number({ min: 400, max: 599 });
|
const code = Math.random() * 199 + 400;
|
||||||
const text = faker.random.words();
|
const text = "Testing fake error";
|
||||||
|
|
||||||
it("loads the layout correctly", async () => {
|
it("loads the layout correctly", async () => {
|
||||||
render(<CustomError statusCode={code} statusText={text} />);
|
render(<CustomError statusCode={code} statusText={text} />);
|
||||||
@@ -16,7 +15,6 @@ it("loads the layout correctly", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the correct status code & text", () => {
|
it("renders the correct status code & text", () => {
|
||||||
const code = faker.datatype.number({ min: 400, max: 599 });
|
|
||||||
render(<CustomError statusCode={code} statusText={text} />);
|
render(<CustomError statusCode={code} statusText={text} />);
|
||||||
|
|
||||||
expect(screen.getByText(code)).toBeVisible();
|
expect(screen.getByText(code)).toBeVisible();
|
||||||
198
tests/Page.test.tsx
Normal file
198
tests/Page.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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) });
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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) });
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { FC, ReactElement } from "react";
|
import { FC, ReactElement, PropsWithChildren } from "react";
|
||||||
import { render, RenderOptions } from "@testing-library/react";
|
import { render, RenderOptions } from "@testing-library/react";
|
||||||
import { ChakraProvider } from "@chakra-ui/react";
|
import { ChakraProvider } from "@chakra-ui/react";
|
||||||
import theme from "@theme";
|
import theme from "@theme";
|
||||||
import { Layout } from "@components";
|
import { Layout } from "@components";
|
||||||
|
import { RouterProviderMock } from "@mocks/next";
|
||||||
|
|
||||||
// Jest JSDOM bug
|
// Jest JSDOM bug
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
@@ -19,12 +20,14 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const Providers: FC = ({ children }) => (
|
const Providers: FC<PropsWithChildren> = ({ children }) => (
|
||||||
<ChakraProvider theme={theme}>
|
<RouterProviderMock>
|
||||||
<Layout>
|
<ChakraProvider theme={theme}>
|
||||||
{children}
|
<Layout>
|
||||||
</Layout>
|
{children}
|
||||||
</ChakraProvider>
|
</Layout>
|
||||||
|
</ChakraProvider>
|
||||||
|
</RouterProviderMock>
|
||||||
);
|
);
|
||||||
|
|
||||||
const customRender = (
|
const customRender = (
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import jestFetchMock from 'jest-fetch-mock';
|
|
||||||
jestFetchMock.enableMocks();
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
32
theme.ts
32
theme.ts
@@ -1,7 +1,19 @@
|
|||||||
import { extendTheme } from "@chakra-ui/react";
|
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({
|
export default extendTheme({
|
||||||
|
styles: {
|
||||||
|
global: {
|
||||||
|
"html, body, #__next": {
|
||||||
|
height: "100%"
|
||||||
|
},
|
||||||
|
"#__next": {
|
||||||
|
isolation: "isolate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
lingva: {
|
lingva: {
|
||||||
50: "#e7f5ed",
|
50: "#e7f5ed",
|
||||||
@@ -17,26 +29,12 @@ export default extendTheme({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
initialColorMode: process.env["DEFAULT_DARK_THEME"] === "true" ? "dark" : "light",
|
initialColorMode: forceDefaultTheme === "light" || forceDefaultTheme === "dark" ? forceDefaultTheme : "system"
|
||||||
useSystemColorMode: false
|
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
Textarea: {
|
|
||||||
variants: {
|
|
||||||
outline: props => ({
|
|
||||||
borderColor: mode("lingva.500", "lingva.200")(props),
|
|
||||||
_hover: {
|
|
||||||
borderColor: mode("lingva.700", "lingva.400")(props),
|
|
||||||
},
|
|
||||||
_readOnly: {
|
|
||||||
userSelect: "auto"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Select: {
|
Select: {
|
||||||
variants: {
|
variants: {
|
||||||
flushed: props => ({
|
flushed: (props: StyleFunctionProps) => ({
|
||||||
field: {
|
field: {
|
||||||
borderColor: mode("lingva.500", "lingva.200")(props)
|
borderColor: mode("lingva.500", "lingva.200")(props)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.tsx",
|
||||||
"cypress"
|
"cypress"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
125
utils/reducer.ts
125
utils/reducer.ts
@@ -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 defaultSourceLang = process.env["NEXT_PUBLIC_DEFAULT_SOURCE_LANG"];
|
||||||
const defaultTargetLang = process.env["NEXT_PUBLIC_DEFAULT_TARGET_LANG"];
|
const defaultTargetLang = process.env["NEXT_PUBLIC_DEFAULT_TARGET_LANG"];
|
||||||
|
|
||||||
type State = {
|
export type State = {
|
||||||
source: LangCode,
|
source: LangCode<"source">,
|
||||||
target: LangCode,
|
target: LangCode<"target">,
|
||||||
query: string,
|
query: string,
|
||||||
delayedQuery: string,
|
delayedQuery: string,
|
||||||
translation: string,
|
translation: string,
|
||||||
isLoading: boolean
|
isLoading: boolean,
|
||||||
|
pronunciation: {
|
||||||
|
query?: string,
|
||||||
|
translation?: string
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
query?: number[],
|
||||||
|
translation?: number[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: State = {
|
export const initialState: State = {
|
||||||
source: isValid(defaultSourceLang) ? defaultSourceLang : "auto",
|
source: isValidCode(defaultSourceLang, LanguageType.SOURCE) ? defaultSourceLang : "auto",
|
||||||
target: isValid(defaultTargetLang) ? defaultTargetLang : "en",
|
target: isValidCode(defaultTargetLang, LanguageType.TARGET) ? defaultTargetLang : "en",
|
||||||
query: "",
|
query: "",
|
||||||
delayedQuery: "",
|
delayedQuery: "",
|
||||||
translation: "",
|
translation: "",
|
||||||
isLoading: true
|
isLoading: true,
|
||||||
|
pronunciation: {},
|
||||||
|
audio: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Actions {
|
export enum Actions {
|
||||||
SET_FIELD,
|
SET_FIELD,
|
||||||
|
SET_SOURCE,
|
||||||
|
SET_TARGET,
|
||||||
SET_ALL,
|
SET_ALL,
|
||||||
SWITCH_LANGS
|
SWITCH_LANGS
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action = {
|
type Action<T extends keyof State = keyof State> = {
|
||||||
type: Actions.SET_FIELD,
|
type: Actions.SET_FIELD,
|
||||||
payload: {
|
payload: {
|
||||||
key: string,
|
key: T,
|
||||||
value: any
|
value: State[T]
|
||||||
|
}
|
||||||
|
} | {
|
||||||
|
type: Actions.SET_SOURCE | Actions.SET_TARGET,
|
||||||
|
payload: {
|
||||||
|
code: string
|
||||||
}
|
}
|
||||||
} | {
|
} | {
|
||||||
type: Actions.SET_ALL,
|
type: Actions.SET_ALL,
|
||||||
@@ -39,36 +56,84 @@ type Action = {
|
|||||||
state: State
|
state: State
|
||||||
}
|
}
|
||||||
} | {
|
} | {
|
||||||
type: Actions.SWITCH_LANGS
|
type: Actions.SWITCH_LANGS,
|
||||||
|
payload: {
|
||||||
|
detectedSource?: LangCode<"source">
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function reducer(state: State, action: Action): State {
|
export default function reducer(state: State, action: Action): State {
|
||||||
const { source, target } = replaceBoth("exception", {
|
|
||||||
source: state.target,
|
|
||||||
target: state.source
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case Actions.SET_FIELD:
|
case Actions.SET_FIELD: {
|
||||||
const { key, value } = action.payload;
|
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 };
|
return { ...state, [key]: value };
|
||||||
case Actions.SET_ALL:
|
}
|
||||||
return { ...state, ...action.payload.state };
|
case Actions.SET_SOURCE: {
|
||||||
case Actions.SWITCH_LANGS:
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
source: source !== target
|
source: code,
|
||||||
? source
|
target: sourceAsTarget !== code
|
||||||
: initialState.source,
|
? sourceAsTarget
|
||||||
target,
|
: "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,
|
query: state.translation,
|
||||||
delayedQuery: 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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
19
utils/slug.ts
Normal file
19
utils/slug.ts
Normal 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 {};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user