diff --git a/lint/README.md b/lint/README.md
index 5fff29b1b2..1ea1fd5b65 100644
--- a/lint/README.md
+++ b/lint/README.md
@@ -1,12 +1,19 @@
-# ESLint plugins
+# DSpace ESLint plugins
Custom ESLint rules for DSpace Angular peculiarities.
-## Overview
+## Documentation
+
+The rules are split up into plugins by language:
+- [TypeScript rules](./docs/ts/index.md)
+- [HTML rules](./docs/html/index.md)
+
+> Run `yarn docs:lint` to generate this documentation!
+
+## Developing
+
+### Overview
-- Different file types must be handled by separate plugins. We support:
- - [TypeScript](./src/ts)
- - [HTML](./src/html)
- All rules are written in TypeScript and compiled into [`dist`](./dist)
- The plugins are linked into the main project dependencies from here
- These directories already contain the necessary `package.json` files to mark them as ESLint plugins
@@ -16,7 +23,7 @@ Custom ESLint rules for DSpace Angular peculiarities.
- [Custom rules in typescript-eslint](https://typescript-eslint.io/developers/custom-rules)
- [Angular ESLint](https://github.com/angular-eslint/angular-eslint)
-## Parsing project metadata in advance ~ TypeScript AST
+### Parsing project metadata in advance ~ TypeScript AST
While it is possible to retain persistent state between files during the linting process, it becomes quite complicated if the content of one file determines how we want to lint another file.
Because the two files may be linted out of order, we may not know whether the first file is wrong before we pass by the second. This means that we cannot report or fix the issue, because the first file is already detached from the linting context.
diff --git a/lint/generate-docs.ts b/lint/generate-docs.ts
new file mode 100644
index 0000000000..b3a798628f
--- /dev/null
+++ b/lint/generate-docs.ts
@@ -0,0 +1,85 @@
+/**
+ * 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 {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ writeFileSync,
+} from 'fs';
+import { rmSync } from 'node:fs';
+import { join } from 'path';
+
+import { default as htmlPlugin } from './src/rules/html';
+import { default as tsPlugin } from './src/rules/ts';
+
+const templates = new Map();
+
+function lazyEJS(path: string, data: object) {
+ if (!templates.has(path)) {
+ templates.set(path, require('ejs').compile(readFileSync(path).toString()));
+ }
+
+ return templates.get(path)(data);
+}
+
+const docsDir = join('lint', 'docs');
+const tsDir = join(docsDir, 'ts');
+const htmlDir = join(docsDir, 'html');
+
+if (existsSync(docsDir)) {
+ rmSync(docsDir, { recursive: true });
+}
+
+mkdirSync(join(tsDir, 'rules'), { recursive: true });
+mkdirSync(join(htmlDir, 'rules'), { recursive: true });
+
+function template(name: string): string {
+ return join('lint', 'src', 'util', 'templates', name);
+}
+
+// TypeScript docs
+writeFileSync(
+ join(tsDir, 'index.md'),
+ lazyEJS(template('index.ejs'), {
+ plugin: tsPlugin,
+ rules: tsPlugin.index.map(rule => rule.info),
+ }),
+);
+
+for (const rule of tsPlugin.index) {
+ writeFileSync(
+ join(tsDir, 'rules', rule.info.name + '.md'),
+ lazyEJS(template('rule.ejs'), {
+ plugin: tsPlugin,
+ rule: rule.info,
+ tests: rule.tests,
+ }),
+ );
+}
+
+// HTML docs
+writeFileSync(
+ join(htmlDir, 'index.md'),
+ lazyEJS(template('index.ejs'), {
+ plugin: htmlPlugin,
+ rules: htmlPlugin.index.map(rule => rule.info),
+ }),
+);
+
+for (const rule of htmlPlugin.index) {
+ writeFileSync(
+ join(htmlDir, 'rules', rule.info.name + '.md'),
+ lazyEJS(template('rule.ejs'), {
+ plugin: htmlPlugin,
+ rule: rule.info,
+ tests: rule.tests,
+ }),
+ );
+}
+
diff --git a/lint/src/rules/html/index.ts b/lint/src/rules/html/index.ts
index ef0b7a87ed..0ea42a3c2b 100644
--- a/lint/src/rules/html/index.ts
+++ b/lint/src/rules/html/index.ts
@@ -6,11 +6,17 @@
* http://www.dspace.org/license/
*/
-import themedComponentUsages from './themed-component-usages';
+import {
+ bundle,
+ RuleExports,
+} from '../../util/structure';
+import * as themedComponentUsages from './themed-component-usages';
+
+const index = [
+ themedComponentUsages,
+] as unknown as RuleExports[];
export = {
- rules: {
- 'themed-component-usages': themedComponentUsages,
- },
parser: require('@angular-eslint/template-parser'),
+ ...bundle('dspace-angular-html', 'HTML', index),
};
diff --git a/lint/src/rules/html/themed-component-usages.ts b/lint/src/rules/html/themed-component-usages.ts
index df0d775acb..0c083f185d 100644
--- a/lint/src/rules/html/themed-component-usages.ts
+++ b/lint/src/rules/html/themed-component-usages.ts
@@ -5,20 +5,39 @@
*
* http://www.dspace.org/license/
*/
+import { fixture } from '../../../test/fixture';
+import { DSpaceESLintRuleInfo } from '../../util/structure';
import {
DISALLOWED_THEME_SELECTORS,
fixSelectors,
} from '../../util/theme-support';
-export default {
+export enum Message {
+ WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
+}
+
+export const info = {
+ name: 'themed-component-usages',
meta: {
+ docs: {
+ description: `Themeable components should be used via the selector of their \`ThemedComponent\` wrapper class
+
+This ensures that custom themes can correctly override _all_ instances of this component.
+The only exception to this rule are unit tests, where we may want to use the base component in order to keep the test setup simple.
+ `,
+ },
type: 'problem',
fixable: 'code',
schema: [],
messages: {
- mustUseThemedWrapperSelector: 'Themeable components should be used via their ThemedComponent wrapper\'s selector',
+ [Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper\'s selector',
},
},
+ defaultOptions: [],
+} as DSpaceESLintRuleInfo;
+
+export const rule = {
+ ...info,
create(context: any) {
if (context.getFilename().includes('.spec.ts')) {
// skip inline templates in unit tests
@@ -28,7 +47,7 @@ export default {
return {
[`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: any) {
context.report({
- messageId: 'mustUseThemedWrapperSelector',
+ messageId: Message.WRONG_SELECTOR,
node,
fix(fixer: any) {
const oldSelector = node.name;
@@ -59,3 +78,95 @@ export default {
};
},
};
+
+export const tests = {
+ plugin: info.name,
+ valid: [
+ {
+ code: `
+
+
+
+ `,
+ },
+ {
+ code: `
+@Component({
+ template: ''
+})
+class Test {
+}
+ `,
+ },
+ {
+ filename: fixture('src/test.spec.ts'),
+ code: `
+@Component({
+ template: ''
+})
+class Test {
+}
+ `,
+ },
+ {
+ filename: fixture('src/test.spec.ts'),
+ code: `
+@Component({
+ template: ''
+})
+class Test {
+}
+ `,
+ },
+ ],
+ invalid: [
+ {
+ code: `
+
+
+
+ `,
+ errors: [
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ ],
+ output: `
+
+
+
+ `,
+ },
+ {
+ code: `
+
+
+
+ `,
+ errors: [
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ ],
+ output: `
+
+
+
+ `,
+ },
+ ],
+};
+
+export default rule;
diff --git a/lint/src/rules/ts/index.ts b/lint/src/rules/ts/index.ts
index b33135d7b0..2983d94386 100644
--- a/lint/src/rules/ts/index.ts
+++ b/lint/src/rules/ts/index.ts
@@ -1,9 +1,15 @@
-import themedComponentSelectors from './themed-component-selectors';
-import themedComponentUsages from './themed-component-usages';
+import {
+ bundle,
+ RuleExports,
+} from '../../util/structure';
+import * as themedComponentUsages from './themed-component-usages';
+import * as themedComponentSelectors from './themed-component-selectors';
+
+const index = [
+ themedComponentUsages,
+ themedComponentSelectors,
+] as unknown as RuleExports[];
export = {
- rules: {
- 'themed-component-selectors': themedComponentSelectors,
- 'themed-component-usages': themedComponentUsages,
- },
+ ...bundle('dspace-angular-ts', 'TypeScript', index),
};
diff --git a/lint/src/rules/ts/themed-component-selectors.ts b/lint/src/rules/ts/themed-component-selectors.ts
index cc195efa47..5c455bf1de 100644
--- a/lint/src/rules/ts/themed-component-selectors.ts
+++ b/lint/src/rules/ts/themed-component-selectors.ts
@@ -6,27 +6,53 @@
* http://www.dspace.org/license/
*/
import { ESLintUtils } from '@typescript-eslint/utils';
+import { fixture } from '../../../test/fixture';
import { getComponentSelectorNode } from '../../util/angular';
import { stringLiteral } from '../../util/misc';
+import { DSpaceESLintRuleInfo } from '../../util/structure';
import {
inThemedComponentOverrideFile,
isThemeableComponent,
isThemedComponentWrapper,
} from '../../util/theme-support';
-export default ESLintUtils.RuleCreator.withoutDocs({
+export enum Message {
+ BASE = 'wrongSelectorUnthemedComponent',
+ WRAPPER = 'wrongSelectorThemedComponentWrapper',
+ THEMED = 'wrongSelectorThemedComponentOverride',
+}
+
+export const info = {
+ name: 'themed-component-selectors',
meta: {
+ docs: {
+ description: `Themeable component selectors should follow the DSpace convention
+
+Each themeable component is comprised of a base component, a wrapper component and any number of themed components
+- Base components should have a selector starting with \`ds-base-\`
+- Themed components should have a selector starting with \`ds-themed-\`
+- Wrapper components should have a selector starting with \`ds-\`, but not \`ds-base-\` or \`ds-themed-\`
+ - This is the regular DSpace selector prefix
+ - **When making a regular component themeable, its selector prefix should be changed to \`ds-base-\`, and the new wrapper's component should reuse the previous selector**
+
+Unit tests are exempt from this rule, because they may redefine components using the same class name as other themeable components elsewhere in the source.
+ `,
+ },
type: 'problem',
schema: [],
fixable: 'code',
messages: {
- wrongSelectorUnthemedComponent: 'Unthemed version of themeable components should have a selector starting with \'ds-base-\'',
- wrongSelectorThemedComponentWrapper: 'Themed component wrapper of themeable components shouldn\'t have a selector starting with \'ds-themed-\'',
- wrongSelectorThemedComponentOverride: 'Theme override of themeable component should have a selector starting with \'ds-themed-\'',
+ [Message.BASE]: 'Unthemed version of themeable components should have a selector starting with \'ds-base-\'',
+ [Message.WRAPPER]: 'Themed component wrapper of themeable components shouldn\'t have a selector starting with \'ds-themed-\'',
+ [Message.THEMED]: 'Theme override of themeable component should have a selector starting with \'ds-themed-\'',
},
},
defaultOptions: [],
+} as DSpaceESLintRuleInfo;
+
+export const rule = ESLintUtils.RuleCreator.withoutDocs({
+ ...info,
create(context: any): any {
if (context.getFilename()?.endsWith('.spec.ts')) {
return {};
@@ -35,7 +61,7 @@ export default ESLintUtils.RuleCreator.withoutDocs({
function enforceWrapperSelector(selectorNode: any) {
if (selectorNode?.value.startsWith('ds-themed-')) {
context.report({
- messageId: 'wrongSelectorThemedComponentWrapper',
+ messageId: Message.WRAPPER,
node: selectorNode,
fix(fixer: any) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-')));
@@ -47,7 +73,7 @@ export default ESLintUtils.RuleCreator.withoutDocs({
function enforceBaseSelector(selectorNode: any) {
if (!selectorNode?.value.startsWith('ds-base-')) {
context.report({
- messageId: 'wrongSelectorUnthemedComponent',
+ messageId: Message.BASE,
node: selectorNode,
fix(fixer: any) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-')));
@@ -59,7 +85,7 @@ export default ESLintUtils.RuleCreator.withoutDocs({
function enforceThemedSelector(selectorNode: any) {
if (!selectorNode?.value.startsWith('ds-themed-')) {
context.report({
- messageId: 'wrongSelectorThemedComponentOverride',
+ messageId: Message.THEMED,
node: selectorNode,
fix(fixer: any) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-')));
@@ -91,3 +117,130 @@ export default ESLintUtils.RuleCreator.withoutDocs({
};
},
});
+
+export const tests = {
+ plugin: info.name,
+ valid: [
+ {
+ name: 'Regular non-themeable component selector',
+ code: `
+ @Component({
+ selector: 'ds-something',
+ })
+ class Something {
+ }
+ `,
+ },
+ {
+ name: 'Themeable component selector should replace the original version, unthemed version should be changed to ds-base-',
+ code: `
+@Component({
+ selector: 'ds-base-something',
+})
+class Something {
+}
+
+@Component({
+ selector: 'ds-something',
+})
+class ThemedSomething extends ThemedComponent {
+}
+
+@Component({
+ selector: 'ds-themed-something',
+})
+class OverrideSomething extends Something {
+}
+ `,
+ },
+ {
+ name: 'Other themed component wrappers should not interfere',
+ code: `
+@Component({
+ selector: 'ds-something',
+})
+class Something {
+}
+
+@Component({
+ selector: 'ds-something-else',
+})
+class ThemedSomethingElse extends ThemedComponent {
+}
+ `,
+ },
+ ],
+ invalid: [
+ {
+ name: 'Wrong selector for base component',
+ filename: fixture('src/app/test/test-themeable.component.ts'),
+ code: `
+@Component({
+ selector: 'ds-something',
+})
+class TestThemeableComponent {
+}
+ `,
+ errors: [
+ {
+ messageId: Message.BASE,
+ },
+ ],
+ output: `
+@Component({
+ selector: 'ds-base-something',
+})
+class TestThemeableComponent {
+}
+ `,
+ },
+ {
+ name: 'Wrong selector for wrapper component',
+ filename: fixture('src/app/test/themed-test-themeable.component.ts'),
+ code: `
+@Component({
+ selector: 'ds-themed-something',
+})
+class ThemedTestThemeableComponent extends ThemedComponent {
+}
+ `,
+ errors: [
+ {
+ messageId: Message.WRAPPER,
+ },
+ ],
+ output: `
+@Component({
+ selector: 'ds-something',
+})
+class ThemedTestThemeableComponent extends ThemedComponent {
+}
+ `,
+ },
+ {
+ name: 'Wrong selector for theme override',
+ filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
+ code: `
+@Component({
+ selector: 'ds-something',
+})
+class TestThememeableComponent extends BaseComponent {
+}
+ `,
+ errors: [
+ {
+ messageId: Message.THEMED,
+ },
+ ],
+ output: `
+@Component({
+ selector: 'ds-themed-something',
+})
+class TestThememeableComponent extends BaseComponent {
+}
+ `,
+ },
+ ],
+};
+
+export default rule;
diff --git a/lint/src/rules/ts/themed-component-usages.ts b/lint/src/rules/ts/themed-component-usages.ts
index 1032b1ef76..54b93363cb 100644
--- a/lint/src/rules/ts/themed-component-usages.ts
+++ b/lint/src/rules/ts/themed-component-usages.ts
@@ -6,8 +6,9 @@
* http://www.dspace.org/license/
*/
import { ESLintUtils } from '@typescript-eslint/utils';
-
+import { fixture } from '../../../test/fixture';
import { findUsages } from '../../util/misc';
+import { DSpaceESLintRuleInfo } from '../../util/structure';
import {
allThemeableComponents,
DISALLOWED_THEME_SELECTORS,
@@ -17,17 +18,40 @@ import {
isAllowedUnthemedUsage,
} from '../../util/theme-support';
-export default ESLintUtils.RuleCreator.withoutDocs({
+export enum Message {
+ WRONG_CLASS = 'mustUseThemedWrapperClass',
+ WRONG_IMPORT = 'mustImportThemedWrapper',
+ WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
+}
+
+export const info = {
+ name: 'themed-component-usages',
meta: {
+ docs: {
+ description: `Themeable components should be used via their \`ThemedComponent\` wrapper class
+
+This ensures that custom themes can correctly override _all_ instances of this component.
+There are a few exceptions where the base class can still be used:
+- Class declaration expressions (otherwise we can't declare, extend or override the class in the first place)
+- Angular modules (except for routing modules)
+- Angular \`@ViewChild\` decorators
+- Type annotations
+ `,
+ },
type: 'problem',
schema: [],
fixable: 'code',
messages: {
- mustUseThemedWrapper: 'Themeable components should be used via their ThemedComponent wrapper',
- mustImportThemedWrapper: 'Themeable components should be used via their ThemedComponent wrapper',
+ [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_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper',
},
},
defaultOptions: [],
+} as DSpaceESLintRuleInfo;
+
+export const rule = ESLintUtils.RuleCreator.withoutDocs({
+ ...info,
create(context: any, options: any): any {
function handleUnthemedUsagesInTypescript(node: any) {
if (isAllowedUnthemedUsage(node)) {
@@ -42,7 +66,7 @@ export default ESLintUtils.RuleCreator.withoutDocs({
}
context.report({
- messageId: 'mustUseThemedWrapper',
+ messageId: Message.WRONG_CLASS,
node: node,
fix(fixer: any) {
return fixer.replaceText(node, entry.wrapperClass);
@@ -53,7 +77,7 @@ export default ESLintUtils.RuleCreator.withoutDocs({
function handleThemedSelectorQueriesInTests(node: any) {
context.report({
node,
- messageId: 'mustUseThemedWrapper',
+ messageId: Message.WRONG_SELECTOR,
fix(fixer: any){
const newSelector = fixSelectors(node.raw);
return fixer.replaceText(node, newSelector);
@@ -79,7 +103,7 @@ export default ESLintUtils.RuleCreator.withoutDocs({
}
context.report({
- messageId: 'mustImportThemedWrapper',
+ messageId: Message.WRONG_IMPORT,
node: importedNode,
fix(fixer: any) {
const ops = [];
@@ -133,3 +157,175 @@ export default ESLintUtils.RuleCreator.withoutDocs({
},
});
+
+export const tests = {
+ plugin: info.name,
+ valid: [
+ {
+ name: 'allow wrapper class usages',
+ code: `
+import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts';
+
+const config = {
+ a: ThemedTestThemeableComponent,
+ b: ChipsComponent,
+}
+ `,
+ },
+ {
+ name: 'allow base class in class declaration',
+ code: `
+export class TestThemeableComponent {
+}
+ `,
+ },
+ {
+ name: 'allow inheriting from base class',
+ code: `
+import { TestThemeableComponent } from '../test/test-themeable.component.ts';
+
+export class ThemedAdminSidebarComponent extends ThemedComponent {
+}
+ `,
+ },
+ {
+ name: 'allow base class in ViewChild',
+ code: `
+import { TestThemeableComponent } from '../test/test-themeable.component.ts';
+
+export class Something {
+ @ViewChild(TestThemeableComponent) test: TestThemeableComponent;
+}
+ `,
+ },
+ {
+ name: 'allow wrapper selectors in test queries',
+ filename: fixture('src/app/test/test.component.spec.ts'),
+ code: `
+By.css('ds-themeable');
+By.Css('#test > ds-themeable > #nest');
+ `,
+ },
+ {
+ name: 'allow wrapper selectors in cypress queries',
+ filename: fixture('src/app/test/test.component.cy.ts'),
+ code: `
+By.css('ds-themeable');
+By.Css('#test > ds-themeable > #nest');
+ `,
+ },
+ ],
+ invalid: [
+ {
+ name: 'disallow direct usages of base class',
+ code: `
+import { TestThemeableComponent } from '../test/test-themeable.component.ts';
+import { TestComponent } from '../test/test.component.ts';
+
+const config = {
+ a: TestThemeableComponent,
+ b: TestComponent,
+}
+ `,
+ errors: [
+ {
+ messageId: Message.WRONG_IMPORT,
+ },
+ {
+ messageId: Message.WRONG_CLASS,
+ },
+ ],
+ output: `
+import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts';
+import { TestComponent } from '../test/test.component.ts';
+
+const config = {
+ a: ThemedTestThemeableComponent,
+ b: TestComponent,
+}
+ `,
+ },
+ {
+ name: 'disallow override selector in test queries',
+ filename: fixture('src/app/test/test.component.spec.ts'),
+ code: `
+By.css('ds-themed-themeable');
+By.css('#test > ds-themed-themeable > #nest');
+ `,
+ errors: [
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ ],
+ output: `
+By.css('ds-themeable');
+By.css('#test > ds-themeable > #nest');
+ `,
+ },
+ {
+ name: 'disallow base selector in test queries',
+ filename: fixture('src/app/test/test.component.spec.ts'),
+ code: `
+By.css('ds-base-themeable');
+By.css('#test > ds-base-themeable > #nest');
+ `,
+ errors: [
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ ],
+ output: `
+By.css('ds-themeable');
+By.css('#test > ds-themeable > #nest');
+ `,
+ },
+ {
+ name: 'disallow override selector in cypress queries',
+ filename: fixture('src/app/test/test.component.cy.ts'),
+ code: `
+cy.get('ds-themed-themeable');
+cy.get('#test > ds-themed-themeable > #nest');
+ `,
+ errors: [
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ ],
+ output: `
+cy.get('ds-themeable');
+cy.get('#test > ds-themeable > #nest');
+ `,
+ },
+ {
+ name: 'disallow base selector in cypress queries',
+ filename: fixture('src/app/test/test.component.cy.ts'),
+ code: `
+cy.get('ds-base-themeable');
+cy.get('#test > ds-base-themeable > #nest');
+ `,
+ errors: [
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ {
+ messageId: Message.WRONG_SELECTOR,
+ },
+ ],
+ output: `
+cy.get('ds-themeable');
+cy.get('#test > ds-themeable > #nest');
+ `,
+ },
+ ],
+};
+
+export default rule;
diff --git a/lint/src/util/structure.ts b/lint/src/util/structure.ts
new file mode 100644
index 0000000000..13535bfe17
--- /dev/null
+++ b/lint/src/util/structure.ts
@@ -0,0 +1,68 @@
+/**
+ * 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 } from '@typescript-eslint/utils';
+import { RuleTester } from 'eslint';
+import { EnumType } from 'typescript';
+
+export type Meta = TSESLint.RuleMetaData;
+export type Valid = RuleTester.ValidTestCase | TSESLint.ValidTestCase;
+export type Invalid = RuleTester.InvalidTestCase | TSESLint.InvalidTestCase;
+
+
+export interface DSpaceESLintRuleInfo {
+ name: string;
+ meta: Meta,
+ defaultOptions: any[],
+}
+
+export interface DSpaceESLintTestInfo {
+ rule: 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,
+}
+
+export function bundle(
+ name: string,
+ language: string,
+ index: RuleExports[],
+): {
+ name: string,
+ language: string,
+ rules: Record,
+ index: RuleExports[],
+} {
+ return index.reduce((o: any, i: any) => {
+ o.rules[i.info.name] = i.rule;
+ return o;
+ }, {
+ name,
+ language,
+ rules: {},
+ index,
+ });
+}
diff --git a/lint/src/util/templates/index.ejs b/lint/src/util/templates/index.ejs
new file mode 100644
index 0000000000..7ce8c15d6b
--- /dev/null
+++ b/lint/src/util/templates/index.ejs
@@ -0,0 +1,5 @@
+[DSpace ESLint plugins](../../README.md) > <%= plugin.language %> rules
+
+<% rules.forEach(rule => { %>
+- [`<%= plugin.name %>/<%= rule.name %>`](./rules/<%= rule.name %>.md)<% if (rule.meta?.docs?.description) {%>: <%= rule.meta.docs.description.split('\n')[0] %><% }%>
+<% }) %>
diff --git a/lint/src/util/templates/rule.ejs b/lint/src/util/templates/rule.ejs
new file mode 100644
index 0000000000..ac5df5815d
--- /dev/null
+++ b/lint/src/util/templates/rule.ejs
@@ -0,0 +1,36 @@
+[DSpace ESLint plugins](../../../README.md) > [<%= plugin.language %> rules](../index.md) > `<%= plugin.name %>/<%= rule.name %>`
+_______
+
+<%- rule.meta.docs?.description %>
+
+_______
+
+[Source code](../../../src/rules/<%- plugin.name.replace('dspace-angular-', '') %>/<%- rule.name %>.ts)
+
+### Examples
+
+<% if (tests.valid) {%>
+#### Valid code
+ <% tests.valid.forEach(test => { %>
+ <% if (test.filename) { %>
+Filename: `<%- test.filename %>`
+ <% } %>
+```
+<%- test.code.trim() %>
+```
+ <% }) %>
+<% } %>
+
+<% if (tests.invalid) {%>
+#### Invalid code
+ <% tests.invalid.forEach(test => { %>
+
+ <% if (test.filename) { %>
+Filename: `<%- test.filename %>`
+ <% } %>
+```
+<%- test.code.trim() %>
+```
+
+ <% }) %>
+<% } %>
diff --git a/lint/test/fixture/index.ts b/lint/test/fixture/index.ts
new file mode 100644
index 0000000000..1d4f33f7e2
--- /dev/null
+++ b/lint/test/fixture/index.ts
@@ -0,0 +1,13 @@
+/**
+ * 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/
+ */
+
+export const FIXTURE = 'lint/test/fixture/';
+
+export function fixture(path: string): string {
+ return FIXTURE + path;
+}
diff --git a/lint/test/rules.spec.ts b/lint/test/rules.spec.ts
new file mode 100644
index 0000000000..a8c1b382b2
--- /dev/null
+++ b/lint/test/rules.spec.ts
@@ -0,0 +1,26 @@
+/**
+ * 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 { default as htmlPlugin } from '../src/rules/html';
+import { default as tsPlugin } from '../src/rules/ts';
+import {
+ htmlRuleTester,
+ tsRuleTester,
+} from './testing';
+
+describe('TypeScript rules', () => {
+ for (const { info, rule, tests } of tsPlugin.index) {
+ tsRuleTester.run(info.name, rule, tests);
+ }
+});
+
+describe('HTML rules', () => {
+ for (const { info, rule, tests } of htmlPlugin.index) {
+ htmlRuleTester.run(info.name, rule, tests);
+ }
+});
diff --git a/lint/test/rules/themed-component-selectors.spec.ts b/lint/test/rules/themed-component-selectors.spec.ts
deleted file mode 100644
index 864c41d598..0000000000
--- a/lint/test/rules/themed-component-selectors.spec.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * 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 rule from '../../src/rules/ts/themed-component-selectors';
-import {
- fixture,
- tsRuleTester,
-} from '../testing';
-
-describe('themed-component-selectors', () => {
- tsRuleTester.run('themed-component-selectors', rule as any, {
- valid: [
- {
- name: 'Regular non-themeable component selector',
- code: `
- @Component({
- selector: 'ds-something',
- })
- class Something {
- }
- `,
- },
- {
- name: 'Themeable component selector should replace the original version, unthemed version should be changed to ds-base-',
- code: `
-@Component({
- selector: 'ds-base-something',
-})
-class Something {
-}
-
-@Component({
- selector: 'ds-something',
-})
-class ThemedSomething extends ThemedComponent {
-}
-
-@Component({
- selector: 'ds-themed-something',
-})
-class OverrideSomething extends Something {
-}
- `,
- },
- {
- name: 'Other themed component wrappers should not interfere',
- code: `
-@Component({
- selector: 'ds-something',
-})
-class Something {
-}
-
-@Component({
- selector: 'ds-something-else',
-})
-class ThemedSomethingElse extends ThemedComponent {
-}
- `,
- },
- ],
- invalid: [
- {
- name: 'Wrong selector for base component',
- filename: fixture('src/app/test/test-themeable.component.ts'),
- code: `
-@Component({
- selector: 'ds-something',
-})
-class TestThemeableComponent {
-}
- `,
- errors: [
- {
- messageId: 'wrongSelectorUnthemedComponent',
- },
- ],
- output: `
-@Component({
- selector: 'ds-base-something',
-})
-class TestThemeableComponent {
-}
- `,
- },
- {
- name: 'Wrong selector for wrapper component',
- filename: fixture('src/app/test/themed-test-themeable.component.ts'),
- code: `
-@Component({
- selector: 'ds-themed-something',
-})
-class ThemedTestThemeableComponent extends ThemedComponent {
-}
- `,
- errors: [
- {
- messageId: 'wrongSelectorThemedComponentWrapper',
- },
- ],
- output: `
-@Component({
- selector: 'ds-something',
-})
-class ThemedTestThemeableComponent extends ThemedComponent {
-}
- `,
- },
- {
- name: 'Wrong selector for theme override',
- filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
- code: `
-@Component({
- selector: 'ds-something',
-})
-class TestThememeableComponent extends BaseComponent {
-}
- `,
- errors: [
- {
- messageId: 'wrongSelectorThemedComponentOverride',
- },
- ],
- output: `
-@Component({
- selector: 'ds-themed-something',
-})
-class TestThememeableComponent extends BaseComponent {
-}
- `,
- },
- ],
- } as any);
-});
diff --git a/lint/test/rules/themed-component-usages.spec.ts b/lint/test/rules/themed-component-usages.spec.ts
deleted file mode 100644
index 4cbb135684..0000000000
--- a/lint/test/rules/themed-component-usages.spec.ts
+++ /dev/null
@@ -1,265 +0,0 @@
-/**
- * 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 htmlRule from '../../src/rules/html/themed-component-usages';
-import tsRule from '../../src/rules/ts/themed-component-usages';
-import {
- fixture,
- htmlRuleTester,
- tsRuleTester,
-} from '../testing';
-
-describe('themed-component-usages (TypeScript)', () => {
- tsRuleTester.run('themed-component-usages', tsRule as any, {
- valid: [
- {
- code: `
-const config = {
- a: ThemedTestThemeableComponent,
- b: ChipsComponent,
-}
- `,
- },
- {
- code: `
-export class TestThemeableComponent {
-}
- `,
- },
- {
- code: `
-import { TestThemeableComponent } from '../test/test-themeable.component.ts';
-
-export class ThemedAdminSidebarComponent extends ThemedComponent {
-}
- `,
- },
- {
- code: `
-import { TestThemeableComponent } from '../test/test-themeable.component.ts';
-
-export class Something {
- @ViewChild(TestThemeableComponent) test: TestThemeableComponent;
-}
- `,
- },
- {
- name: fixture('src/app/test/test.component.spec.ts'),
- code: `
-By.css('ds-themeable');
-By.Css('#test > ds-themeable > #nest');
- `,
- },
- {
- name: fixture('src/app/test/test.component.cy.ts'),
- code: `
-By.css('ds-themeable');
-By.Css('#test > ds-themeable > #nest');
- `,
- },
- ],
- invalid: [
- {
- code: `
-import { TestThemeableComponent } from '../test/test-themeable.component.ts';
-import { TestComponent } from '../test/test.component.ts';
-
-const config = {
- a: TestThemeableComponent,
- b: TestComponent,
-}
- `,
- errors: [
- {
- messageId: 'mustImportThemedWrapper',
- },
- {
- messageId: 'mustUseThemedWrapper',
- },
- ],
- output: `
-import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts';
-import { TestComponent } from '../test/test.component.ts';
-
-const config = {
- a: ThemedTestThemeableComponent,
- b: TestComponent,
-}
- `,
- },
- {
- filename: fixture('src/app/test/test.component.spec.ts'),
- code: `
-By.css('ds-themed-themeable');
-By.css('#test > ds-themed-themeable > #nest');
- `,
- errors: [
- {
- messageId: 'mustUseThemedWrapper',
- },
- {
- messageId: 'mustUseThemedWrapper',
- },
- ],
- output: `
-By.css('ds-themeable');
-By.css('#test > ds-themeable > #nest');
- `,
- },
- {
- filename: fixture('src/app/test/test.component.spec.ts'),
- code: `
-By.css('ds-base-themeable');
-By.css('#test > ds-base-themeable > #nest');
- `,
- errors: [
- {
- messageId: 'mustUseThemedWrapper',
- },
- {
- messageId: 'mustUseThemedWrapper',
- },
- ],
- output: `
-By.css('ds-themeable');
-By.css('#test > ds-themeable > #nest');
- `,
- },
- {
- filename: fixture('src/app/test/test.component.cy.ts'),
- code: `
-cy.get('ds-themed-themeable');
-cy.get('#test > ds-themed-themeable > #nest');
- `,
- errors: [
- {
- messageId: 'mustUseThemedWrapper',
- },
- {
- messageId: 'mustUseThemedWrapper',
- },
- ],
- output: `
-cy.get('ds-themeable');
-cy.get('#test > ds-themeable > #nest');
- `,
- },
- {
- filename: fixture('src/app/test/test.component.cy.ts'),
- code: `
-cy.get('ds-base-themeable');
-cy.get('#test > ds-base-themeable > #nest');
- `,
- errors: [
- {
- messageId: 'mustUseThemedWrapper',
- },
- {
- messageId: 'mustUseThemedWrapper',
- },
- ],
- output: `
-cy.get('ds-themeable');
-cy.get('#test > ds-themeable > #nest');
- `,
- },
- ],
- } as any);
-});
-
-describe('themed-component-usages (HTML)', () => {
- htmlRuleTester.run('themed-component-usages', htmlRule, {
- valid: [
- {
- code: `
-
-
-
- `,
- },
- {
- name: fixture('src/test.ts'),
- code: `
-@Component({
- template: ''
-})
-class Test {
-}
- `,
- },
- {
- name: fixture('src/test.spec.ts'),
- code: `
-@Component({
- template: ''
-})
-class Test {
-}
- `,
- },
- {
- filename: fixture('src/test.spec.ts'),
- code: `
-@Component({
- template: ''
-})
-class Test {
-}
- `,
- },
- ],
- invalid: [
- {
- code: `
-
-
-
- `,
- errors: [
- {
- messageId: 'mustUseThemedWrapperSelector',
- },
- {
- messageId: 'mustUseThemedWrapperSelector',
- },
- {
- messageId: 'mustUseThemedWrapperSelector',
- },
- ],
- output: `
-
-
-
- `,
- },
- {
- code: `
-
-
-
- `,
- errors: [
- {
- messageId: 'mustUseThemedWrapperSelector',
- },
- {
- messageId: 'mustUseThemedWrapperSelector',
- },
- {
- messageId: 'mustUseThemedWrapperSelector',
- },
- ],
- output: `
-
-
-
- `,
- },
- ],
- });
-});
diff --git a/lint/test/structure.spec.ts b/lint/test/structure.spec.ts
new file mode 100644
index 0000000000..24e69e42d9
--- /dev/null
+++ b/lint/test/structure.spec.ts
@@ -0,0 +1,76 @@
+/**
+ * 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 { default as html } from '../src/rules/html';
+import { default as ts } from '../src/rules/ts';
+
+describe('plugin structure', () => {
+ for (const pluginExports of [ts, html]) {
+ const pluginName = pluginExports.name ?? 'UNNAMED PLUGIN';
+
+ describe(pluginName, () => {
+ it('should have a name', () => {
+ expect(pluginExports.name).toBeTruthy();
+ });
+
+ it('should have rules', () => {
+ expect(pluginExports.index).toBeTruthy();
+ expect(pluginExports.rules).toBeTruthy();
+ expect(pluginExports.index.length).toBeGreaterThan(0);
+ });
+
+ for (const ruleExports of pluginExports.index) {
+ const ruleName = ruleExports.info.name ?? 'UNNAMED RULE';
+
+ describe(ruleName, () => {
+ it('should have a name', () => {
+ expect(ruleExports.info.name).toBeTruthy();
+ });
+
+ it('should be included under the right name in the plugin', () => {
+ expect(pluginExports.rules[ruleExports.info.name]).toBe(ruleExports.rule);
+ });
+
+ it('should contain metadata', () => {
+ expect(ruleExports.info).toBeTruthy();
+ expect(ruleExports.info.name).toBeTruthy();
+ expect(ruleExports.info.meta).toBeTruthy();
+ expect(ruleExports.info.defaultOptions).toBeTruthy();
+ });
+
+ it('should contain messages', () => {
+ expect(ruleExports.Message).toBeTruthy();
+ expect(ruleExports.info.meta.messages).toBeTruthy();
+ });
+
+ describe('messages', () => {
+ for (const member of Object.keys(ruleExports.Message)) {
+ describe(member, () => {
+ const id = (ruleExports.Message as any)[member];
+
+ it('should have a valid ID', () => {
+ expect(id).toBeTruthy();
+ });
+
+ it('should have valid metadata', () => {
+ expect(ruleExports.info.meta.messages[id]).toBeTruthy();
+ });
+ });
+ }
+ });
+
+ it('should contain tests', () => {
+ expect(ruleExports.tests).toBeTruthy();
+ expect(ruleExports.tests.valid.length).toBeGreaterThan(0);
+ expect(ruleExports.tests.invalid.length).toBeGreaterThan(0);
+ });
+ });
+ }
+ });
+ }
+});
diff --git a/lint/test/testing.ts b/lint/test/testing.ts
index f9507c00c3..f4f92a0e63 100644
--- a/lint/test/testing.ts
+++ b/lint/test/testing.ts
@@ -8,20 +8,19 @@
import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester';
import { RuleTester } from 'eslint';
+import {
+ FIXTURE,
+ fixture,
+} from './fixture';
import { themeableComponents } from '../src/util/theme-support';
-const FIXTURE = 'lint/test/fixture/';
// Register themed components from test fixture
themeableComponents.initialize(FIXTURE);
TypeScriptRuleTester.itOnly = fit;
-export function fixture(path: string): string {
- return FIXTURE + path;
-}
-
export const tsRuleTester = new TypeScriptRuleTester({
parser: '@typescript-eslint/parser',
defaultFilenames: {
diff --git a/lint/test/util/theme-support.spec.ts b/lint/test/theme-support.spec.ts
similarity index 93%
rename from lint/test/util/theme-support.spec.ts
rename to lint/test/theme-support.spec.ts
index 52e63b4fed..2edf9594b6 100644
--- a/lint/test/util/theme-support.spec.ts
+++ b/lint/test/theme-support.spec.ts
@@ -6,7 +6,7 @@
* http://www.dspace.org/license/
*/
-import { themeableComponents } from '../../src/util/theme-support';
+import { themeableComponents } from '../src/util/theme-support';
describe('theme-support', () => {
describe('themeable component registry', () => {
diff --git a/lint/tsconfig.json b/lint/tsconfig.json
index 2c74bddb24..d3537a7376 100644
--- a/lint/tsconfig.json
+++ b/lint/tsconfig.json
@@ -1,5 +1,9 @@
{
"compilerOptions": {
+ "target": "ES2021",
+ "lib": [
+ "es2021"
+ ],
"module": "nodenext",
"moduleResolution": "nodenext",
"noImplicitReturns": true,
@@ -7,14 +11,14 @@
"strict": true,
"outDir": "./dist",
"sourceMap": true,
+ "allowSyntheticDefaultImports": true,
"types": [
"jasmine",
"node"
]
},
"include": [
- "src/**/*.ts",
- "test/**/*.ts",
+ "**/*.ts",
],
"exclude": [
"dist",
diff --git a/package.json b/package.json
index ed2abad2a8..5ef876c560 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"lint": "yarn build:lint && yarn lint:nobuild",
"lint:nobuild": "ng lint",
"lint-fix": "yarn build:lint && ng lint --fix=true",
+ "docs:lint": "ts-node --project ./lint/tsconfig.json ./lint/generate-docs.ts",
"e2e": "cross-env NODE_ENV=production ng e2e",
"clean:dev:config": "rimraf src/assets/config.json",
"clean:coverage": "rimraf coverage",