@nkzw/eslint-plugin-fbtee 5.0.0 → 6.0.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
@@ -392,6 +392,10 @@ export default [
392
392
 
393
393
  Use `fbtee.configs.strict.rules` to also check for untranslated text.
394
394
 
395
+ ## Migration to fbtee 6
396
+
397
+ `fbt()` now returns strings for text-only messages and real React Fragments for rich messages. Its return type is `string | ReactElement`; use `fbs()` for string-only props, attributes, and storage. The `FbtResult` runtime class and its custom serialization methods are removed. See the [React result migration guide](docs/react-results.md) for compatibility details, tests, and production benchmarks.
398
+
395
399
  ## Migration from fbtee 4
396
400
 
397
401
  fbtee 5 combines `@nkzw/fbtee-cli` and `@nkzw/oxc-transform-fbtee` into `@nkzw/fbtee-compiler`:
@@ -0,0 +1,36 @@
1
+ # Prefer the string API over coercing React translations (@nkzw/fbtee/prefer-fbs)
2
+
3
+ Use `fbs()` when translated text is required. `fbt()` may return a React element, which `String()` silently converts to `[object Object]`. This rule is enabled in the recommended and strict configurations and does not require type information.
4
+
5
+ ## Rule details
6
+
7
+ Examples of incorrect code:
8
+
9
+ ```tsx
10
+ String(fbt('Hello', 'Greeting'));
11
+ String(fbt.c('Save'));
12
+ String(<fbt desc="Greeting">Hello</fbt>);
13
+ ```
14
+
15
+ Use the string API directly:
16
+
17
+ ```tsx
18
+ fbs('Hello', 'Greeting');
19
+ fbs.c('Save');
20
+ fbs(['Hello ', fbs.param('name', name)], 'Greeting');
21
+ ```
22
+
23
+ The rule recognizes the conventional `fbt` binding, aliases imported from `fbtee` or `fbtee/server`, and common-string calls. Parentheses and TypeScript assertions do not hide a direct call. A locally shadowed `String` function is ignored.
24
+
25
+ There is no automatic fix: migrating a call can require adding an `fbs` import, updating nested helpers, and reviewing parameter values. `fbs.param()` accepts strings; do not blindly rename helpers that receive numbers or React nodes.
26
+
27
+ ## Scope
28
+
29
+ This is a direct-expression check. It deliberately does not trace variables, returned values, or translator aliases created through assignments:
30
+
31
+ ```tsx
32
+ const message = fbt('Hello', 'Greeting');
33
+ String(message);
34
+ ```
35
+
36
+ It also does not analyze template interpolation, `.toString()`, or JSON serialization. Review those separately during migration. This rule does not change runtime behavior or add runtime overhead.
package/lib/index.d.mts CHANGED
@@ -1,10 +1,10 @@
1
- //#region src/index.d.ts
2
1
  declare const _default: {
3
2
  configs: {
4
3
  recommended: {
5
4
  rules: {
6
5
  '@nkzw/fbtee/no-empty-strings': number;
7
6
  '@nkzw/fbtee/no-unhelpful-desc': number;
7
+ '@nkzw/fbtee/prefer-fbs': number;
8
8
  };
9
9
  };
10
10
  strict: {
@@ -12,6 +12,7 @@ declare const _default: {
12
12
  '@nkzw/fbtee/no-empty-strings': number;
13
13
  '@nkzw/fbtee/no-unhelpful-desc': number;
14
14
  '@nkzw/fbtee/no-untranslated-strings': number;
15
+ '@nkzw/fbtee/prefer-fbs': number;
15
16
  };
16
17
  };
17
18
  };
@@ -31,7 +32,9 @@ declare const _default: {
31
32
  }], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
32
33
  name: string;
33
34
  };
35
+ 'prefer-fbs': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferFbs", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
36
+ name: string;
37
+ };
34
38
  };
35
39
  };
36
- //#endregion
37
40
  export { _default as default };
package/lib/index.mjs CHANGED
@@ -1,9 +1,8 @@
1
- import { ESLintUtils } from "@typescript-eslint/utils";
2
- //#region package.json
1
+ import { ASTUtils, ESLintUtils } from "@typescript-eslint/utils";
2
+ // package.json
3
3
  var name = "@nkzw/eslint-plugin-fbtee";
4
- var version = "5.0.0";
5
- //#endregion
6
- //#region src/utils.tsx
4
+ var version = "6.0.0";
5
+ // src/utils.tsx
7
6
  const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/nkzw-tech/fbtee/blob/main/packages/eslint-plugin-fbtee/docs/rules/${name}.md`);
8
7
  function resolveMemberExpressions(object, property) {
9
8
  if (object.type === "JSXMemberExpression") return `${resolveMemberExpressions(object.object, object.property)}.${property.name}`;
@@ -81,8 +80,7 @@ function isFbtNode(node) {
81
80
  }
82
81
  return false;
83
82
  }
84
- //#endregion
85
- //#region src/rules/no-empty-strings.tsx
83
+ // src/rules/no-empty-strings.tsx
86
84
  var no_empty_strings_default = createRule({
87
85
  create(context) {
88
86
  return {
@@ -143,8 +141,7 @@ function validateChildren(node) {
143
141
  function isEmptyString(node) {
144
142
  return resolveNodeValue(node)?.trim() === "";
145
143
  }
146
- //#endregion
147
- //#region src/rules/no-unhelpful-desc.tsx
144
+ // src/rules/no-unhelpful-desc.tsx
148
145
  var no_unhelpful_desc_default = createRule({
149
146
  create(context) {
150
147
  return {
@@ -213,8 +210,7 @@ var no_unhelpful_desc_default = createRule({
213
210
  },
214
211
  name: "no-unhelpful-desc"
215
212
  });
216
- //#endregion
217
- //#region src/rules/no-untranslated-strings.tsx
213
+ // src/rules/no-untranslated-strings.tsx
218
214
  const attributes = /* @__PURE__ */ new Set([
219
215
  "title",
220
216
  "placeholder",
@@ -292,18 +288,61 @@ var no_untranslated_strings_default = createRule({
292
288
  },
293
289
  name: "no-untranslated-strings"
294
290
  });
295
- //#endregion
296
- //#region src/index.tsx
291
+ // src/rules/prefer-fbs.tsx
292
+ function unwrap(node) {
293
+ while (node.type === "TSAsExpression" || node.type === "TSTypeAssertion" || node.type === "TSNonNullExpression" || node.type === "TSSatisfiesExpression" || node.type === "ChainExpression") node = node.expression;
294
+ return node;
295
+ }
296
+ var prefer_fbs_default = createRule({
297
+ create(context) {
298
+ function isFbt(node) {
299
+ if (node.type !== "Identifier") return false;
300
+ const variable = ASTUtils.findVariable(context.sourceCode.getScope(node), node.name);
301
+ const imported = variable?.defs.find((definition) => definition.type === "ImportBinding");
302
+ if (imported) return imported.parent.type === "ImportDeclaration" && (imported.parent.source.value === "fbtee" || imported.parent.source.value === "fbtee/server") && (imported.node.type === "ImportDefaultSpecifier" || imported.node.type === "ImportSpecifier" && (imported.node.imported.type === "Identifier" ? imported.node.imported.name : imported.node.imported.value) === "fbt");
303
+ return node.name === "fbt" && !variable?.defs.some((definition) => definition.type === "FunctionName");
304
+ }
305
+ return { CallExpression(node) {
306
+ if (node.callee.type !== "Identifier" || node.callee.name !== "String") return;
307
+ if (ASTUtils.findVariable(context.sourceCode.getScope(node), "String")?.defs.length || !node.arguments[0]) return;
308
+ const argument = unwrap(node.arguments[0]);
309
+ if (argument.type === "JSXElement" && argument.openingElement.name.type === "JSXIdentifier" && argument.openingElement.name.name === "fbt") {
310
+ context.report({
311
+ messageId: "preferFbs",
312
+ node
313
+ });
314
+ return;
315
+ }
316
+ if (argument.type !== "CallExpression") return;
317
+ const callee = unwrap(argument.callee);
318
+ if (isFbt(callee) || callee.type === "MemberExpression" && isFbt(unwrap(callee.object)) && (!callee.computed && callee.property.type === "Identifier" && callee.property.name === "c" || callee.computed && callee.property.type === "Literal" && callee.property.value === "c")) context.report({
319
+ messageId: "preferFbs",
320
+ node
321
+ });
322
+ } };
323
+ },
324
+ defaultOptions: [],
325
+ meta: {
326
+ docs: { description: "Require fbs() instead of String(fbt(...)) for translated text." },
327
+ messages: { preferFbs: "Use fbs() or fbs.c() instead of String() on an fbt result. Rich fbt results stringify to \"[object Object]\"." },
328
+ schema: [],
329
+ type: "problem"
330
+ },
331
+ name: "prefer-fbs"
332
+ });
333
+ // src/index.tsx
297
334
  var src_default = {
298
335
  configs: {
299
336
  recommended: { rules: {
300
337
  "@nkzw/fbtee/no-empty-strings": 2,
301
- "@nkzw/fbtee/no-unhelpful-desc": 2
338
+ "@nkzw/fbtee/no-unhelpful-desc": 2,
339
+ "@nkzw/fbtee/prefer-fbs": 2
302
340
  } },
303
341
  strict: { rules: {
304
342
  "@nkzw/fbtee/no-empty-strings": 2,
305
343
  "@nkzw/fbtee/no-unhelpful-desc": 2,
306
- "@nkzw/fbtee/no-untranslated-strings": 2
344
+ "@nkzw/fbtee/no-untranslated-strings": 2,
345
+ "@nkzw/fbtee/prefer-fbs": 2
307
346
  } }
308
347
  },
309
348
  meta: {
@@ -313,8 +352,8 @@ var src_default = {
313
352
  rules: {
314
353
  "no-empty-strings": no_empty_strings_default,
315
354
  "no-unhelpful-desc": no_unhelpful_desc_default,
316
- "no-untranslated-strings": no_untranslated_strings_default
355
+ "no-untranslated-strings": no_untranslated_strings_default,
356
+ "prefer-fbs": prefer_fbs_default
317
357
  }
318
358
  };
319
- //#endregion
320
359
  export { src_default as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nkzw/eslint-plugin-fbtee",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "license": "MIT",
@@ -15,10 +15,10 @@
15
15
  "import": "./lib/index.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@typescript-eslint/utils": "^8.64.0"
18
+ "@typescript-eslint/utils": "^8.72.2"
19
19
  },
20
20
  "devDependencies": {
21
- "@typescript-eslint/rule-tester": "^8.64.0"
21
+ "@typescript-eslint/rule-tester": "^8.72.2"
22
22
  },
23
23
  "scripts": {
24
24
  "build": "tsdown src/index.tsx -d lib --target=node22 --format=esm --clean --dts"
@@ -0,0 +1,58 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import plugin from '../index.tsx';
3
+ import rule from '../rules/prefer-fbs.tsx';
4
+
5
+ const ruleTester = new RuleTester({
6
+ languageOptions: {
7
+ parserOptions: {
8
+ ecmaFeatures: { jsx: true },
9
+ },
10
+ },
11
+ });
12
+
13
+ ruleTester.run('prefer-fbs', rule, {
14
+ invalid: [
15
+ "String(fbt('Hello', 'Greeting'));",
16
+ "String(fbt.c('Save'));",
17
+ "String(fbt['c']('Save'));",
18
+ "String((fbt('Hello', 'Greeting')));",
19
+ "String(fbt('Hello', 'Greeting') as unknown);",
20
+ "String(fbt('Hello', 'Greeting')!);",
21
+ "String(fbt('Hello', 'Greeting') satisfies unknown);",
22
+ "String(fbt?.('Hello', 'Greeting'));",
23
+ "String(fbt(['Hello ', fbt.param('name', <b>Alice</b>)], 'Greeting'));",
24
+ 'String(<fbt desc="Greeting">Hello</fbt>);',
25
+ "import { fbt } from 'fbtee'; String(fbt('Hello', 'Greeting'));",
26
+ "import { fbt as translate } from 'fbtee'; String(translate('Hello', 'Greeting'));",
27
+ "import { fbt as translate } from 'fbtee/server'; String(translate.c('Save'));",
28
+ "import translate from 'fbtee'; String(translate('Hello', 'Greeting'));",
29
+ "const { fbt } = useFbt(); String(fbt('Hello', 'Greeting'));",
30
+ "function message(fbt) { return String(fbt('Hello', 'Greeting')); }",
31
+ ].map((code) => ({ code, errors: [{ messageId: 'preferFbs' as const }], output: null })),
32
+ valid: [
33
+ "fbs('Hello', 'Greeting');",
34
+ "fbs.c('Save');",
35
+ "String(fbs('Hello', 'Greeting'));",
36
+ "String(fbs.c('Save'));",
37
+ 'String(<fbs desc="Greeting">Hello</fbs>);',
38
+ "String(fbt.param('name', 'Alice'));",
39
+ "String(fbt._('Hello'));",
40
+ "String(other('Hello'));",
41
+ "import { fbs as fbt } from 'fbtee'; String(fbt('Hello', 'Greeting'));",
42
+ "import { fbt } from 'other'; String(fbt('Hello', 'Greeting'));",
43
+ "import fbt = require('other'); String(fbt('Hello'));",
44
+ "function fbt(text) { return text; } String(fbt('Hello'));",
45
+ "function example(String) { return String(fbt('Hello', 'Greeting')); }",
46
+ "const String = value => value; String(fbt('Hello', 'Greeting'));",
47
+ "import { fbt as translate } from 'fbtee'; function example(translate) { return String(translate('Hello')); }",
48
+ "const result = fbt('Hello', 'Greeting'); String(result);",
49
+ "const translate = fbt; String(translate('Hello', 'Greeting'));",
50
+ 'String();',
51
+ ],
52
+ });
53
+
54
+ test('prefer-fbs is enabled in the recommended and strict configs', () => {
55
+ expect(plugin.rules['prefer-fbs']).toBe(rule);
56
+ expect(plugin.configs.recommended.rules['@nkzw/fbtee/prefer-fbs']).toBe(2);
57
+ expect(plugin.configs.strict.rules['@nkzw/fbtee/prefer-fbs']).toBe(2);
58
+ });
package/src/index.tsx CHANGED
@@ -2,6 +2,7 @@ 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
4
  import noUntranslatedStrings from './rules/no-untranslated-strings.tsx';
5
+ import preferFbs from './rules/prefer-fbs.tsx';
5
6
 
6
7
  export default {
7
8
  configs: {
@@ -9,6 +10,7 @@ export default {
9
10
  rules: {
10
11
  '@nkzw/fbtee/no-empty-strings': 2,
11
12
  '@nkzw/fbtee/no-unhelpful-desc': 2,
13
+ '@nkzw/fbtee/prefer-fbs': 2,
12
14
  },
13
15
  },
14
16
  strict: {
@@ -16,6 +18,7 @@ export default {
16
18
  '@nkzw/fbtee/no-empty-strings': 2,
17
19
  '@nkzw/fbtee/no-unhelpful-desc': 2,
18
20
  '@nkzw/fbtee/no-untranslated-strings': 2,
21
+ '@nkzw/fbtee/prefer-fbs': 2,
19
22
  },
20
23
  },
21
24
  },
@@ -27,5 +30,6 @@ export default {
27
30
  'no-empty-strings': noEmptyStringsRule,
28
31
  'no-unhelpful-desc': noUnhelpfulDesc,
29
32
  'no-untranslated-strings': noUntranslatedStrings,
33
+ 'prefer-fbs': preferFbs,
30
34
  },
31
35
  };
@@ -0,0 +1,94 @@
1
+ import { ASTUtils, TSESTree } from '@typescript-eslint/utils';
2
+ import { createRule } from '../utils.tsx';
3
+
4
+ function unwrap(node: TSESTree.Node): TSESTree.Node {
5
+ while (
6
+ node.type === 'TSAsExpression' ||
7
+ node.type === 'TSTypeAssertion' ||
8
+ node.type === 'TSNonNullExpression' ||
9
+ node.type === 'TSSatisfiesExpression' ||
10
+ node.type === 'ChainExpression'
11
+ ) {
12
+ node = node.expression;
13
+ }
14
+ return node;
15
+ }
16
+
17
+ export default createRule<[], 'preferFbs'>({
18
+ create(context) {
19
+ function isFbt(node: TSESTree.Node): boolean {
20
+ if (node.type !== 'Identifier') {
21
+ return false;
22
+ }
23
+ const variable = ASTUtils.findVariable(context.sourceCode.getScope(node), node.name);
24
+ const imported = variable?.defs.find((definition) => definition.type === 'ImportBinding');
25
+ if (imported) {
26
+ return (
27
+ imported.parent.type === 'ImportDeclaration' &&
28
+ (imported.parent.source.value === 'fbtee' ||
29
+ imported.parent.source.value === 'fbtee/server') &&
30
+ (imported.node.type === 'ImportDefaultSpecifier' ||
31
+ (imported.node.type === 'ImportSpecifier' &&
32
+ (imported.node.imported.type === 'Identifier'
33
+ ? imported.node.imported.name
34
+ : imported.node.imported.value) === 'fbt'))
35
+ );
36
+ }
37
+ return (
38
+ node.name === 'fbt' &&
39
+ !variable?.defs.some((definition) => definition.type === 'FunctionName')
40
+ );
41
+ }
42
+
43
+ return {
44
+ CallExpression(node) {
45
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'String') {
46
+ return;
47
+ }
48
+ const string = ASTUtils.findVariable(context.sourceCode.getScope(node), 'String');
49
+ if (string?.defs.length || !node.arguments[0]) {
50
+ return;
51
+ }
52
+ const argument = unwrap(node.arguments[0]);
53
+ if (
54
+ argument.type === 'JSXElement' &&
55
+ argument.openingElement.name.type === 'JSXIdentifier' &&
56
+ argument.openingElement.name.name === 'fbt'
57
+ ) {
58
+ context.report({ messageId: 'preferFbs', node });
59
+ return;
60
+ }
61
+ if (argument.type !== 'CallExpression') {
62
+ return;
63
+ }
64
+ const callee = unwrap(argument.callee);
65
+ if (
66
+ isFbt(callee) ||
67
+ (callee.type === 'MemberExpression' &&
68
+ isFbt(unwrap(callee.object)) &&
69
+ ((!callee.computed &&
70
+ callee.property.type === 'Identifier' &&
71
+ callee.property.name === 'c') ||
72
+ (callee.computed &&
73
+ callee.property.type === 'Literal' &&
74
+ callee.property.value === 'c')))
75
+ ) {
76
+ context.report({ messageId: 'preferFbs', node });
77
+ }
78
+ },
79
+ };
80
+ },
81
+ defaultOptions: [],
82
+ meta: {
83
+ docs: {
84
+ description: 'Require fbs() instead of String(fbt(...)) for translated text.',
85
+ },
86
+ messages: {
87
+ preferFbs:
88
+ 'Use fbs() or fbs.c() instead of String() on an fbt result. Rich fbt results stringify to "[object Object]".',
89
+ },
90
+ schema: [],
91
+ type: 'problem',
92
+ },
93
+ name: 'prefer-fbs',
94
+ });