From 68a85c8b4a597f15294f4c9a8c386f6f8eb2b00f Mon Sep 17 00:00:00 2001 From: RobinDev Date: Mon, 14 Sep 2026 23:10:07 +0200 Subject: [PATCH 1/3] fix: parse link and image destinations and titles --- src/InlineDestination.php | 209 ++++++++++++++++++++++++++++++++++ src/Rules/ImageRule.php | 39 +++++-- src/Rules/LinkRule.php | 30 +++-- src/Tokens/ImageToken.php | 6 + src/Tokens/LinkToken.php | 9 +- tests/Rules/ImageRuleTest.php | 39 +++++++ tests/Rules/LinkRuleTest.php | 113 ++++++++++++++++++ 7 files changed, 428 insertions(+), 17 deletions(-) create mode 100644 src/InlineDestination.php diff --git a/src/InlineDestination.php b/src/InlineDestination.php new file mode 100644 index 0000000..745b82a --- /dev/null +++ b/src/InlineDestination.php @@ -0,0 +1,209 @@ + $beforeWhitespace) { + $scanned = self::scanTitle($content, $position); + + if ($scanned !== null) { + [$title, $position] = $scanned; + $position = self::skipWhitespace($content, $position); + } + } + + if (($content[$position] ?? null) !== ')') { + return null; + } + + return new self($destination, $title, $position + 1 - $start); + } + + /** @return array{string, int}|null */ + private static function scanAngleDestination( + string $content, + int $position, + ): ?array { + $length = strlen($content); + $destination = ''; + $position++; + + while ($position < $length) { + $character = $content[$position]; + + if ($character === '\\' && isset($content[$position + 1])) { + $destination .= $content[$position + 1]; + $position += 2; + + continue; + } + + if ($character === '>') { + return [self::decodeEntities($destination), $position + 1]; + } + + // An unescaped `<` or a line ending closes nothing and makes the + // whole construct literal text. + if ( + $character === '<' + || $character === "\n" + || $character === "\r" + ) { + return null; + } + + $destination .= $character; + $position++; + } + + return null; + } + + /** @return array{string, int}|null */ + private static function scanBareDestination( + string $content, + int $position, + ): ?array { + $length = strlen($content); + $destination = ''; + $depth = 0; + + while ($position < $length) { + $character = $content[$position]; + + if ($character === '\\' && isset($content[$position + 1])) { + $destination .= $content[$position + 1]; + $position += 2; + + continue; + } + + if ($character === '(') { + $depth++; + } elseif ($character === ')') { + if ($depth === 0) { + break; + } + + $depth--; + } elseif ( + $character === ' ' + || $character === "\t" + || $character === "\n" + || $character === "\r" + ) { + break; + } + + $destination .= $character; + $position++; + } + + if ($depth !== 0) { + return null; + } + + return [self::decodeEntities($destination), $position]; + } + + /** @return array{string, int}|null */ + private static function scanTitle(string $content, int $position): ?array + { + $opening = $content[$position] ?? null; + + $closing = match ($opening) { + '"' => '"', + "'" => "'", + '(' => ')', + default => null, + }; + + if ($closing === null) { + return null; + } + + $length = strlen($content); + $title = ''; + $position++; + + while ($position < $length) { + $character = $content[$position]; + + if ($character === '\\' && isset($content[$position + 1])) { + $title .= $content[$position + 1]; + $position += 2; + + continue; + } + + if ($character === $closing) { + return [self::decodeEntities($title), $position + 1]; + } + + $title .= $character; + $position++; + } + + return null; + } + + private static function skipWhitespace(string $content, int $position): int + { + return $position + strspn($content, Parser::WHITESPACE, $position); + } + + private static function decodeEntities(string $value): string + { + return str_contains($value, '&') + ? html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') + : $value; + } +} diff --git a/src/Rules/ImageRule.php b/src/Rules/ImageRule.php index 7bd2bdc..2dc5e0c 100644 --- a/src/Rules/ImageRule.php +++ b/src/Rules/ImageRule.php @@ -4,12 +4,14 @@ use Tempest\Markdown\Exceptions\ImageSourceWasMissing; use Tempest\Markdown\Exceptions\ImageSourceWasNotClosed; +use Tempest\Markdown\InlineDestination; use Tempest\Markdown\Parser; use Tempest\Markdown\ProvidesFirstChar; use Tempest\Markdown\ProvidesStopChar; use Tempest\Markdown\Rule; use Tempest\Markdown\Token; use Tempest\Markdown\Tokens\ImageToken; +use Tempest\Markdown\Tokens\TextToken; final class ImageRule implements Rule, ProvidesFirstChar, ProvidesStopChar { @@ -24,22 +26,45 @@ public function shouldParse(Parser $parser): bool public function parse(Parser $parser): Token { $parser->consumeIncluding('!['); - $alt = $parser->consumeUntil(']') ?: null; + $alt = $parser->consumeUntil(']'); $parser->consumeIncluding(']'); if (! $parser->comesNext('(', 1)) { throw new ImageSourceWasMissing($parser); } - $parser->consumeIncluding('('); - $href = $parser->consumeUntil(')' . Parser::NEW_LINE); + $destination = InlineDestination::scan( + $parser->content, + $parser->position, + ); - if (! $parser->comesNext(')')) { - throw new ImageSourceWasNotClosed($parser); + if ($destination === null) { + if (! $this->closesOnThisLine($parser)) { + throw new ImageSourceWasNotClosed($parser); + } + + // A malformed source is not an image: the label and everything + // after it stay literal text. + return new TextToken('![' . $alt . ']'); } - $parser->consumeIncluding(')'); + $parser->consume($destination->length); + + return new ImageToken( + $destination->destination, + $alt ?: null, + $destination->title, + ); + } + + private function closesOnThisLine(Parser $parser): bool + { + $offset = strcspn( + $parser->content, + ')' . Parser::NEW_LINE, + $parser->position, + ); - return new ImageToken($href, $alt); + return ($parser->content[$parser->position + $offset] ?? null) === ')'; } } diff --git a/src/Rules/LinkRule.php b/src/Rules/LinkRule.php index 3c16b96..59ab8ad 100644 --- a/src/Rules/LinkRule.php +++ b/src/Rules/LinkRule.php @@ -2,12 +2,14 @@ namespace Tempest\Markdown\Rules; +use Tempest\Markdown\InlineDestination; use Tempest\Markdown\Parser; use Tempest\Markdown\ProvidesFirstChar; use Tempest\Markdown\ProvidesStopChar; use Tempest\Markdown\Rule; use Tempest\Markdown\Token; use Tempest\Markdown\Tokens\LinkToken; +use Tempest\Markdown\Tokens\TextToken; final class LinkRule implements Rule, ProvidesFirstChar, ProvidesStopChar { @@ -25,18 +27,28 @@ public function parse(Parser $parser): Token $content = $this->consumeContent($parser); $parser->consumeIncluding(']'); - $href = null; + if (! $parser->comesNext('(', 1)) { + return new LinkToken($content, null); + } + + $destination = InlineDestination::scan( + $parser->content, + $parser->position, + ); - if ($parser->comesNext('(', 1)) { - $parser->consumeIncluding('('); - $href = $parser->consumeUntilUnescaped( - stopAt: ')', - allowNestedAt: '(', - ); - $parser->consumeIncluding(')'); + // A malformed destination is not a link: the label and everything + // after it stay literal text. + if ($destination === null) { + return new TextToken('[' . $content . ']'); } - return new LinkToken($content, $href); + $parser->consume($destination->length); + + return new LinkToken( + $content, + $destination->destination, + $destination->title, + ); } private function consumeContent(Parser $parser): string diff --git a/src/Tokens/ImageToken.php b/src/Tokens/ImageToken.php index 1def56f..51db028 100644 --- a/src/Tokens/ImageToken.php +++ b/src/Tokens/ImageToken.php @@ -10,6 +10,7 @@ public function __construct( public string $src, public ?string $alt, + public ?string $title = null, ) {} public function parse(Parser $parser): string @@ -22,11 +23,16 @@ public function parse(Parser $parser): string ? ' alt="' . htmlspecialchars($this->alt, ENT_QUOTES) . '"' : ''; + $title = $this->title === null + ? '' + : ' title="' . htmlspecialchars($this->title, ENT_QUOTES) . '"'; + return ( '' ); } diff --git a/src/Tokens/LinkToken.php b/src/Tokens/LinkToken.php index 7e8c856..173398a 100644 --- a/src/Tokens/LinkToken.php +++ b/src/Tokens/LinkToken.php @@ -17,6 +17,7 @@ final class LinkToken implements Token public function __construct( public string $content, public ?string $href, + public ?string $title = null, // @todo(aidan-casey): This is a temporary solution to the problem that we don't support Markdown escaping yet. public bool $parseContent = true, @@ -48,10 +49,16 @@ public function parse(Parser $parser): string $blank = ' target="_blank" rel="noopener noreferrer"'; } + $title = $this->title === null + ? '' + : ' title="' . htmlspecialchars($this->title, ENT_QUOTES) . '"'; + return ( '{$content}" + . '"' + . $title + . "{$blank}>{$content}" ); } } diff --git a/tests/Rules/ImageRuleTest.php b/tests/Rules/ImageRuleTest.php index 36fbc97..6c30670 100644 --- a/tests/Rules/ImageRuleTest.php +++ b/tests/Rules/ImageRuleTest.php @@ -7,6 +7,7 @@ use Tempest\Markdown\Exceptions\ImageSourceWasNotClosed; use Tempest\Markdown\Parser; use Tempest\Markdown\Rules\ImageRule; +use Tempest\Markdown\Rules\TextRule; use Tempest\Markdown\Tests\ParserTestCase; class ImageRuleTest extends ParserTestCase @@ -60,4 +61,42 @@ public function test_invalid_image_source_throws_exception(): void TXT, $e->getMessage()); } } + + #[Test] + public function lex_with_title(): void + { + $html = + (string) new Parser(highlighter: null, rules: [new ImageRule()])->parse( + '![alt](/a.png "Title")', + ); + + $this->assertSame( + 'alt', + $html, + ); + } + + #[Test] + public function lex_with_angle_bracket_source(): void + { + $html = + (string) new Parser(highlighter: null, rules: [new ImageRule()])->parse( + '![alt]()', + ); + + $this->assertSame('alt', $html); + } + + #[Test] + public function lex_with_space_in_source_stays_literal(): void + { + $html = (string) new Parser(highlighter: null, rules: [ + new ImageRule(), + new TextRule(), + ])->parse( + 'see ![alt](/my image.png) there', + ); + + $this->assertSame('see ![alt](/my image.png) there', $html); + } } diff --git a/tests/Rules/LinkRuleTest.php b/tests/Rules/LinkRuleTest.php index 4e0a285..803d2d9 100644 --- a/tests/Rules/LinkRuleTest.php +++ b/tests/Rules/LinkRuleTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Tempest\Markdown\Parser; use Tempest\Markdown\Rules\LinkRule; +use Tempest\Markdown\Rules\TextRule; use Tempest\Markdown\Tests\ParserTestCase; class LinkRuleTest extends ParserTestCase @@ -72,4 +73,116 @@ public function lex_with_end_parenthesis_without_start_parenthesis(): void $html, ); } + + #[Test] + public function lex_with_title(): void + { + $html = + (string) new Parser(highlighter: null, rules: [new LinkRule()])->parse( + '[click here](/uri "Title")', + ); + + $this->assertSame( + 'click here', + $html, + ); + } + + #[Test] + public function lex_with_single_quoted_and_parenthesised_title(): void + { + $parser = new Parser(highlighter: null, rules: [new LinkRule()]); + + $this->assertSame( + 'click here', + (string) $parser->parse("[click here](/uri 'Title')"), + ); + + $this->assertSame( + 'click here', + (string) $parser->parse('[click here](/uri (Title))'), + ); + } + + #[Test] + public function lex_with_angle_bracket_destination(): void + { + $html = + (string) new Parser(highlighter: null, rules: [new LinkRule()])->parse( + '[click here]( "Title")', + ); + + $this->assertSame( + 'click here', + $html, + ); + } + + #[Test] + public function lex_with_empty_angle_bracket_destination(): void + { + $html = + (string) new Parser(highlighter: null, rules: [new LinkRule()])->parse( + '[click here](<> "Title")', + ); + + $this->assertSame( + 'click here', + $html, + ); + } + + #[Test] + public function lex_decodes_entities_in_destination_and_title(): void + { + $html = + (string) new Parser(highlighter: null, rules: [new LinkRule()])->parse( + '[click here](/a&b "R&D")', + ); + + $this->assertSame( + 'click here', + $html, + ); + } + + #[Test] + public function lex_escapes_quotes_in_title(): void + { + $html = + (string) new Parser(highlighter: null, rules: [new LinkRule()])->parse( + '[click here](/uri "a \"b\"")', + ); + + $this->assertSame( + 'click here', + $html, + ); + } + + #[Test] + public function lex_with_space_in_destination_stays_literal(): void + { + $html = (string) new Parser(highlighter: null, rules: [ + new LinkRule(), + new TextRule(), + ])->parse( + 'see [click here](/my uri) there', + ); + + $this->assertSame('see [click here](/my uri) there', $html); + } + + #[Test] + public function lex_with_unclosed_destination_stays_literal(): void + { + $html = (string) new Parser(highlighter: null, rules: [ + new LinkRule(), + new TextRule(), + ])->parse( + '[click here](/uri', + ); + + $this->assertSame('[click here](/uri', $html); + } } From 845eb3e8f100675cd404446af7f60727aeea0b50 Mon Sep 17 00:00:00 2001 From: RobinDev Date: Mon, 14 Sep 2026 23:57:52 +0200 Subject: [PATCH 2/3] perf: bulk-skip while scanning inline destinations --- src/InlineDestination.php | 56 ++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/src/InlineDestination.php b/src/InlineDestination.php index 745b82a..62f8208 100644 --- a/src/InlineDestination.php +++ b/src/InlineDestination.php @@ -10,6 +10,10 @@ */ final readonly class InlineDestination { + private const string BARE_STOP_CHARS = '\\()' . Parser::WHITESPACE; + + private const string ANGLE_STOP_CHARS = '\\<>' . Parser::NEW_LINE; + public function __construct( public string $destination, public ?string $title, @@ -77,7 +81,14 @@ private static function scanAngleDestination( $position++; while ($position < $length) { - $character = $content[$position]; + $offset = strcspn($content, self::ANGLE_STOP_CHARS, $position); + + if ($offset > 0) { + $destination .= substr($content, $position, $offset); + $position += $offset; + } + + $character = $content[$position] ?? null; if ($character === '\\' && isset($content[$position + 1])) { $destination .= $content[$position + 1]; @@ -92,16 +103,7 @@ private static function scanAngleDestination( // An unescaped `<` or a line ending closes nothing and makes the // whole construct literal text. - if ( - $character === '<' - || $character === "\n" - || $character === "\r" - ) { - return null; - } - - $destination .= $character; - $position++; + return null; } return null; @@ -117,7 +119,16 @@ private static function scanBareDestination( $depth = 0; while ($position < $length) { - $character = $content[$position]; + // Bulk-skip to the next character that needs attention rather + // than walking the destination one character at a time. + $offset = strcspn($content, self::BARE_STOP_CHARS, $position); + + if ($offset > 0) { + $destination .= substr($content, $position, $offset); + $position += $offset; + } + + $character = $content[$position] ?? null; if ($character === '\\' && isset($content[$position + 1])) { $destination .= $content[$position + 1]; @@ -134,12 +145,8 @@ private static function scanBareDestination( } $depth--; - } elseif ( - $character === ' ' - || $character === "\t" - || $character === "\n" - || $character === "\r" - ) { + } elseif ($character !== '\\') { + // Whitespace, or the end of the content. break; } @@ -171,11 +178,19 @@ private static function scanTitle(string $content, int $position): ?array } $length = strlen($content); + $stopChars = '\\' . $closing; $title = ''; $position++; while ($position < $length) { - $character = $content[$position]; + $offset = strcspn($content, $stopChars, $position); + + if ($offset > 0) { + $title .= substr($content, $position, $offset); + $position += $offset; + } + + $character = $content[$position] ?? null; if ($character === '\\' && isset($content[$position + 1])) { $title .= $content[$position + 1]; @@ -188,8 +203,7 @@ private static function scanTitle(string $content, int $position): ?array return [self::decodeEntities($title), $position + 1]; } - $title .= $character; - $position++; + break; } return null; From 2cf20b5a7c19b3c47d1997371f14d410a6cf5ff6 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 18 Sep 2026 12:23:26 +0200 Subject: [PATCH 3/3] test: cover link and image title regressions --- tests/Rules/LinkRuleTest.php | 25 +++++++++++++++++++++++++ tests/Tokens/ImageTokenTest.php | 20 ++++++++++++++++++++ tests/Tokens/LinkTokenTest.php | 11 +++++++++++ 3 files changed, 56 insertions(+) diff --git a/tests/Rules/LinkRuleTest.php b/tests/Rules/LinkRuleTest.php index 803d2d9..a947514 100644 --- a/tests/Rules/LinkRuleTest.php +++ b/tests/Rules/LinkRuleTest.php @@ -10,6 +10,31 @@ class LinkRuleTest extends ParserTestCase { + #[Test] + public function title_preserves_backslash_before_letter(): void + { + $parser = new Parser(highlighter: null, rules: [new LinkRule()]); + + $this->assertSame( + 'x', + (string) $parser->parse('[x](/a "a\b")'), + ); + } + + #[Test] + public function unescaped_opening_parenthesis_in_title_stays_literal(): void + { + $parser = new Parser(highlighter: null, rules: [ + new LinkRule(), + new TextRule(), + ]); + + $this->assertSame( + '[x](/a (b(c))', + (string) $parser->parse('[x](/a (b(c))'), + ); + } + #[Test] public function test_lex(): void { diff --git a/tests/Tokens/ImageTokenTest.php b/tests/Tokens/ImageTokenTest.php index 4f15737..c19e1c5 100644 --- a/tests/Tokens/ImageTokenTest.php +++ b/tests/Tokens/ImageTokenTest.php @@ -103,4 +103,24 @@ public function test_with_responsive_image(): void )); $this->assertFileExists($config->makePublicPath('/parrot-607-404.jpg')); } + + #[Test] + public function responsive_image_preserves_title(): void + { + $parser = new Parser( + highlighter: null, + imageFactory: new ResponsiveImageFactory( + new ResponsiveImageConfig( + srcPath: __DIR__ . '/../Fixtures/src', + publicPath: __DIR__ . '/../Fixtures/public', + ), + ), + ); + + $html = new ImageToken('/parrot.jpg', 'A parrot', 'Title')->parse( + $parser, + ); + + $this->assertStringContainsString(' title="Title"', $html); + } } diff --git a/tests/Tokens/LinkTokenTest.php b/tests/Tokens/LinkTokenTest.php index 15ed7a9..b703107 100644 --- a/tests/Tokens/LinkTokenTest.php +++ b/tests/Tokens/LinkTokenTest.php @@ -9,6 +9,17 @@ class LinkTokenTest extends ParserTestCase { + #[Test] + public function positional_false_preserves_literal_content(): void + { + $token = new LinkToken('**literal**', '/', false); + + $this->assertSame( + '**literal**', + $token->parse(new Parser(highlighter: null)), + ); + } + #[Test] public function test_parse(): void {