@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.
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
  ```
@@ -190,7 +200,7 @@ The open-source version of `fbt`, however, became unmaintained, difficult to set
190
200
  - **Easier Setup:** fbtee works with modern tools like Vite.
191
201
  - **Statically Typed:** The fbtee compiler ensures correct usage of fbtee, libary TypeScript types are provided, and an eslint plugin helps fix common mistakes.
192
202
  - **Improved React Compatibility:** Removed React-specific hacks and added support for implicit React fragments (`<>`).
193
- - **Enhanced Features:** Fixed and exported `intlList`, which was not functional in the original `fbt`.
203
+ - **Enhanced Features:** Fixed and exported `inltList` as a new `<fbt:list>` construt, which was not functional in the original `fbt`.
194
204
  - **Modernized Codebase:** Rewritten using TypeScript, ES modules (ESM), eslint, and modern JavaScript standards. Removed cruft and legacy code.
195
205
  - **Updated Tooling:** Uses modern tools like pnpm, Vite, and esbuild for faster and more efficient development of **fbtee**.
196
206
 
@@ -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
@@ -18,18 +18,18 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
 
20
20
  // src/index.tsx
21
- var src_exports = {};
22
- __export(src_exports, {
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
23
  configs: () => configs,
24
24
  meta: () => meta,
25
25
  rules: () => rules
26
26
  });
27
- module.exports = __toCommonJS(src_exports);
27
+ module.exports = __toCommonJS(index_exports);
28
28
 
29
29
  // package.json
30
30
  var package_default = {
31
31
  name: "@nkzw/eslint-plugin-fbtee",
32
- version: "0.1.3",
32
+ version: "0.2.0",
33
33
  description: "",
34
34
  keywords: [],
35
35
  repository: {
@@ -47,17 +47,17 @@ var package_default = {
47
47
  build: "tsup src/index.tsx -d lib --target=node22 --format=esm,cjs --clean --no-splitting --dts"
48
48
  },
49
49
  dependencies: {
50
- "@typescript-eslint/utils": "^8.18.0"
50
+ "@typescript-eslint/utils": "^8.18.2"
51
51
  },
52
52
  devDependencies: {
53
- "@typescript-eslint/rule-tester": "^8.18.0"
53
+ "@typescript-eslint/rule-tester": "^8.18.2"
54
54
  }
55
55
  };
56
56
 
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") {
@@ -121,15 +121,59 @@ function resolveNodeValue(node) {
121
121
  if (node.type === "JSXExpressionContainer") {
122
122
  return resolveNodeValue(node.expression);
123
123
  }
124
+ if (node.type === "ConditionalExpression") {
125
+ const consequent = resolveNodeValue(node.consequent);
126
+ const alternate = resolveNodeValue(node.alternate);
127
+ return consequent || alternate || null;
128
+ }
129
+ if (node.type === "LogicalExpression") {
130
+ const left = resolveNodeValue(node.left);
131
+ const right = resolveNodeValue(node.right);
132
+ return left || right || null;
133
+ }
124
134
  return null;
125
135
  }
136
+ function getPropName(prop) {
137
+ if (prop.name.type === "JSXNamespacedName") {
138
+ return `${prop.name.namespace.name}:${prop.name.name.name}`;
139
+ }
140
+ return prop.name.name;
141
+ }
142
+ function hasFbtParent(node) {
143
+ let current = node.parent;
144
+ while (current) {
145
+ if (isFbtNode(current)) {
146
+ return true;
147
+ }
148
+ current = current.parent;
149
+ }
150
+ return false;
151
+ }
152
+ function isFbtNode(node) {
153
+ if (!node) {
154
+ return null;
155
+ }
156
+ if (node.type === "JSXElement") {
157
+ const name = elementType(node);
158
+ return name === "fbt" || name === "fbs" || name?.startsWith("fbt:");
159
+ }
160
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
161
+ const name = node.callee.name;
162
+ return name === "fbt" || name === "fbs";
163
+ }
164
+ if (node.type === "MemberExpression" && node.object.type === "CallExpression" && node.object.callee.type === "Identifier") {
165
+ const name = node.object.callee.name;
166
+ return name === "fbt" || name === "fbs";
167
+ }
168
+ return false;
169
+ }
126
170
 
127
171
  // src/rules/no-empty-strings.tsx
128
172
  var no_empty_strings_default = createRule({
129
173
  create(context) {
130
174
  return {
131
175
  CallExpression(node) {
132
- if (node.callee.type !== "Identifier" || !(node.callee.name === "fbt" || node.callee.name === "fbs")) {
176
+ if (!isFbtNode(node)) {
133
177
  return;
134
178
  }
135
179
  const [text] = node.arguments;
@@ -141,18 +185,18 @@ var no_empty_strings_default = createRule({
141
185
  }
142
186
  },
143
187
  JSXElement(node) {
144
- if (elementType(node) !== "fbt") {
188
+ if (!isFbtNode(node)) {
189
+ return;
190
+ }
191
+ if (node.openingElement.name.type === "JSXNamespacedName") {
145
192
  return;
146
193
  }
147
- const nodesToReport = /* @__PURE__ */ new Set();
148
- const hasTextContent = validateChildren(node, nodesToReport);
194
+ const hasTextContent = validateChildren(node);
149
195
  if (!hasTextContent) {
150
- for (const node2 of nodesToReport) {
151
- context.report({
152
- messageId: "jsxEmptyString",
153
- node: node2
154
- });
155
- }
196
+ context.report({
197
+ messageId: "jsxEmptyString",
198
+ node
199
+ });
156
200
  }
157
201
  }
158
202
  };
@@ -163,30 +207,30 @@ var no_empty_strings_default = createRule({
163
207
  description: "Disallow empty strings in fbt elements or function calls."
164
208
  },
165
209
  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."
210
+ emptyString: "Empty strings are not allowed in fbt() and fbs() function arguments.",
211
+ jsxEmptyString: "Empty strings are not allowed as children of `<fbt>` and `<fbs>` tags."
168
212
  },
169
213
  schema: [],
170
214
  type: "problem"
171
215
  },
172
216
  name: "no-empty-strings"
173
217
  });
174
- function validateChildren(node, nodesToReport) {
218
+ function validateChildren(node) {
175
219
  let hasTextContent = false;
176
220
  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 {
221
+ if (child.type === "JSXText" && child.value.trim() !== "") {
222
+ hasTextContent = true;
223
+ }
224
+ if (child.type === "JSXExpressionContainer") {
225
+ if (child.expression.type === "JSXEmptyExpression") {
226
+ continue;
227
+ }
228
+ if (child.expression.type === "Identifier" && child.expression.name !== "undefined") {
181
229
  hasTextContent = true;
230
+ continue;
182
231
  }
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 {
232
+ const value = resolveNodeValue(child.expression)?.trim();
233
+ if (value) {
190
234
  hasTextContent = true;
191
235
  }
192
236
  }
@@ -196,15 +240,12 @@ function validateChildren(node, nodesToReport) {
196
240
  hasTextContent = true;
197
241
  continue;
198
242
  }
199
- hasTextContent ||= validateChildren(child, nodesToReport);
243
+ hasTextContent ||= validateChildren(child);
200
244
  }
201
245
  if (hasTextContent) {
202
246
  break;
203
247
  }
204
248
  }
205
- if (!hasTextContent && nodesToReport.size === 0) {
206
- nodesToReport.add(node);
207
- }
208
249
  return hasTextContent;
209
250
  }
210
251
  function isEmptyString(node) {
@@ -216,7 +257,7 @@ var no_unhelpful_desc_default = createRule({
216
257
  create(context) {
217
258
  return {
218
259
  CallExpression(node) {
219
- if (node.callee.type !== "Identifier" || !(node.callee.name === "fbt" || node.callee.name === "fbs")) {
260
+ if (!isFbtNode(node)) {
220
261
  return;
221
262
  }
222
263
  const [textArg, descArg] = node.arguments;
@@ -244,7 +285,7 @@ var no_unhelpful_desc_default = createRule({
244
285
  }
245
286
  },
246
287
  JSXAttribute(node) {
247
- if (elementType(node.parent.parent) !== "fbt") {
288
+ if (!isFbtNode(node.parent.parent)) {
248
289
  return;
249
290
  }
250
291
  if (node.name.name !== "desc" || !node.value) {
@@ -295,6 +336,112 @@ var no_unhelpful_desc_default = createRule({
295
336
  name: "no-unhelpful-desc"
296
337
  });
297
338
 
339
+ // src/rules/no-untranslated-strings.tsx
340
+ var attributes = /* @__PURE__ */ new Set([
341
+ "title",
342
+ "placeholder",
343
+ "alt",
344
+ "label",
345
+ "aria-label",
346
+ "aria-errormessage"
347
+ ]);
348
+ var no_untranslated_strings_default = createRule({
349
+ create(context, options) {
350
+ const ignoredWords = new Set(
351
+ options[0].ignoredWords.map((s) => s.trim().toLowerCase())
352
+ );
353
+ function isIgnoredWord(value) {
354
+ return ignoredWords.has(value.trim().toLowerCase());
355
+ }
356
+ return {
357
+ JSXAttribute(node) {
358
+ const propName = getPropName(node);
359
+ if (!attributes.has(propName)) {
360
+ return;
361
+ }
362
+ const value = node.value ? resolveNodeValue(node.value) : null;
363
+ if (!value) {
364
+ return;
365
+ }
366
+ if (isIgnoredWord(value)) {
367
+ return;
368
+ }
369
+ if (node.value?.type === "JSXExpressionContainer" && isFbtNode(node.value.expression)) {
370
+ return;
371
+ }
372
+ context.report({
373
+ messageId: "unwrappedString",
374
+ node
375
+ });
376
+ },
377
+ JSXExpressionContainer(node) {
378
+ if (node.parent.type === "JSXAttribute") {
379
+ return;
380
+ }
381
+ const value = resolveNodeValue(node.expression);
382
+ if (!value) {
383
+ return;
384
+ }
385
+ if (isIgnoredWord(value)) {
386
+ return;
387
+ }
388
+ if (!hasFbtParent(node)) {
389
+ context.report({
390
+ messageId: "unwrappedString",
391
+ node
392
+ });
393
+ }
394
+ },
395
+ JSXText(node) {
396
+ const value = node.value.trim();
397
+ if (!value) {
398
+ return;
399
+ }
400
+ if (isIgnoredWord(value)) {
401
+ return;
402
+ }
403
+ if (!hasFbtParent(node)) {
404
+ const startOffset = node.range[0] + node.value.indexOf(value);
405
+ const endOffset = startOffset + value.length;
406
+ context.report({
407
+ loc: {
408
+ end: context.sourceCode.getLocFromIndex(endOffset),
409
+ start: context.sourceCode.getLocFromIndex(startOffset)
410
+ },
411
+ messageId: "unwrappedString"
412
+ });
413
+ }
414
+ }
415
+ };
416
+ },
417
+ defaultOptions: [{ ignoredWords: [] }],
418
+ meta: {
419
+ docs: {
420
+ description: "Enforce text content to be wrapped with `<fbt>`, `fbt()` or `fbs()`"
421
+ },
422
+ messages: {
423
+ unwrappedString: "Text content must be wrapped in `<fbt>`, `fbt()` or `fbs()`"
424
+ },
425
+ schema: [
426
+ {
427
+ additionalProperties: false,
428
+ properties: {
429
+ ignoredWords: {
430
+ items: {
431
+ type: "string"
432
+ },
433
+ type: "array",
434
+ uniqueItems: true
435
+ }
436
+ },
437
+ type: "object"
438
+ }
439
+ ],
440
+ type: "problem"
441
+ },
442
+ name: "no-untranslated-strings"
443
+ });
444
+
298
445
  // src/index.tsx
299
446
  var meta = {
300
447
  name: package_default.name,
@@ -302,14 +449,22 @@ var meta = {
302
449
  };
303
450
  var rules = {
304
451
  "no-empty-strings": no_empty_strings_default,
305
- "no-unhelpful-desc": no_unhelpful_desc_default
452
+ "no-unhelpful-desc": no_unhelpful_desc_default,
453
+ "no-untranslated-strings": no_untranslated_strings_default
306
454
  };
307
455
  var configs = {
308
- strict: {
456
+ recommended: {
309
457
  rules: {
310
458
  "@nkzw/fbtee/no-empty-strings": 2,
311
459
  "@nkzw/fbtee/no-unhelpful-desc": 2
312
460
  }
461
+ },
462
+ strict: {
463
+ rules: {
464
+ "@nkzw/fbtee/no-empty-strings": 2,
465
+ "@nkzw/fbtee/no-unhelpful-desc": 2,
466
+ "@nkzw/fbtee/no-untranslated-strings": 2
467
+ }
313
468
  }
314
469
  };
315
470
  // 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
  };