Files
igny8/frontend/eslint/eslint-plugin-igny8-design-system.cjs
IGNY8 VPS (Salman) 9d4aa32f9e styling fixes
2026-01-19 23:34:41 +00:00

469 lines
17 KiB
JavaScript

/**
* IGNY8 Design System ESLint Plugin
*
* This plugin enforces consistent component usage across the application.
* It prevents raw HTML elements and ensures all UI elements come from
* the central component library.
*
* DOCUMENTATION:
* - Full component reference: docs/30-FRONTEND/COMPONENT-SYSTEM.md
* - Design guide: docs/30-FRONTEND/DESIGN-GUIDE.md
* - Design tokens: docs/30-FRONTEND/DESIGN-TOKENS.md
* - Live demo: /ui-elements route
*
* RULES:
* 1. no-raw-button - Use <Button> from components/ui/button/Button
* 2. no-raw-input - Use <InputField> from components/form/input/InputField
* 3. no-raw-select - Use <Select> from components/form/Select
* 4. no-raw-textarea - Use <TextArea> from components/form/input/TextArea
* 5. no-icon-children - Use startIcon/endIcon props instead of icon children in Button
* 6. no-tailwind-default-colors - Disallow Tailwind default color utilities
*
* USAGE: Import in eslint.config.js and enable rules
*/
module.exports = {
meta: {
name: 'eslint-plugin-igny8-design-system',
version: '1.0.0',
},
rules: {
/**
* Disallow raw <button> elements
* Must use <Button> component from components/ui/button/Button
*/
'no-raw-button': {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow raw <button> elements. Use <Button> from components/ui/button/Button instead.',
recommended: true,
},
messages: {
useButtonComponent: 'Use <Button> from components/ui/button/Button instead of raw <button>. For icon-only buttons, use <IconButton> from components/ui/button/IconButton.',
},
schema: [],
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type === 'JSXIdentifier' && node.name.name === 'button') {
// Allow in specific files that need raw buttons (like the Button component itself)
const filename = context.getFilename();
const allowedFiles = [
'Button.tsx',
'IconButton.tsx',
'ButtonGroup.tsx',
'Components.tsx', // Demo page
'UIElements.tsx', // Demo page
// Low-level UI components that need raw buttons
'Pagination.tsx',
'CompactPagination.tsx',
'AlertModal.tsx',
'Accordion.tsx',
'AccordionItem.tsx',
'Tabs.tsx',
'Tab.tsx',
'DropdownItem.tsx',
'SelectDropdown.tsx',
'MultiSelect.tsx',
'List.tsx',
'ListRadioItem.tsx',
'Dropdown.tsx',
// Form components
'PhoneInput.tsx',
'SearchModal.tsx',
];
if (allowedFiles.some(f => filename.endsWith(f))) {
return;
}
context.report({
node,
messageId: 'useButtonComponent',
});
}
},
};
},
},
/**
* Disallow raw <input> elements
* Must use <InputField>, <Checkbox>, <Radio>, or <FileInput>
*/
'no-raw-input': {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow raw <input> elements. Use InputField, Checkbox, Radio, or FileInput from components/form/input.',
recommended: true,
},
messages: {
useInputComponent: 'Use InputField, Checkbox, Radio, or FileInput from components/form/input instead of raw <input>.',
},
schema: [],
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type === 'JSXIdentifier' && node.name.name === 'input') {
const filename = context.getFilename();
const allowedFiles = [
'InputField.tsx',
'Checkbox.tsx',
'Radio.tsx',
'RadioSm.tsx',
'FileInput.tsx',
'Switch.tsx',
'Components.tsx',
'UIElements.tsx',
'date-picker.tsx',
// Low-level UI components
'Pagination.tsx',
'CompactPagination.tsx',
'ListRadioItem.tsx',
'SearchModal.tsx',
'PhoneInput.tsx',
'SelectDropdown.tsx',
'MultiSelect.tsx',
'ConfigModal.tsx', // Complex automation config
];
if (allowedFiles.some(f => filename.endsWith(f))) {
return;
}
context.report({
node,
messageId: 'useInputComponent',
});
}
},
};
},
},
/**
* Disallow raw <select> elements
* Must use <Select> or <SelectDropdown> from components/form/
*/
'no-raw-select': {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow raw <select> elements. Use Select or SelectDropdown from components/form.',
recommended: true,
},
messages: {
useSelectComponent: 'Use Select or SelectDropdown from components/form instead of raw <select>.',
},
schema: [],
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type === 'JSXIdentifier' && node.name.name === 'select') {
const filename = context.getFilename();
const allowedFiles = [
'Select.tsx',
'SelectDropdown.tsx',
'MultiSelect.tsx',
'Components.tsx',
'UIElements.tsx',
'CompactPagination.tsx', // Has custom styling needs
];
if (allowedFiles.some(f => filename.endsWith(f))) {
return;
}
context.report({
node,
messageId: 'useSelectComponent',
});
}
},
};
},
},
/**
* Disallow raw <textarea> elements
* Must use <TextArea> from components/form/input/TextArea
*/
'no-raw-textarea': {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow raw <textarea> elements. Use TextArea from components/form/input/TextArea.',
recommended: true,
},
messages: {
useTextAreaComponent: 'Use TextArea from components/form/input/TextArea instead of raw <textarea>.',
},
schema: [],
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type === 'JSXIdentifier' && node.name.name === 'textarea') {
const filename = context.getFilename();
const allowedFiles = [
'TextArea.tsx',
'Components.tsx',
'UIElements.tsx',
];
if (allowedFiles.some(f => filename.endsWith(f))) {
return;
}
context.report({
node,
messageId: 'useTextAreaComponent',
});
}
},
};
},
},
/**
* Disallow icon components as direct children of Button, ButtonGroupItem
* Icons must be passed via startIcon or endIcon props to ensure proper horizontal layout.
* When icons are children, they get wrapped in spans causing vertical stacking.
*
* WRONG: <Button><PlusIcon />Add Item</Button> -- icon appears ABOVE text
* RIGHT: <Button startIcon={<PlusIcon />}>Add Item</Button> -- icon appears LEFT of text
*/
'no-icon-children': {
meta: {
type: 'problem',
docs: {
description: 'Disallow icon components as children of Button. Use startIcon/endIcon props instead.',
recommended: true,
},
messages: {
useIconProps: 'Icon "{{iconName}}" should be passed via startIcon or endIcon prop, not as a child. Icons as children cause vertical stacking. Use: <Button startIcon={<{{iconName}} />}>Text</Button>',
useIconPropsGeneric: 'Icons should be passed via startIcon or endIcon prop, not as children. Icons as children cause vertical stacking.',
},
schema: [],
},
create(context) {
// Components that support startIcon/endIcon props
const buttonComponents = ['Button', 'ButtonGroupItem'];
// Pattern to detect icon component names (ends with Icon or is a known icon)
const iconPatterns = [
/Icon$/, // Any component ending in "Icon" (PlusIcon, RefreshCwIcon, etc.)
/^Icon[A-Z]/, // Icon prefix pattern
];
function isIconComponent(name) {
return iconPatterns.some(pattern => pattern.test(name));
}
return {
JSXElement(node) {
const openingElement = node.openingElement;
// Check if this is a Button or ButtonGroupItem
if (openingElement.name.type !== 'JSXIdentifier') {
return;
}
const componentName = openingElement.name.name;
if (!buttonComponents.includes(componentName)) {
return;
}
// Check if startIcon or endIcon props are already provided
const hasIconProp = openingElement.attributes.some(attr => {
return attr.type === 'JSXAttribute' &&
attr.name &&
(attr.name.name === 'startIcon' || attr.name.name === 'endIcon' || attr.name.name === 'icon');
});
// If icon props are present, likely the component is correctly configured
// Still check children for additional icons
// Check children for icon components
const children = node.children || [];
for (const child of children) {
// Direct JSX element child
if (child.type === 'JSXElement') {
const childName = child.openingElement.name;
if (childName.type === 'JSXIdentifier' && isIconComponent(childName.name)) {
context.report({
node: child,
messageId: 'useIconProps',
data: { iconName: childName.name },
});
}
}
// JSX expression containing icon (e.g., {loading ? <Spinner /> : <PlusIcon />})
if (child.type === 'JSXExpressionContainer') {
const expression = child.expression;
// Direct icon in expression: {<PlusIcon />}
if (expression.type === 'JSXElement') {
const exprName = expression.openingElement.name;
if (exprName.type === 'JSXIdentifier' && isIconComponent(exprName.name)) {
context.report({
node: expression,
messageId: 'useIconProps',
data: { iconName: exprName.name },
});
}
}
// Conditional expression: {condition ? <IconA /> : <IconB />}
if (expression.type === 'ConditionalExpression') {
const consequent = expression.consequent;
const alternate = expression.alternate;
if (consequent.type === 'JSXElement') {
const consName = consequent.openingElement.name;
if (consName.type === 'JSXIdentifier' && isIconComponent(consName.name)) {
context.report({
node: consequent,
messageId: 'useIconProps',
data: { iconName: consName.name },
});
}
}
if (alternate.type === 'JSXElement') {
const altName = alternate.openingElement.name;
if (altName.type === 'JSXIdentifier' && isIconComponent(altName.name)) {
context.report({
node: alternate,
messageId: 'useIconProps',
data: { iconName: altName.name },
});
}
}
}
// Logical expression: {showIcon && <PlusIcon />}
if (expression.type === 'LogicalExpression') {
const right = expression.right;
if (right.type === 'JSXElement') {
const rightName = right.openingElement.name;
if (rightName.type === 'JSXIdentifier' && isIconComponent(rightName.name)) {
context.report({
node: right,
messageId: 'useIconProps',
data: { iconName: rightName.name },
});
}
}
}
}
}
},
};
},
},
/**
* Disallow Tailwind default color utilities (blue-*, red-*, green-*, etc.)
* Only allow brand-*, gray-*, success-*, warning-*, error-*, purple-*
*/
'no-tailwind-default-colors': {
meta: {
type: 'problem',
docs: {
description: 'Disallow Tailwind default color utilities. Use brand/gray/success/warning/error/purple palettes only.',
recommended: true,
},
messages: {
noDefaultColors: 'Disallowed Tailwind color utility "{{className}}". Use brand/gray/success/warning/error/purple instead.',
},
schema: [],
},
create(context) {
const allowedPalettes = new Set(['brand', 'gray', 'success', 'warning', 'error', 'purple']);
const disallowedPalettes = new Set([
'blue', 'red', 'green', 'emerald', 'amber', 'indigo', 'violet', 'info',
'fuchsia', 'pink', 'rose', 'sky', 'teal', 'cyan', 'lime', 'yellow',
'orange', 'slate', 'zinc', 'neutral', 'stone'
]);
const allowedSpecial = new Set([
'text-white', 'bg-white', 'border-white', 'fill-white', 'stroke-white',
'text-black', 'bg-black', 'border-black', 'fill-black', 'stroke-black',
'text-transparent', 'bg-transparent', 'border-transparent',
]);
const classRegex = /^(?:bg|text|border|ring|stroke|fill|from|to|via|outline|decoration|placeholder|caret|divide)-([a-z]+)-(?:\d{2,3})$/;
function extractClassStrings(attrValue) {
const results = [];
if (!attrValue) return results;
if (attrValue.type === 'Literal' && typeof attrValue.value === 'string') {
results.push(attrValue.value);
}
if (attrValue.type === 'JSXExpressionContainer') {
const expr = attrValue.expression;
if (expr.type === 'Literal' && typeof expr.value === 'string') {
results.push(expr.value);
}
if (expr.type === 'TemplateLiteral') {
for (const quasi of expr.quasis) {
if (typeof quasi.value.cooked === 'string') {
results.push(quasi.value.cooked);
}
}
}
}
return results;
}
function normalizeToken(token) {
const base = token.split(':').pop() || token;
return base.replace(/\/[0-9]+$/, '');
}
return {
JSXAttribute(node) {
if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'className') {
return;
}
const classStrings = extractClassStrings(node.value);
if (!classStrings.length) return;
for (const classString of classStrings) {
const tokens = classString.split(/\s+/).filter(Boolean);
for (const token of tokens) {
const normalized = normalizeToken(token);
if (allowedSpecial.has(normalized)) continue;
const match = normalized.match(classRegex);
if (!match) continue;
const palette = match[1];
if (allowedPalettes.has(palette)) continue;
if (disallowedPalettes.has(palette)) {
context.report({
node,
messageId: 'noDefaultColors',
data: { className: token },
});
}
}
}
},
};
},
},
},
};