UNPKG

@wordpress/eslint-plugin

Version:
328 lines (287 loc) 8.06 kB
const { hasTruthyJsxAttribute } = require( '../utils' ); /** * Enforces that specific components from @wordpress/components include the * `__next40pxDefaultSize` prop. * * @type {import('eslint').Rule.RuleModule} */ /** * Components that require the __next40pxDefaultSize prop. * These can be exempted if they have a non-default `size` prop. */ const COMPONENTS_REQUIRING_40PX = new Set( [ 'BorderBoxControl', 'BorderControl', 'BoxControl', 'Button', 'ClipboardButton', 'ComboboxControl', 'CustomSelectControl', 'FontAppearanceControl', 'FontFamilyControl', 'FontSizePicker', 'FormTokenField', 'IconButton', 'InputControl', 'LetterSpacingControl', 'LineHeightControl', 'NumberControl', 'Radio', 'RangeControl', 'SelectControl', 'TextControl', 'TreeSelect', 'ToggleGroupControl', 'UnitControl', ] ); /** * Components that can use the `render` prop as an alternative to __next40pxDefaultSize. */ const COMPONENTS_WITH_RENDER_EXEMPTION = new Set( [ 'FormFileUpload' ] ); /** * All tracked component names for path-based detection. */ const ALL_TRACKED_COMPONENTS = new Set( [ ...COMPONENTS_REQUIRING_40PX, ...COMPONENTS_WITH_RENDER_EXEMPTION, ] ); module.exports = { meta: { type: 'problem', schema: [ { type: 'object', properties: { checkLocalImports: { type: 'boolean', description: 'When true, also checks components imported from relative paths (for use inside @wordpress/components package).', }, }, additionalProperties: false, }, ], messages: { missingProp: '{{ component }} should have the `__next40pxDefaultSize` prop when using the default size.', missingPropFormFileUpload: 'FormFileUpload should have the `__next40pxDefaultSize` prop to opt-in to the new default size.', }, }, create( context ) { const checkLocalImports = context.options[ 0 ]?.checkLocalImports ?? false; // Track local names of components imported from @wordpress/components // Map: localName -> importedName const trackedImports = new Map(); /** * Check if the import source should be tracked. * * @param {string} source - The import source path * @return {boolean} Whether to track imports from this source */ function shouldTrackImportSource( source ) { if ( source === '@wordpress/components' ) { return true; } // When checkLocalImports is enabled, also track relative imports if ( checkLocalImports ) { return source.startsWith( '.' ) || source.startsWith( '/' ); } return false; } /** * Try to infer component name from import path. * e.g., '../button' -> 'Button', '../input-control' -> 'InputControl' * * @param {string} source - The import source path * @return {string|null} The inferred component name or null */ function inferComponentNameFromPath( source ) { // Get the last segment of the path const lastSegment = source.split( '/' ).pop(); if ( ! lastSegment ) { return null; } // Convert kebab-case to PascalCase const pascalCase = lastSegment .split( '-' ) .map( ( part ) => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) ) .join( '' ); // Check if it's one of our tracked components if ( ALL_TRACKED_COMPONENTS.has( pascalCase ) ) { return pascalCase; } return null; } /** * Check if the `size` prop has a non-default value. * * @param {Array} attributes - JSX attributes array * @return {boolean} Whether size has a non-default value */ function hasNonDefaultSize( attributes ) { const sizeAttr = attributes.find( ( a ) => a.type === 'JSXAttribute' && a.name && a.name.name === 'size' ); if ( ! sizeAttr ) { return false; } // String value like `size="small"` or `size="compact"` if ( sizeAttr.value && sizeAttr.value.type === 'Literal' && typeof sizeAttr.value.value === 'string' ) { return sizeAttr.value.value !== 'default'; } // Expression - could be non-default, so don't report if ( sizeAttr.value && sizeAttr.value.type === 'JSXExpressionContainer' ) { return true; } return false; } /** * Check if the `render` prop exists. * * @param {Array} attributes - JSX attributes array * @return {boolean} Whether render prop exists */ function hasRenderProp( attributes ) { return attributes.some( ( a ) => a.type === 'JSXAttribute' && a.name && a.name.name === 'render' ); } /** * Check if the `variant` prop has the value "link". * Button with variant="link" doesn't need __next40pxDefaultSize. * * @param {Array} attributes - JSX attributes array * @return {boolean} Whether variant is "link" */ function hasLinkVariant( attributes ) { const variantAttr = attributes.find( ( a ) => a.type === 'JSXAttribute' && a.name && a.name.name === 'variant' ); if ( ! variantAttr ) { return false; } // String value like `variant="link"` if ( variantAttr.value && variantAttr.value.type === 'Literal' && variantAttr.value.value === 'link' ) { return true; } return false; } return { ImportDeclaration( node ) { const source = node.source.value; if ( ! shouldTrackImportSource( source ) ) { return; } // Handle named imports node.specifiers.forEach( ( specifier ) => { if ( specifier.type !== 'ImportSpecifier' ) { return; } const importedName = specifier.imported.name; const localName = specifier.local.name; // Track components that require the prop if ( COMPONENTS_REQUIRING_40PX.has( importedName ) || COMPONENTS_WITH_RENDER_EXEMPTION.has( importedName ) ) { trackedImports.set( localName, importedName ); } } ); // Handle default imports when checking local imports // e.g., import InputControl from '../input-control' if ( checkLocalImports ) { node.specifiers.forEach( ( specifier ) => { if ( specifier.type === 'ImportDefaultSpecifier' ) { const localName = specifier.local.name; const inferredName = inferComponentNameFromPath( source ); if ( inferredName ) { trackedImports.set( localName, inferredName ); return; } // Support patterns like `import ClipboardButton from '.';` // (common in component folder examples/tests). // If the local name matches a tracked component, treat it as such. if ( ALL_TRACKED_COMPONENTS.has( localName ) ) { trackedImports.set( localName, localName ); } } } ); } }, JSXOpeningElement( node ) { // Only check simple JSX element names (not member expressions) if ( node.name.type !== 'JSXIdentifier' ) { return; } const elementName = node.name.name; const importedName = trackedImports.get( elementName ); // Only check if this is a tracked component from @wordpress/components if ( ! importedName ) { return; } const attributes = node.attributes; // Check if __next40pxDefaultSize has a truthy value if ( hasTruthyJsxAttribute( attributes, '__next40pxDefaultSize' ) ) { return; } // Handle FormFileUpload special case if ( COMPONENTS_WITH_RENDER_EXEMPTION.has( importedName ) ) { // FormFileUpload is valid if it has a `render` prop if ( hasRenderProp( attributes ) ) { return; } context.report( { node, messageId: 'missingPropFormFileUpload', } ); return; } // For other components, check if size prop has a non-default value if ( hasNonDefaultSize( attributes ) ) { return; } // Button with variant="link" doesn't need __next40pxDefaultSize if ( importedName === 'Button' && hasLinkVariant( attributes ) ) { return; } context.report( { node, messageId: 'missingProp', data: { component: importedName, }, } ); }, }; }, };