├── .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 |
21 | 22 | 23 |
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 | [![Latest npm version](https://img.shields.io/npm/v/eslint-plugin-i18n-json.svg)](https://www.npmjs.com/package/eslint-plugin-i18n-json) 4 | [![Build Status](https://travis-ci.org/godaddy/eslint-plugin-i18n-json.svg?branch=master)](https://travis-ci.org/godaddy/eslint-plugin-i18n-json) 5 | 6 | > Fully extendable eslint plugin for JSON i18n translation files. 7 | 8 |

9 | logo 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 | ![](assets/invalid-icu-syntax-screenshot.png) 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 | ![](assets/invalid-json-screenshot.png) 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 | ![](assets/invalid-custom-syntax-screenshot.png) 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 | ![](assets/identical-keys-error-screenshot-2018-04-30.png) 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 | ![](assets/fixable-sorting-notice.png) 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 | --------------------------------------------------------------------------------