mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Make rules more type-safe
This commit is contained in:
@@ -10,9 +10,9 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from 'fs';
|
} from 'fs';
|
||||||
import { rmSync } from 'node:fs';
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { default as htmlPlugin } from './src/rules/html';
|
import { default as htmlPlugin } from './src/rules/html';
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
*
|
*
|
||||||
* http://www.dspace.org/license/
|
* http://www.dspace.org/license/
|
||||||
*/
|
*/
|
||||||
|
/* eslint-disable import/no-namespace */
|
||||||
import {
|
import {
|
||||||
bundle,
|
bundle,
|
||||||
RuleExports,
|
RuleExports,
|
||||||
|
@@ -5,12 +5,23 @@
|
|||||||
*
|
*
|
||||||
* http://www.dspace.org/license/
|
* http://www.dspace.org/license/
|
||||||
*/
|
*/
|
||||||
|
import { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
|
||||||
|
import { getTemplateParserServices } from '@angular-eslint/utils';
|
||||||
|
import {
|
||||||
|
ESLintUtils,
|
||||||
|
TSESLint,
|
||||||
|
} from '@typescript-eslint/utils';
|
||||||
|
|
||||||
import { fixture } from '../../../test/fixture';
|
import { fixture } from '../../../test/fixture';
|
||||||
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
import {
|
||||||
|
DSpaceESLintRuleInfo,
|
||||||
|
NamedTests,
|
||||||
|
} from '../../util/structure';
|
||||||
import {
|
import {
|
||||||
DISALLOWED_THEME_SELECTORS,
|
DISALLOWED_THEME_SELECTORS,
|
||||||
fixSelectors,
|
fixSelectors,
|
||||||
} from '../../util/theme-support';
|
} from '../../util/theme-support';
|
||||||
|
import { getFilename } from '../../util/typescript';
|
||||||
|
|
||||||
export enum Message {
|
export enum Message {
|
||||||
WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
|
WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
|
||||||
@@ -36,39 +47,38 @@ The only exception to this rule are unit tests, where we may want to use the bas
|
|||||||
defaultOptions: [],
|
defaultOptions: [],
|
||||||
} as DSpaceESLintRuleInfo;
|
} as DSpaceESLintRuleInfo;
|
||||||
|
|
||||||
export const rule = {
|
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||||
...info,
|
...info,
|
||||||
create(context: any) {
|
create(context: TSESLint.RuleContext<Message, unknown[]>) {
|
||||||
if (context.getFilename().includes('.spec.ts')) {
|
if (getFilename(context).includes('.spec.ts')) {
|
||||||
// skip inline templates in unit tests
|
// skip inline templates in unit tests
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parserServices = getTemplateParserServices(context as any);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: any) {
|
[`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: TmplAstElement) {
|
||||||
|
const { startSourceSpan, endSourceSpan } = node;
|
||||||
|
const openStart = startSourceSpan.start.offset as number;
|
||||||
|
|
||||||
context.report({
|
context.report({
|
||||||
messageId: Message.WRONG_SELECTOR,
|
messageId: Message.WRONG_SELECTOR,
|
||||||
node,
|
loc: parserServices.convertNodeSourceSpanToLoc(startSourceSpan),
|
||||||
fix(fixer: any) {
|
fix(fixer) {
|
||||||
const oldSelector = node.name;
|
const oldSelector = node.name;
|
||||||
const newSelector = fixSelectors(oldSelector);
|
const newSelector = fixSelectors(oldSelector);
|
||||||
|
|
||||||
const openTagRange = [
|
|
||||||
node.startSourceSpan.start.offset + 1,
|
|
||||||
node.startSourceSpan.start.offset + 1 + oldSelector.length,
|
|
||||||
];
|
|
||||||
|
|
||||||
const ops = [
|
const ops = [
|
||||||
fixer.replaceTextRange(openTagRange, newSelector),
|
fixer.replaceTextRange([openStart + 1, openStart + 1 + oldSelector.length], newSelector),
|
||||||
];
|
];
|
||||||
|
|
||||||
// make sure we don't mangle self-closing tags
|
// make sure we don't mangle self-closing tags
|
||||||
if (node.startSourceSpan.end.offset !== node.endSourceSpan.end.offset) {
|
if (endSourceSpan !== null && startSourceSpan.end.offset !== endSourceSpan.end.offset) {
|
||||||
const closeTagRange = [
|
const closeStart = endSourceSpan.start.offset as number;
|
||||||
node.endSourceSpan.start.offset + 2,
|
const closeEnd = endSourceSpan.end.offset as number;
|
||||||
node.endSourceSpan.end.offset - 1,
|
|
||||||
];
|
ops.push(fixer.replaceTextRange([closeStart + 2, closeEnd - 1], newSelector));
|
||||||
ops.push(fixer.replaceTextRange(closeTagRange, newSelector));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ops;
|
return ops;
|
||||||
@@ -77,7 +87,7 @@ export const rule = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export const tests = {
|
export const tests = {
|
||||||
plugin: info.name,
|
plugin: info.name,
|
||||||
@@ -167,6 +177,6 @@ class Test {
|
|||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
} as NamedTests;
|
||||||
|
|
||||||
export default rule;
|
export default rule;
|
||||||
|
@@ -1,9 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
import {
|
||||||
bundle,
|
bundle,
|
||||||
RuleExports,
|
RuleExports,
|
||||||
} from '../../util/structure';
|
} from '../../util/structure';
|
||||||
import * as themedComponentUsages from './themed-component-usages';
|
/* eslint-disable import/no-namespace */
|
||||||
import * as themedComponentSelectors from './themed-component-selectors';
|
import * as themedComponentSelectors from './themed-component-selectors';
|
||||||
|
import * as themedComponentUsages from './themed-component-usages';
|
||||||
|
|
||||||
const index = [
|
const index = [
|
||||||
themedComponentUsages,
|
themedComponentUsages,
|
||||||
|
@@ -5,9 +5,13 @@
|
|||||||
*
|
*
|
||||||
* http://www.dspace.org/license/
|
* http://www.dspace.org/license/
|
||||||
*/
|
*/
|
||||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
import {
|
||||||
import { fixture } from '../../../test/fixture';
|
ESLintUtils,
|
||||||
|
TSESLint,
|
||||||
|
TSESTree,
|
||||||
|
} from '@typescript-eslint/utils';
|
||||||
|
|
||||||
|
import { fixture } from '../../../test/fixture';
|
||||||
import { getComponentSelectorNode } from '../../util/angular';
|
import { getComponentSelectorNode } from '../../util/angular';
|
||||||
import { stringLiteral } from '../../util/misc';
|
import { stringLiteral } from '../../util/misc';
|
||||||
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
isThemeableComponent,
|
isThemeableComponent,
|
||||||
isThemedComponentWrapper,
|
isThemedComponentWrapper,
|
||||||
} from '../../util/theme-support';
|
} from '../../util/theme-support';
|
||||||
|
import { getFilename } from '../../util/typescript';
|
||||||
|
|
||||||
export enum Message {
|
export enum Message {
|
||||||
BASE = 'wrongSelectorUnthemedComponent',
|
BASE = 'wrongSelectorUnthemedComponent',
|
||||||
@@ -53,41 +58,43 @@ Unit tests are exempt from this rule, because they may redefine components using
|
|||||||
|
|
||||||
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||||
...info,
|
...info,
|
||||||
create(context: any): any {
|
create(context: TSESLint.RuleContext<Message, unknown[]>) {
|
||||||
if (context.getFilename()?.endsWith('.spec.ts')) {
|
const filename = getFilename(context);
|
||||||
|
|
||||||
|
if (filename.endsWith('.spec.ts')) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function enforceWrapperSelector(selectorNode: any) {
|
function enforceWrapperSelector(selectorNode: TSESTree.StringLiteral) {
|
||||||
if (selectorNode?.value.startsWith('ds-themed-')) {
|
if (selectorNode?.value.startsWith('ds-themed-')) {
|
||||||
context.report({
|
context.report({
|
||||||
messageId: Message.WRAPPER,
|
messageId: Message.WRAPPER,
|
||||||
node: selectorNode,
|
node: selectorNode,
|
||||||
fix(fixer: any) {
|
fix(fixer) {
|
||||||
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-')));
|
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-')));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enforceBaseSelector(selectorNode: any) {
|
function enforceBaseSelector(selectorNode: TSESTree.StringLiteral) {
|
||||||
if (!selectorNode?.value.startsWith('ds-base-')) {
|
if (!selectorNode?.value.startsWith('ds-base-')) {
|
||||||
context.report({
|
context.report({
|
||||||
messageId: Message.BASE,
|
messageId: Message.BASE,
|
||||||
node: selectorNode,
|
node: selectorNode,
|
||||||
fix(fixer: any) {
|
fix(fixer) {
|
||||||
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-')));
|
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-')));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function enforceThemedSelector(selectorNode: any) {
|
function enforceThemedSelector(selectorNode: TSESTree.StringLiteral) {
|
||||||
if (!selectorNode?.value.startsWith('ds-themed-')) {
|
if (!selectorNode?.value.startsWith('ds-themed-')) {
|
||||||
context.report({
|
context.report({
|
||||||
messageId: Message.THEMED,
|
messageId: Message.THEMED,
|
||||||
node: selectorNode,
|
node: selectorNode,
|
||||||
fix(fixer: any) {
|
fix(fixer) {
|
||||||
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-')));
|
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-')));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -95,11 +102,15 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: any) {
|
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) {
|
||||||
// keep track of all @Component nodes by their selector
|
|
||||||
const selectorNode = getComponentSelectorNode(node);
|
const selectorNode = getComponentSelectorNode(node);
|
||||||
|
|
||||||
|
if (selectorNode === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const selector = selectorNode?.value;
|
const selector = selectorNode?.value;
|
||||||
const classNode = node.parent;
|
const classNode = node.parent as TSESTree.ClassDeclaration;
|
||||||
const className = classNode.id?.name;
|
const className = classNode.id?.name;
|
||||||
|
|
||||||
if (selector === undefined || className === undefined) {
|
if (selector === undefined || className === undefined) {
|
||||||
@@ -108,7 +119,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
|
|
||||||
if (isThemedComponentWrapper(node)) {
|
if (isThemedComponentWrapper(node)) {
|
||||||
enforceWrapperSelector(selectorNode);
|
enforceWrapperSelector(selectorNode);
|
||||||
} else if (inThemedComponentOverrideFile(context)) {
|
} else if (inThemedComponentOverrideFile(filename)) {
|
||||||
enforceThemedSelector(selectorNode);
|
enforceThemedSelector(selectorNode);
|
||||||
} else if (isThemeableComponent(className)) {
|
} else if (isThemeableComponent(className)) {
|
||||||
enforceBaseSelector(selectorNode);
|
enforceBaseSelector(selectorNode);
|
||||||
@@ -124,11 +135,11 @@ export const tests = {
|
|||||||
{
|
{
|
||||||
name: 'Regular non-themeable component selector',
|
name: 'Regular non-themeable component selector',
|
||||||
code: `
|
code: `
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-something',
|
selector: 'ds-something',
|
||||||
})
|
})
|
||||||
class Something {
|
class Something {
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@@ -5,9 +5,13 @@
|
|||||||
*
|
*
|
||||||
* http://www.dspace.org/license/
|
* http://www.dspace.org/license/
|
||||||
*/
|
*/
|
||||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
import {
|
||||||
|
ESLintUtils,
|
||||||
|
TSESLint,
|
||||||
|
TSESTree,
|
||||||
|
} from '@typescript-eslint/utils';
|
||||||
|
|
||||||
import { fixture } from '../../../test/fixture';
|
import { fixture } from '../../../test/fixture';
|
||||||
import { findUsages } from '../../util/misc';
|
|
||||||
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
||||||
import {
|
import {
|
||||||
allThemeableComponents,
|
allThemeableComponents,
|
||||||
@@ -17,6 +21,10 @@ import {
|
|||||||
inThemedComponentFile,
|
inThemedComponentFile,
|
||||||
isAllowedUnthemedUsage,
|
isAllowedUnthemedUsage,
|
||||||
} from '../../util/theme-support';
|
} from '../../util/theme-support';
|
||||||
|
import {
|
||||||
|
findUsages,
|
||||||
|
getFilename,
|
||||||
|
} from '../../util/typescript';
|
||||||
|
|
||||||
export enum Message {
|
export enum Message {
|
||||||
WRONG_CLASS = 'mustUseThemedWrapperClass',
|
WRONG_CLASS = 'mustUseThemedWrapperClass',
|
||||||
@@ -52,8 +60,10 @@ There are a few exceptions where the base class can still be used:
|
|||||||
|
|
||||||
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||||
...info,
|
...info,
|
||||||
create(context: any, options: any): any {
|
create(context: TSESLint.RuleContext<Message, unknown[]>) {
|
||||||
function handleUnthemedUsagesInTypescript(node: any) {
|
const filename = getFilename(context);
|
||||||
|
|
||||||
|
function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) {
|
||||||
if (isAllowedUnthemedUsage(node)) {
|
if (isAllowedUnthemedUsage(node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,24 +78,24 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
context.report({
|
context.report({
|
||||||
messageId: Message.WRONG_CLASS,
|
messageId: Message.WRONG_CLASS,
|
||||||
node: node,
|
node: node,
|
||||||
fix(fixer: any) {
|
fix(fixer) {
|
||||||
return fixer.replaceText(node, entry.wrapperClass);
|
return fixer.replaceText(node, entry.wrapperClass);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleThemedSelectorQueriesInTests(node: any) {
|
function handleThemedSelectorQueriesInTests(node: TSESTree.Literal) {
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
messageId: Message.WRONG_SELECTOR,
|
messageId: Message.WRONG_SELECTOR,
|
||||||
fix(fixer: any){
|
fix(fixer){
|
||||||
const newSelector = fixSelectors(node.raw);
|
const newSelector = fixSelectors(node.raw);
|
||||||
return fixer.replaceText(node, newSelector);
|
return fixer.replaceText(node, newSelector);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnthemedImportsInTypescript(specifierNode: any) {
|
function handleUnthemedImportsInTypescript(specifierNode: TSESTree.ImportSpecifier) {
|
||||||
const allUsages = findUsages(context, specifierNode.local);
|
const allUsages = findUsages(context, specifierNode.local);
|
||||||
const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage));
|
const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage));
|
||||||
|
|
||||||
@@ -94,7 +104,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const importedNode = specifierNode.imported;
|
const importedNode = specifierNode.imported;
|
||||||
const declarationNode = specifierNode.parent;
|
const declarationNode = specifierNode.parent as TSESTree.ImportDeclaration;
|
||||||
|
|
||||||
const entry = getThemeableComponentByBaseClass(importedNode.name);
|
const entry = getThemeableComponentByBaseClass(importedNode.name);
|
||||||
if (entry === undefined) {
|
if (entry === undefined) {
|
||||||
@@ -105,7 +115,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
context.report({
|
context.report({
|
||||||
messageId: Message.WRONG_IMPORT,
|
messageId: Message.WRONG_IMPORT,
|
||||||
node: importedNode,
|
node: importedNode,
|
||||||
fix(fixer: any) {
|
fix(fixer) {
|
||||||
const ops = [];
|
const ops = [];
|
||||||
|
|
||||||
const oldImportSource = declarationNode.source.value;
|
const oldImportSource = declarationNode.source.value;
|
||||||
@@ -128,17 +138,17 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ignore tests and non-routing modules
|
// ignore tests and non-routing modules
|
||||||
if (context.getFilename()?.endsWith('.spec.ts')) {
|
if (filename.endsWith('.spec.ts')) {
|
||||||
return {
|
return {
|
||||||
[`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
|
[`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
|
||||||
};
|
};
|
||||||
} else if (context.getFilename()?.endsWith('.cy.ts')) {
|
} else if (filename.endsWith('.cy.ts')) {
|
||||||
return {
|
return {
|
||||||
[`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
|
[`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
|
||||||
};
|
};
|
||||||
} else if (
|
} else if (
|
||||||
context.getFilename()?.match(/(?!routing).module.ts$/)
|
filename.match(/(?!routing).module.ts$/)
|
||||||
|| context.getFilename()?.match(/themed-.+\.component\.ts$/)
|
|| filename.match(/themed-.+\.component\.ts$/)
|
||||||
|| inThemedComponentFile(context)
|
|| inThemedComponentFile(context)
|
||||||
) {
|
) {
|
||||||
// do nothing
|
// do nothing
|
||||||
|
@@ -5,12 +5,24 @@
|
|||||||
*
|
*
|
||||||
* http://www.dspace.org/license/
|
* http://www.dspace.org/license/
|
||||||
*/
|
*/
|
||||||
|
import { TSESTree } from '@typescript-eslint/utils';
|
||||||
|
|
||||||
export function getComponentSelectorNode(componentDecoratorNode: any): any | undefined {
|
import { getObjectPropertyNodeByName } from './typescript';
|
||||||
for (const property of componentDecoratorNode.expression.arguments[0].properties) {
|
|
||||||
if (property.key?.name === 'selector') {
|
export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined {
|
||||||
return property?.value;
|
const initializer = (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression;
|
||||||
|
const property = getObjectPropertyNodeByName(initializer, 'selector');
|
||||||
|
|
||||||
|
if (property !== undefined) {
|
||||||
|
// todo: support template literals as well
|
||||||
|
if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'string') {
|
||||||
|
return property as TSESTree.StringLiteral;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPartOfViewChild(node: TSESTree.Identifier): boolean {
|
||||||
|
return (node.parent as any)?.callee?.name === 'ViewChild';
|
||||||
|
}
|
||||||
|
@@ -6,37 +6,11 @@
|
|||||||
* http://www.dspace.org/license/
|
* http://www.dspace.org/license/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function stringLiteral(value: string): string {
|
|
||||||
return `'${value}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function match(rangeA: number[], rangeB: number[]) {
|
export function match(rangeA: number[], rangeB: number[]) {
|
||||||
return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1];
|
return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findUsages(context: any, localNode: any): any[] {
|
|
||||||
const ast = context.getSourceCode().ast;
|
|
||||||
|
|
||||||
const usages: any[] = [];
|
export function stringLiteral(value: string): string {
|
||||||
|
return `'${value}'`;
|
||||||
for (const token of ast.tokens) {
|
|
||||||
if (token.type === 'Identifier' && token.value === localNode.name && !match(token.range, localNode.range)) {
|
|
||||||
usages.push(context.getSourceCode().getNodeByRangeIndex(token.range[0]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return usages;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function isPartOfTypeExpression(node: any): boolean {
|
|
||||||
return node.parent.type.startsWith('TSType');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isClassDeclaration(node: any): boolean {
|
|
||||||
return node.parent.type === 'ClassDeclaration';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPartOfViewChild(node: any): boolean {
|
|
||||||
return node.parent?.callee?.name === 'ViewChild';
|
|
||||||
}
|
}
|
||||||
|
@@ -10,53 +10,42 @@ import { RuleTester } from 'eslint';
|
|||||||
import { EnumType } from 'typescript';
|
import { EnumType } from 'typescript';
|
||||||
|
|
||||||
export type Meta = TSESLint.RuleMetaData<string>;
|
export type Meta = TSESLint.RuleMetaData<string>;
|
||||||
export type Valid = RuleTester.ValidTestCase | TSESLint.ValidTestCase<unknown[]>;
|
export type Valid = TSESLint.ValidTestCase<unknown[]> | RuleTester.ValidTestCase;
|
||||||
export type Invalid = RuleTester.InvalidTestCase | TSESLint.InvalidTestCase<string, unknown[]>;
|
export type Invalid = TSESLint.InvalidTestCase<string, unknown[]> | RuleTester.InvalidTestCase;
|
||||||
|
|
||||||
|
|
||||||
export interface DSpaceESLintRuleInfo {
|
export interface DSpaceESLintRuleInfo {
|
||||||
name: string;
|
name: string;
|
||||||
meta: Meta,
|
meta: Meta,
|
||||||
defaultOptions: any[],
|
defaultOptions: unknown[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DSpaceESLintTestInfo {
|
export interface NamedTests {
|
||||||
rule: string;
|
plugin: string;
|
||||||
valid: Valid[];
|
valid: Valid[];
|
||||||
invalid: Invalid[];
|
invalid: Invalid[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DSpaceESLintPluginInfo {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
rules: DSpaceESLintRuleInfo;
|
|
||||||
tests: DSpaceESLintTestInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DSpaceESLintInfo {
|
|
||||||
html: DSpaceESLintPluginInfo;
|
|
||||||
ts: DSpaceESLintPluginInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuleExports {
|
export interface RuleExports {
|
||||||
Message: EnumType,
|
Message: EnumType,
|
||||||
info: DSpaceESLintRuleInfo,
|
info: DSpaceESLintRuleInfo,
|
||||||
rule: any,
|
rule: TSESLint.RuleModule<string>,
|
||||||
tests: any,
|
tests: NamedTests,
|
||||||
default: any,
|
default: unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginExports {
|
||||||
|
name: string,
|
||||||
|
language: string,
|
||||||
|
rules: Record<string, unknown>,
|
||||||
|
index: RuleExports[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bundle(
|
export function bundle(
|
||||||
name: string,
|
name: string,
|
||||||
language: string,
|
language: string,
|
||||||
index: RuleExports[],
|
index: RuleExports[],
|
||||||
): {
|
): PluginExports {
|
||||||
name: string,
|
return index.reduce((o: PluginExports, i: RuleExports) => {
|
||||||
language: string,
|
|
||||||
rules: Record<string, any>,
|
|
||||||
index: RuleExports[],
|
|
||||||
} {
|
|
||||||
return index.reduce((o: any, i: any) => {
|
|
||||||
o.rules[i.info.name] = i.rule;
|
o.rules[i.info.name] = i.rule;
|
||||||
return o;
|
return o;
|
||||||
}, {
|
}, {
|
||||||
|
@@ -6,17 +6,18 @@
|
|||||||
* http://www.dspace.org/license/
|
* http://www.dspace.org/license/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { TSESTree } from '@typescript-eslint/utils';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { basename } from 'path';
|
import { basename } from 'path';
|
||||||
import ts from 'typescript';
|
import ts, { Identifier } from 'typescript';
|
||||||
|
|
||||||
|
import { isPartOfViewChild } from './angular';
|
||||||
import {
|
import {
|
||||||
isClassDeclaration,
|
AnyRuleContext,
|
||||||
|
getFilename,
|
||||||
|
isPartOfClassDeclaration,
|
||||||
isPartOfTypeExpression,
|
isPartOfTypeExpression,
|
||||||
isPartOfViewChild,
|
} from './typescript';
|
||||||
} from './misc';
|
|
||||||
|
|
||||||
const glob = require('glob');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Couples a themeable Component to its ThemedComponent wrapper
|
* Couples a themeable Component to its ThemedComponent wrapper
|
||||||
@@ -31,6 +32,42 @@ export interface ThemeableComponentRegistryEntry {
|
|||||||
wrapperClass: string;
|
wrapperClass: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAngularComponentDecorator(node: ts.Node) {
|
||||||
|
if (node.kind === ts.SyntaxKind.Decorator && node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
|
||||||
|
const decorator = node as ts.Decorator;
|
||||||
|
|
||||||
|
if (decorator.expression.kind === ts.SyntaxKind.CallExpression) {
|
||||||
|
const method = decorator.expression as ts.CallExpression;
|
||||||
|
|
||||||
|
if (method.expression.kind === ts.SyntaxKind.Identifier) {
|
||||||
|
return (method.expression as Identifier).escapedText === 'Component';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findImportDeclaration(source: ts.SourceFile, identifierName: string): ts.ImportDeclaration | undefined {
|
||||||
|
return ts.forEachChild(source, (topNode: ts.Node) => {
|
||||||
|
if (topNode.kind === ts.SyntaxKind.ImportDeclaration) {
|
||||||
|
const importDeclaration = topNode as ts.ImportDeclaration;
|
||||||
|
|
||||||
|
if (importDeclaration.importClause?.namedBindings?.kind === ts.SyntaxKind.NamedImports) {
|
||||||
|
const namedImports = importDeclaration.importClause?.namedBindings as ts.NamedImports;
|
||||||
|
|
||||||
|
for (const element of namedImports.elements) {
|
||||||
|
if (element.name.escapedText === identifierName) {
|
||||||
|
return importDeclaration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listing of all themeable Components
|
* Listing of all themeable Components
|
||||||
*/
|
*/
|
||||||
@@ -55,20 +92,37 @@ class ThemeableComponentRegistry {
|
|||||||
function registerWrapper(path: string) {
|
function registerWrapper(path: string) {
|
||||||
const source = getSource(path);
|
const source = getSource(path);
|
||||||
|
|
||||||
function traverse(node: any) {
|
function traverse(node: ts.Node) {
|
||||||
if (node.kind === ts.SyntaxKind.Decorator && node.expression.expression.escapedText === 'Component' && node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
|
if (node.parent !== undefined && isAngularComponentDecorator(node)) {
|
||||||
const wrapperClass = node.parent.name.escapedText;
|
const classNode = node.parent as ts.ClassDeclaration;
|
||||||
|
|
||||||
for (const heritageClause of node.parent.heritageClauses) {
|
if (classNode.name === undefined || classNode.heritageClauses === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperClass = classNode.name?.escapedText as string;
|
||||||
|
|
||||||
|
for (const heritageClause of classNode.heritageClauses) {
|
||||||
for (const type of heritageClause.types) {
|
for (const type of heritageClause.types) {
|
||||||
if (type.expression.escapedText === 'ThemedComponent') {
|
if ((type as any).expression.escapedText === 'ThemedComponent') {
|
||||||
const baseClass = type.typeArguments[0].typeName?.escapedText;
|
if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
ts.forEachChild(source, (topNode: any) => {
|
const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode;
|
||||||
if (topNode.kind === ts.SyntaxKind.ImportDeclaration) {
|
const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText;
|
||||||
for (const element of topNode.importClause.namedBindings.elements) {
|
|
||||||
if (element.name.escapedText === baseClass) {
|
if (baseClass === undefined) {
|
||||||
const basePath = resolveLocalPath(topNode.moduleSpecifier.text, path);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importDeclaration = findImportDeclaration(source, baseClass);
|
||||||
|
|
||||||
|
if (importDeclaration === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = resolveLocalPath((importDeclaration.moduleSpecifier as ts.StringLiteral).text, path);
|
||||||
|
|
||||||
themeableComponents.add({
|
themeableComponents.add({
|
||||||
baseClass,
|
baseClass,
|
||||||
@@ -81,10 +135,6 @@ class ThemeableComponentRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
@@ -95,6 +145,8 @@ class ThemeableComponentRegistry {
|
|||||||
traverse(source);
|
traverse(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const glob = require('glob');
|
||||||
|
|
||||||
const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found;
|
const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found;
|
||||||
|
|
||||||
for (const wrapper of wrappers) {
|
for (const wrapper of wrappers) {
|
||||||
@@ -142,8 +194,16 @@ function resolveLocalPath(path: string, relativeTo: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isThemedComponentWrapper(node: any): boolean {
|
export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boolean {
|
||||||
return node.parent.superClass?.name === 'ThemedComponent';
|
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isThemeableComponent(className: string): boolean {
|
export function isThemeableComponent(className: string): boolean {
|
||||||
@@ -151,8 +211,8 @@ export function isThemeableComponent(className: string): boolean {
|
|||||||
return themeableComponents.byBaseClass.has(className);
|
return themeableComponents.byBaseClass.has(className);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inThemedComponentOverrideFile(context: any): boolean {
|
export function inThemedComponentOverrideFile(filename: string): boolean {
|
||||||
const match = context.getFilename().match(/src\/themes\/[^\/]+\/(app\/.*)/);
|
const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return false;
|
return false;
|
||||||
@@ -162,13 +222,14 @@ export function inThemedComponentOverrideFile(context: any): boolean {
|
|||||||
return themeableComponents.byBasePath.has(`src/${match[1]}`);
|
return themeableComponents.byBasePath.has(`src/${match[1]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inThemedComponentFile(context: any): boolean {
|
export function inThemedComponentFile(context: AnyRuleContext): boolean {
|
||||||
themeableComponents.initialize();
|
themeableComponents.initialize();
|
||||||
|
const filename = getFilename(context);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
() => themeableComponents.byBasePath.has(context.getFilename()),
|
() => themeableComponents.byBasePath.has(filename),
|
||||||
() => themeableComponents.byWrapperPath.has(context.getFilename()),
|
() => themeableComponents.byWrapperPath.has(filename),
|
||||||
() => inThemedComponentOverrideFile(context),
|
() => inThemedComponentOverrideFile(filename),
|
||||||
].some(predicate => predicate());
|
].some(predicate => predicate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,8 +243,8 @@ export function getThemeableComponentByBaseClass(baseClass: string): ThemeableCo
|
|||||||
return themeableComponents.byBaseClass.get(baseClass);
|
return themeableComponents.byBaseClass.get(baseClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAllowedUnthemedUsage(usageNode: any) {
|
export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) {
|
||||||
return isClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode);
|
return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-';
|
export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-';
|
||||||
|
75
lint/src/util/typescript.ts
Normal file
75
lint/src/util/typescript.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
TSESLint,
|
||||||
|
TSESTree,
|
||||||
|
} from '@typescript-eslint/utils';
|
||||||
|
|
||||||
|
import { match } from './misc';
|
||||||
|
|
||||||
|
export type AnyRuleContext = TSESLint.RuleContext<string, unknown[]>;
|
||||||
|
|
||||||
|
export function getFilename(context: AnyRuleContext): string {
|
||||||
|
// TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?)
|
||||||
|
// eslint-disable-next-line deprecation/deprecation
|
||||||
|
return context.getFilename();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSourceCode(context: AnyRuleContext): TSESLint.SourceCode {
|
||||||
|
// TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?)
|
||||||
|
// eslint-disable-next-line deprecation/deprecation
|
||||||
|
return context.getSourceCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectPropertyNodeByName(objectNode: TSESTree.ObjectExpression, propertyName: string): TSESTree.Node | undefined {
|
||||||
|
for (const propertyNode of objectNode.properties) {
|
||||||
|
if (
|
||||||
|
propertyNode.type === TSESTree.AST_NODE_TYPES.Property
|
||||||
|
&& (
|
||||||
|
(
|
||||||
|
propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Identifier
|
||||||
|
&& propertyNode.key?.name === propertyName
|
||||||
|
) || (
|
||||||
|
propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Literal
|
||||||
|
&& propertyNode.key?.value === propertyName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return propertyNode.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifier): TSESTree.Identifier[] {
|
||||||
|
const source = getSourceCode(context);
|
||||||
|
|
||||||
|
const usages: TSESTree.Identifier[] = [];
|
||||||
|
|
||||||
|
for (const token of source.ast.tokens) {
|
||||||
|
if (token.type === 'Identifier' && token.value === localNode.name && !match(token.range, localNode.range)) {
|
||||||
|
const node = source.getNodeByRangeIndex(token.range[0]);
|
||||||
|
if (node !== null) {
|
||||||
|
usages.push(node as TSESTree.Identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
|
||||||
|
return node.parent.type.startsWith('TSType');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean {
|
||||||
|
if (node.parent === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return node.parent.type === 'ClassDeclaration';
|
||||||
|
}
|
@@ -15,7 +15,7 @@ import {
|
|||||||
|
|
||||||
describe('TypeScript rules', () => {
|
describe('TypeScript rules', () => {
|
||||||
for (const { info, rule, tests } of tsPlugin.index) {
|
for (const { info, rule, tests } of tsPlugin.index) {
|
||||||
tsRuleTester.run(info.name, rule, tests);
|
tsRuleTester.run(info.name, rule, tests as any);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester';
|
import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester';
|
||||||
import { RuleTester } from 'eslint';
|
import { RuleTester } from 'eslint';
|
||||||
|
|
||||||
|
import { themeableComponents } from '../src/util/theme-support';
|
||||||
import {
|
import {
|
||||||
FIXTURE,
|
FIXTURE,
|
||||||
fixture,
|
fixture,
|
||||||
} from './fixture';
|
} from './fixture';
|
||||||
|
|
||||||
import { themeableComponents } from '../src/util/theme-support';
|
|
||||||
|
|
||||||
|
|
||||||
// Register themed components from test fixture
|
// Register themed components from test fixture
|
||||||
themeableComponents.initialize(FIXTURE);
|
themeableComponents.initialize(FIXTURE);
|
||||||
|
@@ -145,6 +145,7 @@
|
|||||||
"@angular-builders/custom-webpack": "~15.0.0",
|
"@angular-builders/custom-webpack": "~15.0.0",
|
||||||
"@angular-devkit/build-angular": "^15.2.6",
|
"@angular-devkit/build-angular": "^15.2.6",
|
||||||
"@angular-eslint/builder": "15.2.1",
|
"@angular-eslint/builder": "15.2.1",
|
||||||
|
"@angular-eslint/bundled-angular-compiler": "^17.2.1",
|
||||||
"@angular-eslint/eslint-plugin": "15.2.1",
|
"@angular-eslint/eslint-plugin": "15.2.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "15.2.1",
|
"@angular-eslint/eslint-plugin-template": "15.2.1",
|
||||||
"@angular-eslint/schematics": "15.2.1",
|
"@angular-eslint/schematics": "15.2.1",
|
||||||
|
@@ -291,6 +291,11 @@
|
|||||||
resolved "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-15.2.1.tgz"
|
resolved "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-15.2.1.tgz"
|
||||||
integrity sha512-LO7Am8eVCr7oh6a0VmKSL7K03CnQEQhFO7Wt/YtbfYOxVjrbwmYLwJn+wZPOT7A02t/BttOD/WXuDrOWtSMQ/Q==
|
integrity sha512-LO7Am8eVCr7oh6a0VmKSL7K03CnQEQhFO7Wt/YtbfYOxVjrbwmYLwJn+wZPOT7A02t/BttOD/WXuDrOWtSMQ/Q==
|
||||||
|
|
||||||
|
"@angular-eslint/bundled-angular-compiler@^17.2.1":
|
||||||
|
version "17.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-17.2.1.tgz#d849b0845371b41856b9f598af81ce5bf799bca0"
|
||||||
|
integrity sha512-puC0itsZv2QlrDOCcWtq1KZH+DvfrpV+mV78HHhi6+h25R5iIhr8ARKcl3EQxFjvrFq34jhG8pSupxKvFbHVfA==
|
||||||
|
|
||||||
"@angular-eslint/eslint-plugin-template@15.2.1":
|
"@angular-eslint/eslint-plugin-template@15.2.1":
|
||||||
version "15.2.1"
|
version "15.2.1"
|
||||||
resolved "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-15.2.1.tgz"
|
resolved "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-15.2.1.tgz"
|
||||||
|
Reference in New Issue
Block a user