diff --git a/packages/block-library/src/verse/index.js b/packages/block-library/src/verse/index.js
index df40ab80799fa1..04f41e2f6de0cf 100644
--- a/packages/block-library/src/verse/index.js
+++ b/packages/block-library/src/verse/index.js
@@ -25,10 +25,12 @@ export const settings = {
icon,
example: {
attributes: {
+ /* eslint-disable @wordpress/i18n-no-collapsible-whitespace */
// translators: Sample content for the Verse block. Can be replaced with a more locale-adequate work.
content: __(
'WHAT was he doing, the great god Pan,\n Down in the reeds by the river?\nSpreading ruin and scattering ban,\nSplashing and paddling with hoofs of a goat,\nAnd breaking the golden lilies afloat\n With the dragon-fly on the river.'
),
+ /* eslint-enable @wordpress/i18n-no-collapsible-whitespace */
},
},
supports: {
diff --git a/packages/block-library/src/video/edit.js b/packages/block-library/src/video/edit.js
index fc359c655cb986..52a5db7c44fb12 100644
--- a/packages/block-library/src/video/edit.js
+++ b/packages/block-library/src/video/edit.js
@@ -200,6 +200,7 @@ class VideoEdit extends Component {
{ this.props.attributes.poster
? sprintf(
+ /* translators: %s: poster image URL. */
__(
'The current poster image url is %s'
),
diff --git a/packages/components/src/autocomplete/index.js b/packages/components/src/autocomplete/index.js
index 6466328cabd836..8517c7bfc83e0b 100644
--- a/packages/components/src/autocomplete/index.js
+++ b/packages/components/src/autocomplete/index.js
@@ -227,6 +227,7 @@ export class Autocomplete extends Component {
if ( !! filteredOptions.length ) {
debouncedSpeak(
sprintf(
+ /* translators: %d: number of results. */
_n(
'%d result found, use up and down arrow keys to navigate.',
'%d results found, use up and down arrow keys to navigate.',
diff --git a/packages/components/src/form-token-field/index.js b/packages/components/src/form-token-field/index.js
index b2c81977a50fbc..0641a327c64734 100644
--- a/packages/components/src/form-token-field/index.js
+++ b/packages/components/src/form-token-field/index.js
@@ -514,6 +514,7 @@ class FormTokenField extends Component {
const message = hasMatchingSuggestions
? sprintf(
+ /* translators: %d: number of results. */
_n(
'%d result found, use up and down arrow keys to navigate.',
'%d results found, use up and down arrow keys to navigate.',
diff --git a/packages/components/src/guide/page-control.js b/packages/components/src/guide/page-control.js
index 837a0b31e7d5ad..1a39fdd1426c8e 100644
--- a/packages/components/src/guide/page-control.js
+++ b/packages/components/src/guide/page-control.js
@@ -37,8 +37,8 @@ export default function PageControl( {
isSelected={ page === currentPage }
/>
}
- /* translators: %1$d: current page number %2$d: total number of pages */
aria-label={ sprintf(
+ /* translators: 1: current page number 2: total number of pages */
__( 'Page %1$d of %2$d' ),
page + 1,
numberOfPages
diff --git a/packages/components/src/mobile/bottom-sheet/range-cell.native.js b/packages/components/src/mobile/bottom-sheet/range-cell.native.js
index 717ab84232a097..fa361e39429424 100644
--- a/packages/components/src/mobile/bottom-sheet/range-cell.native.js
+++ b/packages/components/src/mobile/bottom-sheet/range-cell.native.js
@@ -133,6 +133,7 @@ class BottomSheetRangeCell extends Component {
}
announceCurrentValue( value ) {
+ /* translators: %s: current cell value. */
const announcement = sprintf( __( 'Current value is %s' ), value );
AccessibilityInfo.announceForAccessibility( announcement );
}
diff --git a/packages/edit-post/src/components/manage-blocks-modal/manager.js b/packages/edit-post/src/components/manage-blocks-modal/manager.js
index bdb8349be888f7..41eba0b4f97e9a 100644
--- a/packages/edit-post/src/components/manage-blocks-modal/manager.js
+++ b/packages/edit-post/src/components/manage-blocks-modal/manager.js
@@ -52,9 +52,10 @@ function BlockManager( {
{ !! numberOfHiddenBlocks && (
{ sprintf(
+ /* translators: %d: number of blocks. */
_n(
- '%1$d block is disabled.',
- '%1$d blocks are disabled.',
+ '%d block is disabled.',
+ '%d blocks are disabled.',
numberOfHiddenBlocks
),
numberOfHiddenBlocks
diff --git a/packages/editor/src/components/post-last-revision/index.js b/packages/editor/src/components/post-last-revision/index.js
index b24f0f3ac0c1cc..643b6c1a4700a6 100644
--- a/packages/editor/src/components/post-last-revision/index.js
+++ b/packages/editor/src/components/post-last-revision/index.js
@@ -24,6 +24,7 @@ function LastRevision( { lastRevisionId, revisionsCount } ) {
icon={ backup }
>
{ sprintf(
+ /* translators: %d: number of revisions */
_n( '%d Revision', '%d Revisions', revisionsCount ),
revisionsCount
) }
diff --git a/packages/editor/src/components/post-publish-panel/maybe-post-format-panel.js b/packages/editor/src/components/post-publish-panel/maybe-post-format-panel.js
index fede6a0a33186d..5d173e4f3c6807 100644
--- a/packages/editor/src/components/post-publish-panel/maybe-post-format-panel.js
+++ b/packages/editor/src/components/post-publish-panel/maybe-post-format-panel.js
@@ -46,6 +46,7 @@ const PostFormatPanel = ( { suggestion, onUpdatePostFormat } ) => {
onUpdatePostFormat={ onUpdatePostFormat }
suggestedPostFormat={ suggestion.id }
suggestionText={ sprintf(
+ /* translators: %s: post format */
__( 'Apply the "%1$s" format.' ),
suggestion.caption
) }
diff --git a/packages/editor/src/components/post-taxonomies/flat-term-selector.js b/packages/editor/src/components/post-taxonomies/flat-term-selector.js
index 299245a5e45aad..c6dd5af6db8530 100644
--- a/packages/editor/src/components/post-taxonomies/flat-term-selector.js
+++ b/packages/editor/src/components/post-taxonomies/flat-term-selector.js
@@ -245,14 +245,17 @@ class FlatTermSelector extends Component {
slug === 'post_tag' ? __( 'Tag' ) : __( 'Term' )
);
const termAddedLabel = sprintf(
+ /* translators: %s: term name. */
_x( '%s added', 'term' ),
singularName
);
const termRemovedLabel = sprintf(
+ /* translators: %s: term name. */
_x( '%s removed', 'term' ),
singularName
);
const removeTermLabel = sprintf(
+ /* translators: %s: term name. */
_x( 'Remove %s', 'term' ),
singularName
);
diff --git a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
index c8897753492f13..234b5f25e8f6ac 100644
--- a/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
+++ b/packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js
@@ -171,6 +171,7 @@ class HierarchicalTermSelector extends Component {
? this.state.availableTerms
: [ term, ...this.state.availableTerms ];
const termAddedMessage = sprintf(
+ /* translators: %s: taxonomy name */
_x( '%s added', 'term' ),
get(
this.props.taxonomy,
@@ -319,6 +320,7 @@ class HierarchicalTermSelector extends Component {
const resultCount = getResultCount( filteredTermsTree );
const resultsFoundMessage = sprintf(
+ /* translators: %d: number of results */
_n( '%d result found.', '%d results found.', resultCount ),
resultCount
);
diff --git a/packages/editor/src/components/reusable-blocks-buttons/reusable-block-delete-button.js b/packages/editor/src/components/reusable-blocks-buttons/reusable-block-delete-button.js
index f2a9664c640800..7f5e29f009d3f5 100644
--- a/packages/editor/src/components/reusable-blocks-buttons/reusable-block-delete-button.js
+++ b/packages/editor/src/components/reusable-blocks-buttons/reusable-block-delete-button.js
@@ -69,6 +69,7 @@ export default compose( [
// TODO: Make this a component or similar
// eslint-disable-next-line no-alert
const hasConfirmed = window.confirm(
+ // eslint-disable-next-line @wordpress/i18n-no-collapsible-whitespace
__(
'Are you sure you want to delete this Reusable Block?\n\n' +
'It will be permanently removed from all posts and pages that use it.'
diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md
index 48149ffc4c43fb..df3ba08a957755 100644
--- a/packages/eslint-plugin/CHANGELOG.md
+++ b/packages/eslint-plugin/CHANGELOG.md
@@ -3,6 +3,17 @@
### New Features
- The `prefer-const` rule included in the `recommended` and `esnext` rulesets has been relaxed to allow a `let` assignment if any of a [destructuring assignment](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) are reassigned.
+- New Rule: [`@wordpress/i18n-text-domain`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/i18n-text-domain.md)
+- New Rule: [`@wordpress/i18n-translator-comments`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/i18n-translator-comments.md)
+- New Rule: [`@wordpress/i18n-no-variables`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/i18n-no-variables.md)
+- New Rule: [`@wordpress/i18n-no-placeholders-only`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/i18n-no-placeholders-only.md)
+- New Rule: [`@wordpress/i18n-no-collapsible-whitespace`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/i18n-no-collapsible-whitespace.md)
+- New Rule: [`@wordpress/i18n-ellipsis`](https://github.com/WordPress/gutenberg/blob/master/packages/eslint-plugin/docs/rules/i18n-ellipsis.md)
+
+### Breaking Changes
+
+- There is a new `i18n` ruleset that includes all i18n-related rules and is included in the `recommended` ruleset.
+- The `valid-sprintf` rule has been moved from the `custom` ruleset to the `i18n` ruleset.
## 4.0.0 (2020-02-10)
diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index 31979f983b52f9..e6bf980d326a3f 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -36,6 +36,7 @@ Alternatively, you can opt-in to only the more granular rulesets offered by the
- `jsdoc`
- `jsx-a11y`
- `react`
+- `i18n`
- `test-e2e`
- `test-unit`
diff --git a/packages/eslint-plugin/configs/custom.js b/packages/eslint-plugin/configs/custom.js
index 8949b63f94d8dc..276d1f15a2584a 100644
--- a/packages/eslint-plugin/configs/custom.js
+++ b/packages/eslint-plugin/configs/custom.js
@@ -2,30 +2,8 @@ module.exports = {
plugins: [ '@wordpress' ],
rules: {
'@wordpress/no-unused-vars-before-return': 'error',
- '@wordpress/valid-sprintf': 'error',
'@wordpress/no-base-control-with-label-without-id': 'error',
'@wordpress/no-unguarded-get-range-at': 'error',
- 'no-restricted-syntax': [
- 'error',
- {
- selector:
- 'CallExpression[callee.name=/^(__|_n|_nx|_x)$/]:not([arguments.0.type=/^Literal|BinaryExpression$/])',
- message:
- 'Translate function arguments must be string literals.',
- },
- {
- selector:
- 'CallExpression[callee.name=/^(_n|_nx|_x)$/]:not([arguments.1.type=/^Literal|BinaryExpression$/])',
- message:
- 'Translate function arguments must be string literals.',
- },
- {
- selector:
- 'CallExpression[callee.name=_nx]:not([arguments.3.type=/^Literal|BinaryExpression$/])',
- message:
- 'Translate function arguments must be string literals.',
- },
- ],
},
overrides: [
{
diff --git a/packages/eslint-plugin/configs/i18n.js b/packages/eslint-plugin/configs/i18n.js
new file mode 100644
index 00000000000000..c3271214e3ef5c
--- /dev/null
+++ b/packages/eslint-plugin/configs/i18n.js
@@ -0,0 +1,12 @@
+module.exports = {
+ plugins: [ '@wordpress' ],
+ rules: {
+ '@wordpress/valid-sprintf': 'error',
+ '@wordpress/i18n-translator-comments': 'error',
+ '@wordpress/i18n-text-domain': 'error',
+ '@wordpress/i18n-no-collapsible-whitespace': 'error',
+ '@wordpress/i18n-no-placeholders-only': 'error',
+ '@wordpress/i18n-no-variables': 'error',
+ '@wordpress/i18n-ellipsis': 'error',
+ },
+};
diff --git a/packages/eslint-plugin/configs/recommended-with-formatting.js b/packages/eslint-plugin/configs/recommended-with-formatting.js
index 61e08359128075..d6e51149a928e9 100644
--- a/packages/eslint-plugin/configs/recommended-with-formatting.js
+++ b/packages/eslint-plugin/configs/recommended-with-formatting.js
@@ -5,6 +5,7 @@ module.exports = {
require.resolve( './custom.js' ),
require.resolve( './react.js' ),
require.resolve( './esnext.js' ),
+ require.resolve( './i18n.js' ),
],
env: {
node: true,
diff --git a/packages/eslint-plugin/docs/rules/i18n-ellipsis.md b/packages/eslint-plugin/docs/rules/i18n-ellipsis.md
new file mode 100644
index 00000000000000..f08a43bc56af4a
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/i18n-ellipsis.md
@@ -0,0 +1,18 @@
+# Disallow using three dots in translatable strings (i18n-ellipsis)
+
+Three dots for indicating an ellipsis should be replaced with the UTF-8 character … (Horizontal Ellipsis, U+2026) as it has a more semantic meaning.
+
+## Rule details
+
+Examples of **incorrect** code for this rule:
+
+```js
+__( 'Continue...' );
+
+```
+
+Examples of **correct** code for this rule:
+
+```js
+__( 'Continue…' );
+```
diff --git a/packages/eslint-plugin/docs/rules/i18n-no-collapsible-whitespace.md b/packages/eslint-plugin/docs/rules/i18n-no-collapsible-whitespace.md
new file mode 100644
index 00000000000000..d9564698535a96
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/i18n-no-collapsible-whitespace.md
@@ -0,0 +1,26 @@
+# Disallow collapsible whitespace in translatable strings. (i18n-no-collapsible-whitespace)
+
+Using complex whitespace in translatable strings and relying on HTML to collapse it can make translation more difficult and lead to unnecessary retranslation.
+
+Whitespace can be appropriate in longer translatable content, for example a whole blog post. These cases are unlikely to occur in the code scanned by eslint but if they do, [disable the rule with inline comments](http://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments. ( e.g. `// eslint-disable-line i18n-no-collapsible-whitespace` ).
+
+## Rule details
+
+Examples of **incorrect** code for this rule:
+
+```js
+__( "A string\non two lines" );
+__( 'A string\non two lines' );
+__( `A string
+on two lines` );
+__( `A string with tabs` );
+__( "Multiple spaces. Even after a full stop. (We're going there)" );
+```
+
+Examples of **correct** code for this rule:
+
+```js
+__( `A long string ` +
+ `spread over ` +
+ `multiple lines.` );
+```
diff --git a/packages/eslint-plugin/docs/rules/i18n-no-placeholders-only.md b/packages/eslint-plugin/docs/rules/i18n-no-placeholders-only.md
new file mode 100644
index 00000000000000..286690a79e70c0
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/i18n-no-placeholders-only.md
@@ -0,0 +1,17 @@
+# Prevent using only placeholders in translatable strings (i18n-no-placeholders-only)
+
+Translatable strings that consist of nothing but a placeholder cannot be translated.
+
+## Rule details
+
+Examples of **incorrect** code for this rule:
+
+```js
+__( '%s' );
+```
+
+Examples of **correct** code for this rule:
+
+```js
+__( 'Hello %s' );
+```
diff --git a/packages/eslint-plugin/docs/rules/i18n-no-variables.md b/packages/eslint-plugin/docs/rules/i18n-no-variables.md
new file mode 100644
index 00000000000000..3478f02acf755d
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/i18n-no-variables.md
@@ -0,0 +1,24 @@
+# Enforce string literals as translation function arguments (i18n-no-variables)
+
+[Translation functions](https://github.com/WordPress/gutenberg/blob/master/packages/i18n/README.md#api) must be called with valid string literals as arguments.
+
+They cannot be variables or functions due to the way these strings are extracted through static analysis of the code. The exception to this rule is string concatenation within the argument itself.
+
+This limitation applies to both singular and plural strings, as well as the `context` argument if present.
+
+## Rule details
+
+Examples of **incorrect** code for this rule:
+
+```js
+__( `Hello ${foo}` );
+__( foo );
+_x( 'Hello World', bar );
+```
+
+Examples of **correct** code for this rule:
+
+```js
+__( 'Hello World' );
+_x( 'Hello' + ' World', 'context', 'foo' );
+```
diff --git a/packages/eslint-plugin/docs/rules/i18n-text-domain.md b/packages/eslint-plugin/docs/rules/i18n-text-domain.md
new file mode 100644
index 00000000000000..7a637fc43aaeb1
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/i18n-text-domain.md
@@ -0,0 +1,26 @@
+# Enforce passing valid text domains (i18n-text-domain)
+
+[Translation functions](https://github.com/WordPress/gutenberg/blob/master/packages/i18n/README.md#api) must be called with a valid string literal as the text domain.
+
+## Rule details
+
+Examples of **incorrect** code for this rule:
+
+```js
+__( 'Hello World' ); // unless allowedTextDomain contains 'default'
+__( 'Hello World', 'default' ); // with allowedTextDomain = [ 'default' ]
+__( 'Hello World', foo );
+```
+
+Examples of **correct** code for this rule:
+
+```js
+__( 'Hello World' ); // with allowedTextDomain = [ 'default' ]
+__( 'Hello World', 'foo-bar' ); // with allowedTextDomain = [ 'foo-bar' ]
+```
+
+## Options
+
+This rule accepts a single options argument:
+
+- Set `allowedTextDomain` to specify the list of allowed text domains, e.g. `[ 'foo', 'bar' ]`. The default is `[ 'default' ]`.
diff --git a/packages/eslint-plugin/docs/rules/i18n-translator-comments.md b/packages/eslint-plugin/docs/rules/i18n-translator-comments.md
new file mode 100644
index 00000000000000..59a9453e46ec01
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/i18n-translator-comments.md
@@ -0,0 +1,38 @@
+# Enforce adding translator comments (i18n-translator-comments)
+
+If using [translation functions](https://github.com/WordPress/gutenberg/blob/master/packages/i18n/README.md#api) with placeholders in them,
+they need accompanying translator comments.
+
+## Rule details
+
+Examples of **incorrect** code for this rule:
+
+```js
+var color = '';
+sprintf( __( 'Color: %s' ), color );
+
+var address = '';
+sprintf(
+ __( 'Address: %s' ),
+ address
+);
+
+// translators: %s: Name
+var name = '';
+sprintf( __( 'Name: %s' ), name );
+```
+
+Examples of **correct** code for this rule:
+
+```js
+var color = '';
+// translators: %s: Color
+sprintf( __( 'Color: %s' ), color );
+
+var address = '';
+sprintf(
+ // translators: %s: Address.
+ __( 'Address: %s' ),
+ address,
+);
+```
diff --git a/packages/eslint-plugin/rules/__tests__/i18n-ellipsis.js b/packages/eslint-plugin/rules/__tests__/i18n-ellipsis.js
new file mode 100644
index 00000000000000..2bc487e92d42bd
--- /dev/null
+++ b/packages/eslint-plugin/rules/__tests__/i18n-ellipsis.js
@@ -0,0 +1,65 @@
+/**
+ * External dependencies
+ */
+import { RuleTester } from 'eslint';
+
+/**
+ * Internal dependencies
+ */
+import rule from '../i18n-ellipsis';
+
+const ruleTester = new RuleTester( {
+ parserOptions: {
+ ecmaVersion: 6,
+ },
+} );
+
+ruleTester.run( 'i18n-ellipsis', rule, {
+ valid: [
+ {
+ code: `__( 'Hello World…' )`,
+ },
+ {
+ code: `__( 'Hello' + 'World…' )`,
+ },
+ {
+ code: `_x( 'Hello World…', 'context' )`,
+ },
+ {
+ code: `_n( 'Singular…', 'Plural…', number)`,
+ },
+ {
+ code: `i18n.__( 'Hello World…' )`,
+ },
+ ],
+ invalid: [
+ {
+ code: `__( 'Hello World...' )`,
+ output: `__( 'Hello World…' )`,
+ errors: [ { messageId: 'foundThreeDots' } ],
+ },
+ {
+ code: `__( 'Hello' + 'World...' )`,
+ output: `__( 'Hello' + 'World…' )`,
+ errors: [ { messageId: 'foundThreeDots' } ],
+ },
+ {
+ code: `_x( 'Hello World...', 'context' )`,
+ output: `_x( 'Hello World…', 'context' )`,
+ errors: [ { messageId: 'foundThreeDots' } ],
+ },
+ {
+ code: `_n( 'Singular...', 'Plural...', number)`,
+ output: `_n( 'Singular…', 'Plural…', number)`,
+ errors: [
+ { messageId: 'foundThreeDots' },
+ { messageId: 'foundThreeDots' },
+ ],
+ },
+ {
+ code: `i18n.__( 'Hello World...' )`,
+ output: `i18n.__( 'Hello World…' )`,
+ errors: [ { messageId: 'foundThreeDots' } ],
+ },
+ ],
+} );
diff --git a/packages/eslint-plugin/rules/__tests__/i18n-no-collapsible-whitespace.js b/packages/eslint-plugin/rules/__tests__/i18n-no-collapsible-whitespace.js
new file mode 100644
index 00000000000000..35e4e47301d7aa
--- /dev/null
+++ b/packages/eslint-plugin/rules/__tests__/i18n-no-collapsible-whitespace.js
@@ -0,0 +1,62 @@
+/**
+ * External dependencies
+ */
+import { RuleTester } from 'eslint';
+
+/**
+ * Internal dependencies
+ */
+import rule from '../i18n-no-collapsible-whitespace';
+
+const ruleTester = new RuleTester( {
+ parserOptions: {
+ ecmaVersion: 6,
+ },
+} );
+
+ruleTester.run( 'i18n-no-collapsible-whitespace', rule, {
+ valid: [
+ {
+ code: `__( 'Hello World…' )`,
+ },
+ {
+ code:
+ '__( `A long string ` +\n `spread over ` +\n `multiple lines.` );',
+ },
+ ],
+ invalid: [
+ {
+ code: '__( "My double-quoted string\\nwith a newline" );',
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ {
+ code: "__( 'My single quoted string\\nwith a newline' );",
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ {
+ code: '__( `My template literal\non two lines` );',
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ {
+ code: "__( ' My tab-indented string.' );",
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ {
+ code: "__( '\tMy string with a tab escape sequence.' );",
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ {
+ code: "__( '\u0009My string with a unicode tab.' );",
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ {
+ code: '__( `A string with \r a carriage return.` );',
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ {
+ code:
+ "__( 'A string with consecutive spaces. These two are after a full stop.' );",
+ errors: [ { messageId: 'noCollapsibleWhitespace' } ],
+ },
+ ],
+} );
diff --git a/packages/eslint-plugin/rules/__tests__/i18n-no-placeholders-only.js b/packages/eslint-plugin/rules/__tests__/i18n-no-placeholders-only.js
new file mode 100644
index 00000000000000..747e380e67f7ee
--- /dev/null
+++ b/packages/eslint-plugin/rules/__tests__/i18n-no-placeholders-only.js
@@ -0,0 +1,48 @@
+/**
+ * External dependencies
+ */
+import { RuleTester } from 'eslint';
+
+/**
+ * Internal dependencies
+ */
+import rule from '../i18n-no-placeholders-only';
+
+const ruleTester = new RuleTester( {
+ parserOptions: {
+ ecmaVersion: 6,
+ },
+} );
+
+ruleTester.run( 'i18n-no-placeholders-only', rule, {
+ valid: [
+ {
+ code: `__( 'Hello %s' )`,
+ },
+ {
+ code: `__( '%d%%' )`,
+ },
+ ],
+ invalid: [
+ {
+ code: `__( '%s' )`,
+ errors: [ { messageId: 'noPlaceholdersOnly' } ],
+ },
+ {
+ code: `__( '%s%s' )`,
+ errors: [ { messageId: 'noPlaceholdersOnly' } ],
+ },
+ // @todo: Update placeholder regex, see https://github.com/WordPress/gutenberg/pull/20574.
+ /*{
+ code: `_x( '%1$s' )`,
+ errors: [ { messageId: 'noPlaceholdersOnly' } ],
+ },*/
+ {
+ code: `_n( '%s', '%s', number)`,
+ errors: [
+ { messageId: 'noPlaceholdersOnly' },
+ { messageId: 'noPlaceholdersOnly' },
+ ],
+ },
+ ],
+} );
diff --git a/packages/eslint-plugin/rules/__tests__/i18n-no-variables.js b/packages/eslint-plugin/rules/__tests__/i18n-no-variables.js
new file mode 100644
index 00000000000000..1ee322944ef9fd
--- /dev/null
+++ b/packages/eslint-plugin/rules/__tests__/i18n-no-variables.js
@@ -0,0 +1,92 @@
+/**
+ * External dependencies
+ */
+import { RuleTester } from 'eslint';
+
+/**
+ * Internal dependencies
+ */
+import rule from '../i18n-no-variables';
+
+const ruleTester = new RuleTester( {
+ parserOptions: {
+ ecmaVersion: 6,
+ },
+} );
+
+ruleTester.run( 'i18n-no-variables', rule, {
+ valid: [
+ {
+ code: `__( 'Hello World' )`,
+ },
+ {
+ code: `__( 'Hello' + 'World' )`,
+ },
+ {
+ code: `_x( 'Hello World', 'context' )`,
+ },
+ {
+ code: `var number = ''; _n( 'Singular', 'Plural', number)`,
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, 'context' )`,
+ },
+ {
+ code: `__( 'Hello World', 'foo' )`,
+ },
+ {
+ code: `_x( 'Hello World', 'context', 'foo' )`,
+ },
+ {
+ code: `var number = ''; _n( 'Singular', 'Plural', number, 'foo' )`,
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, 'context', 'foo' )`,
+ },
+ {
+ code: `i18n.__( 'Hello World' )`,
+ },
+ ],
+ invalid: [
+ {
+ code: `__(foo)`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: '__(`Hello ${foo}`)',
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `_x(foo, 'context' )`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `_x( 'Hello World', bar)`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `var number = ''; _n(foo,'Plural', number)`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `var number = ''; _n( 'Singular', bar, number)`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `var number = ''; _nx(foo, 'Plural', number, 'context' )`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', bar, number, 'context' )`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, baz)`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ {
+ code: `i18n.__(foo)`,
+ errors: [ { messageId: 'invalidArgument' } ],
+ },
+ ],
+} );
diff --git a/packages/eslint-plugin/rules/__tests__/i18n-text-domain.js b/packages/eslint-plugin/rules/__tests__/i18n-text-domain.js
new file mode 100644
index 00000000000000..1212c90d998ed7
--- /dev/null
+++ b/packages/eslint-plugin/rules/__tests__/i18n-text-domain.js
@@ -0,0 +1,170 @@
+/**
+ * External dependencies
+ */
+import { RuleTester } from 'eslint';
+
+/**
+ * Internal dependencies
+ */
+import rule from '../i18n-text-domain';
+
+const ruleTester = new RuleTester( {
+ parserOptions: {
+ ecmaVersion: 6,
+ },
+} );
+
+ruleTester.run( 'i18n-text-domain', rule, {
+ valid: [
+ {
+ code: `__( 'Hello World' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ },
+ {
+ code: `_x( 'Hello World', 'context' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ },
+ {
+ code: `var number = ''; _n( 'Singular', 'Plural', number )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, 'context' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ },
+ {
+ code: `__( 'Hello World', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ },
+ {
+ code: `_x( 'Hello World', 'context', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ },
+ {
+ code: `var number = ''; _n( 'Singular', 'Plural', number, 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, 'context', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ },
+ {
+ code: `i18n.__( 'Hello World' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ },
+ ],
+ invalid: [
+ {
+ code: `__( 'Hello World' )`,
+ output: `__( 'Hello World', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `_x( 'Hello World', 'context' )`,
+ output: `_x( 'Hello World', 'context', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `var number = ''; _n( 'Singular', 'Plural', number )`,
+ output: `var number = ''; _n( 'Singular', 'Plural', number, 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, 'context' )`,
+ output: `var number = ''; _nx( 'Singular', 'Plural', number, 'context', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `__( 'Hello World', 'bar' )`,
+ output: `__( 'Hello World', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'invalidValue' } ],
+ },
+ {
+ code: `_x( 'Hello World', 'context', 'bar' )`,
+ output: `_x( 'Hello World', 'context', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'invalidValue' } ],
+ },
+ {
+ code: `var number = ''; _n( 'Singular', 'Plural', number, 'bar' )`,
+ output: `var number = ''; _n( 'Singular', 'Plural', number, 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'invalidValue' } ],
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, 'context', 'bar' )`,
+ output: `var number = ''; _nx( 'Singular', 'Plural', number, 'context', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'invalidValue' } ],
+ },
+ {
+ code: `var value = ''; __( 'Hello World', value )`,
+ errors: [ { messageId: 'invalidType' } ],
+ },
+ {
+ code: `var value = ''; _x( 'Hello World', 'context', value )`,
+ errors: [ { messageId: 'invalidType' } ],
+ },
+ {
+ code: `var value = ''; var number = ''; _n( 'Singular', 'Plural', number, value )`,
+ errors: [ { messageId: 'invalidType' } ],
+ },
+ {
+ code: `var value = ''; var number = ''; _nx( 'Singular', 'Plural', number, 'context', value )`,
+ errors: [ { messageId: 'invalidType' } ],
+ },
+ {
+ code: `__( 'Hello World', 'default' )`,
+ output: `__( 'Hello World' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ errors: [ { messageId: 'unnecessaryDefault' } ],
+ },
+ {
+ code: `__( 'default', 'default' )`,
+ output: `__( 'default' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ errors: [ { messageId: 'unnecessaryDefault' } ],
+ },
+ {
+ code: `_x( 'Hello World', 'context', 'default' )`,
+ output: `_x( 'Hello World', 'context' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ errors: [ { messageId: 'unnecessaryDefault' } ],
+ },
+ {
+ code: `var number = ''; _n( 'Singular', 'Plural', number, 'default' )`,
+ output: `var number = ''; _n( 'Singular', 'Plural', number )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ errors: [ { messageId: 'unnecessaryDefault' } ],
+ },
+ {
+ code: `var number = ''; _nx( 'Singular', 'Plural', number, 'context', 'default' )`,
+ output: `var number = ''; _nx( 'Singular', 'Plural', number, 'context' )`,
+ options: [ { allowedTextDomain: 'default' } ],
+ errors: [ { messageId: 'unnecessaryDefault' } ],
+ },
+ {
+ code: `i18n.__( 'Hello World' )`,
+ output: `i18n.__( 'Hello World', 'foo' )`,
+ options: [ { allowedTextDomain: 'foo' } ],
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `__( 'Hello World' )`,
+ output: `__( 'Hello World', 'foo' )`,
+ options: [ { allowedTextDomain: [ 'foo' ] } ],
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `__( 'Hello World' )`,
+ output: `__( 'Hello World' )`,
+ options: [ { allowedTextDomain: [ 'foo', 'bar' ] } ],
+ errors: [ { messageId: 'missing' } ],
+ },
+ ],
+} );
diff --git a/packages/eslint-plugin/rules/__tests__/i18n-translator-comments.js b/packages/eslint-plugin/rules/__tests__/i18n-translator-comments.js
new file mode 100644
index 00000000000000..df59a9bbe07083
--- /dev/null
+++ b/packages/eslint-plugin/rules/__tests__/i18n-translator-comments.js
@@ -0,0 +1,84 @@
+/**
+ * External dependencies
+ */
+import { RuleTester } from 'eslint';
+
+/**
+ * Internal dependencies
+ */
+import rule from '../i18n-translator-comments';
+
+const ruleTester = new RuleTester( {
+ parserOptions: {
+ ecmaVersion: 6,
+ },
+} );
+
+ruleTester.run( 'i18n-translator-comments', rule, {
+ valid: [
+ {
+ code: `
+// translators: %s: Color
+sprintf( __( 'Color: %s' ), color );`,
+ },
+ {
+ code: `
+sprintf(
+ // translators: %s: Address.
+ __( 'Address: %s' ),
+ address
+);`,
+ },
+ {
+ code: `
+// translators: %s: Color
+i18n.sprintf( i18n.__( 'Color: %s' ), color );`,
+ },
+ ],
+ invalid: [
+ {
+ code: `
+sprintf( __( 'Color: %s' ), color );`,
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `
+sprintf(
+ __( 'Address: %s' ),
+ address
+);`,
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `
+// translators: %s: Name
+var name = '';
+sprintf( __( 'Name: %s' ), name );`,
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `
+// translators: %s: Surname
+console.log(
+ sprintf( __( 'Surname: %s' ), name )
+);`,
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `
+// translators: %s: Preference
+console.log(
+ sprintf(
+ __( 'Preference: %s' ),
+ preference
+ )
+);`,
+ errors: [ { messageId: 'missing' } ],
+ },
+ {
+ code: `
+i18n.sprintf( i18n.__( 'Color: %s' ), color );`,
+ errors: [ { messageId: 'missing' } ],
+ },
+ ],
+} );
diff --git a/packages/eslint-plugin/rules/i18n-ellipsis.js b/packages/eslint-plugin/rules/i18n-ellipsis.js
new file mode 100644
index 00000000000000..8e1b5eabf9b5e5
--- /dev/null
+++ b/packages/eslint-plugin/rules/i18n-ellipsis.js
@@ -0,0 +1,98 @@
+/**
+ * Internal dependencies
+ */
+const {
+ TRANSLATION_FUNCTIONS,
+ getTextContentFromNode,
+ getTranslateFunctionName,
+ getTranslateFunctionArgs,
+} = require( '../utils' );
+
+const THREE_DOTS = '...';
+const ELLIPSIS = '…';
+
+function replaceThreeDotsWithEllipsis( string ) {
+ return string.replace( /\.\.\./g, ELLIPSIS );
+}
+
+// see eslint-plugin-wpcalypso.
+function makeFixerFunction( arg ) {
+ return ( fixer ) => {
+ switch ( arg.type ) {
+ case 'TemplateLiteral':
+ return arg.quasis.reduce( ( fixes, quasi ) => {
+ if (
+ 'TemplateElement' === quasi.type &&
+ quasi.value.raw.includes( THREE_DOTS )
+ ) {
+ fixes.push(
+ fixer.replaceTextRange(
+ [ quasi.start, quasi.end ],
+ replaceThreeDotsWithEllipsis( quasi.value.raw )
+ )
+ );
+ }
+ return fixes;
+ }, [] );
+
+ case 'Literal':
+ return [
+ fixer.replaceText(
+ arg,
+ replaceThreeDotsWithEllipsis( arg.raw )
+ ),
+ ];
+
+ case 'BinaryExpression':
+ return [
+ ...makeFixerFunction( arg.left )( fixer ),
+ ...makeFixerFunction( arg.right )( fixer ),
+ ];
+ }
+ };
+}
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ schema: [],
+ messages: {
+ foundThreeDots: 'Use ellipsis character (…) in place of three dots',
+ },
+ fixable: 'code',
+ },
+ create( context ) {
+ return {
+ CallExpression( node ) {
+ const { callee, arguments: args } = node;
+
+ const functionName = getTranslateFunctionName( callee );
+
+ if ( ! TRANSLATION_FUNCTIONS.has( functionName ) ) {
+ return;
+ }
+
+ const candidates = getTranslateFunctionArgs(
+ functionName,
+ args
+ );
+
+ for ( const arg of candidates ) {
+ const argumentString = getTextContentFromNode( arg );
+ if (
+ ! argumentString ||
+ ! argumentString.includes( THREE_DOTS )
+ ) {
+ continue;
+ }
+
+ context.report( {
+ node,
+ messageId: 'foundThreeDots',
+ fix: makeFixerFunction( arg ),
+ } );
+ }
+ },
+ };
+ },
+};
diff --git a/packages/eslint-plugin/rules/i18n-no-collapsible-whitespace.js b/packages/eslint-plugin/rules/i18n-no-collapsible-whitespace.js
new file mode 100644
index 00000000000000..1d34472bc7b4a7
--- /dev/null
+++ b/packages/eslint-plugin/rules/i18n-no-collapsible-whitespace.js
@@ -0,0 +1,74 @@
+/**
+ * Internal dependencies
+ */
+const {
+ TRANSLATION_FUNCTIONS,
+ getTextContentFromNode,
+ getTranslateFunctionName,
+ getTranslateFunctionArgs,
+} = require( '../utils' );
+
+const PROBLEMS_BY_CHAR_CODE = {
+ 9: '\\t',
+ 10: '\\n',
+ 13: '\\r',
+ 32: 'consecutive spaces',
+};
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ schema: [],
+ messages: {
+ noCollapsibleWhitespace:
+ 'Translations should not contain collapsible whitespace{{problem}}',
+ },
+ },
+ create( context ) {
+ return {
+ CallExpression( node ) {
+ const { callee, arguments: args } = node;
+
+ const functionName = getTranslateFunctionName( callee );
+
+ if ( ! TRANSLATION_FUNCTIONS.has( functionName ) ) {
+ return;
+ }
+
+ const candidates = getTranslateFunctionArgs(
+ functionName,
+ args
+ );
+
+ for ( const arg of candidates ) {
+ const argumentString = getTextContentFromNode( arg );
+ if ( ! argumentString ) {
+ continue;
+ }
+
+ const collapsibleWhitespace = argumentString.match(
+ /(\n|\t|\r| {2})/
+ );
+
+ if ( ! collapsibleWhitespace ) {
+ continue;
+ }
+
+ const problem =
+ PROBLEMS_BY_CHAR_CODE[
+ collapsibleWhitespace[ 0 ].charCodeAt( 0 )
+ ];
+ const problemString = problem ? ` (${ problem })` : '';
+
+ context.report( {
+ node,
+ messageId: 'noCollapsibleWhitespace',
+ data: {
+ problem: problemString,
+ },
+ } );
+ }
+ },
+ };
+ },
+};
diff --git a/packages/eslint-plugin/rules/i18n-no-placeholders-only.js b/packages/eslint-plugin/rules/i18n-no-placeholders-only.js
new file mode 100644
index 00000000000000..796e42c0b0dc16
--- /dev/null
+++ b/packages/eslint-plugin/rules/i18n-no-placeholders-only.js
@@ -0,0 +1,60 @@
+/**
+ * Internal dependencies
+ */
+const {
+ TRANSLATION_FUNCTIONS,
+ REGEXP_PLACEHOLDER,
+ getTextContentFromNode,
+ getTranslateFunctionName,
+ getTranslateFunctionArgs,
+} = require( '../utils' );
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ schema: [],
+ messages: {
+ noPlaceholdersOnly:
+ 'Translatable strings should not contain nothing but placeholders',
+ },
+ },
+ create( context ) {
+ return {
+ CallExpression( node ) {
+ const { callee, arguments: args } = node;
+
+ const functionName = getTranslateFunctionName( callee );
+
+ if ( ! TRANSLATION_FUNCTIONS.has( functionName ) ) {
+ return;
+ }
+
+ const candidates = getTranslateFunctionArgs(
+ functionName,
+ args
+ );
+
+ for ( const arg of candidates ) {
+ const argumentString = getTextContentFromNode( arg );
+ if ( ! argumentString ) {
+ continue;
+ }
+
+ const modifiedString = argumentString.replace(
+ REGEXP_PLACEHOLDER,
+ ''
+ );
+
+ if ( modifiedString.length > 0 ) {
+ continue;
+ }
+
+ context.report( {
+ node,
+ messageId: 'noPlaceholdersOnly',
+ } );
+ }
+ },
+ };
+ },
+};
diff --git a/packages/eslint-plugin/rules/i18n-no-variables.js b/packages/eslint-plugin/rules/i18n-no-variables.js
new file mode 100644
index 00000000000000..c942ba440a7676
--- /dev/null
+++ b/packages/eslint-plugin/rules/i18n-no-variables.js
@@ -0,0 +1,66 @@
+/**
+ * Internal dependencies
+ */
+const {
+ TRANSLATION_FUNCTIONS,
+ getTranslateFunctionName,
+ getTranslateFunctionArgs,
+} = require( '../utils' );
+
+function isAcceptableLiteralNode( node ) {
+ if ( 'BinaryExpression' === node.type ) {
+ return (
+ '+' === node.operator &&
+ isAcceptableLiteralNode( node.left ) &&
+ isAcceptableLiteralNode( node.right )
+ );
+ }
+
+ if ( 'TemplateLiteral' === node.type ) {
+ // Backticks are fine, but if there's any interpolation in it,
+ // that's a problem
+ return node.expressions.length === 0;
+ }
+
+ return 'Literal' === node.type;
+}
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ schema: [],
+ messages: {
+ invalidArgument:
+ 'Translate function arguments must be string literals.',
+ },
+ },
+ create( context ) {
+ return {
+ CallExpression( node ) {
+ const { callee, arguments: args } = node;
+
+ const functionName = getTranslateFunctionName( callee );
+
+ if ( ! TRANSLATION_FUNCTIONS.has( functionName ) ) {
+ return;
+ }
+
+ const candidates = getTranslateFunctionArgs(
+ functionName,
+ args
+ );
+
+ for ( const arg of candidates ) {
+ if ( isAcceptableLiteralNode( arg ) ) {
+ continue;
+ }
+
+ context.report( {
+ node,
+ messageId: 'invalidArgument',
+ } );
+ }
+ },
+ };
+ },
+};
diff --git a/packages/eslint-plugin/rules/i18n-text-domain.js b/packages/eslint-plugin/rules/i18n-text-domain.js
new file mode 100644
index 00000000000000..94b0619372d547
--- /dev/null
+++ b/packages/eslint-plugin/rules/i18n-text-domain.js
@@ -0,0 +1,158 @@
+/**
+ * Internal dependencies
+ */
+const {
+ TRANSLATION_FUNCTIONS,
+ getTranslateFunctionName,
+} = require( '../utils' );
+
+/**
+ * Returns the text domain passed to the given translation function.
+ *
+ * @param {string} functionName Translation function name.
+ * @param {Array} args Function arguments.
+ * @return {undefined|*} Text domain argument.
+ */
+function getTextDomain( functionName, args ) {
+ switch ( functionName ) {
+ case '__':
+ return args[ 1 ];
+ case '_x':
+ return args[ 2 ];
+ case '_n':
+ return args[ 3 ];
+ case '_nx':
+ return args[ 4 ];
+ default:
+ return undefined;
+ }
+}
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ // Supports a single string as the majority use case,
+ // but also an array of text domains.
+ allowedTextDomain: {
+ anyOf: [
+ {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ uniqueItems: true,
+ },
+ {
+ type: 'string',
+ default: 'default',
+ },
+ ],
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ invalidValue: "Invalid text domain '{{ textDomain }}'",
+ invalidType: 'Text domain is not a string literal',
+ unnecessaryDefault: 'Unnecessary default text domain',
+ missing: 'Missing text domain',
+ useAllowedValue:
+ 'Use one of the whitelisted text domains: {{ textDomains }}',
+ },
+ fixable: 'code',
+ },
+ create( context ) {
+ const options = context.options[ 0 ] || {};
+ const { allowedTextDomain = 'default' } = options;
+ const allowedTextDomains = Array.isArray( allowedTextDomain )
+ ? allowedTextDomain
+ : [ allowedTextDomain ];
+ const canFixTextDomain = allowedTextDomains.length === 1;
+ const allowDefault = allowedTextDomains.includes( 'default' );
+
+ return {
+ CallExpression( node ) {
+ const { callee, arguments: args } = node;
+
+ const functionName = getTranslateFunctionName( callee );
+
+ if ( ! TRANSLATION_FUNCTIONS.has( functionName ) ) {
+ return;
+ }
+
+ const textDomain = getTextDomain( functionName, args );
+
+ if ( textDomain === undefined ) {
+ if ( ! allowDefault ) {
+ const addMissingTextDomain = ( fixer ) => {
+ const lastArg = args[ args.length - 1 ];
+ return fixer.insertTextAfter(
+ lastArg,
+ `, '${ allowedTextDomains[ 0 ] }'`
+ );
+ };
+
+ context.report( {
+ node,
+ messageId: 'missing',
+ fix: canFixTextDomain ? addMissingTextDomain : null,
+ } );
+ }
+ return;
+ }
+
+ const { type, value, range } = textDomain;
+
+ if ( type !== 'Literal' ) {
+ context.report( {
+ node,
+ messageId: 'invalidType',
+ } );
+ return;
+ }
+
+ if ( 'default' === value && allowDefault ) {
+ const removeDefaultTextDomain = ( fixer ) => {
+ const previousArgIndex = args.indexOf( textDomain ) - 1;
+ const previousArg = args[ previousArgIndex ];
+ return fixer.removeRange( [
+ previousArg.range[ 1 ],
+ range[ 1 ],
+ ] );
+ };
+
+ context.report( {
+ node,
+ messageId: 'unnecessaryDefault',
+ fix: removeDefaultTextDomain,
+ } );
+ return;
+ }
+
+ if ( ! allowedTextDomains.includes( value ) ) {
+ const replaceTextDomain = ( fixer ) => {
+ return fixer.replaceTextRange(
+ // account for quotes.
+ [ range[ 0 ] + 1, range[ 1 ] - 1 ],
+ allowedTextDomains[ 0 ]
+ );
+ };
+
+ context.report( {
+ node,
+ messageId: 'invalidValue',
+ data: {
+ textDomain: value,
+ },
+ fix: canFixTextDomain ? replaceTextDomain : null,
+ } );
+ }
+ },
+ };
+ },
+};
diff --git a/packages/eslint-plugin/rules/i18n-translator-comments.js b/packages/eslint-plugin/rules/i18n-translator-comments.js
new file mode 100644
index 00000000000000..8b01ba009ffeda
--- /dev/null
+++ b/packages/eslint-plugin/rules/i18n-translator-comments.js
@@ -0,0 +1,112 @@
+/**
+ * Internal dependencies
+ */
+const {
+ TRANSLATION_FUNCTIONS,
+ REGEXP_PLACEHOLDER,
+ getTranslateFunctionName,
+ getTranslateFunctionArgs,
+ getTextContentFromNode,
+} = require( '../utils' );
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ messages: {
+ missing:
+ 'Translation function with placeholders is missing preceding translator comment',
+ },
+ },
+ create( context ) {
+ return {
+ CallExpression( node ) {
+ const {
+ callee,
+ loc: {
+ start: { line: currentLine },
+ },
+ parent,
+ arguments: args,
+ } = node;
+
+ const functionName = getTranslateFunctionName( callee );
+
+ if ( ! TRANSLATION_FUNCTIONS.has( functionName ) ) {
+ return;
+ }
+
+ const candidates = getTranslateFunctionArgs(
+ functionName,
+ args
+ ).map( getTextContentFromNode );
+
+ if ( candidates.filter( Boolean ).length === 0 ) {
+ return;
+ }
+
+ const hasPlaceholders = candidates.some( ( candidate ) =>
+ REGEXP_PLACEHOLDER.test( candidate )
+ );
+ // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test#Using_test()_on_a_regex_with_the_global_flag.
+ REGEXP_PLACEHOLDER.lastIndex = 0;
+
+ if ( ! hasPlaceholders ) {
+ return;
+ }
+
+ const comments = context.getCommentsBefore( node ).slice();
+
+ let parentNode = parent;
+
+ /**
+ * Loop through all parent nodes and get their preceding comments as well.
+ *
+ * This way we can gather comments that are not directly preceding the translation
+ * function call, but are just on the line above it. This case is commonly supported
+ * by string extraction tools like WP-CLI's i18n command.
+ */
+ while (
+ parentNode &&
+ parentNode.type !== 'Program' &&
+ Math.abs( parentNode.loc.start.line - currentLine ) <= 1
+ ) {
+ comments.push( ...context.getCommentsBefore( parentNode ) );
+ parentNode = parentNode.parent;
+ }
+
+ for ( const comment of comments ) {
+ const {
+ value: commentText,
+ loc: {
+ start: { line: commentLine },
+ },
+ } = comment;
+
+ /*
+ Skip cases like this:
+
+ // translators: %s: Preference
+ console.log(
+ sprintf(
+ __( 'Preference: %s' ),
+ preference
+ )
+ );
+ */
+ if ( Math.abs( commentLine - currentLine ) > 1 ) {
+ break;
+ }
+
+ if ( /translators:\s*\S+/i.test( commentText ) ) {
+ return;
+ }
+ }
+
+ context.report( {
+ node,
+ messageId: 'missing',
+ } );
+ },
+ };
+ },
+};
diff --git a/packages/eslint-plugin/rules/valid-sprintf.js b/packages/eslint-plugin/rules/valid-sprintf.js
index 73101d12b7d676..b37cfa65f0a340 100644
--- a/packages/eslint-plugin/rules/valid-sprintf.js
+++ b/packages/eslint-plugin/rules/valid-sprintf.js
@@ -1,41 +1,11 @@
/**
- * Regular expression matching the presence of a printf format string
- * placeholder. This naive pattern which does not validate the format.
- *
- * @type {RegExp}
+ * Internal dependencies
*/
-const REGEXP_PLACEHOLDER = /%[^%]/g;
-
-/**
- * Given a function name and array of argument Node values, returns all
- * possible string results from the corresponding translate function, or
- * undefined if the function is not a translate function.
- *
- * @param {string} functionName Function name.
- * @param {espree.Node[]} args Espree argument Node objects.
- *
- * @return {?Array} All possible translate function string results.
- */
-function getTranslateStrings( functionName, args ) {
- switch ( functionName ) {
- case '__':
- case '_x':
- args = args.slice( 0, 1 );
- break;
-
- case '_n':
- case '_nx':
- args = args.slice( 0, 2 );
- break;
-
- default:
- return;
- }
-
- return args
- .filter( ( arg ) => arg.type === 'Literal' )
- .map( ( arg ) => arg.value );
-}
+const {
+ REGEXP_PLACEHOLDER,
+ getTranslateFunctionArgs,
+ getTextContentFromNode,
+} = require( '../utils' );
module.exports = {
meta: {
@@ -79,15 +49,16 @@ module.exports = {
case 'CallExpression':
// All possible options (arguments) from a translate
// function must be valid.
- candidates = getTranslateStrings(
+ candidates = getTranslateFunctionArgs(
args[ 0 ].callee.name,
- args[ 0 ].arguments
- );
+ args[ 0 ].arguments,
+ false
+ ).map( getTextContentFromNode );
// An unknown function call may produce a valid string
// value. Ideally its result is verified, but this is
// not straight-forward to implement. Thus, bail.
- if ( candidates === undefined ) {
+ if ( candidates.filter( Boolean ).length === 0 ) {
return;
}
diff --git a/packages/eslint-plugin/utils/constants.js b/packages/eslint-plugin/utils/constants.js
new file mode 100644
index 00000000000000..5ef2980ed62553
--- /dev/null
+++ b/packages/eslint-plugin/utils/constants.js
@@ -0,0 +1,19 @@
+/**
+ * List of translation functions exposed by the `@wordpress/i18n` package.
+ *
+ * @type {Set} Translation functions.
+ */
+const TRANSLATION_FUNCTIONS = new Set( [ '__', '_x', '_n', '_nx' ] );
+
+/**
+ * Regular expression matching the presence of a printf format string
+ * placeholder. This naive pattern which does not validate the format.
+ *
+ * @type {RegExp}
+ */
+const REGEXP_PLACEHOLDER = /%[^%]/g;
+
+module.exports = {
+ TRANSLATION_FUNCTIONS,
+ REGEXP_PLACEHOLDER,
+};
diff --git a/packages/eslint-plugin/utils/get-text-content-from-node.js b/packages/eslint-plugin/utils/get-text-content-from-node.js
new file mode 100644
index 00000000000000..672f4f1aee7d4f
--- /dev/null
+++ b/packages/eslint-plugin/utils/get-text-content-from-node.js
@@ -0,0 +1,34 @@
+/**
+ * Returns the actual text content from an argument passed to a translation function.
+ *
+ * @see eslint-plugin-wpcalypso
+ *
+ * @param {Object} node A Literal, TemplateLiteral or BinaryExpression (+) node
+ * @return {string|boolean} The concatenated string or false.
+ */
+function getTextContentFromNode( node ) {
+ if ( 'Literal' === node.type ) {
+ return node.value;
+ }
+
+ if ( 'BinaryExpression' === node.type && '+' === node.operator ) {
+ const left = getTextContentFromNode( node.left );
+ const right = getTextContentFromNode( node.right );
+
+ if ( left === false || right === false ) {
+ return false;
+ }
+
+ return left + right;
+ }
+
+ if ( node.type === 'TemplateLiteral' ) {
+ return node.quasis.map( ( quasis ) => quasis.value.raw ).join( '' );
+ }
+
+ return false;
+}
+
+module.exports = {
+ getTextContentFromNode,
+};
diff --git a/packages/eslint-plugin/utils/get-translate-function-args.js b/packages/eslint-plugin/utils/get-translate-function-args.js
new file mode 100644
index 00000000000000..2ae5a611bb1b80
--- /dev/null
+++ b/packages/eslint-plugin/utils/get-translate-function-args.js
@@ -0,0 +1,40 @@
+/**
+ * Given a function name and array of argument Node values,
+ * returns all arguments except for text domain and number arguments.
+ *
+ * @param {string} functionName Function name.
+ * @param {espree.Node[]} args Espree argument Node objects.
+ * @param {boolean} includeContext Whether to include the context argument or not.
+ *
+ * @return {espree.Node[]} Translate function arguments.
+ */
+function getTranslateFunctionArgs( functionName, args, includeContext = true ) {
+ switch ( functionName ) {
+ case '__':
+ // __( text, domain ) -> [ text ].
+ return args.slice( 0, 1 );
+
+ case '_x':
+ // _x( text, context, domain ) -> [ text, context ].
+ return includeContext ? args.slice( 0, 2 ) : args.slice( 0, 1 );
+
+ case '_n':
+ // _n( single, plural, number, domain ) -> [ single, plural ].
+ return args.slice( 0, 2 );
+
+ case '_nx':
+ // _nx( single, plural, number, context, domain ) -> [ single, plural, context ].
+ const result = args.slice( 0, 2 );
+ if ( includeContext ) {
+ result.push( args[ 3 ] );
+ }
+ return result;
+
+ default:
+ return [];
+ }
+}
+
+module.exports = {
+ getTranslateFunctionArgs,
+};
diff --git a/packages/eslint-plugin/utils/get-translate-function-name.js b/packages/eslint-plugin/utils/get-translate-function-name.js
new file mode 100644
index 00000000000000..f62143572c62e6
--- /dev/null
+++ b/packages/eslint-plugin/utils/get-translate-function-name.js
@@ -0,0 +1,17 @@
+/**
+ * Get the actual translation function name from a CallExpression callee.
+ *
+ * Returns the "__" part from __ or i18n.__.
+ *
+ * @param {Object} callee
+ * @return {string} Function name.
+ */
+function getTranslateFunctionName( callee ) {
+ return callee.property && callee.property.name
+ ? callee.property.name
+ : callee.name;
+}
+
+module.exports = {
+ getTranslateFunctionName,
+};
diff --git a/packages/eslint-plugin/utils/index.js b/packages/eslint-plugin/utils/index.js
new file mode 100644
index 00000000000000..4cb2f792f648de
--- /dev/null
+++ b/packages/eslint-plugin/utils/index.js
@@ -0,0 +1,12 @@
+const { TRANSLATION_FUNCTIONS, REGEXP_PLACEHOLDER } = require( './constants' );
+const { getTranslateFunctionArgs } = require( './get-translate-function-args' );
+const { getTextContentFromNode } = require( './get-text-content-from-node' );
+const { getTranslateFunctionName } = require( './get-translate-function-name' );
+
+module.exports = {
+ TRANSLATION_FUNCTIONS,
+ REGEXP_PLACEHOLDER,
+ getTranslateFunctionArgs,
+ getTextContentFromNode,
+ getTranslateFunctionName,
+};
diff --git a/packages/i18n/src/test/create-i18n.js b/packages/i18n/src/test/create-i18n.js
index 75e05c1f434a74..ef21d468a33721 100644
--- a/packages/i18n/src/test/create-i18n.js
+++ b/packages/i18n/src/test/create-i18n.js
@@ -1,3 +1,5 @@
+/* eslint-disable @wordpress/i18n-text-domain, @wordpress/i18n-translator-comments */
+
/**
* Internal dependencies
*/
@@ -188,3 +190,5 @@ describe( 'createI18n', () => {
} );
} );
} );
+
+/* eslint-enable @wordpress/i18n-text-domain, @wordpress/i18n-translator-comments */
diff --git a/packages/server-side-render/src/server-side-render.js b/packages/server-side-render/src/server-side-render.js
index 8e2f93446095d4..f428adf8f4495b 100644
--- a/packages/server-side-render/src/server-side-render.js
+++ b/packages/server-side-render/src/server-side-render.js
@@ -131,8 +131,8 @@ ServerSideRender.defaultProps = {
),
ErrorResponsePlaceholder: ( { response, className } ) => {
- // translators: %s: error message describing the problem
const errorMessage = sprintf(
+ // translators: %s: error message describing the problem
__( 'Error loading block: %s' ),
response.errorMsg
);