@nkzw/eslint-plugin-fbtee 0.1.3 → 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.
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) => {
@@ -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
+ }