├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── index.js ├── locale.js └── lookup │ ├── accept-language.js │ ├── cookie.js │ ├── default.js │ ├── hostname.js │ ├── map.js │ └── query.js └── test └── index.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "standard" 9 | ], 10 | "globals": { 11 | "Atomics": "readonly", 12 | "SharedArrayBuffer": "readonly" 13 | }, 14 | "parserOptions": { 15 | "ecmaVersion": 2018 16 | }, 17 | "rules": { 18 | "semi": ["error", "always"] 19 | } 20 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 22 15 | os: 16 | - ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm install 23 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .eslintrc.js 4 | test 5 | .gitignore 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [2.0.0] - 2019-08-30 8 | ### Changed 9 | - Format locale using hyphen instead of underscore (thx @benpptung) 10 | - Drop express 3.x support 11 | - Drop non-lts/latest node support (remove babel build) 12 | 13 | ### Added 14 | - Warn about invalid configuration in non-production environment 15 | 16 | ## [1.2.0] - 2019-03-21 17 | ### Added 18 | - Module usage (`module` entry in package.json) 19 | 20 | ## [1.1.0] - 2019-03-21 21 | ### Added 22 | - Request property configuration option (thx @nachaos) 23 | 24 | ## [1.0.5] - 2017-11-05 25 | ### Fixed 26 | - Tests on old node (0.1x) 27 | 28 | ## [1.0.4] - 2017-10-10 29 | ### Fixed 30 | - Rollback dependency update for old node support 31 | 32 | ## [1.0.3] - 2017-10-10 33 | ### Added 34 | - License file (thx @marionebl) 35 | 36 | ## [1.0.2] - 2017-07-02 37 | ### Fixed 38 | - Babel runtime inclusion 39 | 40 | ## [1.0.1] - 2016-12-06 41 | ### Changed 42 | - Readme code fix 43 | 44 | ## [1.0.0] - 2016-11-05 45 | ### Added 46 | - Support for Express version 4 47 | - Query string lookup 48 | 49 | ### Changed 50 | - Renamed `domain` lookup to `hostname` 51 | - Lookup configuration lives under the same keys as the priority list items 52 | - Language to locale mapping became a lookup 53 | - Custom lookups are supplied within the configuration 54 | - Codebase now uses ES6 syntax 55 | 56 | ## [0.1.2] - 2014-06-28 57 | ### Fixed 58 | - Changed en_UK into en_GB 59 | 60 | ## [0.1.1] - 2014-06-24 61 | ### Added 62 | - First working version 63 | 64 | [Unreleased]: https://github.com/smhg/express-locale/compare/v2.0.0...HEAD 65 | [2.0.0]: https://github.com/smhg/express-locale/compare/v1.2.0...v2.0.0 66 | [1.2.0]: https://github.com/smhg/express-locale/compare/v1.1.0...v1.2.0 67 | [1.1.0]: https://github.com/smhg/express-locale/compare/v1.0.5...v1.1.0 68 | [1.0.5]: https://github.com/smhg/express-locale/compare/v1.0.4...v1.0.5 69 | [1.0.4]: https://github.com/smhg/express-locale/compare/v1.0.3...v1.0.4 70 | [1.0.3]: https://github.com/smhg/express-locale/compare/v1.0.2...v1.0.3 71 | [1.0.2]: https://github.com/smhg/express-locale/compare/v1.0.1...v1.0.2 72 | [1.0.1]: https://github.com/smhg/express-locale/compare/v1.0.0...v1.0.1 73 | [1.0.0]: https://github.com/smhg/express-locale/compare/v0.1.2...v1.0.0 74 | [0.1.2]: https://github.com/smhg/express-locale/compare/v0.1.1...v0.1.2 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-present Sam Hauglustaine 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 | express-locale [![CI](https://github.com/smhg/express-locale/actions/workflows/ci.yml/badge.svg)](https://github.com/smhg/express-locale/actions/workflows/ci.yml) 2 | ============== 3 | 4 | Express middleware to determine the [locale identifier](https://en.wikipedia.org/wiki/Locale_(computer_software)) of the incomming request. 5 | 6 | It returns (only) full locale identifiers based on the middleware's configuration. Configuration defines possible sources, their order and, optionally, a whitelist. For performance reasons, on each request, remaining lookups are ignored as soon as a match is found. 7 | 8 | > Use version 1.x for Express 3 support and/or older Node versions. 9 | 10 | ## Installation 11 | `npm install --save express-locale` 12 | 13 | ## Usage 14 | ```javascript 15 | import express from 'express'; 16 | import createLocaleMiddleware from 'express-locale'; 17 | 18 | express() 19 | .use(createLocaleMiddleware()) 20 | .use((req, res) => { 21 | res.end(`Request locale: ${req.locale}`); 22 | }) 23 | .listen(3000); 24 | ``` 25 | 26 | The `locale` property on the request object will contain an object with these properties: 27 | ```json 28 | { 29 | "source": "default", 30 | "language": "en", 31 | "region": "GB" 32 | } 33 | ``` 34 | When using this object in a string context, its `toString` method returns the locale identifier (`en-GB` in the example above). 35 | 36 | **Note:** only full locales (language-REGION) are returned, but a [mapping](#map) of languages to a default locale can be provided as a lookup. 37 | 38 | 39 | ## Configuration 40 | You can pass a configuration object to `createLocaleMiddleware()` with the default being: 41 | ```json 42 | { 43 | "priority": ["accept-language", "default"], 44 | "default": "en-GB" 45 | } 46 | ``` 47 | This tells the middleware to use 2 sources in order: `accept-language`, which has no configuration, and `default` which is set to `en-GB`. 48 | 49 | The name of the lookup used in the priority list always matches the configuration key. 50 | 51 | ### Options 52 | 53 | * `priority` Array, default: `['accept-language', 'default']` 54 | 55 | Defines the order of lookups. The first lookup to return a full locale will be the final result. 56 | 57 | Built-in lookups: 58 | * `cookie` 59 | * `query` 60 | * `hostname` 61 | * `accept-language` 62 | * `map` 63 | * `default` 64 | 65 | Read below on how to add custom lookups. 66 | 67 | * `allowed` Array 68 | 69 | If provided, each lookup's results are validated against this whitelist. 70 | 71 | > **Note:** since this validation happens after each lookup, values which will still pass through the next lookup (like when using `map`) need to also be whitelisted as in the example below. This will likely be addressed in a future version (see [#20](https://github.com/smhg/express-locale/issues/20)). 72 | ```javascript 73 | // example 74 | createLocaleMiddleware({ 75 | priority: ['accept-language', 'cookie', 'map'], 76 | map: { 77 | 'en': 'en-US', 78 | 'fr': 'fr-CA' 79 | }, 80 | allowed: ['en', 'fr', 'en-US', 'fr-CA'] 81 | }); 82 | ``` 83 | 84 | * `requestProperty` String, default `'locale'` 85 | 86 | The property on the request object (`req`) in which the locale is set. 87 | 88 | * `cookie` Object, default `'{name: 'locale'}'` 89 | 90 | The `name` of the cookie that contains the locale for the cookie lookup. 91 | 92 | Use with [cookie-parser](https://github.com/expressjs/cookie-parser) middleware. 93 | 94 | * `query` Object, default `'{name: 'locale'}'` 95 | 96 | The `name` of the query string parameter that contains the locale for the query lookup. 97 | 98 | * `hostname` Object 99 | 100 | A mapping of hostnames to locales for the hostname lookup. 101 | ```javascript 102 | // example 103 | createLocaleMiddleware({ 104 | priority: ['hostname'], 105 | map: { 106 | 'en.wikipedia.org': 'en-US', 107 | 'nl.wikipedia.org': 'nl-NL' 108 | } 109 | }); 110 | ``` 111 | 112 | * `map` Object 113 | 114 | Maps lookup results that return only a language to a full locale. 115 | ```javascript 116 | // example 117 | createLocaleMiddleware({ 118 | priority: ['accept-language', 'cookie', 'map'], 119 | map: { 120 | 'en': 'en-US', 121 | 'fr': 'fr-CA' 122 | } 123 | }); 124 | ``` 125 | 126 | * `default` String, default `'en-GB'` 127 | 128 | The default locale for the default lookup. 129 | 130 | * `lookups` Object 131 | 132 | Add custom lookups or overwrite the default ones by using the `lookups` property. 133 | ```javascript 134 | // example 135 | createLocaleMiddleware({ 136 | priority: ['custom'], 137 | lookups: { 138 | custom: (req) => req.ip === '127.0.0.1' ? 'en-US' : undefined 139 | } 140 | }); 141 | ``` 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-locale", 3 | "version": "2.0.2", 4 | "description": "Express middleware to determine locale", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "eslint src test", 8 | "test": "node --test", 9 | "coverage": "node --test --experimental-test-coverage", 10 | "preversion": "npm run lint && npm test", 11 | "postversion": "git push && git push --tags", 12 | "prepublishOnly": "npm run lint && npm run test" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/smhg/express-locale.git" 17 | }, 18 | "keywords": [ 19 | "locale", 20 | "express", 21 | "i18n", 22 | "l10n", 23 | "culture" 24 | ], 25 | "author": "Sam Hauglustaine", 26 | "license": "MIT", 27 | "devDependencies": { 28 | "cookie-parser": "^1.4.6", 29 | "eslint": "^8.16.0", 30 | "eslint-config-standard": "^17.0.0", 31 | "eslint-plugin-import": "^2.20.1", 32 | "eslint-plugin-node": "^11.0.0", 33 | "eslint-plugin-promise": "^6.0.0", 34 | "express": "^4.18.2", 35 | "supertest": "^7.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const createCookieLookup = require('./lookup/cookie'); 2 | const createQueryLookup = require('./lookup/query'); 3 | const createHostnameLookup = require('./lookup/hostname'); 4 | const createDefaultLookup = require('./lookup/default'); 5 | const createAcceptLanguageLookup = require('./lookup/accept-language'); 6 | const createMapLookup = require('./lookup/map'); 7 | const createLocale = require('./locale'); 8 | 9 | const LOOKUP_CREATORS = { 10 | cookie: createCookieLookup, 11 | query: createQueryLookup, 12 | hostname: createHostnameLookup, 13 | 'accept-language': createAcceptLanguageLookup, 14 | map: createMapLookup, 15 | default: createDefaultLookup 16 | }; 17 | 18 | const nonLocaleCharacters = /[^a-z]/ig; 19 | const trailingHyphens = /^-+|-+$/g; 20 | const languageOrLocale = /^[a-z]{2}(?:-[a-z]{2})?$/i; 21 | 22 | function trimLocale (locale) { 23 | return locale 24 | .replace(nonLocaleCharacters, '-') 25 | .replace(trailingHyphens, ''); 26 | } 27 | 28 | function isLanguageOrLocale (locale) { 29 | return languageOrLocale.test(locale); 30 | } 31 | 32 | function createLocaleMiddleware (options = {}) { 33 | options = { 34 | priority: ['accept-language', 'default'], 35 | requestProperty: 'locale', 36 | ...options 37 | }; 38 | 39 | if (typeof options.priority === 'string') { 40 | options.priority = options.priority.split(/ *, */g); 41 | } 42 | 43 | options.priority = options.priority.map( 44 | name => name.toLowerCase() in LOOKUP_CREATORS ? name.toLowerCase() : name 45 | ); 46 | 47 | options.lookups = options.lookups || {}; 48 | 49 | const isDefined = name => (name in LOOKUP_CREATORS) || (name in options.lookups); 50 | 51 | if (!options.priority.every(isDefined)) { 52 | const notFound = options.priority.find(name => !isDefined(name)); 53 | 54 | throw new Error(`Undefined lookup (${notFound})`); 55 | } 56 | 57 | const lookups = new Map(options.priority.map( 58 | name => [ 59 | name, 60 | name in options.lookups 61 | ? options.lookups[name] 62 | : LOOKUP_CREATORS[name](options[name]) 63 | ] 64 | )); 65 | 66 | const isAllowed = locale => !options.allowed || options.allowed.indexOf(locale) >= 0; 67 | 68 | if (process.env.NODE_ENV !== 'production') { 69 | // validate configuration 70 | [...lookups] 71 | .forEach(([name, { uses = [] }]) => { 72 | uses.filter(locale => !isAllowed(locale)) 73 | .forEach(locale => { 74 | throw new Error(`Invalid configuration (locale '${locale}' in lookup '${name}' should be whitelisted)`); 75 | }); 76 | }); 77 | } 78 | 79 | function * lookup (req, all) { 80 | for (const source of options.priority) { 81 | let locales = lookups.get(source)(req, all); 82 | 83 | if (typeof locales === 'string') { 84 | locales = [locales]; 85 | } 86 | 87 | if (Array.isArray(locales) && locales.length > 0) { 88 | locales = locales 89 | .map(trimLocale) 90 | .filter(isLanguageOrLocale) 91 | .filter(isAllowed) 92 | .map(code => createLocale(code, source)); 93 | 94 | for (const locale of locales) { 95 | yield locale; 96 | } 97 | } 98 | } 99 | } 100 | 101 | const middleware = function (req, res, next) { 102 | const locales = []; 103 | let result; 104 | let languageBuffer; 105 | 106 | function filterResult (locale) { 107 | if ('region' in locale) { 108 | if (languageBuffer) { 109 | if (languageBuffer.language === locale.language) { 110 | if (languageBuffer.source !== locale.source) { 111 | locale.source = [languageBuffer.source, locale.source]; 112 | } 113 | 114 | return locale; 115 | } 116 | } else { 117 | return locale; 118 | } 119 | } else { 120 | if (!languageBuffer) { 121 | languageBuffer = locale; 122 | } 123 | } 124 | } 125 | 126 | // perform lookups one by one, exiting early 127 | for (const locale of lookup(req, locales)) { 128 | if ((result = filterResult(locale))) { 129 | break; 130 | } 131 | 132 | locales.push(locale); 133 | } 134 | 135 | // if no early exit was found, eliminate results one by one 136 | while (!result && locales.length > 0) { 137 | languageBuffer = undefined; 138 | locales.shift(); 139 | 140 | for (const locale of locales) { 141 | if ((result = filterResult(locale))) { 142 | break; 143 | } 144 | } 145 | } 146 | 147 | req[options.requestProperty] = result; 148 | 149 | next(); 150 | }; 151 | 152 | return middleware; 153 | } 154 | 155 | module.exports = createLocaleMiddleware; 156 | -------------------------------------------------------------------------------- /src/locale.js: -------------------------------------------------------------------------------- 1 | function splitLocale (locale) { 2 | const [, language, region] = locale.match(/([a-z]{2})(?:-([a-z]{2}))?/i); 3 | 4 | const result = { language: language.toLowerCase() }; 5 | 6 | if (region) { 7 | result.region = region.toUpperCase(); 8 | } 9 | 10 | return result; 11 | }; 12 | 13 | function createLocale (code, source) { 14 | let cachedString; 15 | 16 | const proto = { 17 | ...splitLocale(code), 18 | toString: () => { 19 | if (!cachedString) { 20 | cachedString = `${proto.language}-${proto.region}`; 21 | } 22 | 23 | return cachedString; 24 | } 25 | }; 26 | 27 | if (source) { 28 | proto.source = source; 29 | } 30 | 31 | return proto; 32 | } 33 | 34 | module.exports = createLocale; 35 | -------------------------------------------------------------------------------- /src/lookup/accept-language.js: -------------------------------------------------------------------------------- 1 | function createAcceptLanguageLookup () { 2 | return function lookupAcceptLanguage (req) { 3 | let locales; 4 | 5 | if ('acceptsLanguages' in req) { 6 | locales = req.acceptsLanguages(); 7 | } else if ('acceptedLanguages' in req) { 8 | locales = req.acceptedLanguages; 9 | } 10 | 11 | if (!Array.isArray(locales)) { 12 | return; 13 | } 14 | 15 | if (locales.length <= 0) { 16 | return; 17 | } 18 | 19 | return locales; 20 | }; 21 | } 22 | 23 | module.exports = createAcceptLanguageLookup; 24 | -------------------------------------------------------------------------------- /src/lookup/cookie.js: -------------------------------------------------------------------------------- 1 | function createCookieLookup ({ name = 'locale' } = {}) { 2 | const noNameError = new Error('A cookie name is required for cookie locale lookup'); 3 | 4 | if (typeof name !== 'string' || name.trim().length <= 0) { 5 | throw noNameError; 6 | } 7 | 8 | return function lookupCookie (req) { 9 | if (!('cookies' in req)) { 10 | return; 11 | } 12 | 13 | if (!(name in req.cookies)) { 14 | return; 15 | } 16 | 17 | return req.cookies[name]; 18 | }; 19 | }; 20 | 21 | module.exports = createCookieLookup; 22 | -------------------------------------------------------------------------------- /src/lookup/default.js: -------------------------------------------------------------------------------- 1 | function createDefaultLookup (locale = 'en-GB') { 2 | const invalidLocaleError = new Error('A valid locale is required for default lookup'); 3 | 4 | if (typeof locale !== 'string') { 5 | throw invalidLocaleError; 6 | } 7 | 8 | locale = locale.trim(); 9 | 10 | if (locale.length !== 5) { 11 | throw invalidLocaleError; 12 | } 13 | 14 | function lookupDefault () { 15 | return locale; 16 | }; 17 | 18 | lookupDefault.uses = [locale]; 19 | 20 | return lookupDefault; 21 | }; 22 | 23 | module.exports = createDefaultLookup; 24 | -------------------------------------------------------------------------------- /src/lookup/hostname.js: -------------------------------------------------------------------------------- 1 | function createHostnameLookup (map = {}) { 2 | function lookupHostname (req) { 3 | const hostname = req.hostname || req.host; 4 | 5 | if (!(hostname in map)) { 6 | return; 7 | } 8 | 9 | return map[hostname]; 10 | }; 11 | 12 | lookupHostname.uses = Object.values(map); 13 | 14 | return lookupHostname; 15 | } 16 | 17 | module.exports = createHostnameLookup; 18 | -------------------------------------------------------------------------------- /src/lookup/map.js: -------------------------------------------------------------------------------- 1 | function createMapLookup (map = {}) { 2 | function lookupMap (req, locales) { 3 | return locales 4 | .filter(locale => !locale.region && locale.language in map) 5 | .map(locale => map[locale.language]); 6 | }; 7 | 8 | lookupMap.uses = Object.values(map); 9 | 10 | return lookupMap; 11 | }; 12 | 13 | module.exports = createMapLookup; 14 | -------------------------------------------------------------------------------- /src/lookup/query.js: -------------------------------------------------------------------------------- 1 | function createQueryLookup ({ name = 'locale' } = {}) { 2 | const noNameError = new Error('A query string parameter name is required for query string locale lookup'); 3 | 4 | if (typeof name !== 'string' || name.trim().length <= 0) { 5 | throw noNameError; 6 | } 7 | 8 | return function lookupQuery (req) { 9 | if (!(name in req.query)) { 10 | return; 11 | } 12 | 13 | return req.query[name]; 14 | }; 15 | }; 16 | 17 | module.exports = createQueryLookup; 18 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const { describe, it } = require('node:test'); 2 | const assert = require('assert'); 3 | const request = require('supertest'); 4 | const express = require('express'); 5 | const cookieParser = require('cookie-parser'); 6 | const createLocaleMiddleware = require('../src'); 7 | 8 | const createServer = (middlewareOptions) => { 9 | return express() 10 | .use(cookieParser()) 11 | .use(createLocaleMiddleware(middlewareOptions)) 12 | .get('/', (req, res) => { 13 | res.json(req.locale); 14 | }); 15 | }; 16 | 17 | describe('()', () => { 18 | it('should return a function', () => { 19 | const type = typeof createLocaleMiddleware(); 20 | assert.strictEqual(type, 'function'); 21 | }); 22 | 23 | it('should extend the request object adding the default requestProperty', () => { 24 | const localeMiddleware = createLocaleMiddleware(); 25 | 26 | const req = {}; 27 | localeMiddleware(req, {}, () => {}); 28 | assert('locale' in req); 29 | assert.notStrictEqual(req.locale, undefined); 30 | }); 31 | 32 | it('should extend the request object adding the custom requestProperty', () => { 33 | const localeMiddleware = createLocaleMiddleware({ requestProperty: 'custom-locale' }); 34 | 35 | const req = {}; 36 | localeMiddleware(req, {}, () => {}); 37 | assert('custom-locale' in req); 38 | assert.notStrictEqual(req['custom-locale'], undefined); 39 | }); 40 | 41 | it('should support custom lookup methods', () => { 42 | const localeMiddleware = createLocaleMiddleware({ 43 | priority: ['custom'], 44 | lookups: { 45 | custom: () => 'fr-FR' 46 | } 47 | }); 48 | 49 | const req = {}; 50 | 51 | localeMiddleware(req, {}, () => {}); 52 | 53 | assert.strictEqual(req.locale.toString(), 'fr-FR'); 54 | assert.strictEqual(req.locale.source, 'custom'); 55 | }); 56 | 57 | it('should check existence of custom lookup methods', () => { 58 | function createUndefinedLookup () { 59 | createLocaleMiddleware({ 60 | priority: ['abc', 'def', 'ghi'], 61 | lookups: { 62 | abc: () => 'fr_FR' 63 | } 64 | }); 65 | } 66 | 67 | assert.throws(createUndefinedLookup, new Error('Undefined lookup (def)')); 68 | }); 69 | 70 | it('should warn about invalid configuration in non-production env', () => { 71 | function createInvalidConfig () { 72 | createLocaleMiddleware({ 73 | priority: ['default'], 74 | allowed: ['nl-BE', 'fr-BE'], 75 | default: 'en-GB' 76 | }); 77 | } 78 | 79 | assert.throws(createInvalidConfig, new Error('Invalid configuration (locale \'en-GB\' in lookup \'default\' should be whitelisted)')); 80 | }); 81 | }); 82 | 83 | describe('with Express', () => { 84 | it('should hook into Express', async () => { 85 | await request(createServer()) 86 | .get('/') 87 | .expect(200); 88 | }); 89 | 90 | it('should return default', async () => { 91 | await request(createServer()) 92 | .get('/') 93 | .expect({ 94 | source: 'default', 95 | language: 'en', 96 | region: 'GB' 97 | }); 98 | }); 99 | 100 | it('should parse accept-language header', async () => { 101 | await request(createServer({ 102 | priority: 'accept-language' 103 | })) 104 | .get('/') 105 | .set('Accept-Language', 'de-CH;q=0.8,en-GB;q=0.6') 106 | .expect({ 107 | source: 'accept-language', 108 | language: 'de', 109 | region: 'CH' 110 | }); 111 | }); 112 | 113 | describe('should handle lookup letter case', () => { 114 | it('forcing lower case for default lookups', async () => { 115 | await request(createServer({ 116 | priority: 'Accept-Language' 117 | })) 118 | .get('/') 119 | .set('Accept-Language', 'es-MX;q=0.8,en-GB;q=0.6') 120 | .expect({ 121 | source: 'accept-language', 122 | language: 'es', 123 | region: 'MX' 124 | }); 125 | }); 126 | 127 | it('ignoring for custom lookups', async () => { 128 | await request(createServer({ 129 | priority: 'customLookup', 130 | lookups: { 131 | customLookup: () => 'fr_FR' 132 | } 133 | })) 134 | .get('/') 135 | .expect({ 136 | source: 'customLookup', 137 | language: 'fr', 138 | region: 'FR' 139 | }); 140 | }); 141 | }); 142 | 143 | it('should read cookie', async () => { 144 | await request(createServer({ 145 | cookie: { name: 'lang' }, 146 | priority: 'cookie' 147 | })) 148 | .get('/') 149 | .set('Cookie', 'lang=nl-BE') 150 | .expect({ 151 | source: 'cookie', 152 | language: 'nl', 153 | region: 'BE' 154 | }); 155 | }); 156 | 157 | it('should read cookie with locale in underscore format', async () => { 158 | await request(createServer({ 159 | cookie: { name: 'lang' }, 160 | priority: 'cookie' 161 | })) 162 | .get('/') 163 | .set('Cookie', 'lang=nl_BE') 164 | .expect({ 165 | source: 'cookie', 166 | language: 'nl', 167 | region: 'BE' 168 | }); 169 | }); 170 | 171 | it('should parse query string', async () => { 172 | await request(createServer({ 173 | query: { name: 'l' }, 174 | priority: 'query' 175 | })) 176 | .get('/?l=fr-CA') 177 | .expect({ 178 | source: 'query', 179 | language: 'fr', 180 | region: 'CA' 181 | }); 182 | }); 183 | 184 | it('should map hostname', async () => { 185 | await request(createServer({ 186 | hostname: { '127.0.0.1': 'nl-BE' }, 187 | priority: 'hostname' 188 | })) 189 | .get('/') 190 | .expect({ 191 | source: 'hostname', 192 | language: 'nl', 193 | region: 'BE' 194 | }); 195 | }); 196 | 197 | it('should validate against a whitelist', async () => { 198 | await request(createServer({ 199 | default: 'de-DE', 200 | allowed: ['de-DE', 'de-AT', 'de-CH'] 201 | })) 202 | .get('/') 203 | .set('Accept-Language', 'en,en-GB;q=0.8') 204 | .expect({ 205 | source: 'default', 206 | language: 'de', 207 | region: 'DE' 208 | }); 209 | }); 210 | 211 | it('should map a language to a default', async () => { 212 | await request(createServer({ 213 | priority: 'cookie,map', 214 | map: { de: 'de-DE' } 215 | })) 216 | .get('/') 217 | .set('Cookie', 'locale=de') 218 | .expect({ 219 | source: ['cookie', 'map'], 220 | language: 'de', 221 | region: 'DE' 222 | }); 223 | }); 224 | 225 | it('should ignore values not whitelisted', async () => { 226 | await request(createServer({ 227 | priority: ['query', 'map'], 228 | allowed: ['en-CA', 'fr-CA'], 229 | map: { en: 'en-CA', fr: 'fr-CA' } 230 | })) 231 | .get('/?locale=fr') 232 | .expect(''); 233 | }); 234 | 235 | it('should skip mapping if the same language returns in the next locale', async () => { 236 | await request(createServer({ 237 | map: { de: 'de-DE' } 238 | })) 239 | .get('/') 240 | .set('Accept-Language', 'de,de-CH;q=0.8,en;q=0.6') 241 | .expect({ 242 | source: 'accept-language', 243 | language: 'de', 244 | region: 'CH' 245 | }); 246 | }); 247 | 248 | it('should handle multiple lookups', async () => { 249 | await request(createServer({ 250 | priority: ['cookie', 'query', 'accept-language', 'map', 'default'], 251 | map: { cs: 'cs-CZ' } 252 | })) 253 | .get('/') 254 | .set('Accept-Language', 'cs,de;q=0.8,de-AT;q=0.6') 255 | .expect({ 256 | source: ['accept-language', 'map'], 257 | language: 'cs', 258 | region: 'CZ' 259 | }); 260 | }); 261 | 262 | it('should work', async () => { 263 | await request(createServer({ 264 | priority: ['cookie', 'query', 'accept-language', 'map', 'default'], 265 | map: { en: 'en-GB' } 266 | })) 267 | .get('/?locale=en') 268 | .set('Accept-Language', 'nl,nl-BE;q=0.8,en-US;q=0.6') 269 | .expect({ 270 | source: ['query', 'accept-language'], 271 | language: 'en', 272 | region: 'US' 273 | }); 274 | }); 275 | }); 276 | --------------------------------------------------------------------------------