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:
  • \n
  • Hello fengmk2, how are you today?
  • \n
  • foo 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('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo bar
  • \n', done); - - app.httpRequest() - .get('/renderString') - .set('Accept-Language', 'zh_cn') - .expect(200) - .expect('Set-Cookie', /locale=zh-cn; path=\/; expires=[^;]+ GMT/) - .expect('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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:
  • \n
  • Hello fengmk2, how are you today?
  • \n
  • foo bar
  • \n', done); - }); - - it('should render with cookie locale: zh-cn', () => { - return app.httpRequest() - .get('/renderString') - .set('Cookie', 'locale=zh-cn') - .expect(200) - .expect('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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:
  • \n
  • Hello fengmk2, how are you today?
  • \n
  • foo 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('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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:
  • \n
  • Hello fengmk2, how are you today?
  • \n
  • foo bar
  • \n'); + }); + + it('should render with cookie locale: zh-cn', async () => { + await app.httpRequest() + .get('/renderString') + .set('Cookie', 'locale=zh-cn') + .expect(200) + .expect('
  • 邮箱:
  • \n
  • fengmk2,今天过得如何?
  • \n
  • foo 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" + } +}