@nkzw/eslint-plugin-fbtee 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import rule from '../rules/no-untranslated-strings.tsx';
3
+
4
+ const ruleTester = new RuleTester({
5
+ languageOptions: {
6
+ parserOptions: {
7
+ ecmaFeatures: {
8
+ jsx: true,
9
+ },
10
+ },
11
+ },
12
+ });
13
+
14
+ ruleTester.run('no-untranslated-strings', rule, {
15
+ invalid: [
16
+ {
17
+ code: `
18
+ <span>Hello world</span>;
19
+ `,
20
+ errors: [
21
+ {
22
+ messageId: 'unwrappedString',
23
+ },
24
+ ],
25
+ },
26
+ {
27
+ code: `
28
+ <span>{'Hello world'}</span>;
29
+ `,
30
+ errors: [
31
+ {
32
+ messageId: 'unwrappedString',
33
+ },
34
+ ],
35
+ },
36
+ {
37
+ code: `
38
+ <span>{'Hello world' + ''}</span>;
39
+ `,
40
+ errors: [
41
+ {
42
+ messageId: 'unwrappedString',
43
+ },
44
+ ],
45
+ },
46
+ {
47
+ code: `
48
+ <div>
49
+ <span>Hello world</span>
50
+ </div>;
51
+ `,
52
+ errors: [
53
+ {
54
+ line: 3,
55
+ messageId: 'unwrappedString',
56
+ },
57
+ ],
58
+ },
59
+ {
60
+ code: `
61
+ <div>
62
+ <div></div>
63
+ Hello world
64
+ </div>;
65
+ `,
66
+ errors: [
67
+ {
68
+ line: 4,
69
+ messageId: 'unwrappedString',
70
+ },
71
+ ],
72
+ },
73
+ {
74
+ code: `
75
+ <>Hello world</>;
76
+ `,
77
+ errors: [
78
+ {
79
+ messageId: 'unwrappedString',
80
+ },
81
+ ],
82
+ },
83
+ {
84
+ code: `
85
+ <span>{foo ? 'bar' : 'baz'}</span>;
86
+ `,
87
+ errors: [
88
+ {
89
+ messageId: 'unwrappedString',
90
+ },
91
+ ],
92
+ },
93
+ {
94
+ code: `
95
+ <span>{foo ?? 'bar'}</span>;
96
+ `,
97
+ errors: [
98
+ {
99
+ messageId: 'unwrappedString',
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ code: `
105
+ <span>{foo || 'bar'}</span>;
106
+ `,
107
+ errors: [
108
+ {
109
+ messageId: 'unwrappedString',
110
+ },
111
+ ],
112
+ },
113
+ {
114
+ code: `
115
+ const foo = true;
116
+ <span>{foo && 'bar'}</span>;
117
+ `,
118
+ errors: [
119
+ {
120
+ messageId: 'unwrappedString',
121
+ },
122
+ ],
123
+ },
124
+ {
125
+ code: `
126
+ <input placeholder="Enter name..." />;
127
+ `,
128
+ errors: [
129
+ {
130
+ messageId: 'unwrappedString',
131
+ },
132
+ ],
133
+ },
134
+ {
135
+ code: `
136
+ <div aria-label={'Label'}></div>
137
+ `,
138
+ errors: [
139
+ {
140
+ messageId: 'unwrappedString',
141
+ },
142
+ ],
143
+ },
144
+ {
145
+ code: `
146
+ <div aria-label={\`Label\`}></div>
147
+ `,
148
+ errors: [
149
+ {
150
+ messageId: 'unwrappedString',
151
+ },
152
+ ],
153
+ },
154
+ ],
155
+
156
+ valid: [
157
+ {
158
+ code: `
159
+ <fbt desc="Greeting">Hello world</fbt>
160
+ `,
161
+ },
162
+ {
163
+ code: `
164
+ <fbt desc="Greeting">
165
+ Hello world
166
+ </fbt>
167
+ `,
168
+ },
169
+ {
170
+ code: `
171
+ <fbt desc="Greeting">
172
+ <span>Hello world</span>
173
+ </fbt>
174
+ `,
175
+ },
176
+ {
177
+ code: `
178
+ <span>
179
+ {fbt('Hello', 'Greeting')}
180
+ </span>
181
+ `,
182
+ },
183
+ {
184
+ code: `
185
+ <fbt desc="Counter">
186
+ Count:
187
+ <fbt:plural count={1} showCount="yes" many="items">
188
+ item
189
+ </fbt:plural>
190
+ </fbt>
191
+ `,
192
+ },
193
+ {
194
+ code: `
195
+ <fbt desc="Greeting">
196
+ <>Hello</>
197
+ </fbt>
198
+ `,
199
+ },
200
+ {
201
+ code: `
202
+ <fbt desc="Greeting">
203
+ Hello <span>world</span>
204
+ </fbt>;
205
+ `,
206
+ },
207
+ {
208
+ code: `
209
+ <fbt desc="Welcome message">
210
+ Welcome <fbt:param name="name">Steve</fbt:param>
211
+ </fbt>
212
+ `,
213
+ },
214
+ {
215
+ code: `
216
+ <div>
217
+ <p>GitHub</p>
218
+ <p>{'GitHub'}</p>
219
+ <p>{\`GitHub\`}</p>
220
+ </div>
221
+ `,
222
+ options: [
223
+ {
224
+ ignoredWords: ['GitHub'],
225
+ },
226
+ ],
227
+ },
228
+ {
229
+ code: `
230
+ <textarea rows={2}></textarea>
231
+ `,
232
+ },
233
+ {
234
+ code: `
235
+ <a aria-current></a>
236
+ `,
237
+ },
238
+ {
239
+ code: `
240
+ <img src="" alt={fbs('A photo of a dog', 'Image description')} />
241
+ `,
242
+ },
243
+ {
244
+ code: `
245
+ <div aria-label={fbs('Hello', 'Greeting')}></div>
246
+ `,
247
+ },
248
+ {
249
+ code: `
250
+ <div aria-label={fbt('Hello', 'Greeting').toString()}></div>
251
+ `,
252
+ },
253
+ {
254
+ code: `
255
+ foo('Bar')
256
+ `,
257
+ },
258
+ {
259
+ code: `
260
+ foo.bar('Baz')
261
+ `,
262
+ },
263
+ {
264
+ code: `
265
+ const foo = isGreeting ? 'Hello' : 'Goodbye'
266
+ `,
267
+ },
268
+ {
269
+ code: `
270
+ const foo = helloGreeting ?? 'goodbye'
271
+ `,
272
+ },
273
+ {
274
+ code: `
275
+ const foo = helloGreeting || 'goodbye'
276
+ `,
277
+ },
278
+ ],
279
+ });
package/src/index.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import packageJson from '../package.json' with { type: 'json' };
2
2
  import noEmptyStringsRule from './rules/no-empty-strings.tsx';
3
3
  import noUnhelpfulDesc from './rules/no-unhelpful-desc.tsx';
4
+ import noUntranslatedStrings from './rules/no-untranslated-strings.tsx';
4
5
 
5
6
  export const meta = {
6
7
  name: packageJson.name,
@@ -10,13 +11,21 @@ export const meta = {
10
11
  export const rules = {
11
12
  'no-empty-strings': noEmptyStringsRule,
12
13
  'no-unhelpful-desc': noUnhelpfulDesc,
14
+ 'no-untranslated-strings': noUntranslatedStrings,
13
15
  };
14
16
 
15
17
  export const configs = {
18
+ recommended: {
19
+ rules: {
20
+ '@nkzw/fbtee/no-empty-strings': 2,
21
+ '@nkzw/fbtee/no-unhelpful-desc': 2,
22
+ },
23
+ },
16
24
  strict: {
17
25
  rules: {
18
26
  '@nkzw/fbtee/no-empty-strings': 2,
19
27
  '@nkzw/fbtee/no-unhelpful-desc': 2,
28
+ '@nkzw/fbtee/no-untranslated-strings': 2,
20
29
  },
21
30
  },
22
31
  };
@@ -1,14 +1,16 @@
1
1
  import type { TSESTree } from '@typescript-eslint/utils';
2
- import { createRule, elementType, resolveNodeValue } from '../utils.tsx';
2
+ import {
3
+ createRule,
4
+ elementType,
5
+ isFbtNode,
6
+ resolveNodeValue,
7
+ } from '../utils.tsx';
3
8
 
4
9
  export default createRule<[], 'emptyString' | 'jsxEmptyString'>({
5
10
  create(context) {
6
11
  return {
7
12
  CallExpression(node) {
8
- if (
9
- node.callee.type !== 'Identifier' ||
10
- !(node.callee.name === 'fbt' || node.callee.name === 'fbs')
11
- ) {
13
+ if (!isFbtNode(node)) {
12
14
  return;
13
15
  }
14
16
 
@@ -23,21 +25,22 @@ export default createRule<[], 'emptyString' | 'jsxEmptyString'>({
23
25
  },
24
26
 
25
27
  JSXElement(node) {
26
- if (elementType(node) !== 'fbt') {
28
+ if (!isFbtNode(node)) {
29
+ return;
30
+ }
31
+
32
+ if (node.openingElement.name.type === 'JSXNamespacedName') {
27
33
  return;
28
34
  }
29
35
 
30
36
  // Collect nodes to report and validate all children recursively.
31
- const nodesToReport = new Set<TSESTree.Node>();
32
- const hasTextContent = validateChildren(node, nodesToReport);
37
+ const hasTextContent = validateChildren(node);
33
38
 
34
39
  if (!hasTextContent) {
35
- for (const node of nodesToReport) {
36
- context.report({
37
- messageId: 'jsxEmptyString',
38
- node,
39
- });
40
- }
40
+ context.report({
41
+ messageId: 'jsxEmptyString',
42
+ node,
43
+ });
41
44
  }
42
45
  },
43
46
  };
@@ -49,9 +52,9 @@ export default createRule<[], 'emptyString' | 'jsxEmptyString'>({
49
52
  },
50
53
  messages: {
51
54
  emptyString:
52
- 'Empty strings are not allowed in fbt() or fbs() function arguments.',
55
+ 'Empty strings are not allowed in fbt() and fbs() function arguments.',
53
56
  jsxEmptyString:
54
- 'Empty strings are not allowed as children of <fbt> tags.',
57
+ 'Empty strings are not allowed as children of `<fbt>` and `<fbs>` tags.',
55
58
  },
56
59
  schema: [],
57
60
  type: 'problem',
@@ -61,34 +64,31 @@ export default createRule<[], 'emptyString' | 'jsxEmptyString'>({
61
64
 
62
65
  function validateChildren(
63
66
  node: TSESTree.JSXElement | TSESTree.JSXFragment,
64
- nodesToReport: Set<TSESTree.Node>,
65
67
  ): boolean {
66
68
  let hasTextContent = false;
67
69
 
68
70
  for (const child of node.children) {
69
- if (child.type === 'JSXText' && node.children.length === 1) {
70
- if (child.value.trim() === '') {
71
- nodesToReport.add(child);
72
- } else {
73
- hasTextContent = true;
74
- }
71
+ if (child.type === 'JSXText' && child.value.trim() !== '') {
72
+ hasTextContent = true;
75
73
  }
76
74
 
77
- if (
78
- child.type === 'JSXExpressionContainer' &&
79
- child.expression.type !== 'JSXEmptyExpression'
80
- ) {
75
+ if (child.type === 'JSXExpressionContainer') {
76
+ if (child.expression.type === 'JSXEmptyExpression') {
77
+ continue;
78
+ }
79
+
80
+ // Ignore when a variable is used <fbt desc="Greeting">{dynamicValue}</fbt>
81
81
  if (
82
- isEmptyString(child.expression) &&
83
- (node.children.length === 1 ||
84
- node.children.every(
85
- (otherChild) =>
86
- child === otherChild ||
87
- (otherChild.type === 'JSXText' && otherChild.value.trim() === ''),
88
- ))
82
+ child.expression.type === 'Identifier' &&
83
+ child.expression.name !== 'undefined'
89
84
  ) {
90
- nodesToReport.add(child.expression);
91
- } else {
85
+ hasTextContent = true;
86
+ continue;
87
+ }
88
+
89
+ const value = resolveNodeValue(child.expression)?.trim();
90
+
91
+ if (value) {
92
92
  hasTextContent = true;
93
93
  }
94
94
  }
@@ -101,7 +101,7 @@ function validateChildren(
101
101
  continue;
102
102
  }
103
103
 
104
- hasTextContent ||= validateChildren(child, nodesToReport);
104
+ hasTextContent ||= validateChildren(child);
105
105
  }
106
106
 
107
107
  if (hasTextContent) {
@@ -109,10 +109,6 @@ function validateChildren(
109
109
  }
110
110
  }
111
111
 
112
- if (!hasTextContent && nodesToReport.size === 0) {
113
- nodesToReport.add(node);
114
- }
115
-
116
112
  return hasTextContent;
117
113
  }
118
114
 
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createRule,
3
- elementType,
3
+ isFbtNode,
4
4
  resolveJsxElementTextContent,
5
5
  resolveNodeValue,
6
6
  } from '../utils.tsx';
@@ -12,10 +12,7 @@ export default createRule<
12
12
  create(context) {
13
13
  return {
14
14
  CallExpression(node) {
15
- if (
16
- node.callee.type !== 'Identifier' ||
17
- !(node.callee.name === 'fbt' || node.callee.name === 'fbs')
18
- ) {
15
+ if (!isFbtNode(node)) {
19
16
  return;
20
17
  }
21
18
 
@@ -56,7 +53,7 @@ export default createRule<
56
53
  },
57
54
 
58
55
  JSXAttribute(node) {
59
- if (elementType(node.parent.parent) !== 'fbt') {
56
+ if (!isFbtNode(node.parent.parent)) {
60
57
  return;
61
58
  }
62
59
 
@@ -0,0 +1,141 @@
1
+ import {
2
+ createRule,
3
+ getPropName,
4
+ hasFbtParent,
5
+ isFbtNode,
6
+ resolveNodeValue,
7
+ } from '../utils.tsx';
8
+
9
+ type Options = [{ ignoredWords: Array<string> }];
10
+
11
+ const attributes = new Set([
12
+ 'title',
13
+ 'placeholder',
14
+ 'alt',
15
+ 'label',
16
+ 'aria-label',
17
+ 'aria-errormessage',
18
+ ]);
19
+
20
+ export default createRule<Options, 'unwrappedString'>({
21
+ create(context, options) {
22
+ const ignoredWords = new Set(
23
+ options[0].ignoredWords.map((s) => s.trim().toLowerCase()),
24
+ );
25
+
26
+ function isIgnoredWord(value: string) {
27
+ return ignoredWords.has(value.trim().toLowerCase());
28
+ }
29
+
30
+ return {
31
+ JSXAttribute(node) {
32
+ const propName = getPropName(node);
33
+
34
+ if (!attributes.has(propName)) {
35
+ return;
36
+ }
37
+
38
+ const value = node.value ? resolveNodeValue(node.value) : null;
39
+
40
+ if (!value) {
41
+ return;
42
+ }
43
+
44
+ if (isIgnoredWord(value)) {
45
+ return;
46
+ }
47
+
48
+ if (
49
+ node.value?.type === 'JSXExpressionContainer' &&
50
+ isFbtNode(node.value.expression)
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ context.report({
56
+ messageId: 'unwrappedString',
57
+ node,
58
+ });
59
+ },
60
+
61
+ JSXExpressionContainer(node) {
62
+ if (node.parent.type === 'JSXAttribute') {
63
+ return;
64
+ }
65
+
66
+ const value = resolveNodeValue(node.expression);
67
+
68
+ if (!value) {
69
+ return;
70
+ }
71
+
72
+ if (isIgnoredWord(value)) {
73
+ return;
74
+ }
75
+
76
+ if (!hasFbtParent(node)) {
77
+ context.report({
78
+ messageId: 'unwrappedString',
79
+ node,
80
+ });
81
+ }
82
+ },
83
+
84
+ JSXText(node) {
85
+ const value = node.value.trim();
86
+
87
+ if (!value) {
88
+ return;
89
+ }
90
+
91
+ if (isIgnoredWord(value)) {
92
+ return;
93
+ }
94
+
95
+ if (!hasFbtParent(node)) {
96
+ const startOffset = node.range[0] + node.value.indexOf(value);
97
+ const endOffset = startOffset + value.length;
98
+
99
+ context.report({
100
+ loc: {
101
+ end: context.sourceCode.getLocFromIndex(endOffset),
102
+ start: context.sourceCode.getLocFromIndex(startOffset),
103
+ },
104
+ messageId: 'unwrappedString',
105
+ });
106
+ }
107
+ },
108
+ };
109
+ },
110
+
111
+ defaultOptions: [{ ignoredWords: [] }],
112
+
113
+ meta: {
114
+ docs: {
115
+ description:
116
+ 'Enforce text content to be wrapped with `<fbt>`, `fbt()` or `fbs()`',
117
+ },
118
+ messages: {
119
+ unwrappedString:
120
+ 'Text content must be wrapped in `<fbt>`, `fbt()` or `fbs()`',
121
+ },
122
+ schema: [
123
+ {
124
+ additionalProperties: false,
125
+ properties: {
126
+ ignoredWords: {
127
+ items: {
128
+ type: 'string',
129
+ },
130
+ type: 'array',
131
+ uniqueItems: true,
132
+ },
133
+ },
134
+ type: 'object',
135
+ },
136
+ ],
137
+ type: 'problem',
138
+ },
139
+
140
+ name: 'no-untranslated-strings',
141
+ });
package/src/utils.tsx CHANGED
@@ -3,7 +3,7 @@ import type { TSESTree } from '@typescript-eslint/utils';
3
3
 
4
4
  export const createRule = ESLintUtils.RuleCreator(
5
5
  (name) =>
6
- `https://github.com/nkzw-tech/fbtee/tree/main/packages/eslint-plugin-fbt/docs/rules/${name}`,
6
+ `https://github.com/nkzw-tech/fbtee/blob/main/packages/eslint-plugin-fbtee/docs/rules/${name}.md`,
7
7
  );
8
8
 
9
9
  function resolveMemberExpressions(
@@ -47,7 +47,7 @@ export function elementType(
47
47
  }
48
48
 
49
49
  export function resolveJsxElementTextContent(
50
- node: TSESTree.JSXElement,
50
+ node: TSESTree.JSXElement | TSESTree.JSXFragment,
51
51
  ): string {
52
52
  return node.children
53
53
  .map((child) => {
@@ -105,5 +105,67 @@ export function resolveNodeValue(
105
105
  return resolveNodeValue(node.expression);
106
106
  }
107
107
 
108
+ if (node.type === 'ConditionalExpression') {
109
+ const consequent = resolveNodeValue(node.consequent);
110
+ const alternate = resolveNodeValue(node.alternate);
111
+
112
+ return consequent || alternate || null;
113
+ }
114
+
115
+ if (node.type === 'LogicalExpression') {
116
+ const left = resolveNodeValue(node.left);
117
+ const right = resolveNodeValue(node.right);
118
+
119
+ return left || right || null;
120
+ }
121
+
108
122
  return null;
109
123
  }
124
+
125
+ export function getPropName(prop: TSESTree.JSXAttribute) {
126
+ if (prop.name.type === 'JSXNamespacedName') {
127
+ return `${prop.name.namespace.name}:${prop.name.name.name}`;
128
+ }
129
+
130
+ return prop.name.name;
131
+ }
132
+
133
+ export function hasFbtParent(node: TSESTree.Node) {
134
+ let current = node.parent;
135
+
136
+ while (current) {
137
+ if (isFbtNode(current)) {
138
+ return true;
139
+ }
140
+ current = current.parent;
141
+ }
142
+
143
+ return false;
144
+ }
145
+
146
+ export function isFbtNode(node?: TSESTree.Node) {
147
+ if (!node) {
148
+ return null;
149
+ }
150
+
151
+ if (node.type === 'JSXElement') {
152
+ const name = elementType(node);
153
+ return name === 'fbt' || name === 'fbs' || name?.startsWith('fbt:');
154
+ }
155
+
156
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
157
+ const name = node.callee.name;
158
+ return name === 'fbt' || name === 'fbs';
159
+ }
160
+
161
+ if (
162
+ node.type === 'MemberExpression' &&
163
+ node.object.type === 'CallExpression' &&
164
+ node.object.callee.type === 'Identifier'
165
+ ) {
166
+ const name = node.object.callee.name;
167
+ return name === 'fbt' || name === 'fbs';
168
+ }
169
+
170
+ return false;
171
+ }