diff --git a/.autod.conf.js b/.autod.conf.js
deleted file mode 100644
index b70537d..0000000
--- a/.autod.conf.js
+++ /dev/null
@@ -1,23 +0,0 @@
-'use strict'
-
-module.exports = {
- write: true,
- prefix: '^',
- devprefix: '^',
- exclude: [
- 'test/fixtures',
- ],
- devdep: [
- 'autod',
- 'egg-bin',
- 'egg-ci',
- 'egg-mock',
- 'eslint',
- 'eslint-config-egg',
- ],
- keep: [
- ],
- semver: [
- ],
- registry: 'https://r.cnpmjs.org',
-};
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..a24e501
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,2 @@
+test/fixtures
+coverage
diff --git a/.eslintrc b/.eslintrc
index 727e0b0..9bcdb46 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,11 +1,6 @@
{
- "extends": "eslint-config-egg",
- "rules": {
- "no-console": "off",
- "no-magic-numbers": "off",
- "generator-star-spacing": "off",
- "prefer-template": "off",
- "max-len": "off",
- "dot-notation": "off"
- }
+ "extends": [
+ "eslint-config-egg/typescript",
+ "eslint-config-egg/lib/rules/enforce-node-prefix"
+ ]
}
diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
new file mode 100644
index 0000000..fd73aac
--- /dev/null
+++ b/.github/workflows/nodejs.yml
@@ -0,0 +1,16 @@
+name: CI
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ Job:
+ name: Node.js
+ uses: node-modules/github-actions/.github/workflows/node-test.yml@master
+ with:
+ version: '18.19.0, 20, 22'
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml
new file mode 100644
index 0000000..bac3fac
--- /dev/null
+++ b/.github/workflows/pkg.pr.new.yml
@@ -0,0 +1,23 @@
+name: Publish Any Commit
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - run: corepack enable
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Build
+ run: npm run prepublishOnly --if-present
+
+ - run: npx pkg-pr-new publish
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..a2bf04a
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,13 @@
+name: Release
+
+on:
+ push:
+ branches: [ master ]
+
+jobs:
+ release:
+ name: Node.js
+ uses: eggjs/github-actions/.github/workflows/node-release.yml@master
+ secrets:
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
diff --git a/.gitignore b/.gitignore
index ee69671..c010914 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,44 +1,11 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-
-# Runtime data
-pids
-*.pid
-*.seed
-
-# Directory for instrumented libs generated by jscoverage/JSCover
-lib-cov
-
-# Coverage directory used by tools like istanbul
-coverage
-
-# nyc test coverage
-.nyc_output
-
-# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
-.grunt
-
-# node-waf configuration
-.lock-wscript
-
-# Compiled binary addons (http://nodejs.org/api/addons.html)
-build/Release
-
-# Dependency directories
-node_modules
-jspm_packages
-
-# Optional npm cache directory
-.npm
-
-# Optional REPL history
-.node_repl_history
-
-!.gitignore
-!.eslintrc
-!.eslintignore
-!.travis.yml
-
-run
+logs/
+npm-debug.log
+node_modules/
+coverage/
+test/fixtures/**/run
+.DS_Store
+.tshy*
+.eslintcache
+dist
+package-lock.json
+.package-lock.json
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 679b83d..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-sudo: false
-language: node_js
-node_js:
- - '8'
- - '10'
- - '12'
-before_install:
- - npm i npminstall -g
-install:
- - npminstall
-script:
- - npm run ci
-after_script:
- - npminstall codecov && codecov
diff --git a/History.md b/CHANGELOG.md
similarity index 100%
rename from History.md
rename to CHANGELOG.md
diff --git a/README.md b/README.md
index d3e2ee5..793dff7 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,21 @@
-# egg-i18n
+# @eggjs/i18n
[![NPM version][npm-image]][npm-url]
-[![build status][travis-image]][travis-url]
+[![Node.js CI](https://github.com/eggjs/i18n/actions/workflows/nodejs.yml/badge.svg)](https://github.com/eggjs/i18n/actions/workflows/nodejs.yml)
[![Test coverage][codecov-image]][codecov-url]
-[![David deps][david-image]][david-url]
[![Known Vulnerabilities][snyk-image]][snyk-url]
[![npm download][download-image]][download-url]
-
-[npm-image]: https://img.shields.io/npm/v/egg-i18n.svg?style=flat-square
-[npm-url]: https://npmjs.org/package/egg-i18n
-[travis-image]: https://img.shields.io/travis/eggjs/egg-i18n.svg?style=flat-square
-[travis-url]: https://travis-ci.org/eggjs/egg-i18n
-[codecov-image]: https://codecov.io/github/eggjs/egg-i18n/coverage.svg?branch=master
-[codecov-url]: https://codecov.io/github/eggjs/egg-i18n?branch=master
-[david-image]: https://img.shields.io/david/eggjs/egg-i18n.svg?style=flat-square
-[david-url]: https://david-dm.org/eggjs/egg-i18n
-[snyk-image]: https://snyk.io/test/npm/egg-i18n/badge.svg?style=flat-square
-[snyk-url]: https://snyk.io/test/npm/egg-i18n
-[download-image]: https://img.shields.io/npm/dm/egg-i18n.svg?style=flat-square
-[download-url]: https://npmjs.org/package/egg-i18n
+[![Node.js Version](https://img.shields.io/node/v/@eggjs/i18n.svg?style=flat)](https://nodejs.org/en/download/)
+[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com)
+
+[npm-image]: https://img.shields.io/npm/v/@eggjs/i18n.svg?style=flat-square
+[npm-url]: https://npmjs.org/package/@eggjs/i18n
+[codecov-image]: https://img.shields.io/codecov/c/github/eggjs/i18n.svg?style=flat-square
+[codecov-url]: https://codecov.io/github/eggjs/i18n?branch=master
+[snyk-image]: https://snyk.io/test/npm/@eggjs/i18n/badge.svg?style=flat-square
+[snyk-url]: https://snyk.io/test/npm/@eggjs/i18n
+[download-image]: https://img.shields.io/npm/dm/@eggjs/i18n.svg?style=flat-square
+[download-url]: https://npmjs.org/package/@eggjs/i18n
可以为你的应用提供多语言的特性
@@ -30,31 +27,35 @@
## 配置
-默认处于关闭状态,你需要在 `config/plugin.js` 开启它:
+默认处于关闭状态,你需要在 `config/plugin.ts` 开启它:
-```js
-// config/plugin.js
-exports.i18n = {
- enable: true,
- package: 'egg-i18n',
+```ts
+// config/plugin.ts
+export default {
+ i18n: {
+ enable: true,
+ package: '@eggjs/i18n',
+ },
};
```
-你可以修改 `config/config.default.js` 来设定 i18n 的配置项:
-
-```js
-// config/config.default.js
-exports.i18n = {
- // 默认语言,默认 "en_US"
- defaultLocale: 'zh-CN',
- // URL 参数,默认 "locale"
- queryField: 'locale',
- // Cookie 记录的 key, 默认:"locale"
- cookieField: 'locale',
- // Cookie 的 domain 配置,默认为空,代表当前域名有效
- cookieDomain: '',
- // Cookie 默认 `1y` 一年后过期, 如果设置为 Number,则单位为 ms
- cookieMaxAge: '1y',
+你可以修改 `config/config.default.ts` 来设定 i18n 的配置项:
+
+```ts
+// config/config.default.ts
+export default {
+ i18n: {
+ // 默认语言,默认 "en_US"
+ defaultLocale: 'zh-CN',
+ // URL 参数,默认 "locale"
+ queryField: 'locale',
+ // Cookie 记录的 key, 默认:"locale"
+ cookieField: 'locale',
+ // Cookie 的 domain 配置,默认为空,代表当前域名有效
+ cookieDomain: '',
+ // Cookie 默认 `1y` 一年后过期, 如果设置为 Number,则单位为 ms
+ cookieMaxAge: '1y',
+ },
};
```
@@ -62,18 +63,18 @@ exports.i18n = {
## 编写你的 I18n 多语言文件
-```js
-// config/locale/zh-CN.js
-module.exports = {
+```ts
+// config/locale/zh-CN.ts
+export default {
"Email": "邮箱",
"Welcome back, %s!": "欢迎回来,%s!",
"Hello %s, how are you today?": "你好 %s, 今天过得咋样?",
};
```
-```js
-// config/locale/en-US.js
-module.exports = {
+```ts
+// config/locale/en-US.ts
+export default {
"Email": "Email",
};
```
@@ -95,25 +96,26 @@ I18n 为你提供 `__` (Alias: `gettext`) 函数,让你可以轻松获得 loca
> NOTE: __ 是两个下划线哦!
-- ctx.__ = function (key, value[, value2, ...]): 类似 util.format 接口
-- ctx.__ = function (key, values): 支持数组下标占位符方式,如
+- `ctx.__ = function (key, value[, value2, ...])`: 类似 util.format 接口
+- `ctx.__ = function (key, values)`: 支持数组下标占位符方式,如
+
+```ts
+ctx.__('{0} {0} {1} {1}'), ['foo', 'bar']);
+ctx.gettext('{0} {0} {1} {1}'), ['foo', 'bar']);
-```js
-ctx.__('{0} {0} {1} {1}'), ['foo', 'bar'])
-ctx.gettext('{0} {0} {1} {1}'), ['foo', 'bar'])
=>
foo foo bar bar
```
### Controllers 下的使用示例
-```js
-module.exports = function* () {
- this.body = {
- message: this.__('Welcome back, %s!', this.user.name)
+```ts
+export default ctx => {
+ ctx.body = {
+ message: ctx.__('Welcome back, %s!', ctx.user.name)
// 或者使用 gettext,如果觉得 __ 不好看的话
- // message: this.gettext('Welcome back, %s!', this.user.name)
- user: this.user,
+ // message: this.gettext('Welcome back, %s!', ctx.user.name)
+ user: ctx.user,
};
};
```
@@ -132,10 +134,24 @@ module.exports = function* () {
### 修改应用的默认语言
-你可以用下面几种方式修改应用的当前语言(修改或会记录到 Cookie),下次请求直接用设定好的语言。
+你可以用下面几种方式修改应用的当前语言(修改或会记录到 Cookie),下次请求直接用设定好的语言。
优先级从上到下:
-- query: /?locale=en-US
-- cookie: locale=zh-TW
-- header: Accept-Language: zh-CN,zh;q=0.5
+- query: `/?locale=en-US`
+- cookie: `locale=zh-TW`
+- header: `Accept-Language: zh-CN,zh;q=0.5`
+
+## Questions & Suggestions
+
+Please open an issue [here](https://github.com/eggjs/egg/issues).
+
+## License
+
+[MIT](LICENSE)
+
+## Contributors
+
+[![Contributors](https://contrib.rocks/image?repo=eggjs/i18n)](https://github.com/eggjs/i18n/graphs/contributors)
+
+Made with [contributors-img](https://contrib.rocks).
diff --git a/app.js b/app.js
deleted file mode 100644
index 8b9992d..0000000
--- a/app.js
+++ /dev/null
@@ -1,138 +0,0 @@
-'use strict';
-
-const locales = require('koa-locales');
-const fs = require('fs');
-const path = require('path');
-const debug = require('debug')('egg:plugin:i18n');
-
-/**
- * I18n 国际化
- *
- * 通过设置 Plugin 配置 `i18n: true`,开启多语言支持。
- *
- * #### 语言文件存储路径
- *
- * 统一存放在 `config/locale/*.js` 下( 兼容`config/locales/*.js` ),如包含英文,简体中文,繁体中文的语言文件:
- *
- * ```
- * - config/locale/
- * - en-US.js
- * - zh-CN.js
- * - zh-TW.js
- * ```
- * @class I18n
- * @param {App} app Application object.
- * @example
- *
- * #### I18n 文件内容
- *
- * ```js
- * // config/locale/zh-CN.js
- * module.exports = {
- * "Email": "邮箱",
- * "Welcome back, %s!": "欢迎回来, %s!",
- * "Hello %s, how are you today?": "你好 %s, 今天过得咋样?",
- * };
- * ```
- *
- * ```js
- * // config/locale/en-US.js
- * module.exports = {
- * "Email": "Email",
- * };
- * ```
- * 或者也可以用 JSON 格式的文件:
- *
- * ```js
- * // config/locale/zh-CN.json
- * {
- * "email": "邮箱",
- * "login": "帐号",
- * "createdAt": "注册时间"
- * }
- * ```
- */
-module.exports = app => {
- /**
- * 如果开启了 I18n 多语言功能,那么会出现此 API,通过它可以获取到当前请求对应的本地化数据。
- *
- * 详细使用说明,请查看 {@link I18n}
- * - `ctx.__ = function (key, value[, value2, ...])`: 类似 `util.format` 接口
- * - `ctx.__ = function (key, values)`: 支持数组下标占位符方式,如
- * - `__` 的别名是 `gettext(key, value)`
- *
- * > NOTE: __ 是两个下划线哦!
- * @method Context#__
- * @example
- * ```js
- * ctx.__('{0} {0} {1} {1}'), ['foo', 'bar'])
- * ctx.gettext('{0} {0} {1} {1}'), ['foo', 'bar'])
- * =>
- * foo foo bar bar
- * ```
- * ##### Controller 下的使用示例
- *
- * ```js
- * module.exports = function* () {
- * this.body = {
- * message: this.__('Welcome back, %s!', this.user.name),
- * // 或者使用 gettext,如果觉得 __ 不好看的话
- * // message: this.gettext('Welcome back, %s!', this.user.name),
- * user: this.user,
- * };
- * };
- * ```
- *
- * ##### View 文件下的使用示例
- *
- * ```html
- *
{{ __('Email') }}: {{ user.email }}
- *
- * {{ __('Hello %s, how are you today?', user.name) }}
- *
- *
- * {{ __('{0} {0} {1} {1}'), ['foo', 'bar']) }}
- *
- * ```
- *
- * ##### locale 参数获取途径
- *
- * 优先级从上到下:
- *
- * - query: `/?locale=en-US`
- * - cookie: `locale=zh-TW`
- * - header: `Accept-Language: zh-CN,zh;q=0.5`
- */
- app.config.i18n.functionName = '__';
-
- /* istanbul ignore next */
- app.config.i18n.dirs = Array.isArray(app.config.i18n.dirs) ? app.config.i18n.dirs : [];
- // 按 egg > 插件 > 框架 > 应用的顺序遍历 config/locale(config/locales) 目录,加载所有配置文件
- for (const unit of app.loader.getLoadUnits()) {
- const localePath = path.join(unit.path, 'config/locale');
-
- /**
- * 优先选择 `config/locale` 目录下的多语言文件
- * 避免 2 个目录同时存在时可能导致的冲突
- */
- if (fs.existsSync(localePath)) {
- app.config.i18n.dirs.push(localePath);
- } else {
- app.config.i18n.dirs.push(path.join(unit.path, 'config/locales'));
- }
- }
-
- debug('app.config.i18n.dirs:', app.config.i18n.dirs);
-
- locales(app, app.config.i18n);
-
- /**
- * `ctx.__` 的别名。
- * @see {@link Context#__}
- * @method Context#gettext
- */
- app.context.gettext = app.context.__;
-
- // 自动加载 Middleware
- app.config.coreMiddleware.push('i18n');
-};
diff --git a/app/extend/context.js b/app/extend/context.js
deleted file mode 100644
index 673c151..0000000
--- a/app/extend/context.js
+++ /dev/null
@@ -1,16 +0,0 @@
-'use strict';
-
-module.exports = {
- /**
- * get current request locale
- * @member Context#locale
- * @return {String} lower case locale string, e.g.: 'zh-cn', 'en-us'
- */
- get locale() {
- return this.__getLocale();
- },
-
- set locale(l) {
- return this.__setLocale(l);
- },
-};
diff --git a/app/middleware/i18n.js b/app/middleware/i18n.js
deleted file mode 100644
index 5d25a7f..0000000
--- a/app/middleware/i18n.js
+++ /dev/null
@@ -1,15 +0,0 @@
-'use strict';
-
-module.exports = () => {
- return function i18n(ctx, next) {
- function gettext() {
- return ctx.__.apply(ctx, arguments);
- }
- ctx.locals = {
- gettext,
- __: gettext,
- };
-
- return next();
- };
-};
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index aea9477..0000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-environment:
- matrix:
- - nodejs_version: '8'
- - nodejs_version: '10'
- - nodejs_version: '12'
-
-install:
- - ps: Install-Product node $env:nodejs_version
- - npm i npminstall && node_modules\.bin\npminstall
-
-test_script:
- - node --version
- - npm --version
- - npm run test
-
-build: off
diff --git a/config/config.default.js b/config/config.default.js
deleted file mode 100644
index 7baefa8..0000000
--- a/config/config.default.js
+++ /dev/null
@@ -1,30 +0,0 @@
-'use strict';
-
-module.exports = () => {
-
- const exports = {};
-
- /**
- * I18n options
- * @member Config#i18n
- * @property {String} defaultLocale - 默认语言是美式英语,毕竟支持多语言,基本都是以英语为母板
- * @property {Array} dirs - 多语言资源文件存放路径,不建议修改
- * @property {String} queryField - 设置当前语言的 query 参数字段名,默认通过 `query.locale` 获取
- * 如果你想修改为 `query.lang`,那么请通过修改此配置实现
- * @property {String} cookieField - 如果当前请求用户语言有变化,都会设置到 cookie 中保持着,
- * 默认是存储在key 为 locale 的 cookie 中
- * @property {String} cookieDomain - 存储 locale 的 cookie domain 配置,默认不设置,为当前域名才有效
- * @property {String|Number} cookieMaxAge - cookie 默认 `1y` 一年后过期,
- * 如果设置为 Number,则单位为 ms
- */
- exports.i18n = {
- defaultLocale: 'en_US',
- dirs: [],
- queryField: 'locale',
- cookieField: 'locale',
- cookieDomain: '',
- cookieMaxAge: '1y',
- };
-
- return exports;
-};
diff --git a/package.json b/package.json
index 88797a9..b20c11e 100644
--- a/package.json
+++ b/package.json
@@ -1,21 +1,21 @@
{
- "name": "egg-i18n",
+ "name": "@eggjs/i18n",
"version": "2.1.1",
+ "publishConfig": {
+ "access": "public"
+ },
"eggPlugin": {
- "name": "i18n"
+ "name": "i18n",
+ "exports": {
+ "import": "./dist/esm",
+ "require": "./dist/commonjs",
+ "typescript": "./src"
+ }
},
"description": "i18n plugin for egg",
- "main": "index.js",
- "scripts": {
- "lint": "eslint lib test *.js",
- "test": "npm run lint && egg-bin test",
- "cov": "egg-bin cov",
- "ci": "npm run lint && npm run cov",
- "autod": "autod"
- },
"repository": {
"type": "git",
- "url": "git+https://github.com/eggjs/egg-i18n.git"
+ "url": "git+https://github.com/eggjs/i18n.git"
},
"keywords": [
"egg",
@@ -24,33 +24,73 @@
"author": "gxcsoccer ",
"license": "MIT",
"bugs": {
- "url": "https://github.com/eggjs/egg-i18n/issues"
+ "url": "https://github.com/eggjs/i18n/issues"
},
- "homepage": "https://github.com/eggjs/egg-i18n#readme",
+ "homepage": "https://github.com/eggjs/i18n#readme",
"engines": {
- "node": ">= 8.0.0"
- },
- "files": [
- "app",
- "config",
- "app.js"
- ],
- "ci": {
- "version": "8, 10, 12"
+ "node": ">= 18.19.0"
},
"dependencies": {
- "debug": "^3.1.0",
- "koa-locales": "^1.11.0"
+ "@eggjs/core": "^6.2.13",
+ "@eggjs/utils": "^4.2.5",
+ "humanize-ms": "^2.0.0",
+ "ini": "^5.0.0",
+ "js-yaml": "^4.1.0",
+ "utility": "^2.4.0"
},
"devDependencies": {
- "autod": "^2.10.1",
- "egg": "^2.21.1",
- "egg-bin": "^4.3.5",
- "egg-ci": "^1.8.0",
- "egg-mock": "^3.13.1",
- "egg-view-nunjucks": "^2.1.4",
- "eslint": "^4.10.0",
- "eslint-config-egg": "^5.1.1",
- "pedding": "^1.1.0"
- }
+ "@arethetypeswrong/cli": "^0.17.1",
+ "@eggjs/bin": "7",
+ "@eggjs/mock": "6",
+ "@eggjs/tsconfig": "1",
+ "@types/ini": "^4.1.1",
+ "@types/js-yaml": "^4.0.9",
+ "@types/mocha": "10",
+ "@types/node": "22",
+ "egg": "4",
+ "egg-view-nunjucks": "^2.3.0",
+ "eslint": "8",
+ "eslint-config-egg": "14",
+ "rimraf": "6",
+ "tshy": "3",
+ "tshy-after": "1",
+ "typescript": "5"
+ },
+ "scripts": {
+ "lint": "eslint --cache src test --ext .ts",
+ "pretest": "npm run clean && npm run lint -- --fix",
+ "test": "egg-bin test",
+ "preci": "npm run clean && npm run lint",
+ "ci": "egg-bin cov",
+ "postci": "npm run prepublishOnly && npm run clean",
+ "clean": "rimraf dist",
+ "prepublishOnly": "tshy && tshy-after && attw --pack"
+ },
+ "type": "module",
+ "tshy": {
+ "exports": {
+ ".": "./src/index.ts",
+ "./package.json": "./package.json"
+ }
+ },
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/esm/index.d.ts",
+ "default": "./dist/esm/index.js"
+ },
+ "require": {
+ "types": "./dist/commonjs/index.d.ts",
+ "default": "./dist/commonjs/index.js"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "types": "./dist/commonjs/index.d.ts",
+ "main": "./dist/commonjs/index.js",
+ "module": "./dist/esm/index.js"
}
diff --git a/src/app.ts b/src/app.ts
new file mode 100644
index 0000000..9409885
--- /dev/null
+++ b/src/app.ts
@@ -0,0 +1,99 @@
+import path from 'node:path';
+import { debuglog } from 'node:util';
+import { loadLocaleResources } from './locales.js';
+import { exists } from 'utility';
+import { ms } from 'humanize-ms';
+import type { ILifecycleBoot } from '@eggjs/core';
+import type I18nApplication from './app/extend/application.js';
+import { formatLocale } from './utils.js';
+
+const debug = debuglog('@eggjs/i18n/app');
+
+/**
+ * I18n 国际化
+ *
+ * 通过设置 Plugin 配置 `i18n: true`,开启多语言支持。
+ *
+ * #### 语言文件存储路径
+ *
+ * 统一存放在 `config/locale/*.js` 下( 兼容`config/locales/*.js` ),如包含英文,简体中文,繁体中文的语言文件:
+ *
+ * ```
+ * - config/locale/
+ * - en-US.js
+ * - zh-CN.js
+ * - zh-TW.js
+ * ```
+ * @class I18n
+ * @param {App} app Application object.
+ * @example
+ *
+ * #### I18n 文件内容
+ *
+ * ```js
+ * // config/locale/zh-CN.js
+ * module.exports = {
+ * "Email": "邮箱",
+ * "Welcome back, %s!": "欢迎回来, %s!",
+ * "Hello %s, how are you today?": "你好 %s, 今天过得咋样?",
+ * };
+ * ```
+ *
+ * ```js
+ * // config/locale/en-US.js
+ * module.exports = {
+ * "Email": "Email",
+ * };
+ * ```
+ * 或者也可以用 JSON 格式的文件:
+ *
+ * ```js
+ * // config/locale/zh-CN.json
+ * {
+ * "email": "邮箱",
+ * "login": "帐号",
+ * "createdAt": "注册时间"
+ * }
+ * ```
+ */
+
+export default class I18n implements ILifecycleBoot {
+ private readonly app;
+
+ constructor(app: I18nApplication & { locals: Record }) {
+ this.app = app;
+ }
+
+ async didLoad() {
+ const i18nConfig = this.app.config.i18n;
+ i18nConfig.defaultLocale = formatLocale(i18nConfig.defaultLocale);
+ i18nConfig.cookieMaxAge = ms(i18nConfig.cookieMaxAge);
+
+ i18nConfig.dirs = Array.isArray(i18nConfig.dirs) ? i18nConfig.dirs : [];
+ // 按 egg > 插件 > 框架 > 应用的顺序遍历 config/locale(config/locales) 目录,加载所有配置文件
+ for (const unit of this.app.loader.getLoadUnits()) {
+ let localePath = path.join(unit.path, 'config/locale');
+ /**
+ * 优先选择 `config/locale` 目录下的多语言文件,不存在时再选择 `config/locales` 目录
+ * 避免 2 个目录同时存在时可能导致的冲突
+ */
+ if (!(await exists(localePath))) {
+ localePath = path.join(unit.path, 'config/locales');
+ }
+ i18nConfig.dirs.push(localePath);
+ }
+
+ debug('app.config.i18n.dirs:', i18nConfig.dirs);
+
+ await loadLocaleResources(this.app, i18nConfig);
+
+ const app = this.app;
+ function gettextInContext(key: string, ...args: any[]) {
+ const ctx = app.ctxStorage.getStore()!;
+ return ctx.gettext(key, ...args);
+ }
+ // 在 view 中使用 `__(key, value, ...args)`
+ this.app.locals.gettext = gettextInContext;
+ this.app.locals.__ = gettextInContext;
+ }
+}
diff --git a/src/app/extend/application.ts b/src/app/extend/application.ts
new file mode 100644
index 0000000..e5804ff
--- /dev/null
+++ b/src/app/extend/application.ts
@@ -0,0 +1,91 @@
+import { debuglog, format } from 'node:util';
+import { EggCore } from '@eggjs/core';
+import { isObject } from '../../utils.js';
+
+const debug = debuglog('@eggjs/i18n/app/extend/application');
+
+export const I18N_RESOURCES = Symbol('Application i18n resources');
+
+export default class I18nApplication extends EggCore {
+ declare [I18N_RESOURCES]: Record>;
+
+ isSupportLocale(locale: string) {
+ return !!this[I18N_RESOURCES][locale];
+ }
+
+ gettext(locale: string, key: string, value?: any, ...args: any[]) {
+ if (!locale || !key) {
+ // __()
+ // __('en')
+ return '';
+ }
+
+ const resource = this[I18N_RESOURCES][locale] || {};
+
+ let text = resource[key];
+ if (text === undefined) {
+ text = key;
+ }
+
+ debug('%s: %j => %j', locale, key, text);
+ if (!text) {
+ return '';
+ }
+
+ if (value === undefined) {
+ // __(locale, key)
+ return text;
+ }
+ if (args.length === 0) {
+ if (isObject(value)) {
+ // __(locale, key, object)
+ // __('zh', '{a} {b} {b} {a}', {a: 'foo', b: 'bar'})
+ // =>
+ // foo bar bar foo
+ return formatWithObject(text, value);
+ }
+
+ if (Array.isArray(value)) {
+ // __(locale, key, array)
+ // __('zh', '{0} {1} {1} {0}', ['foo', 'bar'])
+ // =>
+ // foo bar bar foo
+ return formatWithArray(text, value);
+ }
+
+ // __(locale, key, value)
+ return format(text, value);
+ }
+
+ // __(locale, key, value1, ...)
+ return format(text, value, ...args);
+ }
+
+ __(locale: string, key: string, value?: any, ...args: any[]) {
+ return this.gettext(locale, key, value, ...args);
+ }
+}
+
+const ARRAY_INDEX_RE = /\{(\d+)\}/g;
+function formatWithArray(text: string, values: any[]) {
+ return text.replace(ARRAY_INDEX_RE, (original, matched) => {
+ const index = parseInt(matched);
+ if (index < values.length) {
+ return values[index];
+ }
+ // not match index, return original text
+ return original;
+ });
+}
+
+const Object_INDEX_RE = /\{(.+?)\}/g;
+function formatWithObject(text: string, values: Record) {
+ return text.replace(Object_INDEX_RE, (original, matched) => {
+ const value = values[matched];
+ if (value) {
+ return value;
+ }
+ // not match index, return original text
+ return original;
+ });
+}
diff --git a/src/app/extend/context.ts b/src/app/extend/context.ts
new file mode 100644
index 0000000..e0fd7de
--- /dev/null
+++ b/src/app/extend/context.ts
@@ -0,0 +1,192 @@
+import { debuglog } from 'node:util';
+import { Context } from '@eggjs/core';
+import { formatLocale } from '../../utils.js';
+
+const debug = debuglog('@eggjs/i18n/app/extend/context');
+
+export default class I18nContext extends Context {
+ /**
+ * get current request locale
+ * @member Context#locale
+ * @return {String} lower case locale string, e.g.: 'zh-cn', 'en-us'
+ */
+ get locale(): string {
+ return this.__getLocale();
+ }
+
+ set locale(l: string) {
+ this.__setLocale(l);
+ }
+
+ /**
+ * `ctx.__` 的别名。
+ * @see {@link Context#__}
+ * @function Context#gettext
+ */
+ gettext(key: string, value?: any, ...args: any[]) {
+ return this.app.gettext(this.locale, key, value, ...args);
+ }
+
+ /**
+ * 如果开启了 I18n 多语言功能,那么会出现此 API,通过它可以获取到当前请求对应的本地化数据。
+ *
+ * 详细使用说明,请查看 {@link I18n}
+ * - `ctx.__ = function (key, value[, value2, ...])`: 类似 `util.format` 接口
+ * - `ctx.__ = function (key, values)`: 支持数组下标占位符方式,如
+ * - `__` 的别名是 `gettext(key, value)`
+ *
+ * > NOTE: __ 是两个下划线哦!
+ * @function Context#__
+ * @example
+ * ```js
+ * ctx.__('{0} {0} {1} {1}'), ['foo', 'bar'])
+ * ctx.gettext('{0} {0} {1} {1}'), ['foo', 'bar'])
+ * =>
+ * foo foo bar bar
+ * ```
+ * ##### Controller 下的使用示例
+ *
+ * ```js
+ * module.exports = function* () {
+ * this.body = {
+ * message: this.__('Welcome back, %s!', this.user.name),
+ * // 或者使用 gettext,如果觉得 __ 不好看的话
+ * // message: this.gettext('Welcome back, %s!', this.user.name),
+ * user: this.user,
+ * };
+ * };
+ * ```
+ *
+ * ##### View 文件下的使用示例
+ *
+ * ```html
+ * {{ __('Email') }}: {{ user.email }}
+ *
+ * {{ __('Hello %s, how are you today?', user.name) }}
+ *
+ *
+ * {{ __('{0} {0} {1} {1}'), ['foo', 'bar']) }}
+ *
+ * ```
+ *
+ * ##### locale 参数获取途径
+ *
+ * 优先级从上到下:
+ *
+ * - query: `/?locale=en-US`
+ * - cookie: `locale=zh-TW`
+ * - header: `Accept-Language: zh-CN,zh;q=0.5`
+ */
+ __(key: string, value?: any, ...args: any[]) {
+ return this.gettext(key, value, ...args);
+ }
+
+ declare __locale: string;
+ // 1. query: /?locale=en-US
+ // 2. cookie: locale=zh-TW
+ // 3. header: Accept-Language: zh-CN,zh;q=0.5
+ __getLocale(): string {
+ if (this.__locale) {
+ return this.__locale;
+ }
+
+ const { localeAlias, defaultLocale, cookieField, queryField, writeCookie } = this.app.config.i18n;
+ const cookieLocale = this.cookies.get(cookieField, { signed: false });
+
+ // 1. Query
+ let locale = this.query[queryField] as string;
+ let localeOrigin = 'query';
+
+ // 2. Cookie
+ if (!locale) {
+ locale = cookieLocale;
+ localeOrigin = 'cookie';
+ }
+
+ // 3. Header
+ if (!locale) {
+ // Accept-Language: zh-CN,zh;q=0.5
+ // Accept-Language: zh-CN
+ let languages = this.acceptsLanguages();
+ if (languages) {
+ if (Array.isArray(languages)) {
+ if (languages[0] === '*') {
+ languages = languages.slice(1);
+ }
+ if (languages.length > 0) {
+ for (const l of languages) {
+ const lang = formatLocale(l);
+ if (this.app.isSupportLocale(lang) || localeAlias[lang]) {
+ locale = lang;
+ localeOrigin = 'header';
+ break;
+ }
+ }
+ }
+ } else {
+ locale = languages;
+ localeOrigin = 'header';
+ }
+ }
+
+ // all missing, set it to defaultLocale
+ if (!locale) {
+ locale = defaultLocale;
+ localeOrigin = 'default';
+ }
+ }
+
+ // cookie alias
+ if (locale in localeAlias) {
+ const originalLocale = locale;
+ locale = localeAlias[locale];
+ debug('Used alias, received %s but using %s', originalLocale, locale);
+ }
+
+ locale = formatLocale(locale);
+
+ // validate locale
+ if (!this.app.isSupportLocale(locale)) {
+ debug('Locale %s is not supported. Using default (%s)', locale, defaultLocale);
+ locale = defaultLocale;
+ }
+
+ // if header not send, set the locale cookie
+ if (writeCookie && cookieLocale !== locale && !this.headerSent) {
+ updateCookie(this, locale);
+ }
+ debug('Locale: %s from %s', locale, localeOrigin);
+ this.__locale = locale;
+ this.__localeOrigin = localeOrigin;
+ return locale;
+ }
+
+ declare __localeOrigin: string;
+ __getLocaleOrigin() {
+ if (this.__localeOrigin) {
+ return this.__localeOrigin;
+ }
+ this.__getLocale();
+ return this.__localeOrigin;
+ }
+
+ __setLocale(locale: string) {
+ this.__locale = locale;
+ this.__localeOrigin = 'set';
+ updateCookie(this, locale);
+ }
+}
+
+function updateCookie(ctx: Context, locale: string) {
+ const { cookieMaxAge, cookieField, cookieDomain } = ctx.app.config.i18n;
+ const cookieOptions = {
+ // make sure browser javascript can read the cookie
+ httpOnly: false,
+ maxAge: cookieMaxAge as number,
+ signed: false,
+ domain: cookieDomain,
+ overwrite: true,
+ };
+ ctx.cookies.set(cookieField, locale, cookieOptions);
+ debug('Saved cookie with locale %s, options: %j', locale, cookieOptions);
+}
diff --git a/src/config/config.default.ts b/src/config/config.default.ts
new file mode 100644
index 0000000..8a35866
--- /dev/null
+++ b/src/config/config.default.ts
@@ -0,0 +1,15 @@
+import type { I18nConfig } from '../types.js';
+
+export default {
+ i18n: {
+ defaultLocale: 'en_US',
+ dirs: [],
+ queryField: 'locale',
+ cookieField: 'locale',
+ cookieDomain: '',
+ cookieMaxAge: '1y',
+ localeAlias: {},
+ writeCookie: true,
+ dir: undefined,
+ } as I18nConfig,
+};
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..ce5fb25
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1 @@
+import './types.js';
diff --git a/src/locales.ts b/src/locales.ts
new file mode 100644
index 0000000..a20fa86
--- /dev/null
+++ b/src/locales.ts
@@ -0,0 +1,73 @@
+import { debuglog } from 'node:util';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import ini from 'ini';
+import yaml from 'js-yaml';
+import { exists, readJSON } from 'utility';
+import { importModule } from '@eggjs/utils';
+import type { I18nConfig } from './types.js';
+import I18nApplication, { I18N_RESOURCES } from './app/extend/application.js';
+import { formatLocale, isObject } from './utils.js';
+
+const debug = debuglog('@eggjs/i18n/locales');
+
+export async function loadLocaleResources(app: I18nApplication, options: I18nConfig) {
+ const localeDirs = options.dirs;
+ const resources: Record> = {};
+
+ if (options.dir && !localeDirs.includes(options.dir)) {
+ localeDirs.push(options.dir);
+ }
+
+ for (const dir of localeDirs) {
+ if (!(await exists(dir))) {
+ continue;
+ }
+
+ const names = await fs.readdir(dir);
+ for (const name of names) {
+ const filepath = path.join(dir, name);
+ // support en_US.js => en-US.js
+ const locale = formatLocale(name.split('.')[0]);
+ let resource: Record = {};
+
+ if (name.endsWith('.js') || name.endsWith('.ts')) {
+ resource = flattening(await importModule(filepath, {
+ importDefaultOnly: true,
+ }));
+ } else if (name.endsWith('.json')) {
+ resource = flattening(await readJSON(filepath));
+ } else if (name.endsWith('.properties')) {
+ resource = ini.parse(await fs.readFile(filepath, 'utf8'));
+ } else if (name.endsWith('.yml') || name.endsWith('.yaml')) {
+ resource = flattening(yaml.load(await fs.readFile(filepath, 'utf8')));
+ }
+
+ resources[locale] = resources[locale] || {};
+ Object.assign(resources[locale], resource);
+ }
+ }
+
+ debug('Init locales with %j, got %j resources', options, Object.keys(resources));
+ app[I18N_RESOURCES] = resources;
+}
+
+function flattening(data: any) {
+ const result: Record = {};
+
+ function deepFlat(data: any, prefix: string) {
+ for (const key in data) {
+ const value = data[key];
+ const k = prefix ? prefix + '.' + key : key;
+ if (isObject(value)) {
+ deepFlat(value, k);
+ } else {
+ result[k] = String(value);
+ }
+ }
+ }
+
+ deepFlat(data, '');
+
+ return result;
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..bea50bb
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,80 @@
+/**
+ * I18n options
+ * @member Config#i18n
+ */
+export interface I18nConfig {
+ /**
+ * 默认语言是美式英语,毕竟支持多语言,基本都是以英语为母板
+ * 默认值是 `en_US`
+ */
+ defaultLocale: string;
+ /**
+ * 多语言资源文件存放路径,不建议修改
+ * 默认值是 `[]`
+ */
+ dirs: string[];
+ /**
+ * @deprecated please use `dirs` instead
+ */
+ dir?: string;
+ /**
+ * 设置当前语言的 query 参数字段名,默认通过 `query.locale` 获取
+ * 如果你想修改为 `query.lang`,那么请通过修改此配置实现
+ * 默认值是 `locale`
+ */
+ queryField: string;
+ /**
+ * 如果当前请求用户语言有变化,都会设置到 cookie 中保持着,
+ * 默认是存储在key 为 locale 的 cookie 中
+ * 默认值是 `locale`
+ */
+ cookieField: string;
+ /**
+ * 存储 locale 的 cookie domain 配置,默认不设置,为当前域名才有效
+ * 默认值是 `''`
+ */
+ cookieDomain: string;
+ /**
+ * cookie 默认一年后过期,如果设置为 Number,则单位为 ms
+ * 默认值是 `'1y'`
+ */
+ cookieMaxAge: string | number;
+ /**
+ * locale 别名,比如 zh_CN => cn
+ * 默认值是 `{}`
+ */
+ localeAlias: Record;
+ /**
+ * 是否写入 cookie
+ * 默认值是 `true`
+ */
+ writeCookie: boolean;
+}
+
+declare module '@eggjs/core' {
+ // add EggAppConfig overrides types
+ interface EggAppConfig {
+ i18n: I18nConfig;
+ }
+
+ interface Context {
+ /**
+ * get and set current request locale
+ * @member Context#locale
+ * @return {String} lower case locale string, e.g.: 'zh-cn', 'en-us'
+ */
+ locale: string;
+
+ gettext(key: string, value?: any, ...args: any[]): string;
+ __(key: string, value?: any, ...args: any[]): string;
+
+ __getLocale(): string;
+ __setLocale(l: string): void;
+ }
+
+ interface EggCore {
+ isSupportLocale(locale: string): boolean;
+ gettext(locale: string, key: string, value?: any, ...args: any[]): string;
+ __(locale: string, key: string, value?: any, ...args: any[]): string;
+ }
+}
diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts
new file mode 100644
index 0000000..53c65c7
--- /dev/null
+++ b/src/typings/index.d.ts
@@ -0,0 +1,4 @@
+// make sure to import egg typings and let typescript know about it
+// @see https://github.com/whxaxes/blog/issues/11
+// and https://www.typescriptlang.org/docs/handbook/declaration-merging.html
+import 'egg';
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..926baff
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,8 @@
+export function isObject(obj: any) {
+ return Object.prototype.toString.call(obj) === '[object Object]';
+}
+
+export function formatLocale(locale: string) {
+ // support zh_CN, en_US => zh-CN, en-US
+ return locale.replace('_', '-').toLowerCase();
+}
diff --git a/test/fixtures/apps/i18n/app/controller/message.js b/test/fixtures/apps/i18n/app/controller/message.js
index a1bf2ff..2d7660a 100644
--- a/test/fixtures/apps/i18n/app/controller/message.js
+++ b/test/fixtures/apps/i18n/app/controller/message.js
@@ -1,5 +1,3 @@
-'use strict';
-
module.exports = async ctx => {
ctx.body = {
message: ctx.__('Hello %s, how are you today? How was your %s.', 'fengmk2', 18),
diff --git a/test/fixtures/apps/i18n/config/locales/zh-CN.js b/test/fixtures/apps/i18n/config/locales/zh-CN.js
index 17f894a..64c5318 100644
--- a/test/fixtures/apps/i18n/config/locales/zh-CN.js
+++ b/test/fixtures/apps/i18n/config/locales/zh-CN.js
@@ -3,4 +3,5 @@
module.exports = {
Email: '邮箱',
'Hello %s, how are you today?': '%s,今天过得如何?',
+ "Hello %s, how are you today? How was your %s.": "%s 你好, 今天过得如何?你的 %s 如何。",
};
diff --git a/test/fixtures/apps/loader/a/config/locales/zh-CN.ts b/test/fixtures/apps/loader/a/config/locales/zh-CN.ts
new file mode 100644
index 0000000..379c635
--- /dev/null
+++ b/test/fixtures/apps/loader/a/config/locales/zh-CN.ts
@@ -0,0 +1,4 @@
+export default {
+ pluginA: true,
+ pluginATS: 'text from ts file',
+}
diff --git a/test/fixtures/apps/loader/a/config/locales/zh-CN.yaml b/test/fixtures/apps/loader/a/config/locales/zh-CN.yaml
new file mode 100644
index 0000000..bfd2088
--- /dev/null
+++ b/test/fixtures/apps/loader/a/config/locales/zh-CN.yaml
@@ -0,0 +1 @@
+EmailYaml: "邮箱 from yaml"
diff --git a/test/fixtures/apps/loader/a/config/locales/zh_CN.properties b/test/fixtures/apps/loader/a/config/locales/zh_CN.properties
new file mode 100644
index 0000000..869fa1d
--- /dev/null
+++ b/test/fixtures/apps/loader/a/config/locales/zh_CN.properties
@@ -0,0 +1 @@
+EmailIni = 邮箱 from properties
diff --git a/test/fixtures/custom_egg/config/locales/zh-CN.js b/test/fixtures/custom_egg/config/locales/zh-CN.js
index f4b7be2..067f927 100644
--- a/test/fixtures/custom_egg/config/locales/zh-CN.js
+++ b/test/fixtures/custom_egg/config/locales/zh-CN.js
@@ -1,5 +1,3 @@
-'use strict';
-
-module.exports = {
+export default {
framework: true,
};
diff --git a/test/fixtures/custom_egg/index.js b/test/fixtures/custom_egg/index.js
index 8a5aef4..4c0f29b 100644
--- a/test/fixtures/custom_egg/index.js
+++ b/test/fixtures/custom_egg/index.js
@@ -1,26 +1,28 @@
-'use strict';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ startCluster as _startCluster,
+ Application as _Application,
+ Agent as _Agent,
+} from 'egg';
-const egg = require('egg');
+const __filename = fileURLToPath(import.meta.url);
+const EGG_PATH = path.dirname(__filename);
-const EGG_PATH = __dirname;
-const startCluster = egg.startCluster;
-
-class CustomApplication extends egg.Application {
+export class Application extends _Application {
get [Symbol.for('egg#eggPath')]() {
return EGG_PATH;
}
}
-class BeggAgent extends egg.Agent {
+export class Agent extends _Agent {
get [Symbol.for('egg#eggPath')]() {
return EGG_PATH;
}
}
-exports.Application = CustomApplication;
-exports.Agent = BeggAgent;
-module.exports.startCluster = (options, callback) => {
+export function startCluster(options, callback) {
options = options || {};
options.customEgg = EGG_PATH;
- startCluster(options, callback);
-};
+ _startCluster(options, callback);
+}
diff --git a/test/fixtures/custom_egg/package.json b/test/fixtures/custom_egg/package.json
index 377e8c3..756cb1e 100644
--- a/test/fixtures/custom_egg/package.json
+++ b/test/fixtures/custom_egg/package.json
@@ -1,3 +1,4 @@
{
- "name": "custom-egg"
+ "name": "custom-egg",
+ "type": "module"
}
diff --git a/test/i18n.test.js b/test/i18n.test.js
deleted file mode 100644
index b247844..0000000
--- a/test/i18n.test.js
+++ /dev/null
@@ -1,271 +0,0 @@
-'use strict';
-
-const mm = require('egg-mock');
-const assert = require('assert');
-const pedding = require('pedding');
-const join = require('path').join;
-
-describe('test/i18n.test.js', () => {
- let app;
- before(done => {
- app = mm.app({
- baseDir: 'apps/i18n',
- plugin: 'i18n',
- });
- app.ready(done);
- });
- after(() => app.close());
-
- describe('ctx.__(key, value)', () => {
- it('should return locale de', done => {
- app.httpRequest()
- .get('/message?locale=de')
- .expect(200)
- .expect('Set-Cookie', /locale=de; path=\/; expires=[^;]+ GMT$/)
- .expect(res => {
- const cookie = res.headers['set-cookie'].join('|');
- assert(cookie);
- // don't set domain
- assert(/\|locale=de; path=\/; expires=[\w, :]+ GMT$/.test(cookie));
- })
- .expect({
- message: 'Hallo fengmk2, wie geht es dir heute? Wie war dein 18.',
- empty: '',
- notexists_key: 'key not exists',
- empty_string: '',
- novalue: 'key %s ok',
- arguments3: '1 2 3',
- arguments4: '1 2 3 4',
- arguments5: '1 2 3 4 5',
- arguments6: '1 2 3 4 5. 6',
- values: 'foo bar foo bar {2} {100}',
- }, done);
- });
-
- it('should return default locale en_US', function(done) {
- app.httpRequest()
- .get('/message?locale=')
- .expect(200)
- .expect('Set-Cookie', /locale=en-us; path=\/; expires=[^;]+ GMT$/)
- .expect({
- message: 'Hello fengmk2, how are you today? How was your 18.',
- empty: '',
- notexists_key: 'key not exists',
- empty_string: '',
- novalue: 'key %s ok',
- arguments3: '1 2 3',
- arguments4: '1 2 3 4',
- arguments5: '1 2 3 4 5',
- arguments6: '1 2 3 4 5. 6',
- values: 'foo bar foo bar {2} {100}',
- }, done);
- });
- });
-
- describe('with cookieDomain', () => {
- let app;
- before(done => {
- app = mm.app({
- baseDir: 'apps/i18n-domain',
- plugin: 'i18n',
- });
- app.ready(done);
- });
- after(() => app.close());
-
- it('should return locale de', done => {
- app.httpRequest()
- .get('/message?locale=de')
- .expect(200)
- .expect('Set-Cookie', /locale=de; path=\/; expires=[^;]+ GMT; domain=.foo.com$/)
- .expect({
- message: 'Hallo fengmk2, wie geht es dir heute? Wie war dein 18.',
- empty: '',
- notexists_key: 'key not exists',
- empty_string: '',
- novalue: 'key %s ok',
- arguments3: '1 2 3',
- arguments4: '1 2 3 4',
- arguments5: '1 2 3 4 5',
- arguments6: '1 2 3 4 5. 6',
- values: 'foo bar foo bar {2} {100}',
- }, done);
- });
-
- it('should return default locale en_US', function(done) {
- app.httpRequest()
- .get('/message?locale=')
- .expect(200)
- .expect('Set-Cookie', /locale=en-us; path=\/; expires=[^;]+ GMT; domain=.foo.com$/)
- .expect({
- message: 'Hello fengmk2, how are you today? How was your 18.',
- empty: '',
- notexists_key: 'key not exists',
- empty_string: '',
- novalue: 'key %s ok',
- arguments3: '1 2 3',
- arguments4: '1 2 3 4',
- arguments5: '1 2 3 4 5',
- arguments6: '1 2 3 4 5. 6',
- values: 'foo bar foo bar {2} {100}',
- }, done);
- });
- });
-
- describe('ctx.locale', () => {
- let app;
- before(() => {
- app = mm.app({
- baseDir: 'apps/i18n',
- plugin: 'i18n',
- });
- return app.ready();
- });
- after(() => app.close());
-
- it('should get request default locale', () => {
- const ctx = app.mockContext();
- assert(ctx.locale === 'en-us');
- });
-
- it('should get request locale from cookie', () => {
- const ctx = app.mockContext({
- headers: {
- Cookie: 'locale=zh-CN',
- },
- });
- assert(ctx.locale === 'zh-cn');
- });
- });
-
- describe('loader', function() {
- let app;
- before(done => {
- app = mm.app({
- baseDir: 'apps/loader',
- customEgg: join(__dirname, './fixtures/custom_egg'),
- });
- app.ready(done);
- });
- after(() => app.close());
-
- it('should return locale from plugin a', function(done) {
- app.httpRequest()
- .get('/?key=pluginA')
- .set('Accept-Language', 'zh-CN,zh;q=0.5')
- .expect('true', done);
- });
-
- it('should return locale from plugin b', function(done) {
- app.httpRequest()
- .get('/?key=pluginB')
- .set('Accept-Language', 'zh-CN,zh;q=0.5')
- .expect('true', done);
- });
-
- it('should return locale from framework', function(done) {
- app.httpRequest()
- .get('/?key=framework')
- .set('Accept-Language', 'zh-CN,zh;q=0.5')
- .expect('true', done);
- });
-
- it('should return locale from locales2', function(done) {
- app.httpRequest()
- .get('/?key=locales2')
- .set('Accept-Language', 'zh-CN,zh;q=0.5')
- .expect('true', done);
- });
-
- it('should use locale/ when both exist locales/ and locale/', function(done) {
- app.httpRequest()
- .get('/?key=pluginC')
- .set('Accept-Language', 'zh-CN,zh;q=0.5')
- .expect('i18n form locale', done);
- });
-
- describe('view renderString with __(key, value)', () => {
- it('should render with default locale: en-US', function(done) {
- app.httpRequest()
- .get('/renderString')
- .expect(200)
- .expect('Set-Cookie', /locale=en-us; path=\/; expires=[^;]+ GMT/)
- .expect('Email: \nHello fengmk2, how are you today?\nfoo bar\n', done);
- });
-
- it('should render with query locale: zh_CN', function(done) {
- app.httpRequest()
- .get('/renderString?locale=zh_CN')
- .expect(200)
- .expect('Set-Cookie', /locale=zh-cn; path=\/; expires=[^;]+ GMT/)
- .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n', done);
- });
-
- // Accept-Language: zh-CN,zh;q=0.5
- // Accept-Language: zh-CN;q=1
- // Accept-Language: zh-CN
- it('should render with Accept-Language: zh-CN,zh;q=0.5', function(done) {
- done = pedding(3, done);
- app.httpRequest()
- .get('/renderString')
- .set('Accept-Language', 'zh-CN,zh;q=0.5')
- .expect(200)
- .expect('Set-Cookie', /locale=zh-cn; path=\/; expires=[^;]+ GMT/)
- .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n', done);
-
- app.httpRequest()
- .get('/renderString')
- .set('Accept-Language', 'zh-CN;q=1')
- .expect(200)
- .expect('Set-Cookie', /locale=zh-cn; path=\/; expires=[^;]+ GMT/)
- .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n', done);
-
- app.httpRequest()
- .get('/renderString')
- .set('Accept-Language', 'zh_cn')
- .expect(200)
- .expect('Set-Cookie', /locale=zh-cn; path=\/; expires=[^;]+ GMT/)
- .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n', done);
- });
-
- it('should render set cookie locale: zh-CN if query locale not equal to cookie', function(done) {
- app.httpRequest()
- .get('/renderString?locale=en-US')
- .set('Cookie', 'locale=zh-CN')
- .expect(200)
- .expect('Set-Cookie', /locale=en-us; path=\/; expires=[^;]+ GMT/)
- .expect('Email: \nHello fengmk2, how are you today?\nfoo bar\n', done);
- });
-
- it('should render with cookie locale: zh-cn', () => {
- return app.httpRequest()
- .get('/renderString')
- .set('Cookie', 'locale=zh-cn')
- .expect(200)
- .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n')
- .expect(res => {
- // cookie should not change
- const setCookies = res.headers['set-cookie'] || [];
- assert(!setCookies.join(',').includes('locale='));
- });
- });
- });
- });
-
- describe('ctx.locale', () => {
- it('should locale work and can be override', () => {
- const ctx = app.mockContext({
- query: { locale: 'zh-cn' },
- });
- assert(ctx.locale === 'zh-cn');
- assert(ctx.response.headers['set-cookie'].length === 1);
- assert(ctx.response.headers['set-cookie'][0].match(/^locale=zh\-cn; path=\/; expires=[^;]+ GMT$/));
- ctx.locale = 'en-us';
- assert(ctx.response.headers['set-cookie'].length === 1);
- assert(ctx.response.headers['set-cookie'][0].match(/^locale=en\-us; path=\/; expires=[^;]+ GMT$/));
- assert(ctx.locale === 'en-us');
- assert(ctx.response.headers['set-cookie'].length === 1);
- assert(ctx.response.headers['set-cookie'][0].match(/^locale=en\-us; path=\/; expires=[^;]+ GMT$/));
- });
- });
-});
diff --git a/test/i18n.test.ts b/test/i18n.test.ts
new file mode 100644
index 0000000..e6e88ac
--- /dev/null
+++ b/test/i18n.test.ts
@@ -0,0 +1,320 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { strict as assert } from 'node:assert';
+import { mm, MockApplication } from '@eggjs/mock';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+describe('test/i18n.test.ts', () => {
+ let app: MockApplication;
+ before(async () => {
+ app = mm.app({
+ baseDir: 'apps/i18n',
+ });
+ await app.ready();
+ });
+ after(() => app.close());
+
+ afterEach(mm.restore);
+
+ describe('ctx.__(key, value)', () => {
+ it('should return locale de', async () => {
+ await app.httpRequest()
+ .get('/message?locale=de')
+ .expect(200)
+ .expect('Set-Cookie', /,locale=de; path=\/; max-age=31557600; expires=[^;]+ GMT$/)
+ .expect({
+ message: 'Hallo fengmk2, wie geht es dir heute? Wie war dein 18.',
+ empty: '',
+ notexists_key: 'key not exists',
+ empty_string: '',
+ novalue: 'key %s ok',
+ arguments3: '1 2 3',
+ arguments4: '1 2 3 4',
+ arguments5: '1 2 3 4 5',
+ arguments6: '1 2 3 4 5. 6',
+ values: 'foo bar foo bar {2} {100}',
+ });
+ });
+
+ it('should return default locale en_US', async () => {
+ await app.httpRequest()
+ .get('/message?locale=')
+ .expect(200)
+ .expect('Set-Cookie', /locale=en-us; path=\/; max-age=31557600; expires=[^;]+ GMT$/)
+ .expect({
+ message: 'Hello fengmk2, how are you today? How was your 18.',
+ empty: '',
+ notexists_key: 'key not exists',
+ empty_string: '',
+ novalue: 'key %s ok',
+ arguments3: '1 2 3',
+ arguments4: '1 2 3 4',
+ arguments5: '1 2 3 4 5',
+ arguments6: '1 2 3 4 5. 6',
+ values: 'foo bar foo bar {2} {100}',
+ });
+ });
+
+ it('should return get locale from cookie', async () => {
+ await app.httpRequest()
+ .get('/message')
+ .set('Cookie', 'locale=zh-cn')
+ .expect(200)
+ .expect({
+ message: 'fengmk2 你好, 今天过得如何?你的 18 如何。',
+ empty: '',
+ notexists_key: 'key not exists',
+ empty_string: '',
+ novalue: 'key %s ok',
+ arguments3: '1 2 3',
+ arguments4: '1 2 3 4',
+ arguments5: '1 2 3 4 5',
+ arguments6: '1 2 3 4 5. 6',
+ values: 'foo bar foo bar {2} {100}',
+ });
+ });
+
+ it('should __() work', () => {
+ const ctx = app.mockContext();
+ assert.strictEqual(ctx.__(''), '');
+ assert.strictEqual(ctx.__('Email'), 'Email');
+ assert.strictEqual(ctx.__('Email %s', 'ok'), 'Email ok');
+ assert.strictEqual(ctx.__('Email %s %d', 'ok', 1), 'Email ok 1');
+ assert.strictEqual(ctx.__('Email %s %d %j', 'ok', 1, { foo: 'bar' }), 'Email ok 1 {"foo":"bar"}');
+ assert.strictEqual(ctx.__('Email %s %d %j %s', 'ok', 1, { foo: 'bar' }, 'foo'), 'Email ok 1 {"foo":"bar"} foo');
+ assert.strictEqual(ctx.__('Email {a} {b}', { a: 'a', b: 'b' }), 'Email a b');
+ assert.strictEqual(ctx.__('Email {a} {b} {c}', { a: 'a', b: 'b' }), 'Email a b {c}');
+ assert.strictEqual(ctx.__('Email {0} {1}', [ 'a', 'b' ]), 'Email a b');
+ assert.strictEqual(ctx.app.__('en-us', 'Email {0} {1}', [ 'a', 'b' ]), 'Email a b');
+ assert.strictEqual(ctx.app.__('en-us', '', [ 'a', 'b' ]), '');
+ });
+ });
+
+ describe('with cookieDomain', () => {
+ let app: MockApplication;
+ before(async () => {
+ app = mm.app({
+ baseDir: 'apps/i18n-domain',
+ });
+ await app.ready();
+ });
+ after(() => app.close());
+
+ it('should return locale de', async () => {
+ await app.httpRequest()
+ .get('/message?locale=de')
+ .expect(200)
+ .expect('Set-Cookie', /locale=de; path=\/; max-age=31557600; expires=[^;]+ GMT; domain=.foo.com$/)
+ .expect({
+ message: 'Hallo fengmk2, wie geht es dir heute? Wie war dein 18.',
+ empty: '',
+ notexists_key: 'key not exists',
+ empty_string: '',
+ novalue: 'key %s ok',
+ arguments3: '1 2 3',
+ arguments4: '1 2 3 4',
+ arguments5: '1 2 3 4 5',
+ arguments6: '1 2 3 4 5. 6',
+ values: 'foo bar foo bar {2} {100}',
+ });
+ });
+
+ it('should return default locale en_US', async () => {
+ await app.httpRequest()
+ .get('/message?locale=')
+ .expect(200)
+ .expect('Set-Cookie', /locale=en-us; path=\/; max-age=31557600; expires=[^;]+ GMT; domain=.foo.com$/)
+ .expect({
+ message: 'Hello fengmk2, how are you today? How was your 18.',
+ empty: '',
+ notexists_key: 'key not exists',
+ empty_string: '',
+ novalue: 'key %s ok',
+ arguments3: '1 2 3',
+ arguments4: '1 2 3 4',
+ arguments5: '1 2 3 4 5',
+ arguments6: '1 2 3 4 5. 6',
+ values: 'foo bar foo bar {2} {100}',
+ });
+ });
+ });
+
+ describe('ctx.locale', () => {
+ let app: MockApplication;
+ before(async () => {
+ app = mm.app({
+ baseDir: 'apps/i18n',
+ });
+ await app.ready();
+ });
+ after(() => app.close());
+
+ it('should get request default locale', () => {
+ const ctx = app.mockContext();
+ assert.strictEqual(ctx.locale, 'en-us');
+ });
+
+ it('should get request locale from cookie', () => {
+ const ctx = app.mockContext({
+ headers: {
+ cookie: 'locale=zh-CN',
+ },
+ });
+ assert.strictEqual(ctx.locale, 'zh-cn');
+ });
+ });
+
+ describe('loader', () => {
+ let app: MockApplication;
+ before(async () => {
+ app = mm.app({
+ baseDir: 'apps/loader',
+ framework: path.join(__dirname, './fixtures/custom_egg'),
+ });
+ await app.ready();
+ });
+ after(() => app.close());
+
+ it('should return locale from plugin a', async () => {
+ await app.httpRequest()
+ .get('/?key=pluginA')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('true');
+ });
+
+ it('should load locale resource from .ts file work', async () => {
+ await app.httpRequest()
+ .get('/?key=pluginATS')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('text from ts file');
+ });
+
+ it('should load locale resource from .yaml file work', async () => {
+ await app.httpRequest()
+ .get('/?key=EmailYaml')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('邮箱 from yaml');
+ });
+
+ it('should load locale resource from .properties file work', async () => {
+ await app.httpRequest()
+ .get('/?key=EmailIni')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('邮箱 from properties');
+ });
+
+ it('should return locale from plugin b', async () => {
+ await app.httpRequest()
+ .get('/?key=pluginB')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('true');
+ });
+
+ it('should return locale from framework', async () => {
+ await app.httpRequest()
+ .get('/?key=framework')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('true');
+ });
+
+ it('should return locale from locales2', async () => {
+ await app.httpRequest()
+ .get('/?key=locales2')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('true');
+ });
+
+ it('should use locale/ when both exist locales/ and locale/', async () => {
+ await app.httpRequest()
+ .get('/?key=pluginC')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect('i18n form locale');
+ });
+
+ describe('view renderString with __(key, value)', () => {
+ it('should render with default locale: en-US', async () => {
+ await app.httpRequest()
+ .get('/renderString')
+ .expect(200)
+ .expect('Set-Cookie', /locale=en-us; path=\/; max-age=31557600; expires=[^;]+ GMT/)
+ .expect('Email: \nHello fengmk2, how are you today?\nfoo bar\n');
+ });
+
+ it('should render with query locale: zh_CN', async () => {
+ await app.httpRequest()
+ .get('/renderString?locale=zh_CN')
+ .expect(200)
+ .expect('Set-Cookie', /locale=zh-cn; path=\/; max-age=31557600; expires=[^;]+ GMT/)
+ .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n');
+ });
+
+ // Accept-Language: zh-CN,zh;q=0.5
+ // Accept-Language: zh-CN;q=1
+ // Accept-Language: zh-CN
+ it('should render with Accept-Language: zh-CN,zh;q=0.5', async () => {
+ await app.httpRequest()
+ .get('/renderString')
+ .set('Accept-Language', 'zh-CN,zh;q=0.5')
+ .expect(200)
+ .expect('Set-Cookie', /locale=zh-cn; path=\/; max-age=31557600; expires=[^;]+ GMT/)
+ .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n');
+
+ await app.httpRequest()
+ .get('/renderString')
+ .set('Accept-Language', 'zh-CN;q=1')
+ .expect(200)
+ .expect('Set-Cookie', /locale=zh-cn; path=\/; max-age=31557600; expires=[^;]+ GMT/)
+ .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n');
+
+ await app.httpRequest()
+ .get('/renderString')
+ .set('Accept-Language', 'zh_cn')
+ .expect(200)
+ .expect('Set-Cookie', /locale=zh-cn; path=\/; max-age=31557600; expires=[^;]+ GMT/)
+ .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n');
+ });
+
+ it('should render set cookie locale: zh-CN if query locale not equal to cookie', async () => {
+ await app.httpRequest()
+ .get('/renderString?locale=en-US')
+ .set('Cookie', 'locale=zh-CN')
+ .expect(200)
+ .expect('Set-Cookie', /locale=en-us; path=\/; max-age=31557600; expires=[^;]+ GMT/)
+ .expect('Email: \nHello fengmk2, how are you today?\nfoo bar\n');
+ });
+
+ it('should render with cookie locale: zh-cn', async () => {
+ await app.httpRequest()
+ .get('/renderString')
+ .set('Cookie', 'locale=zh-cn')
+ .expect(200)
+ .expect('邮箱: \nfengmk2,今天过得如何?\nfoo bar\n')
+ .expect(res => {
+ // cookie should not change
+ const setCookies = res.headers['set-cookie'];
+ assert(!setCookies.includes('locale='));
+ });
+ });
+ });
+ });
+
+ describe('ctx.locale', () => {
+ it('should locale work and can be override', () => {
+ const ctx = app.mockContext({
+ query: { locale: 'zh-cn' },
+ });
+ assert.strictEqual(ctx.locale, 'zh-cn');
+ assert(ctx.response.headers['set-cookie']);
+ assert.strictEqual(ctx.response.headers['set-cookie'].length, 1);
+ assert.match(ctx.response.headers['set-cookie'][0], /^locale=zh\-cn; path=\/; max\-age=31557600; expires=[^;]+ GMT$/);
+ ctx.locale = 'en-us';
+ assert.strictEqual(ctx.response.headers['set-cookie'].length, 1);
+ assert.match(ctx.response.headers['set-cookie'][0], /^locale=en\-us; path=\/; max\-age=31557600; expires=[^;]+ GMT$/);
+ assert.strictEqual(ctx.locale, 'en-us');
+ assert.strictEqual(ctx.response.headers['set-cookie'].length, 1);
+ assert.match(ctx.response.headers['set-cookie'][0], /^locale=en\-us; path=\/; max\-age=31557600; expires=[^;]+ GMT$/);
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ff41b73
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@eggjs/tsconfig",
+ "compilerOptions": {
+ "strict": true,
+ "noImplicitAny": true,
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext"
+ }
+}