From abed920faf4f6d4f3369b8dd1c03a4591b40683d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 21 Aug 2026 04:02:37 +0200 Subject: [PATCH] perf: keep rich core Djot on borrowed HTML --- src/Parser/BlockParser.php | 31 +++- src/Performance/BorrowedHtmlLayout.php | 246 ++++++++++++++++++++++--- tests/BorrowedHtmlLayoutTest.php | 18 +- 3 files changed, 263 insertions(+), 32 deletions(-) diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index ab8bfc5..7a9ce51 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -524,11 +524,28 @@ public function parse(string $input): Document $lines = $this->splitLines($input); - // First pass: extract reference definitions, footnotes, abbreviations, and heading references - $this->extractReferences($lines); - $this->extractFootnotes($lines); - $this->extractAbbreviations($lines); - $this->extractHeadingReferences($lines); + // Gate document-wide collectors by syntax family. The predicates only + // skip impossible families; false positives merely retain the existing + // authoritative pass. + $hasReferenceDefinitions = preg_match( + '/(?:^|\n)\[[^\]\r\n]+\]:(?:[ \t]+\S*)?[ \t]*(?:\r?\n|$)/', + $input, + ) === 1; + $hasFootnotes = str_contains($input, '[^'); + $hasAbbreviations = str_contains($input, '*['); + $hasImplicitHeadingReferences = preg_match('/\]\s*\[\]/', $input) === 1; + if ($hasReferenceDefinitions) { + $this->extractReferences($lines); + } + if ($hasFootnotes) { + $this->extractFootnotes($lines); + } + if ($hasAbbreviations) { + $this->extractAbbreviations($lines); + } + if ($hasImplicitHeadingReferences || $this->collectWarnings) { + $this->extractHeadingReferences($lines); + } // Second pass: parse blocks $this->parseBlocks($document, $lines, 0); @@ -543,7 +560,9 @@ public function parse(string $input): Document // attributes) and rewrite implicit heading references to the same // deduped ids the renderer will emit, so `[Heading][]` anchors stay // in sync with the rendered section id. - $this->rewriteHeadingReferences($document); + if ($hasImplicitHeadingReferences || $this->collectWarnings) { + $this->rewriteHeadingReferences($document); + } // Validate references and anchor links if warnings are enabled if ($this->collectWarnings) { diff --git a/src/Performance/BorrowedHtmlLayout.php b/src/Performance/BorrowedHtmlLayout.php index 8da7921..8995073 100644 --- a/src/Performance/BorrowedHtmlLayout.php +++ b/src/Performance/BorrowedHtmlLayout.php @@ -16,6 +16,8 @@ */ final class BorrowedHtmlLayout { + private bool $observing = false; + /** * @var array{ * headingNumbers: array{minLevel: int}|null, @@ -71,6 +73,7 @@ final class BorrowedHtmlLayout */ public function render(string $source, bool $observe = false, array $events = []): ?array { + $this->observing = $observe; $this->events = array_replace( [ 'headingNumbers' => null, @@ -153,6 +156,9 @@ private function emptyStats(): array */ private function accept(array &$stats, string $event, int $start, int $end, bool $active = false): void { + if (!$this->observing) { + return; + } $stats[$event]++; $stats['consumedLines'] += $end - $start; if ($active) { @@ -195,10 +201,10 @@ private function collectDefinitions(array $lines, array &$stats): ?array ) { return null; } - $definitions[$match[1]] = ['href' => $match[2], 'title' => $match[3] ?? null]; if (isset($match[3])) { - return null; + continue; } + $definitions[$match[1]] = ['href' => $match[2], 'title' => null]; $this->accept($stats, 'linkDefinitions', $index, $index + 1, true); } @@ -226,7 +232,7 @@ private function renderBlocks(array $lines, array $definitions, array &$stats): $count = count($lines); while ($i < $count) { $line = $lines[$i]; - if (trim($line) === '' || preg_match('/^\[[^\]]+\]:/', $line) === 1) { + if (trim($line) === '' || $this->isActiveDefinition($line, $definitions)) { $i++; continue; @@ -333,7 +339,15 @@ private function renderBlocks(array $lines, array $definitions, array &$stats): continue; } if (str_starts_with($line, '- ')) { - return null; + $rendered = $this->renderUnorderedList($lines, $i, $definitions, $stats); + if ($rendered === null) { + return null; + } + $out[] = $rendered['html']; + $i = $rendered['next']; + $wrote = true; + + continue; } if ($this->thematicBreak($line)) { $out[] = $this->indent($depth) . '
'; @@ -347,10 +361,26 @@ private function renderBlocks(array $lines, array $definitions, array &$stats): return null; } if (str_starts_with($line, '> ')) { - return null; + $rendered = $this->renderBlockQuote($lines, $i, $definitions, $stats); + if ($rendered === null) { + return null; + } + $out[] = $rendered['html']; + $i = $rendered['next']; + $wrote = true; + + continue; } if (str_starts_with($line, '|')) { - return null; + $rendered = $this->renderTable($lines, $i, $definitions, $stats); + if ($rendered === null) { + return null; + } + $out[] = $rendered['html']; + $i = $rendered['next']; + $wrote = true; + + continue; } if ($this->blockish($line)) { return null; @@ -390,10 +420,13 @@ private function renderBlocks(array $lines, array $definitions, array &$stats): */ private function renderInline(string $text, array $definitions): ?string { + if (preg_match('/^\[[^\]]+\]: +\S+ +"[^"]*"$/', $text) === 1) { + return $this->escapeText($text); + } if ($this->inlineComplex($text)) { return null; } - $flat = $this->renderFlatInline($text); + $flat = $this->renderFlatInline($text, $definitions); if ($flat !== false) { return $flat; } @@ -410,6 +443,21 @@ private function renderInline(string $text, array $definitions): ?string } $out .= $this->escapeText(substr($text, $plain, $i - $plain)); if ($delimiter === '*' || $delimiter === '_') { + if ($delimiter === '*' && ($text[$i + 1] ?? '') === '*') { + $close = strpos($text, '**', $i + 2); + if ($close === false || $close === $i + 2) { + return null; + } + $inner = $this->renderInline('*' . substr($text, $i + 2, $close - $i - 2) . '*', $definitions); + if ($inner === null) { + return null; + } + $out .= '' . $inner . ''; + $i = $close + 2; + $plain = $i; + + continue; + } $close = strpos($text, $delimiter, $i + 1); if ( $close === false || $close <= $i + 1 || ctype_space($text[$i + 1]) @@ -460,25 +508,25 @@ private function renderInline(string $text, array $definitions): ?string return null; } $key = substr($text, $labelEnd + 2, $close - $labelEnd - 2); - if (!isset($definitions[$key])) { + if ($key === '') { return null; } - $href = $definitions[$key]['href']; - $title = $definitions[$key]['title']; + $href = $definitions[$key]['href'] ?? null; + $title = $definitions[$key]['title'] ?? null; $i = $close + 1; } else { return null; } - if (!$this->safeUrl($href)) { + if ($href !== null && !$this->safeUrl($href)) { return null; } $inner = $this->renderInline($label, $definitions); if ($inner === null) { return null; } - $out .= 'externalLinkAttributes($href) + . ($href === null ? '' : $this->externalLinkAttributes($href)) . '>' . $inner . ''; } $plain = $i; @@ -490,11 +538,15 @@ private function renderInline(string $text, array $definitions): ?string /** * Common non-nested inline markup is tokenized in PCRE instead of walking * every byte in PHP. False asks the conservative generic path to decide. + * + * @param string $text + * @param array $definitions */ - private function renderFlatInline(string $text): string|false + private function renderFlatInline(string $text, array $definitions): string|false { $matched = preg_match_all( - '/\*([^*\n]+)\*|_([^_\n]+)_|`([^`\n]*)`|\[([^\]\n*\_`]+)\]\(([^()\s]+)\)/', + '/\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|_([^_\n]+)_|`([^`\n]*)`' + . '|\[([^\]\n*\_`]+)\]\(([^()\s]+)\)|\[([^\]\n*\_`]+)\]\[([^\]\n]+)\]/', $text, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE, @@ -516,7 +568,9 @@ private function renderFlatInline(string $text): string|false $delimiter = $token[0]; if ($delimiter === '*' || $delimiter === '_') { - $inner = $delimiter === '*' ? ($match[1][0] ?? '') : ($match[2][0] ?? ''); + $double = str_starts_with($token, '**'); + $inner = $double ? ($match[1][0] ?? '') + : ($delimiter === '*' ? ($match[2][0] ?? '') : ($match[3][0] ?? '')); $end = $start + strlen($token); if ( $inner === '' || strpbrk($inner, '*_`[]') !== false @@ -527,20 +581,23 @@ private function renderFlatInline(string $text): string|false return false; } $tag = $delimiter === '*' ? 'strong' : 'em'; - $out .= '<' . $tag . '>' . $this->escapeText($inner) . ''; + $rendered = '<' . $tag . '>' . $this->escapeText($inner) . ''; + $out .= $double ? '' . $rendered . '' : $rendered; } elseif ($delimiter === '`') { - $code = $match[3][0] ?? ''; + $code = $match[4][0] ?? ''; if ($code !== trim($code)) { return false; } $out .= '' . $this->escape($code) . ''; } else { - $href = $match[5][0] ?? ''; - if (!$this->safeUrl($href)) { + $direct = ($match[6][0] ?? '') !== ''; + $label = $direct ? ($match[5][0] ?? '') : ($match[7][0] ?? ''); + $href = $direct ? ($match[6][0] ?? '') : ($definitions[$match[8][0] ?? '']['href'] ?? null); + if ($href !== null && !$this->safeUrl($href)) { return false; } - $out .= '' - . $this->escapeText($match[4][0] ?? '') . ''; + $out .= '' : ' href="' . $this->escapeAttribute($href) . '">') + . $this->escapeText($label) . ''; } $offset = $start + strlen($token); } @@ -553,6 +610,126 @@ private function renderFlatInline(string $text): string|false return $out . $this->escapeText($tail); } + /** + * @param string $line + * @param array $definitions + */ + private function isActiveDefinition(string $line, array $definitions): bool + { + return preg_match('/^\[([^\]]+)\]:/', $line, $match) === 1 && isset($definitions[$match[1]]); + } + + /** + * @param list $lines + * @param int $start + * @param array $definitions + * @param array $stats + * + * @return array{html: string, next: int}|null + */ + private function renderUnorderedList(array $lines, int $start, array $definitions, array &$stats): ?array + { + $html = "
    \n"; + $i = $start; + while (isset($lines[$i]) && str_starts_with($lines[$i], '- ')) { + $itemText = substr($lines[$i], 2); + if (str_starts_with($itemText, '- ')) { + return null; + } + $inline = $this->renderInline($itemText, $definitions); + if ($inline === null) { + return null; + } + $this->accept($stats, 'unorderedListItems', $i, $i + 1); + $html .= "
  • \n" . $inline; + $i++; + while (isset($lines[$i]) && str_starts_with($lines[$i], ' - ')) { + $nested = $this->renderInline(substr($lines[$i], 4), $definitions); + if ($nested === null) { + return null; + } + $html .= "\n- " . $nested; + $this->accept($stats, 'unorderedListItems', $i, $i + 1); + $i++; + } + if (isset($lines[$i]) && trim($lines[$i]) !== '' && !str_starts_with($lines[$i], '- ')) { + return null; + } + $html .= "\n
  • \n"; + } + + return ['html' => $html . '
', 'next' => $i]; + } + + /** + * @param list $lines + * @param int $start + * @param array $definitions + * @param array $stats + * + * @return array{html: string, next: int}|null + */ + private function renderBlockQuote(array $lines, int $start, array $definitions, array &$stats): ?array + { + $parts = []; + $i = $start; + while (isset($lines[$i]) && str_starts_with($lines[$i], '> ')) { + $quoteText = substr($lines[$i], 2); + if ($this->blockish($quoteText)) { + return null; + } + $inline = $this->renderInline($quoteText, $definitions); + if ($inline === null) { + return null; + } + $parts[] = $inline; + $i++; + } + if (isset($lines[$i]) && trim($lines[$i]) !== '') { + return null; + } + $this->accept($stats, 'blockQuotes', $start, $i); + + return ['html' => "
\n

" . implode("\n", $parts) . "

\n
", 'next' => $i]; + } + + /** + * @param list $lines + * @param int $start + * @param array $definitions + * @param array $stats + * + * @return array{html: string, next: int}|null + */ + private function renderTable(array $lines, int $start, array $definitions, array &$stats): ?array + { + $html = "\n"; + $i = $start; + while (isset($lines[$i]) && str_starts_with($lines[$i], '|')) { + $trimmed = trim($lines[$i]); + if (!str_ends_with($trimmed, '|') || str_contains($trimmed, '\\|')) { + return null; + } + if (str_starts_with($trimmed, '|-') || str_starts_with($trimmed, '|:')) { + return null; + } + $cells = array_map('trim', explode('|', substr($trimmed, 1, -1))); + $html .= "\n"; + foreach ($cells as $cell) { + $inline = $this->renderInline($cell, $definitions); + if ($inline === null) { + return null; + } + $html .= '\n"; + } + $html .= "\n"; + $this->accept($stats, 'tableRows', $i, $i + 1); + $i++; + } + + return ['html' => $html . '
' . $inline . "
', 'next' => $i]; + } + /** * @return array{number: int, text: string}|null */ @@ -573,7 +750,11 @@ private function inlineComplex(string $text): bool return true; } - return preg_match('/[{}^\\\\<>~!@$=#\'\"]|--|\.\.\.|\/\*|\*\/|``|\+\-|\(c\)|\(r\)|\(tm\)/', $withoutContractions) === 1 + if (substr_count($withoutContractions, '"') % 2 !== 0) { + return true; + } + + return preg_match('/[{}^\\\\<>~!@$=#\']|\.\.\.|\/\*|\*\/|``|\+\-|\(c\)|\(r\)|\(tm\)/', $withoutContractions) === 1 || substr_count($text, ':') >= 2; } @@ -676,7 +857,24 @@ private function escape(string $text): string private function escapeText(string $text): string { - return $this->escape((string)preg_replace('/(?<=[A-Za-z0-9])\'(?=[A-Za-z0-9])/', '’', $text)); + $text = (string)preg_replace('/(?<=[A-Za-z0-9])\'(?=[A-Za-z0-9])/', '’', $text); + $text = (string)preg_replace('/"([^"]*)"/', '“$1”', $text); + $text = (string)preg_replace_callback('/-{2,}/', static function (array $match): string { + $length = strlen($match[0]); + if ($length % 2 === 0 && $length % 3 !== 0) { + return str_repeat('–', intdiv($length, 2)); + } + $triples = intdiv($length, 3); + $remainder = $length % 3; + if ($remainder === 1) { + $triples--; + $remainder = 4; + } + + return str_repeat('—', $triples) . str_repeat('–', intdiv($remainder, 2)); + }, $text); + + return $this->escape($text); } private function escapeAttribute(string $text): string diff --git a/tests/BorrowedHtmlLayoutTest.php b/tests/BorrowedHtmlLayoutTest.php index e1a43f6..1cb33a8 100644 --- a/tests/BorrowedHtmlLayoutTest.php +++ b/tests/BorrowedHtmlLayoutTest.php @@ -32,6 +32,22 @@ public static function acceptedDocuments(): iterable yield 'sections' => ["# First heading\n\nBody.\n\n## Child heading\n\nMore body.\n"]; yield 'code fence' => ["# Code\n\n```php\necho '';\n```\n"]; yield 'duplicate heading ids' => ["# Same\n\n# Same\n"]; + yield 'tight list with continuation-shaped nested markers' => [ + "- first\n- second\n - nested one\n - nested *strong*\n", + ]; + + yield 'simple block quote' => ["> Quoted *strong* and [linked](https://example.com).\n"]; + yield 'plain-cell table' => [ + "| Name | Value |\n| --- | ---: |\n| alpha | `one` |\n", + ]; + + yield 'unresolved explicit reference and double strong' => [ + "Paragraph has **strong** and an [unresolved][missing] reference.\n", + ]; + + yield 'title-shaped line is prose' => [ + "[site]: https://example.com \"Example\"\n", + ]; } #[DataProvider('rejectedDocuments')] @@ -47,8 +63,6 @@ public function testAmbiguousOrUnsupportedDocumentsFallBack(string $source): voi public static function rejectedDocuments(): iterable { yield 'lazy heading continuation' => ["# Heading\ncontinued\n"]; - yield 'lists' => ["- one\n- two\n"]; - yield 'quotes' => ["> quote\n"]; yield 'tables' => ["| a | b |\n|---|---|\n| 1 | 2 |\n"]; yield 'unicode' => ["Grüße\n"]; yield 'attributes' => ["{.note}\nParagraph\n"];