@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/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.3",
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,30 +197,30 @@ 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
  }
@@ -196,15 +230,12 @@ function validateChildren(node, nodesToReport) {
196
230
  hasTextContent = true;
197
231
  continue;
198
232
  }
199
- hasTextContent ||= validateChildren(child, nodesToReport);
233
+ hasTextContent ||= validateChildren(child);
200
234
  }
201
235
  if (hasTextContent) {
202
236
  break;
203
237
  }
204
238
  }
205
- if (!hasTextContent && nodesToReport.size === 0) {
206
- nodesToReport.add(node);
207
- }
208
239
  return hasTextContent;
209
240
  }
210
241
  function isEmptyString(node) {
@@ -216,7 +247,7 @@ var no_unhelpful_desc_default = createRule({
216
247
  create(context) {
217
248
  return {
218
249
  CallExpression(node) {
219
- if (node.callee.type !== "Identifier" || !(node.callee.name === "fbt" || node.callee.name === "fbs")) {
250
+ if (!isFbtNode(node)) {
220
251
  return;
221
252
  }
222
253
  const [textArg, descArg] = node.arguments;
@@ -244,7 +275,7 @@ var no_unhelpful_desc_default = createRule({
244
275
  }
245
276
  },
246
277
  JSXAttribute(node) {
247
- if (elementType(node.parent.parent) !== "fbt") {
278
+ if (!isFbtNode(node.parent.parent)) {
248
279
  return;
249
280
  }
250
281
  if (node.name.name !== "desc" || !node.value) {
@@ -295,6 +326,112 @@ var no_unhelpful_desc_default = createRule({
295
326
  name: "no-unhelpful-desc"
296
327
  });
297
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
+
298
435
  // src/index.tsx
299
436
  var meta = {
300
437
  name: package_default.name,
@@ -302,14 +439,22 @@ var meta = {
302
439
  };
303
440
  var rules = {
304
441
  "no-empty-strings": no_empty_strings_default,
305
- "no-unhelpful-desc": no_unhelpful_desc_default
442
+ "no-unhelpful-desc": no_unhelpful_desc_default,
443
+ "no-untranslated-strings": no_untranslated_strings_default
306
444
  };
307
445
  var configs = {
308
- strict: {
446
+ recommended: {
309
447
  rules: {
310
448
  "@nkzw/fbtee/no-empty-strings": 2,
311
449
  "@nkzw/fbtee/no-unhelpful-desc": 2
312
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
+ }
313
458
  }
314
459
  };
315
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
  };