Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions src/InlineDestination.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

namespace Tempest\Markdown;

/**
* The `(destination "title")` part of an inline link or image.
*
* @see https://spec.commonmark.org/0.31.2/#link-destination
* @see https://spec.commonmark.org/0.31.2/#link-title
*/
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,

/** 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;
}
}
39 changes: 32 additions & 7 deletions src/Rules/ImageRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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) === ')';
}
}
30 changes: 21 additions & 9 deletions src/Rules/LinkRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/Tokens/ImageToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
public function __construct(
public string $src,
public ?string $alt,
public ?string $title = null,
) {}

public function parse(Parser $parser): string
Expand All @@ -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 (
'<img src="'
. htmlspecialchars($this->src, ENT_QUOTES)
. '"'
. $alt
. $title
. '>'
);
}
Expand Down
9 changes: 8 additions & 1 deletion src/Tokens/LinkToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
'<a href="'
. htmlspecialchars($href, ENT_QUOTES)
. "\"{$blank}>{$content}</a>"
. '"'
. $title
. "{$blank}>{$content}</a>"
);
}
}
Loading
Loading