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) . '' . $tag . '>';
+ $rendered = '<' . $tag . '>' . $this->escapeText($inner) . '' . $tag . '>';
+ $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 .= '| ' . $inline . " | \n";
+ }
+ $html .= "
\n";
+ $this->accept($stats, 'tableRows', $i, $i + 1);
+ $i++;
+ }
+
+ return ['html' => $html . '
', '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"];