├── .babelrc ├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml ├── issue_template.md ├── pull_request_template.md └── workflows │ └── node.js.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.js ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin └── cli.js ├── docs ├── development.md ├── examples.md └── migration.md ├── index.d.ts ├── package.json ├── src ├── broccoli.js ├── helpers.js ├── index.js ├── lexers │ ├── base-lexer.js │ ├── handlebars-lexer.js │ ├── html-lexer.js │ ├── javascript-lexer.js │ └── jsx-lexer.js ├── parser.js └── transform.js ├── test ├── Intl.PluralRules.mock.js ├── broccoli │ ├── Brocfile.js │ ├── broccoli.test.js │ └── src │ │ ├── handlebars.hbs │ │ └── javascript.js ├── cli │ ├── cli.test.js │ ├── html.html │ ├── i18next-parser.config.js │ ├── i18next-parser.config.mjs │ ├── i18next-parser.config.ts │ └── i18next-parser.config.yaml ├── gulp │ ├── gulp.test.js │ └── gulpfile.js ├── helpers │ ├── dotPathToHash.test.js │ ├── getPluralSuffixPosition.test.js │ ├── getSingularForm.test.js │ ├── hasRelatedPluralKey.test.js │ ├── makeDefaultSort.test.js │ ├── mergeHashes.test.js │ └── transferValues.test.js ├── lexers │ ├── base-lexer.test.js │ ├── handlebars-lexer.test.js │ ├── html-lexer.test.js │ ├── javascript-lexer.test.js │ └── jsx-lexer.test.js ├── locales │ ├── ar │ │ ├── test_reset.json │ │ └── test_sort.json │ ├── en │ │ ├── test.yml │ │ ├── test_context.json │ │ ├── test_context_plural.json │ │ ├── test_empty.json │ │ ├── test_fail_on_update.json │ │ ├── test_invalid.json │ │ ├── test_leak.json │ │ ├── test_log.json │ │ ├── test_merge.json │ │ ├── test_old.json │ │ ├── test_old_old.json │ │ ├── test_plural.json │ │ ├── test_reset.json │ │ ├── test_sort_sorted.json │ │ └── test_sort_unsorted.json │ └── fr │ │ ├── test_leak.json │ │ └── test_reset.json ├── parser.test.js └── templating │ ├── handlebars.hbs │ ├── html.html │ ├── javascript.js │ ├── keyPrefix-hook.jsx │ ├── multiple-translation-keys.tsx │ ├── namespace-hoc.jsx │ ├── namespace-hook.jsx │ ├── react.jsx │ └── typescript.tsx └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ], 10 | "plugins": [ 11 | "@babel/transform-runtime", 12 | "@babel/plugin-proposal-object-rest-spread" 13 | ], 14 | "sourceMaps": true, 15 | "retainLines": true 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | 5 | charset = utf-8 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [karellm] 4 | patreon: karelledru 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | target-branch: master 6 | schedule: 7 | interval: daily 8 | versioning-strategy: increase 9 | - package-ecosystem: github-actions 10 | directory: / 11 | target-branch: master 12 | schedule: 13 | interval: daily 14 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | - Is this a bug or a feature request? 2 | - What version are you running (`0.x` or `1.x`)? 3 | - Which runner (cli, gulp...)? 4 | 5 | ## What I'm trying to do? 6 | 7 | _Replace this with a description of what you are trying to do_ 8 | 9 | ## How am I doing it? 10 | 11 | _Replace this section with your config file or cli command_ 12 | 13 | Here is my config file: 14 | 15 | ``` 16 | { 17 | locales: ['en', 'de'], 18 | output: 'locales/$LOCALE/$NAMESPACE.json' 19 | } 20 | ``` 21 | 22 | The command I'm running: 23 | 24 | ``` 25 | i18next ... 26 | ``` 27 | 28 | ## What was the result? 29 | 30 | _Replace this with a description of the result you are getting_ 31 | 32 | ## What was I expecting? 33 | 34 | _Replace this with a description of the result you are expecting_ 35 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Why am I submitting this PR 2 | 3 | Blablabla 4 | 5 | ### Does it fix an existing ticket? 6 | 7 | Yes/No #000 8 | 9 | ### Checklist 10 | 11 | - [ ] only relevant code is changed (make a diff before you submit the PR) 12 | - [ ] do no modify the version in package.json or CHANGELOG.md 13 | - [ ] tests are included and pass: `yarn test` (see [details here](https://github.com/i18next/i18next-parser/blob/master/docs/development.md#tests)) 14 | - [ ] documentation is changed or added 15 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize, reopened, ready_for_review] 9 | branches: 10 | - '**' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [18.x, 20.x, 22.x] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | cache: yarn 24 | node-version: ${{ matrix.node-version }} 25 | - name: Install dependencies 26 | run: yarn install 27 | - name: Prettier 28 | run: yarn prettify 29 | - name: Tests 30 | run: | 31 | yarn test:cli 32 | yarn test 33 | - name: Build 34 | run: yarn build 35 | - name: Coverage 36 | run: yarn coverage 37 | - uses: codecov/codecov-action@v5 38 | with: 39 | name: node-${{ matrix.node-version }} 40 | fail_ci_if_error: false 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Folder view configuration files 2 | .DS_Store 3 | Desktop.ini 4 | 5 | # Thumbnail cache files 6 | ._* 7 | Thumbs.db 8 | 9 | # Files that might appear on external disks 10 | .Spotlight-V100 11 | .Trashes 12 | 13 | # mount files 14 | .apdisk 15 | .fseventsd 16 | 17 | # Editor files 18 | *.sublime-* 19 | *.swp 20 | /sftp-config.json 21 | /.vscode 22 | .history 23 | 24 | # node files 25 | node_modules/ 26 | npm-debug.log 27 | 28 | # project files 29 | .idea/ 30 | 31 | # manual test files 32 | test/manual/ 33 | 34 | # coverage 35 | .nyc_output 36 | coverage 37 | 38 | # build artifacts 39 | dist/ 40 | 41 | # test artifacts 42 | test/broccoli/src/locales 43 | test/gulp/locales 44 | test/cli/locales 45 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn lint-staged 2 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '*': 'prettier --write --ignore-unknown', 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | test/broccoli/src/handlebars.hbs 2 | test/broccoli/src/javascript.js 3 | test/locales/en/test_invalid.json 4 | test/templating/ 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "quoteProps": "as-needed", 6 | "jsxSingleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Mocha Tests", 11 | "cwd": "${workspaceRoot}", 12 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 13 | "args": [ 14 | "-u", "bdd", 15 | "--timeout", "999999", 16 | "--colors", 17 | "--require", "@babel/register", 18 | "--require", "@babel/polyfill", 19 | "--plugins", "@babel/plugin-transform-runtime", 20 | "${workspaceFolder}/test/**/*.test.js", 21 | "${workspaceFolder}/test/*.test.js" 22 | ], 23 | "runtimeArgs": [ 24 | "--nolazy" 25 | ], 26 | "sourceMaps": true, 27 | "internalConsoleOptions": "openOnSessionStart" 28 | }, 29 | { 30 | "type": "node", 31 | "request": "launch", 32 | "name": "Launch Program", 33 | "program": "${workspaceFolder}/src/helpers.js" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 9.3.0 4 | 5 | - Allow to use multiple translation functions with different namespaces and keyPrefixes #1083 #494 #973 #737 6 | 7 | # 9.2.0 8 | 9 | - Support nested property namespace functions in namespaceFunctions #1104 #1103 10 | - Update dependencies 11 | 12 | # 9.1.0 13 | 14 | - Add support for i18next 24 #1090 #1093 15 | - Add namespace information to duplicate warnings #1091 16 | - Update dependencies 17 | 18 | # 9.0.2 19 | 20 | - Fix cheerio dependency #1045 21 | - Update dependencies 22 | 23 | # 9.0.1 24 | 25 | - Fix plurals not being detected when count is a CallExpression #1022 #1015 26 | - Use utf-8 as default encoding to read files #992 27 | - Update dependencies 28 | 29 | # 9.0.0 30 | 31 | - Custom contextSeparator fix #1008 32 | - Remove VueLexer #1007 #617 33 | - Fix t func in options #994 #779 34 | - Support for Node 16 is dropped. Node 18, 20 and 22 are supported. 35 | 36 | # 8.13.0 37 | 38 | - Output the files in which a string is found in customValueTemplate #970 39 | - Fix babel dependency #976 #975 40 | - Update dependencies 41 | 42 | # 8.12.0 43 | 44 | - Fix --fail-on-update false negatives from new sort code #957 #955 45 | - Update dependencies 46 | 47 | # 8.11.0 48 | 49 | - Fix a bug where keepRemoved wasn't failing when the catalog get sorted #926 #932 50 | 51 | # 8.10.0 52 | 53 | - Fix a bug where keepRemoved option makes the failOnUpdate fails without any update #948 #666 54 | - Update dependencies 55 | 56 | # 8.9.0 57 | 58 | - Make tests work on Windows with non-English locale #931 59 | - Update dependencies 60 | 61 | # 8.8.0 62 | 63 | - Handle attribute spreads #909 #908 64 | - Fix index.d.ts #919 65 | - Suppress warning about non-literal child when key/defaults are specified #900 #899 66 | - Support custom namespaced functions and components #913 #912 67 | - Update dependencies 68 | 69 | # 8.7.0 70 | 71 | - Add missing componentFunctions properties to JsxLexerConfig types #891 #842 72 | - Fix unescape logic, expression props, and ICU format for Trans component #892 #886 73 | - Update dependencies 74 | 75 | # 8.6.0 76 | 77 | - Emit warning on non-literal content inside of Trans #881 78 | - Update dependencies 79 | 80 | # 8.5.0 81 | 82 | - Improve warnings #805 83 | - Remove unused dependencies #877 84 | - Update dependencies 85 | 86 | # 8.4.0 87 | 88 | - Fix extracting Trans component without key, but with default value #871 #249 89 | - FIX type error in index.d.ts #873 #868 90 | - Update dependencies 91 | 92 | # 8.3.0 93 | 94 | - Allow ignoring typecheck-helper functions around Trans tag variables #863 95 | - Skip extracting dynamic children in Trans components #862 96 | - Extract format parameter from Trans component interpolations #861 97 | - Update dependencies 98 | 99 | # 8.2.0 100 | 101 | - Add support for patterns in the `keepRemoved` option #693 #700 102 | - Improve namespace extraction from hook/HOC #854 103 | - Update dependencies 104 | 105 | # 8.1.0 106 | 107 | - Allow to disable plural handling #825 #463 108 | - Update dependencies 109 | 110 | # 8.0.0 111 | 112 | - Drop support for node 14 113 | - Add support for node 20 114 | 115 | # 7.9.0 116 | 117 | - Add support for string concatenation in default values #817 118 | - Update dependencies 119 | 120 | # 7.8.0 121 | 122 | - Add support for user-defined component functions #803 #804 123 | - Update dependencies 124 | 125 | # 7.7.0 126 | 127 | - Added missing types property to package.json #774 #775 128 | - Update dependencies 129 | 130 | # 7.6.0 131 | 132 | - Fix a bug: typeArguments may be undefined #764 #765 133 | - Update dependencies 134 | 135 | # 7.5.0 136 | 137 | - Add type declaration to the build #694 #695 138 | 139 | # 7.4.0 140 | 141 | - Fix esm import in cjs project #761 142 | - Update dependencies 143 | 144 | # 7.3.0 145 | 146 | - Parse namespace from t type arguments #703 #701 147 | - Extract namespace for render prop #702 #691 148 | - Updae dependencies 149 | 150 | # 7.2.0 151 | 152 | - Allow for .mjs as a config file extension #733 #708 153 | - Update dependencies 154 | 155 | # 7.1.0 156 | 157 | - Fix config loading on Windows #725 #698 158 | - Update dependencies 159 | 160 | # 7.0.3 161 | 162 | - Fix a bug when using the cli and passing no config file #690 163 | - Update dependencies 164 | 165 | # 7.0.0 166 | 167 | - BREAKING: change the API for `defaultValue`. Deprecate `skipDefaultValues` and `useKeysAsDefaultValues` options. #676 168 | - Add support for `shouldUnescape` option in jsx-lexer #678 169 | - Add support for .ts, .json and .yaml config #673 170 | - Update dependencies 171 | 172 | # 6.6.0 173 | 174 | - Support custom yaml output options #626 175 | - Do not fail on JSX spread attribute in Trans component #637 176 | - Support TypeScript typecasts #604 177 | - Add LICENSE.md 178 | - Update dependencies 179 | 180 | # 6.5.0 181 | 182 | - Fix: coverage testing #586 183 | - Fix: reset nested keys if default value is changed #582 184 | - Update dependencies 185 | 186 | # 6.4.0 187 | 188 | - Fix a bug that was resetting a namespace when given an empty key #502 189 | 190 | # 6.3.0 191 | 192 | - Support keyPrefix of useTranslation hook #485 #486 193 | 194 | # 6.2.0 195 | 196 | - Fix stats of files added, removed and unique keys #546 #498 #489 197 | 198 | # 6.1.0 199 | 200 | - Add a `namespaceFunction` option to the Javascrip Lexer #512 201 | 202 | # 6.0.1 203 | 204 | - BREAKING: Drop support for Node 12 205 | 206 | # 6.0.0 207 | 208 | - BREAKING: Drop support for Node 13, 15. Add support for Node 18. 209 | - BREAKING: This package is now pure ESM 210 | - Update dependencies 211 | 212 | # 5.4.0 213 | 214 | - Set colors dependency to 1.4.0 #503 215 | 216 | # 5.3.0 217 | 218 | - Add i18nextOptions option to generate v3 plurals #462 219 | - Update dependencies 220 | 221 | # 5.2.0 222 | 223 | - Add resetDefaultValueLocale option #451 224 | - Update dependencies 225 | 226 | # 5.1.0 227 | 228 | - Typescript: Parse type details #457 #454 229 | - Add fail-on-update option #471 230 | 231 | # 5.0.0 232 | 233 | - BREAKING: Format Json output conforming to i18next JSON v4 (see: https://www.i18next.com/misc/json-format) #423 234 | - BREAKING: `sort` option as a function has changed signature #437 235 | - Support sorting of plural keys in JSON v4 #437 236 | - Support regex token character for `pluralSeparator` option #437 237 | 238 | # 4.8.0 239 | 240 | - Add template literal support for defaultValue as a second argument #419 #420 241 | - Update dependencies 242 | 243 | # 4.7.0 244 | 245 | - CLI `silent` option is now fully silent #417 246 | - `sort` option can now take a function #418 247 | 248 | # 4.6.0 249 | 250 | - Add support for array argument for useTranslation #389 #305 251 | 252 | # 4.5.0 253 | 254 | - Escape non-printable Unicode characters #413 #361 255 | - Update dependencies 256 | 257 | # 4.4.0 258 | 259 | - Revert #361 #362 260 | - Update dependencies 261 | 262 | # 4.3.0 DO NOT USE THIS VERSION! 263 | 264 | - Extract tagged templates in js and jsx lexers #376 #381 265 | - Support unicode escape sequence in json #361 #362 266 | - Update dependencies 267 | 268 | # 4.2.0 269 | 270 | - Improve warning for missing defaults #332 271 | 272 | # 4.1.1 273 | 274 | - Improve support for spread operator in JS #199 275 | 276 | # 4.0.1 277 | 278 | - Drop support for Node 10 279 | - Update all dependencies 280 | - Fix an error that was causing empty namespace catalogs to be created as `""` instead of `{}` #273 281 | 282 | # 3.11.0 283 | 284 | - Add a pluralSeparator option #300 #302 285 | 286 | # 3.10.0 287 | 288 | - defaultValue, useKeysAsDefaultValue and skipDefaultValues options support function #224 #299 #301 289 | 290 | # 3.9.0 291 | 292 | - Update to babel 7 #298 293 | 294 | # 3.8.1 295 | 296 | - Fix cli that wasn't running #295 #296 297 | 298 | # 3.8.0 299 | 300 | - Update dependencies 301 | 302 | # 3.7.0 303 | 304 | - Improve handling of string literals #261 305 | 306 | # 3.6.0 307 | 308 | - Fix a conflict in jsx lexer #254 309 | 310 | # 3.5.0 311 | 312 | - Stop trying to parse directories #252 313 | 314 | # 3.4.0 315 | 316 | - Support multiline output in YAML #251 317 | - Fix bug with unicode escape sequences #227 318 | 319 | # 3.3.0 320 | 321 | - Fix customValueTemplate interpolation of ${key} #242 322 | - Extract options as third parameter when second parameter is default value string #243 #241 323 | 324 | # 3.2.0 325 | 326 | - Fix defaultValue for plural forms #240 #212 327 | 328 | # 3.1.0 329 | 330 | - Parse default value from 'defaults' prop in Trans #238 #231 #206 331 | - Fix mergeHashes keepRemoved option #237 332 | 333 | # 3.0.1 334 | 335 | - Add a `failOnWarnings` option and improve cli output #236 336 | 337 | # 3.0.0 338 | 339 | - `reactNamespace` option is gone #235 340 | 341 | # 2.2.0 342 | 343 | - Fix namespace parsing #233 #161 344 | 345 | # 2.1.3 346 | 347 | - Support unknow languages #230 348 | 349 | # 2.1.2 350 | 351 | - Support curly braces in jsx Trans elements #229 352 | 353 | # 2.1.1 354 | 355 | - Extract translation from comment in jsx #166 #223 356 | 357 | # 2.1.0 358 | 359 | - Support multiline literals #83 360 | - Parse comments in js #215 361 | 362 | # 2.0.0 363 | 364 | - Drop support for node 6 and 8 (EOL) #208 365 | 366 | # 1.0.7 367 | 368 | - Add support for `withTranslation` 369 | 370 | # 1.0.6 371 | 372 | - Add support for `customValueTemplate` #211 373 | - Add Prettier 374 | 375 | # 1.0.5 376 | 377 | - Add support for the `skipDefaultValues` option #216 378 | 379 | # 1.0.4 380 | 381 | - Revert support for node 6+ 382 | 383 | # 1.0.3 384 | 385 | - Add support for custom lexers #213 386 | - Fix CLI error obfuscation #193 387 | - Drop Node 8 support #208 388 | - Update dependencies 389 | 390 | ## 1.0.0-beta 391 | 392 | - The changelog for the beta can be found in the [releases](https://github.com/i18next/i18next-parser/releases) 393 | 394 | ## 0.13.0 395 | 396 | - Support `defaultValue` option along the translation key (#68) 397 | 398 | ## 0.12.0 399 | 400 | - Support `prefix`, `suffix` and `extension` option on the CLI (#60) 401 | 402 | ## 0.11.1 403 | 404 | - Add a new line at the end of file generated by the CLI (#54) 405 | 406 | ## 0.11.0 407 | 408 | - Update dependencies 409 | 410 | ## 0.10.1 411 | 412 | - Does not parse values from function that ends with a t (PR #52) 413 | 414 | ## 0.10.0 415 | 416 | - Add option to silence the variable errors (PR #47) 417 | - Support for passing a context via an object (PR #49) 418 | 419 | ## 0.9.0 420 | 421 | - Handle strings with newlines, tabs and backslashes in them (PR #42) 422 | 423 | ## 0.8.2 424 | 425 | - Fix the regex introduced in 0.8.1 that was throwing unexpected errors (for good) 426 | 427 | ## 0.8.1 428 | 429 | - Fix the regex introduced in 0.8.1 that was throwing unexpected errors 430 | 431 | ## 0.8.0 432 | 433 | - Throw an error when the translation function use a variable instead of a string 434 | 435 | ## 0.7.0 436 | 437 | - Add --attributes option (cli & gulp) 438 | 439 | ## 0.6.0 440 | 441 | - Add --keep-removed option (cli & gulp) 442 | - Allow writeOld to be disable (cli) 443 | 444 | ## 0.5.0 445 | 446 | - Add support for ES6 template strings: ` ` (closes #32) 447 | 448 | ## 0.4.0 449 | 450 | - Add prefix, suffix and extension options (closes #31) 451 | - Add writeOld option 452 | 453 | ## 0.3.8 454 | 455 | - Add support for multiline array in catalog (fix #26) 456 | 457 | ## 0.3.7 458 | 459 | - Fix the cli (fix #24) 460 | 461 | ## 0.3.6 462 | 463 | - Transfer repository to i18next organization (fix #15) 464 | 465 | ## 0.3.5 466 | 467 | - Fix the output path when using the cli (fix #22) 468 | 469 | ## 0.3.4 470 | 471 | - Handle escaped quotes in translation keys (fix #21) 472 | 473 | ## 0.3.3 474 | 475 | - Trailing separator in translation keys wasn't handled properly (fix #20) 476 | 477 | ## 0.3.2 478 | 479 | - The translation key should be the first truthy match of the regex (fix #18) 480 | 481 | ## 0.3.1 482 | 483 | - Improve support for `data-i18n` attributes in html 484 | 485 | ## 0.3.0 486 | 487 | - Add support for context 488 | 489 | ## 0.2.0 490 | 491 | - Add support for `data-i18n` attributes in html 492 | 493 | ## 0.1.xx 494 | 495 | - Improve parser (0.1.11) 496 | - [cli] namespace and key separator option (0.1.10) 497 | - Add namespace and key separator options (0.1.9) 498 | - Add support for plural keys (0.1.8) 499 | - Catch JSON parsing errors (0.1.8) 500 | - Improve the events emitted by the stream transform (0.1.7) 501 | - [cli] Make sure input and output directory exist (0.1.6) 502 | - [cli] Improve output (0.1.6) 503 | - Fix #1 (0.1.5) 504 | - Fix the regex to exclude functions that would end with `_t()` (0.1.4) 505 | 506 | ## 0.1.0 - Initial release 507 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # i18next Parser [![Build Status](https://travis-ci.org/i18next/i18next-parser.svg?branch=master)](https://travis-ci.org/i18next/i18next-parser) [![codecov](https://codecov.io/gh/i18next/i18next-parser/branch/master/graph/badge.svg?token=CJ74Vps41L)](https://codecov.io/gh/i18next/i18next-parser) 2 | 3 | [![NPM](https://nodei.co/npm/i18next-parser.png?downloads=true&stars=true)](https://www.npmjs.com/package/i18next-parser) 4 | 5 | When translating an application, maintaining the translation catalog by hand is painful. This package parses your code and automates this process. 6 | 7 | Finally, if you want to make this process even less painful, I invite you to check [Locize](https://locize.com/). They are a sponsor of this project. Actually, if you use this package and like it, supporting me on [Patreon](https://www.patreon.com/karelledru) would mean a great deal! 8 | 9 |

10 | 11 | Become a Patreon 12 | 13 |

14 | 15 | ## Features 16 | 17 | - Choose your weapon: A CLI, a standalone parser or a stream transform 18 | - 5 built in lexers: Javascript, JSX, HTML, Handlebars, and TypeScript+tsx 19 | - Handles VueJS 3.0 (.vue) files 20 | - Creates one catalog file per locale and per namespace 21 | - Backs up the old keys your code doesn't use anymore in `namespace_old.json` catalog 22 | - Restores keys from the `_old` file if the one in the translation file is empty 23 | - Parses comments for static keys to support dynamic key translations. 24 | - Supports i18next features: 25 | - **Context**: keys of the form `key_context` 26 | - **Plural**: keys of the form `key_zero`, `key_one`, `key_two`, `key_few`, `key_many` and `key_other` as described [here](https://www.i18next.com/translation-function/plurals) 27 | 28 | ## Versions 29 | 30 | You can find information about major releases on the [dedicated page](https://github.com/i18next/i18next-parser/releases). The [migration documentation](docs/migration.md) will help you figure out the breaking changes between versions. 31 | 32 | - `9.x` is tested on Node 18, 20 and 22. 33 | - `8.x` is tested on Node 16, 18 and 20. 34 | - `7.x` is tested on Node 14, 16 and 18. 35 | - `6.x` is tested on Node 14 and 16. 36 | 37 | ## Usage 38 | 39 | ### CLI 40 | 41 | You can use the CLI with the package installed locally but if you want to use it from anywhere, you better install it globally: 42 | 43 | ``` 44 | yarn global add i18next-parser 45 | npm install -g i18next-parser 46 | i18next 'app/**/*.{js,hbs}' 'lib/**/*.{js,hbs}' [-oc] 47 | ``` 48 | 49 | Multiple globbing patterns are supported to specify complex file selections. You can learn how to write globs [here](https://github.com/isaacs/node-glob). Note that glob must be wrapped with single quotes when passed as arguments. 50 | 51 | **IMPORTANT NOTE**: If you pass the globs as CLI argument, they must be relative to where you run the command (aka relative to `process.cwd()`). If you pass the globs via the `input` option of the config file, they must be relative to the config file. 52 | 53 | - **-c, --config **: Path to the config file (default: i18next-parser.config.{js,mjs,json,ts,yaml,yml}). 54 | - **-o, --output **: Path to the output directory (default: locales/$LOCALE/$NAMESPACE.json). 55 | - **-s, --silent**: Disable logging to stdout. 56 | - **--fail-on-warnings**: Exit with an exit code of 1 on warnings 57 | - **--fail-on-update**: Exit with an exit code of 1 when translations are updated (for CI purpose) 58 | 59 | ### Gulp 60 | 61 | Save the package to your devDependencies: 62 | 63 | ``` 64 | yarn add -D i18next-parser 65 | npm install --save-dev i18next-parser 66 | ``` 67 | 68 | [Gulp](http://gulpjs.com/) defines itself as the streaming build system. Put simply, it is like Grunt, but performant and elegant. 69 | 70 | ```javascript 71 | import { gulp as i18nextParser } from 'i18next-parser' 72 | 73 | gulp.task('i18next', function () { 74 | gulp 75 | .src('app/**') 76 | .pipe( 77 | new i18nextParser({ 78 | locales: ['en', 'de'], 79 | output: 'locales/$LOCALE/$NAMESPACE.json', 80 | }) 81 | ) 82 | .pipe(gulp.dest('./')) 83 | }) 84 | ``` 85 | 86 | **IMPORTANT**: `output` is required to know where to read the catalog from. You might think that `gulp.dest()` is enough though it does not inform the transform where to read the existing catalog from. 87 | 88 | ### Broccoli 89 | 90 | Save the package to your devDependencies: 91 | 92 | ``` 93 | yarn add -D i18next-parser 94 | npm install --save-dev i18next-parser 95 | ``` 96 | 97 | [Broccoli.js](https://github.com/broccolijs/broccoli) defines itself as a fast, reliable asset pipeline, supporting constant-time rebuilds and compact build definitions. 98 | 99 | ```javascript 100 | import Funnel from 'broccoli-funnel' 101 | import { broccoli as i18nextParser } from 'i18next-parser' 102 | 103 | const appRoot = 'broccoli' 104 | 105 | let i18n = new Funnel(appRoot, { 106 | files: ['handlebars.hbs', 'javascript.js'], 107 | annotation: 'i18next-parser', 108 | }) 109 | 110 | i18n = new i18nextParser([i18n], { 111 | output: 'broccoli/locales/$LOCALE/$NAMESPACE.json', 112 | }) 113 | 114 | export default i18n 115 | ``` 116 | 117 | > **Note**: You may need to configure Broccoli to place temporary files (option: tmpdir) within the current working 118 | > directory as I18next-parser does not traverse down beyond that. 119 | 120 | ## Options 121 | 122 | Using a config file gives you fine-grained control over how i18next-parser treats your files. Here's an example config showing all config options with their defaults. 123 | 124 | ```js 125 | // i18next-parser.config.js 126 | 127 | export default { 128 | contextSeparator: '_', 129 | // Key separator used in your translation keys 130 | 131 | createOldCatalogs: true, 132 | // Save the \_old files 133 | 134 | defaultNamespace: 'translation', 135 | // Default namespace used in your i18next config 136 | 137 | defaultValue: '', 138 | // Default value to give to keys with no value 139 | // You may also specify a function accepting the locale, namespace, key, and value as arguments 140 | 141 | indentation: 2, 142 | // Indentation of the catalog files 143 | 144 | keepRemoved: false, 145 | // Keep keys from the catalog that are no longer in code 146 | // You may either specify a boolean to keep or discard all removed keys. 147 | // You may also specify an array of patterns: the keys from the catalog that are no long in the code but match one of the patterns will be kept. 148 | // The patterns are applied to the full key including the namespace, the parent keys and the separators. 149 | 150 | keySeparator: '.', 151 | // Key separator used in your translation keys 152 | // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance. 153 | 154 | // see below for more details 155 | lexers: { 156 | hbs: ['HandlebarsLexer'], 157 | handlebars: ['HandlebarsLexer'], 158 | 159 | htm: ['HTMLLexer'], 160 | html: ['HTMLLexer'], 161 | 162 | mjs: ['JavascriptLexer'], 163 | js: ['JavascriptLexer'], // if you're writing jsx inside .js files, change this to JsxLexer 164 | ts: ['JavascriptLexer'], 165 | jsx: ['JsxLexer'], 166 | tsx: ['JsxLexer'], 167 | 168 | default: ['JavascriptLexer'], 169 | }, 170 | 171 | lineEnding: 'auto', 172 | // Control the line ending. See options at https://github.com/ryanve/eol 173 | 174 | locales: ['en', 'fr'], 175 | // An array of the locales in your applications 176 | 177 | namespaceSeparator: ':', 178 | // Namespace separator used in your translation keys 179 | // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance. 180 | 181 | output: 'locales/$LOCALE/$NAMESPACE.json', 182 | // Supports $LOCALE and $NAMESPACE injection 183 | // Supports JSON (.json) and YAML (.yml) file formats 184 | // Where to write the locale files relative to process.cwd() 185 | 186 | pluralSeparator: '_', 187 | // Plural separator used in your translation keys 188 | // If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys. 189 | // If you don't want to generate keys for plurals (for example, in case you are using ICU format), set `pluralSeparator: false`. 190 | 191 | input: undefined, 192 | // An array of globs that describe where to look for source files 193 | // relative to the location of the configuration file 194 | 195 | sort: false, 196 | // Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters) 197 | 198 | verbose: false, 199 | // Display info about the parsing including some stats 200 | 201 | failOnWarnings: false, 202 | // Exit with an exit code of 1 on warnings 203 | 204 | failOnUpdate: false, 205 | // Exit with an exit code of 1 when translations are updated (for CI purpose) 206 | 207 | customValueTemplate: null, 208 | // If you wish to customize the value output the value as an object, you can set your own format. 209 | // 210 | // - ${defaultValue} is the default value you set in your translation function. 211 | // - ${filePaths} will be expanded to an array that contains the absolute 212 | // file paths where the translations originated in, in case e.g., you need 213 | // to provide translators with context 214 | // 215 | // Any other custom property will be automatically extracted from the 2nd 216 | // argument of your `t()` function or tOptions in 217 | // 218 | // Example: 219 | // For `t('my-key', {maxLength: 150, defaultValue: 'Hello'})` in 220 | // /path/to/your/file.js, 221 | // 222 | // Using the following customValueTemplate: 223 | // 224 | // customValueTemplate: { 225 | // message: "${defaultValue}", 226 | // description: "${maxLength}", 227 | // paths: "${filePaths}", 228 | // } 229 | // 230 | // Will result in the following item being extracted: 231 | // 232 | // "my-key": { 233 | // "message": "Hello", 234 | // "description": 150, 235 | // "paths": ["/path/to/your/file.js"] 236 | // } 237 | 238 | resetDefaultValueLocale: null, 239 | // The locale to compare with default values to determine whether a default value has been changed. 240 | // If this is set and a default value differs from a translation in the specified locale, all entries 241 | // for that key across locales are reset to the default value, and existing translations are moved to 242 | // the `_old` file. 243 | 244 | i18nextOptions: null, 245 | // If you wish to customize options in internally used i18next instance, you can define an object with any 246 | // configuration property supported by i18next (https://www.i18next.com/overview/configuration-options). 247 | // { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals. 248 | 249 | yamlOptions: null, 250 | // If you wish to customize options for yaml output, you can define an object here. 251 | // Configuration options are here (https://github.com/nodeca/js-yaml#dump-object---options-). 252 | // Example: 253 | // { 254 | // lineWidth: -1, 255 | // } 256 | } 257 | ``` 258 | 259 | ### Lexers 260 | 261 | The `lexers` option let you configure which Lexer to use for which extension. Here is the default: 262 | 263 | Note the presence of a `default` which will catch any extension that is not listed. 264 | There are 4 lexers available: `HandlebarsLexer`, `HTMLLexer`, `JavascriptLexer` and 265 | `JsxLexer`. Each has configurations of its own. Typescript is supported via `JavascriptLexer` and `JsxLexer`. 266 | If you need to change the defaults, you can do it like so: 267 | 268 | #### Javascript 269 | 270 | The Javascript lexer uses [Typescript compiler](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API) to walk through your code and extract translation functions. 271 | 272 | The default configuration is below: 273 | 274 | ```js 275 | { 276 | // JavascriptLexer default config (js, mjs) 277 | js: [{ 278 | lexer: 'JavascriptLexer', 279 | functions: ['t'], // Array of functions to match 280 | namespaceFunctions: ['useTranslation', 'withTranslation'], // Array of functions to match for namespace 281 | }], 282 | } 283 | ``` 284 | 285 | #### Jsx 286 | 287 | The JSX lexer builds off of the Javascript lexer and extends it with support for JSX syntax. 288 | 289 | Default configuration: 290 | 291 | ```js 292 | { 293 | // JsxLexer default config (jsx) 294 | // JsxLexer can take all the options of the JavascriptLexer plus the following 295 | jsx: [{ 296 | lexer: 'JsxLexer', 297 | attr: 'i18nKey', // Attribute for the keys 298 | componentFunctions: ['Trans'], // Array of components to match 299 | }], 300 | } 301 | ``` 302 | 303 | If your JSX files have `.js` extension (e.g. create-react-app projects) you should override the default `js` lexer with `JsxLexer` to enable jsx parsing from js files: 304 | 305 | ```js 306 | { 307 | js: [{ 308 | lexer: 'JsxLexer' 309 | }], 310 | } 311 | ``` 312 | 313 | ##### Supporting helper functions in Trans tags 314 | 315 | If you're working with i18next in Typescript, you might be using a helper function to make sure that objects in components pass the typechecker: e.g., 316 | 317 | ```tsx 318 | const SomeComponent = (props) => ( 319 | 320 | Visit 321 | {castAsString({ name: props.name })}'s profile 322 | {/* Equivalent to, but resolves typechecker errors with */} 323 | {{ name: props.name }}'s profile 324 | 325 | ) 326 | 327 | function castAsString(record: Record): string { 328 | return record as unknown as string 329 | } 330 | ``` 331 | 332 | In order for the parser to extract variables properly, you can add a list of 333 | such functions to the lexer options: 334 | 335 | ``` 336 | { 337 | js: [{ 338 | lexer: 'JsxLexer', 339 | transIdentityFunctionsToIgnore: ['castAsString'] 340 | }], 341 | } 342 | ``` 343 | 344 | #### Ts(x) 345 | 346 | Typescript is supported via Javascript and Jsx lexers. If you are using Javascript syntax (e.g. with React), follow the steps in Jsx section, otherwise Javascript section. 347 | 348 | #### Handlebars 349 | 350 | ```js 351 | { 352 | // HandlebarsLexer default config (hbs, handlebars) 353 | handlebars: [ 354 | { 355 | lexer: 'HandlebarsLexer', 356 | functions: ['t'], // Array of functions to match 357 | }, 358 | ] 359 | } 360 | ``` 361 | 362 | #### Html 363 | 364 | ```js 365 | { 366 | // HTMLLexer default config (htm, html) 367 | html: [{ 368 | lexer: 'HTMLLexer', 369 | attr: 'data-i18n' // Attribute for the keys 370 | optionAttr: 'data-i18n-options' // Attribute for the options 371 | }] 372 | } 373 | ``` 374 | 375 | #### Custom lexers 376 | 377 | You can provide function instead of string as a custom lexer. 378 | 379 | ```js 380 | import CustomJsLexer from './CustomJsLexer'; 381 | 382 | // ... 383 | { 384 | js: [CustomJsLexer], 385 | jsx: [{ 386 | lexer: CustomJsLexer, 387 | customOption: true // Custom attribute passed to CustomJsLexer class constructor 388 | }] 389 | } 390 | // ... 391 | ``` 392 | 393 | ### Caveats 394 | 395 | While i18next extracts translation keys in runtime, i18next-parser doesn't run the code, so it can't interpolate values in these expressions: 396 | 397 | ``` 398 | t(key) 399 | t('key' + id) 400 | t(`key${id}`) 401 | ``` 402 | 403 | As a workaround you should specify possible static values in comments anywhere in your file: 404 | 405 | ``` 406 | // t('key_1') 407 | // t('key_2') 408 | t(key) 409 | 410 | /* 411 | t('key1') 412 | t('key2') 413 | */ 414 | t('key' + id) 415 | ``` 416 | 417 | ## Events 418 | 419 | The transform emits a `reading` event for each file it parses: 420 | 421 | `.pipe( i18next().on('reading', (file) => {}) )` 422 | 423 | The transform emits a `error:json` event if the JSON.parse on json files fail: 424 | 425 | `.pipe( i18next().on('error:json', (path, error) => {}) )` 426 | 427 | The transform emits a `warning` event if the file has a key that is not a string litteral or an option object with a spread operator: 428 | 429 | `.pipe( i18next().on('warning', (path, key) => {}) )` 430 | 431 | Here is a list of the warnings: 432 | 433 | - **Key is not a string literal**: the parser cannot parse variables, only literals. If your code contains something like `t(variable)`, the parser will throw a warning. 434 | - **Found same keys with different values**: if you use different default values for the same key, you'll get this error. For example, having `t('key', {defaultValue: 'foo'})` and `t('key', {defaultValue: 'bar'})`. The parser will select the latest one. 435 | - **Found translation key already mapped to a map or parent of new key already mapped to a string**: happens in this kind of situation: `t('parent', {defaultValue: 'foo'})` and `t('parent.child', {defaultValue: 'bar'})`. `parent` is both a translation and an object for `child`. 436 | 437 | ## Contribute 438 | 439 | Any contribution is welcome. Please [read the guidelines](docs/development.md) first. 440 | 441 | Thanks a lot to all the previous [contributors](https://github.com/i18next/i18next-parser/graphs/contributors). 442 | 443 | If you use this package and like it, supporting me on [Patreon](https://www.patreon.com/karelledru) is another great way to contribute! 444 | 445 |

446 | 447 | Become a Patreon 448 | 449 |

450 | 451 | --- 452 | 453 | ## Gold Sponsors 454 | 455 |

456 | 457 | 458 | 459 |

460 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { promises as fsp } from 'fs' 4 | import { program } from 'commander' 5 | import colors from 'colors' 6 | import path from 'path' 7 | import sort from 'gulp-sort' 8 | import vfs from 'vinyl-fs' 9 | import { lilconfig } from 'lilconfig' 10 | import { 11 | esConfigLoader, 12 | tsConfigLoader, 13 | yamlConfigLoader, 14 | } from '../dist/helpers.js' 15 | import i18nTransform from '../dist/transform.js' 16 | ;(async () => { 17 | const pkg = JSON.parse( 18 | await fsp.readFile(new URL('../package.json', import.meta.url), 'utf-8') 19 | ) 20 | 21 | program 22 | .version(pkg.version) 23 | .option( 24 | '-c, --config ', 25 | 'Path to the config file (default: i18next-parser.config.{js,mjs,json,ts,yaml,yml})' 26 | ) 27 | .option( 28 | '-o, --output ', 29 | 'Path to the output directory (default: locales/$LOCALE/$NAMESPACE.json)' 30 | ) 31 | .option('-s, --silent', 'Disable logging to stdout') 32 | .option('--fail-on-warnings', 'Exit with an exit code of 1 on warnings') 33 | .option( 34 | '--fail-on-update', 35 | 'Exit with an exit code of 1 when translations are updated (for CI purpose)' 36 | ) 37 | 38 | program.on('--help', function () { 39 | console.log(' Examples:') 40 | console.log('') 41 | console.log(' $ i18next "src/**/*.{js,jsx}"') 42 | console.log( 43 | ' $ i18next "/path/to/src/app.js" "/path/to/assets/index.html"' 44 | ) 45 | console.log( 46 | ' $ i18next --config i18next-parser.config.js --output locales/$LOCALE/$NAMESPACE.json' 47 | ) 48 | console.log('') 49 | }) 50 | 51 | program.parse(process.argv) 52 | 53 | let config = {} 54 | try { 55 | const lilconfigOptions = { 56 | searchPlaces: [ 57 | `${pkg.name}.config.js`, 58 | `${pkg.name}.config.mjs`, 59 | `${pkg.name}.config.json`, 60 | `${pkg.name}.config.ts`, 61 | `${pkg.name}.config.yaml`, 62 | `${pkg.name}.config.yml`, 63 | ], 64 | loaders: { 65 | '.js': esConfigLoader, 66 | '.mjs': esConfigLoader, 67 | '.ts': tsConfigLoader, 68 | '.yaml': yamlConfigLoader, 69 | '.yml': yamlConfigLoader, 70 | }, 71 | } 72 | let result 73 | if (program.opts().config) { 74 | result = await lilconfig(pkg.name, lilconfigOptions).load( 75 | program.opts().config 76 | ) 77 | } else { 78 | result = await lilconfig(pkg.name, lilconfigOptions).search() 79 | } 80 | 81 | if (result) { 82 | config = result.config 83 | } 84 | } catch (err) { 85 | if (err.code === 'MODULE_NOT_FOUND') { 86 | console.log( 87 | ' [error] '.red + 88 | 'Config file does not exist: ' + 89 | program.opts().config 90 | ) 91 | } else { 92 | throw err 93 | } 94 | } 95 | 96 | config.output = 97 | program.opts().output || config.output || 'locales/$LOCALE/$NAMESPACE.json' 98 | config.failOnWarnings = 99 | program.opts().failOnWarnings || config.failOnWarnings || false 100 | config.failOnUpdate = 101 | program.opts().failOnUpdate || config.failOnUpdate || false 102 | 103 | let args = program.args || [] 104 | let globs 105 | 106 | // prefer globs specified in the cli 107 | if (args.length) { 108 | globs = args.map(function (s) { 109 | s = s.trim() 110 | if (s.match(/(^'.*'$|^".*"$)/)) { 111 | s = s.slice(1, -1) 112 | } 113 | return s 114 | }) 115 | } 116 | 117 | // if config has an input parameter, try to use it 118 | else if (config.input) { 119 | if (!Array.isArray(config.input)) { 120 | if (typeof config.input === 'string') { 121 | config.input = [config.input] 122 | } else { 123 | console.log( 124 | ' [error] '.red + 125 | '`input` must be an array when specified in the config' 126 | ) 127 | program.help() 128 | program.exit(1) 129 | } 130 | } 131 | 132 | let basePath = process.cwd() 133 | 134 | if (program.opts().config) { 135 | basePath = path.dirname(path.resolve(program.opts().config)) 136 | } 137 | 138 | globs = config.input.map(function (s) { 139 | var negate = '' 140 | if (s.startsWith('!')) { 141 | negate = '!' 142 | s = s.substr(1) 143 | } 144 | return negate + path.resolve(basePath, s) 145 | }) 146 | } 147 | 148 | if (!globs || !globs.length) { 149 | console.log(' [error] '.red + 'missing argument: ') 150 | program.help() 151 | process.exit(0) 152 | } 153 | 154 | // Welcome message 155 | if (!program.opts().silent) { 156 | console.log() 157 | console.log(' i18next Parser'.cyan) 158 | console.log(' --------------'.cyan) 159 | console.log(' Input: '.cyan + args.join(', ')) 160 | console.log(' Output: '.cyan + config.output) 161 | console.log() 162 | } 163 | 164 | var count = 0 165 | 166 | vfs 167 | .src(globs) 168 | .pipe(sort()) 169 | .pipe( 170 | new i18nTransform(config) 171 | .on('reading', function (file) { 172 | if (!program.opts().silent) { 173 | console.log(' [read] '.green + file.path) 174 | } 175 | count++ 176 | }) 177 | .on('data', function (file) { 178 | if (!program.opts().silent) { 179 | console.log(' [write] '.green + file.path) 180 | } 181 | }) 182 | .on('error', function (message, region) { 183 | if (typeof region === 'string') { 184 | message += ': ' + region.trim() 185 | } 186 | console.log(' [error] '.red + message) 187 | }) 188 | .on('warning', function (message) { 189 | if (!program.opts().silent) { 190 | console.log(' [warning] '.yellow + message) 191 | } 192 | }) 193 | .on('finish', function () { 194 | if (!program.opts().silent) { 195 | console.log() 196 | console.log(' Stats: '.cyan + count + ' files were parsed') 197 | } 198 | }) 199 | ) 200 | .pipe(vfs.dest(process.cwd())) 201 | })() 202 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Any contribution is welcome. Just follow those guidelines: 4 | 5 | 1. If you are unsure, open a ticket before working on anything. 6 | 2. Fork and clone the project 7 | 3. Create a branch `git checkout -b feature/my-feature` (or `hotfix`). If you want to work on multiple bugs or improvements, do so in multiple branches and PRs. It almost always complicated things to mix unrelated changes. 8 | 4. Push the code to your fork 9 | 5. **Write tests and documentation. I won't merge a PR without it!** 10 | 6. Make a pull request from your new branch 11 | 7. Wait, I am usually pretty fast to merge PRs :) 12 | 13 | Thanks a lot to all the previous [contributors](https://github.com/i18next/i18next-parser/graphs/contributors). 14 | 15 | ## Setup 16 | 17 | ``` 18 | git clone git@github.com:/i18next-parser.git 19 | cd i18next-parser 20 | yarn 21 | ``` 22 | 23 | ## Development 24 | 25 | The code is written using the latest ES6 features. For the cli to run on older node version, it is compiled with Babel. You can run the compiler in watch mode and let it in the background: 26 | 27 | ``` 28 | yarn watch 29 | ``` 30 | 31 | Don't forget to commit the compiled files. 32 | 33 | ## Tests 34 | 35 | Make sure the tests pass: 36 | 37 | ``` 38 | yarn test 39 | ``` 40 | 41 | The CLI, the gulp plugin and the broccoli plugin are also tested but, as thoses tests are highly I/O dependent, you might encounter timeout issue depending on your configuration. You might want to raise the timeout allowed (search for `this.timeout(5000)`) 42 | 43 | To test the CLI specifically: 44 | 45 | ``` 46 | yarn test:cli 47 | yarn test:cli --fail-on-warnings 48 | yarn test:cli "test/cli/**/*.{js,jsx}" 49 | ``` 50 | 51 | ## Deploy 52 | 53 | - update `package.json` version 54 | - create commit and add version tag 55 | - `npm publish` 56 | 57 | ## `0.x` vs `1.x` 58 | 59 | `1.x` is a major release. It is not backward compatible. There are two separate branches: 60 | 61 | - `master` for `1.x` 62 | - `0.x.x` for the old version 63 | 64 | I will not maintain the old version but will welcome bug fixes as PRs. 65 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Changing the output directory 4 | 5 | This will create the files in the specified folder. 6 | 7 | ### Command line 8 | 9 | ``` 10 | $ i18next /path/to/file/or/dir -o /translations/$LOCALE/$NAMESPACE.json 11 | $ i18next /path/to/file/or/dir:/translations/$LOCALE/$NAMESPACE.json 12 | ``` 13 | 14 | ### Gulp 15 | 16 | When using Gulp, note that the files are not created until `dest()` is called. 17 | 18 | ```js 19 | .pipe(i18next({ output: 'translations/$LOCALE/$NAMESPACE.json' })) 20 | ``` 21 | 22 | ## Changing the locales 23 | 24 | This will create a directory for each locale in the output folder: 25 | 26 | ``` 27 | locales/en/... 28 | locales/de/... 29 | locales/sp/... 30 | ``` 31 | 32 | ### CLI 33 | 34 | Add the `locales` option to the config file: 35 | 36 | ```js 37 | { 38 | locales: ['en', 'de', 'sp'] 39 | } 40 | ``` 41 | 42 | ### Gulp 43 | 44 | ```js 45 | .pipe(i18next({ locales: ['en', 'de', 'sp'] }) 46 | ``` 47 | 48 | ## Changing the default namespace 49 | 50 | This will add all the translation from the default namespace in the file `locales/en/my_default_namespace.json` 51 | 52 | ### Command line 53 | 54 | Add the `namespace` option to the config file: 55 | 56 | ```js 57 | { 58 | namespace: 'my_default_namespace' 59 | } 60 | ``` 61 | 62 | ### Gulp 63 | 64 | ```js 65 | pipe(i18next({ namespace: 'my_default_namespace' })) 66 | ``` 67 | 68 | ## Changing namespace and key separators 69 | 70 | This will parse the translation keys as in the following: 71 | 72 | ``` 73 | namespace?key_subkey 74 | 75 | namespace.json 76 | { 77 | key: { 78 | subkey: '' 79 | } 80 | } 81 | ... 82 | ``` 83 | 84 | ### Command line 85 | 86 | Add the `namespaceSeparator` or `keySeparator` option to the config file: 87 | 88 | ```js 89 | { 90 | namespaceSeparator: '?', 91 | keySeparator: '_' 92 | } 93 | ``` 94 | 95 | ### Gulp 96 | 97 | ```js 98 | .pipe(i18next({namespaceSeparator: '?', keySeparator: '_'})) 99 | ``` 100 | 101 | ## Changing the translation functions 102 | 103 | This will parse any of the following function calls in your code and extract the key: 104 | 105 | ``` 106 | __('key' 107 | __ 'key' 108 | __("key" 109 | __ "key" 110 | _e('key' 111 | _e 'key' 112 | _e("key" 113 | _e "key" 114 | ``` 115 | 116 | Add the `function` property to the related lexer in the config file: 117 | 118 | ```js 119 | { 120 | lexers: { 121 | js: [ 122 | { 123 | lexer: 'JavascriptLexer', 124 | functions: ['t', 'TAPi18n.__', '__'], 125 | }, 126 | ] 127 | } 128 | } 129 | ``` 130 | 131 | Add the `parseGenerics` (as well as `typeMap`) if you want to parse the data from the generic types in typescript 132 | 133 | ```js 134 | { 135 | lexers: { 136 | js: [ 137 | { 138 | lexer: 'JavascriptLexer', 139 | parseGenerics: true, 140 | typeMap: { CountType: { count: '' } }, 141 | }, 142 | ] 143 | } 144 | } 145 | ``` 146 | 147 | So that the parser can detect typescript code like : 148 | 149 | ```ts 150 | const MyKey T<{count: number}>('my_key'); 151 | 152 | type CountType = {count : number}; 153 | const MyOtherKey = T('my_other_key'); 154 | 155 | i18next.t(MyKey, {count: 1}); 156 | i18next.t(MyOtherKey, {count: 2}); 157 | ``` 158 | 159 | and generate the correct keys 160 | 161 | ```js 162 | { 163 | lexers: { 164 | js: [ 165 | { 166 | lexer: 'JavascriptLexer', 167 | functions: ['t', 'TAPi18n.__', '__'], 168 | }, 169 | ] 170 | } 171 | } 172 | ``` 173 | 174 | Please note that: 175 | 176 | - We don't match the closing parenthesis, as you might want to pass arguments to your translation function; 177 | - The parser is smart about escaped quotes (single or double) you may have in your key. 178 | 179 | ## Work with Meteor TAP-i18N (gulp) 180 | 181 | ```js 182 | .pipe(i18next({ 183 | output: "i18n/$LOCALE/$NAMESPACE.$LOCALE.i18n.json", 184 | locales: ['en', 'de', 'fr', 'es'], 185 | functions: ['_'], 186 | namespace: 'client', 187 | writeOld: false 188 | })) 189 | ``` 190 | 191 | This will output your files in the format `$LOCALE/client.$LOCALE.i18n.json` in a `i18n/` directory. 192 | -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | # Migrating from `8.x` to `9.x` 2 | 3 | ## Breaking changes 4 | 5 | - Support for the `VueLexer` has been dropped as it creates compatibility problems when trying to parse Vue3 code bases. You must remove any references to `VueLexer` in your configuration files. For Vue3, you can simply use `JavascriptLexer` instead. 6 | - Support for Node 16 is dropped. Node 18, 20 and 22 are supported. 7 | 8 | # Migrating from `7.x` to `8.x` 9 | 10 | ## Breaking changes 11 | 12 | - Support for Node 14 is dropped. Node 16, 18 and 20 are supported. 13 | 14 | # Migrating from `6.x` to `7.x` 15 | 16 | ## Breaking changes 17 | 18 | - The API to manage what default value is being used was getting too complicated. This version simplifies the API by deprecating the `skipDefaultValues` and `useKeysAsDefaultValues` options and adding a `value` argument when the `defaultValue` option is used as a function. Here are couple example of how it can be used: 19 | 20 | To replace `skipDefaultValue`, make the following change: 21 | 22 | ```js 23 | // 5.x.x 24 | { 25 | skipDefaultValues: true 26 | } 27 | 28 | // 6.x.x 29 | { 30 | defaultValue: function (locale, namespace, key, value) { 31 | return ''; 32 | } 33 | } 34 | ``` 35 | 36 | To replace `useKeysAsDefaultValues`, make the following change: 37 | 38 | ```js 39 | // 5.x.x 40 | { 41 | useKeysAsDefaultValues: true 42 | } 43 | 44 | // 6.x.x 45 | { 46 | defaultValue: function (locale, namespace, key, value) { 47 | return key; 48 | } 49 | } 50 | ``` 51 | 52 | And now you have complete control over the logic: 53 | 54 | ```js 55 | // 6.x.x 56 | { 57 | defaultValue: function (locale, namespace, key, value) { 58 | if (locale === 'fr') { 59 | return ''; 60 | } 61 | return value || key; 62 | } 63 | } 64 | ``` 65 | 66 | # Migrating from `5.x` to `6.x` 67 | 68 | ## Breaking changes 69 | 70 | - We dropped support for node versions that are not LTS. Only even numbered versions will be supported going forward. Support is for Node 14+ 71 | - This project is now a pure ESM project. You can read about it [here](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) to help you transition your project. 72 | 73 | # Migrating from `4.x` to `5.x` 74 | 75 | ## Breaking change 76 | 77 | - The output format is now in [JSON v4](https://www.i18next.com/misc/json-format). To convert your existing translations to the new v4 format, have a look at [i18next-v4-format-converter](https://github.com/i18next/i18next-v4-format-converter) or [this web tool](https://i18next.github.io/i18next-v4-format-converter-web/). 78 | 79 | --- 80 | 81 | # Migrating from `2.x` to `3.x` 82 | 83 | ## Breaking change 84 | 85 | - `reactNamespace` option is gone. To use jsx in js file, [overwrite the lexer](https://github.com/i18next/i18next-parser#jsx). 86 | 87 | --- 88 | 89 | # Migrating from `1.x` to `2.x` 90 | 91 | ## Breaking change 92 | 93 | - Drop support for Node 6 and 8 (EOL) 94 | 95 | --- 96 | 97 | # Migrating from `0.x` to `1.x` 98 | 99 | ## Breaking changes 100 | 101 | - Jade is not being tested anymore. If this is something you need, please make a PR with a Lexer for it 102 | - `regex` option was deprecated. If you need to support a custom file format, please make a PR with a Lexer for it 103 | - `ignoreVariables` was deprecated. Keys that are not string litterals now emit a warning 104 | - `writeOld` was renamed `createOldLibraries`. It defaults to `true`. 105 | - `namespace` was renamed `defaultNamespace`. It defaults to `translation`. 106 | - `prefix` was deprecated. Use `output` 107 | - `suffix` was deprecated. Use `output` 108 | - `filename` was deprecated. Use `output` 109 | - `extension` was deprecated. Use `output` 110 | - catalogs are no longer sorted by default. Set `sort` to `true` to enable this. 111 | 112 | ## Improvements 113 | 114 | - `defaultValue`: replace empty keys with the given value 115 | - `output` support for `$NAMESPACE` and `$LOCALE` variables 116 | - `indentation` let you control the indentation of the catalogs 117 | - `lineEnding` let you control the line ending of the catalogs 118 | - `sort` let you enable sorting. 119 | 120 | ## Lexers 121 | 122 | Instead of writing a single regex to match all use cases or to run many regexes on all files, the new version introduce the concept of "Lexer". Each file format has its own Lexer. It adds some code but reduces complexity a lot and improves maintainability. 123 | 124 | ## CLI 125 | 126 | - `i18next input:output` syntax was deprecated. Use the `--output` option 127 | - `recursive` was deprecated. You can now pass a glob 128 | - `directoryFilter` was deprecated. You can now pass a glob 129 | - `fileFilter` was deprecated. You can now pass a glob 130 | 131 | ### `0.x` 132 | 133 | `i18next src --recursive --fileFilter '*.hbs,*.js' --directoryFilter '!.git'` 134 | 135 | ### `1.x` 136 | 137 | `i18next 'src/**/*.{js,hbs}' '!.git'` 138 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | 3 | export type SupportedLexer = 4 | | 'HandlebarsLexer' 5 | | 'HTMLLexer' 6 | | 'JavascriptLexer' 7 | | 'JsxLexer' 8 | 9 | // BaseLexer is not importable therefore this is the best if done simple 10 | export class CustomLexerClass extends EventEmitter {} 11 | export type CustomLexer = typeof CustomLexerClass 12 | 13 | export interface CustomLexerConfig extends Record { 14 | lexer: CustomLexer 15 | } 16 | 17 | export interface HandlebarsLexerConfig { 18 | lexer: 'HandlebarsLexer' 19 | functions?: string[] 20 | } 21 | 22 | export interface HTMLLexerConfig { 23 | lexer: 'HTMLLexer' 24 | functions?: string[] 25 | attr?: string 26 | optionAttr?: string 27 | } 28 | 29 | export interface JavascriptLexerConfig { 30 | lexer: 'JavascriptLexer' 31 | functions?: string[] 32 | namespaceFunctions?: string[] 33 | attr?: string 34 | parseGenerics?: false 35 | typeMap?: Record 36 | } 37 | 38 | export interface JavascriptWithTypesLexerConfig { 39 | lexer: 'JavascriptLexer' 40 | functions?: string[] 41 | namespaceFunctions?: string[] 42 | attr?: string 43 | parseGenerics: true 44 | typeMap: Record 45 | } 46 | 47 | export interface JsxLexerConfig { 48 | lexer: 'JsxLexer' 49 | functions?: string[] 50 | namespaceFunctions?: string[] 51 | componentFunctions?: string[] 52 | attr?: string 53 | transSupportBasicHtmlNodes?: boolean 54 | transKeepBasicHtmlNodesFor?: string[] 55 | parseGenerics?: false 56 | typeMap?: Record 57 | } 58 | 59 | export interface JsxWithTypesLexerConfig { 60 | lexer: 'JsxLexer' 61 | functions?: string[] 62 | namespaceFunctions?: string[] 63 | componentFunctions?: string[] 64 | attr?: string 65 | transSupportBasicHtmlNodes?: boolean 66 | transKeepBasicHtmlNodesFor?: string[] 67 | parseGenerics: true 68 | typeMap: Record 69 | /** 70 | * Identity functions within trans that should be parsed for their 71 | * first arguments. Used for making typecheckers happy in a safe way: e.g., if in your code, you use: 72 | * 73 | * ``` 74 | * Hello {castToString({ name })} 75 | * ``` 76 | * 77 | * you'd want to pass in `transIdentityFunctionsToIgnore: ['castToString']` 78 | */ 79 | transIdentityFunctionsToIgnore?: string[] 80 | } 81 | 82 | export type LexerConfig = 83 | | HandlebarsLexerConfig 84 | | HTMLLexerConfig 85 | | JavascriptLexerConfig 86 | | JavascriptWithTypesLexerConfig 87 | | JsxLexerConfig 88 | | JsxWithTypesLexerConfig 89 | | CustomLexerConfig 90 | 91 | export interface UserConfig { 92 | contextSeparator?: string 93 | createOldCatalogs?: boolean 94 | defaultNamespace?: string 95 | defaultValue?: 96 | | string 97 | | (( 98 | locale?: string, 99 | namespace?: string, 100 | key?: string, 101 | value?: string 102 | ) => string) 103 | indentation?: number 104 | keepRemoved?: boolean | readonly RegExp[] 105 | keySeparator?: string | false 106 | lexers?: { 107 | hbs?: (SupportedLexer | CustomLexer | LexerConfig)[] 108 | handlebars?: (SupportedLexer | CustomLexer | LexerConfig)[] 109 | htm?: (SupportedLexer | CustomLexer | LexerConfig)[] 110 | html?: (SupportedLexer | CustomLexer | LexerConfig)[] 111 | mjs?: (SupportedLexer | CustomLexer | LexerConfig)[] 112 | js?: (SupportedLexer | CustomLexer | LexerConfig)[] 113 | ts?: (SupportedLexer | CustomLexer | LexerConfig)[] 114 | jsx?: (SupportedLexer | CustomLexer | LexerConfig)[] 115 | tsx?: (SupportedLexer | CustomLexer | LexerConfig)[] 116 | default?: (SupportedLexer | CustomLexer | LexerConfig)[] 117 | } 118 | lineEnding?: 'auto' | 'crlf' | '\r\n' | 'cr' | '\r' | 'lf' | '\n' 119 | locales?: string[] 120 | namespaceSeparator?: string | false 121 | output?: string 122 | pluralSeparator?: string 123 | input?: string | string[] 124 | sort?: boolean | ((a: string, b: string) => -1 | 0 | 1) 125 | verbose?: boolean 126 | failOnWarnings?: boolean 127 | failOnUpdate?: boolean 128 | customValueTemplate?: Record | null 129 | resetDefaultValueLocale?: string | null 130 | i18nextOptions?: Record | null 131 | yamlOptions?: Record | null 132 | } 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "i18next-parser", 3 | "description": "Command Line tool for i18next", 4 | "version": "9.3.0", 5 | "type": "module", 6 | "license": "MIT", 7 | "author": "Karel Ledru", 8 | "exports": "./dist/index.js", 9 | "types": "./dist/index.d.ts", 10 | "bin": { 11 | "i18next": "./bin/cli.js" 12 | }, 13 | "engines": { 14 | "node": "^18.0.0 || ^20.0.0 || ^22.0.0", 15 | "npm": ">=6", 16 | "yarn": ">=1" 17 | }, 18 | "scripts": { 19 | "coverage": "c8 --all --include='src/**/*[.js|.jsx|.ts|.tsx]' --reporter=lcov --reporter=text yarn test", 20 | "test": "mocha -r @babel/register -r @babel/polyfill --recursive test/*.test.js test/**/*.test.js", 21 | "test:cli": "yarn -s build && node ./bin/cli.js '**/*.html' -o test/manual/$LOCALE/$NAMESPACE.json && node ./bin/cli.js -c test/cli/i18next-parser.config.js && node ./bin/cli.js -c test/cli/i18next-parser.config.mjs && node ./bin/cli.js -c test/cli/i18next-parser.config.ts && node ./bin/cli.js -c test/cli/i18next-parser.config.yaml", 22 | "test:watch": "mocha -r @babel/register -r @babel/polyfill --watch --parallel --recursive", 23 | "watch": "babel src -d dist -w", 24 | "prettify": "prettier --write --list-different .", 25 | "build": "babel src -d dist && cp index.d.ts dist", 26 | "prepare": "husky", 27 | "prepublishOnly": "yarn -s prettify && yarn -s build" 28 | }, 29 | "dependencies": { 30 | "@babel/runtime": "^7.25.0", 31 | "broccoli-plugin": "^4.0.7", 32 | "cheerio": "^1.0.0", 33 | "colors": "^1.4.0", 34 | "commander": "^12.1.0", 35 | "eol": "^0.9.1", 36 | "esbuild": "^0.25.0", 37 | "fs-extra": "^11.2.0", 38 | "gulp-sort": "^2.0.0", 39 | "i18next": "^23.5.1 || ^24.2.0", 40 | "js-yaml": "^4.1.0", 41 | "lilconfig": "^3.1.3", 42 | "rsvp": "^4.8.5", 43 | "sort-keys": "^5.0.0", 44 | "typescript": "^5.0.4", 45 | "vinyl": "^3.0.0", 46 | "vinyl-fs": "^4.0.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "^7.24.8", 50 | "@babel/core": "^7.26.8", 51 | "@babel/plugin-proposal-object-rest-spread": "^7.20.7", 52 | "@babel/plugin-transform-runtime": "^7.24.7", 53 | "@babel/polyfill": "^7.12.1", 54 | "@babel/preset-env": "^7.26.0", 55 | "@babel/register": "^7.24.6", 56 | "broccoli": "^3.5.2", 57 | "broccoli-cli": "^1.0.0", 58 | "broccoli-funnel": "^3.0.8", 59 | "c8": "^10.1.2", 60 | "chai": "^5.1.1", 61 | "execa": "^9.5.2", 62 | "gulp": "^5.0.0", 63 | "husky": "^9.1.6", 64 | "lint-staged": "^15.2.9", 65 | "mocha": "^11.1.0", 66 | "p-event": "^6.0.1", 67 | "prettier": "^3.3.3", 68 | "sinon": "^19.0.2" 69 | }, 70 | "repository": { 71 | "type": "git", 72 | "url": "https://github.com/i18next/i18next-parser" 73 | }, 74 | "files": [ 75 | "bin/", 76 | "dist/", 77 | "i18next-parser.config.js" 78 | ], 79 | "keywords": [ 80 | "gulpplugin", 81 | "i18next", 82 | "parser", 83 | "commandline" 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /src/broccoli.js: -------------------------------------------------------------------------------- 1 | import colors from 'colors' 2 | import fse from 'fs-extra' 3 | import Plugin from 'broccoli-plugin' 4 | import rsvp from 'rsvp' 5 | import sort from 'gulp-sort' 6 | import vfs from 'vinyl-fs' 7 | 8 | import i18nTransform from './transform.js' 9 | 10 | const Promise = rsvp.Promise 11 | 12 | export default class i18nextParser extends Plugin { 13 | constructor(inputNodes, options = {}) { 14 | super(...arguments) 15 | this.options = options 16 | } 17 | 18 | build() { 19 | const outputPath = this.outputPath 20 | return new Promise((resolve, reject) => { 21 | const files = [] 22 | let count = 0 23 | 24 | vfs 25 | .src(this.inputPaths.map((x) => x + '/**/*.{js,hbs}')) 26 | .pipe(sort()) 27 | .pipe( 28 | new i18nTransform(this.options) 29 | .on('reading', function (file) { 30 | if (!this.options.silent) { 31 | console.log(' [read] '.green + file.path) 32 | } 33 | count++ 34 | }) 35 | .on('data', function (file) { 36 | files.push(fse.outputFile(file.path, file.contents)) 37 | if (!this.options.silent) { 38 | console.log(' [write] '.green + file.path) 39 | } 40 | }) 41 | .on('error', function (message, region) { 42 | if (typeof region === 'string') { 43 | message += ': ' + region.trim() 44 | } 45 | console.log(' [error] '.red + message) 46 | }) 47 | .on('finish', function () { 48 | if (!this.options.silent) { 49 | console.log() 50 | console.log( 51 | ' Stats: '.yellow + String(count) + ' files were parsed' 52 | ) 53 | } 54 | 55 | Promise.all(files).then(() => { 56 | resolve(files) 57 | }) 58 | }) 59 | ) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from 'url' 2 | import { build } from 'esbuild' 3 | import { rmSync } from 'fs' 4 | import yaml from 'js-yaml' 5 | import { builtinModules } from 'module' 6 | 7 | /** 8 | * Take an entry for the Parser and turn it into a hash, 9 | * turning the key path 'foo.bar' into an hash {foo: {bar: ""}} 10 | * The generated hash can be merged with an optional `target`. 11 | * @returns An `{ target, duplicate, conflict }` object. `target` is the hash 12 | * that was passed as an argument or a new hash if none was passed. `duplicate` 13 | * indicates whether the entry already existed in the `target` hash. `conflict` 14 | * is `"key"` if a parent of the key was already mapped to a string (e.g. when 15 | * merging entry {one: {two: "bla"}} with target {one: "bla"}) or the key was 16 | * already mapped to a map (e.g. when merging entry {one: "bla"} with target 17 | * {one: {two: "bla"}}), `"value"` if the same key already exists with a 18 | * different value, or `false`. 19 | */ 20 | function dotPathToHash(entry, target = {}, options = {}) { 21 | let conflict = false 22 | let duplicate = false 23 | let path = entry.keyWithNamespace 24 | if (options.suffix) { 25 | path += options.suffix 26 | } 27 | 28 | const separator = options.separator || '.' 29 | 30 | const key = entry.keyWithNamespace.substring( 31 | entry.keyWithNamespace.indexOf(separator) + separator.length, 32 | entry.keyWithNamespace.length 33 | ) 34 | 35 | // There is no key to process so we return an empty object 36 | if (!key) { 37 | if (!target[entry.namespace]) { 38 | target[entry.namespace] = {} 39 | } 40 | return { target, duplicate, conflict } 41 | } 42 | 43 | const defaultValue = 44 | entry[`defaultValue${options.suffix}`] || entry.defaultValue || '' 45 | 46 | let newValue = 47 | typeof options.value === 'function' 48 | ? options.value(options.locale, entry.namespace, key, defaultValue) 49 | : options.value || defaultValue 50 | 51 | if (path.endsWith(separator)) { 52 | path = path.slice(0, -separator.length) 53 | } 54 | 55 | const segments = path.split(separator) 56 | let inner = target 57 | for (let i = 0; i < segments.length - 1; i += 1) { 58 | const segment = segments[i] 59 | if (segment) { 60 | if (typeof inner[segment] === 'string') { 61 | conflict = 'key' 62 | } 63 | if (inner[segment] === undefined || conflict) { 64 | inner[segment] = {} 65 | } 66 | inner = inner[segment] 67 | } 68 | } 69 | 70 | const lastSegment = segments[segments.length - 1] 71 | const oldValue = inner[lastSegment] 72 | if (oldValue !== undefined && oldValue !== newValue) { 73 | if (typeof oldValue !== typeof newValue) { 74 | conflict = 'key' 75 | } else if (oldValue !== '') { 76 | if (newValue === '') { 77 | newValue = oldValue 78 | } else { 79 | conflict = 'value' 80 | } 81 | } 82 | } 83 | duplicate = oldValue !== undefined || conflict !== false 84 | 85 | if (options.customValueTemplate) { 86 | inner[lastSegment] = {} 87 | 88 | const entries = Object.entries(options.customValueTemplate) 89 | entries.forEach((valueEntry) => { 90 | if (valueEntry[1] === '${defaultValue}') { 91 | inner[lastSegment][valueEntry[0]] = newValue 92 | } else if (valueEntry[1] === '${filePaths}') { 93 | inner[lastSegment][valueEntry[0]] = entry.filePaths 94 | } else { 95 | inner[lastSegment][valueEntry[0]] = 96 | entry[valueEntry[1].replace(/\${(\w+)}/, '$1')] || '' 97 | } 98 | }) 99 | } else { 100 | inner[lastSegment] = newValue 101 | } 102 | 103 | return { target, duplicate, conflict } 104 | } 105 | 106 | /** 107 | * Takes a `source` hash and makes sure its value 108 | * is pasted in the `target` hash, if the target 109 | * hash has the corresponding key (or if `options.keepRemoved` is true). 110 | * @returns An `{ old, new, mergeCount, pullCount, oldCount, reset, resetCount }` object. 111 | * `old` is a hash of values that have not been merged into `target`. 112 | * `new` is `target`. `mergeCount` is the number of keys merged into 113 | * `new`, `pullCount` is the number of context and plural keys added to 114 | * `new` and `oldCount` is the number of keys that were either added to `old` or 115 | * `new` (if `options.keepRemoved` is true and `target` didn't have the corresponding 116 | * key) and `reset` is the keys that were reset due to not matching default values, 117 | * and `resetCount` which is the number of keys reset. 118 | */ 119 | function mergeHashes(source, target, options = {}, resetValues = {}) { 120 | let old = {} 121 | let reset = {} 122 | let mergeCount = 0 123 | let pullCount = 0 124 | let oldCount = 0 125 | let resetCount = 0 126 | 127 | const keepRemoved = 128 | (typeof options.keepRemoved === 'boolean' && options.keepRemoved) || false 129 | const keepRemovedPatterns = 130 | (typeof options.keepRemoved !== 'boolean' && options.keepRemoved) || [] 131 | const fullKeyPrefix = options.fullKeyPrefix || '' 132 | const keySeparator = options.keySeparator || '.' 133 | const pluralSeparator = options.pluralSeparator || '_' 134 | const contextSeparator = options.contextSeparator || '_' 135 | 136 | for (const key in source) { 137 | const hasNestedEntries = 138 | typeof target[key] === 'object' && !Array.isArray(target[key]) 139 | 140 | if (hasNestedEntries) { 141 | const nested = mergeHashes( 142 | source[key], 143 | target[key], 144 | { ...options, fullKeyPrefix: fullKeyPrefix + key + keySeparator }, 145 | resetValues[key] 146 | ) 147 | mergeCount += nested.mergeCount 148 | pullCount += nested.pullCount 149 | oldCount += nested.oldCount 150 | resetCount += nested.resetCount 151 | if (Object.keys(nested.old).length) { 152 | old[key] = nested.old 153 | } 154 | if (Object.keys(nested.reset).length) { 155 | reset[key] = nested.reset 156 | } 157 | } else if (target[key] !== undefined) { 158 | if (typeof source[key] !== 'string' && !Array.isArray(source[key])) { 159 | old[key] = source[key] 160 | oldCount += 1 161 | } else { 162 | if ( 163 | (options.resetAndFlag && 164 | !isPlural(key) && 165 | typeof source[key] === 'string' && 166 | source[key] !== target[key]) || 167 | resetValues[key] 168 | ) { 169 | old[key] = source[key] 170 | oldCount += 1 171 | reset[key] = true 172 | resetCount += 1 173 | } else { 174 | target[key] = source[key] 175 | mergeCount += 1 176 | } 177 | } 178 | } else { 179 | // support for plural in keys 180 | const singularKey = getSingularForm(key, pluralSeparator) 181 | const pluralMatch = key !== singularKey 182 | 183 | // support for context in keys 184 | const contextRegex = new RegExp( 185 | `\\${contextSeparator}([^\\${contextSeparator}]+)?$` 186 | ) 187 | const contextMatch = contextRegex.test(singularKey) 188 | const rawKey = singularKey.replace(contextRegex, '') 189 | 190 | if ( 191 | (contextMatch && target[rawKey] !== undefined) || 192 | (pluralMatch && 193 | hasRelatedPluralKey(`${singularKey}${pluralSeparator}`, target)) 194 | ) { 195 | target[key] = source[key] 196 | pullCount += 1 197 | } else { 198 | const keepKey = 199 | keepRemoved || 200 | keepRemovedPatterns.some((pattern) => 201 | pattern.test(fullKeyPrefix + key) 202 | ) 203 | if (keepKey) { 204 | target[key] = source[key] 205 | } else { 206 | old[key] = source[key] 207 | } 208 | oldCount += 1 209 | } 210 | } 211 | } 212 | 213 | return { 214 | old, 215 | new: target, 216 | mergeCount, 217 | pullCount, 218 | oldCount, 219 | reset, 220 | resetCount, 221 | } 222 | } 223 | 224 | /** 225 | * Merge `source` into `target` by merging nested dictionaries. 226 | */ 227 | function transferValues(source, target) { 228 | for (const key in source) { 229 | const sourceValue = source[key] 230 | const targetValue = target[key] 231 | if ( 232 | typeof sourceValue === 'object' && 233 | typeof targetValue === 'object' && 234 | !Array.isArray(sourceValue) 235 | ) { 236 | transferValues(sourceValue, targetValue) 237 | } else { 238 | target[key] = sourceValue 239 | } 240 | } 241 | } 242 | 243 | const pluralSuffixes = ['zero', 'one', 'two', 'few', 'many', 'other'] 244 | 245 | function isPlural(key) { 246 | return pluralSuffixes.some((suffix) => key.endsWith(suffix)) 247 | } 248 | 249 | function hasRelatedPluralKey(rawKey, source) { 250 | return pluralSuffixes.some( 251 | (suffix) => source[`${rawKey}${suffix}`] !== undefined 252 | ) 253 | } 254 | 255 | function getSingularForm(key, pluralSeparator) { 256 | const pluralRegex = new RegExp( 257 | `(\\${pluralSeparator}(?:zero|one|two|few|many|other))$` 258 | ) 259 | 260 | return key.replace(pluralRegex, '') 261 | } 262 | 263 | function getPluralSuffixPosition(key) { 264 | for (let i = 0, len = pluralSuffixes.length; i < len; i++) { 265 | if (key.endsWith(pluralSuffixes[i])) return i 266 | } 267 | 268 | return -1 269 | } 270 | 271 | function makeDefaultSort(pluralSeparator) { 272 | return function defaultSort(key1, key2) { 273 | const singularKey1 = getSingularForm(key1, pluralSeparator) 274 | const singularKey2 = getSingularForm(key2, pluralSeparator) 275 | 276 | if (singularKey1 === singularKey2) { 277 | return getPluralSuffixPosition(key1) - getPluralSuffixPosition(key2) 278 | } 279 | 280 | return singularKey1.localeCompare(singularKey2, 'en') 281 | } 282 | } 283 | 284 | async function esConfigLoader(filepath) { 285 | return (await import(pathToFileURL(filepath))).default 286 | } 287 | 288 | async function tsConfigLoader(filepath) { 289 | const outfile = filepath + '.bundle.mjs' 290 | await build({ 291 | absWorkingDir: process.cwd(), 292 | entryPoints: [filepath], 293 | outfile, 294 | write: true, 295 | target: ['node14.18', 'node16'], 296 | platform: 'node', 297 | bundle: true, 298 | format: 'esm', 299 | sourcemap: 'inline', 300 | external: [ 301 | ...builtinModules, 302 | ...builtinModules.map((mod) => 'node:' + mod), 303 | ], 304 | }) 305 | const config = await esConfigLoader(outfile) 306 | rmSync(outfile) 307 | return config 308 | } 309 | 310 | function yamlConfigLoader(filepath, content) { 311 | return yaml.load(content) 312 | } 313 | 314 | // unescape common html entities 315 | // code from react-18next taken from 316 | // https://github.com/i18next/react-i18next/blob/d3247b5c232f5d8c1a154fe5dd0090ca88c82dcf/src/unescape.js 317 | function unescape(text) { 318 | const matchHtmlEntity = 319 | /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g 320 | const htmlEntities = { 321 | '&': '&', 322 | '&': '&', 323 | '<': '<', 324 | '<': '<', 325 | '>': '>', 326 | '>': '>', 327 | ''': "'", 328 | ''': "'", 329 | '"': '"', 330 | '"': '"', 331 | ' ': ' ', 332 | ' ': ' ', 333 | '©': '©', 334 | '©': '©', 335 | '®': '®', 336 | '®': '®', 337 | '…': '…', 338 | '…': '…', 339 | '/': '/', 340 | '/': '/', 341 | } 342 | 343 | const unescapeHtmlEntity = (m) => htmlEntities[m] 344 | 345 | return text.replace(matchHtmlEntity, unescapeHtmlEntity) 346 | } 347 | 348 | export { 349 | dotPathToHash, 350 | mergeHashes, 351 | transferValues, 352 | hasRelatedPluralKey, 353 | getSingularForm, 354 | getPluralSuffixPosition, 355 | makeDefaultSort, 356 | esConfigLoader, 357 | tsConfigLoader, 358 | yamlConfigLoader, 359 | unescape, 360 | } 361 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as broccoli } from './broccoli.js' 2 | export { default as parser } from './parser.js' 3 | export { default as transform } from './transform.js' 4 | export { default as gulp } from './transform.js' 5 | 6 | // Lexers 7 | export { default as BaseLexer } from './lexers/base-lexer.js' 8 | export { default as HandlebarsLexer } from './lexers/handlebars-lexer.js' 9 | export { default as HTMLLexer } from './lexers/html-lexer.js' 10 | export { default as JavascriptLexer } from './lexers/javascript-lexer.js' 11 | export { default as JsxLexer } from './lexers/jsx-lexer.js' 12 | -------------------------------------------------------------------------------- /src/lexers/base-lexer.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | 3 | export default class BaseLexer extends EventEmitter { 4 | constructor(options = {}) { 5 | super() 6 | this.keys = [] 7 | this.functions = options.functions || ['t'] 8 | } 9 | 10 | validateString(string) { 11 | const regex = new RegExp('^' + BaseLexer.stringPattern + '$', 'i') 12 | return regex.test(string) 13 | } 14 | 15 | functionPattern() { 16 | return '(?:' + this.functions.join('|').replace('.', '\\.') + ')' 17 | } 18 | 19 | static get singleQuotePattern() { 20 | return "'(?:[^'].*?[^\\\\])?'" 21 | } 22 | 23 | static get doubleQuotePattern() { 24 | return '"(?:[^"].*?[^\\\\])?"' 25 | } 26 | 27 | static get backQuotePattern() { 28 | return '`(?:[^`].*?[^\\\\])?`' 29 | } 30 | 31 | static get variablePattern() { 32 | return '(?:[A-Z0-9_.-]+)' 33 | } 34 | 35 | static get stringPattern() { 36 | return ( 37 | '(?:' + 38 | [BaseLexer.singleQuotePattern, BaseLexer.doubleQuotePattern].join('|') + 39 | ')' 40 | ) 41 | } 42 | 43 | static get stringOrVariablePattern() { 44 | return ( 45 | '(?:' + 46 | [ 47 | BaseLexer.singleQuotePattern, 48 | BaseLexer.doubleQuotePattern, 49 | BaseLexer.variablePattern, 50 | ].join('|') + 51 | ')' 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lexers/handlebars-lexer.js: -------------------------------------------------------------------------------- 1 | import BaseLexer from './base-lexer.js' 2 | 3 | export default class HandlebarsLexer extends BaseLexer { 4 | constructor(options = {}) { 5 | super(options) 6 | 7 | this.functions = options.functions || ['t'] 8 | 9 | this.createFunctionRegex() 10 | this.createArgumentsRegex() 11 | } 12 | 13 | extract(content) { 14 | let matches 15 | 16 | while ((matches = this.functionRegex.exec(content))) { 17 | const args = this.parseArguments(matches[1] || matches[2]) 18 | this.populateKeysFromArguments(args) 19 | } 20 | 21 | return this.keys 22 | } 23 | 24 | parseArguments(args) { 25 | let matches 26 | const result = { 27 | arguments: [], 28 | options: {}, 29 | } 30 | while ((matches = this.argumentsRegex.exec(args))) { 31 | const arg = matches[1] 32 | const parts = arg.split('=') 33 | result.arguments.push(arg) 34 | if (parts.length === 2 && this.validateString(parts[1])) { 35 | const value = parts[1].slice(1, -1) 36 | if (value === 'true') { 37 | result.options[parts[0]] = true 38 | } else if (value === 'false') { 39 | result.options[parts[0]] = false 40 | } else { 41 | result.options[parts[0]] = value 42 | } 43 | } 44 | } 45 | return result 46 | } 47 | 48 | populateKeysFromArguments(args) { 49 | const firstArgument = args.arguments[0] 50 | const secondArgument = args.arguments[1] 51 | const isKeyString = this.validateString(firstArgument) 52 | const isDefaultValueString = this.validateString(secondArgument) 53 | 54 | if (!isKeyString) { 55 | this.emit('warning', `Key is not a string literal: ${firstArgument}`) 56 | } else { 57 | const result = { 58 | ...args.options, 59 | key: firstArgument.slice(1, -1), 60 | } 61 | if (isDefaultValueString) { 62 | result.defaultValue = secondArgument.slice(1, -1) 63 | } 64 | this.keys.push(result) 65 | } 66 | } 67 | 68 | createFunctionRegex() { 69 | const functionPattern = this.functionPattern() 70 | const curlyPattern = '(?:{{)' + functionPattern + '\\s+(.*?)(?:}})' 71 | const parenthesisPattern = '(?:\\()' + functionPattern + '\\s+(.*)(?:\\))' 72 | const pattern = curlyPattern + '|' + parenthesisPattern 73 | this.functionRegex = new RegExp(pattern, 'gi') 74 | return this.functionRegex 75 | } 76 | 77 | createArgumentsRegex() { 78 | const pattern = 79 | '(?:\\s+|^)' + 80 | '(' + 81 | '(?:' + 82 | BaseLexer.variablePattern + 83 | '(?:=' + 84 | BaseLexer.stringOrVariablePattern + 85 | ')?' + 86 | ')' + 87 | '|' + 88 | BaseLexer.stringPattern + 89 | ')' 90 | this.argumentsRegex = new RegExp(pattern, 'gi') 91 | return this.argumentsRegex 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/lexers/html-lexer.js: -------------------------------------------------------------------------------- 1 | import BaseLexer from './base-lexer.js' 2 | import * as cheerio from 'cheerio' 3 | 4 | export default class HTMLLexer extends BaseLexer { 5 | constructor(options = {}) { 6 | super(options) 7 | 8 | this.attr = options.attr || 'data-i18n' 9 | this.optionAttr = options.optionAttr || 'data-i18n-options' 10 | } 11 | 12 | extract(content) { 13 | const that = this 14 | const $ = cheerio.load(content) 15 | $(`[${that.attr}]`).each((index, node) => { 16 | const $node = cheerio.load(node) 17 | 18 | // the attribute can hold multiple keys 19 | const keys = node.attribs[that.attr].split(';') 20 | let options = node.attribs[that.optionAttr] 21 | 22 | if (options) { 23 | try { 24 | options = JSON.parse(options) 25 | } finally { 26 | } 27 | } 28 | 29 | for (let key of keys) { 30 | // remove any leading [] in the key 31 | key = key.replace(/^\[[a-zA-Z0-9_-]*\]/, '') 32 | 33 | // if empty grab innerHTML from regex 34 | key = key || $node.text() 35 | 36 | if (key) { 37 | this.keys.push({ ...options, key }) 38 | } 39 | } 40 | }) 41 | 42 | return this.keys 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lexers/javascript-lexer.js: -------------------------------------------------------------------------------- 1 | import BaseLexer from './base-lexer.js' 2 | import ts from 'typescript' 3 | 4 | export default class JavascriptLexer extends BaseLexer { 5 | constructor(options = {}) { 6 | super(options) 7 | 8 | this.callPattern = '(?<=^|\\s|\\.)' + this.functionPattern() + '\\(.*\\)' 9 | this.functions = options.functions || ['t'] 10 | this.namespaceFunctions = options.namespaceFunctions || [ 11 | 'useTranslation', 12 | 'withTranslation', 13 | ] 14 | this.attr = options.attr || 'i18nKey' 15 | this.parseGenerics = options.parseGenerics || false 16 | this.typeMap = options.typeMap || {} 17 | this.translationFunctionsWithArgs = {} 18 | } 19 | 20 | createCommentNodeParser() { 21 | const visitedComments = new Set() 22 | 23 | return (keys, node, content) => { 24 | ts.forEachLeadingCommentRange( 25 | content, 26 | node.getFullStart(), 27 | (pos, end, kind) => { 28 | const commentId = `${pos}_${end}` 29 | if ( 30 | (kind === ts.SyntaxKind.MultiLineCommentTrivia || 31 | kind === ts.SyntaxKind.SingleLineCommentTrivia) && 32 | !visitedComments.has(commentId) 33 | ) { 34 | visitedComments.add(commentId) 35 | const text = content.slice(pos, end) 36 | const commentKeys = this.commentExtractor.call(this, text) 37 | if (commentKeys) { 38 | keys.push(...commentKeys) 39 | } 40 | } 41 | } 42 | ) 43 | } 44 | } 45 | 46 | setNamespaces(keys) { 47 | return keys.map((entry) => { 48 | const namespace = 49 | entry.ns ?? 50 | this.translationFunctionsWithArgs?.[entry.functionName]?.ns ?? 51 | this.defaultNamespace 52 | return namespace 53 | ? { 54 | ...entry, 55 | namespace, 56 | } 57 | : entry 58 | }) 59 | } 60 | 61 | setKeyPrefixes(keys) { 62 | return keys.map(({ functionName, ...key }) => { 63 | const keyPrefix = 64 | this.translationFunctionsWithArgs?.[functionName]?.keyPrefix ?? 65 | this.keyPrefix 66 | return keyPrefix 67 | ? { 68 | ...key, 69 | keyPrefix, 70 | } 71 | : key 72 | }) 73 | } 74 | 75 | variableDeclarationExtractor(node) { 76 | const firstDeconstructedProp = node.name.elements?.[0] 77 | if ( 78 | (firstDeconstructedProp?.propertyName ?? firstDeconstructedProp?.name) 79 | ?.escapedText === 't' && 80 | this.functions.includes(firstDeconstructedProp?.name?.escapedText) && 81 | this.namespaceFunctions.includes(node.initializer.expression?.escapedText) 82 | ) { 83 | this.translationFunctionsWithArgs[ 84 | firstDeconstructedProp.name.escapedText 85 | ] = { 86 | pos: node.initializer.pos, 87 | storeGlobally: !firstDeconstructedProp.propertyName?.escapedText, 88 | } 89 | } 90 | } 91 | 92 | extract(content, filename = '__default.js') { 93 | const keys = [] 94 | 95 | const parseCommentNode = this.createCommentNodeParser() 96 | 97 | const parseTree = (node) => { 98 | parseCommentNode(keys, node, content) 99 | 100 | if (node.kind === ts.SyntaxKind.VariableDeclaration) { 101 | this.variableDeclarationExtractor.call(this, node) 102 | } 103 | if ( 104 | node.kind === ts.SyntaxKind.ArrowFunction || 105 | node.kind === ts.SyntaxKind.FunctionDeclaration 106 | ) { 107 | this.functionParamExtractor.call(this, node) 108 | } 109 | 110 | if (node.kind === ts.SyntaxKind.TaggedTemplateExpression) { 111 | const entry = this.taggedTemplateExpressionExtractor.call(this, node) 112 | if (entry) { 113 | keys.push(entry) 114 | } 115 | } 116 | 117 | if (node.kind === ts.SyntaxKind.CallExpression) { 118 | const entries = this.expressionExtractor.call(this, node) 119 | if (entries) { 120 | keys.push(...entries) 121 | } 122 | } 123 | 124 | node.forEachChild(parseTree) 125 | } 126 | 127 | const sourceFile = ts.createSourceFile( 128 | filename, 129 | content, 130 | ts.ScriptTarget.Latest 131 | ) 132 | parseTree(sourceFile) 133 | 134 | return this.setKeyPrefixes(this.setNamespaces(keys)) 135 | } 136 | 137 | /** @param {ts.FunctionLikeDeclaration} node */ 138 | functionParamExtractor(node) { 139 | const tFunctionParam = 140 | node.parameters && 141 | node.parameters.find( 142 | (param) => 143 | param.name && 144 | param.name.kind === ts.SyntaxKind.Identifier && 145 | this.functions.includes(param.name.text) 146 | ) 147 | 148 | if ( 149 | tFunctionParam && 150 | tFunctionParam.type && 151 | tFunctionParam.type.typeName && 152 | tFunctionParam.type.typeName.text === 'TFunction' 153 | ) { 154 | const { typeArguments } = tFunctionParam.type 155 | if ( 156 | typeArguments && 157 | typeArguments.length && 158 | typeArguments[0].kind === ts.SyntaxKind.LiteralType 159 | ) { 160 | this.defaultNamespace = typeArguments[0].literal.text 161 | } 162 | } 163 | } 164 | 165 | taggedTemplateExpressionExtractor(node) { 166 | const entry = {} 167 | 168 | const { tag, template } = node 169 | 170 | const isTranslationFunction = 171 | (tag.text && this.functions.includes(tag.text)) || 172 | (tag.name && this.functions.includes(tag.name.text)) 173 | 174 | if (!isTranslationFunction) return null 175 | 176 | if (template.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) { 177 | entry.key = template.text 178 | } else if (template.kind === ts.SyntaxKind.TemplateExpression) { 179 | this.emit( 180 | 'warning', 181 | 'A key that is a template string must not have any interpolations.' 182 | ) 183 | return null 184 | } 185 | 186 | return entry 187 | } 188 | 189 | expressionExtractor(node) { 190 | const entries = [{}] 191 | 192 | const functionDefinition = Object.entries( 193 | this.translationFunctionsWithArgs 194 | ).find(([name, translationFunc]) => translationFunc?.pos === node.pos) 195 | let storeGlobally = functionDefinition?.[1].storeGlobally ?? true 196 | 197 | const isNamespaceFunction = 198 | this.namespaceFunctions.includes(node.expression.escapedText) || 199 | // Support matching the namespace as well, i.e. match `i18n.useTranslation('ns')` 200 | this.namespaceFunctions.includes(this.expressionToName(node.expression)) 201 | 202 | if (isNamespaceFunction && node.arguments.length) { 203 | storeGlobally |= node.expression.escapedText === 'withTranslation' 204 | const namespaceArgument = node.arguments[0] 205 | const optionsArgument = node.arguments[1] 206 | // The namespace argument can be either an array of namespaces or a single namespace, 207 | // so we convert it to an array in the case of a single namespace so that we can use 208 | // the same code in both cases 209 | const namespaces = namespaceArgument.elements || [namespaceArgument] 210 | 211 | // Find the first namespace that is a string literal, or is `undefined`. In the case 212 | // of `undefined`, we do nothing (see below), leaving the default namespace unchanged 213 | const namespace = namespaces.find( 214 | (ns) => 215 | ns.kind === ts.SyntaxKind.StringLiteral || 216 | (ns.kind === ts.SyntaxKind.Identifier && ns.text === 'undefined') 217 | ) 218 | 219 | if (!namespace) { 220 | // We know that the namespace argument was provided, so if we're unable to find a 221 | // namespace, emit a warning since this will likely cause issues for the user 222 | this.emit( 223 | 'warning', 224 | namespaceArgument.kind === ts.SyntaxKind.Identifier 225 | ? `Namespace is not a string literal nor an array containing a string literal: ${namespaceArgument.text}` 226 | : 'Namespace is not a string literal nor an array containing a string literal' 227 | ) 228 | } else if (namespace.kind === ts.SyntaxKind.StringLiteral) { 229 | // We found a string literal namespace, so we'll use this instead of the default 230 | if (storeGlobally) { 231 | this.defaultNamespace = namespace.text 232 | } 233 | entries[0].ns = namespace.text 234 | } 235 | 236 | if ( 237 | optionsArgument && 238 | optionsArgument.kind === ts.SyntaxKind.ObjectLiteralExpression 239 | ) { 240 | const keyPrefixNode = optionsArgument.properties.find( 241 | (p) => p.name.escapedText === 'keyPrefix' 242 | ) 243 | if (keyPrefixNode != null) { 244 | if (storeGlobally) { 245 | this.keyPrefix = keyPrefixNode.initializer.text 246 | } 247 | entries[0].keyPrefix = keyPrefixNode.initializer.text 248 | } 249 | } 250 | } 251 | 252 | const isTranslationFunction = 253 | // If the expression is a string literal, we can just check if it's in the 254 | // list of functions 255 | (node.expression.text && this.functions.includes(node.expression.text)) || 256 | // Support the case where the function is contained in a namespace, i.e. 257 | // match `i18n.t()` when this.functions = ['t']. 258 | (node.expression.name && 259 | this.functions.includes(node.expression.name.text)) || 260 | // Support matching the namespace as well, i.e. match `i18n.t()` but _not_ 261 | // `l10n.t()` when this.functions = ['i18n.t'] 262 | this.functions.includes(this.expressionToName(node.expression)) 263 | 264 | if (isTranslationFunction) { 265 | const keyArgument = node.arguments.shift() 266 | 267 | if (!keyArgument) { 268 | return null 269 | } 270 | 271 | if ( 272 | keyArgument.kind === ts.SyntaxKind.StringLiteral || 273 | keyArgument.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral 274 | ) { 275 | entries[0].key = keyArgument.text 276 | } else if (keyArgument.kind === ts.SyntaxKind.BinaryExpression) { 277 | const concatenatedString = this.concatenateString(keyArgument) 278 | if (!concatenatedString) { 279 | this.emit( 280 | 'warning', 281 | `Key is not a string literal: ${keyArgument.text}` 282 | ) 283 | return null 284 | } 285 | entries[0].key = concatenatedString 286 | } else { 287 | this.emit( 288 | 'warning', 289 | keyArgument.kind === ts.SyntaxKind.Identifier 290 | ? `Key is not a string literal: ${keyArgument.text}` 291 | : 'Key is not a string literal' 292 | ) 293 | return null 294 | } 295 | 296 | if (this.parseGenerics && node.typeArguments) { 297 | let typeArgument = node.typeArguments.shift() 298 | 299 | const parseTypeArgument = (typeArg) => { 300 | if (!typeArg) { 301 | return 302 | } 303 | if (typeArg.kind === ts.SyntaxKind.TypeLiteral) { 304 | for (const member of typeArg.members) { 305 | entries[0][member.name.text] = '' 306 | } 307 | } else if ( 308 | typeArg.kind === ts.SyntaxKind.TypeReference && 309 | typeArg.typeName.kind === ts.SyntaxKind.Identifier 310 | ) { 311 | const typeName = typeArg.typeName.text 312 | if (typeName in this.typeMap) { 313 | Object.assign(entries[0], this.typeMap[typeName]) 314 | } 315 | } else if (Array.isArray(typeArg.types)) { 316 | typeArgument.types.forEach((tp) => parseTypeArgument(tp)) 317 | } 318 | } 319 | 320 | parseTypeArgument(typeArgument) 321 | } 322 | 323 | let optionsArgument = node.arguments.shift() 324 | 325 | // Second argument could be a (concatenated) string default value 326 | if ( 327 | optionsArgument && 328 | (optionsArgument.kind === ts.SyntaxKind.StringLiteral || 329 | optionsArgument.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) 330 | ) { 331 | entries[0].defaultValue = optionsArgument.text 332 | optionsArgument = node.arguments.shift() 333 | } else if ( 334 | optionsArgument && 335 | optionsArgument.kind === ts.SyntaxKind.BinaryExpression 336 | ) { 337 | const concatenatedString = this.concatenateString(optionsArgument) 338 | if (!concatenatedString) { 339 | this.emit( 340 | 'warning', 341 | `Default value is not a string literal: ${optionsArgument.text}` 342 | ) 343 | return null 344 | } 345 | entries[0].defaultValue = concatenatedString 346 | optionsArgument = node.arguments.shift() 347 | } 348 | 349 | if ( 350 | optionsArgument && 351 | optionsArgument.kind === ts.SyntaxKind.ObjectLiteralExpression 352 | ) { 353 | for (const p of optionsArgument.properties) { 354 | if (p.kind === ts.SyntaxKind.SpreadAssignment) { 355 | this.emit( 356 | 'warning', 357 | `Options argument is a spread operator : ${p.expression.text}` 358 | ) 359 | } else if (p.initializer) { 360 | if (p.initializer.kind === ts.SyntaxKind.TrueKeyword) { 361 | entries[0][p.name.text] = true 362 | } else if (p.initializer.kind === ts.SyntaxKind.FalseKeyword) { 363 | entries[0][p.name.text] = false 364 | } else if (p.initializer.kind === ts.SyntaxKind.CallExpression) { 365 | const nestedEntries = this.expressionExtractor(p.initializer) 366 | if (nestedEntries) { 367 | entries.push(...nestedEntries) 368 | } else { 369 | entries[0][p.name.text] = p.initializer.text || '' 370 | } 371 | } else { 372 | entries[0][p.name.text] = p.initializer.text || '' 373 | } 374 | } else { 375 | entries[0][p.name.text] = '' 376 | } 377 | } 378 | } 379 | 380 | if (entries[0].ns) { 381 | if (typeof entries[0].ns === 'string') { 382 | entries[0].namespace = entries[0].ns 383 | } else if (typeof entries.ns === 'object' && entries.ns.length) { 384 | entries[0].namespace = entries[0].ns[0] 385 | } 386 | } 387 | entries[0].functionName = node.expression.escapedText 388 | 389 | return entries 390 | } 391 | 392 | const isTranslationFunctionCreation = 393 | node.expression.escapedText && 394 | this.namespaceFunctions.includes(node.expression.escapedText) 395 | if (isTranslationFunctionCreation) { 396 | this.translationFunctionsWithArgs[functionDefinition?.[0]] = entries[0] 397 | } 398 | return null 399 | } 400 | 401 | commentExtractor(commentText) { 402 | const regexp = new RegExp(this.callPattern, 'g') 403 | const expressions = commentText.match(regexp) 404 | 405 | if (!expressions) { 406 | return null 407 | } 408 | 409 | const keys = [] 410 | expressions.forEach((expression) => { 411 | const expressionKeys = this.extract(expression) 412 | if (expressionKeys) { 413 | keys.push(...expressionKeys) 414 | } 415 | }) 416 | return keys 417 | } 418 | 419 | concatenateString(binaryExpression, string = '') { 420 | if (binaryExpression.operatorToken.kind !== ts.SyntaxKind.PlusToken) { 421 | return 422 | } 423 | 424 | if (binaryExpression.left.kind === ts.SyntaxKind.BinaryExpression) { 425 | string += this.concatenateString(binaryExpression.left, string) 426 | } else if (binaryExpression.left.kind === ts.SyntaxKind.StringLiteral) { 427 | string += binaryExpression.left.text 428 | } else { 429 | return 430 | } 431 | 432 | if (binaryExpression.right.kind === ts.SyntaxKind.BinaryExpression) { 433 | string += this.concatenateString(binaryExpression.right, string) 434 | } else if (binaryExpression.right.kind === ts.SyntaxKind.StringLiteral) { 435 | string += binaryExpression.right.text 436 | } else { 437 | return 438 | } 439 | 440 | return string 441 | } 442 | 443 | /** 444 | * Recursively computes the name of a dot-separated expression, e.g. `t` or `t.ns` 445 | * @type {(expression: ts.LeftHandSideExpression | ts.JsxTagNameExpression) => string} 446 | */ 447 | expressionToName(expression) { 448 | if (expression) { 449 | if (expression.text) { 450 | return expression.text 451 | } else if (expression.name) { 452 | return [ 453 | this.expressionToName(expression.expression), 454 | this.expressionToName(expression.name), 455 | ] 456 | .filter((s) => s && s.length > 0) 457 | .join('.') 458 | } 459 | } 460 | return undefined 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /src/lexers/jsx-lexer.js: -------------------------------------------------------------------------------- 1 | import JavascriptLexer from './javascript-lexer.js' 2 | import ts from 'typescript' 3 | import { unescape } from '../helpers.js' 4 | 5 | export default class JsxLexer extends JavascriptLexer { 6 | constructor(options = {}) { 7 | super(options) 8 | 9 | this.componentFunctions = options.componentFunctions || ['Trans'] 10 | this.transSupportBasicHtmlNodes = 11 | options.transSupportBasicHtmlNodes || false 12 | this.transKeepBasicHtmlNodesFor = options.transKeepBasicHtmlNodesFor || [ 13 | 'br', 14 | 'strong', 15 | 'i', 16 | 'p', 17 | ] 18 | this.omitAttributes = [this.attr, 'ns', 'defaults'] 19 | this.transIdentityFunctionsToIgnore = 20 | options.transIdentityFunctionsToIgnore || [] 21 | } 22 | 23 | extract(content, filename = '__default.jsx') { 24 | const keys = [] 25 | 26 | const parseCommentNode = this.createCommentNodeParser() 27 | 28 | const parseTree = (node) => { 29 | let entry 30 | 31 | parseCommentNode(keys, node, content) 32 | 33 | switch (node.kind) { 34 | case ts.SyntaxKind.VariableDeclaration: 35 | this.variableDeclarationExtractor.call(this, node) 36 | break 37 | case ts.SyntaxKind.CallExpression: 38 | const entries = this.expressionExtractor.call(this, node) 39 | if (entries) { 40 | keys.push(...entries) 41 | } 42 | break 43 | case ts.SyntaxKind.TaggedTemplateExpression: 44 | entry = this.taggedTemplateExpressionExtractor.call(this, node) 45 | break 46 | case ts.SyntaxKind.JsxElement: 47 | entry = this.jsxExtractor.call(this, node, content) 48 | break 49 | case ts.SyntaxKind.JsxSelfClosingElement: 50 | entry = this.jsxExtractor.call(this, node, content) 51 | break 52 | } 53 | 54 | if (entry) { 55 | keys.push(entry) 56 | } 57 | 58 | node.forEachChild(parseTree) 59 | } 60 | 61 | const sourceFile = ts.createSourceFile( 62 | filename, 63 | content, 64 | ts.ScriptTarget.Latest 65 | ) 66 | parseTree(sourceFile) 67 | 68 | const keysWithNamespace = this.setNamespaces(keys) 69 | const keysWithPrefixes = this.setKeyPrefixes(keysWithNamespace) 70 | 71 | return keysWithPrefixes 72 | } 73 | 74 | jsxExtractor(node, sourceText) { 75 | const tagNode = node.openingElement || node 76 | 77 | const getPropValue = (node, attributeName) => { 78 | const attribute = node.attributes.properties.find( 79 | (attr) => attr.name !== undefined && attr.name.text === attributeName 80 | ) 81 | if (!attribute) { 82 | return undefined 83 | } 84 | 85 | if (attribute.initializer.expression?.kind === ts.SyntaxKind.Identifier) { 86 | this.emit( 87 | 'warning', 88 | `"${attributeName}" prop is not a string literal: ${attribute.initializer.expression.text}` 89 | ) 90 | 91 | return undefined 92 | } 93 | 94 | return attribute.initializer.expression 95 | ? attribute.initializer.expression.text 96 | : attribute.initializer.text 97 | } 98 | 99 | const getKey = (node) => getPropValue(node, this.attr) 100 | 101 | if ( 102 | this.componentFunctions.includes(this.expressionToName(tagNode.tagName)) 103 | ) { 104 | const entry = {} 105 | entry.key = getKey(tagNode) 106 | 107 | const namespace = getPropValue(tagNode, 'ns') 108 | if (namespace) { 109 | entry.namespace = namespace 110 | } 111 | 112 | tagNode.attributes.properties.forEach((property) => { 113 | if (property.kind === ts.SyntaxKind.JsxSpreadAttribute) { 114 | this.emit( 115 | 'warning', 116 | `Component attribute is a JSX spread attribute : ${property.expression.text}` 117 | ) 118 | return 119 | } 120 | 121 | if (this.omitAttributes.includes(property.name.text)) { 122 | return 123 | } 124 | 125 | if (property.initializer) { 126 | if (property.initializer.expression) { 127 | if ( 128 | property.initializer.expression.kind === ts.SyntaxKind.TrueKeyword 129 | ) { 130 | entry[property.name.text] = true 131 | } else if ( 132 | property.initializer.expression.kind === 133 | ts.SyntaxKind.FalseKeyword 134 | ) { 135 | entry[property.name.text] = false 136 | } else { 137 | entry[property.name.text] = `{${ 138 | property.initializer.expression.text || 139 | this.cleanMultiLineCode( 140 | sourceText.slice( 141 | property.initializer.expression.pos, 142 | property.initializer.expression.end 143 | ) 144 | ) 145 | }}` 146 | } 147 | } else { 148 | entry[property.name.text] = property.initializer.text 149 | } 150 | } else entry[property.name.text] = true 151 | }) 152 | 153 | const nodeAsString = this.nodeToString.call(this, node, sourceText) 154 | const defaultsProp = getPropValue(tagNode, 'defaults') 155 | let defaultValue = defaultsProp || nodeAsString 156 | 157 | // If `shouldUnescape` is not true, it means the value cannot contain HTML entities, 158 | // so we need to unescape these entities now so that they can be properly rendered later 159 | if (entry.shouldUnescape !== true) { 160 | defaultValue = unescape(defaultValue) 161 | } 162 | 163 | if (defaultValue !== '') { 164 | entry.defaultValue = defaultValue 165 | 166 | if (!entry.key) { 167 | // If there's no key, default to the stringified unescaped node, then to the default value: 168 | // https://github.com/i18next/react-i18next/blob/95f9c6a7b602a7b1fd33c1ded6dcfc23a52b853b/src/TransWithoutContext.js#L337 169 | entry.key = unescape(nodeAsString) || entry.defaultValue 170 | } 171 | } 172 | 173 | return entry.key ? entry : null 174 | } else if (tagNode.tagName.text === 'Interpolate') { 175 | const entry = {} 176 | entry.key = getKey(tagNode) 177 | return entry.key ? entry : null 178 | } else if (tagNode.tagName.text === 'Translation') { 179 | const namespace = getPropValue(tagNode, 'ns') 180 | if (namespace) { 181 | this.defaultNamespace = namespace 182 | } 183 | } 184 | } 185 | 186 | nodeToString(node, sourceText) { 187 | const children = this.parseChildren.call( 188 | this, 189 | node, 190 | node.children, 191 | sourceText 192 | ) 193 | 194 | const elemsToString = (children) => 195 | children 196 | .map((child, index) => { 197 | switch (child.type) { 198 | case 'js': 199 | case 'text': 200 | return child.content 201 | case 'tag': 202 | const useTagName = 203 | child.isBasic && 204 | this.transSupportBasicHtmlNodes && 205 | this.transKeepBasicHtmlNodesFor.includes(child.name) 206 | const elementName = useTagName ? child.name : index 207 | const childrenString = elemsToString(child.children) 208 | return childrenString || !(useTagName && child.selfClosing) 209 | ? `<${elementName}>${childrenString}` 210 | : `<${elementName} />` 211 | default: 212 | throw new Error('Unknown parsed content: ' + child.type) 213 | } 214 | }) 215 | .join('') 216 | 217 | return elemsToString(children) 218 | } 219 | 220 | cleanMultiLineCode(text) { 221 | return text 222 | .replace(/(^(\n|\r)\s*)|((\n|\r)\s*$)/g, '') 223 | .replace(/(\n|\r)\s*/g, ' ') 224 | } 225 | 226 | parseChildren(node, children = [], sourceText) { 227 | return children 228 | .map((child) => { 229 | if (child.kind === ts.SyntaxKind.JsxText) { 230 | return { 231 | type: 'text', 232 | content: this.cleanMultiLineCode(child.text), 233 | } 234 | } else if ( 235 | child.kind === ts.SyntaxKind.JsxElement || 236 | child.kind === ts.SyntaxKind.JsxSelfClosingElement 237 | ) { 238 | const element = child.openingElement || child 239 | const name = element.tagName.escapedText 240 | const isBasic = !element.attributes.properties.length 241 | const hasDynamicChildren = element.attributes.properties.find( 242 | (prop) => 243 | prop.kind === ts.SyntaxKind.JsxAttribute && 244 | prop.name.escapedText === 'i18nIsDynamicList' 245 | ) 246 | return { 247 | type: 'tag', 248 | children: hasDynamicChildren 249 | ? [] 250 | : this.parseChildren(child, child.children, sourceText), 251 | name, 252 | isBasic, 253 | selfClosing: child.kind === ts.SyntaxKind.JsxSelfClosingElement, 254 | } 255 | } else if (child.kind === ts.SyntaxKind.JsxExpression) { 256 | // strip empty expressions 257 | if (!child.expression) { 258 | return { 259 | type: 'text', 260 | content: '', 261 | } 262 | } 263 | 264 | // simplify trivial expressions, like TypeScript typecasts 265 | while (child.expression.kind === ts.SyntaxKind.AsExpression) { 266 | child = child.expression 267 | } 268 | 269 | // Sometimes, we might want to wrap ObjectExpressions in a function 270 | // for typechecker compatibility: e.g., 271 | // 272 | // Instead of 273 | // `Hello, {{ name }}` 274 | // we might want: 275 | // `Hello, {castToString({ name })}` 276 | // 277 | // because that way, we can have {castToString(...)} be typed 278 | // in a a way to return a string, which would be type-compatible 279 | // with `children?: React.ReactNode` 280 | // 281 | // In these cases, we want to look at the object expressions within 282 | // the function call to extract the variables 283 | if ( 284 | child.expression.kind === ts.SyntaxKind.CallExpression && 285 | child.expression.expression.kind === ts.SyntaxKind.Identifier && 286 | this.transIdentityFunctionsToIgnore.includes( 287 | child.expression.expression.escapedText 288 | ) && 289 | child.expression.arguments.length >= 1 290 | ) { 291 | child = { expression: child.expression.arguments[0] } 292 | } 293 | 294 | if (child.expression.kind === ts.SyntaxKind.StringLiteral) { 295 | return { 296 | type: 'text', 297 | content: child.expression.text, 298 | } 299 | } 300 | 301 | // strip properties from ObjectExpressions 302 | // annoying (and who knows how many other exceptions we'll need to write) but necessary 303 | else if ( 304 | child.expression.kind === ts.SyntaxKind.ObjectLiteralExpression 305 | ) { 306 | // i18next-react only accepts two props, any random single prop, and a format prop 307 | 308 | const nonFormatProperties = child.expression.properties.filter( 309 | (prop) => prop.name.text !== 'format' 310 | ) 311 | const formatProperty = child.expression.properties.find( 312 | (prop) => prop.name.text === 'format' 313 | ) 314 | 315 | // more than one property throw a warning in i18next-react, but still works as a key 316 | if (nonFormatProperties.length > 1) { 317 | this.emit( 318 | 'warning', 319 | `The passed in object contained more than one variable - the object should look like {{ value, format }} where format is optional.` 320 | ) 321 | 322 | return { 323 | type: 'text', 324 | content: '', 325 | } 326 | } 327 | 328 | // This matches the behaviour of the Trans component in i18next as of v13.0.2: 329 | // https://github.com/i18next/react-i18next/blob/0a4681e428c888fe986bcc0109eb19eab6ff2eb3/src/TransWithoutContext.js#L88 330 | const value = formatProperty 331 | ? `${nonFormatProperties[0].name.text}, ${formatProperty.initializer.text}` 332 | : nonFormatProperties[0].name.text 333 | 334 | return { 335 | type: 'js', 336 | content: `{{${value}}}`, 337 | } 338 | } 339 | 340 | // slice on the expression so that we ignore comments around it 341 | const slicedExpression = sourceText.slice( 342 | child.expression.pos, 343 | child.expression.end 344 | ) 345 | 346 | const tagNode = node.openingElement || node 347 | const attrValues = tagNode.attributes.properties 348 | .filter((attr) => [this.attr, 'defaults'].includes(attr.name?.text)) 349 | .map( 350 | (attr) => 351 | attr.initializer.expression?.text ?? attr.initializer.text 352 | ) 353 | 354 | if (attrValues.some((attr) => !attr)) { 355 | this.emit('warning', `Child is not literal: ${slicedExpression}`) 356 | } 357 | 358 | return { 359 | type: 'js', 360 | content: `{${slicedExpression}}`, 361 | } 362 | } else { 363 | throw new Error('Unknown ast element when parsing jsx: ' + child.kind) 364 | } 365 | }) 366 | .filter((child) => child.type !== 'text' || child.content) 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/parser.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import EventEmitter from 'events' 3 | import HandlebarsLexer from './lexers/handlebars-lexer.js' 4 | import HTMLLexer from './lexers/html-lexer.js' 5 | import JavascriptLexer from './lexers/javascript-lexer.js' 6 | import JsxLexer from './lexers/jsx-lexer.js' 7 | 8 | const lexers = { 9 | hbs: ['HandlebarsLexer'], 10 | handlebars: ['HandlebarsLexer'], 11 | 12 | htm: ['HTMLLexer'], 13 | html: ['HTMLLexer'], 14 | 15 | mjs: ['JavascriptLexer'], 16 | js: ['JavascriptLexer'], 17 | ts: ['JavascriptLexer'], 18 | jsx: ['JsxLexer'], 19 | tsx: ['JsxLexer'], 20 | 21 | vue: ['JavascriptLexer'], 22 | 23 | default: ['JavascriptLexer'], 24 | } 25 | 26 | const lexersMap = { 27 | HandlebarsLexer, 28 | HTMLLexer, 29 | JavascriptLexer, 30 | JsxLexer, 31 | } 32 | 33 | export default class Parser extends EventEmitter { 34 | constructor(options = {}) { 35 | super(options) 36 | this.options = options 37 | this.lexers = { ...lexers, ...options.lexers } 38 | } 39 | 40 | parse(content, filename) { 41 | let keys = [] 42 | const extension = path.extname(filename).substr(1) 43 | const lexers = this.lexers[extension] || this.lexers.default 44 | 45 | for (const lexerConfig of lexers) { 46 | let lexerName 47 | let lexerOptions 48 | 49 | if ( 50 | typeof lexerConfig === 'string' || 51 | typeof lexerConfig === 'function' 52 | ) { 53 | lexerName = lexerConfig 54 | lexerOptions = {} 55 | } else { 56 | lexerName = lexerConfig.lexer 57 | lexerOptions = lexerConfig 58 | } 59 | 60 | let Lexer 61 | if (typeof lexerName === 'function') { 62 | Lexer = lexerName 63 | } else { 64 | if (!lexersMap[lexerName]) { 65 | this.emit('error', new Error(`Lexer '${lexerName}' does not exist`)) 66 | } 67 | 68 | Lexer = lexersMap[lexerName] 69 | } 70 | 71 | const lexer = new Lexer(lexerOptions) 72 | lexer.on('warning', (warning) => this.emit('warning', warning)) 73 | keys = keys.concat(lexer.extract(content, filename)) 74 | } 75 | 76 | return keys 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/transform.js: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream' 2 | import eol from 'eol' 3 | import fs from 'fs' 4 | import path from 'path' 5 | import VirtualFile from 'vinyl' 6 | import yaml from 'js-yaml' 7 | import i18next from 'i18next' 8 | import sortKeys from 'sort-keys' 9 | 10 | import { 11 | dotPathToHash, 12 | mergeHashes, 13 | transferValues, 14 | makeDefaultSort, 15 | } from './helpers.js' 16 | import Parser from './parser.js' 17 | 18 | export default class i18nTransform extends Transform { 19 | constructor(options = {}) { 20 | options.objectMode = true 21 | super(options) 22 | 23 | this.defaults = { 24 | contextSeparator: '_', 25 | createOldCatalogs: true, 26 | defaultNamespace: 'translation', 27 | defaultValue: '', 28 | indentation: 2, 29 | keepRemoved: false, 30 | keySeparator: '.', 31 | lexers: {}, 32 | lineEnding: 'auto', 33 | locales: ['en', 'fr'], 34 | namespaceSeparator: ':', 35 | pluralSeparator: '_', 36 | output: 'locales/$LOCALE/$NAMESPACE.json', 37 | resetDefaultValueLocale: null, 38 | sort: false, 39 | verbose: false, 40 | customValueTemplate: null, 41 | failOnWarnings: false, 42 | yamlOptions: null, 43 | } 44 | 45 | this.options = { ...this.defaults, ...options } 46 | this.options.i18nextOptions = { 47 | ...options.i18nextOptions, 48 | pluralSeparator: this.options.pluralSeparator, 49 | nsSeparator: this.options.namespaceSeparator, 50 | } 51 | 52 | if (this.options.keySeparator === false) { 53 | this.options.keySeparator = '__!NO_KEY_SEPARATOR!__' 54 | } 55 | if (this.options.namespaceSeparator === false) { 56 | this.options.namespaceSeparator = '__!NO_NAMESPACE_SEPARATOR!__' 57 | } 58 | this.entries = [] 59 | 60 | this.parserHadWarnings = false 61 | this.parserHadUpdate = false 62 | this.parser = new Parser(this.options) 63 | this.parser.on('error', (error) => this.error(error)) 64 | this.parser.on('warning', (warning) => this.warn(warning)) 65 | 66 | this.localeRegex = /\$LOCALE/g 67 | this.namespaceRegex = /\$NAMESPACE/g 68 | 69 | this.i18next = i18next.createInstance() 70 | this.i18next.init(this.options.i18nextOptions) 71 | 72 | // Track where individual keys are found, for use in customValueTemplate 73 | // substitution into "${filePath}" 74 | this.keyToFilePaths = {} 75 | } 76 | 77 | error(error) { 78 | this.emit('error', error) 79 | if (this.options.verbose) { 80 | console.error('\x1b[31m%s\x1b[0m', error) 81 | } 82 | } 83 | 84 | warn(warning) { 85 | this.emit('warning', warning) 86 | this.parserHadWarnings = true 87 | if (this.options.verbose) { 88 | console.warn('\x1b[33m%s\x1b[0m', warning) 89 | } 90 | } 91 | 92 | _transform(file, encoding, done) { 93 | let content 94 | if (file.isBuffer()) { 95 | content = file.contents.toString('utf8') 96 | } else if (fs.lstatSync(file.path).isDirectory()) { 97 | const warning = `${file.path} is a directory: skipping` 98 | this.warn(warning) 99 | done() 100 | return 101 | } else { 102 | content = fs.readFileSync(file.path, encoding || 'utf-8') 103 | } 104 | 105 | this.emit('reading', file) 106 | if (this.options.verbose) { 107 | console.log(`Parsing ${file.path}`) 108 | } 109 | 110 | const filename = path.basename(file.path) 111 | const entries = this.parser.parse(content, filename) 112 | 113 | for (const entry of entries) { 114 | let key = entry.key 115 | 116 | if (entry.keyPrefix) { 117 | key = entry.keyPrefix + this.options.keySeparator + key 118 | } 119 | 120 | const parts = key.split(this.options.namespaceSeparator) 121 | 122 | // make sure we're not pulling a 'namespace' out of a default value 123 | if (parts.length > 1 && key !== entry.defaultValue) { 124 | entry.namespace = parts.shift() 125 | } 126 | entry.namespace = entry.namespace || this.options.defaultNamespace 127 | 128 | key = parts.join(this.options.namespaceSeparator) 129 | key = key.replace(/\\('|"|`)/g, '$1') 130 | key = key.replace(/\\n/g, '\n') 131 | key = key.replace(/\\r/g, '\r') 132 | key = key.replace(/\\t/g, '\t') 133 | key = key.replace(/\\\\/g, '\\') 134 | entry.key = key 135 | entry.keyWithNamespace = entry.namespace + this.options.keySeparator + key 136 | // Add the filename so that we can use it in customValueTemplate 137 | this.keyToFilePaths[key] = this.keyToFilePaths[key] || [] 138 | if (!this.keyToFilePaths[key].includes(file.path)) { 139 | this.keyToFilePaths[key].push(file.path) 140 | } 141 | entry.filePaths = this.keyToFilePaths[key] 142 | 143 | this.addEntry(entry) 144 | } 145 | 146 | done() 147 | } 148 | 149 | _flush(done) { 150 | let maybeSortedLocales = this.options.locales 151 | if (this.options.resetDefaultValueLocale) { 152 | // ensure we process the reset locale first 153 | maybeSortedLocales.sort((a) => 154 | a === this.options.resetDefaultValueLocale ? -1 : 1 155 | ) 156 | } 157 | 158 | // Tracks keys to reset by namespace 159 | let resetValues = {} 160 | 161 | for (const locale of maybeSortedLocales) { 162 | const catalog = {} 163 | const resetAndFlag = this.options.resetDefaultValueLocale === locale 164 | 165 | let uniqueCount = {} 166 | let uniquePluralsCount = {} 167 | 168 | const transformEntry = (entry, suffix) => { 169 | if (uniqueCount[entry.namespace] === undefined) { 170 | uniqueCount[entry.namespace] = 0 171 | } 172 | if (uniquePluralsCount[entry.namespace] === undefined) { 173 | uniquePluralsCount[entry.namespace] = 0 174 | } 175 | 176 | const { duplicate, conflict } = dotPathToHash(entry, catalog, { 177 | suffix, 178 | locale, 179 | separator: this.options.keySeparator, 180 | pluralSeparator: this.options.pluralSeparator, 181 | value: this.options.defaultValue, 182 | customValueTemplate: this.options.customValueTemplate, 183 | }) 184 | 185 | if (duplicate) { 186 | if (conflict === 'key') { 187 | this.warn( 188 | `Found translation key already mapped to a map or parent of ` + 189 | `new key already mapped to a string: ${ 190 | entry.namespace + this.options.namespaceSeparator + entry.key 191 | }` 192 | ) 193 | } else if (conflict === 'value') { 194 | this.warn( 195 | `Found same keys with different values: ${ 196 | entry.namespace + this.options.namespaceSeparator + entry.key 197 | }` 198 | ) 199 | } 200 | } else { 201 | uniqueCount[entry.namespace] += 1 202 | if (suffix) { 203 | uniquePluralsCount[entry.namespace] += 1 204 | } 205 | } 206 | } 207 | 208 | // generates plurals according to i18next rules: key_zero, key_one, key_two, key_few, key_many and key_other 209 | for (const entry of this.entries) { 210 | if ( 211 | this.options.pluralSeparator !== false && 212 | entry.count !== undefined 213 | ) { 214 | this.i18next.services.pluralResolver 215 | .getSuffixes(locale, { ordinal: entry.ordinal }) 216 | .forEach((suffix) => { 217 | transformEntry(entry, suffix) 218 | }) 219 | } else { 220 | transformEntry(entry) 221 | } 222 | } 223 | 224 | const outputPath = path.resolve(this.options.output) 225 | 226 | for (const namespace in catalog) { 227 | let namespacePath = outputPath 228 | namespacePath = namespacePath.replace(this.localeRegex, locale) 229 | namespacePath = namespacePath.replace(this.namespaceRegex, namespace) 230 | 231 | let parsedNamespacePath = path.parse(namespacePath) 232 | 233 | const namespaceOldPath = path.join( 234 | parsedNamespacePath.dir, 235 | `${parsedNamespacePath.name}_old${parsedNamespacePath.ext}` 236 | ) 237 | 238 | let existingCatalog = this.getCatalog(namespacePath) 239 | let existingOldCatalog = this.getCatalog(namespaceOldPath) 240 | 241 | // merges existing translations with the new ones 242 | const { 243 | new: newCatalog, 244 | old: oldKeys, 245 | mergeCount, 246 | oldCount, 247 | reset: resetFlags, 248 | resetCount, 249 | } = mergeHashes( 250 | existingCatalog, 251 | catalog[namespace], 252 | { 253 | ...this.options, 254 | resetAndFlag, 255 | fullKeyPrefix: namespace + this.options.namespaceSeparator, 256 | }, 257 | resetValues[namespace] 258 | ) 259 | 260 | // record values to be reset 261 | // assumes that the 'default' namespace is processed first 262 | if (resetAndFlag && !resetValues[namespace]) { 263 | resetValues[namespace] = resetFlags 264 | } 265 | 266 | // restore old translations 267 | const { old: oldCatalog, mergeCount: restoreCount } = mergeHashes( 268 | existingOldCatalog, 269 | newCatalog, 270 | { ...this.options, keepRemoved: false } 271 | ) 272 | 273 | // backup unused translations 274 | transferValues(oldKeys, oldCatalog) 275 | 276 | if (this.options.verbose) { 277 | console.log(`[${locale}] ${namespace}`) 278 | console.log( 279 | `Unique keys: ${uniqueCount[namespace]} (${uniquePluralsCount[namespace]} are plurals)` 280 | ) 281 | const addCount = uniqueCount[namespace] - mergeCount 282 | console.log(`Added keys: ${addCount}`) 283 | console.log(`Restored keys: ${restoreCount}`) 284 | if (this.options.keepRemoved) { 285 | console.log(`Unreferenced keys: ${oldCount}`) 286 | } else { 287 | console.log(`Removed keys: ${oldCount}`) 288 | } 289 | if (this.options.resetDefaultValueLocale) { 290 | console.log(`Reset keys: ${resetCount}`) 291 | } 292 | console.log('') 293 | } 294 | 295 | if (this.options.failOnUpdate) { 296 | const addCount = uniqueCount[namespace] - mergeCount 297 | const refCount = 298 | addCount + restoreCount + (this.options.keepRemoved ? 0 : oldCount) 299 | if (refCount !== 0) { 300 | this.parserHadUpdate = true 301 | continue 302 | } 303 | } 304 | 305 | if (this.options.failOnWarnings && this.parserHadWarnings) { 306 | continue 307 | } 308 | 309 | let maybeSortedNewCatalog = newCatalog 310 | let maybeSortedOldCatalog = oldCatalog 311 | const { sort } = this.options 312 | if (sort) { 313 | const compare = 314 | typeof sort === 'function' 315 | ? sort 316 | : makeDefaultSort(this.options.pluralSeparator) 317 | maybeSortedNewCatalog = sortKeys(newCatalog, { 318 | deep: true, 319 | compare, 320 | }) 321 | 322 | maybeSortedOldCatalog = sortKeys(oldCatalog, { deep: true, compare }) 323 | 324 | if (this.options.failOnUpdate && !this.parserHadUpdate) { 325 | // No updates to the catalog, so we do the deeper check to ensure it is also sorted. 326 | // This is easiest to accomplish by simply converting both objects to JSON, as we'd have 327 | // to do a deep comparison anyway 328 | this.parserHadSortUpdate = 329 | JSON.stringify(maybeSortedNewCatalog) !== 330 | JSON.stringify(existingCatalog) 331 | } 332 | } 333 | 334 | // push files back to the stream 335 | this.pushFile(namespacePath, maybeSortedNewCatalog) 336 | if ( 337 | this.options.createOldCatalogs && 338 | (Object.keys(oldCatalog).length || existingOldCatalog) 339 | ) { 340 | this.pushFile(namespaceOldPath, maybeSortedOldCatalog) 341 | } 342 | } 343 | } 344 | 345 | if (this.options.failOnWarnings && this.parserHadWarnings) { 346 | this.emit( 347 | 'error', 348 | 'Warnings were triggered and failOnWarnings option is enabled. Exiting...' 349 | ) 350 | process.exit(1) 351 | } 352 | 353 | if (this.options.failOnUpdate) { 354 | if (this.parserHadUpdate) { 355 | this.emit( 356 | 'error', 357 | 'Some translations was updated and failOnUpdate option is enabled. Exiting...' 358 | ) 359 | process.exit(1) 360 | } 361 | if (this.parserHadSortUpdate) { 362 | this.emit( 363 | 'error', 364 | 'Some keys were sorted and failOnUpdate option is enabled. Exiting...' 365 | ) 366 | process.exit(1) 367 | } 368 | } 369 | 370 | done() 371 | } 372 | 373 | addEntry(entry) { 374 | if (entry.context) { 375 | const contextEntry = Object.assign({}, entry) 376 | delete contextEntry.context 377 | contextEntry.key += this.options.contextSeparator + entry.context 378 | contextEntry.keyWithNamespace += 379 | this.options.contextSeparator + entry.context 380 | this.entries.push(contextEntry) 381 | } else { 382 | this.entries.push(entry) 383 | } 384 | } 385 | 386 | getCatalog(path) { 387 | try { 388 | let content 389 | if (path.endsWith('yml')) { 390 | content = yaml.load(fs.readFileSync(path).toString()) 391 | } else { 392 | content = JSON.parse(fs.readFileSync(path)) 393 | } 394 | return content 395 | } catch (error) { 396 | if (error.code !== 'ENOENT') { 397 | this.emit('error', error) 398 | } 399 | } 400 | 401 | return null 402 | } 403 | 404 | pushFile(path, contents) { 405 | let text 406 | if (path.endsWith('yml')) { 407 | text = yaml.dump(contents, { 408 | indent: this.options.indentation, 409 | ...this.options.yamlOptions, 410 | }) 411 | } else { 412 | text = JSON.stringify(contents, null, this.options.indentation) + '\n' 413 | // Convert non-printable Unicode characters to unicode escape sequence 414 | // https://unicode.org/reports/tr18/#General_Category_Property 415 | text = text.replace(/[\p{Z}\p{Cc}\p{Cf}]/gu, (chr) => { 416 | const n = chr.charCodeAt(0) 417 | return n < 128 ? chr : `\\u${`0000${n.toString(16)}`.substr(-4)}` 418 | }) 419 | } 420 | 421 | if (this.options.lineEnding === 'auto') { 422 | text = eol.auto(text) 423 | } else if ( 424 | this.options.lineEnding === '\r\n' || 425 | this.options.lineEnding === 'crlf' 426 | ) { 427 | text = eol.crlf(text) 428 | } else if ( 429 | this.options.lineEnding === '\r' || 430 | this.options.lineEnding === 'cr' 431 | ) { 432 | text = eol.cr(text) 433 | } else { 434 | // Defaults to LF, aka \n 435 | text = eol.lf(text) 436 | } 437 | 438 | const file = new VirtualFile({ 439 | path, 440 | contents: Buffer.from(text), 441 | }) 442 | this.push(file) 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /test/Intl.PluralRules.mock.js: -------------------------------------------------------------------------------- 1 | export default class PluralRules { 2 | constructor(locale) { 3 | this.locale = locale 4 | } 5 | 6 | resolvedOptions() { 7 | let pluralCategories = ['one', 'other'] 8 | 9 | if (this.locale === 'fr') { 10 | pluralCategories = ['one', 'many', 'other'] 11 | } 12 | 13 | return { 14 | pluralCategories, 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/broccoli/Brocfile.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileURLToPath } from 'url' 3 | import Funnel from 'broccoli-funnel' 4 | // In a real use case, it should be: 5 | // import i18next from 'i18next-parser').broccol 6 | import { broccoli as i18nextParser } from '../../src/index.js' 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 9 | const appRoot = path.resolve(__dirname, 'src') 10 | 11 | const filesToParse = new Funnel(appRoot, { 12 | files: ['handlebars.hbs', 'javascript.js'], 13 | annotation: 'files for i18next-parser', 14 | }) 15 | 16 | const i18n = new i18nextParser([filesToParse], { 17 | output: path.resolve(__dirname, 'src/locales/$LOCALE/$NAMESPACE.json'), 18 | sort: true, 19 | silent: true, 20 | }) 21 | 22 | export default i18n 23 | -------------------------------------------------------------------------------- /test/broccoli/broccoli.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { fileURLToPath } from 'url' 3 | import broccoli from 'broccoli' 4 | import fs from 'fs-extra' 5 | import path from 'path' 6 | import sinon from 'sinon' 7 | import brocFile from './Brocfile.js' 8 | import PluralRulesMock from '../Intl.PluralRules.mock.js' 9 | 10 | const { Builder } = broccoli 11 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 12 | const broccoliTmpDir = path.join(__dirname, '_broccoli_tmp') 13 | 14 | describe('broccoli plugin', function () { 15 | // test execution time depends on I/O 16 | this.timeout(0) 17 | 18 | let builder = null 19 | 20 | beforeEach(async () => { 21 | await fs.emptyDir(path.resolve(__dirname, './src/locales')) 22 | await fs.emptyDir(broccoliTmpDir) 23 | 24 | sinon.replace(Intl, 'PluralRules', PluralRulesMock) 25 | builder = new Builder(brocFile, { 26 | // Place temporary files someplace under the current working directory, 27 | // as glob-stream doesn't look further down on the file system than that 28 | tmpdir: broccoliTmpDir, 29 | }) 30 | }) 31 | 32 | afterEach(async () => { 33 | await fs.emptyDir(path.resolve(__dirname, './src/locales')) 34 | 35 | await builder.cleanup() 36 | sinon.restore() 37 | }) 38 | 39 | it('works as a broccoli plugin', async () => { 40 | await builder.build() 41 | 42 | const enTranslation = await fs.readJson( 43 | path.resolve(__dirname, './src/locales/en/translation.json') 44 | ) 45 | 46 | try { 47 | await fs.readJson( 48 | path.resolve(__dirname, './src/locales/en/translation_old.json') 49 | ) 50 | } catch (error) { 51 | assert.strictEqual(error.code, 'ENOENT') 52 | } 53 | 54 | const frTranslation = await fs.readJson( 55 | path.resolve(__dirname, './src/locales/fr/translation.json') 56 | ) 57 | 58 | try { 59 | await fs.readJson( 60 | path.resolve(__dirname, './src/locales/fr/translation_old.json') 61 | ) 62 | } catch (error) { 63 | assert.strictEqual(error.code, 'ENOENT') 64 | } 65 | 66 | assert.deepEqual(enTranslation, { 67 | eighth_one: '', 68 | eighth_other: '', 69 | fifth_male: '', 70 | first: '', 71 | fourth: '', 72 | fourth_male: 'defaultValue', 73 | second: 'defaultValue', 74 | second_male: 'defaultValue', 75 | seventh: 'defaultValue', 76 | sixth: '', 77 | third: '{{var}} defaultValue', 78 | third_female: 'defaultValue', 79 | }) 80 | 81 | assert.deepEqual(frTranslation, { 82 | eighth_one: '', 83 | eighth_many: '', 84 | eighth_other: '', 85 | fifth_male: '', 86 | first: '', 87 | fourth: '', 88 | fourth_male: 'defaultValue', 89 | second: 'defaultValue', 90 | second_male: 'defaultValue', 91 | seventh: 'defaultValue', 92 | sixth: '', 93 | third: '{{var}} defaultValue', 94 | third_female: 'defaultValue', 95 | }) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /test/broccoli/src/handlebars.hbs: -------------------------------------------------------------------------------- 1 |
2 |

{{t "first"}}

3 |

{{t "second" "defaultValue" option="foo" context="male"}}

4 |

{{t "third" defaultValue="defaultValue" context="female"}}

5 |

{{t "fourth" context="male" bla='asd' defaultValue="defaultValue"}}

6 |

{{t 7 | variable 8 | "defaultValue" 9 | option="foo" 10 | context="male" 11 | }}

12 |

{{t "fifth" variable option="foo" context="male"}}

13 |

{{t bljha bla-bla "second dsf" "defaultValue" option="foo" context="male"}}

14 | {{link-to (t "sixth") "foo"}} 15 | {{link-to (t "seventh" "defaultValue") "foo"}} 16 |

{{t "eighth" count="22"}}

17 |
18 | -------------------------------------------------------------------------------- /test/broccoli/src/javascript.js: -------------------------------------------------------------------------------- 1 | import bla from 'bla'; 2 | 3 | notRelated() 4 | i18n.t('first') 5 | i18n.t('second', 'defaultValue') 6 | i18n.t('third', { 7 | defaultValue: '{{var}} defaultValue' 8 | }) 9 | i18n.t( 10 | 'fou' + 11 | 'rt' + 12 | 'h' 13 | ) 14 | if (true) { 15 | i18n.t('not picked' + variable, {foo: bar}, 'bla' + 'asd', {}, foo+bar+baz ) 16 | } 17 | i18n.t(variable, {foo: bar}, 'bla' + 'asd', {}, foo+bar+baz ) 18 | -------------------------------------------------------------------------------- /test/cli/cli.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { fileURLToPath } from 'url' 3 | import { execaCommand } from 'execa' 4 | import fs from 'fs-extra' 5 | import path from 'path' 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 8 | 9 | describe('CLI', function () { 10 | // test execution time depends on I/O 11 | this.timeout(0) 12 | 13 | it('works without options', async () => { 14 | const subprocess = await execaCommand('yarn test:cli') 15 | 16 | const enTranslation = await fs.readJson( 17 | path.resolve(__dirname, './locales/en/translation.json') 18 | ) 19 | 20 | try { 21 | await fs.readJson( 22 | path.resolve(__dirname, './locales/en/translation_old.json') 23 | ) 24 | } catch (error) { 25 | assert.strictEqual(error.code, 'ENOENT') 26 | } 27 | 28 | const enEmpty = await fs.readJson( 29 | path.resolve(__dirname, './locales/en/empty.json') 30 | ) 31 | 32 | try { 33 | await fs.readJson(path.resolve(__dirname, './locales/en/empty_old.json')) 34 | } catch (error) { 35 | assert.strictEqual(error.code, 'ENOENT') 36 | } 37 | 38 | const frTranslation = await fs.readJson( 39 | path.resolve(__dirname, './locales/fr/translation.json') 40 | ) 41 | 42 | try { 43 | await fs.readJson( 44 | path.resolve(__dirname, './locales/fr/translation_old.json') 45 | ) 46 | } catch (error) { 47 | assert.strictEqual(error.code, 'ENOENT') 48 | } 49 | 50 | const frEmpty = await fs.readJson( 51 | path.resolve(__dirname, './locales/fr/empty.json') 52 | ) 53 | 54 | try { 55 | await fs.readJson(path.resolve(__dirname, './locales/fr/empty_old.json')) 56 | } catch (error) { 57 | assert.strictEqual(error.code, 'ENOENT') 58 | } 59 | 60 | assert.deepEqual(enTranslation, { 61 | selfClosing: '', 62 | first: '', 63 | second: '', 64 | third: '', 65 | fourth: '', 66 | fifth: 'bar', 67 | sixth: '', 68 | seventh: 'bar', 69 | }) 70 | assert.deepEqual(enEmpty, {}) 71 | 72 | assert.deepEqual(frTranslation, { 73 | selfClosing: '', 74 | first: '', 75 | second: '', 76 | third: '', 77 | fourth: '', 78 | fifth: 'bar', 79 | sixth: '', 80 | seventh: 'bar', 81 | }) 82 | assert.deepEqual(frEmpty, {}) 83 | }) 84 | 85 | it('works with `--fail-on-warnings` option', async () => { 86 | try { 87 | await execaCommand('yarn test:cli --fail-on-warnings') 88 | } catch (error) { 89 | assert.strictEqual(error.exitCode, 1) 90 | assert.include( 91 | error.stdout.replace(), 92 | 'Warnings were triggered and failOnWarnings option is enabled. Exiting...' 93 | ) 94 | } 95 | }) 96 | 97 | it('works when no input found', async () => { 98 | const subprocess = await execaCommand( 99 | 'yarn test:cli cli/**/*.{ts,jsx} --fail-on-warnings' 100 | ) 101 | 102 | assert.include(subprocess.stdout, '0 files were parsed') 103 | }) 104 | 105 | it('works with `--fail-on-update` option', async () => { 106 | try { 107 | await execaCommand('yarn test:cli --fail-on-update') 108 | } catch (error) { 109 | assert.strictEqual(error.exitCode, 1) 110 | assert.include( 111 | error.stdout.replace(), 112 | 'Some translations was updated and failOnUpdate option is enabled. Exiting...' 113 | ) 114 | } 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/cli/html.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Lorem ipsum dolor sit amet, consectetur adipiscing elit 10 | 11 | 12 | 13 |
14 |

First

15 |

second

16 |

Fourth

17 |

23 |

asd

24 |

25 | Seventh 26 |

27 |

28 | Seventh 29 |

30 |

Empty

31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /test/cli/i18next-parser.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileURLToPath } from 'url' 3 | 4 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 5 | 6 | export default { 7 | input: ['**/*.html'], 8 | output: path.resolve(__dirname, 'locales/$LOCALE/$NAMESPACE.json'), 9 | sort: true, 10 | } 11 | -------------------------------------------------------------------------------- /test/cli/i18next-parser.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileURLToPath } from 'url' 3 | 4 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 5 | 6 | export default { 7 | input: ['**/*.html'], 8 | output: path.resolve(__dirname, 'locales/$LOCALE/$NAMESPACE.json'), 9 | sort: true, 10 | } 11 | -------------------------------------------------------------------------------- /test/cli/i18next-parser.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileURLToPath } from 'url' 3 | 4 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 5 | 6 | export default { 7 | input: ['**/*.html'], 8 | output: path.resolve(__dirname, 'locales/$LOCALE/$NAMESPACE.json'), 9 | sort: true, 10 | } 11 | -------------------------------------------------------------------------------- /test/cli/i18next-parser.config.yaml: -------------------------------------------------------------------------------- 1 | input: 2 | - '**/*.html' 3 | output: test/cli/locales/$LOCALE/$NAMESPACE.json 4 | sort: true 5 | -------------------------------------------------------------------------------- /test/gulp/gulp.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { pEvent } from 'p-event' 3 | import { fileURLToPath } from 'url' 4 | import fs from 'fs-extra' 5 | import gulp from 'gulp' 6 | import path from 'path' 7 | import sinon from 'sinon' 8 | import './gulpfile.js' 9 | import PluralRulesMock from '../Intl.PluralRules.mock.js' 10 | 11 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 12 | 13 | describe('gulp plugin', function () { 14 | // test execution time depends on I/O 15 | this.timeout(0) 16 | 17 | beforeEach(async () => { 18 | await fs.emptyDir(path.resolve(__dirname, './locales')) 19 | sinon.replace(Intl, 'PluralRules', PluralRulesMock) 20 | }) 21 | 22 | afterEach(async () => { 23 | await fs.emptyDir(path.resolve(__dirname, './locales')) 24 | sinon.restore() 25 | }) 26 | 27 | it('works as a gulp plugin', async () => { 28 | const gulpStream = gulp.task('i18next')() 29 | 30 | await pEvent(gulpStream, 'end') 31 | 32 | const enReact = await fs.readJson( 33 | path.resolve(__dirname, './locales/en/react.json') 34 | ) 35 | 36 | try { 37 | await fs.readJson(path.resolve(__dirname, './locales/en/react_old.json')) 38 | } catch (error) { 39 | assert.strictEqual(error.code, 'ENOENT') 40 | } 41 | 42 | const enNamespace = await fs.readJson( 43 | path.resolve(__dirname, './locales/en/test-namespace.json') 44 | ) 45 | 46 | try { 47 | await fs.readJson( 48 | path.resolve(__dirname, './locales/en/test-namespace_old.json') 49 | ) 50 | } catch (error) { 51 | assert.strictEqual(error.code, 'ENOENT') 52 | } 53 | 54 | const enKeyPrefix = await fs.readJson( 55 | path.resolve(__dirname, './locales/en/key-prefix.json') 56 | ) 57 | 58 | try { 59 | await fs.readJson( 60 | path.resolve(__dirname, './locales/en/key-prefix_old.json') 61 | ) 62 | } catch (error) { 63 | assert.strictEqual(error.code, 'ENOENT') 64 | } 65 | 66 | const enTranslation = await fs.readJson( 67 | path.resolve(__dirname, './locales/en/translation.json') 68 | ) 69 | 70 | try { 71 | await fs.readJson( 72 | path.resolve(__dirname, './locales/en/translation_old.json') 73 | ) 74 | } catch (error) { 75 | assert.strictEqual(error.code, 'ENOENT') 76 | } 77 | 78 | const frReact = await fs.readJson( 79 | path.resolve(__dirname, './locales/fr/react.json') 80 | ) 81 | 82 | try { 83 | await fs.readJson(path.resolve(__dirname, './locales/fr/react_old.json')) 84 | } catch (error) { 85 | assert.strictEqual(error.code, 'ENOENT') 86 | } 87 | const frNamespace = await fs.readJson( 88 | path.resolve(__dirname, './locales/fr/test-namespace.json') 89 | ) 90 | 91 | try { 92 | await fs.readJson(path.resolve(__dirname, './locales/fr/react_old.json')) 93 | } catch (error) { 94 | assert.strictEqual(error.code, 'ENOENT') 95 | } 96 | const frTranslation = await fs.readJson( 97 | path.resolve(__dirname, './locales/fr/translation.json') 98 | ) 99 | 100 | try { 101 | await fs.readJson(path.resolve(__dirname, './locales/fr/react_old.json')) 102 | } catch (error) { 103 | assert.strictEqual(error.code, 'ENOENT') 104 | } 105 | 106 | assert.deepEqual(enReact, { 107 | bar: '', 108 | "don't split {{on}}": "don't split {{on}}", 109 | fifth_one: '', 110 | fifth_other: '', 111 | first: '', 112 | foo: '', 113 | fourth: '', 114 | 'override-default': 'default override', 115 | second: '', 116 | third: { 117 | first_one: 118 | 'Hello <1>{{name}}, you have {{count}} unread message. <5>Go to messages.', 119 | first_other: 120 | 'Hello <1>{{name}}, you have {{count}} unread message. <5>Go to messages.', 121 | second: " <1>Hello, this shouldn't be trimmed.", 122 | third: "<0>Hello,this should be trimmed.<2> and this shoudln't", 123 | }, 124 | 'This should be part of the value and the key': 125 | 'This should be part of the value and the key', 126 | }) 127 | assert.deepEqual(enNamespace, { 128 | 'test-1': '', 129 | 'test-2': '', 130 | }) 131 | assert.deepEqual(enKeyPrefix, { 132 | 'test-prefix': { 133 | foo: '', 134 | bar: '', 135 | }, 136 | }) 137 | assert.deepEqual(enTranslation, { 138 | fifth: 'bar', 139 | fifth_male: '', 140 | first: '', 141 | fourth: '', 142 | fourth_male: 'defaultValue', 143 | second: 'defaultValue', 144 | second_male: 'defaultValue', 145 | selfClosing: '', 146 | seventh: 'defaultValue', 147 | sixth: '', 148 | third: '{{var}} defaultValue', 149 | third_female: 'defaultValue', 150 | }) 151 | 152 | assert.deepEqual(frReact, { 153 | bar: '', 154 | "don't split {{on}}": "don't split {{on}}", 155 | fifth_one: '', 156 | fifth_many: '', 157 | fifth_other: '', 158 | first: '', 159 | foo: '', 160 | fourth: '', 161 | 'override-default': 'default override', 162 | second: '', 163 | third: { 164 | first_one: 165 | 'Hello <1>{{name}}, you have {{count}} unread message. <5>Go to messages.', 166 | first_many: 167 | 'Hello <1>{{name}}, you have {{count}} unread message. <5>Go to messages.', 168 | first_other: 169 | 'Hello <1>{{name}}, you have {{count}} unread message. <5>Go to messages.', 170 | second: " <1>Hello, this shouldn't be trimmed.", 171 | third: "<0>Hello,this should be trimmed.<2> and this shoudln't", 172 | }, 173 | 'This should be part of the value and the key': 174 | 'This should be part of the value and the key', 175 | }) 176 | assert.deepEqual(frNamespace, { 177 | 'test-1': '', 178 | 'test-2': '', 179 | }) 180 | assert.deepEqual(frTranslation, { 181 | fifth: 'bar', 182 | fifth_male: '', 183 | first: '', 184 | fourth: '', 185 | fourth_male: 'defaultValue', 186 | second: 'defaultValue', 187 | second_male: 'defaultValue', 188 | selfClosing: '', 189 | seventh: 'defaultValue', 190 | sixth: '', 191 | third: '{{var}} defaultValue', 192 | third_female: 'defaultValue', 193 | }) 194 | }) 195 | }) 196 | -------------------------------------------------------------------------------- /test/gulp/gulpfile.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url' 2 | import gulp from 'gulp' 3 | import path from 'path' 4 | // In a real use case, it should be: 5 | // import { gulp as i18next } from 'i18next-parser' 6 | import { gulp as i18next } from '../../src/index.js' 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 9 | 10 | gulp.task('i18next', function () { 11 | return gulp 12 | .src([path.resolve(__dirname, '../templating/*')]) 13 | .pipe( 14 | new i18next({ 15 | locales: ['en', 'fr'], 16 | output: path.resolve(__dirname, 'locales/$LOCALE/$NAMESPACE.json'), 17 | sort: true, 18 | }) 19 | ) 20 | .pipe(gulp.dest('./')) 21 | }) 22 | -------------------------------------------------------------------------------- /test/helpers/dotPathToHash.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { dotPathToHash } from '../../src/helpers.js' 3 | 4 | describe('dotPathToHash helper function', () => { 5 | it('creates an object from a string path', (done) => { 6 | const { target, duplicate } = dotPathToHash({ keyWithNamespace: 'one' }) 7 | assert.deepEqual(target, { one: '' }) 8 | assert.equal(duplicate, false) 9 | done() 10 | }) 11 | 12 | it('ignores trailing separator', (done) => { 13 | const { target } = dotPathToHash( 14 | { keyWithNamespace: 'one.two.' }, 15 | {}, 16 | { separator: '.' } 17 | ) 18 | assert.deepEqual(target, { one: { two: '' } }) 19 | done() 20 | }) 21 | 22 | it('ignores duplicated separator', (done) => { 23 | const { target } = dotPathToHash({ keyWithNamespace: 'one..two' }) 24 | assert.deepEqual(target, { one: { two: '' } }) 25 | done() 26 | }) 27 | 28 | it('supports custom separator', (done) => { 29 | const { target } = dotPathToHash( 30 | { keyWithNamespace: 'one-two' }, 31 | {}, 32 | { separator: '-' } 33 | ) 34 | assert.deepEqual(target, { one: { two: '' } }) 35 | done() 36 | }) 37 | 38 | it('handles an empty namespace', (done) => { 39 | const { target, duplicate } = dotPathToHash({ 40 | keyWithNamespace: 'ns.', 41 | namespace: 'ns', 42 | }) 43 | assert.deepEqual(target, { ns: {} }) 44 | assert.equal(duplicate, false) 45 | done() 46 | }) 47 | 48 | it('handles a target hash', (done) => { 49 | const { target, duplicate } = dotPathToHash( 50 | { keyWithNamespace: 'one.two.three' }, 51 | { one: { twenty: '' } } 52 | ) 53 | assert.deepEqual(target, { one: { two: { three: '' }, twenty: '' } }) 54 | assert.equal(duplicate, false) 55 | done() 56 | }) 57 | 58 | it('handles a `defaultValue` option', (done) => { 59 | const { target } = dotPathToHash( 60 | { keyWithNamespace: 'one' }, 61 | {}, 62 | { value: 'myDefaultValue' } 63 | ) 64 | assert.deepEqual(target, { one: 'myDefaultValue' }) 65 | done() 66 | }) 67 | 68 | it('handles a `separator` option', (done) => { 69 | const { target } = dotPathToHash( 70 | { keyWithNamespace: 'one_two_three.' }, 71 | {}, 72 | { separator: '_' } 73 | ) 74 | assert.deepEqual(target, { one: { two: { 'three.': '' } } }) 75 | done() 76 | }) 77 | 78 | it('detects duplicate keys with the same value', (done) => { 79 | const { target, duplicate, conflict } = dotPathToHash( 80 | { keyWithNamespace: 'one.two.three' }, 81 | { one: { two: { three: '' } } } 82 | ) 83 | assert.deepEqual(target, { one: { two: { three: '' } } }) 84 | assert.equal(duplicate, true) 85 | assert.equal(conflict, false) 86 | done() 87 | }) 88 | 89 | it('detects and overwrites duplicate keys with different values', (done) => { 90 | const { target, duplicate, conflict } = dotPathToHash( 91 | { keyWithNamespace: 'one.two.three', defaultValue: 'new' }, 92 | { one: { two: { three: 'old' } } } 93 | ) 94 | assert.deepEqual(target, { one: { two: { three: 'new' } } }) 95 | assert.equal(duplicate, true) 96 | assert.equal(conflict, 'value') 97 | done() 98 | }) 99 | 100 | it('overwrites keys already mapped to a string with an object value', (done) => { 101 | const { target, duplicate, conflict } = dotPathToHash( 102 | { keyWithNamespace: 'one', defaultValue: 'bla' }, 103 | { one: { two: { three: 'bla' } } } 104 | ) 105 | assert.deepEqual(target, { one: 'bla' }) 106 | assert.equal(duplicate, true) 107 | assert.equal(conflict, 'key') 108 | done() 109 | }) 110 | 111 | it('overwrites keys already mapped to an object with a string value', (done) => { 112 | const { target, duplicate, conflict } = dotPathToHash( 113 | { keyWithNamespace: 'one.two.three', defaultValue: 'bla' }, 114 | { one: 'bla' } 115 | ) 116 | assert.deepEqual(target, { one: { two: { three: 'bla' } } }) 117 | assert.equal(duplicate, true) 118 | assert.equal(conflict, 'key') 119 | done() 120 | }) 121 | 122 | it('uses old value when there is no new value and does not conflict', (done) => { 123 | const { target, duplicate, conflict } = dotPathToHash( 124 | { keyWithNamespace: 'one.two.three' }, 125 | { one: { two: { three: 'old' } } } 126 | ) 127 | assert.deepEqual(target, { one: { two: { three: 'old' } } }) 128 | assert.equal(duplicate, true) 129 | assert.equal(conflict, false) 130 | done() 131 | }) 132 | 133 | it('uses new value when there is no old value and does not conflict', (done) => { 134 | const { target, duplicate, conflict } = dotPathToHash( 135 | { keyWithNamespace: 'one.two.three', defaultValue: 'new' }, 136 | { one: { two: { three: '' } } } 137 | ) 138 | assert.deepEqual(target, { one: { two: { three: 'new' } } }) 139 | assert.equal(duplicate, true) 140 | assert.equal(conflict, false) 141 | done() 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /test/helpers/getPluralSuffixPosition.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { getPluralSuffixPosition } from '../../src/helpers.js' 3 | 4 | describe('getPluralSuffixPosition helper function', () => { 5 | it('returns suffix position for all valid plural forms', (done) => { 6 | const keys = { 7 | // default pluralSeparator 8 | key1_other: 5, 9 | key1_many: 4, 10 | key1_few: 3, 11 | key1_two: 2, 12 | key1_one: 1, 13 | key1_zero: 0, 14 | 15 | // custom pluralSeparator 16 | 'key1|other': 5, 17 | 'key1|many': 4, 18 | 'key1|few': 3, 19 | 'key1|two': 2, 20 | 'key1|one': 1, 21 | 'key1|zero': 0, 22 | } 23 | 24 | Object.entries(keys).forEach(([key, position]) => { 25 | const res = getPluralSuffixPosition(key) 26 | 27 | assert.strictEqual(res, position) 28 | }) 29 | 30 | done() 31 | }) 32 | 33 | it('returns `-1` for non plural keys', (done) => { 34 | const nonPluralKeys = [ 35 | 'key1', 36 | 'key1_context', 37 | 'key1_zero_edgeCase', 38 | 'key1_one_edgeCase', 39 | 'key1_two_edgeCase', 40 | 'key1_few_edgeCase', 41 | 'key1_many_edgeCase', 42 | 'key1_other_edgeCase', 43 | ] 44 | 45 | nonPluralKeys.forEach((key) => { 46 | const res = getPluralSuffixPosition(key) 47 | 48 | assert.strictEqual(res, -1) 49 | }) 50 | 51 | done() 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/helpers/getSingularForm.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { getSingularForm } from '../../src/helpers.js' 3 | 4 | describe('getSingularForm helper function', () => { 5 | it('returns singular key for all valid plural forms', (done) => { 6 | const pluralSeparator = '_' 7 | const validPluralForms = [ 8 | 'key1_zero', 9 | 'key1_one', 10 | 'key1_two', 11 | 'key1_few', 12 | 'key1_many', 13 | 'key1_other', 14 | ] 15 | 16 | validPluralForms.forEach((pluralKey) => { 17 | assert.strictEqual(getSingularForm(pluralKey, pluralSeparator), 'key1') 18 | }) 19 | 20 | done() 21 | }) 22 | 23 | it('returns the key unchanged when not in plural form', (done) => { 24 | const pluralSeparator = '_' 25 | const nonPluralKeys = [ 26 | 'key1', 27 | 'key1_context', 28 | 'key1-zero', 29 | 'key1-one', 30 | 'key1-two', 31 | 'key1-few', 32 | 'key1-many', 33 | 'key1-other', 34 | 'key1_zero_edgeCase', 35 | 'key1_one_edgeCase', 36 | 'key1_two_edgeCase', 37 | 'key1_few_edgeCase', 38 | 'key1_many_edgeCase', 39 | 'key1_other_edgeCase', 40 | ] 41 | 42 | nonPluralKeys.forEach((key) => { 43 | assert.strictEqual(getSingularForm(key, pluralSeparator), key) 44 | }) 45 | 46 | done() 47 | }) 48 | 49 | it('returns singular key for all valid plural forms with custom pluralSeparator', (done) => { 50 | const pluralSeparator = '|' 51 | const validPluralForms = [ 52 | 'key1|zero', 53 | 'key1|one', 54 | 'key1|two', 55 | 'key1|few', 56 | 'key1|many', 57 | 'key1|other', 58 | ] 59 | 60 | validPluralForms.forEach((pluralKey) => { 61 | assert.strictEqual(getSingularForm(pluralKey, pluralSeparator), 'key1') 62 | }) 63 | 64 | done() 65 | }) 66 | 67 | it('returns the key unchanged when not in plural form with custom pluralSeparator', (done) => { 68 | const pluralSeparator = '|' 69 | const nonPluralKeys = [ 70 | 'key1', 71 | 'key1_context', 72 | 'key1_zero', 73 | 'key1_one', 74 | 'key1_two', 75 | 'key1_few', 76 | 'key1_many', 77 | 'key1_other', 78 | 'key1|zero|edgeCase', 79 | 'key1|one|edgeCase', 80 | 'key1|two|edgeCase', 81 | 'key1|few|edgeCase', 82 | 'key1|many|edgeCase', 83 | 'key1|other|edgeCase', 84 | ] 85 | 86 | nonPluralKeys.forEach((key) => { 87 | assert.strictEqual(getSingularForm(key, pluralSeparator), key) 88 | }) 89 | 90 | done() 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /test/helpers/hasRelatedPluralKey.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { hasRelatedPluralKey } from '../../src/helpers.js' 3 | 4 | describe('hasRelatedPluralKey helper function', () => { 5 | it('returns false when `source` does not contain valid plural form of `rawKey`', (done) => { 6 | const rawKey = 'key1_' 7 | const source = { 8 | key1: '', 9 | key1_: '', 10 | key1_0: '', 11 | key1_1: '', 12 | key1_three: '', 13 | } 14 | const res = hasRelatedPluralKey(rawKey, source) 15 | 16 | assert.strictEqual(res, false) 17 | done() 18 | }) 19 | 20 | it('returns true when `source` contains any valid plural form of `rawKey`', (done) => { 21 | const rawKey = 'key1_' 22 | const sources = [ 23 | { key1_zero: '' }, 24 | { key1_one: '' }, 25 | { key1_two: '' }, 26 | { key1_few: '' }, 27 | { key1_many: '' }, 28 | { key1_other: '' }, 29 | { 30 | key1_zero: '', 31 | key1_one: '', 32 | key1_two: '', 33 | key1_few: '', 34 | key1_many: '', 35 | key1_other: '', 36 | }, 37 | ] 38 | 39 | sources.forEach((source) => { 40 | const res = hasRelatedPluralKey(rawKey, source) 41 | 42 | assert.strictEqual(res, true) 43 | }) 44 | 45 | done() 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /test/helpers/makeDefaultSort.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { makeDefaultSort } from '../../src/helpers.js' 3 | 4 | describe('makeDefaultSort helper function', () => { 5 | it('sorts the keys alphanumerically', (done) => { 6 | const pluralSeparator = '_' 7 | const keys = ['BBB', '222', 'bbb', 'AAA', '111', 'aaa'] 8 | 9 | const defaultSort = makeDefaultSort(pluralSeparator) 10 | const res = keys.sort(defaultSort) 11 | 12 | assert.deepEqual(res, ['111', '222', 'aaa', 'AAA', 'bbb', 'BBB']) 13 | 14 | done() 15 | }) 16 | 17 | it('sorts plural keys in count order', (done) => { 18 | const pluralSeparator = '_' 19 | const keys = [ 20 | 'key1_two', 21 | 'key1_other', 22 | 'key1_zero', 23 | 'key1_many', 24 | 'key1_few', 25 | 'key1_one', 26 | ] 27 | 28 | const defaultSort = makeDefaultSort(pluralSeparator) 29 | const res = keys.sort(defaultSort) 30 | 31 | assert.deepEqual(res, [ 32 | 'key1_zero', 33 | 'key1_one', 34 | 'key1_two', 35 | 'key1_few', 36 | 'key1_many', 37 | 'key1_other', 38 | ]) 39 | 40 | done() 41 | }) 42 | 43 | it('sorts plural keys among other one', (done) => { 44 | const pluralSeparator = '_' 45 | const keys = [ 46 | 'key1_two', 47 | 'key1_other', 48 | 'key1_zero', 49 | 'key1_male', 50 | 'key1', 51 | 'key1_many', 52 | 'key1_few', 53 | 'key2', 54 | 'key1_female', 55 | 'key1_one', 56 | ] 57 | 58 | const defaultSort = makeDefaultSort(pluralSeparator) 59 | const res = keys.sort(defaultSort) 60 | 61 | assert.deepEqual(res, [ 62 | 'key1', 63 | 'key1_zero', 64 | 'key1_one', 65 | 'key1_two', 66 | 'key1_few', 67 | 'key1_many', 68 | 'key1_other', 69 | 'key1_female', 70 | 'key1_male', 71 | 'key2', 72 | ]) 73 | 74 | done() 75 | }) 76 | 77 | it('sorts keys with custom `pluralSelector`', (done) => { 78 | const pluralSeparator = '|' 79 | const keys = [ 80 | 'key1|two', 81 | 'key1|other', 82 | 'key1|zero', 83 | 'key1_male', 84 | 'key1', 85 | 'key12', 86 | 'key1|many', 87 | 'key1|few', 88 | 'key2', 89 | 'key1_female', 90 | 'key1|one', 91 | ] 92 | 93 | const defaultSort = makeDefaultSort(pluralSeparator) 94 | const res = keys.sort(defaultSort) 95 | 96 | assert.deepEqual(res, [ 97 | 'key1', 98 | 'key1|zero', 99 | 'key1|one', 100 | 'key1|two', 101 | 'key1|few', 102 | 'key1|many', 103 | 'key1|other', 104 | 'key1_female', 105 | 'key1_male', 106 | 'key12', 107 | 'key2', 108 | ]) 109 | 110 | done() 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /test/helpers/mergeHashes.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { mergeHashes } from '../../src/helpers.js' 3 | 4 | describe('mergeHashes helper function', () => { 5 | it('replaces empty `target` keys with `source`', (done) => { 6 | const source = { key1: 'value1' } 7 | const target = { key1: '' } 8 | const res = mergeHashes(source, target) 9 | 10 | assert.deepEqual(res.new, { key1: 'value1' }) 11 | assert.deepEqual(res.old, {}) 12 | assert.strictEqual(res.mergeCount, 1) 13 | assert.strictEqual(res.pullCount, 0) 14 | assert.strictEqual(res.oldCount, 0) 15 | done() 16 | }) 17 | 18 | it('does not replace empty `target` keys with `source` if it is a hash', (done) => { 19 | const source = { key1: { key11: 'value1' } } 20 | const target = { key1: '' } 21 | const res = mergeHashes(source, target) 22 | 23 | assert.deepEqual(res.new, { key1: '' }) 24 | assert.deepEqual(res.old, { key1: { key11: 'value1' } }) 25 | assert.strictEqual(res.mergeCount, 0) 26 | assert.strictEqual(res.pullCount, 0) 27 | assert.strictEqual(res.oldCount, 1) 28 | done() 29 | }) 30 | 31 | it('keeps `target` keys not in `source`', (done) => { 32 | const source = { key1: 'value1' } 33 | const target = { key1: '', key2: '' } 34 | const res = mergeHashes(source, target) 35 | 36 | assert.deepEqual(res.new, { key1: 'value1', key2: '' }) 37 | assert.deepEqual(res.old, {}) 38 | assert.strictEqual(res.mergeCount, 1) 39 | assert.strictEqual(res.pullCount, 0) 40 | assert.strictEqual(res.oldCount, 0) 41 | done() 42 | }) 43 | 44 | it('stores into `old` the keys from `source` that are not in `target`', (done) => { 45 | const source = { key1: 'value1', key2: 'value2' } 46 | const target = { key1: '' } 47 | const res = mergeHashes(source, target) 48 | 49 | assert.deepEqual(res.new, { key1: 'value1' }) 50 | assert.deepEqual(res.old, { key2: 'value2' }) 51 | assert.strictEqual(res.mergeCount, 1) 52 | assert.strictEqual(res.pullCount, 0) 53 | assert.strictEqual(res.oldCount, 1) 54 | done() 55 | }) 56 | 57 | it('copies `source` keys to `target` regardless of presence when `keepRemoved` is enabled', (done) => { 58 | const source = { 59 | key1: 'value1', 60 | key2: 'value2', 61 | key4: { key41: 'value41' }, 62 | } 63 | const target = { key1: '', key3: '' } 64 | const res = mergeHashes(source, target, { keepRemoved: true }) 65 | 66 | assert.deepEqual(res.new, { 67 | key1: 'value1', 68 | key2: 'value2', 69 | key3: '', 70 | key4: { key41: 'value41' }, 71 | }) 72 | assert.deepEqual(res.old, {}) 73 | assert.strictEqual(res.mergeCount, 1) 74 | assert.strictEqual(res.pullCount, 0) 75 | assert.strictEqual(res.oldCount, 2) 76 | done() 77 | }) 78 | 79 | it('copies `source` nested keys to `target` regardless of presence when `keepRemoved` is enabled', (done) => { 80 | const source = { 81 | key1: 'value1', 82 | key2: 'value2', 83 | key4: { key41: 'value41' }, 84 | } 85 | const target = { key1: '', key3: '', key4: { key42: '' } } 86 | const res = mergeHashes(source, target, { keepRemoved: true }) 87 | 88 | assert.deepEqual(res.new, { 89 | key1: 'value1', 90 | key2: 'value2', 91 | key3: '', 92 | key4: { key41: 'value41', key42: '' }, 93 | }) 94 | assert.deepEqual(res.old, {}) 95 | assert.strictEqual(res.mergeCount, 1) 96 | assert.strictEqual(res.pullCount, 0) 97 | assert.strictEqual(res.oldCount, 2) 98 | done() 99 | }) 100 | 101 | it('restores plural keys when the singular one exists', (done) => { 102 | const source = { key1_one: '', key1_other: 'value1' } 103 | const target = { key1_one: '' } 104 | const res = mergeHashes(source, target) 105 | 106 | assert.deepEqual(res.new, { key1_one: '', key1_other: 'value1' }) 107 | assert.deepEqual(res.old, {}) 108 | assert.strictEqual(res.mergeCount, 1) 109 | assert.strictEqual(res.pullCount, 1) 110 | assert.strictEqual(res.oldCount, 0) 111 | done() 112 | }) 113 | 114 | it('does not restore plural keys when the singular one does not', (done) => { 115 | const source = { key1_one: '', key1_other: 'value1' } 116 | const target = { key2: '' } 117 | const res = mergeHashes(source, target) 118 | 119 | assert.deepEqual(res.new, { key2: '' }) 120 | assert.deepEqual(res.old, { key1_one: '', key1_other: 'value1' }) 121 | assert.strictEqual(res.mergeCount, 0) 122 | assert.strictEqual(res.pullCount, 0) 123 | assert.strictEqual(res.oldCount, 2) 124 | done() 125 | }) 126 | 127 | it('restores context keys when the singular one exists', (done) => { 128 | const source = { key1: '', key1_context: 'value1' } 129 | const target = { key1: '' } 130 | const res = mergeHashes(source, target) 131 | 132 | assert.deepEqual(res.new, { key1: '', key1_context: 'value1' }) 133 | assert.deepEqual(res.old, {}) 134 | assert.strictEqual(res.mergeCount, 1) 135 | assert.strictEqual(res.pullCount, 1) 136 | assert.strictEqual(res.oldCount, 0) 137 | done() 138 | }) 139 | 140 | it('restores context keys when the singular one exists (custom contextSeparator)', (done) => { 141 | const source = { key1: '', 'key1|context': 'value1' } 142 | const target = { key1: '' } 143 | const res = mergeHashes(source, target, { contextSeparator: '|' }) 144 | 145 | assert.deepEqual(res.new, { key1: '', 'key1|context': 'value1' }) 146 | assert.deepEqual(res.old, {}) 147 | assert.strictEqual(res.mergeCount, 1) 148 | assert.strictEqual(res.pullCount, 1) 149 | assert.strictEqual(res.oldCount, 0) 150 | done() 151 | }) 152 | 153 | it('does not restore context keys when the singular one does not', (done) => { 154 | const source = { key1: '', key1_context: 'value1' } 155 | const target = { key2: '' } 156 | const res = mergeHashes(source, target) 157 | 158 | assert.deepEqual(res.new, { key2: '' }) 159 | assert.deepEqual(res.old, { key1: '', key1_context: 'value1' }) 160 | assert.strictEqual(res.mergeCount, 0) 161 | assert.strictEqual(res.pullCount, 0) 162 | assert.strictEqual(res.oldCount, 2) 163 | done() 164 | }) 165 | 166 | it('does not restore context keys when the singular one does not (custom contextSeparator)', (done) => { 167 | const source = { key1: '', 'key1|context': 'value1' } 168 | const target = { key2: '' } 169 | const res = mergeHashes(source, target, { contextSeparator: '|' }) 170 | 171 | assert.deepEqual(res.new, { key2: '' }) 172 | assert.deepEqual(res.old, { key1: '', 'key1|context': 'value1' }) 173 | assert.strictEqual(res.mergeCount, 0) 174 | assert.strictEqual(res.pullCount, 0) 175 | assert.strictEqual(res.oldCount, 2) 176 | done() 177 | }) 178 | 179 | it('works with deep objects', (done) => { 180 | const source = { 181 | key1: 'value1', 182 | key2: { 183 | key21: 'value21', 184 | key22: { 185 | key221: 'value221', 186 | key222: 'value222', 187 | }, 188 | key23: 'value23', 189 | }, 190 | key4: { 191 | key41: 'value41', 192 | }, 193 | } 194 | const target = { 195 | key1: '', 196 | key2: { 197 | key21: '', 198 | key22: { 199 | key222: '', 200 | key223: '', 201 | }, 202 | key24: '', 203 | }, 204 | key3: '', 205 | key4: { 206 | key41: 'value41', 207 | }, 208 | } 209 | 210 | const res = mergeHashes(source, target) 211 | 212 | const expected_target = { 213 | key1: 'value1', 214 | key2: { 215 | key21: 'value21', 216 | key22: { 217 | key222: 'value222', 218 | key223: '', 219 | }, 220 | key24: '', 221 | }, 222 | key3: '', 223 | key4: { 224 | key41: 'value41', 225 | }, 226 | } 227 | 228 | const expected_old = { 229 | key2: { 230 | key22: { 231 | key221: 'value221', 232 | }, 233 | key23: 'value23', 234 | }, 235 | } 236 | 237 | assert.deepEqual(res.new, expected_target) 238 | assert.deepEqual(res.old, expected_old) 239 | assert.strictEqual(res.mergeCount, 4) 240 | assert.strictEqual(res.pullCount, 0) 241 | assert.strictEqual(res.oldCount, 2) 242 | done() 243 | }) 244 | 245 | it('leaves arrays of values (multiline) untouched', (done) => { 246 | const source = { key1: ['Line one.', 'Line two.'] } 247 | const target = { key1: '' } 248 | const res = mergeHashes(source, target) 249 | 250 | assert.deepEqual(res.new, { key1: ['Line one.', 'Line two.'] }) 251 | assert.deepEqual(res.old, {}) 252 | assert.strictEqual(res.mergeCount, 1) 253 | assert.strictEqual(res.pullCount, 0) 254 | assert.strictEqual(res.oldCount, 0) 255 | done() 256 | }) 257 | 258 | it('resets keys to the target value if they are flagged in the resetKeys object', (done) => { 259 | const source = { key1: 'key1', key2: 'key2' } 260 | const target = { key1: 'changedKey1', key2: 'changedKey2' } 261 | const res = mergeHashes(source, target, {}, { key1: true }) 262 | 263 | assert.deepEqual(res.new, { key1: 'changedKey1', key2: 'key2' }) 264 | assert.deepEqual(res.old, { key1: 'key1' }) 265 | assert.strictEqual(res.resetCount, 1) 266 | done() 267 | }) 268 | 269 | it('resets nested keys to the target value if they are flagged in the resetKeys object', (done) => { 270 | const source = { 271 | key1: { 272 | key2: 'key2', 273 | }, 274 | key3: { 275 | key4: 'key4', 276 | }, 277 | } 278 | const target = { 279 | key1: { 280 | key2: 'changedKey2', 281 | }, 282 | key3: { 283 | key4: 'changedKey4', 284 | }, 285 | } 286 | const res = mergeHashes(source, target, {}, { key1: { key2: true } }) 287 | 288 | assert.deepEqual(res.new, { 289 | key1: { 290 | key2: 'changedKey2', 291 | }, 292 | key3: { 293 | key4: 'key4', 294 | }, 295 | }) 296 | assert.deepEqual(res.old, { 297 | key1: { 298 | key2: 'key2', 299 | }, 300 | }) 301 | assert.strictEqual(res.resetCount, 1) 302 | done() 303 | }) 304 | 305 | it('ignores keys if they are plurals', (done) => { 306 | const source = { key1_one: 'key1', key2: 'key2' } 307 | const target = { key1_one: 'changedKey1', key2: 'changedKey2' } 308 | const res = mergeHashes(source, target, {}, { key1: true }) 309 | 310 | assert.deepEqual(res.new, { key1_one: 'key1', key2: 'key2' }) 311 | assert.deepEqual(res.old, {}) 312 | assert.strictEqual(res.resetCount, 0) 313 | done() 314 | }) 315 | 316 | it('resets and flags keys if the resetAndFlag value is set', (done) => { 317 | const source = { key1: 'key1', key2: 'key2' } 318 | const target = { key1: 'changedKey1', key2: 'key2' } 319 | const res = mergeHashes(source, target, { 320 | resetAndFlag: true, 321 | }) 322 | 323 | assert.deepEqual(res.new, { key1: 'changedKey1', key2: 'key2' }) 324 | assert.deepEqual(res.old, { key1: 'key1' }) 325 | assert.deepEqual(res.reset, { key1: true }) 326 | assert.strictEqual(res.resetCount, 1) 327 | done() 328 | }) 329 | 330 | it('resets and flags nested keys if the resetAndFlag value is set', (done) => { 331 | const source = { 332 | key1: { 333 | key2: 'key2', 334 | }, 335 | key3: { 336 | key4: 'key4', 337 | }, 338 | } 339 | const target = { 340 | key1: { 341 | key2: 'changedKey2', 342 | }, 343 | key3: { 344 | key4: 'key4', 345 | }, 346 | } 347 | const res = mergeHashes(source, target, { 348 | resetAndFlag: true, 349 | }) 350 | 351 | assert.deepEqual(res.new, { 352 | key1: { 353 | key2: 'changedKey2', 354 | }, 355 | key3: { 356 | key4: 'key4', 357 | }, 358 | }) 359 | assert.deepEqual(res.old, { 360 | key1: { 361 | key2: 'key2', 362 | }, 363 | }) 364 | assert.deepEqual(res.reset, { 365 | key1: { 366 | key2: true, 367 | }, 368 | }) 369 | assert.strictEqual(res.resetCount, 1) 370 | done() 371 | }) 372 | 373 | it('keeps the unused keys if they match patterns in keepRemoved', (done) => { 374 | const source = { key1: 'key1', key2: 'key2', dummy: 'unusedValue' } 375 | const target = { key1: 'code1' } 376 | const res = mergeHashes(source, target, { 377 | keepRemoved: [/key.*/], 378 | keySeparator: '.', 379 | }) 380 | assert.deepEqual(res.new, { 381 | key1: 'key1', 382 | key2: 'key2', 383 | }) 384 | assert.deepEqual(res.old, { 385 | dummy: 'unusedValue', 386 | }) 387 | done() 388 | }) 389 | 390 | it('keeps unused nested keys if they match the patterns in keepRemoved', (done) => { 391 | const source = { 392 | nesting: { key1: 'key1', key2: 'key2', dummy: 'unusedValue' }, 393 | key2: 'rootKey2', 394 | } 395 | const target = { nesting: { key1: 'code1' } } 396 | const res = mergeHashes(source, target, { 397 | keepRemoved: [/nesting\.key.*/], 398 | keySeparator: '.', 399 | }) 400 | assert.deepEqual(res.new, { 401 | nesting: { 402 | key1: 'key1', 403 | key2: 'key2', 404 | }, 405 | }) 406 | assert.deepEqual(res.old, { 407 | nesting: { 408 | dummy: 'unusedValue', 409 | }, 410 | key2: 'rootKey2', 411 | }) 412 | done() 413 | }) 414 | }) 415 | -------------------------------------------------------------------------------- /test/helpers/transferValues.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import { transferValues } from '../../src/helpers.js' 3 | 4 | describe('transferValues helper function', () => { 5 | it('sets undefined keys', (done) => { 6 | const source = { key1: 'value1', key2: { key21: 'value21' } } 7 | const target = {} 8 | transferValues(source, target) 9 | 10 | assert.deepEqual(target, source) 11 | done() 12 | }) 13 | 14 | it('overwrites existing keys', (done) => { 15 | const source = { key1: 'value1', key2: { key21: 'value21' } } 16 | const target = { key1: 'value1_old', key2: { key21: 'value21_old' } } 17 | transferValues(source, target) 18 | 19 | assert.deepEqual(target, source) 20 | done() 21 | }) 22 | 23 | it('keeps nonexisting keys', (done) => { 24 | const source = { key1: 'value1', key2: { key21: 'value21' } } 25 | const target = { key0: 'value0', key2: { key20: 'value20_old' } } 26 | transferValues(source, target) 27 | 28 | assert.deepEqual(target, { 29 | key0: 'value0', 30 | key1: 'value1', 31 | key2: { key20: 'value20_old', key21: 'value21' }, 32 | }) 33 | done() 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/lexers/base-lexer.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import BaseLexer from '../../src/lexers/base-lexer.js' 3 | 4 | describe('BaseLexer', () => { 5 | it('functionPattern() return a regex pattern', (done) => { 6 | const Lexer = new BaseLexer({ functions: ['this.t', '__'] }) 7 | assert.equal(Lexer.functionPattern(), '(?:this\\.t|__)') 8 | done() 9 | }) 10 | 11 | describe('validateString()', () => { 12 | it('matches double quote strings', (done) => { 13 | const Lexer = new BaseLexer() 14 | assert.equal(Lexer.validateString('"args"'), true) 15 | done() 16 | }) 17 | 18 | it('matches single quote strings', (done) => { 19 | const Lexer = new BaseLexer() 20 | assert.equal(Lexer.validateString("'args'"), true) 21 | done() 22 | }) 23 | 24 | it('does not match variables', (done) => { 25 | const Lexer = new BaseLexer() 26 | assert.equal(Lexer.validateString('args'), false) 27 | done() 28 | }) 29 | 30 | it('does not match null value', (done) => { 31 | const Lexer = new BaseLexer() 32 | assert.equal(Lexer.validateString(null), false) 33 | done() 34 | }) 35 | 36 | it('does not match undefined value', (done) => { 37 | const Lexer = new BaseLexer() 38 | assert.equal(Lexer.validateString(undefined), false) 39 | done() 40 | }) 41 | 42 | it('does not match empty string', (done) => { 43 | const Lexer = new BaseLexer() 44 | assert.equal(Lexer.validateString(''), false) 45 | done() 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /test/lexers/handlebars-lexer.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import HandlebarsLexer from '../../src/lexers/handlebars-lexer.js' 3 | 4 | describe('HandlebarsLexer', () => { 5 | it('extracts keys from translation components', (done) => { 6 | const Lexer = new HandlebarsLexer() 7 | const content = '

{{t "first"}}

' 8 | assert.deepEqual(Lexer.extract(content), [{ key: 'first' }]) 9 | done() 10 | }) 11 | 12 | it('extracts multiple keys on a single line', (done) => { 13 | const Lexer = new HandlebarsLexer() 14 | const content = '

{{t "first"}} {{t "second"}}

' 15 | assert.deepEqual(Lexer.extract(content), [ 16 | { key: 'first' }, 17 | { key: 'second' }, 18 | ]) 19 | done() 20 | }) 21 | 22 | it('extracts the second argument as defaultValue', (done) => { 23 | const Lexer = new HandlebarsLexer() 24 | const content = '

{{t "first" "bla"}}

' 25 | assert.deepEqual(Lexer.extract(content), [ 26 | { key: 'first', defaultValue: 'bla' }, 27 | ]) 28 | done() 29 | }) 30 | 31 | it('extracts the defaultValue arguments', (done) => { 32 | const Lexer = new HandlebarsLexer() 33 | const content = '

{{t "first" defaultValue="bla"}}

' 34 | assert.deepEqual(Lexer.extract(content), [ 35 | { key: 'first', defaultValue: 'bla' }, 36 | ]) 37 | done() 38 | }) 39 | 40 | it('extracts the context arguments', (done) => { 41 | const Lexer = new HandlebarsLexer() 42 | const content = '

{{t "first" context="bla"}}

' 43 | assert.deepEqual(Lexer.extract(content), [{ key: 'first', context: 'bla' }]) 44 | done() 45 | }) 46 | 47 | it('extracts keys from translation functions', (done) => { 48 | const Lexer = new HandlebarsLexer() 49 | const content = '

{{link-to (t "first") "foo"}}

' 50 | assert.deepEqual(Lexer.extract(content), [{ key: 'first' }]) 51 | done() 52 | }) 53 | 54 | it('supports a `functions` option', (done) => { 55 | const Lexer = new HandlebarsLexer({ functions: ['tt', '_e'] }) 56 | const content = '

{{link-to (tt "first") "foo"}}: {{_e "second"}}

' 57 | assert.deepEqual(Lexer.extract(content), [ 58 | { key: 'first' }, 59 | { key: 'second' }, 60 | ]) 61 | done() 62 | }) 63 | 64 | it('extracts custom options', (done) => { 65 | const Lexer = new HandlebarsLexer() 66 | const content = '

{{t "first" description="bla"}}

' 67 | assert.deepEqual(Lexer.extract(content), [ 68 | { key: 'first', description: 'bla' }, 69 | ]) 70 | done() 71 | }) 72 | 73 | it('extracts boolean options', (done) => { 74 | const Lexer = new HandlebarsLexer() 75 | const content = '

{{t "first" ordinal="true" custom="false"}}

' 76 | assert.deepEqual(Lexer.extract(content), [ 77 | { key: 'first', ordinal: true, custom: false }, 78 | ]) 79 | done() 80 | }) 81 | 82 | describe('parseArguments()', () => { 83 | it('matches string arguments', (done) => { 84 | const Lexer = new HandlebarsLexer() 85 | const args = '"first" "bla"' 86 | assert.deepEqual(Lexer.parseArguments(args), { 87 | arguments: ['"first"', '"bla"'], 88 | options: {}, 89 | }) 90 | done() 91 | }) 92 | 93 | it('matches variable arguments', (done) => { 94 | const Lexer = new HandlebarsLexer() 95 | const args = 'first bla' 96 | assert.deepEqual(Lexer.parseArguments(args), { 97 | arguments: ['first', 'bla'], 98 | options: {}, 99 | }) 100 | done() 101 | }) 102 | 103 | it('matches key-value arguments', (done) => { 104 | const Lexer = new HandlebarsLexer() 105 | const args = 'first="bla"' 106 | assert.deepEqual(Lexer.parseArguments(args), { 107 | arguments: ['first="bla"'], 108 | options: { 109 | first: 'bla', 110 | }, 111 | }) 112 | done() 113 | }) 114 | 115 | it('skips key-value arguments that are variables', (done) => { 116 | const Lexer = new HandlebarsLexer() 117 | const args = 'second=bla' 118 | assert.deepEqual(Lexer.parseArguments(args), { 119 | arguments: ['second=bla'], 120 | options: { 121 | // empty! 122 | }, 123 | }) 124 | done() 125 | }) 126 | 127 | it('matches combinations', (done) => { 128 | const Lexer = new HandlebarsLexer() 129 | const args = 130 | '"first" second third-one="bla bla" fourth fifth=\'bla\' "sixth"' 131 | assert.deepEqual(Lexer.parseArguments(args), { 132 | arguments: [ 133 | '"first"', 134 | 'second', 135 | 'third-one="bla bla"', 136 | 'fourth', 137 | "fifth='bla'", 138 | '"sixth"', 139 | ], 140 | options: { 141 | 'third-one': 'bla bla', 142 | fifth: 'bla', 143 | }, 144 | }) 145 | done() 146 | }) 147 | }) 148 | }) 149 | -------------------------------------------------------------------------------- /test/lexers/html-lexer.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import HTMLLexer from '../../src/lexers/html-lexer.js' 3 | 4 | describe('HTMLLexer', () => { 5 | it('extracts keys from html attributes', (done) => { 6 | const Lexer = new HTMLLexer() 7 | const content = '

' 8 | assert.deepEqual(Lexer.extract(content), [ 9 | { key: 'first' }, 10 | { key: 'second' }, 11 | ]) 12 | done() 13 | }) 14 | 15 | it('ignores leading [] of the key', (done) => { 16 | const Lexer = new HTMLLexer() 17 | const content = '

' 18 | assert.deepEqual(Lexer.extract(content), [ 19 | { key: 'first' }, 20 | { key: 'second' }, 21 | ]) 22 | done() 23 | }) 24 | 25 | it('supports the defaultValue option', (done) => { 26 | const Lexer = new HTMLLexer() 27 | const content = 28 | '

first

' 29 | assert.deepEqual(Lexer.extract(content), [ 30 | { key: 'first', defaultValue: 'bla' }, 31 | ]) 32 | done() 33 | }) 34 | 35 | it('grabs the default from innerHTML if missing', (done) => { 36 | const Lexer = new HTMLLexer() 37 | const content = '

first

' 38 | assert.deepEqual(Lexer.extract(content), [{ key: 'first' }]) 39 | done() 40 | }) 41 | 42 | it('supports multiline', (done) => { 43 | const Lexer = new HTMLLexer() 44 | const content = 45 | '

Fourth

' + 46 | '

' 47 | assert.deepEqual(Lexer.extract(content), [ 48 | { key: 'third' }, 49 | { key: 'fourth' }, 50 | { key: 'first', defaultValue: 'bar' }, 51 | ]) 52 | done() 53 | }) 54 | 55 | it('skip if no key is found', (done) => { 56 | const Lexer = new HTMLLexer() 57 | const content = '

' 58 | assert.deepEqual(Lexer.extract(content), []) 59 | done() 60 | }) 61 | 62 | it('supports a `attr` option', (done) => { 63 | const Lexer = new HTMLLexer({ attr: 'data-other' }) 64 | const content = '

' 65 | assert.deepEqual(Lexer.extract(content), [ 66 | { key: 'first' }, 67 | { key: 'second' }, 68 | ]) 69 | done() 70 | }) 71 | 72 | it('supports a `optionAttr` option', (done) => { 73 | const Lexer = new HTMLLexer({ optionAttr: 'data-other-options' }) 74 | const content = 75 | '

' 76 | assert.deepEqual(Lexer.extract(content), [ 77 | { key: 'first', defaultValue: 'bar' }, 78 | ]) 79 | done() 80 | }) 81 | 82 | it('extracts custom options', (done) => { 83 | const Lexer = new HTMLLexer() 84 | const content = 85 | '

first

' 86 | assert.deepEqual(Lexer.extract(content), [ 87 | { key: 'first', description: 'bla' }, 88 | ]) 89 | done() 90 | }) 91 | 92 | it('extracts boolean options', (done) => { 93 | const Lexer = new HTMLLexer() 94 | const content = 95 | '

first

' 96 | assert.deepEqual(Lexer.extract(content), [ 97 | { key: 'first', ordinal: true, custom: false }, 98 | ]) 99 | done() 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/lexers/javascript-lexer.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | import JavascriptLexer from '../../src/lexers/javascript-lexer.js' 3 | 4 | describe('JavascriptLexer', () => { 5 | it('extracts keys from translation components', (done) => { 6 | const Lexer = new JavascriptLexer() 7 | const content = 'i18n.t("first")' 8 | assert.deepEqual(Lexer.extract(content), [{ key: 'first' }]) 9 | done() 10 | }) 11 | 12 | it('extracts the second argument string literal as defaultValue', (done) => { 13 | const Lexer = new JavascriptLexer() 14 | const content = 'i18n.t("first", "bla")' 15 | assert.deepEqual(Lexer.extract(content), [ 16 | { key: 'first', defaultValue: 'bla' }, 17 | ]) 18 | done() 19 | }) 20 | 21 | it('extracts the second argument template literal as defaultValue', (done) => { 22 | const Lexer = new JavascriptLexer() 23 | const content = 'i18n.t("first", `bla`)' 24 | assert.deepEqual(Lexer.extract(content), [ 25 | { key: 'first', defaultValue: 'bla' }, 26 | ]) 27 | done() 28 | }) 29 | 30 | it('extracts the second argument string concatenation as defaultValue', (done) => { 31 | const Lexer = new JavascriptLexer() 32 | const content = 'i18n.t("first", "bla" + "bla" + "bla")' 33 | assert.deepEqual(Lexer.extract(content), [ 34 | { key: 'first', defaultValue: 'blablabla' }, 35 | ]) 36 | done() 37 | }) 38 | 39 | it('extracts the defaultValue/context options', (done) => { 40 | const Lexer = new JavascriptLexer() 41 | const content = 'i18n.t("first", {defaultValue: "foo", context: \'bar\'})' 42 | assert.deepEqual(Lexer.extract(content), [ 43 | { key: 'first', defaultValue: 'foo', context: 'bar' }, 44 | ]) 45 | done() 46 | }) 47 | 48 | it('emits a `warning` event if the option argument contains a spread operator', (done) => { 49 | const Lexer = new JavascriptLexer() 50 | const content = `{t('foo', { defaultValue: 'bar', ...spread })}` 51 | Lexer.on('warning', (message) => { 52 | assert.equal(message, 'Options argument is a spread operator : spread') 53 | done() 54 | }) 55 | assert.deepEqual(Lexer.extract(content), [ 56 | { key: 'foo', defaultValue: 'bar' }, 57 | ]) 58 | }) 59 | 60 | it('extracts the defaultValue/context on multiple lines', (done) => { 61 | const Lexer = new JavascriptLexer() 62 | const content = 63 | 'i18n.t("first", {\ndefaultValue: "foo",\n context: \'bar\'})' 64 | assert.deepEqual(Lexer.extract(content), [ 65 | { key: 'first', defaultValue: 'foo', context: 'bar' }, 66 | ]) 67 | done() 68 | }) 69 | 70 | it('extracts the defaultValue/context options with quotation marks', (done) => { 71 | const Lexer = new JavascriptLexer() 72 | const content = 'i18n.t("first", {context: "foo", "defaultValue": \'bla\'})' 73 | assert.deepEqual(Lexer.extract(content), [ 74 | { key: 'first', defaultValue: 'bla', context: 'foo' }, 75 | ]) 76 | done() 77 | }) 78 | 79 | it('extracts the defaultValue/context options with interpolated value', (done) => { 80 | const Lexer = new JavascriptLexer() 81 | const content = 82 | 'i18n.t("first", {context: "foo", "defaultValue": \'{{var}} bla\'})' 83 | assert.deepEqual(Lexer.extract(content), [ 84 | { key: 'first', defaultValue: '{{var}} bla', context: 'foo' }, 85 | ]) 86 | done() 87 | }) 88 | 89 | it('supports multiline and concatenation', (done) => { 90 | const Lexer = new JavascriptLexer() 91 | const content = 'i18n.t("foo" + \n "bar")' 92 | assert.deepEqual(Lexer.extract(content), [{ key: 'foobar' }]) 93 | done() 94 | }) 95 | 96 | it('supports multiline template literal keys', (done) => { 97 | const Lexer = new JavascriptLexer() 98 | const content = 'i18n.t(`foo\nbar`)' 99 | assert.deepEqual(Lexer.extract(content), [{ key: 'foo\nbar' }]) 100 | done() 101 | }) 102 | 103 | it('extracts keys from single line comments', (done) => { 104 | const Lexer = new JavascriptLexer() 105 | const content = ` 106 | // i18n.t('commentKey1') 107 | i18n.t('commentKey' + i) 108 | // i18n.t('commentKey2') 109 | i18n.t(\`commentKey\${i}\`) 110 | // Irrelevant comment 111 | // i18n.t('commentKey3') 112 | ` 113 | assert.deepEqual(Lexer.extract(content), [ 114 | { key: 'commentKey1' }, 115 | { key: 'commentKey2' }, 116 | { key: 'commentKey3' }, 117 | ]) 118 | done() 119 | }) 120 | 121 | it('extracts keys from multiline comments', (done) => { 122 | const Lexer = new JavascriptLexer() 123 | const content = ` 124 | /* 125 | i18n.t('commentKey1') 126 | i18n.t('commentKey2') 127 | */ 128 | i18n.t(\`commentKey\${i}\`) 129 | // Irrelevant comment 130 | /* i18n.t('commentKey3') */ 131 | ` 132 | assert.deepEqual(Lexer.extract(content), [ 133 | { key: 'commentKey1' }, 134 | { key: 'commentKey2' }, 135 | { key: 'commentKey3' }, 136 | ]) 137 | done() 138 | }) 139 | 140 | it('parses namespace from `t` type argument', (done) => { 141 | const Lexer = new JavascriptLexer() 142 | const content = ` 143 | const content = (t: TFunction<"foo">) => ({ 144 | title: t("bar"), 145 | }) 146 | ` 147 | assert.deepEqual(Lexer.extract(content), [{ key: 'bar', namespace: 'foo' }]) 148 | done() 149 | }) 150 | 151 | it("does not parse text with `doesn't` or isolated `t` in it", (done) => { 152 | const Lexer = new JavascriptLexer() 153 | const js = 154 | "// FIX this doesn't work and this t is all alone\nt('first')\nt = () => {}" 155 | assert.deepEqual(Lexer.extract(js), [{ key: 'first' }]) 156 | done() 157 | }) 158 | 159 | it('ignores functions that ends with a t', (done) => { 160 | const Lexer = new JavascriptLexer() 161 | const js = "ttt('first')" 162 | assert.deepEqual(Lexer.extract(js), []) 163 | done() 164 | }) 165 | 166 | it('supports a `functions` option', (done) => { 167 | const Lexer = new JavascriptLexer({ functions: ['tt', '_e', 'f.g'] }) 168 | const content = 'tt("first") + _e("second") + x.tt("third") + f.g("fourth")' 169 | assert.deepEqual(Lexer.extract(content), [ 170 | { key: 'first' }, 171 | { key: 'second' }, 172 | { key: 'third' }, 173 | { key: 'fourth' }, 174 | ]) 175 | done() 176 | }) 177 | 178 | it('supports async/await', (done) => { 179 | const Lexer = new JavascriptLexer() 180 | const content = 'const data = async () => await Promise.resolve()' 181 | Lexer.extract(content) 182 | done() 183 | }) 184 | 185 | it('supports the spread operator', (done) => { 186 | const Lexer = new JavascriptLexer() 187 | const content = 188 | 'const data = { text: t("foo"), ...rest }; const { text, ...more } = data;' 189 | assert.deepEqual(Lexer.extract(content), [{ key: 'foo' }]) 190 | done() 191 | }) 192 | 193 | it('supports dynamic imports', (done) => { 194 | const Lexer = new JavascriptLexer() 195 | const content = 'import("path/to/some/file").then(doSomethingWithData)' 196 | Lexer.extract(content) 197 | done() 198 | }) 199 | 200 | it('supports the es7 syntax', (done) => { 201 | const Lexer = new JavascriptLexer() 202 | const content = '@decorator() class Test { test() { t("foo") } }' 203 | assert.deepEqual(Lexer.extract(content), [{ key: 'foo' }]) 204 | done() 205 | }) 206 | 207 | it('supports basic typescript syntax', () => { 208 | const Lexer = new JavascriptLexer() 209 | const content = 'i18n.t("first") as potato' 210 | assert.deepEqual(Lexer.extract(content), [{ key: 'first' }]) 211 | }) 212 | it('supports for t function in options', () => { 213 | const Lexer = new JavascriptLexer() 214 | const content = 215 | 'i18n.t("first", {option: i18n.t("second",{option2: i18n.t("third")}), option3: i18n.t("fourth")})' 216 | assert.deepEqual(Lexer.extract(content), [ 217 | { key: 'first' }, 218 | { key: 'second' }, 219 | { key: 'third' }, 220 | { key: 'fourth' }, 221 | ]) 222 | }) 223 | 224 | describe('useTranslation', () => { 225 | it('extracts default namespace', () => { 226 | const Lexer = new JavascriptLexer() 227 | const content = 'const {t} = useTranslation("foo"); t("bar");' 228 | assert.deepEqual(Lexer.extract(content), [ 229 | { namespace: 'foo', key: 'bar' }, 230 | ]) 231 | }) 232 | 233 | it('extracts the first valid namespace when it is an array', () => { 234 | const Lexer = new JavascriptLexer() 235 | const content = 236 | 'const {t} = useTranslation([someVariable, "baz"]); t("bar");' 237 | assert.deepEqual(Lexer.extract(content), [ 238 | { namespace: 'baz', key: 'bar' }, 239 | ]) 240 | }) 241 | 242 | it('emits a `warning` event if the extracted namespace is not a string literal or undefined', (done) => { 243 | const Lexer = new JavascriptLexer() 244 | const content = 'const {t} = useTranslation(someVariable); t("bar");' 245 | Lexer.on('warning', (message) => { 246 | assert.equal( 247 | message, 248 | 'Namespace is not a string literal nor an array containing a string literal: someVariable' 249 | ) 250 | done() 251 | }) 252 | assert.deepEqual(Lexer.extract(content), [{ key: 'bar' }]) 253 | }) 254 | 255 | it('leaves the default namespace unchanged if `undefined` is passed', () => { 256 | const Lexer = new JavascriptLexer() 257 | const content = 'const {t} = useTranslation(undefined); t("bar");' 258 | assert.deepEqual(Lexer.extract(content), [{ key: 'bar' }]) 259 | }) 260 | 261 | it('leaves the default namespace unchanged if `undefined` is passed in an array', () => { 262 | const Lexer = new JavascriptLexer() 263 | const content = 264 | 'const {t} = useTranslation([someVariable, undefined]); t("bar");' 265 | assert.deepEqual(Lexer.extract(content), [{ key: 'bar' }]) 266 | }) 267 | 268 | it('uses namespace from t function with priority', () => { 269 | const Lexer = new JavascriptLexer() 270 | const content = 271 | 'const {t} = useTranslation("foo"); t("bar", {ns: "baz"});' 272 | assert.deepEqual(Lexer.extract(content), [ 273 | { namespace: 'baz', key: 'bar', ns: 'baz' }, 274 | ]) 275 | }) 276 | 277 | it('extracts namespace with a custom hook', () => { 278 | const Lexer = new JavascriptLexer({ 279 | namespaceFunctions: ['useCustomTranslationHook'], 280 | }) 281 | const content = 'const {t} = useCustomTranslationHook("foo"); t("bar");' 282 | assert.deepEqual(Lexer.extract(content), [ 283 | { namespace: 'foo', key: 'bar' }, 284 | ]) 285 | }) 286 | 287 | it('extracts namespace with a custom hook defined as nested properties', () => { 288 | const Lexer = new JavascriptLexer({ 289 | namespaceFunctions: ['i18n.useTranslate'], 290 | }) 291 | const content = 'const {t} = i18n.useTranslate("foo"); t("bar");' 292 | assert.deepEqual(Lexer.extract(content), [ 293 | { namespace: 'foo', key: 'bar' }, 294 | ]) 295 | }) 296 | }) 297 | 298 | describe('withTranslation', () => { 299 | it('extracts default namespace when it is a string', () => { 300 | const Lexer = new JavascriptLexer() 301 | const content = 302 | 'const ExtendedComponent = withTranslation("foo")(MyComponent); t("bar");' 303 | assert.deepEqual(Lexer.extract(content), [ 304 | { namespace: 'foo', key: 'bar' }, 305 | ]) 306 | }) 307 | 308 | it('extracts first valid namespace when it is an array', () => { 309 | const Lexer = new JavascriptLexer() 310 | const content = 311 | 'const ExtendedComponent = withTranslation([someVariable, "baz"])(MyComponent); t("bar");' 312 | assert.deepEqual(Lexer.extract(content), [ 313 | { namespace: 'baz', key: 'bar' }, 314 | ]) 315 | }) 316 | 317 | it('emits a `warning` event if the extracted namespace is not a string literal or undefined', (done) => { 318 | const Lexer = new JavascriptLexer() 319 | const content = 320 | 'const ExtendedComponent = withTranslation(someVariable)(MyComponent); t("bar");' 321 | Lexer.on('warning', (message) => { 322 | assert.equal( 323 | message, 324 | 'Namespace is not a string literal nor an array containing a string literal: someVariable' 325 | ) 326 | done() 327 | }) 328 | assert.deepEqual(Lexer.extract(content), [{ key: 'bar' }]) 329 | }) 330 | 331 | it('leaves the default namespace unchanged if `undefined` is passed', () => { 332 | const Lexer = new JavascriptLexer() 333 | const content = 334 | 'const ExtendedComponent = withTranslation(undefined)(MyComponent); t("bar");' 335 | assert.deepEqual(Lexer.extract(content), [{ key: 'bar' }]) 336 | }) 337 | 338 | it('leaves the default namespace unchanged if `undefined` is passed in an array', () => { 339 | const Lexer = new JavascriptLexer() 340 | const content = 341 | 'const ExtendedComponent = withTranslation([someVariable, undefined])(MyComponent); t("bar");' 342 | assert.deepEqual(Lexer.extract(content), [{ key: 'bar' }]) 343 | }) 344 | 345 | it('uses namespace from t function with priority', () => { 346 | const Lexer = new JavascriptLexer() 347 | const content = 348 | 'const ExtendedComponent = withTranslation("foo")(MyComponent); t("bar", {ns: "baz"});' 349 | assert.deepEqual(Lexer.extract(content), [ 350 | { namespace: 'baz', key: 'bar', ns: 'baz' }, 351 | ]) 352 | }) 353 | }) 354 | 355 | it('extracts custom options', () => { 356 | const Lexer = new JavascriptLexer() 357 | 358 | const content = 'i18n.t("headline", {description: "Fantastic key!"});' 359 | assert.deepEqual(Lexer.extract(content), [ 360 | { 361 | key: 'headline', 362 | description: 'Fantastic key!', 363 | }, 364 | ]) 365 | }) 366 | 367 | it('extracts boolean options', () => { 368 | const Lexer = new JavascriptLexer() 369 | 370 | const content = 'i18n.t("headline", {ordinal: true, custom: false});' 371 | assert.deepEqual(Lexer.extract(content), [ 372 | { 373 | key: 'headline', 374 | ordinal: true, 375 | custom: false, 376 | }, 377 | ]) 378 | }) 379 | 380 | it('emits warnings on dynamic keys', () => { 381 | const Lexer = new JavascriptLexer() 382 | const content = 383 | 'const bar = "bar"; i18n.t("foo"); i18n.t(bar); i18n.t(`foo.${bar}`); i18n.t(`babar`);' 384 | 385 | let warningCount = 0 386 | Lexer.on('warning', (warning) => { 387 | if (warning.indexOf('Key is not a string literal') === 0) { 388 | warningCount++ 389 | } 390 | }) 391 | 392 | assert.deepEqual(Lexer.extract(content), [ 393 | { 394 | key: 'foo', 395 | }, 396 | { 397 | key: 'babar', 398 | }, 399 | ]) 400 | assert.strictEqual(warningCount, 2) 401 | }) 402 | 403 | it('extracts non-interpolated tagged templates', () => { 404 | const Lexer = new JavascriptLexer() 405 | const content = 'i18n.t`some-key`' 406 | assert.deepEqual(Lexer.extract(content), [ 407 | { 408 | key: 'some-key', 409 | }, 410 | ]) 411 | }) 412 | 413 | it('emits warnings on interpolated tagged templates', () => { 414 | const Lexer = new JavascriptLexer() 415 | const content = 'i18n.t`some-key${someVar}keykey`' 416 | 417 | let warningCount = 0 418 | Lexer.on('warning', (warning) => { 419 | if ( 420 | warning.indexOf( 421 | 'A key that is a template string must not have any interpolations.' 422 | ) === 0 423 | ) { 424 | warningCount++ 425 | } 426 | }) 427 | 428 | Lexer.extract(content) 429 | 430 | assert.equal(warningCount, 1) 431 | }) 432 | 433 | it('extracts count options', () => { 434 | const Lexer = new JavascriptLexer({ 435 | typeMap: { CountType: { count: '' } }, 436 | parseGenerics: true, 437 | }) 438 | 439 | const content = 'i18n.t<{count: number}>("key_count");' 440 | assert.deepEqual(Lexer.extract(content, 'file.ts'), [ 441 | { 442 | key: 'key_count', 443 | count: '', 444 | }, 445 | ]) 446 | 447 | const content2 = `type CountType = {count : number}; 448 | i18n.t("key_count");` 449 | assert.deepEqual(Lexer.extract(content2, 'file.ts'), [ 450 | { 451 | count: '', 452 | key: 'key_count', 453 | }, 454 | ]) 455 | 456 | const content3 = `type CountType = {count : number}; 457 | i18n.t("key_count");` 458 | assert.deepEqual(Lexer.extract(content3, 'file.ts'), [ 459 | { 460 | key: 'key_count', 461 | count: '', 462 | my_custom: '', 463 | }, 464 | ]) 465 | 466 | const content4 = `type CountType = {count : number}; 467 | i18n.t("key_count");` 468 | assert.deepEqual(Lexer.extract(content4, 'file.ts'), [ 469 | { 470 | key: 'key_count', 471 | count: '', 472 | my_custom: '', 473 | }, 474 | ]) 475 | }) 476 | }) 477 | -------------------------------------------------------------------------------- /test/locales/ar/test_reset.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "ar_translation", 3 | "key2": { 4 | "key3": "ar_translation" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/locales/ar/test_sort.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluralKey_two": "", 3 | "pluralKey_withContext_female_few": "", 4 | "aaa_aaa": "", 5 | "pluralKey_one": "", 6 | "bbb": { 7 | "pluralKey_few": "", 8 | "pluralKey_withContext_female_few": "", 9 | "pluralKey_many": "", 10 | "pluralKey_one": "", 11 | "pluralKey_withContext_male_other": "", 12 | "pluralKey_withContext_female_one": "", 13 | "aaA": "", 14 | "pluralKey_withContext_female_other": "", 15 | "ccc": "", 16 | "pluralKey_withContext_female_two": "", 17 | "aaa": "", 18 | "pluralKey_withContext_female_zero": "", 19 | "withContext_male": "", 20 | "pluralKey_withContext_male_few": "", 21 | "pluralKey_other": "", 22 | "pluralKey_withContext_female_many": "", 23 | "withContext_female": "", 24 | "pluralKey_withContext_male_many": "", 25 | "aa1": "", 26 | "pluralKey_withContext_male_one": "", 27 | "aaa_aaa": "", 28 | "pluralKey_withContext_male_two": "", 29 | "pluralKey_two": "", 30 | "pluralKey_withContext_male_zero": "", 31 | "pluralKey_zero": "" 32 | }, 33 | "pluralKey_few": "", 34 | "pluralKey_withContext_male_one": "", 35 | "pluralKey_other": "", 36 | "pluralKey_withContext_female_one": "", 37 | "pluralKey_withContext_male_zero": "", 38 | "withContext_male": "", 39 | "pluralKey_withContext_female_other": "", 40 | "aa1": "", 41 | "pluralKey_withContext_female_two": "", 42 | "pluralKey_withContext_male_few": "", 43 | "aaa": "", 44 | "pluralKey_withContext_male_many": "", 45 | "pluralKey_many": "", 46 | "pluralKey_withContext_female_zero": "", 47 | "pluralKey_withContext_male_other": "", 48 | "aaA": "", 49 | "pluralKey_withContext_male_two": "", 50 | "pluralKey_withContext_female_many": "", 51 | "pluralKey_zero": "", 52 | "withContext_female": "" 53 | } 54 | -------------------------------------------------------------------------------- /test/locales/en/test.yml: -------------------------------------------------------------------------------- 1 | first: foo 2 | -------------------------------------------------------------------------------- /test/locales/en/test_context.json: -------------------------------------------------------------------------------- 1 | { 2 | "first": "first", 3 | "first_context1": "first context1", 4 | "first_context2": "" 5 | } 6 | -------------------------------------------------------------------------------- /test/locales/en/test_context_plural.json: -------------------------------------------------------------------------------- 1 | { 2 | "first": "first", 3 | "first_context1_other": "first context1 plural", 4 | "first_context2_two": "first context2 plural 2" 5 | } 6 | -------------------------------------------------------------------------------- /test/locales/en/test_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "" 3 | } 4 | -------------------------------------------------------------------------------- /test/locales/en/test_fail_on_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "key_existing_1": "exists", 3 | "key_existing_2": "exists" 4 | } 5 | -------------------------------------------------------------------------------- /test/locales/en/test_invalid.json: -------------------------------------------------------------------------------- 1 | // { 2 | "invalid": "json", 3 | } 4 | -------------------------------------------------------------------------------- /test/locales/en/test_leak.json: -------------------------------------------------------------------------------- 1 | { 2 | "first": "first", 3 | "second": "second" 4 | } 5 | -------------------------------------------------------------------------------- /test/locales/en/test_log.json: -------------------------------------------------------------------------------- 1 | { 2 | "was_present": "first", 3 | "was_present_but_not_plural": "bla", 4 | "was_present_and_plural_but_no_more_one": "bla", 5 | "was_present_and_plural_but_no_more_other": "bla" 6 | } 7 | -------------------------------------------------------------------------------- /test/locales/en/test_merge.json: -------------------------------------------------------------------------------- 1 | { 2 | "first": "first", 3 | "second": "", 4 | "third": "third" 5 | } 6 | -------------------------------------------------------------------------------- /test/locales/en/test_old.json: -------------------------------------------------------------------------------- 1 | { 2 | "parent": { 3 | "first": "first" 4 | }, 5 | "second": "second" 6 | } 7 | -------------------------------------------------------------------------------- /test/locales/en/test_old_old.json: -------------------------------------------------------------------------------- 1 | { 2 | "parent": { 3 | "some": "some" 4 | }, 5 | "other": "other" 6 | } 7 | -------------------------------------------------------------------------------- /test/locales/en/test_plural.json: -------------------------------------------------------------------------------- 1 | { 2 | "first_one": "first", 3 | "first_other": "first plural", 4 | "second_one": "second", 5 | "second_zero": "second plural zero", 6 | "second_other": "second plural other" 7 | } 8 | -------------------------------------------------------------------------------- /test/locales/en/test_reset.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "en_translation", 3 | "key2": { 4 | "key3": "en_translation" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/locales/en/test_sort_sorted.json: -------------------------------------------------------------------------------- 1 | { 2 | "first": "first", 3 | "second": "second", 4 | "third": { 5 | "a": "a", 6 | "b": "b" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/locales/en/test_sort_unsorted.json: -------------------------------------------------------------------------------- 1 | { 2 | "second": "second", 3 | "first": "first", 4 | "third": { 5 | "b": "b", 6 | "a": "a" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/locales/fr/test_leak.json: -------------------------------------------------------------------------------- 1 | { 2 | "first": "premier", 3 | "second": "" 4 | } 5 | -------------------------------------------------------------------------------- /test/locales/fr/test_reset.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "defaultTranslation", 3 | "key2": { 4 | "key3": "defaultTranslation" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/templating/handlebars.hbs: -------------------------------------------------------------------------------- 1 |
2 |

{{t "first"}} {{t "second" "defaultValue" option="foo" context="male"}}

3 |

{{t "third" defaultValue="defaultValue" context="female"}}

4 |

{{t "fourth" context="male" bla='asd' defaultValue="defaultValue"}}

5 |

{{t 6 | variable 7 | "defaultValue" 8 | option="foo" 9 | context="male" 10 | }}

11 |

{{t "fifth" variable option="foo" context="male"}}

12 |

{{t bljha bla-bla "second dsf" "defaultValue" option="foo" context="male"}}

13 | {{link-to (t "sixth") "foo"}} 14 | {{link-to (t "seventh" "defaultValue") "foo"}} 15 |
16 | -------------------------------------------------------------------------------- /test/templating/html.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Lorem ipsum dolor sit amet, consectetur adipiscing elit 10 | 11 | 12 | 13 |
14 |

First

15 |

second

16 |

Fourth

17 |

23 |

asd

28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test/templating/javascript.js: -------------------------------------------------------------------------------- 1 | import bla from 'bla'; 2 | 3 | notRelated() 4 | i18n.t('first') 5 | i18n.t('second', 'defaultValue') 6 | i18n.t('third', { 7 | defaultValue: '{{var}} defaultValue' 8 | }) 9 | i18n.t( 10 | 'fou' + 11 | 'rt' + 12 | 'h' 13 | ) 14 | if (true) { 15 | i18n.t('not picked' + variable, {foo: bar}, 'bla' + 'asd', {}, foo+bar+baz ) 16 | } 17 | i18n.t(variable, {foo: bar}, 'bla' + 'asd', {}, foo+bar+baz ) 18 | -------------------------------------------------------------------------------- /test/templating/keyPrefix-hook.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTranslation, Trans } from 'react-i18next' 3 | 4 | // This will have test-namespace even though it comes before useTranslation during parsing 5 | const Component = () => { 6 | return 7 | } 8 | 9 | function TestComponent() { 10 | const { t } = useTranslation('key-prefix', { keyPrefix: 'test-prefix' }) 11 | return ( 12 | <> 13 | 14 |

{t('bar')}

15 | 16 | ) 17 | } 18 | 19 | export default TestComponent 20 | -------------------------------------------------------------------------------- /test/templating/multiple-translation-keys.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | 4 | function TestComponent() { 5 | const { t: tCommon } = useTranslation('common') 6 | const { t: tPrefix } = useTranslation('common', {keyPrefix: "random"}) 7 | const { t } = useTranslation('test') 8 | return ( 9 | <> 10 |

{t('bar')}

11 |

{tCommon('foo')}

12 |

{tPrefix('stuff')}

13 | 14 | ) 15 | } 16 | 17 | export default TestComponent 18 | -------------------------------------------------------------------------------- /test/templating/namespace-hoc.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withTranslation, Trans } from 'react-i18next' 3 | 4 | // This will have test-namespace even though it comes before withTranslation during parsing 5 | const Component = () => { 6 | return 7 | } 8 | 9 | function TestComponent({ t, i18n }) { 10 | return ( 11 | <> 12 | 13 |

{t('test-2')}

14 | 15 | ) 16 | } 17 | 18 | export default withTranslation('test-namespace')(TestComponent) 19 | -------------------------------------------------------------------------------- /test/templating/namespace-hook.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTranslation, Trans } from 'react-i18next' 3 | 4 | // This will have test-namespace even though it comes before useTranslation during parsing 5 | const Component = () => { 6 | return 7 | } 8 | 9 | function TestComponent() { 10 | const { t } = useTranslation('test-namespace') 11 | return ( 12 | <> 13 | 14 |

{t('test-2')}

15 | 16 | ) 17 | } 18 | 19 | export default TestComponent 20 | -------------------------------------------------------------------------------- /test/templating/react.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withTranslation, Trans, Interpolate } from 'react-i18next' 3 | 4 | // These will have namespace "react" even though it comes before withtranslation during parsing 5 | const bar = () => ( 6 |
7 | 8 |
9 | ); 10 | 11 | const foo = () => ( 12 |
13 | 14 |
15 | ); 16 | 17 | class Test extends React.Component { 18 | render () { 19 | const { t } = this.props 20 | return ( 21 |
22 |

{t('first')}

23 | 24 | 25 | Hello {{name}}, you have {{count}} unread message. Go to messages. 26 | 27 | 28 | Hello, this shouldn't be trimmed. 29 | 30 | Hello, 31 | this should be trimmed. 32 | and this shoudln't 33 | 34 | 35 | This should be part of the value and the key 36 | {/* this shouldn't */} 37 | 38 | 39 | don't split {{ on: this }} 40 | 41 | ignore me 42 |
43 | ) 44 | } 45 | } 46 | 47 | export default withTranslation('react')(Test) 48 | -------------------------------------------------------------------------------- /test/templating/typescript.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withTranslation, Trans, Interpolate } from 'react-i18next' 3 | 4 | 5 | // These will have namespace "react" even though it comes before withtranslation during parsing 6 | const bar = (): React.ReactElement => ( 7 |
8 | 9 |
10 | ); 11 | 12 | const foo = (): React.ReactElement => ( 13 |
14 | 15 |
16 | ); 17 | 18 | class Test extends React.Component<{ t: any }, {}> { 19 | render () { 20 | const { t } = this.props 21 | return ( 22 |
23 |

{t('first')}

24 | 25 | 26 | Hello {{name}}, you have {{count}} unread message. Go to messages. 27 | 28 | 29 | Hello, this shouldn't be trimmed. 30 | 31 | Hello, 32 | this should be trimmed. 33 | and this shoudln't 34 | 35 | 36 | This should be part of the value and the key 37 | {/* this shouldn't */} 38 | 39 | 40 | don't split {{ on: this }} 41 | 42 |
43 | ) 44 | } 45 | } 46 | 47 | export default withTranslation('react')(Test) 48 | --------------------------------------------------------------------------------