|
16 | 16 | use Yiisoft\ErrorHandler\ThrowableRendererInterface; |
17 | 17 | use Yiisoft\FriendlyException\FriendlyExceptionInterface; |
18 | 18 | use Yiisoft\Http\Header; |
| 19 | +use ReflectionClass; |
19 | 20 | use ReflectionException; |
20 | 21 | use ReflectionFunction; |
21 | 22 | use ReflectionMethod; |
|
45 | 46 | use function ob_implicit_flush; |
46 | 47 | use function ob_start; |
47 | 48 | use function realpath; |
| 49 | +use function preg_match; |
| 50 | +use function preg_replace; |
| 51 | +use function preg_replace_callback; |
| 52 | +use function preg_split; |
48 | 53 | use function str_replace; |
| 54 | +use function str_starts_with; |
49 | 55 | use function stripos; |
50 | 56 | use function strlen; |
51 | 57 | use function count; |
52 | 58 | use function function_exists; |
| 59 | +use function trim; |
53 | 60 |
|
54 | 61 | use const DIRECTORY_SEPARATOR; |
55 | 62 | use const ENT_QUOTES; |
56 | 63 | use const EXTR_OVERWRITE; |
| 64 | +use const PREG_SPLIT_DELIM_CAPTURE; |
57 | 65 |
|
58 | 66 | /** |
59 | 67 | * Formats throwable into HTML string. |
@@ -204,10 +212,29 @@ public function render(Throwable $t, ?ServerRequestInterface $request = null): E |
204 | 212 |
|
205 | 213 | public function renderVerbose(Throwable $t, ?ServerRequestInterface $request = null): ErrorData |
206 | 214 | { |
| 215 | + $solution = null; |
| 216 | + $exceptionDescription = null; |
| 217 | + $displayThrowable = $t; |
| 218 | + |
| 219 | + if ($t instanceof CompositeException) { |
| 220 | + $displayThrowable = $t->getFirstException(); |
| 221 | + } |
| 222 | + |
| 223 | + if ($displayThrowable instanceof FriendlyExceptionInterface) { |
| 224 | + $solution = $displayThrowable->getSolution(); |
| 225 | + } else { |
| 226 | + $exceptionDescription = $this->getThrowableDescription($displayThrowable); |
| 227 | + } |
| 228 | + |
207 | 229 | return new ErrorData( |
208 | 230 | $this->renderTemplate($this->verboseTemplate, [ |
209 | 231 | 'request' => $request, |
210 | 232 | 'throwable' => $t, |
| 233 | + 'displayThrowable' => $displayThrowable, |
| 234 | + 'solution' => $solution, |
| 235 | + 'exceptionClass' => $displayThrowable::class, |
| 236 | + 'exceptionMessage' => $displayThrowable->getMessage(), |
| 237 | + 'exceptionDescription' => $exceptionDescription, |
211 | 238 | ]), |
212 | 239 | [Header::CONTENT_TYPE => self::CONTENT_TYPE], |
213 | 240 | ); |
@@ -541,6 +568,111 @@ public function removeAnonymous(string $value): string |
541 | 568 | return $anonymousPosition !== false ? substr($value, 0, $anonymousPosition) : $value; |
542 | 569 | } |
543 | 570 |
|
| 571 | + /** |
| 572 | + * Extracts a user-facing description from throwable class PHPDoc. |
| 573 | + * |
| 574 | + * Takes only descriptive text before block tags, normalizes unsafe markup |
| 575 | + * into safe markdown/plain text and converts it into an HTML fragment |
| 576 | + * suitable for direct inclusion in the error template. |
| 577 | + * Inline {@see ...}/{@link ...} annotations are rendered as markdown links. |
| 578 | + * |
| 579 | + * The returned value is an HTML snippet (for example, containing <p>, <a>, |
| 580 | + * <code> elements) and is intended to be inserted into the template as-is, |
| 581 | + * without additional HTML-escaping. |
| 582 | + * |
| 583 | + * @return string|null HTML fragment describing the throwable, or null if no description is available. |
| 584 | + */ |
| 585 | + private function getThrowableDescription(Throwable $throwable): ?string |
| 586 | + { |
| 587 | + $docComment = (new ReflectionClass($throwable))->getDocComment(); |
| 588 | + if ($docComment === false) { |
| 589 | + return null; |
| 590 | + } |
| 591 | + |
| 592 | + $descriptionLines = []; |
| 593 | + foreach (preg_split('/\R/', $docComment) ?: [] as $line) { |
| 594 | + $line = trim($line); |
| 595 | + $line = preg_replace( |
| 596 | + ['/^\/\*\*?/', '/\*\/$/', '/^\*\s?/'], |
| 597 | + '', |
| 598 | + $line, |
| 599 | + ) ?? $line; |
| 600 | + $line = trim($line); |
| 601 | + |
| 602 | + if ($line !== '' && str_starts_with($line, '@')) { |
| 603 | + break; |
| 604 | + } |
| 605 | + |
| 606 | + $descriptionLines[] = $line; |
| 607 | + } |
| 608 | + |
| 609 | + $description = trim(implode("\n", $descriptionLines)); |
| 610 | + if ($description === '') { |
| 611 | + return null; |
| 612 | + } |
| 613 | + |
| 614 | + $description = preg_replace_callback( |
| 615 | + '/\{@(?:see|link)\s+(?<target>[^\s}]+)(?:\s+(?<label>[^}]+))?}/i', |
| 616 | + static function (array $matches): string { |
| 617 | + $target = $matches['target']; |
| 618 | + $label = trim($matches['label'] ?? ''); |
| 619 | + |
| 620 | + if (preg_match('/^https?:\/\//i', $target) === 1) { |
| 621 | + $text = $label !== '' ? $label : $target; |
| 622 | + return '[' . $text . '](' . $target . ')'; |
| 623 | + } |
| 624 | + |
| 625 | + if ($label !== '') { |
| 626 | + return $label . ' (`' . $target . '`)'; |
| 627 | + } |
| 628 | + |
| 629 | + return '`' . $target . '`'; |
| 630 | + }, |
| 631 | + $description, |
| 632 | + ) ?? $description; |
| 633 | + |
| 634 | + $tokenPattern = '/^(?:`(?<code>[^`]+)`|(?<image>!)?\[(?<label>[^\]]+)]\((?<target>[^)]+)\))$/'; |
| 635 | + $parts = preg_split( |
| 636 | + '/(!?\[[^]]+]\([^)]+\)|`[^`]+`)/', |
| 637 | + $description, |
| 638 | + -1, |
| 639 | + PREG_SPLIT_DELIM_CAPTURE, |
| 640 | + ) ?: []; |
| 641 | + |
| 642 | + $normalized = []; |
| 643 | + |
| 644 | + foreach ($parts as $part) { |
| 645 | + if ($part === '') { |
| 646 | + continue; |
| 647 | + } |
| 648 | + |
| 649 | + if (preg_match($tokenPattern, $part, $matches) !== 1) { |
| 650 | + $normalized[] = $this->htmlEncode($part); |
| 651 | + continue; |
| 652 | + } |
| 653 | + |
| 654 | + if (($matches['code'] ?? '') !== '') { |
| 655 | + $normalized[] = '<code>' . $this->htmlEncode($matches['code']) . '</code>'; |
| 656 | + continue; |
| 657 | + } |
| 658 | + |
| 659 | + $label = $this->htmlEncode($matches['label']); |
| 660 | + $target = $matches['target']; |
| 661 | + $imageMarker = $matches['image'] ?? ''; |
| 662 | + |
| 663 | + if ($imageMarker === '' && preg_match('/^https?:\/\//i', $target) === 1) { |
| 664 | + $normalized[] = '[' . $label . '](' . $target . ')'; |
| 665 | + continue; |
| 666 | + } |
| 667 | + |
| 668 | + $normalized[] = $imageMarker . $label . ' (<code>' . $this->htmlEncode($target) . '</code>)'; |
| 669 | + } |
| 670 | + |
| 671 | + $normalized = trim(implode('', $normalized)); |
| 672 | + |
| 673 | + return $this->parseMarkdown($normalized); |
| 674 | + } |
| 675 | + |
544 | 676 | /** |
545 | 677 | * Renders a template. |
546 | 678 | * |
|
0 commit comments