diff --git a/web/modules/custom/sdc_validator/drush.services.yml b/web/modules/custom/sdc_validator/drush.services.yml
new file mode 100644
index 000000000..01affa37e
--- /dev/null
+++ b/web/modules/custom/sdc_validator/drush.services.yml
@@ -0,0 +1,6 @@
+services:
+ sdc_validator.commands:
+ class: \Drupal\sdc_validator\Commands\ValidateComponentCommand
+ arguments: ['@plugin.manager.sdc', '@twig']
+ tags:
+ - { name: drush.command }
diff --git a/web/modules/custom/sdc_validator/phpunit.xml.dist b/web/modules/custom/sdc_validator/phpunit.xml.dist
new file mode 100644
index 000000000..b373d6f4b
--- /dev/null
+++ b/web/modules/custom/sdc_validator/phpunit.xml.dist
@@ -0,0 +1,25 @@
+
+
+
+
+ ./tests/src/
+
+
+
+
+
+
+
+
+
diff --git a/web/modules/custom/sdc_validator/sdc_validator.info.yml b/web/modules/custom/sdc_validator/sdc_validator.info.yml
new file mode 100644
index 000000000..67dbcb484
--- /dev/null
+++ b/web/modules/custom/sdc_validator/sdc_validator.info.yml
@@ -0,0 +1,5 @@
+name: Single Directory Component Validator
+type: module
+description: 'Validator for Single Directory components.'
+package: Custom
+core_version_requirement: ^10 || ^11
diff --git a/web/modules/custom/sdc_validator/sdc_validator.module b/web/modules/custom/sdc_validator/sdc_validator.module
new file mode 100644
index 000000000..d55bd636b
--- /dev/null
+++ b/web/modules/custom/sdc_validator/sdc_validator.module
@@ -0,0 +1,37 @@
+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 ''
+ * @usage drush sdc_validator:validate 'web/themes/custom/civictheme/components'
+ *
+ * @SuppressWarnings(PHPMD.StaticAccess)
+ */
+ public function validateComponentDefinitions(string $components_path): CommandResult {
+ 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);
+ }
+ $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);
+ $this->validateSlots($component);
+ $this->validateComponentFile($component_file, $component_id);
+ $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']));
+ }
+ return CommandResult::dataWithExitCode('Component validation failed.', Command::FAILURE);
+ }
+ return CommandResult::dataWithExitCode('✨ All components are valid', Command::SUCCESS);
+ }
+
+ /**
+ * Validates a single component definition file.
+ *
+ * @param string $component_file
+ * Path to the file.
+ * @param string $component_id
+ * The component id.
+ *
+ * @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException
+ */
+ public function validateComponentFile(string $component_file, string $component_id): void {
+ [, $component_name] = explode(':', $component_id);
+ $definition = Yaml::parseFile($component_file);
+ // Merge with additional required keys.
+ $definition = array_merge(
+ $definition,
+ [
+ 'machineName' => $component_name,
+ 'extension_type' => 'theme',
+ 'id' => $component_id,
+ 'library' => ['css' => ['component' => ['foo.css' => []]]],
+ 'path' => '',
+ 'provider' => 'civictheme',
+ 'template' => $component_name . '.twig',
+ 'group' => 'civictheme-group',
+ 'description' => 'CivicTheme component',
+ ]
+ );
+ $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.
+ *
+ * @param \Drupal\Core\Plugin\Component $component
+ * The component to validate the slots against.
+ *
+ * @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException
+ * When the twig doesn't parse or template does not exist.
+ * @throws \Exception
+ * When the slots don't pass validation.
+ *
+ * @see \Drupal\Core\Template\ComponentNodeVisitor::validateSlots
+ */
+ protected function validateSlots(Component $component): void {
+ $template_path = $component->getTemplatePath();
+ if ($template_path === NULL) {
+ throw new \Exception(sprintf('❌ %s does not have a template.', $component->getI));
+ }
+ $source = $this->twig->getLoader()->getSourceContext($template_path);
+ try {
+ // Need to load as a component.
+ $node_tree = $this->twig->parse($this->twig->tokenize($source));
+ $node = $node_tree->getNode('blocks');
+ }
+ catch (Error $error) {
+ throw new \Exception("❌ Error parsing twig file: " . $error->getMessage(), $error->getCode(), $error);
+ }
+
+ $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);
+ }
+ }
+
+}
diff --git a/web/modules/custom/sdc_validator/tests/src/Kernel/ValidateComponentCommandTest.php b/web/modules/custom/sdc_validator/tests/src/Kernel/ValidateComponentCommandTest.php
new file mode 100644
index 000000000..8980d2c68
--- /dev/null
+++ b/web/modules/custom/sdc_validator/tests/src/Kernel/ValidateComponentCommandTest.php
@@ -0,0 +1,279 @@
+fileSystem = $this->container->get('file_system');
+
+ $this->testComponentsDir = $this->fileSystem->getTempDirectory() . '/test_components_' . uniqid();
+ $this->fileSystem->mkdir($this->testComponentsDir);
+
+ $this->command = new ValidateComponentCommandMock($this->container->get('plugin.manager.sdc'), $this->container->get('twig'));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function tearDown(): void {
+ // Clean up test directory.
+ if ($this->testComponentsDir && is_dir($this->testComponentsDir)) {
+ $this->fileSystem->deleteRecursive($this->testComponentsDir);
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * Tests validation of a valid component.
+ */
+ public function testValidComponent(): void {
+ // Create a valid component file.
+ file_put_contents(
+ $this->testComponentsDir . '/valid.component.yml',
+ <<command->validateComponentDefinitions($this->testComponentsDir);
+ $output = $this->command->getOutputBuffer();
+
+ $this->assertStringContainsString('✅ 1 components are valid', $output);
+ $this->assertStringContainsString('✨ All components are valid', $output);
+ }
+
+ /**
+ * Tests validation with invalid YAML syntax.
+ */
+ public function testInvalidYamlSyntax(): void {
+ // Create component with invalid YAML.
+ file_put_contents(
+ $this->testComponentsDir . '/invalid-yaml.component.yml',
+ <<expectException(\Exception::class);
+ $this->expectExceptionMessage('Component validation failed.');
+
+ try {
+ $this->command->validateComponentDefinitions($this->testComponentsDir);
+ }
+ catch (\Exception $exception) {
+ $output = $this->command->getOutputBuffer();
+ $this->assertStringContainsString('Unable to parse at line', $output);
+ throw $exception;
+ }
+ }
+
+ /**
+ * Tests validation with empty slots property.
+ */
+ public function testEmptySlotsProperty(): void {
+ // Create component with empty slots.
+ file_put_contents(
+ $this->testComponentsDir . '/empty-slots.component.yml',
+ <<expectException(\Exception::class);
+ $this->expectExceptionMessage('Component validation failed.');
+
+ try {
+ $this->command->validateComponentDefinitions($this->testComponentsDir);
+ }
+ catch (\Exception $exception) {
+ $output = $this->command->getOutputBuffer();
+ $this->assertStringContainsString('[slots] NULL value found, but an object is required', $output);
+ throw $exception;
+ }
+ }
+
+ /**
+ * Tests validation with non-existent directory.
+ */
+ public function testNonExistentDirectory(): void {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Components directory not found');
+ $this->command->validateComponentDefinitions('/non/existent/path');
+ }
+
+ /**
+ * Tests validation with directory containing no component files.
+ */
+ public function testNoComponentFiles(): void {
+ // Create empty directory.
+ $empty_dir = $this->testComponentsDir . '/empty';
+ $this->fileSystem->mkdir($empty_dir);
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('No component definition files found');
+ $this->command->validateComponentDefinitions($empty_dir);
+ }
+
+ /**
+ * Tests validation with multiple components including failures.
+ */
+ public function testMultipleComponentsWithFailures(): void {
+ // Create valid component.
+ file_put_contents(
+ $this->testComponentsDir . '/valid.component.yml',
+ <<testComponentsDir . '/invalid.component.yml',
+ <<expectException(\Exception::class);
+ $this->expectExceptionMessage('Component validation failed.');
+
+ try {
+ $this->command->validateComponentDefinitions($this->testComponentsDir);
+ }
+ catch (\Exception $exception) {
+ $output = $this->command->getOutputBuffer();
+ // Should show 1 valid component.
+ $this->assertStringContainsString('✅ 1 components are valid', $output);
+ // Should show the failure.
+ $this->assertStringContainsString('Failed components:', $output);
+ $this->assertStringContainsString('Mapping values are not allowed', $output);
+ throw $exception;
+ }
+ }
+
+}
+
+/**
+ * Mock class for ValidateComponentCommand that captures output.
+ */
+class ValidateComponentCommandMock extends ValidateComponentCommand {
+
+ /**
+ * Output buffer.
+ */
+ private readonly BufferedOutput $outputBuffer;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(protected ComponentPluginManager $componentPluginManager, protected TwigEnvironment $twig) {
+ parent::__construct($componentPluginManager, $twig);
+ $this->outputBuffer = new BufferedOutput();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function output() {
+ return $this->outputBuffer;
+ }
+
+ /**
+ * Get the output buffer content.
+ */
+ public function getOutputBuffer(): string {
+ return $this->outputBuffer->fetch();
+ }
+
+}