mirror of
https://github.com/TheDavidDelta/lingva-translate.git
synced 2025-10-06 00:02:52 +02:00
Refactor to external scraper and update dependencies (#113)
This commit is contained in:
@@ -5,7 +5,7 @@ node_js:
|
||||
|
||||
os: linux
|
||||
|
||||
dist: xenial
|
||||
dist: focal
|
||||
|
||||
services:
|
||||
- docker
|
||||
@@ -26,7 +26,7 @@ branches:
|
||||
|
||||
script:
|
||||
- yarn test --ci
|
||||
- yarn build
|
||||
- NEXT_PUBLIC_FORCE_DEFAULT_THEME=light yarn build
|
||||
- yarn start & wait-on http://localhost:3000
|
||||
- 'if [ "$TRAVIS_PULL_REQUEST" = "false" ];
|
||||
then
|
||||
|
@@ -23,7 +23,8 @@ ENV NODE_ENV production
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain DEFAULT_DARK_THEME=$dark_theme \
|
||||
CMD NEXT_PUBLIC_SITE_DOMAIN=$site_domain\
|
||||
NEXT_PUBLIC_FORCE_DEFAULT_THEME=$force_default_theme \
|
||||
NEXT_PUBLIC_DEFAULT_SOURCE_LANG=$default_source_lang \
|
||||
NEXT_PUBLIC_DEFAULT_TARGET_LANG=$default_target_lang \
|
||||
yarn build && yarn start
|
||||
|
62
README.md
62
README.md
@@ -2,10 +2,10 @@
|
||||
|
||||
<img src="public/logo.svg" width="128" align="right">
|
||||
|
||||
[](https://travis-ci.com/TheDavidDelta/lingva-translate)
|
||||
[](https://lingva.ml/)
|
||||
[](https://travis-ci.com/thedaviddelta/lingva-translate)
|
||||
[](https://lingva.ml/)
|
||||
[](https://dashboard.cypress.io/projects/qgjdyd/runs)
|
||||
[](./LICENSE)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/humanetech-community/awesome-humane-tech)
|
||||
[<img src="https://www.datocms-assets.com/31049/1618983297-powered-by-vercel.svg" alt="Powered by Vercel" height="20">](https://vercel.com?utm_source=lingva-team&utm_campaign=oss)
|
||||
|
||||
@@ -14,10 +14,11 @@ Alternative front-end for Google Translate, serving as a Free and Open Source tr
|
||||
|
||||
## How does it work?
|
||||
|
||||
Inspired by projects like [NewPipe](https://github.com/TeamNewPipe/NewPipe), [Nitter](https://github.com/zedeus/nitter), [Invidious](https://github.com/iv-org/invidious) or [Bibliogram](https://git.sr.ht/~cadence/bibliogram), *Lingva* scrapes through GTranslate and retrieves the translation without using any Google-related service, preventing them from tracking.
|
||||
Inspired by projects like [NewPipe](https://github.com/TeamNewPipe/NewPipe), [Nitter](https://github.com/zedeus/nitter), [Invidious](https://github.com/iv-org/invidious) or [Bibliogram](https://git.sr.ht/~cadence/bibliogram), *Lingva* scrapes through Google Translate and retrieves the translation without directly accessing any Google-related service, preventing them from tracking.
|
||||
|
||||
For this purpose, *Lingva* is built, among others, with the following Open Source resources:
|
||||
|
||||
+ [Lingva Scraper](https://github.com/thedaviddelta/lingva-scraper), a Google Translate scraper built and maintained specifically for this project, which obtains all kind of information from this platform.
|
||||
+ [TypeScript](https://www.typescriptlang.org/), the JavaScript superset, as the language.
|
||||
+ [React](https://reactjs.org/) as the main front-end framework.
|
||||
+ [Next.js](https://nextjs.org/) as the complementary React framework, that provides Server-Side Rendering, Static Site Generation or serverless API endpoints.
|
||||
@@ -33,11 +34,14 @@ As *Lingva* is a [Next.js](https://nextjs.org/) project you can deploy your own
|
||||
|
||||
The only requirement is to set an environment variable called `NEXT_PUBLIC_SITE_DOMAIN` with the domain you're deploying the instance under. This is used for the canonical URL and the meta tags.
|
||||
|
||||
Optionally, there's another environment variable available called `DEFAULT_DARK_THEME` for selecting dark as the default page theme on the first load. The theme will be light by default unless this variable is set to `true`.
|
||||
Optionally, there are other environment variables available:
|
||||
+ `NEXT_PUBLIC_FORCE_DEFAULT_THEME`: Force a certain theme over the system preference set by the user. The accepted values are `light` and `dark`.
|
||||
+ `NEXT_PUBLIC_DEFAULT_SOURCE_LANG`: Set an initial *source* language instead of the default `auto`.
|
||||
+ `NEXT_PUBLIC_DEFAULT_TARGET_LANG`: Set an initial *target* language instead of the default `en`.
|
||||
|
||||
### Docker
|
||||
|
||||
An [official Docker image](https://hub.docker.com/r/thedaviddelta/lingva-translate) is available to ease the deployment using Compose, Kubernetes or similar technologies. Remember to also include the environment variables (simplified to `site_domain` and `dark_theme`) when running the container.
|
||||
An [official Docker image](https://hub.docker.com/r/thedaviddelta/lingva-translate) is available to ease the deployment using Compose, Kubernetes or similar technologies. Remember to also include the environment variables (simplified to `site_domain`, `force_default_theme`, `default_source_lang` and `default_target_lang`) when running the container.
|
||||
|
||||
#### Docker Compose:
|
||||
|
||||
@@ -52,7 +56,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- site_domain=lingva.ml
|
||||
- dark_theme=false
|
||||
- force_default_theme=light
|
||||
- default_source_lang=auto
|
||||
- default_target_lang=en
|
||||
ports:
|
||||
@@ -62,14 +66,14 @@ services:
|
||||
#### Docker Run
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -e site_domain=lingva.ml -e dark_theme=false -e default_source_lang=auto -e default_target_lang=en thedaviddelta/lingva-translate:latest
|
||||
docker run -p 3000:3000 -e site_domain=lingva.ml -e force_default_theme=light -e default_source_lang=auto -e default_target_lang=en thedaviddelta/lingva-translate:latest
|
||||
```
|
||||
|
||||
### Vercel
|
||||
|
||||
Another easy way is to use the Next.js creators' own platform, [Vercel](https://vercel.com/), where you can deploy it for free with the following button.
|
||||
|
||||
[](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
|
||||
@@ -79,12 +83,7 @@ These are the currently known *Lingva* instances. Feel free to make a Pull Reque
|
||||
| Domain | Hosting | SSL Provider |
|
||||
|:-------------------------------------------------------------------:|:-----------------------------------------:|:--------------------------------------------------------------------------------------------:|
|
||||
| [lingva.ml](https://lingva.ml/) (Official) | [Vercel](https://vercel.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.ml) |
|
||||
| [translate.alefvanoon.xyz](https://translate.alefvanoon.xyz) | [Vercel](https://vercel.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.alefvanoon.xyz) |
|
||||
| [translate.igna.rocks](https://translate.igna.rocks) | [Vercel](https://vercel.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.igna.rocks) |
|
||||
| [lingva.pussthecat.org](https://lingva.pussthecat.org) | [Hetzner](https://hetzner.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.pussthecat.org) |
|
||||
| [translate.datatunnel.xyz](https://translate.datatunnel.xyz) | [Hetzner](https://hetzner.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.datatunnel.xyz) |
|
||||
| [lingva.esmailelbob.xyz](https://lingva.esmailelbob.xyz/) | [Kimsufi](https://kimsufi.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.esmailelbob.xyz) |
|
||||
| [translate.plausibility.cloud](https://translate.plausibiity.cloud) | [Hetzner](https://hetzner.com/) | [Let's Encrypt](https://www.ssllabs.com/ssltest/analyze.html?d=translate.plausibility.cloud) |
|
||||
| [lingva.lunar.icu](https://lingva.lunar.icu/) | [Lansol](https://lansol.de/) | [Cloudflare](https://www.ssllabs.com/ssltest/analyze.html?d=lingva.lunar.icu) |
|
||||
|
||||
## Public APIs
|
||||
@@ -99,6 +98,7 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs:
|
||||
```typescript
|
||||
{
|
||||
translation: string
|
||||
info?: TranslationInfo
|
||||
}
|
||||
```
|
||||
|
||||
@@ -141,6 +141,23 @@ query {
|
||||
}
|
||||
text: String!
|
||||
audio: [Int]!
|
||||
detected: {
|
||||
code: String
|
||||
name: String
|
||||
}
|
||||
typo: String
|
||||
pronunciation: String
|
||||
definitions: {
|
||||
type: String
|
||||
list: {
|
||||
definition: String
|
||||
example: String
|
||||
field: String
|
||||
synonyms: [String]
|
||||
}
|
||||
}
|
||||
examples: [String]
|
||||
similar: [String]
|
||||
}
|
||||
target: {
|
||||
lang: {
|
||||
@@ -149,6 +166,16 @@ query {
|
||||
}
|
||||
text: String!
|
||||
audio: [Int]!
|
||||
pronunciation: String
|
||||
extraTranslations: {
|
||||
type: String
|
||||
list: {
|
||||
word: String
|
||||
article: String
|
||||
frequency: Int
|
||||
meanings: [String]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
audio(lang: String! query: String!) {
|
||||
@@ -169,9 +196,12 @@ query {
|
||||
|
||||
## Related projects
|
||||
|
||||
+ [SimplyTranslate](https://sr.ht/~metalune/SimplyTranslate/) - Very simple translation front-end with multi-engine support
|
||||
+ [Lingva Scraper](https://github.com/thedaviddelta/lingva-scraper) - Google Translate scraper built and maintained specifically for this project
|
||||
+ [SimplyTranslate](https://codeberg.org/SimpleWeb/SimplyTranslate-Web) - Very simple translation front-end with multi-engine support
|
||||
+ [LibreTranslate](https://github.com/LibreTranslate/LibreTranslate) - FOSS translation service that uses the open [Argos](https://github.com/argosopentech/argos-translate) engine
|
||||
+ [Lentil for Android](https://github.com/yaxarat/lingvaandroid) - Unofficial native client for Android that uses Lingva's public API
|
||||
+ [Arna Translate](https://github.com/MahanRahmati/translate) - Unofficial cross-platform native client that uses Lingva's public API
|
||||
+ [Translate-UT](https://github.com/walking-octopus/translate-ut) - Unofficial native client for Ubuntu Touch that uses Lingva's public API
|
||||
|
||||
|
||||
## Contributors
|
||||
@@ -201,5 +231,5 @@ This project follows the [all-contributors](https://github.com/all-contributors/
|
||||
|
||||
[](https://www.gnu.org/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.
|
||||
|
@@ -5,7 +5,7 @@ import theme from "@theme";
|
||||
|
||||
type Props = {
|
||||
customTitle?: string,
|
||||
home?: true
|
||||
home?: boolean
|
||||
};
|
||||
|
||||
const title = "Lingva Translate";
|
||||
|
@@ -19,8 +19,8 @@ const Footer: FC<Props> = (props) => (
|
||||
spacing={[1, null, 2]}
|
||||
{...props}
|
||||
>
|
||||
<Link href="https://github.com/TheDavidDelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
|
||||
<Text as="span">© 2021 TheDavidDelta & contributors</Text>
|
||||
<Link href="https://github.com/thedaviddelta/lingva-translate/blob/main/LICENSE" isExternal={true}>
|
||||
<Text as="span">© 2021 thedaviddelta & contributors</Text>
|
||||
</Link>
|
||||
<Text as="span" display={["none", null, "unset"]}>·</Text>
|
||||
<Link href="https://www.gnu.org/licenses/agpl-3.0.html" isExternal={true}>
|
||||
|
@@ -42,7 +42,7 @@ const Header: FC<Props> = (props) => (
|
||||
/>
|
||||
<IconButton
|
||||
as={Link}
|
||||
href="https://github.com/TheDavidDelta/lingva-translate"
|
||||
href="https://github.com/thedaviddelta/lingva-translate"
|
||||
isExternal={true}
|
||||
aria-label="GitHub"
|
||||
icon={<FaGithub />}
|
||||
|
@@ -1,14 +1,18 @@
|
||||
import { FC, ChangeEvent } from "react";
|
||||
import { Select } from "@chakra-ui/react";
|
||||
import { LangCode } from "lingva-scraper";
|
||||
|
||||
type Props = {
|
||||
value: string,
|
||||
onChange: (e: ChangeEvent<any>) => void,
|
||||
langs: [string, string][],
|
||||
langs: {
|
||||
[code in LangCode]: string
|
||||
},
|
||||
detectedSource?: LangCode<"source">,
|
||||
[key: string]: any
|
||||
};
|
||||
|
||||
const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
|
||||
const LangSelect: FC<Props> = ({ value, onChange, langs, detectedSource, ...props }) => (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
@@ -18,8 +22,10 @@ const LangSelect: FC<Props> = ({ value, onChange, langs, ...props }) => (
|
||||
style={{ textAlignLast: "center" }}
|
||||
{...props}
|
||||
>
|
||||
{langs.map(([code, name]) => (
|
||||
<option value={code} key={code}>{name}</option>
|
||||
{Object.entries(langs).map(([code, name]) => (
|
||||
<option value={code} key={code}>
|
||||
{name}{code === "auto" && !!detectedSource && ` (${langs[detectedSource]})`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { FC } from "react";
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
import { Flex, VStack, Button, Link, useColorModeValue } from "@chakra-ui/react";
|
||||
import { Header, Footer } from ".";
|
||||
|
||||
@@ -6,7 +6,7 @@ type Props = {
|
||||
[key: string]: any
|
||||
};
|
||||
|
||||
const Layout: FC<Props> = ({ children, ...props }) => (
|
||||
const Layout: FC<PropsWithChildren<Props>> = ({ children, ...props }) => (
|
||||
<>
|
||||
<Button
|
||||
as={Link}
|
||||
@@ -22,7 +22,7 @@ const Layout: FC<Props> = ({ children, ...props }) => (
|
||||
Skip to content
|
||||
</Button>
|
||||
|
||||
<VStack minH="100vh" spacing={8}>
|
||||
<VStack minH="100%" spacing={7}>
|
||||
<Header
|
||||
bgColor={useColorModeValue("lingva.100", "lingva.900")}
|
||||
/>
|
||||
|
@@ -1,5 +1,17 @@
|
||||
import { FC } from "react";
|
||||
import { Box, HStack, Textarea, IconButton, Tooltip, Spinner, TextareaProps, useBreakpointValue, useColorModeValue, useClipboard } from "@chakra-ui/react";
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Textarea,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Spinner,
|
||||
TextareaProps,
|
||||
useBreakpointValue,
|
||||
useColorModeValue,
|
||||
useClipboard
|
||||
} from "@chakra-ui/react";
|
||||
import { FaCopy, FaCheck, FaPlay, FaStop } from "react-icons/fa";
|
||||
import { useAudioFromBuffer } from "@hooks";
|
||||
|
||||
@@ -14,20 +26,32 @@ type Props = {
|
||||
[key: string]: any
|
||||
};
|
||||
|
||||
const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio, canCopy, isLoading, ...props }) => {
|
||||
const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio, canCopy, isLoading, pronunciation, ...props }) => {
|
||||
const { hasCopied, onCopy } = useClipboard(value);
|
||||
const { audioExists, isAudioPlaying, onAudioClick } = useAudioFromBuffer(audio);
|
||||
const spinnerProps = {
|
||||
size: useBreakpointValue(["lg", null, "xl"]) ?? undefined,
|
||||
size: useBreakpointValue(["lg", null, "xl"]) ?? "lg",
|
||||
color: useColorModeValue("lingva.500", "lingva.200"),
|
||||
emptyColor: useColorModeValue("gray.300", "gray.600")
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
<VStack
|
||||
w="full"
|
||||
h={useBreakpointValue([44, null, 80]) ?? 44}
|
||||
align="stretch"
|
||||
spacing={0}
|
||||
position="relative"
|
||||
isolation="isolate"
|
||||
border="1px solid"
|
||||
borderColor={useColorModeValue("lingva.500", "lingva.200")}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
borderColor: useColorModeValue("lingva.800", "lingva.400"),
|
||||
}}
|
||||
_readOnly={{
|
||||
userSelect: "auto"
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
value={value}
|
||||
@@ -35,54 +59,66 @@ const TranslationArea: FC<Props> = ({ value, onChange, onSubmit, readOnly, audio
|
||||
readOnly={readOnly}
|
||||
dir="auto"
|
||||
resize="none"
|
||||
rows={useBreakpointValue([6, null, 12]) ?? undefined}
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
boxShadow={`inset 0 0 1px ${useColorModeValue("hsl(146 100% 17% / 25%)", "hsl(142 40% 82% / 25%)")}`}
|
||||
data-gramm_editor={false}
|
||||
onKeyPress={e => (e.ctrlKey || e.metaKey) && e.key === "Enter" && onSubmit?.()}
|
||||
flex={1}
|
||||
bgColor="transparent"
|
||||
_focus={{
|
||||
bgColor: useColorModeValue("hsl(0deg 0% 0% / 2.5%)", "hsl(0deg 0% 100% / 2.5%)")
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<HStack
|
||||
position="absolute"
|
||||
bottom={4}
|
||||
right={4}
|
||||
// Needed because the textarea stacks over on focus
|
||||
zIndex={3}
|
||||
position={!pronunciation ? "absolute" : undefined}
|
||||
pointerEvents={!pronunciation ? "none" : undefined}
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
>
|
||||
{canCopy && (
|
||||
<Tooltip label={hasCopied ? "Copied!" : "Copy to clipboard"}>
|
||||
<IconButton
|
||||
aria-label="Copy to clipboard"
|
||||
icon={hasCopied ? <FaCheck /> : <FaCopy />}
|
||||
onClick={onCopy}
|
||||
colorScheme="lingva"
|
||||
variant="ghost"
|
||||
disabled={!value}
|
||||
/>
|
||||
<HStack justify="space-between" px={5} h={useBreakpointValue([12, null, 14]) ?? 12} w="0px" flex={1}>
|
||||
<Tooltip label={pronunciation}>
|
||||
<Text aria-label="Pronunciation" opacity={0.75} whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
|
||||
{pronunciation}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={isAudioPlaying ? "Stop audio" : "Play audio"}>
|
||||
<IconButton
|
||||
aria-label={isAudioPlaying ? "Stop audio" : "Play audio"}
|
||||
icon={isAudioPlaying ? <FaStop /> : <FaPlay />}
|
||||
onClick={onAudioClick}
|
||||
colorScheme="lingva"
|
||||
variant="ghost"
|
||||
disabled={!audioExists}
|
||||
/>
|
||||
</Tooltip>
|
||||
<HStack pointerEvents="auto">
|
||||
{canCopy && (
|
||||
<Tooltip label={hasCopied ? "Copied!" : "Copy to clipboard"}>
|
||||
<IconButton
|
||||
aria-label="Copy to clipboard"
|
||||
icon={hasCopied ? <FaCheck /> : <FaCopy />}
|
||||
onClick={onCopy}
|
||||
colorScheme="lingva"
|
||||
variant="ghost"
|
||||
disabled={!value}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={isAudioPlaying ? "Stop audio" : "Play audio"}>
|
||||
<IconButton
|
||||
aria-label={isAudioPlaying ? "Stop audio" : "Play audio"}
|
||||
icon={isAudioPlaying ? <FaStop /> : <FaPlay />}
|
||||
onClick={onAudioClick}
|
||||
colorScheme="lingva"
|
||||
variant="ghost"
|
||||
disabled={!audioExists}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
{isLoading && <Spinner
|
||||
position="absolute"
|
||||
top="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
m="auto"
|
||||
inset={0}
|
||||
m="auto !important"
|
||||
thickness="3px"
|
||||
label="Loading translation"
|
||||
{...spinnerProps}
|
||||
/>}
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
13
cypress.config.ts
Normal file
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" />
|
||||
|
||||
import faker from "faker";
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visit("/");
|
||||
cy.clearLocalStorage();
|
||||
@@ -72,7 +70,7 @@ it("switches page on inputs change & goes back correctly", () => {
|
||||
});
|
||||
|
||||
it("switches first loaded page and back and forth on language change", () => {
|
||||
const query = faker.random.words();
|
||||
const query = "Texto aleatorio";
|
||||
cy.visit(`/auto/en/${query}`);
|
||||
|
||||
cy.findByRole("button", { name: /switch auto/i })
|
||||
@@ -134,8 +132,8 @@ it("language switching button is disabled on 'auto', but enables when other", ()
|
||||
});
|
||||
|
||||
it("loads & plays audio correctly", () => {
|
||||
const query = faker.lorem.words(5);
|
||||
cy.visit(`/la/en/${query}`);
|
||||
const query = "No hi havia a València dos amants com nosaltres,\ncar d'amants com nosaltres en són parits ben pocs.";
|
||||
cy.visit(`/ca/en/${query}`);
|
||||
|
||||
const play = "Play audio";
|
||||
const stop = "Stop audio";
|
@@ -1,10 +1,5 @@
|
||||
[
|
||||
"https://lingva.ml",
|
||||
"https://translate.alefvanoon.xyz",
|
||||
"https://translate.igna.rocks",
|
||||
"https://lingva.pussthecat.org",
|
||||
"https://translate.datatunnel.xyz",
|
||||
"https://lingva.esmailelbob.xyz",
|
||||
"https://translate.plausibility.cloud",
|
||||
"https://lingva.lunar.icu"
|
||||
]
|
||||
|
@@ -1,24 +1,18 @@
|
||||
module.exports = {
|
||||
transform: {
|
||||
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest'
|
||||
const nextJest = require("next/jest");
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: "./"
|
||||
});
|
||||
|
||||
module.exports = createJestConfig({
|
||||
testEnvironment: "jest-environment-jsdom",
|
||||
moduleNameMapper: {
|
||||
"^@(components|hooks|mocks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2"
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$',
|
||||
'^.+\\.module\\.(css|sass|scss)$',
|
||||
],
|
||||
testMatch: [
|
||||
'<rootDir>/**/tests/*/**/*.{js,jsx,ts,tsx}',
|
||||
'<rootDir>/**/*.{spec,test}.{js,jsx,ts,tsx}'
|
||||
],
|
||||
testPathIgnorePatterns: [
|
||||
'<rootDir>/cypress/'
|
||||
],
|
||||
setupFilesAfterEnv: [
|
||||
"<rootDir>/tests/setupTests.ts"
|
||||
],
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
|
||||
moduleNameMapper: {
|
||||
"^@(components|hooks|mocks|pages|public|tests|utils|theme)(.*)$": "<rootDir>/$1$2"
|
||||
},
|
||||
testEnvironment: "jsdom"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
2978
mocks/data/audio.json
Normal file
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");
|
||||
|
||||
module.exports = withPWA({
|
||||
swcMinify: true,
|
||||
pwa: {
|
||||
dest: "public"
|
||||
},
|
||||
|
65
package.json
65
package.json
@@ -9,60 +9,55 @@
|
||||
"start": "next start --port ${PORT-3000}",
|
||||
"test": "jest",
|
||||
"cy:open": "cypress open",
|
||||
"cy:run": "cypress run"
|
||||
"cy:run": "cypress run",
|
||||
"renew-mock-data": "ts-node --skip-project mocks/renewData.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "1.0.15",
|
||||
"@chakra-ui/react": "1.6.6",
|
||||
"@chakra-ui/icons": "2.0.2",
|
||||
"@chakra-ui/react": "2.2.1",
|
||||
"@emotion/react": "^11",
|
||||
"@emotion/styled": "^11",
|
||||
"apollo-server-micro": "^2.25.2",
|
||||
"cheerio": "^1.0.0-rc.10",
|
||||
"framer-motion": "^4",
|
||||
"framer-motion": "^6",
|
||||
"graphql": "^15.8.0",
|
||||
"next": "12.1.0",
|
||||
"lingva-scraper": "1.0.0",
|
||||
"next": "12.1.6",
|
||||
"next-pwa": "^5.4.4",
|
||||
"nextjs-cors": "^2.1.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-hotkeys-hook": "^3.4.4",
|
||||
"react-icons": "^4.3.1",
|
||||
"user-agents": "^1.0.937"
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hotkeys-hook": "^3.4.6",
|
||||
"react-icons": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/cypress": "^8.0.2",
|
||||
"@testing-library/jest-dom": "^5.16.2",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/faker": "^5.5.6",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/user-agents": "^1.0.2",
|
||||
"apollo-server-testing": "^2.25.2",
|
||||
"@testing-library/cypress": "^8.0.3",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^14.2.0",
|
||||
"@types/jest": "^28.1.1",
|
||||
"@types/node": "^17.0.43",
|
||||
"@types/react": "^18.0.12",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"cypress": "^9.5.0",
|
||||
"eslint": "^8.9.0",
|
||||
"eslint-config-next": "^12.1.0",
|
||||
"eslint-config-react-app": "^7.0.0",
|
||||
"cypress": "^10.1.0",
|
||||
"eslint": "^8.17.0",
|
||||
"eslint-config-next": "^12.1.6",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"faker": "^5.5.3",
|
||||
"jest": "^27.5.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"node-mocks-http": "^1.11.0",
|
||||
"typescript": "^4.5.5",
|
||||
"jest": "^28.1.1",
|
||||
"jest-environment-jsdom": "^28.1.1",
|
||||
"ts-node": "^10.8.1",
|
||||
"typescript": "^4.7.3",
|
||||
"wait-on": "^6.0.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest",
|
||||
"plugin:cypress/recommended",
|
||||
"next"
|
||||
"next",
|
||||
"plugin:cypress/recommended"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["cypress/integration/*.ts"],
|
||||
"files": [
|
||||
"cypress/integration/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"testing-library/await-async-query": "off",
|
||||
"testing-library/prefer-screen-queries": "off"
|
||||
|
@@ -1,125 +1,191 @@
|
||||
import { useEffect, useReducer, useCallback, FC, ChangeEvent } from "react";
|
||||
import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
|
||||
import Router from "next/router";
|
||||
import { useCallback, useEffect, useReducer } from "react";
|
||||
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Stack, VStack, HStack, IconButton } from "@chakra-ui/react";
|
||||
import {
|
||||
getTranslationInfo,
|
||||
getTranslationText,
|
||||
getAudio,
|
||||
languageList,
|
||||
LanguageType,
|
||||
replaceExceptedCode,
|
||||
isValidCode,
|
||||
TranslationInfo,
|
||||
LangCode
|
||||
} from "lingva-scraper";
|
||||
import { HStack, IconButton, Stack, VStack } from "@chakra-ui/react";
|
||||
import { FaExchangeAlt } from "react-icons/fa";
|
||||
import { HiTranslate } from "react-icons/hi";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { CustomHead, LangSelect, TranslationArea } from "@components";
|
||||
import { useToastOnLoad } from "@hooks";
|
||||
import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate";
|
||||
import { retrieveFromType, replaceBoth, isValid } from "@utils/language";
|
||||
import langReducer, { Actions, initialState } from "@utils/reducer";
|
||||
import { extractSlug } from "@utils/slug";
|
||||
import langReducer, { Actions, initialState, State } from "@utils/reducer";
|
||||
import { localGetItem, localSetItem } from "@utils/storage";
|
||||
|
||||
const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false });
|
||||
|
||||
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
|
||||
const [{ source, target, query, delayedQuery, translation, isLoading }, dispatch] = useReducer(langReducer, initialState);
|
||||
export enum ResponseType {
|
||||
SUCCESS,
|
||||
ERROR,
|
||||
HOME
|
||||
}
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
type Props = {
|
||||
type: ResponseType.SUCCESS,
|
||||
translation: string,
|
||||
info: TranslationInfo | null,
|
||||
audio: {
|
||||
query: number[] | null,
|
||||
translation: number[] | null
|
||||
},
|
||||
initial: {
|
||||
source: LangCode<"source">,
|
||||
target: LangCode<"target">,
|
||||
query: string
|
||||
}
|
||||
} | {
|
||||
type: ResponseType.ERROR,
|
||||
errorMsg: string,
|
||||
initial: {
|
||||
source: LangCode<"source">,
|
||||
target: LangCode<"target">,
|
||||
query: string
|
||||
}
|
||||
} | {
|
||||
type: ResponseType.HOME
|
||||
};
|
||||
|
||||
const Page: NextPage<Props> = (props) => {
|
||||
const [
|
||||
{ source, target, query, delayedQuery, translation, isLoading, pronunciation, audio },
|
||||
dispatch
|
||||
] = useReducer(langReducer, initialState);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setField = useCallback(<T extends keyof State,>(key: T, value: State[T]) => (
|
||||
dispatch({ type: Actions.SET_FIELD, payload: { key, value }})
|
||||
), []);
|
||||
|
||||
const setAllFields = useCallback((state: State) => (
|
||||
dispatch({ type: Actions.SET_ALL, payload: { state }})
|
||||
), []);
|
||||
|
||||
const setLanguage = useCallback((type: typeof LanguageType[keyof typeof LanguageType], code: string) => (
|
||||
dispatch({
|
||||
type: Actions.SET_FIELD,
|
||||
payload: {
|
||||
key: e.target.id,
|
||||
value: e.target.value
|
||||
}
|
||||
});
|
||||
};
|
||||
type: type === LanguageType.SOURCE
|
||||
? Actions.SET_SOURCE
|
||||
: Actions.SET_TARGET,
|
||||
payload: { code }
|
||||
})
|
||||
), []);
|
||||
|
||||
const switchLanguages = useCallback((detectedSource?: LangCode<"source">) => (
|
||||
dispatch({ type: Actions.SWITCH_LANGS, payload: { detectedSource } })
|
||||
), []);
|
||||
|
||||
const changeRoute = useCallback((customQuery: string) => {
|
||||
if (isLoading)
|
||||
if (isLoading || router.isFallback)
|
||||
return;
|
||||
if (!customQuery || customQuery === initialState.query)
|
||||
return;
|
||||
if (!home && !initial)
|
||||
return;
|
||||
if (!home && customQuery === initial.query && source === initial.source && target === initial.target)
|
||||
if (props.type === ResponseType.SUCCESS && customQuery === props.initial.query
|
||||
&& source === props.initial.source && target === props.initial.target)
|
||||
return;
|
||||
|
||||
localSetItem("source", source);
|
||||
localSetItem("target", target);
|
||||
localSetItem(LanguageType.SOURCE, source);
|
||||
localSetItem(LanguageType.TARGET, target);
|
||||
|
||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
|
||||
Router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`);
|
||||
}, [isLoading, source, target, home, initial]);
|
||||
setField("isLoading", true);
|
||||
router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`);
|
||||
}, [isLoading, source, target, props, router, setField]);
|
||||
|
||||
useEffect(() => {
|
||||
if (home) {
|
||||
const localSource = localGetItem("source");
|
||||
const localTarget = localGetItem("target");
|
||||
return dispatch({
|
||||
type: Actions.SET_ALL,
|
||||
payload: {
|
||||
state: {
|
||||
...initialState,
|
||||
source: isValid(localSource) ? localSource : initialState.source,
|
||||
target: isValid(localTarget) ? localTarget : initialState.target,
|
||||
isLoading: false
|
||||
}
|
||||
}
|
||||
if (router.isFallback)
|
||||
return;
|
||||
|
||||
if (props.type === ResponseType.HOME) {
|
||||
const localSource = localGetItem(LanguageType.SOURCE);
|
||||
const localTarget = localGetItem(LanguageType.TARGET);
|
||||
|
||||
return setAllFields({
|
||||
...initialState,
|
||||
source: isValidCode(localSource, LanguageType.SOURCE)
|
||||
? localSource
|
||||
: initialState.source,
|
||||
target: isValidCode(localTarget, LanguageType.TARGET)
|
||||
? localTarget
|
||||
: initialState.target,
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
|
||||
if (!initial)
|
||||
return;
|
||||
if (props.type === ResponseType.ERROR)
|
||||
return setAllFields({
|
||||
...initialState,
|
||||
...props.initial,
|
||||
delayedQuery: props.initial.query,
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: Actions.SET_ALL,
|
||||
payload: {
|
||||
state: {
|
||||
...initial,
|
||||
delayedQuery: initial.query,
|
||||
translation: translationRes,
|
||||
isLoading: false
|
||||
}
|
||||
setAllFields({
|
||||
...props.initial,
|
||||
delayedQuery: props.initial.query,
|
||||
translation: props.translation,
|
||||
isLoading: false,
|
||||
pronunciation: props.info?.pronunciation ?? {},
|
||||
audio: {
|
||||
query: props.audio.query ?? undefined,
|
||||
translation: props.audio.translation ?? undefined
|
||||
}
|
||||
});
|
||||
}, [initial, translationRes, home]);
|
||||
}, [props, router, setAllFields]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() =>
|
||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "delayedQuery", value: query }}
|
||||
), 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query]);
|
||||
const timeoutId = setTimeout(() => setField("delayedQuery", query), 1000);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [query, setField]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (url: string) => {
|
||||
url === Router.asPath || dispatch({ type: Actions.SET_FIELD, payload: { key: "isLoading", value: true }});
|
||||
url === router.asPath || setField("isLoading", true);
|
||||
|
||||
if (url !== "/")
|
||||
return;
|
||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "source", value: initialState.source }});
|
||||
localSetItem("source", initialState.source);
|
||||
dispatch({ type: Actions.SET_FIELD, payload: { key: "target", value: initialState.target }});
|
||||
localSetItem("target", initialState.target);
|
||||
setLanguage(LanguageType.SOURCE, initialState.source);
|
||||
localSetItem(LanguageType.SOURCE, initialState.source);
|
||||
setLanguage(LanguageType.TARGET, initialState.target);
|
||||
localSetItem(LanguageType.TARGET, initialState.target);
|
||||
};
|
||||
Router.events.on("beforeHistoryChange", handler);
|
||||
return () => Router.events.off("beforeHistoryChange", handler);
|
||||
}, []);
|
||||
|
||||
const sourceLangs = retrieveFromType("source");
|
||||
const targetLangs = retrieveFromType("target");
|
||||
const { source: transLang, target: queryLang } = replaceBoth("exception", { source: target, target: source });
|
||||
router.events.on("beforeHistoryChange", handler);
|
||||
return () => router.events.off("beforeHistoryChange", handler);
|
||||
}, [router, setLanguage, setField]);
|
||||
|
||||
useToastOnLoad({
|
||||
title: "Unexpected error",
|
||||
description: errorMsg,
|
||||
status: "error",
|
||||
updateDeps: initial
|
||||
title: "Unexpected error",
|
||||
description: props.type === ResponseType.ERROR ? props.errorMsg : undefined,
|
||||
updateDeps: props.type === ResponseType.ERROR ? props.initial : undefined
|
||||
});
|
||||
|
||||
const canSwitch = source !== "auto" && !isLoading;
|
||||
const detectedSource = props.type === ResponseType.SUCCESS ? props.info?.detectedSource : undefined;
|
||||
|
||||
const canSwitch = !isLoading && (source !== "auto" || !!detectedSource);
|
||||
|
||||
useHotkeys("ctrl+shift+s, command+shift+s, ctrl+shift+f, command+shift+f", () => (
|
||||
canSwitch && dispatch({ type: Actions.SWITCH_LANGS })
|
||||
), [canSwitch]);
|
||||
canSwitch && switchLanguages(detectedSource)
|
||||
), [canSwitch, detectedSource, switchLanguages]);
|
||||
|
||||
// parse existing code with opposite exceptions in order to flatten to the standards
|
||||
const queryLang = source === "auto" && !!detectedSource
|
||||
? detectedSource
|
||||
: replaceExceptedCode(LanguageType.TARGET, source);
|
||||
const transLang = replaceExceptedCode(LanguageType.SOURCE, target);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomHead home={home} />
|
||||
<CustomHead home={props.type === ResponseType.HOME} />
|
||||
|
||||
<VStack px={[8, null, 24, 40]} w="full">
|
||||
<HStack px={[1, null, 3, 4]} w="full">
|
||||
@@ -127,23 +193,24 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
||||
id="source"
|
||||
aria-label="Source language"
|
||||
value={source}
|
||||
onChange={handleChange}
|
||||
langs={sourceLangs}
|
||||
detectedSource={detectedSource}
|
||||
onChange={e => setLanguage(LanguageType.SOURCE, e.target.value)}
|
||||
langs={languageList.source}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Switch languages"
|
||||
icon={<FaExchangeAlt />}
|
||||
colorScheme="lingva"
|
||||
variant="ghost"
|
||||
onClick={() => dispatch({ type: Actions.SWITCH_LANGS })}
|
||||
onClick={() => switchLanguages(detectedSource)}
|
||||
isDisabled={!canSwitch}
|
||||
/>
|
||||
<LangSelect
|
||||
id="target"
|
||||
aria-label="Target language"
|
||||
value={target}
|
||||
onChange={handleChange}
|
||||
langs={targetLangs}
|
||||
onChange={e => setLanguage(LanguageType.TARGET, e.target.value)}
|
||||
langs={languageList.target}
|
||||
/>
|
||||
</HStack>
|
||||
<Stack direction={["column", null, "row"]} w="full">
|
||||
@@ -152,10 +219,11 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
||||
aria-label="Translation query"
|
||||
placeholder="Text"
|
||||
value={query}
|
||||
onChange={e => isLoading || handleChange(e)}
|
||||
onSubmit={useCallback(() => changeRoute(query), [query, changeRoute])}
|
||||
onChange={e => isLoading || setField("query", e.target.value)}
|
||||
onSubmit={() => changeRoute(query)}
|
||||
lang={queryLang}
|
||||
audio={audio?.source}
|
||||
audio={audio.query}
|
||||
pronunciation={pronunciation.query}
|
||||
/>
|
||||
<Stack direction={["row", null, "column"]} justify="center" spacing={3} px={[2, null, "initial"]}>
|
||||
<IconButton
|
||||
@@ -168,8 +236,9 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
||||
w={["full", null, "auto"]}
|
||||
/>
|
||||
<AutoTranslateButton
|
||||
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
|
||||
isDisabled={isLoading}
|
||||
// runs on effect update
|
||||
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
|
||||
w={["full", null, "auto"]}
|
||||
/>
|
||||
</Stack>
|
||||
@@ -180,9 +249,10 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
|
||||
value={translation ?? ""}
|
||||
readOnly={true}
|
||||
lang={transLang}
|
||||
audio={audio?.target}
|
||||
audio={audio.translation}
|
||||
canCopy={true}
|
||||
isLoading={isLoading}
|
||||
pronunciation={pronunciation.translation}
|
||||
/>
|
||||
</Stack>
|
||||
</VStack>
|
||||
@@ -201,10 +271,12 @@ export const getStaticPaths: GetStaticPaths = async () => ({
|
||||
fallback: true
|
||||
});
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
|
||||
if (!params?.slug || !Array.isArray(params.slug))
|
||||
return {
|
||||
props: { home: true }
|
||||
props: {
|
||||
type: ResponseType.HOME
|
||||
}
|
||||
};
|
||||
|
||||
const { source, target, query } = extractSlug(params.slug);
|
||||
@@ -220,35 +292,52 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
|
||||
destination: `/${source ?? "auto"}/${target ?? "en"}/${query}`,
|
||||
permanent: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isValid(source) || !isValid(target))
|
||||
if (!isValidCode(source, LanguageType.SOURCE) || !isValidCode(target, LanguageType.TARGET))
|
||||
return {
|
||||
notFound: true
|
||||
};
|
||||
|
||||
const textScrape = await googleScrape(source, target, query);
|
||||
const initial = { source, target, query };
|
||||
|
||||
const [sourceAudio, targetAudio] = await Promise.all([
|
||||
textToSpeechScrape(source, query),
|
||||
"translationRes" in textScrape
|
||||
? textToSpeechScrape(target, textScrape.translationRes)
|
||||
: null
|
||||
const translation = await getTranslationText(source, target, query);
|
||||
|
||||
if (!translation)
|
||||
return {
|
||||
props: {
|
||||
type: ResponseType.ERROR,
|
||||
errorMsg: "An error occurred while retrieving the translation",
|
||||
initial
|
||||
},
|
||||
revalidate: 1
|
||||
};
|
||||
|
||||
const info = await getTranslationInfo(source, target, query);
|
||||
|
||||
const audioSource = source === "auto" && info?.detectedSource
|
||||
? info.detectedSource
|
||||
: source;
|
||||
const parsedAudioSource = replaceExceptedCode(LanguageType.TARGET, audioSource);
|
||||
|
||||
const [audioQuery, audioTranslation] = await Promise.all([
|
||||
getAudio(parsedAudioSource, query),
|
||||
getAudio(target, translation)
|
||||
]);
|
||||
|
||||
const audio = {
|
||||
query: audioQuery,
|
||||
translation: audioTranslation
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
...textScrape,
|
||||
audio: {
|
||||
source: sourceAudio,
|
||||
target: targetAudio
|
||||
},
|
||||
initial: {
|
||||
source, target, query
|
||||
}
|
||||
type: ResponseType.SUCCESS,
|
||||
translation,
|
||||
info,
|
||||
audio,
|
||||
initial
|
||||
},
|
||||
revalidate: !("errorMsg" in textScrape)
|
||||
? 2 * 30 * 24 * 60 * 60 // 2 months
|
||||
: 1
|
||||
revalidate: 2 * 30 * 24 * 60 * 60 // 2 months
|
||||
};
|
||||
};
|
||||
|
@@ -1,8 +1,16 @@
|
||||
import { ApolloServer, gql, IResolvers, ApolloError, UserInputError } from "apollo-server-micro";
|
||||
import { NextApiHandler } from "next";
|
||||
import NextCors from "nextjs-cors";
|
||||
import { googleScrape, textToSpeechScrape } from "@utils/translate";
|
||||
import { retrieveFromType, getName, isValid } from "@utils/language";
|
||||
import {
|
||||
getTranslationInfo,
|
||||
getTranslationText,
|
||||
getAudio,
|
||||
replaceExceptedCode,
|
||||
isValidCode,
|
||||
LanguageType,
|
||||
languageList,
|
||||
LangCode
|
||||
} from "lingva-scraper";
|
||||
|
||||
export const typeDefs = gql`
|
||||
enum LangType {
|
||||
@@ -11,14 +19,32 @@ export const typeDefs = gql`
|
||||
}
|
||||
type Query {
|
||||
translation(source: String="auto" target: String="en" query: String!): Translation!
|
||||
audio(lang: String! query: String!): Entry!
|
||||
audio(lang: String! query: String!): AudioEntry!
|
||||
languages(type: LangType): [Language]!
|
||||
}
|
||||
type Translation {
|
||||
source: Entry!
|
||||
target: Entry!
|
||||
source: SourceEntry!
|
||||
target: TargetEntry!
|
||||
}
|
||||
type Entry {
|
||||
type SourceEntry {
|
||||
lang: Language!
|
||||
text: String!
|
||||
audio: [Int]!
|
||||
detected: Language
|
||||
typo: String
|
||||
pronunciation: String
|
||||
definitions: [DefinitionsGroup]
|
||||
examples: [String]
|
||||
similar: [String]
|
||||
}
|
||||
type TargetEntry {
|
||||
lang: Language!
|
||||
text: String!
|
||||
audio: [Int]!
|
||||
pronunciation: String
|
||||
extraTranslations: [ExtraTranslationsGroup]
|
||||
}
|
||||
type AudioEntry {
|
||||
lang: Language!
|
||||
text: String!
|
||||
audio: [Int]!
|
||||
@@ -27,34 +53,70 @@ export const typeDefs = gql`
|
||||
code: String!
|
||||
name: String!
|
||||
}
|
||||
type DefinitionsGroup {
|
||||
type: String!
|
||||
list: [DefinitionList]!
|
||||
}
|
||||
type DefinitionList {
|
||||
definition: String!
|
||||
example: String!
|
||||
field: String
|
||||
synonyms: [String]
|
||||
}
|
||||
type ExtraTranslationsGroup {
|
||||
type: String!
|
||||
list: [ExtraTranslationList]!
|
||||
}
|
||||
type ExtraTranslationList {
|
||||
word: String!
|
||||
article: String
|
||||
frequency: Int!
|
||||
meanings: [String]
|
||||
}
|
||||
`;
|
||||
|
||||
export const resolvers: IResolvers = {
|
||||
Query: {
|
||||
translation(_, args) {
|
||||
async translation(_, args) {
|
||||
const { source, target, query } = args;
|
||||
|
||||
if (!isValid(source) || !isValid(target))
|
||||
if (!isValidCode(source, LanguageType.SOURCE) || !isValidCode(target, LanguageType.TARGET))
|
||||
throw new UserInputError("Invalid language code");
|
||||
|
||||
const translation = await getTranslationText(source, target, query);
|
||||
if (!translation)
|
||||
throw new ApolloError("An error occurred while retrieving the translation");
|
||||
|
||||
const info = await getTranslationInfo(source, target, query);
|
||||
return {
|
||||
source: {
|
||||
lang: {
|
||||
code: source
|
||||
},
|
||||
text: query
|
||||
text: query,
|
||||
detected: info?.detectedSource && {
|
||||
code: info.detectedSource
|
||||
},
|
||||
typo: info?.typo,
|
||||
pronunciation: info?.pronunciation.query,
|
||||
definitions: info?.definitions,
|
||||
examples: info?.examples,
|
||||
similar: info?.similar
|
||||
},
|
||||
target: {
|
||||
lang: {
|
||||
code: target
|
||||
}
|
||||
},
|
||||
text: translation,
|
||||
pronunciation: info?.pronunciation.translation,
|
||||
extraTranslations: info?.extraTranslations
|
||||
}
|
||||
};
|
||||
},
|
||||
audio(_, args) {
|
||||
const { lang, query } = args;
|
||||
|
||||
if (!isValid(lang))
|
||||
if (!isValidCode(lang))
|
||||
throw new UserInputError("Invalid language code");
|
||||
|
||||
return {
|
||||
@@ -66,36 +128,28 @@ export const resolvers: IResolvers = {
|
||||
},
|
||||
languages(_, args) {
|
||||
const { type } = args;
|
||||
const langEntries = retrieveFromType(type?.toLocaleLowerCase());
|
||||
const lowerType = type?.toLocaleLowerCase() as typeof LanguageType[keyof typeof LanguageType] | undefined;
|
||||
const langEntries = Object.entries(languageList[lowerType ?? "all"]);
|
||||
return langEntries.map(([code, name]) => ({ code, name }));
|
||||
}
|
||||
},
|
||||
Translation: {
|
||||
async target(parent) {
|
||||
const { source, target } = parent;
|
||||
const textScrape = await googleScrape(source.lang.code, target.lang.code, source.text);
|
||||
|
||||
if ("errorMsg" in textScrape)
|
||||
throw new ApolloError(textScrape.errorMsg);
|
||||
return {
|
||||
lang: target.lang,
|
||||
text: textScrape.translationRes
|
||||
};
|
||||
...(["SourceEntry", "TargetEntry", "AudioEntry"].reduce((acc, key) => ({
|
||||
...acc,
|
||||
[key]: {
|
||||
async audio(parent) {
|
||||
const { lang, text } = parent;
|
||||
const parsedLang = replaceExceptedCode(LanguageType.TARGET, lang.code);
|
||||
const audio = await getAudio(parsedLang, text);
|
||||
if (!audio)
|
||||
throw new ApolloError("An error occurred while retrieving the audio");
|
||||
return audio;
|
||||
}
|
||||
}
|
||||
},
|
||||
Entry: {
|
||||
async audio(parent) {
|
||||
const { lang, text } = parent;
|
||||
const audio = await textToSpeechScrape(lang.code, text);
|
||||
if (!audio)
|
||||
throw new ApolloError("An error occurred while retrieving the audio");
|
||||
return audio;
|
||||
}
|
||||
},
|
||||
}), {} as IResolvers)),
|
||||
Language: {
|
||||
name(parent) {
|
||||
const { code, name } = parent;
|
||||
return name || getName(code);
|
||||
return name || languageList.all[code as LangCode];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { NextApiHandler } from "next";
|
||||
import NextCors from "nextjs-cors";
|
||||
import { googleScrape, textToSpeechScrape } from "@utils/translate";
|
||||
import { isValid } from "@utils/language";
|
||||
import { getTranslationInfo, getTranslationText, getAudio, isValidCode, LanguageType, TranslationInfo } from "lingva-scraper";
|
||||
|
||||
type Data = {
|
||||
translation: string,
|
||||
info?: TranslationInfo
|
||||
} | {
|
||||
audio: number[]
|
||||
} | {
|
||||
@@ -35,24 +35,29 @@ const handler: NextApiHandler<Data> = async (req, res) => {
|
||||
|
||||
const [source, target, query] = slug;
|
||||
|
||||
if (!isValid(target))
|
||||
if (!isValidCode(target, LanguageType.TARGET))
|
||||
return res.status(400).json({ error: "Invalid target language" });
|
||||
|
||||
if (source === "audio") {
|
||||
const audio = await textToSpeechScrape(target, query);
|
||||
const audio = await getAudio(target, query);
|
||||
return audio
|
||||
? res.status(200).json({ audio })
|
||||
: res.status(500).json({ error: "An error occurred while retrieving the audio" });
|
||||
}
|
||||
|
||||
if (!isValid(source))
|
||||
if (!isValidCode(source, LanguageType.SOURCE))
|
||||
return res.status(400).json({ error: "Invalid source language" });
|
||||
|
||||
const textScrape = await googleScrape(source, target, query);
|
||||
const translation = await getTranslationText(source, target, query);
|
||||
|
||||
if ("errorMsg" in textScrape)
|
||||
return res.status(500).json({ error: textScrape.errorMsg });
|
||||
res.status(200).json({ translation: textScrape.translationRes });
|
||||
if (!translation)
|
||||
return res.status(500).json({ error: "An error occurred while retrieving the translation" });
|
||||
|
||||
const info = await getTranslationInfo(source, target, query);
|
||||
|
||||
return info
|
||||
? res.status(200).json({ translation, info })
|
||||
: res.status(200).json({ translation });
|
||||
}
|
||||
|
||||
export default handler;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { NextApiHandler } from "next";
|
||||
import NextCors from "nextjs-cors";
|
||||
import { retrieveFromType, LangCode } from "@utils/language";
|
||||
import { languageList, LangCode } from "lingva-scraper";
|
||||
|
||||
type Data = {
|
||||
languages: {
|
||||
@@ -38,7 +38,7 @@ const handler: NextApiHandler<Data> = async (req, res) => {
|
||||
if (type !== undefined && type !== "source" && type !== "target")
|
||||
return res.status(400).json({ error: "Type should be 'source', 'target' or empty" });
|
||||
|
||||
const langEntries = retrieveFromType(type);
|
||||
const langEntries = Object.entries(languageList[type ?? "all"]) as [LangCode, string][];
|
||||
const languages = langEntries.map(([code, name]) => ({ code, name }));
|
||||
|
||||
res.status(200).json({ languages });
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import { render, screen } from "@tests/reactUtils";
|
||||
import faker from "faker";
|
||||
import { render, screen } from "./reactUtils";
|
||||
import CustomError from "@components/CustomError";
|
||||
|
||||
const code = faker.datatype.number({ min: 400, max: 599 });
|
||||
const text = faker.random.words();
|
||||
const code = Math.random() * 199 + 400;
|
||||
const text = "Testing fake error";
|
||||
|
||||
it("loads the layout correctly", async () => {
|
||||
render(<CustomError statusCode={code} statusText={text} />);
|
||||
@@ -16,7 +15,6 @@ it("loads the layout correctly", async () => {
|
||||
});
|
||||
|
||||
it("renders the correct status code & text", () => {
|
||||
const code = faker.datatype.number({ min: 400, max: 599 });
|
||||
render(<CustomError statusCode={code} statusText={text} />);
|
||||
|
||||
expect(screen.getByText(code)).toBeVisible();
|
198
tests/Page.test.tsx
Normal file
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 { ChakraProvider } from "@chakra-ui/react";
|
||||
import theme from "@theme";
|
||||
import { Layout } from "@components";
|
||||
import { RouterProviderMock } from "@mocks/next";
|
||||
|
||||
// Jest JSDOM bug
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
@@ -19,12 +20,14 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
})),
|
||||
});
|
||||
|
||||
const Providers: FC = ({ children }) => (
|
||||
<ChakraProvider theme={theme}>
|
||||
<Layout>
|
||||
{children}
|
||||
</Layout>
|
||||
</ChakraProvider>
|
||||
const Providers: FC<PropsWithChildren> = ({ children }) => (
|
||||
<RouterProviderMock>
|
||||
<ChakraProvider theme={theme}>
|
||||
<Layout>
|
||||
{children}
|
||||
</Layout>
|
||||
</ChakraProvider>
|
||||
</RouterProviderMock>
|
||||
);
|
||||
|
||||
const customRender = (
|
||||
|
@@ -1,3 +1 @@
|
||||
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 { mode } from "@chakra-ui/theme-tools";
|
||||
import { mode, StyleFunctionProps } from "@chakra-ui/theme-tools";
|
||||
|
||||
const forceDefaultTheme = process.env["NEXT_PUBLIC_FORCE_DEFAULT_THEME"];
|
||||
|
||||
export default extendTheme({
|
||||
styles: {
|
||||
global: {
|
||||
"html, body, #__next": {
|
||||
height: "100%"
|
||||
},
|
||||
"#__next": {
|
||||
isolation: "isolate"
|
||||
}
|
||||
}
|
||||
},
|
||||
colors: {
|
||||
lingva: {
|
||||
50: "#e7f5ed",
|
||||
@@ -17,26 +29,12 @@ export default extendTheme({
|
||||
}
|
||||
},
|
||||
config: {
|
||||
initialColorMode: process.env["DEFAULT_DARK_THEME"] === "true" ? "dark" : "light",
|
||||
useSystemColorMode: false
|
||||
initialColorMode: forceDefaultTheme === "light" || forceDefaultTheme === "dark" ? forceDefaultTheme : "system"
|
||||
},
|
||||
components: {
|
||||
Textarea: {
|
||||
variants: {
|
||||
outline: props => ({
|
||||
borderColor: mode("lingva.500", "lingva.200")(props),
|
||||
_hover: {
|
||||
borderColor: mode("lingva.700", "lingva.400")(props),
|
||||
},
|
||||
_readOnly: {
|
||||
userSelect: "auto"
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
Select: {
|
||||
variants: {
|
||||
flushed: props => ({
|
||||
flushed: (props: StyleFunctionProps) => ({
|
||||
field: {
|
||||
borderColor: mode("lingva.500", "lingva.200")(props)
|
||||
}
|
||||
|
@@ -30,6 +30,8 @@
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"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 defaultTargetLang = process.env["NEXT_PUBLIC_DEFAULT_TARGET_LANG"];
|
||||
|
||||
type State = {
|
||||
source: LangCode,
|
||||
target: LangCode,
|
||||
export type State = {
|
||||
source: LangCode<"source">,
|
||||
target: LangCode<"target">,
|
||||
query: string,
|
||||
delayedQuery: string,
|
||||
translation: string,
|
||||
isLoading: boolean
|
||||
isLoading: boolean,
|
||||
pronunciation: {
|
||||
query?: string,
|
||||
translation?: string
|
||||
},
|
||||
audio: {
|
||||
query?: number[],
|
||||
translation?: number[]
|
||||
}
|
||||
}
|
||||
|
||||
export const initialState: State = {
|
||||
source: isValid(defaultSourceLang) ? defaultSourceLang : "auto",
|
||||
target: isValid(defaultTargetLang) ? defaultTargetLang : "en",
|
||||
source: isValidCode(defaultSourceLang, LanguageType.SOURCE) ? defaultSourceLang : "auto",
|
||||
target: isValidCode(defaultTargetLang, LanguageType.TARGET) ? defaultTargetLang : "en",
|
||||
query: "",
|
||||
delayedQuery: "",
|
||||
translation: "",
|
||||
isLoading: true
|
||||
isLoading: true,
|
||||
pronunciation: {},
|
||||
audio: {}
|
||||
}
|
||||
|
||||
export enum Actions {
|
||||
SET_FIELD,
|
||||
SET_SOURCE,
|
||||
SET_TARGET,
|
||||
SET_ALL,
|
||||
SWITCH_LANGS
|
||||
}
|
||||
|
||||
type Action = {
|
||||
type Action<T extends keyof State = keyof State> = {
|
||||
type: Actions.SET_FIELD,
|
||||
payload: {
|
||||
key: string,
|
||||
value: any
|
||||
key: T,
|
||||
value: State[T]
|
||||
}
|
||||
} | {
|
||||
type: Actions.SET_SOURCE | Actions.SET_TARGET,
|
||||
payload: {
|
||||
code: string
|
||||
}
|
||||
} | {
|
||||
type: Actions.SET_ALL,
|
||||
@@ -39,36 +56,84 @@ type Action = {
|
||||
state: State
|
||||
}
|
||||
} | {
|
||||
type: Actions.SWITCH_LANGS
|
||||
type: Actions.SWITCH_LANGS,
|
||||
payload: {
|
||||
detectedSource?: LangCode<"source">
|
||||
}
|
||||
}
|
||||
|
||||
export default function reducer(state: State, action: Action): State {
|
||||
const { source, target } = replaceBoth("exception", {
|
||||
source: state.target,
|
||||
target: state.source
|
||||
});
|
||||
|
||||
switch (action.type) {
|
||||
case Actions.SET_FIELD:
|
||||
case Actions.SET_FIELD: {
|
||||
const { key, value } = action.payload;
|
||||
if (key === "source" && value === state.target)
|
||||
return { ...state, [key]: value, target: target !== value ? target : "eo" };
|
||||
if (key === "target" && value === state.source)
|
||||
return { ...state, [key]: value, source };
|
||||
return { ...state, [key]: value };
|
||||
case Actions.SET_ALL:
|
||||
return { ...state, ...action.payload.state };
|
||||
case Actions.SWITCH_LANGS:
|
||||
}
|
||||
case Actions.SET_SOURCE: {
|
||||
const { code } = action.payload;
|
||||
if (!isValidCode(code, LanguageType.SOURCE))
|
||||
return state;
|
||||
|
||||
if (code !== state.target)
|
||||
return { ...state, source: code };
|
||||
|
||||
const sourceAsTarget = replaceExceptedCode(LanguageType.TARGET, state.source);
|
||||
return {
|
||||
...state,
|
||||
source: source !== target
|
||||
? source
|
||||
: initialState.source,
|
||||
target,
|
||||
source: code,
|
||||
target: sourceAsTarget !== code
|
||||
? sourceAsTarget
|
||||
: "eo"
|
||||
};
|
||||
}
|
||||
case Actions.SET_TARGET: {
|
||||
const { code } = action.payload;
|
||||
if (!isValidCode(code, LanguageType.TARGET))
|
||||
return state;
|
||||
|
||||
if (code !== state.source)
|
||||
return { ...state, target: code };
|
||||
|
||||
const targetAsSource = replaceExceptedCode(LanguageType.SOURCE, state.target);
|
||||
return {
|
||||
...state,
|
||||
target: code,
|
||||
source: targetAsSource !== code
|
||||
? targetAsSource
|
||||
: "auto"
|
||||
};
|
||||
}
|
||||
case Actions.SET_ALL: {
|
||||
return { ...state, ...action.payload.state };
|
||||
}
|
||||
case Actions.SWITCH_LANGS: {
|
||||
const { detectedSource } = action.payload;
|
||||
|
||||
const newTarget = state.source === "auto" && detectedSource
|
||||
? detectedSource
|
||||
: state.source;
|
||||
const parsedNewTarget = replaceExceptedCode(LanguageType.TARGET, newTarget);
|
||||
|
||||
const parsedNewSource = parsedNewTarget === state.target
|
||||
? initialState.source
|
||||
: replaceExceptedCode(LanguageType.SOURCE, state.target);
|
||||
|
||||
return {
|
||||
...state,
|
||||
source: parsedNewSource,
|
||||
target: parsedNewTarget,
|
||||
query: state.translation,
|
||||
delayedQuery: state.translation,
|
||||
translation: state.query
|
||||
translation: state.query,
|
||||
pronunciation: {
|
||||
query: state.pronunciation.translation,
|
||||
translation: state.pronunciation.query
|
||||
},
|
||||
audio: {
|
||||
query: state.audio.translation,
|
||||
translation: state.audio.query
|
||||
}
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
19
utils/slug.ts
Normal file
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