fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,127 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../data-no-store-string-literals';
const ruleTester = new RuleTester( {
parserOptions: {
sourceType: 'module',
ecmaVersion: 6,
},
} );
const valid = [
// Callback functions.
`import { createRegistrySelector } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; createRegistrySelector(( select ) => { select(coreStore); });`,
`import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; useSelect(( select ) => { select(coreStore); });`,
`import { withSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; withSelect(( select ) => { select(coreStore); });`,
`import { withDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; withDispatch(( select ) => { select(coreStore); });`,
`import { withDispatch as withDispatchAlias } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; withDispatchAlias(( select ) => { select(coreStore); });`,
// Direct function calls.
`import { useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; useDispatch( coreStore );`,
`import { dispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; dispatch( coreStore );`,
`import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; useSelect( coreStore );`,
`import { select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; select( coreStore );`,
`import { resolveSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; resolveSelect( coreStore );`,
`import { resolveSelect as resolveSelectAlias } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; resolveSelectAlias( coreStore );`,
// Object property function calls.
`import { controls } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; controls.select( coreStore );`,
`import { controls } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; controls.dispatch( coreStore );`,
`import { controls } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; controls.resolveSelect( coreStore );`,
`import { controls as controlsAlias } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; controlsAlias.resolveSelect( coreStore );`,
];
const createSuggestionTestCase = ( code, output ) => ( {
code,
errors: [
{
suggestions: [
{
desc: 'Replace literal with store definition. Import store if necessary.',
output,
},
],
},
],
} );
const invalid = [
// Callback functions.
`import { createRegistrySelector } from '@wordpress/data'; createRegistrySelector(( select ) => { select( 'core' ); });`,
`import { useSelect } from '@wordpress/data'; useSelect(( select ) => { select( 'core' ); });`,
`import { withSelect } from '@wordpress/data'; withSelect(( select ) => { select( 'core' ); });`,
`import { withDispatch } from '@wordpress/data'; withDispatch(( select ) => { select( 'core' ); });`,
`import { withDispatch as withDispatchAlias } from '@wordpress/data'; withDispatchAlias(( select ) => { select( 'core' ); });`,
// Direct function calls.
`import { useDispatch } from '@wordpress/data'; useDispatch( 'core' );`,
`import { dispatch } from '@wordpress/data'; dispatch( 'core' );`,
`import { useSelect } from '@wordpress/data'; useSelect( 'core' );`,
`import { select } from '@wordpress/data'; select( 'core' );`,
`import { resolveSelect } from '@wordpress/data'; resolveSelect( 'core' );`,
`import { resolveSelect as resolveSelectAlias } from '@wordpress/data'; resolveSelectAlias( 'core' );`,
// Object property function calls.
`import { controls } from '@wordpress/data'; controls.select( 'core' );`,
`import { controls } from '@wordpress/data'; controls.dispatch( 'core' );`,
`import { controls } from '@wordpress/data'; controls.resolveSelect( 'core' );`,
`import { controls as controlsAlias } from '@wordpress/data'; controlsAlias.resolveSelect( 'core' );`,
// Direct function calls suggestions
// Replace core with coreStore and import coreStore.
createSuggestionTestCase(
`import { select } from '@wordpress/data'; select( 'core' );`,
`import { select } from '@wordpress/data';\nimport { store as coreStore } from '@wordpress/core-data'; select( coreStore );`
),
// Replace core with coreStore. A @wordpress/core-data already exists, so it should append the import to that one.
createSuggestionTestCase(
`import { select } from '@wordpress/data'; import { something } from '@wordpress/core-data'; select( 'core' );`,
`import { select } from '@wordpress/data'; import { something,store as coreStore } from '@wordpress/core-data'; select( coreStore );`
),
// Replace core with coreStore. A @wordpress/core-data already exists, so it should append the import to that one.
// This time there is a comma after the import.
createSuggestionTestCase(
`import { select } from '@wordpress/data'; import { something, } from '@wordpress/core-data'; select( 'core' );`,
`import { select } from '@wordpress/data'; import { something,store as coreStore, } from '@wordpress/core-data'; select( coreStore );`
),
// Replace core with coreStore. Store import already exists. It shouldn't modify the import, just replace the literal with the store definition.
createSuggestionTestCase(
`import { select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; select( 'core' );`,
`import { select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; select( coreStore );`
),
// Replace core with coreStore. There are internal and WordPress dependencies.
// It should append the import after the last WordPress dependency import.
createSuggestionTestCase(
`import { a } from './a'; import { select } from '@wordpress/data'; import { b } from './b'; select( 'core' );`,
`import { a } from './a'; import { select } from '@wordpress/data';\nimport { store as coreStore } from '@wordpress/core-data'; import { b } from './b'; select( coreStore );`
),
// Replace block-editor with blockEditorStore.
createSuggestionTestCase(
`import { select } from '@wordpress/data'; select( 'core/block-editor' );`,
`import { select } from '@wordpress/data';\nimport { store as blockEditorStore } from '@wordpress/block-editor'; select( blockEditorStore );`
),
// Replace notices with noticesStore.
createSuggestionTestCase(
`import { select } from '@wordpress/data'; select( 'core/notices' );`,
`import { select } from '@wordpress/data';\nimport { store as noticesStore } from '@wordpress/notices'; select( noticesStore );`
),
];
const errors = [
{
message: `Do not use string literals ( 'core' ) for accessing @wordpress/data stores. Pass the store definition instead`,
},
];
ruleTester.run( 'data-no-store-string-literals', rule, {
valid: valid.map( ( code ) => ( { code } ) ),
invalid: invalid.map( ( code ) =>
typeof code === 'string' ? { code, errors } : code
),
} );

View File

@@ -0,0 +1,135 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../dependency-group';
const ruleTester = new RuleTester( {
parserOptions: {
sourceType: 'module',
ecmaVersion: 6,
},
} );
ruleTester.run( 'dependency-group', rule, {
valid: [
{
code: `
/**
* External dependencies
*/
import { camelCase } from 'change-case';
import clsx from 'clsx';;
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import edit from './edit';`,
},
{
code: `
/**
* External dependencies
*/
const { camelCase } = require( 'change-case' );
const clsx = require( 'clsx' );
/**
* WordPress dependencies
*/
const { Component } = require( '@wordpress/element' );
/**
* Internal dependencies
*/
const edit = require( './edit' );`,
},
],
invalid: [
{
code: `
import { camelCase } from 'change-case';
import clsx from 'clsx';;
/*
* wordpress dependencies.
*/
import { Component } from '@wordpress/element';
import edit from './edit';`,
errors: [
{
message:
'Expected preceding "External dependencies" comment block',
},
{
message:
'Expected preceding "WordPress dependencies" comment block',
},
{
message:
'Expected preceding "Internal dependencies" comment block',
},
],
output: `
/**
* External dependencies
*/
import { camelCase } from 'change-case';
import clsx from 'clsx';;
/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import edit from './edit';`,
},
{
code: `
const { camelCase } = require( 'change-case' );
const clsx = require( 'clsx' );
/*
* wordpress dependencies.
*/
const { Component } = require( '@wordpress/element' );
const edit = require( './edit' );`,
errors: [
{
message:
'Expected preceding "External dependencies" comment block',
},
{
message:
'Expected preceding "WordPress dependencies" comment block',
},
{
message:
'Expected preceding "Internal dependencies" comment block',
},
],
output: `
/**
* External dependencies
*/
const { camelCase } = require( 'change-case' );
const clsx = require( 'clsx' );
/**
* WordPress dependencies
*/
const { Component } = require( '@wordpress/element' );
/**
* Internal dependencies
*/
const edit = require( './edit' );`,
},
],
} );

View File

@@ -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' } ],
},
],
} );

View File

@@ -0,0 +1,63 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../i18n-hyphenated-range';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
ruleTester.run( 'i18n-hyphenated-range', rule, {
valid: [
{
code: `__( 'Hyphenated-words are ok' )`,
},
{
code: `__( 'Hyphen - when used in this case - is ok' )`,
},
{
code: `__('en dash and em dash — are ok')`,
},
{
code: `__( 'en dash ranges work 199 or 2 98' )`,
},
{
code: `__( 'Negative numbers like -99 or -33 are ok' )`,
},
{
code: `__( 'Numbers with trailing hyphens are odd but ok like 99-' )`,
},
{
code: `__( '1 0 -1' )`,
},
],
invalid: [
{
code: `__( 'guess a number 1 - 10' )`,
output: `__( 'guess a number 1 10' )`,
errors: [ { messageId: 'foundHyphen' } ],
},
{
code: `__( 'No spaces: 00-99' )`,
output: `__( 'No spaces: 0099' )`,
errors: [ { messageId: 'foundHyphen' } ],
},
{
code: `__( 'From 0 - 2 many spaces in the range' )`,
output: `__( 'From 0 2 many spaces in the range' )`,
errors: [ { messageId: 'foundHyphen' } ],
},
{
code: `__( '1-2' + ' fixing multiple strings' )`,
output: `__( '12' + ' fixing multiple strings' )`,
errors: [ { messageId: 'foundHyphen' } ],
},
],
} );

View File

@@ -0,0 +1,60 @@
/**
* 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' } ],
},
],
} );

View File

@@ -0,0 +1,76 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../i18n-no-flanking-whitespace';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
ruleTester.run( 'i18n-no-flanking-whitespace', rule, {
valid: [
{
code: `__( 'Hello World…' )`,
},
{
code: '__( `A long string ` +\n `spread over ` +\n `multiple lines.` );',
},
{
code: `__( 'Not concerned about \t whitespace rules')`,
},
],
invalid: [
{
code: '__( "Double quoted string with a trailing newline\\n" );',
output: `__( 'Double quoted string with a trailing newline' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: `__( ' Leading whitespace.' );`,
output: `__( 'Leading whitespace.' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: `__( 'Trailing whitespace. ' );`,
output: `__( 'Trailing whitespace.' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: `__( ' Flanking whitespace. ' );`,
output: `__( 'Flanking whitespace.' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: '__( "\tLeading tab." );',
output: `__( 'Leading tab.' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: '__( "\u0009Leading unicode tab." );',
output: `__( 'Leading unicode tab.' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: `__( "Trailing tab.\t" );`,
output: `__( 'Trailing tab.' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: `__( "\tFlanking tab.\t" );`,
output: `__( 'Flanking tab.' );`,
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
{
code: '__( ` Template literals ` )',
errors: [ { messageId: 'noFlankingWhitespace' } ],
},
],
} );

View File

@@ -0,0 +1,50 @@
/**
* 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: `i18n.__( 'Hello %s' )`,
},
{
code: `__( '%d%%' )`,
},
],
invalid: [
{
code: `__( '%s' )`,
errors: [ { messageId: 'noPlaceholdersOnly' } ],
},
{
code: `__( '%s%s' )`,
errors: [ { messageId: 'noPlaceholdersOnly' } ],
},
{
code: `_x( '%1$s' )`,
errors: [ { messageId: 'noPlaceholdersOnly' } ],
},
{
code: `_n( '%s', '%s', number)`,
errors: [
{ messageId: 'noPlaceholdersOnly' },
{ messageId: 'noPlaceholdersOnly' },
],
},
],
} );

View File

@@ -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' } ],
},
],
} );

View File

@@ -0,0 +1,176 @@
/**
* 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: `_x( 'Hello World' )`,
},
{
code: `_x( 'Hello World', 'random' )`,
},
{
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' } ],
},
],
} );

View File

@@ -0,0 +1,101 @@
/**
* 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 );`,
},
{
code: `
sprintf(
/*
* translators: %s is the name of the city we couldn't locate.
* Replace the examples with cities related to your locale. Test that
* they match the expected location and have upcoming events before
* including them. If no cities related to your locale have events,
* then use cities related to your locale that would be recognizable
* to most users. Use only the city name itself, without any region
* or country. Use the endonym (native locale name) instead of the
* English name if possible.
*/
__( 'We couldnt locate %s. Please try another nearby city. For example: Kansas City; Springfield; Portland.' ),
templateParams.unknownCity
);`,
},
],
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' } ],
},
],
} );

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../is-gutenberg-plugin';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
const ERROR_MESSAGE =
'The `process.env.IS_GUTENBERG_PLUGIN` constant should only be used as the condition in an if statement or ternary expression.';
ruleTester.run( 'is-gutenberg-plugin', rule, {
valid: [
{ code: `if ( process.env.IS_GUTENBERG_PLUGIN ) {}` },
{ code: `if ( ! process.env.IS_GUTENBERG_PLUGIN ) {}` },
{
// Ensure whitespace is ok.
code: `if (
process.env.
IS_GUTENBERG_PLUGIN
) {}`,
},
{ code: `const test = process.env.IS_GUTENBERG_PLUGIN ? foo : bar` },
{ code: `const test = ! process.env.IS_GUTENBERG_PLUGIN ? bar : foo` },
{
// Ensure whitespace is ok.
code: `const test = ! process.env.
IS_GUTENBERG_PLUGIN ? bar : foo`,
},
],
invalid: [
{
code: `if ( IS_GUTENBERG_PLUGIN ) {}`,
errors: [ { message: ERROR_MESSAGE } ],
},
{
code: `if ( window[ 'IS_GUTENBERG_PLUGIN' ] ) {}`,
errors: [ { message: ERROR_MESSAGE } ],
},
{
code: `if ( true ) { process.env.IS_GUTENBERG_PLUGIN === 2 }`,
errors: [ { message: ERROR_MESSAGE } ],
},
{
code: `if ( process.env.IS_GUTENBERG_PLUGIN === 2 ) {}`,
errors: [ { message: ERROR_MESSAGE } ],
},
{
code: `if ( true || process.env.IS_GUTENBERG_PLUGIN === 2 ) {}`,
errors: [ { message: ERROR_MESSAGE } ],
},
{
code: `const isFeatureActive = process.env.IS_GUTENBERG_PLUGIN;`,
errors: [ { message: ERROR_MESSAGE } ],
},
],
} );

View File

@@ -0,0 +1,84 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../no-base-control-with-label-without-id';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
ecmaFeatures: {
jsx: true,
},
},
} );
ruleTester.run( 'no-base-control-with-label-without-id', rule, {
valid: [
{
code: `
<BaseControl
label="ok"
id="my-id"
/>`,
},
{
code: `<BaseControl />`,
},
{
code: `
<BaseControl
label="ok"
id="my-id"
>
<input id="my-id" />
</BaseControl>`,
},
{
code: `
<BaseControl>
<input id="my-id" />
</BaseControl>`,
},
{
code: `
<BaseControl
id="my-id"
>
<input id="my-id" />
</BaseControl>`,
},
],
invalid: [
{
code: `
<BaseControl
label="ok"
>
<input id="my-id" />
</BaseControl>`,
errors: [
{
message:
'When using BaseControl component if a label property is passed an id property should also be passed.',
},
],
},
{
code: `
<BaseControl
label="ok"
/>`,
errors: [
{
message:
'When using BaseControl component if a label property is passed an id property should also be passed.',
},
],
},
],
} );

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../no-global-active-element';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
ruleTester.run( 'no-global-active-element', rule, {
valid: [
{
code: 'ownerDocument.activeElement;',
},
],
invalid: [
{
code: 'document.activeElement;',
errors: [
{
message:
'Avoid accessing the active element with a global. Use the ownerDocument property on a node ref instead.',
},
],
},
],
} );

View File

@@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../no-global-get-selection';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
ruleTester.run( 'no-global-get-selection', rule, {
valid: [
{
code: 'defaultView.getSelection();',
},
],
invalid: [
{
code: 'window.getSelection();',
errors: [
{
message:
'Avoid accessing the selection with a global. Use the ownerDocument.defaultView property on a node ref instead.',
},
],
},
],
} );

View File

@@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../no-unguarded-get-range-at';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
ruleTester.run( 'no-unguarded-get-range-at', rule, {
valid: [
{
code: `const selection = defaultView.getSelection(); const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null;`,
},
],
invalid: [
{
code: `defaultView.getSelection().getRangeAt( 0 );`,
errors: [ { message: 'Avoid unguarded getRangeAt' } ],
},
],
} );

View File

@@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../no-unsafe-wp-apis';
const ruleTester = new RuleTester( {
parserOptions: {
sourceType: 'module',
ecmaVersion: 6,
},
} );
const options = [
{ '@wordpress/package': [ '__experimentalSafe', '__unstableSafe' ] },
];
ruleTester.run( 'no-unsafe-wp-apis', rule, {
valid: [
{ code: "import _ from 'change-case';", options },
{ code: "import { camelCase } from 'change-case';", options },
{ code: "import { __experimentalFoo } from 'change-case';", options },
{ code: "import { __unstableFoo } from 'change-case';", options },
{ code: "import _, { __unstableFoo } from 'change-case';", options },
{ code: "import * as _ from 'change-case';", options },
{ code: "import _ from './x';", options },
{ code: "import { camelCase } from './x';", options },
{ code: "import { __experimentalFoo } from './x';", options },
{ code: "import { __unstableFoo } from './x';", options },
{ code: "import _, { __unstableFoo } from './x';", options },
{ code: "import * as _ from './x';", options },
{ code: "import s from '@wordpress/package';", options },
{ code: "import { feature } from '@wordpress/package';", options },
{
code: "import { __experimentalSafe } from '@wordpress/package';",
options,
},
{
code: "import { __unstableSafe } from '@wordpress/package';",
options,
},
{
code: "import { feature, __experimentalSafe } from '@wordpress/package';",
options,
},
{
code: "import s, { __experimentalSafe } from '@wordpress/package';",
options,
},
{ code: "import * as s from '@wordpress/package';", options },
],
invalid: [
{
code: "import { __experimentalUnsafe } from '@wordpress/package';",
options,
errors: [
{
message: `Usage of \`__experimentalUnsafe\` from \`@wordpress/package\` is not allowed.
See https://developer.wordpress.org/block-editor/contributors/develop/coding-guidelines/#experimental-and-unstable-apis for details.`,
type: 'ImportSpecifier',
},
],
},
{
code: "import { __experimentalSafe } from '@wordpress/unsafe';",
options,
errors: [
{
message: `Usage of \`__experimentalSafe\` from \`@wordpress/unsafe\` is not allowed.
See https://developer.wordpress.org/block-editor/contributors/develop/coding-guidelines/#experimental-and-unstable-apis for details.`,
type: 'ImportSpecifier',
},
],
},
{
code: "import { feature, __experimentalSafe } from '@wordpress/unsafe';",
options,
errors: [
{
message: `Usage of \`__experimentalSafe\` from \`@wordpress/unsafe\` is not allowed.
See https://developer.wordpress.org/block-editor/contributors/develop/coding-guidelines/#experimental-and-unstable-apis for details.`,
type: 'ImportSpecifier',
},
],
},
{
code: "import s, { __experimentalUnsafe } from '@wordpress/package';",
options,
errors: [
{
message: `Usage of \`__experimentalUnsafe\` from \`@wordpress/package\` is not allowed.
See https://developer.wordpress.org/block-editor/contributors/develop/coding-guidelines/#experimental-and-unstable-apis for details.`,
type: 'ImportSpecifier',
},
],
},
{
code: "import { __unstableFeature } from '@wordpress/package';",
options,
errors: [
{
message: `Usage of \`__unstableFeature\` from \`@wordpress/package\` is not allowed.
See https://developer.wordpress.org/block-editor/contributors/develop/coding-guidelines/#experimental-and-unstable-apis for details.`,
type: 'ImportSpecifier',
},
],
},
],
} );

View File

@@ -0,0 +1,137 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../no-unused-vars-before-return';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
ecmaFeatures: {
jsx: true,
},
},
} );
ruleTester.run( 'no-unused-vars-before-return', rule, {
valid: [
{
code: `
function example( number ) {
if ( number > 10 ) {
return number + 1;
}
const foo = doSomeCostlyOperation();
return number + foo;
}`,
},
{
code: `
function example() {
const { foo, bar } = doSomeCostlyOperation();
if ( number > 10 ) {
return number + bar + 1;
}
return number + foo;
}`,
},
{
code: `
function example() {
const foo = doSomeCostlyOperation();
if ( number > 10 ) {
return number + 1;
}
return number + foo;
}`,
options: [ { excludePattern: '^do' } ],
},
{
code: `
function MyComponent() {
const Foo = getSomeComponent();
return <Foo />;
}`,
},
],
invalid: [
{
code: `
function example( number ) {
const foo = doSomeCostlyOperation();
if ( number > 10 ) {
return number + 1;
}
return number + foo;
}`,
errors: [
{
message:
'Variables should not be assigned until just prior its first reference. An early return statement may leave this variable unused.',
},
],
},
{
code: `
function example() {
const { foo } = doSomeCostlyOperation();
if ( number > 10 ) {
return number + 1;
}
return number + foo;
}`,
errors: [
{
message:
'Variables should not be assigned until just prior its first reference. An early return statement may leave this variable unused.',
},
],
},
{
code: `
function example() {
const foo = doSomeCostlyOperation();
if ( number > 10 ) {
return number + 1;
}
return number + foo;
}`,
options: [ { excludePattern: '^run' } ],
errors: [
{
message:
'Variables should not be assigned until just prior its first reference. An early return statement may leave this variable unused.',
},
],
},
{
code: `
function example() {
const foo = doSomeCostlyOperation();
const bar = anotherCostlyOperation( foo );
if ( number > 10 ) {
return number + 1;
}
return number + foo + bar;
}`,
options: [ { excludePattern: '^do' } ],
errors: [
{
message:
'Variables should not be assigned until just prior its first reference. An early return statement may leave this variable unused.',
},
],
},
],
} );

View File

@@ -0,0 +1,85 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../react-no-unsafe-timeout';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
ruleTester.run( 'react-no-unsafe-timeout', rule, {
valid: [
{
code: `function getNotComponent() { setTimeout(); }`,
},
{
code: `function MyComponent( props ) { const { setTimeout } = props; ( () => { setTimeout(); } )(); }`,
},
{
code: `function MyComponent( props ) { props.setTimeout(); }`,
},
{
code: `class MyNotComponent { doAction() { setTimeout(); } }`,
},
{
code: `class MyComponent extends wp.element.Component { componentDidMount() { const { setTimeout } = this.props; setTimeout(); } }`,
},
{
code: `class MyComponent extends Component { componentDidMount() { const { setTimeout } = this.props; setTimeout(); } }`,
},
{
code: `class MyComponent extends Component { componentDidMount() { this.props.setTimeout(); } }`,
},
{
code: `class MyComponent extends Component { componentDidMount() { this.timeoutId = setTimeout(); } }`,
},
{
code: `
function MyComponent() {
useEffect( () => {
const timeoutHandle = setTimeout( () => {} );
return () => clearTimeout( timeoutHandle );
}, [] );
return null;
}`,
},
],
invalid: [
{
code: `function MyComponent() { setTimeout(); }`,
errors: [
{
message:
'setTimeout in a component must be cancelled on unmount',
},
],
},
{
code: `class MyComponent extends Component { componentDidMount() { setTimeout(); } }`,
errors: [
{
message:
'setTimeout in a component must be cancelled on unmount',
},
],
},
{
code: `class MyComponent extends wp.element.Component { componentDidMount() { setTimeout(); } }`,
errors: [
{
message:
'setTimeout in a component must be cancelled on unmount',
},
],
},
],
} );

View File

@@ -0,0 +1,131 @@
/**
* External dependencies
*/
import { RuleTester } from 'eslint';
/**
* Internal dependencies
*/
import rule from '../valid-sprintf';
const ruleTester = new RuleTester( {
parserOptions: {
ecmaVersion: 6,
},
} );
ruleTester.run( 'valid-sprintf', rule, {
valid: [
{
code: `sprintf( '%s', 'substitute' )`,
},
{
code: `sprintf( '%1$d%%', 500 )`,
},
{
code: `sprintf( __( '%s' ), 'substitute' )`,
},
{
code: `sprintf( _x( '%s' ), 'substitute' )`,
},
{
code: `sprintf( _n( '%s', '%s' ), 'substitute' )`,
},
{
code: `sprintf( _nx( '%s', '%s' ), 'substitute' )`,
},
{
code: `var getValue = () => ''; sprintf( getValue(), 'substitute' )`,
},
{
code: `var value = ''; sprintf( value, 'substitute' )`,
},
{
code: `
sprintf(
/* translators: 1: number of blocks. 2: average rating. */
_n(
'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,
authorBlockRating
);`,
},
{
code: `i18n.sprintf( '%s', 'substitute' )`,
},
{
code: `i18n.sprintf( i18n.__( '%s' ), 'substitute' )`,
},
{
code: `sprintf( ...args )`,
},
{
code: `sprintf( '%1$s %2$s', 'foo', 'bar' )`,
},
{
code: `sprintf( '%(greeting)s', 'Hello' )`,
},
{
code: `sprintf( '%(greeting)s %(toWhom)s', 'Hello', 'World' )`,
},
],
invalid: [
{
code: `sprintf()`,
errors: [ { messageId: 'noFormatString' } ],
},
{
code: `sprintf( '%s' )`,
errors: [ { messageId: 'noPlaceholderArgs' } ],
},
{
code: `sprintf( 1, 'substitute' )`,
errors: [ { messageId: 'invalidFormatString' } ],
},
{
code: `sprintf( [], 'substitute' )`,
errors: [ { messageId: 'invalidFormatString' } ],
},
{
code: `sprintf( '%%', 'substitute' )`,
errors: [ { messageId: 'noPlaceholders' } ],
},
{
code: `sprintf( __( '%%' ), 'substitute' )`,
errors: [ { messageId: 'noPlaceholders' } ],
},
{
code: `sprintf( _n( '%s', '' ), 'substitute' )`,
errors: [ { messageId: 'placeholderMismatch' } ],
},
{
code: `sprintf( _n( '%s', '%s %s' ), 'substitute' )`,
errors: [ { messageId: 'placeholderMismatch' } ],
},
{
code: `
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.',
authorBlockCount
),
authorBlockCount,
authorBlockRating
);`,
errors: [ { messageId: 'noOrderedPlaceholders' } ],
},
{
code: `i18n.sprintf()`,
errors: [ { messageId: 'noFormatString' } ],
},
{
code: `i18n.sprintf( i18n.__( '%%' ), 'substitute' )`,
errors: [ { messageId: 'noPlaceholders' } ],
},
],
} );

View File

@@ -0,0 +1,249 @@
/**
* Converts store name to variable name.
* Removes dashes and uppercases the characters after dashes and appends `Store` at the end.
*
* @param {string} storeName
* @return {string} store name as variable name
*/
function storeNameToVariableNames( storeName ) {
return (
storeName
.split( '-' )
.map( ( value, index ) =>
index === 0
? value.toLowerCase()
: value[ 0 ].toUpperCase() + value.slice( 1 ).toLowerCase()
)
.join( '' ) + 'Store'
);
}
/**
* Returns last element of an array.
*
* @param {Array} array
* @return {*} last element of the array
*/
function arrayLast( array ) {
return array[ array.length - 1 ];
}
function getReferences( context, specifiers ) {
const variables = specifiers.reduce(
( acc, specifier ) =>
acc.concat( context.getDeclaredVariables( specifier ) ),
[]
);
const references = variables.reduce(
( acc, variable ) => acc.concat( variable.references ),
[]
);
return references;
}
function collectAllNodesFromCallbackFunctions( context, node ) {
const functionSpecifiers = node.specifiers.filter(
( specifier ) =>
specifier.imported &&
[
'createRegistrySelector',
'useSelect',
'withSelect',
'withDispatch',
].includes( specifier.imported.name )
);
const functionReferences = getReferences( context, functionSpecifiers );
const functionArgumentVariables = functionReferences.reduce(
( acc, { identifier: { parent } } ) =>
parent && parent.arguments && parent.arguments.length > 0
? acc.concat(
context.getDeclaredVariables( parent.arguments[ 0 ] )
)
: acc,
[]
);
const functionArgumentReferences = functionArgumentVariables.reduce(
( acc, variable ) => acc.concat( variable.references ),
[]
);
const possibleCallExpressionNodes = functionArgumentReferences
.filter( ( reference ) => reference.identifier.parent )
.map( ( reference ) => reference.identifier.parent );
return possibleCallExpressionNodes;
}
function collectAllNodesFromDirectFunctionCalls( context, node ) {
const specifiers = node.specifiers.filter(
( specifier ) =>
specifier.imported &&
[
'useDispatch',
'dispatch',
'useSelect',
'select',
'resolveSelect',
].includes( specifier.imported.name )
);
const references = getReferences( context, specifiers );
const possibleCallExpressionNodes = references
.filter( ( reference ) => reference.identifier.parent )
.map( ( reference ) => reference.identifier.parent );
return possibleCallExpressionNodes;
}
function collectAllNodesFromObjectPropertyFunctionCalls( context, node ) {
const specifiers = node.specifiers.filter(
( specifier ) =>
specifier.imported &&
[ 'controls' ].includes( specifier.imported.name )
);
const references = getReferences( context, specifiers );
const referencesWithPropertyCalls = references.filter(
( reference ) =>
reference.identifier.parent.property &&
[ 'select', 'resolveSelect', 'dispatch' ].includes(
reference.identifier.parent.property.name
)
);
const possibleCallExpressionNodes = referencesWithPropertyCalls
.filter(
( reference ) =>
reference.identifier.parent &&
reference.identifier.parent.parent
)
.map( ( reference ) => reference.identifier.parent.parent );
return possibleCallExpressionNodes;
}
function getSuggest( context, callNode ) {
return [
{
desc: 'Replace literal with store definition. Import store if necessary.',
fix: ( fixer ) => getFixes( fixer, context, callNode ),
},
];
}
function getFixes( fixer, context, callNode ) {
const storeName = callNode.arguments[ 0 ].value;
const storeDefinitions = {
core: {
import: '@wordpress/core-data',
variable: 'coreStore',
},
};
let storeDefinition = storeDefinitions[ storeName ];
if ( ! storeDefinition && storeName.startsWith( 'core/' ) ) {
const storeNameWithoutCore = storeName.substring( 5 );
storeDefinition = {
import: `@wordpress/${ storeNameWithoutCore }`,
variable: storeNameToVariableNames( storeNameWithoutCore ),
};
}
if ( ! storeDefinition ) {
return null;
}
const { variable: variableName, import: importName } = storeDefinition;
const fixes = [
fixer.replaceText( callNode.arguments[ 0 ], variableName ),
];
const imports = context
.getAncestors()[ 0 ]
.body.filter( ( node ) => node.type === 'ImportDeclaration' );
const packageImports = imports.filter(
( node ) => node.source.value === importName
);
const packageImport =
packageImports.length > 0 ? packageImports[ 0 ] : null;
if ( packageImport ) {
const alreadyHasStore = packageImport.specifiers.some(
( specifier ) => specifier.imported.name === 'store'
);
if ( ! alreadyHasStore ) {
const lastSpecifier = arrayLast( packageImport.specifiers );
fixes.push(
fixer.insertTextAfter(
lastSpecifier,
`,store as ${ variableName }`
)
);
}
} else {
const wpImports = imports.filter( ( node ) =>
node.source.value.startsWith( '@wordpress/' )
);
const lastImport =
wpImports.length > 0
? arrayLast( wpImports )
: arrayLast( imports );
fixes.push(
fixer.insertTextAfter(
lastImport,
`\nimport { store as ${ variableName } } from '${ importName }';`
)
);
}
return fixes;
}
module.exports = {
meta: {
type: 'problem',
hasSuggestions: true,
schema: [],
messages: {
doNotUseStringLiteral: `Do not use string literals ( '{{ argument }}' ) for accessing @wordpress/data stores. Pass the store definition instead`,
},
},
create( context ) {
return {
ImportDeclaration( node ) {
if ( node.source.value !== '@wordpress/data' ) {
return;
}
const callbackFunctionNodes =
collectAllNodesFromCallbackFunctions( context, node );
const directNodes = collectAllNodesFromDirectFunctionCalls(
context,
node
);
const objectPropertyCallNodes =
collectAllNodesFromObjectPropertyFunctionCalls(
context,
node
);
const allNodes = [
...callbackFunctionNodes,
...directNodes,
...objectPropertyCallNodes,
];
allNodes
.filter(
( callNode ) =>
callNode &&
callNode.type === 'CallExpression' &&
callNode.arguments.length > 0 &&
callNode.arguments[ 0 ].type === 'Literal'
)
.forEach( ( callNode ) => {
context.report( {
node: callNode.parent,
messageId: 'doNotUseStringLiteral',
data: { argument: callNode.arguments[ 0 ].value },
suggest: getSuggest( context, callNode ),
} );
} );
},
};
},
};

View File

@@ -0,0 +1,260 @@
/** @typedef {import('estree').Comment} Comment */
/** @typedef {import('estree').Node} Node */
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'Enforce dependencies docblocks formatting',
url: 'https://github.com/WordPress/gutenberg/blob/HEAD/packages/eslint-plugin/docs/rules/dependency-group.md',
},
schema: [],
fixable: 'code',
},
create( context ) {
const comments = context.getSourceCode().getAllComments();
/**
* Locality classification of an import, one of "External",
* "WordPress", "Internal".
*
* @typedef {string} WPPackageLocality
*/
/**
* Object describing a dependency block correction to be made.
*
* @typedef WPDependencyBlockCorrection
*
* @property {Comment} [comment] Comment node on which to replace value,
* if one can be salvaged.
* @property {string} value Expected comment node value.
*/
/**
* Given a desired locality, generates the expected comment node value
* property.
*
* @param {WPPackageLocality} locality Desired package locality.
*
* @return {string} Expected comment node value.
*/
function getCommentValue( locality ) {
return `*\n * ${ locality } dependencies\n `;
}
/**
* Given an import source string, returns the locality classification
* of the import sort.
*
* @param {string} source Import source string.
*
* @return {WPPackageLocality} Package locality.
*/
function getPackageLocality( source ) {
if ( source.startsWith( '.' ) ) {
return 'Internal';
} else if ( source.startsWith( '@wordpress/' ) ) {
return 'WordPress';
}
return 'External';
}
/**
* Returns true if the given comment node satisfies a desired locality,
* or false otherwise.
*
* @param {Comment} node Comment node to check.
* @param {WPPackageLocality} locality Desired package locality.
*
* @return {boolean} Whether comment node satisfies locality.
*/
function isLocalityDependencyBlock( node, locality ) {
const { type, value } = node;
if ( type !== 'Block' ) {
return false;
}
// Tolerances:
// - Normalize `/**` and `/*`
// - Case insensitive "Dependencies" vs. "dependencies"
// - Ending period
// - "Node" dependencies as an alias for External.
if ( locality === 'External' ) {
locality = '(External|Node)';
}
const pattern = new RegExp(
`^\\*?\\n \\* ${ locality } dependencies\\.?\\n $`,
'i'
);
return pattern.test( value );
}
/**
* Returns true if the given node occurs prior in code to a reference,
* or false otherwise.
*
* @param {Comment} node Node to test being before reference.
* @param {Node} reference Node against which to compare.
*
* @return {boolean} Whether node occurs before reference.
*/
function isBefore( node, reference ) {
if ( ! node.range || ! reference.range ) {
return false;
}
return node.range[ 0 ] < reference.range[ 0 ];
}
/**
* Tests source comments to determine whether a comment exists which
* satisfies the desired locality. If a match is found and requires no
* updates, the function returns undefined. Otherwise, it will return
* a WPDependencyBlockCorrection object describing a correction.
*
* @param {Node} node Node to test.
* @param {WPPackageLocality} locality Desired package locality.
*
* @return {WPDependencyBlockCorrection | undefined} Correction, if applicable.
*/
function getDependencyBlockCorrection( node, locality ) {
const value = getCommentValue( locality );
let comment;
for ( let i = 0; i < comments.length; i++ ) {
comment = comments[ i ];
if ( ! isBefore( comment, node ) ) {
// Exhausted options.
break;
}
if ( ! isLocalityDependencyBlock( comment, locality ) ) {
// Not usable (either not an block comment, or not one
// matching a tolerable pattern).
continue;
}
if ( comment.value === value ) {
// No change needed. (OK)
return;
}
// Found a comment needing correction.
return { comment, value };
}
return { value };
}
return {
/**
* @param {import('estree').Program} node Program node.
*/
Program( node ) {
/**
* The set of package localities which have been reported for
* the current program. Each locality is reported at most one
* time, since otherwise the fixer would insert a comment
* block for each individual import statement.
*
* @type {Set<WPPackageLocality>}
*/
const verified = new Set();
/**
* Nodes to check for violations associated with module import,
* an array of tuples of the node and its import source string.
*
* @type {Array<[Node,string]>}
*/
const candidates = [];
// Since we only care to enforce imports which occur at the
// top-level scope, match on Program and test its children,
// rather than matching the import nodes directly.
node.body.forEach( ( child ) => {
/** @type {string} */
let source;
switch ( child.type ) {
case 'ImportDeclaration':
source = /** @type {string} */ (
child.source.value
);
candidates.push( [ child, source ] );
break;
case 'VariableDeclaration':
child.declarations.forEach( ( declaration ) => {
const { init } = declaration;
if (
! init ||
init.type !== 'CallExpression' ||
/** @type {import('estree').CallExpression} */ (
init
).callee.type !== 'Identifier' ||
/** @type {import('estree').Identifier} */ (
init.callee
).name !== 'require'
) {
return;
}
const { arguments: args } = init;
if (
args.length === 1 &&
args[ 0 ].type === 'Literal' &&
typeof args[ 0 ].value === 'string'
) {
source = args[ 0 ].value;
candidates.push( [ child, source ] );
}
} );
}
} );
for ( const [ child, source ] of candidates ) {
const locality = getPackageLocality( source );
if ( verified.has( locality ) ) {
continue;
}
// Avoid verifying any other imports for the locality,
// regardless whether a correction must be made.
verified.add( locality );
// Determine whether a correction must be made.
const correction = getDependencyBlockCorrection(
child,
locality
);
if ( ! correction ) {
continue;
}
context.report( {
node: child,
message: `Expected preceding "${ locality } dependencies" comment block`,
fix( fixer ) {
const { comment, value } = correction;
const text = `/*${ value }*/`;
if ( comment && comment.range ) {
return fixer.replaceTextRange(
comment.range,
text
);
}
return fixer.insertTextBefore( child, text + '\n' );
},
} );
}
},
};
},
};

View File

@@ -0,0 +1,174 @@
/**
* Traverse up through the chain of parent AST nodes returning the first parent
* the predicate returns a truthy value for.
*
* @param {Object} sourceNode The AST node to search from.
* @param {Function} predicate A predicate invoked for each parent.
*
* @return {Object | undefined} The first encountered parent node where the predicate
* returns a truthy value.
*/
function findParent( sourceNode, predicate ) {
if ( ! sourceNode.parent ) {
return;
}
if ( predicate( sourceNode.parent ) ) {
return sourceNode.parent;
}
return findParent( sourceNode.parent, predicate );
}
/**
* Tests whether the GUTENBERG_PHASE variable is accessed via
* `process.env.GUTENBERG_PHASE`.
*
* @example
* ```js
* // good
* if ( process.env.GUTENBERG_PHASE === 2 ) {
*
* // bad
* if ( GUTENBERG_PHASE === 2 ) {
* ```
*
* @param {Object} node The GUTENBERG_PHASE identifier node.
* @param {Object} context The eslint context object.
*/
function testIsAccessedViaProcessEnv( node, context ) {
const parent = node.parent;
if (
parent &&
parent.type === 'MemberExpression' &&
context.getSource( parent ) === 'process.env.GUTENBERG_PHASE'
) {
return;
}
context.report(
node,
'The `GUTENBERG_PHASE` constant should be accessed using `process.env.GUTENBERG_PHASE`.'
);
}
/**
* Tests whether the GUTENBERG_PHASE variable is used in a strict binary
* equality expression in a comparison with a number, triggering a
* violation if not.
*
* @example
* ```js
* // good
* if ( process.env.GUTENBERG_PHASE === 2 ) {
*
* // bad
* if ( process.env.GUTENBERG_PHASE >= '2' ) {
* ```
*
* @param {Object} node The GUTENBERG_PHASE identifier node.
* @param {Object} context The eslint context object.
*/
function testIsUsedInStrictBinaryExpression( node, context ) {
const parent = findParent(
node,
( candidate ) => candidate.type === 'BinaryExpression'
);
if ( parent ) {
const comparisonNode =
node.parent.type === 'MemberExpression' ? node.parent : node;
// Test for process.env.GUTENBERG_PHASE === <number> or <number> === process.env.GUTENBERG_PHASE.
const hasCorrectOperator = [ '===', '!==' ].includes( parent.operator );
const hasCorrectOperands =
( parent.left === comparisonNode &&
typeof parent.right.value === 'number' ) ||
( parent.right === comparisonNode &&
typeof parent.left.value === 'number' );
if ( hasCorrectOperator && hasCorrectOperands ) {
return;
}
}
context.report(
node,
'The `GUTENBERG_PHASE` constant should only be used in a strict equality comparison with a primitive number.'
);
}
/**
* Tests whether the GUTENBERG_PHASE variable is used as the condition for an
* if statement, triggering a violation if not.
*
* @example
* ```js
* // good
* if ( process.env.GUTENBERG_PHASE === 2 ) {
*
* // bad
* const isFeatureActive = process.env.GUTENBERG_PHASE === 2;
* ```
*
* @param {Object} node The GUTENBERG_PHASE identifier node.
* @param {Object} context The eslint context object.
*/
function testIsUsedInIfOrTernary( node, context ) {
const conditionalParent = findParent( node, ( candidate ) =>
[ 'IfStatement', 'ConditionalExpression' ].includes( candidate.type )
);
const binaryParent = findParent(
node,
( candidate ) => candidate.type === 'BinaryExpression'
);
if (
conditionalParent &&
binaryParent &&
conditionalParent.test &&
conditionalParent.test.range[ 0 ] === binaryParent.range[ 0 ] &&
conditionalParent.test.range[ 1 ] === binaryParent.range[ 1 ]
) {
return;
}
context.report(
node,
'The `GUTENBERG_PHASE` constant should only be used as part of the condition in an if statement or ternary expression.'
);
}
module.exports = {
meta: {
type: 'problem',
schema: [],
deprecated: true,
replacedBy: '@wordpress/is-gutenberg-plugin',
},
create( context ) {
return {
Identifier( node ) {
// Bypass any identifiers with a node name different to `GUTENBERG_PHASE`.
if ( node.name !== 'GUTENBERG_PHASE' ) {
return;
}
testIsAccessedViaProcessEnv( node, context );
testIsUsedInStrictBinaryExpression( node, context );
testIsUsedInIfOrTernary( node, context );
},
Literal( node ) {
// Bypass any identifiers with a node value different to `GUTENBERG_PHASE`.
if ( node.value !== 'GUTENBERG_PHASE' ) {
return;
}
if ( node.parent && node.parent.type === 'MemberExpression' ) {
testIsAccessedViaProcessEnv( node, context );
}
},
};
},
};

View File

@@ -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 ),
} );
}
},
};
},
};

View File

@@ -0,0 +1,101 @@
/**
* Internal dependencies
*/
const {
TRANSLATION_FUNCTIONS,
getTextContentFromNode,
getTranslateFunctionName,
getTranslateFunctionArgs,
} = require( '../utils' );
const EN_DASH = '';
const HYPHEN_IN_RANGE = /(\d\s+-\s+\d)|(\d-\d)/g;
function replaceHyphenWithEnDash( string ) {
if ( string.match( HYPHEN_IN_RANGE ) ) {
return string.replace( '-', EN_DASH );
}
return string;
}
function makeFixerFunction( arg ) {
return ( fixer ) => {
switch ( arg.type ) {
case 'TemplateLiteral':
return arg.quasis.reduce( ( fixes, quasi ) => {
if (
'TemplateElement' === quasi.type &&
quasi.value.raw.match( HYPHEN_IN_RANGE )
) {
fixes.push(
fixer.replaceTextRange(
[ quasi.start, quasi.end ],
replaceHyphenWithEnDash( quasi.value.raw )
)
);
}
return fixes;
}, [] );
case 'Literal':
return [
fixer.replaceText(
arg,
replaceHyphenWithEnDash( arg.raw )
),
];
case 'BinaryExpression':
return [
...makeFixerFunction( arg.left )( fixer ),
...makeFixerFunction( arg.right )( fixer ),
];
}
};
}
module.exports = {
meta: {
type: 'problem',
schema: [],
messages: {
foundHyphen:
'Use dashes (en or em) in place of hyphens for numeric ranges.',
},
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.match( HYPHEN_IN_RANGE )
) {
continue;
}
context.report( {
node,
messageId: 'foundHyphen',
fix: makeFixerFunction( arg ),
} );
}
},
};
},
};

View File

@@ -0,0 +1,73 @@
/**
* 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,
},
} );
}
},
};
},
};

View File

@@ -0,0 +1,90 @@
/**
* Internal dependencies
*/
const {
TRANSLATION_FUNCTIONS,
getTextContentFromNode,
getTranslateFunctionName,
getTranslateFunctionArgs,
} = require( '../utils' );
const PROBLEMS_BY_CHAR_CODE = {
9: '\\t',
10: '\\n',
13: '\\r',
32: 'whitespace',
};
function makeFixerFunction( arg ) {
return ( fixer ) => {
switch ( arg.type ) {
case 'Literal':
return [ fixer.replaceText( arg, `'${ arg.value.trim() }'` ) ];
case 'BinaryExpression':
return [
...makeFixerFunction( arg.left )( fixer ),
...makeFixerFunction( arg.right )( fixer ),
];
}
};
}
module.exports = {
meta: {
type: 'problem',
schema: [],
messages: {
noFlankingWhitespace:
'Translations should not contain flanking whitespace{{problem}}',
},
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 ) {
continue;
}
const trimmableWhitespace =
argumentString.match( /^\s|\s$/ );
if ( ! trimmableWhitespace ) {
continue;
}
const problem =
PROBLEMS_BY_CHAR_CODE[
trimmableWhitespace[ 0 ].charCodeAt( 0 )
];
const problemString = problem ? ` (${ problem })` : '';
context.report( {
node,
messageId: 'noFlankingWhitespace',
data: {
problem: problemString,
},
fix: makeFixerFunction( arg ),
} );
}
},
};
},
};

View File

@@ -0,0 +1,59 @@
/**
* Internal dependencies
*/
const {
TRANSLATION_FUNCTIONS,
REGEXP_SPRINTF_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( /%%/g, 'VALID_ESCAPED_PERCENTAGE_SIGN' )
.replace( REGEXP_SPRINTF_PLACEHOLDER, '' );
if ( modifiedString.length > 0 ) {
continue;
}
context.report( {
node,
messageId: 'noPlaceholdersOnly',
} );
}
},
};
},
};

View File

@@ -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',
} );
}
},
};
},
};

View File

@@ -0,0 +1,162 @@
/**
* 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',
},
],
},
},
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 allowed text domains: {{ textDomains }}',
},
fixable: 'code',
},
create( context ) {
const options = context.options[ 0 ] || {};
const { allowedTextDomain } = options;
const allowedTextDomains = Array.isArray( allowedTextDomain )
? allowedTextDomain
: [ allowedTextDomain ].filter( ( value ) => value );
const canFixTextDomain = allowedTextDomains.length === 1;
const allowDefault =
allowedTextDomains.length === 0 ||
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.length &&
! 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,
} );
}
},
};
},
};

View File

@@ -0,0 +1,112 @@
/**
* Internal dependencies
*/
const {
TRANSLATION_FUNCTIONS,
REGEXP_SPRINTF_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_SPRINTF_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_SPRINTF_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: {
end: { 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',
} );
},
};
},
};

1
node_modules/@wordpress/eslint-plugin/rules/index.js generated vendored Normal file
View File

@@ -0,0 +1 @@
module.exports = require( 'requireindex' )( __dirname );

View File

@@ -0,0 +1,94 @@
/**
* Traverse up through the chain of parent AST nodes returning the first parent
* the predicate returns a truthy value for.
*
* @param {Object} sourceNode The AST node to search from.
* @param {Function} predicate A predicate invoked for each parent.
*
* @return {Object | undefined} The first encountered parent node where the predicate
* returns a truthy value.
*/
function findParent( sourceNode, predicate ) {
if ( ! sourceNode.parent ) {
return;
}
if ( predicate( sourceNode.parent ) ) {
return sourceNode.parent;
}
return findParent( sourceNode.parent, predicate );
}
/**
* Tests whether the IS_GUTENBERG_PLUGIN variable is used as the condition for an
* if statement or ternary, triggering a violation if not.
*
* @example
* ```js
* // good
* if ( process.env.IS_GUTENBERG_PLUGIN ) {
*
* // bad
* const isFeatureActive = process.env.IS_GUTENBERG_PLUGIN;
* ```
*
* @param {Object} node The IS_GUTENBERG_PLUGIN identifier node.
* @param {Object} context The eslint context object.
*/
function isUsedInConditional( node, context ) {
const conditionalParent = findParent( node, ( candidate ) =>
[ 'IfStatement', 'ConditionalExpression' ].includes( candidate.type )
);
if ( ! conditionalParent ) {
return false;
}
// Allow for whitespace as prettier sometimes breaks this on separate lines.
const textRegex = /^\s*!?\s*process\s*\.\s*env\s*\.\s*IS_GUTENBERG_PLUGIN$/;
const testSource = context.getSource( conditionalParent.test );
if ( ! textRegex.test( testSource ) ) {
return false;
}
return true;
}
const ERROR_MESSAGE =
'The `process.env.IS_GUTENBERG_PLUGIN` constant should only be used as the condition in an if statement or ternary expression.';
module.exports = {
meta: {
type: 'problem',
schema: [],
},
create( context ) {
return {
Identifier( node ) {
// Bypass any identifiers with a node name different to `IS_GUTENBERG_PLUGIN`.
if ( node.name !== 'IS_GUTENBERG_PLUGIN' ) {
return;
}
if ( ! isUsedInConditional( node, context ) ) {
context.report( node, ERROR_MESSAGE );
}
},
// Check for literals, e.g. when 'IS_GUTENBERG_PLUGIN' is used as a string via something like 'window[ 'IS_GUTENBERG_PLUGIN' ]'.
Literal( node ) {
// Bypass any identifiers with a node value different to `IS_GUTENBERG_PLUGIN`.
if ( node.value !== 'IS_GUTENBERG_PLUGIN' ) {
return;
}
if ( node.parent && node.parent.type === 'MemberExpression' ) {
if ( ! isUsedInConditional( node, context ) ) {
context.report( node, ERROR_MESSAGE );
}
}
},
};
},
};

View File

@@ -0,0 +1,29 @@
module.exports = {
meta: {
type: 'problem',
schema: [],
},
create( context ) {
return {
"JSXOpeningElement[name.name='BaseControl']": ( node ) => {
const containsAttribute = ( attrName ) => {
return node.attributes.some( ( attribute ) => {
return (
attribute.name && attribute.name.name === attrName
);
} );
};
if (
containsAttribute( 'label' ) &&
! containsAttribute( 'id' )
) {
context.report( {
node,
message:
'When using BaseControl component if a label property is passed an id property should also be passed.',
} );
}
},
};
},
};

View File

@@ -0,0 +1,19 @@
module.exports = {
meta: {
type: 'problem',
schema: [],
},
create( context ) {
return {
'MemberExpression[object.name="document"][property.name="activeElement"]'(
node
) {
context.report( {
node,
message:
'Avoid accessing the active element with a global. Use the ownerDocument property on a node ref instead.',
} );
},
};
},
};

View File

@@ -0,0 +1,19 @@
module.exports = {
meta: {
type: 'problem',
schema: [],
},
create( context ) {
return {
'CallExpression[callee.object.name="window"][callee.property.name="getSelection"]'(
node
) {
context.report( {
node,
message:
'Avoid accessing the selection with a global. Use the ownerDocument.defaultView property on a node ref instead.',
} );
},
};
},
};

View File

@@ -0,0 +1,18 @@
module.exports = {
meta: {
type: 'problem',
schema: [],
},
create( context ) {
return {
'CallExpression[callee.object.callee.property.name="getSelection"][callee.property.name="getRangeAt"]'(
node
) {
context.report( {
node,
message: 'Avoid unguarded getRangeAt',
} );
},
};
},
};

View File

@@ -0,0 +1,88 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
type: 'problem',
meta: {
schema: [
{
type: 'object',
additionalProperties: false,
patternProperties: {
'^@wordpress\\/[a-zA-Z0-9_-]+$': {
type: 'array',
uniqueItems: true,
minItems: 1,
items: {
type: 'string',
pattern: '^(?:__experimental|__unstable)',
},
},
},
},
],
},
create( context ) {
/** @type {AllowedImportsMap} */
const allowedImports =
( context.options &&
typeof context.options[ 0 ] === 'object' &&
context.options[ 0 ] ) ||
{};
const reporter = makeListener( { allowedImports, context } );
return { ImportDeclaration: reporter };
},
};
/**
* @param {Object} _
* @param {AllowedImportsMap} _.allowedImports
* @param {import('eslint').Rule.RuleContext} _.context
*
* @return {(node: Node) => void} Listener function
*/
function makeListener( { allowedImports, context } ) {
return function reporter( node ) {
if ( node.type !== 'ImportDeclaration' ) {
return;
}
if ( typeof node.source.value !== 'string' ) {
return;
}
const sourceModule = node.source.value.trim();
// Ignore non-WordPress packages.
if ( ! sourceModule.startsWith( '@wordpress/' ) ) {
return;
}
const allowedImportNames = allowedImports[ sourceModule ] || [];
node.specifiers.forEach( ( specifierNode ) => {
if ( specifierNode.type !== 'ImportSpecifier' ) {
return;
}
const importedName = specifierNode.imported.name;
if (
! importedName.startsWith( '__unstable' ) &&
! importedName.startsWith( '__experimental' )
) {
return;
}
if ( allowedImportNames.includes( importedName ) ) {
return;
}
context.report( {
message: `Usage of \`${ importedName }\` from \`${ sourceModule }\` is not allowed.\nSee https://developer.wordpress.org/block-editor/contributors/develop/coding-guidelines/#experimental-and-unstable-apis for details.`,
node: specifierNode,
} );
} );
};
}
/** @typedef {import('estree').Node} Node */
/** @typedef {Record<string, string[]|undefined>} AllowedImportsMap */

View File

@@ -0,0 +1,156 @@
/** @typedef {import('eslint').Scope.Scope} ESLintScope */
/** @typedef {import('eslint').Rule.RuleContext} ESLintRuleContext */
/** @typedef {import('estree').Node} ESTreeNode */
/**
* Mapping of function scope objects to a set of identified JSX identifiers
* within that scope.
*
* @type {WeakMap<ESLintScope,Set<ESTreeNode>>}
*/
const FUNCTION_SCOPE_JSX_IDENTIFIERS = new WeakMap();
/**
* Returns the closest function scope for the current ESLint context object, or
* undefined if it cannot be determined.
*
* @param {ESLintRuleContext} context ESLint context object.
*
* @return {ESLintScope|undefined} Function scope, if known.
*/
function getClosestFunctionScope( context ) {
let functionScope = context.getScope();
while ( functionScope.type !== 'function' && functionScope.upper ) {
functionScope = functionScope.upper;
}
return functionScope;
}
module.exports = /** @type {import('eslint').Rule} */ ( {
meta: {
type: 'problem',
schema: [
{
type: 'object',
properties: {
excludePattern: {
type: 'string',
},
},
additionalProperties: false,
},
],
},
/**
* @param {ESLintRuleContext} context Rule context.
*/
create( context ) {
const options = context.options[ 0 ] || {};
const { excludePattern } = options;
/**
* Given an Espree VariableDeclarator node, returns true if the node
* can be exempted from consideration as unused, or false otherwise. A
* node can be exempt if it destructures to multiple variables, since
* those other variables may be used prior to the return statement. A
* future enhancement could validate that they are in-fact referenced.
*
* @param {Object} node Node to test.
*
* @return {boolean} Whether declarator is emempt from consideration.
*/
function isExemptObjectDestructureDeclarator( node ) {
return (
node.id.type === 'ObjectPattern' &&
node.id.properties.length > 1
);
}
return {
JSXIdentifier( node ) {
// Currently, a scope's variable references does not include JSX
// identifiers. Account for this by visiting JSX identifiers
// first, and tracking them in a map per function scope, which
// is later merged with the known variable references.
const functionScope = getClosestFunctionScope( context );
if ( ! functionScope ) {
return;
}
if ( ! FUNCTION_SCOPE_JSX_IDENTIFIERS.has( functionScope ) ) {
FUNCTION_SCOPE_JSX_IDENTIFIERS.set(
functionScope,
new Set()
);
}
FUNCTION_SCOPE_JSX_IDENTIFIERS.get( functionScope ).add( node );
},
'ReturnStatement:exit'( node ) {
const functionScope = getClosestFunctionScope( context );
if ( ! functionScope ) {
return;
}
for ( const variable of functionScope.variables ) {
const declaratorCandidate = variable.defs.find( ( def ) => {
return (
def.node.type === 'VariableDeclarator' &&
// Allow declarations which are not initialized.
def.node.init &&
// Target function calls as "expensive".
def.node.init.type === 'CallExpression' &&
// Allow unused if part of an object destructuring.
! isExemptObjectDestructureDeclarator( def.node ) &&
// Only target assignments preceding `return`.
def.node.range[ 1 ] < node.range[ 1 ]
);
} );
if ( ! declaratorCandidate ) {
continue;
}
if (
excludePattern !== undefined &&
new RegExp( excludePattern ).test(
declaratorCandidate.node.init.callee.name
)
) {
continue;
}
// The first entry in `references` is the declaration
// itself, which can be ignored.
const identifiers = variable.references
.slice( 1 )
.map( ( reference ) => reference.identifier );
// Merge with any JSX identifiers in scope, if any.
if ( FUNCTION_SCOPE_JSX_IDENTIFIERS.has( functionScope ) ) {
const jsxIdentifiers =
FUNCTION_SCOPE_JSX_IDENTIFIERS.get( functionScope );
identifiers.push( ...jsxIdentifiers );
}
const isUsedBeforeReturn = identifiers.some(
( identifier ) =>
identifier.range[ 1 ] < node.range[ 1 ]
);
if ( isUsedBeforeReturn ) {
continue;
}
context.report(
declaratorCandidate.node,
'Variables should not be assigned until just prior its first reference. ' +
'An early return statement may leave this variable unused.'
);
}
},
};
},
} );

View File

@@ -0,0 +1,93 @@
/**
* Given an Espree Node, returns true if the node is a component.
*
* @param {espree.Node} node Node to check.
*
* @return {boolean} Whether node is a component.
*/
function isComponent( node ) {
// Assume function component by naming convention of UpperCamelCase.
if (
node.type === 'FunctionDeclaration' &&
node.id &&
/^[A-Z]/.test( node.id.name )
) {
return true;
}
// Assume class component by extends name `Component`.
if ( node.type === 'ClassDeclaration' && node.superClass ) {
let superClassName;
switch ( node.superClass.type ) {
case 'Identifier':
superClassName = node.superClass.name;
break;
case 'MemberExpression':
superClassName = node.superClass.property.name;
break;
}
if ( superClassName === 'Component' ) {
return true;
}
}
return false;
}
module.exports = {
meta: {
type: 'problem',
schema: [],
},
create( context ) {
return {
'CallExpression[callee.name="setTimeout"]'( node ) {
// If the result of a `setTimeout` call is assigned to a
// variable, assume the timer ID is handled by a cancellation.
const hasAssignment =
node.parent.type === 'AssignmentExpression' ||
node.parent.type === 'VariableDeclarator';
if ( hasAssignment ) {
return;
}
let isInComponent = false;
let parent = node;
while ( ( parent = parent.parent ) ) {
if ( isComponent( parent ) ) {
isInComponent = true;
break;
}
}
// Only consider `setTimeout` which occur within a component.
if ( ! isInComponent ) {
return;
}
// Consider whether `setTimeout` is a reference to the global
// by checking references to see if `setTimeout` resolves to a
// variable in scope.
const { references } = context.getScope();
const hasResolvedReference = references.some(
( reference ) =>
reference.identifier.name === 'setTimeout' &&
!! reference.resolved &&
reference.resolved.scope.type !== 'global'
);
if ( hasResolvedReference ) {
return;
}
context.report(
node,
'setTimeout in a component must be cancelled on unmount'
);
},
};
},
};

View File

@@ -0,0 +1,175 @@
/**
* Internal dependencies
*/
const {
REGEXP_SPRINTF_PLACEHOLDER,
REGEXP_SPRINTF_PLACEHOLDER_UNORDERED,
getTranslateFunctionName,
getTranslateFunctionArgs,
getTextContentFromNode,
} = require( '../utils' );
module.exports = {
meta: {
type: 'problem',
schema: [],
messages: {
noFormatString: 'sprintf must be called with a format string',
invalidFormatString:
'sprintf must be called with a valid format string',
noPlaceholderArgs:
'sprintf must be called with placeholder value argument(s)',
noPlaceholders:
'sprintf format string must contain at least one placeholder',
placeholderMismatch:
'sprintf format string options must have the same number of placeholders',
noOrderedPlaceholders:
'Multiple sprintf placeholders should be ordered. Mix of ordered and non-ordered placeholders found.',
},
},
create( context ) {
return {
CallExpression( node ) {
const { callee, arguments: args } = node;
const functionName =
callee.property && callee.property.name
? callee.property.name
: callee.name;
if ( functionName !== 'sprintf' ) {
return;
}
if ( ! args.length ) {
context.report( {
node,
messageId: 'noFormatString',
} );
return;
}
if ( args.length < 2 ) {
if ( args[ 0 ].type === 'SpreadElement' ) {
return;
}
context.report( {
node,
messageId: 'noPlaceholderArgs',
} );
return;
}
let candidates;
switch ( args[ 0 ].type ) {
case 'Literal':
candidates = [ args[ 0 ].value ].filter( ( arg ) => {
// Since a Literal may be a number, verify the
// value is a string.
return typeof arg === 'string';
} );
break;
case 'CallExpression':
const argFunctionName = getTranslateFunctionName(
args[ 0 ].callee
);
// All possible options (arguments) from a translate
// function must be valid.
candidates = getTranslateFunctionArgs(
argFunctionName,
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.filter( Boolean ).length === 0 ) {
return;
}
break;
case 'Identifier':
// Identifiers may refer to a valid string variable.
// Ideally its reference value is verified, but this is
// not straight-forward to implement. Thus, bail.
return;
default:
candidates = [];
}
if ( ! candidates.length ) {
context.report( {
node,
messageId: 'invalidFormatString',
} );
return;
}
let numPlaceholders;
for ( const candidate of candidates ) {
const allMatches = candidate.match(
REGEXP_SPRINTF_PLACEHOLDER
);
// Prioritize placeholder number consistency over matching
// placeholder, since it's a more common error to omit a
// placeholder from the singular form of pluralization.
if (
numPlaceholders !== undefined &&
( ! allMatches ||
numPlaceholders !== allMatches.length )
) {
context.report( {
node,
messageId: 'placeholderMismatch',
} );
return;
}
const unorderedMatches = candidate.match(
REGEXP_SPRINTF_PLACEHOLDER_UNORDERED
);
if (
unorderedMatches &&
allMatches &&
unorderedMatches.length > 0 &&
allMatches.length > 1 &&
unorderedMatches.length !== allMatches.length
) {
context.report( {
node,
messageId: 'noOrderedPlaceholders',
} );
return;
}
// Catch cases where a string only contains %% (escaped percentage sign).
if (
! allMatches ||
( allMatches.length === 1 && allMatches[ 0 ] === '%%' )
) {
context.report( {
node,
messageId: 'noPlaceholders',
} );
return;
}
if ( numPlaceholders === undefined ) {
// Track the number of placeholders discovered in the
// string to verify that all other candidate options
// have the same number.
numPlaceholders = allMatches.length;
}
}
},
};
},
};