diff --git a/bin/guides b/bin/guides old mode 100644 new mode 100755 index ae0906f82..df06dec63 --- a/bin/guides +++ b/bin/guides @@ -1,7 +1,138 @@ #!/usr/bin/env php true, 'errors' => []]; + } + + if (!file_exists($xsdPath)) { + // XSD not found, skip validation + return ['valid' => true, 'errors' => []]; + } + + libxml_use_internal_errors(true); + + $dom = new DOMDocument(); + if (!$dom->load($xmlPath)) { + $errors[] = sprintf('Failed to load %s as XML', $xmlPath); + foreach (libxml_get_errors() as $error) { + $errors[] = sprintf(' Line %d, Column %d: %s', $error->line, $error->column, trim($error->message)); + } + libxml_clear_errors(); + return ['valid' => false, 'errors' => $errors]; + } + + if (!$dom->schemaValidate($xsdPath)) { + $errors[] = sprintf('Schema validation failed for %s', $xmlPath); + foreach (libxml_get_errors() as $error) { + $errors[] = sprintf(' Line %d, Column %d: %s', $error->line, $error->column, trim($error->message)); + } + libxml_clear_errors(); + return ['valid' => false, 'errors' => $errors]; + } + + libxml_clear_errors(); + return ['valid' => true, 'errors' => []]; +} + +/** + * Outputs an error message to STDERR with formatting. + */ +function outputError(string $message, bool $isHeader = false): void +{ + if ($isHeader) { + fwrite(STDERR, "\033[37;41m " . $message . " \033[0m\n"); + } else { + fwrite(STDERR, $message . "\n"); + } +} + +// Determine which guides.xml files will be loaded (mirrors upstream logic) +$input = new ArgvInput(); +$vendorDir = $root . '/vendor'; +$xsdPath = $vendorDir . '/phpdocumentor/guides-cli/resources/schema/guides.xsd'; + +$configFiles = []; + +// Project-level config +$projectConfig = $vendorDir . '/../guides.xml'; +if (is_file($projectConfig)) { + $realPath = realpath($projectConfig); + if ($realPath !== false) { + $configFiles[] = $realPath; + } +} + +// Local config (from --config or working directory) +$workingDir = $input->getParameterOption(['--working-dir', '-w'], getcwd(), true); +$localConfigDir = $input->getParameterOption(['--config', '-c'], $workingDir, true); +$localConfig = $localConfigDir . '/guides.xml'; + +if (is_file($localConfig)) { + $realLocalConfig = realpath($localConfig); + $realProjectConfig = isset($realPath) ? $realPath : null; + if ($realLocalConfig !== false && $realLocalConfig !== $realProjectConfig) { + $configFiles[] = $realLocalConfig; + } +} + +// Validate all config files against XSD +$hasValidationErrors = false; +$allErrors = []; + +foreach ($configFiles as $configFile) { + $result = validateGuidesXml($configFile, $xsdPath); + if (!$result['valid']) { + $hasValidationErrors = true; + $allErrors = array_merge($allErrors, $result['errors']); + } +} + +if ($hasValidationErrors) { + outputError('Invalid guides.xml configuration', true); + fwrite(STDERR, "\n"); + fwrite(STDERR, "Your guides.xml file failed XSD schema validation:\n"); + fwrite(STDERR, "\n"); + foreach ($allErrors as $error) { + fwrite(STDERR, "\033[33m" . $error . "\033[0m\n"); + } + fwrite(STDERR, "\n"); + fwrite(STDERR, "See: https://docs.typo3.org/m/typo3/docs-how-to-document/main/en-us/GeneralConventions/GuideXml.html\n"); + fwrite(STDERR, "\n"); + exit(1); +} + +// Validation passed, proceed with normal execution +// Keep a fallback catch for any edge cases not covered by XSD +try { + require_once $root . '/vendor/phpdocumentor/guides-cli/bin/guides'; +} catch (InvalidTypeException|InvalidConfigurationException $e) { + outputError('Invalid guides.xml configuration', true); + fwrite(STDERR, "\n"); + fwrite(STDERR, "Your guides.xml file contains a configuration error:\n"); + fwrite(STDERR, " \033[33m" . $e->getMessage() . "\033[0m\n"); + fwrite(STDERR, "\n"); + fwrite(STDERR, "Run \033[33mvendor/bin/typo3-guides lint-guides-xml\033[0m for detailed validation.\n"); + fwrite(STDERR, "See: https://docs.typo3.org/m/typo3/docs-how-to-document/main/en-us/GeneralConventions/GuideXml.html\n"); + fwrite(STDERR, "\n"); + exit(1); +} diff --git a/tests/Integration/InvalidGuidesXmlTest.php b/tests/Integration/InvalidGuidesXmlTest.php new file mode 100644 index 000000000..ae970b3bd --- /dev/null +++ b/tests/Integration/InvalidGuidesXmlTest.php @@ -0,0 +1,119 @@ +tempDir = sys_get_temp_dir() . '/render-guides-invalid-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + + // Copy fixture files to temp directory + copy(self::FIXTURE_SOURCE . '/guides.xml.fixture', $this->tempDir . '/guides.xml'); + copy(self::FIXTURE_SOURCE . '/Index.rst', $this->tempDir . '/Index.rst'); + } + + protected function tearDown(): void + { + if (is_dir($this->tempDir)) { + system('rm -rf ' . escapeshellarg($this->tempDir)); + } + } + + public function testInvalidGuidesXmlShowsHelpfulErrorMessage(): void + { + $binPath = dirname(__DIR__, 2) . '/bin/guides'; + + $process = new Process([ + 'php', + $binPath, + 'run', + '--config=' . $this->tempDir, + $this->tempDir, + ]); + + $process->run(); + + // Should fail with exit code 1, not crash with fatal error + self::assertSame(1, $process->getExitCode(), 'Expected exit code 1 for invalid guides.xml'); + + $stderr = $process->getErrorOutput(); + + // Should contain helpful error message, not PHP fatal error + self::assertStringContainsString('Invalid guides.xml configuration', $stderr); + self::assertStringNotContainsString('PHP Fatal error', $stderr); + self::assertStringNotContainsString('Stack trace', $stderr); + + // Should reference documentation + self::assertStringContainsString('https://docs.typo3.org', $stderr); + } + + public function testInvalidGuidesXmlShowsLineNumber(): void + { + $binPath = dirname(__DIR__, 2) . '/bin/guides'; + + $process = new Process([ + 'php', + $binPath, + 'run', + '--config=' . $this->tempDir, + $this->tempDir, + ]); + + $process->run(); + + $stderr = $process->getErrorOutput(); + + // Should show XSD validation error with line number + self::assertStringContainsString('Line 3', $stderr); + self::assertStringContainsString('theme', $stderr); + } + + public function testValidGuidesXmlRendersSuccessfully(): void + { + $binPath = dirname(__DIR__, 2) . '/bin/guides'; + $validFixturePath = __DIR__ . '/tests/getting-started/input'; + + // Skip if fixture doesn't exist + if (!is_dir($validFixturePath)) { + self::markTestSkipped('Valid fixture not available'); + } + + $outputPath = sys_get_temp_dir() . '/render-guides-test-' . uniqid(); + + $process = new Process([ + 'php', + $binPath, + 'run', + '--config=' . $validFixturePath, + '--output=' . $outputPath, + $validFixturePath, + ]); + + $process->run(); + + // Clean up + if (is_dir($outputPath)) { + system('rm -rf ' . escapeshellarg($outputPath)); + } + + // Should succeed + self::assertSame(0, $process->getExitCode(), 'Expected exit code 0 for valid guides.xml. Error: ' . $process->getErrorOutput()); + } +} diff --git a/tests/fixtures/invalid-guides-xml/Index.rst b/tests/fixtures/invalid-guides-xml/Index.rst new file mode 100644 index 000000000..50a726c9d --- /dev/null +++ b/tests/fixtures/invalid-guides-xml/Index.rst @@ -0,0 +1,5 @@ +===== +Test +===== + +This should not render due to invalid guides.xml. diff --git a/tests/fixtures/invalid-guides-xml/guides.xml.fixture b/tests/fixtures/invalid-guides-xml/guides.xml.fixture new file mode 100644 index 000000000..88cc4c732 --- /dev/null +++ b/tests/fixtures/invalid-guides-xml/guides.xml.fixture @@ -0,0 +1,5 @@ + + + + +