- Moved DESIGN-GUIDE.md → docs/30-FRONTEND/DESIGN-GUIDE.md - Moved frontend/DESIGN_SYSTEM.md → docs/30-FRONTEND/DESIGN-TOKENS.md - Moved IGNY8-APP.md → docs/00-SYSTEM/IGNY8-APP.md - Moved fixes-kb.md → docs/90-REFERENCE/FIXES-KB.md - Moved FINAL_PRELAUNCH.md → docs/plans/FINAL-PRELAUNCH.md - Updated all references in .rules, README.md, docs/INDEX.md - Updated ESLint plugin documentation comments - Root folder now only contains: .rules, CHANGELOG.md, README.md
372 lines
13 KiB
JavaScript
372 lines
13 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
|
|
*
|
|
* 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 },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
};
|