diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/bad-type-import.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/bad-type-import.cjs new file mode 100644 index 0000000000..eebdca6c75 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/bad-type-import.cjs @@ -0,0 +1,31 @@ + /** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Ensures the use of the `import type` operator from the `src/core/models/index.ts` file.', + category: 'Best Practices', + recommended: true, + }, + fixable: 'code', + schema: [], + }, + create: function (context) { + return { + ImportDeclaration: function (node) { + if ( + node.source.parent.importKind !== 'type' && + (node.source.value.endsWith('/models') || node.source.value === 'router-slot/model') + ) { + const sourceCode = context.getSourceCode(); + const nodeSource = sourceCode.getText(node); + context.report({ + node, + message: 'Use `import type` instead of `import`.', + fix: (fixer) => fixer.replaceText(node, nodeSource.replace('import', 'import type')), + }); + } + }, + }; + }, +}; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-element-suffix-on-element-class-name.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-element-suffix-on-element-class-name.cjs new file mode 100644 index 0000000000..865fcab738 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-element-suffix-on-element-class-name.cjs @@ -0,0 +1,31 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce Element class name to end with "Element".', + category: 'Naming', + recommended: true, + }, + schema: [], + }, + create: function (context) { + return { + ClassDeclaration(node) { + // check if the class extends HTMLElement, LitElement, or UmbLitElement + const isExtendingElement = + node.superClass && ['HTMLElement', 'LitElement', 'UmbLitElement'].includes(node.superClass.name); + // check if the class name ends with 'Element' + const isClassNameValid = node.id.name.endsWith('Element'); + + if (isExtendingElement && !isClassNameValid) { + context.report({ + node, + message: "Element class name should end with 'Element'.", + // There us no fixer on purpose because it's not safe to rename the class. We want to do that trough the refactoring tool. + }); + } + }, + }; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umbraco-external-imports.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umbraco-external-imports.cjs new file mode 100644 index 0000000000..6484da2b82 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umbraco-external-imports.cjs @@ -0,0 +1,50 @@ +/** @type {import('eslint').Rule.RuleModule}*/ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Ensures that the application strictly uses node_modules imports from `@umbraco-cms/backoffice/external`. This is needed to run the application in the browser.', + recommended: true, + }, + fixable: 'code', + schema: { + type: 'array', + minItems: 0, + maxItems: 1, + items: [ + { + type: 'object', + properties: { + exceptions: { type: 'array' }, + }, + additionalProperties: false, + }, + ], + }, + }, + create: (context) => { + return { + ImportDeclaration: (node) => { + const { source } = node; + const { value } = source; + + const options = context.options[0] || {}; + const exceptions = options.exceptions || []; + + // If import starts with any of the following, then it's allowed + if (exceptions.some((v) => value.startsWith(v))) { + return; + } + + context.report({ + node, + message: + 'node_modules imports should be proxied through `@umbraco-cms/backoffice/external`. Please create it if it does not exist.', + fix: (fixer) => + fixer.replaceText(source, `'@umbraco-cms/backoffice/external${value.startsWith('/') ? '' : '/'}${value}'`), + }); + }, + }; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/ensure-relative-import-use-js-extension.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/ensure-relative-import-use-js-extension.cjs new file mode 100644 index 0000000000..ee53dab5fc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/ensure-relative-import-use-js-extension.cjs @@ -0,0 +1,91 @@ +/** @type {import('eslint').Rule.RuleModule}*/ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Ensures relative imports use the ".js" file extension.', + category: 'Best Practices', + recommended: true, + }, + fixable: 'code', + schema: [], + }, + create: (context) => { + function correctImport(value) { + if (value === '.') { + return './index.js'; + } + + if ( + value && + value.startsWith('.') && + !value.endsWith('.js') && + !value.endsWith('.css') && + !value.endsWith('.json') && + !value.endsWith('.svg') && + !value.endsWith('.jpg') && + !value.endsWith('.png') + ) { + return (value.endsWith('/') ? value + 'index' : value) + '.js'; + } + + return null; + } + + return { + ImportDeclaration: (node) => { + const { source } = node; + const { value } = source; + + const fixedValue = correctImport(value); + if (fixedValue) { + context.report({ + node, + message: 'Relative imports should use the ".js" file extension.', + fix: (fixer) => fixer.replaceText(source, `'${fixedValue}'`), + }); + } + }, + ImportExpression: (node) => { + const { source } = node; + const { value } = source; + + const fixedSource = correctImport(value); + if (fixedSource) { + context.report({ + node: source, + message: 'Relative imports should use the ".js" file extension.', + fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`), + }); + } + }, + ExportAllDeclaration: (node) => { + const { source } = node; + const { value } = source; + + const fixedSource = correctImport(value); + if (fixedSource) { + context.report({ + node: source, + message: 'Relative exports should use the ".js" file extension.', + fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`), + }); + } + }, + ExportNamedDeclaration: (node) => { + const { source } = node; + if (!source) return; + const { value } = source; + + const fixedSource = correctImport(value); + if (fixedSource) { + context.report({ + node: source, + message: 'Relative exports should use the ".js" file extension.', + fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`), + }); + } + }, + }; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/no-direct-api-import.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/no-direct-api-import.cjs new file mode 100644 index 0000000000..b43660c5cc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/no-direct-api-import.cjs @@ -0,0 +1,41 @@ +module.exports = { + meta: { + docs: { + description: + 'Ensures that any API resources from the `@umbraco-cms/backoffice/backend-api` module are not used directly. Instead you should use the `tryExecuteAndNotify` function from the `@umbraco-cms/backoffice/resources` module.', + category: 'Best Practices', + recommended: true, + }, + fixable: 'code', + schema: [], + }, + create: function (context) { + return { + // If methods called on *Resource classes are not already wrapped with `await tryExecuteAndNotify()`, then we should suggest to wrap them. + CallExpression: function (node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name.endsWith('Resource') && + node.callee.property.type === 'Identifier' && + node.callee.property.name !== 'constructor' + ) { + const hasTryExecuteAndNotify = + node.parent && + node.parent.callee && + (node.parent.callee.name === 'tryExecute' || node.parent.callee.name === 'tryExecuteAndNotify'); + if (!hasTryExecuteAndNotify) { + context.report({ + node, + message: 'Wrap this call with `tryExecuteAndNotify()`. Make sure to `await` the result.', + fix: (fixer) => [ + fixer.insertTextBefore(node, 'tryExecuteAndNotify(this, '), + fixer.insertTextAfter(node, ')'), + ], + }); + } + } + }, + }; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/prefer-import-aliases.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/prefer-import-aliases.cjs new file mode 100644 index 0000000000..6794a4426c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/prefer-import-aliases.cjs @@ -0,0 +1,26 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'Ensures that the application does not rely on file system paths for imports. Instead, use import aliases or relative imports. This also solves a problem where GitHub fails on the test runner step.', + category: 'Best Practices', + recommended: true, + }, + schema: [], + }, + create: function (context) { + return { + ImportDeclaration: function (node) { + if (node.source.value.startsWith('src/')) { + context.report({ + node, + message: + 'Prefer using import aliases or relative imports instead of absolute imports. Example: `import { MyComponent } from "src/components/MyComponent";` should be `import { MyComponent } from "@components/MyComponent";`', + }); + } + }, + }; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/prefer-static-styles-last.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/prefer-static-styles-last.cjs new file mode 100644 index 0000000000..2442e18848 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/prefer-static-styles-last.cjs @@ -0,0 +1,46 @@ +/** @type {import('eslint').Rule.RuleModule}*/ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'Enforce the "styles" property with the static modifier to be the last property of a class that ends with "Element".', + category: 'Best Practices', + recommended: true, + }, + fixable: 'code', + schema: [], + }, + create: function (context) { + return { + ClassDeclaration(node) { + const className = node.id.name; + if (className.endsWith('Element')) { + const staticStylesProperty = node.body.body.find((bodyNode) => { + return bodyNode.type === 'PropertyDefinition' && bodyNode.key.name === 'styles' && bodyNode.static; + }); + if (staticStylesProperty) { + const lastProperty = node.body.body[node.body.body.length - 1]; + if (lastProperty.key.name !== staticStylesProperty.key.name) { + context.report({ + node: staticStylesProperty, + message: 'The "styles" property should be the last property of a class declaration.', + data: { + className: className, + }, + fix: function (fixer) { + const sourceCode = context.getSourceCode(); + const staticStylesPropertyText = sourceCode.getText(staticStylesProperty); + return [ + fixer.replaceTextRange(staticStylesProperty.range, ''), + fixer.insertTextAfterRange(lastProperty.range, '\n \n ' + staticStylesPropertyText), + ]; + }, + }); + } + } + } + }, + }; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs new file mode 100644 index 0000000000..b9dce9c039 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs @@ -0,0 +1,26 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Ensure that all class declarations are prefixed with "Umb"', + category: 'Best Practices', + recommended: true, + }, + schema: [], + }, + create: function (context) { + function checkClassName(node) { + if (node.id && node.id.name && !node.id.name.startsWith('Umb')) { + context.report({ + node: node.id, + message: 'Class declaration should be prefixed with "Umb"', + }); + } + } + + return { + ClassDeclaration: checkClassName, + }; + }, +}; diff --git a/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs b/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs index e5642b64b7..ba1259b8cd 100644 --- a/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs +++ b/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs @@ -1,389 +1,21 @@ 'use strict'; -/* - * A eslint rule that ensures the use of the `import type` operator from the `src/core/models/index.ts` file. - */ -// eslint-disable-next-line no-undef +const badTypeImportRule = require('./devops/eslint/rules/bad-type-import.cjs'); +const enforceElementSuffixOnElementClassNameRule = require('./devops/eslint/rules/enforce-element-suffix-on-element-class-name.cjs'); +const enforceUmbracoExternalImportsRule = require('./devops/eslint/rules/enforce-umbraco-external-imports.cjs'); +const ensureRelativeImportUseJsExtensionRule = require('./devops/eslint/rules/ensure-relative-import-use-js-extension.cjs'); +const noDirectApiImportRule = require('./devops/eslint/rules/no-direct-api-import.cjs'); +const preferImportAliasesRule = require('./devops/eslint/rules/prefer-import-aliases.cjs'); +const preferStaticStylesLastRule = require('./devops/eslint/rules/prefer-static-styles-last.cjs'); +const umbClassPrefixRule = require('./devops/eslint/rules/umb-class-prefix.cjs'); + module.exports = { - /** @type {import('eslint').Rule.RuleModule} */ - 'bad-type-import': { - meta: { - type: 'problem', - docs: { - description: 'Ensures the use of the `import type` operator from the `src/core/models/index.ts` file.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - schema: [], - }, - create: function (context) { - return { - ImportDeclaration: function (node) { - if ( - node.source.parent.importKind !== 'type' && - (node.source.value.endsWith('/models') || node.source.value === 'router-slot/model') - ) { - const sourceCode = context.getSourceCode(); - const nodeSource = sourceCode.getText(node); - context.report({ - node, - message: 'Use `import type` instead of `import`.', - fix: (fixer) => fixer.replaceText(node, nodeSource.replace('import', 'import type')), - }); - } - }, - }; - }, - }, - - /** @type {import('eslint').Rule.RuleModule} */ - 'no-direct-api-import': { - meta: { - type: 'suggestion', - docs: { - description: - 'Ensures that any API resources from the `@umbraco-cms/backoffice/backend-api` module are not used directly. Instead you should use the `tryExecuteAndNotify` function from the `@umbraco-cms/backoffice/resources` module.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - schema: [], - }, - create: function (context) { - return { - // If methods called on *Resource classes are not already wrapped with `await tryExecuteAndNotify()`, then we should suggest to wrap them. - CallExpression: function (node) { - if ( - node.callee.type === 'MemberExpression' && - node.callee.object.type === 'Identifier' && - node.callee.object.name.endsWith('Resource') && - node.callee.property.type === 'Identifier' && - node.callee.property.name !== 'constructor' - ) { - const hasTryExecuteAndNotify = - node.parent && - node.parent.callee && - (node.parent.callee.name === 'tryExecute' || node.parent.callee.name === 'tryExecuteAndNotify'); - if (!hasTryExecuteAndNotify) { - context.report({ - node, - message: 'Wrap this call with `tryExecuteAndNotify()`. Make sure to `await` the result.', - fix: (fixer) => [ - fixer.insertTextBefore(node, 'tryExecuteAndNotify(this, '), - fixer.insertTextAfter(node, ')'), - ], - }); - } - } - }, - }; - }, - }, - - /** @type {import('eslint').Rule.RuleModule} */ - 'prefer-import-aliases': { - meta: { - type: 'suggestion', - docs: { - description: - 'Ensures that the application does not rely on file system paths for imports. Instead, use import aliases or relative imports. This also solves a problem where GitHub fails on the test runner step.', - category: 'Best Practices', - recommended: true, - }, - schema: [], - }, - create: function (context) { - return { - ImportDeclaration: function (node) { - if (node.source.value.startsWith('src/')) { - context.report({ - node, - message: - 'Prefer using import aliases or relative imports instead of absolute imports. Example: `import { MyComponent } from "src/components/MyComponent";` should be `import { MyComponent } from "@components/MyComponent";`', - }); - } - }, - }; - }, - }, - - /** @type {import('eslint').Rule.RuleModule} */ - 'enforce-element-suffix-on-element-class-name': { - meta: { - type: 'suggestion', - docs: { - description: 'Enforce Element class name to end with "Element".', - category: 'Naming', - recommended: true, - }, - schema: [], - }, - create: function (context) { - return { - ClassDeclaration(node) { - // check if the class extends HTMLElement, LitElement, or UmbLitElement - const isExtendingElement = - node.superClass && ['HTMLElement', 'LitElement', 'UmbLitElement'].includes(node.superClass.name); - // check if the class name ends with 'Element' - const isClassNameValid = node.id.name.endsWith('Element'); - - if (isExtendingElement && !isClassNameValid) { - context.report({ - node, - message: "Element class name should end with 'Element'.", - // There us no fixer on purpose because it's not safe to rename the class. We want to do that trough the refactoring tool. - }); - } - }, - }; - }, - }, - - /** @type {import('eslint').Rule.RuleModule} */ - /* - 'no-external-imports': { - meta: { - type: 'problem', - docs: { - description: - 'Ensures that the application does not rely on imports from external packages. Instead, use the @umbraco-cms/backoffice libs.', - recommended: true, - }, - fixable: 'code', - schema: [], - }, - create: function (context) { - return { - ImportDeclaration: function (node) { - // Check for imports from "router-slot" - if (node.source.value.startsWith('router-slot')) { - context.report({ - node, - message: - 'Use the `@umbraco-cms/backoffice/router` package instead of importing directly from "router-slot" because we might change that dependency in the future.', - fix: (fixer) => { - return fixer.replaceTextRange(node.source.range, `'@umbraco-cms/backoffice/router'`); - }, - }); - } - }, - }; - }, - }, - */ - - /** @type {import('eslint').Rule.RuleModule} */ - 'umb-class-prefix': { - meta: { - type: 'problem', - docs: { - description: 'Ensure that all class declarations are prefixed with "Umb"', - category: 'Best Practices', - recommended: true, - }, - schema: [], - }, - create: function (context) { - function checkClassName(node) { - if (node.id && node.id.name && !node.id.name.startsWith('Umb')) { - context.report({ - node: node.id, - message: 'Class declaration should be prefixed with "Umb"', - }); - } - } - - return { - ClassDeclaration: checkClassName, - }; - }, - }, - - /** @type {import('eslint').Rule.RuleModule}*/ - 'prefer-static-styles-last': { - meta: { - type: 'suggestion', - docs: { - description: - 'Enforce the "styles" property with the static modifier to be the last property of a class that ends with "Element".', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - schema: [], - }, - create: function (context) { - return { - ClassDeclaration(node) { - const className = node.id.name; - if (className.endsWith('Element')) { - const staticStylesProperty = node.body.body.find((bodyNode) => { - return bodyNode.type === 'PropertyDefinition' && bodyNode.key.name === 'styles' && bodyNode.static; - }); - if (staticStylesProperty) { - const lastProperty = node.body.body[node.body.body.length - 1]; - if (lastProperty.key.name !== staticStylesProperty.key.name) { - context.report({ - node: staticStylesProperty, - message: 'The "styles" property should be the last property of a class declaration.', - data: { - className: className, - }, - fix: function (fixer) { - const sourceCode = context.getSourceCode(); - const staticStylesPropertyText = sourceCode.getText(staticStylesProperty); - return [ - fixer.replaceTextRange(staticStylesProperty.range, ''), - fixer.insertTextAfterRange(lastProperty.range, '\n \n ' + staticStylesPropertyText), - ]; - }, - }); - } - } - } - }, - }; - }, - }, - - /** @type {import('eslint').Rule.RuleModule}*/ - 'ensure-relative-import-use-js-extension': { - meta: { - type: 'problem', - docs: { - description: 'Ensures relative imports use the ".js" file extension.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - schema: [], - }, - create: (context) => { - function correctImport(value) { - if (value === '.') { - return './index.js'; - } - - if ( - value && - value.startsWith('.') && - !value.endsWith('.js') && - !value.endsWith('.css') && - !value.endsWith('.json') && - !value.endsWith('.svg') && - !value.endsWith('.jpg') && - !value.endsWith('.png') - ) { - return (value.endsWith('/') ? value + 'index' : value) + '.js'; - } - - return null; - } - - return { - ImportDeclaration: (node) => { - const { source } = node; - const { value } = source; - - const fixedValue = correctImport(value); - if (fixedValue) { - context.report({ - node, - message: 'Relative imports should use the ".js" file extension.', - fix: (fixer) => fixer.replaceText(source, `'${fixedValue}'`), - }); - } - }, - ImportExpression: (node) => { - const { source } = node; - const { value } = source; - - const fixedSource = correctImport(value); - if (fixedSource) { - context.report({ - node: source, - message: 'Relative imports should use the ".js" file extension.', - fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`), - }); - } - }, - ExportAllDeclaration: (node) => { - const { source } = node; - const { value } = source; - - const fixedSource = correctImport(value); - if (fixedSource) { - context.report({ - node: source, - message: 'Relative exports should use the ".js" file extension.', - fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`), - }); - } - }, - ExportNamedDeclaration: (node) => { - const { source } = node; - if (!source) return; - const { value } = source; - - const fixedSource = correctImport(value); - if (fixedSource) { - context.report({ - node: source, - message: 'Relative exports should use the ".js" file extension.', - fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`), - }); - } - } - }; - }, - }, - - /** @type {import('eslint').Rule.RuleModule}*/ - 'enforce-umbraco-external-imports': { - meta: { - type: 'problem', - docs: { - description: 'Ensures that the application strictly uses node_modules imports from `@umbraco-cms/backoffice/external`. This is needed to run the application in the browser.', - recommended: true, - }, - fixable: 'code', - schema: { - type: "array", - minItems: 0, - maxItems: 1, - items: [ - { - type: "object", - properties: { - exceptions: { type: "array" } - }, - additionalProperties: false - } - ] - } - }, - create: (context) => { - return { - ImportDeclaration: (node) => { - const { source } = node; - const { value } = source; - - const options = context.options[0] || {}; - const exceptions = options.exceptions || []; - - // If import starts with any of the following, then it's allowed - if (exceptions.some(v => value.startsWith(v))) { - return; - } - - context.report({ - node, - message: 'node_modules imports should be proxied through `@umbraco-cms/backoffice/external`. Please create it if it does not exist.', - fix: (fixer) => fixer.replaceText(source, `'@umbraco-cms/backoffice/external${value.startsWith('/') ? '' : '/'}${value}'`), - }); - }, - }; - }, - }, + 'bad-type-import': badTypeImportRule, + 'enforce-element-suffix-on-element-class-name': enforceElementSuffixOnElementClassNameRule, + 'enforce-umbraco-external-imports': enforceUmbracoExternalImportsRule, + 'ensure-relative-import-use-js-extension': ensureRelativeImportUseJsExtensionRule, + 'no-direct-api-import': noDirectApiImportRule, + 'prefer-import-aliases': preferImportAliasesRule, + 'prefer-static-styles-last': preferStaticStylesLastRule, + 'umb-class-prefix': umbClassPrefixRule, };