/** * ESLint rule to forbid raw HTML across all UI layers * * All HTML must be encapsulated in UI elements from ui/ directory * * Rationale: * - app/ should only contain page/layout components * - components/ should use ui/ elements for all rendering * - templates/ should use ui/ elements for all rendering * - Raw HTML with styling violates separation of concerns * - UI elements ensure consistency and reusability */ module.exports = { meta: { type: 'problem', docs: { description: 'Forbid raw HTML with styling in app/, components/, and templates/ directories', category: 'Architecture', recommended: true, }, fixable: null, schema: [], messages: { noRawHtml: 'Raw HTML with styling is forbidden. Use a UI element from ui/ directory.', noRawHtmlInApp: 'Raw HTML in app/ is forbidden. Use components/ or ui/ elements.', noRawHtmlInComponents: 'Raw HTML in components/ should use ui/ elements instead.', noRawHtmlInTemplates: 'Raw HTML in templates/ should use ui/ elements instead.', }, }, create(context) { const filename = context.getFilename(); // Determine which layer we're in const isInApp = filename.includes('/app/'); const isInComponents = filename.includes('/components/'); const isInTemplates = filename.includes('/templates/'); const isInUi = filename.includes('/ui/'); // Only apply to UI layers (not ui/ itself) if (!isInApp && !isInComponents && !isInTemplates) { return {}; } // HTML tags that should be wrapped in UI elements const htmlTags = [ 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'button', 'input', 'form', 'label', 'select', 'textarea', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'section', 'article', 'header', 'footer', 'nav', 'aside', 'main', 'aside', 'figure', 'figcaption', 'blockquote', 'code', 'pre', 'a', 'img', 'svg', 'path', 'g', 'rect', 'circle', 'hr', 'br', 'strong', 'em', 'b', 'i', 'u', 'small', 'mark' ]; // UI elements that are allowed (from ui/ directory) const allowedUiElements = [ 'Button', 'Input', 'Form', 'Label', 'Select', 'Textarea', 'Card', 'Container', 'Grid', 'Stack', 'Box', 'Text', 'Heading', 'List', 'Table', 'Section', 'Article', 'Header', 'Footer', 'Nav', 'Aside', 'Link', 'Image', 'Icon', 'Avatar', 'Badge', 'Chip', 'Pill', 'Modal', 'Dialog', 'Toast', 'Notification', 'Alert', 'StepIndicator', 'Loading', 'Spinner', 'Progress' ]; return { JSXElement(node) { const openingElement = node.openingElement; if (openingElement.name.type !== 'JSXIdentifier') return; const tagName = openingElement.name.name; // Skip allowed UI elements if (allowedUiElements.includes(tagName)) { return; } // Check if it's a raw HTML element (lowercase) if (htmlTags.includes(tagName) && tagName[0] === tagName[0].toLowerCase()) { // Check for styling attributes const hasClassName = openingElement.attributes.some( attr => attr.type === 'JSXAttribute' && attr.name.name === 'className' ); const hasStyle = openingElement.attributes.some( attr => attr.type === 'JSXAttribute' && attr.name.name === 'style' ); // Check for inline event handlers const hasInlineHandlers = openingElement.attributes.some( attr => attr.type === 'JSXAttribute' && attr.name.name && attr.name.name.startsWith('on') ); // Check for other common attributes that suggest styling/behavior const hasCommonAttrs = openingElement.attributes.some( attr => attr.type === 'JSXAttribute' && ['id', 'role', 'aria-label', 'aria-hidden'].includes(attr.name.name) ); if (hasClassName || hasStyle || hasInlineHandlers || hasCommonAttrs) { let messageId = 'noRawHtml'; if (isInApp) { messageId = 'noRawHtmlInApp'; } else if (isInComponents) { messageId = 'noRawHtmlInComponents'; } else if (isInTemplates) { messageId = 'noRawHtmlInTemplates'; } context.report({ node, messageId, }); } } }, // Also check for dangerouslySetInnerHTML JSXAttribute(node) { if (node.name.name === 'dangerouslySetInnerHTML') { let messageId = 'noRawHtml'; if (isInApp) { messageId = 'noRawHtmlInApp'; } else if (isInComponents) { messageId = 'noRawHtmlInComponents'; } else if (isInTemplates) { messageId = 'noRawHtmlInTemplates'; } context.report({ node, messageId, }); } }, // Check for HTML strings in JSX expressions JSXExpressionContainer(node) { if (node.expression.type === 'Literal' && typeof node.expression.value === 'string') { const value = node.expression.value.trim(); // Check if it contains HTML-like content if (value.includes('<') && value.includes('>') && (value.includes('class=') || value.includes('style=') || value.match(/<\w+[^>]*>/))) { let messageId = 'noRawHtml'; if (isInApp) { messageId = 'noRawHtmlInApp'; } else if (isInComponents) { messageId = 'noRawHtmlInComponents'; } else if (isInTemplates) { messageId = 'noRawHtmlInTemplates'; } context.report({ node, messageId, }); } } }, }; }, };