This eslint plugin allows you to enforce certain rules in your ICU message.
Usage#
npm i -D eslint-plugin-formatjs
Then in your eslint config:
export default [
// other configs...
{
plugins: {
formatjs,
},
rules: {
'formatjs/no-offset': 'error',
},
},
]
React#
The plugin checks formatMessage, $formatMessage, $t, defineMessage, defineMessages, and <FormattedMessage> messages. Function calls match by name, including methods on any receiver, just like @formatjs/unplugin. For example, wrappedIntl.formatMessage(...) and messages.defineMessages(...) are checked without resolving imports or receiver types:
const messages = defineMessages({
foo: {
defaultMessage: 'foo',
description: 'bar',
},
})
defineMessage({
defaultMessage: 'single message',
})
<FormattedMessage defaultMessage="foo" description="bar" />
function foo() {
intl.formatMessage({
defaultMessage: 'foo',
})
}
TypeScript wrappers (as, satisfies, non-null assertions, and angle-bracket
assertions in .ts files) do not hide inline descriptors. This also applies to
defineMessages maps and their individual entries. Autofixes preserve the
surrounding wrappers.
const wrappedIntl = useIntl()
wrappedIntl.formatMessage({defaultMessage: 'Hello'} as const)
messages.defineMessages({
greeting: {defaultMessage: 'Hello'} satisfies MessageDescriptor,
} as const)
Vue#
This will check against intl.formatMessage, $formatMessage function calls in both your JS/TS & your SFC .vue files. For example:
<template>
<p>
{{
$formatMessage({
defaultMessage: 'today is {now, date}',
})
}}
</p>
</template>
Shared Settings#
These settings are applied globally to all formatjs rules once specified. See Shared Settings for more details on how to set them.
formatjs.additionalFunctionNames#
Similar to babel-plugin-formatjs & @formatjs/ts-transformer, this allows you to specify additional function names to check besides formatMessage, $formatMessage, and $t. These names also match methods: configuring customMessage checks both customMessage(...) and helpers.customMessage(...).
formatjs.additionalComponentNames#
Similar to babel-plugin-formatjs & @formatjs/ts-transformer, this allows you to specify additional component names to check besides FormattedMessage.
Shareable Configs#
The plugin provides the following two shareable configs:
recommendedstrict
By using these, you can simplify your configuration while still using a set of rules that aligns with your quality standards.
Example#
export default [
formatjs.configs.recommended,
// Other configs...
]
Available Rules#
blocklist-elements#
This blocklists usage of specific elements in ICU message.
Why#
- Certain translation vendors cannot handle things like
selectordinal
Available elements#
enum Element {
// literal text, like `defaultMessage: 'some text'`
literal = 'literal',
// placeholder, like `defaultMessage: '{placeholder} var'`
argument = 'argument',
// number, like `defaultMessage: '{placeholder, number} var'`
number = 'number',
// date, like `defaultMessage: '{placeholder, date} var'`
date = 'date',
// time, like `defaultMessage: '{placeholder, time} var'`
time = 'time',
// select, like `defaultMessage: '{var, select, foo{one} bar{two}} var'`
select = 'select',
// selectordinal, like `defaultMessage: '{var, selectordinal, one{one} other{two}} var'`
selectordinal = 'selectordinal',
// plural, like `defaultMessage: '{var, plural, one{one} other{two}} var'`
plural = 'plural',
}
Example#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/blocklist-elements': [
2,
[
'selectordinal',
{
type: 'select',
allow: {
variable: 'gender',
options: ['male', 'female', 'other'],
},
},
],
],
},
},
]
String entries block every element of that type. Object entries allow an
exception for the named variable. When options is present, selector options
must match that list exactly. Both recommended and strict block
selectordinal and allow select only for gender with male, female, and
other options.
enforce-description#
This enforces description in the message descriptor.
Why#
- Description provides helpful context for translators
const messages = defineMessages({
// WORKS
foo: {
defaultMessage: 'foo',
description: 'bar',
},
// FAILS
bar: {
defaultMessage: 'bar',
},
})
Options#
The rule accepts a single option, which can be either a string or an object.
String option:
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/enforce-description': ['error', 'literal'],
},
},
]
Setting literal forces description to always be a string literal instead of function calls or variables. This is helpful for extraction tools that expects description to always be a literal.
Object option:
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/enforce-description': [
'error',
{minLength: 10, mode: 'literal'},
],
},
},
]
| Property | Type | Required | Description |
|---|---|---|---|
minLength | integer | No | Minimum character length for the description string. |
mode | string | No | Set to 'literal' to require description to be a string literal. |
enforce-default-message#
This enforces defaultMessage in the message descriptor.
Why#
- Can be useful in case we want to extract messages for translations from source code. This way can make sure people won't forget about defaultMessage
const messages = defineMessages({
// WORKS
foo: {
defaultMessage: 'This is default message',
description: 'bar',
},
// FAILS
bar: {
description: 'bar',
},
})
Options#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/enforce-default-message': ['error', 'literal'],
},
},
]
Setting literal forces defaultMessage to always be a string literal instead of function calls or variables. This is helpful for extraction tools that expects defaultMessage to always be a literal
enforce-placeholders#
Makes sure all values are passed in if message has placeholders (number/date/time/plural/select/selectordinal). This requires values to be passed in as literal object (not a variable).
// WORKS, no error
<FormattedMessage
defaultMessage="this is a {placeholder}"
values={{placeholder: 'dog'}}
/>
// WORKS, no error
intl.formatMessage({
defaultMessage: 'this is a {placeholder}'
}, {placeholder: 'dog'})
// WORKS, error bc no values were provided
<FormattedMessage
defaultMessage="this is a {placeholder}"
/>
// WORKS, error bc no values were provided
intl.formatMessage({
defaultMessage: 'this is a {placeholder}'
})
// WORKS, error bc `placeholder` is not passed in
<FormattedMessage
defaultMessage="this is a {placeholder}"
values={{foo: 1}}
/>
// WORKS, error bc `placeholder` is not passed in
intl.formatMessage({
defaultMessage: 'this is a {placeholder}'
}, {foo: 1})
// DOESN'T WORK
<FormattedMessage
defaultMessage="this is a {placeholder}"
values={someVar}
/>
// DOESN'T WORK
intl.formatMessage({
defaultMessage: 'this is a {placeholder}'
}, values)
Options#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/enforce-placeholders': [
'error',
{
ignoreList: ['foo'],
},
],
},
},
]
ignoreList: List of placeholder names to ignore. This works withdefaultRichTextElementsinreact-intlso we don't provide false positive for ambient global tag formatting
enforce-plural-rules#
Enforce certain plural rules to always be specified/forbidden in a message.
Why#
- It is recommended to always specify
otheras fallback in the message. - Some translation vendors only accept certain rules.
Available rules#
enum LDML {
zero = 'zero',
one = 'one',
two = 'two',
few = 'few',
many = 'many',
other = 'other',
}
Example#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/enforce-plural-rules': [
2,
{
one: true,
other: true,
zero: false,
},
],
},
},
]
no-camel-case#
This make sure placeholders are not camel-case.
Why#
- This is to prevent case-sensitivity issue in certain translation vendors.
const messages = defineMessages({
// WORKS
foo: {
defaultMessage: 'foo {snake_case} {nothing}',
},
// FAILS
bar: {
defaultMessage: 'foo {camelCase}',
},
})
no-missing-icu-plural-one-placeholders#
Messages that look like {thing, plural, one {1 thing} other {# things}} will need to be changed to {thing, plural, one {# thing} other {# things}}
Why#
- one is a category for any number that behaves like 1. So in some languages, for example Ukrainian, Russian and Serbian, one → numbers that end in 1 (like 1, 21, 151) but that don’t end in 11 (like 11, 111, 10311). More info
no-emoji#
This prevents usage of emojis (or above a certain Unicode version) in message
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/no-emoji': ['error'],
},
},
// OR
{
plugins: {
formatjs,
},
rules: {
'formatjs/no-emoji': ['error', {versionAbove: '12.0'}],
},
},
]
Why#
- Certain translation vendors cannot handle emojis.
- Cross-platform encoding for emojis are faulty.
const messages = defineMessages({
// WORKS
foo: {
defaultMessage: 'Smileys & People',
},
// WORKS with option {versionAbove: '12.0'}
foo_bar: {
defaultMessage: '😃 Smileys & People',
},
// FAILS
bar: {
defaultMessage: '😃 Smileys & People',
},
// FAILS with option {versionAbove: '12.0'}
bar_foo: {
defaultMessage: '🥹 Smileys & People',
},
})
no-literal-string-in-jsx#
This prevents untranslated strings in JSX.
Why#
- It is easy to forget wrapping JSX text in translation functions or components.
- It is easy to forget wrapping certain accessibility attributes (e.g.
aria-label) in translation functions.
// WORKS
<Button>
<FormattedMessage defaultMessage="Submit" />
</Button>
// WORKS
<Button>
{customTranslateFn("Submit")}
</Button>
// WORKS
<input aria-label={intl.formatMessage({defaultMessage: "Label"})} />
// WORKS
<img
src="/example.png"
alt={intl.formatMessage({defaultMessage: "Image description"})}
/>
// FAILS
<Button>Submit</Button>
// FAILS
<Button>{'Submit'}</Button>
// FAILS
<Button>{`Te` + 's' + t}</Button>
// FAILS
<input aria-label="Untranslated label" />
// FAILS
<img src="/example.png" alt="Image description" />
// FAILS
<input aria-label={`Untranslated label`} />
This linter reports text literals or string expressions, including string concatenation expressions in the JSX children. It also checks certain JSX attributes that you can customize.
Example#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/no-literal-string-in-jsx': [
2,
{
// Include or exclude additional prop checks (merged with the default checks)
props: {
include: [
// picomatch style glob pattern for tag name and prop name.
// check `name` prop of `UI.Button` tag.
['UI.Button', 'name'],
// check `message` of any component.
['*', 'message'],
],
// Exclude will always override include.
exclude: [
// do not check `message` of the `Foo` tag.
['Foo', 'message'],
// do not check aria-label and aria-description of `Bar` tag.
['Bar', 'aria-{label,description}'],
],
},
},
],
},
},
]
The default prop checks are:
{
include: [
// check aria attributes that the screen reader announces.
['*', 'aria-{label,description,details,errormessage}'],
// check placeholder and title attribute of all native DOM elements.
['[a-z]*([a-z0-9])', '(placeholder|title)'],
// check alt attribute of the img tag.
['img', 'alt'],
],
exclude: []
}
no-literal-string-in-object#
This prevents untranslated strings in chosen object properties.
Why#
- It is easy to forget wrapping literal strings in translation functions, when they are defined in an object field like
{label: "Untranslated label"}.
const options = () => [
// FAILS
{value: 'chocolate', label: 'Chocolate'},
// WORKS
{
value: 'strawberry',
label: intl.formatMessage({defaultMessage: 'Strawberry'}),
},
// WORKS, custom translation function
{
value: 'mint',
label: customTranslateFn('Mint'),
},
// FAILS, string concatenation
{
value: 'coconut',
label: 'Coconut' + intl.formatMessage({defaultMessage: 'Ice Cream'}),
},
// FAILS, template literal
{
value: 'mango',
label: `Mango ${intl.formatMessage({defaultMessage: 'Ice Cream'})}`,
},
// FAILS, conditional rendering
{
value: 'recommended',
label: feelLikeSour
? intl.formatMessage({defaultMessage: 'Lime'})
: 'Vanilla',
},
]
const MyComponent = () => <Select options={options()} />
This linter reports text literals or string expressions, including string concatenation expressions in the object properties that you can customize.
Example#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/no-literal-string-in-object': [
'warn',
{
// The object properties to check for untranslated literal strings, default: ['label']
include: ['label'],
},
],
},
},
]
no-multiple-whitespaces#
This prevents usage of multiple consecutive whitespaces in message.
Why#
- Consecutive whitespaces are handled differently in different locales.
- Prevents
\linebreaks in JS string which results in awkward whitespaces.
const messages = defineMessages({
// WORKS
foo: {
defaultMessage: 'Smileys & People',
},
// FAILS
bar: {
defaultMessage: 'Smileys & People',
},
// FAILS
baz: {
defaultMessage:
'this message is too long \
so I wanna line break it.',
},
})
no-multiple-plurals#
This prevents specifying multiple plurals in your message.
Why#
- Nested plurals are hard to translate across languages so some translation vendors don't allow it.
const messages = defineMessages({
// WORKS
foo: {
defaultMessage: '{p1, plural, one{one}}',
},
// FAILS
bar: {
defaultMessage: '{p1, plural, one{one}} {p2, plural, one{two}}',
}
// ALSO FAILS
bar2: {
defaultMessage: '{p1, plural, one{{p2, plural, one{two}}}}',
}
})
no-offset#
This prevents specifying offset in plural rules in your message.
Why#
- Offset has complicated logic implication so some translation vendors don't allow it.
const messages = defineMessages({
// PASS
foo: {
defaultMessage: '{var, plural, one{one} other{other}}',
},
// FAILS
bar: {
defaultMessage: '{var, plural, offset:1 one{one} other{other}}',
},
})
enforce-id#
This enforces generated ID to be set in MessageDescriptor.
Why#
Pipelines can enforce automatic/manual ID generation at the linter level (autofix to insert autogen ID) so this guarantees that.
const messages = defineMessages({
// PASS
foo: {
id: '19shaf'
defaultMessage: '{var, plural, one{one} other{other}}',
},
// FAILS
bar: {
id: 'something',
defaultMessage: '{var, plural, offset:1 one{one} other{other}}',
},
// FAILS
bar: {
defaultMessage: '{var, plural, offset:1 one{one} other{other}}',
},
});
Options#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/enforce-id': [
'error',
{
idInterpolationPattern: '[sha512:contenthash:base64:6]',
},
],
},
},
]
idInterpolationPattern: Pattern to verify ID againstidWhitelist: An array of strings with regular expressions. This array allows allowlist custom ids for messages. For example '\\.' allows any id which has dot;'^payment_.*'- allows any custom id which has prefixpayment_. Be aware that any backslash \ provided via string must be escaped with an additional backslash.
no-invalid-icu#
This bans strings inside defaultMessage that are syntactically invalid.
Why#
It's easy to miss strings that look correct to you as a developer but which are actually syntactically invalid ICU strings. For instance, the following would cause an eslint error:
formatMessage(
{
defaultMessage: '{count, plural one {#} other {# more}}', //Missing a comma!
},
{
count: 1,
}
)
no-id#
This bans explicit ID in MessageDescriptor.
Why#
We generally encourage automatic ID generation due to these reasons. This makes sure no explicit IDs are set.
no-complex-selectors#
Make sure a sentence is not too complex. Complexity is determined by how many strings are produced when we try to flatten the sentence given its selectors. For example:
I have {count, plural, one{a dog} other{many dogs}}
has the complexity of 2 because flattening the plural selector results in 2 sentences: I have a dog & I have many dogs.
Default complexity limit is 20 (using Smartling as a reference)
Options#
export default [
{
plugins: {
formatjs,
},
rules: {
'formatjs/no-complex-selectors': [
'error',
{
limit: 3,
},
],
},
},
]
no-useless-message#
This bans messages that do not require translation.
Why#
Messages like {test} is not actionable by translators. The code should just directly reference test.
prefer-formatted-message#
Use <FormattedMessage> instead of the imperative intl.formatMessage(...) if applicable.
// Bad
<p>
{intl.formatMessage({defaultMessage: 'hello'})}
</p>
// Good
<p>
<FormattedMessage defaultMessage="hello" />
</p>
Why#
Consistent coding style in JSX and less syntax clutter.
prefer-full-sentence#
Messages should be self-contained, full sentences. Leading or trailing whitespace in a message suggests it is a fragment being concatenated with other strings, which is an anti-pattern for localization. Placeholders joined directly to words, such as {count}day, also fail because units and spacing vary by locale.
Why#
- String concatenation breaks translation because different languages have different word orders, grammar rules, and sentence structures.
- Translators need the full sentence to produce an accurate translation.
- Leading/trailing whitespace is a code smell indicating the message is a fragment.
- Units should use ICU number skeletons or an
Intlformatter instead of being joined to placeholders.
const messages = defineMessages({
// WORKS
foo: {
defaultMessage: 'Hello {name}, welcome back!',
},
// FAILS — leading whitespace
bar: {
defaultMessage: ' items in your cart',
},
// FAILS — trailing whitespace
baz: {
defaultMessage: 'You have ',
},
// FAILS — placeholder joined to a unit
qux: {
defaultMessage: '{duration}day',
},
})
prefer-pound-in-plural#
Use # in the plural argument to reference the count instead of repeating the argument.
Bad:
I have {count} {
count, plural,
one
other
}
}
Bad:
I have {count} {
count, plural,
one
other
}
}
Good:
I have {
count, plural,
one
other
}
}
Bad:
I have {
count, plural,
one
other
}
}
Good:
I have {
count, plural,
one
other
}
}
Bad:
I won the {ranking}{
count, selectordinal,
one
two
few
other
} place.
Good:
I won the {ranking}{
count, selectordinal,
one
two
few
other
} place.
Why#
- More concise message.
- Ensures that the count are correctly formatted as numbers.