diff --git a/README.md b/README.md index 8862d9d..d76f6fb 100644 --- a/README.md +++ b/README.md @@ -107,4 +107,5 @@ Alternatively, add `lingui` to the plugins section, and configure the rules you - [text-restrictions](docs/rules/text-restrictions.md) - [consistent-plural-format](docs/rules/consistent-plural-format.md) - [no-plural-inside-trans](docs/rules/no-plural-inside-trans.md) +- [require-explicit-comment](docs/rules/require-explicit-comment.md) - [require-explicit-id](docs/rules/require-explicit-id.md) diff --git a/docs/rules/require-explicit-comment.md b/docs/rules/require-explicit-comment.md new file mode 100644 index 0000000..6c1969e --- /dev/null +++ b/docs/rules/require-explicit-comment.md @@ -0,0 +1,39 @@ +# require-explicit-comment + +Enforce that Lingui message declarations provide an explicit `comment` for translators, unless `context` is already provided. + +Translator comments improve translation quality by giving additional intent about where and how a string is used. + +Tagged template literals (`` t`Hello` ``) don't support `comment` - use the function call form instead. + +```jsx +// nope ⛔️ +Hello + +t`Hello` +t({ message: "Hello" }) + +// ok ✅ +Hello + +`, ``: + - require `comment` prop if `context` prop is not present +- `` t`...` ``, `` msg`...` ``, `` defineMessage`...` ``: + - always invalid because tagged template form cannot carry `comment` + +## Notes + +- `comment` validation is presence-only: any value type is accepted (including expressions). diff --git a/src/helpers.ts b/src/helpers.ts index f50d8af..af87523 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -52,6 +52,11 @@ export const LinguiCallExpressionPluralQuery = 'CallExpression[callee.name=plura export const LinguiPluralComponentQuery = 'JSXElement[openingElement.name.name=Plural]' +export const LinguiSelectComponentQuery = 'JSXElement[openingElement.name.name=Select]' + +export const LinguiSelectOrdinalComponentQuery = + 'JSXElement[openingElement.name.name=SelectOrdinal]' + export function isNativeDOMTag(str: string) { return DOM_TAGS.includes(str) } diff --git a/src/index.ts b/src/index.ts index ea53a81..c4aa1ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import * as noTransInsideTransRule from './rules/no-trans-inside-trans' import * as consistentPluralFormatRule from './rules/consistent-plural-format' import * as noPluralInsideTransRule from './rules/no-plural-inside-trans' import * as requireExplicitIdRule from './rules/require-explicit-id' +import * as requireExplicitCommentRule from './rules/require-explicit-comment' import { ESLint, Linter } from 'eslint' import { FlatConfig, RuleModule } from '@typescript-eslint/utils/ts-eslint' @@ -23,6 +24,7 @@ const rules = { [consistentPluralFormatRule.name]: consistentPluralFormatRule.rule, [noPluralInsideTransRule.name]: noPluralInsideTransRule.rule, [requireExplicitIdRule.name]: requireExplicitIdRule.rule, + [requireExplicitCommentRule.name]: requireExplicitCommentRule.rule, } type RuleKey = keyof typeof rules diff --git a/src/rules/require-explicit-comment.ts b/src/rules/require-explicit-comment.ts new file mode 100644 index 0000000..f549688 --- /dev/null +++ b/src/rules/require-explicit-comment.ts @@ -0,0 +1,128 @@ +import { TSESTree } from '@typescript-eslint/utils' +import { createRule } from '../create-rule' +import { + LinguiCallExpressionQuery, + LinguiPluralComponentQuery, + LinguiSelectComponentQuery, + LinguiSelectOrdinalComponentQuery, + LinguiTaggedTemplateExpressionMessageQuery, + LinguiTransQuery, +} from '../helpers' + +export const name = 'require-explicit-comment' +export const rule = createRule<[], string>({ + name, + meta: { + docs: { + description: + "enforce 'comment' property or attribute for Lingui macros, unless 'context' is provided", + recommended: 'error', + }, + messages: { + missingCommentJsx: + '{{ component }} requires an explicit `comment` prop when `context` is absent', + missingCommentCall: + "Macro function call requires an explicit 'comment' property when 'context' is absent", + noCommentInTaggedTemplate: + "Tagged template literal doesn't support 'comment'. Use {{ fn }}({ comment: '...', message: '...' }) or provide `context` instead", + }, + schema: [], + type: 'problem' as const, + }, + + defaultOptions: [], + + create: function (context) { + const getJSXProp = (node: TSESTree.JSXElement, propName: string) => + node.openingElement.attributes.find( + (attr): attr is TSESTree.JSXAttribute => + attr.type === TSESTree.AST_NODE_TYPES.JSXAttribute && + attr.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier && + attr.name.name === propName, + ) + + const getObjectProp = (node: TSESTree.ObjectExpression, propName: string) => + node.properties.find( + (prop): prop is TSESTree.Property => + prop.type === TSESTree.AST_NODE_TYPES.Property && + ((prop.key.type === TSESTree.AST_NODE_TYPES.Identifier && prop.key.name === propName) || + (prop.key.type === TSESTree.AST_NODE_TYPES.Literal && prop.key.value === propName)), + ) + + const checkJSXComment = (node: TSESTree.JSXElement) => { + const hasContext = Boolean(getJSXProp(node, 'context')) + if (hasContext) { + return + } + + const hasComment = Boolean(getJSXProp(node, 'comment')) + if (hasComment) { + return + } + + const component = + node.openingElement.name.type === TSESTree.AST_NODE_TYPES.JSXIdentifier + ? node.openingElement.name.name + : 'Component' + + context.report({ + node, + messageId: 'missingCommentJsx', + data: { component }, + }) + } + + return { + [LinguiTransQuery](node: TSESTree.JSXElement) { + checkJSXComment(node) + }, + + [LinguiPluralComponentQuery](node: TSESTree.JSXElement) { + checkJSXComment(node) + }, + + [LinguiSelectComponentQuery](node: TSESTree.JSXElement) { + checkJSXComment(node) + }, + + [LinguiSelectOrdinalComponentQuery](node: TSESTree.JSXElement) { + checkJSXComment(node) + }, + + [LinguiTaggedTemplateExpressionMessageQuery](node: TSESTree.TemplateLiteral) { + const parent = node.parent as TSESTree.TaggedTemplateExpression + const fn = + parent.tag.type === TSESTree.AST_NODE_TYPES.Identifier ? parent.tag.name : 'function' + + context.report({ + node: parent, + messageId: 'noCommentInTaggedTemplate', + data: { fn }, + }) + }, + + [LinguiCallExpressionQuery](node: TSESTree.CallExpression) { + const arg = node.arguments[0] + + if (!arg || arg.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) { + return + } + + const hasContext = Boolean(getObjectProp(arg, 'context')) + if (hasContext) { + return + } + + const hasComment = Boolean(getObjectProp(arg, 'comment')) + if (hasComment) { + return + } + + context.report({ + node, + messageId: 'missingCommentCall', + }) + }, + } + }, +}) diff --git a/tests/src/rules/require-explicit-comment.test.ts b/tests/src/rules/require-explicit-comment.test.ts new file mode 100644 index 0000000..37fad39 --- /dev/null +++ b/tests/src/rules/require-explicit-comment.test.ts @@ -0,0 +1,131 @@ +import { rule, name } from '../../../src/rules/require-explicit-comment' +import { RuleTester } from '@typescript-eslint/rule-tester' + +describe('', () => {}) + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}) + +ruleTester.run(name, rule, { + valid: [ + // Call expressions with explicit comment + { + code: 't({ comment: "Greeting", message: "Hello" })', + }, + { + code: 'msg({ comment: "Greeting", message: "Hello" })', + }, + { + code: 'defineMessage({ comment: "Greeting", message: "Hello" })', + }, + // Presence-only: non-literal comment values are accepted + { + code: 't({ comment: commentHint, message: "Hello" })', + }, + { + code: 't({ comment: getHint(), message: "Hello" })', + }, + // context allows omitting comment + { + code: 't({ context: "homepage", message: "Hello" })', + }, + { + code: 'defineMessage({ context: "navigation.link", message: "About us" })', + }, + // non-object and no-arg calls are intentionally ignored + { + code: 't()', + }, + { + code: 't("Hello")', + }, + + // Trans and React macros with explicit comment + { + code: 'Hello', + }, + { + code: '', + }, + { + code: '', + }, + { + code: '', + }, + ], + invalid: [ + // Tagged template literals must switch to object form for comment/context + { + code: 't`Hello`', + errors: [{ messageId: 'noCommentInTaggedTemplate' }], + }, + { + code: 'msg`Hello`', + errors: [{ messageId: 'noCommentInTaggedTemplate' }], + }, + { + code: 'defineMessage`Hello`', + errors: [{ messageId: 'noCommentInTaggedTemplate' }], + }, + + // Call expressions missing both comment and context + { + code: 't({ message: "Hello" })', + errors: [{ messageId: 'missingCommentCall' }], + }, + { + code: 'msg({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'missingCommentCall' }], + }, + { + code: 'defineMessage({ id: "msg.hello", message: "Hello" })', + errors: [{ messageId: 'missingCommentCall' }], + }, + + // JSX macros missing both comment and context + { + code: 'Hello', + errors: [{ messageId: 'missingCommentJsx' }], + }, + { + code: '', + errors: [{ messageId: 'missingCommentJsx' }], + }, + { + code: '