Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 266 additions & 1 deletion tools/eslint-excludelist.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions tools/eslint/eslint-plugin-jetpack/index.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
rules: {
'use-recommended-jetpack-components': require( './rules/use-recommended-jetpack-components.cjs' ),
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"components": {},
"subpaths": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const { createRequire } = require( 'node:module' );
const path = require( 'node:path' );
const requireFromJsTools = createRequire(
path.join( __dirname, '..', '..', '..', '..', 'js-tools', 'package.json' )
);
const { RuleTester } = requireFromJsTools( 'eslint' );
const rule = require( '../use-recommended-jetpack-components.cjs' );

const denylistPath = path.resolve(
__dirname,
'..',
'..',
'..',
'jetpack-components-denylist.json'
);

const ruleTester = new RuleTester( {
languageOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
},
} );

ruleTester.run( 'use-recommended-jetpack-components', rule, {
valid: [
"import { getRedirectUrl } from '@automattic/jetpack-api';",
{
code: "import { JetpackLogo } from '@automattic/jetpack-components';",
options: [ { denylistPath: path.join( __dirname, 'fixtures', 'empty-denylist.json' ) } ],
},
],

invalid: [
{
code: "import { Button } from '@automattic/jetpack-components';",
options: [ { denylistPath } ],
errors: [
{
message: 'Use `Button` from `@wordpress/ui` instead.',
},
],
},
{
code: "import Button from '@automattic/jetpack-components/button';",
options: [ { denylistPath } ],
errors: [
{
message: 'Use `Button` from `@wordpress/ui` instead.',
},
],
},
],
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
const fs = require( 'node:fs' );
const path = require( 'node:path' );

const PACKAGE_NAME = '@automattic/jetpack-components';
const DEFAULT_DENYLIST_PATH = path.resolve(
__dirname,
'..',
'..',
'jetpack-components-denylist.json'
);

/**
* Interpolate `{{ name }}` and `{{ source }}` placeholders in a lint message.
*
* @param {string|undefined} template - Message template.
* @param {string} name - Component name.
* @param {string} source - Import source.
* @return {string} Resolved message string.
*/
function resolveMessage( template, name, source ) {
if ( ! template ) {
return `\`${ name }\` from \`${ source }\` is not recommended. Prefer \`@wordpress/components\` or \`@wordpress/ui\` when an equivalent exists.`;
}

return template.replace( /\{\{\s*name\s*\}\}/g, name ).replace( /\{\{\s*source\s*\}\}/g, source );
}

/**
* Load the Jetpack components denylist from disk.
*
* @param {string} denylistPath - Path to the denylist JSON file.
* @return {{ components: Record<string, string>, subpaths: Record<string, string> }} Denylist data.
*/
function loadDenylist( denylistPath ) {
const raw = JSON.parse( fs.readFileSync( denylistPath, 'utf8' ) );

return {
components: normalizeEntries( raw.components ),
subpaths: normalizeEntries( raw.subpaths ),
};
}

/**
* Normalize denylist entries to a flat component-name → message map.
*
* @param {Record<string, string | { message: string }>|undefined} entries - Denylist entries.
* @return {Record<string, string>} Normalized message map.
*/
function normalizeEntries( entries ) {
if ( ! entries ) {
return {};
}

return Object.fromEntries(
Object.entries( entries ).map( ( [ key, value ] ) => {
if ( typeof value === 'string' ) {
return [ key, value ];
}

return [ key, value.message ];
} )
);
}

/** @type {import('eslint').Rule.RuleModule} */
const rule = {
meta: {
type: 'suggestion',
docs: {
description:
'Encourage the use of WordPress components instead of discouraged Jetpack components.',
},
schema: [
{
type: 'object',
properties: {
denylistPath: {
type: 'string',
},
},
additionalProperties: false,
},
],
},
create( context ) {
const options = context.options[ 0 ] ?? {};
const configuredPath = options.denylistPath ?? DEFAULT_DENYLIST_PATH;
const denylistPath = path.isAbsolute( configuredPath )
? configuredPath
: path.resolve( context.cwd ?? process.cwd(), configuredPath );
const { components, subpaths } = loadDenylist( denylistPath );

return {
/**
* Lint import declarations from Jetpack components.
*
* @param {import('estree').ImportDeclaration} node - Import declaration AST node.
*/
ImportDeclaration( node ) {
if ( typeof node.source.value !== 'string' ) {
return;
}

const source = node.source.value;

if ( source === PACKAGE_NAME ) {
reportMainPackageImports( context, node, source, components );
return;
}

if ( source.startsWith( `${ PACKAGE_NAME }/` ) ) {
reportSubpathImports(
context,
node,
source,
source.slice( PACKAGE_NAME.length + 1 ),
components,
subpaths
);
}
},
};
},
};

/**
* Report discouraged imports from `@automattic/jetpack-components`.
*
* @param {import('eslint').Rule.RuleContext} context - ESLint context.
* @param {import('estree').ImportDeclaration} node - Import declaration node.
* @param {string} source - Import source.
* @param {Record<string, string>} components - Component denylist.
*/
function reportMainPackageImports( context, node, source, components ) {
node.specifiers.forEach( specifier => {
if ( specifier.type !== 'ImportSpecifier' ) {
return;
}

const name = specifier.imported.name;
const message = components[ name ];

if ( message ) {
context.report( {
node: specifier,
message: resolveMessage( message, name, source ),
} );
}
} );
}

/**
* Report discouraged imports from `@automattic/jetpack-components/*` subpaths.
*
* @param {import('eslint').Rule.RuleContext} context - ESLint context.
* @param {import('estree').ImportDeclaration} node - Import declaration node.
* @param {string} source - Import source.
* @param {string} subpath - Subpath without leading slash.
* @param {Record<string, string>} components - Component denylist.
* @param {Record<string, string>} subpaths - Subpath denylist.
*/
function reportSubpathImports( context, node, source, subpath, components, subpaths ) {
const subpathMessage = subpaths[ subpath ];

node.specifiers.forEach( specifier => {
if ( specifier.type === 'ImportDefaultSpecifier' && subpathMessage ) {
context.report( {
node: specifier,
message: resolveMessage( subpathMessage, subpath, source ),
} );
return;
}

if ( specifier.type !== 'ImportSpecifier' ) {
return;
}

const name = specifier.imported.name;
const message = components[ name ] ?? subpathMessage;

if ( message ) {
context.report( {
node: specifier,
message: resolveMessage( message, name, source ),
} );
}
} );
}

module.exports = rule;
80 changes: 80 additions & 0 deletions tools/eslint/jetpack-components-denylist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"components": {
"ActionButton": "Use `Button` from `@wordpress/ui` instead.",
"ActionPopover": "Use `Popover` from `@wordpress/ui` instead.",
"AdminSection": "Jetpack-specific layout component — remove from denylist to allow.",
"AdminSectionHero": "Jetpack-specific layout component — remove from denylist to allow.",
"Alert": "Use `Notice` from `@wordpress/ui` instead.",
"AutomatticBylineLogo": "Jetpack branding component — remove from denylist to allow.",
"AutomatticForAgenciesLogo": "Jetpack branding component — remove from denylist to allow.",
"AutomatticIconLogo": "Jetpack branding component — remove from denylist to allow.",
"BoostScoreBar": "Jetpack-specific visualization — remove from denylist to allow.",
"BoostScoreGraph": "Jetpack-specific visualization — remove from denylist to allow.",
"Button": "Use `Button` from `@wordpress/ui` instead.",
"Chip": "Use `Badge` from `@wordpress/ui` instead.",
"cleanLocale": "Jetpack utility — remove from denylist to allow.",
"Col": "Jetpack layout component — remove from denylist to allow or use `Stack` from `@wordpress/ui`.",
"Container": "Jetpack layout component — remove from denylist to allow or use `Stack` from `@wordpress/ui`.",
Comment on lines +16 to +17
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Via @keoshi:

Col and Container seem good to deprecate in favor of Stack . The instruction seems a bit conservative, but we should do it.

"ContextualUpgradeTrigger": "Jetpack-specific upsell component — remove from denylist to allow.",
"CopyToClipboard": "Use `ClipboardButton` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
"DecorativeCard": "Use `Card` from `@wordpress/ui` instead.",
"DetailsViewer": "Jetpack-specific component — remove from denylist to allow.",
"Dialog": "Use `Dialog` from `@wordpress/ui` instead.",
"DiffViewer": "Jetpack-specific component — remove from denylist to allow.",
"DonutMeter": "Jetpack-specific visualization — remove from denylist to allow.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@keoshi noted:

DonutMeter not related to wp-ui, but we are consolidating all data viz pieces in the chart library; e.g.: https://automattic.github.io/jetpack-storybook/?path=/docs/js-packages-charts-library-charts-pie-chart--docs

Copy link
Copy Markdown
Member Author

@simison simison Jun 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"DonutMeter": "Jetpack-specific visualization — remove from denylist to allow.",
"DonutMeter": "Use `PieChart` from `@automattic/charts` instead.",

See package

"DotPager": "Jetpack-specific component — remove from denylist to allow.",
"getProductCheckoutUrl": "Jetpack utility — remove from denylist to allow.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"getProductCheckoutUrl": "Jetpack utility — remove from denylist to allow.",

Remove utility.

"GlobalNotices": "Use `@wordpress/notices` instead.",
"globalNoticesStore": "Use `@wordpress/notices` instead.",
"Gravatar": "Use `@gravatar-com/hovercards` or `@wordpress/components` when appropriate.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Gravatar": "Use `@gravatar-com/hovercards` or `@wordpress/components` when appropriate.",

Gravatar component is fine until we have something in UI and/or Gravatar package.

"H2": "Use `Text` with `variant=\"heading-*\"` from `@wordpress/ui` instead.",
"H3": "Use `Text` with `variant=\"heading-*\"` from `@wordpress/ui` instead.",
Comment on lines +30 to +31
Copy link
Copy Markdown
Member Author

@simison simison Jun 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to map to a specific variants.

"IconTooltip": "Use `Tooltip` from `@wordpress/ui` instead.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"IconTooltip": "Use `Tooltip` from `@wordpress/ui` instead.",
"IconTooltip": "Use combination of `Icon` and `Tooltip` from `@wordpress/ui` instead.",

"IconsCard": "Jetpack-specific component — remove from denylist to allow.",
"IndeterminateProgressBar": "Use `Spinner` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend Spinner and/or ProgressBar?

"isFirstMonthTrial": "Jetpack utility — remove from denylist to allow.",
"JetpackFooter": "Jetpack branding component — remove from denylist to allow.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"JetpackFooter": "Jetpack branding component — remove from denylist to allow.",

Allow

"JetpackProtectLogo": "Jetpack branding component — remove from denylist to allow.",
"JetpackSearchLogo": "Jetpack branding component — remove from denylist to allow.",
"JetpackVaultPressBackupLogo": "Jetpack branding component — remove from denylist to allow.",
"JetpackVideoPressLogo": "Jetpack branding component — remove from denylist to allow.",
Comment on lines +37 to +40
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allow logos?

Suggested change
"JetpackProtectLogo": "Jetpack branding component — remove from denylist to allow.",
"JetpackSearchLogo": "Jetpack branding component — remove from denylist to allow.",
"JetpackVaultPressBackupLogo": "Jetpack branding component — remove from denylist to allow.",
"JetpackVideoPressLogo": "Jetpack branding component — remove from denylist to allow.",

"LoadingPlaceholder": "Use `Placeholder` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect, Placeholder has a different purpose.

Keep allowing or migrate to Spinner/ProgressBar for now?

"MarkedLines": "Jetpack-specific component — remove from denylist to allow.",
"NavigatorModal": "Use `Dialog` from `@wordpress/ui` and `Navigator` from `@wordpress/components` instead.",
"Notice": "Use `Notice` from `@wordpress/ui` instead.",
"NumberControl": "Use `__experimentalNumberControl` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
"NumberSlider": "Use `RangeControl` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
"Popover": "Use `Popover` from `@wordpress/ui` instead.",
"PricingTable": "Jetpack-specific pricing component — remove from denylist to allow.",
"PricingTableColumn": "Jetpack-specific pricing component — remove from denylist to allow.",
"PricingTableHeader": "Jetpack-specific pricing component — remove from denylist to allow.",
"PricingTableItem": "Jetpack-specific pricing component — remove from denylist to allow.",
"ProductOffer": "Jetpack-specific pricing component — remove from denylist to allow.",
"ProductPrice": "Jetpack-specific pricing component — remove from denylist to allow.",
"ProgressBar": "Use `ProgressBar` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
"QRCode": "Jetpack-specific component — remove from denylist to allow.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"QRCode": "Jetpack-specific component — remove from denylist to allow.",

Used twice in the repo.

"RadioControl": "Use `RadioControl` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
"RecordMeterBar": "Jetpack-specific visualization — remove from denylist to allow.",
"Spinner": "Use `Spinner` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
"SplitButton": "Use `Button` from `@wordpress/ui` (and `Dropdown` from `@wordpress/components` if needed) instead.",
"StatCard": "Jetpack-specific component — remove from denylist to allow.",
"Status": "Use `Badge` from `@wordpress/ui` instead.",
"TermsOfService": "Jetpack-specific component — remove from denylist to allow.",
"Testimonials": "Jetpack-specific component — remove from denylist to allow.",
"Text": "Use `Text` from `@wordpress/ui` instead.",
"ThemeProvider": "Use `@wordpress/theme` instead.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might still continue provide Jetpack brand color but that said, it's a bit heavy-handed way to supply it.

"Title": "Use `Text` from `@wordpress/ui` instead.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to pin a specific variant.

"ToggleControl": "Use `ToggleControl` from `@wordpress/components` (no `@wordpress/ui` equivalent yet).",
"useBreakpointMatch": "Jetpack layout hook — remove from denylist to allow.",
"useGlobalNotices": "Use `@wordpress/notices` instead.",
"ZendeskChat": "Jetpack-specific integration — remove from denylist to allow."
},
"subpaths": {
"button": "Use `Button` from `@wordpress/ui` instead.",
"global-notices": "Use `@wordpress/notices` instead.",
"gravatar": "Use `@gravatar-com/hovercards` or `@wordpress/components` when appropriate.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"gravatar": "Use `@gravatar-com/hovercards` or `@wordpress/components` when appropriate.",

Allow.

"jetpack-footer": "Jetpack branding component — remove from denylist to allow.",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"jetpack-footer": "Jetpack branding component — remove from denylist to allow.",

Allow

"pricing-table": "Jetpack-specific pricing component — remove from denylist to allow.",
"product-price": "Jetpack-specific pricing component — remove from denylist to allow."
}
}
34 changes: 34 additions & 0 deletions tools/eslint/jetpack-components-denylist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Jetpack components denylist

Curate discouraged `@automattic/jetpack-components` imports in
[`jetpack-components-denylist.json`](./jetpack-components-denylist.json).

The ESLint rule `@automattic/jetpack/use-recommended-jetpack-components` reads this
file and reports imports that match entries in `components` or `subpaths`.

## How to curate

1. Open `jetpack-components-denylist.json`.
2. **Allow a component:** delete its key from `components` (or `subpaths` for subpath imports).
3. **Change the lint message:** edit the string value or `{ "message": "..." }` object.
4. **Add a component:** add a key under `components` for named imports from the main
package, or under `subpaths` for `@automattic/jetpack-components/<path>` imports.

When writing messages, prefer **`@wordpress/ui`** over **`@wordpress/components`**
when both packages ship the same primitive (e.g. `Button`, `Text`, `Popover`,
`Notice`, `Dialog`, `Badge`, `Card`). Use `@wordpress/components` only when there
is no `@wordpress/ui` equivalent yet (e.g. `Spinner`, `ToggleControl`).

Message placeholders:

- `{{ name }}` — imported component or subpath name
- `{{ source }}` — full import source (e.g. `@automattic/jetpack-components`)

## Suppressions

Existing violations are tracked in [`eslint-excludelist.json`](../eslint-excludelist.json).

## Scope

The rule is disabled inside `projects/js-packages/components/` so the package can
import its own modules.
Loading
Loading