├── .eslintignore ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── lib └── i18n.ts ├── package.json ├── test ├── .eslintrc ├── .mocharc.yml └── index.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | tmp/ 4 | dist/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "hexo/ts.js", 4 | "parserOptions": { 5 | "sourceType": "module", 6 | "ecmaVersion": 2020 7 | } 8 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | 9 | env: 10 | default_node_version: 14 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | needs: build 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | node-version: ["14", "16", "18"] 21 | fail-fast: false 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Setup Node.js ${{ matrix.node-version }} and Cache 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: npm 29 | cache-dependency-path: "package.json" 30 | 31 | - name: Install Dependencies 32 | run: npm install 33 | - name: Test 34 | run: npm run test 35 | 36 | coverage: 37 | name: Coverage 38 | needs: build 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Setup Node.js ${{env.default_node_version}} and Cache 43 | uses: actions/setup-node@v4 44 | with: 45 | node-version: ${{env.default_node_version}} 46 | cache: npm 47 | cache-dependency-path: "package.json" 48 | 49 | - name: Install Dependencies 50 | run: npm install 51 | - name: Coverage 52 | run: npm run test-cov 53 | - name: Coveralls 54 | uses: coverallsapp/github-action@v2 55 | with: 56 | github-token: ${{ secrets.github_token }} 57 | 58 | build: 59 | name: Build 60 | runs-on: ubuntu-latest 61 | steps: 62 | - uses: actions/checkout@v4 63 | - name: Setup Node.js ${{env.default_node_version}} and Cache 64 | uses: actions/setup-node@v4 65 | with: 66 | node-version: ${{env.default_node_version}} 67 | cache: npm 68 | cache-dependency-path: "package.json" 69 | 70 | - name: Install Dependencies 71 | run: npm install 72 | - name: Build 73 | run: npm run build 74 | 75 | lint: 76 | name: Lint 77 | runs-on: ubuntu-latest 78 | steps: 79 | - uses: actions/checkout@v4 80 | - name: Setup Node.js ${{env.default_node_version}} and Cache 81 | uses: actions/setup-node@v4 82 | with: 83 | node-version: ${{env.default_node_version}} 84 | cache: npm 85 | cache-dependency-path: "package.json" 86 | 87 | - name: Install Dependencies 88 | run: npm install 89 | - name: Lint 90 | run: npm run eslint 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | tmp/ 4 | *.log 5 | .idea/ 6 | coverage 7 | dist 8 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Tommy Chen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hexo-i18n 2 | 3 | [![CI](https://github.com/hexojs/hexo-i18n/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/hexojs/hexo-i18n/actions/workflows/ci.yml) 4 | [![NPM version](https://badge.fury.io/js/hexo-i18n.svg)](https://www.npmjs.com/package/hexo-i18n) 5 | [![Coverage Status](https://coveralls.io/repos/github/hexojs/hexo-i18n/badge.svg)](https://coveralls.io/github/hexojs/hexo-i18n) 6 | 7 | i18n module for [Hexo]. 8 | 9 | ## Installation 10 | 11 | ``` bash 12 | $ npm install hexo-i18n --save 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Example 18 | 19 | ``` js 20 | var i18n = new require('hexo-i18n')({ 21 | languages: ['zh-TW', 'en'] 22 | }); 23 | 24 | i18n.set('en', { 25 | ok: 'OK', 26 | name: 'My name is %1$s %2$s.', 27 | index: { 28 | title: 'Home' 29 | }, 30 | video: { 31 | zero: 'No videos', 32 | one: 'A video', 33 | other: '%d videos' 34 | } 35 | }); 36 | 37 | i18n.set('zh-TW', { 38 | name: '我的名字是 %2$s %1$s。', 39 | index: { 40 | title: '首頁' 41 | }, 42 | video: { 43 | zero: '沒有影片', 44 | one: '一部影片', 45 | other: '%d 部影片' 46 | } 47 | }); 48 | 49 | var __ = i18n.__(); 50 | var _p = i18n._p(); 51 | 52 | __('ok') // OK 53 | __('index.title') // 首頁 54 | __('name', '大呆', '王') // 我的名字是王大呆 55 | _p('video', 0) // 沒有影片 56 | _p('video', 1) // 一部影片 57 | _p('video', 10) // 10 部影片 58 | ``` 59 | 60 | ### new i18n([options]) 61 | 62 | Creates a new i18n instance. 63 | 64 | Option | Description | Default 65 | --- | --- | --- 66 | `languages` | Default languages. It can be an array or a string | `default` 67 | 68 | ### i18n.get([lang]) → Object 69 | 70 | Returns a set of localization data. `lang` can be an array or a string, or the default language defined in constructor if not set. This method will build the data in order of languages. 71 | 72 | ### i18n.set(lang, data) 73 | 74 | Loads localization data. 75 | 76 | ### i18n.remove(lang) 77 | 78 | Unloads localization data. 79 | 80 | ### i18n.list() 81 | 82 | Lists loaded languages. 83 | 84 | ### i18n.__() → Function(key, arg...) 85 | 86 | Returns a function for localization. 87 | 88 | ### i18n._p() → Function(key, count, ...) 89 | 90 | This method is similar to `i18n.__`, but it returns pluralized string based on the second parameter. For example: 91 | 92 | ``` js 93 | _p('video', 0) = __('video.zero', 0) 94 | _p('video', 1) = __('video.one', 1) 95 | _p('video', 10) = __('video.other', 10) 96 | ``` 97 | 98 | ## License 99 | 100 | MIT 101 | 102 | [Hexo]: https://hexo.io/ -------------------------------------------------------------------------------- /lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import { vsprintf } from 'sprintf-js'; 2 | 3 | interface Options { 4 | languages?: string[] | string; 5 | } 6 | 7 | class i18n { 8 | data: any; 9 | languages: string[]; 10 | 11 | constructor(options: Options = {}) { 12 | this.data = {}; 13 | this.languages = options.languages as any || ['default']; 14 | 15 | if (!Array.isArray(this.languages)) { 16 | this.languages = [this.languages]; 17 | } 18 | } 19 | 20 | get(languages?: string[] | string) { 21 | const { data } = this; 22 | const result = {}; 23 | 24 | if (languages) { 25 | if (!Array.isArray(languages)) { 26 | languages = [languages]; 27 | } 28 | } else { 29 | languages = this.languages; 30 | } 31 | 32 | languages.forEach(lang => { 33 | const langData = data[lang]; 34 | 35 | if (langData) { 36 | Object.keys(langData).forEach(key => { 37 | if (!Object.prototype.hasOwnProperty.call(result, key)) { 38 | result[key] = langData[key]; 39 | } 40 | }); 41 | } 42 | }); 43 | 44 | return result; 45 | } 46 | 47 | set(lang: string, data: object) { 48 | if (typeof lang !== 'string') throw new TypeError('lang must be a string!'); 49 | if (typeof data !== 'object') throw new TypeError('data is required!'); 50 | 51 | this.data[lang] = flattenObject(data); 52 | 53 | return this; 54 | } 55 | 56 | remove(lang: string) { 57 | if (typeof lang !== 'string') throw new TypeError('lang must be a string!'); 58 | 59 | delete this.data[lang]; 60 | 61 | return this; 62 | } 63 | 64 | list() { 65 | return Object.keys(this.data); 66 | } 67 | 68 | __(lang?: string | string[]) { 69 | const data = this.get(lang); 70 | 71 | return (key?: string, ...args) => { 72 | if (!key) return ''; 73 | 74 | const str = data[key] || key; 75 | 76 | return vsprintf(str, args); 77 | }; 78 | } 79 | 80 | _p(lang?: string | string[]) { 81 | const data = this.get(lang); 82 | 83 | return (key?: string, ...args) => { 84 | if (!key) return ''; 85 | 86 | const number = args.length ? +args[0] : 0; 87 | let str = key; 88 | 89 | if (!number && Object.prototype.hasOwnProperty.call(data, `${key}.zero`)) { 90 | str = data[`${key}.zero`]; 91 | } else if (number === 1 && Object.prototype.hasOwnProperty.call(data, `${key}.one`)) { 92 | str = data[`${key}.one`]; 93 | } else if (Object.prototype.hasOwnProperty.call(data, `${key}.other`)) { 94 | str = data[`${key}.other`]; 95 | } else if (Object.prototype.hasOwnProperty.call(data, key)) { 96 | str = data[key]; 97 | } 98 | 99 | return vsprintf(str, args); 100 | }; 101 | } 102 | } 103 | 104 | function flattenObject(data, obj = {}, parent = '') { 105 | Object.keys(data).forEach(key => { 106 | const item = data[key]; 107 | 108 | if (typeof item === 'object') { 109 | flattenObject(item, obj, `${parent + key}.`); 110 | } else { 111 | obj[parent + key] = item; 112 | } 113 | }); 114 | 115 | return obj; 116 | } 117 | 118 | export = i18n; 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hexo-i18n", 3 | "version": "2.0.0", 4 | "description": "i18n module for Hexo.", 5 | "main": "dist/i18n.js", 6 | "scripts": { 7 | "prepublish ": "npm run clean && npm run build", 8 | "build": "tsc -b", 9 | "clean": "tsc -b --clean", 10 | "eslint": "eslint .", 11 | "pretest": "npm run clean && npm run build", 12 | "test": "mocha test/index.ts --require ts-node/register", 13 | "test-cov": "c8 --reporter=lcovonly npm run test" 14 | }, 15 | "files": [ 16 | "dist/**" 17 | ], 18 | "types": "./dist/i18n.d.ts", 19 | "engines": { 20 | "node": ">=14" 21 | }, 22 | "repository": "hexojs/hexo-i18n", 23 | "homepage": "https://hexo.io/", 24 | "keywords": [ 25 | "hexo", 26 | "i18n", 27 | "localization" 28 | ], 29 | "author": "Tommy Chen (https://zespia.tw)", 30 | "license": "MIT", 31 | "dependencies": { 32 | "sprintf-js": "^1.1.2" 33 | }, 34 | "devDependencies": { 35 | "@types/chai": "^4.3.12", 36 | "@types/mocha": "^10.0.6", 37 | "@types/node": "^18.11.8", 38 | "@types/sprintf-js": "^1.1.2", 39 | "c8": "^9.1.0", 40 | "chai": "^4.3.6", 41 | "eslint": "^8.49.0", 42 | "eslint-config-hexo": "^5.0.0", 43 | "mocha": "^10.1.0", 44 | "ts-node": "^10.9.1", 45 | "typescript": "^5.0.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "hexo/ts-test", 3 | "rules": { 4 | "@typescript-eslint/no-var-requires": 0, 5 | "node/no-missing-require": 0, 6 | "@typescript-eslint/ban-ts-comment": 0 7 | } 8 | } -------------------------------------------------------------------------------- /test/.mocharc.yml: -------------------------------------------------------------------------------- 1 | reporter: spec 2 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import Ctor from '../lib/i18n'; 3 | const should = chai.should(); 4 | 5 | describe('i18n', () => { 6 | const i18n = new Ctor({ 7 | languages: ['zh-TW', 'en'] 8 | }); 9 | 10 | // Load fixtures 11 | i18n.set('en', { 12 | ok: 'OK', 13 | add: 'Add', 14 | name: 'My name is %1$s %2$s.', 15 | index: { 16 | title: 'Home', 17 | video: { 18 | zero: 'No videos', 19 | one: 'A video', 20 | other: '%d videos' 21 | } 22 | } 23 | }); 24 | 25 | i18n.set('zh-TW', { 26 | add: '新增', 27 | name: '我的名字是%2$s%1$s。', 28 | index: { 29 | title: '首頁', 30 | video: { 31 | zero: '沒有影片', 32 | one: '一部影片', 33 | other: '%d 部影片' 34 | } 35 | } 36 | }); 37 | 38 | it('construCtor', () => { 39 | let i18n = new Ctor(); 40 | i18n.languages.should.eql(['default']); 41 | 42 | i18n = new Ctor({ 43 | languages: 'en' 44 | }); 45 | 46 | i18n.languages.should.eql(['en']); 47 | 48 | i18n = new Ctor({ 49 | languages: ['zh-TW', 'en'] 50 | }); 51 | 52 | i18n.languages.should.eql(['zh-TW', 'en']); 53 | }); 54 | 55 | it('set()', () => { 56 | const i18n = new Ctor(); 57 | 58 | i18n.set('en', { 59 | yes: 'Yes', 60 | no: 'No', 61 | index: { 62 | title: 'Home', 63 | author: { 64 | name: 'John' 65 | } 66 | } 67 | }); 68 | 69 | i18n.data.en.should.eql({ 70 | yes: 'Yes', 71 | no: 'No', 72 | 'index.title': 'Home', 73 | 'index.author.name': 'John' 74 | }); 75 | }); 76 | 77 | it('set() - lang must be a string', () => { 78 | try { 79 | // @ts-expect-error 80 | i18n.set(); 81 | } catch (err) { 82 | err.should.have.property('message', 'lang must be a string!'); 83 | } 84 | }); 85 | 86 | it('set() - data is required', () => { 87 | try { 88 | // @ts-expect-error 89 | i18n.set('en'); 90 | } catch (err) { 91 | err.should.have.property('message', 'data is required!'); 92 | } 93 | }); 94 | 95 | it('get() - default languages', () => { 96 | const result = i18n.get(); 97 | 98 | result.should.eql({ 99 | add: '新增', 100 | 'index.title': '首頁', 101 | 'index.video.zero': '沒有影片', 102 | 'index.video.one': '一部影片', 103 | 'index.video.other': '%d 部影片', 104 | name: '我的名字是%2$s%1$s。', 105 | ok: 'OK' 106 | }); 107 | }); 108 | 109 | it('get() - custom languages', () => { 110 | const result = i18n.get('en'); 111 | 112 | result.should.eql({ 113 | add: 'Add', 114 | 'index.title': 'Home', 115 | 'index.video.zero': 'No videos', 116 | 'index.video.one': 'A video', 117 | 'index.video.other': '%d videos', 118 | name: 'My name is %1$s %2$s.', 119 | ok: 'OK' 120 | }); 121 | }); 122 | 123 | it('remove()', () => { 124 | const i18n = new Ctor(); 125 | 126 | i18n.set('en', {}); 127 | i18n.remove('en'); 128 | 129 | should.not.exist(i18n.data.en); 130 | }); 131 | 132 | it('remove() - lang must be a string', () => { 133 | try { 134 | // @ts-expect-error 135 | i18n.remove(); 136 | } catch (err) { 137 | err.should.have.property('message', 'lang must be a string!'); 138 | } 139 | }); 140 | 141 | it('list()', () => { 142 | i18n.list().should.have.members(['en', 'zh-TW']); 143 | }); 144 | 145 | it('__() - default languages', () => { 146 | const __ = i18n.__(); 147 | 148 | __().should.eql(''); 149 | __('add').should.eql('新增'); 150 | __('ok').should.eql('OK'); 151 | __('index.title').should.eql('首頁'); 152 | __('name', '大呆', '王').should.eql('我的名字是王大呆。'); 153 | __('Hello world').should.eql('Hello world'); 154 | }); 155 | 156 | it('__() - custom languages', () => { 157 | const __ = i18n.__('en'); 158 | 159 | __('add').should.eql('Add'); 160 | __('ok').should.eql('OK'); 161 | __('index.title').should.eql('Home'); 162 | __('name', 'John', 'Doe').should.eql('My name is John Doe.'); 163 | }); 164 | 165 | it('_p() - default languages', () => { 166 | const _p = i18n._p(); 167 | 168 | _p().should.eql(''); 169 | _p('ok').should.eql('OK'); 170 | _p('index.video', 0).should.eql('沒有影片'); 171 | _p('index.video', 1).should.eql('一部影片'); 172 | _p('index.video', 10).should.eql('10 部影片'); 173 | _p('Hello world').should.eql('Hello world'); 174 | }); 175 | 176 | it('_p() - custom languages', () => { 177 | const _p = i18n._p('en'); 178 | 179 | _p('ok').should.eql('OK'); 180 | _p('index.video', 0).should.eql('No videos'); 181 | _p('index.video', 1).should.eql('A video'); 182 | _p('index.video', 10).should.eql('10 videos'); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "sourceMap": true, 6 | "outDir": "dist", 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "types": [ 10 | "node", 11 | "mocha" 12 | ] 13 | }, 14 | "include": [ 15 | "lib/i18n.ts" 16 | ], 17 | "exclude": [ 18 | "node_modules" 19 | ] 20 | } --------------------------------------------------------------------------------