mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-13 04:53:06 +00:00
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:
9
lint/src/rules/ts/index.ts
Normal file
9
lint/src/rules/ts/index.ts
Normal 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,
|
||||
},
|
||||
};
|
92
lint/src/rules/ts/themed-component-selectors.ts
Normal file
92
lint/src/rules/ts/themed-component-selectors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
132
lint/src/rules/ts/themed-component-usages.ts
Normal file
132
lint/src/rules/ts/themed-component-usages.ts
Normal 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,
|
||||
};
|
||||
}, {},
|
||||
);
|
||||
}
|
||||
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user