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(); + } + +}