@angular-eslint/eslint-plugin
Version:
ESLint plugin for Angular applications, following https://angular.dev/style-guide
219 lines (218 loc) • 11.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RULE_DOCS_EXTENSION = exports.RULE_NAME = void 0;
const utils_1 = require("@angular-eslint/utils");
const utils_2 = require("@typescript-eslint/utils");
const create_eslint_rule_1 = require("../utils/create-eslint-rule");
exports.RULE_NAME = 'component-selector';
const VIEW_ENCAPSULATION_SHADOW_DOM = 'ShadowDom';
const VIEW_ENCAPSULATION = 'ViewEncapsulation';
const STYLE_GUIDE_LINK = 'https://angular.dev/style-guide#choosing-component-selectors';
const SHADOW_DOM_ENCAPSULATED_STYLE_LINK = 'https://github.com/angular-eslint/angular-eslint/issues/534';
exports.default = (0, create_eslint_rule_1.createESLintRule)({
name: exports.RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description: `Component selectors should follow given naming rules. See more at ${STYLE_GUIDE_LINK}.`,
},
schema: [
{
oneOf: [
// Single config object
{
type: 'object',
properties: {
type: {
oneOf: [
{ type: 'string' },
{
type: 'array',
items: {
type: 'string',
enum: [
utils_1.SelectorUtils.OPTION_TYPE_ELEMENT,
utils_1.SelectorUtils.OPTION_TYPE_ATTRIBUTE,
],
},
},
],
},
prefix: {
oneOf: [{ type: 'string' }, { type: 'array' }],
},
style: {
type: 'string',
enum: [
utils_1.ASTUtils.OPTION_STYLE_CAMEL_CASE,
utils_1.ASTUtils.OPTION_STYLE_KEBAB_CASE,
],
},
},
required: ['type', 'style'],
additionalProperties: false,
},
// Array of 1-2 config objects
{
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: [
utils_1.SelectorUtils.OPTION_TYPE_ELEMENT,
utils_1.SelectorUtils.OPTION_TYPE_ATTRIBUTE,
],
},
prefix: {
oneOf: [{ type: 'string' }, { type: 'array' }],
},
style: {
type: 'string',
enum: [
utils_1.ASTUtils.OPTION_STYLE_CAMEL_CASE,
utils_1.ASTUtils.OPTION_STYLE_KEBAB_CASE,
],
},
},
additionalProperties: false,
required: ['type', 'style'],
},
minItems: 1,
maxItems: 2,
},
],
},
],
messages: {
prefixFailure: `The selector should start with one of these prefixes: {{prefix}} (${STYLE_GUIDE_LINK})`,
styleFailure: `The selector should be {{style}} (${STYLE_GUIDE_LINK})`,
styleAndPrefixFailure: `The selector should be {{style}} and start with one of these prefixes: {{prefix}} (${STYLE_GUIDE_LINK} and ${STYLE_GUIDE_LINK})`,
typeFailure: `The selector should be used as an {{type}} (${STYLE_GUIDE_LINK})`,
shadowDomEncapsulatedStyleFailure: `The selector of a ShadowDom-encapsulated component should be \`${utils_1.ASTUtils.OPTION_STYLE_KEBAB_CASE}\` (${SHADOW_DOM_ENCAPSULATED_STYLE_LINK})`,
selectorAfterPrefixFailure: `There should be a selector after the {{prefix}} prefix`,
},
},
defaultOptions: [
{
type: undefined,
prefix: 'app', // Match default Angular CLI prefix
style: undefined,
},
],
create(context, [options]) {
// Options are required by schema, so if undefined, ESLint will throw an error
if (!options) {
return {};
}
// Normalize options to a consistent format using shared utility
const configByType = utils_1.SelectorUtils.normalizeOptionsToConfigs(options);
return {
[utils_1.Selectors.COMPONENT_CLASS_DECORATOR](node) {
const rawSelectors = utils_1.ASTUtils.getDecoratorPropertyValue(node, 'selector');
if (!rawSelectors) {
return;
}
// Parse selectors once for reuse
const parsedSelectors = utils_1.SelectorUtils.parseSelectorNode(rawSelectors);
if (!parsedSelectors || parsedSelectors.length === 0) {
return;
}
const applicableConfig = utils_1.SelectorUtils.getApplicableConfig(rawSelectors, configByType);
if (!applicableConfig) {
return;
}
const { type, prefix, style } = applicableConfig;
const isValidOptions = utils_1.SelectorUtils.checkValidOptions(type, prefix, style);
if (!isValidOptions) {
return;
}
// Override `style` for ShadowDom-encapsulated components. See https://github.com/angular-eslint/angular-eslint/issues/534.
const overrideStyle = style !== utils_1.ASTUtils.OPTION_STYLE_KEBAB_CASE &&
hasEncapsulationShadowDomProperty(node)
? utils_1.ASTUtils.OPTION_STYLE_KEBAB_CASE
: style;
const hasExpectedSelector = utils_1.SelectorUtils.checkSelector(rawSelectors, type, prefix, overrideStyle, parsedSelectors);
if (hasExpectedSelector === null) {
return;
}
// Special check for ShadowDom-encapsulated components
// They must have a hyphen in the selector (e.g., 'app-selector' not 'appSelector')
const isShadowDom = style !== overrideStyle;
if (isShadowDom) {
// For ShadowDom components, check if any selector contains a hyphen
// We need to check the raw selector values from parsedSelectors
const hasHyphen = parsedSelectors.some((selector) => {
// Check if the element selector contains a hyphen
return selector.element && selector.element.includes('-');
});
if (!hasHyphen) {
context.report({
node: rawSelectors,
messageId: 'shadowDomEncapsulatedStyleFailure',
});
return;
}
}
// Component-specific validation logic (includes styleAndPrefixFailure)
if (!hasExpectedSelector.hasExpectedType) {
utils_1.SelectorUtils.reportTypeError(rawSelectors, type, context);
}
else if (!hasExpectedSelector.hasSelectorAfterPrefix) {
// Only report selector after prefix error if prefix is actually required
if (prefix !== undefined) {
const prefixArray = (0, utils_1.arrayify)(prefix);
if (prefixArray.length > 0) {
utils_1.SelectorUtils.reportSelectorAfterPrefixError(rawSelectors, prefix, context);
}
}
}
else if (!hasExpectedSelector.hasExpectedStyle) {
if (style === overrideStyle) {
if (!hasExpectedSelector.hasExpectedPrefix) {
if (prefix !== undefined) {
// Only report style and prefix error if prefix is actually required
utils_1.SelectorUtils.reportStyleAndPrefixError(rawSelectors, style, prefix, context);
}
else {
// If no prefix required, just report style error
utils_1.SelectorUtils.reportStyleError(rawSelectors, style, context);
}
}
else {
utils_1.SelectorUtils.reportStyleError(rawSelectors, style, context);
}
}
else {
context.report({
node: rawSelectors,
messageId: 'shadowDomEncapsulatedStyleFailure',
});
}
}
else if (!hasExpectedSelector.hasExpectedPrefix) {
// Only report prefix error if prefix is actually required (not empty)
if (prefix !== undefined) {
const prefixArray = (0, utils_1.arrayify)(prefix);
if (prefixArray.length > 0) {
utils_1.SelectorUtils.reportPrefixError(rawSelectors, prefix, context);
}
}
}
},
};
},
});
function hasEncapsulationShadowDomProperty(node) {
const encapsulationValue = utils_1.ASTUtils.getDecoratorPropertyValue(node, 'encapsulation');
return (encapsulationValue &&
utils_1.ASTUtils.isMemberExpression(encapsulationValue) &&
utils_2.ASTUtils.isIdentifier(encapsulationValue.object) &&
encapsulationValue.object.name === VIEW_ENCAPSULATION &&
utils_2.ASTUtils.isIdentifier(encapsulationValue.property) &&
encapsulationValue.property.name === VIEW_ENCAPSULATION_SHADOW_DOM);
}
exports.RULE_DOCS_EXTENSION = {
rationale: "Consistent component selector naming conventions provide several benefits: they make components easily identifiable in templates and browser DevTools, prevent naming collisions with native HTML elements and third-party components, enable teams to quickly identify which library or feature area a component belongs to, and align with the Web Components specification for custom elements. For example, prefixing selectors with 'app-' (like 'app-user-profile') clearly distinguishes your application components from third-party libraries.",
};