├── .gitignore ├── test ├── locales │ ├── en │ │ └── translation.json │ └── zh │ │ └── translation.json └── index.test.js ├── History.md ├── .tern-project ├── .babelrc ├── rollup.config.js ├── package.json ├── src ├── detect.js └── index.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /test/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "hi" 3 | } 4 | -------------------------------------------------------------------------------- /test/locales/zh/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "你好" 3 | } 4 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 1.1.0 / 2018-05-21 2 | ================== 3 | * add getResourcesHandler and getMissingKeyHandler 4 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaVersion": 6, 3 | "libs": [], 4 | "plugins": { 5 | "node": {}, 6 | "modules": {}, 7 | "es_modules": {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ "env", { 4 | "targets": { 5 | "node": "8" 6 | }, 7 | "modules": "commonjs" 8 | }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | let pkg = require('./package.json') 2 | let external = Object.keys(pkg.dependencies) 3 | 4 | export default { 5 | input: 'src/index.js', 6 | external: external, 7 | output: [ 8 | { 9 | file: pkg['main'], 10 | format: 'cjs', 11 | sourcemap: true 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-i18next", 3 | "version": "1.1.1", 4 | "description": "koa middleware for i18next", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "test": "ava | tap-spec", 8 | "watch:test": "ava --watch | tap-spec", 9 | "build": "NODE_ENV=production rollup -c", 10 | "prepublish": "npm run build" 11 | }, 12 | "publishConfig": { 13 | "registry": "https://registry.npmjs.org" 14 | }, 15 | "files": [ 16 | "lib/index.js" 17 | ], 18 | "keywords": [ 19 | "i18n", 20 | "i18next", 21 | "koa", 22 | "middleware" 23 | ], 24 | "author": "sunfuze ", 25 | "ava": { 26 | "files": [ 27 | "test/**/*.test.js" 28 | ], 29 | "require": [ 30 | "babel-register" 31 | ], 32 | "tap": "tap-spec", 33 | "timeout": "5s" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git@github.com:sunfuze/koa-i18next.git" 38 | }, 39 | "engines": { 40 | "node": ">= 8.0.0" 41 | }, 42 | "devDependencies": { 43 | "ava": "^0.15.2", 44 | "babel-preset-env": "^1.6.1", 45 | "babel-register": "^6.26.0", 46 | "babelrc-rollup": "^1.1.0", 47 | "i18next": "^10.6.0", 48 | "i18next-sync-fs-backend": "^1.0.0", 49 | "koa": "^2.5.0", 50 | "koa-router": "^7.4.0", 51 | "koa-session": "^5.8.1", 52 | "rollup": "^0.59.1", 53 | "rollup-plugin-babel": "^2.7.1", 54 | "supertest": "^1.2.0", 55 | "tap-spec": "^4.1.1" 56 | }, 57 | "license": "MIT", 58 | "dependencies": { 59 | "debug": "^2.2.0", 60 | "koa-convert": "^1.2.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/detect.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('koa:i18next') 2 | 3 | const DEFAULT_ORDER = ['querystring', 'cookie', 'header'] 4 | 5 | const detectors = { 6 | cookie: function (context, options) { 7 | let cookie = options.lookupCookie || 'i18next' 8 | return context.cookies.get(cookie) 9 | }, 10 | 11 | // fork from i18next-express-middleware 12 | header: function (context) { 13 | let acceptLanguage = context.get('accept-language') 14 | let found 15 | let locales = [] 16 | if (acceptLanguage) { 17 | let lngs = [] 18 | 19 | // associate language tags by their 'q' value (between 1 and 0) 20 | acceptLanguage.split(',').forEach(function (l) { 21 | let parts = l.split(';') // 'en-GB;q=0.8' -> ['en-GB', 'q=0.8'] 22 | 23 | // get the language tag qvalue: 'q=0.8' -> 0.8 24 | let qvalue = 1 // default qvalue 25 | 26 | for (let i = 0; i < parts.length; i++) { 27 | let part = parts[i].split('=') 28 | if (part[0] === 'q' && !isNaN(part[1])) { 29 | qvalue = Number(part[1]) 30 | break 31 | } 32 | } 33 | // add the tag and primary subtag to the qvalue associations 34 | lngs.push({ 35 | lng: parts[0], 36 | q: qvalue 37 | }) 38 | }) 39 | 40 | lngs.sort(function (a, b) { 41 | return b.q - a.q 42 | }) 43 | 44 | for (let i = 0; i < lngs.length; i++) { 45 | locales.push(lngs[i].lng) 46 | } 47 | 48 | if (locales.length) found = locales 49 | } 50 | 51 | return found 52 | }, 53 | path: function (context, options) { 54 | let found 55 | 56 | if (options.lookupPath !== undefined && context.params) { 57 | found = context.params[options.lookupPath] 58 | } 59 | 60 | if (!found && options.lookupFromPathIndex !== undefined) { 61 | let parts = context.path.split('/') 62 | if (parts[0] === '') { // Handle paths that start with a slash, i.e., '/foo' -> ['', 'foo'] 63 | parts.shift() 64 | } 65 | 66 | if (parts.length > options.lookupFromPathIndex) { 67 | found = parts[options.lookupFromPathIndex] 68 | } 69 | } 70 | return found 71 | }, 72 | querystring: function (context, options) { 73 | let name = options.lookupQuerystring || 'lng' 74 | return context.query[name] 75 | }, 76 | 77 | session: function (context, options) { 78 | let name = options.lookupSession || 'lng' 79 | return context.session && context.session[name] 80 | } 81 | } 82 | 83 | export default function (context, options = {}) { 84 | let { order, fallback } = options 85 | order = order && Array.isArray(order) 86 | ? order 87 | : DEFAULT_ORDER 88 | 89 | let lngs = [] 90 | 91 | for (let i = 0, len = order.length; i < len; i++) { 92 | let detector = detectors[order[i]] 93 | let lng 94 | if (detector) { 95 | lng = detector(context, options) 96 | } 97 | if (lng && typeof lng === 'string') { 98 | lngs.push(lng) 99 | } else { 100 | lngs = lngs.concat(lng) 101 | } 102 | } 103 | let found 104 | for (let i = 0, len = lngs.length; i < len; i++) { 105 | let cleanedLng = context.i18next.services.languageUtils.formatLanguageCode(lngs[i]) 106 | if (context.i18next.services.languageUtils.isWhitelisted(cleanedLng)) found = cleanedLng 107 | if (found) break 108 | } 109 | 110 | return found || fallback 111 | } 112 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import test from 'ava' 3 | import agent from 'supertest' 4 | import path from 'path' 5 | import Koa from 'koa' 6 | import Router from 'koa-router' 7 | import session from 'koa-session' 8 | import i18next from 'i18next' 9 | import i18nextBackend from 'i18next-sync-fs-backend' 10 | import i18n from '../src/index' 11 | 12 | const debug = require('debug')('koa:i18next') 13 | 14 | const defaultConfig = { 15 | lookupCookie: 'i18next', 16 | lookupQuerystring: 'lng', 17 | lookupPath: 'lng', 18 | order: ['querystring', 'cookie', 'header', 'path', 'session'] 19 | } 20 | 21 | function request (app) { 22 | return agent(http.createServer(app.callback())) 23 | } 24 | 25 | request.agent = function (app) { 26 | return agent.agent(http.createServer(app.callback())) 27 | } 28 | 29 | function appFactory (config = {}) { 30 | const app = new Koa() 31 | app.keys = ['abc', 'edf'] 32 | app.use(session(app)) 33 | app.use(i18n(i18next, Object.assign({}, defaultConfig, config))) 34 | // add routes 35 | const router = new Router() 36 | router.get('/', action) 37 | router.get('/zh/hello', action) 38 | router.get('/v1/:lng/hello', action) 39 | router.get('/session', async function(ctx, next) { 40 | ctx.status = 200 41 | ctx.session.lng = 'zh' 42 | await next() 43 | }) 44 | app.use(router.routes()) 45 | return app 46 | } 47 | 48 | async function action(ctx, next) { 49 | ctx.status = 200 50 | ctx.body = { message: ctx.t('hello') } 51 | await next() 52 | } 53 | 54 | test.before('init i18next', t => { 55 | i18next 56 | .use(i18nextBackend) 57 | .init({ 58 | backend: { 59 | loadPath: path.resolve('./locales/{{lng}}/{{ns}}.json'), 60 | addPath: path.resolve('./locales/{{lng}}/{{ns}}.missing.json') 61 | }, 62 | preload: ['zh', 'en'], 63 | fallbackLng: 'en' 64 | }) 65 | i18next.on('loaded', loaded => { 66 | debug('loaded resource', loaded) 67 | }) 68 | }) 69 | 70 | test.cb('set language by header', t => { 71 | const app = appFactory() 72 | request(app) 73 | .get('/') 74 | .set('Accept-Language', 'zh') 75 | .expect(200) 76 | .expect('Content-Language', 'zh') 77 | .end((err, res) => { 78 | t.truthy(res.body) 79 | t.is(res.body.message, '你好') 80 | t.end() 81 | }) 82 | }) 83 | 84 | test.cb('set language by cookie', t => { 85 | const app = appFactory({ 86 | order: ['cookie'] 87 | }) 88 | 89 | const cookies = 'i18next=zh' 90 | let req = request(app).get('/') 91 | req.cookies = cookies 92 | req.expect(200, { 93 | message: '你好' 94 | }, t.end) 95 | }) 96 | 97 | test.cb('set language by query', t => { 98 | const app = appFactory({ 99 | order: ['querystring'] 100 | }) 101 | request(app) 102 | .get('/?lng=zh') 103 | .expect(200, { 104 | message: '你好' 105 | }, t.end) 106 | }) 107 | 108 | test.cb('set language by lookupPath', t => { 109 | const app = appFactory({ 110 | order: ['path'] 111 | }) 112 | request(app) 113 | .get('/v1/zh/hello') 114 | .expect(200, { 115 | message: '你好' 116 | }, t.end) 117 | }) 118 | 119 | test.cb('set language by lookup path index', t => { 120 | const app = appFactory({ 121 | lookupFromPathIndex: 0, 122 | order: ['path'] 123 | }) 124 | request(app) 125 | .get('/zh/hello') 126 | .expect(200, { 127 | message: '你好' 128 | }, t.end) 129 | }) 130 | 131 | test.cb('set language by session', t => { 132 | const app = appFactory({ 133 | lookupSession: 'lng', 134 | order: ['session'] 135 | }) 136 | 137 | const agent = request.agent(app) 138 | agent 139 | .get('/session') 140 | .expect(200) 141 | .end((err, res) => { 142 | agent 143 | .get('/') 144 | .expect(200, { 145 | message: '你好' 146 | }, t.end) 147 | }) 148 | }) 149 | 150 | test.cb('wiil set cookie', t => { 151 | const app = appFactory() 152 | request(app) 153 | .get('/v1/zh/hello') 154 | .set('Accept-Language', 'zh') 155 | .expect('set-cookie', 'i18next=zh; path=/;') 156 | .expect(200, { 157 | message: '你好' 158 | }, t.end) 159 | }) 160 | 161 | test.cb('will fallback to en', t => { 162 | const app = appFactory() 163 | request(app) 164 | .get('/v1/de/hello') 165 | .expect(200, { 166 | message: 'hi' 167 | }, t.end) 168 | }) 169 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import detect from './detect' 2 | 3 | const debug = require('debug')('koa:i18next') 4 | 5 | export default function koaI18next(i18next, options = {}) { 6 | 7 | return async function i18nextMiddleware(ctx, next) { 8 | ctx.i18next = i18next 9 | let { 10 | lookupCookie, 11 | lookupSession 12 | } = options 13 | 14 | let lng = detect(ctx, options) 15 | lng && setLanguage(ctx, lng, options) 16 | 17 | debug('language is', lng) 18 | 19 | ctx.t = function (...args) { 20 | let key 21 | let opts 22 | // do detect path 23 | if (!lng && isDetectPath(options.order)) { 24 | lng = detect(ctx, Object.assign(options, { order: ['path'] })) 25 | lng && setLanguage(ctx, lng, options) 26 | } 27 | 28 | if (args.length === 1) { 29 | args.push({}) 30 | } 31 | 32 | for (let i = 0, len = args.length; i < len; i++) { 33 | let arg = args[i] 34 | if (typeof arg === 'object' && !Array.isArray(arg)) { 35 | arg.lng = lng 36 | } 37 | } 38 | return i18next.t.apply(i18next, args) 39 | } 40 | 41 | await next() 42 | } 43 | } 44 | 45 | 46 | function setPath(object, path, newValue) { 47 | let stack; 48 | if (typeof path !== "string") stack = [].concat(path); 49 | if (typeof path === "string") stack = path.split("."); 50 | 51 | while (stack.length > 1) { 52 | let key = stack.shift(); 53 | if (key.indexOf("###") > -1) key = key.replace(/###/g, "."); 54 | if (!object[key]) object[key] = {}; 55 | object = object[key]; 56 | } 57 | 58 | let key = stack.shift(); 59 | if (key.indexOf("###") > -1) key = key.replace(/###/g, "."); 60 | object[key] = newValue; 61 | } 62 | 63 | koaI18next.getResourcesHandler = function (i18next, options) { 64 | options = options || {}; 65 | let maxAge = options.maxAge || 60 * 60 * 24 * 30; 66 | const propertyParam = options.propertyParam || 'query'; 67 | 68 | return async function (ctx, next) { 69 | if (options.path && ctx.path !== options.path) { 70 | return await next(); 71 | } 72 | if (!i18next.services.backendConnector) return ctx.throw(404, "koa-i18next-middleware:: no backend configured"); 73 | 74 | let resources = {}; 75 | 76 | ctx.type = "json"; 77 | if (options.cache !== undefined ? options.cache : process.env.NODE_ENV === "production") { 78 | ctx.set("Cache-Control", "public, max-age=" + maxAge); 79 | ctx.set("Expires", new Date(new Date().getTime() + maxAge * 1000).toUTCString()); 80 | } else { 81 | ctx.set("Pragma", "no-cache"); 82 | ctx.set("Cache-Control", "no-cache"); 83 | } 84 | 85 | let languages = ctx[propertyParam][options.lngParam || "lng"] ? ctx[propertyParam][options.lngParam || "lng"].split(" ") : []; 86 | let namespaces = ctx[propertyParam][options.nsParam || "ns"] ? ctx[propertyParam][options.nsParam || "ns"].split(" ") : []; 87 | 88 | // extend ns 89 | namespaces.forEach(ns => { 90 | if (i18next.options.ns && i18next.options.ns.indexOf(ns) < 0) i18next.options.ns.push(ns); 91 | }); 92 | 93 | i18next.services.backendConnector.load(languages, namespaces, function () { 94 | languages.forEach(lng => { 95 | namespaces.forEach(ns => { 96 | setPath(resources, [lng, ns], i18next.getResourceBundle(lng, ns)); 97 | }); 98 | }); 99 | ctx.body = resources; 100 | }); 101 | }; 102 | } 103 | 104 | koaI18next.getMissingKeyHandler = function (i18next, options) { 105 | options = options || {}; 106 | const propertyParam = options.propertyParam || 'query'; 107 | 108 | return async function (ctx, next) { 109 | if (options.path && ctx.path !== options.path) { 110 | return await next(); 111 | } 112 | let lng = ctx[propertyParam][options.lngParam || "lng"]; 113 | let ns = ctx[propertyParam][options.nsParam || "ns"]; 114 | 115 | if (!i18next.services.backendConnector) return ctx.throw(404, "koa-i18next-middleware:: no backend configured"); 116 | 117 | for (var m in ctx.request.body) { 118 | i18next.services.backendConnector.saveMissing([lng], ns, m, ctx.request.body[m]); 119 | } 120 | ctx.body = "ok"; 121 | }; 122 | } 123 | 124 | function isDetectPath(order = []) { 125 | return order.indexOf('path') !== -1 126 | } 127 | 128 | function setLanguage(context, lng, options = {}) { 129 | const { 130 | lookupCookie 131 | , lookupPath 132 | , lookupSession 133 | } = options 134 | context.locals = Object.assign(context.locals || {}, { lng }) 135 | context.state = Object.assign(context.state || {}, { lng }) 136 | context.language = context.lng = lng 137 | context.set('content-language', lng) 138 | if (lookupCookie) { 139 | context.cookies.set(lookupCookie, lng, { httpOnly: false, signed: false }); 140 | } 141 | if (lookupSession && context.session) { 142 | context.session[lookupSession] = lng 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This repo is *deprecated*, please use [koa-i18next-middleware](https://github.com/lxzxl/koa-i18next-middleware). 2 | 3 | 4 | # Introduction 5 | A middleware to use i18next in koajs. 6 | 7 | # Getting started 8 | install dependencies 9 | ```bash 10 | npm install koa-i18next 11 | ``` 12 | ## working with backend 13 | 14 | ```javascript 15 | const koa = require('koa') 16 | const i18next = require('i18next') 17 | const Backend = require('i18next-sync-fs-backend') // or i18next-node-fs-backend 18 | const koaI18next = require('koa-i18next') 19 | 20 | i18next 21 | .use(Backend) 22 | .init({ 23 | backend: { 24 | // translation resources 25 | loadPath: path.resolve('./locales/{{lng}}/{{ns}}.json'), 26 | addPath: path.resolve('./locales/{{lng}}/{{ns}}.missing.json') 27 | }, 28 | preload: ['zh', 'en'], // must know what languages to use 29 | fallbackLng: 'en' 30 | }) 31 | 32 | const app = koa() 33 | app.use(koaI18next(i18next, { 34 | lookupCookie: 'i18next', // detecting language in cookie 35 | /** 36 | * Detecting language in path params, need third part route middleware. 37 | * Example 38 | * path: `/api/:lng/hello 39 | */ 40 | lookupPath: 'lng', 41 | lookupFromPathIndex: 0, // detecting language in path, like `/api/zh/hello` which `zh` is the language and the index is 1 42 | lookupQuerystring: 'lng', // detect language in query, 43 | lookupSession: 'lng', // detect language in session 44 | /** 45 | * support querystring, cookie, header, session, path 46 | * default order: ['querystring', 'cookie', 'header'] 47 | */ 48 | order: ['querystring'], 49 | next: true // if koa is version 2 50 | })) 51 | // koa@1.X 52 | app.use(function* (next) { 53 | this.body = {message: this.t('lalala')} 54 | }) 55 | ``` 56 | 57 | ## language detection 58 | 59 | Support for: 60 | - querystring 61 | - cookie 62 | - header 63 | - session 64 | - path 65 | 66 | If you don't config this, it will use ['querystring', 'cookie', 'header'] as default detecting order. 67 | 68 | ## options 69 | 70 | ```javascript 71 | { 72 | // order and from where user language should be detected 73 | order: ['querystring', 'cookie', 'header'/*, 'path', 'session'*/], 74 | // keys or params to lookup language from 75 | lookupQuerystring: 'lng', 76 | lookupCookie: 'i18next', 77 | lookupSession: 'lng', 78 | lookupPath: 'lng', 79 | lookupFromPathIndex: 0, 80 | // if koa is v2 81 | next: true 82 | } 83 | ``` 84 | 85 | ## api 86 | you can use i18next in koa middleware, as: 87 | 88 | ```javascript 89 | this.t('balabala', options) 90 | ``` 91 | 92 | `i18next.t` arguments are all supported. [i18next.t](http://i18next.com/docs/api/#t) 93 | 94 | 95 | ## Resources middleware 96 | > inspired by i18next-express-middleware 97 | 98 | If needed, you can serve the translations with the resources middleware. 99 | As Koa doesn't come with built-in routing system, you'll have to handle the request path matching by a routing library or by specifying a path in the options. 100 | 101 | ```javascript 102 | const koaI18next = require('koa-i18next') 103 | ... 104 | // Exemple without router 105 | app.use(koaI18next.getResourcesHandler(i18next, {path: '/locales/resources.json'})); 106 | 107 | // Exemple with koa-router 108 | app.get('/locales/resources.json', koaI18next.getResourcesHandler(i18next)); 109 | ``` 110 | 111 | Requesting `/locales/resources.json?lng=en&ns=fr` will return the translations of the `common` namespace of the `en` language. 112 | Note: Multiple languages and namespaces are supported. 113 | 114 | Available options (with default) are : 115 | ```javascript 116 | { 117 | // Serve resources only if ctx.path match given path (handle every request by default) 118 | path: false, 119 | // Where to look for lng & ns parameters on the context (query, params, ...) 120 | propertyParam: 'query', 121 | // Name of the lng param 122 | lngParam: 'lng', 123 | // Name of the ns param 124 | nsParam: 'ns' 125 | } 126 | ``` 127 | 128 | ## Missing Keys middleware 129 | > inspired by i18next-express-middleware 130 | 131 | You can handle missing keys with the Missing Keys middleware. It'll need the `bodyparser` in order to get the submitted missing translations. 132 | 133 | ```javascript 134 | const koaI18next = require('koa-i18next') 135 | ... 136 | // Exemple without router 137 | app.use(koaI18next.getMissingKeysHandler(i18next, {path: '/locales/add'})); 138 | 139 | // Exemple with koa-router 140 | app.post('/locales/add', koaI18next.getMissingKeysHandler(i18next)); 141 | ``` 142 | 143 | Posting on `/locales/add?lng=en&ns=common` with an array of missing message as body will perform a save missing for the `common` namespace and the `en` language. 144 | 145 | Available options (with default) are : 146 | ```javascript 147 | { 148 | // Handle request only if ctx.path match given path 149 | path: false, 150 | // Where to look for lng & ns parameters on the context (query, params, ...) 151 | propertyParam: 'query', 152 | // Name of the lng params 153 | lngParam: 'lng', 154 | // Name of the ns param 155 | nsParam: 'ns' 156 | 157 | } 158 | ``` 159 | 160 | # License 161 | [MIT](http://opensource.org/licenses/MIT) 162 | --------------------------------------------------------------------------------