├── .codeclimate.yml ├── .eslintignore ├── .eslintrc.yml ├── .github └── FUNDING.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml ├── universal-language-detector.iml └── vcs.xml ├── .nvmrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── buildspec.yml ├── examples └── with-next │ ├── .gitignore │ ├── README.md │ ├── components │ ├── footer.js │ ├── i18nCard.js │ └── nav.js │ ├── now.json │ ├── package.json │ ├── pages │ ├── _app.js │ ├── index.js │ └── page2.js │ ├── public │ └── favicon.ico │ ├── utils │ └── i18n.js │ └── yarn.lock ├── jest.config.js ├── package.json ├── src ├── index.test.ts ├── index.ts ├── serverDetectors │ ├── acceptLanguage.test.ts │ ├── acceptLanguage.ts │ ├── serverCookie.test.ts │ └── serverCookie.ts ├── types │ └── allowJavaScriptModules.d.ts ├── universalDetectors │ └── fallback.ts └── utils │ └── error.ts ├── tsconfig.json └── yarn.lock /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | # XXX See https://docs.codeclimate.com/docs/advanced-configuration 2 | version: "2" 3 | checks: 4 | argument-count: 5 | enabled: true 6 | config: 7 | threshold: 4 8 | complex-logic: 9 | enabled: true 10 | config: 11 | threshold: 4 12 | file-lines: 13 | enabled: true 14 | config: 15 | threshold: 400 # 250 by default 16 | method-complexity: 17 | enabled: true 18 | config: 19 | threshold: 5 20 | method-count: 21 | enabled: true 22 | config: 23 | threshold: 20 24 | method-lines: 25 | enabled: true 26 | config: 27 | threshold: 100 # 25 by default 28 | nested-control-flow: 29 | enabled: true 30 | config: 31 | threshold: 4 32 | return-statements: 33 | enabled: true 34 | config: 35 | threshold: 4 36 | 37 | plugins: 38 | # eslint: # https://docs.codeclimate.com/docs/eslint 39 | # enabled: true 40 | # channel: "eslint-4" # Depends on installed ESLint version - See https://docs.codeclimate.com/docs/eslint#section-eslint-versions 41 | duplication: # https://docs.codeclimate.com/docs/duplication 42 | enabled: true 43 | config: 44 | languages: 45 | javascript: 46 | mass_threshold: 80 # Instead of 50 - See https://docs.codeclimate.com/docs/duplication#section-understand-the-engine 47 | fixme: # https://docs.codeclimate.com/docs/fixme 48 | enabled: true 49 | config: 50 | strings: # Skip "XXX" as we don't use it for things to fix but rather for highlighting comments (DX) 51 | - FIXME 52 | - BUG 53 | - TODO 54 | - HACK 55 | git-legal: # https://docs.codeclimate.com/docs/git-legal 56 | enabled: true 57 | # tslint: # https://docs.codeclimate.com/docs/tslint TODO configure tslint if needed 58 | # enabled: true 59 | # config: tslint.json 60 | 61 | # See https://docs.codeclimate.com/docs/excluding-files-and-folders 62 | exclude_patterns: 63 | - "**/*.test.*" 64 | - "**/*.spec.*" 65 | - "mocks/" 66 | - "lib/" 67 | - "examples/" 68 | 69 | # Default CC excluded paths: 70 | - "config/" 71 | - "db/" 72 | - "dist/" 73 | - "features/" 74 | - "**/node_modules/" 75 | - "script/" 76 | - "**/spec/" 77 | - "**/test/" 78 | - "**/tests/" 79 | - "**/vendor/" 80 | - "**/*.d.ts" 81 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | /lib/** 3 | /coverage/** 4 | src/**/*.test* 5 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | browser: true 4 | commonjs: true 5 | es6: true 6 | node: true 7 | extends: 8 | - plugin:@typescript-eslint/recommended 9 | globals: 10 | Atomics: readonly 11 | SharedArrayBuffer: readonly 12 | plugins: 13 | - jest 14 | parser: '@typescript-eslint/parser' 15 | parserOptions: 16 | project: ./tsconfig.json 17 | settings: 18 | react: 19 | version: detect 20 | rules: # See https://eslint.org/docs/rules 21 | semi: 22 | - error 23 | - always # Always put commas, to avoid multilines git diff when new lines are added 24 | quotes: 25 | - error 26 | - single # Prefer simple quotes 27 | - allowTemplateLiterals: true # Allow the use of `` instead of '' and don't try to replace it, even when `` isn't needed 28 | comma-spacing: 29 | - error 30 | - before: false 31 | after: true 32 | indent: 33 | - error 34 | - 2 35 | - SwitchCase: 1 36 | arrow-parens: 37 | - error 38 | - always 39 | max-len: 0 # Disable line length checks, because the IDE is already configured to warn about it, and it's a waste of time to check for lines that are too long, especially in comments (like this one!) 40 | strict: 'off' 41 | no-console: 1 # Shouldn't use "console", but "logger" instead 42 | allowArrowFunctions: 0 43 | no-unused-vars: 44 | - warn # Warn otherwise it false-positive with needed React imports 45 | - args: none # Allow to declare unused variables in function arguments, meant to be used later 46 | import/prefer-default-export: 0 # When there is only a single export from a module, don't enforce a default export, but rather let developer choose what's best 47 | no-else-return: 0 # Don't enforce, let developer choose. Sometimes we like to specifically use "return" for the sake of comprehensibility and avoid ambiguity 48 | no-underscore-dangle: 0 # Allow _ before/after variables and functions, convention for something meant to be "private" 49 | arrow-body-style: 0 # Don't enforce, let developer choose. Sometimes we like to specifically use "return" for ease of debugging and printing 50 | quote-props: 51 | - warn 52 | - consistent-as-needed # Enforce consistency with quotes on props, either all must be quoted, or all unquoted for a given object 53 | no-return-await: 0 # Useful before, but recent node.js enhancements make it useless on node 12+ (we use 10, but still, for consistency) - Read https://stackoverflow.com/questions/44806135/why-no-return-await-vs-const-x-await 54 | no-extra-boolean-cast: 0 # Don't enforce, let developer choose. Using "!!!" is sometimes useful (edge cases), and has a semantic value (dev intention) 55 | object-curly-newline: 56 | - warn 57 | - ObjectExpression: 58 | multiline: true 59 | minProperties: 5 60 | consistent: true 61 | ObjectPattern: 62 | multiline: true 63 | minProperties: 5 64 | consistent: true 65 | ImportDeclaration: 66 | multiline: true 67 | minProperties: 8 # Doesn't play so well with webstorm, which wraps based on the number of chars in the row, not based on the number of props #sucks 68 | consistent: true 69 | ExportDeclaration: 70 | multiline: true 71 | minProperties: 5 72 | consistent: true 73 | linebreak-style: 74 | - error 75 | - unix 76 | '@typescript-eslint/ban-ts-ignore': warn 77 | '@typescript-eslint/no-use-before-define': warn 78 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: unlyEd 2 | github: [UnlyEd, Vadorequest] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # User-specific stuff 5 | .idea/**/workspace.xml 6 | .idea/**/tasks.xml 7 | .idea/**/usage.statistics.xml 8 | .idea/**/dictionaries 9 | .idea/**/shelf 10 | 11 | # Generated files 12 | .idea/**/contentModel.xml 13 | 14 | # Sensitive or high-churn files 15 | .idea/**/dataSources/ 16 | .idea/**/dataSources.ids 17 | .idea/**/dataSources.local.xml 18 | .idea/**/sqlDataSources.xml 19 | .idea/**/dynamic.xml 20 | .idea/**/uiDesigner.xml 21 | .idea/**/dbnavigator.xml 22 | 23 | # Gradle 24 | .idea/**/gradle.xml 25 | .idea/**/libraries 26 | 27 | # Gradle and Maven with auto-import 28 | # When using Gradle or Maven with auto-import, you should exclude module files, 29 | # since they will be recreated, and may cause churn. Uncomment if using 30 | # auto-import. 31 | # .idea/modules.xml 32 | # .idea/*.iml 33 | # .idea/modules 34 | 35 | # CMake 36 | cmake-build-*/ 37 | 38 | # Mongo Explorer plugin 39 | .idea/**/mongoSettings.xml 40 | 41 | # File-based project format 42 | *.iws 43 | 44 | # IntelliJ 45 | out/ 46 | 47 | # mpeltonen/sbt-idea plugin 48 | .idea_modules/ 49 | 50 | # JIRA plugin 51 | atlassian-ide-plugin.xml 52 | 53 | # Cursive Clojure plugin 54 | .idea/replstate.xml 55 | 56 | # Crashlytics plugin (for Android Studio and IntelliJ) 57 | com_crashlytics_export_strings.xml 58 | crashlytics.properties 59 | crashlytics-build.properties 60 | fabric.properties 61 | 62 | # Editor-based Rest Client 63 | .idea/httpRequests 64 | 65 | # Android studio 3.1+ serialized cache file 66 | .idea/caches/build_file_checksums.ser 67 | 68 | # Logs 69 | logs 70 | *.log 71 | npm-debug.log* 72 | yarn-debug.log* 73 | yarn-error.log* 74 | 75 | # Runtime data 76 | pids 77 | *.pid 78 | *.seed 79 | *.pid.lock 80 | 81 | # Directory for instrumented libs generated by jscoverage/JSCover 82 | lib-cov 83 | 84 | # Coverage directory used by tools like istanbul 85 | coverage 86 | 87 | # nyc test coverage 88 | .nyc_output 89 | 90 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 91 | .grunt 92 | 93 | # Bower dependency directory (https://bower.io/) 94 | bower_components 95 | 96 | # node-waf configuration 97 | .lock-wscript 98 | 99 | # Compiled binary addons (https://nodejs.org/api/addons.html) 100 | build/Release 101 | 102 | # Dependency directories 103 | node_modules/ 104 | jspm_packages/ 105 | 106 | # TypeScript v1 declaration files 107 | typings/ 108 | 109 | # Optional npm cache directory 110 | .npm 111 | 112 | # Optional eslint cache 113 | .eslintcache 114 | 115 | # Optional REPL history 116 | .node_repl_history 117 | 118 | # Output of 'npm pack' 119 | *.tgz 120 | 121 | # Yarn Integrity file 122 | .yarn-integrity 123 | 124 | # Ignore build directory (will be packed in the npm release, but shouldn't be tracked within git) 125 | /lib 126 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 115 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/universal-language-detector.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.14.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12.14" 4 | cache: 5 | directories: 6 | - node_modules 7 | install: 8 | - yarn install 9 | script: 10 | - yarn run test:once 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | === 3 | 4 | - v2.0.3 - 2020-03-04 5 | - [Bugfix] Localised languages such as fr-FR/en-US where blacklisted when using the "navigator" resolver, which caused the language to be wrongfully detected (i.e: "en-US, fr, en" would return "fr"), now it properly returns "en" 6 | - v2.0.2 - 2020-02-06 7 | - [Bugfix] Use `@unly/iso3166-1` instead of `iso3166-1` in source code (forgot to change this alongside the package.json in v2.0.1) 8 | - v2.0.1 - 2020-02-04 9 | - [Enhancement] Use `@unly/iso3166-1` instead of `iso3166-1` (robustness) 10 | - v2.0.0 - 2020-01-09 11 | - [Release] Release v2 with **breaking API changes** 12 | - Remove all stuff that was related to our internal business logic at Unly (this lib is a port of an internal project and was suffering from internal business logic/needs that shouldn't have been part of the Open Source release, they have been removed) 13 | - [BREAKING] Removed API function `universalLanguagesDetect` (plural), kept only `universalLanguageDetect` 14 | - [BREAKING] Renamed `acceptedLanguages` into `supportedLanguages` to avoid confusion with `accept-language` header 15 | - [BREAKING] Renamed `acceptLanguage` into `acceptLanguageHeader` for clarity 16 | - v1.0.0 - 2019-12-30 17 | - [Release] Release production-ready 1.0.0 version 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Unly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Unly logo 2 | [![Maintainability](https://api.codeclimate.com/v1/badges/424ff73928475fd2331f/maintainability)](https://codeclimate.com/github/UnlyEd/universal-language-detector/maintainability) 3 | [![Test Coverage](https://api.codeclimate.com/v1/badges/424ff73928475fd2331f/test_coverage)](https://codeclimate.com/github/UnlyEd/universal-language-detector/test_coverage) 4 | [![Known Vulnerabilities](https://snyk.io/test/github/UnlyEd/universal-language-detector/badge.svg?targetFile=package.json)](https://snyk.io/test/github/UnlyEd/universal-language-detector?targetFile=package.json) 5 | # Universal Language Detector 6 | 7 | > Language detector that works universally (browser + server) 8 | > 9 | > - On the server, will rely on "cookies > accept-language header" 10 | > - On the browser, will rely on "cookies > navigator settings" 11 | > 12 | > Meant to be used with a universal framework, such as Next.js 13 | 14 | 15 | Note that this lib helps resolving the **`language`** (`fr`, `en`, `es`, etc.), **not the locale** (`fr-FR`, `en-US`, etc.) 16 | 17 | _It is not out of scope though, PR are welcome to support universal locale detection._ 18 | 19 | --- 20 | 21 | 22 | 23 | * [Demo](#demo) 24 | * [Getting started](#getting-started) 25 | * [Examples](#examples) 26 | * [API](#api) 27 | + [`universalLanguageDetect`](#universallanguagedetect) 28 | * [Contributing](#contributing) 29 | + [Working locally](#working-locally) 30 | + [Test](#test) 31 | + [Versions](#versions) 32 | - [SemVer](#semver) 33 | + [Releasing and publishing](#releasing-and-publishing) 34 | * [Changelog](#changelog) 35 | * [License](#license) 36 | - [Vulnerability disclosure](#vulnerability-disclosure) 37 | - [Contributors and maintainers](#contributors-and-maintainers) 38 | - [**[ABOUT UNLY]**](#about-unly-) 39 | 40 | 41 | 42 | --- 43 | 44 | ## Demo 45 | 46 | [Live demo with the Next.js example](https://universal-language-detector.now.sh/) 47 | 48 | ## Getting started 49 | 50 | ``` 51 | yarn install @unly/universal-language-detector 52 | ``` 53 | 54 | Use: 55 | 56 | ``` 57 | import universalLanguageDetect from '@unly/universal-language-detector'; 58 | 59 | OR 60 | 61 | import { universalLanguageDetect } from '@unly/universal-language-detector'; 62 | ``` 63 | 64 | ## Examples 65 | 66 | See [our example](./examples/with-next) featuring the Next.js framework 67 | 68 | --- 69 | 70 | ## API 71 | 72 | > Extensive API documentation can be found in the [source code documentation](./src/index.ts) 73 | > 74 | > Only the most useful API methods are documented here, the other aren't meant to be used, even though they may be 75 | 76 | ### `universalLanguageDetect` 77 | 78 | > Detects the language used, universally. 79 | 80 | **Parameters:** 81 | - `supportedLanguages`: string[]; 82 | - `fallbackLanguage`: string; 83 | - `acceptLanguageHeader?`: string | undefined; 84 | - `serverCookies?`: object | undefined; 85 | - `errorHandler?`: [ErrorHandler](./src/utils/error.ts) | undefined; 86 | 87 | **Example:** 88 | ```js 89 | const lang = universalLanguageDetect({ 90 | supportedLanguages: SUPPORTED_LANGUAGES, // Whitelist of supported languages, will be used to filter out languages that aren't supported 91 | fallbackLanguage: FALLBACK_LANG, // Fallback language in case the user's language cannot be resolved 92 | acceptLanguageHeader: get(req, 'headers.accept-language'), // Optional - Accept-language header will be used when resolving the language on the server side 93 | serverCookies: cookies, // Optional - Cookie "i18next" takes precedence over navigator configuration (ex: "i18next: fr"), will only be used on the server side 94 | errorHandler: (error) => { // Optional - Use you own logger here, Sentry, etc. 95 | console.log('Custom error handler:'); 96 | console.error(error); 97 | }, 98 | }); 99 | ``` 100 | 101 | --- 102 | 103 | ## Contributing 104 | 105 | We gladly accept PRs, but please open an issue first so we can discuss it beforehand. 106 | 107 | ### Working locally 108 | 109 | ``` 110 | yarn start # Shortcut - Runs linter + build + tests in concurrent mode (watch mode) 111 | 112 | OR run each process separately for finer control 113 | 114 | yarn lint 115 | yarn build 116 | yarn test 117 | ``` 118 | 119 | ### Test 120 | 121 | ``` 122 | yarn test # Run all tests, interactive and watch mode 123 | yarn test:once 124 | yarn test:coverage 125 | ``` 126 | 127 | ### Versions 128 | 129 | #### SemVer 130 | 131 | We use Semantic Versioning for this project: https://semver.org/. (`vMAJOR.MINOR.PATCH`: `v1.0.1`) 132 | 133 | - Major version: Must be changed when Breaking Changes are made (public API isn't backward compatible). 134 | - A function has been renamed/removed from the public API 135 | - Something has changed that will cause the app to behave differently with the same configuration 136 | - Minor version: Must be changed when a new feature is added or updated (without breaking change nor behavioral change) 137 | - Patch version: Must be changed when any change is made that isn't either Major nor Minor. (Misc, doc, etc.) 138 | 139 | ### Releasing and publishing 140 | 141 | ``` 142 | yarn releaseAndPublish # Shortcut - Will prompt for bump version, commit, create git tag, push commit/tag and publish to NPM 143 | 144 | yarn release # Will prompt for bump version, commit, create git tag, push commit/tag 145 | npm publish # Will publish to NPM 146 | ``` 147 | 148 | > Don't forget we are using SemVer, please follow our SemVer rules. 149 | 150 | **Pro hint**: use `beta` tag if you're in a work-in-progress (or unsure) to avoid releasing WIP versions that looks legit 151 | 152 | --- 153 | 154 | ## Changelog 155 | 156 | > Our API change (including breaking changes and "how to migrate") are documented in the Changelog. 157 | 158 | See [changelog](./CHANGELOG.md) 159 | 160 | --- 161 | 162 | ## License 163 | 164 | MIT 165 | 166 | --- 167 | 168 | > This project was generated using https://github.com/UnlyEd/boilerplate-generator/tree/master/templates/typescript-OSS 169 | 170 | # Vulnerability disclosure 171 | 172 | [See our policy](https://github.com/UnlyEd/Unly). 173 | 174 | --- 175 | 176 | # Contributors and maintainers 177 | 178 | This project is being maintained by: 179 | - [Unly] Ambroise Dhenain ([Vadorequest](https://github.com/vadorequest)) **(active)** 180 | 181 | --- 182 | 183 | # **[ABOUT UNLY]** Unly logo 184 | 185 | > [Unly](https://unly.org) is a socially responsible company, fighting inequality and facilitating access to higher education. 186 | > Unly is committed to making education more inclusive, through responsible funding for students. 187 | 188 | We provide technological solutions to help students find the necessary funding for their studies. 189 | 190 | We proudly participate in many TechForGood initiatives. To support and learn more about our actions to make education accessible, visit : 191 | - https://twitter.com/UnlyEd 192 | - https://www.facebook.com/UnlyEd/ 193 | - https://www.linkedin.com/company/unly 194 | - [Interested to work with us?](https://jobs.zenploy.io/unly/about) 195 | 196 | Tech tips and tricks from our CTO on our [Medium page](https://medium.com/unly-org/tech/home)! 197 | 198 | #TECHFORGOOD #EDUCATIONFORALL -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-typescript', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | env: 4 | # Please refer to https://github.com/UnlyEd/slack-codebuild 5 | variables: 6 | SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/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" 7 | CODEBUILD_NOTIFY_ONLY_IF_FAIL: 1 8 | CC_TEST_REPORTER_ID: 119c96b7eb866b1e2a12e2bb86b96792a4b0a7353b7450c9b1cf4bc13286d4fd 9 | 10 | phases: 11 | install: 12 | runtime-versions: 13 | docker: 18 14 | nodejs: 10 15 | commands: 16 | - yarn --production=false # Install devDependencies (to run tests) - See https://yarnpkg.com/lang/en/docs/cli/install/#toc-yarn-install-production-true-false 17 | - yarn global add @unly/slack-codebuild 18 | - echo Installing codebuild-extras... # Install and execute aws-codebuild-extra, which adds env variables necessary on CodeBuild (including some for CodeClimate) 19 | - curl -fsSL https://raw.githubusercontent.com/UnlyEd/aws-codebuild-extras/master/install >> extras.sh 20 | - . ./extras.sh 21 | 22 | # See https://github.com/codeclimate/test-reporter/issues/379 for additional info regarding how to setup CodeBuild with CodeClimate 23 | pre_build: 24 | commands: 25 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 26 | - chmod +x ./cc-test-reporter 27 | - ./cc-test-reporter before-build 28 | 29 | build: 30 | commands: 31 | - yarn test:coverage 32 | 33 | post_build: 34 | commands: 35 | - ./cc-test-reporter format-coverage -t lcov --prefix ${CODEBUILD_SRC_DIR} # Looks for ./coverage/lcov.info 36 | - ./cc-test-reporter after-build --debug -t lcov --exit-code $? # Uploads ./coverage/lcov.info and ./coverage/codeclimate.json 37 | finally: 38 | - slack-codebuild 39 | -------------------------------------------------------------------------------- /examples/with-next/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /examples/with-next/README.md: -------------------------------------------------------------------------------- 1 | # Universal Language Detector - With Next.js example 2 | 3 | See the [_app.js](./pages/_app.js) file, that's where the magic happens. 4 | 5 | In this demo, we automatically resolve the language for all pages through the _app file, which makes the `lang` variable available in all pages. 6 | 7 | It works from the server side (SSR), and upon client side navigation too 8 | 9 | --- 10 | 11 | ## Demo 12 | 13 | [https://universal-language-detector.now.sh/](https://universal-language-detector.now.sh/) 14 | 15 | --- 16 | 17 | ## Contributing 18 | 19 | ### Development 20 | 21 | `yarn start` 22 | 23 | ### Deploying 24 | 25 | `yarn deploy` 26 | 27 | ### Deploying to production 28 | 29 | `yarn deploy:production` 30 | -------------------------------------------------------------------------------- /examples/with-next/components/footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import packageJson from '../package' 3 | 4 | const footer = (props) => { 5 | const uldVersion = packageJson.dependencies['@unly/universal-language-detector']; 6 | 7 | return ( 8 | <> 9 |
10 | GitHub 11 | 12 |
18 | Demo using `@unly/universal-language-detector`: '{uldVersion}' 19 |
20 |
21 | 22 | 28 | 29 | ); 30 | }; 31 | 32 | export default footer; 33 | -------------------------------------------------------------------------------- /examples/with-next/components/i18nCard.js: -------------------------------------------------------------------------------- 1 | import { COOKIE_LOOKUP_KEY_LANG } from '@unly/universal-language-detector'; 2 | import Cookies from 'js-cookie'; 3 | import React from 'react'; 4 | 5 | import { FALLBACK_LANG, SUPPORTED_LANGUAGES } from '../utils/i18n'; 6 | 7 | const i18nCard = (props) => { 8 | const { lang } = props; 9 | 10 | return ( 11 | <> 12 |
13 | Detected language:
{lang}
14 | Using fallback language (if lang cannot be resolved):
{FALLBACK_LANG}
15 | Using supported languages (unsupported languages will be ignored):
{SUPPORTED_LANGUAGES.join(', ')}
16 |
17 | 18 |
19 | Change the language (set a cookie that will take precedence over the browser language preferences): 20 |
21 | 29 | 37 | 45 | 53 | 54 |
55 | 56 | 64 | 65 |
71 | When using cookies, the "Deutsch" cookie will be ignored, because it's not within the supported languages 72 |
73 |
74 |
75 | 76 | 89 | 90 | ); 91 | }; 92 | 93 | export default i18nCard; 94 | -------------------------------------------------------------------------------- /examples/with-next/components/nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | 4 | const Nav = () => ( 5 | 41 | ) 42 | 43 | export default Nav 44 | -------------------------------------------------------------------------------- /examples/with-next/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "universal-language-detector", 4 | "scope": "team_qnVfSEVc2WwmOE1OYhZr4VST", 5 | "env": {}, 6 | "build": { 7 | "env": {} 8 | }, 9 | "routes": [], 10 | "public": false 11 | } 12 | -------------------------------------------------------------------------------- /examples/with-next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-next", 3 | "version": "2.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "yarn dev", 9 | "deploy:all": "yarn deploy && yarn deploy:production", 10 | "deploy": "now", 11 | "deploy:production": "now --prod" 12 | }, 13 | "dependencies": { 14 | "@unly/universal-language-detector": "2.0.3", 15 | "js-cookie": "2.2.1", 16 | "next": "9.1.6", 17 | "next-cookies": "2.0.3", 18 | "react": "16.12.0", 19 | "react-dom": "16.12.0" 20 | }, 21 | "devDependencies": { 22 | "now": "16.7.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/with-next/pages/_app.js: -------------------------------------------------------------------------------- 1 | import universalLanguageDetect from '@unly/universal-language-detector'; 2 | import get from 'lodash.get'; 3 | import NextCookies from 'next-cookies'; 4 | import NextApp from 'next/app'; 5 | import React from 'react'; 6 | 7 | import { FALLBACK_LANG, SUPPORTED_LANGUAGES } from '../utils/i18n'; 8 | 9 | class App extends NextApp { 10 | static async getInitialProps(props) { 11 | const { ctx } = props; 12 | const { req } = ctx; 13 | const cookies = NextCookies(ctx); // Parses Next.js cookies in a universal way (server + client) - It's an object 14 | 15 | // Universally detects the user's language 16 | const lang = universalLanguageDetect({ 17 | supportedLanguages: SUPPORTED_LANGUAGES, // Whitelist of supported languages, will be used to filter out languages that aren't supported 18 | fallbackLanguage: FALLBACK_LANG, // Fallback language in case the user's language cannot be resolved 19 | acceptLanguageHeader: get(req, 'headers.accept-language'), // Optional - Accept-language header will be used when resolving the language on the server side 20 | serverCookies: cookies, // Optional - Cookie "i18next" takes precedence over navigator configuration (ex: "i18next: fr"), will only be used on the server side 21 | errorHandler: (error, level, origin, context) => { // Optional - Use you own logger here, Sentry, etc. 22 | console.log('Custom error handler:'); 23 | console.error(error); 24 | 25 | // Example if using Sentry in your app: 26 | // Sentry.withScope((scope): void => { 27 | // scope.setExtra('level', level); 28 | // scope.setExtra('origin', origin); 29 | // scope.setContext('context', context); 30 | // Sentry.captureException(error); 31 | // }); 32 | }, 33 | }); 34 | console.log('lang', lang) 35 | 36 | // Calls page's `getInitialProps` and fills `appProps.pageProps` - XXX See https://nextjs.org/docs#custom-app 37 | const appProps = await NextApp.getInitialProps(props); 38 | 39 | appProps.pageProps = { 40 | ...appProps.pageProps, 41 | cookies, // Object containing all cookies 42 | lang, // i.e: 'en' 43 | isSSR: !!req, 44 | }; 45 | 46 | return { ...appProps }; 47 | } 48 | 49 | render() { 50 | const { Component, pageProps, router, err } = this.props; 51 | const modifiedPageProps = { 52 | ...pageProps, 53 | err, 54 | router, 55 | }; 56 | 57 | return ( 58 | 59 | ); 60 | } 61 | } 62 | 63 | export default App; 64 | -------------------------------------------------------------------------------- /examples/with-next/pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import Footer from '../components/footer'; 5 | 6 | import Nav from '../components/nav'; 7 | import I18nCard from '../components/i18nCard'; 8 | 9 | const Home = (props) => { 10 | const { lang, isSSR } = props; 11 | 12 | return ( 13 |
14 | 15 | Home 16 | 17 | 18 | 19 |
87 | ); 88 | }; 89 | 90 | export default Home; 91 | -------------------------------------------------------------------------------- /examples/with-next/pages/page2.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import Footer from '../components/footer'; 5 | import I18nCard from '../components/i18nCard'; 6 | 7 | import Nav from '../components/nav'; 8 | 9 | const Page2 = (props) => { 10 | const { lang, isSSR } = props; 11 | 12 | return ( 13 |
14 | 15 | Page 2 16 | 17 | 18 | 19 |
87 | ); 88 | }; 89 | 90 | export default Page2; 91 | -------------------------------------------------------------------------------- /examples/with-next/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnlyEd/universal-language-detector/2637628bfe7e12f79a2b128bd0cca26c5b657ff3/examples/with-next/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-next/utils/i18n.js: -------------------------------------------------------------------------------- 1 | export const FALLBACK_LANG = 'es'; 2 | export const SUPPORTED_LANGUAGES = [ 3 | 'fr', 4 | 'en', 5 | 'es', 6 | ]; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | testEnvironment: 'node', 4 | preset: 'ts-jest', 5 | roots: [ 6 | '/src', 7 | ], 8 | transform: { 9 | '^.+\\.tsx?$': 'ts-jest', 10 | }, 11 | collectCoverageFrom: [ 12 | "src/**/*.{ts,tsx}", 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unly/universal-language-detector", 3 | "version": "2.0.3", 4 | "description": "Language detector that works universally (browser + server) - Meant to be used with a universal framework, such as Next.js", 5 | "scripts": { 6 | "start": "cross-env-shell 'concurrently -p '{name}' -n 'lint,build,test' -c 'gray.bgWhite,yellow.bgBlue,green.bgWhite' \"yarn lint\" \"yarn build\" \"yarn test\"'", 7 | "build": "tsc -w", 8 | "build:once": "tsc", 9 | "clean": "rm -rf lib/", 10 | "lint": "esw src -w --ext .ts --ext .tsx", 11 | "lint:once": "eslint src --ext .ts --ext .tsx", 12 | "lint:fix": "eslint src --ext .ts --ext .tsx --fix", 13 | "lint:fix:preview": "eslint src --ext .ts --ext .tsx --fix-dry-run", 14 | "preversion": "yarn lint:once && yarn test:once && yarn doc:toc", 15 | "postversion": "git add README.md CHANGELOG.md && git commit --amend --no-edit && git push && git push --tags", 16 | "prepublishOnly": "yarn clean && yarn build:once && yarn publish:preview && cli-confirm \"Do you really want to release a new version? Please check the files that will be publicly released first. (y/n)\"", 17 | "publish:preview": "npm pack && tar -xvzf *.tgz && rm -rf package *.tgz", 18 | "release": "yarn bump --prompt --commit --tag", 19 | "releaseAndPublish": "yarn release && npm publish", 20 | "doc:toc": "yarn markdown-toc --maxdepth 4 -i README.md", 21 | "test": "NODE_ENV=test jest --watchAll", 22 | "test:once": "NODE_ENV=test jest", 23 | "test:coverage": "NODE_ENV=test jest --coverage" 24 | }, 25 | "main": "lib/index.js", 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/UnlyEd/universal-language-detector.git" 29 | }, 30 | "author": "unlyEd", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/UnlyEd/universal-language-detector/issues" 34 | }, 35 | "homepage": "https://github.com/UnlyEd/universal-language-detector", 36 | "publishConfig": { 37 | "registry": "https://registry.npmjs.org/", 38 | "access": "public" 39 | }, 40 | "files": [ 41 | "/lib" 42 | ], 43 | "jest": { 44 | "setupFilesAfterEnv": [ 45 | "jest-extended" 46 | ], 47 | "verbose": true 48 | }, 49 | "dependencies": { 50 | "@unly/iso3166-1": "1.0.2", 51 | "@unly/utils": "1.0.3", 52 | "accept-language-parser": "1.5.0", 53 | "i18next": "19.0.2", 54 | "i18next-browser-languagedetector": "4.0.1", 55 | "lodash.get": "4.4.2", 56 | "lodash.includes": "4.3.0" 57 | }, 58 | "devDependencies": { 59 | "@babel/core": "7.5.0", 60 | "@babel/preset-env": "7.5.0", 61 | "@babel/preset-typescript": "7.3.3", 62 | "@types/jest": "24.0.25", 63 | "@types/lodash": "4.14.149", 64 | "@types/node": "13.1.1", 65 | "@typescript-eslint/eslint-plugin": "2.13.0", 66 | "@typescript-eslint/parser": "2.13.0", 67 | "@unly/cli-confirm": "1.1.1", 68 | "babel-jest": "24.9.0", 69 | "concurrently": "4.1.0", 70 | "cross-env": "5.2.0", 71 | "eslint": "6.8.0", 72 | "eslint-config-airbnb-base": "14.0.0", 73 | "eslint-plugin-import": "2.19.1", 74 | "eslint-plugin-jest": "23.2.0", 75 | "eslint-watch": "6.0.1", 76 | "jest": "24.9.0", 77 | "jsdoc-to-markdown": "5.0.3", 78 | "markdown-toc": "1.2.0", 79 | "ts-jest": "24.2.0", 80 | "typescript": "3.7.4", 81 | "version-bump-prompt": "4.2.2" 82 | } 83 | } -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { COOKIE_LOOKUP_KEY_LANG, universalLanguageDetect } from './index'; 2 | 3 | const LANG_EN = 'en'; 4 | const LANG_FR = 'fr'; 5 | const LANG_ES = 'es'; 6 | const LANG_DE = 'de'; 7 | 8 | describe(`index.ts`, () => { 9 | describe(`universalLanguageDetect`, () => { 10 | describe(`should resolve the proper locale on the server`, () => { 11 | beforeEach(() => { 12 | // @ts-ignore 13 | global[`window`] = undefined; // Reset to avoid tests affecting other tests 14 | // @ts-ignore 15 | global[`document`] = undefined; // Reset to avoid tests affecting other tests 16 | }); 17 | 18 | test(`when using "acceptLanguageHeader"`, async () => { 19 | expect(universalLanguageDetect({ 20 | fallbackLanguage: LANG_EN, 21 | acceptLanguageHeader: `fr,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 22 | supportedLanguages: [LANG_EN, LANG_FR], 23 | }), 24 | ).toEqual(LANG_FR); 25 | 26 | expect(universalLanguageDetect({ 27 | fallbackLanguage: LANG_EN, 28 | acceptLanguageHeader: `fr,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 29 | supportedLanguages: [LANG_EN], 30 | }), 31 | ).toEqual(LANG_EN); 32 | 33 | expect(universalLanguageDetect({ 34 | fallbackLanguage: LANG_ES, 35 | acceptLanguageHeader: `fr,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 36 | supportedLanguages: [LANG_DE, LANG_ES], 37 | }), 38 | ).toEqual(LANG_DE); 39 | }); 40 | 41 | test(`when the language is stored in a cookie, it should use the cookie's value`, async () => { 42 | const cookieLanguage = LANG_FR; 43 | 44 | expect(universalLanguageDetect({ 45 | fallbackLanguage: LANG_EN, 46 | serverCookies: { 47 | [COOKIE_LOOKUP_KEY_LANG]: cookieLanguage, 48 | }, 49 | supportedLanguages: [LANG_EN, LANG_FR], 50 | })).toEqual(cookieLanguage); 51 | }); 52 | 53 | test(`when using both cookies and acceptLanguageHeader, it should use cookies in priority`, async () => { 54 | const cookieLanguage = LANG_ES; 55 | 56 | expect(universalLanguageDetect({ 57 | fallbackLanguage: LANG_DE, 58 | serverCookies: { 59 | [COOKIE_LOOKUP_KEY_LANG]: cookieLanguage, 60 | }, 61 | acceptLanguageHeader: `fr,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 62 | supportedLanguages: [LANG_EN, LANG_FR, LANG_DE, LANG_ES], 63 | })).toEqual(cookieLanguage); 64 | }); 65 | }); 66 | 67 | describe(`should resolve the proper locale on the browser`, () => { 68 | beforeEach(() => { 69 | // @ts-ignore 70 | global[`window`] = {}; // Make believe we're running against a browser 71 | // @ts-ignore 72 | global[`document`] = undefined; // Reset to avoid tests affecting other tests 73 | }); 74 | 75 | test(`when using a fallback value, it should fallback to the provided fallback value`, async () => { 76 | expect(universalLanguageDetect({ 77 | fallbackLanguage: LANG_EN, 78 | supportedLanguages: [LANG_EN, LANG_FR], 79 | })).toEqual(LANG_EN); 80 | }); 81 | 82 | test(`when relying on "navigator" resolver`, async () => { 83 | // @ts-ignore 84 | global[`navigator`] = { 85 | languages: [LANG_EN, LANG_FR], 86 | }; 87 | 88 | expect(universalLanguageDetect({ 89 | fallbackLanguage: LANG_FR, 90 | supportedLanguages: [LANG_EN, LANG_FR], 91 | })).toEqual(LANG_EN); 92 | }); 93 | 94 | test(`when relying on "navigator" resolver and the device uses localised languages (eg: "en-US")`, async () => { 95 | // @ts-ignore 96 | global[`navigator`] = { 97 | languages: ["en-US", "fr-FR", "fr", "en"], 98 | }; 99 | 100 | expect(universalLanguageDetect({ 101 | fallbackLanguage: LANG_FR, 102 | supportedLanguages: [LANG_EN, LANG_FR], 103 | })).toEqual(LANG_EN); 104 | }); 105 | 106 | test(`when the language is stored in a cookie, it should use the cookie's value`, async () => { 107 | const cookieLanguage = 'fr'; 108 | 109 | // Make believe a cookie is available 110 | // @ts-ignore 111 | global[`document`] = { 112 | cookie: `${COOKIE_LOOKUP_KEY_LANG}=${cookieLanguage};`, 113 | }; 114 | 115 | expect(universalLanguageDetect({ 116 | fallbackLanguage: LANG_EN, 117 | supportedLanguages: [LANG_EN, LANG_FR], 118 | })).toEqual(cookieLanguage); 119 | }); 120 | }); 121 | 122 | describe(`should throw an error when misconfigured`, () => { 123 | test(`when the supported languages isn't properly defined`, async () => { 124 | expect(() => { 125 | universalLanguageDetect({ 126 | fallbackLanguage: LANG_EN, 127 | supportedLanguages: [], 128 | }); 129 | }).toThrowError(); 130 | 131 | expect(() => { 132 | universalLanguageDetect({ 133 | fallbackLanguage: LANG_EN, 134 | // @ts-ignore 135 | supportedLanguages: undefined, 136 | }); 137 | }).toThrowError(); 138 | 139 | expect(() => { 140 | universalLanguageDetect({ 141 | fallbackLanguage: LANG_EN, 142 | // @ts-ignore 143 | supportedLanguages: null, 144 | }); 145 | }).toThrowError(); 146 | }); 147 | 148 | test(`when the fallback language isn't present in the supported languages`, async () => { 149 | expect(() => { 150 | universalLanguageDetect({ 151 | fallbackLanguage: LANG_EN, 152 | supportedLanguages: [LANG_FR], 153 | }); 154 | }).toThrowError(); 155 | }); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from '@unly/utils'; 2 | import I18next from 'i18next'; 3 | import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'; 4 | import includes from 'lodash.includes'; 5 | import get from 'lodash.get'; 6 | 7 | import AcceptLanguageDetector from './serverDetectors/acceptLanguage'; 8 | import ServerCookieDetector from './serverDetectors/serverCookie'; 9 | import FallbackDetector from './universalDetectors/fallback'; 10 | import { ErrorHandler } from './utils/error'; 11 | 12 | /** 13 | * Lookup key that contains the value of the user's selected language 14 | * 15 | * @see https://github.com/i18next/i18next-browser-languageDetector#detector-options 16 | * 17 | * @type {string} 18 | */ 19 | export const COOKIE_LOOKUP_KEY_LANG = 'i18next'; 20 | 21 | /** 22 | * Detects the user's language, universally (browser + server) 23 | * Internally relies on i18next to help resolve the language 24 | * 25 | * Language lookup: 26 | * - On the server, relies on cookies (provided), then on the accept-language header 27 | * - On the browser, relies on cookies (global), then navigator's language 28 | * 29 | * @param props 30 | * @return {string} 31 | */ 32 | export const universalLanguageDetect = (props: { 33 | supportedLanguages: string[]; 34 | fallbackLanguage: string; 35 | acceptLanguageHeader?: string | undefined; 36 | serverCookies?: object | undefined; 37 | errorHandler?: ErrorHandler | undefined; 38 | }): string => { 39 | const { 40 | supportedLanguages, 41 | fallbackLanguage, 42 | acceptLanguageHeader = undefined, 43 | serverCookies = undefined, 44 | errorHandler = undefined, 45 | } = props; 46 | 47 | if(!get(supportedLanguages, 'length')){ 48 | throw new Error(`universal-language-detector is misconfigured. Your "supportedLanguages" should be an array containing at least one language (eg: ['en']).`); 49 | } 50 | 51 | if(!includes(supportedLanguages, fallbackLanguage)){ 52 | throw new Error(`universal-language-detector is misconfigured. Your "fallbackLanguage" (value: "${fallbackLanguage}") should be within your "supportedLanguages" array.`); 53 | } 54 | 55 | // Init may be async, but it doesn't matter here, because we just want to init the services (which is sync) so that we may use them 56 | I18next.init({ 57 | whitelist: supportedLanguages, // Filter out unsupported languages (i.e: when using "navigator" detector) - See https://www.i18next.com/overview/configuration-options#languages-namespaces-resources 58 | nonExplicitWhitelist: true, // We only provide "simple" supported languages ("en", "fr", etc.) - This option ensures en-US and alike are still matched and not ignored - See https://www.i18next.com/overview/configuration-options#languages-namespaces-resources 59 | }); 60 | 61 | const i18nextServices = I18next.services; 62 | const i18nextUniversalLanguageDetector = new I18nextBrowserLanguageDetector(); 63 | 64 | // Add common detectors between the browser and the server 65 | const fallbackDetector = FallbackDetector(fallbackLanguage); 66 | i18nextUniversalLanguageDetector.addDetector(fallbackDetector); 67 | 68 | if (isBrowser()) { 69 | // Rely on native i18next detectors 70 | i18nextUniversalLanguageDetector.init(i18nextServices, { 71 | order: ['cookie', 'navigator', fallbackDetector.name], 72 | lookupCookie: COOKIE_LOOKUP_KEY_LANG, 73 | }); 74 | 75 | } else { 76 | // Use our own detectors 77 | const serverCookieDetector = ServerCookieDetector(serverCookies); 78 | const acceptLanguageDetector = AcceptLanguageDetector(supportedLanguages, acceptLanguageHeader, errorHandler); 79 | 80 | i18nextUniversalLanguageDetector.addDetector(serverCookieDetector); 81 | i18nextUniversalLanguageDetector.addDetector(acceptLanguageDetector); 82 | 83 | i18nextUniversalLanguageDetector.init(i18nextServices, { 84 | order: [serverCookieDetector.name, acceptLanguageDetector.name, fallbackDetector.name], 85 | lookupCookie: COOKIE_LOOKUP_KEY_LANG, 86 | }); 87 | } 88 | 89 | // Transform potential localised language into non-localised language: 90 | // "en-US" > "en" 91 | // "en" > "en" 92 | return (i18nextUniversalLanguageDetector.detect() as string).split('-')[0]; 93 | }; 94 | 95 | export default universalLanguageDetect; 96 | -------------------------------------------------------------------------------- /src/serverDetectors/acceptLanguage.test.ts: -------------------------------------------------------------------------------- 1 | import { _resolveAcceptLanguage } from './acceptLanguage'; 2 | 3 | const LANG_EN = 'en'; 4 | const LANG_FR = 'fr'; 5 | const LANG_ES = 'es'; 6 | const LANG_DE = 'de'; 7 | 8 | describe(`serverDetectors/acceptLanguage.ts`, () => { 9 | describe(`_resolveAcceptLanguage`, () => { 10 | describe(`when using languages ('fr', 'en', etc.) as acceptLanguageHeader`, () => { 11 | test(`should resolve the main language, when the main language is supported`, async () => { 12 | expect(_resolveAcceptLanguage( 13 | [LANG_EN, LANG_FR], 14 | `fr,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 15 | )).toEqual(LANG_FR); 16 | }); 17 | 18 | test(`should resolve the fallback language, when the main language is not supported`, async () => { 19 | expect(_resolveAcceptLanguage( 20 | [LANG_EN], 21 | `fr,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 22 | )).toEqual(LANG_EN); 23 | }); 24 | 25 | test(`should resolve the best supported language, when the main language is not supported but there is another supported language available`, async () => { 26 | expect(_resolveAcceptLanguage( 27 | [LANG_EN, LANG_ES], 28 | `fr,es,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 29 | )).toEqual(LANG_ES); 30 | 31 | expect(_resolveAcceptLanguage( 32 | [LANG_EN, LANG_ES], 33 | `fr,es;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 34 | )).toEqual(LANG_ES); 35 | 36 | expect(_resolveAcceptLanguage( 37 | [LANG_DE], 38 | `fr,es;q=0.9,en-GB;q=0.8,en-US;q=0.7,de;q=0.6`, 39 | )).toEqual(LANG_DE); 40 | }); 41 | }); 42 | 43 | describe(`when using localized languages ('en-GB', 'en-US', etc.) as acceptLanguageHeader`, () => { 44 | test(`should resolve the main language, when the main language is supported`, async () => { 45 | expect(_resolveAcceptLanguage( 46 | [LANG_EN, LANG_FR], 47 | 'en-GB,en-US;q=0.9,fr-CA;q=0.7,en;q=0.8', 48 | )).toEqual(LANG_EN); 49 | }); 50 | 51 | test(`should resolve the fallback language, when the main language is not supported`, async () => { 52 | expect(_resolveAcceptLanguage( 53 | [LANG_EN], 54 | 'en-GB,en-US;q=0.9,fr-CA;q=0.7,en;q=0.8', 55 | )).toEqual(LANG_EN); 56 | }); 57 | }); 58 | 59 | describe(`when using invalid acceptLanguageHeader`, () => { 60 | test(`should return undefined (empty string)`, async () => { 61 | expect(_resolveAcceptLanguage( 62 | [LANG_EN, LANG_FR], 63 | '', 64 | )).toEqual(undefined); 65 | }); 66 | 67 | test(`should return undefined (null)`, async () => { 68 | expect(_resolveAcceptLanguage( 69 | [LANG_EN, LANG_FR], 70 | // @ts-ignore 71 | null, 72 | )).toEqual(undefined); 73 | }); 74 | 75 | test(`should return undefined (undefined)`, async () => { 76 | expect(_resolveAcceptLanguage( 77 | [LANG_EN, LANG_FR], 78 | // @ts-ignore 79 | undefined, 80 | )).toEqual(undefined); 81 | }); 82 | 83 | test(`should return undefined (object)`, async () => { 84 | expect(_resolveAcceptLanguage( 85 | [LANG_EN, LANG_FR], 86 | // @ts-ignore 87 | {}, 88 | )).toEqual(undefined); 89 | }); 90 | 91 | test(`should return undefined (number)`, async () => { 92 | expect(_resolveAcceptLanguage( 93 | [LANG_EN, LANG_FR], 94 | // @ts-ignore 95 | 15, 96 | )).toEqual(undefined); 97 | }); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/serverDetectors/acceptLanguage.ts: -------------------------------------------------------------------------------- 1 | import acceptLanguageParser from 'accept-language-parser'; 2 | import { CustomDetector, DetectorOptions } from 'i18next-browser-languagedetector'; 3 | import iso3166 from '@unly/iso3166-1'; 4 | import { _defaultErrorHandler, ERROR_LEVELS, ErrorHandler } from '../utils/error'; 5 | 6 | /** 7 | * Resolves the best supported language from a accept-language header 8 | * Returns "undefined" if fails to resolve 9 | * 10 | * @param supportedLanguages 11 | * @param acceptLanguageHeader 12 | * @param errorHandler 13 | * @private 14 | */ 15 | export const _resolveAcceptLanguage = (supportedLanguages: string[], acceptLanguageHeader: string | undefined, errorHandler: ErrorHandler = _defaultErrorHandler): string | undefined => { 16 | let bestSupportedLanguage: string | undefined; 17 | 18 | try { 19 | // Resolves the best language to used, based on an array of supportedLanguages (filters out disallowed languages) 20 | // acceptLanguageParser.pick returns either a language ('fr') or a locale ('fr-FR') 21 | bestSupportedLanguage = acceptLanguageParser.pick(supportedLanguages, acceptLanguageHeader, { 22 | loose: true, // See https://www.npmjs.com/package/accept-language-parser#parserpicksupportedlangugagesarray-acceptlanguageheader-options-- 23 | }); 24 | 25 | } catch (e) { 26 | errorHandler(e, ERROR_LEVELS.ERROR, '_resolveAcceptLanguage', { 27 | inputs: { 28 | supportedLanguages, 29 | acceptLanguageHeader, 30 | }, 31 | }); 32 | } 33 | 34 | if (bestSupportedLanguage) { 35 | try { 36 | // Attempts to convert the language/locale into an actual language (2 chars string) 37 | return iso3166.to2(iso3166.fromLocale(bestSupportedLanguage)).toLowerCase(); 38 | } catch (e) { 39 | errorHandler(e, ERROR_LEVELS.ERROR, '_resolveAcceptLanguage', { 40 | inputs: { 41 | supportedLanguages, 42 | acceptLanguageHeader, 43 | }, 44 | bestSupportedLanguage, 45 | }); 46 | } 47 | } 48 | }; 49 | 50 | export const acceptLanguage = (supportedLanguages: string[], acceptLanguageHeader: string | undefined, errorHandler?: ErrorHandler | undefined): CustomDetector => { 51 | return { 52 | name: 'acceptLanguage', 53 | lookup: (options: DetectorOptions): string | undefined => { 54 | return _resolveAcceptLanguage(supportedLanguages, acceptLanguageHeader, errorHandler); 55 | }, 56 | cacheUserLanguage: (): void => { 57 | // Do nothing, can't cache the user language on the server 58 | }, 59 | }; 60 | }; 61 | 62 | export default acceptLanguage; 63 | -------------------------------------------------------------------------------- /src/serverDetectors/serverCookie.test.ts: -------------------------------------------------------------------------------- 1 | import { COOKIE_LOOKUP_KEY_LANG } from '../index'; 2 | import { _resolveServerCookie } from './serverCookie'; 3 | 4 | const LANG_FR = 'fr'; 5 | const LANG_DE = 'de'; 6 | 7 | describe(`serverDetectors/serverCookie.ts`, () => { 8 | describe(`_resolveServerCookie`, () => { 9 | test(`should resolve the cookie's value when the cookie is defined`, async () => { 10 | expect(_resolveServerCookie({ 11 | [COOKIE_LOOKUP_KEY_LANG]: LANG_DE, 12 | })).toEqual(LANG_DE); 13 | 14 | expect(_resolveServerCookie({ 15 | [COOKIE_LOOKUP_KEY_LANG]: LANG_FR, 16 | })).toEqual(LANG_FR); 17 | }); 18 | 19 | test(`should return undefined when the cookie is not defined`, async () => { 20 | expect(_resolveServerCookie({})).toEqual(undefined); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/serverDetectors/serverCookie.ts: -------------------------------------------------------------------------------- 1 | import { CustomDetector, DetectorOptions } from 'i18next-browser-languagedetector'; 2 | import get from 'lodash.get'; 3 | 4 | import { COOKIE_LOOKUP_KEY_LANG } from '../index'; 5 | 6 | /** 7 | * Resolves the language from a cookie object 8 | * Returns "undefined" if fails to resolve 9 | * 10 | * @param serverCookies 11 | * @private 12 | */ 13 | export const _resolveServerCookie = (serverCookies: object | undefined): string | undefined => { 14 | return get(serverCookies, COOKIE_LOOKUP_KEY_LANG, undefined); 15 | }; 16 | 17 | export const serverCookie = (serverCookies: object | undefined): CustomDetector => { 18 | return { 19 | name: 'serverCookie', 20 | lookup: (options: DetectorOptions): string | undefined => { 21 | return _resolveServerCookie(serverCookies); 22 | }, 23 | cacheUserLanguage: (): void => { 24 | // Do nothing, we could cache the value but it doesn't make sense to overwrite the cookie with the same value 25 | }, 26 | }; 27 | }; 28 | 29 | export default serverCookie; 30 | -------------------------------------------------------------------------------- /src/types/allowJavaScriptModules.d.ts: -------------------------------------------------------------------------------- 1 | // Treat modules as "any" by default, if no specific types were provided for them 2 | // See https://blog.atomist.com/declaration-file-fix/ 3 | declare module '*'; 4 | -------------------------------------------------------------------------------- /src/universalDetectors/fallback.ts: -------------------------------------------------------------------------------- 1 | import { CustomDetector, DetectorOptions } from 'i18next-browser-languagedetector'; 2 | 3 | export const fallback = (fallbackLanguage: string): CustomDetector => { 4 | return { 5 | name: 'fallback', 6 | lookup: (options: DetectorOptions): string => { 7 | return fallbackLanguage; 8 | }, 9 | cacheUserLanguage: (): void => { 10 | // Do nothing, can't cache the user language on the server 11 | }, 12 | }; 13 | }; 14 | 15 | export default fallback; 16 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | export enum ERROR_LEVELS { 2 | ERROR = 'error', 3 | WARNING = 'warning', 4 | } 5 | 6 | export const LEVEL_ERROR = ERROR_LEVELS.ERROR; 7 | export const LEVEL_WARNING = ERROR_LEVELS.WARNING; 8 | 9 | export declare type ErrorHandler = ( 10 | error: Error, 11 | level: ERROR_LEVELS, 12 | origin: string, // Origin of the error (function's name) 13 | context?: object, // Additional data context to help further debug 14 | ) => void; 15 | 16 | /** 17 | * Default error handler 18 | * Doesn't do anything but log the error to the console 19 | * 20 | * @param error 21 | * @param level 22 | * @private 23 | */ 24 | export const _defaultErrorHandler: ErrorHandler = (error: Error, level: ERROR_LEVELS): void => { 25 | if (level === LEVEL_ERROR) { 26 | // eslint-disable-next-line no-console 27 | console.error(error); 28 | } else if (level === LEVEL_WARNING) { 29 | // eslint-disable-next-line no-console 30 | console.warn(error); 31 | } else { 32 | // eslint-disable-next-line no-console 33 | console.error(error); 34 | } 35 | }; 36 | 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "lib": [ 9 | "dom", 10 | "dom.iterable", 11 | "es2017" 12 | ], 13 | "target": "es2018", 14 | "module": "commonjs", 15 | "moduleResolution": "node", 16 | "noEmit": false, 17 | "noUnusedLocals": false, 18 | "noUnusedParameters": false, 19 | "preserveConstEnums": true, 20 | "resolveJsonModule": true, 21 | "skipLibCheck": true, 22 | "sourceMap": true, 23 | "declaration": true, 24 | "outDir": "lib", 25 | "strict": true, 26 | "removeComments": true 27 | }, 28 | "include": [ 29 | "src/**/*" 30 | ], 31 | "exclude": [ 32 | "node_modules", 33 | "src/**/*.test.*" 34 | ] 35 | } 36 | --------------------------------------------------------------------------------