├── .eslintignore ├── tslint.json ├── index.js ├── .prettierrc ├── .babelrc ├── index.d.mts ├── .npmignore ├── tsconfig.nonEsModuleInterop.json ├── .editorconfig ├── .gitignore ├── src ├── browserLookups │ ├── htmlTag.js │ ├── path.js │ ├── navigator.js │ ├── subdomain.js │ ├── querystring.js │ ├── sessionStorage.js │ ├── hash.js │ ├── localStorage.js │ └── cookie.js ├── utils.js └── index.js ├── tsconfig.json ├── .eslintrc ├── .github └── stale.yml ├── LICENSE ├── rollup.config.js ├── test ├── typescript │ └── usage.test.ts └── languageDetector.test.js ├── index.d.ts ├── package.json ├── README.md ├── i18nextBrowserLanguageDetector.min.js ├── CHANGELOG.md └── i18nextBrowserLanguageDetector.js /.eslintignore: -------------------------------------------------------------------------------- 1 | **/dist/* 2 | **/node_modules/* 3 | **/*.min.* 4 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": "dtslint/dtslint.json", 4 | "rules": { 5 | "semicolon": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint no-var: 0 */ 2 | var main = require('./dist/cjs/i18nextBrowserLanguageDetector.js'); 3 | 4 | module.exports = main; 5 | module.exports.default = main; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "jsxBracketSameLine": false, 4 | "printWidth": 100, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { 7 | "browsers": ["defaults"] 8 | } 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /index.d.mts: -------------------------------------------------------------------------------- 1 | import * as index from './index.js'; 2 | 3 | export default index.default; 4 | 5 | export type DetectorOptions = index.DetectorOptions; 6 | export type CustomDetector = index.CustomDetector; 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | coverage/ 3 | src/ 4 | .babelrc 5 | .editorconfig 6 | .eslintignore 7 | .eslintrc 8 | .gitignore 9 | bower.json 10 | gulpfile.js 11 | karma.conf.js 12 | rollup.config.js 13 | tsconfig.json 14 | tslint.json -------------------------------------------------------------------------------- /tsconfig.nonEsModuleInterop.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | // typescript defaults to these 5 | "esModuleInterop": false, 6 | "allowSyntheticDefaultImports": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | root = true 3 | 4 | [*.{js,jsx,json}] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore specific files 2 | .settings.xml 3 | .monitor 4 | .DS_Store 5 | *.orig 6 | npm-debug.log 7 | npm-debug.log.* 8 | *.dat 9 | 10 | # Ignore various temporary files 11 | *~ 12 | *.swp 13 | 14 | 15 | # Ignore various Node.js related directories and files 16 | node_modules 17 | node_modules/**/* 18 | coverage/**/* 19 | dist/**/* 20 | -------------------------------------------------------------------------------- /src/browserLookups/htmlTag.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'htmlTag', 3 | 4 | // Deconstruct the options object and extract the htmlTag property 5 | lookup({ htmlTag }) { 6 | let found; 7 | const internalHtmlTag = htmlTag || (typeof document !== 'undefined' ? document.documentElement : null); 8 | 9 | if (internalHtmlTag && typeof internalHtmlTag.getAttribute === 'function') { 10 | found = internalHtmlTag.getAttribute('lang'); 11 | } 12 | 13 | return found; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/browserLookups/path.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'path', 3 | 4 | // Deconstruct the options object and extract the lookupFromPathIndex property 5 | lookup({ lookupFromPathIndex }) { 6 | 7 | if (typeof window === 'undefined') return undefined; 8 | 9 | const language = window.location.pathname.match(/\/([a-zA-Z-]*)/g); 10 | if (!Array.isArray(language)) return undefined; 11 | 12 | const index = typeof lookupFromPathIndex === 'number' ? lookupFromPathIndex : 0; 13 | return language[index]?.replace('/', ''); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": ["es6", "dom"], 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "noEmit": true, 11 | "baseUrl": ".", 12 | 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | 16 | "paths": { "i18next-browser-languagedetector": ["./index.d.mts"] } 17 | }, 18 | "include": ["./indext.d.mts", "./test/**/*.ts*"], 19 | "exclude": ["test/typescript/nonEsModuleInterop/**/*.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /src/browserLookups/navigator.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'navigator', 3 | 4 | lookup(options) { 5 | const found = []; 6 | 7 | if (typeof navigator !== 'undefined') { 8 | const { languages, userLanguage, language } = navigator; 9 | if (languages) { 10 | // chrome only; not an array, so can't use .push.apply instead of iterating 11 | for (let i = 0; i < languages.length; i++) { 12 | found.push(languages[i]); 13 | } 14 | } 15 | if (userLanguage) { 16 | found.push(userLanguage); 17 | } 18 | if (language) { 19 | found.push(language); 20 | } 21 | } 22 | 23 | return found.length > 0 ? found : undefined; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | parser: "@babel/eslint-parser" 2 | extends: airbnb 3 | 4 | rules: 5 | max-len: [0, 100] 6 | no-constant-condition: 0 7 | arrow-body-style: [1, "as-needed"] 8 | comma-dangle: [2, "never"] 9 | padded-blocks: [0, "never"] 10 | no-unused-vars: [2, {vars: all, args: none}] 11 | no-param-reassign: 0 12 | guard-for-in: 0 13 | no-restricted-syntax: 0 14 | prefer-rest-params: 0 15 | import/extensions: 0 16 | no-plusplus: 0 17 | react/prop-types: 18 | - 0 19 | - ignore: #coming from hoc 20 | - location 21 | - fields 22 | - handleSubmit 23 | 24 | globals: 25 | expect: false 26 | window: false 27 | navigator: false 28 | document: false 29 | describe: false 30 | it: false 31 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | 2 | # Number of days of inactivity before an issue becomes stale 3 | daysUntilStale: 7 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | # Issues with these labels will never be considered stale 7 | exemptLabels: 8 | - "discussion" 9 | - "feature request" 10 | - "bug" 11 | - "breaking change" 12 | - "doc" 13 | - "issue" 14 | - "help wanted" 15 | - "good first issue" 16 | # Label to use when marking an issue as stale 17 | staleLabel: stale 18 | # Comment to post when marking an issue as stale. Set to `false` to disable 19 | markComment: > 20 | This issue has been automatically marked as stale because it has not had 21 | recent activity. It will be closed if no further activity occurs. Thank you 22 | for your contributions. 23 | # Comment to post when closing a stale issue. Set to `false` to disable 24 | closeComment: false -------------------------------------------------------------------------------- /src/browserLookups/subdomain.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'subdomain', 3 | 4 | lookup({ lookupFromSubdomainIndex }) { 5 | // If given get the subdomain index else 1 6 | const internalLookupFromSubdomainIndex = typeof lookupFromSubdomainIndex === 'number' ? lookupFromSubdomainIndex + 1 : 1; 7 | // get all matches if window.location. is existing 8 | // first item of match is the match itself and the second is the first group match which should be the first subdomain match 9 | // is the hostname no public domain get the or option of localhost 10 | const language = typeof window !== 'undefined' && window.location?.hostname?.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i); 11 | 12 | // if there is no match (null) return undefined 13 | if (!language) return undefined; 14 | // return the given group match 15 | return language[internalLookupFromSubdomainIndex]; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/browserLookups/querystring.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'querystring', 3 | 4 | // Deconstruct the options object and extract the lookupQuerystring property 5 | lookup({ lookupQuerystring }) { 6 | let found; 7 | 8 | if (typeof window !== 'undefined') { 9 | let { search } = window.location; 10 | if (!window.location.search && window.location.hash?.indexOf('?') > -1) { 11 | search = window.location.hash.substring(window.location.hash.indexOf('?')); 12 | } 13 | const query = search.substring(1); 14 | const params = query.split('&'); 15 | for (let i = 0; i < params.length; i++) { 16 | const pos = params[i].indexOf('='); 17 | if (pos > 0) { 18 | const key = params[i].substring(0, pos); 19 | if (key === lookupQuerystring) { 20 | found = params[i].substring(pos + 1); 21 | } 22 | } 23 | } 24 | } 25 | 26 | return found; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 i18next 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 | 23 | -------------------------------------------------------------------------------- /src/browserLookups/sessionStorage.js: -------------------------------------------------------------------------------- 1 | let hasSessionStorageSupport = null; 2 | 3 | const sessionStorageAvailable = () => { 4 | if (hasSessionStorageSupport !== null) return hasSessionStorageSupport; 5 | 6 | try { 7 | hasSessionStorageSupport = typeof window !== 'undefined' && window.sessionStorage !== null; 8 | if (!hasSessionStorageSupport) { return false; } 9 | const testKey = 'i18next.translate.boo'; 10 | window.sessionStorage.setItem(testKey, 'foo'); 11 | window.sessionStorage.removeItem(testKey); 12 | } catch (e) { 13 | hasSessionStorageSupport = false; 14 | } 15 | return hasSessionStorageSupport; 16 | }; 17 | 18 | export default { 19 | name: 'sessionStorage', 20 | 21 | lookup({ lookupSessionStorage }) { 22 | if (lookupSessionStorage && sessionStorageAvailable()) { 23 | return window.sessionStorage.getItem(lookupSessionStorage) || undefined; 24 | } 25 | return undefined; 26 | }, 27 | 28 | cacheUserLanguage(lng, { lookupSessionStorage }) { 29 | if (lookupSessionStorage && sessionStorageAvailable()) { 30 | window.sessionStorage.setItem(lookupSessionStorage, lng); 31 | } 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const { slice, forEach } = []; 2 | 3 | export function defaults(obj) { 4 | forEach.call(slice.call(arguments, 1), (source) => { 5 | if (source) { 6 | for (const prop in source) { 7 | if (obj[prop] === undefined) obj[prop] = source[prop]; 8 | } 9 | } 10 | }); 11 | return obj; 12 | } 13 | 14 | export function extend(obj) { 15 | forEach.call(slice.call(arguments, 1), (source) => { 16 | if (source) { 17 | for (const prop in source) { 18 | obj[prop] = source[prop]; 19 | } 20 | } 21 | }); 22 | return obj; 23 | } 24 | 25 | export function hasXSS(input) { 26 | if (typeof input !== 'string') return false; 27 | 28 | // Common XSS attack patterns 29 | const xssPatterns = [ 30 | /<\s*script.*?>/i, 31 | /<\s*\/\s*script\s*>/i, 32 | /<\s*img.*?on\w+\s*=/i, 33 | /<\s*\w+\s*on\w+\s*=.*?>/i, 34 | /javascript\s*:/i, 35 | /vbscript\s*:/i, 36 | /expression\s*\(/i, 37 | /eval\s*\(/i, 38 | /alert\s*\(/i, 39 | /document\.cookie/i, 40 | /document\.write\s*\(/i, 41 | /window\.location/i, 42 | /innerHTML/i 43 | ]; 44 | 45 | return xssPatterns.some((pattern) => pattern.test(input)); 46 | } 47 | -------------------------------------------------------------------------------- /src/browserLookups/hash.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'hash', 3 | 4 | // Deconstruct the options object and extract the lookupHash property and the lookupFromHashIndex property 5 | lookup({ lookupHash, lookupFromHashIndex }) { 6 | let found; 7 | 8 | if (typeof window !== 'undefined') { 9 | const { hash } = window.location; 10 | if (hash && hash.length > 2) { 11 | const query = hash.substring(1); 12 | if (lookupHash) { 13 | const params = query.split('&'); 14 | for (let i = 0; i < params.length; i++) { 15 | const pos = params[i].indexOf('='); 16 | if (pos > 0) { 17 | const key = params[i].substring(0, pos); 18 | if (key === lookupHash) { 19 | found = params[i].substring(pos + 1); 20 | } 21 | } 22 | } 23 | } 24 | 25 | if (found) return found; 26 | 27 | if (!found && lookupFromHashIndex > -1) { 28 | const language = hash.match(/\/([a-zA-Z-]*)/g); 29 | if (!Array.isArray(language)) return undefined; 30 | const index = typeof lookupFromHashIndex === 'number' ? lookupFromHashIndex : 0; 31 | return language[index]?.replace('/', ''); 32 | } 33 | } 34 | } 35 | 36 | return found; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/browserLookups/localStorage.js: -------------------------------------------------------------------------------- 1 | let hasLocalStorageSupport = null; 2 | 3 | const localStorageAvailable = () => { 4 | if (hasLocalStorageSupport !== null) return hasLocalStorageSupport; 5 | 6 | try { 7 | hasLocalStorageSupport = typeof window !== 'undefined' && window.localStorage !== null; 8 | if (!hasLocalStorageSupport) { return false; } 9 | const testKey = 'i18next.translate.boo'; 10 | window.localStorage.setItem(testKey, 'foo'); 11 | window.localStorage.removeItem(testKey); 12 | } catch (e) { 13 | hasLocalStorageSupport = false; 14 | } 15 | return hasLocalStorageSupport; 16 | }; 17 | 18 | export default { 19 | name: 'localStorage', 20 | 21 | // Deconstruct the options object and extract the lookupLocalStorage property 22 | lookup({ lookupLocalStorage }) { 23 | if (lookupLocalStorage && localStorageAvailable()) { 24 | return window.localStorage.getItem(lookupLocalStorage) || undefined; // Undefined ensures type consistency with the previous version of this function 25 | } 26 | return undefined; 27 | }, 28 | 29 | // Deconstruct the options object and extract the lookupLocalStorage property 30 | cacheUserLanguage(lng, { lookupLocalStorage }) { 31 | if (lookupLocalStorage && localStorageAvailable()) { 32 | window.localStorage.setItem(lookupLocalStorage, lng); 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import nodeResolve from '@rollup/plugin-node-resolve'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | import pkg from './package.json'; 5 | 6 | const getBabelOptions = ({ useESModules }) => ({ 7 | exclude: /node_modules/, 8 | babelHelpers: 'runtime', 9 | plugins: [ 10 | ['@babel/transform-runtime', { useESModules }] 11 | ] 12 | }); 13 | 14 | 15 | const input = './src/index.js'; 16 | const name = 'i18nextBrowserLanguageDetector' 17 | // check relative and absolute paths for windows and unix 18 | const external = id => !id.startsWith('.') && !id.startsWith('/') && !id.includes(':'); 19 | 20 | export default [ 21 | { 22 | input, 23 | output: { format: 'cjs', file: pkg.main }, 24 | external, 25 | plugins: [ 26 | babel(getBabelOptions({ useESModules: false })) 27 | ] 28 | }, 29 | 30 | { 31 | input, 32 | output: { format: 'esm', file: pkg.module }, 33 | external, 34 | plugins: [ 35 | babel(getBabelOptions({ useESModules: true })) 36 | ] 37 | }, 38 | 39 | { 40 | input, 41 | output: { format: 'umd', name, file: `dist/umd/${name}.js` }, 42 | plugins: [ 43 | babel(getBabelOptions({ useESModules: true })), 44 | nodeResolve() 45 | ], 46 | }, 47 | { 48 | input, 49 | output: { format: 'umd', name, file: `dist/umd/${name}.min.js` }, 50 | plugins: [ 51 | babel(getBabelOptions({ useESModules: true })), 52 | nodeResolve(), 53 | terser() 54 | ], 55 | } 56 | ] 57 | -------------------------------------------------------------------------------- /test/typescript/usage.test.ts: -------------------------------------------------------------------------------- 1 | import LngDetector, { CustomDetector, DetectorOptions } from 'i18next-browser-languagedetector'; 2 | import i18next from 'i18next'; 3 | 4 | /** 5 | * NOTE: only the imports should differ in these `usage*.ts` files 6 | */ 7 | 8 | const options: DetectorOptions = { 9 | // order and from where user language should be detected 10 | order: ['querystring', 'cookie', 'localStorage', 'navigator', 'htmlTag'], 11 | 12 | // keys or params to lookup language from 13 | lookupQuerystring: 'lng', 14 | lookupCookie: 'i18next', 15 | lookupLocalStorage: 'i18nextLng', 16 | 17 | // cache user language on 18 | caches: ['localStorage', 'cookie'], 19 | excludeCacheFor: ['cimode'], // languages to not persist (cookie, localStorage) 20 | 21 | // optional expire and domain for set cookie 22 | cookieMinutes: 10, 23 | cookieDomain: 'myDomain', 24 | 25 | // optional htmlTag with lang attribute, the default is: 26 | htmlTag: document.documentElement, 27 | }; 28 | 29 | const customDetector: CustomDetector = { 30 | name: 'myDetectorsName', 31 | 32 | lookup(options: DetectorOptions) { 33 | // options -> are passed in options 34 | return 'en'; 35 | }, 36 | 37 | cacheUserLanguage(lng: string, options: DetectorOptions) { 38 | // options -> are passed in options 39 | // lng -> current language, will be called after init and on changeLanguage 40 | // store it 41 | }, 42 | }; 43 | 44 | const customDetector2: CustomDetector = { 45 | name: 'myDetectorsName', 46 | lookup(options: DetectorOptions) { 47 | return undefined; 48 | }, 49 | }; 50 | 51 | const lngDetector = new LngDetector(null, options); 52 | 53 | lngDetector.init(options); 54 | lngDetector.addDetector(customDetector); 55 | 56 | // instance based 57 | i18next.use(lngDetector).init({}); 58 | 59 | // class based 60 | i18next.use(LngDetector).init({}); 61 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as i18next from 'i18next'; 2 | 3 | interface CookieOptions { 4 | maxAge?: number; 5 | expires?: Date; 6 | httpOnly?: boolean; 7 | path?: string; 8 | domain?: string; 9 | secure?: boolean; 10 | sameSite?: boolean | 'lax' | 'strict' | 'none'; 11 | } 12 | 13 | export interface DetectorOptions { 14 | /** 15 | * order and from where user language should be detected 16 | */ 17 | order?: Array< 18 | 'querystring' | 'cookie' | 'sessionStorage' | 'localStorage' | 'navigator' | 'htmlTag' | string 19 | >; 20 | 21 | /** 22 | * keys or params to lookup language from 23 | */ 24 | lookupQuerystring?: string; 25 | lookupCookie?: string; 26 | lookupSessionStorage?: string; 27 | lookupLocalStorage?: string; 28 | lookupFromPathIndex?: number; 29 | lookupFromSubdomainIndex?: number; 30 | 31 | /** 32 | * cache user language on 33 | */ 34 | caches?: string[]; 35 | 36 | /** 37 | * languages to not persist (cookie, localStorage) 38 | */ 39 | excludeCacheFor?: string[]; 40 | 41 | /** 42 | * optional expire for set cookie 43 | * @default 10 44 | */ 45 | cookieMinutes?: number; 46 | 47 | /** 48 | * optional domain for set cookie 49 | */ 50 | cookieDomain?: string; 51 | 52 | /** 53 | * optional cookie options 54 | */ 55 | cookieOptions?: CookieOptions 56 | 57 | /** 58 | * optional htmlTag with lang attribute 59 | * @default document.documentElement 60 | */ 61 | htmlTag?: HTMLElement | null; 62 | 63 | /** 64 | * optional conversion function to use to modify the detected language code 65 | */ 66 | convertDetectedLanguage?: 'Iso15897' | ((lng: string) => string); 67 | } 68 | 69 | export interface CustomDetector { 70 | name: string; 71 | cacheUserLanguage?(lng: string, options: DetectorOptions): void; 72 | lookup(options: DetectorOptions): string | string[] | undefined; 73 | } 74 | 75 | export default class I18nextBrowserLanguageDetector implements i18next.LanguageDetectorModule { 76 | constructor(services?: any, options?: DetectorOptions); 77 | /** 78 | * Adds detector. 79 | */ 80 | addDetector(detector: CustomDetector): I18nextBrowserLanguageDetector; 81 | 82 | /** 83 | * Initializes detector. 84 | */ 85 | init(services?: any, options?: DetectorOptions): void; 86 | 87 | detect(detectionOrder?: DetectorOptions['order']): string | string[] | undefined; 88 | 89 | cacheUserLanguage(lng: string, caches?: string[]): void; 90 | 91 | type: 'languageDetector'; 92 | detectors: { [key: string]: any }; 93 | services: any; 94 | i18nOptions: any; 95 | } 96 | 97 | declare module 'i18next' { 98 | interface CustomPluginOptions { 99 | detection?: DetectorOptions; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "i18next-browser-languagedetector", 3 | "version": "8.2.0", 4 | "description": "language detector used in browser environment for i18next", 5 | "main": "./dist/cjs/i18nextBrowserLanguageDetector.js", 6 | "module": "./dist/esm/i18nextBrowserLanguageDetector.js", 7 | "types": "./index.d.mts", 8 | "exports": { 9 | "./package.json": "./package.json", 10 | ".": { 11 | "types": { 12 | "require": "./index.d.ts", 13 | "import": "./index.d.mts" 14 | }, 15 | "module": "./dist/esm/i18nextBrowserLanguageDetector.js", 16 | "import": "./dist/esm/i18nextBrowserLanguageDetector.js", 17 | "require": "./dist/cjs/i18nextBrowserLanguageDetector.js", 18 | "default": "./dist/esm/i18nextBrowserLanguageDetector.js" 19 | }, 20 | "./cjs": { 21 | "types": "./index.d.ts", 22 | "default": "./dist/cjs/i18nextBrowserLanguageDetector.js" 23 | }, 24 | "./esm": { 25 | "types": "./index.d.mts", 26 | "default": "./dist/esm/i18nextBrowserLanguageDetector.js" 27 | } 28 | }, 29 | "keywords": [ 30 | "i18next", 31 | "i18next-languageDetector" 32 | ], 33 | "homepage": "https://github.com/i18next/i18next-browser-languageDetector", 34 | "bugs": "https://github.com/i18next/i18next-browser-languageDetector/issues", 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/i18next/i18next-browser-languageDetector.git" 38 | }, 39 | "dependencies": { 40 | "@babel/runtime": "^7.23.2" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.23.3", 44 | "@babel/plugin-transform-runtime": "^7.23.3", 45 | "@babel/preset-env": "^7.23.3", 46 | "@babel/eslint-parser": "^7.23.3", 47 | "babel-polyfill": "^6.26.0", 48 | "babelify": "^10.0.0", 49 | "browserify": "17.0.0", 50 | "browserify-istanbul": "3.0.1", 51 | "chai": "4.3.10", 52 | "coveralls": "3.1.1", 53 | "cpy-cli": "^5.0.0", 54 | "dtslint": "^4.2.1", 55 | "eslint": "8.53.0", 56 | "eslint-config-airbnb": "19.0.4", 57 | "expect.js": "0.3.1", 58 | "i18next": "23.7.1", 59 | "mkdirp": "3.0.1", 60 | "mocha": "10.8.2", 61 | "rimraf": "5.0.5", 62 | "rollup": "^2.79.1", 63 | "@rollup/plugin-babel": "^5.3.1", 64 | "@rollup/plugin-node-resolve": "^14.1.0", 65 | "rollup-plugin-terser": "^7.0.2", 66 | "tslint": "^5.20.1", 67 | "tsd": "0.29.0", 68 | "typescript": "5.1.3", 69 | "yargs": "17.7.2" 70 | }, 71 | "scripts": { 72 | "lint": "eslint src", 73 | "pretest": "npm run lint && npm run test:typescript && npm run test:typescript:noninterop", 74 | "test": "npm run build && mocha test -R spec --exit", 75 | "test:typescript": "tslint --project tsconfig.json && tsd", 76 | "test:typescript:noninterop": "tslint --project tsconfig.nonEsModuleInterop.json", 77 | "build": "rimraf dist && rollup -c && echo '{\"type\":\"module\"}' > dist/esm/package.json && cpy \"./dist/umd/*.js\" ./", 78 | "fix_dist_package": "node -e 'console.log(`{\"type\":\"module\",\"version\":\"${process.env.npm_package_version}\"}`)' > dist/esm/package.json", 79 | "preversion": "npm run test && npm run build && git push", 80 | "postversion": "npm run fix_dist_package && git push && git push --tags" 81 | }, 82 | "tsd": { 83 | "directory": "test/typescript" 84 | }, 85 | "author": "Jan Mühlemann (https://github.com/jamuhl)", 86 | "license": "MIT" 87 | } 88 | -------------------------------------------------------------------------------- /src/browserLookups/cookie.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-control-regex 2 | const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/; 3 | 4 | const serializeCookie = (name, val, options = { path: '/' }) => { 5 | const opt = options; 6 | const value = encodeURIComponent(val); 7 | let str = `${name}=${value}`; 8 | if (opt.maxAge > 0) { 9 | const maxAge = opt.maxAge - 0; 10 | if (Number.isNaN(maxAge)) throw new Error('maxAge should be a Number'); 11 | str += `; Max-Age=${Math.floor(maxAge)}`; 12 | } 13 | if (opt.domain) { 14 | if (!fieldContentRegExp.test(opt.domain)) { 15 | throw new TypeError('option domain is invalid'); 16 | } 17 | str += `; Domain=${opt.domain}`; 18 | } 19 | if (opt.path) { 20 | if (!fieldContentRegExp.test(opt.path)) { 21 | throw new TypeError('option path is invalid'); 22 | } 23 | str += `; Path=${opt.path}`; 24 | } 25 | if (opt.expires) { 26 | if (typeof opt.expires.toUTCString !== 'function') { 27 | throw new TypeError('option expires is invalid'); 28 | } 29 | str += `; Expires=${opt.expires.toUTCString()}`; 30 | } 31 | if (opt.httpOnly) str += '; HttpOnly'; 32 | if (opt.secure) str += '; Secure'; 33 | if (opt.sameSite) { 34 | const sameSite = typeof opt.sameSite === 'string' ? opt.sameSite.toLowerCase() : opt.sameSite; 35 | switch (sameSite) { 36 | case true: 37 | str += '; SameSite=Strict'; 38 | break; 39 | case 'lax': 40 | str += '; SameSite=Lax'; 41 | break; 42 | case 'strict': 43 | str += '; SameSite=Strict'; 44 | break; 45 | case 'none': 46 | str += '; SameSite=None'; 47 | break; 48 | default: 49 | throw new TypeError('option sameSite is invalid'); 50 | } 51 | } 52 | if (opt.partitioned) str += '; Partitioned'; 53 | return str; 54 | }; 55 | 56 | const cookie = { 57 | create(name, value, minutes, domain, cookieOptions = { path: '/', sameSite: 'strict' }) { 58 | if (minutes) { 59 | cookieOptions.expires = new Date(); 60 | cookieOptions.expires.setTime(cookieOptions.expires.getTime() + (minutes * 60 * 1000)); 61 | } 62 | if (domain) cookieOptions.domain = domain; 63 | document.cookie = serializeCookie(name, value, cookieOptions); 64 | }, 65 | 66 | read(name) { 67 | const nameEQ = `${name}=`; 68 | const ca = document.cookie.split(';'); 69 | for (let i = 0; i < ca.length; i++) { 70 | let c = ca[i]; 71 | while (c.charAt(0) === ' ') c = c.substring(1, c.length); 72 | if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); 73 | } 74 | return null; 75 | }, 76 | 77 | remove(name, domain) { 78 | this.create(name, '', -1, domain); 79 | } 80 | }; 81 | 82 | export default { 83 | name: 'cookie', 84 | 85 | // Deconstruct the options object and extract the lookupCookie property 86 | lookup({ lookupCookie }) { 87 | if (lookupCookie && typeof document !== 'undefined') { 88 | return cookie.read(lookupCookie) || undefined; 89 | } 90 | 91 | return undefined; 92 | }, 93 | 94 | // Deconstruct the options object and extract the lookupCookie, cookieMinutes, cookieDomain, and cookieOptions properties 95 | cacheUserLanguage(lng, { 96 | lookupCookie, cookieMinutes, cookieDomain, cookieOptions 97 | }) { 98 | if (lookupCookie && typeof document !== 'undefined') { 99 | cookie.create(lookupCookie, lng, cookieMinutes, cookieDomain, cookieOptions); 100 | } 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as utils from './utils.js'; 2 | import cookie from './browserLookups/cookie.js'; 3 | import querystring from './browserLookups/querystring.js'; 4 | import hash from './browserLookups/hash.js'; 5 | import localStorage from './browserLookups/localStorage.js'; 6 | import sessionStorage from './browserLookups/sessionStorage.js'; 7 | import navigator from './browserLookups/navigator.js'; 8 | import htmlTag from './browserLookups/htmlTag.js'; 9 | import path from './browserLookups/path.js'; 10 | import subdomain from './browserLookups/subdomain.js'; 11 | 12 | // some environments, throws when accessing document.cookie 13 | let canCookies = false; 14 | try { 15 | // eslint-disable-next-line no-unused-expressions 16 | document.cookie; 17 | canCookies = true; 18 | // eslint-disable-next-line no-empty 19 | } catch (e) {} 20 | const order = ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag']; 21 | if (!canCookies) order.splice(1, 1); 22 | const getDefaults = () => ({ 23 | order, 24 | lookupQuerystring: 'lng', 25 | lookupCookie: 'i18next', 26 | lookupLocalStorage: 'i18nextLng', 27 | lookupSessionStorage: 'i18nextLng', 28 | 29 | // cache user language 30 | caches: ['localStorage'], 31 | excludeCacheFor: ['cimode'], 32 | // cookieMinutes: 10, 33 | // cookieDomain: 'myDomain' 34 | 35 | convertDetectedLanguage: (l) => l 36 | }); 37 | 38 | class Browser { 39 | constructor(services, options = {}) { 40 | this.type = 'languageDetector'; 41 | this.detectors = {}; 42 | 43 | this.init(services, options); 44 | } 45 | 46 | init(services = { languageUtils: {} }, options = {}, i18nOptions = {}) { 47 | this.services = services; 48 | this.options = utils.defaults(options, this.options || {}, getDefaults()); 49 | if (typeof this.options.convertDetectedLanguage === 'string' && this.options.convertDetectedLanguage.indexOf('15897') > -1) { 50 | this.options.convertDetectedLanguage = (l) => l.replace('-', '_'); 51 | } 52 | 53 | // backwards compatibility 54 | if (this.options.lookupFromUrlIndex) this.options.lookupFromPathIndex = this.options.lookupFromUrlIndex; 55 | 56 | this.i18nOptions = i18nOptions; 57 | 58 | this.addDetector(cookie); 59 | this.addDetector(querystring); 60 | this.addDetector(localStorage); 61 | this.addDetector(sessionStorage); 62 | this.addDetector(navigator); 63 | this.addDetector(htmlTag); 64 | this.addDetector(path); 65 | this.addDetector(subdomain); 66 | this.addDetector(hash); 67 | } 68 | 69 | addDetector(detector) { 70 | this.detectors[detector.name] = detector; 71 | return this; 72 | } 73 | 74 | detect(detectionOrder = this.options.order) { 75 | let detected = []; 76 | detectionOrder.forEach((detectorName) => { 77 | if (this.detectors[detectorName]) { 78 | let lookup = this.detectors[detectorName].lookup(this.options); 79 | if (lookup && typeof lookup === 'string') lookup = [lookup]; 80 | if (lookup) detected = detected.concat(lookup); 81 | } 82 | }); 83 | 84 | detected = detected 85 | .filter((d) => d !== undefined && d !== null && !utils.hasXSS(d)) 86 | .map((d) => this.options.convertDetectedLanguage(d)); 87 | 88 | if (this.services && this.services.languageUtils && this.services.languageUtils.getBestMatchFromCodes) return detected; // new i18next v19.5.0 89 | return detected.length > 0 ? detected[0] : null; // a little backward compatibility 90 | } 91 | 92 | cacheUserLanguage(lng, caches = this.options.caches) { 93 | if (!caches) return; 94 | if (this.options.excludeCacheFor && this.options.excludeCacheFor.indexOf(lng) > -1) return; 95 | caches.forEach((cacheName) => { 96 | if (this.detectors[cacheName]) this.detectors[cacheName].cacheUserLanguage(lng, this.options); 97 | }); 98 | } 99 | } 100 | 101 | Browser.type = 'languageDetector'; 102 | 103 | export default Browser; 104 | -------------------------------------------------------------------------------- /test/languageDetector.test.js: -------------------------------------------------------------------------------- 1 | const expect = require('expect.js'); 2 | const i18next = require('i18next'); 3 | const LanguageDetector = require('../dist/cjs/i18nextBrowserLanguageDetector.js'); 4 | 5 | i18next.init(); 6 | 7 | describe('language detector', () => { 8 | const ld = new LanguageDetector(i18next.services, { 9 | order: [ 10 | 'subdomain', 11 | 'querystring', 12 | 'path', 13 | 'cookie', 14 | 'sessionStorage', 15 | 'localStorage', 16 | 'navigator', 17 | 'htmlTag' 18 | ] 19 | }); 20 | 21 | describe('cookie', () => { 22 | it('detect', () => { 23 | global.document = { 24 | cookie: 'i18next=de' 25 | }; 26 | const lng = ld.detect(); 27 | expect(lng).to.contain('de'); 28 | }); 29 | 30 | it('cacheUserLanguage', () => { 31 | global.document = { 32 | cookie: 'my=cookie' 33 | }; 34 | ld.cacheUserLanguage('it', ['cookie']); 35 | expect(global.document.cookie).to.match(/i18next=it/); 36 | expect(global.document.cookie).to.match(/Path=\//); 37 | // expect(global.document.cookie).to.match(/my=cookie/) 38 | }); 39 | }); 40 | 41 | describe('path', () => { 42 | it('detect', () => { 43 | global.window = { 44 | location: { 45 | pathname: '/fr/some/route', 46 | search: '' 47 | } 48 | }; 49 | const lng = ld.detect(); 50 | expect(lng).to.contain('fr'); 51 | }); 52 | }); 53 | 54 | describe('querystring', () => { 55 | it('detect', () => { 56 | global.window = { 57 | location: { 58 | pathname: '/fr/some/route', 59 | search: '?lng=de' 60 | } 61 | }; 62 | const lng = ld.detect(); 63 | expect(lng).to.contain('de'); 64 | }); 65 | }); 66 | 67 | describe('querystring (fragment)', () => { 68 | it('detect', () => { 69 | global.window = { 70 | location: { 71 | pathname: '/fr/some/route', 72 | hash: '#/something?lng=de', 73 | search: '' 74 | } 75 | }; 76 | const lng = ld.detect(); 77 | expect(lng).to.contain('de'); 78 | }); 79 | }); 80 | 81 | describe('hash', () => { 82 | const ldH = new LanguageDetector(i18next.services, { 83 | order: [ 84 | 'hash' 85 | ], 86 | lookupHash: 'lng', 87 | lookupFromHashIndex: 0 88 | }); 89 | it('detect via lookupHash', () => { 90 | global.window = { 91 | location: { 92 | pathname: '/fr/some/route', 93 | hash: '#lng=pt' 94 | } 95 | }; 96 | const lng = ldH.detect(); 97 | expect(lng).to.contain('pt'); 98 | }); 99 | it('detect via lookupFromHashIndex', () => { 100 | global.window = { 101 | location: { 102 | pathname: '/fr/some/route', 103 | hash: '#/es' 104 | } 105 | }; 106 | const lng = ldH.detect(); 107 | expect(lng).to.contain('es'); 108 | }); 109 | }); 110 | 111 | describe('subdomain', () => { 112 | it('detect', () => { 113 | global.window = { 114 | location: { 115 | hostname: 'es.foot-print-on-the-moon-1968.org', 116 | href: 'http://es.foot-print-on-the-moon-1968.org/fr/some/route', 117 | pathname: '/fr/some/route', 118 | hash: '#/something?lng=de', 119 | search: '?lng=de' 120 | } 121 | }; 122 | const lng = ld.detect(); 123 | expect(lng).to.contain('es'); 124 | }); 125 | }); 126 | }); 127 | 128 | describe('language detector (ISO 15897 locales)', () => { 129 | const ld = new LanguageDetector(i18next.services, { 130 | order: [ 131 | 'subdomain', 132 | 'querystring', 133 | 'path', 134 | 'cookie', 135 | 'sessionStorage', 136 | 'localStorage', 137 | 'navigator', 138 | 'htmlTag' 139 | ], 140 | convertDetectedLanguage: 'Iso15897' 141 | }); 142 | 143 | describe('cookie', () => { 144 | it('detect', () => { 145 | global.document = { 146 | cookie: 'i18next=de-CH' 147 | }; 148 | const lng = ld.detect(); 149 | expect(lng).to.contain('de_CH'); 150 | }); 151 | 152 | it('cacheUserLanguage', () => { 153 | global.document = { 154 | cookie: 'my=cookie' 155 | }; 156 | ld.cacheUserLanguage('it_IT', ['cookie']); 157 | expect(global.document.cookie).to.match(/i18next=it_IT/); 158 | expect(global.document.cookie).to.match(/Path=\//); 159 | // expect(global.document.cookie).to.match(/my=cookie/) 160 | }); 161 | }); 162 | 163 | describe('path', () => { 164 | it('detect', () => { 165 | global.window = { 166 | location: { 167 | pathname: '/fr-FR/some/route', 168 | search: '' 169 | } 170 | }; 171 | const lng = ld.detect(); 172 | expect(lng).to.contain('fr_FR'); 173 | }); 174 | }); 175 | 176 | describe('querystring', () => { 177 | it('detect', () => { 178 | global.window = { 179 | location: { 180 | pathname: '/fr-FR/some/route', 181 | search: '?lng=de-CH' 182 | } 183 | }; 184 | const lng = ld.detect(); 185 | expect(lng).to.contain('de_CH'); 186 | }); 187 | }); 188 | 189 | describe('querystring (fragment)', () => { 190 | it('detect', () => { 191 | global.window = { 192 | location: { 193 | pathname: '/fr-FR/some/route', 194 | hash: '#/something?lng=de-CH', 195 | search: '' 196 | } 197 | }; 198 | const lng = ld.detect(); 199 | expect(lng).to.contain('de_CH'); 200 | }); 201 | }); 202 | }); 203 | 204 | describe('language detector (with xss filter)', () => { 205 | const ld = new LanguageDetector(i18next.services, { 206 | order: [ 207 | 'cookie', 208 | 'path' 209 | ] 210 | }); 211 | 212 | it('detect', () => { 213 | global.document = { 214 | cookie: 'i18next=de-">' 215 | }; 216 | global.window = { 217 | location: { 218 | pathname: '/fr-CH/some/route', 219 | search: '' 220 | } 221 | }; 222 | const lngs = ld.detect(); 223 | expect(lngs).to.eql(['fr-CH']); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [![npm version](https://img.shields.io/npm/v/i18next-browser-languagedetector.svg?style=flat-square)](https://www.npmjs.com/package/i18next-browser-languagedetector) 4 | 5 | This is an i18next language detection plugin used to detect user language in the browser, with support for: 6 | 7 | - cookie (set cookie i18next=LANGUAGE) 8 | - sessionStorage (set key i18nextLng=LANGUAGE) 9 | - localStorage (set key i18nextLng=LANGUAGE) 10 | - navigator (set browser language) 11 | - querystring (append `?lng=LANGUAGE` to URL) 12 | - htmlTag (add html language tag lng.replace('-', '_') 82 | } 83 | ``` 84 | 85 | Options can be passed in: 86 | 87 | **preferred** - by setting options.detection in i18next.init: 88 | 89 | ```js 90 | import i18next from 'i18next'; 91 | import LanguageDetector from 'i18next-browser-languagedetector'; 92 | 93 | i18next.use(LanguageDetector).init({ 94 | detection: options, 95 | }); 96 | ``` 97 | 98 | on construction: 99 | 100 | ```js 101 | import LanguageDetector from 'i18next-browser-languagedetector'; 102 | const languageDetector = new LanguageDetector(null, options); 103 | ``` 104 | 105 | via calling init: 106 | 107 | ```js 108 | import LanguageDetector from 'i18next-browser-languagedetector'; 109 | const languageDetector = new LanguageDetector(); 110 | languageDetector.init(options); 111 | ``` 112 | 113 | ## Adding own detection functionality 114 | 115 | ### interface 116 | 117 | ```js 118 | export default { 119 | name: 'myDetectorsName', 120 | 121 | lookup(options) { 122 | // options -> are passed in options 123 | return 'en'; 124 | }, 125 | 126 | cacheUserLanguage(lng, options) { 127 | // options -> are passed in options 128 | // lng -> current language, will be called after init and on changeLanguage 129 | // store it 130 | }, 131 | }; 132 | ``` 133 | 134 | ### adding it 135 | 136 | ```js 137 | import LanguageDetector from 'i18next-browser-languagedetector'; 138 | const languageDetector = new LanguageDetector(); 139 | languageDetector.addDetector(myDetector); 140 | 141 | i18next.use(languageDetector).init({ 142 | detection: options, 143 | }); 144 | ``` 145 | 146 | Don't forget: You have to add the name of your detector (`myDetectorsName` in this case) to the `order` array in your `options` object. Without that, your detector won't be used. See the [Detector Options section for more](#detector-options). 147 | 148 | --- 149 | 150 |

Gold Sponsors

151 | 152 |

153 | 154 | 155 | 156 |

157 | 158 | --- 159 | 160 | **localization as a service - locize.com** 161 | 162 | Needing a translation management? Want to edit your translations with an InContext Editor? Use the orginal provided to you by the maintainers of i18next! 163 | 164 | ![locize](https://cdn.prod.website-files.com/67a323e323a50df7f24f0a6f/67b8bbb29365c3a3c21c0898_github_locize.png) 165 | 166 | With using [locize](http://locize.com/?utm_source=react_i18next_readme&utm_medium=github) you directly support the future of i18next and react-i18next. 167 | 168 | --- 169 | -------------------------------------------------------------------------------- /i18nextBrowserLanguageDetector.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).i18nextBrowserLanguageDetector=t()}(this,(function(){"use strict";const{slice:e,forEach:t}=[];const o=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/,n={create(e,t,n,i){let r=arguments.length>4&&void 0!==arguments[4]?arguments[4]:{path:"/",sameSite:"strict"};n&&(r.expires=new Date,r.expires.setTime(r.expires.getTime()+60*n*1e3)),i&&(r.domain=i),document.cookie=function(e,t){const n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{path:"/"};let i=`${e}=${encodeURIComponent(t)}`;if(n.maxAge>0){const e=n.maxAge-0;if(Number.isNaN(e))throw new Error("maxAge should be a Number");i+=`; Max-Age=${Math.floor(e)}`}if(n.domain){if(!o.test(n.domain))throw new TypeError("option domain is invalid");i+=`; Domain=${n.domain}`}if(n.path){if(!o.test(n.path))throw new TypeError("option path is invalid");i+=`; Path=${n.path}`}if(n.expires){if("function"!=typeof n.expires.toUTCString)throw new TypeError("option expires is invalid");i+=`; Expires=${n.expires.toUTCString()}`}if(n.httpOnly&&(i+="; HttpOnly"),n.secure&&(i+="; Secure"),n.sameSite)switch("string"==typeof n.sameSite?n.sameSite.toLowerCase():n.sameSite){case!0:i+="; SameSite=Strict";break;case"lax":i+="; SameSite=Lax";break;case"strict":i+="; SameSite=Strict";break;case"none":i+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}return n.partitioned&&(i+="; Partitioned"),i}(e,t,r)},read(e){const t=`${e}=`,o=document.cookie.split(";");for(let e=0;e-1&&(e=window.location.hash.substring(window.location.hash.indexOf("?")));const n=e.substring(1).split("&");for(let e=0;e0){n[e].substring(0,i)===o&&(t=n[e].substring(i+1))}}}return t}},s={name:"hash",lookup(e){let t,{lookupHash:o,lookupFromHashIndex:n}=e;if("undefined"!=typeof window){const{hash:e}=window.location;if(e&&e.length>2){const i=e.substring(1);if(o){const e=i.split("&");for(let n=0;n0){e[n].substring(0,i)===o&&(t=e[n].substring(i+1))}}}if(t)return t;if(!t&&n>-1){const t=e.match(/\/([a-zA-Z-]*)/g);if(!Array.isArray(t))return;const o="number"==typeof n?n:0;return t[o]?.replace("/","")}}}return t}};let a=null;const l=()=>{if(null!==a)return a;try{if(a="undefined"!=typeof window&&null!==window.localStorage,!a)return!1;const e="i18next.translate.boo";window.localStorage.setItem(e,"foo"),window.localStorage.removeItem(e)}catch(e){a=!1}return a};var c={name:"localStorage",lookup(e){let{lookupLocalStorage:t}=e;if(t&&l())return window.localStorage.getItem(t)||void 0},cacheUserLanguage(e,t){let{lookupLocalStorage:o}=t;o&&l()&&window.localStorage.setItem(o,e)}};let u=null;const d=()=>{if(null!==u)return u;try{if(u="undefined"!=typeof window&&null!==window.sessionStorage,!u)return!1;const e="i18next.translate.boo";window.sessionStorage.setItem(e,"foo"),window.sessionStorage.removeItem(e)}catch(e){u=!1}return u};var h={name:"sessionStorage",lookup(e){let{lookupSessionStorage:t}=e;if(t&&d())return window.sessionStorage.getItem(t)||void 0},cacheUserLanguage(e,t){let{lookupSessionStorage:o}=t;o&&d()&&window.sessionStorage.setItem(o,e)}},g={name:"navigator",lookup(e){const t=[];if("undefined"!=typeof navigator){const{languages:e,userLanguage:o,language:n}=navigator;if(e)for(let o=0;o0?t:void 0}},p={name:"htmlTag",lookup(e){let t,{htmlTag:o}=e;const n=o||("undefined"!=typeof document?document.documentElement:null);return n&&"function"==typeof n.getAttribute&&(t=n.getAttribute("lang")),t}},f={name:"path",lookup(e){let{lookupFromPathIndex:t}=e;if("undefined"==typeof window)return;const o=window.location.pathname.match(/\/([a-zA-Z-]*)/g);if(!Array.isArray(o))return;const n="number"==typeof t?t:0;return o[n]?.replace("/","")}},m={name:"subdomain",lookup(e){let{lookupFromSubdomainIndex:t}=e;const o="number"==typeof t?t+1:1,n="undefined"!=typeof window&&window.location?.hostname?.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i);if(n)return n[o]}};let w=!1;try{document.cookie,w=!0}catch(e){}const k=["querystring","cookie","localStorage","sessionStorage","navigator","htmlTag"];w||k.splice(1,1);class S{constructor(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};this.type="languageDetector",this.detectors={},this.init(e,t)}init(){let o=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{languageUtils:{}},n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},a=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};this.services=o,this.options=function(o){return t.call(e.call(arguments,1),(e=>{if(e)for(const t in e)void 0===o[t]&&(o[t]=e[t])})),o}(n,this.options||{},{order:k,lookupQuerystring:"lng",lookupCookie:"i18next",lookupLocalStorage:"i18nextLng",lookupSessionStorage:"i18nextLng",caches:["localStorage"],excludeCacheFor:["cimode"],convertDetectedLanguage:e=>e}),"string"==typeof this.options.convertDetectedLanguage&&this.options.convertDetectedLanguage.indexOf("15897")>-1&&(this.options.convertDetectedLanguage=e=>e.replace("-","_")),this.options.lookupFromUrlIndex&&(this.options.lookupFromPathIndex=this.options.lookupFromUrlIndex),this.i18nOptions=a,this.addDetector(i),this.addDetector(r),this.addDetector(c),this.addDetector(h),this.addDetector(g),this.addDetector(p),this.addDetector(f),this.addDetector(m),this.addDetector(s)}addDetector(e){return this.detectors[e.name]=e,this}detect(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.options.order,t=[];return e.forEach((e=>{if(this.detectors[e]){let o=this.detectors[e].lookup(this.options);o&&"string"==typeof o&&(o=[o]),o&&(t=t.concat(o))}})),t=t.filter((e=>{return null!=e&&!("string"==typeof(t=e)&&[/<\s*script.*?>/i,/<\s*\/\s*script\s*>/i,/<\s*img.*?on\w+\s*=/i,/<\s*\w+\s*on\w+\s*=.*?>/i,/javascript\s*:/i,/vbscript\s*:/i,/expression\s*\(/i,/eval\s*\(/i,/alert\s*\(/i,/document\.cookie/i,/document\.write\s*\(/i,/window\.location/i,/innerHTML/i].some((e=>e.test(t))));var t})).map((e=>this.options.convertDetectedLanguage(e))),this.services&&this.services.languageUtils&&this.services.languageUtils.getBestMatchFromCodes?t:t.length>0?t[0]:null}cacheUserLanguage(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.options.caches;t&&(this.options.excludeCacheFor&&this.options.excludeCacheFor.indexOf(e)>-1||t.forEach((t=>{this.detectors[t]&&this.detectors[t].cacheUserLanguage(e,this.options)})))}}return S.type="languageDetector",S})); 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 8.2.0 2 | 3 | - feat: add support for hash detector [304](https://github.com/i18next/i18next-browser-languageDetector/issues/304) 4 | 5 | ### 8.1.0 6 | 7 | - feat: add support for Partitioned cookies [303](https://github.com/i18next/i18next-browser-languageDetector/pull/303) 8 | 9 | ### 8.0.5 10 | 11 | - check for common xss attack patterns on detected language 12 | 13 | ### 8.0.4 14 | 15 | - fix localstorage check to try to address [297](https://github.com/i18next/i18next-browser-languageDetector/issues/297) [300](https://github.com/i18next/i18next-browser-languageDetector/pull/300) 16 | 17 | ### 8.0.3 18 | 19 | - change localstorage check to try to address [297](https://github.com/i18next/i18next-browser-languageDetector/issues/297) 20 | 21 | ### 8.0.2 22 | 23 | - fix for when passed services are null, should address [296](https://github.com/i18next/i18next-browser-languageDetector/issues/296) 24 | 25 | ### 8.0.1 26 | 27 | - some environments, throws when accessing document.cookie 28 | 29 | ### 8.0.0 30 | 31 | - chore: set browsers target to defaults [286](https://github.com/i18next/i18next-browser-languageDetector/pull/286) 32 | - perf: use object deconstruction, optional chaining and hot path optimisations [287](https://github.com/i18next/i18next-browser-languageDetector/pull/287) 33 | 34 | ### 7.2.1 35 | 36 | - fix: align addDetector impementation to type definition [282](https://github.com/i18next/i18next-browser-languageDetector/issues/282) 37 | 38 | ### 7.2.0 39 | 40 | - fix: separate cjs and mjs typings 41 | 42 | ### 7.1.0 43 | 44 | - introduce convertDetectedLanguage option 45 | 46 | ### 7.0.2 47 | 48 | - simplify usage without i18next 49 | 50 | ### 7.0.1 51 | 52 | - try to force esm moduel type for esm build [269](https://github.com/i18next/i18next-browser-languageDetector/issues/269) 53 | 54 | ### 7.0.0 55 | 56 | - typescript fix for i18next v22 57 | 58 | ### 6.1.8 59 | 60 | - fix export order for typescript [267](https://github.com/i18next/i18next-browser-languageDetector/issues/267) 61 | 62 | ### 6.1.7 63 | 64 | - Types entries missing in package exports (Needed for Typescript >= 4.7 and moduleResolution="Node16"/"Nodenext") [266](https://github.com/i18next/i18next-browser-languageDetector/issues/266) 65 | 66 | ### 6.1.6 67 | 68 | - define module exports in package.json 69 | 70 | ### 6.1.5 71 | 72 | - lookup subdomain with localhost option [264](https://github.com/i18next/i18next-browser-languageDetector/pull/264) 73 | 74 | ### 6.1.4 75 | 76 | - fix querystring lookup if happening after # [256](https://github.com/i18next/i18next-browser-languageDetector/issues/256) 77 | 78 | ### 6.1.3 79 | 80 | - export DecetorOptions and CustomDetector types 81 | 82 | 83 | ### 6.1.2 84 | 85 | - fix lookup return types [245](https://github.com/i18next/i18next-browser-languageDetector/issues/245) 86 | 87 | 88 | ### 6.1.1 89 | 90 | - cookieOptions types [239](https://github.com/i18next/i18next-browser-languageDetector/pull/239) 91 | 92 | 93 | ### 6.1.0 94 | 95 | - Type PluginOptions properly [235](https://github.com/i18next/i18next-browser-languageDetector/pull/235) 96 | 97 | 98 | ### 6.0.1 99 | 100 | - optimize check for local storage and session storage [222](https://github.com/i18next/i18next-browser-languageDetector/pull/222) 101 | 102 | 103 | ### 6.0.0 104 | 105 | - **BREAKING** rename lookupSessionStorage and add it to defaults [221](https://github.com/i18next/i18next-browser-languageDetector/pull/221) 106 | 107 | ### 5.0.1 108 | 109 | - optimize cookie serialization and set sameSite to strict by default, to prepare for browser changes 110 | 111 | ### 5.0.0 112 | 113 | - **BREAKING** needs i18next >= 19.5.0 114 | - let i18next figure out which detected lng is best match 115 | 116 | ### 4.3.1 117 | 118 | - typescript Updated typescript typings for DetectorOptions to align with current options [216](https://github.com/i18next/i18next-browser-languageDetector/pull/216) 119 | 120 | ### 4.3.0 121 | 122 | - sessionStorage support [215](https://github.com/i18next/i18next-browser-languageDetector/pull/215) 123 | 124 | ### 4.2.0 125 | 126 | - Add config option checkForSimilarInWhitelist [211](https://github.com/i18next/i18next-browser-languageDetector/pull/211) 127 | 128 | ### 4.1.1 129 | 130 | - fix: pass cookieOptions with the cacheUserLang [205](https://github.com/i18next/i18next-browser-languageDetector/pull/205) 131 | 132 | ### 4.1.0 133 | 134 | - feat: add cookieOptions for setting cookies [203](https://github.com/i18next/i18next-browser-languageDetector/pull/203) 135 | 136 | ### 4.0.2 137 | 138 | - update index file to reflect build changes done in 4.0.0 139 | 140 | ### 4.0.1 141 | 142 | - typescript: Use updated ts export default from i18next [194](https://github.com/i18next/i18next-browser-languageDetector/pull/194) 143 | 144 | ### 4.0.0 145 | 146 | - removes deprecated jsnext:main from package.json 147 | - Bundle all entry points with rollup bringing it up to same standard as [xhr-backend](https://github.com/i18next/i18next-xhr-backend/pull/314) 148 | - **note:** dist/es -> dist/esm, dist/commonjs -> dist/cjs (individual files -> one bundled file) 149 | - removes bower finally 150 | 151 | ### v3.1.1 152 | 153 | - add default checkWhitelist: true 154 | 155 | ### v3.1.0 156 | 157 | - Added option to prevent checking whitelist for detected languages `checkWhitelist: true` [190](https://github.com/i18next/i18next-browser-languageDetector/pull/190) 158 | 159 | ### v3.0.3 160 | 161 | - Remove clutter from npm package [181](https://github.com/i18next/i18next-browser-languageDetector/pull/181) 162 | 163 | ### v3.0.2 164 | 165 | - typescript: Fix types for `use()` module [180](https://github.com/i18next/i18next-browser-languageDetector/pull/180) 166 | 167 | ### v3.0.1 168 | 169 | - typescript: fix types [165](https://github.com/i18next/i18next-browser-languageDetector/pull/165) 170 | 171 | ### v3.0.0 172 | 173 | - typescript: add types [164](https://github.com/i18next/i18next-browser-languageDetector/pull/164) 174 | 175 | ### v2.2.4 176 | 177 | - fix [157](https://github.com/i18next/i18next-browser-languageDetector/issues/157) 178 | 179 | ### v2.2.3 180 | 181 | - fix [159](https://github.com/i18next/i18next-browser-languageDetector/pull/159) 182 | 183 | ### v2.2.2 184 | 185 | - Lang by path: skip if language not found [159](https://github.com/i18next/i18next-browser-languageDetector/pull/159) 186 | 187 | ### v2.2.1 188 | 189 | - fixes option validation in path lookup [158](https://github.com/i18next/i18next-browser-languageDetector/issues/158) 190 | - fixes lookup from href for subdomain [157](https://github.com/i18next/i18next-browser-languageDetector/issues/157) 191 | 192 | ### v2.2.0 193 | 194 | - add detector for path and subdomain [PR153](https://github.com/i18next/i18next-browser-languageDetector/pull/153) and [PR152](https://github.com/i18next/i18next-browser-languageDetector/pull/152) 195 | 196 | ### v2.1.1 197 | 198 | - support for fallback language in form of object [151](https://github.com/i18next/i18next-browser-languageDetector/issues/151) 199 | 200 | ### v2.1.0 201 | 202 | - add .js for browser import implementation [PR147](https://github.com/i18next/i18next-browser-languageDetector/pull/147) 203 | 204 | ### v2.0.0 205 | 206 | - [BREAKING] options.excludeCacheFor (array of language codes; default ['cimode']): if a language maps a value in that list the language will not be written to cache (eg. localStorage, cookie). If you use lng cimode in your tests and require it to be cached set the option to false or empty array 207 | -------------------------------------------------------------------------------- /i18nextBrowserLanguageDetector.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 3 | typeof define === 'function' && define.amd ? define(factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.i18nextBrowserLanguageDetector = factory()); 5 | })(this, (function () { 'use strict'; 6 | 7 | const { 8 | slice, 9 | forEach 10 | } = []; 11 | function defaults(obj) { 12 | forEach.call(slice.call(arguments, 1), source => { 13 | if (source) { 14 | for (const prop in source) { 15 | if (obj[prop] === undefined) obj[prop] = source[prop]; 16 | } 17 | } 18 | }); 19 | return obj; 20 | } 21 | function hasXSS(input) { 22 | if (typeof input !== 'string') return false; 23 | 24 | // Common XSS attack patterns 25 | const xssPatterns = [/<\s*script.*?>/i, /<\s*\/\s*script\s*>/i, /<\s*img.*?on\w+\s*=/i, /<\s*\w+\s*on\w+\s*=.*?>/i, /javascript\s*:/i, /vbscript\s*:/i, /expression\s*\(/i, /eval\s*\(/i, /alert\s*\(/i, /document\.cookie/i, /document\.write\s*\(/i, /window\.location/i, /innerHTML/i]; 26 | return xssPatterns.some(pattern => pattern.test(input)); 27 | } 28 | 29 | // eslint-disable-next-line no-control-regex 30 | const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/; 31 | const serializeCookie = function (name, val) { 32 | let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : { 33 | path: '/' 34 | }; 35 | const opt = options; 36 | const value = encodeURIComponent(val); 37 | let str = `${name}=${value}`; 38 | if (opt.maxAge > 0) { 39 | const maxAge = opt.maxAge - 0; 40 | if (Number.isNaN(maxAge)) throw new Error('maxAge should be a Number'); 41 | str += `; Max-Age=${Math.floor(maxAge)}`; 42 | } 43 | if (opt.domain) { 44 | if (!fieldContentRegExp.test(opt.domain)) { 45 | throw new TypeError('option domain is invalid'); 46 | } 47 | str += `; Domain=${opt.domain}`; 48 | } 49 | if (opt.path) { 50 | if (!fieldContentRegExp.test(opt.path)) { 51 | throw new TypeError('option path is invalid'); 52 | } 53 | str += `; Path=${opt.path}`; 54 | } 55 | if (opt.expires) { 56 | if (typeof opt.expires.toUTCString !== 'function') { 57 | throw new TypeError('option expires is invalid'); 58 | } 59 | str += `; Expires=${opt.expires.toUTCString()}`; 60 | } 61 | if (opt.httpOnly) str += '; HttpOnly'; 62 | if (opt.secure) str += '; Secure'; 63 | if (opt.sameSite) { 64 | const sameSite = typeof opt.sameSite === 'string' ? opt.sameSite.toLowerCase() : opt.sameSite; 65 | switch (sameSite) { 66 | case true: 67 | str += '; SameSite=Strict'; 68 | break; 69 | case 'lax': 70 | str += '; SameSite=Lax'; 71 | break; 72 | case 'strict': 73 | str += '; SameSite=Strict'; 74 | break; 75 | case 'none': 76 | str += '; SameSite=None'; 77 | break; 78 | default: 79 | throw new TypeError('option sameSite is invalid'); 80 | } 81 | } 82 | if (opt.partitioned) str += '; Partitioned'; 83 | return str; 84 | }; 85 | const cookie = { 86 | create(name, value, minutes, domain) { 87 | let cookieOptions = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : { 88 | path: '/', 89 | sameSite: 'strict' 90 | }; 91 | if (minutes) { 92 | cookieOptions.expires = new Date(); 93 | cookieOptions.expires.setTime(cookieOptions.expires.getTime() + minutes * 60 * 1000); 94 | } 95 | if (domain) cookieOptions.domain = domain; 96 | document.cookie = serializeCookie(name, value, cookieOptions); 97 | }, 98 | read(name) { 99 | const nameEQ = `${name}=`; 100 | const ca = document.cookie.split(';'); 101 | for (let i = 0; i < ca.length; i++) { 102 | let c = ca[i]; 103 | while (c.charAt(0) === ' ') c = c.substring(1, c.length); 104 | if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); 105 | } 106 | return null; 107 | }, 108 | remove(name, domain) { 109 | this.create(name, '', -1, domain); 110 | } 111 | }; 112 | var cookie$1 = { 113 | name: 'cookie', 114 | // Deconstruct the options object and extract the lookupCookie property 115 | lookup(_ref) { 116 | let { 117 | lookupCookie 118 | } = _ref; 119 | if (lookupCookie && typeof document !== 'undefined') { 120 | return cookie.read(lookupCookie) || undefined; 121 | } 122 | return undefined; 123 | }, 124 | // Deconstruct the options object and extract the lookupCookie, cookieMinutes, cookieDomain, and cookieOptions properties 125 | cacheUserLanguage(lng, _ref2) { 126 | let { 127 | lookupCookie, 128 | cookieMinutes, 129 | cookieDomain, 130 | cookieOptions 131 | } = _ref2; 132 | if (lookupCookie && typeof document !== 'undefined') { 133 | cookie.create(lookupCookie, lng, cookieMinutes, cookieDomain, cookieOptions); 134 | } 135 | } 136 | }; 137 | 138 | var querystring = { 139 | name: 'querystring', 140 | // Deconstruct the options object and extract the lookupQuerystring property 141 | lookup(_ref) { 142 | let { 143 | lookupQuerystring 144 | } = _ref; 145 | let found; 146 | if (typeof window !== 'undefined') { 147 | let { 148 | search 149 | } = window.location; 150 | if (!window.location.search && window.location.hash?.indexOf('?') > -1) { 151 | search = window.location.hash.substring(window.location.hash.indexOf('?')); 152 | } 153 | const query = search.substring(1); 154 | const params = query.split('&'); 155 | for (let i = 0; i < params.length; i++) { 156 | const pos = params[i].indexOf('='); 157 | if (pos > 0) { 158 | const key = params[i].substring(0, pos); 159 | if (key === lookupQuerystring) { 160 | found = params[i].substring(pos + 1); 161 | } 162 | } 163 | } 164 | } 165 | return found; 166 | } 167 | }; 168 | 169 | var hash = { 170 | name: 'hash', 171 | // Deconstruct the options object and extract the lookupHash property and the lookupFromHashIndex property 172 | lookup(_ref) { 173 | let { 174 | lookupHash, 175 | lookupFromHashIndex 176 | } = _ref; 177 | let found; 178 | if (typeof window !== 'undefined') { 179 | const { 180 | hash 181 | } = window.location; 182 | if (hash && hash.length > 2) { 183 | const query = hash.substring(1); 184 | if (lookupHash) { 185 | const params = query.split('&'); 186 | for (let i = 0; i < params.length; i++) { 187 | const pos = params[i].indexOf('='); 188 | if (pos > 0) { 189 | const key = params[i].substring(0, pos); 190 | if (key === lookupHash) { 191 | found = params[i].substring(pos + 1); 192 | } 193 | } 194 | } 195 | } 196 | if (found) return found; 197 | if (!found && lookupFromHashIndex > -1) { 198 | const language = hash.match(/\/([a-zA-Z-]*)/g); 199 | if (!Array.isArray(language)) return undefined; 200 | const index = typeof lookupFromHashIndex === 'number' ? lookupFromHashIndex : 0; 201 | return language[index]?.replace('/', ''); 202 | } 203 | } 204 | } 205 | return found; 206 | } 207 | }; 208 | 209 | let hasLocalStorageSupport = null; 210 | const localStorageAvailable = () => { 211 | if (hasLocalStorageSupport !== null) return hasLocalStorageSupport; 212 | try { 213 | hasLocalStorageSupport = typeof window !== 'undefined' && window.localStorage !== null; 214 | if (!hasLocalStorageSupport) { 215 | return false; 216 | } 217 | const testKey = 'i18next.translate.boo'; 218 | window.localStorage.setItem(testKey, 'foo'); 219 | window.localStorage.removeItem(testKey); 220 | } catch (e) { 221 | hasLocalStorageSupport = false; 222 | } 223 | return hasLocalStorageSupport; 224 | }; 225 | var localStorage = { 226 | name: 'localStorage', 227 | // Deconstruct the options object and extract the lookupLocalStorage property 228 | lookup(_ref) { 229 | let { 230 | lookupLocalStorage 231 | } = _ref; 232 | if (lookupLocalStorage && localStorageAvailable()) { 233 | return window.localStorage.getItem(lookupLocalStorage) || undefined; // Undefined ensures type consistency with the previous version of this function 234 | } 235 | return undefined; 236 | }, 237 | // Deconstruct the options object and extract the lookupLocalStorage property 238 | cacheUserLanguage(lng, _ref2) { 239 | let { 240 | lookupLocalStorage 241 | } = _ref2; 242 | if (lookupLocalStorage && localStorageAvailable()) { 243 | window.localStorage.setItem(lookupLocalStorage, lng); 244 | } 245 | } 246 | }; 247 | 248 | let hasSessionStorageSupport = null; 249 | const sessionStorageAvailable = () => { 250 | if (hasSessionStorageSupport !== null) return hasSessionStorageSupport; 251 | try { 252 | hasSessionStorageSupport = typeof window !== 'undefined' && window.sessionStorage !== null; 253 | if (!hasSessionStorageSupport) { 254 | return false; 255 | } 256 | const testKey = 'i18next.translate.boo'; 257 | window.sessionStorage.setItem(testKey, 'foo'); 258 | window.sessionStorage.removeItem(testKey); 259 | } catch (e) { 260 | hasSessionStorageSupport = false; 261 | } 262 | return hasSessionStorageSupport; 263 | }; 264 | var sessionStorage = { 265 | name: 'sessionStorage', 266 | lookup(_ref) { 267 | let { 268 | lookupSessionStorage 269 | } = _ref; 270 | if (lookupSessionStorage && sessionStorageAvailable()) { 271 | return window.sessionStorage.getItem(lookupSessionStorage) || undefined; 272 | } 273 | return undefined; 274 | }, 275 | cacheUserLanguage(lng, _ref2) { 276 | let { 277 | lookupSessionStorage 278 | } = _ref2; 279 | if (lookupSessionStorage && sessionStorageAvailable()) { 280 | window.sessionStorage.setItem(lookupSessionStorage, lng); 281 | } 282 | } 283 | }; 284 | 285 | var navigator$1 = { 286 | name: 'navigator', 287 | lookup(options) { 288 | const found = []; 289 | if (typeof navigator !== 'undefined') { 290 | const { 291 | languages, 292 | userLanguage, 293 | language 294 | } = navigator; 295 | if (languages) { 296 | // chrome only; not an array, so can't use .push.apply instead of iterating 297 | for (let i = 0; i < languages.length; i++) { 298 | found.push(languages[i]); 299 | } 300 | } 301 | if (userLanguage) { 302 | found.push(userLanguage); 303 | } 304 | if (language) { 305 | found.push(language); 306 | } 307 | } 308 | return found.length > 0 ? found : undefined; 309 | } 310 | }; 311 | 312 | var htmlTag = { 313 | name: 'htmlTag', 314 | // Deconstruct the options object and extract the htmlTag property 315 | lookup(_ref) { 316 | let { 317 | htmlTag 318 | } = _ref; 319 | let found; 320 | const internalHtmlTag = htmlTag || (typeof document !== 'undefined' ? document.documentElement : null); 321 | if (internalHtmlTag && typeof internalHtmlTag.getAttribute === 'function') { 322 | found = internalHtmlTag.getAttribute('lang'); 323 | } 324 | return found; 325 | } 326 | }; 327 | 328 | var path = { 329 | name: 'path', 330 | // Deconstruct the options object and extract the lookupFromPathIndex property 331 | lookup(_ref) { 332 | let { 333 | lookupFromPathIndex 334 | } = _ref; 335 | if (typeof window === 'undefined') return undefined; 336 | const language = window.location.pathname.match(/\/([a-zA-Z-]*)/g); 337 | if (!Array.isArray(language)) return undefined; 338 | const index = typeof lookupFromPathIndex === 'number' ? lookupFromPathIndex : 0; 339 | return language[index]?.replace('/', ''); 340 | } 341 | }; 342 | 343 | var subdomain = { 344 | name: 'subdomain', 345 | lookup(_ref) { 346 | let { 347 | lookupFromSubdomainIndex 348 | } = _ref; 349 | // If given get the subdomain index else 1 350 | const internalLookupFromSubdomainIndex = typeof lookupFromSubdomainIndex === 'number' ? lookupFromSubdomainIndex + 1 : 1; 351 | // get all matches if window.location. is existing 352 | // first item of match is the match itself and the second is the first group match which should be the first subdomain match 353 | // is the hostname no public domain get the or option of localhost 354 | const language = typeof window !== 'undefined' && window.location?.hostname?.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i); 355 | 356 | // if there is no match (null) return undefined 357 | if (!language) return undefined; 358 | // return the given group match 359 | return language[internalLookupFromSubdomainIndex]; 360 | } 361 | }; 362 | 363 | // some environments, throws when accessing document.cookie 364 | let canCookies = false; 365 | try { 366 | // eslint-disable-next-line no-unused-expressions 367 | document.cookie; 368 | canCookies = true; 369 | // eslint-disable-next-line no-empty 370 | } catch (e) {} 371 | const order = ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag']; 372 | if (!canCookies) order.splice(1, 1); 373 | const getDefaults = () => ({ 374 | order, 375 | lookupQuerystring: 'lng', 376 | lookupCookie: 'i18next', 377 | lookupLocalStorage: 'i18nextLng', 378 | lookupSessionStorage: 'i18nextLng', 379 | // cache user language 380 | caches: ['localStorage'], 381 | excludeCacheFor: ['cimode'], 382 | // cookieMinutes: 10, 383 | // cookieDomain: 'myDomain' 384 | 385 | convertDetectedLanguage: l => l 386 | }); 387 | class Browser { 388 | constructor(services) { 389 | let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 390 | this.type = 'languageDetector'; 391 | this.detectors = {}; 392 | this.init(services, options); 393 | } 394 | init() { 395 | let services = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : { 396 | languageUtils: {} 397 | }; 398 | let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 399 | let i18nOptions = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; 400 | this.services = services; 401 | this.options = defaults(options, this.options || {}, getDefaults()); 402 | if (typeof this.options.convertDetectedLanguage === 'string' && this.options.convertDetectedLanguage.indexOf('15897') > -1) { 403 | this.options.convertDetectedLanguage = l => l.replace('-', '_'); 404 | } 405 | 406 | // backwards compatibility 407 | if (this.options.lookupFromUrlIndex) this.options.lookupFromPathIndex = this.options.lookupFromUrlIndex; 408 | this.i18nOptions = i18nOptions; 409 | this.addDetector(cookie$1); 410 | this.addDetector(querystring); 411 | this.addDetector(localStorage); 412 | this.addDetector(sessionStorage); 413 | this.addDetector(navigator$1); 414 | this.addDetector(htmlTag); 415 | this.addDetector(path); 416 | this.addDetector(subdomain); 417 | this.addDetector(hash); 418 | } 419 | addDetector(detector) { 420 | this.detectors[detector.name] = detector; 421 | return this; 422 | } 423 | detect() { 424 | let detectionOrder = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.options.order; 425 | let detected = []; 426 | detectionOrder.forEach(detectorName => { 427 | if (this.detectors[detectorName]) { 428 | let lookup = this.detectors[detectorName].lookup(this.options); 429 | if (lookup && typeof lookup === 'string') lookup = [lookup]; 430 | if (lookup) detected = detected.concat(lookup); 431 | } 432 | }); 433 | detected = detected.filter(d => d !== undefined && d !== null && !hasXSS(d)).map(d => this.options.convertDetectedLanguage(d)); 434 | if (this.services && this.services.languageUtils && this.services.languageUtils.getBestMatchFromCodes) return detected; // new i18next v19.5.0 435 | return detected.length > 0 ? detected[0] : null; // a little backward compatibility 436 | } 437 | cacheUserLanguage(lng) { 438 | let caches = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.options.caches; 439 | if (!caches) return; 440 | if (this.options.excludeCacheFor && this.options.excludeCacheFor.indexOf(lng) > -1) return; 441 | caches.forEach(cacheName => { 442 | if (this.detectors[cacheName]) this.detectors[cacheName].cacheUserLanguage(lng, this.options); 443 | }); 444 | } 445 | } 446 | Browser.type = 'languageDetector'; 447 | 448 | return Browser; 449 | 450 | })); 451 | --------------------------------------------------------------------------------