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,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { default as htmlPlugin } from './src/rules/html';
|
||||
|
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
|
||||
/* eslint-disable import/no-namespace */
|
||||
import {
|
||||
bundle,
|
||||
RuleExports,
|
||||
|
@@ -5,12 +5,23 @@
|
||||
*
|
||||
* 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 { DSpaceESLintRuleInfo } from '../../util/structure';
|
||||
import {
|
||||
DSpaceESLintRuleInfo,
|
||||
NamedTests,
|
||||
} from '../../util/structure';
|
||||
import {
|
||||
DISALLOWED_THEME_SELECTORS,
|
||||
fixSelectors,
|
||||
} from '../../util/theme-support';
|
||||
import { getFilename } from '../../util/typescript';
|
||||
|
||||
export enum Message {
|
||||
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: [],
|
||||
} as DSpaceESLintRuleInfo;
|
||||
|
||||
export const rule = {
|
||||
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
...info,
|
||||
create(context: any) {
|
||||
if (context.getFilename().includes('.spec.ts')) {
|
||||
create(context: TSESLint.RuleContext<Message, unknown[]>) {
|
||||
if (getFilename(context).includes('.spec.ts')) {
|
||||
// skip inline templates in unit tests
|
||||
return {};
|
||||
}
|
||||
|
||||
const parserServices = getTemplateParserServices(context as any);
|
||||
|
||||
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({
|
||||
messageId: Message.WRONG_SELECTOR,
|
||||
node,
|
||||
fix(fixer: any) {
|
||||
loc: parserServices.convertNodeSourceSpanToLoc(startSourceSpan),
|
||||
fix(fixer) {
|
||||
const oldSelector = node.name;
|
||||
const newSelector = fixSelectors(oldSelector);
|
||||
|
||||
const openTagRange = [
|
||||
node.startSourceSpan.start.offset + 1,
|
||||
node.startSourceSpan.start.offset + 1 + oldSelector.length,
|
||||
];
|
||||
|
||||
const ops = [
|
||||
fixer.replaceTextRange(openTagRange, newSelector),
|
||||
fixer.replaceTextRange([openStart + 1, openStart + 1 + oldSelector.length], newSelector),
|
||||
];
|
||||
|
||||
// make sure we don't mangle self-closing tags
|
||||
if (node.startSourceSpan.end.offset !== node.endSourceSpan.end.offset) {
|
||||
const closeTagRange = [
|
||||
node.endSourceSpan.start.offset + 2,
|
||||
node.endSourceSpan.end.offset - 1,
|
||||
];
|
||||
ops.push(fixer.replaceTextRange(closeTagRange, newSelector));
|
||||
if (endSourceSpan !== null && startSourceSpan.end.offset !== endSourceSpan.end.offset) {
|
||||
const closeStart = endSourceSpan.start.offset as number;
|
||||
const closeEnd = endSourceSpan.end.offset as number;
|
||||
|
||||
ops.push(fixer.replaceTextRange([closeStart + 2, closeEnd - 1], newSelector));
|
||||
}
|
||||
|
||||
return ops;
|
||||
@@ -77,7 +87,7 @@ export const rule = {
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const tests = {
|
||||
plugin: info.name,
|
||||
@@ -167,6 +177,6 @@ class Test {
|
||||
`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} as NamedTests;
|
||||
|
||||
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 {
|
||||
bundle,
|
||||
RuleExports,
|
||||
} from '../../util/structure';
|
||||
import * as themedComponentUsages from './themed-component-usages';
|
||||
/* eslint-disable import/no-namespace */
|
||||
import * as themedComponentSelectors from './themed-component-selectors';
|
||||
import * as themedComponentUsages from './themed-component-usages';
|
||||
|
||||
const index = [
|
||||
themedComponentUsages,
|
||||
|
@@ -5,9 +5,13 @@
|
||||
*
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
import { ESLintUtils } from '@typescript-eslint/utils';
|
||||
import { fixture } from '../../../test/fixture';
|
||||
import {
|
||||
ESLintUtils,
|
||||
TSESLint,
|
||||
TSESTree,
|
||||
} from '@typescript-eslint/utils';
|
||||
|
||||
import { fixture } from '../../../test/fixture';
|
||||
import { getComponentSelectorNode } from '../../util/angular';
|
||||
import { stringLiteral } from '../../util/misc';
|
||||
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
isThemeableComponent,
|
||||
isThemedComponentWrapper,
|
||||
} from '../../util/theme-support';
|
||||
import { getFilename } from '../../util/typescript';
|
||||
|
||||
export enum Message {
|
||||
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({
|
||||
...info,
|
||||
create(context: any): any {
|
||||
if (context.getFilename()?.endsWith('.spec.ts')) {
|
||||
create(context: TSESLint.RuleContext<Message, unknown[]>) {
|
||||
const filename = getFilename(context);
|
||||
|
||||
if (filename.endsWith('.spec.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function enforceWrapperSelector(selectorNode: any) {
|
||||
function enforceWrapperSelector(selectorNode: TSESTree.StringLiteral) {
|
||||
if (selectorNode?.value.startsWith('ds-themed-')) {
|
||||
context.report({
|
||||
messageId: Message.WRAPPER,
|
||||
node: selectorNode,
|
||||
fix(fixer: any) {
|
||||
fix(fixer) {
|
||||
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-')) {
|
||||
context.report({
|
||||
messageId: Message.BASE,
|
||||
node: selectorNode,
|
||||
fix(fixer: any) {
|
||||
fix(fixer) {
|
||||
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-')) {
|
||||
context.report({
|
||||
messageId: Message.THEMED,
|
||||
node: selectorNode,
|
||||
fix(fixer: any) {
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-')));
|
||||
},
|
||||
});
|
||||
@@ -95,11 +102,15 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
}
|
||||
|
||||
return {
|
||||
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: any) {
|
||||
// keep track of all @Component nodes by their selector
|
||||
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) {
|
||||
const selectorNode = getComponentSelectorNode(node);
|
||||
|
||||
if (selectorNode === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selector = selectorNode?.value;
|
||||
const classNode = node.parent;
|
||||
const classNode = node.parent as TSESTree.ClassDeclaration;
|
||||
const className = classNode.id?.name;
|
||||
|
||||
if (selector === undefined || className === undefined) {
|
||||
@@ -108,7 +119,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
|
||||
if (isThemedComponentWrapper(node)) {
|
||||
enforceWrapperSelector(selectorNode);
|
||||
} else if (inThemedComponentOverrideFile(context)) {
|
||||
} else if (inThemedComponentOverrideFile(filename)) {
|
||||
enforceThemedSelector(selectorNode);
|
||||
} else if (isThemeableComponent(className)) {
|
||||
enforceBaseSelector(selectorNode);
|
||||
@@ -124,11 +135,11 @@ export const tests = {
|
||||
{
|
||||
name: 'Regular non-themeable component selector',
|
||||
code: `
|
||||
@Component({
|
||||
selector: 'ds-something',
|
||||
})
|
||||
class Something {
|
||||
}
|
||||
@Component({
|
||||
selector: 'ds-something',
|
||||
})
|
||||
class Something {
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
|
@@ -5,9 +5,13 @@
|
||||
*
|
||||
* 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 { findUsages } from '../../util/misc';
|
||||
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
||||
import {
|
||||
allThemeableComponents,
|
||||
@@ -17,6 +21,10 @@ import {
|
||||
inThemedComponentFile,
|
||||
isAllowedUnthemedUsage,
|
||||
} from '../../util/theme-support';
|
||||
import {
|
||||
findUsages,
|
||||
getFilename,
|
||||
} from '../../util/typescript';
|
||||
|
||||
export enum Message {
|
||||
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({
|
||||
...info,
|
||||
create(context: any, options: any): any {
|
||||
function handleUnthemedUsagesInTypescript(node: any) {
|
||||
create(context: TSESLint.RuleContext<Message, unknown[]>) {
|
||||
const filename = getFilename(context);
|
||||
|
||||
function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) {
|
||||
if (isAllowedUnthemedUsage(node)) {
|
||||
return;
|
||||
}
|
||||
@@ -68,24 +78,24 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
context.report({
|
||||
messageId: Message.WRONG_CLASS,
|
||||
node: node,
|
||||
fix(fixer: any) {
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, entry.wrapperClass);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleThemedSelectorQueriesInTests(node: any) {
|
||||
function handleThemedSelectorQueriesInTests(node: TSESTree.Literal) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: Message.WRONG_SELECTOR,
|
||||
fix(fixer: any){
|
||||
fix(fixer){
|
||||
const newSelector = fixSelectors(node.raw);
|
||||
return fixer.replaceText(node, newSelector);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnthemedImportsInTypescript(specifierNode: any) {
|
||||
function handleUnthemedImportsInTypescript(specifierNode: TSESTree.ImportSpecifier) {
|
||||
const allUsages = findUsages(context, specifierNode.local);
|
||||
const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage));
|
||||
|
||||
@@ -94,7 +104,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
}
|
||||
|
||||
const importedNode = specifierNode.imported;
|
||||
const declarationNode = specifierNode.parent;
|
||||
const declarationNode = specifierNode.parent as TSESTree.ImportDeclaration;
|
||||
|
||||
const entry = getThemeableComponentByBaseClass(importedNode.name);
|
||||
if (entry === undefined) {
|
||||
@@ -105,7 +115,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
context.report({
|
||||
messageId: Message.WRONG_IMPORT,
|
||||
node: importedNode,
|
||||
fix(fixer: any) {
|
||||
fix(fixer) {
|
||||
const ops = [];
|
||||
|
||||
const oldImportSource = declarationNode.source.value;
|
||||
@@ -128,17 +138,17 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||
}
|
||||
|
||||
// ignore tests and non-routing modules
|
||||
if (context.getFilename()?.endsWith('.spec.ts')) {
|
||||
if (filename.endsWith('.spec.ts')) {
|
||||
return {
|
||||
[`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 {
|
||||
[`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
|
||||
};
|
||||
} else if (
|
||||
context.getFilename()?.match(/(?!routing).module.ts$/)
|
||||
|| context.getFilename()?.match(/themed-.+\.component\.ts$/)
|
||||
filename.match(/(?!routing).module.ts$/)
|
||||
|| filename.match(/themed-.+\.component\.ts$/)
|
||||
|| inThemedComponentFile(context)
|
||||
) {
|
||||
// do nothing
|
||||
|
@@ -5,12 +5,24 @@
|
||||
*
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
|
||||
export function getComponentSelectorNode(componentDecoratorNode: any): any | undefined {
|
||||
for (const property of componentDecoratorNode.expression.arguments[0].properties) {
|
||||
if (property.key?.name === 'selector') {
|
||||
return property?.value;
|
||||
import { getObjectPropertyNodeByName } from './typescript';
|
||||
|
||||
export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined {
|
||||
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;
|
||||
}
|
||||
|
||||
export function isPartOfViewChild(node: TSESTree.Identifier): boolean {
|
||||
return (node.parent as any)?.callee?.name === 'ViewChild';
|
||||
}
|
||||
|
@@ -6,37 +6,11 @@
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
|
||||
export function stringLiteral(value: string): string {
|
||||
return `'${value}'`;
|
||||
}
|
||||
|
||||
export function match(rangeA: number[], rangeB: number[]) {
|
||||
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[] = [];
|
||||
|
||||
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';
|
||||
export function stringLiteral(value: string): string {
|
||||
return `'${value}'`;
|
||||
}
|
||||
|
@@ -10,53 +10,42 @@ import { RuleTester } from 'eslint';
|
||||
import { EnumType } from 'typescript';
|
||||
|
||||
export type Meta = TSESLint.RuleMetaData<string>;
|
||||
export type Valid = RuleTester.ValidTestCase | TSESLint.ValidTestCase<unknown[]>;
|
||||
export type Invalid = RuleTester.InvalidTestCase | TSESLint.InvalidTestCase<string, unknown[]>;
|
||||
|
||||
export type Valid = TSESLint.ValidTestCase<unknown[]> | RuleTester.ValidTestCase;
|
||||
export type Invalid = TSESLint.InvalidTestCase<string, unknown[]> | RuleTester.InvalidTestCase;
|
||||
|
||||
export interface DSpaceESLintRuleInfo {
|
||||
name: string;
|
||||
meta: Meta,
|
||||
defaultOptions: any[],
|
||||
defaultOptions: unknown[],
|
||||
}
|
||||
|
||||
export interface DSpaceESLintTestInfo {
|
||||
rule: string;
|
||||
export interface NamedTests {
|
||||
plugin: string;
|
||||
valid: Valid[];
|
||||
invalid: Invalid[];
|
||||
}
|
||||
|
||||
export interface DSpaceESLintPluginInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
rules: DSpaceESLintRuleInfo;
|
||||
tests: DSpaceESLintTestInfo;
|
||||
}
|
||||
|
||||
export interface DSpaceESLintInfo {
|
||||
html: DSpaceESLintPluginInfo;
|
||||
ts: DSpaceESLintPluginInfo;
|
||||
}
|
||||
|
||||
export interface RuleExports {
|
||||
Message: EnumType,
|
||||
info: DSpaceESLintRuleInfo,
|
||||
rule: any,
|
||||
tests: any,
|
||||
default: any,
|
||||
rule: TSESLint.RuleModule<string>,
|
||||
tests: NamedTests,
|
||||
default: unknown,
|
||||
}
|
||||
|
||||
export interface PluginExports {
|
||||
name: string,
|
||||
language: string,
|
||||
rules: Record<string, unknown>,
|
||||
index: RuleExports[],
|
||||
}
|
||||
|
||||
export function bundle(
|
||||
name: string,
|
||||
language: string,
|
||||
index: RuleExports[],
|
||||
): {
|
||||
name: string,
|
||||
language: string,
|
||||
rules: Record<string, any>,
|
||||
index: RuleExports[],
|
||||
} {
|
||||
return index.reduce((o: any, i: any) => {
|
||||
): PluginExports {
|
||||
return index.reduce((o: PluginExports, i: RuleExports) => {
|
||||
o.rules[i.info.name] = i.rule;
|
||||
return o;
|
||||
}, {
|
||||
|
@@ -6,17 +6,18 @@
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
import { readFileSync } from 'fs';
|
||||
import { basename } from 'path';
|
||||
import ts from 'typescript';
|
||||
import ts, { Identifier } from 'typescript';
|
||||
|
||||
import { isPartOfViewChild } from './angular';
|
||||
import {
|
||||
isClassDeclaration,
|
||||
AnyRuleContext,
|
||||
getFilename,
|
||||
isPartOfClassDeclaration,
|
||||
isPartOfTypeExpression,
|
||||
isPartOfViewChild,
|
||||
} from './misc';
|
||||
|
||||
const glob = require('glob');
|
||||
} from './typescript';
|
||||
|
||||
/**
|
||||
* Couples a themeable Component to its ThemedComponent wrapper
|
||||
@@ -31,6 +32,42 @@ export interface ThemeableComponentRegistryEntry {
|
||||
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
|
||||
*/
|
||||
@@ -55,32 +92,45 @@ class ThemeableComponentRegistry {
|
||||
function registerWrapper(path: string) {
|
||||
const source = getSource(path);
|
||||
|
||||
function traverse(node: any) {
|
||||
if (node.kind === ts.SyntaxKind.Decorator && node.expression.expression.escapedText === 'Component' && node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
|
||||
const wrapperClass = node.parent.name.escapedText;
|
||||
function traverse(node: ts.Node) {
|
||||
if (node.parent !== undefined && isAngularComponentDecorator(node)) {
|
||||
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) {
|
||||
if (type.expression.escapedText === 'ThemedComponent') {
|
||||
const baseClass = type.typeArguments[0].typeName?.escapedText;
|
||||
if ((type as any).expression.escapedText === 'ThemedComponent') {
|
||||
if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ts.forEachChild(source, (topNode: any) => {
|
||||
if (topNode.kind === ts.SyntaxKind.ImportDeclaration) {
|
||||
for (const element of topNode.importClause.namedBindings.elements) {
|
||||
if (element.name.escapedText === baseClass) {
|
||||
const basePath = resolveLocalPath(topNode.moduleSpecifier.text, path);
|
||||
const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode;
|
||||
const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText;
|
||||
|
||||
themeableComponents.add({
|
||||
baseClass,
|
||||
basePath: basePath.replace(new RegExp(`^${prefix}`), ''),
|
||||
baseFileName: basename(basePath).replace(/\.ts$/, ''),
|
||||
wrapperClass,
|
||||
wrapperPath: path.replace(new RegExp(`^${prefix}`), ''),
|
||||
wrapperFileName: basename(path).replace(/\.ts$/, ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (baseClass === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importDeclaration = findImportDeclaration(source, baseClass);
|
||||
|
||||
if (importDeclaration === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const basePath = resolveLocalPath((importDeclaration.moduleSpecifier as ts.StringLiteral).text, path);
|
||||
|
||||
themeableComponents.add({
|
||||
baseClass,
|
||||
basePath: basePath.replace(new RegExp(`^${prefix}`), ''),
|
||||
baseFileName: basename(basePath).replace(/\.ts$/, ''),
|
||||
wrapperClass,
|
||||
wrapperPath: path.replace(new RegExp(`^${prefix}`), ''),
|
||||
wrapperFileName: basename(path).replace(/\.ts$/, ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -95,6 +145,8 @@ class ThemeableComponentRegistry {
|
||||
traverse(source);
|
||||
}
|
||||
|
||||
const glob = require('glob');
|
||||
|
||||
const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found;
|
||||
|
||||
for (const wrapper of wrappers) {
|
||||
@@ -142,8 +194,16 @@ function resolveLocalPath(path: string, relativeTo: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function isThemedComponentWrapper(node: any): boolean {
|
||||
return node.parent.superClass?.name === 'ThemedComponent';
|
||||
export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boolean {
|
||||
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 {
|
||||
@@ -151,8 +211,8 @@ export function isThemeableComponent(className: string): boolean {
|
||||
return themeableComponents.byBaseClass.has(className);
|
||||
}
|
||||
|
||||
export function inThemedComponentOverrideFile(context: any): boolean {
|
||||
const match = context.getFilename().match(/src\/themes\/[^\/]+\/(app\/.*)/);
|
||||
export function inThemedComponentOverrideFile(filename: string): boolean {
|
||||
const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/);
|
||||
|
||||
if (!match) {
|
||||
return false;
|
||||
@@ -162,13 +222,14 @@ export function inThemedComponentOverrideFile(context: any): boolean {
|
||||
return themeableComponents.byBasePath.has(`src/${match[1]}`);
|
||||
}
|
||||
|
||||
export function inThemedComponentFile(context: any): boolean {
|
||||
export function inThemedComponentFile(context: AnyRuleContext): boolean {
|
||||
themeableComponents.initialize();
|
||||
const filename = getFilename(context);
|
||||
|
||||
return [
|
||||
() => themeableComponents.byBasePath.has(context.getFilename()),
|
||||
() => themeableComponents.byWrapperPath.has(context.getFilename()),
|
||||
() => inThemedComponentOverrideFile(context),
|
||||
() => themeableComponents.byBasePath.has(filename),
|
||||
() => themeableComponents.byWrapperPath.has(filename),
|
||||
() => inThemedComponentOverrideFile(filename),
|
||||
].some(predicate => predicate());
|
||||
}
|
||||
|
||||
@@ -182,8 +243,8 @@ export function getThemeableComponentByBaseClass(baseClass: string): ThemeableCo
|
||||
return themeableComponents.byBaseClass.get(baseClass);
|
||||
}
|
||||
|
||||
export function isAllowedUnthemedUsage(usageNode: any) {
|
||||
return isClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode);
|
||||
export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) {
|
||||
return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode);
|
||||
}
|
||||
|
||||
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', () => {
|
||||
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 } from 'eslint';
|
||||
|
||||
import { themeableComponents } from '../src/util/theme-support';
|
||||
import {
|
||||
FIXTURE,
|
||||
fixture,
|
||||
} from './fixture';
|
||||
|
||||
import { themeableComponents } from '../src/util/theme-support';
|
||||
|
||||
|
||||
// Register themed components from test fixture
|
||||
themeableComponents.initialize(FIXTURE);
|
||||
|
Reference in New Issue
Block a user