Skip to content

Commit 036bdc4

Browse files
committed
feat(rules): add text-restrictions rule
Enforces project-specific restrictions on message text: - forbiddenPatterns: regex patterns to disallow - minLength: minimum message length requirement Not included in recommended config (requires configuration).
1 parent a3d95dd commit 036bdc4

File tree

5 files changed

+347
-1
lines changed

5 files changed

+347
-1
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,15 @@ export default [
5858
| [no-single-variable-message](docs/rules/no-single-variable-message.md) | Disallow messages that consist only of a single variable ||
5959
| [valid-t-call-location](docs/rules/valid-t-call-location.md) | Enforce `t` calls inside functions ||
6060

61+
### Optional Rules
62+
63+
| Rule | Description |
64+
|------|-------------|
65+
| [text-restrictions](docs/rules/text-restrictions.md) | Enforce project-specific text restrictions |
66+
6167
### Planned Rules
6268

6369
- `no-unlocalized-strings` — Detect user-visible strings not wrapped in Lingui
64-
- `text-restrictions` — Enforce project-specific text restrictions
6570
- `consistent-plural-format` — Ensure consistent plural usage
6671

6772
## License

docs/rules/text-restrictions.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# text-restrictions
2+
3+
Enforce project-specific restrictions on Lingui message text content.
4+
5+
## Why?
6+
7+
Consistent message formatting helps with:
8+
- Translation quality (avoiding HTML entities, escaped characters)
9+
- Preventing trivial messages (single characters)
10+
- Enforcing project-specific style guides
11+
12+
## Rule Details
13+
14+
This rule allows you to configure forbidden patterns and minimum length requirements for translation messages.
15+
16+
### ❌ Invalid (with configuration)
17+
18+
```tsx
19+
// With forbiddenPatterns: ["\\n", " "]
20+
t`Hello\nWorld` // Contains newline
21+
t`Hello World` // Contains HTML entity
22+
<Trans>Line&nbsp;1</Trans>
23+
24+
// With minLength: 5
25+
t`Hi` // Too short (2 chars)
26+
<Trans>X</Trans> // Too short (1 char)
27+
28+
// With forbiddenPatterns: ["!{2,}"]
29+
t`Hello World!!!` // Matches regex (multiple exclamation marks)
30+
```
31+
32+
### ✅ Valid
33+
34+
```tsx
35+
// Normal messages
36+
t`Hello World`
37+
<Trans>Welcome to our app</Trans>
38+
39+
// Messages meeting minLength requirement
40+
t`Hello` // With minLength: 5
41+
42+
// Messages not matching forbidden patterns
43+
t`Hello World` // With forbiddenPatterns: ["\\n"]
44+
```
45+
46+
## Options
47+
48+
### `forbiddenPatterns`
49+
50+
Array of regex patterns that should not appear in messages. Default: `[]`
51+
52+
```ts
53+
{
54+
"lingui-ts/text-restrictions": ["error", {
55+
"forbiddenPatterns": [
56+
"\\n", // No newlines
57+
"&nbsp;", // No HTML entities
58+
"&[a-z]+;", // No HTML entities (regex)
59+
"TODO", // No TODO markers
60+
"!{2,}" // No multiple exclamation marks
61+
]
62+
}]
63+
}
64+
```
65+
66+
### `minLength`
67+
68+
Minimum character length for messages (after trimming whitespace). Default: `null` (no minimum)
69+
70+
```ts
71+
{
72+
"lingui-ts/text-restrictions": ["error", {
73+
"minLength": 2
74+
}]
75+
}
76+
```
77+
78+
Note: Empty messages (`t```) don't trigger `minLength` — use other rules to handle empty messages.
79+
80+
## When Not To Use It
81+
82+
This rule is opt-in and requires configuration. If you don't have specific text formatting requirements, you don't need to enable it.
83+
84+
## Not in Recommended Config
85+
86+
This rule is **not** included in the recommended config because it requires project-specific configuration to be useful.
87+

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { noComplexExpressionsInMessage } from "./rules/no-complex-expressions-in
88
import { noNestedMacros } from "./rules/no-nested-macros.js"
99
import { noSingleTagMessage } from "./rules/no-single-tag-message.js"
1010
import { noSingleVariableMessage } from "./rules/no-single-variable-message.js"
11+
import { textRestrictions } from "./rules/text-restrictions.js"
1112
import { validTCallLocation } from "./rules/valid-t-call-location.js"
1213

1314
const plugin = {
@@ -20,6 +21,7 @@ const plugin = {
2021
"no-nested-macros": noNestedMacros,
2122
"no-single-tag-message": noSingleTagMessage,
2223
"no-single-variable-message": noSingleVariableMessage,
24+
"text-restrictions": textRestrictions,
2325
"valid-t-call-location": validTCallLocation
2426
},
2527
configs: {} as Record<string, unknown>
@@ -37,6 +39,7 @@ plugin.configs = {
3739
"lingui-ts/no-single-tag-message": "error",
3840
"lingui-ts/no-single-variable-message": "error",
3941
"lingui-ts/valid-t-call-location": "error"
42+
// text-restrictions not in recommended (requires configuration)
4043
}
4144
}
4245
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester"
2+
import { afterAll, describe, it } from "vitest"
3+
4+
import { textRestrictions } from "./text-restrictions.js"
5+
6+
RuleTester.afterAll = afterAll
7+
RuleTester.describe = describe
8+
RuleTester.it = it
9+
10+
const ruleTester = new RuleTester({
11+
languageOptions: {
12+
parserOptions: {
13+
ecmaVersion: 2022,
14+
sourceType: "module",
15+
ecmaFeatures: {
16+
jsx: true
17+
}
18+
}
19+
}
20+
})
21+
22+
ruleTester.run("text-restrictions", textRestrictions, {
23+
valid: [
24+
// Default options (no restrictions)
25+
"t`Hello World`",
26+
"<Trans>Hello World</Trans>",
27+
28+
// With minLength, messages meeting requirement
29+
{
30+
code: "t`Hello World`",
31+
options: [{ forbiddenPatterns: [], minLength: 5 }]
32+
},
33+
{
34+
code: "<Trans>Hello World</Trans>",
35+
options: [{ forbiddenPatterns: [], minLength: 5 }]
36+
},
37+
38+
// With forbiddenPatterns, messages not matching
39+
{
40+
code: "t`Hello World`",
41+
options: [{ forbiddenPatterns: ["\\n", "&nbsp;"], minLength: null }]
42+
},
43+
{
44+
code: "<Trans>Hello World</Trans>",
45+
options: [{ forbiddenPatterns: ["<br>", "TODO"], minLength: null }]
46+
},
47+
48+
// Empty messages don't trigger minLength (handled by other rules)
49+
{
50+
code: "t``",
51+
options: [{ forbiddenPatterns: [], minLength: 5 }]
52+
},
53+
54+
// Non-Lingui templates ignored
55+
{
56+
code: "css`&nbsp;`",
57+
options: [{ forbiddenPatterns: ["&nbsp;"], minLength: null }]
58+
},
59+
60+
// Non-Trans JSX ignored
61+
{
62+
code: "<div>X</div>",
63+
options: [{ forbiddenPatterns: [], minLength: 5 }]
64+
}
65+
],
66+
invalid: [
67+
// Forbidden pattern: HTML entity
68+
{
69+
code: "t`Hello&nbsp;World`",
70+
options: [{ forbiddenPatterns: ["&nbsp;"], minLength: null }],
71+
errors: [{ messageId: "forbiddenPattern" }]
72+
},
73+
74+
// Forbidden pattern in JSX
75+
{
76+
code: "<Trans>HelloTODOWorld</Trans>",
77+
options: [{ forbiddenPatterns: ["TODO"], minLength: null }],
78+
errors: [{ messageId: "forbiddenPattern" }]
79+
},
80+
81+
// Multiple forbidden patterns matched
82+
{
83+
code: "t`Hello&nbsp;&amp;World`",
84+
options: [{ forbiddenPatterns: ["&nbsp;", "&amp;"], minLength: null }],
85+
errors: [
86+
{ messageId: "forbiddenPattern" },
87+
{ messageId: "forbiddenPattern" }
88+
]
89+
},
90+
91+
// Too short message
92+
{
93+
code: "t`Hi`",
94+
options: [{ forbiddenPatterns: [], minLength: 5 }],
95+
errors: [{ messageId: "tooShort" }]
96+
},
97+
{
98+
code: "t`X`",
99+
options: [{ forbiddenPatterns: [], minLength: 2 }],
100+
errors: [{ messageId: "tooShort" }]
101+
},
102+
103+
// Too short JSX message
104+
{
105+
code: "<Trans>Hi</Trans>",
106+
options: [{ forbiddenPatterns: [], minLength: 5 }],
107+
errors: [{ messageId: "tooShort" }]
108+
},
109+
110+
// Both forbidden pattern and too short
111+
{
112+
code: "t`X&`",
113+
options: [{ forbiddenPatterns: ["&"], minLength: 5 }],
114+
errors: [
115+
{ messageId: "forbiddenPattern" },
116+
{ messageId: "tooShort" }
117+
]
118+
},
119+
120+
// Regex pattern
121+
{
122+
code: "t`Hello World!!!`",
123+
options: [{ forbiddenPatterns: ["!{2,}"], minLength: null }],
124+
errors: [{ messageId: "forbiddenPattern" }]
125+
}
126+
]
127+
})
128+

src/rules/text-restrictions.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"
2+
3+
import { createRule } from "../utils/create-rule.js"
4+
5+
type MessageId = "forbiddenPattern" | "tooShort"
6+
7+
interface Options {
8+
forbiddenPatterns: string[]
9+
minLength: number | null
10+
}
11+
12+
/**
13+
* Extracts text content from a template literal (excluding expressions).
14+
*/
15+
function getTemplateText(node: TSESTree.TemplateLiteral): string {
16+
return node.quasis.map((quasi) => quasi.value.raw).join("")
17+
}
18+
19+
/**
20+
* Extracts text content from JSX children (excluding expressions).
21+
*/
22+
function getJSXText(children: TSESTree.JSXChild[]): string {
23+
return children
24+
.filter((child): child is TSESTree.JSXText => child.type === AST_NODE_TYPES.JSXText)
25+
.map((child) => child.value)
26+
.join("")
27+
}
28+
29+
export const textRestrictions = createRule<[Options], MessageId>({
30+
name: "text-restrictions",
31+
meta: {
32+
type: "problem",
33+
docs: {
34+
description: "Enforce project-specific restrictions on Lingui message text content"
35+
},
36+
messages: {
37+
forbiddenPattern: "Message contains forbidden pattern: {{pattern}}",
38+
tooShort: "Message is too short ({{length}} chars). Minimum required: {{minLength}}"
39+
},
40+
schema: [
41+
{
42+
type: "object",
43+
properties: {
44+
forbiddenPatterns: {
45+
type: "array",
46+
items: { type: "string" },
47+
default: []
48+
},
49+
minLength: {
50+
type: ["number", "null"],
51+
default: null
52+
}
53+
},
54+
additionalProperties: false
55+
}
56+
]
57+
},
58+
defaultOptions: [
59+
{
60+
forbiddenPatterns: [],
61+
minLength: null
62+
}
63+
],
64+
create(context, [options]) {
65+
const forbiddenRegexes = options.forbiddenPatterns.map((pattern) => ({
66+
pattern,
67+
regex: new RegExp(pattern)
68+
}))
69+
70+
function checkText(text: string, node: TSESTree.Node): void {
71+
// Check forbidden patterns
72+
for (const { pattern, regex } of forbiddenRegexes) {
73+
if (regex.test(text)) {
74+
context.report({
75+
node,
76+
messageId: "forbiddenPattern",
77+
data: { pattern }
78+
})
79+
}
80+
}
81+
82+
// Check minimum length
83+
const trimmedLength = text.trim().length
84+
if (options.minLength !== null && trimmedLength > 0 && trimmedLength < options.minLength) {
85+
context.report({
86+
node,
87+
messageId: "tooShort",
88+
data: {
89+
length: String(trimmedLength),
90+
minLength: String(options.minLength)
91+
}
92+
})
93+
}
94+
}
95+
96+
return {
97+
// Check t`...` pattern
98+
TaggedTemplateExpression(node): void {
99+
if (node.tag.type !== AST_NODE_TYPES.Identifier || node.tag.name !== "t") {
100+
return
101+
}
102+
103+
const text = getTemplateText(node.quasi)
104+
checkText(text, node)
105+
},
106+
107+
// Check <Trans>...</Trans> pattern
108+
JSXElement(node): void {
109+
const openingElement = node.openingElement
110+
if (
111+
openingElement.name.type !== AST_NODE_TYPES.JSXIdentifier ||
112+
openingElement.name.name !== "Trans"
113+
) {
114+
return
115+
}
116+
117+
const text = getJSXText(node.children)
118+
checkText(text, node)
119+
}
120+
}
121+
}
122+
})
123+

0 commit comments

Comments
 (0)