diff --git a/src/InlineDestination.php b/src/InlineDestination.php new file mode 100644 index 0000000..62f8208 --- /dev/null +++ b/src/InlineDestination.php @@ -0,0 +1,223 @@ +' . Parser::NEW_LINE; + + public function __construct( + public string $destination, + public ?string $title, + + /** How many characters the destination spans, closing parenthesis included. */ + public int $length, + ) {} + + /** + * Scans an inline destination starting at its opening parenthesis. Returns + * `null` when it is malformed, in which case the surrounding construct is + * not a link or an image and must be left as literal text. + */ + public static function scan(string $content, int $position): ?self + { + $start = $position; + + if (($content[$position] ?? null) !== '(') { + return null; + } + + $position++; + $position = self::skipWhitespace($content, $position); + + $destination = ($content[$position] ?? null) === '<' + ? self::scanAngleDestination($content, $position) + : self::scanBareDestination($content, $position); + + if ($destination === null) { + return null; + } + + [$destination, $position] = $destination; + + $beforeWhitespace = $position; + $position = self::skipWhitespace($content, $position); + + $title = null; + + // A title has to be separated from the destination by whitespace, + // otherwise `(a"b)` would be a destination followed by an open title. + if ($position > $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) { + $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]; + $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. + return null; + } + + 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) { + // 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]; + $position += 2; + + continue; + } + + if ($character === '(') { + $depth++; + } elseif ($character === ')') { + if ($depth === 0) { + break; + } + + $depth--; + } elseif ($character !== '\\') { + // Whitespace, or the end of the content. + 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); + $stopChars = '\\' . $closing; + $title = ''; + $position++; + + while ($position < $length) { + $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]; + $position += 2; + + continue; + } + + if ($character === $closing) { + return [self::decodeEntities($title), $position + 1]; + } + + break; + } + + 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); + } }