├── .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 | [](https://www.npmjs.com/package/eslint-plugin-lingui)
8 | [](https://www.npmjs.com/package/eslint-plugin-lingui)
9 | [](https://github.com/lingui/eslint-plugin/actions/workflows/ci.yml)
10 | [](https://codecov.io/gh/lingui/eslint-plugin)
11 | [](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