mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Update plugins to support standalone components
- ThemedComponent wrappers should always import their base component. This ensures that it's always enough to only import the wrapper when we use it. - This implies that all themeable components must be standalone → added rules to enforce this → updated usage rule to improve declaration/import handling
This commit is contained in:
@@ -245,6 +245,7 @@
|
|||||||
"rxjs/no-nested-subscribe": "off", // todo: go over _all_ cases
|
"rxjs/no-nested-subscribe": "off", // todo: go over _all_ cases
|
||||||
|
|
||||||
// Custom DSpace Angular rules
|
// Custom DSpace Angular rules
|
||||||
|
"dspace-angular-ts/themed-component-classes": "error",
|
||||||
"dspace-angular-ts/themed-component-selectors": "error",
|
"dspace-angular-ts/themed-component-selectors": "error",
|
||||||
"dspace-angular-ts/themed-component-usages": "error"
|
"dspace-angular-ts/themed-component-usages": "error"
|
||||||
}
|
}
|
||||||
|
@@ -10,12 +10,14 @@ import {
|
|||||||
RuleExports,
|
RuleExports,
|
||||||
} from '../../util/structure';
|
} from '../../util/structure';
|
||||||
/* eslint-disable import/no-namespace */
|
/* eslint-disable import/no-namespace */
|
||||||
|
import * as themedComponentClasses from './themed-component-classes';
|
||||||
import * as themedComponentSelectors from './themed-component-selectors';
|
import * as themedComponentSelectors from './themed-component-selectors';
|
||||||
import * as themedComponentUsages from './themed-component-usages';
|
import * as themedComponentUsages from './themed-component-usages';
|
||||||
|
|
||||||
const index = [
|
const index = [
|
||||||
themedComponentUsages,
|
themedComponentClasses,
|
||||||
themedComponentSelectors,
|
themedComponentSelectors,
|
||||||
|
themedComponentUsages,
|
||||||
] as unknown as RuleExports[];
|
] as unknown as RuleExports[];
|
||||||
|
|
||||||
export = {
|
export = {
|
||||||
|
378
lint/src/rules/ts/themed-component-classes.ts
Normal file
378
lint/src/rules/ts/themed-component-classes.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
TSESLint,
|
||||||
|
TSESTree,
|
||||||
|
} from '@typescript-eslint/utils';
|
||||||
|
|
||||||
|
import { fixture } from '../../../test/fixture';
|
||||||
|
import {
|
||||||
|
getComponentImportNode,
|
||||||
|
getComponentInitializer,
|
||||||
|
getComponentStandaloneNode,
|
||||||
|
} from '../../util/angular';
|
||||||
|
import { appendObjectProperties } from '../../util/fix';
|
||||||
|
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
||||||
|
import {
|
||||||
|
getBaseComponentClassName,
|
||||||
|
inThemedComponentOverrideFile,
|
||||||
|
isThemeableComponent,
|
||||||
|
isThemedComponentWrapper,
|
||||||
|
} from '../../util/theme-support';
|
||||||
|
import { getFilename } from '../../util/typescript';
|
||||||
|
|
||||||
|
export enum Message {
|
||||||
|
NOT_STANDALONE = 'mustBeStandalone',
|
||||||
|
NOT_STANDALONE_IMPORTS_BASE = 'mustBeStandaloneAndImportBase',
|
||||||
|
WRAPPER_IMPORTS_BASE = 'wrapperShouldImportBase',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const info = {
|
||||||
|
name: 'themed-component-classes',
|
||||||
|
meta: {
|
||||||
|
docs: {
|
||||||
|
description: `Formatting rules for themeable component classes`,
|
||||||
|
},
|
||||||
|
type: 'problem',
|
||||||
|
fixable: 'code',
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
[Message.NOT_STANDALONE]: 'Themeable components must be standalone',
|
||||||
|
[Message.NOT_STANDALONE_IMPORTS_BASE]: 'Themeable component wrapper classes must be standalone and import the base class',
|
||||||
|
[Message.WRAPPER_IMPORTS_BASE]: 'Themed component wrapper classes must import the base class',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultOptions: [],
|
||||||
|
} as DSpaceESLintRuleInfo;
|
||||||
|
|
||||||
|
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
||||||
|
...info,
|
||||||
|
create(context: TSESLint.RuleContext<Message, unknown[]>) {
|
||||||
|
const filename = getFilename(context);
|
||||||
|
|
||||||
|
if (filename.endsWith('.spec.ts')) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceStandalone(decoratorNode: TSESTree.Decorator, withBaseImport = false) {
|
||||||
|
const standaloneNode = getComponentStandaloneNode(decoratorNode);
|
||||||
|
|
||||||
|
if (standaloneNode === undefined) {
|
||||||
|
// We may need to add these properties in one go
|
||||||
|
if (!withBaseImport) {
|
||||||
|
context.report({
|
||||||
|
messageId: Message.NOT_STANDALONE,
|
||||||
|
node: decoratorNode,
|
||||||
|
fix(fixer) {
|
||||||
|
const initializer = getComponentInitializer(decoratorNode);
|
||||||
|
return appendObjectProperties(context, fixer, initializer, ['standalone: true']);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!standaloneNode.value) {
|
||||||
|
context.report({
|
||||||
|
messageId: Message.NOT_STANDALONE,
|
||||||
|
node: standaloneNode,
|
||||||
|
fix(fixer) {
|
||||||
|
return fixer.replaceText(standaloneNode, 'true');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withBaseImport) {
|
||||||
|
const baseClass = getBaseComponentClassName(decoratorNode);
|
||||||
|
|
||||||
|
if (baseClass === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importsNode = getComponentImportNode(decoratorNode);
|
||||||
|
|
||||||
|
if (importsNode === undefined) {
|
||||||
|
if (standaloneNode === undefined) {
|
||||||
|
context.report({
|
||||||
|
messageId: Message.NOT_STANDALONE_IMPORTS_BASE,
|
||||||
|
node: decoratorNode,
|
||||||
|
fix(fixer) {
|
||||||
|
const initializer = getComponentInitializer(decoratorNode);
|
||||||
|
return appendObjectProperties(context, fixer, initializer, ['standalone: true', `imports: [${baseClass}]`]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
context.report({
|
||||||
|
messageId: Message.WRAPPER_IMPORTS_BASE,
|
||||||
|
node: decoratorNode,
|
||||||
|
fix(fixer) {
|
||||||
|
const initializer = getComponentInitializer(decoratorNode);
|
||||||
|
return appendObjectProperties(context, fixer, initializer, [`imports: [${baseClass}]`]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If we have an imports node, standalone: true will be enforced by another rule
|
||||||
|
|
||||||
|
const imports = importsNode.elements.map(e => (e as TSESTree.Identifier).name);
|
||||||
|
|
||||||
|
if (!imports.includes(baseClass) || imports.length > 1) {
|
||||||
|
// The wrapper should _only_ import the base component
|
||||||
|
context.report({
|
||||||
|
messageId: Message.WRAPPER_IMPORTS_BASE,
|
||||||
|
node: importsNode,
|
||||||
|
fix(fixer) {
|
||||||
|
// todo: this may leave unused imports, but that's better than mangling things
|
||||||
|
return fixer.replaceText(importsNode, `[${baseClass}]`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) {
|
||||||
|
const classNode = node.parent as TSESTree.ClassDeclaration;
|
||||||
|
const className = classNode.id?.name;
|
||||||
|
|
||||||
|
if (className === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isThemedComponentWrapper(node)) {
|
||||||
|
enforceStandalone(node, true);
|
||||||
|
} else if (inThemedComponentOverrideFile(filename)) {
|
||||||
|
enforceStandalone(node);
|
||||||
|
} else if (isThemeableComponent(className)) {
|
||||||
|
enforceStandalone(node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tests = {
|
||||||
|
plugin: info.name,
|
||||||
|
valid: [
|
||||||
|
{
|
||||||
|
name: 'Regular non-themeable component',
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-something',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
class Something {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Base component',
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-base-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
class TestThemeableTomponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wrapper component',
|
||||||
|
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
TestThemeableComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableTomponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Override component',
|
||||||
|
filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
class Override extends BaseComponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invalid: [
|
||||||
|
{
|
||||||
|
name: 'Base component must be standalone',
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-base-test-themable',
|
||||||
|
})
|
||||||
|
class TestThemeableComponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors:[
|
||||||
|
{
|
||||||
|
messageId: Message.NOT_STANDALONE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-base-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
class TestThemeableComponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wrapper component must be standalone and import base component',
|
||||||
|
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors:[
|
||||||
|
{
|
||||||
|
messageId: Message.NOT_STANDALONE_IMPORTS_BASE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TestThemeableComponent],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'Wrapper component must import base component (array present but empty)',
|
||||||
|
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors:[
|
||||||
|
{
|
||||||
|
messageId: Message.WRAPPER_IMPORTS_BASE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TestThemeableComponent],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Wrapper component must import base component (array is wrong)',
|
||||||
|
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
import { SomethingElse } from './somewhere-else';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
SomethingElse,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors:[
|
||||||
|
{
|
||||||
|
messageId: Message.WRAPPER_IMPORTS_BASE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
import { SomethingElse } from './somewhere-else';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TestThemeableComponent],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}, {
|
||||||
|
name: 'Wrapper component must import base component (array is wrong)',
|
||||||
|
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
import { Something, SomethingElse } from './somewhere-else';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
SomethingElse,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors:[
|
||||||
|
{
|
||||||
|
messageId: Message.WRAPPER_IMPORTS_BASE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
import { Something, SomethingElse } from './somewhere-else';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TestThemeableComponent],
|
||||||
|
})
|
||||||
|
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Override component must be standalone',
|
||||||
|
filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-test-themable',
|
||||||
|
})
|
||||||
|
class Override extends BaseComponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors:[
|
||||||
|
{
|
||||||
|
messageId: Message.NOT_STANDALONE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-test-themable',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
class Override extends BaseComponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
@@ -12,6 +12,10 @@ import {
|
|||||||
} from '@typescript-eslint/utils';
|
} from '@typescript-eslint/utils';
|
||||||
|
|
||||||
import { fixture } from '../../../test/fixture';
|
import { fixture } from '../../../test/fixture';
|
||||||
|
import {
|
||||||
|
removeWithCommas,
|
||||||
|
replaceOrRemoveArrayIdentifier,
|
||||||
|
} from '../../util/fix';
|
||||||
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
import { DSpaceESLintRuleInfo } from '../../util/structure';
|
||||||
import {
|
import {
|
||||||
allThemeableComponents,
|
allThemeableComponents,
|
||||||
@@ -22,14 +26,18 @@ import {
|
|||||||
isAllowedUnthemedUsage,
|
isAllowedUnthemedUsage,
|
||||||
} from '../../util/theme-support';
|
} from '../../util/theme-support';
|
||||||
import {
|
import {
|
||||||
|
findImportSpecifier,
|
||||||
findUsages,
|
findUsages,
|
||||||
|
findUsagesByName,
|
||||||
getFilename,
|
getFilename,
|
||||||
|
relativePath,
|
||||||
} from '../../util/typescript';
|
} from '../../util/typescript';
|
||||||
|
|
||||||
export enum Message {
|
export enum Message {
|
||||||
WRONG_CLASS = 'mustUseThemedWrapperClass',
|
WRONG_CLASS = 'mustUseThemedWrapperClass',
|
||||||
WRONG_IMPORT = 'mustImportThemedWrapper',
|
WRONG_IMPORT = 'mustImportThemedWrapper',
|
||||||
WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
|
WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
|
||||||
|
BASE_IN_MODULE = 'baseComponentNotNeededInModule',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const info = {
|
export const info = {
|
||||||
@@ -53,6 +61,7 @@ There are a few exceptions where the base class can still be used:
|
|||||||
[Message.WRONG_CLASS]: 'Themeable components should be used via their ThemedComponent wrapper',
|
[Message.WRONG_CLASS]: 'Themeable components should be used via their ThemedComponent wrapper',
|
||||||
[Message.WRONG_IMPORT]: 'Themeable components should be used via their ThemedComponent wrapper',
|
[Message.WRONG_IMPORT]: 'Themeable components should be used via their ThemedComponent wrapper',
|
||||||
[Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper',
|
[Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper',
|
||||||
|
[Message.BASE_IN_MODULE]: 'Base themeable components shouldn\'t be declared in modules',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultOptions: [],
|
defaultOptions: [],
|
||||||
@@ -79,7 +88,11 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
messageId: Message.WRONG_CLASS,
|
messageId: Message.WRONG_CLASS,
|
||||||
node: node,
|
node: node,
|
||||||
fix(fixer) {
|
fix(fixer) {
|
||||||
|
if (node.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
|
||||||
|
return replaceOrRemoveArrayIdentifier(context, fixer, node, entry.wrapperClass);
|
||||||
|
} else {
|
||||||
return fixer.replaceText(node, entry.wrapperClass);
|
return fixer.replaceText(node, entry.wrapperClass);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,18 +131,36 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
fix(fixer) {
|
fix(fixer) {
|
||||||
const ops = [];
|
const ops = [];
|
||||||
|
|
||||||
const oldImportSource = declarationNode.source.value;
|
const wrapperImport = findImportSpecifier(context, entry.wrapperClass);
|
||||||
const newImportLine = `import { ${entry.wrapperClass} } from '${oldImportSource.replace(entry.baseFileName, entry.wrapperFileName)}';`;
|
|
||||||
|
if (findUsagesByName(context, entry.wrapperClass).length === 0) {
|
||||||
|
// Wrapper is not present in this file, safe to add import
|
||||||
|
|
||||||
|
const newImportLine = `import { ${entry.wrapperClass} } from '${relativePath(filename, entry.wrapperPath)}';`;
|
||||||
|
|
||||||
if (declarationNode.specifiers.length === 1) {
|
if (declarationNode.specifiers.length === 1) {
|
||||||
if (allUsages.length === badUsages.length) {
|
if (allUsages.length === badUsages.length) {
|
||||||
ops.push(fixer.replaceText(declarationNode, newImportLine));
|
ops.push(fixer.replaceText(declarationNode, newImportLine));
|
||||||
} else {
|
} else if (wrapperImport === undefined) {
|
||||||
ops.push(fixer.insertTextAfter(declarationNode, newImportLine));
|
ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ops.push(fixer.replaceText(specifierNode, entry.wrapperClass));
|
ops.push(...removeWithCommas(context, fixer, specifierNode));
|
||||||
ops.push(fixer.insertTextAfter(declarationNode, newImportLine));
|
if (wrapperImport === undefined) {
|
||||||
|
ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Wrapper already present in the file, remove import instead
|
||||||
|
|
||||||
|
if (allUsages.length === badUsages.length) {
|
||||||
|
if (declarationNode.specifiers.length === 1) {
|
||||||
|
// Make sure we remove the newline as well
|
||||||
|
ops.push(fixer.removeRange([declarationNode.range[0], declarationNode.range[1] + 1]));
|
||||||
|
} else {
|
||||||
|
ops.push(...removeWithCommas(context, fixer, specifierNode));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ops;
|
return ops;
|
||||||
@@ -147,9 +178,8 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|||||||
[`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 (
|
||||||
filename.match(/(?!routing).module.ts$/)
|
filename.match(/(?!src\/themes\/).*(?!routing).module.ts$/)
|
||||||
|| filename.match(/themed-.+\.component\.ts$/)
|
|| filename.match(/themed-.+\.component\.ts$/)
|
||||||
|| inThemedComponentFile(context)
|
|
||||||
) {
|
) {
|
||||||
// do nothing
|
// do nothing
|
||||||
return {};
|
return {};
|
||||||
@@ -174,7 +204,7 @@ export const tests = {
|
|||||||
{
|
{
|
||||||
name: 'allow wrapper class usages',
|
name: 'allow wrapper class usages',
|
||||||
code: `
|
code: `
|
||||||
import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts';
|
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
a: ThemedTestThemeableComponent,
|
a: ThemedTestThemeableComponent,
|
||||||
@@ -192,7 +222,7 @@ export class TestThemeableComponent {
|
|||||||
{
|
{
|
||||||
name: 'allow inheriting from base class',
|
name: 'allow inheriting from base class',
|
||||||
code: `
|
code: `
|
||||||
import { TestThemeableComponent } from '../test/test-themeable.component.ts';
|
import { TestThemeableComponent } from './app/test/test-themeable.component';
|
||||||
|
|
||||||
export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableComponent> {
|
export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
}
|
}
|
||||||
@@ -201,7 +231,7 @@ export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableCo
|
|||||||
{
|
{
|
||||||
name: 'allow base class in ViewChild',
|
name: 'allow base class in ViewChild',
|
||||||
code: `
|
code: `
|
||||||
import { TestThemeableComponent } from '../test/test-themeable.component.ts';
|
import { TestThemeableComponent } from './app/test/test-themeable.component';
|
||||||
|
|
||||||
export class Something {
|
export class Something {
|
||||||
@ViewChild(TestThemeableComponent) test: TestThemeableComponent;
|
@ViewChild(TestThemeableComponent) test: TestThemeableComponent;
|
||||||
@@ -229,8 +259,8 @@ By.Css('#test > ds-themeable > #nest');
|
|||||||
{
|
{
|
||||||
name: 'disallow direct usages of base class',
|
name: 'disallow direct usages of base class',
|
||||||
code: `
|
code: `
|
||||||
import { TestThemeableComponent } from '../test/test-themeable.component.ts';
|
import { TestThemeableComponent } from './app/test/test-themeable.component';
|
||||||
import { TestComponent } from '../test/test.component.ts';
|
import { TestComponent } from './app/test/test.component';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
a: TestThemeableComponent,
|
a: TestThemeableComponent,
|
||||||
@@ -246,8 +276,8 @@ const config = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
output: `
|
output: `
|
||||||
import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts';
|
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
|
||||||
import { TestComponent } from '../test/test.component.ts';
|
import { TestComponent } from './app/test/test.component';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
a: ThemedTestThemeableComponent,
|
a: ThemedTestThemeableComponent,
|
||||||
@@ -255,6 +285,61 @@ const config = {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'disallow direct usages of base class, keep other imports',
|
||||||
|
code: `
|
||||||
|
import { Something, TestThemeableComponent } from './app/test/test-themeable.component';
|
||||||
|
import { TestComponent } from './app/test/test.component';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
a: TestThemeableComponent,
|
||||||
|
b: TestComponent,
|
||||||
|
c: Something,
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
messageId: Message.WRONG_IMPORT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
messageId: Message.WRONG_CLASS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
import { Something } from './app/test/test-themeable.component';
|
||||||
|
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
|
||||||
|
import { TestComponent } from './app/test/test.component';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
a: ThemedTestThemeableComponent,
|
||||||
|
b: TestComponent,
|
||||||
|
c: Something,
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'handle array replacements correctly',
|
||||||
|
code: `
|
||||||
|
const DECLARATIONS = [
|
||||||
|
Something,
|
||||||
|
TestThemeableComponent,
|
||||||
|
Something,
|
||||||
|
ThemedTestThemeableComponent,
|
||||||
|
];
|
||||||
|
`,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
messageId: Message.WRONG_CLASS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
const DECLARATIONS = [
|
||||||
|
Something,
|
||||||
|
Something,
|
||||||
|
ThemedTestThemeableComponent,
|
||||||
|
];
|
||||||
|
`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'disallow override selector in test queries',
|
name: 'disallow override selector in test queries',
|
||||||
filename: fixture('src/app/test/test.component.spec.ts'),
|
filename: fixture('src/app/test/test.component.spec.ts'),
|
||||||
@@ -337,30 +422,18 @@ cy.get('#test > ds-themeable > #nest');
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'edge case: unable to find usage node through usage token, but import is still flagged and fixed',
|
name: 'edge case: unable to find usage node through usage token, but import is still flagged and fixed',
|
||||||
|
filename: fixture('src/themes/test/app/test/other-themeable.component.ts'),
|
||||||
code: `
|
code: `
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
import { Context } from '../../core/shared/context.model';
|
import { Context } from './app/core/shared/context.model';
|
||||||
import { TestThemeableComponent } from '../test/test-themeable.component.ts';
|
import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-admin-search-page',
|
|
||||||
templateUrl: './admin-search-page.component.html',
|
|
||||||
styleUrls: ['./admin-search-page.component.scss'],
|
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [TestThemeableComponent],
|
||||||
TestThemeableComponent
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
export class UsageComponent {
|
||||||
/**
|
|
||||||
* Component that represents a search page for administrators
|
|
||||||
*/
|
|
||||||
export class AdminSearchPageComponent {
|
|
||||||
/**
|
|
||||||
* The context of this page
|
|
||||||
*/
|
|
||||||
context: Context = Context.AdminSearch;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
errors: [
|
errors: [
|
||||||
@@ -374,27 +447,53 @@ export class AdminSearchPageComponent {
|
|||||||
output: `
|
output: `
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
import { Context } from '../../core/shared/context.model';
|
import { Context } from './app/core/shared/context.model';
|
||||||
import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts';
|
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-admin-search-page',
|
|
||||||
templateUrl: './admin-search-page.component.html',
|
|
||||||
styleUrls: ['./admin-search-page.component.scss'],
|
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [ThemedTestThemeableComponent],
|
||||||
ThemedTestThemeableComponent
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
export class UsageComponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'edge case edge case: both are imported, only wrapper is retained',
|
||||||
|
filename: fixture('src/themes/test/app/test/other-themeable.component.ts'),
|
||||||
|
code: `
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
/**
|
import { Context } from './app/core/shared/context.model';
|
||||||
* Component that represents a search page for administrators
|
import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
|
||||||
*/
|
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
|
||||||
export class AdminSearchPageComponent {
|
|
||||||
/**
|
@Component({
|
||||||
* The context of this page
|
standalone: true,
|
||||||
*/
|
imports: [TestThemeableComponent, ThemedTestThemeableComponent],
|
||||||
context: Context = Context.AdminSearch;
|
})
|
||||||
|
export class UsageComponent {
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
messageId: Message.WRONG_IMPORT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
messageId: Message.WRONG_CLASS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: `
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
import { Context } from './app/core/shared/context.model';
|
||||||
|
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [ThemedTestThemeableComponent],
|
||||||
|
})
|
||||||
|
export class UsageComponent {
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
@@ -10,8 +10,7 @@ import { TSESTree } from '@typescript-eslint/utils';
|
|||||||
import { getObjectPropertyNodeByName } from './typescript';
|
import { getObjectPropertyNodeByName } from './typescript';
|
||||||
|
|
||||||
export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined {
|
export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined {
|
||||||
const initializer = (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression;
|
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'selector');
|
||||||
const property = getObjectPropertyNodeByName(initializer, 'selector');
|
|
||||||
|
|
||||||
if (property !== undefined) {
|
if (property !== undefined) {
|
||||||
// todo: support template literals as well
|
// todo: support template literals as well
|
||||||
@@ -23,6 +22,62 @@ export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decora
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getComponentStandaloneNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.BooleanLiteral | undefined {
|
||||||
|
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'standalone');
|
||||||
|
|
||||||
|
if (property !== undefined) {
|
||||||
|
if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'boolean') {
|
||||||
|
return property as TSESTree.BooleanLiteral;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export function getComponentImportNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.ArrayExpression | undefined {
|
||||||
|
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'imports');
|
||||||
|
|
||||||
|
if (property !== undefined) {
|
||||||
|
if (property.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
|
||||||
|
return property as TSESTree.ArrayExpression;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined {
|
||||||
|
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoratorNode.parent.id?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoratorNode.parent.id.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentSuperClassName(decoratorNode: TSESTree.Decorator): string | undefined {
|
||||||
|
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoratorNode.parent.superClass.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentInitializer(componentDecoratorNode: TSESTree.Decorator): TSESTree.ObjectExpression {
|
||||||
|
return (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentInitializerNodeByName(componentDecoratorNode: TSESTree.Decorator, name: string): TSESTree.Node | undefined {
|
||||||
|
const initializer = getComponentInitializer(componentDecoratorNode);
|
||||||
|
return getObjectPropertyNodeByName(initializer, name);
|
||||||
|
}
|
||||||
|
|
||||||
export function isPartOfViewChild(node: TSESTree.Identifier): boolean {
|
export function isPartOfViewChild(node: TSESTree.Identifier): boolean {
|
||||||
return (node.parent as any)?.callee?.name === 'ViewChild';
|
return (node.parent as any)?.callee?.name === 'ViewChild';
|
||||||
}
|
}
|
||||||
|
125
lint/src/util/fix.ts
Normal file
125
lint/src/util/fix.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* 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 { TSESTree } from '@typescript-eslint/utils';
|
||||||
|
import {
|
||||||
|
RuleContext,
|
||||||
|
RuleFix,
|
||||||
|
RuleFixer,
|
||||||
|
} from '@typescript-eslint/utils/ts-eslint';
|
||||||
|
|
||||||
|
import { getSourceCode } from './typescript';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function appendObjectProperties(context: RuleContext<any, any>, fixer: RuleFixer, objectNode: TSESTree.ObjectExpression, properties: string[]): RuleFix {
|
||||||
|
// todo: may not handle empty objects too well
|
||||||
|
const lastProperty = objectNode.properties[objectNode.properties.length - 1];
|
||||||
|
const source = getSourceCode(context);
|
||||||
|
const nextToken = source.getTokenAfter(lastProperty);
|
||||||
|
|
||||||
|
// todo: newline & indentation are hardcoded for @Component({})
|
||||||
|
// todo: we're assuming that we need trailing commas, what if we don't?
|
||||||
|
const newPart = '\n' + properties.map(p => ` ${p},`).join('\n');
|
||||||
|
|
||||||
|
if (nextToken !== null && nextToken.value === ',') {
|
||||||
|
return fixer.insertTextAfter(nextToken, newPart);
|
||||||
|
} else {
|
||||||
|
return fixer.insertTextAfter(lastProperty, ',' + newPart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendArrayElement(context: RuleContext<any, any>, fixer: RuleFixer, arrayNode: TSESTree.ArrayExpression, value: string): RuleFix {
|
||||||
|
const source = getSourceCode(context);
|
||||||
|
|
||||||
|
if (arrayNode.elements.length === 0) {
|
||||||
|
// This is the first element
|
||||||
|
const openArray = source.getTokenByRangeStart(arrayNode.range[0]);
|
||||||
|
|
||||||
|
if (openArray == null) {
|
||||||
|
throw new Error('Unexpected null token for opening square bracket');
|
||||||
|
}
|
||||||
|
|
||||||
|
// safe to assume the list is single-line
|
||||||
|
return fixer.insertTextAfter(openArray, `${value}`);
|
||||||
|
} else {
|
||||||
|
const lastElement = arrayNode.elements[arrayNode.elements.length - 1];
|
||||||
|
|
||||||
|
if (lastElement == null) {
|
||||||
|
throw new Error('Unexpected null node in array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextToken = source.getTokenAfter(lastElement);
|
||||||
|
|
||||||
|
// todo: we don't know if the list is chopped or not, so we can't make any assumptions -- may produce output that will be flagged by other rules on the next run!
|
||||||
|
// todo: we're assuming that we need trailing commas, what if we don't?
|
||||||
|
if (nextToken !== null && nextToken.value === ',') {
|
||||||
|
return fixer.insertTextAfter(nextToken, ` ${value},`);
|
||||||
|
} else {
|
||||||
|
return fixer.insertTextAfter(lastElement, `, ${value},`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLast(elementNode: TSESTree.Node): boolean {
|
||||||
|
if (!elementNode.parent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let siblingNodes: (TSESTree.Node | null)[] = [null];
|
||||||
|
if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
|
||||||
|
siblingNodes = elementNode.parent.elements;
|
||||||
|
} else if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ImportDeclaration) {
|
||||||
|
siblingNodes = elementNode.parent.specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return elementNode === siblingNodes[siblingNodes.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWithCommas(context: RuleContext<any, any>, fixer: RuleFixer, elementNode: TSESTree.Node): RuleFix[] {
|
||||||
|
const ops = [];
|
||||||
|
|
||||||
|
const source = getSourceCode(context);
|
||||||
|
let nextToken = source.getTokenAfter(elementNode);
|
||||||
|
let prevToken = source.getTokenBefore(elementNode);
|
||||||
|
|
||||||
|
if (nextToken !== null && prevToken !== null) {
|
||||||
|
if (nextToken.value === ',') {
|
||||||
|
nextToken = source.getTokenAfter(nextToken);
|
||||||
|
if (nextToken !== null) {
|
||||||
|
ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isLast(elementNode) && prevToken.value === ',') {
|
||||||
|
prevToken = source.getTokenBefore(prevToken);
|
||||||
|
if (prevToken !== null) {
|
||||||
|
ops.push(fixer.removeRange([prevToken.range[1], elementNode.range[1]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (nextToken !== null) {
|
||||||
|
ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceOrRemoveArrayIdentifier(context: RuleContext<any, any>, fixer: RuleFixer, identifierNode: TSESTree.Identifier, newValue: string): RuleFix[] {
|
||||||
|
if (identifierNode.parent.type !== TSESTree.AST_NODE_TYPES.ArrayExpression) {
|
||||||
|
throw new Error('Parent node is not an array expression!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const array = identifierNode.parent as TSESTree.ArrayExpression;
|
||||||
|
|
||||||
|
for (const element of array.elements) {
|
||||||
|
if (element !== null && element.type === TSESTree.AST_NODE_TYPES.Identifier && element.name === newValue) {
|
||||||
|
return removeWithCommas(context, fixer, identifierNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [fixer.replaceText(identifierNode, newValue)];
|
||||||
|
}
|
@@ -11,7 +11,10 @@ import { readFileSync } from 'fs';
|
|||||||
import { basename } from 'path';
|
import { basename } from 'path';
|
||||||
import ts, { Identifier } from 'typescript';
|
import ts, { Identifier } from 'typescript';
|
||||||
|
|
||||||
import { isPartOfViewChild } from './angular';
|
import {
|
||||||
|
getComponentClassName,
|
||||||
|
isPartOfViewChild,
|
||||||
|
} from './angular';
|
||||||
import {
|
import {
|
||||||
AnyRuleContext,
|
AnyRuleContext,
|
||||||
getFilename,
|
getFilename,
|
||||||
@@ -74,12 +77,14 @@ function findImportDeclaration(source: ts.SourceFile, identifierName: string): t
|
|||||||
class ThemeableComponentRegistry {
|
class ThemeableComponentRegistry {
|
||||||
public readonly entries: Set<ThemeableComponentRegistryEntry>;
|
public readonly entries: Set<ThemeableComponentRegistryEntry>;
|
||||||
public readonly byBaseClass: Map<string, ThemeableComponentRegistryEntry>;
|
public readonly byBaseClass: Map<string, ThemeableComponentRegistryEntry>;
|
||||||
|
public readonly byWrapperClass: Map<string, ThemeableComponentRegistryEntry>;
|
||||||
public readonly byBasePath: Map<string, ThemeableComponentRegistryEntry>;
|
public readonly byBasePath: Map<string, ThemeableComponentRegistryEntry>;
|
||||||
public readonly byWrapperPath: Map<string, ThemeableComponentRegistryEntry>;
|
public readonly byWrapperPath: Map<string, ThemeableComponentRegistryEntry>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.entries = new Set();
|
this.entries = new Set();
|
||||||
this.byBaseClass = new Map();
|
this.byBaseClass = new Map();
|
||||||
|
this.byWrapperClass = new Map();
|
||||||
this.byBasePath = new Map();
|
this.byBasePath = new Map();
|
||||||
this.byWrapperPath = new Map();
|
this.byWrapperPath = new Map();
|
||||||
}
|
}
|
||||||
@@ -157,6 +162,7 @@ class ThemeableComponentRegistry {
|
|||||||
private add(entry: ThemeableComponentRegistryEntry) {
|
private add(entry: ThemeableComponentRegistryEntry) {
|
||||||
this.entries.add(entry);
|
this.entries.add(entry);
|
||||||
this.byBaseClass.set(entry.baseClass, entry);
|
this.byBaseClass.set(entry.baseClass, entry);
|
||||||
|
this.byWrapperClass.set(entry.wrapperClass, entry);
|
||||||
this.byBasePath.set(entry.basePath, entry);
|
this.byBasePath.set(entry.basePath, entry);
|
||||||
this.byWrapperPath.set(entry.wrapperPath, entry);
|
this.byWrapperPath.set(entry.wrapperPath, entry);
|
||||||
}
|
}
|
||||||
@@ -206,6 +212,23 @@ export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boo
|
|||||||
return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent';
|
return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBaseComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined {
|
||||||
|
const wrapperClass = getComponentClassName(decoratorNode);
|
||||||
|
|
||||||
|
if (wrapperClass === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
themeableComponents.initialize();
|
||||||
|
const entry = themeableComponents.byWrapperClass.get(wrapperClass);
|
||||||
|
|
||||||
|
if (entry === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.baseClass;
|
||||||
|
}
|
||||||
|
|
||||||
export function isThemeableComponent(className: string): boolean {
|
export function isThemeableComponent(className: string): boolean {
|
||||||
themeableComponents.initialize();
|
themeableComponents.initialize();
|
||||||
return themeableComponents.byBaseClass.has(className);
|
return themeableComponents.byBaseClass.has(className);
|
||||||
|
@@ -64,6 +64,24 @@ export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifi
|
|||||||
return usages;
|
return usages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findUsagesByName(context: AnyRuleContext, identifier: string): TSESTree.Identifier[] {
|
||||||
|
const source = getSourceCode(context);
|
||||||
|
|
||||||
|
const usages: TSESTree.Identifier[] = [];
|
||||||
|
|
||||||
|
for (const token of source.ast.tokens) {
|
||||||
|
if (token.type === 'Identifier' && token.value === identifier) {
|
||||||
|
const node = source.getNodeByRangeIndex(token.range[0]);
|
||||||
|
// todo: in some cases, the resulting node can actually be the whole program (!)
|
||||||
|
if (node !== null) {
|
||||||
|
usages.push(node as TSESTree.Identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
|
|
||||||
export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
|
export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
|
||||||
return node.parent?.type?.startsWith('TSType');
|
return node.parent?.type?.startsWith('TSType');
|
||||||
}
|
}
|
||||||
@@ -71,3 +89,59 @@ export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
|
|||||||
export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean {
|
export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean {
|
||||||
return node.parent?.type === 'ClassDeclaration';
|
return node.parent?.type === 'ClassDeclaration';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fromSrc(path: string): string {
|
||||||
|
const m = path.match(/^.*(src\/.+)(\.(ts|json|js)?)$/);
|
||||||
|
|
||||||
|
if (m) {
|
||||||
|
return m[1];
|
||||||
|
} else {
|
||||||
|
throw new Error(`Can't infer project-absolute TS/resource path from: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function relativePath(thisFile: string, importFile: string): string {
|
||||||
|
const fromParts = fromSrc(thisFile).split('/');
|
||||||
|
const toParts = fromSrc(importFile).split('/');
|
||||||
|
|
||||||
|
let lastCommon = 0;
|
||||||
|
for (let i = 0; i < fromParts.length - 1; i++) {
|
||||||
|
if (fromParts[i] === toParts[i]) {
|
||||||
|
lastCommon++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = toParts.slice(lastCommon, toParts.length).join('/');
|
||||||
|
const backtrack = fromParts.length - lastCommon - 1;
|
||||||
|
|
||||||
|
let prefix: string;
|
||||||
|
if (backtrack > 0) {
|
||||||
|
prefix = '../'.repeat(backtrack);
|
||||||
|
} else {
|
||||||
|
prefix = './';
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function findImportSpecifier(context: AnyRuleContext, identifier: string): TSESTree.ImportSpecifier | undefined {
|
||||||
|
const source = getSourceCode(context);
|
||||||
|
|
||||||
|
const usages: TSESTree.Identifier[] = [];
|
||||||
|
|
||||||
|
for (const token of source.ast.tokens) {
|
||||||
|
if (token.type === 'Identifier' && token.value === identifier) {
|
||||||
|
const node = source.getNodeByRangeIndex(token.range[0]);
|
||||||
|
// todo: in some cases, the resulting node can actually be the whole program (!)
|
||||||
|
if (node && node.parent && node.parent.type === TSESTree.AST_NODE_TYPES.ImportSpecifier) {
|
||||||
|
return node.parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
@@ -10,6 +10,7 @@ import { Component } from '@angular/core';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-base-test-themeable',
|
selector: 'ds-base-test-themeable',
|
||||||
template: '',
|
template: '',
|
||||||
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class TestThemeableComponent {
|
export class TestThemeableComponent {
|
||||||
}
|
}
|
||||||
|
@@ -13,6 +13,8 @@ import { TestThemeableComponent } from './test-themeable.component';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-test-themeable',
|
selector: 'ds-test-themeable',
|
||||||
template: '',
|
template: '',
|
||||||
|
standalone: true,
|
||||||
|
imports: [TestThemeableComponent],
|
||||||
})
|
})
|
||||||
export class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
export class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
|
||||||
protected getComponentName(): string {
|
protected getComponentName(): string {
|
||||||
|
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-test-themeable',
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
export class OtherThemeableComponent {
|
||||||
|
|
||||||
|
}
|
@@ -8,11 +8,13 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { OtherThemeableComponent } from './app/test/other-themeable.component';
|
||||||
import { TestThemeableComponent } from './app/test/test-themeable.component';
|
import { TestThemeableComponent } from './app/test/test-themeable.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
TestThemeableComponent,
|
TestThemeableComponent,
|
||||||
|
OtherThemeableComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TestModule {
|
export class TestModule {
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.json",
|
||||||
"include": [
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["dist"]
|
"exclude": ["dist"]
|
||||||
|
@@ -20,6 +20,7 @@ import {
|
|||||||
themeableComponents.initialize(FIXTURE);
|
themeableComponents.initialize(FIXTURE);
|
||||||
|
|
||||||
TypeScriptRuleTester.itOnly = fit;
|
TypeScriptRuleTester.itOnly = fit;
|
||||||
|
TypeScriptRuleTester.itSkip = xit;
|
||||||
|
|
||||||
export const tsRuleTester = new TypeScriptRuleTester({
|
export const tsRuleTester = new TypeScriptRuleTester({
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
|
Reference in New Issue
Block a user