├── .node-version ├── .eslintignore ├── .stylelintignore ├── src ├── __mocks__ │ ├── file.ts │ └── axios.ts ├── favicon.ico ├── components │ ├── footer │ │ ├── img │ │ │ ├── stsi.gif │ │ │ ├── github.png │ │ │ ├── GCI_logo.png │ │ │ ├── logouapp.gif │ │ │ ├── gplv3-88x31.png │ │ │ ├── logomitc120.jpg │ │ │ ├── logo_bytemark.gif │ │ │ ├── prompsit150x52.png │ │ │ ├── logo_mae_ro_75pc.jpg │ │ │ └── cc-by-sa-3.0-88x31.png │ │ ├── footer.css │ │ ├── DownloadModal.tsx │ │ ├── DocumentationModal.tsx │ │ ├── ContactModal.tsx │ │ ├── __tests__ │ │ │ └── index.test.tsx │ │ ├── AboutModal.tsx │ │ └── index.tsx │ ├── navbar │ │ ├── Apertium_box_white_small.embed.png │ │ ├── __tests__ │ │ │ └── index.test.tsx │ │ └── index.tsx │ ├── __tests__ │ │ ├── ErrorAlert.test.tsx │ │ ├── LocaleSelector.test.tsx │ │ ├── WithLocale.test.tsx │ │ ├── Sandbox.test.tsx │ │ └── WithInstallationAlert.test.tsx │ ├── ErrorAlert.tsx │ ├── LocaleSelector.tsx │ ├── translator │ │ ├── translator.css │ │ ├── index.ts │ │ ├── __tests__ │ │ │ ├── WithSortedLanguages.test.tsx │ │ │ └── TranslationOptions.test.tsx │ │ ├── WithSortedLanguages.tsx │ │ └── TranslationOptions.tsx │ ├── WithLocale.tsx │ ├── WithInstallationAlert.tsx │ └── Sandbox.tsx ├── testEnvSetup.ts ├── app.css ├── util │ ├── strings.ts │ ├── index.ts │ ├── __tests__ │ │ ├── url.test.ts │ │ ├── index.test.ts │ │ ├── languages.test.ts │ │ ├── localization.test.tsx │ │ └── useLocalStorage.test.ts │ ├── url.ts │ ├── useLocalStorage.ts │ └── localization.ts ├── globals.d.ts ├── strings │ ├── Makefile │ ├── progresstable.sh │ ├── locales.json │ ├── README.md │ ├── ron.json │ ├── zho.json │ ├── eus.json │ ├── ava.json │ ├── por.json │ ├── sme.json │ ├── spa.json │ ├── kir.json │ ├── heb.json │ ├── tha.json │ ├── kaz.json │ ├── uig.json │ ├── rus.json │ ├── oci.json │ ├── swe.json │ ├── srd.json │ ├── ara.json │ ├── uzb.json │ ├── fin.json │ └── nno.json ├── context.ts ├── index.tsx ├── types.ts ├── __tests__ │ ├── index.test.tsx │ └── App.test.tsx ├── index.html ├── rtl.css ├── testSetup.ts └── App.tsx ├── .gitattributes ├── .prettierrc.toml ├── .dockerignore ├── .stylelintrc.json ├── .gitignore ├── .prettierignore ├── tsconfig.json ├── Dockerfile ├── .github └── workflows │ ├── build.yml │ └── check.yml ├── jest.config.ts ├── config.ts ├── .eslintrc.json ├── package.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 14.19.1 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /coverage/ 4 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | src/bootstrap.css 2 | src/**/*.tsx 3 | -------------------------------------------------------------------------------- /src/__mocks__/file.ts: -------------------------------------------------------------------------------- 1 | export default 'http://placekitten.com/200/300'; 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | printWidth = 120 2 | singleQuote = true 3 | trailingComma = 'all' 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | tsconfig.tsbuildinfo 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /src/__mocks__/axios.ts: -------------------------------------------------------------------------------- 1 | import mockAxios from 'jest-mock-axios'; 2 | export default mockAxios; 3 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard", "stylelint-config-prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /yarn-error.log 3 | /meta.json 4 | /tsconfig.tsbuildinfo 5 | /coverage/ 6 | /dist/ 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /coverage/ 3 | /node_modules/ 4 | /src/strings/ 5 | /src/bootstrap.css 6 | /meta.json 7 | -------------------------------------------------------------------------------- /src/components/footer/img/stsi.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/stsi.gif -------------------------------------------------------------------------------- /src/components/footer/img/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/github.png -------------------------------------------------------------------------------- /src/components/footer/img/GCI_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/GCI_logo.png -------------------------------------------------------------------------------- /src/components/footer/img/logouapp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/logouapp.gif -------------------------------------------------------------------------------- /src/components/footer/img/gplv3-88x31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/gplv3-88x31.png -------------------------------------------------------------------------------- /src/components/footer/img/logomitc120.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/logomitc120.jpg -------------------------------------------------------------------------------- /src/components/footer/img/logo_bytemark.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/logo_bytemark.gif -------------------------------------------------------------------------------- /src/components/footer/img/prompsit150x52.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/prompsit150x52.png -------------------------------------------------------------------------------- /src/components/footer/img/logo_mae_ro_75pc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/logo_mae_ro_75pc.jpg -------------------------------------------------------------------------------- /src/components/footer/img/cc-by-sa-3.0-88x31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/footer/img/cc-by-sa-3.0-88x31.png -------------------------------------------------------------------------------- /src/testEnvSetup.ts: -------------------------------------------------------------------------------- 1 | import mockAxios from 'jest-mock-axios'; 2 | 3 | afterEach(() => { 4 | mockAxios.reset(); 5 | window.localStorage.clear(); 6 | }); 7 | -------------------------------------------------------------------------------- /src/components/navbar/Apertium_box_white_small.embed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertium/apertium-html-tools/HEAD/src/components/navbar/Apertium_box_white_small.embed.png -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | .blurred { 2 | opacity: 0.5; 3 | transition: all 1s ease; 4 | } 5 | 6 | .btn-primary { 7 | background-color: #446e9b; 8 | border-color: #446e9b; 9 | color: #fff; 10 | } 11 | 12 | .btn-primary:hover { 13 | background-color: #385a7f; 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "strict": true, 5 | "incremental": true, 6 | "lib": ["es2019", "dom"], 7 | "esModuleInterop": true, 8 | "resolveJsonModule": true, 9 | "downlevelIteration": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-buster-slim 2 | LABEL maintainer sushain@skc.name 3 | WORKDIR /root 4 | 5 | RUN apt-get -qq update && \ 6 | apt-get -qq install --no-install-recommends git 7 | 8 | COPY package.json . 9 | COPY yarn.lock . 10 | RUN yarn install --dev 11 | 12 | COPY . . 13 | 14 | ENTRYPOINT ["yarn"] 15 | CMD ["build", "--prod"] 16 | -------------------------------------------------------------------------------- /src/util/strings.ts: -------------------------------------------------------------------------------- 1 | export type Strings = { 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-ignore 4 | readonly '@langNames': Record; 5 | readonly [id: string]: string; 6 | }; 7 | 8 | // eslint-disable-next-line 9 | export const PRELOADED_STRINGS: Readonly> = (window as any).PRELOADED_STRINGS; 10 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const url: string; 3 | export default url; 4 | } 5 | 6 | declare module '*.gif' { 7 | const url: string; 8 | export default url; 9 | } 10 | 11 | declare module '*.jpg' { 12 | const url: string; 13 | export default url; 14 | } 15 | 16 | declare module '*.svg' { 17 | const url: string; 18 | export default url; 19 | } 20 | -------------------------------------------------------------------------------- /src/strings/Makefile: -------------------------------------------------------------------------------- 1 | all: cleanup update-readme 2 | 3 | cleanup: $(shell find . -name '*.json' | egrep '/[a-z]{3}\.json') 4 | ./localisation-tools.py cleanup $? 5 | 6 | update-readme: README.md 7 | sed -n "//q;p" $^ > $^.tmp 8 | echo "" >> $^.tmp 9 | ./progresstable.sh md >> $^.tmp 10 | mv $^.tmp $^ 11 | 12 | test: README.md 13 | git diff --exit-code . 14 | -------------------------------------------------------------------------------- /src/components/footer/footer.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #react-mount { 4 | height: 100%; /* The html and body elements cannot have any padding or margin. */ 5 | } 6 | 7 | @media print { 8 | #footer { 9 | display: none; 10 | } 11 | } 12 | 13 | .version:hover, 14 | .version:active, 15 | .version:focus { 16 | text-decoration: none; 17 | } 18 | 19 | .footer-link { 20 | font-size: 1rem; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/__tests__/ErrorAlert.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | 4 | import ErrorAlert from '../ErrorAlert'; 5 | 6 | it('shows generic errors', () => { 7 | render(); 8 | 9 | const error = screen.getByRole('alert'); 10 | expect(error.textContent).toMatchInlineSnapshot(`" Error: Network Error"`); 11 | }); 12 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import Config from '../config'; 4 | import { PRELOADED_STRINGS } from './util/strings'; 5 | import { apyFetch } from './util'; 6 | 7 | const LocaleContext = React.createContext(Config.defaultLocale); 8 | const ConfigContext = React.createContext(Config); 9 | const StringsContext = React.createContext(PRELOADED_STRINGS); 10 | const APyContext = React.createContext(apyFetch); 11 | 12 | export { APyContext, ConfigContext, LocaleContext, StringsContext }; 13 | -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | import * as queryString from 'query-string'; 2 | import axios, { AxiosPromise, CancelTokenSource } from 'axios'; 3 | 4 | export const apyFetch = (path: string, params?: Record): [CancelTokenSource, AxiosPromise] => { 5 | const source = axios.CancelToken.source(); 6 | 7 | return [ 8 | source, 9 | axios.post(path, params ? queryString.stringify(params) : '', { 10 | headers: { 11 | 'Content-Type': 'application/x-www-form-urlencoded', 12 | }, 13 | cancelToken: source.token, 14 | validateStatus: (status) => status === 200, 15 | }), 16 | ]; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/footer/DownloadModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Modal, { ModalProps } from 'react-bootstrap/Modal'; 3 | 4 | import { useLocalization } from '../../util/localization'; 5 | 6 | const DownloadModal = (props: ModalProps): React.ReactElement => { 7 | const { t } = useLocalization(); 8 | 9 | return ( 10 | 11 | 12 | {t('Apertium_Downloads')} 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default DownloadModal; 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - name: Checkout repo 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version-file: .node-version 16 | - name: Install dependencies 17 | uses: bahmutov/npm-install@v1 18 | 19 | - name: Build 20 | run: yarn build --prod 21 | - name: Upload build 22 | uses: actions/upload-artifact@v4 23 | with: 24 | name: dist 25 | path: dist/ 26 | -------------------------------------------------------------------------------- /src/components/footer/DocumentationModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Modal, { ModalProps } from 'react-bootstrap/Modal'; 3 | 4 | import { useLocalization } from '../../util/localization'; 5 | 6 | const DocumentationModal = (props: ModalProps): React.ReactElement => { 7 | const { t } = useLocalization(); 8 | 9 | return ( 10 | 11 | 12 | {t('Apertium_Documentation')} 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default DocumentationModal; 20 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import App from './App'; 5 | 6 | // `BrowserRouter` uses the `history` library underneath which persists 7 | // `window.location.search` inside the hash rather than as proper query 8 | // parameters. While this behavior isn't a problem per se, it conflicts with the 9 | // pre-v5 URL scheme. For backwards compatibility, we convert forwards here. 10 | const { search } = window.location; 11 | if (search.length) { 12 | window.history.pushState({}, '', `${window.location.pathname}${window.location.hash || '#'}${search}`); 13 | } 14 | 15 | ReactDOM.render(, document.getElementById('react-mount')); 16 | -------------------------------------------------------------------------------- /src/components/footer/ContactModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Modal, { ModalProps } from 'react-bootstrap/Modal'; 3 | 4 | import { useLocalization } from '../../util/localization'; 5 | 6 | const ContactModal = (props: ModalProps): React.ReactElement => { 7 | const { t } = useLocalization(); 8 | 9 | return ( 10 | 11 | 12 | {t('Contact')} 13 | 14 | 15 |

{t('Contact_Us')}

16 |
17 | 18 | 19 | ); 20 | }; 21 | 22 | export default ContactModal; 23 | -------------------------------------------------------------------------------- /src/util/__tests__/url.test.ts: -------------------------------------------------------------------------------- 1 | import { buildNewSearch, getUrlParam } from '../url'; 2 | 3 | describe('getUrlParam', () => { 4 | afterEach(() => jest.restoreAllMocks()); 5 | 6 | it.each([ 7 | ['dir', 'eng-spa', '?dir=eng-spa'], 8 | ['dir', 'eng-spa', '?dir=eng-spa&dir=cat-spa'], 9 | ['lang', null, '?dir=eng-spa'], 10 | ['dir', null, ''], 11 | ])('extracts %s to %s in "%s"', (param, value, search) => { 12 | expect(getUrlParam(search, param)).toBe(value); 13 | }); 14 | }); 15 | 16 | describe('buildNewSearch', () => { 17 | it.each([ 18 | [{}, '?'], 19 | [{ dir: 'eng-spa' }, '?dir=eng-spa'], 20 | ])('maps %s to %s', (params, url) => expect(buildNewSearch(params)).toBe(url)); 21 | }); 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { createInstance } from '@datapunt/matomo-tracker-react'; 2 | 3 | export enum Mode { 4 | Translation = 'translation', 5 | Analysis = 'analysis', 6 | Generation = 'generation', 7 | Sandbox = 'sandbox', 8 | } 9 | 10 | export type Config = { 11 | defaultLocale: string; 12 | htmlUrl: string; 13 | apyURL: string; 14 | stringReplacements: Record; 15 | matomoConfig?: Parameters[0]; 16 | 17 | allowedLangs?: Set; 18 | allowedVariants?: Set; 19 | 20 | defaultMode: Mode; 21 | enabledModes: Set; 22 | translationChaining: boolean; 23 | 24 | subtitle?: string; 25 | subtitleColor?: string; 26 | showMoreLanguagesLink: boolean; 27 | }; 28 | -------------------------------------------------------------------------------- /src/util/url.ts: -------------------------------------------------------------------------------- 1 | import * as queryString from 'query-string'; 2 | 3 | // https://stackoverflow.com/q/417142/1266600. Preserve enough space for the 4 | // host and path. 5 | // eslint-disable-next-line no-magic-numbers 6 | export const MaxURLLength = 2048 - window.location.origin.length - 25; 7 | 8 | export const getUrlParam = (search: string, key: string): string | null => { 9 | const value = queryString.parse(search)[key]; 10 | if (value == null) { 11 | return null; 12 | } 13 | if (typeof value === 'string') { 14 | return value; 15 | } 16 | return value.length > 0 ? value[0] : null; 17 | }; 18 | 19 | export const buildNewSearch = (params: Record): string => { 20 | return `?${queryString.stringify(params)}`; 21 | }; 22 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | export default { 4 | transform: { 5 | '^.+\\.tsx?$': [ 6 | 'esbuild-jest', 7 | { 8 | sourcemap: true, 9 | }, 10 | ], 11 | }, 12 | moduleNameMapper: { 13 | '\\.(css)$': 'identity-obj-proxy', 14 | '\\.(gif|png|jpg|svg)$': '/src/__mocks__/file.ts', 15 | }, 16 | timers: 'modern', 17 | 18 | setupFiles: ['./src/testSetup.ts'], 19 | setupFilesAfterEnv: ['./src/testEnvSetup.ts'], 20 | 21 | collectCoverageFrom: ['src/**/*.ts', 'src/**/*.tsx'], 22 | coverageThreshold: { 23 | global: { 24 | branches: 90, 25 | functions: 95, 26 | lines: 95, 27 | statements: 95, 28 | }, 29 | }, 30 | } as Config.InitialOptions; 31 | -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | import { Config, Mode } from './src/types'; 2 | 3 | export default { 4 | defaultLocale: 'eng', 5 | htmlUrl: 'https://beta.apertium.org/', 6 | apyURL: 'https://beta.apertium.org/apy', 7 | 8 | defaultMode: Mode.Translation, 9 | enabledModes: new Set([Mode.Translation, Mode.Analysis, Mode.Generation, Mode.Sandbox]), 10 | translationChaining: true, 11 | 12 | subtitle: 'Beta', 13 | subtitleColor: 'rgb(220, 41, 38)', 14 | 15 | stringReplacements: { 16 | '{{maintainer}}': "Apertium", 17 | '{{more_languages}}': "beta.apertium.org", 18 | }, 19 | showMoreLanguagesLink: false, 20 | } as Config; 21 | -------------------------------------------------------------------------------- /src/strings/progresstable.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | completion=`grep "completion" *.json | sort -nrk1.33,1.35 -nrk1.38,1.43` 4 | 5 | if [[ $1 == "md" ]]; then 6 | echo "$completion" | awk -F '"' 'BEGIN {print "| code | CBE* | CBC** |\n|------|------|-------|" } {sub(/\..*$/, "", $1); split($4, s, " "); print "| " $1 " | " s[1] " | " s[2] " |"} END { print "\n\\*CBE: completion by entries
\n\\**CBC: completion by characters (i.e., ratio of characters to English ~source)"}' 7 | else 8 | echo "$completion" | awk -F '"' 'BEGIN {print "{| class=\"wikitable sortable\"\n|-\n!|code\n!|CBE*\n!|CBC**" } {sub(/\..*$/, "", $1); split($4, s, " "); print "|-\n|| " $1 " || " s[1] " || " s[2]} END { print "|}\n\n *CBE: completion by entries
\n **CBC: completion by characters (i.e., ratio of characters to English ~source)"}' 9 | fi 10 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | 4 | it('mounts to react-mount', () => { 5 | render( 6 | <> 7 | 8 |
9 | , 10 | ); 11 | 12 | jest.isolateModules(() => void require('..')); 13 | expect(screen.getByRole('img', { name: 'Apertium Box' })).toBeDefined(); 14 | }); 15 | 16 | it('moves actual URL parameters into URL hash', () => { 17 | window.history.replaceState(null, '', '/?dir=cat-spa'); 18 | 19 | render( 20 | <> 21 | 22 |
23 | , 24 | ); 25 | 26 | jest.isolateModules(() => void require('..')); 27 | 28 | expect(window.location.hash).toContain('dir=cat-spa'); 29 | }); 30 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apertium | A free/open-source machine translation platform 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/strings/locales.json: -------------------------------------------------------------------------------- 1 | { 2 | "ara": "العَرَبِيَّة", 3 | "arg": "aragonés", 4 | "ava": "магӏарул мацӏ", 5 | "cat": "català", 6 | "deu": "deutsch", 7 | "eng": "English", 8 | "eus": "euskara", 9 | "dan": "dansk", 10 | "fin": "suomi", 11 | "fra": "français", 12 | "frp": "arpetan", 13 | "glg": "galego", 14 | "heb": "עברית", 15 | "hin": "हिन्दी", 16 | "kaa": "qaraqalpaqsha", 17 | "kaz": "қазақша", 18 | "kir": "кыргызча", 19 | "mar": "मराठी", 20 | "nno": "nynorsk", 21 | "nob": "norsk bokmål", 22 | "oci": "occitan", 23 | "por": "português", 24 | "ron": "românǎ", 25 | "rus": "русский", 26 | "sat": "ᱥᱟᱱᱛᱟᱲᱤ", 27 | "skr": "سرائیکی", 28 | "sme": "davvisámegiella", 29 | "spa": "español", 30 | "srd": "sardu", 31 | "swe": "svenska", 32 | "szl": "ślōnskŏ mŏwa", 33 | "tat": "татарча", 34 | "tur": "Türkçe", 35 | "uig": "ئۇيغۇرچە", 36 | "ukr": "українська", 37 | "uzb": "oʻzbekcha", 38 | "zho": "汉语" 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ErrorAlert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Alert from 'react-bootstrap/Alert'; 3 | import { AxiosError } from 'axios'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; 6 | 7 | const isAxiosError = (error: Error): error is AxiosError => (error as AxiosError).isAxiosError; 8 | 9 | const ErrorAlert = ({ error }: { error: Error }): React.ReactElement => { 10 | let errorText = error.toString(); 11 | if ( 12 | isAxiosError(error) && 13 | error.response && 14 | error.response['data'] && 15 | (error.response['data'] as { explanation?: string }).explanation 16 | ) { 17 | errorText = (error.response['data'] as { explanation: string }).explanation; 18 | } 19 | return ( 20 | 21 | {errorText} 22 | 23 | ); 24 | }; 25 | 26 | export default ErrorAlert; 27 | -------------------------------------------------------------------------------- /src/rtl.css: -------------------------------------------------------------------------------- 1 | html[dir='rtl'] body { 2 | text-align: right !important; 3 | } 4 | 5 | html[dir='rtl'] .float-right { 6 | float: left !important; 7 | } 8 | 9 | html[dir='rtl'] .float-left { 10 | float: right !important; 11 | } 12 | 13 | html[dir='rtl'] textarea { 14 | text-align: right; 15 | } 16 | 17 | html[dir='rtl'] .ml-auto { 18 | margin-left: unset !important; 19 | margin-right: auto !important; 20 | } 21 | 22 | html[dir='rtl'] .mr-1 { 23 | margin-right: unset !important; 24 | margin-left: 0.25rem !important; 25 | } 26 | 27 | html[dir='rtl'] .mr-2 { 28 | margin-right: unset !important; 29 | margin-left: 0.5rem !important; 30 | } 31 | 32 | html[dir='rtl'] .mr-3 { 33 | margin-right: unset !important; 34 | margin-left: 1rem !important; 35 | } 36 | 37 | html[dir='rtl'] .ml-1 { 38 | margin-left: unset !important; 39 | margin-right: 0.25rem !important; 40 | } 41 | 42 | html[dir='rtl'] .ml-2 { 43 | margin-left: unset !important; 44 | margin-right: 0.5rem !important; 45 | } 46 | 47 | html[dir='rtl'] .ml-3 { 48 | margin-left: unset !important; 49 | margin-right: 1rem !important; 50 | } 51 | -------------------------------------------------------------------------------- /src/components/__tests__/LocaleSelector.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | import { LocaleContext } from '../../context'; 6 | import LocaleSelector from '../LocaleSelector'; 7 | 8 | it('selects currently active locale', () => { 9 | render( 10 | 11 | 12 | , 13 | ); 14 | 15 | const selector = screen.getByRole('combobox'); 16 | expect((selector as HTMLSelectElement).value).toBe('spa'); 17 | }); 18 | 19 | it('calls setLocale on updates', () => { 20 | const setLocale = jest.fn(); 21 | render( 22 | 23 | 24 | , 25 | ); 26 | 27 | const selector = screen.getByRole('combobox'); 28 | userEvent.selectOptions(selector, screen.getByRole('option', { name: 'English' })); 29 | 30 | expect(setLocale).toHaveBeenCalledTimes(1); 31 | expect(setLocale).toHaveBeenCalledWith('eng'); 32 | }); 33 | -------------------------------------------------------------------------------- /src/util/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | jest.dontMock('axios'); 2 | 3 | import type { apyFetch as apyFetchT } from '..'; 4 | import axios from 'axios'; 5 | 6 | describe('apyFetch', () => { 7 | // eslint-disable-next-line 8 | const { apyFetch }: { apyFetch: typeof apyFetchT } = require('..'); 9 | 10 | it('POSTs to the provided path', async () => { 11 | const [, response] = apyFetch<{ method: string }>('https://httpbin.org/anything'); 12 | expect((await response).data['method']).toBe('POST'); 13 | }); 14 | 15 | it('throws on non-200', async () => { 16 | const [, response] = apyFetch<{ method: string }>('https://httpbin.org/status/404'); 17 | await expect(response).rejects.toThrowErrorMatchingInlineSnapshot(`"Request failed with status code 404"`); 18 | }); 19 | 20 | it('allows cancelling requests', async () => { 21 | const [ref, response] = apyFetch<{ method: string }>('https://httpbin.org/delay/10'); 22 | ref.cancel(); 23 | 24 | expect.assertions(1); 25 | try { 26 | await response; 27 | } catch (err) { 28 | // eslint-disable-next-line 29 | expect(axios.isCancel(err)).toBeTruthy(); 30 | } 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/util/__tests__/languages.test.ts: -------------------------------------------------------------------------------- 1 | import { isVariant, langDirection, parentLang, toAlpha2Code, toAlpha3Code } from '../languages'; 2 | 3 | describe('toAlpha2Code', () => { 4 | it.each([ 5 | ['myv', null], 6 | ['eng', 'en'], 7 | ['eng_US', 'en_US'], 8 | ['en', 'en'], 9 | [null, null], 10 | ])('maps %s to %s', (before, after) => expect(toAlpha2Code(before)).toBe(after)); 11 | }); 12 | 13 | describe('toAlpha3Code', () => { 14 | it.each([ 15 | ['xxx', 'xxx'], 16 | ['tel', 'tel'], 17 | ['eng', 'eng'], 18 | ['en_US', 'eng_US'], 19 | ['en', 'eng'], 20 | [null, null], 21 | ])('maps %s to %s', (before, after) => expect(toAlpha3Code(before)).toBe(after)); 22 | }); 23 | 24 | describe('langDirection', () => { 25 | it.each([ 26 | ['eng', 'ltr'], 27 | ['ara', 'rtl'], 28 | ])('maps %s to %s', (lang, direction) => expect(langDirection(lang)).toBe(direction)); 29 | }); 30 | 31 | describe('parentLang', () => { 32 | it.each([ 33 | ['eng', 'eng'], 34 | ['eng_US', 'eng'], 35 | ])('maps %s to %s', (lang, parent) => expect(parentLang(lang)).toBe(parent)); 36 | }); 37 | 38 | describe('isVariant', () => { 39 | it.each([ 40 | ['eng', false], 41 | ['eng_US', true], 42 | ])('maps %s to %s', (lang, variant) => expect(isVariant(lang)).toBe(variant)); 43 | }); 44 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "plugins": ["@typescript-eslint", "react-hooks", "jsx-a11y", "jest"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:react-hooks/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 13 | "plugin:jest/recommended", 14 | "plugin:jsx-a11y/strict" 15 | ], 16 | "settings": { 17 | "react": { 18 | "version": "detect" 19 | } 20 | }, 21 | "rules": { 22 | "eqeqeq": ["error", "smart"], 23 | "complexity": ["error", 40], 24 | "no-magic-numbers": ["error", { "ignore": [-1, 0, 1, 2, 3, 4, 10, 100, 200, 1000] }], 25 | "sort-imports": [ 26 | "error", 27 | { 28 | "allowSeparatedGroups": true 29 | } 30 | ], 31 | "spaced-comment": "error", 32 | "react/jsx-sort-props": "error", 33 | "react/self-closing-comp": "error", 34 | "jsx-a11y/no-autofocus": "off" 35 | }, 36 | "overrides": [ 37 | { 38 | "files": ["*.test.ts", "*.test.tsx"], 39 | "rules": { 40 | "no-magic-numbers": "off", 41 | "@typescript-eslint/no-unnecessary-type-assertion": "off" 42 | } 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - name: Checkout repo 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version-file: .node-version 16 | - name: Install dependencies 17 | uses: bahmutov/npm-install@v1 18 | 19 | - name: Typecheck 20 | run: yarn tsc 21 | - name: Lint scripts 22 | run: yarn eslint 23 | - name: Lint styles 24 | run: yarn stylelint 25 | - name: Prettier 26 | run: yarn prettier 27 | test: 28 | runs-on: ubuntu-22.04 29 | steps: 30 | - name: Checkout repo 31 | uses: actions/checkout@v3 32 | 33 | - name: Setup Node 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version-file: .node-version 37 | - name: Install dependencies 38 | uses: bahmutov/npm-install@v1 39 | 40 | - name: Collect test coverage 41 | run: yarn coverage --ci 42 | - name: Upload coverage 43 | uses: coverallsapp/github-action@master 44 | with: 45 | github-token: ${{ secrets.GITHUB_TOKEN }} 46 | path-to-lcov: ./coverage/lcov.info 47 | 48 | - name: Check strings 49 | run: make -C src/strings test 50 | -------------------------------------------------------------------------------- /src/strings/README.md: -------------------------------------------------------------------------------- 1 | Localization 2 | ============ 3 | 4 | This directory contains the JSON files powering Html-tools' localization as well as some helpful utilities. `locales.json` contains a reference to each JSON strings file as well as each language's endonym. Localization instructions are available on the [Apertium Wiki](https://wiki.apertium.org/wiki/Apertium-html-tools). 5 | 6 | After editing localizations, please run `make` and commit its changes. 7 | 8 | 9 | | code | CBE* | CBC** | 10 | |------|------|-------| 11 | | ukr | 100% | 101.41% | 12 | | eng | 100% | 100.00% | 13 | | arg | 99% | 110.66% | 14 | | cat | 99% | 110.61% | 15 | | sat | 99% | 107.53% | 16 | | kaa | 99% | 102.73% | 17 | | hin | 99% | 102.66% | 18 | | skr | 99% | 100.09% | 19 | | dan | 99% | 99.82% | 20 | | nob | 99% | 99.69% | 21 | | nno | 99% | 98.57% | 22 | | deu | 95% | 112.44% | 23 | | fra | 95% | 108.54% | 24 | | glg | 95% | 106.96% | 25 | | frp | 95% | 106.01% | 26 | | tat | 95% | 103.28% | 27 | | tur | 95% | 102.09% | 28 | | szl | 95% | 100.09% | 29 | | mar | 95% | 99.47% | 30 | | fin | 95% | 93.59% | 31 | | ara | 95% | 89.08% | 32 | | kir | 84% | 45.06% | 33 | | srd | 80% | 89.80% | 34 | | uzb | 79% | 90.20% | 35 | | swe | 79% | 84.74% | 36 | | rus | 79% | 80.80% | 37 | | tha | 75% | 74.23% | 38 | | spa | 70% | 26.56% | 39 | | oci | 66% | 82.29% | 40 | | uig | 66% | 77.01% | 41 | | kaz | 66% | 76.30% | 42 | | heb | 66% | 67.69% | 43 | | sme | 55% | 14.53% | 44 | | por | 48% | 12.18% | 45 | | eus | 44% | 12.97% | 46 | | ava | 42% | 12.33% | 47 | | zho | 42% | 3.83% | 48 | | ron | 39% | 8.92% | 49 | 50 | \*CBE: completion by entries
51 | \**CBC: completion by characters (i.e., ratio of characters to English ~source) 52 | -------------------------------------------------------------------------------- /src/testSetup.ts: -------------------------------------------------------------------------------- 1 | import engStrings from './strings/eng.json'; 2 | 3 | const defaultStrings: Record = {}; 4 | Object.keys(engStrings).forEach((key) => (defaultStrings[key] = `${key}-Default`)); 5 | // eslint-disable-next-line 6 | (defaultStrings as any)['@langNames'] = { eng: 'English-Default' }; 7 | defaultStrings['Maintainer'] = '{{maintainer}}-Default'; 8 | 9 | // eslint-disable-next-line 10 | (window as any).PRELOADED_STRINGS = { eng: defaultStrings }; 11 | 12 | // eslint-disable-next-line 13 | (window as any).PAIRS = [ 14 | { sourceLanguage: 'eng', targetLanguage: 'cat' }, 15 | { sourceLanguage: 'eng', targetLanguage: 'spa' }, 16 | { sourceLanguage: 'cat', targetLanguage: 'eng' }, 17 | { sourceLanguage: 'cat', targetLanguage: 'spa' }, 18 | { sourceLanguage: 'spa', targetLanguage: 'eng' }, 19 | { sourceLanguage: 'cat_foo', targetLanguage: 'spa' }, 20 | { sourceLanguage: 'pan_Guru', targetLanguage: 'hin' }, 21 | { sourceLanguage: 'pan_Arab', targetLanguage: 'hin' }, 22 | ]; 23 | 24 | // eslint-disable-next-line 25 | (window as any).PAIR_PREFS = { 26 | ['eng-cat']: { foo: { eng: 'foo_pref' }, bar: { cat: 'bar_pref' } }, 27 | }; 28 | 29 | // eslint-disable-next-line 30 | (window as any).ANALYZERS = { eng: 'eng-morph', spa: 'spa-morph' }; 31 | 32 | // eslint-disable-next-line 33 | (window as any).GENERATORS = { eng: 'eng-gener', spa: 'spa-gener' }; 34 | 35 | process.on('unhandledRejection', (err) => { 36 | // eslint-disable-next-line jest/no-jasmine-globals 37 | fail(err); 38 | }); 39 | 40 | Object.defineProperty(window, 'matchMedia', { 41 | writable: true, 42 | value: jest.fn().mockImplementation((query: string) => ({ 43 | matches: true, 44 | media: query, 45 | onchange: null, 46 | addEventListener: jest.fn(), 47 | removeEventListener: jest.fn(), 48 | dispatchEvent: jest.fn(), 49 | })), 50 | }); 51 | -------------------------------------------------------------------------------- /src/util/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // Modeled after https://usehooks.com/useLocalStorage/. 4 | 5 | const storeValue = (key: string, value: T) => { 6 | const serializedValue = JSON.stringify(value); 7 | try { 8 | window.localStorage.setItem(key, serializedValue); 9 | } catch (error) { 10 | console.warn(`Failed to set LocalStorage[${key}] = ${serializedValue}`, error); 11 | } 12 | }; 13 | 14 | export default ( 15 | key: string, 16 | initialValue: T | (() => T), 17 | options?: { 18 | overrideValue?: T | null; 19 | validateValue?: (value: T) => boolean; 20 | }, 21 | ): [T, React.Dispatch>] => { 22 | const { overrideValue, validateValue } = options || {}; 23 | const validateValueFinal = validateValue || (() => true); 24 | 25 | const [stateValue, setStateValue] = React.useState(() => { 26 | if (overrideValue && validateValueFinal(overrideValue)) { 27 | storeValue(key, overrideValue); 28 | return overrideValue; 29 | } 30 | 31 | try { 32 | const item = window.localStorage.getItem(key); 33 | if (item) { 34 | const parsedItem = JSON.parse(item) as T; 35 | if (validateValueFinal(parsedItem)) { 36 | return parsedItem; 37 | } else { 38 | console.warn(`Initial value (${item}) for LocalStorage[${key}] failed validation`); 39 | } 40 | } 41 | } catch (error) { 42 | console.warn(`Failed to parse LocalStorage[${key}]`, error); 43 | } 44 | 45 | const value = initialValue instanceof Function ? initialValue() : initialValue; 46 | storeValue(key, value); 47 | return value; 48 | }); 49 | 50 | const setValue = (value: T | ((val: T) => T)) => { 51 | const finalValue = value instanceof Function ? value(stateValue) : value; 52 | setStateValue(finalValue); 53 | storeValue(key, finalValue); 54 | }; 55 | 56 | return [stateValue, setValue]; 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/LocaleSelector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import classNames from 'classnames'; 4 | import { faGlobe } from '@fortawesome/free-solid-svg-icons'; 5 | import { useMatomo } from '@datapunt/matomo-tracker-react'; 6 | 7 | import { LocaleContext } from '../context'; 8 | import { langDirection } from '../util/languages'; 9 | import locales from '../strings/locales.json'; 10 | 11 | const height = '1.5rem'; 12 | 13 | const LocaleSelector = ({ 14 | setLocale, 15 | inverse, 16 | className = '', 17 | }: { 18 | inverse?: boolean; 19 | className?: string; 20 | setLocale: React.Dispatch>; 21 | }): React.ReactElement => { 22 | const locale = React.useContext(LocaleContext); 23 | const { trackEvent } = useMatomo(); 24 | 25 | const onChange = React.useCallback( 26 | ({ target: { value } }: React.ChangeEvent) => { 27 | trackEvent({ category: 'localization', action: 'localize', name: value }); 28 | setLocale(value); 29 | }, 30 | [setLocale, trackEvent], 31 | ); 32 | 33 | return ( 34 |
35 | 36 | {/* eslint-disable-next-line jsx-a11y/no-onchange */} 37 | 48 |
49 | ); 50 | }; 51 | 52 | export default LocaleSelector; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apertium-html-tools", 3 | "version": "5.1.8", 4 | "author": "Sushain Cherivirala ", 5 | "license": "GPL-3.0-or-later", 6 | "scripts": { 7 | "build": "ts-node build.ts", 8 | "serve": "python3 -m http.server --directory dist", 9 | "tsc": "tsc --noEmit", 10 | "eslint": "eslint . --ext .ts,.tsx --max-warnings 0", 11 | "stylelint": "stylelint 'src/**/*.css' --max-warnings 0", 12 | "test": "jest", 13 | "prettier": "prettier . --check", 14 | "verify": "$npm_execpath run tsc && $npm_execpath run eslint && $npm_execpath stylelint && $npm_execpath run prettier && $npm_execpath run test", 15 | "coverage": "jest --silent --coverage" 16 | }, 17 | "dependencies": { 18 | "@datapunt/matomo-tracker-react": "^0.5.1", 19 | "@fortawesome/fontawesome-svg-core": "^6.1.1", 20 | "@fortawesome/free-solid-svg-icons": "^6.1.1", 21 | "@fortawesome/react-fontawesome": "^0.1.14", 22 | "axios": "^0.28.0", 23 | "bootstrap": "^4.6.0", 24 | "classnames": "^2.3.1", 25 | "query-string": "^7.1.1", 26 | "react": "^17.0.2", 27 | "react-bootstrap": "^1.5.2", 28 | "react-dom": "^17.0.2", 29 | "react-router-bootstrap": "^0.25.0", 30 | "react-router-dom": "^5.2.0" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/dom": "^8.11.1", 34 | "@testing-library/react": "^12.1.2", 35 | "@testing-library/react-hooks": "^7.0.2", 36 | "@testing-library/user-event": "^13.1.4", 37 | "@types/jest": "^26.0.22", 38 | "@types/node": "^14.14.37", 39 | "@types/react-dom": "^17.0.3", 40 | "@types/react-router-bootstrap": "^0.24.5", 41 | "@types/react-router-dom": "^5.1.7", 42 | "@typescript-eslint/eslint-plugin": "^5.9.0", 43 | "@typescript-eslint/parser": "^5.9.0", 44 | "esbuild": "^0.14.0", 45 | "esbuild-jest": "^0.4.0", 46 | "eslint": "^8.6.0", 47 | "eslint-plugin-jest": "^25.3.4", 48 | "eslint-plugin-jsx-a11y": "^6.5.1", 49 | "eslint-plugin-react": "^7.28.0", 50 | "eslint-plugin-react-hooks": "^4.3.0", 51 | "identity-obj-proxy": "^3.0.0", 52 | "jest": "^26.6.3", 53 | "jest-mock-axios": "^4.4.0", 54 | "prettier": "^2.5.1", 55 | "stylelint": "^15.10.1", 56 | "stylelint-config-prettier": "^9.0.3", 57 | "stylelint-config-standard": "^25.0.0", 58 | "ts-node": "^10.4.0", 59 | "typescript": "^4.2.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/strings/ron.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "shopskasalata" 5 | ], 6 | "last-updated": "2014-01-27", 7 | "locale": [ 8 | "ro", 9 | "ron" 10 | ], 11 | "completion": " 39% 8.92%", 12 | "missing": [ 13 | "About", 14 | "About_Apertium", 15 | "About_Title", 16 | "Apertium_Documentation", 17 | "Apertium_Downloads", 18 | "Cancel", 19 | "Contact", 20 | "Contact_Para", 21 | "Contact_Us", 22 | "Documentation", 23 | "Documentation_Para", 24 | "Download", 25 | "Downloads_Para", 26 | "Drop_Document", 27 | "Enable_JS_Warning", 28 | "File_Too_Large", 29 | "Format_Not_Supported", 30 | "Install_Apertium", 31 | "Install_Apertium_Para", 32 | "Instant_Translation", 33 | "Maintainer", 34 | "Mark_Unknown_Words", 35 | "More_Languages", 36 | "Morphological_Analysis_Help", 37 | "Morphological_Generation_Help", 38 | "Multi_Step_Translation", 39 | "Norm_Preferences", 40 | "Not_Found_Error", 41 | "Supported_Formats", 42 | "Translate_Document", 43 | "Translate_Webpage", 44 | "Translation_Help", 45 | "What_Is_Apertium", 46 | "description" 47 | ] 48 | }, 49 | "title": "Apertium | O platformă liberă de traducere automată", 50 | "tagline": "O platformă liberă de traducere automată", 51 | "Translation": "Traducere", 52 | "Translate": "Traducere", 53 | "Detect_Language": "Detectarea limbii", 54 | "detected": "detectată", 55 | "Not_Available": "Traducerea nu este încă disponibilă!", 56 | "Morphological_Analysis": "Analiză morfologică", 57 | "Analyze": "Analizare", 58 | "Morphological_Generation": "Generare morfologică", 59 | "Generate": "Generare", 60 | "Spell_Checker": "Corector ortografic", 61 | "APy_Sandbox": "Cutie cu nisip APy", 62 | "APy_Sandbox_Help": "Trimiteți solciitări arbitrare", 63 | "APy_Request": "Solicitare APy", 64 | "Request": "Solicitare", 65 | "Language": "Limbă", 66 | "Input_Text": "Text de intrare", 67 | "Notice_Mistake": "Ați observat vreo greșeală?", 68 | "Help_Improve": "Ajutați-ne să îmbunătățim Apertium!" 69 | } 70 | -------------------------------------------------------------------------------- /src/strings/zho.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "comment": "Note to future contributors: For phrases, I may have translated them wrongly. Do double-check the translations for phrases. Sentences should be fine. I have included a couple of partial translations that may help to save time.", 4 | "authors": [ 5 | "wei2912" 6 | ], 7 | "last-updated": "2014-05-11", 8 | "locale": [ 9 | "zh", 10 | "zho" 11 | ], 12 | "completion": " 42% 3.83%", 13 | "missing": [ 14 | "APy_Request", 15 | "APy_Sandbox", 16 | "APy_Sandbox_Help", 17 | "Apertium_Documentation", 18 | "Apertium_Downloads", 19 | "Cancel", 20 | "Contact_Para", 21 | "Documentation_Para", 22 | "Downloads_Para", 23 | "Drop_Document", 24 | "Enable_JS_Warning", 25 | "File_Too_Large", 26 | "Format_Not_Supported", 27 | "Input_Text", 28 | "Install_Apertium", 29 | "Install_Apertium_Para", 30 | "Maintainer", 31 | "Mark_Unknown_Words", 32 | "More_Languages", 33 | "Morphological_Analysis_Help", 34 | "Morphological_Generation_Help", 35 | "Multi_Step_Translation", 36 | "Norm_Preferences", 37 | "Not_Found_Error", 38 | "Supported_Formats", 39 | "Translate_Document", 40 | "Translate_Webpage", 41 | "Translation_Help", 42 | "What_Is_Apertium", 43 | "description", 44 | "tagline", 45 | "title" 46 | ] 47 | }, 48 | "Translation": "翻译", 49 | "Translate": "翻译", 50 | "Detect_Language": "自动检测", 51 | "detected": "检测到的语言", 52 | "Instant_Translation": "实时翻译", 53 | "Not_Available": "翻译还没有完成!", 54 | "Morphological_Analysis": "形态分析", 55 | "Analyze": "分析", 56 | "Morphological_Generation": "形态生成", 57 | "Generate": "生成", 58 | "Spell_Checker": "拼写检查器", 59 | "Request": "请求", 60 | "Language": "语言", 61 | "Notice_Mistake": "发现了任何错误?", 62 | "Help_Improve": "帮助我们改进Apertium!", 63 | "Contact_Us": "如果您发现了任何错误,希望我们work on a specific project,或者想要为Apertium做出贡献,请通知我们。", 64 | "About_Title": "关于这个网站", 65 | "About": "关于Apertium", 66 | "Download": "下载", 67 | "Contact": "联络", 68 | "Documentation": "文档", 69 | "About_Apertium": "关于Apertium" 70 | } 71 | -------------------------------------------------------------------------------- /src/components/translator/translator.css: -------------------------------------------------------------------------------- 1 | .language-dropdown-button > button { 2 | border-top-left-radius: 0; 3 | border-bottom-left-radius: 0; 4 | } 5 | 6 | .language-button:disabled { 7 | color: #808080; 8 | background-color: #474949; 9 | border-color: #474949; 10 | opacity: 1; 11 | } 12 | 13 | .clear-text-button, 14 | .copy-text-button { 15 | background-color: rgb(0 0 0 / 0%); 16 | padding: 0 3px; 17 | top: 3px; 18 | } 19 | 20 | .clear-text-button:hover, 21 | .clear-text-button:focus, 22 | .copy-text-button:hover, 23 | .copy-text-button:focus { 24 | background-color: #eee; 25 | color: #000; 26 | } 27 | 28 | html[dir='ltr'] .clear-text-button, 29 | html[dir='ltr'] .copy-text-button { 30 | right: 18px; 31 | } 32 | 33 | html[dir='rtl'] .clear-text-button, 34 | html[dir='rtl'] .copy-text-button { 35 | left: 18px; 36 | } 37 | 38 | .language-name-col { 39 | float: left; 40 | padding: 0 15px; 41 | position: relative; 42 | } 43 | 44 | .language-name { 45 | display: block; 46 | padding: 0; 47 | overflow-x: hidden; 48 | position: relative; 49 | text-overflow: ellipsis; 50 | border: none; 51 | background: none; 52 | width: 100%; 53 | text-align: unset; 54 | } 55 | 56 | html[dir='ltr'] .language-name { 57 | padding-left: 0.5em; 58 | } 59 | 60 | html[dir='rtl'] .language-name { 61 | padding-right: 0.5em; 62 | text-align: right; 63 | } 64 | 65 | html[dir='rtl'] .language-button:first-child { 66 | border-radius: 0 0.2rem 0.2rem 0 !important; 67 | } 68 | 69 | html[dir='rtl'] .language-dropdown-button > button { 70 | border-radius: 0.2rem 0 0 0.2rem; 71 | } 72 | 73 | .language-name:hover:not(.text-muted) { 74 | background-color: #446e9b; 75 | color: #fff; 76 | cursor: pointer; 77 | } 78 | 79 | .variant-language-name { 80 | font-size: 0.8em; 81 | } 82 | 83 | html[dir='ltr'] .variant-language-name { 84 | padding-left: 2.5em; 85 | } 86 | 87 | html[dir='rtl'] .variant-language-name { 88 | padding-right: 2.5em; 89 | } 90 | 91 | html[dir='ltr'] .translation-modes > :not(:last-child), 92 | html[dir='rtl'] .translation-modes > :not(:first-child) { 93 | margin-right: 0.5rem; 94 | } 95 | 96 | .translated-webpage { 97 | border: 1px solid #ccc; 98 | border-radius: 4px; 99 | height: 700px; 100 | } 101 | 102 | html[dir='ltr'] .translation-text-input { 103 | padding-right: 1.85rem; 104 | } 105 | 106 | html[dir='rtl'] .translation-text-input { 107 | padding-left: 1.85rem; 108 | } 109 | -------------------------------------------------------------------------------- /src/strings/eus.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [], 4 | "last-updated": "", 5 | "locale": [ 6 | "eus" 7 | ], 8 | "completion": " 44% 12.97%", 9 | "missing": [ 10 | "About", 11 | "About_Apertium", 12 | "Apertium_Documentation", 13 | "Apertium_Downloads", 14 | "Cancel", 15 | "Contact", 16 | "Contact_Para", 17 | "Documentation", 18 | "Documentation_Para", 19 | "Download", 20 | "Downloads_Para", 21 | "Drop_Document", 22 | "Enable_JS_Warning", 23 | "File_Too_Large", 24 | "Format_Not_Supported", 25 | "Install_Apertium", 26 | "Install_Apertium_Para", 27 | "Instant_Translation", 28 | "Mark_Unknown_Words", 29 | "More_Languages", 30 | "Morphological_Analysis_Help", 31 | "Morphological_Generation_Help", 32 | "Multi_Step_Translation", 33 | "Norm_Preferences", 34 | "Not_Found_Error", 35 | "Supported_Formats", 36 | "Translate_Document", 37 | "Translate_Webpage", 38 | "Translation_Help", 39 | "What_Is_Apertium", 40 | "description" 41 | ] 42 | }, 43 | "title": "Apertium | Kode irekiko itzulpen automatiko plataforma librea", 44 | "tagline": "Kode irekiko itzulpen automatiko plataforma librea", 45 | "Translation": "Itzulpena", 46 | "Translate": "Itzuli", 47 | "Detect_Language": "Hizkuntza detektatu", 48 | "detected": "detektatua", 49 | "Not_Available": "Itzulpena ez dago oraindik eskuragarri!", 50 | "Morphological_Analysis": "Analisi morfologikoa", 51 | "Analyze": "Analizatu", 52 | "Morphological_Generation": "Sorkuntza morfologikoa", 53 | "Generate": "Sorkuntza", 54 | "Spell_Checker": "Zuzentzaile ortografikoa", 55 | "APy_Sandbox": "APy Sandbox", 56 | "APy_Sandbox_Help": "Eskaera arbitrarioak bidali", 57 | "APy_Request": "APy Eskaera", 58 | "Request": "Eskaera", 59 | "Language": "Hizkuntza", 60 | "Input_Text": "Sarrera testua", 61 | "Notice_Mistake": "Akats bat bilatu duzu?", 62 | "Help_Improve": "Lagun iezaiguzu Apertium hobetzen!", 63 | "Contact_Us": "Akats bat bilatzemn baduzu, proiektu bat guk lantzea nahi baduzu, edo lagundu nahi ba diguzu, kontakta gaitzazu.", 64 | "Maintainer": "Webgune hau {{maintainer}}-ek mantentzen du.", 65 | "About_Title": "Webgune honetaz" 66 | } 67 | -------------------------------------------------------------------------------- /src/strings/ava.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "solinus" 5 | ], 6 | "last-updated": "2014-04-12", 7 | "locale": [ 8 | "av", 9 | "ava" 10 | ], 11 | "completion": " 42% 12.33%", 12 | "missing": [ 13 | "About", 14 | "About_Apertium", 15 | "Apertium_Documentation", 16 | "Apertium_Downloads", 17 | "Cancel", 18 | "Contact", 19 | "Contact_Para", 20 | "Documentation", 21 | "Documentation_Para", 22 | "Download", 23 | "Downloads_Para", 24 | "Drop_Document", 25 | "Enable_JS_Warning", 26 | "File_Too_Large", 27 | "Format_Not_Supported", 28 | "Install_Apertium", 29 | "Install_Apertium_Para", 30 | "Instant_Translation", 31 | "Mark_Unknown_Words", 32 | "More_Languages", 33 | "Morphological_Analysis_Help", 34 | "Morphological_Generation_Help", 35 | "Multi_Step_Translation", 36 | "Norm_Preferences", 37 | "Not_Available", 38 | "Not_Found_Error", 39 | "Supported_Formats", 40 | "Translate_Document", 41 | "Translate_Webpage", 42 | "Translation_Help", 43 | "What_Is_Apertium", 44 | "description" 45 | ] 46 | }, 47 | "title": "Апертиум | Машинияб таржамалъул эркенаб/рагьараб тӏагӏел", 48 | "tagline": "Машинияб таржамалъул эркенаб/рагьараб тӏагӏел", 49 | "Translation": "Таржама", 50 | "Translate": "Таржама гьабе", 51 | "Detect_Language": "Мацӏ батӏа бахъизабе", 52 | "detected": "батӏа бахъизабун буго", 53 | "Morphological_Analysis": "Морфологияб анализ", 54 | "Analyze": "Анализ гьабе", 55 | "Morphological_Generation": "Морфологияб синтез", 56 | "Generate": "Синтез гьабе", 57 | "Spell_Checker": "Битӏунхъваялъул хал-шал", 58 | "APy_Sandbox": "APy расанизари", 59 | "APy_Sandbox_Help": "Бокьараб гьикъи битӏе", 60 | "APy_Request": "APy тӏалаб гьабе", 61 | "Request": "ТӀалаб гьабе", 62 | "Language": "Мацӏ", 63 | "Input_Text": "Жаниб бачулеб текст", 64 | "Notice_Mistake": "Гъалатӏ батанищ?", 65 | "Help_Improve": "Апертиум лъиклъизе кумек гьабе!", 66 | "Contact_Us": "Гьара-рахьи ратани, гъалатӏ батун батани яги кумек гьабизе бокьун батани, хъвай нижехе.", 67 | "Maintainer": "Гьаб сайталъул кверчӏвай ва тадбир гьабулеб буго {{maintainer}}.", 68 | "About_Title": "Гьаб сайталъул хӏакъалъулъ" 69 | } 70 | -------------------------------------------------------------------------------- /src/strings/por.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "hbwashington", 5 | "sushain97" 6 | ], 7 | "last-updated": "2014-05-11", 8 | "locale": [ 9 | "pt", 10 | "por" 11 | ], 12 | "completion": " 48% 12.18%", 13 | "missing": [ 14 | "About_Apertium", 15 | "Apertium_Documentation", 16 | "Apertium_Downloads", 17 | "Cancel", 18 | "Contact", 19 | "Contact_Para", 20 | "Documentation", 21 | "Documentation_Para", 22 | "Download", 23 | "Downloads_Para", 24 | "Drop_Document", 25 | "Enable_JS_Warning", 26 | "File_Too_Large", 27 | "Format_Not_Supported", 28 | "Install_Apertium", 29 | "Install_Apertium_Para", 30 | "Mark_Unknown_Words", 31 | "More_Languages", 32 | "Morphological_Analysis_Help", 33 | "Morphological_Generation_Help", 34 | "Multi_Step_Translation", 35 | "Norm_Preferences", 36 | "Not_Found_Error", 37 | "Supported_Formats", 38 | "Translate_Document", 39 | "Translate_Webpage", 40 | "Translation_Help", 41 | "What_Is_Apertium", 42 | "description" 43 | ] 44 | }, 45 | "title": "Apertium | Uma plataforma livre para a tradução automática", 46 | "tagline": "Plataforma livre para a tradução automática", 47 | "Translation": "Tradução", 48 | "Translate": "Traduzir", 49 | "Detect_Language": "Detectar língua", 50 | "detected": "detectada", 51 | "Instant_Translation": "tradução instantânea", 52 | "Not_Available": "Tradução aina não disponível!", 53 | "Morphological_Analysis": "Análise morfológica", 54 | "Analyze": "Analisar!", 55 | "Morphological_Generation": "Geração morfológica", 56 | "Generate": "Gerar", 57 | "Spell_Checker": "Corretor ortográfico", 58 | "APy_Sandbox": "Entorno de provas", 59 | "APy_Sandbox_Help": "Envie qualquer consulta", 60 | "APy_Request": "Consulta de APy", 61 | "Request": "Consulta", 62 | "Language": "Língua", 63 | "Input_Text": "Texto de entrada", 64 | "Notice_Mistake": "Encontrou um erro?", 65 | "Help_Improve": "Ajude-nos a melhorar Apertium!", 66 | "Contact_Us": "Contate-nos se encontrar um erro, pensar um projeto para nós fazermos, ou quiser ajudar.", 67 | "Maintainer": "Este website é mantido por {{maintainer}}.", 68 | "About_Title": "Sobre este Website", 69 | "About": "Sobre" 70 | } 71 | -------------------------------------------------------------------------------- /src/util/localization.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { LocaleContext, StringsContext } from '../context'; 4 | import { PRELOADED_STRINGS, Strings } from './strings'; 5 | import { languages, toAlpha2Code, toAlpha3Code } from './languages'; 6 | import Config from '../../config'; 7 | import locales from '../strings/locales.json'; 8 | 9 | export { PRELOADED_STRINGS }; 10 | export type { Strings }; 11 | 12 | const defaultStrings = PRELOADED_STRINGS[Config.defaultLocale]; 13 | 14 | const t = (locale: string, strings: Record): ((id: string) => string) => { 15 | return (id: string) => tt(id, locale, strings); 16 | }; 17 | 18 | export const tt = (id: string, locale: string, strings: Record): string => { 19 | const localeStrings = strings[locale]; 20 | 21 | let translated = defaultStrings[id] || id; 22 | if (localeStrings) { 23 | const localeString = localeStrings[id]; 24 | if (localeString && !localeString.startsWith('%%UNAVAILABLE%%')) { 25 | translated = localeString; 26 | } 27 | } 28 | 29 | Object.entries(Config.stringReplacements).forEach(([placeholder, replacement]) => { 30 | translated = translated.replace(placeholder, replacement); 31 | }); 32 | 33 | return translated; 34 | }; 35 | 36 | const tLang = (locale: string, strings: Record) => { 37 | return (id: string) => ttLang(id, locale, strings); 38 | }; 39 | 40 | const ttLang = (code: string, locale: string, strings: Record): string => { 41 | const alpha2Code = toAlpha2Code(code); 42 | 43 | const localeNames = strings[locale] && strings[locale]['@langNames']; 44 | if (localeNames) { 45 | const localeName = localeNames[code] || (alpha2Code && localeNames[alpha2Code]); 46 | if (localeName) { 47 | return localeName; 48 | } 49 | } 50 | 51 | const defaultNames = defaultStrings['@langNames']; 52 | if (defaultNames) { 53 | const defaultName = defaultNames[code] || (alpha2Code && defaultNames[alpha2Code]); 54 | if (defaultName) { 55 | return defaultName; 56 | } 57 | } 58 | 59 | return (alpha2Code && languages[alpha2Code]) || code; 60 | }; 61 | 62 | export const useLocalization = (): { t: (id: string) => string; tLang: (code: string) => string } => { 63 | const strings = React.useContext(StringsContext); 64 | const locale = React.useContext(LocaleContext); 65 | 66 | return React.useMemo(() => ({ t: t(locale, strings), tLang: tLang(locale, strings) }), [strings, locale]); 67 | }; 68 | 69 | export const validLocale = (code: string): boolean => { 70 | const alpha3Code = toAlpha3Code(code); 71 | return alpha3Code != null && alpha3Code in locales; 72 | }; 73 | -------------------------------------------------------------------------------- /src/strings/sme.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "Lene Antonsen", 5 | "unhammer" 6 | ], 7 | "last-updated": "2014-05-18", 8 | "locale": [ 9 | "se", 10 | "sme" 11 | ], 12 | "completion": " 55% 14.53%", 13 | "missing": [ 14 | "About_Apertium", 15 | "Apertium_Documentation", 16 | "Apertium_Downloads", 17 | "Contact_Para", 18 | "Documentation_Para", 19 | "Downloads_Para", 20 | "Drop_Document", 21 | "Enable_JS_Warning", 22 | "File_Too_Large", 23 | "Format_Not_Supported", 24 | "Install_Apertium", 25 | "Install_Apertium_Para", 26 | "Instant_Translation", 27 | "Mark_Unknown_Words", 28 | "More_Languages", 29 | "Morphological_Analysis_Help", 30 | "Morphological_Generation_Help", 31 | "Multi_Step_Translation", 32 | "Norm_Preferences", 33 | "Not_Found_Error", 34 | "Supported_Formats", 35 | "Translate_Document", 36 | "Translation_Help", 37 | "What_Is_Apertium", 38 | "description" 39 | ] 40 | }, 41 | "title": "Apertium | Friddja ja rabasgáldokodavuđot dihtorjorgalanvuogádat", 42 | "tagline": "Friddja ja rabasgáldokodavuđot dihtorjorgalanvuogádat", 43 | "Translation": "Jorgaleapmi", 44 | "Translate": "Jorgal", 45 | "Detect_Language": "Dovdá giela", 46 | "detected": "Árvaluvvon giella", 47 | "Not_Available": "Jorgalus ii gávdno!", 48 | "Cancel": "Gaskkalduhte", 49 | "Translate_Webpage": "Jorgal neahttasiiddu", 50 | "Morphological_Analysis": "Morfologalaš analysa", 51 | "Analyze": "Analysere", 52 | "Morphological_Generation": "Morfologalaš genereren", 53 | "Generate": "Generere", 54 | "Spell_Checker": "Sátnedárkkisteapmi", 55 | "APy_Sandbox": "APy-sáttokássa", 56 | "APy_Sandbox_Help": "Gohččomiid geavaheapmi", 57 | "APy_Request": "APy-gohččun", 58 | "Request": "Gohččun", 59 | "Language": "Giella", 60 | "Input_Text": "Álgoteaksta", 61 | "Notice_Mistake": "Gávdnetgo meattáhusa?", 62 | "Help_Improve": "Veahket min buoridit Apertiuma!", 63 | "Contact_Us": "Váldde áinnas oktavuođa minguin jus gávnnat meattáhusa, jus dus lea miella bargat prošeavttain dahje don eará ládje háliidat min veahkehit.", 64 | "Maintainer": "Dán neahttabáikki fuolaha {{maintainer}}.", 65 | "About_Title": "Neahttabáikki birra", 66 | "About": "Birra", 67 | "Download": "Viežžat", 68 | "Contact": "Váldde oktavuođa", 69 | "Documentation": "Dokumentašuvdna" 70 | } 71 | -------------------------------------------------------------------------------- /src/components/__tests__/WithLocale.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cleanup, render, screen, waitFor } from '@testing-library/react'; 3 | import mockAxios from 'jest-mock-axios'; 4 | 5 | import Config from '../../../config'; 6 | import { LocaleContext } from '../../context'; 7 | import WithLocale from '../WithLocale'; 8 | 9 | const setLocation = (location: string) => window.history.replaceState({}, '', location); 10 | 11 | afterEach(() => setLocation('/')); 12 | 13 | const ShowLocale: React.FunctionComponent<{ setLocale: React.Dispatch> }> = () => ( 14 |
{React.useContext(LocaleContext)}
15 | ); 16 | 17 | const renderWithLocale = () => render({(props) => }); 18 | 19 | describe('default locale selection', () => { 20 | it('restores from browser state', () => { 21 | setLocation('/index.heb.html'); 22 | renderWithLocale(); 23 | 24 | cleanup(); 25 | 26 | setLocation('/'); 27 | renderWithLocale(); 28 | 29 | expect(screen.getByRole('main').textContent).toEqual('heb'); 30 | }); 31 | 32 | it('prefers lang parameter over url path', () => { 33 | setLocation('/index.heb.html?lang=spa'); 34 | renderWithLocale(); 35 | expect(screen.getByRole('main').textContent).toEqual('spa'); 36 | }); 37 | 38 | it('prefers url path over browser storage', () => { 39 | setLocation('/index.heb.html'); 40 | renderWithLocale(); 41 | 42 | cleanup(); 43 | 44 | setLocation('/index.spa.html'); 45 | renderWithLocale(); 46 | expect(screen.getByRole('main').textContent).toEqual('spa'); 47 | }); 48 | 49 | describe('fetching locale from APy', () => { 50 | it('uses valid response', async () => { 51 | renderWithLocale(); 52 | 53 | mockAxios.mockResponse({ data: ['es'] }); 54 | await waitFor(() => 55 | expect(mockAxios.post).toHaveBeenCalledWith(expect.stringContaining('getLocale'), '', expect.anything()), 56 | ); 57 | 58 | expect(screen.getByRole('main').textContent).toEqual('spa'); 59 | }); 60 | 61 | it('handles variants', () => { 62 | renderWithLocale(); 63 | 64 | mockAxios.mockResponse({ data: ['en-US'] }); 65 | 66 | expect(screen.getByRole('main').textContent).toEqual('eng'); 67 | }); 68 | 69 | it('falls back to default for unknown response', () => { 70 | renderWithLocale(); 71 | 72 | mockAxios.mockResponse({ data: ['zzz'] }); 73 | 74 | expect(screen.getByRole('main').textContent).toEqual(Config.defaultLocale); 75 | }); 76 | 77 | it('falls back to default on failure', () => { 78 | renderWithLocale(); 79 | 80 | mockAxios.mockError(); 81 | 82 | expect(screen.getByRole('main').textContent).toEqual(Config.defaultLocale); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/components/WithLocale.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ConfigContext, LocaleContext } from '../context'; 4 | import { apyFetch } from '../util'; 5 | import { getUrlParam } from '../util/url'; 6 | import { toAlpha3Code } from '../util/languages'; 7 | import useLocalStorage from '../util/useLocalStorage'; 8 | import { validLocale } from '../util/localization'; 9 | 10 | const loadBrowserLocale = (apyURL: string, setLocale: React.Dispatch>) => { 11 | void (async () => { 12 | let locales: Array; 13 | try { 14 | locales = (await apyFetch(`${apyURL}/getLocale`)[1]).data as Array; 15 | } catch (error) { 16 | console.warn('Failed to fetch browser locale, falling back to default', error); 17 | return; 18 | } 19 | 20 | for (let localeGuess of locales) { 21 | if (localeGuess.indexOf('-') !== -1) { 22 | localeGuess = localeGuess.split('-')[0]; 23 | } 24 | 25 | const locale = toAlpha3Code(localeGuess); 26 | if (validLocale(locale)) { 27 | setLocale(locale); 28 | } 29 | } 30 | })(); 31 | }; 32 | 33 | const WithLocale = ({ 34 | children, 35 | }: { 36 | children: (props: { setLocale: React.Dispatch> }) => React.ReactNode; 37 | }): React.ReactElement => { 38 | const { apyURL, defaultLocale } = React.useContext(ConfigContext); 39 | 40 | // Locale selection priority: 41 | // 1. `lang` parameter from URL 42 | // 2. locale section from URL path 43 | // 3. `locale` key from LocalStorage 44 | // 4. browser's preferred locale from APy 45 | const urlPathMatch = /index\.(\w{3})\.html/.exec(window.location.pathname); 46 | const urlPathLocale = urlPathMatch && urlPathMatch[1]; 47 | const langParam = getUrlParam(window.location.search, 'lang'); 48 | const urlQueryLocale = toAlpha3Code(langParam)?.replace('/', ''); 49 | let shouldLoadBrowserLocale = false; 50 | const [locale, setLocale] = useLocalStorage( 51 | 'locale', 52 | () => { 53 | shouldLoadBrowserLocale = true; 54 | return defaultLocale; 55 | }, 56 | { overrideValue: urlQueryLocale || urlPathLocale, validateValue: validLocale }, 57 | ); 58 | React.useEffect(() => { 59 | if (shouldLoadBrowserLocale) { 60 | loadBrowserLocale(apyURL, setLocale); 61 | } 62 | }, [shouldLoadBrowserLocale, setLocale, apyURL]); 63 | 64 | React.useEffect(() => { 65 | // We use the real `window.history` here since we intend to modify the real 66 | // URL path, not just the URL hash. 67 | window.history.pushState({}, '', `index.${locale}.html${window.location.search}${window.location.hash}`); 68 | }, [locale]); 69 | 70 | return {children({ setLocale })}; 71 | }; 72 | 73 | export default WithLocale; 74 | -------------------------------------------------------------------------------- /src/util/__tests__/localization.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { LocaleContext, StringsContext } from '../../context'; 5 | import { Strings, useLocalization, validLocale } from '../localization'; 6 | 7 | describe('validLocale', () => { 8 | it.each([ 9 | ['eng', true], 10 | ['en', true], 11 | ['tel', false], 12 | ])('maps %s to %s', (lang, valid) => expect(validLocale(lang)).toBe(valid)); 13 | }); 14 | 15 | describe('useLocalization', () => { 16 | describe('with loaded locale', () => { 17 | const wrapper = ({ children }: { children: React.ReactElement[] }) => ( 18 | 27 | {children} 28 | 29 | ); 30 | const { 31 | result: { 32 | error, 33 | current: { t, tLang }, 34 | }, 35 | } = renderHook(() => useLocalization(), { wrapper }); 36 | 37 | it('does not error', () => expect(error).toBeUndefined()); 38 | 39 | describe('t', () => { 40 | it.each([ 41 | ['Input_Text', 'Input_Text-Default'], // default 42 | ['Translation', 'Traducción'], // present 43 | ['Detect_Language', 'Detect_Language-Default'], // unavailable 44 | ['SomethingMissing', 'SomethingMissing'], // missing 45 | 46 | // replacements 47 | [ 48 | 'Maintainer', 49 | `Apertium-Default`, 50 | ], 51 | ])('maps %s to %s', (id, value) => expect(t(id)).toBe(value)); 52 | }); 53 | 54 | describe('tLang', () => { 55 | it.each([ 56 | ['eng', 'English-Default'], // default 57 | ['sco', 'escocés'], // present 58 | ['fin', 'suomi'], // autonym 59 | ['xyz', 'xyz'], // missing 60 | ])('maps %s to %s', (code, name) => expect(tLang(code)).toBe(name)); 61 | }); 62 | }); 63 | 64 | describe('without loaded locale', () => { 65 | const wrapper = ({ children }: { children: React.ReactElement[] }) => ( 66 | 67 | {children} 68 | 69 | ); 70 | const { 71 | result: { 72 | error, 73 | current: { t, tLang }, 74 | }, 75 | } = renderHook(() => useLocalization(), { wrapper }); 76 | 77 | it('does not error', () => expect(error).toBeUndefined()); 78 | 79 | it('t returns defaults', () => expect(t('Input_Text')).toBe('Input_Text-Default')); 80 | 81 | it('tLang returns defaults', () => expect(tLang('eng')).toBe('English-Default')); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/components/translator/index.ts: -------------------------------------------------------------------------------- 1 | import { parentLang } from '../../util/languages'; 2 | 3 | export type Pairs = Readonly>>; 4 | export type NamedLangs = Array<[string, string]>; 5 | 6 | // eslint-disable-next-line 7 | const rawPairs = (window as any).PAIRS as Array<{ 8 | sourceLanguage: string; 9 | targetLanguage: string; 10 | }>; 11 | export const DirectPairs: Pairs = rawPairs.reduce((pairs, { sourceLanguage, targetLanguage }) => { 12 | pairs[sourceLanguage] = pairs[sourceLanguage] || new Set(); 13 | pairs[sourceLanguage].add(targetLanguage); 14 | 15 | const parent = parentLang(sourceLanguage); 16 | pairs[parent] = pairs[parent] || new Set(); 17 | 18 | return pairs; 19 | }, {} as Record>); 20 | 21 | const getChainedTgtLangs = (srcLang: string) => { 22 | const tgtLangs: Set = new Set(); 23 | 24 | const tgtsSeen = new Set([srcLang]); 25 | let tgsFrontier = [...DirectPairs[srcLang]]; 26 | let tgtLang; 27 | while ((tgtLang = tgsFrontier.pop())) { 28 | if (!tgtsSeen.has(tgtLang)) { 29 | tgtLangs.add(tgtLang); 30 | if (DirectPairs[tgtLang]) { 31 | tgsFrontier = [...tgsFrontier, ...DirectPairs[tgtLang]]; 32 | } 33 | tgtsSeen.add(tgtLang); 34 | } 35 | } 36 | 37 | return tgtLangs; 38 | }; 39 | export const chainedPairs: Record> = {}; 40 | Object.keys(DirectPairs).forEach((srcLang) => { 41 | chainedPairs[srcLang] = getChainedTgtLangs(srcLang); 42 | }); 43 | export const ChainedPairs: Pairs = chainedPairs; 44 | 45 | export const SrcLangs = new Set(Object.keys(DirectPairs)); 46 | 47 | export const TgtLangs = new Set( 48 | ([] as Array).concat(...Object.values(DirectPairs).map((ls) => Array.from(ls))), 49 | ); 50 | TgtLangs.forEach((lang) => { 51 | const parent = parentLang(lang); 52 | if (!TgtLangs.has(parent)) { 53 | TgtLangs.add(parent); 54 | } 55 | }); 56 | 57 | export const isPair = (pairs: Pairs, src: string, tgt: string): boolean => pairs[src] && pairs[src].has(tgt); 58 | 59 | // eslint-disable-next-line 60 | const pairPrefs = (window as any).PAIR_PREFS as Record>>; 61 | 62 | export type PairPrefs = Record; 63 | export type PairPrefValues = Record; 64 | 65 | export const getPairPrefs = (locale: string, srcLang: string, tgtLang: string): PairPrefs => { 66 | const localizedPrefs: Record = {}; 67 | Object.entries(pairPrefs[`${srcLang}-${tgtLang}`] || {}).forEach(([id, prefs]) => { 68 | localizedPrefs[id] = prefs[locale] || Object.values(prefs)[0]; 69 | }); 70 | return localizedPrefs; 71 | }; 72 | 73 | export enum Mode { 74 | Text, 75 | Document, 76 | Webpage, 77 | } 78 | 79 | export const pairUrlParam = 'dir'; 80 | 81 | export const baseUrlParams = ({ srcLang, tgtLang }: { srcLang: string; tgtLang: string }): Record => { 82 | const pair = `${srcLang}-${tgtLang}`; 83 | return { [pairUrlParam]: pair }; 84 | }; 85 | 86 | export const TranslateEvent = 'translate'; 87 | 88 | export const DetectEvent = 'detect-language'; 89 | export const DetectCompleteEvent = 'detect-language-complete'; 90 | -------------------------------------------------------------------------------- /src/components/__tests__/Sandbox.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cleanup, render, screen, waitFor } from '@testing-library/react'; 3 | import { Router } from 'react-router-dom'; 4 | import { createMemoryHistory } from 'history'; 5 | import mockAxios from 'jest-mock-axios'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import Sandbox from '../Sandbox'; 9 | 10 | const input = '/analyse?lang=eng&q=kicked'; 11 | 12 | const renderSandbox = () => 13 | render( 14 | 15 | 16 | , 17 | ); 18 | 19 | const type = (input: string): HTMLTextAreaElement => { 20 | const textbox = screen.getByRole('textbox'); 21 | userEvent.type(textbox, input); 22 | return textbox as HTMLTextAreaElement; 23 | }; 24 | 25 | const submit = () => userEvent.click(screen.getByRole('button')); 26 | 27 | it('allows typing an input', () => { 28 | renderSandbox(); 29 | 30 | const textbox = type(input); 31 | 32 | expect(textbox.value).toBe(input); 33 | }); 34 | 35 | it('persists input in browser storage', () => { 36 | renderSandbox(); 37 | type(input); 38 | cleanup(); 39 | 40 | renderSandbox(); 41 | 42 | const textbox = screen.getByRole('textbox'); 43 | expect((textbox as HTMLSelectElement).value).toBe(input); 44 | }); 45 | 46 | describe('requests', () => { 47 | it('no-ops an empty input', () => { 48 | renderSandbox(); 49 | submit(); 50 | expect(mockAxios.post).not.toBeCalled(); 51 | }); 52 | 53 | it('requests on button click', async () => { 54 | renderSandbox(); 55 | type(input); 56 | submit(); 57 | 58 | mockAxios.mockResponse({ 59 | data: [ 60 | ['kicked/kick/kick', 'kicked'], 61 | ["'/'/'s", "'"], 62 | ], 63 | }); 64 | await waitFor(() => 65 | expect(mockAxios.post).toHaveBeenCalledWith(expect.stringContaining(input), '', expect.anything()), 66 | ); 67 | 68 | const output = screen.getByRole('main'); 69 | expect(output.textContent).toContain('ms'); 70 | expect(output.textContent).toContain('"kicked/kick/kick",'); 71 | }); 72 | 73 | it('requests on enter', async () => { 74 | renderSandbox(); 75 | 76 | type(`${input}{enter}`); 77 | 78 | mockAxios.mockResponse({ data: 'the-response' }); 79 | await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); 80 | 81 | const output = screen.getByRole('main'); 82 | expect(output.textContent).toContain('the-response'); 83 | }); 84 | 85 | it('shows errors', async () => { 86 | renderSandbox(); 87 | type(input); 88 | submit(); 89 | 90 | mockAxios.mockError({ 91 | response: { 92 | data: { status: 'error', code: 400, message: 'Bad Request', explanation: 'That mode is not installed' }, 93 | }, 94 | }); 95 | await waitFor(() => expect(mockAxios.post).toHaveBeenCalledTimes(1)); 96 | 97 | const error = screen.getByRole('alert'); 98 | expect(error.textContent).toMatchInlineSnapshot(`" That mode is not installed"`); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/strings/spa.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "spectre360", 5 | "hbwashington", 6 | "sushain97" 7 | ], 8 | "last-updated": "2014-05-11", 9 | "locale": [ 10 | "es", 11 | "spa" 12 | ], 13 | "completion": " 70% 26.56%", 14 | "missing": [ 15 | "Apertium_Documentation", 16 | "Contact_Para", 17 | "Documentation_Para", 18 | "Downloads_Para", 19 | "Drop_Document", 20 | "Install_Apertium", 21 | "Install_Apertium_Para", 22 | "More_Languages", 23 | "Morphological_Analysis_Help", 24 | "Morphological_Generation_Help", 25 | "Multi_Step_Translation", 26 | "Norm_Preferences", 27 | "Supported_Formats", 28 | "Translate_Webpage", 29 | "Translation_Help", 30 | "What_Is_Apertium", 31 | "description" 32 | ] 33 | }, 34 | "title": "Apertium | Una plataforma libre para la traducción automática", 35 | "tagline": "Plataforma libre para la traducción automática", 36 | "Translation": "Traducción", 37 | "Translate": "Traducir", 38 | "Detect_Language": "Detectar idioma", 39 | "detected": "detectado", 40 | "Instant_Translation": "Traducción instantánea", 41 | "Mark_Unknown_Words": "Marcar palabras desconocidas", 42 | "Translate_Document": "Traducir un documento", 43 | "Not_Available": "Traducción todavía no disponible!", 44 | "File_Too_Large": "¡El fichero es demasiado grande!", 45 | "Cancel": "Cancelar", 46 | "Format_Not_Supported": "Formato desconocido!", 47 | "Morphological_Analysis": "Análisis morfológico", 48 | "Analyze": "Analizar", 49 | "Morphological_Generation": "Generación morfológica", 50 | "Generate": "Generar", 51 | "Spell_Checker": "Corrector ortográfico", 52 | "APy_Sandbox": "Entorno de pruebas", 53 | "APy_Sandbox_Help": "Envia cualquier consulta", 54 | "APy_Request": "Consulta de APy", 55 | "Request": "Consulta", 56 | "Language": "Idioma", 57 | "Input_Text": "Texto de entrada", 58 | "Notice_Mistake": "¿Has encontrado un error?", 59 | "Help_Improve": "Ayúdanos a mejorar Apertium!", 60 | "Contact_Us": "Por favor ponte en contacto con nosotros si encuentras un error en la página, si hay un proyecto que crees que nos podría interesar o si te interesaría colaborar con nosotros.", 61 | "Maintainer": "Esta página está gestionada por {{maintainer}}.", 62 | "About_Title": "Sobre Apertium", 63 | "Enable_JS_Warning": "La web sólo funciona si tienes habilitado JavaScript, si no puedes habilitar JavaSCript, puedes utilizar los traductores de Apertium a Prompsit.", 64 | "Not_Found_Error": "404 Error: Lo sentimos, ¡esta página ya no existe!", 65 | "About": "Acerca de", 66 | "Download": "Descargas", 67 | "Contact": "Contactar", 68 | "Documentation": "Documentación", 69 | "About_Apertium": "Acerca de Apertium", 70 | "Apertium_Downloads": "Descargas de Apertium" 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apertium Html-tools 2 | 3 | [![Build 4 | Status](https://github.com/apertium/apertium-html-tools/workflows/Check/badge.svg?branch=master)](https://github.com/apertium/apertium-html-tools/actions/workflows/check.yml?query=branch%3Amaster) 5 | [![Coverage Status](https://coveralls.io/repos/github/apertium/apertium-html-tools/badge.svg?branch=master)](https://coveralls.io/github/apertium/apertium-html-tools?branch=master) 6 | 7 | [Apertium Html-tools][1] is a web application providing a fully localised 8 | interface for text/document/website translation, analysis, and generation 9 | powered by [Apertium][2]. Html-tools relies on an Apertium HTTP API such as 10 | [Apertium-apy][3] or [ScaleMT][4] (to a lesser extent). More information along 11 | with instructions for localization is available on the [Apertium Wiki][5]. 12 | 13 | ## Configuration 14 | 15 | Configure the build by editing `config.ts`. 16 | 17 | ## Dependencies 18 | 19 | ### Development 20 | 21 | Our sources are written in [TypeScript][6]. 22 | 23 | Development requires installing [Node.js][7] and [Yarn][8]. After installing 24 | both, use `yarn install --dev` to install JavaScript packages. We use 25 | [ESLint][9] & [Stylelint][10] for linting, [Prettier][11] for code formatting 26 | and [Jest][12] as a test runner. 27 | 28 | ### Runtime 29 | 30 | We use a variety of JS libraries at runtime: 31 | 32 | - [React](https://reactjs.org/) 33 | - [React-Bootstrap](https://react-bootstrap.netlify.app/) 34 | - [Font Awesome](https://fontawesome.com/) 35 | - [React Router](https://reactrouter.com/) 36 | 37 | To avoid distributing hundreds of JS files, we use [esbuild][13] to bundle 38 | sources into browser-ready JS. 39 | 40 | ## Building 41 | 42 | First, follow the development instructions. Then, running `yarn build` will 43 | output built bundles to `dist/`. Use `--prod` to minify bundles. Any web server 44 | capable of serving static assets can be pointed directly to `dist/`. 45 | 46 | Alternatively, if you'd like to avoid polluting your host system with build 47 | dependencies, use Docker: 48 | 49 | docker build -t apertium-html-tools . 50 | docker run --rm -v $(pwd)/dist:/root/dist apertium-html-tools 51 | 52 | ## Contributing 53 | 54 | - Use `yarn build --watch` to keep `dist/` up-to-date with new bundles. 55 | - Use `yarn serve` to run a simple Python server which serves `dist/` on 56 | `localhost:8000`. 57 | - Use `yarn verify` to run the typechecker, linters and tests. See 58 | `package.json` for more granular scripts. 59 | 60 | To analyze the bundle size, run a prod build and upload the resulting 61 | `meta.json` file to [Bundle Buddy][14]. 62 | 63 | We use [GitHub Actions][15] to run tests, linting, typechecking, etc. on each 64 | commit. 65 | 66 | [1]: https://wiki.apertium.org/wiki/Apertium-html-tools 67 | [2]: https://apertium.org 68 | [3]: https://wiki.apertium.org/wiki/Apertium-apy 69 | [4]: https://wiki.apertium.org/wiki/ScaleMT 70 | [5]: https://wiki.apertium.org/wiki/Apertium-html-tools 71 | [6]: https://www.typescriptlang.org/ 72 | [7]: https://nodejs.org/en/download/ 73 | [8]: https://classic.yarnpkg.com/en/docs/install 74 | [9]: https://eslint.org/ 75 | [10]: https://stylelint.io/ 76 | [11]: https://prettier.io/ 77 | [12]: https://jestjs.io/ 78 | [13]: https://esbuild.github.io/ 79 | [14]: https://bundle-buddy.com/ 80 | [15]: https://docs.github.com/actions 81 | -------------------------------------------------------------------------------- /src/components/translator/__tests__/WithSortedLanguages.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import WithSortedLanguages, { ChildProps, Props } from '../WithSortedLanguages'; 5 | 6 | const getSortedLangs = (props: Omit): [Array, Array] => { 7 | const childProps: { props: ChildProps } = { props: {} as ChildProps }; 8 | 9 | render( 10 | 11 | {(props: ChildProps) => { 12 | childProps.props = props; 13 | return
; 14 | }} 15 | , 16 | ); 17 | 18 | return [childProps.props.srcLangs.map(([code]) => code), childProps.props.tgtLangs.map(([code]) => code)]; 19 | }; 20 | 21 | // Adapted from https://stackoverflow.com/a/37580979/1266600. 22 | function* permute(permutation_: Array): Generator> { 23 | const permutation = permutation_.slice(), 24 | length = permutation.length, 25 | c = Array(length).fill(0) as Array; 26 | 27 | let i = 1, 28 | k: number, 29 | p: T; 30 | 31 | yield permutation.slice(); 32 | while (i < length) { 33 | if (c[i] < i) { 34 | k = i % 2 && c[i]; 35 | p = permutation[i]; 36 | permutation[i] = permutation[k]; 37 | permutation[k] = p; 38 | ++c[i]; 39 | i = 1; 40 | yield permutation.slice(); 41 | } else { 42 | c[i] = 0; 43 | ++i; 44 | } 45 | } 46 | } 47 | 48 | describe('source language sorting', () => { 49 | it.each([ 50 | // language name 51 | [['cat', 'eng', 'spa']], 52 | 53 | // variants after parent 54 | [['cat', 'cat_bar', 'eng', 'eng_foo', 'spa']], 55 | 56 | // variants by name 57 | [['cat', 'cat_bar', 'cat_foo', 'eng', 'spa']], 58 | ])('sorts to %s', (srcLangs) => { 59 | for (const srcLangsPermutation of permute(srcLangs)) { 60 | const [sortedSrcLangs] = getSortedLangs({ 61 | pairs: {}, 62 | srcLang: 'eng', 63 | srcLangs: new Set(srcLangsPermutation), 64 | tgtLangs: new Set(), 65 | }); 66 | expect(sortedSrcLangs).toStrictEqual(srcLangs); 67 | } 68 | }); 69 | }); 70 | 71 | describe('target language sorting', () => { 72 | const srcLang = 'eng'; 73 | 74 | it.each([ 75 | // possible languages first 76 | { 77 | possibleTgtLangs: ['spa'], 78 | tgtLangs: ['spa', 'cat', srcLang], 79 | }, 80 | 81 | // possible families first 82 | { 83 | possibleTgtLangs: ['spa_foo'], 84 | tgtLangs: ['spa', 'spa_foo', 'cat', srcLang], 85 | }, 86 | 87 | // possible variants first 88 | { 89 | possibleTgtLangs: ['spa_foo'], 90 | tgtLangs: ['spa', 'spa_foo', 'spa_bar', 'cat', srcLang], 91 | }, 92 | 93 | // possible variants by name 94 | { 95 | possibleTgtLangs: ['spa_foo', 'spa_bar'], 96 | tgtLangs: ['spa', 'spa_bar', 'spa_foo', 'cat', srcLang], 97 | }, 98 | ])('sorts to $tgtLangs when $possibleTgtLangs possible', ({ tgtLangs, possibleTgtLangs }) => { 99 | for (const tgtLangsPermutation of permute(tgtLangs)) { 100 | const [, sortedTgtLangs] = getSortedLangs({ 101 | srcLang, 102 | srcLangs: new Set(), 103 | tgtLangs: new Set(tgtLangsPermutation), 104 | pairs: { [srcLang]: new Set(possibleTgtLangs) }, 105 | }); 106 | expect(sortedTgtLangs).toStrictEqual(tgtLangs); 107 | } 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/components/__tests__/WithInstallationAlert.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render, screen, waitFor } from '@testing-library/react'; 3 | import mockAxios from 'jest-mock-axios'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | import { APyContext } from '../../context'; 7 | import WithInstallationAlert from '../WithInstallationAlert'; 8 | 9 | const renderWithInstallationAlert = (length = 1) => { 10 | const TestComponent = () => { 11 | const apyFetch = React.useContext(APyContext); 12 | Array.from({ length }, () => apyFetch('')); 13 | return null; 14 | }; 15 | 16 | render( 17 | 18 | 19 | , 20 | ); 21 | }; 22 | 23 | afterEach(() => jest.setSystemTime(jest.getRealSystemTime())); 24 | 25 | it('is closed by default', () => { 26 | render(); 27 | expect(screen.queryByRole('alert')).toBeNull(); 28 | }); 29 | 30 | it('renders children', () => { 31 | render( 32 | 33 |
hello
34 |
, 35 | ); 36 | expect(screen.getByRole('main').textContent).toBe('hello'); 37 | }); 38 | 39 | describe('request interactions', () => { 40 | it('does not open after a fast request', async () => { 41 | renderWithInstallationAlert(); 42 | 43 | await waitFor(() => expect(mockAxios.queue()).toHaveLength(1)); 44 | jest.setSystemTime(Date.now() + 100); 45 | act(() => mockAxios.mockResponse()); 46 | 47 | expect(screen.queryByRole('alert')).toBeNull(); 48 | }); 49 | 50 | it('opens due to a single slow request', async () => { 51 | renderWithInstallationAlert(); 52 | 53 | await waitFor(() => expect(mockAxios.queue()).toHaveLength(1)); 54 | jest.setSystemTime(Date.now() + 100000); 55 | act(() => mockAxios.mockResponse()); 56 | 57 | expect(screen.getByRole('alert').textContent).toContain('Install_Apertium-Default'); 58 | }); 59 | 60 | it('opens after a series of slow requests', async () => { 61 | renderWithInstallationAlert(6); 62 | 63 | await waitFor(() => expect(mockAxios.queue()).toHaveLength(6)); 64 | 65 | jest.setSystemTime(Date.now() + 3100); 66 | act(() => void Array.from({ length: 5 }, () => mockAxios.mockResponse())); 67 | 68 | expect(screen.queryByRole('alert')).toBeNull(); 69 | 70 | act(() => mockAxios.mockResponse()); 71 | 72 | expect(screen.getByRole('alert')).toBeDefined(); 73 | }); 74 | }); 75 | 76 | describe('open behavior', () => { 77 | const openAlert = async () => { 78 | renderWithInstallationAlert(); 79 | 80 | await waitFor(() => expect(mockAxios.queue()).toHaveLength(1)); 81 | act(() => { 82 | jest.setSystemTime(Date.now() + 100000); 83 | mockAxios.mockResponse(); 84 | }); 85 | }; 86 | 87 | it('closes after button click', async () => { 88 | await openAlert(); 89 | 90 | userEvent.click(screen.getByRole('button')); 91 | 92 | await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); 93 | }); 94 | 95 | it('closes after timeout', async () => { 96 | await openAlert(); 97 | 98 | act(() => void jest.advanceTimersByTime(110000)); 99 | 100 | await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); 101 | }); 102 | 103 | it('stays open on hover', async () => { 104 | await openAlert(); 105 | 106 | userEvent.hover(screen.getByRole('alert')); 107 | jest.runAllTimers(); 108 | 109 | expect(screen.getByRole('alert')).toBeDefined(); 110 | 111 | userEvent.unhover(screen.getByRole('alert')); 112 | 113 | act(() => void jest.advanceTimersByTime(110000)); 114 | 115 | await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/strings/kir.json: -------------------------------------------------------------------------------- 1 | { 2 | "@metadata": { 3 | "authors": [ 4 | "jonorthwash", 5 | "tolgonay" 6 | ], 7 | "last-updated": "2014-05-10", 8 | "locale": [ 9 | "ky", 10 | "kir" 11 | ], 12 | "completion": " 84% 45.06%", 13 | "missing": [ 14 | "Apertium_Documentation", 15 | "Contact_Para", 16 | "Downloads_Para", 17 | "Install_Apertium_Para", 18 | "More_Languages", 19 | "Morphological_Generation_Help", 20 | "Multi_Step_Translation", 21 | "Norm_Preferences", 22 | "What_Is_Apertium" 23 | ] 24 | }, 25 | "title": "Apertium | Эркин/ачык машинелик котормо платформасы", 26 | "tagline": "Эркин/ачык машинелик котормо платформасы", 27 | "description": "Апертиум деген эрежелүү машинелик котормо флатформасы. Apertium is a rule-based machine translation platform. Ал эркин жана ачык софт, жана GNU GPL жалпы ачык лицензиясынын шарттары боюнча камсыздалат.", 28 | "Translation": "Которуу", 29 | "Translation_Help": "Текст же URL киргизиңиз.", 30 | "Translate": "Котор", 31 | "Detect_Language": "Тилди автоматтык түрдө аныктоо", 32 | "detected": "автоматтык түрдө аныкталды", 33 | "Instant_Translation": "Ыкчам которуу", 34 | "Mark_Unknown_Words": "Билбеген сөздөрдү белгилөө", 35 | "Translate_Document": "Документ которуу", 36 | "Drop_Document": "Документ бул жерге ташыңыз", 37 | "Not_Available": "Котормо даяр эмес", 38 | "File_Too_Large": "Файлдын көлөмү лимиттен ашып кетти!", 39 | "Cancel": "Артка кайтуу", 40 | "Format_Not_Supported": "Формат колдолбойт", 41 | "Translate_Webpage": "Веб барагы которуу", 42 | "Supported_Formats": "Колдолгон форматтар: .txt, .rtf, .odp, .ods, .odt (LibreOffice/OpenOffice), .xlsx, .pptx, .docx (Microsoft Office 2003-төн кийинки).
Word 97 .doc файлдары жана .pdf файлдары колдолбойт.", 43 | "Morphological_Analysis": "Морфологиялык талдоо", 44 | "Morphological_Analysis_Help": "Синтездөгүңүз келген сөзформалар киргизиңиз.", 45 | "Analyze": "Талдоо", 46 | "Morphological_Generation": "Морфологиялык синтездөө", 47 | "Generate": "Синтездөө", 48 | "Spell_Checker": "Катаны текшерүү", 49 | "APy_Sandbox": "APy кум аянтчасы", 50 | "APy_Sandbox_Help": "Каалаган сурамжылоону жөнөтүү", 51 | "APy_Request": "APy сурамжылоосу", 52 | "Request": "Сурамжылоо", 53 | "Language": "Тил", 54 | "Input_Text": "Киргизилген текст", 55 | "Notice_Mistake": "Кандайдыр бир ката таптыңызбы?", 56 | "Help_Improve": "Апертиумду жакшыртууга жардам бериңиз!", 57 | "Contact_Us": "Эгер катаны табсаңыз, проектти демитүү тууралуу калооңуз болсо же жардамдашкыңыз келсе, бизге кабарлаңыз.", 58 | "Maintainer": "Бул вебсайт {{maintainer}} аркылуу башкарылат.", 59 | "About_Title": "Бул вебсайт жөнүндө", 60 | "Enable_JS_Warning": "Бул сайт JavaScript аркылуу гана иштейт. Эгер Javascriptти иштете албасаңыз, Prompsitтеги котормочуларды колдонсоңуз болот.", 61 | "Not_Found_Error": "Ката 404: Кечириңиз, изделген баракча табылган жок!", 62 | "About": "Проект жөнүндө", 63 | "Download": "Жүктөп алуу", 64 | "Contact": "Байланышуу", 65 | "Documentation": "Документация", 66 | "About_Apertium": "Апертиум жөнүндө", 67 | "Documentation_Para": "Документация викибиздеги Документция барагында табылат. Биз айрым презентация жана илимий баяндама басып чыгарганбыз. Алардын тизмеси викинин Чыгармалар барагында табылат.", 68 | "Apertium_Downloads": "Апертиумду жүктөп алуу", 69 | "Install_Apertium": "Апертиумду орнотуу" 70 | } 71 | -------------------------------------------------------------------------------- /src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { fireEvent, getByRole, render, screen, waitFor } from '@testing-library/react'; 3 | import mockAxios from 'jest-mock-axios'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | import App from '../App'; 7 | 8 | const renderApp = () => { 9 | window.history.replaceState(null, '', '/index.eng.html'); 10 | 11 | render( 12 | <> 13 | 14 | 15 | , 16 | ); 17 | }; 18 | 19 | const selectLocale = (name: string) => { 20 | const selector = screen 21 | .getAllByRole('combobox') 22 | .find((e) => Array.from(e.childNodes).some((c) => (c as HTMLOptionElement).value === 'eng')) as HTMLSelectElement; 23 | userEvent.selectOptions(selector, getByRole(selector, 'option', { name })); 24 | }; 25 | 26 | describe('document drag', () => { 27 | it('switches to doc translation on first hover', () => { 28 | renderApp(); 29 | 30 | const body = document.getElementsByTagName('body')[0]; 31 | fireEvent(body, new Event('dragenter')); 32 | 33 | expect(window.location.hash).toMatch(/^#docTranslation/); 34 | }); 35 | 36 | it('ignores second hover', () => { 37 | renderApp(); 38 | 39 | const initialLength = window.history.length; 40 | 41 | const body = document.getElementsByTagName('body')[0]; 42 | fireEvent(body, new Event('dragenter')); 43 | 44 | const postInitialDragLength = window.history.length; 45 | expect(postInitialDragLength).toBeGreaterThan(initialLength); 46 | 47 | fireEvent(body, new Event('dragenter')); 48 | expect(window.history.length).toEqual(postInitialDragLength); 49 | }); 50 | }); 51 | 52 | describe('document level attributes', () => { 53 | it('sets on render', () => { 54 | renderApp(); 55 | 56 | expect(document.title).toMatchInlineSnapshot(`"title-Default"`); 57 | 58 | const html = document.getElementsByTagName('html')[0]; 59 | expect(html.dir).toBe('ltr'); 60 | expect(html.lang).toBe('en'); 61 | 62 | expect((document.getElementById('meta-description') as HTMLMetaElement).content).toMatchInlineSnapshot( 63 | `"description-Default"`, 64 | ); 65 | }); 66 | 67 | it('falls back to alpha3 code for lang', () => { 68 | renderApp(); 69 | 70 | selectLocale('arpetan'); 71 | 72 | const html = document.getElementsByTagName('html')[0]; 73 | expect(html.lang).toBe('frp'); 74 | }); 75 | }); 76 | 77 | describe('changing locale', () => { 78 | it('switches on select', async () => { 79 | renderApp(); 80 | 81 | selectLocale('español'); 82 | 83 | const title = 'title-Spanish'; 84 | mockAxios.mockResponse({ data: { title } }); 85 | await waitFor(() => expect(mockAxios).toHaveBeenCalledWith(expect.objectContaining({ url: 'strings/spa.json' }))); 86 | 87 | await waitFor(() => expect(document.title).toBe(title)); 88 | }); 89 | 90 | it('falls back to defaults on error', async () => { 91 | renderApp(); 92 | 93 | const defaultTitle = document.title; 94 | 95 | selectLocale('español'); 96 | 97 | const title = 'title-Spanish'; 98 | mockAxios.mockResponse({ data: { title } }); 99 | await waitFor(() => expect(mockAxios).toHaveBeenCalled()); 100 | await waitFor(() => expect(document.title).toBe(title)); 101 | 102 | selectLocale('arpetan'); 103 | mockAxios.mockError(); 104 | 105 | await waitFor(() => expect(document.title).toBe(defaultTitle)); 106 | }); 107 | 108 | it('caches results', async () => { 109 | renderApp(); 110 | 111 | selectLocale('español'); 112 | 113 | mockAxios.mockResponse({ data: { title: 'title-Spanish' } }); 114 | await waitFor(() => expect(mockAxios).toHaveBeenCalledWith(expect.objectContaining({ url: 'strings/spa.json' }))); 115 | 116 | selectLocale('English'); 117 | selectLocale('español'); 118 | 119 | expect(mockAxios.queue()).toHaveLength(0); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/components/translator/WithSortedLanguages.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { NamedLangs, Pairs, isPair } from '.'; 3 | 4 | import { isVariant, parentLang, toAlpha2Code, variantSeperator } from '../../util/languages'; 5 | import { LocaleContext } from '../../context'; 6 | import { useLocalization } from '../../util/localization'; 7 | 8 | export type Props = { 9 | pairs: Pairs; 10 | srcLang: string; 11 | srcLangs: Set; 12 | tgtLangs: Set; 13 | children: (props: ChildProps) => React.ReactElement; 14 | }; 15 | 16 | export type ChildProps = { 17 | srcLangs: NamedLangs; 18 | tgtLangs: NamedLangs; 19 | }; 20 | 21 | const WithSortedLanguages = ({ pairs, srcLang, srcLangs, tgtLangs, children }: Props): React.ReactElement => { 22 | const { tLang } = useLocalization(); 23 | const locale = React.useContext(LocaleContext); 24 | 25 | const collator = React.useMemo(() => new Intl.Collator((toAlpha2Code(locale) || locale).replace('_', '-')), [locale]); 26 | 27 | const compareLangCodes = React.useCallback( 28 | (a: string, b: string): number => { 29 | const [aParent, aVariant] = a.split(variantSeperator, 2), 30 | [bParent, bVariant] = b.split(variantSeperator, 2); 31 | if (aVariant && bVariant && aParent === bParent) { 32 | return collator.compare(tLang(a), tLang(b)); 33 | } else if (aVariant && aParent === b) { 34 | return 1; 35 | } else if (bVariant && bParent === a) { 36 | return -1; 37 | } else { 38 | return collator.compare(tLang(aParent), tLang(bParent)); 39 | } 40 | }, 41 | [collator, tLang], 42 | ); 43 | 44 | const sortedSrcLangs: NamedLangs = React.useMemo( 45 | () => [...srcLangs].sort(compareLangCodes).map((code) => [code, tLang(code)]), 46 | [compareLangCodes, srcLangs, tLang], 47 | ); 48 | 49 | const possibleTgtFamilies: Set = React.useMemo( 50 | () => 51 | new Set( 52 | Array.from(tgtLangs).filter((lang) => { 53 | const parent = parentLang(lang); 54 | const possibleTgtLangs = Array.from(pairs[srcLang]); 55 | 56 | return ( 57 | isPair(pairs, srcLang, lang) || 58 | possibleTgtLangs.includes(parent) || 59 | possibleTgtLangs.some((possibleLang) => parentLang(possibleLang) === parent) 60 | ); 61 | }), 62 | ), 63 | [pairs, srcLang, tgtLangs], 64 | ); 65 | 66 | const sortedTgtLangs: NamedLangs = React.useMemo( 67 | () => 68 | [...tgtLangs] 69 | .sort((a, b) => { 70 | const aParent = parentLang(a), 71 | bParent = parentLang(b); 72 | 73 | const aFamilyPossible = possibleTgtFamilies.has(aParent), 74 | bFamilyPossible = possibleTgtFamilies.has(bParent); 75 | 76 | if (aFamilyPossible === bFamilyPossible) { 77 | if (aParent === bParent) { 78 | const aVariant = isVariant(a), 79 | bVariant = isVariant(b); 80 | if (aVariant && bVariant) { 81 | const aPossible = isPair(pairs, srcLang, a), 82 | bPossible = isPair(pairs, srcLang, b); 83 | if (aPossible === bPossible) { 84 | return compareLangCodes(a, b); 85 | } else if (aPossible) { 86 | return -1; 87 | } else { 88 | return 1; 89 | } 90 | } else if (aVariant) { 91 | return 1; 92 | } else { 93 | return -1; 94 | } 95 | } else { 96 | return compareLangCodes(a, b); 97 | } 98 | } else if (aFamilyPossible) { 99 | return -1; 100 | } else { 101 | return 1; 102 | } 103 | }) 104 | .map((code) => [code, tLang(code)]), 105 | [compareLangCodes, pairs, possibleTgtFamilies, srcLang, tLang, tgtLangs], 106 | ); 107 | 108 | return children({ srcLangs: sortedSrcLangs, tgtLangs: sortedTgtLangs }); 109 | }; 110 | 111 | export default WithSortedLanguages; 112 | -------------------------------------------------------------------------------- /src/components/WithInstallationAlert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { AxiosPromise, CancelTokenSource } from 'axios'; 3 | import Alert from 'react-bootstrap/Alert'; 4 | 5 | import { APyContext, ConfigContext } from '../context'; 6 | import { apyFetch } from '../util'; 7 | import { useLocalization } from '../util/localization'; 8 | 9 | const requestBufferLength = 5, 10 | individualDurationThreshold = 4000, 11 | cumulativeDurationThreshold = 3000, 12 | notificationDuration = 10000; 13 | 14 | const InstallationAlert = ({ show, onClose }: { show: boolean; onClose: () => void }): React.ReactElement => { 15 | const { t } = useLocalization(); 16 | 17 | const openTimeoutRef = React.useRef(null); 18 | 19 | const scheduleClose = React.useCallback(() => { 20 | if (!openTimeoutRef.current) { 21 | openTimeoutRef.current = window.setTimeout(onClose, notificationDuration); 22 | } 23 | }, [onClose]); 24 | 25 | React.useEffect(() => { 26 | const { current } = openTimeoutRef; 27 | if (show) { 28 | scheduleClose(); 29 | } else if (current) { 30 | window.clearTimeout(current); 31 | openTimeoutRef.current = null; 32 | } 33 | 34 | return () => { 35 | if (openTimeoutRef.current) { 36 | window.clearTimeout(openTimeoutRef.current); 37 | } 38 | }; 39 | }, [onClose, scheduleClose, show]); 40 | 41 | const onMouseOver = React.useCallback(() => { 42 | const { current } = openTimeoutRef; 43 | if (current) { 44 | window.clearTimeout(current); 45 | openTimeoutRef.current = null; 46 | } 47 | }, []); 48 | 49 | const onMouseOut = React.useCallback(() => { 50 | if (show) { 51 | scheduleClose(); 52 | } 53 | }, [scheduleClose, show]); 54 | 55 | return ( 56 | 67 | {t('Install_Apertium')} 68 |

69 | 70 | ); 71 | }; 72 | 73 | const WithInstallationAlert = ({ children }: { children?: React.ReactNode }): React.ReactElement => { 74 | const { apyURL } = React.useContext(ConfigContext); 75 | 76 | const [show, setShow] = React.useState(false); 77 | 78 | const requestTimings = React.useRef>([]); 79 | 80 | const wrappedApyFetch = React.useCallback( 81 | (path: string, params?: Record): [CancelTokenSource, AxiosPromise] => { 82 | const start = Date.now(); 83 | 84 | const handleRequestComplete = () => { 85 | const duration = Date.now() - start; 86 | const timings = requestTimings.current; 87 | 88 | let cumulativeAPyDuration = 0; 89 | 90 | if (timings.length === requestBufferLength) { 91 | cumulativeAPyDuration = timings.reduce((totalDuration, duration) => totalDuration + duration); 92 | 93 | timings.shift(); 94 | timings.push(duration); 95 | } else { 96 | timings.push(duration); 97 | } 98 | 99 | const averageDuration = cumulativeAPyDuration / timings.length; 100 | 101 | if (duration > individualDurationThreshold || averageDuration > cumulativeDurationThreshold) { 102 | setShow(true); 103 | } 104 | }; 105 | 106 | const [cancel, request] = apyFetch(`${apyURL}/${path}`, params); 107 | return [cancel, request.finally(handleRequestComplete)]; 108 | }, 109 | [apyURL], 110 | ); 111 | 112 | return ( 113 | <> 114 | 115 | setShow(false)} show={show} /> 116 | {children} 117 | 118 | 119 | ); 120 | }; 121 | 122 | export default WithInstallationAlert; 123 | -------------------------------------------------------------------------------- /src/util/__tests__/useLocalStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { RenderResult, act, renderHook } from '@testing-library/react-hooks'; 2 | 3 | import useLocalStorage from '../useLocalStorage'; 4 | 5 | const localStorageKey = () => Math.random().toString(36); 6 | 7 | declare global { 8 | // eslint-disable-next-line @typescript-eslint/no-namespace 9 | namespace jest { 10 | interface Matchers { 11 | noRenderError(): R; 12 | renderValueToBe(value: T): R; 13 | } 14 | } 15 | } 16 | 17 | expect.extend({ 18 | noRenderError(result: RenderResult<[T, React.Dispatch>]>) { 19 | return { 20 | message: () => `expected error to be empty`, 21 | pass: result.error == null, 22 | }; 23 | }, 24 | renderValueToBe(result: RenderResult<[T, React.Dispatch>]>, value: T) { 25 | return { 26 | message: () => `expected ${new String(result.current[0]).toString()} to be ${new String(value).toString()}`, 27 | pass: result.current[0] === value, 28 | }; 29 | }, 30 | }); 31 | 32 | it('uses initial value', () => { 33 | const { result } = renderHook(() => useLocalStorage(localStorageKey(), 'bar')); 34 | expect(result).noRenderError(); 35 | expect(result).renderValueToBe('bar'); 36 | }); 37 | 38 | it('uses initial function value', () => { 39 | const { result } = renderHook(() => useLocalStorage(localStorageKey(), () => 'bar')); 40 | expect(result).noRenderError(); 41 | expect(result).renderValueToBe('bar'); 42 | }); 43 | 44 | it('sets new value', () => { 45 | const { result } = renderHook(() => useLocalStorage(localStorageKey(), 'bar')); 46 | expect(result).noRenderError(); 47 | act(() => result.current[1]('qux')); 48 | expect(result).renderValueToBe('qux'); 49 | }); 50 | 51 | it('sets new function value', () => { 52 | const { result } = renderHook(() => useLocalStorage(localStorageKey(), 'bar')); 53 | expect(result).noRenderError(); 54 | act(() => result.current[1]((s) => s + s)); 55 | expect(result).renderValueToBe('barbar'); 56 | }); 57 | 58 | it('restores saved value', () => { 59 | const key = localStorageKey(); 60 | const { result } = renderHook(() => useLocalStorage(key, 'bar')); 61 | expect(result).noRenderError(); 62 | 63 | { 64 | const { result } = renderHook(() => useLocalStorage(key, 'qux')); 65 | expect(result).noRenderError(); 66 | expect(result).renderValueToBe('bar'); 67 | } 68 | }); 69 | 70 | it('uses override value over initial value', () => { 71 | const { result } = renderHook(() => useLocalStorage(localStorageKey(), 'bar', { overrideValue: 'qux' })); 72 | expect(result).noRenderError(); 73 | expect(result).renderValueToBe('qux'); 74 | }); 75 | 76 | it('uses override value over saved value', () => { 77 | const key = localStorageKey(); 78 | const { result } = renderHook(() => useLocalStorage(key, 'bar')); 79 | expect(result).noRenderError(); 80 | 81 | { 82 | const { result } = renderHook(() => useLocalStorage(key, 'bar', { overrideValue: 'qux' })); 83 | expect(result).noRenderError(); 84 | expect(result).renderValueToBe('qux'); 85 | } 86 | }); 87 | 88 | it('discards saved value that fails validation', () => { 89 | const key = localStorageKey(); 90 | const { result } = renderHook(() => useLocalStorage(key, 'bar')); 91 | expect(result).noRenderError(); 92 | 93 | { 94 | const { result } = renderHook(() => useLocalStorage(key, 'barr', { validateValue: (s) => s.length === 4 })); 95 | expect(result).noRenderError(); 96 | expect(result).renderValueToBe('barr'); 97 | } 98 | }); 99 | 100 | it('discards override value that fails validation', () => { 101 | const { result } = renderHook(() => 102 | useLocalStorage(localStorageKey(), 'bar', { overrideValue: 'barr', validateValue: (s) => s.length === 3 }), 103 | ); 104 | expect(result).noRenderError(); 105 | expect(result).renderValueToBe('bar'); 106 | }); 107 | 108 | it('discards invalid saved value', () => { 109 | const key = localStorageKey(); 110 | window.localStorage.setItem(key, 'I AM NOT VALID JSON'); 111 | const { result } = renderHook(() => useLocalStorage(key, 'bar')); 112 | expect(result).noRenderError(); 113 | expect(result).renderValueToBe('bar'); 114 | }); 115 | -------------------------------------------------------------------------------- /src/components/translator/TranslationOptions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Col from 'react-bootstrap/Col'; 3 | import DropdownButton from 'react-bootstrap/DropdownButton'; 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 | import Form from 'react-bootstrap/Form'; 6 | import Row from 'react-bootstrap/Row'; 7 | import { faCog } from '@fortawesome/free-solid-svg-icons'; 8 | 9 | import { ConfigContext, LocaleContext } from '../../context'; 10 | import { PairPrefValues, getPairPrefs } from '.'; 11 | import { useLocalization } from '../../util/localization'; 12 | 13 | export type Props = { 14 | markUnknown: boolean; 15 | setMarkUnknown: React.Dispatch>; 16 | instantTranslation: boolean; 17 | setInstantTranslation: React.Dispatch>; 18 | translationChaining: boolean; 19 | setTranslationChaining: React.Dispatch>; 20 | srcLang: string; 21 | tgtLang: string; 22 | pairPrefs: PairPrefValues; 23 | setPairPrefs: (prefs: PairPrefValues) => void; 24 | }; 25 | 26 | const TranslationOptions = ({ 27 | markUnknown, 28 | setMarkUnknown, 29 | instantTranslation, 30 | setInstantTranslation, 31 | translationChaining, 32 | setTranslationChaining, 33 | srcLang, 34 | tgtLang, 35 | pairPrefs, 36 | setPairPrefs, 37 | }: Props): React.ReactElement => { 38 | const { t } = useLocalization(); 39 | const locale = React.useContext(LocaleContext); 40 | const config = React.useContext(ConfigContext); 41 | 42 | const prefs = React.useMemo(() => getPairPrefs(locale, srcLang, tgtLang), [locale, srcLang, tgtLang]); 43 | const [showPrefDropdown, setShowPrefDropdown] = React.useState(false); 44 | 45 | return ( 46 | <> 47 | {Object.keys(prefs).length > 0 && ( 48 | { 52 | if (isOpen) { 53 | setShowPrefDropdown(true); 54 | } 55 | 56 | if (source === 'rootClose') { 57 | setShowPrefDropdown(false); 58 | } 59 | }} 60 | show={showPrefDropdown} 61 | size="sm" 62 | title={ 63 | <> 64 | {t('Norm_Preferences')} 65 | 66 | } 67 | variant="secondary" 68 | > 69 | 77 | {Object.entries(prefs).map(([id, description]) => ( 78 | 79 | setPairPrefs({ ...pairPrefs, [id]: currentTarget.checked })} 87 | style={{ wordBreak: 'break-word' }} 88 | /> 89 | 90 | ))} 91 | 92 | 93 | )} 94 | setMarkUnknown(currentTarget.checked)} 100 | /> 101 | setInstantTranslation(currentTarget.checked)} 107 | /> 108 | {config.translationChaining && ( 109 | } 114 | onChange={({ currentTarget }) => setTranslationChaining(currentTarget.checked)} 115 | /> 116 | )} 117 | 118 | ); 119 | }; 120 | 121 | export default TranslationOptions; 122 | -------------------------------------------------------------------------------- /src/components/translator/__tests__/TranslationOptions.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | import TranslationOptions, { Props } from '../TranslationOptions'; 6 | 7 | const renderTranslationOptions = (props_: Partial = {}) => { 8 | const props = { 9 | markUnknown: false, 10 | setMarkUnknown: jest.fn(), 11 | instantTranslation: false, 12 | setInstantTranslation: jest.fn(), 13 | translationChaining: false, 14 | setTranslationChaining: jest.fn(), 15 | srcLang: 'eng', 16 | tgtLang: 'spa', 17 | pairPrefs: {}, 18 | setPairPrefs: jest.fn(), 19 | ...props_, 20 | }; 21 | 22 | render(); 23 | 24 | return props; 25 | }; 26 | 27 | describe('mark unknown', () => { 28 | const name = 'Mark_Unknown_Words-Default'; 29 | 30 | it.each([false, true])('renders %s', (markUnknown) => { 31 | renderTranslationOptions({ markUnknown }); 32 | expect(screen.getByRole('checkbox', { name, checked: markUnknown })).toBeDefined(); 33 | }); 34 | 35 | it.each([false, true])('toggles from %s', (markUnknown) => { 36 | const props = renderTranslationOptions({ markUnknown }); 37 | 38 | userEvent.click(screen.getByRole('checkbox', { name })); 39 | 40 | expect(props.setMarkUnknown).toHaveBeenCalledWith(!markUnknown); 41 | }); 42 | }); 43 | 44 | describe('instant translation', () => { 45 | const name = 'Instant_Translation-Default'; 46 | 47 | it.each([false, true])('renders %s', (instantTranslation) => { 48 | renderTranslationOptions({ instantTranslation }); 49 | expect(screen.getByRole('checkbox', { name, checked: instantTranslation })).toBeDefined(); 50 | }); 51 | 52 | it.each([false, true])('toggles from %s', (instantTranslation) => { 53 | const { setInstantTranslation } = renderTranslationOptions({ instantTranslation }); 54 | 55 | userEvent.click(screen.getByRole('checkbox', { name })); 56 | 57 | expect(setInstantTranslation).toHaveBeenCalledWith(!instantTranslation); 58 | }); 59 | }); 60 | 61 | describe('translation chaining', () => { 62 | const name = 'Multi_Step_Translation-Default'; 63 | 64 | it.each([false, true])('renders %s', (translationChaining) => { 65 | renderTranslationOptions({ translationChaining }); 66 | expect(screen.getByRole('checkbox', { name, checked: translationChaining })).toBeDefined(); 67 | }); 68 | 69 | it.each([false, true])('toggles from %s', (translationChaining) => { 70 | const { setTranslationChaining } = renderTranslationOptions({ translationChaining }); 71 | 72 | userEvent.click(screen.getByRole('checkbox', { name })); 73 | 74 | expect(setTranslationChaining).toHaveBeenCalledWith(!translationChaining); 75 | }); 76 | }); 77 | 78 | describe('pair prefs', () => { 79 | const withPrefsOptions = { srcLang: 'eng', tgtLang: 'cat' }; 80 | 81 | it('renders nothing when prefs are unavailable', () => { 82 | renderTranslationOptions(); 83 | expect(screen.queryByRole('button')).toBeNull(); 84 | }); 85 | 86 | it('renders closed dropdown when prefs are available', () => { 87 | renderTranslationOptions(withPrefsOptions); 88 | expect(screen.getByRole('button').textContent).toBe(' Norm_Preferences-Default'); 89 | }); 90 | 91 | it('renders prefs when dropdown clicked', async () => { 92 | renderTranslationOptions(withPrefsOptions); 93 | userEvent.click(screen.getByRole('button')); 94 | 95 | expect(await screen.findByRole('checkbox', { name: 'foo_pref' })).toBeDefined(); 96 | }); 97 | 98 | it('closes dropdown when clicked', () => { 99 | renderTranslationOptions(withPrefsOptions); 100 | 101 | const button = screen.getByRole('button'); 102 | userEvent.click(button); 103 | expect(button.getAttribute('aria-expanded')).toBe('true'); 104 | 105 | userEvent.click(button); 106 | expect(button.getAttribute('aria-expanded')).toBe('false'); 107 | }); 108 | 109 | it('updates prefs when checkbox clicked', async () => { 110 | const { setPairPrefs } = renderTranslationOptions(withPrefsOptions); 111 | 112 | userEvent.click(screen.getByRole('button')); 113 | userEvent.click(await screen.findByRole('checkbox', { name: 'foo_pref' })); 114 | 115 | expect(setPairPrefs).toBeCalledWith({ foo: true }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/components/footer/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { getAllByRole, render, screen, waitFor } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | import Config from '../../../../config'; 6 | import { ConfigContext } from '../../../context'; 7 | import { Config as ConfigType } from '../../../types'; 8 | 9 | import Footer from '..'; 10 | 11 | const renderFooter = (config: Partial = {}) => { 12 | const wrapRef = React.createRef(); 13 | const pushRef = React.createRef(); 14 | 15 | render( 16 | 17 | <> 18 |

19 |
20 |
21 |