Skip to content

Commit f5001ac

Browse files
committed
Add PF relative import eslint rule
1 parent edb2f9c commit f5001ac

File tree

5 files changed

+198
-0
lines changed

5 files changed

+198
-0
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module.exports = {
1818
},
1919
],
2020
'react/no-unknown-property': ['error', { ignore: ['widget-type', 'widget-id', 'page-type', 'ouiaId'] }],
21+
'rulesdir/forbid-pf-relative-imports': 1,
2122
},
2223
overrides: [
2324
{

packages/create-crc-app/templates/src/Routes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { Suspense, lazy } from 'react';
22
import { Redirect, Route, Switch } from 'react-router-dom';
33

4+
// eslint-disable-next-line rulesdir/forbid-pf-relative-imports
45
import { Bullseye, Spinner } from '@patternfly/react-core';
56

67
const SamplePage = lazy(() => import(/* webpackChunkName: "SamplePage" */ './Routes/SamplePage/SamplePage'));

packages/create-crc-app/templates/src/Routes/SamplePage/SamplePage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { Suspense, lazy, useEffect } from 'react';
22
import { Link } from 'react-router-dom';
33
import { useDispatch } from 'react-redux';
44

5+
// eslint-disable-next-line rulesdir/forbid-pf-relative-imports
56
import { Button, Spinner, Stack, StackItem, Title } from '@patternfly/react-core';
67
import { Main } from '@redhat-cloud-services/frontend-components/Main';
78
import { PageHeader, PageHeaderTitle } from '@redhat-cloud-services/frontend-components/PageHeader';

packages/eslint-config/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module.exports = {
2424
'rulesdir/disallow-fec-relative-imports': 2,
2525
'rulesdir/deprecated-packages': 1,
2626
'rulesdir/no-chrome-api-call-from-window': 2,
27+
'rulesdir/forbid-pf-relative-imports': 2,
2728
},
2829
globals: {
2930
CRC_APP_NAME: 'readonly',
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* @fileoverview Rule to disallow relative imports from @patternfly packages to enable "treeshaking" in module federation environment
3+
* @author Martin Marosi
4+
*/
5+
const glob = require('glob');
6+
const path = require('path');
7+
const PFPackages = ['@patternfly/react-core', '@patternfly/react-icons', '@patternfly/tokens'];
8+
9+
const PROPS_MATCH = /Props$/g;
10+
const VARIANT_MATCH = /Variants?$/g;
11+
const POSITION_MATCH = /Position$/g;
12+
13+
const ICONS_NAME_FIX = {
14+
AnsibeTowerIcon: 'ansibeTower-icon',
15+
ChartSpikeIcon: 'chartSpike-icon',
16+
CloudServerIcon: 'cloudServer-icon',
17+
};
18+
19+
let CORE_CACHE = {};
20+
let COMPONENTS_CACHE = {};
21+
22+
function findCoreComponent(name) {
23+
if (CORE_CACHE[name]) {
24+
return CORE_CACHE[name];
25+
}
26+
let source = glob
27+
.sync(path.resolve(process.cwd(), `node_modules/${PFPackages[0]}/dist/esm/**/${name}.js`))
28+
.filter((path) => !path.includes('deprecated'))?.[0];
29+
if (!source && name.match(PROPS_MATCH)) {
30+
source = glob
31+
.sync(path.resolve(process.cwd(), `node_modules/${PFPackages[0]}/dist/esm/**/${name.replace(PROPS_MATCH, '')}.js`))
32+
.filter((path) => !path.includes('deprecated'))?.[0];
33+
}
34+
if (!source && name.match(VARIANT_MATCH)) {
35+
source = glob
36+
.sync(path.resolve(process.cwd(), `node_modules/${PFPackages[0]}/dist/esm/**/${name.replace(VARIANT_MATCH, '')}.js`))
37+
.filter((path) => !path.includes('deprecated'))?.[0];
38+
}
39+
if (!source && name.match(POSITION_MATCH)) {
40+
source = glob
41+
.sync(path.resolve(process.cwd(), `node_modules/${PFPackages[0]}/dist/esm/**/${name.replace(POSITION_MATCH, '')}.js`))
42+
.filter((path) => !path.includes('deprecated'))?.[0];
43+
}
44+
if (!source) {
45+
return false;
46+
}
47+
let dynamicSource = source.split('node_modules/').pop().replace('/esm/', '/dynamic/').split('/');
48+
dynamicSource.pop();
49+
dynamicSource = dynamicSource.join('/');
50+
CORE_CACHE[name] = dynamicSource;
51+
return dynamicSource;
52+
}
53+
54+
function camelToDash(str) {
55+
return str.replace(/([A-Z])/g, (g) => `-${g[0].toLowerCase()}`).replace(/^-/, '');
56+
}
57+
58+
function findIcon(name) {
59+
if (COMPONENTS_CACHE[name]) {
60+
return COMPONENTS_CACHE[name];
61+
}
62+
const nameSpecifier = ICONS_NAME_FIX[name] || camelToDash(name);
63+
return `@patternfly/react-icons/dist/dynamic/icons/${nameSpecifier}`;
64+
}
65+
66+
CORE_CACHE = {
67+
...CORE_CACHE,
68+
getResizeObserver: findCoreComponent('resizeObserver'),
69+
useOUIAProps: findCoreComponent('ouia'),
70+
OUIAProps: findCoreComponent('ouia'),
71+
getDefaultOUIAId: findCoreComponent('ouia'),
72+
useOUIAId: findCoreComponent('ouia'),
73+
handleArrows: findCoreComponent('KeyboardHandler'),
74+
setTabIndex: findCoreComponent('KeyboardHandler'),
75+
IconComponentProps: findCoreComponent('Icon'),
76+
TreeViewDataItem: findCoreComponent('TreeView'),
77+
Popper: findCoreComponent('Popper/Popper'),
78+
};
79+
80+
module.exports = {
81+
meta: {
82+
type: 'suggestion',
83+
docs: {
84+
description: 'forbid relative imports from PF packages',
85+
category: 'Possible build errors',
86+
recommended: true,
87+
},
88+
fixable: 'code',
89+
messages: {
90+
avoidRelativeImport:
91+
'Avoid using relative imports from {{ package }}. Use direct import path to {{ source }}. Module may be found at {{ hint }}.',
92+
avoidRelativeIconImport:
93+
'Avoid using relative imports from {{ package }}. Use direct import path to {{ source }}. Module may be found at {{ hint }}.',
94+
avoidImportingStyles: 'Avoid importing styles from {{ package }}. Styles are injected with components automatically.',
95+
},
96+
},
97+
create: function (context) {
98+
return {
99+
ImportDeclaration: function (codePath) {
100+
const importString = codePath.source.value;
101+
const pfImport = importString === PFPackages[0];
102+
const iconsImport = importString === PFPackages[1];
103+
if (PFPackages.includes(importString) && importString.match(/(css|scss|sass)/gim)) {
104+
context.report({
105+
node: codePath,
106+
messageId: 'avoidImportingStyles',
107+
data: {
108+
package: importString,
109+
},
110+
fix: function (fixer) {
111+
return fixer.remove(codePath.parent);
112+
},
113+
});
114+
}
115+
116+
/**
117+
* Check if import is from FEC package and if it directly matches the package name which means its relative import
118+
*/
119+
if (pfImport && PFPackages.includes(importString)) {
120+
const fullImport = context.getSourceCode(codePath.parent).text;
121+
/**
122+
* Check if the import is not full import statement
123+
*/
124+
if (!fullImport.includes('from')) {
125+
return;
126+
}
127+
/**
128+
* Determine correct variable for direct import
129+
*/
130+
131+
let variables = context.getDeclaredVariables(codePath);
132+
let varName = 'Unknown';
133+
if (variables.length > 0) {
134+
varName = variables[0].name;
135+
}
136+
137+
const newText = variables.map(function (data) {
138+
const importPartial = findCoreComponent(data.name);
139+
return `import { ${data.name} } from '${importPartial}'`;
140+
});
141+
context.report({
142+
node: codePath,
143+
messageId: 'avoidRelativeImport',
144+
data: {
145+
package: importString,
146+
source: varName,
147+
hint: newText.join('\n'),
148+
},
149+
fix: function (fixer) {
150+
return fixer.replaceText(codePath, newText.join('\n'));
151+
},
152+
});
153+
}
154+
155+
/**
156+
* Check icons import
157+
*/
158+
if (iconsImport) {
159+
const fullImport = context.getSourceCode(codePath.parent).text;
160+
/**
161+
* Check if the import is not full import statement
162+
*/
163+
if (!fullImport.includes('from')) {
164+
return;
165+
}
166+
167+
let variables = context.getDeclaredVariables(codePath);
168+
let varName = 'Unknown';
169+
if (variables.length > 0) {
170+
varName = variables[0].name;
171+
}
172+
173+
const newText = variables.map(function (data) {
174+
const importPartial = findIcon(data.name);
175+
return `import ${data.name} from '${importPartial}'`;
176+
});
177+
178+
context.report({
179+
node: codePath,
180+
messageId: 'avoidRelativeIconImport',
181+
data: {
182+
package: importString,
183+
source: varName,
184+
hint: newText.join('\n'),
185+
},
186+
fix: function (fixer) {
187+
return fixer.replaceText(codePath, newText.join('\n'));
188+
},
189+
});
190+
}
191+
},
192+
};
193+
},
194+
};

0 commit comments

Comments
 (0)