Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8918890
Render exception class PHPDoc description in HTML debug output
dbuhonov Mar 6, 2026
23ae325
Escape unsafe links in exception class PHPDoc descriptions rendered i…
dbuhonov Mar 10, 2026
86d6226
Apply PHP CS Fixer and Rector changes (CI)
dbuhonov Mar 10, 2026
70bd017
Add tests for rendering exception PHPDoc descriptions and unsafe cont…
dbuhonov Mar 10, 2026
4a102aa
Merge remote-tracking branch 'fork/feature/104-render-exception-descr…
dbuhonov Mar 10, 2026
2aa9e7e
Update throwable description handling to produce HTML fragments for e…
dbuhonov Mar 11, 2026
1e978c3
Add tests for escaping unsafe content in exception PHPDoc description…
dbuhonov Mar 11, 2026
82446ae
Improve verbose error rendering by adding solution and original excep…
dbuhonov Mar 12, 2026
9e3d0d8
Keep original throwable for templates, prepare separate displayThrowa…
dbuhonov Mar 12, 2026
aa1cb98
Apply PHP CS Fixer and Rector changes (CI)
dbuhonov Mar 12, 2026
7bd6931
Apply PHP CS Fixer and Rector changes (CI)
samdark Mar 12, 2026
71bfa0c
Potential fix for pull request finding
dbuhonov Mar 19, 2026
4a941b7
Normalize throwable description handling to ensure safe markdown-to-H…
dbuhonov Mar 19, 2026
c4c6883
Handle safe markdown links with query parameters in throwable descrip…
dbuhonov Mar 19, 2026
91cccef
Add test for handling parentheses in markdown links within throwable …
dbuhonov Mar 19, 2026
5822909
Add test for handling parentheses in markdown links in exception PHPD…
dbuhonov Mar 19, 2026
0ece04b
Add test for handling parentheses in markdown links in exception PHPD…
dbuhonov Mar 20, 2026
cf005fd
Render exception class PHPDoc description with safe markdown links in…
dbuhonov Mar 20, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 5.0.0 under development

- Enh #104: Render exception class PHPDoc description with safe markdown links in HTML debug output (@dbuhonov)
- Chg #162: Replace deprecated `ThrowableResponseFactory` class usage to new one, and remove it (@vjik)
- Enh #163: Explicitly import classes, functions, and constants in "use" section (@mspirkov)
- Bug #164: Fix missing items in stack trace HTML output when handling a PHP error (@vjik)
Expand Down
132 changes: 132 additions & 0 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Yiisoft\ErrorHandler\ThrowableRendererInterface;
use Yiisoft\FriendlyException\FriendlyExceptionInterface;
use Yiisoft\Http\Header;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionMethod;
Expand Down Expand Up @@ -45,15 +46,22 @@
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;
use const EXTR_OVERWRITE;
use const PREG_SPLIT_DELIM_CAPTURE;

/**
* Formats throwable into HTML string.
Expand Down Expand Up @@ -204,10 +212,29 @@ public function render(Throwable $t, ?ServerRequestInterface $request = null): E

public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData
{
$solution = null;
$exceptionDescription = null;
$displayThrowable = $t;

if ($t instanceof CompositeException) {
$displayThrowable = $t->getFirstException();
}

if ($displayThrowable instanceof FriendlyExceptionInterface) {
$solution = $displayThrowable->getSolution();
} else {
$exceptionDescription = $this->getThrowableDescription($displayThrowable);
}

return new ErrorData(
$this->renderTemplate($this->verboseTemplate, [
'request' => $request,
'throwable' => $t,
'displayThrowable' => $displayThrowable,
'solution' => $solution,
'exceptionClass' => $displayThrowable::class,
'exceptionMessage' => $displayThrowable->getMessage(),
'exceptionDescription' => $exceptionDescription,
]),
[Header::CONTENT_TYPE => self::CONTENT_TYPE],
);
Expand Down Expand Up @@ -541,6 +568,111 @@ 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, normalizes unsafe markup
* into safe markdown/plain text and converts it into an HTML fragment
* suitable for direct inclusion in the error template.
* Inline {@see ...}/{@link ...} annotations are rendered as markdown links.
*
* The returned value is an HTML snippet (for example, containing <p>, <a>,
* <code> elements) and is intended to be inserted into the template as-is,
* without additional HTML-escaping.
*
* @return string|null HTML fragment describing the throwable, or null if no description is available.
*/
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(
['/^\/\*\*?/', '/\*\/$/', '/^\*\s?/'],
'',
$line,
) ?? $line;
$line = trim($line);

if ($line !== '' && str_starts_with($line, '@')) {
break;
}

$descriptionLines[] = $line;
}

$description = trim(implode("\n", $descriptionLines));
if ($description === '') {
return null;
}

$description = preg_replace_callback(
'/\{@(?:see|link)\s+(?<target>[^\s}]+)(?:\s+(?<label>[^}]+))?}/i',
static function (array $matches): string {
$target = $matches['target'];
$label = trim($matches['label'] ?? '');

if (preg_match('/^https?:\/\//i', $target) === 1) {
$text = $label !== '' ? $label : $target;
return '[' . $text . '](' . $target . ')';
}

if ($label !== '') {
return $label . ' (`' . $target . '`)';
}

return '`' . $target . '`';
},
$description,
) ?? $description;

$tokenPattern = '/^(?:`(?<code>[^`]+)`|(?<image>!)?\[(?<label>[^\]]+)]\((?<target>[^)]+)\))$/';
$parts = preg_split(
'/(!?\[[^]]+]\([^)]+\)|`[^`]+`)/',
$description,
-1,
PREG_SPLIT_DELIM_CAPTURE,
) ?: [];

$normalized = [];

foreach ($parts as $part) {
if ($part === '') {
continue;
}

if (preg_match($tokenPattern, $part, $matches) !== 1) {
$normalized[] = $this->htmlEncode($part);
continue;
}

if (($matches['code'] ?? '') !== '') {
$normalized[] = '<code>' . $this->htmlEncode($matches['code']) . '</code>';
continue;
}

$label = $this->htmlEncode($matches['label']);
$target = $matches['target'];
$imageMarker = $matches['image'] ?? '';

if ($imageMarker === '' && preg_match('/^https?:\/\//i', $target) === 1) {
$normalized[] = '[' . $label . '](' . $target . ')';
continue;
}

$normalized[] = $imageMarker . $label . ' (<code>' . $this->htmlEncode($target) . '</code>)';
}
Comment on lines +634 to +669
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

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

getThrowableDescription() tokenizes markdown links with regexes that stop link targets at the first ) (\((?<target>[^)]+)\) / \([^)]+\)). This will truncate and mis-render otherwise valid HTTP(S) URLs containing parentheses (common in docs, e.g. Wikipedia links), producing broken links/odd leftover text in the rendered description. Consider replacing the regex-based markdown tokenization with a more robust parser (e.g. rely on the markdown parser’s link parsing and then sanitize the produced <a> tags/href schemes), or implement balanced-parentheses handling for link targets so ) inside URLs doesn’t break rendering.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

@dbuhonov dbuhonov Mar 19, 2026

Choose a reason for hiding this comment

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

I checked it and couldnt reproduce this for safe HTTP(S) URLs with parentheses. I added coverage for that in HtmlRendererTest::testVerboseOutputRendersThrowableDescriptionLinksWithParentheses.
Since the reported safe-link case is not actually broken here, I prefer not to expand this PR with changes.


$normalized = trim(implode('', $normalized));

return $this->parseMarkdown($normalized);
}

/**
* Renders a template.
*
Expand Down
36 changes: 18 additions & 18 deletions templates/development.php
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
<?php

use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\ErrorHandler\CompositeException;
use Yiisoft\ErrorHandler\Exception\ErrorException;
use Yiisoft\ErrorHandler\Renderer\HtmlRenderer;
use Yiisoft\FriendlyException\FriendlyExceptionInterface;

/**
* @var ServerRequestInterface|null $request
* @var Throwable $throwable
* @var Throwable $displayThrowable
* @var string|null $solution
* @var string $exceptionClass
* @var string $exceptionMessage
* @var string|null $exceptionDescription
*/

$theme = $_COOKIE['yii-exception-theme'] ?? '';

$originalException = $throwable;
if ($throwable instanceof CompositeException) {
$throwable = $throwable->getFirstException();
}
$solution = $throwable instanceof FriendlyExceptionInterface ? $throwable->getSolution() : null;
$exceptionClass = get_class($throwable);
$exceptionMessage = $throwable->getMessage();

/**
* @var HtmlRenderer $this
*/
Expand All @@ -32,7 +28,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
<?= $this->htmlEncode($this->getThrowableName($throwable)) ?>
<?= $this->htmlEncode($this->getThrowableName($displayThrowable)) ?>
</title>
<style>
<?= file_get_contents(__DIR__ . '/development.css') ?>
Expand Down Expand Up @@ -79,25 +75,29 @@
<div class="exception-card">
<div class="exception-class">
<?php
if ($throwable instanceof FriendlyExceptionInterface): ?>
<span><?= $this->htmlEncode($throwable->getName())?></span>
if ($displayThrowable instanceof FriendlyExceptionInterface): ?>
<span><?= $this->htmlEncode($displayThrowable->getName())?></span>
&mdash;
<?= $exceptionClass ?>
<?php else: ?>
<span><?= $exceptionClass ?></span>
<?php endif ?>
(Code #<?= $throwable->getCode() ?>)
(Code #<?= $displayThrowable->getCode() ?>)
</div>

<div class="exception-message">
<?= nl2br($this->htmlEncode($exceptionMessage)) ?>
</div>

<?php if ($exceptionDescription !== null): ?>
<div class="exception-description solution"><?= $exceptionDescription ?></div>
<?php endif ?>

<?php if ($solution !== null): ?>
<div class="solution"><?= $this->parseMarkdown($solution) ?></div>
<?php endif ?>

<?= $this->renderPreviousExceptions($originalException) ?>
<?= $this->renderPreviousExceptions($throwable) ?>

<textarea id="clipboard"><?= $this->htmlEncode((string) $throwable) ?></textarea>
<span id="copied">Copied!</span>
Expand All @@ -117,10 +117,10 @@ class="copy-clipboard"
<main>
<div class="call-stack">
<?= $this->renderCallStack(
$throwable,
$originalException === $throwable && $originalException instanceof ErrorException
? $originalException->getBacktrace()
: $throwable->getTrace()
$displayThrowable,
$displayThrowable === $throwable && $throwable instanceof ErrorException
? $throwable->getBacktrace()
: $displayThrowable->getTrace(),
) ?>
</div>
<?php if ($request && ($requestInfo = $this->renderRequest($request)) !== ''): ?>
Expand Down
Loading
Loading