├── .github └── workflows │ ├── ci.yml │ ├── lint-pr-title.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .release-it.json ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── rules │ ├── no-expression-in-message.md │ ├── no-single-tag-to-translate.md │ ├── no-single-variables-to-translate.md │ ├── no-trans-inside-trans.md │ ├── no-unlocalized-strings.md │ ├── t-call-in-function.md │ └── text-restrictions.md ├── jest.config.js ├── package.json ├── src ├── constants.ts ├── create-rule.ts ├── helpers.test.ts ├── helpers.ts ├── index.ts └── rules │ ├── no-expression-in-message.ts │ ├── no-single-tag-to-translate.ts │ ├── no-single-variables-to-translate.ts │ ├── no-trans-inside-trans.ts │ ├── no-unlocalized-strings.ts │ ├── t-call-in-function.ts │ └── text-restrictions.ts ├── tests ├── helpers │ ├── messages.json │ └── parsers.ts └── src │ └── rules │ ├── no-expression-in-message.test.ts │ ├── no-single-tag-to-translate.test.ts │ ├── no-single-variables-to-translate.test.ts │ ├── no-trans-inside-trans.test.ts │ ├── no-unlocalized-strings.test.ts │ ├── t-call-in-function.test.ts │ └── text-restrictions.test.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: main-suite 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | validate: 13 | timeout-minutes: 10 14 | runs-on: ubuntu-latest 15 | name: validate (20, ubuntu-latest) 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Enable Corepack 20 | run: corepack enable 21 | 22 | - name: Setup node 23 | uses: actions/setup-node@v4 24 | with: 25 | cache: yarn 26 | node-version: 20 27 | 28 | - name: Install dependencies 29 | run: yarn install 30 | 31 | - name: Check Prettier Formatting 32 | run: yarn prettier:check 33 | 34 | - name: Unit Testing 35 | run: yarn test:ci 36 | 37 | - name: Check coverage 38 | uses: codecov/codecov-action@v4 39 | with: 40 | verbose: true 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr-title.yml: -------------------------------------------------------------------------------- 1 | name: lint-pr-title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - reopened 8 | - edited 9 | - synchronize 10 | 11 | jobs: 12 | main: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: amannn/action-semantic-pull-request@v5 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish-npm: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Enable Corepack 18 | run: corepack enable 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: yarn 24 | registry-url: https://registry.npmjs.org/ 25 | 26 | - name: Install dependencies and build 27 | run: | 28 | yarn install 29 | yarn build 30 | 31 | - name: Install release-it globally 32 | run: | 33 | npm i -g release-it@17.8.2 34 | npm i -g @release-it/conventional-changelog@8.0.2 35 | 36 | - name: git config 37 | run: | 38 | git config user.name "${GITHUB_ACTOR}" 39 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 40 | 41 | - name: npm config 42 | run: npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} 43 | 44 | - name: Bump version 45 | run: npx release-it --increment 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .DS_Store 4 | coverage 5 | .idea 6 | .yarn -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules/* 2 | **/lib/* 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "printWidth": 100, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "push": true, 4 | "commit": true, 5 | "commitMessage": "chore: version ${version} [skip ci]", 6 | "requireBranch": "main", 7 | "tag": true 8 | }, 9 | "github": { 10 | "release": true, 11 | "autoGenerate": true, 12 | "releaseName": "${version}" 13 | }, 14 | "npm": { 15 | "publish": true 16 | }, 17 | "plugins": { 18 | "@release-it/conventional-changelog": { 19 | "preset": { 20 | "name": "conventionalcommits", 21 | "types": [ 22 | { 23 | "type": "feat", 24 | "section": "Features" 25 | }, 26 | { 27 | "type": "fix", 28 | "section": "Bug Fixes" 29 | }, 30 | {} 31 | ] 32 | }, 33 | "infile": "CHANGELOG.md", 34 | "header": "# Change Log" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | - fix(text-restrictions): trim text in JSXText only (#102) (c3ef70c) 4 | 5 | * feat(no-expression-in-message): add support for ph and explicit labels (#100) (c33e2d5) 6 | * docs(t-call-in-function): fix documentation link (fec90fe) 7 | 8 | - feat: add support for `as const` assertions, string literal union types, and quoted object keys (#96) (04b198e) 9 | 10 | * fix: no-unlocalized-strings corrected support for interface prop names (#95) (56eb2ee) 11 | 12 | - fix: `no-unlocalized-strings` rule to ignore string literals in expressions assigned to variables specified in `ignoreNames` (#94) (df19c1c) 13 | 14 | * fix: no-unlocalized-strings rule to correctly handle as const assertions in property values with ignoreNames (#92) (4048c4d) 15 | * fix: no-unlocalized-strings rule to ignore default parameter values specified in ignoreNames (#91) (68b9052) 16 | 17 | - fix(no-unlocalized-strings): ignore more cases (#84) (061ef0d) 18 | 19 | * chore(deps): bump cross-spawn from 7.0.3 to 7.0.5 (#83) (f10feb9) 20 | * chore(deps): bump @eslint/plugin-kit from 0.2.0 to 0.2.3 (#81) (297bcdd) 21 | * feat(no-unlocalized-strings): remove default configuration (#78) (32c823c) 22 | * feat(no-unlocalized-strings): add patterns for ignore functions (#77) (e249254) 23 | * feat(no-unlocalized-strings): add ignoreVariable option + refactor options (#76) (a407d3d) 24 | 25 | - fix: not listed dependency `@typescript-eslint/scope-manager` (#75) (0ded72c) 26 | - feat(recommended-config): add lingui/no-expression-in-message (#74) (5430625) 27 | 28 | * feat(no-unlocalized-strings): add regex patterns support (#70) (1c248a2) 29 | * fix: no-single-variables-to-translate renamed to no-single-variable... (#69) (b048305) 30 | * feat(no-expression-in-message): correctly handle {' '} in JSX, report for each found expression (#68) (e935e01) 31 | 32 | ## 0.5.0 (2024-10-15) 33 | 34 | - feat(no-expression-in-message): check Trans component (#60) (3fcb131) 35 | - feat: support msg and defineMessage in various rules (#61) (15a4d04) 36 | - refactor: use more eslint features instead of custom implementations (#62) (c30ccc6) 37 | - feat: support messages defined as MessageDescriptor (#63) (7952a56) 38 | - feat(text-restrictions): apply rules only for translation messages support different Lingui signatures (#64) (de9637f) 39 | - feat(no-unlocalized-strings): add strictAttribute option (#66) (a284b26) 40 | 41 | ## 0.4.0 (2024-10-11) 42 | 43 | - ci: fix release workflow (#57) (27cd472) 44 | - fix(no-unlocalized-strings): ignore Literals in computed MemberExpression (#56) (edb2bc1) 45 | - fix(config): get rid of array in config export (#55) (3c6d349) 46 | - fix(no-expression-in-message): add select and selectOrdinal to exclusion (#54) (89f5953) 47 | - feat: allow calls to plural function in no-expression-in-message (#48) (7a8d062) 48 | - docs(no-unlocalized-strings): more examples for valid cases (#52) (f99a709) 49 | - fix(no-unlocalized-strings): `ignoreProperty` support MemberExpression assignments (#51) (1ecc912) 50 | - feat: Eslint 9 + flat config support (#49) (b57329b) 51 | - chore(deps): bump braces from 3.0.2 to 3.0.3 (#44) (0c80feb) 52 | 53 | ## 0.3.0 (2024-02-09) 54 | 55 | - feat: respect lazy translation tags in no-single-variables-to-translate (#34) (3cb7b6a) 56 | - ci: minor fixes for the release process (#27) (cb0e1d5) 57 | 58 | ## 0.2.2 (2023-12-20) 59 | 60 | - fix: ban top level t() calls aside from t`` calls (#26) (5ca48ad) 61 | - chore(deps): bump semver from 6.3.0 to 6.3.1 (#24) (7c588c4) 62 | - chore(deps): bump @babel/traverse from 7.22.8 to 7.23.5 (#23) (7c3bd2a) 63 | 64 | ## 0.2.1 (2023-12-08) 65 | 66 | - fix(no-unlocalized-strings): utilize ignoreFunction which is used as CallExpression (#21) (e84de6e) 67 | - fix(no-unlocalized-strings): utilize TS enums when if parserServices.program is empty (#22) (5078ea0) 68 | 69 | ## 0.2.0 (2023-10-06) 70 | 71 | - feat(no-unlocalized-strings): add ignoreProperty option (#20) (a4d7683) 72 | 73 | ## 0.1.2 (2023-09-28) 74 | 75 | - test: add a new test case for the ignoreFunction option (#19) (3b32de7) 76 | - fix: update no-unlocalized to properly utilize ignoreFunction option (#16) (5e95b3e) 77 | 78 | ## 0.1.1 (2023-09-11) 79 | 80 | - ci: improve the release process (#15) (46077d3) 81 | - chore: change nodejs engine requirement (#14) (4cb2f1e) 82 | 83 | ## 0.1.0 (2023-09-04) 84 | 85 | Initial release 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lingui 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 | #
An ESLint Plugin For Linguijs
2 | 3 |
4 | 5 | Set of eslint rules for [Lingui](https://lingui.dev) projects 6 | 7 | [![npm](https://img.shields.io/npm/v/eslint-plugin-lingui?logo=npm&cacheSeconds=1800)](https://www.npmjs.com/package/eslint-plugin-lingui) 8 | [![npm](https://img.shields.io/npm/dt/eslint-plugin-lingui?cacheSeconds=500)](https://www.npmjs.com/package/eslint-plugin-lingui) 9 | [![main-suite](https://github.com/lingui/eslint-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/lingui/eslint-plugin/actions/workflows/ci.yml) 10 | [![codecov](https://codecov.io/gh/lingui/eslint-plugin/graph/badge.svg?token=ULkNOaWVaw)](https://codecov.io/gh/lingui/eslint-plugin) 11 | [![GitHub](https://img.shields.io/github/license/lingui/eslint-plugin)](https://github.com/lingui/eslint-plugin/blob/main/LICENSE) 12 | 13 |
14 | 15 | ## Installation 16 | 17 | You'll first need to install [ESLint](http://eslint.org): 18 | 19 | ```bash 20 | npm install --save-dev eslint 21 | # or 22 | yarn add eslint --dev 23 | ``` 24 | 25 | Next, install `eslint-plugin-lingui`: 26 | 27 | ```bash 28 | npm install --save-dev eslint-plugin-lingui 29 | # or 30 | yarn add eslint-plugin-lingui --dev 31 | ``` 32 | 33 | **Note:** If you installed ESLint globally (using the `-g` flag) then you must also install `eslint-plugin-lingui` globally. 34 | 35 | ## Flat Config (`eslint.config.js`) 36 | 37 | ### Recommended Setup 38 | 39 | To enable all the recommended rules for our plugin, add the following config: 40 | 41 | ```js 42 | import pluginLingui from 'eslint-plugin-lingui' 43 | 44 | export default [ 45 | pluginLingui.configs['flat/recommended'], 46 | // Any other config... 47 | ] 48 | ``` 49 | 50 | We also recommend enabling the [no-unlocalized-strings](docs/rules/no-unlocalized-strings.md) rule. It’s not enabled by default because it needs to be set up specifically for your project. Please check the rule's documentation for example configurations. 51 | 52 | ### Custom setup 53 | 54 | Alternatively, you can load the plugin and configure only the rules you want to use: 55 | 56 | ```js 57 | import pluginLingui from 'eslint-plugin-lingui' 58 | 59 | export default [ 60 | { 61 | plugins: { 62 | lingui: pluginLingui, 63 | }, 64 | rules: { 65 | 'lingui/t-call-in-function': 'error', 66 | }, 67 | }, 68 | // Any other config... 69 | ] 70 | ``` 71 | 72 | ## Legacy Config (`.eslintrc`) 73 | 74 | ### Recommended setup 75 | 76 | To enable all of the recommended rules for our plugin, add `plugin:lingui/recommended` in extends: 77 | 78 | ```json 79 | { 80 | "extends": ["plugin:lingui/recommended"] 81 | } 82 | ``` 83 | 84 | ### Custom setup 85 | 86 | Alternatively, add `lingui` to the plugins section, and configure the rules you want to use: 87 | 88 | ```json 89 | { 90 | "plugins": ["lingui"], 91 | "rules": { 92 | "lingui/t-call-in-function": "error" 93 | } 94 | } 95 | ``` 96 | 97 | ## Rules 98 | 99 | ✅ - Recommended 100 | 101 | - ✅ [no-expression-in-message](docs/rules/no-expression-in-message.md) 102 | - ✅ [no-single-tag-to-translate](docs/rules/no-single-tag-to-translate.md) 103 | - ✅ [no-single-variables-to-translate](docs/rules/no-single-variables-to-translate.md) 104 | - ✅ [no-trans-inside-trans](docs/rules/no-trans-inside-trans.md) 105 | - ✅ [t-call-in-function](docs/rules/t-call-in-function.md) 106 | - [no-unlocalized-strings](docs/rules/no-unlocalized-strings.md) 107 | - [text-restrictions](docs/rules/text-restrictions.md) 108 | -------------------------------------------------------------------------------- /docs/rules/no-expression-in-message.md: -------------------------------------------------------------------------------- 1 | # no-expression-in-message 2 | 3 | Check that `` t` ` `` doesn't contain member or function expressions like `` t`Hello ${user.name}` `` or `` t`Hello ${getName()}` `` 4 | 5 | Such expressions would be transformed to its index position such as `Hello {0}` which gives zero to little context for translator. 6 | 7 | Use a variable identifier instead. 8 | 9 | Examples of invalid code with this rule: 10 | 11 | ```jsx 12 | // invalid ⛔ 13 | t`Hello ${user.name}` // => 'Hello {0}' 14 | msg`Hello ${user.name}` // => 'Hello {0}' 15 | defineMessage`Hello ${user.name}` // => 'Hello {0}' 16 | ``` 17 | 18 | Examples of valid code with this rule: 19 | 20 | ```jsx 21 | // valid ✅ 22 | const userName = user.name 23 | t`Hello ${userName}` // => 'Hello {userName}' 24 | msg`Hello ${userName}` // => 'Hello {userName}' 25 | defineMessage`Hello ${userName}` // => 'Hello {userName}' 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/rules/no-single-tag-to-translate.md: -------------------------------------------------------------------------------- 1 | # no-single-tag-to-translate 2 | 3 | > [!TIP] 4 | > This rule is included into the `lingui/recommended` config 5 | 6 | Ensures `` isn't wrapping a single element unnecessarily 7 | 8 | ```jsx 9 | // nope ⛔️ 10 | Foo bar 11 | 12 | // ok ✅ 13 | Foo bar 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/rules/no-single-variables-to-translate.md: -------------------------------------------------------------------------------- 1 | # no-single-variables-to-translate 2 | 3 | > [!TIP] 4 | > This rule is included into the `lingui/recommended` config 5 | 6 | Doesn't allow single variables without text to translate like `{variable}` or `` t`${variable}` `` 7 | 8 | Such expression would pollute message catalog with useless string which has nothing to translate. 9 | 10 | Examples of invalid code with this rule: 11 | 12 | ```jsx 13 | // invalid ⛔️ 14 | ;{user} 15 | t`${user}` 16 | msg`${user}` 17 | ``` 18 | 19 | Examples of valid code with this rule: 20 | 21 | ```jsx 22 | // valid ✅ 23 | ;Hello {user} 24 | t`Hello ${user}` 25 | msg`Hello ${user}` 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/rules/no-trans-inside-trans.md: -------------------------------------------------------------------------------- 1 | # no-trans-inside-trans 2 | 3 | > [!TIP] 4 | > This rule is included into the `lingui/recommended` config 5 | 6 | Check that no `Trans` inside `Trans` components. 7 | 8 | ```jsx 9 | // nope ⛔️ 10 | Hello World! 11 | 12 | // ok ✅ 13 | Hello World! 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/rules/no-unlocalized-strings.md: -------------------------------------------------------------------------------- 1 | # no-unlocalized-strings 2 | 3 | Ensures that all string literals, templates, and JSX text are wrapped using ``, `t`, or `msg` for localization. 4 | 5 | > [!IMPORTANT] 6 | > This rule may require TypeScript type information. Enable this feature by setting `{ useTsTypes: true }`. 7 | 8 | This rule is designed to **match all** JSXText, StringLiterals, and TmplLiterals, and then exclude some of them based on attributes, property names, variable names, and so on. 9 | 10 | The rule doesn’t come with built-in ignore settings because each project is unique and needs different configurations. You can use the following config as a starting point and then adjust it for your project: 11 | 12 | 13 | ```json5 14 | { 15 | "no-unlocalized-strings": [ 16 | "error", 17 | { 18 | "ignore": [ 19 | // Ignore strings which are a single "word" (no spaces) 20 | // and doesn't start with an uppercase letter 21 | "^(?![A-Z])\\S+$", 22 | // Ignore UPPERCASE literals 23 | // Example: const test = "FOO" 24 | "^[A-Z0-9_-]+$" 25 | ], 26 | "ignoreNames": [ 27 | // Ignore matching className (case-insensitive) 28 | { "regex": { "pattern": "className", "flags": "i" } }, 29 | // Ignore UPPERCASE names 30 | // Example: test.FOO = "ola!" 31 | { "regex": { "pattern": "^[A-Z0-9_-]+$" } }, 32 | "styleName", 33 | "src", 34 | "srcSet", 35 | "type", 36 | "id", 37 | "width", 38 | "height", 39 | "displayName", 40 | "Authorization" 41 | ], 42 | "ignoreFunctions": [ 43 | "cva", 44 | "cn", 45 | "track", 46 | "Error", 47 | "console.*", 48 | "*headers.set", 49 | "*.addEventListener", 50 | "*.removeEventListener", 51 | "*.postMessage", 52 | "*.getElementById", 53 | "*.dispatch", 54 | "*.commit", 55 | "*.includes", 56 | "*.indexOf", 57 | "*.endsWith", 58 | "*.startsWith", 59 | "require" 60 | ], 61 | // Following settings require typed linting https://typescript-eslint.io/getting-started/typed-linting/ 62 | "useTsTypes": true, 63 | "ignoreMethodsOnTypes": [ 64 | // Ignore specified methods on Map and Set types 65 | "Map.get", 66 | "Map.has", 67 | "Set.has" 68 | ] 69 | } 70 | ] 71 | } 72 | ``` 73 | 74 | ## Options 75 | 76 | ### `useTsTypes` 77 | 78 | Enables the rule to use TypeScript type information. Requires [typed linting](https://typescript-eslint.io/getting-started/typed-linting/) to be configured. 79 | 80 | ### `ignore` 81 | 82 | Specifies patterns for string literals to ignore. Strings matching any of the provided regular expressions will not trigger the rule. 83 | 84 | Example for `{ "ignore": ["rgba"] }`: 85 | 86 | ```jsx 87 | /*eslint lingui/no-unlocalized-strings: ["error", {"ignore": ["rgba"]}]*/ 88 | const color =
89 | ``` 90 | 91 | ### `ignoreFunctions` 92 | 93 | Specifies functions whose string arguments should be ignored. 94 | 95 | Example of `correct` code with this option: 96 | 97 | ```js 98 | /*eslint lingui/no-unlocalized-strings: ["error", {"ignoreFunctions": ["showIntercomMessage"]}]*/ 99 | showIntercomMessage('Please write me') 100 | 101 | /*eslint lingui/no-unlocalized-strings: ["error", { "ignoreFunctions": ["cva"] }]*/ 102 | const labelVariants = cva('text-form-input-content-helper', { 103 | variants: { 104 | size: { 105 | sm: 'text-sm leading-5', 106 | md: 'text-base leading-6', 107 | }, 108 | }, 109 | }) 110 | ``` 111 | 112 | This option also supports member expressions. Example for `{ "ignoreFunctions": ["console.log"] }`: 113 | 114 | ```js 115 | /*eslint lingui/no-unlocalized-strings: ["error", {"ignoreFunctions": ["console.log"]}]*/ 116 | console.log('Log this message') 117 | ``` 118 | 119 | You can use patterns (processed by [micromatch](https://www.npmjs.com/package/micromatch)) to match function calls. 120 | 121 | ```js 122 | /*eslint lingui/no-unlocalized-strings: ["error", {"ignoreFunctions": ["console.*"]}]*/ 123 | console.log('Log this message') 124 | ``` 125 | 126 | ```js 127 | /*eslint lingui/no-unlocalized-strings: ["error", {"ignoreFunctions": ["*.headers.set"]}]*/ 128 | context.headers.set('Authorization', `Bearer ${token}`) 129 | ``` 130 | 131 | Dynamic segments are replaced with `$`, you can target them as 132 | 133 | ```js 134 | /*eslint lingui/no-unlocalized-strings: ["error", {"ignoreFunctions": ["foo.$.set"]}]*/ 135 | foo[getName()].set('Hello') 136 | ``` 137 | 138 | ### `ignoreNames` 139 | 140 | List of identifier names to ignore across attributes, properties, and variables. Use this option to exclude specific names, like "className", from being flagged by the rule. This option covers any of these contexts: JSX attribute names, variable names, or property names. 141 | 142 | Example for `{ "ignoreNames": ["style"] }`: 143 | 144 | Example of `correct` code with this option: 145 | 146 | ```jsx 147 | /* eslint lingui/no-unlocalized-strings: ["error", {"ignoreNames": ["style"]}] */ 148 | // ignored by JSX sttribute name 149 | const element =
150 | // ignored by variable name 151 | const style = 'Ignored value!' 152 | 153 | /* eslint lingui/no-unlocalized-strings: ["error", {"ignoreNames": ["displayName"]}] */ 154 | // ignored by property name 155 | const obj = { displayName: 'Ignored value' } 156 | obj.displayName = 'Ignored value' 157 | 158 | class MyClass { 159 | displayName = 'Ignored value' 160 | } 161 | ``` 162 | 163 | #### `regex` 164 | 165 | Defines regex patterns for ignored names. 166 | 167 | Example: 168 | 169 | ```json 170 | { 171 | "no-unlocalized-strings": [ 172 | "error", 173 | { 174 | "ignoreNames": [ 175 | { 176 | "regex": { 177 | "pattern": "classname", 178 | "flags": "i" 179 | } 180 | } 181 | ] 182 | } 183 | ] 184 | } 185 | ``` 186 | 187 | Example of **correct** code: 188 | 189 | ```jsx 190 | // ignored by JSX attribute name 191 | const element =
192 | 193 | // ignored by variable name 194 | const wrapperClassName = 'Ignored value' 195 | 196 | // ignored by property name 197 | const obj = { wrapperClassName: 'Ignored value' } 198 | obj.wrapperClassName = 'Ignored value' 199 | 200 | class MyClass { 201 | wrapperClassName = 'Ignored value' 202 | } 203 | ``` 204 | 205 | ### `ignoreMethodsOnTypes` 206 | 207 | Uses TypeScript type information to ignore methods defined on specific types. 208 | 209 | Requires `useTsTypes: true`. 210 | 211 | Specify methods as `Type.method`, where both the type and method are matched by name. 212 | 213 | Example for `{ "ignoreMethodsOnTypes": ["Foo.get"], "useTsTypes": true }`: 214 | 215 | ```ts 216 | interface Foo { 217 | get: (key: string) => string 218 | } 219 | 220 | const foo: Foo 221 | foo.get('Some string') 222 | ``` 223 | -------------------------------------------------------------------------------- /docs/rules/t-call-in-function.md: -------------------------------------------------------------------------------- 1 | # t-call-in-function 2 | 3 | > [!TIP] 4 | > This rule is included into the `lingui/recommended` config 5 | 6 | Check that `t` calls are inside `function`. They should not be at the module level otherwise they will not react to language switching. 7 | 8 | ```jsx 9 | import { t } from '@lingui/macro' 10 | 11 | // nope ⛔️ 12 | const msg = t`Hello world!` 13 | 14 | // ok ✅ 15 | function getGreeting() { 16 | return t`Hello world!` 17 | } 18 | ``` 19 | 20 | Check the [Lingui Docs](https://lingui.dev/ref/macro#using-macros) for more info. 21 | -------------------------------------------------------------------------------- /docs/rules/text-restrictions.md: -------------------------------------------------------------------------------- 1 | # text-restrictions 2 | 3 | Check that translated messages doesn't contain patterns from the rules. 4 | 5 | This rule enforces a consistency rules inside your messages. 6 | 7 | ## rules 8 | 9 | `rules` is array of rules when one rule has structure 10 | 11 | ```json 12 | { 13 | "patterns": ["first", "second"], 14 | "message": "error message" 15 | } 16 | ``` 17 | 18 | each `rule` has a structure: 19 | 20 | - `patterns` is an array of regex or strings 21 | - `message` is an error message that will be displayed if restricting pattern matches text 22 | - `flags` is a string with regex flags for patterns 23 | 24 | ## Example 25 | 26 | Restrict specific quotes to be used in the messages: 27 | 28 | ```json 29 | { 30 | "lingui/text-restrictions": [ 31 | "error", 32 | { 33 | "rules": [ 34 | { 35 | "patterns": ["''", "’", "“"], 36 | "message": "Quotes should be ' or \"" 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | ``` 43 | 44 | Example of invalid code with this rule: 45 | 46 | ```js 47 | t`Hello “mate“` 48 | msg`Hello “mate“` 49 | t({ message: `Hello “mate“` }) 50 | ``` 51 | 52 | Example of valid code with this rule: 53 | 54 | ```js 55 | t`Hello "mate"` 56 | ``` 57 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-lingui", 3 | "version": "0.10.1", 4 | "description": "ESLint plugin for Lingui", 5 | "keywords": [ 6 | "eslint", 7 | "eslintplugin", 8 | "eslint-plugin", 9 | "lingui", 10 | "linguijs", 11 | "lingui-js" 12 | ], 13 | "repository": "lingui/eslint-plugin", 14 | "bugs": "https://github.com/lingui/eslint-plugin/issues", 15 | "author": "Igor Dolzhenkov", 16 | "main": "lib/index.js", 17 | "files": [ 18 | "/lib", 19 | "README.md" 20 | ], 21 | "type": "commonjs", 22 | "scripts": { 23 | "test": "jest", 24 | "test:ci": "jest --ci --runInBand --coverage", 25 | "build": "rm -rf lib && tsc --project tsconfig.build.json", 26 | "prettier:check": "prettier --check --ignore-unknown '**/*'", 27 | "prepare": "husky install" 28 | }, 29 | "lint-staged": { 30 | "**/*": "prettier --write --ignore-unknown" 31 | }, 32 | "dependencies": { 33 | "@typescript-eslint/utils": "^8.0.0", 34 | "micromatch": "^4.0.0" 35 | }, 36 | "peerDependencies": { 37 | "eslint": "^8.37.0 || ^9.0.0" 38 | }, 39 | "devDependencies": { 40 | "@types/eslint": "^8.40.2", 41 | "@types/jest": "^29.5.13", 42 | "@types/micromatch": "^4.0.9", 43 | "@types/node": "^20.3.3", 44 | "@typescript-eslint/parser": "^8.0.0", 45 | "@typescript-eslint/rule-tester": "^8.0.0", 46 | "@typescript-eslint/scope-manager": "^8.0.0", 47 | "eslint": "^9.12.0", 48 | "husky": "^8.0.3", 49 | "jest": "^29.5.0", 50 | "lint-staged": "^14.0.0", 51 | "prettier": "3.3.3", 52 | "ts-jest": "^29.1.1", 53 | "ts-node": "^10.9.1", 54 | "typescript": "^5.1.6" 55 | }, 56 | "engines": { 57 | "node": ">=16.0.0" 58 | }, 59 | "publishConfig": { 60 | "access": "public" 61 | }, 62 | "license": "MIT", 63 | "packageManager": "yarn@4.5.0+sha512.837566d24eec14ec0f5f1411adb544e892b3454255e61fdef8fd05f3429480102806bac7446bc9daff3896b01ae4b62d00096c7e989f1596f2af10b927532f39" 64 | } 65 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DOM_TAGS = [ 2 | 'a', 3 | 'abbr', 4 | 'acronym', 5 | 'address', 6 | 'applet', 7 | 'area', 8 | 'article', 9 | 'aside', 10 | 'audio', 11 | 'b', 12 | 'base', 13 | 'basefont', 14 | 'bdi', 15 | 'bdo', 16 | 'big', 17 | 'blockquote', 18 | 'body', 19 | 'br', 20 | 'button', 21 | 'canvas', 22 | 'caption', 23 | 'center', 24 | 'cite', 25 | 'code', 26 | 'col', 27 | 'colgroup', 28 | 'data', 29 | 'datalist', 30 | 'dd', 31 | 'del', 32 | 'details', 33 | 'dfn', 34 | 'dialog', 35 | 'dir', 36 | 'div', 37 | 'dl', 38 | 'dt', 39 | 'em', 40 | 'embed', 41 | 'fieldset', 42 | 'figcaption', 43 | 'figure', 44 | 'font', 45 | 'footer', 46 | 'form', 47 | 'frame', 48 | 'frameset', 49 | 'h1 to h6', 50 | 'head', 51 | 'header', 52 | 'hr', 53 | 'html', 54 | 'i', 55 | 'iframe', 56 | 'img', 57 | 'input', 58 | 'ins', 59 | 'kbd', 60 | 'label', 61 | 'legend', 62 | 'li', 63 | 'link', 64 | 'main', 65 | 'map', 66 | 'mark', 67 | 'meta', 68 | 'meter', 69 | 'nav', 70 | 'noframes', 71 | 'noscript', 72 | 'object', 73 | 'ol', 74 | 'optgroup', 75 | 'option', 76 | 'output', 77 | 'p', 78 | 'param', 79 | 'picture', 80 | 'pre', 81 | 'progress', 82 | 'q', 83 | 'rp', 84 | 'rt', 85 | 'ruby', 86 | 's', 87 | 'samp', 88 | 'script', 89 | 'section', 90 | 'select', 91 | 'small', 92 | 'source', 93 | 'span', 94 | 'strike', 95 | 'strong', 96 | 'style', 97 | 'sub', 98 | 'summary', 99 | 'sup', 100 | 'svg', 101 | 'table', 102 | 'tbody', 103 | 'td', 104 | 'template', 105 | 'textarea', 106 | 'tfoot', 107 | 'th', 108 | 'thead', 109 | 'time', 110 | 'title', 111 | 'tr', 112 | 'track', 113 | 'tt', 114 | 'u', 115 | 'ul', 116 | 'var', 117 | 'video', 118 | 'wbr', 119 | ] 120 | 121 | export const SVG_TAGS = [ 122 | 'a', 123 | 'animate', 124 | 'animateMotion', 125 | 'animateTransform', 126 | 'circle', 127 | 'clipPath', 128 | 'color-profile', 129 | 'defs', 130 | 'desc', 131 | 'discard', 132 | 'ellipse', 133 | 'feBlend', 134 | 'feColorMatrix', 135 | 'feComponentTransfer', 136 | 'feComposite', 137 | 'feConvolveMatrix', 138 | 'feDiffuseLighting', 139 | 'feDisplacementMap', 140 | 'feDistantLight', 141 | 'feDropShadow', 142 | 'feFlood', 143 | 'feFuncA', 144 | 'feFuncB', 145 | 'feFuncG', 146 | 'feFuncR', 147 | 'feGaussianBlur', 148 | 'feImage', 149 | 'feMerge', 150 | 'feMergeNode', 151 | 'feMorphology', 152 | 'feOffset', 153 | 'fePointLight', 154 | 'feSpecularLighting', 155 | 'feSpotLight', 156 | 'feTile', 157 | 'feTurbulence', 158 | 'filter', 159 | 'foreignObject', 160 | 'g', 161 | 'hatch', 162 | 'hatchpath', 163 | 'image', 164 | 'line', 165 | 'linearGradient', 166 | 'marker', 167 | 'mask', 168 | 'mesh', 169 | 'meshgradient', 170 | 'meshpatch', 171 | 'meshrow', 172 | 'metadata', 173 | 'mpath', 174 | 'path', 175 | 'pattern', 176 | 'polygon', 177 | 'polyline', 178 | 'radialGradient', 179 | 'rect', 180 | 'script', 181 | 'set', 182 | 'solidcolor', 183 | 'stop', 184 | 'style', 185 | 'svg', 186 | 'switch', 187 | 'symbol', 188 | 'text', 189 | 'textPath', 190 | 'title', 191 | 'tspan', 192 | 'unknown', 193 | 'use', 194 | 'view', 195 | ] 196 | -------------------------------------------------------------------------------- /src/create-rule.ts: -------------------------------------------------------------------------------- 1 | import { ESLintUtils } from '@typescript-eslint/utils' 2 | 3 | export type ExtraRuleDocs = { 4 | recommended: 'strict' | 'error' | 'warn' 5 | } 6 | 7 | export const createRule = ESLintUtils.RuleCreator( 8 | (name) => `https://github.com/lingui/eslint-plugin/blob/main/docs/rules/${name}.md`, 9 | ) 10 | -------------------------------------------------------------------------------- /src/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '@typescript-eslint/parser' 2 | import { TSESTree } from '@typescript-eslint/utils' 3 | import { buildCalleePath } from './helpers' 4 | 5 | describe('buildCalleePath', () => { 6 | function buildCallExp(code: string) { 7 | const t = parse(code) 8 | 9 | return (t.body[0] as TSESTree.ExpressionStatement).expression as TSESTree.CallExpression 10 | } 11 | 12 | it('Should build callee path', () => { 13 | const exp = buildCallExp('one.two.three.four()') 14 | 15 | expect(buildCalleePath(exp.callee)).toBe('one.two.three.four') 16 | }) 17 | 18 | it('Should build with dynamic element', () => { 19 | const exp = buildCallExp('one.two.three[getProp()]()') 20 | 21 | expect(buildCalleePath(exp.callee)).toBe('one.two.three.$') 22 | }) 23 | 24 | it('Should build with dynamic first element', () => { 25 | const exp = buildCallExp('getData().two.three.four()') 26 | 27 | expect(buildCalleePath(exp.callee)).toBe('$.two.three.four') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | 3 | import { DOM_TAGS, SVG_TAGS } from './constants' 4 | 5 | /** 6 | * Queries for TemplateLiteral in TaggedTemplateExpression expressions: 7 | * 8 | * t`Hello` 9 | * msg`Hello` 10 | * defineMessage`Hello` 11 | */ 12 | export const LinguiTaggedTemplateExpressionMessageQuery = 13 | ':matches(TaggedTemplateExpression[tag.name=t], TaggedTemplateExpression[tag.name=msg], TaggedTemplateExpression[tag.name=defineMessage]) TemplateLiteral' 14 | 15 | /** 16 | * Queries for TemplateLiteral | StringLiteral in CallExpression expressions: 17 | * 18 | * t({message: ``}); t({message: ''}) 19 | * msg({message: ``}); msg({message: ''}) 20 | * defineMessage({message: ``}); defineMessage({message: ''}) 21 | */ 22 | export const LinguiCallExpressionMessageQuery = 23 | ':matches(CallExpression[callee.name=t], CallExpression[callee.name=msg], CallExpression[callee.name=defineMessage]) :matches(TemplateLiteral, Literal)' 24 | 25 | /** 26 | * Queries for Trans 27 | * 28 | * 29 | */ 30 | export const LinguiTransQuery = 'JSXElement[openingElement.name.name=Trans]' 31 | 32 | export function isNativeDOMTag(str: string) { 33 | return DOM_TAGS.includes(str) 34 | } 35 | 36 | export function isSvgTag(str: string) { 37 | return SVG_TAGS.includes(str) 38 | } 39 | 40 | const blacklistAttrs = ['placeholder', 'alt', 'aria-label', 'value'] 41 | export function isAllowedDOMAttr(tag: string, attr: string, attributeNames: string[]) { 42 | if (isSvgTag(tag)) return true 43 | if (isNativeDOMTag(tag)) { 44 | return !blacklistAttrs.includes(attr) 45 | } 46 | 47 | return false 48 | } 49 | 50 | export function getNearestAncestor(node: any, type: string): Type | null { 51 | let temp = node.parent 52 | while (temp) { 53 | if (temp.type === type) { 54 | return temp as Type 55 | } 56 | temp = temp.parent 57 | } 58 | return null 59 | } 60 | 61 | export const getText = ( 62 | node: TSESTree.TemplateLiteral | TSESTree.Literal | TSESTree.JSXText, 63 | trimmed = true, 64 | ): string => { 65 | let result = '' 66 | if (node.type === TSESTree.AST_NODE_TYPES.TemplateLiteral) { 67 | result = node.quasis.map((quasis) => quasis.value.cooked).join('') 68 | } else { 69 | result = node.value.toString() 70 | } 71 | 72 | return trimmed ? result.trim() : result 73 | } 74 | 75 | export function getIdentifierName(jsxTagNameExpression: TSESTree.JSXTagNameExpression) { 76 | switch (jsxTagNameExpression.type) { 77 | case TSESTree.AST_NODE_TYPES.JSXIdentifier: 78 | return jsxTagNameExpression.name 79 | default: 80 | return null 81 | } 82 | } 83 | 84 | export function isLiteral(node: TSESTree.Node | undefined): node is TSESTree.Literal { 85 | return node?.type === TSESTree.AST_NODE_TYPES.Literal 86 | } 87 | 88 | export function isTemplateLiteral( 89 | node: TSESTree.Node | undefined, 90 | ): node is TSESTree.TemplateLiteral { 91 | return node?.type === TSESTree.AST_NODE_TYPES.TemplateLiteral 92 | } 93 | 94 | export function isIdentifier(node: TSESTree.Node | undefined): node is TSESTree.Identifier { 95 | return (node as TSESTree.Node)?.type === TSESTree.AST_NODE_TYPES.Identifier 96 | } 97 | 98 | export function isMemberExpression( 99 | node: TSESTree.Node | undefined, 100 | ): node is TSESTree.MemberExpression { 101 | return (node as TSESTree.Node)?.type === TSESTree.AST_NODE_TYPES.MemberExpression 102 | } 103 | 104 | export function isJSXAttribute(node: TSESTree.Node | undefined): node is TSESTree.JSXAttribute { 105 | return (node as TSESTree.Node)?.type === TSESTree.AST_NODE_TYPES.JSXAttribute 106 | } 107 | 108 | export function buildCalleePath(node: TSESTree.Expression) { 109 | let current = node 110 | 111 | const path: string[] = [] 112 | 113 | const push = (exp: TSESTree.Node) => { 114 | if (isIdentifier(exp)) { 115 | path.push(exp.name) 116 | } else { 117 | path.push('$') 118 | } 119 | } 120 | 121 | while (isMemberExpression(current)) { 122 | push(current.property) 123 | current = current.object 124 | } 125 | 126 | push(current) 127 | 128 | return path.reverse().join('.') 129 | } 130 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as noExpressionInMessageRule from './rules/no-expression-in-message' 2 | import * as noUnlocalizedStringsRule from './rules/no-unlocalized-strings' 3 | import * as noSingleTagToTranslateRule from './rules/no-single-tag-to-translate' 4 | import * as noSingleVariablesToTranslateRule from './rules/no-single-variables-to-translate' 5 | import * as tCallInFunctionRule from './rules/t-call-in-function' 6 | import * as textRestrictionsRule from './rules/text-restrictions' 7 | import * as noTransInsideTransRule from './rules/no-trans-inside-trans' 8 | 9 | import { ESLint, Linter } from 'eslint' 10 | import { FlatConfig, RuleModule } from '@typescript-eslint/utils/ts-eslint' 11 | 12 | const rules = { 13 | [noExpressionInMessageRule.name]: noExpressionInMessageRule.rule, 14 | [noUnlocalizedStringsRule.name]: noUnlocalizedStringsRule.rule, 15 | [noSingleTagToTranslateRule.name]: noSingleTagToTranslateRule.rule, 16 | [noSingleVariablesToTranslateRule.name]: noSingleVariablesToTranslateRule.rule, 17 | [tCallInFunctionRule.name]: tCallInFunctionRule.rule, 18 | [textRestrictionsRule.name]: textRestrictionsRule.rule, 19 | [noTransInsideTransRule.name]: noTransInsideTransRule.rule, 20 | } 21 | 22 | type RuleKey = keyof typeof rules 23 | 24 | interface Plugin extends Omit { 25 | rules: Record> 26 | configs: { 27 | recommended: ESLint.ConfigData 28 | 'flat/recommended': Linter.FlatConfig 29 | } 30 | } 31 | 32 | const plugin = { 33 | meta: { 34 | name: 'eslint-plugin-lingui', 35 | }, 36 | configs: {} as Plugin['configs'], 37 | rules, 38 | } satisfies Plugin 39 | 40 | const recommendedRules: { [K in RuleKey as `lingui/${K}`]?: FlatConfig.RuleLevel } = { 41 | 'lingui/t-call-in-function': 'error', 42 | 'lingui/no-single-tag-to-translate': 'warn', 43 | 'lingui/no-single-variables-to-translate': 'warn', 44 | 'lingui/no-trans-inside-trans': 'warn', 45 | 'lingui/no-expression-in-message': 'warn', 46 | } 47 | 48 | // Assign configs here so we can reference `plugin` 49 | Object.assign(plugin.configs, { 50 | recommended: { 51 | plugins: ['lingui'], 52 | rules: recommendedRules, 53 | }, 54 | 'flat/recommended': { 55 | plugins: { 56 | lingui: plugin, 57 | }, 58 | rules: recommendedRules, 59 | }, 60 | }) 61 | 62 | export = plugin 63 | -------------------------------------------------------------------------------- /src/rules/no-expression-in-message.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { 3 | LinguiCallExpressionMessageQuery, 4 | LinguiTaggedTemplateExpressionMessageQuery, 5 | LinguiTransQuery, 6 | } from '../helpers' 7 | import { createRule } from '../create-rule' 8 | 9 | export const name = 'no-expression-in-message' 10 | export const rule = createRule({ 11 | name: 'no-expression-in-message', 12 | meta: { 13 | docs: { 14 | description: "doesn't allow functions or member expressions in templates", 15 | recommended: 'error', 16 | }, 17 | messages: { 18 | default: 'Should be ${variable}, not ${object.property} or ${myFunction()}', 19 | multiplePlaceholders: 20 | 'Invalid placeholder: Expected an object with a single key-value pair, but found multiple keys', 21 | }, 22 | schema: [ 23 | { 24 | type: 'object', 25 | properties: {}, 26 | additionalProperties: false, 27 | }, 28 | ], 29 | type: 'problem' as const, 30 | }, 31 | 32 | defaultOptions: [], 33 | create: function (context) { 34 | const linguiMacroFunctionNames = ['plural', 'select', 'selectOrdinal', 'ph'] 35 | 36 | function checkExpressionsInTplLiteral(node: TSESTree.TemplateLiteral) { 37 | node.expressions.forEach((expression) => checkExpression(expression)) 38 | } 39 | 40 | function checkExpression(expression: TSESTree.Expression) { 41 | if (expression.type === TSESTree.AST_NODE_TYPES.Identifier) { 42 | return 43 | } 44 | 45 | const isCallToLinguiMacro = 46 | expression.type === TSESTree.AST_NODE_TYPES.CallExpression && 47 | expression.callee.type === TSESTree.AST_NODE_TYPES.Identifier && 48 | linguiMacroFunctionNames.includes(expression.callee.name) 49 | 50 | if (isCallToLinguiMacro) { 51 | return 52 | } 53 | 54 | const isExplicitLabel = expression.type === TSESTree.AST_NODE_TYPES.ObjectExpression 55 | 56 | if (isExplicitLabel) { 57 | // there can be only one key in the object 58 | if (expression.properties.length === 1) { 59 | return 60 | } 61 | context.report({ 62 | node: expression, 63 | messageId: 'multiplePlaceholders', 64 | }) 65 | return 66 | } 67 | 68 | context.report({ 69 | node: expression, 70 | messageId: 'default', 71 | }) 72 | } 73 | 74 | return { 75 | [`${LinguiTaggedTemplateExpressionMessageQuery}, ${LinguiCallExpressionMessageQuery}`]( 76 | node: TSESTree.TemplateLiteral | TSESTree.Literal, 77 | ) { 78 | if (node.type === TSESTree.AST_NODE_TYPES.Literal) { 79 | return 80 | } 81 | 82 | checkExpressionsInTplLiteral(node) 83 | }, 84 | [`${LinguiTransQuery} JSXExpressionContainer:not([parent.type=JSXAttribute]) > :expression`]( 85 | node: TSESTree.Expression, 86 | ) { 87 | if (node.type === TSESTree.AST_NODE_TYPES.Literal) { 88 | // skip strings as expression in JSX, including spaces {' '} 89 | return 90 | } 91 | 92 | if (node.type === TSESTree.AST_NODE_TYPES.TemplateLiteral) { 93 | // {`How much is ${obj.prop}?`} 94 | return checkExpressionsInTplLiteral(node) 95 | } 96 | 97 | if (node.type === TSESTree.AST_NODE_TYPES.ObjectExpression) { 98 | // Hello {{name: obj.prop}} 99 | return checkExpression(node) 100 | } 101 | 102 | if (node.type === TSESTree.AST_NODE_TYPES.CallExpression) { 103 | // Hello {ph({name: obj.prop})} 104 | return checkExpression(node) 105 | } 106 | 107 | if (node.type !== TSESTree.AST_NODE_TYPES.Identifier) { 108 | context.report({ 109 | node, 110 | messageId: 'default', 111 | }) 112 | } 113 | }, 114 | } 115 | }, 116 | }) 117 | -------------------------------------------------------------------------------- /src/rules/no-single-tag-to-translate.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { createRule } from '../create-rule' 3 | import { LinguiTransQuery } from '../helpers' 4 | 5 | export const name = 'no-single-tag-to-translate' 6 | export const rule = createRule({ 7 | name, 8 | meta: { 9 | docs: { 10 | description: "doesn't allow to wrap a single element unnecessarily.", 11 | recommended: 'error', 12 | }, 13 | messages: { 14 | default: ' should not wrap a single element unnecessarily', 15 | }, 16 | schema: [ 17 | { 18 | type: 'object', 19 | properties: {}, 20 | additionalProperties: false, 21 | }, 22 | ], 23 | type: 'problem' as const, 24 | }, 25 | 26 | defaultOptions: [], 27 | 28 | create: function (context) { 29 | return { 30 | [LinguiTransQuery](node: TSESTree.JSXElement) { 31 | // delete all spaces or breaks 32 | const filteredChildren = node.children.filter((child: TSESTree.JSXChild) => { 33 | switch (child.type) { 34 | case TSESTree.AST_NODE_TYPES.JSXText: 35 | return child.value?.trim() !== '' 36 | default: 37 | return true 38 | } 39 | }) 40 | 41 | if ( 42 | filteredChildren.length === 1 && 43 | filteredChildren[0].type !== TSESTree.AST_NODE_TYPES.JSXText 44 | ) { 45 | context.report({ 46 | node, 47 | messageId: 'default', 48 | }) 49 | } 50 | }, 51 | } 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /src/rules/no-single-variables-to-translate.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | 3 | import { 4 | getText, 5 | isJSXAttribute, 6 | LinguiCallExpressionMessageQuery, 7 | LinguiTaggedTemplateExpressionMessageQuery, 8 | } from '../helpers' 9 | import { createRule } from '../create-rule' 10 | 11 | export const name = 'no-single-variables-to-translate' 12 | export const rule = createRule({ 13 | name, 14 | meta: { 15 | docs: { 16 | description: "doesn't allow single variables without text to translate", 17 | recommended: 'error', 18 | }, 19 | messages: { 20 | asJsx: "You couldn't translate just a variable, remove Trans or add some text inside", 21 | asFunction: "You couldn't translate just a variable, remove t`` or add some text inside", 22 | }, 23 | schema: [ 24 | { 25 | type: 'object', 26 | properties: {}, 27 | additionalProperties: false, 28 | }, 29 | ], 30 | type: 'problem' as const, 31 | }, 32 | 33 | defaultOptions: [], 34 | 35 | create: function (context) { 36 | const hasSomeJSXTextWithContent = (nodes: TSESTree.JSXChild[]): boolean => { 37 | return nodes.some((jsxChild) => { 38 | switch (jsxChild.type) { 39 | case TSESTree.AST_NODE_TYPES.JSXText: 40 | return jsxChild.value.trim().length > 0 41 | case TSESTree.AST_NODE_TYPES.JSXElement: 42 | case TSESTree.AST_NODE_TYPES.JSXFragment: 43 | return hasSomeJSXTextWithContent(jsxChild.children) 44 | default: 45 | return false 46 | } 47 | }) 48 | } 49 | return { 50 | 'JSXElement[openingElement.name.name=Trans]'(node: TSESTree.JSXElement) { 51 | const hasIdProperty = 52 | node.openingElement.attributes.find( 53 | (attr) => isJSXAttribute(attr) && attr.name.name === 'id', 54 | ) !== undefined 55 | 56 | if (!hasSomeJSXTextWithContent(node.children) && !hasIdProperty) { 57 | context.report({ 58 | node, 59 | messageId: 'asJsx', 60 | }) 61 | } 62 | }, 63 | [`${LinguiTaggedTemplateExpressionMessageQuery}, ${LinguiCallExpressionMessageQuery}`]( 64 | node: TSESTree.TemplateLiteral | TSESTree.Literal, 65 | ) { 66 | if (!getText(node)) { 67 | context.report({ 68 | node, 69 | messageId: 'asFunction', 70 | }) 71 | } 72 | 73 | return 74 | }, 75 | } 76 | }, 77 | }) 78 | -------------------------------------------------------------------------------- /src/rules/no-trans-inside-trans.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { createRule } from '../create-rule' 3 | import { LinguiTransQuery } from '../helpers' 4 | 5 | export const name = 'no-trans-inside-trans' 6 | export const rule = createRule({ 7 | name, 8 | meta: { 9 | docs: { 10 | description: "doesn't allow Trans component be inside Trans component", 11 | recommended: 'error', 12 | }, 13 | messages: { 14 | default: "Trans couldn't be wrapped into Trans", 15 | }, 16 | schema: [ 17 | { 18 | type: 'object', 19 | properties: {}, 20 | additionalProperties: false, 21 | }, 22 | ], 23 | type: 'problem' as const, 24 | }, 25 | 26 | defaultOptions: [], 27 | 28 | create: function (context) { 29 | return { 30 | [`${LinguiTransQuery} ${LinguiTransQuery}`](node: TSESTree.JSXElement) { 31 | context.report({ 32 | node, 33 | messageId: 'default', 34 | }) 35 | 36 | return 37 | }, 38 | } 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /src/rules/no-unlocalized-strings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ESLintUtils, 3 | JSONSchema, 4 | ParserServicesWithTypeInformation, 5 | TSESTree, 6 | } from '@typescript-eslint/utils' 7 | import { 8 | buildCalleePath, 9 | getIdentifierName, 10 | getNearestAncestor, 11 | getText, 12 | isAllowedDOMAttr, 13 | isIdentifier, 14 | isJSXAttribute, 15 | isLiteral, 16 | isMemberExpression, 17 | isTemplateLiteral, 18 | } from '../helpers' 19 | import { createRule } from '../create-rule' 20 | import * as micromatch from 'micromatch' 21 | import { TypeFlags, UnionType, Type, Expression } from 'typescript' 22 | 23 | type MatcherDef = string | { regex: { pattern: string; flags?: string } } 24 | 25 | export type Option = { 26 | ignore?: string[] 27 | ignoreFunctions?: string[] 28 | ignoreNames?: MatcherDef[] 29 | ignoreMethodsOnTypes?: string[] 30 | useTsTypes?: boolean 31 | } 32 | 33 | const MatcherSchema: JSONSchema.JSONSchema4 = { 34 | oneOf: [ 35 | { 36 | type: 'string', 37 | }, 38 | { 39 | type: 'object', 40 | properties: { 41 | regex: { 42 | type: 'object', 43 | properties: { 44 | pattern: { 45 | type: 'string', 46 | }, 47 | flags: { 48 | type: 'string', 49 | }, 50 | }, 51 | required: ['pattern'], 52 | additionalProperties: false, 53 | }, 54 | }, 55 | required: ['regex'], 56 | additionalProperties: false, 57 | }, 58 | ], 59 | } 60 | 61 | function createMatcher(patterns: MatcherDef[]) { 62 | const _patterns = patterns.map((item) => 63 | typeof item === 'string' ? item : new RegExp(item.regex.pattern, item.regex.flags), 64 | ) 65 | 66 | return (str: string) => { 67 | return _patterns.some((pattern) => { 68 | if (typeof pattern === 'string') { 69 | return pattern === str 70 | } 71 | 72 | return pattern.test(str) 73 | }) 74 | } 75 | } 76 | 77 | function isAcceptableExpression(node: TSESTree.Node): boolean { 78 | switch (node.type) { 79 | case TSESTree.AST_NODE_TYPES.LogicalExpression: 80 | case TSESTree.AST_NODE_TYPES.BinaryExpression: 81 | case TSESTree.AST_NODE_TYPES.ConditionalExpression: 82 | case TSESTree.AST_NODE_TYPES.UnaryExpression: 83 | case TSESTree.AST_NODE_TYPES.TSAsExpression: 84 | return true 85 | default: 86 | return false 87 | } 88 | } 89 | 90 | function isAssignedToIgnoredVariable( 91 | node: TSESTree.Node, 92 | isIgnoredName: (name: string) => boolean, 93 | ): boolean { 94 | let current = node 95 | let parent = current.parent 96 | 97 | while (parent && isAcceptableExpression(parent)) { 98 | current = parent 99 | parent = parent.parent 100 | } 101 | 102 | if (!parent) return false 103 | 104 | if (parent.type === TSESTree.AST_NODE_TYPES.VariableDeclarator && parent.init === current) { 105 | const variableDeclarator = parent as TSESTree.VariableDeclarator 106 | if (isIdentifier(variableDeclarator.id) && isIgnoredName(variableDeclarator.id.name)) { 107 | return true 108 | } 109 | } else if ( 110 | parent.type === TSESTree.AST_NODE_TYPES.AssignmentExpression && 111 | parent.right === current 112 | ) { 113 | const assignmentExpression = parent as TSESTree.AssignmentExpression 114 | if (isIdentifier(assignmentExpression.left) && isIgnoredName(assignmentExpression.left.name)) { 115 | return true 116 | } 117 | } 118 | 119 | return false 120 | } 121 | 122 | function isAsConstAssertion(node: TSESTree.Node): boolean { 123 | const parent = node.parent 124 | if (parent?.type === TSESTree.AST_NODE_TYPES.TSAsExpression) { 125 | const typeAnnotation = parent.typeAnnotation 126 | return ( 127 | typeAnnotation.type === TSESTree.AST_NODE_TYPES.TSTypeReference && 128 | isIdentifier(typeAnnotation.typeName) && 129 | typeAnnotation.typeName.name === 'const' 130 | ) 131 | } 132 | return false 133 | } 134 | 135 | function isStringLiteralFromUnionType( 136 | node: TSESTree.Node, 137 | tsService: ParserServicesWithTypeInformation, 138 | ): boolean { 139 | try { 140 | const checker = tsService.program.getTypeChecker() 141 | const nodeTsNode = tsService.esTreeNodeToTSNodeMap.get(node) 142 | 143 | const isStringLiteralType = (type: Type): boolean => { 144 | if (type.flags & TypeFlags.Union) { 145 | const unionType = type as UnionType 146 | return unionType.types.every((t) => t.flags & TypeFlags.StringLiteral) 147 | } 148 | return !!(type.flags & TypeFlags.StringLiteral) 149 | } 150 | 151 | // For arguments, check parameter type first 152 | if (node.parent?.type === TSESTree.AST_NODE_TYPES.CallExpression) { 153 | const callNode = node.parent 154 | const tsCallNode = tsService.esTreeNodeToTSNodeMap.get(callNode) 155 | 156 | const args = callNode.arguments as TSESTree.CallExpressionArgument[] 157 | const argIndex = args.findIndex((arg) => arg === node) 158 | 159 | const signature = checker.getResolvedSignature(tsCallNode) 160 | // Only proceed if we have a valid signature and the argument index is valid 161 | if (signature?.parameters && argIndex >= 0 && argIndex < signature.parameters.length) { 162 | const param = signature.parameters[argIndex] 163 | const paramType = checker.getTypeAtLocation(param.valueDeclaration) 164 | 165 | // For function parameters, we ONLY accept union types of string literals 166 | if (paramType.flags & TypeFlags.Union) { 167 | const unionType = paramType as UnionType 168 | return unionType.types.every((t) => t.flags & TypeFlags.StringLiteral) 169 | } 170 | } 171 | // If we're here, it's a function call argument that didn't match our criteria 172 | return false 173 | } 174 | 175 | // Try to get the contextual type first 176 | const contextualType = checker.getContextualType(nodeTsNode as Expression) 177 | if (contextualType && isStringLiteralType(contextualType)) { 178 | return true 179 | } 180 | } catch (error) {} 181 | 182 | /* istanbul ignore next */ 183 | return false 184 | } 185 | 186 | export const name = 'no-unlocalized-strings' 187 | export const rule = createRule({ 188 | name, 189 | meta: { 190 | docs: { 191 | description: 192 | 'Ensures all strings, templates, and JSX text are properly wrapped with ``, `t`, or `msg` for translation.', 193 | recommended: 'error', 194 | }, 195 | messages: { 196 | default: 'String not marked for translation. Wrap it with t``, , or msg``.', 197 | forJsxText: 'String not marked for translation. Wrap it with .', 198 | forAttribute: 199 | 'Attribute not marked for translation. \n Wrap it with t`` from useLingui() macro hook.', 200 | }, 201 | schema: [ 202 | { 203 | type: 'object', 204 | properties: { 205 | ignore: { 206 | type: 'array', 207 | items: { 208 | type: 'string', 209 | }, 210 | }, 211 | ignoreNames: { 212 | type: 'array', 213 | items: MatcherSchema, 214 | }, 215 | ignoreFunctions: { 216 | type: 'array', 217 | items: { 218 | type: 'string', 219 | }, 220 | }, 221 | ignoreMethodsOnTypes: { 222 | type: 'array', 223 | items: { 224 | type: 'string', 225 | }, 226 | }, 227 | useTsTypes: { 228 | type: 'boolean', 229 | }, 230 | }, 231 | additionalProperties: false, 232 | }, 233 | ], 234 | type: 'problem' as const, 235 | }, 236 | 237 | defaultOptions: [], 238 | 239 | create: function (context) { 240 | const { 241 | options: [option], 242 | } = context 243 | 244 | let tsService: ParserServicesWithTypeInformation 245 | if (option?.useTsTypes) { 246 | tsService = ESLintUtils.getParserServices(context, false) 247 | } 248 | const whitelists = [ 249 | // 250 | /^[^\p{L}]+$/u, // ignore non word messages 251 | ...(option?.ignore || []).map((item) => new RegExp(item)), 252 | ] 253 | 254 | const calleeWhitelists = [ 255 | // lingui callee 256 | 'i18n._', 257 | 't', 258 | 'plural', 259 | 'select', 260 | 'selectOrdinal', 261 | 'msg', 262 | ...(option?.ignoreFunctions || []), 263 | ].map((pattern) => micromatch.matcher(pattern)) 264 | 265 | const isCalleeWhitelisted = (callee: string) => 266 | calleeWhitelists.some((matcher) => matcher(callee)) 267 | 268 | //---------------------------------------------------------------------- 269 | // Helpers 270 | //---------------------------------------------------------------------- 271 | function isTextWhiteListed(str: string) { 272 | return whitelists.some((item) => item.test(str)) 273 | } 274 | 275 | function isValidFunctionCall({ 276 | callee, 277 | }: TSESTree.CallExpression | TSESTree.NewExpression): boolean { 278 | switch (callee.type) { 279 | case TSESTree.AST_NODE_TYPES.MemberExpression: { 280 | if (isCalleeWhitelisted(buildCalleePath(callee))) { 281 | return true 282 | } 283 | 284 | // use power of TS compiler to exclude call on specific types, such Map.get, Set.get and so on 285 | if (tsService && isIdentifier(callee.property)) { 286 | for (const ignore of ignoredMethodsOnTypes) { 287 | const [type, method] = ignore.split('.') 288 | 289 | if (method === callee.property.name) { 290 | const typeObj = tsService.getTypeAtLocation(callee.object) 291 | 292 | if (type === typeObj?.getSymbol()?.name) { 293 | return true 294 | } 295 | } 296 | } 297 | } 298 | 299 | return false 300 | } 301 | case TSESTree.AST_NODE_TYPES.Identifier: { 302 | return isCalleeWhitelisted(callee.name) 303 | } 304 | case TSESTree.AST_NODE_TYPES.CallExpression: { 305 | return ( 306 | (isMemberExpression(callee.callee) || isIdentifier(callee.callee)) && 307 | isValidFunctionCall(callee) 308 | ) 309 | } 310 | /* istanbul ignore next */ 311 | default: 312 | return false 313 | } 314 | } 315 | 316 | /** 317 | * Helper function to determine if a node is inside an ignored property. 318 | */ 319 | function isInsideIgnoredProperty(node: TSESTree.Node): boolean { 320 | let parent = node.parent 321 | 322 | while (parent) { 323 | if (parent.type === TSESTree.AST_NODE_TYPES.Property) { 324 | const key = parent.key 325 | if ( 326 | (isIdentifier(key) && isIgnoredName(key.name)) || 327 | ((isLiteral(key) || isTemplateLiteral(key)) && isIgnoredName(getText(key))) 328 | ) { 329 | return true 330 | } 331 | } 332 | parent = parent.parent 333 | } 334 | 335 | return false 336 | } 337 | 338 | const ignoredJSXSymbols = ['←', ' ', '·'] 339 | 340 | const ignoredMethodsOnTypes = option?.ignoreMethodsOnTypes || [] 341 | 342 | //---------------------------------------------------------------------- 343 | // Public 344 | //---------------------------------------------------------------------- 345 | const visited = new WeakSet() 346 | 347 | function isIgnoredSymbol(str: string) { 348 | return ignoredJSXSymbols.some((name) => name === str) 349 | } 350 | 351 | const isIgnoredName = createMatcher(option?.ignoreNames || []) 352 | 353 | function isStringLiteral(node: TSESTree.Node | null | undefined): boolean { 354 | if (!node) return false 355 | 356 | switch (node.type) { 357 | case TSESTree.AST_NODE_TYPES.Literal: 358 | return typeof node.value === 'string' 359 | case TSESTree.AST_NODE_TYPES.TemplateLiteral: 360 | return Boolean(node.quasis) 361 | case TSESTree.AST_NODE_TYPES.JSXText: 362 | return true 363 | /* istanbul ignore next */ 364 | default: 365 | return false 366 | } 367 | } 368 | 369 | const getAttrName = (node: TSESTree.JSXIdentifier | string) => { 370 | if (typeof node === 'string') { 371 | return node 372 | } 373 | 374 | /* istanbul ignore next */ 375 | return node?.name 376 | } 377 | 378 | function isLiteralInsideJSX(node: TSESTree.Node): boolean { 379 | let parent = node.parent 380 | let insideJSXExpression = false 381 | 382 | while (parent) { 383 | if (parent.type === TSESTree.AST_NODE_TYPES.JSXExpressionContainer) { 384 | insideJSXExpression = true 385 | } 386 | if (parent.type === TSESTree.AST_NODE_TYPES.JSXElement && insideJSXExpression) { 387 | return true 388 | } 389 | parent = parent.parent 390 | } 391 | 392 | /* istanbul ignore next */ 393 | return false 394 | } 395 | 396 | function isInsideTypeContext(node: TSESTree.Node): boolean { 397 | let parent = node.parent 398 | 399 | while (parent) { 400 | switch (parent.type) { 401 | case TSESTree.AST_NODE_TYPES.TSPropertySignature: 402 | case TSESTree.AST_NODE_TYPES.TSIndexSignature: 403 | case TSESTree.AST_NODE_TYPES.TSTypeAnnotation: 404 | case TSESTree.AST_NODE_TYPES.TSTypeLiteral: 405 | case TSESTree.AST_NODE_TYPES.TSLiteralType: 406 | return true 407 | } 408 | parent = parent.parent 409 | } 410 | 411 | return false 412 | } 413 | 414 | const processTextNode = ( 415 | node: TSESTree.Literal | TSESTree.TemplateLiteral | TSESTree.JSXText, 416 | ) => { 417 | visited.add(node) 418 | 419 | const text = getText(node) 420 | if (!text || isIgnoredSymbol(text) || isTextWhiteListed(text)) { 421 | /* istanbul ignore next */ 422 | return 423 | } 424 | 425 | // First, handle the JSXText case directly 426 | if (node.type === TSESTree.AST_NODE_TYPES.JSXText) { 427 | context.report({ node, messageId: 'forJsxText' }) 428 | return 429 | } 430 | 431 | // If it's not JSXText, it might be a Literal or TemplateLiteral. 432 | // Check if it's inside JSX. 433 | if (isLiteralInsideJSX(node)) { 434 | // If it's a Literal/TemplateLiteral inside a JSXExpressionContainer within JSXElement, 435 | // treat it like JSX text and report with `forJsxText`. 436 | context.report({ node, messageId: 'forJsxText' }) 437 | return 438 | } 439 | 440 | /* istanbul ignore next */ 441 | // If neither JSXText nor a Literal inside JSX, fall back to default messageId. 442 | context.report({ node, messageId: 'default' }) 443 | } 444 | 445 | const visitor: { 446 | [key: string]: (node: any) => void 447 | } = { 448 | 'ImportDeclaration Literal'(node: TSESTree.Literal) { 449 | // allow (import abc form 'abc') 450 | visited.add(node) 451 | }, 452 | 453 | 'ExportAllDeclaration Literal'(node: TSESTree.Literal) { 454 | // allow export * from 'mod' 455 | visited.add(node) 456 | }, 457 | 458 | 'ExportNamedDeclaration > Literal'(node: TSESTree.Literal) { 459 | // allow export { named } from 'mod' 460 | visited.add(node) 461 | }, 462 | 463 | [`:matches(${['Trans', 'Plural', 'Select', 'SelectOrdinal'].map((name) => `JSXElement[openingElement.name.name=${name}]`)}) :matches(TemplateLiteral, Literal, JSXText)`]( 464 | node, 465 | ) { 466 | visited.add(node) 467 | }, 468 | 469 | 'JSXElement > JSXExpressionContainer > Literal'(node: TSESTree.Literal) { 470 | processTextNode(node) 471 | }, 472 | 473 | 'JSXElement > JSXExpressionContainer > TemplateLiteral'(node: TSESTree.TemplateLiteral) { 474 | processTextNode(node) 475 | }, 476 | 477 | 'JSXAttribute :matches(Literal,TemplateLiteral)'( 478 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 479 | ) { 480 | const parent = getNearestAncestor( 481 | node, 482 | TSESTree.AST_NODE_TYPES.JSXAttribute, 483 | ) 484 | const attrName = getAttrName(parent?.name?.name) 485 | 486 | // allow 487 | if (isIgnoredName(attrName)) { 488 | visited.add(node) 489 | return 490 | } 491 | 492 | const jsxElement = getNearestAncestor( 493 | node, 494 | TSESTree.AST_NODE_TYPES.JSXOpeningElement, 495 | ) 496 | const tagName = getIdentifierName(jsxElement?.name) 497 | const attributeNames = jsxElement?.attributes.map( 498 | (attr) => isJSXAttribute(attr) && getAttrName(attr.name.name), 499 | ) 500 | if (isAllowedDOMAttr(tagName, attrName, attributeNames)) { 501 | visited.add(node) 502 | return 503 | } 504 | }, 505 | 506 | 'TSLiteralType Literal'(node: TSESTree.Literal) { 507 | // allow var a: Type['member']; 508 | visited.add(node) 509 | }, 510 | // ───────────────────────────────────────────────────────────────── 511 | 'ClassProperty > :matches(Literal,TemplateLiteral), PropertyDefinition > :matches(Literal,TemplateLiteral)'( 512 | node: TSESTree.Literal, 513 | ) { 514 | const { parent } = node 515 | if ( 516 | (parent.type === TSESTree.AST_NODE_TYPES.Property || 517 | parent.type === TSESTree.AST_NODE_TYPES.PropertyDefinition || 518 | //@ts-ignore 519 | parent.type === 'ClassProperty') && 520 | isIdentifier(parent.key) && 521 | isIgnoredName(parent.key.name) 522 | ) { 523 | visited.add(node) 524 | } 525 | }, 526 | 527 | 'TSEnumMember > :matches(Literal,TemplateLiteral)'(node: TSESTree.Literal) { 528 | visited.add(node) 529 | }, 530 | 531 | 'VariableDeclarator > :matches(Literal,TemplateLiteral)'( 532 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 533 | ) { 534 | const parent = node.parent as TSESTree.VariableDeclarator 535 | 536 | // allow statements like const A_B = "test" 537 | if (isIdentifier(parent.id) && isIgnoredName(parent.id.name)) { 538 | visited.add(node) 539 | } 540 | }, 541 | 'MemberExpression[computed=true] > :matches(Literal,TemplateLiteral)'( 542 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 543 | ) { 544 | // obj["key with space"] 545 | visited.add(node) 546 | }, 547 | "AssignmentExpression[left.type='MemberExpression'] > Literal"(node: TSESTree.Literal) { 548 | // options: { ignoreProperties: ['myProperty'] } 549 | // MyComponent.myProperty = "Hello" 550 | const assignmentExp = node.parent as TSESTree.AssignmentExpression 551 | const memberExp = assignmentExp.left as TSESTree.MemberExpression 552 | if ( 553 | !memberExp.computed && 554 | isIdentifier(memberExp.property) && 555 | isIgnoredName(memberExp.property.name) 556 | ) { 557 | visited.add(node) 558 | } 559 | }, 560 | 'BinaryExpression > :matches(Literal,TemplateLiteral)'( 561 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 562 | ) { 563 | if (node.parent.type === TSESTree.AST_NODE_TYPES.BinaryExpression) { 564 | const { 565 | parent: { operator }, 566 | } = node 567 | 568 | // allow name === 'String' 569 | if (operator !== '+') { 570 | visited.add(node) 571 | } 572 | } 573 | }, 574 | 575 | 'CallExpression :matches(Literal,TemplateLiteral)'( 576 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 577 | ) { 578 | const parent = getNearestAncestor( 579 | node, 580 | TSESTree.AST_NODE_TYPES.CallExpression, 581 | ) 582 | 583 | if (isValidFunctionCall(parent)) { 584 | visited.add(node) 585 | return 586 | } 587 | }, 588 | 589 | 'NewExpression :matches(Literal,TemplateLiteral)'( 590 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 591 | ) { 592 | const parent = getNearestAncestor( 593 | node, 594 | TSESTree.AST_NODE_TYPES.NewExpression, 595 | ) 596 | 597 | if (isValidFunctionCall(parent)) { 598 | visited.add(node) 599 | return 600 | } 601 | }, 602 | 603 | 'SwitchCase > :matches(Literal,TemplateLiteral)'( 604 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 605 | ) { 606 | visited.add(node) 607 | }, 608 | 609 | 'TaggedTemplateExpression > TemplateLiteral'(node: TSESTree.TemplateLiteral) { 610 | visited.add(node) 611 | }, 612 | 613 | 'TaggedTemplateExpression > TemplateLiteral :matches(Literal,TemplateLiteral)'( 614 | node: TSESTree.Literal, 615 | ) { 616 | visited.add(node) 617 | }, 618 | 619 | 'JSXText:exit'(node: TSESTree.JSXText) { 620 | if (visited.has(node)) return 621 | processTextNode(node) 622 | }, 623 | 624 | 'Literal:exit'(node: TSESTree.Literal) { 625 | if (visited.has(node)) return 626 | const trimmed = `${node.value}`.trim() 627 | if (!trimmed) return 628 | 629 | if (isTextWhiteListed(trimmed)) { 630 | return 631 | } 632 | 633 | if (isAsConstAssertion(node)) { 634 | return 635 | } 636 | 637 | // Add check for object property key 638 | const parent = node.parent 639 | if (parent?.type === TSESTree.AST_NODE_TYPES.Property && parent.key === node) { 640 | return 641 | } 642 | 643 | // More thorough type checking when enabled 644 | if (option?.useTsTypes && tsService) { 645 | try { 646 | if (isStringLiteralFromUnionType(node, tsService)) { 647 | return 648 | } 649 | } catch (error) { 650 | // Ignore type checking errors 651 | } 652 | } 653 | 654 | if (isAssignedToIgnoredVariable(node, isIgnoredName)) { 655 | return 656 | } 657 | 658 | if (isInsideIgnoredProperty(node)) { 659 | return 660 | } 661 | 662 | if (isInsideTypeContext(node)) { 663 | return 664 | } 665 | 666 | context.report({ node, messageId: 'default' }) 667 | }, 668 | 669 | 'TemplateLiteral:exit'(node: TSESTree.TemplateLiteral) { 670 | if (visited.has(node)) return 671 | const text = getText(node) 672 | 673 | if (!text || isTextWhiteListed(text)) return 674 | 675 | if (isAsConstAssertion(node)) { 676 | return 677 | } 678 | 679 | if (isAssignedToIgnoredVariable(node, isIgnoredName)) { 680 | return // Do not report this template literal 681 | } 682 | 683 | if (isInsideIgnoredProperty(node)) { 684 | return 685 | } 686 | 687 | context.report({ node, messageId: 'default' }) 688 | }, 689 | 690 | 'AssignmentPattern > :matches(Literal,TemplateLiteral)'( 691 | node: TSESTree.Literal | TSESTree.TemplateLiteral, 692 | ) { 693 | const parent = node.parent as TSESTree.AssignmentPattern 694 | 695 | if (isIdentifier(parent.left) && isIgnoredName(parent.left.name)) { 696 | visited.add(node) 697 | } 698 | }, 699 | } 700 | 701 | function wrapVisitor< 702 | Type extends TSESTree.Literal | TSESTree.TemplateLiteral | TSESTree.JSXText, 703 | >(visitor: { [key: string]: (node: Type) => void }) { 704 | const newVisitor: { 705 | [key: string]: (node: Type) => void 706 | } = {} 707 | Object.keys(visitor).forEach((key) => { 708 | const old: (node: Type) => void = visitor[key] 709 | 710 | newVisitor[key] = (node: Type) => { 711 | // make sure node is string literal 712 | if (!isStringLiteral(node)) return 713 | 714 | old(node) 715 | } 716 | }) 717 | return newVisitor 718 | } 719 | 720 | return wrapVisitor(visitor) 721 | }, 722 | }) 723 | -------------------------------------------------------------------------------- /src/rules/t-call-in-function.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import type { Scope, ScopeType } from '@typescript-eslint/scope-manager' 3 | import { createRule } from '../create-rule' 4 | 5 | export function hasAncestorScope(node: Scope, types: `${ScopeType}`[]): boolean { 6 | let current = node 7 | 8 | while (current) { 9 | if (types.includes(current.type)) { 10 | return true 11 | } 12 | current = current.upper 13 | } 14 | return false 15 | } 16 | 17 | export const name = 't-call-in-function' 18 | export const rule = createRule({ 19 | name, 20 | meta: { 21 | docs: { 22 | description: 'allow t call only inside functions', 23 | recommended: 'error', 24 | }, 25 | messages: { 26 | default: 't`` and t() call should be inside function', 27 | }, 28 | schema: [ 29 | { 30 | type: 'object', 31 | properties: {}, 32 | additionalProperties: false, 33 | }, 34 | ], 35 | type: 'problem' as const, 36 | }, 37 | 38 | defaultOptions: [], 39 | 40 | create: (context) => { 41 | const sourceCode = context.sourceCode ?? context.getSourceCode() 42 | 43 | return { 44 | 'TaggedTemplateExpression[tag.name=t], CallExpression[callee.name=t]'( 45 | node: TSESTree.TaggedTemplateExpression, 46 | ) { 47 | const scope = sourceCode.getScope 48 | ? // available from ESLint v8.37.0 49 | sourceCode.getScope(node) 50 | : // deprecated and remove in V9 51 | context.getScope() 52 | 53 | if (!hasAncestorScope(scope, ['function', 'class-field-initializer'])) { 54 | context.report({ 55 | node, 56 | messageId: 'default', 57 | }) 58 | } 59 | }, 60 | } 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /src/rules/text-restrictions.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | 3 | import { 4 | getText, 5 | LinguiCallExpressionMessageQuery, 6 | LinguiTaggedTemplateExpressionMessageQuery, 7 | LinguiTransQuery, 8 | } from '../helpers' 9 | import { createRule } from '../create-rule' 10 | 11 | export type RestrictionRule = { 12 | patterns: string[] 13 | message: string 14 | flags?: string 15 | } 16 | 17 | type RegexRule = { 18 | patterns: RegExp[] 19 | message: string 20 | flags?: string 21 | } 22 | 23 | export type Option = { 24 | rules: RestrictionRule[] 25 | } 26 | 27 | export const name = 'text-restrictions' 28 | export const rule = createRule({ 29 | name, 30 | meta: { 31 | docs: { 32 | description: 'Text restrictions', 33 | recommended: 'error', 34 | }, 35 | messages: { 36 | default: '{{ message }}', 37 | }, 38 | schema: [ 39 | { 40 | type: 'object', 41 | properties: { 42 | rules: { 43 | type: 'array', 44 | items: { 45 | type: 'object', 46 | properties: { 47 | patterns: { 48 | type: 'array', 49 | items: { 50 | type: 'string', 51 | }, 52 | }, 53 | flags: { 54 | type: 'string', 55 | }, 56 | message: { 57 | type: 'string', 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | additionalProperties: false, 64 | }, 65 | ], 66 | type: 'problem' as const, 67 | }, 68 | 69 | defaultOptions: [], 70 | 71 | create: function (context) { 72 | const { 73 | options: [option], 74 | } = context 75 | if (!option?.rules?.length) { 76 | return {} 77 | } 78 | 79 | const { rules } = option 80 | 81 | const rulePatterns: RegexRule[] = rules.map( 82 | ({ patterns, message, flags }: RestrictionRule) => ({ 83 | patterns: patterns.map((item: string) => new RegExp(item, flags)), 84 | message, 85 | }), 86 | ) 87 | 88 | return { 89 | [`${LinguiTaggedTemplateExpressionMessageQuery}, ${LinguiCallExpressionMessageQuery}, ${LinguiTransQuery} JSXText`]( 90 | node: TSESTree.TemplateLiteral | TSESTree.Literal | TSESTree.JSXText, 91 | ) { 92 | const text = getText(node, node.type === TSESTree.AST_NODE_TYPES.JSXText) 93 | 94 | rulePatterns.forEach(({ patterns, message }: RegexRule) => { 95 | if (patterns.some((item: RegExp) => item.test(text))) { 96 | context.report({ node, messageId: 'default', data: { message: message } }) 97 | } 98 | }) 99 | 100 | return 101 | }, 102 | } 103 | }, 104 | }) 105 | -------------------------------------------------------------------------------- /tests/helpers/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "Hello": "Hello", 3 | "Hello {hello}": "Hello {hello}", 4 | "Hello {0}": "Hello {0}", 5 | "Hello {0} {1}": "Hello {0} {1}", 6 | "Hello {0} {hello} {1}": "Hello {0} {hello} {1}", 7 | "Hello {hello} {0} {1}": "Hello {hello} {0} {1}", 8 | "Hello <0>nice": "Hello <0>nice", 9 | "Hello <0>nice <1>hello": "Hello <0>nice <1>hello", 10 | "<0>nice Hello <1>hello": "<0>nice Hello <1>hello", 11 | "<0>nice <1>Hello, <2>nice <3>hello": "<0>nice <1>Hello, <2>nice <3>hello", 12 | "Hello <0/> <1>nice": "Hello <0/> <1>nice", 13 | "Hello {nice} <0>nice": "Hello {nice} <0>nice", 14 | "Hello {0} <0>nice": "Hello {0} <0>nice", 15 | "Please check if you clicked the right link or contact your admin to get a new invite to Pleo.": "Please check if you clicked the right link or contact your admin to get a new invite to Pleo.", 16 | "Please <0>check if you {click} <1>the right<1/> link or contact <2/>your admin to get a new invite to Pleo.": "Please <0>check if you {click} <1>the right<1/> link or contact <2/>your admin to get a new invite to Pleo.", 17 | "Welcome to Pleo <0>👋": "Welcome to Pleo <0>👋", 18 | "Create your account to join <0>{companyName}": "Create your account to join <0>{companyName}", 19 | "Pleo will import everything from your {0} {tagsDescription}": "Pleo will import everything from your {0} {tagsDescription}", 20 | "Done with {0, plural, one {# error} other {# errors}}": "Done with {0, plural, one {# error} other {# errors}}", 21 | "{0, plural, one {# hello} other {# hellos}}": "{0, plural, one {# hello} other {# hellos}}", 22 | "{failures, plural, one {# bye} other {# byes}}": "{failures, plural, one {# bye} other {# byes}}", 23 | "{numberOfInvoices, plural, _0 {Nothing found...} one {# invoice} other {# invoices}}": "{numberOfInvoices, plural, _0 {Nothing found...} one {# invoice} other {# invoices}}", 24 | "{0, plural, _0 {Nothing found...} one {# invoice} other {# invoices}}": "{0, plural, _0 {Nothing found...} one {# invoice} other {# invoices}}", 25 | "{remainingCount, plural, one {and 1 other team} other {and {remainingCount} other teams}}": "{remainingCount, plural, one {and 1 other team} other {and {remainingCount} other teams}}", 26 | "<0>{0, plural, one {# expense} other {# expenses}} totalling {1}": "<0>{0, plural, one {# expense} other {# expenses}} totalling {1}", 27 | "split-header": "Split", 28 | "For your export to work, the custom Buchsymbol '<0>{booksymbol}' has to be added manually in BMD.": "For your export to work, the custom Buchsymbol '<0>{booksymbol}' has to be added manually in BMD.", 29 | "Scheduled for today": "Scheduled for today", 30 | "[Payment due on] {FULL_DATE}": "[Payment due on] {FULL_DATE}" 31 | } 32 | -------------------------------------------------------------------------------- /tests/helpers/parsers.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | const NODE_MODULES = '../../node_modules' 4 | 5 | export const TYPESCRIPT_ESLINT = path.join(__dirname, NODE_MODULES, '@typescript-eslint/parser') 6 | -------------------------------------------------------------------------------- /tests/src/rules/no-expression-in-message.test.ts: -------------------------------------------------------------------------------- 1 | import { rule, name } from '../../../src/rules/no-expression-in-message' 2 | import { RuleTester } from '@typescript-eslint/rule-tester' 3 | 4 | describe('', () => {}) 5 | 6 | const ruleTester = new RuleTester({ 7 | languageOptions: { 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | }, 13 | }, 14 | }) 15 | 16 | ruleTester.run(name, rule, { 17 | valid: [ 18 | { 19 | code: '`Hello ${hello}`', 20 | }, 21 | { 22 | code: '`Hello ${obj.prop}`', 23 | }, 24 | { 25 | code: '`Hello ${func()}`', 26 | }, 27 | { 28 | code: 'g`Hello ${hello}`', 29 | }, 30 | { 31 | code: 'g`Hello ${obj.prop}`', 32 | }, 33 | { 34 | code: 'g`Hello ${func()}`', 35 | }, 36 | { 37 | code: 't`Hello ${hello}`', 38 | }, 39 | { 40 | code: 'msg`Hello ${hello}`', 41 | }, 42 | { 43 | code: 'defineMessage`Hello ${hello}`', 44 | }, 45 | { 46 | code: 'b({message: `hello ${user.name}?`})', 47 | }, 48 | { 49 | code: 't({message: `hello ${user}?`})', 50 | }, 51 | { 52 | code: 't({message: "StringLiteral"})', 53 | }, 54 | { 55 | code: 'msg({message: `hello ${user}?`})', 56 | }, 57 | { 58 | code: 'defineMessage({message: `hello ${user}?`})', 59 | }, 60 | { 61 | code: 't`Hello ${plural()}`', 62 | }, 63 | { 64 | code: 't`Hello ${select()}`', 65 | }, 66 | { 67 | code: 't`Hello ${selectOrdinal()}`', 68 | }, 69 | { 70 | code: 'Hello ', 71 | }, 72 | { 73 | code: 'Hello ', 74 | }, 75 | { 76 | code: 'Hello {userName}', 77 | }, 78 | { 79 | name: 'Should not be triggered for JSX Whitespace expression', 80 | code: "Did you mean{' '}something{` `}", 81 | }, 82 | { 83 | name: 'Template literals as children with identifiers', 84 | code: ' {`How much is ${expression}? ${count}`}', 85 | }, 86 | { 87 | name: 'Strings as children are preserved', 88 | code: '{"hello {count, plural, one {world} other {worlds}}"}', 89 | }, 90 | { 91 | code: 't`hello ${{name: obj.prop}}`', 92 | }, 93 | { 94 | code: 't`hello ${ph({name: obj.prop})}`', 95 | }, 96 | { 97 | code: 'hello {{name: obj.prop}}', 98 | }, 99 | { 100 | code: 'hello {ph({name: obj.prop})}', 101 | }, 102 | ], 103 | invalid: [ 104 | { 105 | code: 't`hello ${obj.prop}?`', 106 | errors: [{ messageId: 'default' }], 107 | }, 108 | { 109 | code: 'msg`hello ${obj.prop}?`', 110 | errors: [{ messageId: 'default' }], 111 | }, 112 | { 113 | code: 'defineMessage`hello ${obj.prop}?`', 114 | errors: [{ messageId: 'default' }], 115 | }, 116 | { 117 | code: 't({message: `hello ${obj.prop}?`})', 118 | errors: [{ messageId: 'default' }], 119 | }, 120 | { 121 | code: 'msg({message: `hello ${obj.prop}?`})', 122 | errors: [{ messageId: 'default' }], 123 | }, 124 | { 125 | code: 'defineMessage({message: `hello ${obj.prop}?`})', 126 | errors: [{ messageId: 'default' }], 127 | }, 128 | { 129 | name: 'Should trigger for each expression in the message', 130 | code: 't`hello ${obj.prop} ${obj.prop}?`', 131 | errors: [{ messageId: 'default' }, { messageId: 'default' }], 132 | }, 133 | { 134 | code: 'Hello {obj.prop}', 135 | errors: [{ messageId: 'default' }], 136 | }, 137 | { 138 | name: 'Template literals as children with expressions', 139 | code: '{`How much is ${obj.prop}?`}', 140 | errors: [{ messageId: 'default' }], 141 | }, 142 | { 143 | code: 't`hello ${func()}?`', 144 | errors: [{ messageId: 'default' }], 145 | }, 146 | { 147 | code: 't`hello ${{name: obj.foo, surname: obj.bar}}`', 148 | errors: [{ messageId: 'multiplePlaceholders' }], 149 | }, 150 | { 151 | code: 't`hello ${greeting({name: obj.prop})}`', 152 | errors: [{ messageId: 'default' }], 153 | }, 154 | { 155 | code: 'hello {{name: obj.foo, surname: obj.bar}}', 156 | errors: [{ messageId: 'multiplePlaceholders' }], 157 | }, 158 | { 159 | code: 'hello {greeting({name: obj.prop})}', 160 | errors: [{ messageId: 'default' }], 161 | }, 162 | ], 163 | }) 164 | -------------------------------------------------------------------------------- /tests/src/rules/no-single-tag-to-translate.test.ts: -------------------------------------------------------------------------------- 1 | import { rule, name } from '../../../src/rules/no-single-tag-to-translate' 2 | import { RuleTester } from '@typescript-eslint/rule-tester' 3 | 4 | describe('', () => {}) 5 | 6 | const ruleTester = new RuleTester({ 7 | languageOptions: { 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | }, 13 | }, 14 | }) 15 | 16 | ruleTester.run(name, rule, { 17 | valid: [ 18 | { 19 | code: 'foo', 20 | }, 21 | { 22 | code: '
foo
', 23 | }, 24 | { 25 | code: '
foo
bar
', 26 | }, 27 | { 28 | code: '
foosomething bold!bar
', 29 | }, 30 | { 31 | code: '
foo
hello thereweee 😃
bar
', 32 | }, 33 | ], 34 | invalid: [ 35 | { 36 | code: `Yoooo`, 37 | errors: [{ messageId: 'default' }], 38 | }, 39 | { 40 | code: `Yoooo`, 41 | errors: [{ messageId: 'default' }], 42 | }, 43 | { 44 | code: ` 45 | 46 | Yoooo 47 | 48 | `, 49 | errors: [{ messageId: 'default' }], 50 | }, 51 | ], 52 | }) 53 | -------------------------------------------------------------------------------- /tests/src/rules/no-single-variables-to-translate.test.ts: -------------------------------------------------------------------------------- 1 | import { rule, name } from '../../../src/rules/no-single-variables-to-translate' 2 | import { RuleTester } from '@typescript-eslint/rule-tester' 3 | 4 | describe('', () => {}) 5 | 6 | const errorsForTrans = [{ messageId: 'asJsx' }] as const 7 | const errorsForT = [{ messageId: 'asFunction' }] as const 8 | 9 | const ruleTester = new RuleTester({ 10 | languageOptions: { 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | }, 16 | }, 17 | }) 18 | 19 | ruleTester.run(name, rule, { 20 | valid: [ 21 | { 22 | code: 't`Hello`', 23 | }, 24 | { 25 | code: 't`Hello ${hello}`', 26 | }, 27 | { 28 | code: 'msg`Hello ${hello}`', 29 | }, 30 | { 31 | code: 'defineMessage`Hello ${hello}`', 32 | }, 33 | { 34 | code: 't`${hello} Hello ${hello}`', 35 | }, 36 | { 37 | code: 't({message: "StringLiteral"})', 38 | }, 39 | { 40 | code: 'Hello', 41 | }, 42 | 43 | { 44 | code: 'Hello {hello}', 45 | }, 46 | 47 | { 48 | code: '{hello} Hello {hello}', 49 | }, 50 | 51 | { 52 | code: 'Hello', 53 | }, 54 | 55 | { 56 | code: '{hello}', 57 | }, 58 | 59 | { 60 | code: '`${hello}`', 61 | }, 62 | { 63 | code: 'b({message: `${hello}`})', 64 | }, 65 | { 66 | code: 't({message: `Hello ${user}`})', 67 | }, 68 | { 69 | code: 'msg({message: `Hello ${user}`})', 70 | }, 71 | { 72 | code: 'defineMessage({message: `Hello ${user}`})', 73 | }, 74 | { 75 | code: '', 76 | }, 77 | ], 78 | 79 | invalid: [ 80 | { 81 | code: '{hello}', 82 | 83 | errors: errorsForTrans, 84 | }, 85 | { 86 | code: '{hello} {hello}', 87 | 88 | errors: errorsForTrans, 89 | }, 90 | { 91 | code: ` 92 | {formatCurrency( 93 | invoice.total.value, 94 | invoice.total.currency 95 | )} 96 | `, 97 | 98 | errors: errorsForTrans, 99 | }, 100 | { 101 | code: ` 102 | {limitType}{' '} 103 | {formatCurrency( 104 | Math.abs(employee.limits.limitValue), 105 | currency 106 | )} 107 | `, 108 | errors: errorsForTrans, 109 | }, 110 | { 111 | code: ` 112 | {formatCurrency( 113 | invoice.total.value, 114 | invoice.total.currency 115 | )} 116 | {formatCurrency( 117 | invoice.total.value, 118 | invoice.total.currency 119 | )} 120 | `, 121 | 122 | errors: errorsForTrans, 123 | }, 124 | { 125 | code: 't`${hello}`', 126 | 127 | errors: errorsForT, 128 | }, 129 | { 130 | code: 'msg`${hello}`', 131 | 132 | errors: errorsForT, 133 | }, 134 | { 135 | code: 'defineMessage`${hello}`', 136 | 137 | errors: errorsForT, 138 | }, 139 | { 140 | code: 't({message: `${hello}`})', 141 | errors: errorsForT, 142 | }, 143 | { 144 | code: 'msg({message: `${user}`})', 145 | errors: errorsForT, 146 | }, 147 | { 148 | code: 'defineMessage({message: `${user}`})', 149 | errors: errorsForT, 150 | }, 151 | ], 152 | }) 153 | -------------------------------------------------------------------------------- /tests/src/rules/no-trans-inside-trans.test.ts: -------------------------------------------------------------------------------- 1 | import { rule, name } from '../../../src/rules/no-trans-inside-trans' 2 | import { RuleTester } from '@typescript-eslint/rule-tester' 3 | 4 | describe('', () => {}) 5 | 6 | const ruleTester = new RuleTester({ 7 | languageOptions: { 8 | parserOptions: { 9 | ecmaFeatures: { 10 | jsx: true, 11 | }, 12 | }, 13 | }, 14 | }) 15 | 16 | ruleTester.run(name, rule, { 17 | valid: [ 18 | { 19 | code: 'Hello', 20 | }, 21 | ], 22 | invalid: [ 23 | { 24 | code: 'Hello, John', 25 | errors: [{ messageId: 'default' }], 26 | }, 27 | { 28 | code: ` 29 | All done 30 | `, 31 | errors: [{ messageId: 'default' }], 32 | }, 33 | ], 34 | }) 35 | -------------------------------------------------------------------------------- /tests/src/rules/no-unlocalized-strings.test.ts: -------------------------------------------------------------------------------- 1 | import { rule, name, Option } from '../../../src/rules/no-unlocalized-strings' 2 | import { RuleTester } from '@typescript-eslint/rule-tester' 3 | 4 | const ruleTester = new RuleTester({ 5 | languageOptions: { 6 | parserOptions: { 7 | ecmaFeatures: { 8 | jsx: true, 9 | }, 10 | projectService: { 11 | allowDefaultProject: ['*.ts*'], 12 | }, 13 | }, 14 | }, 15 | }) 16 | 17 | const defaultError = [{ messageId: 'default' }] 18 | const jsxTextError = [{ messageId: 'forJsxText' }] 19 | const upperCaseRegex = '^[A-Z_-]+$' 20 | const ignoreUpperCaseName = { ignoreNames: [{ regex: { pattern: upperCaseRegex } }] } 21 | 22 | ruleTester.run(name, rule, { 23 | valid: [ 24 | // ==================== TypeScript Types with Ignored Methods ==================== 25 | { 26 | name: 'allows Map methods with string literals', 27 | code: 'const myMap = new Map(); myMap.get("foo with spaces"); myMap.has("bar with spaces");', 28 | options: [{ useTsTypes: true, ignoreMethodsOnTypes: ['Map.get', 'Map.has'] }], 29 | }, 30 | { 31 | name: 'allows interface method with string literal when type matches', 32 | code: 'interface Foo { get: (key: string) => string }; const foo: Foo; foo.get("string with spaces");', 33 | options: [{ useTsTypes: true, ignoreMethodsOnTypes: ['Foo.get'] }], 34 | }, 35 | { 36 | name: 'allows interface method as type assertion', 37 | code: 'interface Foo {get: (key: string) => string}; (foo as Foo).get("string with spaces")', 38 | options: [{ useTsTypes: true, ignoreMethodsOnTypes: ['Foo.get'] }], 39 | }, 40 | 41 | // ==================== Assignment Pattern ==================== 42 | { 43 | name: 'allows string literal in assignment pattern with ignored name', 44 | code: 'function test({ MY_PARAM = "default value" }) {}', 45 | options: [ignoreUpperCaseName], 46 | }, 47 | { 48 | name: 'allows template literal in assignment pattern with ignored name', 49 | code: 'function test({ MY_PARAM = `default value` }) {}', 50 | options: [ignoreUpperCaseName], 51 | }, 52 | 53 | // ==================== Basic i18n Usage ==================== 54 | { 55 | name: 'allows i18n template literals with interpolation', 56 | code: 'i18n._(t`Hello ${nice}`)', 57 | }, 58 | { 59 | name: 'allows i18n function with template message', 60 | code: 't(i18n)({ message: `Hello ${name}` })', 61 | }, 62 | { 63 | name: 'allows i18n with template literal', 64 | code: 'i18n._(`hello`)', 65 | }, 66 | { 67 | name: 'allows i18n with string literal', 68 | code: 'i18n._("hello")', 69 | }, 70 | 71 | // ==================== Non-Word Strings ==================== 72 | { 73 | name: 'ignores numeric strings', 74 | code: 'const test = "1111"', 75 | }, 76 | { 77 | name: 'ignores special character strings', 78 | code: 'const special = `0123456789!@#$%^&*()_+|~-=\\`[]{};\':",./<>?`;', 79 | }, 80 | { 81 | name: 'accepts TSAsExpression assignment', 82 | code: 'const unique = "this-is-unique" as const;', 83 | }, 84 | { 85 | name: 'accepts TSAsExpression assignment template literal', 86 | code: 'const unique = `this-is-unique` as const;', 87 | }, 88 | { 89 | name: 'accepts TSAsExpression in array', 90 | code: 'const names = ["name" as const, "city" as const];', 91 | }, 92 | { 93 | name: 'accepts TSAsExpression in object', 94 | code: 'const paramsByDropSide = { top: "above" as const, bottom: "below" as const };', 95 | }, 96 | 97 | // ==================== Template Literals with Variables ==================== 98 | { 99 | name: 'allows template literal with single variable', 100 | code: 'const t = `${BRAND_NAME}`', 101 | }, 102 | { 103 | name: 'allows template literal with multiple variables', 104 | code: 'const t = `${BRAND_NAME}${BRAND_NAME}`', 105 | }, 106 | { 107 | name: 'allows template literal with variables and spaces', 108 | code: 'const t = ` ${BRAND_NAME} ${BRAND_NAME} `', 109 | }, 110 | { 111 | name: 'accepts standalone TemplateLiteral in uppercase variable', 112 | code: 'const MY_TEMPLATE = `Hello world`', 113 | options: [ignoreUpperCaseName], 114 | }, 115 | 116 | // ==================== Class Properties ==================== 117 | { 118 | name: 'allows string in class property with ignored name', 119 | code: 'class MyClass { MY_PROP = "Hello World" }', 120 | options: [ignoreUpperCaseName], 121 | }, 122 | { 123 | name: 'allows string in property definition with ignored name', 124 | code: 'class MyClass { static MY_STATIC = "Hello World" }', 125 | options: [ignoreUpperCaseName], 126 | }, 127 | 128 | // ==================== Member Expressions ==================== 129 | { 130 | name: 'allows computed member expression with string literal', 131 | code: 'obj["key with spaces"] = value', 132 | }, 133 | { 134 | name: 'allows declaring object keys in quotes', 135 | code: 'const styles = { ":hover" : { color: theme.brand } }', 136 | }, 137 | { 138 | name: 'allows computed member expression with template literal', 139 | code: 'obj[`key with spaces`] = value', 140 | }, 141 | { 142 | name: 'allow union types with string literals', 143 | code: 'type Action = "add" | "remove"; function doAction(action: Action) {} doAction("add");', 144 | options: [{ useTsTypes: true }], 145 | }, 146 | { 147 | name: 'allow inline union types with string literals', 148 | code: 'function doAction(action: "add" | "remove") {} doAction("add");', 149 | options: [{ useTsTypes: true }], 150 | }, 151 | { 152 | name: 'allow union types with optional string literals', 153 | code: 'type Action = "add" | "remove" | undefined; function doAction(action: Action) {} doAction("add");', 154 | options: [{ useTsTypes: true }], 155 | }, 156 | { 157 | name: 'allows direct union type variable assignment', 158 | code: 'let value: "a" | "b"; value = "a";', 159 | options: [{ useTsTypes: true }], 160 | }, 161 | { 162 | name: 'allows direct union type in object', 163 | code: 'type Options = { mode: "light" | "dark" }; const options: Options = { mode: "light" };', 164 | options: [{ useTsTypes: true }], 165 | }, 166 | { 167 | name: 'allows string literal in function parameter with union type', 168 | code: ` 169 | function test(param: "a" | "b") {} 170 | test("a"); 171 | `, 172 | options: [{ useTsTypes: true }], 173 | }, 174 | { 175 | name: 'allows string literal in method parameter with union type', 176 | code: ` 177 | class Test { 178 | method(param: "x" | "y") {} 179 | } 180 | new Test().method("x"); 181 | `, 182 | options: [{ useTsTypes: true }], 183 | }, 184 | { 185 | name: 'allows string literal union in multi-parameter function', 186 | code: ` 187 | function test(first: string, second: "yes" | "no") { 188 | test(first, "yes"); // second argument should be fine 189 | } 190 | `, 191 | options: [{ useTsTypes: true }], 192 | }, 193 | { 194 | name: 'allows assignment to ignored member expression', 195 | code: 'myObj.MY_PROP = "Hello World"', 196 | options: [ignoreUpperCaseName], 197 | }, 198 | { 199 | name: 'ignores property key when name is in ignored list', 200 | code: `const obj = { ignoredKey: "value" };`, 201 | options: [{ ignoreNames: ['ignoredKey'] }], 202 | }, 203 | 204 | // ==================== Switch Cases ==================== 205 | { 206 | name: 'allows string literals in switch cases', 207 | code: 'switch(value) { case "hello": break; case `world`: break; default: break; }', 208 | }, 209 | 210 | // ==================== Tagged Template Expressions ==================== 211 | { 212 | name: 'allows literals in tagged template expressions', 213 | code: 'styled.div`color: ${"red"};`', 214 | }, 215 | { 216 | name: 'allows template literals in tagged template expressions', 217 | code: 'styled.div`color: ${`red`};`', 218 | }, 219 | 220 | // ==================== Ignored Functions ==================== 221 | { 222 | name: 'allows whitelisted function calls', 223 | code: 'hello("Hello")', 224 | options: [{ ignoreFunctions: ['hello'] }], 225 | }, 226 | { 227 | name: 'allows whitelisted constructor calls', 228 | code: 'new Error("hello")', 229 | options: [{ ignoreFunctions: ['Error'] }], 230 | }, 231 | { 232 | name: 'allows nested whitelisted function calls', 233 | code: 'custom.wrapper()({message: "Hello!"})', 234 | options: [{ ignoreFunctions: ['custom.wrapper'] }], 235 | }, 236 | { 237 | name: 'allows whitelisted methods with wildcards', 238 | code: 'getData().two.three.four("Hello")', 239 | options: [{ ignoreFunctions: ['*.three.four'] }], 240 | }, 241 | 242 | // ==================== Console Methods ==================== 243 | { 244 | name: 'allows console methods', 245 | code: 'console.log("Hello"); console.error("Hello");', 246 | options: [{ ignoreFunctions: ['console.*'] }], 247 | }, 248 | { 249 | name: 'allows multilevel methods', 250 | code: 'context.headers.set("Hello"); level.context.headers.set("Hello");', 251 | options: [{ ignoreFunctions: ['*.headers.set'] }], 252 | }, 253 | 254 | // ==================== JSX Attributes ==================== 255 | { 256 | name: 'allows className attribute', 257 | code: '
', 258 | }, 259 | { 260 | name: 'allows className with template literal', 261 | code: '
', 262 | }, 263 | { 264 | name: 'allows className with conditional', 265 | code: '
', 266 | }, 267 | 268 | // ==================== SVG Elements ==================== 269 | { 270 | name: 'allows SVG viewBox attribute', 271 | code: '', 272 | }, 273 | { 274 | name: 'allows SVG path data', 275 | code: '', 276 | }, 277 | { 278 | name: 'allows SVG circle attributes', 279 | code: '', 280 | }, 281 | 282 | // ==================== Translation Components ==================== 283 | { 284 | name: 'allows basic Trans component', 285 | code: 'Hello', 286 | }, 287 | { 288 | name: 'allows nested Trans component', 289 | code: 'Hello', 290 | }, 291 | { 292 | name: 'allows Plural component', 293 | code: "", 294 | }, 295 | { 296 | name: 'allows Select component', 297 | code: "