eslint-plugin-formatjs 6.1.4 → 6.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/index.js CHANGED
@@ -18,6 +18,7 @@ import { rule as noUselessMessage, name as noUselessMessageName } from "./rules/
18
18
  import { rule as preferFormattedMessage, name as preferFormattedMessageName } from "./rules/prefer-formatted-message.js";
19
19
  import { rule as preferPoundInPlural, name as preferPoundInPluralName } from "./rules/prefer-pound-in-plural.js";
20
20
  import { rule as noLiteralStringInObject, name as noLiteralStringInObjectName } from "./rules/no-literal-string-in-object.js";
21
+ import { rule as preferFullSentence, name as preferFullSentenceName } from "./rules/prefer-full-sentence.js";
21
22
  import * as packageJsonNs from "./package.json" with { type: "json" };
22
23
  const packageJson = packageJsonNs.default ?? packageJsonNs;
23
24
  const { name, version } = packageJson;
@@ -42,7 +43,8 @@ const rules = {
42
43
  [preferFormattedMessageName]: preferFormattedMessage,
43
44
  [preferPoundInPluralName]: preferPoundInPlural,
44
45
  [noMissingIcuPluralOnePlaceholdersName]: noMissingIcuPluralOnePlaceholders,
45
- [noLiteralStringInObjectName]: noLiteralStringInObject
46
+ [noLiteralStringInObjectName]: noLiteralStringInObject,
47
+ [preferFullSentenceName]: preferFullSentence
46
48
  };
47
49
  // Base plugin
48
50
  const plugin = {
@@ -76,7 +78,8 @@ const configs = {
76
78
  other: true
77
79
  }],
78
80
  "formatjs/no-literal-string-in-jsx": ["error", { props: { include: [["*", "{label,placeholder,title}"]] } }],
79
- "formatjs/blocklist-elements": ["error", ["selectordinal"]]
81
+ "formatjs/blocklist-elements": ["error", ["selectordinal"]],
82
+ "formatjs/prefer-full-sentence": "error"
80
83
  }
81
84
  },
82
85
  recommended: {
@@ -99,7 +102,8 @@ const configs = {
99
102
  other: true
100
103
  }],
101
104
  "formatjs/no-literal-string-in-jsx": ["warn", { props: { include: [["*", "{label,placeholder,title}"]] } }],
102
- "formatjs/blocklist-elements": ["error", ["selectordinal"]]
105
+ "formatjs/blocklist-elements": ["error", ["selectordinal"]],
106
+ "formatjs/prefer-full-sentence": "error"
103
107
  }
104
108
  }
105
109
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eslint-plugin-formatjs",
3
3
  "description": "ESLint plugin for formatjs",
4
- "version": "6.1.4",
4
+ "version": "6.2.0",
5
5
  "license": "MIT",
6
6
  "author": "Long Ho <holevietlong@gmail.com>",
7
7
  "type": "module",
@@ -0,0 +1,3 @@
1
+ import type { Rule } from "eslint";
2
+ export declare const name = "prefer-full-sentence";
3
+ export declare const rule: Rule.RuleModule;
@@ -0,0 +1,111 @@
1
+ import { parse, TYPE } from "@formatjs/icu-messageformat-parser";
2
+ import { extractMessages, getSettings } from "../util.js";
3
+ import { CORE_MESSAGES } from "../messages.js";
4
+ /**
5
+ * Get the first boundary element, recursing into tags since
6
+ * <b>hello</b> starts with the literal "hello".
7
+ */
8
+ function getFirstBoundaryElement(ast) {
9
+ if (ast.length === 0) return null;
10
+ const first = ast[0];
11
+ if (first.type === TYPE.tag) {
12
+ return getFirstBoundaryElement(first.children);
13
+ }
14
+ return first;
15
+ }
16
+ /**
17
+ * Get the last boundary element, recursing into tags.
18
+ */
19
+ function getLastBoundaryElement(ast) {
20
+ if (ast.length === 0) return null;
21
+ const last = ast[ast.length - 1];
22
+ if (last.type === TYPE.tag) {
23
+ return getLastBoundaryElement(last.children);
24
+ }
25
+ return last;
26
+ }
27
+ function findWhitespaceIssues(ast) {
28
+ const issues = [];
29
+ const first = getFirstBoundaryElement(ast);
30
+ const last = getLastBoundaryElement(ast);
31
+ // Ignore messages that are only whitespace (edge case)
32
+ if (first === last && first?.type === TYPE.literal && ast.length === 1 && first.value.trim() === "") {
33
+ return issues;
34
+ }
35
+ if (first?.type === TYPE.literal && /^\s/.test(first.value)) {
36
+ issues.push("leadingWhitespace");
37
+ }
38
+ if (last?.type === TYPE.literal && /\s$/.test(last.value)) {
39
+ issues.push("trailingWhitespace");
40
+ }
41
+ // Check each plural/select option branch independently
42
+ for (const element of ast) {
43
+ switch (element.type) {
44
+ case TYPE.plural:
45
+ case TYPE.select: {
46
+ for (const option of Object.values(element.options)) {
47
+ issues.push(...findWhitespaceIssues(option.value));
48
+ }
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ return issues;
54
+ }
55
+ function checkNode(context, node) {
56
+ const msgs = extractMessages(node, getSettings(context));
57
+ for (const [{ message: { defaultMessage }, messageNode }] of msgs) {
58
+ if (!defaultMessage || !messageNode) {
59
+ continue;
60
+ }
61
+ let ast;
62
+ try {
63
+ ast = parse(defaultMessage);
64
+ } catch (e) {
65
+ context.report({
66
+ node: messageNode,
67
+ messageId: "parseError",
68
+ data: { error: e instanceof Error ? e.message : String(e) }
69
+ });
70
+ return;
71
+ }
72
+ const issues = findWhitespaceIssues(ast);
73
+ // Deduplicate
74
+ const seen = new Set();
75
+ for (const issue of issues) {
76
+ if (seen.has(issue)) continue;
77
+ seen.add(issue);
78
+ context.report({
79
+ node: messageNode,
80
+ messageId: issue
81
+ });
82
+ }
83
+ }
84
+ }
85
+ export const name = "prefer-full-sentence";
86
+ export const rule = {
87
+ meta: {
88
+ type: "suggestion",
89
+ docs: {
90
+ description: "Detects messages with leading/trailing whitespace, which suggests string concatenation instead of full sentences",
91
+ url: "https://formatjs.github.io/docs/tooling/linter#prefer-full-sentence"
92
+ },
93
+ messages: {
94
+ ...CORE_MESSAGES,
95
+ leadingWhitespace: "Messages should be full sentences — leading whitespace suggests string concatenation",
96
+ trailingWhitespace: "Messages should be full sentences — trailing whitespace suggests string concatenation"
97
+ },
98
+ schema: []
99
+ },
100
+ create(context) {
101
+ const callExpressionVisitor = (node) => checkNode(context, node);
102
+ const parserServices = context.sourceCode.parserServices;
103
+ if (parserServices?.defineTemplateBodyVisitor) {
104
+ return parserServices.defineTemplateBodyVisitor({ CallExpression: callExpressionVisitor }, { CallExpression: callExpressionVisitor });
105
+ }
106
+ return {
107
+ JSXOpeningElement: (node) => checkNode(context, node),
108
+ CallExpression: callExpressionVisitor
109
+ };
110
+ }
111
+ };