@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.
package/README.md CHANGED
@@ -18,13 +18,13 @@ This is a fork of Facebook's original `fbt` library, which has been archived. Th
18
18
  **fbtee** requires at least Node 22, and React 19 if you are using React.
19
19
 
20
20
  ```bash
21
- npm install fbtee @nkzw/babel-fbtee
21
+ npm install fbtee @nkzw/babel-preset-fbtee
22
22
  ```
23
23
 
24
24
  In your `vite.config.ts`:
25
25
 
26
26
  ```ts
27
- import fbteePreset from '@nkzw/babel-fbtee';
27
+ import fbteePreset from '@nkzw/babel-preset-fbtee';
28
28
  import react from '@vitejs/plugin-react';
29
29
 
30
30
  export default {
@@ -136,7 +136,7 @@ const Greeting = ({ name }) => (
136
136
  );
137
137
  ```
138
138
 
139
- _Note: `<fbt>` is auto-imported for you by the `@nkzw/babel-fbtee` plugin._
139
+ _Note: `<fbt>` is auto-imported for you by the `@nkzw/babel-preset-fbtee` plugin._
140
140
 
141
141
  After marking your strings for translation with `<fbt>`, run the following commands to extract, and compile translations:
142
142
 
@@ -162,6 +162,15 @@ npm install @nkzw/eslint-plugin-fbtee
162
162
 
163
163
  Add the following configuration to your ESLint configuration:
164
164
 
165
+ ```js
166
+ {
167
+ extends: ['plugin:@nkzw/eslint-plugin-fbtee/recommended'],
168
+ plugins: ['@nkzw/eslint-plugin-fbtee'],
169
+ }
170
+ ```
171
+
172
+ If you want stricter enforcement of translation rules, you can use the strict configuration, which enables the `no-untranslated-strings` rule. This ensures that all strings in your codebase are marked for translation.
173
+
165
174
  ```js
166
175
  {
167
176
  extends: ['plugin:@nkzw/eslint-plugin-fbtee/strict'],
@@ -177,6 +186,7 @@ Or, if you'd like more granular control over the rules:
177
186
  rules: {
178
187
  '@nkzw/fbtee/no-empty-strings': 'error',
179
188
  '@nkzw/fbtee/no-unhelpful-desc': 'error',
189
+ '@nkzw/fbtee/no-untranslated-strings': 'error',
180
190
  },
181
191
  }
182
192
  ```
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Rule Details
4
4
 
5
- This rule enforces that meaningful and non-empty descriptions are provided to <fbt> elements and fbt()/fbs() function calls. Descriptions (desc) are crucial for ensuring proper translation context and avoiding ambiguous or unhelpful translations.
5
+ This rule enforces that meaningful and non-empty descriptions are provided to `<fbt>` elements and fbt()/fbs() function calls. Descriptions (desc) are crucial for ensuring proper translation context and avoiding ambiguous or unhelpful translations.
6
6
 
7
7
  Examples of **incorrect** code for this rule:
8
8
 
@@ -0,0 +1,55 @@
1
+ # Enforce strings to be wrapped with `<fbt>` (fbtee/no-untranslated-strings)
2
+
3
+ ## Rule Details
4
+
5
+ This rule enforces that all literal strings in your JSX are wrapped with an `<fbt>` tag, or translated using `fbt()`/`fbs()` functions, ensuring they are properly marked for translation.
6
+
7
+ Examples of **incorrect** code for this rule:
8
+
9
+ ```jsx
10
+ <h1>Hello</h1>
11
+ ```
12
+
13
+ ```jsx
14
+ <h1>{`Hello`}</h1>
15
+ ```
16
+
17
+ ```jsx
18
+ <span title="Hello" />
19
+ ```
20
+
21
+ Examples of **correct** code for this rule:
22
+
23
+ ```jsx
24
+ <h1>
25
+ <fbt desc="Greeting">Hello</fbt>
26
+ </h1>
27
+ ```
28
+
29
+ ```jsx
30
+ <h1>{fbt('Hello', 'Greeting')}</h1>
31
+ ```
32
+
33
+ ```jsx
34
+ <span title={fbs('Hello', 'Greeting')} />
35
+ ```
36
+
37
+ ## Rule Options
38
+
39
+ ```js
40
+ ...
41
+ "fbt/no-untranslated-strings": [<enabled>, {"ignoredWords": Array<string>}]
42
+ ...
43
+ ```
44
+
45
+ ### `ignoredWords`
46
+
47
+ A list of words that are not required to be translated. Like so:
48
+
49
+ ```jsx
50
+ "fbt/no-untranslated-strings": ["warn", { "ignoredWords": ["GitHub"] }]
51
+ ```
52
+
53
+ ## When Not To Use It
54
+
55
+ If you do not want to enforce all strings to be marked for translation, then you can disable this rule.
package/lib/index.cjs CHANGED
@@ -29,7 +29,7 @@ module.exports = __toCommonJS(src_exports);
29
29
  // package.json
30
30
  var package_default = {
31
31
  name: "@nkzw/eslint-plugin-fbtee",
32
- version: "0.1.2",
32
+ version: "0.1.4",
33
33
  description: "",
34
34
  keywords: [],
35
35
  repository: {
@@ -57,7 +57,7 @@ var package_default = {
57
57
  // src/utils.tsx
58
58
  var import_utils = require("@typescript-eslint/utils");
59
59
  var createRule = import_utils.ESLintUtils.RuleCreator(
60
- (name) => `https://github.com/nkzw-tech/fbtee/tree/main/packages/eslint-plugin-fbt/docs/rules/${name}`
60
+ (name) => `https://github.com/nkzw-tech/fbtee/blob/main/packages/eslint-plugin-fbtee/docs/rules/${name}.md`
61
61
  );
62
62
  function resolveMemberExpressions(object, property) {
63
63
  if (object.type === "JSXMemberExpression") {
@@ -123,13 +123,47 @@ function resolveNodeValue(node) {
123
123
  }
124
124
  return null;
125
125
  }
126
+ function getPropName(prop) {
127
+ if (prop.name.type === "JSXNamespacedName") {
128
+ return `${prop.name.namespace.name}:${prop.name.name.name}`;
129
+ }
130
+ return prop.name.name;
131
+ }
132
+ function hasFbtParent(node) {
133
+ let current = node.parent;
134
+ while (current) {
135
+ if (isFbtNode(current)) {
136
+ return true;
137
+ }
138
+ current = current.parent;
139
+ }
140
+ return false;
141
+ }
142
+ function isFbtNode(node) {
143
+ if (!node) {
144
+ return null;
145
+ }
146
+ if (node.type === "JSXElement") {
147
+ const name = elementType(node);
148
+ return name === "fbt" || name === "fbs" || name?.startsWith("fbt:");
149
+ }
150
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
151
+ const name = node.callee.name;
152
+ return name === "fbt" || name === "fbs";
153
+ }
154
+ if (node.type === "MemberExpression" && node.object.type === "CallExpression" && node.object.callee.type === "Identifier") {
155
+ const name = node.object.callee.name;
156
+ return name === "fbt" || name === "fbs";
157
+ }
158
+ return false;
159
+ }
126
160
 
127
161
  // src/rules/no-empty-strings.tsx
128
162
  var no_empty_strings_default = createRule({
129
163
  create(context) {
130
164
  return {
131
165
  CallExpression(node) {
132
- if (node.callee.type !== "Identifier" || !(node.callee.name === "fbt" || node.callee.name === "fbs")) {
166
+ if (!isFbtNode(node)) {
133
167
  return;
134
168
  }
135
169
  const [text] = node.arguments;
@@ -141,18 +175,18 @@ var no_empty_strings_default = createRule({
141
175
  }
142
176
  },
143
177
  JSXElement(node) {
144
- if (elementType(node) !== "fbt") {
178
+ if (!isFbtNode(node)) {
145
179
  return;
146
180
  }
147
- const nodesToReport = /* @__PURE__ */ new Set();
148
- const hasTextContent = validateChildren(node, nodesToReport);
181
+ if (node.openingElement.name.type === "JSXNamespacedName") {
182
+ return;
183
+ }
184
+ const hasTextContent = validateChildren(node);
149
185
  if (!hasTextContent) {
150
- for (const node2 of nodesToReport) {
151
- context.report({
152
- messageId: "jsxEmptyString",
153
- node: node2
154
- });
155
- }
186
+ context.report({
187
+ messageId: "jsxEmptyString",
188
+ node
189
+ });
156
190
  }
157
191
  }
158
192
  };
@@ -163,43 +197,45 @@ var no_empty_strings_default = createRule({
163
197
  description: "Disallow empty strings in fbt elements or function calls."
164
198
  },
165
199
  messages: {
166
- emptyString: "Empty strings are not allowed in fbt() or fbs() function arguments.",
167
- jsxEmptyString: "Empty strings are not allowed as children of <fbt> tags."
200
+ emptyString: "Empty strings are not allowed in fbt() and fbs() function arguments.",
201
+ jsxEmptyString: "Empty strings are not allowed as children of `<fbt>` and `<fbs>` tags."
168
202
  },
169
203
  schema: [],
170
204
  type: "problem"
171
205
  },
172
206
  name: "no-empty-strings"
173
207
  });
174
- function validateChildren(node, nodesToReport) {
208
+ function validateChildren(node) {
175
209
  let hasTextContent = false;
176
210
  for (const child of node.children) {
177
- if (child.type === "JSXText" && node.children.length === 1) {
178
- if (child.value.trim() === "") {
179
- nodesToReport.add(child);
180
- } else {
211
+ if (child.type === "JSXText" && child.value.trim() !== "") {
212
+ hasTextContent = true;
213
+ }
214
+ if (child.type === "JSXExpressionContainer") {
215
+ if (child.expression.type === "JSXEmptyExpression") {
216
+ continue;
217
+ }
218
+ if (child.expression.type === "Identifier" && child.expression.name !== "undefined") {
181
219
  hasTextContent = true;
220
+ continue;
182
221
  }
183
- }
184
- if (child.type === "JSXExpressionContainer" && child.expression.type !== "JSXEmptyExpression") {
185
- if (isEmptyString(child.expression) && (node.children.length === 1 || node.children.every(
186
- (otherChild) => child === otherChild || otherChild.type === "JSXText" && otherChild.value.trim() === ""
187
- ))) {
188
- nodesToReport.add(child.expression);
189
- } else {
222
+ const value = resolveNodeValue(child.expression)?.trim();
223
+ if (value) {
190
224
  hasTextContent = true;
191
225
  }
192
226
  }
193
227
  if (child.type === "JSXElement" || child.type === "JSXFragment") {
194
- hasTextContent ||= validateChildren(child, nodesToReport);
228
+ const childName = elementType(child);
229
+ if (/^[A-Z]/.test(childName) || childName.startsWith("fbt:")) {
230
+ hasTextContent = true;
231
+ continue;
232
+ }
233
+ hasTextContent ||= validateChildren(child);
195
234
  }
196
235
  if (hasTextContent) {
197
236
  break;
198
237
  }
199
238
  }
200
- if (!hasTextContent && nodesToReport.size === 0) {
201
- nodesToReport.add(node);
202
- }
203
239
  return hasTextContent;
204
240
  }
205
241
  function isEmptyString(node) {
@@ -211,7 +247,7 @@ var no_unhelpful_desc_default = createRule({
211
247
  create(context) {
212
248
  return {
213
249
  CallExpression(node) {
214
- if (node.callee.type !== "Identifier" || !(node.callee.name === "fbt" || node.callee.name === "fbs")) {
250
+ if (!isFbtNode(node)) {
215
251
  return;
216
252
  }
217
253
  const [textArg, descArg] = node.arguments;
@@ -239,7 +275,7 @@ var no_unhelpful_desc_default = createRule({
239
275
  }
240
276
  },
241
277
  JSXAttribute(node) {
242
- if (elementType(node.parent.parent) !== "fbt") {
278
+ if (!isFbtNode(node.parent.parent)) {
243
279
  return;
244
280
  }
245
281
  if (node.name.name !== "desc" || !node.value) {
@@ -290,6 +326,112 @@ var no_unhelpful_desc_default = createRule({
290
326
  name: "no-unhelpful-desc"
291
327
  });
292
328
 
329
+ // src/rules/no-untranslated-strings.tsx
330
+ var attributes = /* @__PURE__ */ new Set([
331
+ "title",
332
+ "placeholder",
333
+ "alt",
334
+ "label",
335
+ "aria-label",
336
+ "aria-errormessage"
337
+ ]);
338
+ var no_untranslated_strings_default = createRule({
339
+ create(context, options) {
340
+ const ignoredWords = new Set(
341
+ options[0].ignoredWords.map((s) => s.trim().toLowerCase())
342
+ );
343
+ function isIgnoredWord(value) {
344
+ return ignoredWords.has(value.trim().toLowerCase());
345
+ }
346
+ return {
347
+ JSXAttribute(node) {
348
+ const propName = getPropName(node);
349
+ if (!attributes.has(propName)) {
350
+ return;
351
+ }
352
+ const value = node.value ? resolveNodeValue(node.value) : null;
353
+ if (!value) {
354
+ return;
355
+ }
356
+ if (isIgnoredWord(value)) {
357
+ return;
358
+ }
359
+ if (node.value?.type === "JSXExpressionContainer" && isFbtNode(node.value.expression)) {
360
+ return;
361
+ }
362
+ context.report({
363
+ messageId: "unwrappedString",
364
+ node
365
+ });
366
+ },
367
+ JSXExpressionContainer(node) {
368
+ if (node.parent.type === "JSXAttribute") {
369
+ return;
370
+ }
371
+ const value = resolveNodeValue(node.expression);
372
+ if (!value) {
373
+ return;
374
+ }
375
+ if (isIgnoredWord(value)) {
376
+ return;
377
+ }
378
+ if (!hasFbtParent(node)) {
379
+ context.report({
380
+ messageId: "unwrappedString",
381
+ node
382
+ });
383
+ }
384
+ },
385
+ JSXText(node) {
386
+ const value = node.value.trim();
387
+ if (!value) {
388
+ return;
389
+ }
390
+ if (isIgnoredWord(value)) {
391
+ return;
392
+ }
393
+ if (!hasFbtParent(node)) {
394
+ const startOffset = node.range[0] + node.value.indexOf(value);
395
+ const endOffset = startOffset + value.length;
396
+ context.report({
397
+ loc: {
398
+ end: context.sourceCode.getLocFromIndex(endOffset),
399
+ start: context.sourceCode.getLocFromIndex(startOffset)
400
+ },
401
+ messageId: "unwrappedString"
402
+ });
403
+ }
404
+ }
405
+ };
406
+ },
407
+ defaultOptions: [{ ignoredWords: [] }],
408
+ meta: {
409
+ docs: {
410
+ description: "Enforce text content to be wrapped with `<fbt>`, `fbt()` or `fbs()`"
411
+ },
412
+ messages: {
413
+ unwrappedString: "Text content must be wrapped in `<fbt>`, `fbt()` or `fbs()`"
414
+ },
415
+ schema: [
416
+ {
417
+ additionalProperties: false,
418
+ properties: {
419
+ ignoredWords: {
420
+ items: {
421
+ type: "string"
422
+ },
423
+ type: "array",
424
+ uniqueItems: true
425
+ }
426
+ },
427
+ type: "object"
428
+ }
429
+ ],
430
+ type: "problem"
431
+ },
432
+ name: "no-untranslated-strings"
433
+ });
434
+
293
435
  // src/index.tsx
294
436
  var meta = {
295
437
  name: package_default.name,
@@ -297,14 +439,22 @@ var meta = {
297
439
  };
298
440
  var rules = {
299
441
  "no-empty-strings": no_empty_strings_default,
300
- "no-unhelpful-desc": no_unhelpful_desc_default
442
+ "no-unhelpful-desc": no_unhelpful_desc_default,
443
+ "no-untranslated-strings": no_untranslated_strings_default
301
444
  };
302
445
  var configs = {
303
- strict: {
446
+ recommended: {
304
447
  rules: {
305
448
  "@nkzw/fbtee/no-empty-strings": 2,
306
449
  "@nkzw/fbtee/no-unhelpful-desc": 2
307
450
  }
451
+ },
452
+ strict: {
453
+ rules: {
454
+ "@nkzw/fbtee/no-empty-strings": 2,
455
+ "@nkzw/fbtee/no-unhelpful-desc": 2,
456
+ "@nkzw/fbtee/no-untranslated-strings": 2
457
+ }
308
458
  }
309
459
  };
310
460
  // Annotate the CommonJS export names for ESM import in node:
package/lib/index.d.cts CHANGED
@@ -7,12 +7,22 @@ declare const meta: {
7
7
  declare const rules: {
8
8
  'no-empty-strings': _typescript_eslint_utils_ts_eslint.RuleModule<"emptyString" | "jsxEmptyString", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
9
9
  'no-unhelpful-desc': _typescript_eslint_utils_ts_eslint.RuleModule<"emptyDesc" | "jsxEmptyDesc" | "duplicateDesc" | "shortDesc", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
10
+ 'no-untranslated-strings': _typescript_eslint_utils_ts_eslint.RuleModule<"unwrappedString", [{
11
+ ignoredWords: Array<string>;
12
+ }], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
10
13
  };
11
14
  declare const configs: {
15
+ recommended: {
16
+ rules: {
17
+ '@nkzw/fbtee/no-empty-strings': number;
18
+ '@nkzw/fbtee/no-unhelpful-desc': number;
19
+ };
20
+ };
12
21
  strict: {
13
22
  rules: {
14
23
  '@nkzw/fbtee/no-empty-strings': number;
15
24
  '@nkzw/fbtee/no-unhelpful-desc': number;
25
+ '@nkzw/fbtee/no-untranslated-strings': number;
16
26
  };
17
27
  };
18
28
  };
package/lib/index.d.ts CHANGED
@@ -7,12 +7,22 @@ declare const meta: {
7
7
  declare const rules: {
8
8
  'no-empty-strings': _typescript_eslint_utils_ts_eslint.RuleModule<"emptyString" | "jsxEmptyString", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
9
9
  'no-unhelpful-desc': _typescript_eslint_utils_ts_eslint.RuleModule<"emptyDesc" | "jsxEmptyDesc" | "duplicateDesc" | "shortDesc", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
10
+ 'no-untranslated-strings': _typescript_eslint_utils_ts_eslint.RuleModule<"unwrappedString", [{
11
+ ignoredWords: Array<string>;
12
+ }], unknown, _typescript_eslint_utils_ts_eslint.RuleListener>;
10
13
  };
11
14
  declare const configs: {
15
+ recommended: {
16
+ rules: {
17
+ '@nkzw/fbtee/no-empty-strings': number;
18
+ '@nkzw/fbtee/no-unhelpful-desc': number;
19
+ };
20
+ };
12
21
  strict: {
13
22
  rules: {
14
23
  '@nkzw/fbtee/no-empty-strings': number;
15
24
  '@nkzw/fbtee/no-unhelpful-desc': number;
25
+ '@nkzw/fbtee/no-untranslated-strings': number;
16
26
  };
17
27
  };
18
28
  };