Custom ESLint rules to enforce new ThemedComponent selector convention

The following cases are covered:
- ThemedComponent wrapper selectors must not start with ds-themed-
- Base component selectors must start with ds-base-
- Themed component selectors must start with ds-themed-
- The ThemedComponent wrapper must always be used in HTML
- The ThemedComponent wrapper must be used in TypeScript _where appropriate_:
  - Required
    - Explicit usages (e.g. modal instantiation, routing modules, ...)
    - By.css selector queries (in order to align with the HTML rule)
  - Unchecked
    - Non-routing modules (to ensure the components can be declared)
    - ViewChild hooks (since they need to attach to the underlying component)

All rules work with --fix to automatically migrate to the new convention
This covers most of the codebase, but minor manual adjustment are needed afterwards
This commit is contained in:
Yury Bondarenko
2024-03-14 10:00:10 +01:00
parent 41eccbbfe1
commit 3937be13f2
35 changed files with 1352 additions and 34 deletions

View File

@@ -0,0 +1,9 @@
import themedComponentSelectors from './themed-component-selectors';
import themedComponentUsages from './themed-component-usages';
export = {
rules: {
'themed-component-selectors': themedComponentSelectors,
'themed-component-usages': themedComponentUsages,
},
};

View File

@@ -0,0 +1,92 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { ESLintUtils } from '@typescript-eslint/utils';
import { getComponentSelectorNode } from '../../util/angular';
import { stringLiteral } from '../../util/misc';
import {
inThemedComponentOverrideFile,
isThemeableComponent,
isThemedComponentWrapper,
} from '../../util/theme-support';
export default ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
schema: [],
fixable: 'code',
messages: {
wrongSelectorUnthemedComponent: 'Unthemed version of themeable components should have a selector starting with \'ds-base-\'',
wrongSelectorThemedComponentWrapper: 'Themed component wrapper of themeable components shouldn\'t have a selector starting with \'ds-themed-\'',
wrongSelectorThemedComponentOverride: 'Theme override of themeable component should have a selector starting with \'ds-themed-\'',
}
},
defaultOptions: [],
create(context: any): any {
if (context.getFilename()?.endsWith('.spec.ts')) {
return {};
}
function enforceWrapperSelector(selectorNode: any) {
if (selectorNode?.value.startsWith('ds-themed-')) {
context.report({
messageId: 'wrongSelectorThemedComponentWrapper',
node: selectorNode,
fix(fixer: any) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-')));
},
});
}
}
function enforceBaseSelector(selectorNode: any) {
if (!selectorNode?.value.startsWith('ds-base-')) {
context.report({
messageId: 'wrongSelectorUnthemedComponent',
node: selectorNode,
fix(fixer: any) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-')));
},
});
}
}
function enforceThemedSelector(selectorNode: any) {
if (!selectorNode?.value.startsWith('ds-themed-')) {
context.report({
messageId: 'wrongSelectorThemedComponentOverride',
node: selectorNode,
fix(fixer: any) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-')));
},
});
}
}
return {
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: any) {
// keep track of all @Component nodes by their selector
const selectorNode = getComponentSelectorNode(node);
const selector = selectorNode?.value;
const classNode = node.parent;
const className = classNode.id?.name;
if (selector === undefined || className === undefined) {
return;
}
if (isThemedComponentWrapper(node)) {
enforceWrapperSelector(selectorNode);
} else if (inThemedComponentOverrideFile(context)) {
enforceThemedSelector(selectorNode);
} else if (isThemeableComponent(className)) {
enforceBaseSelector(selectorNode);
}
}
};
}
});

View File

@@ -0,0 +1,132 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { ESLintUtils } from '@typescript-eslint/utils';
import { findUsages } from '../../util/misc';
import {
allThemeableComponents,
DISALLOWED_THEME_SELECTORS,
fixSelectors,
getThemeableComponentByBaseClass,
inThemedComponentFile,
isAllowedUnthemedUsage,
} from '../../util/theme-support';
export default ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
schema: [],
fixable: 'code',
messages: {
mustUseThemedWrapper: 'Themeable components should be used via their ThemedComponent wrapper',
mustImportThemedWrapper: 'Themeable components should be used via their ThemedComponent wrapper',
},
},
defaultOptions: [],
create(context: any, options: any): any {
function handleUnthemedUsagesInTypescript(node: any) {
if (isAllowedUnthemedUsage(node)) {
return;
}
const entry = getThemeableComponentByBaseClass(node.name);
if (entry === undefined) {
// this should never happen
throw new Error(`No such themeable component in registry: '${node.name}'`);
}
context.report({
messageId: 'mustUseThemedWrapper',
node: node,
fix(fixer: any) {
return fixer.replaceText(node, entry.wrapperClass);
},
});
}
function handleThemedSelectorQueriesInTests(node: any) {
}
function handleUnthemedImportsInTypescript(specifierNode: any) {
const allUsages = findUsages(context, specifierNode.local);
const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage));
if (badUsages.length === 0) {
return;
}
const importedNode = specifierNode.imported;
const declarationNode = specifierNode.parent;
const entry = getThemeableComponentByBaseClass(importedNode.name);
if (entry === undefined) {
// this should never happen
throw new Error(`No such themeable component in registry: '${importedNode.name}'`);
}
context.report({
messageId: 'mustImportThemedWrapper',
node: importedNode,
fix(fixer: any) {
const ops = [];
const oldImportSource = declarationNode.source.value;
const newImportLine = `import { ${entry.wrapperClass} } from '${oldImportSource.replace(entry.baseFileName, entry.wrapperFileName)}';`;
if (declarationNode.specifiers.length === 1) {
if (allUsages.length === badUsages.length) {
ops.push(fixer.replaceText(declarationNode, newImportLine));
} else {
ops.push(fixer.insertTextAfter(declarationNode, newImportLine));
}
} else {
ops.push(fixer.replaceText(specifierNode, entry.wrapperClass));
ops.push(fixer.insertTextAfter(declarationNode, newImportLine));
}
return ops;
},
});
}
// ignore tests and non-routing modules
if (context.getFilename()?.endsWith('.spec.ts')) {
return {
[`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`](node: any) {
context.report({
node,
messageId: 'mustUseThemedWrapper',
fix(fixer: any){
const newSelector = fixSelectors(node.raw);
return fixer.replaceText(node, newSelector);
}
});
},
};
} else if (
context.getFilename()?.match(/(?!routing).module.ts$/)
|| context.getFilename()?.match(/themed-.+\.component\.ts$/)
|| inThemedComponentFile(context)
) {
// do nothing
return {};
} else {
return allThemeableComponents().reduce(
(rules, entry) => {
return {
...rules,
[`:not(:matches(ClassDeclaration, ImportSpecifier)) > Identifier[name = "${entry.baseClass}"]`]: handleUnthemedUsagesInTypescript,
[`ImportSpecifier[imported.name = "${entry.baseClass}"]`]: handleUnthemedImportsInTypescript,
};
}, {},
);
}
},
});