Files
dspace-angular/lint/src/util/theme-support.ts
Alan Orth 26c522176b Use node: for Node.js builtin modules
Use the the `node:` protocol when importing Node.js builtin modules
and enable rule in eslint to enforce.

The `node:` protocol for Node.js builtin modules has been available
since Node.js v14.

See: https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/enforce-node-protocol-usage.md
(cherry picked from commit af71c152c1)
2025-09-10 17:23:32 +00:00

283 lines
8.5 KiB
TypeScript

/**
* 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 { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import { TSESTree } from '@typescript-eslint/utils';
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
import ts, { Identifier } from 'typescript';
import {
getComponentClassName,
isPartOfViewChild,
} from './angular';
import {
isPartOfClassDeclaration,
isPartOfTypeExpression,
} from './typescript';
/**
* Couples a themeable Component to its ThemedComponent wrapper
*/
export interface ThemeableComponentRegistryEntry {
basePath: string;
baseFileName: string,
baseClass: string;
wrapperPath: string;
wrapperFileName: string,
wrapperClass: string;
}
function isAngularComponentDecorator(node: ts.Node) {
if (node.kind === ts.SyntaxKind.Decorator && node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
const decorator = node as ts.Decorator;
if (decorator.expression.kind === ts.SyntaxKind.CallExpression) {
const method = decorator.expression as ts.CallExpression;
if (method.expression.kind === ts.SyntaxKind.Identifier) {
return (method.expression as Identifier).text === '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.text === identifierName) {
return importDeclaration;
}
}
}
}
return undefined;
});
}
/**
* Listing of all themeable Components
*/
class ThemeableComponentRegistry {
public readonly entries: Set<ThemeableComponentRegistryEntry>;
public readonly byBaseClass: Map<string, ThemeableComponentRegistryEntry>;
public readonly byWrapperClass: Map<string, ThemeableComponentRegistryEntry>;
public readonly byBasePath: Map<string, ThemeableComponentRegistryEntry>;
public readonly byWrapperPath: Map<string, ThemeableComponentRegistryEntry>;
constructor() {
this.entries = new Set();
this.byBaseClass = new Map();
this.byWrapperClass = new Map();
this.byBasePath = new Map();
this.byWrapperPath = new Map();
}
public initialize(prefix = '') {
if (this.entries.size > 0) {
return;
}
function registerWrapper(path: string) {
const source = getSource(path);
function traverse(node: ts.Node) {
if (node.parent !== undefined && isAngularComponentDecorator(node)) {
const classNode = node.parent as ts.ClassDeclaration;
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 as any).expression.escapedText === 'ThemedComponent') {
if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) {
continue;
}
const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode;
const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText;
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$/, ''),
});
}
}
}
return;
} else {
ts.forEachChild(node, traverse);
}
}
traverse(source);
}
const glob = require('glob');
// note: this outputs Unix-style paths on Windows
const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found;
for (const wrapper of wrappers) {
registerWrapper(wrapper);
}
}
private add(entry: ThemeableComponentRegistryEntry) {
this.entries.add(entry);
this.byBaseClass.set(entry.baseClass, entry);
this.byWrapperClass.set(entry.wrapperClass, entry);
this.byBasePath.set(entry.basePath, entry);
this.byWrapperPath.set(entry.wrapperPath, entry);
}
}
export const themeableComponents = new ThemeableComponentRegistry();
/**
* Construct the AST of a TypeScript source file
* @param file
*/
function getSource(file: string): ts.SourceFile {
return ts.createSourceFile(
file,
readFileSync(file).toString(),
ts.ScriptTarget.ES2020, // todo: actually use tsconfig.json?
/*setParentNodes */ true,
);
}
/**
* Resolve a possibly relative local path into an absolute path starting from the root directory of the project
*/
function resolveLocalPath(path: string, relativeTo: string) {
if (path.startsWith('src/')) {
return path;
} else if (path.startsWith('./')) {
const parts = relativeTo.split('/');
return [
...parts.slice(0, parts.length - 1),
path.replace(/^.\//, ''),
].join('/') + '.ts';
} else {
throw new Error(`Unsupported local path: ${path}`);
}
}
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 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 {
themeableComponents.initialize();
return themeableComponents.byBaseClass.has(className);
}
export function inThemedComponentOverrideFile(filename: string): boolean {
const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/);
if (!match) {
return false;
}
themeableComponents.initialize();
// todo: this is fragile!
return themeableComponents.byBasePath.has(`src/${match[1]}`);
}
export function allThemeableComponents(): ThemeableComponentRegistryEntry[] {
themeableComponents.initialize();
return [...themeableComponents.entries];
}
export function getThemeableComponentByBaseClass(baseClass: string): ThemeableComponentRegistryEntry | undefined {
themeableComponents.initialize();
return themeableComponents.byBaseClass.get(baseClass);
}
export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) {
return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode);
}
export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-';
export function fixSelectors(text: string): string {
return text.replaceAll(/ds-(base|themed)-/g, 'ds-');
}
/**
* Determine the theme of the current file based on its path in the project.
* @param context the current ESLint rule context
*/
export function getFileTheme(context: RuleContext<any, any>): string | undefined {
// note: shouldn't use plain .filename (doesn't work in DSpace Angular 7.4)
const m = context.getFilename()?.match(/\/src\/themes\/([^/]+)\//);
if (m?.length === 2) {
return m[1];
}
return undefined;
}