Add kebab-case detection to JQHTML redundant class rule

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-11 21:16:29 +00:00
parent ed8f24b26d
commit 84136be744
3 changed files with 168 additions and 31 deletions

View File

@@ -7,9 +7,13 @@ use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
/** /**
* JQHTML Redundant Class Rule * JQHTML Redundant Class Rule
* *
* Detects when a component's class name is redundantly specified in the class * Detects when a component's class name (or kebab-case equivalent) is redundantly
* attribute of the Define tag. Component names are automatically added to the * specified in the class attribute of the Define tag. Component names are automatically
* rendered element's class list, so explicitly including them is unnecessary. * added to the rendered element's class list, so explicitly including them is unnecessary.
*
* Detects both:
* <Define:Breadcrumb_Nav class="Breadcrumb_Nav"> // Exact match
* <Define:Breadcrumb_Nav class="breadcrumb-nav"> // Kebab-case equivalent
*/ */
class JqhtmlRedundantClass_CodeQualityRule extends CodeQualityRule_Abstract class JqhtmlRedundantClass_CodeQualityRule extends CodeQualityRule_Abstract
{ {
@@ -34,7 +38,7 @@ class JqhtmlRedundantClass_CodeQualityRule extends CodeQualityRule_Abstract
*/ */
public function get_description(): string public function get_description(): string
{ {
return 'Detects when component class name is redundantly specified in class attribute'; return 'Detects when component class name (or kebab-case equivalent) is redundantly specified in class attribute';
} }
/** /**
@@ -45,6 +49,14 @@ class JqhtmlRedundantClass_CodeQualityRule extends CodeQualityRule_Abstract
return ['*.jqhtml']; return ['*.jqhtml'];
} }
/**
* This rule runs during manifest scan for immediate feedback
*/
public function is_called_during_manifest_scan(): bool
{
return true;
}
/** /**
* Get the default severity level * Get the default severity level
*/ */
@@ -69,43 +81,167 @@ class JqhtmlRedundantClass_CodeQualityRule extends CodeQualityRule_Abstract
if (preg_match('/<Define:([a-zA-Z_][a-zA-Z0-9_]*)\s+[^>]*class=["\']([^"\']*)["\']/', $line, $matches)) { if (preg_match('/<Define:([a-zA-Z_][a-zA-Z0-9_]*)\s+[^>]*class=["\']([^"\']*)["\']/', $line, $matches)) {
$component_name = $matches[1]; $component_name = $matches[1];
$class_attribute = $matches[2]; $class_attribute = $matches[2];
$kebab_case = $this->to_kebab_case($component_name);
// Check if component name appears in the class attribute // Split class attribute into individual classes
// Use word boundary to avoid false positives (e.g., "Client" in "Client_Selector") $classes = preg_split('/\s+/', trim($class_attribute));
if (preg_match('/\b' . preg_quote($component_name, '/') . '\b/', $class_attribute)) {
$this->add_violation( foreach ($classes as $class) {
$file_path, $class = trim($class);
$line_number, if (empty($class)) {
"Redundant class name '{$component_name}' in Define tag class attribute", continue;
trim($line), }
"Remove '{$component_name}' from the class attribute. Component names are automatically added to the rendered element's class list by the jqhtml framework. Explicitly including the component name in the class attribute is unnecessary.\n\n" .
"CURRENT:\n" . // Check for exact match with component name
" <Define:{$component_name} class=\"{$class_attribute}\">\n\n" . if ($class === $component_name) {
"CORRECTED:\n" . $this->add_violation(
" <Define:{$component_name} class=\"" . $this->remove_component_from_class($class_attribute, $component_name) . "\">", $file_path,
'medium' $line_number,
); "Redundant class=\"{$component_name}\" on component {$component_name}. " .
"The component automatically gets class=\"{$component_name}\" assigned to it.",
trim($line),
$this->build_exact_match_suggestion($component_name, $class_attribute),
'medium'
);
}
// Check for kebab-case equivalent (case-insensitive)
// Only flag if component name has underscores (multi-word), since single-word
// lowercase classes like "card" could be legitimate external CSS framework classes
// Also skip known CSS framework class prefixes (Bootstrap, etc.)
elseif (strpos($component_name, '_') !== false
&& strtolower($class) === $kebab_case
&& !$this->is_known_framework_class($class)) {
$this->add_violation(
$file_path,
$line_number,
"Unnecessary class=\"{$class}\" on component {$component_name}. " .
"The component automatically gets class=\"{$component_name}\" assigned to it. " .
"Use .{$component_name} in CSS/JS selectors instead of .{$class}",
trim($line),
$this->build_kebab_case_suggestion($component_name, $class),
'medium'
);
}
} }
} }
} }
} }
/** /**
* Remove component name from class attribute string * Convert PascalCase_With_Underscores to kebab-case
* *
* @param string $class_attribute The class attribute value * My_Component -> my-component
* @param string $component_name The component name to remove * Breadcrumb_Nav -> breadcrumb-nav
* @return string The cleaned class attribute
*/ */
private function remove_component_from_class(string $class_attribute, string $component_name): string private function to_kebab_case(string $component_name): string
{ {
// Remove the component name, handling multiple spaces return strtolower(str_replace('_', '-', $component_name));
$cleaned = preg_replace('/\b' . preg_quote($component_name, '/') . '\b\s*/', '', $class_attribute); }
// Clean up any double spaces /**
* Check if a class name is a known CSS framework class
*
* These are legitimate uses of kebab-case classes from external frameworks
* like Bootstrap that happen to match component names.
*/
private function is_known_framework_class(string $class): bool
{
// Bootstrap class prefixes that are commonly used
$bootstrap_prefixes = [
'card-',
'btn-',
'nav-',
'navbar-',
'form-',
'input-',
'list-',
'table-',
'modal-',
'alert-',
'badge-',
'dropdown-',
'breadcrumb-',
'pagination-',
'progress-',
'spinner-',
'toast-',
'tooltip-',
'popover-',
'carousel-',
'accordion-',
'offcanvas-',
'placeholder-',
];
foreach ($bootstrap_prefixes as $prefix) {
if (strpos($class, $prefix) === 0) {
return true;
}
}
return false;
}
/**
* Build suggestion for exact component name match
*/
private function build_exact_match_suggestion(string $component_name, string $class_attribute): string
{
$cleaned = $this->remove_class_from_attribute($class_attribute, $component_name);
$lines = [];
$lines[] = "Remove '{$component_name}' from the class attribute. Component names are";
$lines[] = "automatically added to the rendered element's class list by the jqhtml framework.";
$lines[] = "";
$lines[] = "CURRENT:";
$lines[] = " <Define:{$component_name} class=\"{$class_attribute}\">";
$lines[] = "";
if (empty($cleaned)) {
$lines[] = "CORRECTED:";
$lines[] = " <Define:{$component_name}>";
} else {
$lines[] = "CORRECTED:";
$lines[] = " <Define:{$component_name} class=\"{$cleaned}\">";
}
return implode("\n", $lines);
}
/**
* Build suggestion for kebab-case equivalent
*/
private function build_kebab_case_suggestion(string $component_name, string $redundant_class): string
{
$lines = [];
$lines[] = "The class \"{$redundant_class}\" is redundant because jqhtml components";
$lines[] = "automatically receive their component name as a CSS class.";
$lines[] = "";
$lines[] = "When you write:";
$lines[] = " <Define:{$component_name} class=\"{$redundant_class}\">";
$lines[] = "";
$lines[] = "The rendered element becomes:";
$lines[] = " <div class=\"{$component_name} {$redundant_class}\">";
$lines[] = "";
$lines[] = "TO FIX:";
$lines[] = " 1. Remove the redundant class from the Define tag:";
$lines[] = " <Define:{$component_name}>";
$lines[] = "";
$lines[] = " 2. Update any CSS/SCSS selectors to use the component class:";
$lines[] = " .{$component_name} { ... } // Instead of .{$redundant_class}";
$lines[] = "";
$lines[] = " 3. Update any JavaScript selectors:";
$lines[] = " \$('.{$component_name}') // Instead of \$('.{$redundant_class}')";
return implode("\n", $lines);
}
/**
* Remove a class from the class attribute string
*/
private function remove_class_from_attribute(string $class_attribute, string $class_to_remove): string
{
$cleaned = preg_replace('/\b' . preg_quote($class_to_remove, '/') . '\b\s*/', '', $class_attribute);
$cleaned = preg_replace('/\s+/', ' ', $cleaned); $cleaned = preg_replace('/\s+/', ' ', $cleaned);
// Trim leading/trailing spaces
return trim($cleaned); return trim($cleaned);
} }
} }

View File

@@ -9,7 +9,7 @@ $expand_depth - How many levels deep to expand
$label - Optional key/index label for this node $label - Optional key/index label for this node
$show_class_names - If true, display class names for named object instances $show_class_names - If true, display class names for named object instances
--%> --%>
<Define:JS_Tree_Debug_Node tag="div" class="js-tree-debug-node"> <Define:JS_Tree_Debug_Node tag="div">
<% <%
const class_name = this.args.show_class_names ? JS_Tree_Debug_Node.get_class_name(this.args.data) : null; const class_name = this.args.show_class_names ? JS_Tree_Debug_Node.get_class_name(this.args.data) : null;
const relationships = JS_Tree_Debug_Node.get_object_relationships(this.args.data); const relationships = JS_Tree_Debug_Node.get_object_relationships(this.args.data);

View File

@@ -13,7 +13,8 @@
border-radius: 4px; border-radius: 4px;
background: #fafafa; background: #fafafa;
.js-tree-debug-node { .js-tree-debug-node,
.JS_Tree_Debug_Node {
margin-left: 0; margin-left: 0;
} }