Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions web/modules/custom/sdc_validator/drush.services.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
services:
sdc_validator.commands:
class: \Drupal\sdc_validator\Commands\ValidateComponentCommand
arguments: ['@plugin.manager.sdc', '@twig']
tags:
- { name: drush.command }
25 changes: 25 additions & 0 deletions web/modules/custom/sdc_validator/phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
bootstrap="../build/web/core/tests/bootstrap.php"
colors="true"
cacheResultFile="../build/.phpunit.cache/test-results"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
beStrictAboutTodoAnnotatedTests="true"
convertWarningsToExceptions="false"
failOnRisky="true"
failOnWarning="false"
verbose="true">
<testsuites>
<testsuite name="sdc_validator">
<directory>./tests/src/</directory>
</testsuite>
</testsuites>
<php>
<ini name="error_reporting" value="-1" />
<server name="KERNEL_DIR" value="../build/web/core" />
<env name="SIMPLETEST_BASE_URL" value="http://localhost" />
<env name="SIMPLETEST_DB" value="sqlite://localhost/sites/default/files/.ht.sqlite" />
</php>
</phpunit>
5 changes: 5 additions & 0 deletions web/modules/custom/sdc_validator/sdc_validator.info.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: Single Directory Component Validator
type: module
description: 'Validator for Single Directory components.'
package: Custom
core_version_requirement: ^10 || ^11
37 changes: 37 additions & 0 deletions web/modules/custom/sdc_validator/sdc_validator.module
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/**
* @file
*/

declare(strict_types=1);

/**
* @file
* CivicTheme Development module.
*/

/**
* Implements hook_twig_validator_rule_info_alter().
*/
function sdc_validator_twig_validator_rule_info_alter(array &$info): void {
// @see https://www.drupal.org/project/sdc_devel/issues/3517321
$rule_name_ignore = -1;
$rule_name_error = 3;
$rule_name_warning = 4;
$rule_name_notice = 5;

$info['filter']['rule_on_name'][$rule_name_ignore][] = 'raw';

// Ignore "Use slots instead of hard embedding a component in the template with `@name`.".
unset($info['include']);
// Ignore "Use slots instead of hard embedding a component in the template with `includ\r\ne`.".
unset($info['function']['rule_on_name'][$rule_name_error]['parent']);
// Ignore "Replace with Twig function include()".
unset($info['function']['rule_on_name'][$rule_name_notice]['pattern']);
// Ignore "Careful with Twig function: `source`. Bad architecture, but sometimes needed\r\n for shared static files.".
$info['function']['rule_on_name'][$rule_name_ignore][] = 'source';
unset($info['function']['rule_on_name'][$rule_name_warning]['source']);
// Ignore "`is iterable` test is too ambiguous. Use `is sequence` or `is mapping`.".
unset($info['test']);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php

declare(strict_types=1);

namespace Drupal\sdc_validator\Commands;

use Drupal\Core\Plugin\Component;
use Drupal\Core\Render\Component\Exception\InvalidComponentException;
use Drupal\Core\Template\ComponentNodeVisitor;
use Drupal\Core\Template\TwigEnvironment;
use Drupal\Core\Theme\Component\ComponentValidator;
use Drupal\Core\Theme\ComponentPluginManager;
use Drush\Commands\DrushCommands;
use Symfony\Component\Yaml\Yaml;
use Twig\Error\Error;
use Twig\Node\Node;

/**
* Validates SDC component definitions using Drupal core's ComponentValidator.
*
* @package Drupal\sdc_validator\Commands
*/
class ValidateComponentCommand extends DrushCommands {

/**
* Defines the component validator.
*/
protected ComponentValidator $componentValidator;

/**
* Validates slots of a component.
*/
protected ComponentNodeVisitor $componentSlotValidator;

/**
* {@inheritdoc}
*/
public function __construct(protected ComponentPluginManager $componentPluginManager, protected TwigEnvironment $twig) {
parent::__construct();
$this->componentValidator = new ComponentValidator();
$this->componentValidator->setValidator();
$this->componentSlotValidator = new ComponentNodeVisitor($this->componentPluginManager);
}

/**
* Validates all component definitions in a given path.
*
* @param string $components_path
* Path to the directory containing component folders.
*
* @command sdc_validator:validate
* @usage drush sdc_validator:validate '<path to components>'
* @usage drush sdc_validator:validate 'web/themes/custom/civictheme/components'
*/
public function validateComponentDefinitions(string $components_path): void {
if (!is_dir($components_path)) {
throw new \Exception('❌ Components directory not found: ' . $components_path);
}
$this->output()->writeln(sprintf('🔍 Validating components in %s', $components_path));
$component_path_parts = explode('/', $components_path);
array_filter($component_path_parts);
$component_base_identifier = $component_path_parts[count($component_path_parts) - 2] ?? NULL;
if ($component_base_identifier === NULL) {
throw new \Exception('❌ Cannot validate components that are not located in a theme or a module: ' . $components_path);
}
Comment on lines +63 to +68
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix path sanitisation before deriving the component ID.

array_filter($component_path_parts); does nothing without reassigning the filtered result. With a trailing slash (common when tab-completing paths) the last segment becomes an empty string, so $component_base_identifier resolves to 'components', and find() looks up components:<name> instead of the actual theme/module. The command then fails despite a valid directory. Reassign the filtered segments (and reindex) before using them:

-    $component_path_parts = explode('/', $components_path);
-    array_filter($component_path_parts);
+    $component_path_parts = array_values(array_filter(
+      explode('/', $components_path),
+      static fn ($segment) => $segment !== ''
+    ));

This guarantees the second-last element really is the extension machine name.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$component_path_parts = explode('/', $components_path);
array_filter($component_path_parts);
$component_base_identifier = $component_path_parts[count($component_path_parts) - 2] ?? NULL;
if ($component_base_identifier === NULL) {
throw new \Exception('❌ Cannot validate components that are not located in a theme or a module: ' . $components_path);
}
$component_path_parts = array_values(array_filter(
explode('/', $components_path),
static fn ($segment) => $segment !== ''
));
$component_base_identifier = $component_path_parts[count($component_path_parts) - 2] ?? NULL;
if ($component_base_identifier === NULL) {
throw new \Exception('❌ Cannot validate components that are not located in a theme or a module: ' . $components_path);
}
🤖 Prompt for AI Agents
In web/modules/custom/sdc_validator/src/Commands/ValidateComponentCommand.php
around lines 60 to 65, the code calls array_filter($component_path_parts)
without reassigning, so trailing slashes leave empty segments and the
second-last element can be wrong; reassign the filtered and reindexed array
(e.g. $component_path_parts = array_values(array_filter($component_path_parts)))
before computing $component_base_identifier, then compute the second-last
element via count($component_path_parts) - 2 and keep the existing
NULL/exception guard when there aren’t enough segments.

$component_files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($components_path),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'yml' && str_ends_with((string) $file->getFilename(), '.component.yml')) {
$component_files[] = $file->getPathname();
}
}

if (empty($component_files)) {
throw new \Exception('❌ No component definition files found in: ' . $components_path);
}

$errors = [];
$valid_count = 0;

foreach ($component_files as $component_file) {
try {
$component_name = basename((string) $component_file, '.component.yml');
$component_id = $component_base_identifier . ':' . $component_name;
$component = $this->componentPluginManager->find($component_id);
$template_path = $component->getTemplatePath();
if ($template_path === NULL) {
throw new \Exception(sprintf('❌ %s does not have a template.', $component_id));
}
$source = $this->twig->getLoader()->getSourceContext($template_path);
try {
// Need to load as a component.
$node_tree = $this->twig->parse($this->twig->tokenize($source));
}
catch (Error $error) {
throw new \Exception("❌ Error parsing twig file: " . $error->getMessage(), $error->getCode(), $error);
}
$this->validateSlots($component, $node_tree->getNode('blocks'));
$definition = Yaml::parseFile($component_file);
// Merge with additional required keys.
$definition = array_merge(
$definition,
[
'machineName' => $component_name,
'extension_type' => 'theme',
'id' => 'civictheme:' . $component_name,
'library' => ['css' => ['component' => ['foo.css' => []]]],
'path' => '',
'provider' => 'civictheme',
'template' => $component_name . '.twig',
'group' => 'civictheme-group',
'description' => 'CivicTheme component',
]
);
$this->validateComponentFile($definition);
$valid_count++;
}
catch (\Exception $e) {
$errors[] = [
'file' => basename((string) $component_file),
'error' => $e->getMessage(),
];
}
}

// Display summary.
if ($valid_count > 0) {
$this->output()->writeln(sprintf("✅ %d components are valid", $valid_count));
}
if ($errors !== []) {
$this->output()->writeln("Failed components:");
foreach ($errors as $error) {
$this->output()->writeln(sprintf("❌ %s - %s", $error['file'], $error['error']));
}
throw new \Exception("Component validation failed.");
}
$this->output()->writeln("✨ All components are valid");
}

/**
* Validates a single component definition file.
*
* @param array $definition
* The component definition.
*
* @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException
*/
public function validateComponentFile(array $definition): void {
$this->componentValidator->validateDefinition($definition, TRUE);
}

/**
* Moved from \Drupal\Core\Template\ComponentNodeVisitor::validateSlots.
*
* Performs a cheap validation of the slots in the template.
*
* It validates them against the JSON Schema provided in the component
* definition file and massaged in the ComponentMetadata class. We don't use
* the JSON Schema validator because we just want to validate required and
* undeclared slots. This cheap validation lets us validate during runtime
* even in production.
*
* @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException
* When the slots don't pass validation.
*
* @see \Drupal\Core\Template\ComponentNodeVisitor::validateSlots
*/
protected function validateSlots(Component $component, Node $node): void {
$metadata = $component->metadata;
if (!$metadata->mandatorySchemas) {
return;
}
$slot_definitions = $metadata->slots;
$ids_available = array_keys($slot_definitions);
$undocumented_ids = [];
try {
$it = $node->getIterator();
}
catch (\Exception) {
return;
}
if ($it instanceof \SeekableIterator) {
while ($it->valid()) {
$provided_id = $it->key();
if (!in_array($provided_id, $ids_available, TRUE)) {
$undocumented_ids[] = $provided_id;
}
$it->next();
}
}
// Now build the error message.
$error_messages = [];
if (!empty($undocumented_ids)) {
$error_messages[] = sprintf(
'We found an unexpected slot that is not declared: [%s]. Declare them in "%s.component.yml".',
implode(', ', $undocumented_ids),
$component->machineName
);
}
if (!empty($error_messages)) {
$message = implode("\n", $error_messages);
throw new InvalidComponentException($message);
}
}

}
Loading
Loading