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(
+ '',
+ );
+
+ $this->assertSame(
+ '
',
+ $html,
+ );
+ }
+
+ #[Test]
+ public function lex_with_angle_bracket_source(): void
+ {
+ $html =
+ (string) new Parser(highlighter: null, rules: [new ImageRule()])->parse(
+ '![alt]()',
+ );
+
+ $this->assertSame('
', $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  there',
+ );
+
+ $this->assertSame('see  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);
+ }
}