├── .npmrc
├── examples
├── webpack-local-dev
│ ├── .gitignore
│ ├── .babelrc
│ ├── src
│ │ ├── index.html
│ │ ├── i18n
│ │ │ ├── .eslintrc.js
│ │ │ ├── en-US.json
│ │ │ └── es-MX.json
│ │ └── index.js
│ ├── .eslintrc.js
│ ├── README.md
│ ├── webpack.config.dev.js
│ └── package.json
├── simple
│ ├── translations
│ │ ├── index.js
│ │ ├── en-US
│ │ │ └── index.json
│ │ └── es-MX
│ │ │ └── index.json
│ ├── .eslintrc.js
│ └── package.json
├── multiple-files-per-locale
│ ├── translations
│ │ ├── hi-IN
│ │ │ ├── search-results.json
│ │ │ ├── login.json
│ │ │ └── todos.json
│ │ ├── en-US
│ │ │ ├── search-results.json
│ │ │ ├── login.json
│ │ │ └── todos.json
│ │ └── es-MX
│ │ │ ├── search-results.json
│ │ │ ├── login.json
│ │ │ └── todos.json
│ ├── package.json
│ └── .eslintrc.js
├── custom-sort
│ ├── .eslintrc.js
│ ├── package.json
│ ├── custom-sort.js
│ └── translations
│ │ ├── en-US
│ │ └── index.json
│ │ └── es-MX
│ │ └── index.json
├── custom-message-syntax
│ ├── .eslintrc.js
│ ├── package.json
│ ├── custom-message-syntax.js
│ └── translations
│ │ ├── en-US
│ │ └── index.json
│ │ └── es-MX
│ │ └── index.json
├── README.md
├── ignore-keys
│ ├── package.json
│ ├── .eslintrc.js
│ └── translations
│ │ ├── es-MX
│ │ └── index.json
│ │ └── en-US
│ │ └── index.json
└── identical-keys-simple
│ ├── package.json
│ ├── .eslintrc.js
│ └── translations
│ ├── en-US
│ └── index.json
│ └── es-MX
│ └── index.json
├── .travis.yml
├── .gitignore
├── assets
├── logo-transparent.png
├── fixable-sorting-notice.png
├── logo-sketch-project.sketch
├── logo-with-background.png
├── invalid-json-screenshot.png
├── invalid-icu-syntax-screenshot.png
├── invalid-custom-syntax-screenshot.png
└── identical-keys-error-screenshot-2018-04-30.png
├── src
├── message-validators
│ ├── is-string.js
│ ├── icu.js
│ └── not-empty.js
├── util
│ ├── key-traversals.js
│ ├── require-no-cache.js
│ ├── key-traversals.test.js
│ ├── get-translation-file-source.js
│ ├── deep-for-own.test.js
│ ├── compare-translations-structure.js
│ ├── deep-for-own.js
│ └── get-translation-file-source.test.js
├── __snapshots__
│ ├── identical-keys.test.js.snap
│ └── valid-message-syntax.test.js.snap
├── valid-json.js
├── valid-json.test.js
├── sorted-keys.js
├── identical-placeholders.js
├── identical-placeholders.test.js
├── valid-message-syntax.js
├── sorted-keys.test.js
├── identical-keys.js
├── valid-message-syntax.test.js
└── identical-keys.test.js
├── .eslintrc.json
├── test
└── run-rule.js
├── SECURITY.md
├── LICENSE
├── __snapshots__
└── formatter.test.js.snap
├── index.js
├── package.json
├── CONTRIBUTING.md
├── formatter.js
├── formatter.test.js
├── CHANGELOG.md
└── README.md
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
--------------------------------------------------------------------------------
/examples/webpack-local-dev/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | yarn.lock
--------------------------------------------------------------------------------
/examples/simple/translations/index.js:
--------------------------------------------------------------------------------
1 | module.exports = "translations";
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js:
4 | - "lts/*"
5 | - "7"
6 | - "6"
7 |
--------------------------------------------------------------------------------
/examples/webpack-local-dev/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | coverage/
3 | *.log
4 | .DS_Store
5 | .vscode
6 | package-lock.json
7 | *.lock
8 | /.idea
9 |
--------------------------------------------------------------------------------
/assets/logo-transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/logo-transparent.png
--------------------------------------------------------------------------------
/assets/fixable-sorting-notice.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/fixable-sorting-notice.png
--------------------------------------------------------------------------------
/assets/logo-sketch-project.sketch:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/logo-sketch-project.sketch
--------------------------------------------------------------------------------
/assets/logo-with-background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/logo-with-background.png
--------------------------------------------------------------------------------
/assets/invalid-json-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/invalid-json-screenshot.png
--------------------------------------------------------------------------------
/assets/invalid-icu-syntax-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/invalid-icu-syntax-screenshot.png
--------------------------------------------------------------------------------
/assets/invalid-custom-syntax-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/invalid-custom-syntax-screenshot.png
--------------------------------------------------------------------------------
/assets/identical-keys-error-screenshot-2018-04-30.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/godaddy/eslint-plugin-i18n-json/HEAD/assets/identical-keys-error-screenshot-2018-04-30.png
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/hi-IN/search-results.json:
--------------------------------------------------------------------------------
1 | {
2 | "search.noResults": "सभी नहीं मिला!",
3 | "search.placeholder": "सभी को ढूंढें"
4 | }
5 |
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/en-US/search-results.json:
--------------------------------------------------------------------------------
1 | {
2 | "search.noResults": "No todos matching query found!",
3 | "search.placeholder": "Search Todos"
4 | }
--------------------------------------------------------------------------------
/src/message-validators/is-string.js:
--------------------------------------------------------------------------------
1 | module.exports = (message) => {
2 | if (typeof message !== 'string') {
3 | throw new TypeError('Message must be a String.');
4 | }
5 | };
6 |
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/es-MX/search-results.json:
--------------------------------------------------------------------------------
1 | {
2 | "search.noResults": "No se han encontrado resultados para {query}",
3 | "search.placeholder": "Buscar Todos"
4 | }
--------------------------------------------------------------------------------
/examples/webpack-local-dev/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | I18n WebApp
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/webpack-local-dev/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // since this example folder is embedded into the project.
5 | parser: 'babel-eslint'
6 | }
7 |
--------------------------------------------------------------------------------
/examples/simple/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // since this example folder is embedded into the project. just ignore this.
5 | extends: [
6 | 'plugin:i18n-json/recommended'
7 | ],
8 | }
9 |
--------------------------------------------------------------------------------
/src/message-validators/icu.js:
--------------------------------------------------------------------------------
1 | const icuMessageParser = require('@formatjs/icu-messageformat-parser');
2 |
3 | // a message validator should throw if there is any error
4 | module.exports = (message) => {
5 | icuMessageParser.parse(message);
6 | };
7 |
--------------------------------------------------------------------------------
/src/message-validators/not-empty.js:
--------------------------------------------------------------------------------
1 | module.exports = (message) => {
2 | let normalized = message;
3 | if (typeof message === 'string') {
4 | normalized = message.trim();
5 | }
6 | if (!normalized) {
7 | throw new SyntaxError('Message is Empty.');
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true,
4 | "es6": true,
5 | "jest": true
6 | },
7 | "parserOptions": {
8 | "ecmaVersion": 6
9 | },
10 | "extends": ["airbnb-base"],
11 | "rules": {
12 | "comma-dangle": [
13 | "error",
14 | {
15 | "functions": "never"
16 | }
17 | ]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/en-US/login.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancel",
5 | "resetPassword": "Reset Password",
6 | "submit": "Login Now!"
7 | },
8 | "invalidAuth": "Wrong Username or Password",
9 | "lastLoginAt": "Last Login at {lastLoginTime, time, short}"
10 | }
11 | }
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/hi-IN/login.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "रद्द करना",
5 | "resetPassword": "पासवर्ड रीसेट",
6 | "submit": "अब प्रवेश करें!"
7 | },
8 | "invalidAuth": "गलत उपयोगकर्ता नाम या पासवर्ड",
9 | "lastLoginAt": "पर अंतिम लॉगिन {lastLoginTime, time, short}"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/en-US/todos.json:
--------------------------------------------------------------------------------
1 | {
2 | "todos.addTodo": "Add new todo",
3 | "todos.count": "You have {count, plural, =0 {no todos} other { # todos} }",
4 | "todos.greeting": "Welcome {userName}",
5 | "todos.markCompleted": "Mark Completed",
6 | "todos.nextPage": "Next",
7 | "todos.previousPage": "Previous",
8 | "todos.removeTodo": "Remove Todo"
9 | }
--------------------------------------------------------------------------------
/src/util/key-traversals.js:
--------------------------------------------------------------------------------
1 | // case sensitive traversal orders
2 | const keyTraversals = {
3 | asc: obj => Object.keys(obj).sort(),
4 | desc: obj => Object.keys(obj).sort((a, b) => {
5 | // note, objects can't have duplicate keys of the same case
6 | if (a < b) {
7 | return 1;
8 | }
9 | return -1;
10 | })
11 | };
12 |
13 | module.exports = keyTraversals;
14 |
--------------------------------------------------------------------------------
/examples/custom-sort/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // since this example folder is embedded into the project. just ignore this.
5 | extends: [
6 | 'plugin:i18n-json/recommended'
7 | ],
8 | rules: {
9 | 'i18n-json/sorted-keys': [2, {
10 | sortFunctionPath: path.resolve('./custom-sort')
11 | }]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/es-MX/login.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancelar",
5 | "resetPassword": "Restablecer la contraseña",
6 | "submit": "¡Iniciar sesión!"
7 | },
8 | "invalidAuth": "Nombre de usuario o contraseña incorrectos",
9 | "lastLoginAt": "Último inicio de sesión en {lastLoginTime, time, short}"
10 | }
11 | }
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/es-MX/todos.json:
--------------------------------------------------------------------------------
1 | {
2 | "todos.addTodo": "Agregar nuevo Todo",
3 | "todos.count": "Tienes {count, plural, =0 {no todos} other { # todos} }",
4 | "todos.greeting": "Bienvenido {userName}",
5 | "todos.markCompleted": "Marca Completada",
6 | "todos.nextPage": "Siguiente",
7 | "todos.previousPage": "Anterior",
8 | "todos.removeTodo": "Eliminar todo"
9 | }
--------------------------------------------------------------------------------
/examples/custom-message-syntax/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // since this example folder is embedded into the project. just ignore this.
5 | extends: [
6 | 'plugin:i18n-json/recommended'
7 | ],
8 | rules: {
9 | 'i18n-json/valid-message-syntax': [2, {
10 | syntax: path.resolve('./custom-message-syntax')
11 | }]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/util/require-no-cache.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require, import/no-dynamic-require */
2 |
3 | // Delete the file from the require cache.
4 | // This forces the file to be read from disk again.
5 | // e.g) webpack dev server eslint loader support
6 |
7 | const requireNoCache = (path) => {
8 | delete require.cache[path];
9 | return require(path);
10 | };
11 |
12 | module.exports = requireNoCache;
13 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # Project Use Examples
2 |
3 | - All examples are designed so it's easy to get started and see how the plugin can be configured to fit your needs.
4 |
5 | - **Thanks for checking out the examples! 😀**
6 |
7 | ## Disclaimer
8 |
9 | - *None of the translations in the examples reflect actual GoDaddy translations for the various languages used.* They were just created using Google Translate for example's sake 😉.
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/translations/hi-IN/todos.json:
--------------------------------------------------------------------------------
1 | {
2 | "todos.addTodo": "नया टोडो जोड़ें",
3 | "todos.count": "आपके पास {count, plural, =0 {कोई टॉडोस नहीं है} other {# टोडो है} }",
4 | "todos.greeting": "स्वागत हे {userName}",
5 | "todos.markCompleted": "पूर्ण के रूप में चिह्नित करें",
6 | "todos.nextPage": "आगामी",
7 | "todos.previousPage": "पिछला",
8 | "todos.removeTodo": "टूडू को मिटाएं"
9 | }
10 |
--------------------------------------------------------------------------------
/examples/webpack-local-dev/README.md:
--------------------------------------------------------------------------------
1 | # Webpack Development Example
2 |
3 | This example shows how to get eslint-plugin-i18n-json working
4 | in a project setup to use webpack/webpack dev server.
5 |
6 | In order to isolate the i18n json files, notice that we have a separate eslint configuration for all json files in the `i18n` folder. Also we have a separate `eslint-loader` rule for only the i18n json files. This rule also is setup to use the formatter exported by eslint-plugin-i18n-json.
--------------------------------------------------------------------------------
/examples/simple/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": "",
3 | "description": "",
4 | "devDependencies": {
5 | "eslint": "^4.0.0",
6 | "eslint-plugin-i18n-json": "^2.0.0"
7 | },
8 | "license": "MIT",
9 | "main": "index.js",
10 | "name": "simple",
11 | "scripts": {
12 | "test": "echo \"Error: no test specified\" && exit 1",
13 | "lint": "eslint --fix --ext .json,.js --format node_modules/eslint-plugin-i18n-json/formatter.js translations/"
14 | },
15 | "version": "1.0.0"
16 | }
17 |
--------------------------------------------------------------------------------
/examples/ignore-keys/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": "",
3 | "description": "",
4 | "devDependencies": {
5 | "eslint": "^4.0.0",
6 | "eslint-plugin-i18n-json": "^2.0.0"
7 | },
8 | "license": "MIT",
9 | "main": "index.js",
10 | "name": "ignore-keys",
11 | "scripts": {
12 | "test": "echo \"Error: no test specified\" && exit 1",
13 | "lint": "eslint --fix --ext .json --format node_modules/eslint-plugin-i18n-json/formatter.js translations/"
14 | },
15 | "version": "1.0.0"
16 | }
17 |
--------------------------------------------------------------------------------
/src/util/key-traversals.test.js:
--------------------------------------------------------------------------------
1 | const keyTraversals = require('./key-traversals');
2 |
3 | describe('keyTraversals', () => {
4 | it('sorts object keys in descending case sensitive order', () => {
5 | expect(keyTraversals.desc({
6 | C: 'value',
7 | B: 'value',
8 | A: 'value',
9 | b: 'value',
10 | a: 'value',
11 | c: 'value'
12 | })).toEqual([
13 | 'c',
14 | 'b',
15 | 'a',
16 | 'C',
17 | 'B',
18 | 'A'
19 | ]);
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/examples/custom-sort/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": "",
3 | "description": "",
4 | "devDependencies": {
5 | "eslint": "^4.0.0",
6 | "eslint-plugin-i18n-json": "^3.0.0"
7 | },
8 | "license": "MIT",
9 | "main": "index.js",
10 | "name": "custom-message-syntax",
11 | "scripts": {
12 | "test": "echo \"Error: no test specified\" && exit 1",
13 | "lint": "eslint --fix --ext .json --format node_modules/eslint-plugin-i18n-json/formatter.js translations/"
14 | },
15 | "version": "1.0.0"
16 | }
17 |
--------------------------------------------------------------------------------
/examples/custom-message-syntax/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": "",
3 | "description": "",
4 | "devDependencies": {
5 | "eslint": "^4.0.0",
6 | "eslint-plugin-i18n-json": "^2.0.0"
7 | },
8 | "license": "MIT",
9 | "main": "index.js",
10 | "name": "custom-message-syntax",
11 | "scripts": {
12 | "test": "echo \"Error: no test specified\" && exit 1",
13 | "lint": "eslint --fix --ext .json --format node_modules/eslint-plugin-i18n-json/formatter.js translations/"
14 | },
15 | "version": "1.0.0"
16 | }
17 |
--------------------------------------------------------------------------------
/examples/identical-keys-simple/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": "",
3 | "description": "",
4 | "devDependencies": {
5 | "eslint": "^4.0.0",
6 | "eslint-plugin-i18n-json": "^2.0.0"
7 | },
8 | "license": "MIT",
9 | "main": "index.js",
10 | "name": "identical-keys-simple",
11 | "scripts": {
12 | "test": "echo \"Error: no test specified\" && exit 1",
13 | "lint": "eslint --fix --ext .json --format node_modules/eslint-plugin-i18n-json/formatter.js translations/"
14 | },
15 | "version": "1.0.0"
16 | }
17 |
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": "",
3 | "description": "",
4 | "devDependencies": {
5 | "eslint": "^4.0.0",
6 | "eslint-plugin-i18n-json": "^2.0.0"
7 | },
8 | "license": "MIT",
9 | "main": "index.js",
10 | "name": "multiple-files-per-locale",
11 | "scripts": {
12 | "test": "echo \"Error: no test specified\" && exit 1",
13 | "lint": "eslint --fix --ext .json --format node_modules/eslint-plugin-i18n-json/formatter.js translations/"
14 | },
15 | "version": "1.0.0"
16 | }
17 |
--------------------------------------------------------------------------------
/examples/webpack-local-dev/src/i18n/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // this eslint configuration only applies to this folder.
5 | extends: [
6 | 'plugin:i18n-json/recommended'
7 | ],
8 | rules: {
9 | // option for this rule the absolute path to the comparision file the plugin should require.
10 | 'i18n-json/identical-keys': [2, {
11 | // each file's key structure compared with this file.
12 | filePath: path.resolve('./src/i18n/en-US.json')
13 | }]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/examples/identical-keys-simple/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // since this example folder is embedded into the project. just ignore this.
5 | extends: [
6 | 'plugin:i18n-json/recommended'
7 | ],
8 | rules: {
9 | // option for this rule the absolute path to the comparision file the plugin should require.
10 | 'i18n-json/identical-keys': [2, {
11 | // each file's key structure compared with this file.
12 | filePath: path.resolve('./translations/en-US/index.json')
13 | }]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/examples/custom-message-syntax/custom-message-syntax.js:
--------------------------------------------------------------------------------
1 | /*
2 | I like Pizza Message Syntax Validator.
3 |
4 |
5 | This is a custom message syntax validator
6 | it must throw a SyntaxError (or any Error) when a message has
7 | invalid syntax.
8 |
9 | You can even import a 3rd party library to validate the syntax.
10 | Feel free to make a Pull Request also :)
11 | */
12 |
13 | module.exports = (message) => {
14 | if(message.split(/\s/).shift() !== 'PIZZA'){
15 | throw new SyntaxError('PIZZA word must prepended to each message. E.g, PIZZA message');
16 | }
17 | };
--------------------------------------------------------------------------------
/test/run-rule.js:
--------------------------------------------------------------------------------
1 | const espree = require('espree');
2 |
3 | /*
4 | Rule runner which gives back the actual
5 | errors.
6 | */
7 |
8 | module.exports = rule => ({ code, options, filename }) => {
9 | const node = espree.parse(code, {
10 | comment: true
11 | });
12 | const receivedErrors = [];
13 | const test = {
14 | context: {
15 | report: error => receivedErrors.push(error),
16 | options,
17 | getFilename: () => filename
18 | },
19 | node
20 | };
21 | rule.create(test.context).Program(test.node);
22 | return receivedErrors;
23 | };
24 |
--------------------------------------------------------------------------------
/examples/custom-sort/custom-sort.js:
--------------------------------------------------------------------------------
1 | /*
2 | An example ascending (a -> z) sort function.
3 | Return the keys in the order you want them to be sorted.
4 |
5 | The sort function is called at each level of the translations object.
6 | The sort function MUST ALWAYS return the same sorted order of keys.
7 | */
8 |
9 | module.exports = (translations) => {
10 | return Object.keys(translations).sort((keyA, keyB) => {
11 | if (keyA === keyB) {
12 | return 0;
13 | } else if (keyA < keyB) {
14 | return -1;
15 | } else {
16 | return 1;
17 | }
18 | })
19 | };
20 |
--------------------------------------------------------------------------------
/examples/multiple-files-per-locale/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // since this example folder is embedded into the project. just ignore this.
5 | extends: [
6 | 'plugin:i18n-json/recommended'
7 | ],
8 | rules: {
9 | // must use absolute path to the module which will return the object structure to compare to.
10 | 'i18n-json/identical-keys': [2, {
11 | filePath: {
12 | 'login.json': path.resolve('./translations/en-US/login.json'),
13 | 'search-results.json': path.resolve('./translations/en-US/search-results.json'),
14 | 'todos.json': path.resolve('./translations/en-US/todos.json')
15 | }
16 | }]
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/examples/simple/translations/en-US/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancel",
5 | "resetPassword": "Reset Password",
6 | "submit": "Login!"
7 | },
8 | "invalidAuth": "Wrong Username or Password",
9 | "lastLoginAt": "Last Login at {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "No todos matching query found!",
12 | "search.placeholder": "Search Todos",
13 | "todos.addTodo": "Add new todo",
14 | "todos.count": "You have { count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "Welcome {userName}",
16 | "todos.markCompleted": "Mark Completed",
17 | "todos.nextPage": "Next",
18 | "todos.previousPage": "Previous",
19 | "todos.removeTodo": "Remove Todo"
20 | }
--------------------------------------------------------------------------------
/examples/webpack-local-dev/src/i18n/en-US.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancel",
5 | "resetPassword": "Reset Password",
6 | "submit": "Login Now!"
7 | },
8 | "invalidAuth": "Wrong Username or Password",
9 | "lastLoginAt": "Last Login at {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "No todos matching query found!",
12 | "search.placeholder": "Search Todos",
13 | "todos.addTodo": "Add new todo",
14 | "todos.count": "You have {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "Welcome {userName}",
16 | "todos.markCompleted": "Mark Completed",
17 | "todos.nextPage": "Next",
18 | "todos.previousPage": "Previous",
19 | "todos.removeTodo": "Remove Todo"
20 | }
--------------------------------------------------------------------------------
/examples/identical-keys-simple/translations/en-US/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancel",
5 | "resetPassword": "Reset Password",
6 | "submit": "Login Now!"
7 | },
8 | "invalidAuth": "Wrong Username or Password",
9 | "lastLoginAt": "Last Login at {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "No todos matching query found!",
12 | "search.placeholder": "Search Todos",
13 | "todos.addTodo": "Add new todo",
14 | "todos.count": "You have {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "Welcome {userName}",
16 | "todos.markCompleted": "Mark Completed",
17 | "todos.nextPage": "Next",
18 | "todos.previousPage": "Previous",
19 | "todos.removeTodo": "Remove Todo"
20 | }
--------------------------------------------------------------------------------
/examples/simple/translations/es-MX/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancel",
5 | "resetPassword": "Reset Password",
6 | "submit": "Login!"
7 | },
8 | "invalidAuth": "Wrong Username or Password",
9 | "lastLoginAt": "Last Login at {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "No se han encontrado resultados para {query}",
12 | "search.placeholder": "Buscar Todos",
13 | "todos.addTodo": "Agregar nuevo Todo",
14 | "todos.count": "Tienes {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "Bienvenido {userName}",
16 | "todos.markCompleted": "Marca Completada",
17 | "todos.nextPage": "Siguiente",
18 | "todos.previousPage": "Anterior",
19 | "todos.removeTodo": "Eliminar todo"
20 | }
--------------------------------------------------------------------------------
/examples/custom-sort/translations/en-US/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "PIZZA Cancel",
5 | "resetPassword": "PIZZA Reset Password",
6 | "submit": "PIZZA Login Now!"
7 | },
8 | "invalidAuth": "PIZZA Wrong Username or Password",
9 | "lastLoginAt": "PIZZA Last Login at {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "PIZZA No results found!",
12 | "search.placeholder": "PIZZA Search Todos",
13 | "todos.addTodo": "PIZZA Add new todo",
14 | "todos.count": "PIZZA You have {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "PIZZA Welcome {userName}",
16 | "todos.markCompleted": "PIZZA Mark Completed",
17 | "todos.nextPage": "PIZZA Next",
18 | "todos.previousPage": "PIZZA Previous",
19 | "todos.removeTodo": "PIZZA Remove Todo"
20 | }
--------------------------------------------------------------------------------
/examples/webpack-local-dev/src/i18n/es-MX.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancelar",
5 | "resetPassword": "Restablecer la contraseña",
6 | "submit": "¡Iniciar sesión!"
7 | },
8 | "invalidAuth": "Nombre de usuario o contraseña incorrectos",
9 | "lastLoginAt": "Último inicio de sesión en {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "No se han encontrado resultados para {query}",
12 | "search.placeholder": "Buscar Todos",
13 | "todos.addTodo": "Agregar nuevo Todo",
14 | "todos.count": "Tienes {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "Bienvenido {userName}",
16 | "todos.markCompleted": "Marca Completada",
17 | "todos.nextPage": "Siguiente",
18 | "todos.previousPage": "Anterior",
19 | "todos.removeTodo": "Eliminar todo"
20 | }
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Reporting Security Issues
2 |
3 | We take security very seriously at GoDaddy. We appreciate your efforts to
4 | responsibly disclose your findings, and will make every effort to acknowledge
5 | your contributions.
6 |
7 | ## Where should I report security issues?
8 |
9 | In order to give the community time to respond and upgrade, we strongly urge you
10 | report all security issues privately.
11 |
12 | To report a security issue in one of our Open Source projects email us directly
13 | at **oss@godaddy.com** and include the word "SECURITY" in the subject line.
14 |
15 | This mail is delivered to our Open Source Security team.
16 |
17 | After the initial reply to your report, the team will keep you informed of the
18 | progress being made towards a fix and announcement, and may ask for additional
19 | information or guidance.
20 |
--------------------------------------------------------------------------------
/examples/custom-message-syntax/translations/en-US/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "PIZZA Cancel",
5 | "resetPassword": "PIZZA Reset Password",
6 | "submit": "PIZZA Login Now!"
7 | },
8 | "invalidAuth": "PIZZA Wrong Username or Password",
9 | "lastLoginAt": "PIZZA Last Login at {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "PIZZA No results found!",
12 | "search.placeholder": "PIZZA Search Todos",
13 | "todos.addTodo": "PIZZA Add new todo",
14 | "todos.count": "PIZZA You have {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "PIZZA Welcome {userName}",
16 | "todos.markCompleted": "PIZZA Mark Completed",
17 | "todos.nextPage": "PIZZA Next",
18 | "todos.previousPage": "PIZZA Previous",
19 | "todos.removeTodo": "PIZZA Remove Todo"
20 | }
--------------------------------------------------------------------------------
/examples/identical-keys-simple/translations/es-MX/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancelar",
5 | "resetPassword": "Restablecer la contraseña",
6 | "submit": "¡Iniciar sesión!"
7 | },
8 | "invalidAuth": "Nombre de usuario o contraseña incorrectos",
9 | "lastLoginAt": "Último inicio de sesión en {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "No se han encontrado resultados para {query}",
12 | "search.placeholder": "Buscar Todos",
13 | "todos.addTodo": "Agregar nuevo Todo",
14 | "todos.count": "Tienes {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "Bienvenido {userName}",
16 | "todos.markCompleted": "Marca Completada",
17 | "todos.nextPage": "Siguiente",
18 | "todos.previousPage": "Anterior",
19 | "todos.removeTodo": "Eliminar todo"
20 | }
--------------------------------------------------------------------------------
/src/util/get-translation-file-source.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const isJSONFile = context => path.extname(context.getFilename()) === '.json';
4 |
5 | const INVALID_SOURCE = {
6 | valid: false,
7 | source: null,
8 | sourceFilePath: null
9 | };
10 |
11 | module.exports = ({ context, node }) => {
12 | if (
13 | !isJSONFile(context) ||
14 | !Array.isArray(node.comments) ||
15 | node.comments.length < 2
16 | ) {
17 | // is not a json file or the file
18 | // has not been through the plugin preprocessor
19 | return INVALID_SOURCE;
20 | }
21 |
22 | const { value: source } = node.comments[0];
23 | const { value: sourceFilePath } = node.comments[1];
24 |
25 | // valid source
26 | return {
27 | valid: true,
28 | source: source && source.trim(),
29 | sourceFilePath: sourceFilePath && sourceFilePath.trim()
30 | };
31 | };
32 |
--------------------------------------------------------------------------------
/examples/webpack-local-dev/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | const HtmlWebpackPlugin = require('html-webpack-plugin');
2 | const CleanWebpackPlugin = require('clean-webpack-plugin');
3 |
4 | module.exports = {
5 | mode: 'development',
6 | devServer: {
7 | contentBase: './dist',
8 | },
9 | module: {
10 | rules: [
11 | {
12 | test: /\.js$/,
13 | exclude: /node_modules/,
14 | use: ['babel-loader', 'eslint-loader'],
15 | },
16 | {
17 | test: /\.json$/,
18 | include: /i18n/,
19 | loader: 'eslint-loader',
20 | options: {
21 | formatter: require('eslint-plugin-i18n-json/formatter.js'),
22 | },
23 | },
24 | ],
25 | },
26 | plugins: [
27 | new CleanWebpackPlugin(['dist']),
28 | new HtmlWebpackPlugin({
29 | template: './src/index.html',
30 | filename: 'index.html',
31 | }),
32 | ],
33 | };
34 |
--------------------------------------------------------------------------------
/examples/custom-sort/translations/es-MX/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "PIZZA Cancelar",
5 | "resetPassword": "PIZZA Restablecer la contraseña",
6 | "submit": "PIZZA ¡Iniciar sesión!"
7 | },
8 | "invalidAuth": "PIZZA Nombre de usuario o contraseña incorrectos",
9 | "lastLoginAt": "PIZZA Último inicio de sesión en {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "PIZZA No se han encontrado resultados para {query}",
12 | "search.placeholder": "PIZZA Buscar Todos",
13 | "todos.addTodo": "PIZZA Agregar nuevo Todo",
14 | "todos.count": "PIZZA Tienes {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "PIZZA Bienvenido {userName}",
16 | "todos.markCompleted": "PIZZA Marca Completada",
17 | "todos.nextPage": "PIZZA Siguiente",
18 | "todos.previousPage": "PIZZA Anterior",
19 | "todos.removeTodo": "PIZZA Eliminar todo"
20 | }
--------------------------------------------------------------------------------
/examples/custom-message-syntax/translations/es-MX/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "PIZZA Cancelar",
5 | "resetPassword": "PIZZA Restablecer la contraseña",
6 | "submit": "PIZZA ¡Iniciar sesión!"
7 | },
8 | "invalidAuth": "PIZZA Nombre de usuario o contraseña incorrectos",
9 | "lastLoginAt": "PIZZA Último inicio de sesión en {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "PIZZA No se han encontrado resultados para {query}",
12 | "search.placeholder": "PIZZA Buscar Todos",
13 | "todos.addTodo": "PIZZA Agregar nuevo Todo",
14 | "todos.count": "PIZZA Tienes {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "PIZZA Bienvenido {userName}",
16 | "todos.markCompleted": "PIZZA Marca Completada",
17 | "todos.nextPage": "PIZZA Siguiente",
18 | "todos.previousPage": "PIZZA Anterior",
19 | "todos.removeTodo": "PIZZA Eliminar todo"
20 | }
--------------------------------------------------------------------------------
/examples/webpack-local-dev/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "i18n-webpack-local-dev-example",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "scripts": {
7 | "build:dev": "webpack --config webpack.config.dev.js",
8 | "start:dev": "webpack-dev-server --open --config webpack.config.dev.js"
9 | },
10 | "devDependencies": {
11 | "@babel/core": "^7.2.2",
12 | "@babel/preset-env": "^7.2.3",
13 | "@babel/preset-react": "^7.0.0",
14 | "babel-eslint": "^10.0.1",
15 | "babel-loader": "^8.0.5",
16 | "clean-webpack-plugin": "^1.0.0",
17 | "eslint": "^5.12.0",
18 | "eslint-loader": "^2.1.1",
19 | "eslint-plugin-i18n-json": "^2.4.0",
20 | "html-webpack-plugin": "^3.2.0",
21 | "webpack": "^4.27.1",
22 | "webpack-cli": "^3.1.2",
23 | "webpack-dev-server": "^3.1.14"
24 | },
25 | "dependencies": {
26 | "react": "^16.7.0",
27 | "react-dom": "^16.7.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/examples/ignore-keys/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | root: true, // since this example folder is embedded into the project. just ignore this.
5 | extends: [
6 | 'plugin:i18n-json/recommended'
7 | ],
8 | settings: {
9 | /*
10 | None of the key paths listed below
11 | will be checked for valid i18n syntax
12 | nor used in the identical-keys rule comparison.
13 | (if the key path points to an object, the object is ignored)
14 | */
15 | 'i18n-json/ignore-keys': [
16 | 'translationMetadata',
17 | 'login.form.inProgressTranslationKey'
18 | ]
19 | },
20 | rules: {
21 | // option for this rule the absolute path to the comparision file the plugin should require.
22 | 'i18n-json/identical-keys': [2, {
23 | // each file's key structure compared with this file.
24 | filePath: path.resolve('./translations/en-US/index.json')
25 | }]
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/examples/webpack-local-dev/src/index.js:
--------------------------------------------------------------------------------
1 | /*eslint no-param-reassign: "error"*/
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import englishTranslations from './i18n/en-US.json';
5 | import spanishTranslations from './i18n/es-MX.json';
6 |
7 | const AddTodoTitleEnglish = param => {
8 | // INTENTIONAL - UNCOMMENT to show how 2 eslint loader instances can display their corresponding errors
9 | //param = 'some other value';
10 | return {englishTranslations['todos.addTodo']}
;
11 | };
12 |
13 | const AddTodoTitleSpanish = param => {
14 | // INTENTIONAL - UNCOMMENT to show how 2 eslint loader instances can display their corresponding errors
15 | //param = 'some other value';
16 | return {spanishTranslations['todos.addTodo']}
;
17 | };
18 | const App = () => {
19 | return (
20 |
24 | );
25 | };
26 |
27 | ReactDOM.render(, document.getElementById('app'));
28 |
--------------------------------------------------------------------------------
/examples/ignore-keys/translations/es-MX/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancelar",
5 | "resetPassword": "Restablecer la contraseña",
6 | "submit": "¡Iniciar sesión!"
7 | },
8 | "invalidAuth": "Nombre de usuario o contraseña incorrectos",
9 | "lastLoginAt": "Último inicio de sesión en {lastLoginTime, time, short}"
10 | },
11 | "search.noResults": "No se han encontrado resultados para {query}",
12 | "search.placeholder": "Buscar Todos",
13 | "todos.addTodo": "Agregar nuevo Todo",
14 | "todos.count": "Tienes {count, plural, =0 {no todos} other { # todos} }",
15 | "todos.greeting": "Bienvenido {userName}",
16 | "todos.markCompleted": "Marca Completada",
17 | "todos.nextPage": "Siguiente",
18 | "todos.previousPage": "Anterior",
19 | "todos.removeTodo": "Eliminar todo",
20 | "translationMetadata": {
21 | "campaigns_MX": [
22 | "marketing-campaign-XYZ"
23 | ],
24 | "lastUpdatedBy": "translator",
25 | "placeholder_format_custom": [
26 | "\\[\\[.*?\\]\\]"
27 | ],
28 | "string_format": "icu",
29 | "version": "version-1.0"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 GoDaddy Operating Company, LLC.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/examples/ignore-keys/translations/en-US/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": {
3 | "form": {
4 | "cancel": "Cancel",
5 | "inProgressTranslationKey": "this translation needs to still be propagated to the other files.",
6 | "resetPassword": "Reset Password",
7 | "submit": "Login Now!"
8 | },
9 | "invalidAuth": "Wrong Username or Password",
10 | "lastLoginAt": "Last Login at {lastLoginTime, time, short}"
11 | },
12 | "search.noResults": "No todos matching query found!",
13 | "search.placeholder": "Search Todos",
14 | "todos.addTodo": "Add new todo",
15 | "todos.count": "You have {count, plural, =0 {no todos} other { # todos} }",
16 | "todos.greeting": "Welcome {userName}",
17 | "todos.markCompleted": "Mark Completed",
18 | "todos.nextPage": "Next",
19 | "todos.previousPage": "Previous",
20 | "todos.removeTodo": "Remove Todo",
21 | "translationMetadata": {
22 | "campaigns_US": [
23 | "marketing-campaign-XYZ"
24 | ],
25 | "lastUpdatedBy": "translator",
26 | "placeholder_format_custom": [
27 | "\\[\\[.*?\\]\\]"
28 | ],
29 | "string_format": "icu",
30 | "version": "version-1.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/util/deep-for-own.test.js:
--------------------------------------------------------------------------------
1 | const deepForOwn = require('./deep-for-own');
2 |
3 | describe('deepForOwn', () => {
4 | it('will stop early if the iteratee returns false', () => {
5 | const obj = {
6 | a: {
7 | b: {
8 | c: 'value'
9 | }
10 | }
11 | };
12 | const visited = [];
13 | deepForOwn(obj, (value, key) => {
14 | visited.push(key);
15 | if (key === 'b') {
16 | return false;
17 | }
18 | return true;
19 | });
20 | expect(visited).toEqual(['a', 'b']);
21 | });
22 | it('will not traverse ignored paths', () => {
23 | const obj = {
24 | a: {
25 | b: {
26 | c: 'value'
27 | }
28 | },
29 | d: {
30 | e: {
31 | f: 'value'
32 | }
33 | },
34 | g: {
35 | h: {
36 | i: 'value'
37 | }
38 | },
39 | j: 'value'
40 | };
41 | const visited = [];
42 | deepForOwn(obj, (value, key) => {
43 | visited.push(key);
44 | return true;
45 | }, {
46 | ignorePaths: ['a.b', 'd.e.f', 'g']
47 | });
48 | expect(visited).toEqual(['a', 'd', 'j', 'e']);
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/__snapshots__/formatter.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`formatter returns an empty string when there aren't any warnings or errors across all files 1`] = `""`;
4 |
5 | exports[`formatter will display errors before warnings 1`] = `
6 | "bad/file
7 |
8 | ✖ ERROR (some-rule)
9 | file is bad
10 |
11 | ✖ ERROR (some-rule)
12 | file is pretty bad
13 |
14 | ⚠ WARNING (some-rule)
15 | file has first warning
16 |
17 | ⚠ WARNING (some-rule)
18 | file has second warning
19 |
20 | > ✖ 2 ERRORS
21 | > ⚠ 2 WARNINGS"
22 | `;
23 |
24 | exports[`formatter will display issues across many files 1`] = `
25 | "bad/file
26 |
27 | ✖ ERROR (some-rule)
28 | file is bad
29 |
30 | bad/file2
31 |
32 | ⚠ WARNING (some-rule)
33 | file has a warning
34 |
35 | bad/file3
36 |
37 | ✖ ERROR (some-rule)
38 | file is bad
39 |
40 | ⚠ WARNING (some-rule)
41 | file has a warning
42 |
43 | > ✖ 2 ERRORS
44 | > ⚠ 2 WARNINGS"
45 | `;
46 |
47 | exports[`formatter will not display any message for an individual file which does not have any warnings or errors 1`] = `
48 | "bad/file
49 |
50 | ✖ ERROR (some-rule)
51 | file is bad
52 |
53 | > ✖ 1 ERROR
54 | > ⚠ 0 WARNINGS"
55 | `;
56 |
--------------------------------------------------------------------------------
/src/util/compare-translations-structure.js:
--------------------------------------------------------------------------------
1 | const { set } = require('lodash');
2 | const diff = require('jest-diff');
3 | const deepForOwn = require('./deep-for-own');
4 |
5 | const DIFF_OPTIONS = {
6 | expand: false,
7 | contextLines: 1
8 | };
9 |
10 | // we don't care what the actual values are.
11 | // lodash.set will automatically convert a previous string value
12 | // into an object, if the current path states that a key is nested inside.
13 | // reminder, deepForOwn goes from the root level to the deepest level (preorder)
14 | const compareTranslationsStructure = (settings, translationsA, translationsB) => {
15 | const augmentedTranslationsA = {};
16 | const augmentedTranslationsB = {};
17 |
18 | const ignorePaths = settings['i18n-json/ignore-keys'] || [];
19 |
20 | const opts = {
21 | ignorePaths
22 | };
23 |
24 | deepForOwn(translationsA, (value, key, path) => {
25 | set(augmentedTranslationsA, path, 'Message');
26 | }, opts);
27 | deepForOwn(translationsB, (value, key, path) => {
28 | set(augmentedTranslationsB, path, 'Message');
29 | }, opts);
30 | return diff(augmentedTranslationsA, augmentedTranslationsB, DIFF_OPTIONS);
31 | };
32 |
33 | module.exports = compareTranslationsStructure;
34 |
--------------------------------------------------------------------------------
/src/util/deep-for-own.js:
--------------------------------------------------------------------------------
1 | const { isPlainObject } = require('lodash');
2 |
3 | /*
4 | deep level order traversal.
5 | Takes the `keyTraversal` iterator
6 | which specify in what order the current level's
7 | key should be visited.
8 |
9 | // calls iteratee with the path to the object.
10 | */
11 |
12 | const shouldIgnorePath = (ignoreList, keyPath) => ignoreList.includes(keyPath.join('.'));
13 | const defaultTraversal = obj => Object.keys(obj);
14 |
15 | const deepForOwn = (obj, iteratee, {
16 | keyTraversal = defaultTraversal,
17 | ignorePaths = []
18 | } = {}) => {
19 | const queue = [];
20 | queue.push({
21 | currentObj: obj,
22 | path: []
23 | });
24 | while (queue.length > 0) {
25 | const { currentObj, path } = queue.shift();
26 | const levelSuccess = keyTraversal(currentObj).every((key) => {
27 | const keyPath = [...path, key];
28 | // skip over ignored paths and their children
29 | if (shouldIgnorePath(ignorePaths, keyPath)) {
30 | return true;
31 | }
32 | if (isPlainObject(currentObj[key])) {
33 | queue.push({
34 | currentObj: currentObj[key],
35 | path: keyPath
36 | });
37 | }
38 | return iteratee(currentObj[key], key, keyPath) !== false;
39 | });
40 | if (!levelSuccess) {
41 | break;
42 | }
43 | }
44 | };
45 |
46 | module.exports = deepForOwn;
47 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require */
2 |
3 | module.exports = {
4 | rules: {
5 | 'valid-json': require('./src/valid-json'),
6 | 'valid-message-syntax': require('./src/valid-message-syntax'),
7 | 'identical-keys': require('./src/identical-keys'),
8 | 'sorted-keys': require('./src/sorted-keys'),
9 | 'identical-placeholders': require('./src/identical-placeholders')
10 | },
11 | processors: {
12 | '.json': {
13 | preprocess: (source, filePath) =>
14 | // augment the json into a comment
15 | // along with the source path :D
16 | // so we can pass it to the rules
17 |
18 | // note: due to the spaced comment rule, include
19 | // spaced comments
20 | [`/* ${source.trim()} *//* ${filePath.trim()} */\n`],
21 | // since we only return one line in the preprocess step,
22 | // we only care about the first array of errors
23 | postprocess: ([errors]) => [...errors],
24 | supportsAutofix: true
25 | }
26 | },
27 | configs: {
28 | recommended: {
29 | plugins: ['i18n-json'],
30 | rules: {
31 | 'i18n-json/valid-message-syntax': [
32 | 2,
33 | {
34 | syntax: 'icu' // default syntax
35 | }
36 | ],
37 | 'i18n-json/valid-json': 2,
38 | 'i18n-json/sorted-keys': [
39 | 2,
40 | {
41 | order: 'asc',
42 | indentSpaces: 2
43 | }
44 | ],
45 | 'i18n-json/identical-keys': 0,
46 | 'i18n-json/identical-placeholders': 0
47 | }
48 | }
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/src/__snapshots__/identical-keys.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Snapshot Tests for Invalid Code map of comparison files - structure mismatch 1`] = `
4 | "
5 | - Expected
6 | + Received
7 |
8 | @@ -2,10 +2,5 @@
9 | \\"translationLevelOne\\": Object {
10 | - \\"translationKeyA\\": \\"Message\\",
11 | - \\"translationLevelTwo\\": Object {
12 | - \\"translationKeyB\\": \\"Message\\",
13 | - \\"translationsLevelThree\\": Object {
14 | - \\"translationKeyC\\": \\"Message\\",
15 | + \\"translationKeyY\\": \\"Message\\",
16 | + \\"translationKeyZ\\": \\"Message\\",
17 | },
18 | - },
19 | - },
20 | }"
21 | `;
22 |
23 | exports[`Snapshot Tests for Invalid Code single comparison file - structure mismatch 1`] = `
24 | "
25 | - Expected
26 | + Received
27 |
28 | @@ -2,10 +2,5 @@
29 | \\"translationLevelOne\\": Object {
30 | - \\"translationKeyA\\": \\"Message\\",
31 | - \\"translationLevelTwo\\": Object {
32 | - \\"translationKeyB\\": \\"Message\\",
33 | - \\"translationsLevelThree\\": Object {
34 | - \\"translationKeyC\\": \\"Message\\",
35 | + \\"translationKeyY\\": \\"Message\\",
36 | + \\"translationKeyZ\\": \\"Message\\",
37 | },
38 | - },
39 | - },
40 | }"
41 | `;
42 |
43 | exports[`Snapshot Tests for Invalid Code structure generator function - structure mismatch 1`] = `
44 | "
45 | - Expected
46 | + Received
47 |
48 | Object {
49 | - \\"other-key\\": \\"Message\\",
50 | + \\"translationLevelOne\\": Object {
51 | + \\"translationKeyY\\": \\"Message\\",
52 | + \\"translationKeyZ\\": \\"Message\\",
53 | + },
54 | }"
55 | `;
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint-plugin-i18n-json",
3 | "version": "4.0.1",
4 | "description": "Fully extendable eslint plugin for JSON i18n translation files.",
5 | "main": "index.js",
6 | "scripts": {
7 | "lint": "eslint index.js formatter*.js src/ --fix",
8 | "test": "npm run lint && jest --coverage"
9 | },
10 | "files": [
11 | "src",
12 | "index.js",
13 | "formatter.js"
14 | ],
15 | "repository": {
16 | "type": "git",
17 | "url": "git@github.com:godaddy/eslint-plugin-i18n-json"
18 | },
19 | "keywords": [
20 | "eslint",
21 | "json",
22 | "i18n",
23 | "translations",
24 | "internationalization",
25 | "intl",
26 | "linter"
27 | ],
28 | "author": "GoDaddy Operating Company, LLC",
29 | "contributors": [
30 | "Mayank Jethva ",
31 | "Niko Muukkonen "
32 | ],
33 | "jest": {
34 | "roots": [
35 | "src/",
36 | ""
37 | ],
38 | "collectCoverageFrom": [
39 | "src/**/*.js",
40 | "formatter.js"
41 | ],
42 | "testEnvironment": "node"
43 | },
44 | "license": "MIT",
45 | "engines": {
46 | "node": ">=6.0.0"
47 | },
48 | "peerDependencies": {
49 | "eslint": ">=4.0.0"
50 | },
51 | "devDependencies": {
52 | "eslint": "^4.15.0",
53 | "eslint-config-airbnb-base": "^12.1.0",
54 | "eslint-plugin-import": "^2.8.0",
55 | "espree": "^3.5.2",
56 | "jest": "^23.0.4",
57 | "strip-ansi": "^4.0.0"
58 | },
59 | "dependencies": {
60 | "@formatjs/icu-messageformat-parser": "^2.0.18",
61 | "chalk": "^2.3.2",
62 | "indent-string": "^3.2.0",
63 | "jest-diff": "^22.0.3",
64 | "lodash": "^4.17.21",
65 | "log-symbols": "^2.2.0",
66 | "parse-json": "^5.2.0",
67 | "plur": "^2.1.2",
68 | "pretty-format": "^22.0.3"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/valid-json.js:
--------------------------------------------------------------------------------
1 | const parseJson = require('parse-json');
2 | const { isPlainObject } = require('lodash');
3 | const chalk = require('chalk');
4 | const requireNoCache = require('./util/require-no-cache');
5 | const getTranslationFileSource = require('./util/get-translation-file-source');
6 |
7 | const lineRegex = /line\s+(\d+):?/i;
8 |
9 | const validJSON = ([{ linter } = {}], source) => {
10 | const errors = [];
11 | try {
12 | let parsed;
13 |
14 | if (linter) {
15 | // use custom linter
16 | parsed = requireNoCache(linter)(source);
17 | } else {
18 | parsed = parseJson(source);
19 | }
20 |
21 | if (!isPlainObject(parsed)) {
22 | throw new SyntaxError('Translation file must be a JSON object.');
23 | }
24 | } catch (e) {
25 | const [, lineNumber = 0] = e.message.match(lineRegex) || [];
26 | errors.push({
27 | message: `\n${chalk.bold.red('Invalid JSON.')}\n\n${e}`,
28 | loc: {
29 | start: {
30 | line: Number.parseInt(lineNumber, 10),
31 | col: 0
32 | }
33 | }
34 | });
35 | }
36 | return errors;
37 | };
38 |
39 | module.exports = {
40 | meta: {
41 | docs: {
42 | category: 'Validation',
43 | description: 'Validates the JSON translation file',
44 | recommended: true
45 | },
46 | schema: [
47 | {
48 | properties: {
49 | linter: {
50 | type: 'string'
51 | }
52 | },
53 | type: 'object',
54 | additionalProperties: false
55 | }
56 | ]
57 | },
58 | create(context) {
59 | return {
60 | Program(node) {
61 | const { valid, source } = getTranslationFileSource({
62 | context,
63 | node
64 | });
65 | if (!valid) {
66 | return;
67 | }
68 | const errors = validJSON(context.options, source);
69 | errors.forEach((error) => {
70 | context.report(error);
71 | });
72 | }
73 | };
74 | }
75 | };
76 |
--------------------------------------------------------------------------------
/src/util/get-translation-file-source.test.js:
--------------------------------------------------------------------------------
1 | const getTranslationFileSource = require('./get-translation-file-source');
2 |
3 | const INVALID_FILE_SOURCE = {
4 | valid: false,
5 | source: null,
6 | sourceFilePath: null
7 | };
8 |
9 | describe('#getTranslationFileSource', () => {
10 | it('will return an invalid file source object if the file\'s extension is not .json', () => {
11 | const context = {
12 | getFilename: jest.fn().mockReturnValueOnce('file.js')
13 | };
14 | const node = {};
15 | expect(getTranslationFileSource({ context, node })).toEqual(INVALID_FILE_SOURCE);
16 | });
17 | it('will return an invalid file source object if parsed file ast node does not have a comments property', () => {
18 | const context = {
19 | getFilename: jest.fn().mockReturnValueOnce('file.json')
20 | };
21 | const node = {};
22 | expect(getTranslationFileSource({ context, node })).toEqual(INVALID_FILE_SOURCE);
23 | });
24 | it('will return an invalid file source object if parsed file ast node has less than 2 comments', () => {
25 | const context = {
26 | getFilename: jest.fn().mockReturnValueOnce('file.json')
27 | };
28 | const node = {
29 | comments: [
30 | {
31 | value: 'comment 1'
32 | }
33 | ]
34 | };
35 | expect(getTranslationFileSource({ context, node })).toEqual(INVALID_FILE_SOURCE);
36 | });
37 | it('will return a valid trimmed file source if the source is a json file and it was processed by plugin preprocessor', () => {
38 | const context = {
39 | getFilename: jest.fn().mockReturnValueOnce('file.json')
40 | };
41 | const node = {
42 | comments: [
43 | {
44 | value: ' json source '
45 | },
46 | {
47 | value: ' path/to/file.json '
48 | }
49 | ]
50 | };
51 | expect(getTranslationFileSource({ context, node })).toEqual({
52 | valid: true,
53 | source: 'json source',
54 | sourceFilePath: 'path/to/file.json'
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | [Originally sourced from the 🏎 `downshift` contributing guide](https://github.com/downshift-js/downshift/edit/master/CONTRIBUTING.md)
2 |
3 | # Contributing
4 |
5 | Thanks for being willing to contribute!
6 |
7 | **Working on your first Pull Request?** You can learn how from this _free_
8 | series [How to Contribute to an Open Source Project on GitHub][egghead]
9 |
10 | ## Project setup
11 |
12 | 1. Fork and clone the repo
13 | 2. Create a branch for your PR
14 |
15 | > Tip: Keep your `master` branch pointing at the original repository and make
16 | > pull requests from branches on your fork. To do this, run:
17 | >
18 | > ```
19 | > git remote add upstream https://github.com/godaddy/eslint-plugin-i18n-json.git
20 | > git fetch upstream
21 | > git branch --set-upstream-to=upstream/master master
22 | > ```
23 | >
24 | > This will add the original repository as a "remote" called "upstream," Then
25 | > fetch the git information from that remote, then set your local `master`
26 | > branch to use the upstream master branch whenever you run `git pull`. Then you
27 | > can make all of your pull request branches based on this `master` branch.
28 | > Whenever you want to update your version of `master`, do a regular `git pull`.
29 |
30 | ## Committing and Pushing changes
31 |
32 | Please make sure to run the tests before you commit your changes. You can run
33 | `npm run test -u` which will update any snapshots that need updating. Make
34 | sure to include those changes (if they exist) in your commit.
35 |
36 | ## Help needed
37 |
38 | Please feel free to create an issue to discuss.
39 |
40 | Thanks!!! :smile:
41 |
42 | ---
43 |
44 | ## For Maintainers
45 |
46 | ### Making PR(s)
47 |
48 | 1. properly set your public git email locally for this repo: `git config user.email my-public-email@provider.com`
49 |
50 | ### Publishing A New Version
51 |
52 | **(ensure to lint and test beforehand)**
53 |
54 | 1. `npm login --registry=https://registry.npmjs.org/`
55 | 2. verify who you are: `npm whoami`
56 | 3. bump `package.json` and merge
57 | 4. `git tag vX.X.X`
58 | 5. `git push origin --tags`
59 | 6. `npm publish`
60 |
--------------------------------------------------------------------------------
/src/__snapshots__/valid-message-syntax.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Snapshot Tests for Invalid Code custom message format (upper case only) 1`] = `
4 | "
5 | - Expected
6 | + Received
7 |
8 | Object {
9 | - \\"translationKeyA\\": \\"ValidMessage\\",
10 | - \\"translationKeyB\\": \\"ValidMessage\\",
11 | + \\"translationKeyA\\": \\"String('translation value a') ===> MESSAGE MUST BE IN UPPERCASE!\\",
12 | + \\"translationKeyB\\": \\"String('translation value b') ===> MESSAGE MUST BE IN UPPERCASE!\\",
13 | }"
14 | `;
15 |
16 | exports[`Snapshot Tests for Invalid Code nested translations - icu syntax check 1`] = `
17 | "
18 | - Expected
19 | + Received
20 |
21 | Object {
22 | \\"levelOne\\": Object {
23 | \\"levelTwo\\": Object {
24 | - \\"translationKeyC\\": \\"ValidMessage\\",
25 | + \\"translationKeyC\\": \\"String('translation value c {') ===> EXPECT_ARGUMENT_CLOSING_BRACE\\",
26 | },
27 | - \\"translationKeyB\\": \\"ValidMessage\\",
28 | + \\"translationKeyB\\": \\"String('translation value b {') ===> EXPECT_ARGUMENT_CLOSING_BRACE\\",
29 | },
30 | }"
31 | `;
32 |
33 | exports[`Snapshot Tests for Invalid Code no arrays or numbers 1`] = `
34 | "
35 | - Expected
36 | + Received
37 |
38 | Object {
39 | - \\"levelOne\\": \\"ObjectContaining | ValidMessage\\",
40 | - \\"levelTwo\\": \\"ValidMessage\\",
41 | + \\"levelOne\\": \\"Array [] ===> TypeError: An Array cannot be a translation value.\\",
42 | + \\"levelTwo\\": \\"Number(5) ===> Message must be a String.\\",
43 | }"
44 | `;
45 |
46 | exports[`Snapshot Tests for Invalid Code no empty objects 1`] = `
47 | "
48 | - Expected
49 | + Received
50 |
51 | Object {
52 | - \\"levelOne\\": \\"ObjectContaining | ValidMessage\\",
53 | + \\"levelOne\\": \\"Object {} ===> SyntaxError: Empty object.\\",
54 | }"
55 | `;
56 |
57 | exports[`Snapshot Tests for Invalid Code using "non-empty-string" validator and has empty messages 1`] = `
58 | "
59 | - Expected
60 | + Received
61 |
62 | Object {
63 | - \\"translationKeyA\\": \\"ValidMessage\\",
64 | - \\"translationKeyB\\": \\"ValidMessage\\",
65 | + \\"translationKeyA\\": \\"String('') ===> Message is Empty.\\",
66 | + \\"translationKeyB\\": \\"null ===> Message is Empty.\\",
67 | }"
68 | `;
69 |
--------------------------------------------------------------------------------
/formatter.js:
--------------------------------------------------------------------------------
1 | /*
2 | Custom eslint formatter for eslint-plugin-i18n-json to allow better error message display.
3 | Heavily inspired from https://github.com/sindresorhus/eslint-formatter-pretty.
4 | */
5 |
6 | /* eslint no-useless-concat: "off" */
7 |
8 | const chalk = require('chalk');
9 | const plur = require('plur');
10 | const logSymbols = require('log-symbols');
11 | const indentString = require('indent-string');
12 | const path = require('path');
13 |
14 | const CWD = process.cwd();
15 |
16 | const formatter = (results) => {
17 | let totalErrorsCount = 0;
18 | let totalWarningsCount = 0;
19 |
20 | const formattedLintMessagesPerFile = results.map(({
21 | filePath,
22 | messages: fileMessages,
23 | errorCount: fileErrorCount,
24 | warningCount: fileWarningCount
25 | }) => {
26 | if (fileErrorCount + fileWarningCount === 0) {
27 | return '';
28 | }
29 |
30 | totalErrorsCount += fileErrorCount;
31 | totalWarningsCount += fileWarningCount;
32 |
33 | const relativePath = path.relative(CWD, filePath);
34 | const fileMessagesHeader = chalk.underline.white(relativePath);
35 |
36 | fileMessages.sort((a, b) => b.severity - a.severity); // display errors first
37 |
38 | const formattedFileMessages = fileMessages.map(({ ruleId, severity, message }) => {
39 | let messageHeader = severity === 1 ? `${logSymbols.warning} ${chalk.inverse.yellow(' WARNING ')}`
40 | : `${logSymbols.error} ${chalk.inverse.red(' ERROR ')}`;
41 |
42 | messageHeader += (` ${chalk.white(`(${ruleId})`)}`);
43 |
44 | return `\n\n${messageHeader}\n${indentString(message, 2)}`;
45 | }).join('');
46 |
47 | return `${fileMessagesHeader}${formattedFileMessages}`;
48 | }).filter(fileLintMessages => fileLintMessages.trim().length > 0);
49 |
50 | let aggregateReport = formattedLintMessagesPerFile.join('\n\n');
51 |
52 | // append in total error and warnings count to aggregrate report
53 | const totalErrorsCountFormatted = `${chalk.bold.red('>')} ${logSymbols.error} ${chalk.bold.red(totalErrorsCount)} ${chalk.bold.red(plur('ERROR', totalErrorsCount))}`;
54 | const totalWarningsCountFormatted = `${chalk.bold.yellow('>')} ${logSymbols.warning} ${chalk.bold.yellow(totalWarningsCount)} ${chalk.bold.yellow(plur('WARNING', totalWarningsCount))}`;
55 |
56 | aggregateReport += `\n\n${totalErrorsCountFormatted}\n${totalWarningsCountFormatted}`;
57 |
58 | return (totalErrorsCount + totalWarningsCount > 0) ? aggregateReport : '';
59 | };
60 |
61 | module.exports = formatter;
62 |
--------------------------------------------------------------------------------
/src/valid-json.test.js:
--------------------------------------------------------------------------------
1 | const rule = require('./valid-json');
2 | const { RuleTester } = require('eslint');
3 |
4 | const ruleTester = new RuleTester();
5 |
6 | jest.mock('chalk', () => ({
7 | bold: {
8 | red: jest.fn(str => str)
9 | }
10 | }));
11 |
12 | jest.mock('./util/require-no-cache', () =>
13 | jest.fn().mockImplementation((path) => {
14 | switch (path) {
15 | case './json-linter-pass.js':
16 | return () => ({});
17 | case './json-linter-error.js':
18 | return () => {
19 | throw new SyntaxError('line 5: invalid json syntax');
20 | };
21 | default:
22 | return undefined;
23 | }
24 | }));
25 |
26 | ruleTester.run('valid-json', rule, {
27 | valid: [
28 | // ignores non json files
29 | {
30 | code: `
31 | /*var x = 123;*//*path/to/file.js*/
32 | `,
33 | options: [],
34 | filename: 'file.js'
35 | },
36 | {
37 | code: `
38 | /*{
39 | "translationKeyA": "translation value a",
40 | "translationKeyB": "translation value b"
41 | }*//*path/to/file.json*/
42 | `,
43 | options: [],
44 | filename: 'file.json'
45 | },
46 | // supports a custom linter
47 | {
48 | code: `
49 | /*{}*//*path/to/file.json*/
50 | `,
51 | options: [
52 | {
53 | linter: './json-linter-pass.js'
54 | }
55 | ],
56 | filename: 'file.json'
57 | }
58 | ],
59 | invalid: [
60 | {
61 | code: `
62 | /*{
63 | "translationKeyA": "translation value a"
64 | "translationKeyB: "translation value b"
65 | }*//*path/to/file.json*/
66 | `,
67 | options: [],
68 | filename: 'file.json',
69 | errors: [
70 | {
71 | message: /\nInvalid JSON\.\n\n.*/,
72 | line: 0,
73 | col: 0
74 | }
75 | ]
76 | },
77 | {
78 | code: `
79 | /**//*path/to/file.json*/
80 | `,
81 | options: [],
82 | filename: 'file.json',
83 | errors: [
84 | {
85 | message: /\nInvalid JSON\.\n\n.*/,
86 | line: 0,
87 | col: 0
88 | }
89 | ]
90 | },
91 | // supports a custom linter
92 | {
93 | code: `
94 | /*{*//*path/to/file.json*/
95 | `,
96 | options: [
97 | {
98 | linter: './json-linter-error.js'
99 | }
100 | ],
101 | filename: 'file.json',
102 | errors: [
103 | {
104 | message: /\nInvalid JSON\.\n\n.*/,
105 | line: 5,
106 | col: 0
107 | }
108 | ]
109 | },
110 | // parser must return a plain object
111 | {
112 | code: `
113 | /*"SOME_VALID_JSON"*//*path/to/file.json*/
114 | `,
115 | options: [],
116 | filename: 'file.json',
117 | errors: [
118 | {
119 | message: /\nInvalid JSON\.\n\n.*SyntaxError: Translation file must be a JSON object\./,
120 | line: 0,
121 | col: 0
122 | }
123 | ]
124 | }
125 | ]
126 | });
127 |
--------------------------------------------------------------------------------
/formatter.test.js:
--------------------------------------------------------------------------------
1 | const formatter = require('./formatter');
2 | const stripAnsi = require('strip-ansi');
3 |
4 | const strippedFormatter = (...args) => stripAnsi(formatter(...args));
5 |
6 | describe('formatter', () => {
7 | it('returns an empty string when there aren\'t any warnings or errors across all files', () => {
8 | expect(strippedFormatter([
9 | {
10 | filePath: 'some/file',
11 | messages: [],
12 | warningCount: 0,
13 | errorCount: 0
14 | }
15 | ])).toMatchSnapshot();
16 | });
17 | it('will not display any message for an individual file which does not have any warnings or errors', () => {
18 | const output = strippedFormatter([
19 | {
20 | filePath: 'bad/file',
21 | messages: [{
22 | ruleId: 'some-rule',
23 | severity: 2,
24 | message: 'file is bad'
25 | }],
26 | errorCount: 1,
27 | warningCount: 0
28 | },
29 | {
30 | filePath: 'good/file',
31 | messages: [],
32 | warningCount: 0,
33 | errorCount: 0
34 | }
35 | ]);
36 | expect(output).toMatchSnapshot();
37 | });
38 | it('will display errors before warnings', () => {
39 | const output = strippedFormatter([
40 | {
41 | filePath: 'bad/file',
42 | messages: [
43 | {
44 | ruleId: 'some-rule',
45 | severity: 1,
46 | message: 'file has first warning'
47 | },
48 | {
49 | ruleId: 'some-rule',
50 | severity: 1,
51 | message: 'file has second warning'
52 | },
53 | {
54 | ruleId: 'some-rule',
55 | severity: 2,
56 | message: 'file is bad'
57 | },
58 | {
59 | ruleId: 'some-rule',
60 | severity: 2,
61 | message: 'file is pretty bad'
62 | }
63 | ],
64 | errorCount: 2,
65 | warningCount: 2
66 | }
67 | ]);
68 | expect(output).toMatchSnapshot();
69 | });
70 | it('will display issues across many files', () => {
71 | const output = strippedFormatter([
72 | {
73 | filePath: 'bad/file',
74 | messages: [{
75 | ruleId: 'some-rule',
76 | severity: 2,
77 | message: 'file is bad'
78 | }],
79 | errorCount: 1,
80 | warningCount: 0
81 | },
82 | {
83 | filePath: 'bad/file2',
84 | messages: [{
85 | ruleId: 'some-rule',
86 | severity: 1,
87 | message: 'file has a warning'
88 | }],
89 | errorCount: 0,
90 | warningCount: 1
91 | },
92 | {
93 | filePath: 'bad/file3',
94 | messages: [
95 | {
96 | ruleId: 'some-rule',
97 | severity: 2,
98 | message: 'file is bad'
99 | },
100 | {
101 | ruleId: 'some-rule',
102 | severity: 1,
103 | message: 'file has a warning'
104 | }
105 | ],
106 | errorCount: 1,
107 | warningCount: 1
108 | }
109 | ]);
110 | expect(output).toMatchSnapshot();
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/src/sorted-keys.js:
--------------------------------------------------------------------------------
1 | const { set, isEqual, isPlainObject } = require('lodash');
2 | const deepForOwn = require('./util/deep-for-own');
3 | const keyTraversals = require('./util/key-traversals');
4 | const getTranslationFileSource = require('./util/get-translation-file-source');
5 | const requireNoCache = require('./util/require-no-cache');
6 |
7 | const sortedKeys = ([{ order = 'asc', sortFunctionPath, indentSpaces = 2 } = {}], source) => {
8 | let translations = null;
9 |
10 | try {
11 | translations = JSON.parse(source);
12 | } catch (e) {
13 | // ignore errors, this will
14 | // be caught by the i18n-json/valid-json rule
15 | return [];
16 | }
17 |
18 | let traversalOrder = null;
19 |
20 | if (sortFunctionPath) {
21 | traversalOrder = requireNoCache(sortFunctionPath);
22 | } else if (order.toLowerCase() === 'desc') {
23 | traversalOrder = keyTraversals.desc;
24 | } else {
25 | traversalOrder = keyTraversals.asc;
26 | }
27 |
28 | const sortedTranslations = {};
29 | const sortedTranslationPaths = [];
30 |
31 | deepForOwn(
32 | translations,
33 | (value, key, path) => {
34 | // if plain object, stub in a clean one to then get filled.
35 | set(sortedTranslations, path, isPlainObject(value) ? {} : value);
36 | sortedTranslationPaths.push(path);
37 | },
38 | {
39 | keyTraversal: traversalOrder
40 | }
41 | );
42 |
43 | // only need to fix if the order of the keys is not the same
44 | const originalTranslationPaths = [];
45 | deepForOwn(translations, (value, key, path) => {
46 | originalTranslationPaths.push(path);
47 | });
48 |
49 | if (!isEqual(originalTranslationPaths, sortedTranslationPaths)) {
50 | const sortedWithIndent = JSON.stringify(
51 | sortedTranslations,
52 | null,
53 | indentSpaces
54 | );
55 |
56 | return [
57 | {
58 | message: 'Keys should be sorted, please use --fix.',
59 | loc: {
60 | start: {
61 | line: 0,
62 | col: 0
63 | }
64 | },
65 | fix: fixer =>
66 | fixer.replaceTextRange([0, source.length], sortedWithIndent),
67 | line: 0,
68 | column: 0
69 | }
70 | ];
71 | }
72 | // no errors
73 | return [];
74 | };
75 |
76 | module.exports = {
77 | meta: {
78 | fixable: 'code',
79 | docs: {
80 | category: 'Stylistic Issues',
81 | description: 'Ensure an order for the translation keys. (Recursive)',
82 | recommended: true
83 | },
84 | schema: [
85 | {
86 | properties: {
87 | order: {
88 | type: 'string'
89 | },
90 | sortFunctionPath: {
91 | type: 'string'
92 | },
93 | indentSpaces: {
94 | type: 'number'
95 | }
96 | },
97 | type: 'object',
98 | additionalProperties: false
99 | }
100 | ]
101 | },
102 | create(context) {
103 | return {
104 | Program(node) {
105 | const { valid, source } = getTranslationFileSource({
106 | context,
107 | node
108 | });
109 | if (!valid) {
110 | return;
111 | }
112 | const errors = sortedKeys(context.options, source);
113 | errors.forEach((error) => {
114 | context.report(error);
115 | });
116 | }
117 | };
118 | }
119 | };
120 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is loosely based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7 |
8 | ## [4.0.1]
9 |
10 | - **security:** Bump Lodash due to [CVE-2020-8203](https://github.com/advisories/GHSA-p6mc-m468-83gw)
11 | - Update readme with instructions on how to use this package with npm `>= 9.0.0` as described in https://github.com/godaddy/eslint-plugin-i18n-json/issues/62
12 |
13 | ## [4.0.0] - 2021-04-13 - MAJOR BUMP
14 |
15 | - Switch to `@formatjs/icu-messageformat-parser` as `intl-messageformat-parser` is now deprecated. This is a breaking change, the new parser uses icu4j implementation and has stricter validations.
16 | - Add new rule `i18n-json/identical-placeholders`.
17 | - Related PRs
18 | - [PR #51](https://github.com/godaddy/eslint-plugin-i18n-json/pull/51)
19 |
20 |
21 | ## [3.0.0] - 2020-08-18 - MAJOR BUMP
22 |
23 | - **security:** Bump intl-messageformat-parser from v3 to v5 due to [CVE-2020-7660](https://nvd.nist.gov/vuln/detail/CVE-2020-7660)
24 | - Major bump as a precaution, due to `intl-messageformat-parser` getting bumped from v3 to v5.
25 | - Related PRs
26 | - [Issue #36](https://github.com/godaddy/eslint-plugin-i18n-json/pull/36) - Thanks Michael Desantis - Intertek Alchemy LP.
27 |
28 |
29 | ## [2.4.3] - 2019-11-05
30 |
31 | ### Fixed
32 |
33 | - ignore non json files which are passed to the plugin rules.
34 | - Related PRs/Issues
35 | - [Issue #26](https://github.com/godaddy/eslint-plugin-i18n-json/issues/26)
36 | - [PR #27](https://github.com/godaddy/eslint-plugin-i18n-json/pull/27) - Thanks [@unlight](https://github.com/unlight)
37 |
38 |
39 | ## [2.4.2] - 2019-08-30
40 |
41 | ### Fixed
42 |
43 | **valid-message-syntax:** upgrade `intl-messageformat-parser` to `^3.0.7` for parsing fixes.
44 | - Related PRs
45 | - [#25](https://github.com/godaddy/eslint-plugin-i18n-json/pull/25) - Thanks [@darkyndy](https://github.com/darkyndy)
46 |
47 | ## [2.4.1] - 2018-01-08
48 |
49 | ### Fixed
50 |
51 | - update to make plugin compatible when used within a file watching service. e.g) webpack dev server.
52 | - new example project showcasing how to configure the plugin with webpack development.
53 | - Related PRs
54 | - [#21](https://github.com/godaddy/eslint-plugin-i18n-json/pull/21) - Thanks [@maccuaa](https://github.com/maccuaa)
55 | - [#23](https://github.com/godaddy/eslint-plugin-i18n-json/pull/23)
56 |
57 | ## [2.4.0] - 2018-07-01
58 |
59 | ### Added
60 |
61 | - **New plugin setting:** `i18n-json/ignore-keys`. Takes a list of key paths (case sensitive) to ignore when checking syntax and doing key structure comparisons. Only acknowledged by the `identical-keys` and `valid-syntax` rules. See README for more details on use.
62 | - new example project for `i18n-json/ignore-keys` to showcase usage.
63 | - Related PRs
64 | - [#17](https://github.com/godaddy/eslint-plugin-i18n-json/pull/17)
65 |
66 | ## [2.3.0] - 2018-05-04
67 |
68 | ### Changed
69 |
70 | - **sorted-keys:** converted the sort feature into an actual eslint rule (backwards compatible with <= 2.2.0). See README for more details on use.
71 | - May the 4th be with you! :)
72 | - Related PRs
73 | - [#13](https://github.com/godaddy/eslint-plugin-i18n-json/pull/13)
74 |
75 | ## [2.2.0] - 2018-04-30
76 |
77 | ### Fixed
78 |
79 | **sorted-keys:** prevent eslint from failing when `--fix` is not passed.
80 | - Related PRs
81 | - [#11](https://github.com/godaddy/eslint-plugin-i18n-json/pull/11) - Thanks [@Nainterceptor](https://github.com/Nainterceptor)
82 |
83 | ### Changed
84 |
85 | **identical-keys:** minimized diffing output.
86 | - Related PRs
87 | - [#6](https://github.com/godaddy/eslint-plugin-i18n-json/pull/6).
88 | Thanks @tvarsis
89 |
--------------------------------------------------------------------------------
/src/identical-placeholders.js:
--------------------------------------------------------------------------------
1 | const { parse, TYPE } = require('@formatjs/icu-messageformat-parser');
2 | const { set, get } = require('lodash');
3 | const diff = require('jest-diff');
4 | const requireNoCache = require('./util/require-no-cache');
5 | const getTranslationFileSource = require('./util/get-translation-file-source');
6 | const deepForOwn = require('./util/deep-for-own');
7 |
8 | const sortAstNodes = (a, b) => `${a.type}${a.value}`.localeCompare(`${b.type}${b.value}`);
9 |
10 | const compareAst = (astA, astB) => {
11 | // Skip raw text
12 | const astAFiltered = astA.filter(a => a.type !== TYPE.literal).sort(sortAstNodes);
13 | const astBFiltered = astB.filter(a => a.type !== TYPE.literal).sort(sortAstNodes);
14 |
15 | if (astAFiltered.length !== astBFiltered.length) {
16 | return false;
17 | }
18 |
19 | if (astAFiltered.length === 0) {
20 | return true;
21 | }
22 |
23 | return astAFiltered.every((elementA, index) => {
24 | const elementB = astBFiltered[index];
25 |
26 | // Type and value should match for each placeholder
27 | if (elementA.type !== elementB.type || elementA.value !== elementB.value) {
28 | return false;
29 | }
30 |
31 | if (elementA.type === TYPE.select || elementA.type === TYPE.plural) {
32 | const elementAOptions = Object.keys(elementA.options).sort();
33 | const elementBOptions = Object.keys(elementB.options).sort();
34 | if (
35 | elementAOptions.length !== elementBOptions.length ||
36 | elementAOptions.join('|') !== elementBOptions.join('|')
37 | ) {
38 | return false;
39 | }
40 |
41 | return elementAOptions.every(o =>
42 | compareAst(elementA.options[o].value, elementB.options[o].value));
43 | }
44 |
45 | // Compare children for type 8 (rich text)
46 | if (elementA.type === TYPE.tag) {
47 | return compareAst(elementA.children, elementB.children);
48 | }
49 |
50 | return true;
51 | });
52 | };
53 |
54 | const identicalPlaceholders = (context, source, sourceFilePath) => {
55 | const { options, settings } = context;
56 | const { filePath } = options[0] || {};
57 |
58 | if (!filePath) {
59 | return [
60 | {
61 | message: '"filePath" rule option not specified.',
62 | loc: {
63 | start: {
64 | line: 0,
65 | col: 0
66 | }
67 | }
68 | }
69 | ];
70 | }
71 |
72 | // skip comparison with reference file
73 | if (filePath === sourceFilePath) {
74 | return [];
75 | }
76 |
77 | let referenceTranslations = null;
78 | let sourceTranslations = null;
79 | try {
80 | referenceTranslations = requireNoCache(filePath);
81 | sourceTranslations = JSON.parse(source);
82 | } catch (e) {
83 | // don't return any errors
84 | // will be caught with the valid-json rule.
85 | return [];
86 | }
87 |
88 | const ignorePaths = settings['i18n-json/ignore-keys'] || [];
89 | const invalidMessages = [];
90 |
91 | deepForOwn(
92 | referenceTranslations,
93 | (referenceTranslation, _, path) => {
94 | if (typeof referenceTranslation === 'string') {
95 | const sourceTranslation = get(sourceTranslations, path);
96 | if (sourceTranslation) {
97 | let referenceAst;
98 | let sourceAst;
99 | try {
100 | referenceAst = parse(referenceTranslation);
101 | sourceAst = parse(sourceTranslation);
102 | } catch (e) {
103 | // don't return any errors
104 | // will be caught with the valid-message-syntax rule.
105 | return;
106 | }
107 | if (!compareAst(referenceAst, sourceAst)) {
108 | invalidMessages.push({
109 | path,
110 | referenceTranslation,
111 | sourceTranslation
112 | });
113 | }
114 | }
115 | }
116 | },
117 | {
118 | ignorePaths
119 | }
120 | );
121 |
122 | if (invalidMessages.length === 0) {
123 | return [];
124 | }
125 |
126 | const expected = {};
127 | const received = {};
128 | invalidMessages.forEach(({ path, referenceTranslation, sourceTranslation }) => {
129 | set(expected, path, referenceTranslation);
130 | set(received, path, `${sourceTranslation} ===> Placeholders don't match`);
131 | });
132 |
133 | return [
134 | {
135 | message: `\n${diff(expected, received)}`,
136 | loc: {
137 | start: {
138 | line: 0,
139 | col: 0
140 | }
141 | }
142 | }
143 | ];
144 | };
145 |
146 | module.exports = {
147 | meta: {
148 | type: 'problem',
149 | docs: {
150 | category: 'Consistency',
151 | description:
152 | 'Verifies the ICU message placeholders for the translations matches with the reference language file specified in the options'
153 | },
154 | schema: [
155 | {
156 | properties: {
157 | filePath: {
158 | type: 'string'
159 | }
160 | },
161 | type: 'object'
162 | }
163 | ]
164 | },
165 | create(context) {
166 | return {
167 | Program(node) {
168 | const { valid, source, sourceFilePath } = getTranslationFileSource({
169 | context,
170 | node
171 | });
172 | if (!valid) {
173 | return;
174 | }
175 | const errors = identicalPlaceholders(context, source, sourceFilePath);
176 | errors.forEach((error) => {
177 | context.report(error);
178 | });
179 | }
180 | };
181 | }
182 | };
183 |
--------------------------------------------------------------------------------
/src/identical-placeholders.test.js:
--------------------------------------------------------------------------------
1 | const { RuleTester } = require('eslint');
2 | const rule = require('./identical-placeholders');
3 |
4 | const ruleTester = new RuleTester();
5 |
6 | jest.mock(
7 | 'path/to/reference-file.json',
8 | () => ({
9 | rawText: {
10 | label1: 'Title'
11 | },
12 | noformat: {
13 | search: {
14 | label2: 'Hi {user}'
15 | }
16 | },
17 | multipleVariables: 'Hi {user}, it is {today, date, medium}.',
18 | numberFormat: '{count, number} users',
19 | select: 'You selected {choice, select, yes {Yea} no {Nay} other {Maybe}}',
20 | 'nested.select': '{done, select, no {There is more to it {count, number}.} other {Done.}}',
21 | 'plural.with.substitution':
22 | 'Cart: {itemCount, plural, =0 {no items} one {# item} other {# items}}.',
23 | richText: 'this is the price {price, number}.'
24 | }),
25 | {
26 | virtual: true
27 | }
28 | );
29 |
30 | const testCaseConfig = {
31 | options: [
32 | {
33 | filePath: 'path/to/reference-file.json'
34 | }
35 | ],
36 | filename: 'file.json'
37 | };
38 | const mismatchError = {
39 | errors: [
40 | {
41 | message: /Placeholders don't match/,
42 | line: 0
43 | }
44 | ]
45 | };
46 |
47 | ruleTester.run('identical-placeholders', rule, {
48 | valid: [
49 | // ignores non json files
50 | {
51 | ...testCaseConfig,
52 | code: `
53 | /*var x = 123;*//*path/to/file.js*/
54 | `,
55 | filename: 'file.js'
56 | },
57 | // ignores based on ignore-keys settings
58 | {
59 | ...testCaseConfig,
60 | code: `
61 | /*{
62 | "noformat": {
63 | "search": {
64 | "label2": "Hi {user11}"
65 | }
66 | }
67 | }*//*path/to/file.json*/
68 | `,
69 | settings: {
70 | 'i18n-json/ignore-keys': ['noformat.search.label2']
71 | }
72 | },
73 | // ignores invaid format messages
74 | {
75 | ...testCaseConfig,
76 | code: `
77 | /*{
78 | "numberFormat": "{count, number12} users"
79 | }*//*path/to/file.json*/
80 | `
81 | },
82 | // ignores any differences in raw text
83 | {
84 | ...testCaseConfig,
85 | code: `
86 | /*{
87 | "rawText": {
88 | "label1": "†ï†lê"
89 | },
90 | "plural.with.substitution": '¢คrt -> {itemCount, plural, =0 {ɳσ ιƚҽɱʂ} one {# ιƚҽɱ} other {# ιƚҽɱʂ}}',
91 | }*//*path/to/file.json*/
92 | `
93 | },
94 | // skips comparison with reference file
95 | {
96 | ...testCaseConfig,
97 | code: `
98 | /*{
99 | "rawText": {
100 | "label1": "†ï†lê"
101 | }
102 | }*//*path/to/reference-file.json*/
103 | `,
104 | filename: 'reference-file.json'
105 | },
106 | // doesn't error on valid strings
107 | {
108 | ...testCaseConfig,
109 | code: `
110 | /*{
111 | "rawText": {
112 | "label1": "Tιƚʅҽ"
113 | },
114 | "multipleVariables": "It is {today, date, medium}, {user}.",
115 | "numberFormat": "{count, number} users"
116 | }*//*path/to/file.json*/
117 | `
118 | }
119 | ],
120 | invalid: [
121 | // errors on invalid config
122 | {
123 | ...testCaseConfig,
124 | code: `
125 | /*{}*//*path/to/file.json*/
126 | `,
127 | options: [],
128 | errors: [
129 | {
130 | message: '"filePath" rule option not specified.',
131 | line: 0
132 | }
133 | ]
134 | },
135 | // errors on variable name mismatch
136 | {
137 | ...testCaseConfig,
138 | code: `
139 | /*{
140 | "noformat": {
141 | "search": {
142 | "label2": "Hi {user11}"
143 | }
144 | }
145 | }*//*path/to/file.json*/
146 | `,
147 | ...mismatchError
148 | },
149 | // errors on variables count mismatch
150 | {
151 | ...testCaseConfig,
152 | code: `
153 | /*{
154 | "noformat": {
155 | "search": {
156 | "label2": "Hi"
157 | }
158 | }
159 | }*//*path/to/file.json*/
160 | `,
161 | ...mismatchError
162 | },
163 | // errors on 'select' format options mismatch
164 | {
165 | ...testCaseConfig,
166 | code: `
167 | /*{
168 | "select": "You selected {choice, select, YΣƧ {Yea} no {Nay} other {Maybe}}"
169 | }*//*path/to/file.json*/
170 | `,
171 | ...mismatchError
172 | },
173 | // errors on 'select' format options count mismatch
174 | {
175 | ...testCaseConfig,
176 | code: `
177 | /*{
178 | "select": "You selected {choice, select, yes {Yea} other {Maybe}}"
179 | }*//*path/to/file.json*/
180 | `,
181 | ...mismatchError
182 | },
183 | // errors on nested variables mismatch
184 | {
185 | ...testCaseConfig,
186 | code: `
187 | /*{
188 | "nested.select": "{done, select, no {There is more to it {count1, number}.} other {Done.}}"
189 | }*//*path/to/file.json*/
190 | `,
191 | ...mismatchError
192 | },
193 | // errors on 'plural' format options mismatch
194 | {
195 | ...testCaseConfig,
196 | code: `
197 | /*{
198 | "plural.with.substitution": "Cart: {itemCount, plural, =0 {no items} uno {# item} other {# items}}."
199 | }*//*path/to/file.json*/
200 | `,
201 | ...mismatchError
202 | },
203 | // errors on rich text variables mismatch
204 | {
205 | ...testCaseConfig,
206 | code: `
207 | /*{
208 | "richText": "this is the price {price}."
209 | }*//*path/to/file.json*/
210 | `,
211 | ...mismatchError
212 | }
213 | ]
214 | });
215 |
--------------------------------------------------------------------------------
/src/valid-message-syntax.js:
--------------------------------------------------------------------------------
1 | const { set, isPlainObject } = require('lodash');
2 | const diff = require('jest-diff');
3 | const prettyFormat = require('pretty-format');
4 | const icuValidator = require('./message-validators/icu');
5 | const notEmpty = require('./message-validators/not-empty');
6 | const isString = require('./message-validators/is-string');
7 | const deepForOwn = require('./util/deep-for-own');
8 | const requireNoCache = require('./util/require-no-cache');
9 | const getTranslationFileSource = require('./util/get-translation-file-source');
10 |
11 | /* Error tokens */
12 | const EMPTY_OBJECT = Symbol.for('EMPTY_OBJECT');
13 | const ARRAY = Symbol.for('ARRAY');
14 |
15 | /* Formatting */
16 | const ALL_BACKSLASHES = /[\\]/g;
17 | const ALL_DOUBLE_QUOTES = /["]/g;
18 |
19 | const prettyFormatTypePlugin = {
20 | test(val) {
21 | return typeof val === 'number' || typeof val === 'string';
22 | },
23 | serialize(val) {
24 | return (
25 | (typeof val === 'string' && `String(${`'${val}'`})`) || `Number(${val})`
26 | );
27 | }
28 | };
29 |
30 | const formatExpectedValue = ({ value }) => {
31 | switch (value) {
32 | case EMPTY_OBJECT:
33 | case ARRAY:
34 | return 'ObjectContaining | ValidMessage';
35 | default:
36 | return 'ValidMessage';
37 | }
38 | };
39 |
40 | const formatReceivedValue = ({ value, error }) => {
41 | const errorMessage = error.message
42 | .replace(ALL_BACKSLASHES, '')
43 | .replace(ALL_DOUBLE_QUOTES, "'");
44 | switch (value) {
45 | case EMPTY_OBJECT:
46 | return `${prettyFormat({})} ===> ${error}`;
47 | case ARRAY:
48 | return `${prettyFormat([])} ===> ${error}`;
49 | default:
50 | return `${prettyFormat(value, {
51 | plugins: [prettyFormatTypePlugin]
52 | })} ===> ${errorMessage}`;
53 | }
54 | };
55 |
56 | const createValidator = (syntax) => {
57 | // each syntax type defined here must have a case!
58 | if (['icu', 'non-empty-string'].includes(syntax)) {
59 | return (value) => {
60 | switch (syntax) {
61 | case 'icu':
62 | notEmpty(value);
63 | isString(value);
64 | icuValidator(value);
65 | break;
66 | default:
67 | notEmpty(value);
68 | isString(value);
69 | }
70 | };
71 | }
72 | // custom validator
73 | const customValidator = requireNoCache(syntax);
74 | return (value, key) => {
75 | customValidator(value, key);
76 | };
77 | };
78 |
79 | const validMessageSyntax = (context, source) => {
80 | const { options, settings = {} } = context;
81 |
82 | let { syntax } = options[0] || {};
83 |
84 | syntax = syntax && syntax.trim();
85 |
86 | let translations = null;
87 | const invalidMessages = [];
88 |
89 | if (!syntax) {
90 | return [
91 | {
92 | message: '"syntax" not specified in rule option.',
93 | loc: {
94 | start: {
95 | line: 0,
96 | col: 0
97 | }
98 | }
99 | }
100 | ];
101 | }
102 |
103 | try {
104 | translations = JSON.parse(source);
105 | } catch (e) {
106 | return [];
107 | }
108 |
109 | let validate;
110 |
111 | try {
112 | validate = createValidator(syntax);
113 | } catch (e) {
114 | return [
115 | {
116 | message: `Error configuring syntax validator. Rule option specified: ${syntax}. ${e}`,
117 | loc: {
118 | start: {
119 | line: 0,
120 | col: 0
121 | }
122 | }
123 | }
124 | ];
125 | }
126 |
127 | const ignorePaths = settings['i18n-json/ignore-keys'] || [];
128 |
129 | deepForOwn(
130 | translations,
131 | (value, key, path) => {
132 | // empty object itself is an error
133 | if (isPlainObject(value)) {
134 | if (Object.keys(value).length === 0) {
135 | invalidMessages.push({
136 | value: EMPTY_OBJECT,
137 | key,
138 | path,
139 | error: new SyntaxError('Empty object.')
140 | });
141 | }
142 | } else if (Array.isArray(value)) {
143 | invalidMessages.push({
144 | value: ARRAY,
145 | key,
146 | path,
147 | error: new TypeError('An Array cannot be a translation value.')
148 | });
149 | } else {
150 | try {
151 | validate(value, key);
152 | } catch (validationError) {
153 | invalidMessages.push({
154 | value,
155 | key,
156 | path,
157 | error: validationError
158 | });
159 | }
160 | }
161 | },
162 | {
163 | ignorePaths
164 | }
165 | );
166 |
167 | if (invalidMessages.length > 0) {
168 | const expected = {};
169 | const received = {};
170 | invalidMessages.forEach((invalidMessage) => {
171 | set(expected, invalidMessage.path, formatExpectedValue(invalidMessage));
172 | set(received, invalidMessage.path, formatReceivedValue(invalidMessage));
173 | });
174 |
175 | return [
176 | {
177 | message: `\n${diff(expected, received)}`,
178 | loc: {
179 | start: {
180 | line: 0,
181 | col: 0
182 | }
183 | }
184 | }
185 | ];
186 | }
187 | // no errors
188 | return [];
189 | };
190 |
191 | module.exports = {
192 | meta: {
193 | docs: {
194 | category: 'Validation',
195 | description:
196 | 'Validates message syntax for each translation key in the file.',
197 | recommended: true
198 | },
199 | schema: [
200 | {
201 | properties: {
202 | syntax: {
203 | type: ['string']
204 | }
205 | },
206 | type: 'object',
207 | additionalProperties: false
208 | }
209 | ]
210 | },
211 | create(context) {
212 | return {
213 | Program(node) {
214 | const { valid, source } = getTranslationFileSource({
215 | context,
216 | node
217 | });
218 | if (!valid) {
219 | return;
220 | }
221 | const errors = validMessageSyntax(context, source);
222 | errors.forEach((error) => {
223 | context.report(error);
224 | });
225 | }
226 | };
227 | }
228 | };
229 |
--------------------------------------------------------------------------------
/src/sorted-keys.test.js:
--------------------------------------------------------------------------------
1 | const { RuleTester } = require('eslint');
2 | const rule = require('./sorted-keys');
3 |
4 | jest.mock(
5 | 'path/to/custom-sort.js',
6 | () => translations => Object.keys(translations).sort((keyA, keyB) => keyA.localeCompare(keyB, 'de')),
7 | {
8 | virtual: true
9 | }
10 | );
11 |
12 | const ruleTester = new RuleTester();
13 |
14 | ruleTester.run('sorted-keys', rule, {
15 | valid: [
16 | // ignores non json files
17 | {
18 | code: `
19 | /*var x = 123;*//*path/to/file.js*/
20 | `,
21 | options: [],
22 | filename: 'file.js'
23 | },
24 | // default sort order and indentSpace.
25 | {
26 | code: `
27 | /*{
28 | "translationKeyA": "translation value a",
29 | "translationKeyB": "translation value b"
30 | }*//*path/to/file.json*/
31 | `,
32 | options: [],
33 | filename: 'file.json'
34 | },
35 | {
36 | code: `
37 | /*{
38 | "translationKeyA": "translation value a",
39 | "translationKeyB": "translation value b"
40 | }*//*path/to/file.json*/
41 | `,
42 | options: [
43 | {
44 | order: 'asc',
45 | indentSpaces: 2
46 | }
47 | ],
48 | filename: 'file.json'
49 | },
50 | {
51 | code: `
52 | /*{
53 | "translationKeyB": "translation value b",
54 | "translationKeyA": "translation value a"
55 | }*//*path/to/file.json*/
56 | `,
57 | options: [
58 | {
59 | order: 'desc',
60 | indentSpaces: 2
61 | }
62 | ],
63 | filename: 'file.json'
64 | },
65 | {
66 | code: `
67 | /*{
68 | "translationKeyB": {
69 | "nested2": "nested value 1",
70 | "nested1": "nested value 2"
71 | },
72 | "translationKeyA": "translation value a"
73 | }*//*path/to/file.json*/
74 | `,
75 | options: [
76 | {
77 | order: 'desc',
78 | indentSpaces: 2
79 | }
80 | ],
81 | filename: 'file.json'
82 | },
83 | {
84 | code: `
85 | /*{
86 | "translationKeyA": {
87 | "nested1": "nested value 1",
88 | "nested2": "nested value 2"
89 | },
90 | "translationKeyB": "translation value a"
91 | }*//*path/to/file.json*/
92 | `,
93 | options: [
94 | {
95 | order: 'asc',
96 | indentSpaces: 2
97 | }
98 | ],
99 | filename: 'file.json'
100 | },
101 | {
102 | code: `
103 | /*{
104 | "äTranslationKey": "translation value ä",
105 | "zTranslationKey": "translation value z"
106 | }*//*path/to/file.json*/
107 | `,
108 | options: [
109 | {
110 | sortFunctionPath: 'path/to/custom-sort.js',
111 | indentSpaces: 2
112 | }
113 | ],
114 | filename: 'file.json'
115 | },
116 | {
117 | // error parsing the json - ignore to allow i18n-json/valid-json rule to handle it
118 | code: `
119 | /*{*//*path/to/file.json*/
120 | `,
121 | options: [
122 | {
123 | order: 'asc',
124 | indentSpaces: 2
125 | }
126 | ],
127 | filename: 'file.json'
128 | }
129 | ],
130 | invalid: [
131 | /*
132 | if order doesn't match what is specified,
133 | it should report a fixable error with
134 | range spanning the whole JSON file,
135 | and emitted text being the sorted translations
136 | with proper indent format.
137 | */
138 | // ascending order test
139 | {
140 | code: `
141 | /*{
142 | "translationKeyB": "translation value b",
143 | "translationKeyA": "translation value a"
144 | }*//*path/to/file.json*/
145 | `,
146 | options: [
147 | {
148 | order: 'asc',
149 | indentSpaces: 2
150 | }
151 | ],
152 | filename: 'file.json',
153 | errors: [
154 | {
155 | message: 'Keys should be sorted, please use --fix.',
156 | line: 0,
157 | fix: {
158 | range: [0, 112],
159 | text: JSON.stringify(
160 | {
161 | translationA: 'translation value a',
162 | translationB: 'translation value b'
163 | },
164 | null,
165 | 2
166 | )
167 | }
168 | }
169 | ]
170 | },
171 | // descending order test
172 | {
173 | code: `
174 | /*{
175 | "translationKeyA": "translation value a",
176 | "translationKeyB": "translation value b"
177 | }*//*path/to/file.json*/
178 | `,
179 | options: [
180 | {
181 | order: 'desc',
182 | indentSpaces: 1
183 | }
184 | ],
185 | filename: 'file.json',
186 | errors: [
187 | {
188 | message: 'Keys should be sorted, please use --fix.',
189 | line: 0,
190 | fix: {
191 | range: [0, 112],
192 | text: JSON.stringify(
193 | {
194 | translationB: 'translation value b',
195 | translationA: 'translation value a'
196 | },
197 | null,
198 | 2
199 | )
200 | }
201 | }
202 | ]
203 | },
204 | {
205 | code: `
206 | /*{
207 | "zTranslationKey": "translation value z",
208 | "äTranslationKey": "translation value ä"
209 | }*//*path/to/file.json*/
210 | `,
211 | options: [
212 | {
213 | sortFunctionPath: 'path/to/custom-sort.js',
214 | indentSpaces: 2
215 | }
216 | ],
217 | filename: 'file.json',
218 | errors: [
219 | {
220 | message: 'Keys should be sorted, please use --fix.',
221 | line: 0,
222 | fix: {
223 | range: [0, 112],
224 | text: JSON.stringify(
225 | {
226 | äTranslationKey: 'translation value ä',
227 | zTranslationKey: 'translation value z'
228 | },
229 | null,
230 | 2
231 | )
232 | }
233 | }
234 | ]
235 | }
236 | ]
237 | });
238 |
--------------------------------------------------------------------------------
/src/identical-keys.js:
--------------------------------------------------------------------------------
1 | const requireNoCache = require('./util/require-no-cache');
2 | const compareTranslationsStructure = require('./util/compare-translations-structure');
3 | const getTranslationFileSource = require('./util/get-translation-file-source');
4 |
5 | const noDifferenceRegex = /Compared\s+values\s+have\s+no\s+visual\s+difference/i;
6 |
7 | // suffix match each key in the mapping with the current source file path.
8 | // pick the first match.
9 | const getKeyStructureFromMap = (filePathMap, sourceFilePath) => {
10 | // do a suffix match
11 | const match = Object.keys(filePathMap)
12 | .filter(filePath => sourceFilePath.endsWith(filePath))
13 | .pop();
14 | if (match) {
15 | try {
16 | const filepath = filePathMap[match];
17 | return requireNoCache(filepath);
18 | } catch (e) {
19 | throw new Error(`\n Error parsing or retrieving key structure comparison file based on "filePath" mapping\n\n "${match}" => "${filePathMap[match]}".\n\n Check the "filePath" option for this rule. \n ${e}`);
20 | }
21 | }
22 | throw new Error('\n Current translation file does not have a matching entry in the "filePath" map.\n Check the "filePath" option for this rule.\n');
23 | };
24 |
25 | /*
26 | comparisonOptions : {
27 | filePath = (string | Function | Object)
28 |
29 | If it's a string, then it can be a file to require in order to compare
30 | it's key structure with the current translation file.
31 |
32 | - If the required value is a function, then the function is called
33 | with the sourceFilePath and parsed translations to retreive the key structure.
34 |
35 | If it's an object , then it should have a mapping b/w file names
36 | and what key structure file to require.
37 | }
38 | */
39 |
40 | const getKeyStructureToMatch = (
41 | options = {},
42 | currentTranslations,
43 | sourceFilePath
44 | ) => {
45 | let keyStructure = null;
46 | let { filePath } = options;
47 |
48 | if (typeof filePath === 'string') {
49 | filePath = filePath.trim();
50 | }
51 |
52 | if (!filePath) {
53 | return {
54 | errors: [
55 | {
56 | message: '"filePath" rule option not specified.',
57 | loc: {
58 | start: {
59 | line: 0,
60 | col: 0
61 | }
62 | }
63 | }
64 | ]
65 | };
66 | }
67 |
68 | if (typeof filePath === 'string') {
69 | try {
70 | keyStructure = requireNoCache(filePath); //eslint-disable-line
71 | } catch (e) {
72 | return {
73 | errors: [
74 | {
75 | message: `\n Error parsing or retrieving key structure comparison file from\n "${filePath}".\n Check the "filePath" option for this rule.\n ${e}`,
76 | loc: {
77 | start: {
78 | line: 0,
79 | col: 0
80 | }
81 | }
82 | }
83 | ]
84 | };
85 | }
86 |
87 | if (typeof keyStructure !== 'function') {
88 | return {
89 | keyStructure
90 | };
91 | }
92 |
93 | // keyStructure exported a function
94 | try {
95 | return {
96 | keyStructure: keyStructure(currentTranslations, sourceFilePath)
97 | };
98 | } catch (e) {
99 | return {
100 | errors: [
101 | {
102 | message: `\n Error when calling custom key structure function from\n "${filePath}".\n Check the "filePath" option for this rule.\n ${e}`,
103 | loc: {
104 | start: {
105 | line: 0,
106 | col: 0
107 | }
108 | }
109 | }
110 | ]
111 | };
112 | }
113 | }
114 |
115 | // due to eslint rule schema, we can assume the "filePath" option is an object.
116 | // anything else will be caught by the eslint rule schema validator.
117 | try {
118 | return {
119 | keyStructure: getKeyStructureFromMap(filePath, sourceFilePath)
120 | };
121 | } catch (e) {
122 | return {
123 | errors: [
124 | {
125 | message: `${e}`,
126 | loc: {
127 | start: {
128 | line: 0,
129 | col: 0
130 | }
131 | }
132 | }
133 | ]
134 | };
135 | }
136 | };
137 |
138 | const identicalKeys = (context, source, sourceFilePath) => {
139 | const { options, settings = {} } = context;
140 |
141 | const comparisonOptions = options[0];
142 |
143 | let currentTranslations = null;
144 | try {
145 | currentTranslations = JSON.parse(source);
146 | } catch (e) {
147 | // don't return any errors
148 | // will be caught with the valid-json rule.
149 | return [];
150 | }
151 | const { errors, keyStructure } = getKeyStructureToMatch(
152 | comparisonOptions,
153 | currentTranslations,
154 | sourceFilePath
155 | );
156 |
157 | if (errors) {
158 | // errors generated from trying to get the key structure
159 | return errors;
160 | }
161 |
162 | const diffString = compareTranslationsStructure(
163 | settings,
164 | keyStructure,
165 | currentTranslations
166 | );
167 |
168 | if (noDifferenceRegex.test(diffString.trim())) {
169 | // success
170 | return [];
171 | }
172 | // mismatch
173 | return [
174 | {
175 | message: `\n${diffString}`,
176 | loc: {
177 | start: {
178 | line: 0,
179 | col: 0
180 | }
181 | }
182 | }
183 | ];
184 | };
185 |
186 | module.exports = {
187 | meta: {
188 | docs: {
189 | category: 'Consistency',
190 | description:
191 | 'Verifies the key structure for the translation file matches the key structure specified in the options',
192 | recommended: false
193 | },
194 | schema: [
195 | {
196 | properties: {
197 | filePath: {
198 | type: ['string', 'object']
199 | }
200 | },
201 | type: 'object'
202 | }
203 | ]
204 | },
205 | create(context) {
206 | return {
207 | Program(node) {
208 | const { valid, source, sourceFilePath } = getTranslationFileSource({
209 | context,
210 | node
211 | });
212 | if (!valid) {
213 | return;
214 | }
215 | const errors = identicalKeys(context, source, sourceFilePath);
216 | errors.forEach((error) => {
217 | context.report(error);
218 | });
219 | }
220 | };
221 | }
222 | };
223 |
--------------------------------------------------------------------------------
/src/valid-message-syntax.test.js:
--------------------------------------------------------------------------------
1 | const { RuleTester } = require('eslint');
2 | const strip = require('strip-ansi');
3 | const runRule = require('../test/run-rule');
4 | const rule = require('./valid-message-syntax');
5 |
6 | const ruleTester = new RuleTester();
7 |
8 | jest.mock(
9 | 'path/to/upper-case-only-format.js',
10 | () => (message) => {
11 | if (message.toUpperCase() !== message) {
12 | throw new SyntaxError('MESSAGE MUST BE IN UPPERCASE!');
13 | }
14 | },
15 | {
16 | virtual: true
17 | }
18 | );
19 |
20 | ruleTester.run('valid-message-syntax', rule, {
21 | valid: [
22 | // ignores non json files
23 | {
24 | code: `
25 | /*var x = 123;*//*path/to/file.js*/
26 | `,
27 | options: [],
28 | filename: 'file.js'
29 | },
30 | // integrated icu check
31 | {
32 | code: `
33 | /*{
34 | "translationKeyA": "translation value a",
35 | "translationKeyB": "translation value b",
36 | "translationKeyC": "translation value escaped curly brackets '{}'",
37 | "translationKeyD": "translation value with backslash \u005C"
38 | }*//*path/to/file.json*/
39 | `,
40 | options: [
41 | {
42 | syntax: 'icu'
43 | }
44 | ],
45 | filename: 'file.json'
46 | },
47 | // non-empty-string check nested translations
48 | {
49 | code: `
50 | /*{
51 | "levelOne": {
52 | "translationKeyA": "translation value a",
53 | "translationKeyB": "translation value b",
54 | "levelTwo" : {
55 | "translationKeyC": "translation value c"
56 | }
57 | }
58 | }*//*path/to/file.json*/
59 | `,
60 | options: [
61 | {
62 | syntax: 'icu'
63 | }
64 | ],
65 | filename: 'file.json'
66 | },
67 | // non-empty-string check nested translations
68 | {
69 | code: `
70 | /*{
71 | "levelOne": {
72 | "translationKeyA": "a",
73 | "translationKeyB": "b",
74 | "levelTwo" : {
75 | "translationKeyC": "c"
76 | }
77 | }
78 | }*//*path/to/file.json*/
79 | `,
80 | options: [
81 | {
82 | syntax: 'non-empty-string'
83 | }
84 | ],
85 | filename: 'file.json'
86 | },
87 | // custom message format (upper case only)
88 | {
89 | code: `
90 | /*{
91 | "translationKeyA": "TRANSLATION VALUE A",
92 | "translationKeyB": "TRANSLATION VALUE B"
93 | }*//*path/to/file.json*/
94 | `,
95 | options: [
96 | {
97 | syntax: 'path/to/upper-case-only-format.js'
98 | }
99 | ],
100 | filename: 'file.json'
101 | },
102 | // no keys
103 | {
104 | code: `
105 | /*{}*//*path/to/file.json*/
106 | `,
107 | options: [
108 | {
109 | syntax: 'icu'
110 | }
111 | ],
112 | filename: 'file.json'
113 | },
114 | {
115 | // error parsing the json - ignore to allow i18n-json/valid-json rule to handle it
116 | code: `
117 | /*{*//*path/to/file.json*/
118 | `,
119 | options: [
120 | {
121 | syntax: 'icu'
122 | }
123 | ],
124 | filename: 'file.json'
125 | },
126 | // ignore keys
127 | {
128 | code: `
129 | /*{
130 | "translationKeyA": "invalid translation { value a",
131 | "translationKeyB": "translation value b",
132 | "translationKeyC": {
133 | "metadata": [ "value" ]
134 | }
135 | }*//*path/to/file.json*/
136 | `,
137 | options: [
138 | {
139 | syntax: 'icu'
140 | }
141 | ],
142 | filename: 'file.json',
143 | settings: {
144 | 'i18n-json/ignore-keys': ['translationKeyA', 'translationKeyC']
145 | }
146 | }
147 | ],
148 | invalid: [
149 | // bad path for custom message format
150 | {
151 | code: `
152 | /*{
153 | "translationKeyA": "translation value a",
154 | "translationKeyB": "translation value b"
155 | }*//*path/to/file.json*/
156 | `,
157 | options: [
158 | {
159 | syntax: 'path/to/does-not-exist.js'
160 | }
161 | ],
162 | filename: 'file.json',
163 | errors: [
164 | {
165 | message: /Error configuring syntax validator\. Rule option specified: path\/to\/does-not-exist\.js\. Error: cannot find module /gi,
166 | line: 0
167 | }
168 | ]
169 | },
170 | // no option specified
171 | {
172 | code: `
173 | /*{
174 | "translationKeyA": "translation value a {"
175 | }*//*path/to/file.json*/
176 | `,
177 | options: [],
178 | filename: 'file.json',
179 | errors: [
180 | {
181 | message: /"syntax" not specified in rule option/g,
182 | line: 0
183 | }
184 | ]
185 | }
186 | ]
187 | });
188 |
189 | describe('Snapshot Tests for Invalid Code', () => {
190 | const run = runRule(rule);
191 | test('using "non-empty-string" validator and has empty messages', () => {
192 | const errors = run({
193 | code: `
194 | /*{
195 | "translationKeyA": "",
196 | "translationKeyB": null
197 | }*//*path/to/file.json*/
198 | `,
199 | options: [
200 | {
201 | syntax: 'non-empty-string'
202 | }
203 | ],
204 | filename: 'file.json'
205 | });
206 | expect(strip(errors[0].message)).toMatchSnapshot();
207 | });
208 | test('custom message format (upper case only)', () => {
209 | const errors = run({
210 | code: `
211 | /*{
212 | "translationKeyA": "translation value a",
213 | "translationKeyB": "translation value b"
214 | }*//*path/to/file.json*/
215 | `,
216 | options: [
217 | {
218 | syntax: 'path/to/upper-case-only-format.js'
219 | }
220 | ],
221 | filename: 'file.json'
222 | });
223 | expect(strip(errors[0].message)).toMatchSnapshot();
224 | });
225 | test('nested translations - icu syntax check', () => {
226 | const errors = run({
227 | code: `
228 | /*{
229 | "levelOne": {
230 | "translationKeyA": "translation value a",
231 | "translationKeyB": "translation value b {",
232 | "translationKeyD": "translation value d '{}",
233 | "levelTwo" : {
234 | "translationKeyC": "translation value c {"
235 | }
236 | }
237 | }*//*path/to/file.json*/
238 | `,
239 | options: [
240 | {
241 | syntax: 'icu'
242 | }
243 | ],
244 | filename: 'file.json'
245 | });
246 | expect(strip(errors[0].message)).toMatchSnapshot();
247 | });
248 | test('no empty objects', () => {
249 | const errors = run({
250 | code: `
251 | /*{
252 | "levelOne": {}
253 | }*//*path/to/file.json*/
254 | `,
255 | options: [
256 | {
257 | syntax: 'icu'
258 | }
259 | ],
260 | filename: 'file.json'
261 | });
262 | expect(strip(errors[0].message)).toMatchSnapshot();
263 | });
264 | test('no arrays or numbers', () => {
265 | const errors = run({
266 | code: `
267 | /*{
268 | "levelOne": [ "data" ],
269 | "levelTwo": 5
270 | }*//*path/to/file.json*/
271 | `,
272 | options: [
273 | {
274 | syntax: 'icu'
275 | }
276 | ],
277 | filename: 'file.json'
278 | });
279 | expect(strip(errors[0].message)).toMatchSnapshot();
280 | });
281 | });
282 |
--------------------------------------------------------------------------------
/src/identical-keys.test.js:
--------------------------------------------------------------------------------
1 | const { RuleTester } = require('eslint');
2 | const strip = require('strip-ansi');
3 | const rule = require('./identical-keys');
4 | const runRule = require('../test/run-rule');
5 |
6 | const ruleTester = new RuleTester();
7 |
8 | jest.mock(
9 | 'path/to/compare-file-b.json',
10 | () => ({
11 | translationLevelOne: {
12 | translationKeyA: 'value a',
13 | translationKeyB: 'value b',
14 | translationKeyC: 'value c'
15 | }
16 | }),
17 | {
18 | virtual: true
19 | }
20 | );
21 |
22 | jest.mock(
23 | 'path/to/compare-file-a.json',
24 | () => ({
25 | translationLevelOne: {
26 | translationKeyA: 'value a',
27 | translationLevelTwo: {
28 | translationKeyB: 'value b',
29 | translationsLevelThree: {
30 | translationKeyC: 'value c'
31 | }
32 | }
33 | }
34 | }),
35 | {
36 | virtual: true
37 | }
38 | );
39 |
40 | jest.mock(
41 | 'path/to/identity-structure.js',
42 | () =>
43 | jest
44 | .fn()
45 | .mockImplementationOnce(t => t)
46 | .mockImplementationOnce(() => {
47 | throw new Error('something went wrong');
48 | }),
49 | {
50 | virtual: true
51 | }
52 | );
53 |
54 | jest.mock(
55 | 'path/to/wrong-structure-generator.js',
56 | () =>
57 | jest.fn().mockImplementationOnce(() => ({
58 | 'other-key': 'other value'
59 | })),
60 | {
61 | virtual: true
62 | }
63 | );
64 |
65 | ruleTester.run('identical-keys', rule, {
66 | valid: [
67 | // ignores non json files
68 | {
69 | code: `
70 | /*var x = 123;*//*path/to/file.js*/
71 | `,
72 | options: [],
73 | filename: 'file.js'
74 | },
75 | // single file path to compare with
76 | {
77 | code: `
78 | /*{
79 | "translationLevelOne": {
80 | "translationKeyA": "value a",
81 | "translationLevelTwo": {
82 | "translationKeyB": "value b",
83 | "translationsLevelThree": {
84 | "translationKeyC": "value c"
85 | }
86 | }
87 | }
88 | }*//*path/to/file.json*/
89 | `,
90 | options: [
91 | {
92 | filePath: 'path/to/compare-file-a.json'
93 | }
94 | ],
95 | filename: 'file.json'
96 | },
97 | // mapping to match which file we should use to compare structure
98 | {
99 | code: `
100 | /*{
101 | "translationLevelOne": {
102 | "translationKeyA": "value a",
103 | "translationLevelTwo": {
104 | "translationKeyB": "value b",
105 | "translationsLevelThree": {
106 | "translationKeyC": "value c"
107 | }
108 | }
109 | }
110 | }*//*/path/to/compare-file-a.json*/
111 | `,
112 | options: [
113 | {
114 | filePath: {
115 | 'compare-file-a.json': 'path/to/compare-file-a.json',
116 | 'compare-file-b.json': 'path/to/compare-file-b.json'
117 | }
118 | }
119 | ],
120 | filename: 'compare-file-a.json'
121 | },
122 | // structure generator function
123 | {
124 | code: `
125 | /*{
126 | "translationLevelOne": {
127 | "translationKeyA": "value a"
128 | }
129 | }*//*/path/to/file.json*/
130 | `,
131 | options: [
132 | {
133 | filePath: 'path/to/identity-structure.js'
134 | }
135 | ],
136 | filename: 'file.json'
137 | },
138 | // let the i18n-json/valid-json rule catch the invalid json translations
139 | {
140 | code: `
141 | /*{*//*path/to/file.json*/
142 | `,
143 | options: [
144 | {
145 | filePath: 'path/to/compare-file.json'
146 | }
147 | ],
148 | filename: 'file.json'
149 | },
150 | // ignore-keys global setting
151 | {
152 | code: `
153 | /*{
154 | "translationLevelOne": {
155 | "translationKeyA": "value a",
156 | "translationLevelTwo": {
157 | "translationKeyD": "value d",
158 | "translationsLevelThree": {
159 | "translationKeyE": "value e"
160 | }
161 | }
162 | }
163 | }*//*path/to/file.json*/
164 | `,
165 | options: [
166 | {
167 | filePath: 'path/to/compare-file-a.json'
168 | }
169 | ],
170 | filename: 'file.json',
171 | settings: {
172 | 'i18n-json/ignore-keys': ['translationLevelOne.translationLevelTwo']
173 | }
174 | }
175 | ],
176 | invalid: [
177 | // no option passed
178 | {
179 | code: `
180 | /*{}*//*path/to/file.json*/
181 | `,
182 | filename: 'file.json',
183 | errors: [
184 | {
185 | message: '"filePath" rule option not specified.',
186 | line: 0
187 | }
188 | ]
189 | },
190 | // key structure function throws
191 | {
192 | code: `
193 | /*{}*//*path/to/file.json*/
194 | `,
195 | options: [
196 | {
197 | filePath: 'path/to/identity-structure.js'
198 | }
199 | ],
200 | filename: 'file.json',
201 | errors: [
202 | {
203 | message: /Error when calling custom key structure function/,
204 | line: 0
205 | }
206 | ]
207 | },
208 | // comparison file doesn't exist
209 | {
210 | code: `
211 | /*{}*//*path/to/file.json*/
212 | `,
213 | options: [
214 | {
215 | filePath: 'path/to/does-not-exist.js'
216 | }
217 | ],
218 | filename: 'file.json',
219 | errors: [
220 | {
221 | message: /Error parsing or retrieving key structure comparison file/,
222 | line: 0
223 | }
224 | ]
225 | },
226 | // mapped file doesn't exist
227 | {
228 | code: `
229 | /*{}*//*path/to/file.json*/
230 | `,
231 | options: [
232 | {
233 | filePath: {
234 | 'file.json': 'path/to/does-not-exist.json'
235 | }
236 | }
237 | ],
238 | filename: 'file.json',
239 | errors: [
240 | {
241 | message: /Error parsing or retrieving key structure comparison file based on "filePath" mapping/,
242 | line: 0
243 | }
244 | ]
245 | },
246 | // no match for this file found in the mapping
247 | {
248 | code: `
249 | /*{}*//*path/to/file.json*/
250 | `,
251 | options: [
252 | {
253 | filePath: {
254 | 'other-file.json': 'path/to/does-not-exist.json' // shouldn't require does-not-exist.json, since it doesn't match
255 | }
256 | }
257 | ],
258 | filename: 'file.json',
259 | errors: [
260 | {
261 | message: /Current translation file does not have a matching entry in the "filePath" map/,
262 | line: 0
263 | }
264 | ]
265 | }
266 | ]
267 | });
268 |
269 | /*
270 | For tests which should result in errors, we will be using
271 | Snapshot testing for increased readability of the jest-diff
272 | output
273 | */
274 |
275 | describe('Snapshot Tests for Invalid Code', () => {
276 | const run = runRule(rule);
277 | test('single comparison file - structure mismatch', () => {
278 | const errors = run({
279 | code: `
280 | /*{
281 | "translationLevelOne": {
282 | "translationKeyY": "value y",
283 | "translationKeyZ": "value z"
284 | }
285 | }*//*/path/to/invalid-file.json*/
286 | `,
287 | options: [
288 | {
289 | filePath: 'path/to/compare-file-a.json'
290 | }
291 | ],
292 | filename: 'file.json'
293 | });
294 | expect(strip(errors[0].message)).toMatchSnapshot();
295 | });
296 | test('map of comparison files - structure mismatch', () => {
297 | const errors = run({
298 | code: `
299 | /*{
300 | "translationLevelOne": {
301 | "translationKeyY": "value y",
302 | "translationKeyZ": "value z"
303 | }
304 | }*//*/path/to/file.json*/
305 | `,
306 | options: [
307 | {
308 | filePath: {
309 | 'file.json': 'path/to/compare-file-a.json'
310 | }
311 | }
312 | ],
313 | filename: 'file.json'
314 | });
315 | expect(strip(errors[0].message)).toMatchSnapshot();
316 | });
317 | test('structure generator function - structure mismatch', () => {
318 | const errors = run({
319 | code: `
320 | /*{
321 | "translationLevelOne": {
322 | "translationKeyY": "value y",
323 | "translationKeyZ": "value z"
324 | }
325 | }*//*/path/to/file.json*/
326 | `,
327 | options: [
328 | {
329 | filePath: 'path/to/wrong-structure-generator.js'
330 | }
331 | ],
332 | filename: 'file.json'
333 | });
334 | expect(strip(errors[0].message)).toMatchSnapshot();
335 | });
336 | });
337 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # eslint-plugin-i18n-json
2 |
3 | [](https://www.npmjs.com/package/eslint-plugin-i18n-json)
4 | [](https://travis-ci.org/godaddy/eslint-plugin-i18n-json)
5 |
6 | > Fully extendable eslint plugin for JSON i18n translation files.
7 |
8 |
9 |
10 |
11 |
12 | 🎉 [**Check out the introductory blog post!**](https://godaddy.github.io/2018/04/02/introducing-eslint-plugin-i18n-json/)
13 |
14 | ## Table of Contents
15 |
16 | - [Features](#features-)
17 | - [Getting started](#getting-started)
18 | - [Examples](#examples)
19 | - [Configuring your .eslintrc file (ESLint version `< 9.0.0`)](#configuring-your-eslintrc-file-eslint-version--900)
20 | - [EsLint version `>= 9.0.0`](#eslint-version--900)
21 | - [Rules](#rules)
22 | - [i18n-json/valid-json](#i18n-jsonvalid-json)
23 | - [i18n-json/valid-message-syntax](#i18n-jsonvalid-message-syntax)
24 | - [i18n-json/identical-keys](#i18n-jsonidentical-keys)
25 | - [i18n-json/sorted-keys](#i18n-jsonsorted-keys)
26 | - [i18n-json/identical-placeholders](#i18n-jsonidentical-placeholders)
27 | - [Settings](#settings)
28 | - [i18n-json/ignore-keys](#i18n-jsonignore-keys)
29 | - [Special Thanks](#special-thanks-)
30 | - [License](#license-)
31 | - [Changelog](CHANGELOG.md)
32 |
33 |
34 | ## Features 🚀
35 |
36 | - lint JSON translation files
37 | - rule: `i18n-json/valid-json`
38 | - configure a custom linter in case the default doesn't fit your needs.
39 |
40 | - validate syntax per message
41 | - rule: `i18n-json/valid-message-syntax`
42 | - default syntax check is for ICU Message Syntax
43 | - can support any message syntax through custom validators. [Example](examples/custom-message-syntax/)
44 |
45 | - ensure translation files have identical keys
46 | - rule: `i18n-json/identical-keys`
47 | - supports different custom mappings and on the fly key structure generation
48 |
49 | - sort translation keys in ascending order through eslint auto-fix (case-sensitive)
50 | - rule: `i18n-json/sorted-keys`
51 | - can support a custom sort function to satisfy different sorting needs
52 |
53 | - ensure translation files have identical placeholders
54 | - rule: `i18n-json/identical-placeholders`
55 |
56 | - ability to ignore certain keys. Example: metadata keys, in progress translations, etc.
57 | - setting: `i18n-json/ignore-keys` [Example](examples/ignore-keys/)
58 |
59 | - The plugin supports **any level of nesting** in the translation file. (escapes `.` in key names)
60 |
61 | **Note: Check out the [Examples](examples/) folder to see different use cases and project setups.**
62 |
63 | ### Requires
64 |
65 | - eslint >= 4.0.0
66 | - node >= 6.0.0
67 |
68 | ## Examples
69 | Check out the [Examples](examples/) folder to see different use cases.
70 |
71 | - [Basic Setup](examples/simple)
72 | - [Custom Message Syntax](examples/custom-message-syntax)
73 | - [Custom Sorting Function For Keys](examples/custom-sort)
74 | - [Identical Keys (Simple)](examples/identical-keys-simple)
75 | - [Ignoring Keys](examples/ignore-keys)
76 | - [Multiple Files Per Locale](examples/multiple-files-per-locale)
77 | - [Webpack Development (eslint-loader)](examples/webpack-local-dev)
78 |
79 | ## Getting Started
80 |
81 | Right out of the box you get the following through our recommended ruleset `i18n-json/recommended`:
82 |
83 | - i18n-json/valid-json
84 | - linting of each JSON translation file
85 | - default severity: error | 2
86 | - i18n-json/valid-message-syntax
87 | - default ICU Message syntax validation (using `@formatjs/icu-messageformat-parser`)
88 | - default severity: error | 2
89 | - i18n-json/sorted-keys
90 | - automatic case-sensitive ascending sort of all keys in the translation file.
91 | - Does a level order traversal of keys, and supports sorting nested objects
92 |
93 | Let's say your translations project directory looks like the following, (project name: simple)
94 |
95 | ```BASH
96 | > tree simple -I node_modules
97 |
98 | simple
99 | ├── package.json
100 | ├── readme.md
101 | └── translations
102 | ├── en-US
103 | │ └── index.json
104 | └── es-MX
105 | └── index.json
106 | ```
107 |
108 | **In this project directory, do the following:**
109 | 1) >npm install --save-dev eslint-plugin-i18n-json
110 | 2) If you are using eslint `< 9.0.0` Create a `.eslintrc.js` file in the root dir of your project. For this example: `/simple/.eslintrc.js`.
111 | 3) paste in the following:
112 | ```javascript
113 | module.exports = {
114 | extends: [
115 | 'plugin:i18n-json/recommended',
116 | ],
117 | };
118 | ```
119 | 4) add this npm script to your `package.json` file.
120 |
121 | - **note:**
122 | - without the `--fix` option, sorting the translation file won't work
123 | - the default eslint report formatter, `stylish`, doesn't handle lint messages of varying length well. Hence, we have also built a `custom report formatter` well suited for this plugin.
124 | ```json
125 | {
126 | "scripts": {
127 | "lint": "eslint --fix --ext .json --format node_modules/eslint-plugin-i18n-json/formatter.js translations/"
128 | }
129 | }
130 | ```
131 | - *Also, the following builtin formatters provided by eslint also work well: `compact`, `unix`, `visualstudio`, `json`.* [Learn more here](https://eslint.org/docs/user-guide/formatters/)
132 | - Example usage: `eslint --fix --ext .json --format compact translations/`
133 |
134 | 5) >npm run lint
135 |
136 | 6) **Profit!** Relax knowing that each change to the translations project will go through strict checks by the eslint plugin.
137 |
138 | *Example where we have invalid ICU message syntax.*
139 |
140 | 
141 |
142 | ## Configuring your .eslintrc file (ESLint version `< 9.0.0`)
143 | - Simply update your `.eslintrc.*` with overrides for the individual rules.
144 | - Eslint severities: 2 = error, 1 = warning, 0 = off
145 | - Example of the module's default rule configuration:
146 | - see below for more information about how to further configure each rule. (some options may require switching to a `.eslintrc.js` file)
147 |
148 | ```json
149 | // .eslintrc.json
150 | {
151 | "rules": {
152 | "i18n-json/valid-message-syntax": [2, {
153 | "syntax": "icu"
154 | }],
155 | "i18n-json/valid-json": 2,
156 | "i18n-json/sorted-keys": [2, {
157 | "order": "asc",
158 | "indentSpaces": 2,
159 | }],
160 | "i18n-json/identical-keys": 0
161 | }
162 | }
163 | ```
164 |
165 | ```javascript
166 | // .eslintrc.js
167 | module.exports = {
168 | rules: {
169 | 'i18n-json/valid-message-syntax': [2, {
170 | syntax: 'icu',
171 | }],
172 | 'i18n-json/valid-json': 2,
173 | 'i18n-json/sorted-keys': [2, {
174 | order: 'asc',
175 | indentSpaces: 2,
176 | }],
177 | 'i18n-json/identical-keys': 0,
178 | },
179 | };
180 | ```
181 |
182 | ## ESLint version `>= 9.0.0`
183 |
184 | - ESLint version `>= 9.0.0` uses flat configuration
185 |
186 | ```javascript
187 | // eslint.config.(m)js
188 | import i18nJsonPlugin from 'eslint-plugin-i18n-json';
189 |
190 | export default {
191 | files: ['**/*.json'],
192 | plugins: { 'i18n-json': i18nJsonPlugin },
193 | processor: {
194 | meta: { name: '.json' },
195 | ...i18nJsonPlugin.processors['.json'],
196 | },
197 | rules: {
198 | ...i18nJsonPlugin.configs.recommended.rules,
199 | 'i18n-json/valid-message-syntax': 'off',
200 | },
201 | };
202 |
203 | ```
204 |
205 | ## Rules
206 |
207 | ### i18n-json/valid-json
208 |
209 | - linting of each JSON translation file
210 | - builtin linter uses `json-lint`
211 | - default severity: error | 2
212 | - **options**
213 | - `linter`: String (Optional)
214 | - Absolute path to a module which exports a JSON linting function.
215 | - `Function(source: String)`
216 | - This function will be passed the source of the current file being processed.
217 | - It **should throw an Error**, just like `JSON.parse`.
218 | ```javascript
219 | // .eslintrc.js
220 | module.exports = {
221 | rules: {
222 | 'i18n-json/valid-json': [2, {
223 | linter: path.resolve('path/to/custom-linter.js'),
224 | }],
225 | },
226 | };
227 | ```
228 | ```javascript
229 | // custom-linter.js
230 | module.exports = (source) => {
231 | if (isBad(source)) {
232 | throw new SyntaxError('invalid syntax');
233 | }
234 | };
235 | ```
236 |
237 | Example output for Invalid JSON.
238 |
239 | 
240 |
241 | ### i18n-json/valid-message-syntax
242 |
243 | - default ICU Message syntax validation (using `@formatjs/icu-messageformat-parser`)
244 | - default severity: error | 2
245 | - **options**
246 | - `syntax`: String (Optional). Default value: `icu`.
247 | - **Can be a built in validator: `icu`, `non-empty-string`.**
248 | ```javascript
249 | // .eslintrc.js
250 | module.exports = {
251 | rules: {
252 | 'i18n-json/valid-message-syntax': [2, {
253 | syntax: 'non-empty-string',
254 | }],
255 | },
256 | };
257 | ```
258 |
259 | - **Can be an absolute path to a module which exports a Syntax Validator Function.**
260 |
261 | - `Function(message: String, key: String)`
262 | - This function will be invoked with each `message` and its corresponding `key`
263 | - It **should throw an Error**, just like `JSON.parse` on invalid syntax.
264 | ```javascript
265 | // .eslintrc.js
266 | module.exports = {
267 | rules: {
268 | 'i18n-json/valid-message-syntax': [2, {
269 | syntax: path.resolve('path/to/custom-syntax-validator.js'),
270 | }],
271 | },
272 | };
273 | ```
274 | ```javascript
275 | // custom-syntax-validator.js example
276 | module.exports = (message, key) => {
277 | // each message should be in all caps.
278 | if (message !== message.toUpperCase()) {
279 | throw new SyntaxError('MESSAGE MUST BE IN ALL CAPS!');
280 | }
281 | };
282 | ```
283 | Output from the [custom-message-syntax](/examples/custom-message-syntax) example where each message must have the word 'PIZZA' prepended to it.
284 |
285 | 
286 |
287 | ### i18n-json/identical-keys
288 |
289 | - compare each translation file's key structure with a reference translation file to ensure consistency
290 | - severity: 0 | off , this rule is OFF by default
291 | - Can turn this rule on by specifying options for it through your `.eslintrc.*` file.
292 | - **options**
293 | - `filePath` : String | Object (Required)
294 |
295 | - **Can be an absolute path to the reference translation file.**
296 | ```javascript
297 | // .eslintrc.js
298 | module.exports = {
299 | rules: {
300 | 'i18n-json/identical-keys': [2, {
301 | filePath: path.resolve('path/to/locale/en-US.json'),
302 | }],
303 | },
304 | };
305 | ```
306 |
307 | - **Can be an Object which contains a Mapping for how to choose a reference translation file. (chosen by suffix match)**
308 | ```javascript
309 | // .eslintrc.js
310 | module.exports = {
311 | rules: {
312 | 'i18n-json/identical-keys': [2, {
313 | filePath: {
314 | 'login.json': path.resolve('./translations/en-US/login.json'),
315 | 'search-results.json': path.resolve('./translations/en-US/search-results.json'),
316 | 'todos.json': path.resolve('./translations/en-US/todos.json'),
317 | },
318 | }],
319 | },
320 | };
321 | ```
322 | - values in the path must be the absolute file path to the reference translation file.
323 | - the plugin will do a **suffix** match on the current file's path to determine which reference translation file to choose.
324 |
325 | - **Can be an absolute path to an exported function which generates the reference key structure on the fly.** The function will be passed the parsed JSON translations object and absolute path of the current file being processed.
326 |
327 | - `Function(translations: Object, currentFileAbsolutePath: String) : Object`
328 | ```javascript
329 | // .eslintrc.js
330 | module.exports = {
331 | rules: {
332 | 'i18n-json/identical-keys': [2, {
333 | filePath: path.resolve('path/to/key-structure-generator.js'),
334 | }],
335 | },
336 | };
337 | ```
338 | ```javascript
339 | // key-structure-generator.js example
340 | module.exports = (translations, currentFileAbsolutePath) => {
341 | // identity key structure generator
342 | return translations;
343 | };
344 | ```
345 |
346 | Output from the slightly advanced [identical keys](/examples/multiple-keys-per-locale) example where some keys from the reference translation file (`en-US`) were not found during comparison.
347 |
348 | 
349 |
350 | ### i18n-json/sorted-keys
351 |
352 | - automatic case-sensitive ascending sort of all keys in the translation file
353 | - if turned on, the this rule by will sort keys in an ascending order by default.
354 | - default severity: error | 2
355 | - **options**
356 | - `sortFunctionPath`: String (Optional). Absolute path to a module which exports a custom sort function. The function should return the desired order of translation keys. The rule will do a level order traversal of the translations and call this custom sort at each level of the object, hence supporting nested objects. This option takes precedence over the `order` option.
357 | - **NOTE**: eslint does additional verification passes on your files after a "fix" is applied (in our case, once the sorted keys are written back to your JSON file). Ensure your sort function won't switch the ordering once the keys are already sorted. For example, if your sort function looks like `Object.keys(translations).reverse()`, then on the initial pass your keys would be sorted correctly, but in the next pass the order of keys would again be reversed. This would lead to a loop where eslint cannot verify the fix is working correctly. Eslint will not apply the intended sorting fixes in this scenarios.
358 | - `Function(translations: Object) : Array`
359 | ```javascript
360 | // .eslintrc.js
361 | module.exports = {
362 | rules: {
363 | 'i18n-json/sorted-keys': [2, {
364 | sortFunctionPath: path.resolve('path/to/custom-sort.js'),
365 | }],
366 | },
367 | };
368 | ```
369 | ```javascript
370 | // custom-sort.js example
371 | // Ascending sort
372 | module.exports = (translations) => {
373 | return Object.keys(translations).sort((keyA, keyB) => {
374 | if (keyA == keyB) {
375 | return 0;
376 | } else if (keyA < keyB) {
377 | return -1;
378 | } else {
379 | return 1;
380 | }
381 | })
382 | };
383 | ```
384 | - `order`: String (Optional). Possible values: `asc|desc`. Default value: `asc`. Case-sensitive sort order of translation keys. The rule does a level order traversal of object keys. Supports nested objects. Note: if you supply a custom sort function through `sortFunctionPath`, then this option will be ignored.
385 | - `indentSpaces` : Number (Optional). Default value: `2`. The number of spaces to indent the emitted sorted translations with. (Will be passed to `JSON.stringify` when generating fixed output).
386 | In the case `--fix` is not supplied to eslint, and the `i18n-json/sorted-keys` rule is not switched off, it will emit an
387 | `error` (or `warning`) if it detects an invalid sort order for translation keys.
388 |
389 | 
390 |
391 | ### i18n-json/identical-placeholders
392 |
393 | - compare each translation's placeholders with the reference file to ensure consistency
394 | - severity: 0 | off , this rule is OFF by default
395 | - Can turn this rule on by specifying options for it through your `.eslintrc.*` file.
396 | - **options**
397 | - `filePath` : String (Required)
398 |
399 | - **Can be an absolute path to the reference translation file.**
400 | ```javascript
401 | // .eslintrc.js
402 | module.exports = {
403 | rules: {
404 | 'i18n-json/identical-placeholders': [2, {
405 | filePath: path.resolve('path/to/locale/en-US.json'),
406 | }],
407 | },
408 | };
409 | ```
410 | ## Settings
411 |
412 | ### i18n-json/ignore-keys
413 |
414 | - list of key paths (case sensitive) to ignore when checking syntax and doing key structure comparisons. [Example](examples/ignore-keys/)
415 | - this setting is used by the following rules: `i18n-json/identical-keys`, `i18n-json/valid-syntax` and `i18n-json/identical-placeholders`.
416 | - if the key path points to an object, the nested paths are also ignored.
417 | - e.g. if the key `a` was added to the `ignore-keys` list, then `a.b` will also be ignored.
418 | ```json
419 | {
420 | "a": {
421 | "b": "translation"
422 | }
423 | }
424 | ```
425 | - example usage: metadata keys with values not corresponding to the syntax specified or work-in-progress translation keys which should not be used in comparisons.
426 |
427 | **Example setting configuration:**
428 |
429 | ```javascript
430 | // .eslintrc.js
431 | {
432 | settings: {
433 | /*
434 | None of the key paths listed below
435 | will be checked for valid i18n syntax
436 | nor be used in the identical-keys rule comparison.
437 | (if the key path points to an object, the nested paths are also ignored)
438 | */
439 | 'i18n-json/ignore-keys': [
440 | 'translationMetadata',
441 | 'login.form.inProgressTranslationKey',
442 | 'some-key'
443 | ],
444 | },
445 | }
446 | ```
447 |
448 | ## Disclaimer
449 |
450 | - **None of the translations in the examples provided reflect actual GoDaddy translations.** They were just created using Google Translate for example's sake 😉.
451 |
452 | ## Special Thanks 👏
453 |
454 | - Jest platform packages
455 |
456 | - @formatjs/icu-messageformat-parser
457 |
458 | - report formatter ui heavily inspired from: https://github.com/sindresorhus/eslint-formatter-pretty
459 |
460 | - ["Translate" icon](https://thenounproject.com/term/translate/1007332) created by Björn Andersson, from [the Noun Project](https://thenounproject.com/). Used with attribution under Creative Commons.
461 |
462 | ## License 📋
463 |
464 | MIT
465 |
--------------------------------------------------------------------------------