From 891889021766a5933ce0208e101a7bb9434dfeae Mon Sep 17 00:00:00 2001 From: Dmitrii Bukhonov Date: Fri, 6 Mar 2026 18:42:24 +0400 Subject: [PATCH 1/4] Render exception class PHPDoc description in HTML debug output --- src/Renderer/HtmlRenderer.php | 67 +++++++++++++++++++ templates/development.php | 5 ++ tests/Renderer/HtmlRendererTest.php | 41 ++++++++++++ tests/Support/TestDocBlockException.php | 16 +++++ .../Support/TestExceptionWithoutDocBlock.php | 9 +++ 5 files changed, 138 insertions(+) create mode 100644 tests/Support/TestDocBlockException.php create mode 100644 tests/Support/TestExceptionWithoutDocBlock.php diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index a3c8eb6..a9c90e0 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -16,6 +16,7 @@ use Yiisoft\ErrorHandler\ThrowableRendererInterface; use Yiisoft\FriendlyException\FriendlyExceptionInterface; use Yiisoft\Http\Header; +use ReflectionClass; use ReflectionException; use ReflectionFunction; use ReflectionMethod; @@ -45,11 +46,17 @@ use function ob_implicit_flush; use function ob_start; use function realpath; +use function preg_match; +use function preg_replace; +use function preg_replace_callback; +use function preg_split; use function str_replace; +use function str_starts_with; use function stripos; use function strlen; use function count; use function function_exists; +use function trim; use const DIRECTORY_SEPARATOR; use const ENT_QUOTES; @@ -204,10 +211,16 @@ public function render(Throwable $t, ?ServerRequestInterface $request = null): E public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData { + $displayThrowable = $t instanceof CompositeException ? $t->getFirstException() : $t; + $exceptionDescription = $displayThrowable instanceof FriendlyExceptionInterface + ? null + : $this->getThrowableDescription($displayThrowable); + return new ErrorData( $this->renderTemplate($this->verboseTemplate, [ 'request' => $request, 'throwable' => $t, + 'exceptionDescription' => $exceptionDescription, ]), [Header::CONTENT_TYPE => self::CONTENT_TYPE], ); @@ -541,6 +554,60 @@ public function removeAnonymous(string $value): string return $anonymousPosition !== false ? substr($value, 0, $anonymousPosition) : $value; } + /** + * Extracts a user-facing description from throwable class PHPDoc. + * + * Takes only descriptive text before block tags and normalizes inline + * {@see ...}/{@link ...} annotations into markdown-friendly form. + */ + private function getThrowableDescription(Throwable $throwable): ?string + { + $docComment = (new ReflectionClass($throwable))->getDocComment(); + if ($docComment === false) { + return null; + } + + $descriptionLines = []; + foreach (preg_split('/\R/', $docComment) ?: [] as $line) { + $line = trim($line); + $line = preg_replace('/^\/\*\*?/', '', $line) ?? $line; + $line = preg_replace('/\*\/$/', '', $line) ?? $line; + $line = preg_replace('/^\*/', '', $line) ?? $line; + $line = trim($line); + + if ($line !== '' && str_starts_with($line, '@')) { + break; + } + + $descriptionLines[] = $line; + } + + $description = trim(implode("\n", $descriptionLines)); + if ($description === '') { + return null; + } + + return preg_replace_callback( + '/\{@(see|link)\s+([^\s}]+)(?:\s+([^}]+))?\}/i', + static function (array $matches): string { + $target = $matches[2]; + $label = trim($matches[3] ?? ''); + + if (preg_match('/^https?:\/\//i', $target) === 1) { + $text = $label !== '' ? $label : $target; + return '[' . $text . '](' . $target . ')'; + } + + if ($label !== '') { + return $label . ' (`' . $target . '`)'; + } + + return '`' . $target . '`'; + }, + $description, + ) ?? $description; + } + /** * Renders a template. * diff --git a/templates/development.php b/templates/development.php index 47db4e5..88e57bb 100644 --- a/templates/development.php +++ b/templates/development.php @@ -9,6 +9,7 @@ /** * @var ServerRequestInterface|null $request * @var Throwable $throwable + * @var string|null $exceptionDescription */ $theme = $_COOKIE['yii-exception-theme'] ?? ''; @@ -93,6 +94,10 @@ htmlEncode($exceptionMessage)) ?> + +
parseMarkdown($exceptionDescription) ?>
+ +
parseMarkdown($solution) ?>
diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index 3d464f0..ce4bf8d 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -15,6 +15,9 @@ use RuntimeException; use Yiisoft\ErrorHandler\Exception\ErrorException; use Yiisoft\ErrorHandler\Renderer\HtmlRenderer; +use Yiisoft\ErrorHandler\Tests\Support\TestDocBlockException; +use Yiisoft\ErrorHandler\Tests\Support\TestExceptionWithoutDocBlock; +use Yiisoft\ErrorHandler\Tests\Support\TestFriendlyException; use Yiisoft\ErrorHandler\Tests\Support\TestHelper; use function dirname; @@ -66,6 +69,44 @@ public function testVerboseOutput(): void $this->assertStringContainsString($exceptionMessage, (string) $errorData); } + public function testVerboseOutputRendersThrowableDescriptionFromDocComment(): void + { + $renderer = new HtmlRenderer(); + $exception = new TestDocBlockException('exception-test-message'); + + $errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock()); + $result = (string) $errorData; + + $this->assertStringContainsString('
', $result); + $this->assertStringContainsString('Test summary with RuntimeException.', $result); + $this->assertStringContainsString( + 'Yii Framework', + $result, + ); + } + + public function testVerboseOutputDoesNotRenderThrowableDescriptionWhenNoDocComment(): void + { + $renderer = new HtmlRenderer(); + $exception = new TestExceptionWithoutDocBlock('exception-test-message'); + + $errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock()); + + $this->assertStringNotContainsString('
', (string) $errorData); + } + + public function testVerboseOutputKeepsFriendlyExceptionBehaviorWithoutDescriptionDuplication(): void + { + $renderer = new HtmlRenderer(); + $exception = new TestFriendlyException('exception-test-message'); + + $errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock()); + $result = (string) $errorData; + + $this->assertStringContainsString('
', $result); + $this->assertStringNotContainsString('
', $result); + } + public function testNonVerboseOutputWithCustomTemplate(): void { $templateFileContents = 'getMessage();?>'; diff --git a/tests/Support/TestDocBlockException.php b/tests/Support/TestDocBlockException.php new file mode 100644 index 0000000..ae13ef7 --- /dev/null +++ b/tests/Support/TestDocBlockException.php @@ -0,0 +1,16 @@ + Date: Tue, 10 Mar 2026 19:21:42 +0400 Subject: [PATCH 2/4] Escape unsafe links in exception class PHPDoc descriptions rendered in HTML debug output --- src/Renderer/HtmlRenderer.php | 52 ++++++++++++++++++- templates/development.php | 2 +- tests/Renderer/HtmlRendererTest.php | 17 ++++++ tests/Support/TestUnsafeDocBlockException.php | 14 +++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/Support/TestUnsafeDocBlockException.php diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index a9c90e0..f066a57 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -587,8 +587,8 @@ private function getThrowableDescription(Throwable $throwable): ?string return null; } - return preg_replace_callback( - '/\{@(see|link)\s+([^\s}]+)(?:\s+([^}]+))?\}/i', + $description = preg_replace_callback( + '/\{@(see|link)\s+([^\s}]+)(?:\s+([^}]+))?}/i', static function (array $matches): string { $target = $matches[2]; $label = trim($matches[3] ?? ''); @@ -606,6 +606,54 @@ static function (array $matches): string { }, $description, ) ?? $description; + + $paragraphs = preg_split('/\R\s*\R/', $description) ?: []; + $result = []; + + foreach ($paragraphs as $paragraph) { + $paragraph = trim($paragraph); + if ($paragraph === '') { + continue; + } + + $parts = preg_split( + '/(\[[^]]+]\([^)]+\)|`[^`]+`)/', + $paragraph, + -1, + PREG_SPLIT_DELIM_CAPTURE, + ) ?: []; + + $html = ''; + foreach ($parts as $part) { + if ($part === '') { + continue; + } + + if (preg_match('/^\[([^]]+)]\(([^)]+)\)$/', $part, $matches) === 1) { + if (preg_match('/^https?:\/\//i', $matches[2]) === 1) { + $html .= '' + . $this->htmlEncode($matches[1]) + . ''; + } else { + $html .= $this->htmlEncode($matches[1]) + . ' (' . $this->htmlEncode($matches[2]) . ')'; + } + + continue; + } + + if (preg_match('/^`([^`]+)`$/', $part, $matches) === 1) { + $html .= '' . $this->htmlEncode($matches[1]) . ''; + continue; + } + + $html .= $this->htmlEncode($part); + } + + $result[] = '

' . $html . '

'; + } + + return $result === [] ? null : implode("\n", $result); } /** diff --git a/templates/development.php b/templates/development.php index 88e57bb..9ba2087 100644 --- a/templates/development.php +++ b/templates/development.php @@ -95,7 +95,7 @@
-
parseMarkdown($exceptionDescription) ?>
+
diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index ce4bf8d..1e37e5e 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -19,6 +19,7 @@ use Yiisoft\ErrorHandler\Tests\Support\TestExceptionWithoutDocBlock; use Yiisoft\ErrorHandler\Tests\Support\TestFriendlyException; use Yiisoft\ErrorHandler\Tests\Support\TestHelper; +use Yiisoft\ErrorHandler\Tests\Support\TestUnsafeDocBlockException; use function dirname; use function file_exists; @@ -107,6 +108,22 @@ public function testVerboseOutputKeepsFriendlyExceptionBehaviorWithoutDescriptio $this->assertStringNotContainsString('
', $result); } + public function testVerboseOutputEscapesUnsafeThrowableDescriptionLinks(): void + { + $renderer = new HtmlRenderer(); + $exception = new TestUnsafeDocBlockException('exception-test-message'); + + $errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock()); + $result = (string) $errorData; + + $this->assertStringNotContainsString('href="javascript:alert(1)"', $result); + $this->assertStringContainsString('Click me (javascript:alert(1))', $result); + $this->assertStringContainsString( + 'Safe link', + $result, + ); + } + public function testNonVerboseOutputWithCustomTemplate(): void { $templateFileContents = 'getMessage();?>'; diff --git a/tests/Support/TestUnsafeDocBlockException.php b/tests/Support/TestUnsafeDocBlockException.php new file mode 100644 index 0000000..625f884 --- /dev/null +++ b/tests/Support/TestUnsafeDocBlockException.php @@ -0,0 +1,14 @@ + should not survive. + * + * {@link javascript:alert(1) Click me} and {@link https://www.yiiframework.com Safe link}. + */ +final class TestUnsafeDocBlockException extends RuntimeException {} From 86d62269aacad9e945dab09550bddda0e8b930d7 Mon Sep 17 00:00:00 2001 From: dbuhonov <47827492+dbuhonov@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:31:14 +0000 Subject: [PATCH 3/4] Apply PHP CS Fixer and Rector changes (CI) --- src/Renderer/HtmlRenderer.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index f066a57..e891e79 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -61,6 +61,7 @@ use const DIRECTORY_SEPARATOR; use const ENT_QUOTES; use const EXTR_OVERWRITE; +use const PREG_SPLIT_DELIM_CAPTURE; /** * Formats throwable into HTML string. From 70bd017fa4c31bc1edcdaa12c09e708c365bc506 Mon Sep 17 00:00:00 2001 From: Dmitrii Bukhonov Date: Tue, 10 Mar 2026 19:47:54 +0400 Subject: [PATCH 4/4] Add tests for rendering exception PHPDoc descriptions and unsafe content handling in HTML debug output --- tests/Renderer/HtmlRendererTest.php | 24 +++++++++++++++++++ .../TestEmptyDescriptionDocBlockException.php | 13 ++++++++++ .../TestInlineCodeDocBlockException.php | 12 ++++++++++ 3 files changed, 49 insertions(+) create mode 100644 tests/Support/TestEmptyDescriptionDocBlockException.php create mode 100644 tests/Support/TestInlineCodeDocBlockException.php diff --git a/tests/Renderer/HtmlRendererTest.php b/tests/Renderer/HtmlRendererTest.php index 1e37e5e..475080b 100644 --- a/tests/Renderer/HtmlRendererTest.php +++ b/tests/Renderer/HtmlRendererTest.php @@ -16,9 +16,11 @@ use Yiisoft\ErrorHandler\Exception\ErrorException; use Yiisoft\ErrorHandler\Renderer\HtmlRenderer; use Yiisoft\ErrorHandler\Tests\Support\TestDocBlockException; +use Yiisoft\ErrorHandler\Tests\Support\TestEmptyDescriptionDocBlockException; use Yiisoft\ErrorHandler\Tests\Support\TestExceptionWithoutDocBlock; use Yiisoft\ErrorHandler\Tests\Support\TestFriendlyException; use Yiisoft\ErrorHandler\Tests\Support\TestHelper; +use Yiisoft\ErrorHandler\Tests\Support\TestInlineCodeDocBlockException; use Yiisoft\ErrorHandler\Tests\Support\TestUnsafeDocBlockException; use function dirname; @@ -96,6 +98,16 @@ public function testVerboseOutputDoesNotRenderThrowableDescriptionWhenNoDocComme $this->assertStringNotContainsString('
', (string) $errorData); } + public function testVerboseOutputDoesNotRenderThrowableDescriptionWhenDocCommentHasNoDescription(): void + { + $renderer = new HtmlRenderer(); + $exception = new TestEmptyDescriptionDocBlockException('exception-test-message'); + + $errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock()); + + $this->assertStringNotContainsString('
', (string) $errorData); + } + public function testVerboseOutputKeepsFriendlyExceptionBehaviorWithoutDescriptionDuplication(): void { $renderer = new HtmlRenderer(); @@ -124,6 +136,18 @@ public function testVerboseOutputEscapesUnsafeThrowableDescriptionLinks(): void ); } + public function testVerboseOutputRendersInlineCodeAndSeeTagWithoutLabel(): void + { + $renderer = new HtmlRenderer(); + $exception = new TestInlineCodeDocBlockException('exception-test-message'); + + $errorData = $renderer->renderVerbose($exception, $this->createServerRequestMock()); + $result = (string) $errorData; + + $this->assertStringContainsString('inline-code', $result); + $this->assertStringContainsString('RuntimeException', $result); + } + public function testNonVerboseOutputWithCustomTemplate(): void { $templateFileContents = 'getMessage();?>'; diff --git a/tests/Support/TestEmptyDescriptionDocBlockException.php b/tests/Support/TestEmptyDescriptionDocBlockException.php new file mode 100644 index 0000000..433f471 --- /dev/null +++ b/tests/Support/TestEmptyDescriptionDocBlockException.php @@ -0,0 +1,13 @@ +