diff --git a/.eslintrc.js b/.eslintrc.js index 9f7da03674a7df..391ad41f26ae24 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -42,6 +42,12 @@ module.exports = { '@wordpress/dependency-group': 'error', '@wordpress/gutenberg-phase': 'error', '@wordpress/react-no-unsafe-timeout': 'error', + '@wordpress/i18n-text-domain': [ + 'error', + { + allowedTextDomain: 'default', + }, + ], 'no-restricted-syntax': [ 'error', // NOTE: We can't include the forward slash in our regex or @@ -67,29 +73,6 @@ module.exports = { message: 'Deprecated functions must be removed before releasing this version.', }, - { - 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.', - }, - { - selector: - 'CallExpression[callee.name=/^(__|_x|_n|_nx)$/] Literal[value=/\\.{3}/]', - message: 'Use ellipsis character (…) in place of three dots', - }, { selector: 'ImportDeclaration[source.value="redux"] Identifier.imported[name="combineReducers"]', diff --git a/package-lock.json b/package-lock.json index 277652195f2763..0bdd78a0438d98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20634,7 +20634,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", + "resolved": false, "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -20653,7 +20653,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "resolved": false, "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, diff --git a/packages/block-directory/src/components/block-ratings/index.js b/packages/block-directory/src/components/block-ratings/index.js index c55cace300028c..c04f6c0fd7c5cd 100644 --- a/packages/block-directory/src/components/block-ratings/index.js +++ b/packages/block-directory/src/components/block-ratings/index.js @@ -13,13 +13,11 @@ export const BlockRatings = ( { rating, ratingCount } ) => ( ({ ratingCount }) diff --git a/packages/block-directory/src/components/block-ratings/stars.js b/packages/block-directory/src/components/block-ratings/stars.js index 843fca4385ce2a..a2d13b0aacb51b 100644 --- a/packages/block-directory/src/components/block-ratings/stars.js +++ b/packages/block-directory/src/components/block-ratings/stars.js @@ -17,7 +17,13 @@ function Stars( { rating } ) { const emptyStarCount = 5 - ( fullStarCount + halfStarCount ); return ( -
+
{ times( fullStarCount, ( i ) => ( - { sprintf( __( 'Authored by %s' ), author ) } + { sprintf( + /* translators: %s: author name. */ + __( 'Authored by %s' ), + author + ) } { sprintf( + /* translators: 1: number of blocks. 2: average rating. */ _n( - 'This author has %d block, with an average rating of %d.', - 'This author has %d blocks, with an average rating of %d.', + 'This author has %1$d block, with an average rating of %2$d.', + 'This author has %1$d blocks, with an average rating of %2$d.', authorBlockCount ), authorBlockCount, diff --git a/packages/block-directory/src/components/downloadable-block-header/index.js b/packages/block-directory/src/components/downloadable-block-header/index.js index 78ab2962e60ce8..dc835ed3295399 100644 --- a/packages/block-directory/src/components/downloadable-block-header/index.js +++ b/packages/block-directory/src/components/downloadable-block-header/index.js @@ -20,10 +20,13 @@ function DownloadableBlockHeader( { return (
{ icon.match( /\.(jpeg|jpg|gif|png)(?:\?.*)?$/ ) !== null ? ( - // translators: %s: Name of the plugin e.g: "Akismet". { ) : ( diff --git a/packages/block-directory/src/components/downloadable-block-info/index.js b/packages/block-directory/src/components/downloadable-block-info/index.js index b6865a5c9197e9..4c65e639554a9d 100644 --- a/packages/block-directory/src/components/downloadable-block-info/index.js +++ b/packages/block-directory/src/components/downloadable-block-info/index.js @@ -19,6 +19,7 @@ function DownloadableBlockInfo( {
{ sprintf( + /* translators: %s: number of active installations. */ _n( '%d active installation', '%d active installations', diff --git a/packages/block-directory/src/components/downloadable-blocks-panel/index.js b/packages/block-directory/src/components/downloadable-blocks-panel/index.js index 29d7cbb29ecac0..08d346d6774a58 100644 --- a/packages/block-directory/src/components/downloadable-blocks-panel/index.js +++ b/packages/block-directory/src/components/downloadable-blocks-panel/index.js @@ -55,6 +55,7 @@ function DownloadableBlocksPanel( { } const resultsFoundMessage = sprintf( + /* translators: %s: number of available blocks. */ _n( 'No blocks found in your library. We did find %d block available for download.', 'No blocks found in your library. We did find %d blocks available for download.', diff --git a/packages/block-editor/src/components/block-mover/mover-description.js b/packages/block-editor/src/components/block-mover/mover-description.js index 88e67e5ff4a715..fbdc10cb256138 100644 --- a/packages/block-editor/src/components/block-mover/mover-description.js +++ b/packages/block-editor/src/components/block-mover/mover-description.js @@ -58,8 +58,8 @@ export function getBlockMoverDescription( } if ( isFirst && isLast ) { - // translators: %s: Type of block (i.e. Text, Image etc) return sprintf( + // translators: %s: Type of block (i.e. Text, Image etc) __( 'Block %s is the only block, and cannot be moved' ), type ); diff --git a/packages/block-editor/src/components/block-switcher/index.js b/packages/block-editor/src/components/block-switcher/index.js index e74cfc9ad94b76..95794f8f935731 100644 --- a/packages/block-editor/src/components/block-switcher/index.js +++ b/packages/block-editor/src/components/block-switcher/index.js @@ -114,6 +114,7 @@ export class BlockSwitcher extends Component { 1 === blocks.length ? __( 'Change block type or style' ) : sprintf( + /* translators: %s: number of blocks. */ _n( 'Change type of %d block', 'Change type of %d blocks', diff --git a/packages/block-editor/src/components/button-block-appender/index.js b/packages/block-editor/src/components/button-block-appender/index.js index 91de376e909117..4d4a50e6712d58 100644 --- a/packages/block-editor/src/components/button-block-appender/index.js +++ b/packages/block-editor/src/components/button-block-appender/index.js @@ -33,8 +33,8 @@ function ButtonBlockAppender( { } ) => { let label; if ( hasSingleBlockType ) { - // translators: %s: the name of the block when there is only one label = sprintf( + // translators: %s: the name of the block when there is only one _x( 'Add %s', 'directly add the only allowed block' ), blockTitle ); diff --git a/packages/block-editor/src/components/inserter/block-list.js b/packages/block-editor/src/components/inserter/block-list.js index c16ffe5c5ad388..acb1a68c55ee2a 100644 --- a/packages/block-editor/src/components/inserter/block-list.js +++ b/packages/block-editor/src/components/inserter/block-list.js @@ -315,6 +315,7 @@ function InserterBlockList( { ); const resultsFoundMessage = sprintf( + /* translators: %d: number of results. */ _n( '%d result found.', '%d results found.', resultCount ), resultCount ); diff --git a/packages/block-editor/src/components/inserter/index.js b/packages/block-editor/src/components/inserter/index.js index 5f03ae982b2cd1..6b292f4506d49a 100644 --- a/packages/block-editor/src/components/inserter/index.js +++ b/packages/block-editor/src/components/inserter/index.js @@ -29,8 +29,8 @@ const defaultRenderToggle = ( { } ) => { let label; if ( hasSingleBlockType ) { - // translators: %s: the name of the block when there is only one label = sprintf( + // translators: %s: the name of the block when there is only one _x( 'Add %s', 'directly add the only allowed block' ), blockTitle ); @@ -236,8 +236,8 @@ export default compose( [ ); if ( ! selectBlockOnInsert ) { - // translators: %s: the name of the block that has been added const message = sprintf( + // translators: %s: the name of the block that has been added __( '%s block added' ), allowedBlockType.title ); diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index c9177beb091d5e..2e7234f7809e31 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -444,7 +444,11 @@ function LinkControl( { const searchResultsLabelId = `block-editor-link-control-search-results-label-${ instanceId }`; const labelText = isInitialSuggestions ? __( 'Recently updated' ) - : sprintf( __( 'Search results for "%s"' ), inputValue ); + : sprintf( + /* translators: %s: search term. */ + __( 'Search results for "%s"' ), + inputValue + ); // VisuallyHidden rightly doesn't accept custom classNames // so we conditionally render it as a wrapper to visually hide the label diff --git a/packages/block-editor/src/components/link-control/search-create-button.js b/packages/block-editor/src/components/link-control/search-create-button.js index b9014a7c9c87e8..5f9cb9406eed9e 100644 --- a/packages/block-editor/src/components/link-control/search-create-button.js +++ b/packages/block-editor/src/components/link-control/search-create-button.js @@ -40,6 +40,7 @@ export const LinkControlSearchCreate = ( { { createInterpolateElement( sprintf( + /* translators: %s: search term. */ __( 'New page: %s' ), searchTerm ), diff --git a/packages/block-editor/src/components/media-upload-progress/index.native.js b/packages/block-editor/src/components/media-upload-progress/index.native.js index 63f53c97a1d3a3..0692077c8595e3 100644 --- a/packages/block-editor/src/components/media-upload-progress/index.native.js +++ b/packages/block-editor/src/components/media-upload-progress/index.native.js @@ -121,6 +121,7 @@ export class MediaUploadProgress extends React.Component { const { isUploadInProgress, isUploadFailed } = this.state; const showSpinner = this.state.isUploadInProgress; const progress = this.state.progress * 100; + // eslint-disable-next-line @wordpress/i18n-no-collapsible-whitespace const retryMessage = __( 'Failed to insert media.\nPlease tap for options.' ); diff --git a/packages/block-editor/src/components/multi-selection-inspector/index.js b/packages/block-editor/src/components/multi-selection-inspector/index.js index d501e1247d71ac..3109834295d559 100644 --- a/packages/block-editor/src/components/multi-selection-inspector/index.js +++ b/packages/block-editor/src/components/multi-selection-inspector/index.js @@ -27,15 +27,18 @@ function MultiSelectionInspector( { blocks } ) { />
- { /* translators: %d: number of blocks */ - sprintf( + { sprintf( + /* translators: %d: number of blocks */ _n( '%d block', '%d blocks', blocks.length ), blocks.length ) }
- { /* translators: %d: number of words */ - sprintf( _n( '%d word', '%d words', words ), words ) } + { sprintf( + /* translators: %d: number of words */ + _n( '%d word', '%d words', words ), + words + ) }
diff --git a/packages/block-editor/src/components/responsive-block-control/index.js b/packages/block-editor/src/components/responsive-block-control/index.js index 8a3d3eef08145e..53854980a1070d 100644 --- a/packages/block-editor/src/components/responsive-block-control/index.js +++ b/packages/block-editor/src/components/responsive-block-control/index.js @@ -26,9 +26,8 @@ function ResponsiveBlockControl( props ) { isResponsive = false, defaultLabel = { id: 'all', - label: __( - 'All' - ) /* translators: 'Label. Used to signify a layout property (eg: margin, padding) will apply uniformly to all screensizes.' */, + /* translators: 'Label. Used to signify a layout property (eg: margin, padding) will apply uniformly to all screensizes.' */ + label: __( 'All' ), }, viewports = [ { @@ -50,10 +49,13 @@ function ResponsiveBlockControl( props ) { return null; } - /* translators: 'Toggle control label. Should the property be the same across all screen sizes or unique per screen size.'. %s property value for the control (eg: margin, padding...etc) */ const toggleControlLabel = toggleLabel || - sprintf( __( 'Use the same %s on all screensizes.' ), property ); + sprintf( + /* translators: 'Toggle control label. Should the property be the same across all screen sizes or unique per screen size.'. %s property value for the control (eg: margin, padding...etc) */ + __( 'Use the same %s on all screensizes.' ), + property + ); /* translators: 'Help text for the responsive mode toggle control.' */ const toggleHelpText = __( diff --git a/packages/block-editor/src/components/responsive-block-control/label.js b/packages/block-editor/src/components/responsive-block-control/label.js index f1445c0872df70..b0bd0c180cb902 100644 --- a/packages/block-editor/src/components/responsive-block-control/label.js +++ b/packages/block-editor/src/components/responsive-block-control/label.js @@ -15,6 +15,7 @@ export default function ResponsiveBlockControlLabel( { const accessibleLabel = desc || sprintf( + /* translators: 1: property name. 2: viewport name. */ _x( 'Controls the %1$s property for %2$s viewports.', 'Text labelling a interface as controlling a given layout property (eg: margin) for a given screen size.' diff --git a/packages/block-editor/src/components/url-input/index.js b/packages/block-editor/src/components/url-input/index.js index 0be1523df6dd4c..7c2f403d28938e 100644 --- a/packages/block-editor/src/components/url-input/index.js +++ b/packages/block-editor/src/components/url-input/index.js @@ -178,6 +178,7 @@ class URLInput extends Component { if ( !! suggestions.length ) { this.props.debouncedSpeak( sprintf( + /* translators: %s: 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/block-editor/src/store/effects.js b/packages/block-editor/src/store/effects.js index b01e37b1e76dd8..5142020ffffa1e 100644 --- a/packages/block-editor/src/store/effects.js +++ b/packages/block-editor/src/store/effects.js @@ -223,9 +223,9 @@ export default { MULTI_SELECT: ( action, { getState } ) => { const blockCount = getSelectedBlockCount( getState() ); - /* translators: %s: number of selected blocks */ speak( sprintf( + /* translators: %s: number of selected blocks */ _n( '%s block selected.', '%s blocks selected.', blockCount ), blockCount ), diff --git a/packages/block-library/src/code/index.js b/packages/block-library/src/code/index.js index fe763312794a5c..ce07ea4e3e98ad 100644 --- a/packages/block-library/src/code/index.js +++ b/packages/block-library/src/code/index.js @@ -24,10 +24,12 @@ export const settings = { icon, example: { attributes: { + /* eslint-disable @wordpress/i18n-no-collapsible-whitespace */ // translators: Preserve \n markers for line breaks content: __( '// A "block" is the abstract term used\n// to describe units of markup that\n// when composed together, form the\n// content or layout of a page.\nregisterBlockType( name, settings );' ), + /* eslint-enable @wordpress/i18n-no-collapsible-whitespace */ }, }, supports: { diff --git a/packages/block-library/src/embed/embed-preview.js b/packages/block-library/src/embed/embed-preview.js index 419ef17c7bb115..0e70f84729acc6 100644 --- a/packages/block-library/src/embed/embed-preview.js +++ b/packages/block-library/src/embed/embed-preview.js @@ -73,8 +73,8 @@ class EmbedPreview extends Component { .splice( parsedHost.length - 2, parsedHost.length - 1 ) .join( '.' ); const cannotPreview = includes( HOSTS_NO_PREVIEWS, parsedHostBaseUrl ); - // translators: %s: host providing embed content e.g: www.youtube.com const iframeTitle = sprintf( + // translators: %s: host providing embed content e.g: www.youtube.com __( 'Embedded content from %s' ), parsedHostBaseUrl ); @@ -125,8 +125,8 @@ class EmbedPreview extends Component { { url }

- { /* translators: %s: host providing embed content e.g: www.youtube.com */ - sprintf( + { sprintf( + /* translators: %s: host providing embed content e.g: www.youtube.com */ __( "Embedded content from %s can't be previewed in the editor." ), diff --git a/packages/block-library/src/gallery/gallery.js b/packages/block-library/src/gallery/gallery.js index d0f0c03d9b3260..53c319d26c0594 100644 --- a/packages/block-library/src/gallery/gallery.js +++ b/packages/block-library/src/gallery/gallery.js @@ -51,8 +51,8 @@ export const Gallery = ( props ) => { >

    { images.map( ( img, index ) => { - /* translators: %1$d is the order number of the image, %2$d is the total number of images. */ const ariaLabel = sprintf( + /* translators: 1: the order number of the image. 2: the total number of images. */ __( 'image %1$d of %2$d in gallery' ), index + 1, images.length diff --git a/packages/block-library/src/gallery/gallery.native.js b/packages/block-library/src/gallery/gallery.native.js index 0a72cd8bbe42b9..70cd50e08e5e77 100644 --- a/packages/block-library/src/gallery/gallery.native.js +++ b/packages/block-library/src/gallery/gallery.native.js @@ -88,8 +88,8 @@ export const Gallery = ( props ) => { } > { images.map( ( img, index ) => { - /* translators: %1$d is the order number of the image, %2$d is the total number of images. */ const ariaLabel = sprintf( + /* translators: 1: the order number of the image. 2: the total number of images. */ __( 'image %1$d of %2$d in gallery' ), index + 1, images.length diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index a80dadeec55c46..30ddaefd89602e 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -541,6 +541,7 @@ export class ImageEdit extends Component { defaultedAlt = alt; } else if ( filename ) { defaultedAlt = sprintf( + /* translators: %s: file name */ __( 'This image has an empty alt attribute; its file name is %s' ), diff --git a/packages/block-library/src/missing/edit.js b/packages/block-library/src/missing/edit.js index b9caa890a5b950..3f1d39f11edc01 100644 --- a/packages/block-library/src/missing/edit.js +++ b/packages/block-library/src/missing/edit.js @@ -17,6 +17,7 @@ function MissingBlockWarning( { attributes, convertToHTML } ) { let messageHTML; if ( hasContent && hasHTMLBlock ) { messageHTML = sprintf( + /* translators: %s: block name */ __( 'Your site doesn’t include support for the "%s" block. You can leave this block intact, convert its content to a Custom HTML block, or remove it entirely.' ), @@ -29,6 +30,7 @@ function MissingBlockWarning( { attributes, convertToHTML } ) { ); } else { messageHTML = sprintf( + /* translators: %s: block name */ __( 'Your site doesn’t include support for the "%s" block. You can leave this block intact or remove it entirely.' ), diff --git a/packages/block-library/src/missing/edit.native.js b/packages/block-library/src/missing/edit.native.js index f77e29ce9c94a3..f4072ca42000db 100644 --- a/packages/block-library/src/missing/edit.native.js +++ b/packages/block-library/src/missing/edit.native.js @@ -75,11 +75,12 @@ export class UnsupportedBlockEdit extends Component { styles.infoSheetIconDark ); - // translators: %s: Name of the block const titleFormat = Platform.OS === 'android' - ? __( "'%s' isn't yet supported on WordPress for Android" ) - : __( "'%s' isn't yet supported on WordPress for iOS" ); + ? // translators: %s: Name of the block + __( "'%s' isn't yet supported on WordPress for Android" ) + : // translators: %s: Name of the block + __( "'%s' isn't yet supported on WordPress for iOS" ); const infoTitle = sprintf( titleFormat, title ); return ( diff --git a/packages/block-library/src/post-author/edit.js b/packages/block-library/src/post-author/edit.js index 9ca968c5679f70..05ab61f3ef92a7 100644 --- a/packages/block-library/src/post-author/edit.js +++ b/packages/block-library/src/post-author/edit.js @@ -13,7 +13,13 @@ function PostAuthorDisplay() { [ authorId ] ); return author ? ( -
    { sprintf( __( 'By %s' ), author.name ) }
    +
    + { sprintf( + /* translators: %s: author name. */ + __( 'By %s' ), + author.name + ) } +
    ) : null; } diff --git a/packages/block-library/src/preformatted/index.js b/packages/block-library/src/preformatted/index.js index 291f11b448a909..3fe52d58965739 100644 --- a/packages/block-library/src/preformatted/index.js +++ b/packages/block-library/src/preformatted/index.js @@ -24,10 +24,12 @@ export const settings = { icon, example: { attributes: { + /* eslint-disable @wordpress/i18n-no-collapsible-whitespace */ // translators: Sample content for the Preformatted block. Can be replaced with a more locale-adequate work. content: __( 'EXT. XANADU - FAINT DAWN - 1940 (MINIATURE)\nWindow, very small in the distance, illuminated.\nAll around this is an almost totally black screen. Now, as the camera moves slowly towards the window which is almost a postage stamp in the frame, other forms appear;' ), + /* eslint-enable @wordpress/i18n-no-collapsible-whitespace */ }, }, transforms, diff --git a/packages/block-library/src/social-link/edit.js b/packages/block-library/src/social-link/edit.js index 2dd4e747638c28..99ac2ac636e144 100644 --- a/packages/block-library/src/social-link/edit.js +++ b/packages/block-library/src/social-link/edit.js @@ -41,7 +41,11 @@ const SocialLinkEdit = ( { attributes, setAttributes, isSelected } ) => { 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 {
    { 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 );