@nkzw/eslint-plugin-fbtee 0.1.2 → 0.1.4

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,223 @@
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
+ <input placeholder="Enter name..." />;
86
+ `,
87
+ errors: [
88
+ {
89
+ messageId: 'unwrappedString',
90
+ },
91
+ ],
92
+ },
93
+ {
94
+ code: `
95
+ <div aria-label={'Label'}></div>
96
+ `,
97
+ errors: [
98
+ {
99
+ messageId: 'unwrappedString',
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ code: `
105
+ <div aria-label={\`Label\`}></div>
106
+ `,
107
+ errors: [
108
+ {
109
+ messageId: 'unwrappedString',
110
+ },
111
+ ],
112
+ },
113
+ ],
114
+
115
+ valid: [
116
+ {
117
+ code: `
118
+ <fbt desc="Greeting">Hello world</fbt>
119
+ `,
120
+ },
121
+ {
122
+ code: `
123
+ <fbt desc="Greeting">
124
+ Hello world
125
+ </fbt>
126
+ `,
127
+ },
128
+ {
129
+ code: `
130
+ <fbt desc="Greeting">
131
+ <span>Hello world</span>
132
+ </fbt>
133
+ `,
134
+ },
135
+ {
136
+ code: `
137
+ <span>
138
+ {fbt('Hello', 'Greeting')}
139
+ </span>
140
+ `,
141
+ },
142
+ {
143
+ code: `
144
+ <fbt desc="Counter">
145
+ Count:
146
+ <fbt:plural count={1} showCount="yes" many="items">
147
+ item
148
+ </fbt:plural>
149
+ </fbt>
150
+ `,
151
+ },
152
+ {
153
+ code: `
154
+ <fbt desc="Greeting">
155
+ <>Hello</>
156
+ </fbt>
157
+ `,
158
+ },
159
+ {
160
+ code: `
161
+ <fbt desc="Greeting">
162
+ Hello <span>world</span>
163
+ </fbt>;
164
+ `,
165
+ },
166
+ {
167
+ code: `
168
+ <fbt desc="Welcome message">
169
+ Welcome <fbt:param name="name">Steve</fbt:param>
170
+ </fbt>
171
+ `,
172
+ },
173
+ {
174
+ code: `
175
+ <div>
176
+ <p>GitHub</p>
177
+ <p>{'GitHub'}</p>
178
+ <p>{\`GitHub\`}</p>
179
+ </div>
180
+ `,
181
+ options: [
182
+ {
183
+ ignoredWords: ['GitHub'],
184
+ },
185
+ ],
186
+ },
187
+ {
188
+ code: `
189
+ <textarea rows={2}></textarea>
190
+ `,
191
+ },
192
+ {
193
+ code: `
194
+ <a aria-current></a>
195
+ `,
196
+ },
197
+ {
198
+ code: `
199
+ <img src="" alt={fbs('A photo of a dog', 'Image description')} />
200
+ `,
201
+ },
202
+ {
203
+ code: `
204
+ <div aria-label={fbs('Hello', 'Greeting')}></div>
205
+ `,
206
+ },
207
+ {
208
+ code: `
209
+ <div aria-label={fbt('Hello', 'Greeting').toString()}></div>
210
+ `,
211
+ },
212
+ {
213
+ code: `
214
+ foo('Bar')
215
+ `,
216
+ },
217
+ {
218
+ code: `
219
+ foo.bar('Baz')
220
+ `,
221
+ },
222
+ ],
223
+ });
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,40 +64,44 @@ 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
  }
95
95
 
96
96
  if (child.type === 'JSXElement' || child.type === 'JSXFragment') {
97
- hasTextContent ||= validateChildren(child, nodesToReport);
97
+ const childName = elementType(child);
98
+
99
+ if (/^[A-Z]/.test(childName) || childName.startsWith('fbt:')) {
100
+ hasTextContent = true;
101
+ continue;
102
+ }
103
+
104
+ hasTextContent ||= validateChildren(child);
98
105
  }
99
106
 
100
107
  if (hasTextContent) {
@@ -102,10 +109,6 @@ function validateChildren(
102
109
  }
103
110
  }
104
111
 
105
- if (!hasTextContent && nodesToReport.size === 0) {
106
- nodesToReport.add(node);
107
- }
108
-
109
112
  return hasTextContent;
110
113
  }
111
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) => {
@@ -107,3 +107,51 @@ export function resolveNodeValue(
107
107
 
108
108
  return null;
109
109
  }
110
+
111
+ export function getPropName(prop: TSESTree.JSXAttribute) {
112
+ if (prop.name.type === 'JSXNamespacedName') {
113
+ return `${prop.name.namespace.name}:${prop.name.name.name}`;
114
+ }
115
+
116
+ return prop.name.name;
117
+ }
118
+
119
+ export function hasFbtParent(node: TSESTree.Node) {
120
+ let current = node.parent;
121
+
122
+ while (current) {
123
+ if (isFbtNode(current)) {
124
+ return true;
125
+ }
126
+ current = current.parent;
127
+ }
128
+
129
+ return false;
130
+ }
131
+
132
+ export function isFbtNode(node?: TSESTree.Node) {
133
+ if (!node) {
134
+ return null;
135
+ }
136
+
137
+ if (node.type === 'JSXElement') {
138
+ const name = elementType(node);
139
+ return name === 'fbt' || name === 'fbs' || name?.startsWith('fbt:');
140
+ }
141
+
142
+ if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
143
+ const name = node.callee.name;
144
+ return name === 'fbt' || name === 'fbs';
145
+ }
146
+
147
+ if (
148
+ node.type === 'MemberExpression' &&
149
+ node.object.type === 'CallExpression' &&
150
+ node.object.callee.type === 'Identifier'
151
+ ) {
152
+ const name = node.object.callee.name;
153
+ return name === 'fbt' || name === 'fbs';
154
+ }
155
+
156
+ return false;
157
+ }