Merge jqhtml class naming rules, add BEM child class check
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
416
app/RSpade/CodeQuality/Rules/Jqhtml/JqhtmlClassNaming_CodeQualityRule.php
Executable file
416
app/RSpade/CodeQuality/Rules/Jqhtml/JqhtmlClassNaming_CodeQualityRule.php
Executable file
@@ -0,0 +1,416 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\CodeQuality\Rules\Jqhtml;
|
||||||
|
|
||||||
|
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
||||||
|
use App\RSpade\Core\Manifest\Manifest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JQHTML Class Naming Rule
|
||||||
|
*
|
||||||
|
* Combined rule for efficient single-pass checking of jqhtml class naming:
|
||||||
|
*
|
||||||
|
* 1. Component names must start with uppercase (library requirement)
|
||||||
|
* 2. Redundant class attributes on Define tags (component name auto-added)
|
||||||
|
* 3. BEM child elements must use PascalCase prefix, not kebab-case
|
||||||
|
*
|
||||||
|
* For example, with <Define:My_Component>:
|
||||||
|
* - SCSS: .My_Component { &__element { ... } } compiles to .My_Component__element
|
||||||
|
* - HTML must use: class="My_Component__element"
|
||||||
|
* - NOT: class="my-component__element" (styles won't apply)
|
||||||
|
*/
|
||||||
|
class JqhtmlClassNaming_CodeQualityRule extends CodeQualityRule_Abstract
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the unique identifier for this rule
|
||||||
|
*/
|
||||||
|
public function get_id(): string
|
||||||
|
{
|
||||||
|
return 'JQHTML-CLASS-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the human-readable name of this rule
|
||||||
|
*/
|
||||||
|
public function get_name(): string
|
||||||
|
{
|
||||||
|
return 'JQHTML Class Naming';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the description of what this rule checks
|
||||||
|
*/
|
||||||
|
public function get_description(): string
|
||||||
|
{
|
||||||
|
return 'Validates component naming, redundant class attributes, and BEM child class naming in jqhtml templates';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file patterns this rule should check
|
||||||
|
*/
|
||||||
|
public function get_file_patterns(): array
|
||||||
|
{
|
||||||
|
return ['*.jqhtml', '*.js'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This rule runs during manifest scan for immediate feedback
|
||||||
|
*/
|
||||||
|
public function is_called_during_manifest_scan(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the file for violations
|
||||||
|
*/
|
||||||
|
public function check(string $file_path, string $contents, array $metadata = []): void
|
||||||
|
{
|
||||||
|
if (str_ends_with($file_path, '.jqhtml')) {
|
||||||
|
$this->check_jqhtml_file($file_path, $contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($file_path, '.js')) {
|
||||||
|
$this->check_javascript_file($file_path, $contents, $metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check jqhtml template files - single pass for all checks
|
||||||
|
*/
|
||||||
|
private function check_jqhtml_file(string $file_path, string $contents): void
|
||||||
|
{
|
||||||
|
$lines = explode("\n", $contents);
|
||||||
|
|
||||||
|
// First, find the component name from the Define tag
|
||||||
|
$component_name = null;
|
||||||
|
$component_line_number = null;
|
||||||
|
|
||||||
|
foreach ($lines as $idx => $line) {
|
||||||
|
// Match <Define:ComponentName> or <Define:ComponentName ...>
|
||||||
|
if (preg_match('/<Define:([a-zA-Z_][a-zA-Z0-9_]*)/', $line, $matches)) {
|
||||||
|
$component_name = $matches[1];
|
||||||
|
$component_line_number = $idx + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no component name found, bail gracefully
|
||||||
|
if ($component_name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kebab_case = $this->to_kebab_case($component_name);
|
||||||
|
$underscore_lower = strtolower($component_name);
|
||||||
|
|
||||||
|
// Check 1: Component name must start with uppercase
|
||||||
|
if (!ctype_upper($component_name[0])) {
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$component_line_number,
|
||||||
|
"JQHTML component name '{$component_name}' must start with an uppercase letter",
|
||||||
|
trim($lines[$component_line_number - 1]),
|
||||||
|
"Change '{$component_name}' to '" . ucfirst($component_name) . "'. " .
|
||||||
|
"This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
|
||||||
|
'critical'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now scan all lines for class attribute issues
|
||||||
|
$line_number = 0;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line_number++;
|
||||||
|
|
||||||
|
// Check 2: Redundant class on Define tag
|
||||||
|
if (preg_match('/<Define:' . preg_quote($component_name, '/') . '\s+[^>]*class=["\']([^"\']*)["\']/', $line, $matches)) {
|
||||||
|
$class_attribute = $matches[1];
|
||||||
|
$this->check_redundant_define_class($file_path, $line_number, $line, $component_name, $class_attribute, $kebab_case);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: BEM child classes using wrong case
|
||||||
|
// Find all class="..." attributes in this line
|
||||||
|
if (preg_match_all('/class=["\']([^"\']*)["\']/', $line, $all_class_matches)) {
|
||||||
|
foreach ($all_class_matches[1] as $class_attribute) {
|
||||||
|
$this->check_bem_child_classes($file_path, $line_number, $line, $component_name, $class_attribute, $kebab_case, $underscore_lower);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for redundant class names on the Define tag
|
||||||
|
*/
|
||||||
|
private function check_redundant_define_class(
|
||||||
|
string $file_path,
|
||||||
|
int $line_number,
|
||||||
|
string $line,
|
||||||
|
string $component_name,
|
||||||
|
string $class_attribute,
|
||||||
|
string $kebab_case
|
||||||
|
): void {
|
||||||
|
$classes = preg_split('/\s+/', trim($class_attribute));
|
||||||
|
|
||||||
|
foreach ($classes as $class) {
|
||||||
|
$class = trim($class);
|
||||||
|
if (empty($class)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match with component name
|
||||||
|
if ($class === $component_name) {
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_number,
|
||||||
|
"Redundant class=\"{$component_name}\" on component {$component_name}. " .
|
||||||
|
"The component automatically gets class=\"{$component_name}\" assigned to it.",
|
||||||
|
trim($line),
|
||||||
|
$this->build_redundant_class_suggestion($component_name, $class_attribute, $class),
|
||||||
|
'medium'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Kebab-case equivalent (only for multi-word components)
|
||||||
|
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_redundant_class_suggestion($component_name, $class_attribute, $class),
|
||||||
|
'medium'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for BEM child classes using wrong case prefix
|
||||||
|
*
|
||||||
|
* Detects patterns like:
|
||||||
|
* my-component__element (should be My_Component__element)
|
||||||
|
* my-component--modifier (should be My_Component--modifier)
|
||||||
|
* my_component__element (should be My_Component__element)
|
||||||
|
*/
|
||||||
|
private function check_bem_child_classes(
|
||||||
|
string $file_path,
|
||||||
|
int $line_number,
|
||||||
|
string $line,
|
||||||
|
string $component_name,
|
||||||
|
string $class_attribute,
|
||||||
|
string $kebab_case,
|
||||||
|
string $underscore_lower
|
||||||
|
): void {
|
||||||
|
// Only check multi-word component names (with underscores)
|
||||||
|
if (strpos($component_name, '_') === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = preg_split('/\s+/', trim($class_attribute));
|
||||||
|
|
||||||
|
foreach ($classes as $class) {
|
||||||
|
$class = trim($class);
|
||||||
|
if (empty($class)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if class correctly starts with component name (PascalCase)
|
||||||
|
if (strpos($class, $component_name . '__') === 0 || strpos($class, $component_name . '--') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for kebab-case prefix with BEM separator (__ or --)
|
||||||
|
// Pattern: my-component__something or my-component--something
|
||||||
|
if (preg_match('/^' . preg_quote($kebab_case, '/') . '(__|--)(.+)$/', $class, $matches)) {
|
||||||
|
$separator = $matches[1];
|
||||||
|
$suffix = $matches[2];
|
||||||
|
$correct_class = $component_name . $separator . $suffix;
|
||||||
|
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_number,
|
||||||
|
"BEM class \"{$class}\" uses kebab-case prefix. " .
|
||||||
|
"SCSS compiles .{$component_name} { &{$separator}{$suffix} } to .{$correct_class}, so HTML must match.",
|
||||||
|
trim($line),
|
||||||
|
$this->build_bem_suggestion($component_name, $class, $correct_class),
|
||||||
|
'high'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for lowercase underscore prefix with BEM separator
|
||||||
|
// Pattern: my_component__something or my_component--something
|
||||||
|
if (preg_match('/^' . preg_quote($underscore_lower, '/') . '(__|--)(.+)$/', $class, $matches)) {
|
||||||
|
$separator = $matches[1];
|
||||||
|
$suffix = $matches[2];
|
||||||
|
$correct_class = $component_name . $separator . $suffix;
|
||||||
|
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_number,
|
||||||
|
"BEM class \"{$class}\" uses lowercase prefix. " .
|
||||||
|
"SCSS compiles .{$component_name} { &{$separator}{$suffix} } to .{$correct_class}, so HTML must match.",
|
||||||
|
trim($line),
|
||||||
|
$this->build_bem_suggestion($component_name, $class, $correct_class),
|
||||||
|
'high'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check JavaScript files for Component subclasses
|
||||||
|
*/
|
||||||
|
private function check_javascript_file(string $file_path, string $contents, array $metadata = []): void
|
||||||
|
{
|
||||||
|
$lines = explode("\n", $contents);
|
||||||
|
|
||||||
|
// Get JavaScript class from manifest metadata
|
||||||
|
$js_classes = [];
|
||||||
|
if (isset($metadata['class']) && isset($metadata['extension']) && $metadata['extension'] === 'js') {
|
||||||
|
$js_classes = [$metadata['class']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check class definitions from metadata
|
||||||
|
if (!empty($js_classes)) {
|
||||||
|
$class_definitions = [];
|
||||||
|
foreach ($js_classes as $class_name) {
|
||||||
|
foreach ($lines as $idx => $line) {
|
||||||
|
if (preg_match('/class\s+' . preg_quote($class_name, '/') . '\s+/', $line)) {
|
||||||
|
$class_definitions[$class_name] = $idx + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($class_definitions as $class_name => $line_num) {
|
||||||
|
if (Manifest::js_is_subclass_of($class_name, 'Component')) {
|
||||||
|
if (!ctype_upper($class_name[0])) {
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_num,
|
||||||
|
"JQHTML component class '{$class_name}' must start with an uppercase letter",
|
||||||
|
trim($lines[$line_num - 1]),
|
||||||
|
"Change '{$class_name}' to '" . ucfirst($class_name) . "'. " .
|
||||||
|
"This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
|
||||||
|
'critical'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for component registration patterns
|
||||||
|
$line_number = 0;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line_number++;
|
||||||
|
|
||||||
|
if (preg_match('/jqhtml\.component\([\'"]([a-zA-Z_][a-zA-Z0-9_]*)[\'"]/', $line, $matches)) {
|
||||||
|
$component_name = $matches[1];
|
||||||
|
|
||||||
|
if (!ctype_upper($component_name[0])) {
|
||||||
|
$this->add_violation(
|
||||||
|
$file_path,
|
||||||
|
$line_number,
|
||||||
|
"JQHTML component registration '{$component_name}' must use uppercase name",
|
||||||
|
trim($line),
|
||||||
|
"Change '{$component_name}' to '" . ucfirst($component_name) . "'. " .
|
||||||
|
"This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
|
||||||
|
'critical'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert PascalCase_With_Underscores to kebab-case
|
||||||
|
*/
|
||||||
|
private function to_kebab_case(string $component_name): string
|
||||||
|
{
|
||||||
|
return strtolower(str_replace('_', '-', $component_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a class name is a known CSS framework class
|
||||||
|
*/
|
||||||
|
private function is_known_framework_class(string $class): bool
|
||||||
|
{
|
||||||
|
$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 redundant class
|
||||||
|
*/
|
||||||
|
private function build_redundant_class_suggestion(string $component_name, string $class_attribute, string $redundant_class): string
|
||||||
|
{
|
||||||
|
$cleaned = $this->remove_class_from_attribute($class_attribute, $redundant_class);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
$lines[] = "Remove '{$redundant_class}' 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 BEM class naming
|
||||||
|
*/
|
||||||
|
private function build_bem_suggestion(string $component_name, string $wrong_class, string $correct_class): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
$lines[] = "BEM child element classes must use the component's exact PascalCase name as prefix.";
|
||||||
|
$lines[] = "";
|
||||||
|
$lines[] = "SCSS nesting like:";
|
||||||
|
$lines[] = " .{$component_name} {";
|
||||||
|
$lines[] = " &__element { ... }";
|
||||||
|
$lines[] = " }";
|
||||||
|
$lines[] = "";
|
||||||
|
$lines[] = "Compiles to: .{$component_name}__element";
|
||||||
|
$lines[] = "";
|
||||||
|
$lines[] = "So HTML must match:";
|
||||||
|
$lines[] = " WRONG: class=\"{$wrong_class}\"";
|
||||||
|
$lines[] = " CORRECT: class=\"{$correct_class}\"";
|
||||||
|
$lines[] = "";
|
||||||
|
$lines[] = "This is a common mistake when following general web conventions where BEM uses";
|
||||||
|
$lines[] = "kebab-case. In RSX, component class names are PascalCase, so BEM children must also be.";
|
||||||
|
|
||||||
|
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);
|
||||||
|
return trim($cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\RSpade\CodeQuality\Rules\Jqhtml;
|
|
||||||
|
|
||||||
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
|
||||||
use App\RSpade\Core\Manifest\Manifest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JQHTML Component Naming Rule
|
|
||||||
*
|
|
||||||
* Enforces that all jqhtml component names start with an uppercase letter.
|
|
||||||
* This is a hard requirement of the jqhtml library.
|
|
||||||
*/
|
|
||||||
class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the unique identifier for this rule
|
|
||||||
*/
|
|
||||||
public function get_id(): string
|
|
||||||
{
|
|
||||||
return 'JQHTML-NAMING-01';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the human-readable name of this rule
|
|
||||||
*/
|
|
||||||
public function get_name(): string
|
|
||||||
{
|
|
||||||
return 'JQHTML Component Names Must Start Uppercase';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the description of what this rule checks
|
|
||||||
*/
|
|
||||||
public function get_description(): string
|
|
||||||
{
|
|
||||||
return 'Ensures all jqhtml component names start with an uppercase letter (library requirement)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file patterns this rule should check
|
|
||||||
*/
|
|
||||||
public function get_file_patterns(): array
|
|
||||||
{
|
|
||||||
return ['*.jqhtml', '*.js'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This rule should run at manifest-time for immediate feedback
|
|
||||||
* since incorrect naming would break the jqhtml library
|
|
||||||
*/
|
|
||||||
public function is_called_during_manifest_scan(): bool
|
|
||||||
{
|
|
||||||
return true; // Critical library requirement
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the file for violations
|
|
||||||
*/
|
|
||||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
|
||||||
{
|
|
||||||
// Check .jqhtml files for Define: tags
|
|
||||||
if (str_ends_with($file_path, '.jqhtml')) {
|
|
||||||
$this->check_jqhtml_file($file_path, $contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check .js files for classes extending Component
|
|
||||||
if (str_ends_with($file_path, '.js')) {
|
|
||||||
$this->check_javascript_file($file_path, $contents, $metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check jqhtml template files
|
|
||||||
*/
|
|
||||||
private function check_jqhtml_file(string $file_path, string $contents): void
|
|
||||||
{
|
|
||||||
$lines = explode("\n", $contents);
|
|
||||||
$line_number = 0;
|
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$line_number++;
|
|
||||||
|
|
||||||
// Look for <Define:ComponentName> tags
|
|
||||||
if (preg_match('/<Define:([a-zA-Z_][a-zA-Z0-9_]*)>/', $line, $matches)) {
|
|
||||||
$component_name = $matches[1];
|
|
||||||
|
|
||||||
// Check if first character is not uppercase
|
|
||||||
if (!ctype_upper($component_name[0])) {
|
|
||||||
$this->add_violation(
|
|
||||||
$file_path,
|
|
||||||
$line_number,
|
|
||||||
"JQHTML component name '{$component_name}' must start with an uppercase letter",
|
|
||||||
trim($line),
|
|
||||||
"Change '{$component_name}' to '" . ucfirst($component_name) . "'. This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
|
|
||||||
'critical'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check JavaScript files for Component subclasses
|
|
||||||
*/
|
|
||||||
private function check_javascript_file(string $file_path, string $contents, array $metadata = []): void
|
|
||||||
{
|
|
||||||
$lines = explode("\n", $contents);
|
|
||||||
$line_number = 0;
|
|
||||||
|
|
||||||
// Get JavaScript class from manifest metadata
|
|
||||||
$js_classes = [];
|
|
||||||
if (isset($metadata['class']) && isset($metadata['extension']) && $metadata['extension'] === 'js') {
|
|
||||||
$js_classes = [$metadata['class']];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no classes in metadata, nothing to check for class definitions
|
|
||||||
if (!empty($js_classes)) {
|
|
||||||
// Find line numbers for each class
|
|
||||||
$class_definitions = [];
|
|
||||||
foreach ($js_classes as $class_name) {
|
|
||||||
// Find where this class is defined in the source
|
|
||||||
foreach ($lines as $idx => $line) {
|
|
||||||
if (preg_match('/class\s+' . preg_quote($class_name, '/') . '\s+/', $line)) {
|
|
||||||
$class_definitions[$class_name] = $idx + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each class to see if it's a JQHTML component
|
|
||||||
foreach ($class_definitions as $class_name => $line_num) {
|
|
||||||
// Use Manifest to check if this is a JQHTML component (handles indirect inheritance)
|
|
||||||
if (Manifest::js_is_subclass_of($class_name, 'Component')) {
|
|
||||||
// Check if first character is not uppercase
|
|
||||||
if (!ctype_upper($class_name[0])) {
|
|
||||||
$this->add_violation(
|
|
||||||
$file_path,
|
|
||||||
$line_num,
|
|
||||||
"JQHTML component class '{$class_name}' must start with an uppercase letter",
|
|
||||||
trim($lines[$line_num - 1]),
|
|
||||||
"Change '{$class_name}' to '" . ucfirst($class_name) . "'. This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
|
|
||||||
'critical'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still check for component registration patterns
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$line_number++;
|
|
||||||
|
|
||||||
// Also check for component registration patterns
|
|
||||||
if (preg_match('/jqhtml\.component\([\'"]([a-zA-Z_][a-zA-Z0-9_]*)[\'"]/', $line, $matches)) {
|
|
||||||
$component_name = $matches[1];
|
|
||||||
|
|
||||||
if (!ctype_upper($component_name[0])) {
|
|
||||||
$this->add_violation(
|
|
||||||
$file_path,
|
|
||||||
$line_number,
|
|
||||||
"JQHTML component registration '{$component_name}' must use uppercase name",
|
|
||||||
trim($line),
|
|
||||||
"Change '{$component_name}' to '" . ucfirst($component_name) . "'. This is a hard requirement of the jqhtml library - component names MUST start with an uppercase letter.",
|
|
||||||
'critical'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\RSpade\CodeQuality\Rules\Jqhtml;
|
|
||||||
|
|
||||||
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JQHTML Redundant Class Rule
|
|
||||||
*
|
|
||||||
* Detects when a component's class name (or kebab-case equivalent) is redundantly
|
|
||||||
* specified in the class attribute of the Define tag. Component names are automatically
|
|
||||||
* 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
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the unique identifier for this rule
|
|
||||||
*/
|
|
||||||
public function get_id(): string
|
|
||||||
{
|
|
||||||
return 'JQHTML-CLASS-01';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the human-readable name of this rule
|
|
||||||
*/
|
|
||||||
public function get_name(): string
|
|
||||||
{
|
|
||||||
return 'JQHTML Redundant Component Class Name';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the description of what this rule checks
|
|
||||||
*/
|
|
||||||
public function get_description(): string
|
|
||||||
{
|
|
||||||
return 'Detects when component class name (or kebab-case equivalent) is redundantly specified in class attribute';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file patterns this rule should check
|
|
||||||
*/
|
|
||||||
public function get_file_patterns(): array
|
|
||||||
{
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
public function get_default_severity(): string
|
|
||||||
{
|
|
||||||
return 'medium';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the file for violations
|
|
||||||
*/
|
|
||||||
public function check(string $file_path, string $contents, array $metadata = []): void
|
|
||||||
{
|
|
||||||
$lines = explode("\n", $contents);
|
|
||||||
$line_number = 0;
|
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$line_number++;
|
|
||||||
|
|
||||||
// Look for <Define:ComponentName ... class="..."> patterns
|
|
||||||
// Match Define tag with component name and class attribute on same line
|
|
||||||
if (preg_match('/<Define:([a-zA-Z_][a-zA-Z0-9_]*)\s+[^>]*class=["\']([^"\']*)["\']/', $line, $matches)) {
|
|
||||||
$component_name = $matches[1];
|
|
||||||
$class_attribute = $matches[2];
|
|
||||||
$kebab_case = $this->to_kebab_case($component_name);
|
|
||||||
|
|
||||||
// Split class attribute into individual classes
|
|
||||||
$classes = preg_split('/\s+/', trim($class_attribute));
|
|
||||||
|
|
||||||
foreach ($classes as $class) {
|
|
||||||
$class = trim($class);
|
|
||||||
if (empty($class)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for exact match with component name
|
|
||||||
if ($class === $component_name) {
|
|
||||||
$this->add_violation(
|
|
||||||
$file_path,
|
|
||||||
$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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert PascalCase_With_Underscores to kebab-case
|
|
||||||
*
|
|
||||||
* My_Component -> my-component
|
|
||||||
* Breadcrumb_Nav -> breadcrumb-nav
|
|
||||||
*/
|
|
||||||
private function to_kebab_case(string $component_name): string
|
|
||||||
{
|
|
||||||
return strtolower(str_replace('_', '-', $component_name));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
return trim($cleaned);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -285,6 +285,40 @@ HOW IT WORKS
|
|||||||
2. It matches a valid Component subclass or Blade @rsx_id
|
2. It matches a valid Component subclass or Blade @rsx_id
|
||||||
3. The filename matches the associated file
|
3. The filename matches the associated file
|
||||||
|
|
||||||
|
BEM CHILD CLASSES
|
||||||
|
|
||||||
|
When using BEM notation inside component SCSS, child element class
|
||||||
|
names must preserve the component's exact PascalCase class name as
|
||||||
|
the prefix. Do NOT convert to kebab-case.
|
||||||
|
|
||||||
|
The SCSS nesting syntax compiles &__element to the parent selector
|
||||||
|
plus __element. Since the parent is .Component_Name, the result is
|
||||||
|
.Component_Name__element - and HTML must use that exact class.
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
// SCSS
|
||||||
|
.DataGrid_Kanban {
|
||||||
|
&__loading { ... }
|
||||||
|
&__board { ... }
|
||||||
|
&__column { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML (jqhtml template)
|
||||||
|
<div class="DataGrid_Kanban__loading">
|
||||||
|
<div class="DataGrid_Kanban__board">
|
||||||
|
<div class="DataGrid_Kanban__column">
|
||||||
|
|
||||||
|
Wrong:
|
||||||
|
|
||||||
|
// HTML - kebab-case does NOT match compiled CSS
|
||||||
|
<div class="datagrid-kanban__loading"> // No styles applied!
|
||||||
|
<div class="datagrid-kanban__board"> // No styles applied!
|
||||||
|
|
||||||
|
This is a common mistake when following general web conventions where
|
||||||
|
BEM uses kebab-case. In RSX, component class names are PascalCase,
|
||||||
|
so BEM children must also be PascalCase.
|
||||||
|
|
||||||
NO EXEMPTIONS
|
NO EXEMPTIONS
|
||||||
|
|
||||||
There are NO exemptions to this rule for files in rsx/app/ or
|
There are NO exemptions to this rule for files in rsx/app/ or
|
||||||
|
|||||||
@@ -440,6 +440,8 @@ The process involves creating Action classes with @route decorators and converti
|
|||||||
|
|
||||||
**Enforcement**: SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. This works because all jqhtml components, SPA actions/layouts, and Blade views with `@rsx_id` automatically render with `class="Component_Name"` on their root element. `rsx/lib/` is for non-visual plumbing (validators, utilities). `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides.
|
**Enforcement**: SCSS in `rsx/app/` and `rsx/theme/components/` must wrap in a single component class matching the jqhtml/blade file. This works because all jqhtml components, SPA actions/layouts, and Blade views with `@rsx_id` automatically render with `class="Component_Name"` on their root element. `rsx/lib/` is for non-visual plumbing (validators, utilities). `rsx/theme/` (outside components/) holds primitives, variables, Bootstrap overrides.
|
||||||
|
|
||||||
|
**BEM Child Classes**: When using BEM notation, child element classes must use the component's exact class name as prefix. SCSS `.Component_Name { &__element }` compiles to `.Component_Name__element`, so HTML must match: `<div class="Component_Name__element">` not `<div class="component-name__element">`. No kebab-case conversion.
|
||||||
|
|
||||||
**Variables**: Define shared values (colors, spacing, border-radius) in `rsx/theme/variables.scss` or similar. These must be explicitly included before directory includes in bundle definitions. Component-local variables can be defined within the scoped rule.
|
**Variables**: Define shared values (colors, spacing, border-radius) in `rsx/theme/variables.scss` or similar. These must be explicitly included before directory includes in bundle definitions. Component-local variables can be defined within the scoped rule.
|
||||||
|
|
||||||
**Supplemental files**: Multiple SCSS files can target the same component (e.g., breakpoint-specific styles) if a primary file with matching filename exists.
|
**Supplemental files**: Multiple SCSS files can target the same component (e.g., breakpoint-specific styles) if a primary file with matching filename exists.
|
||||||
|
|||||||
Reference in New Issue
Block a user