From 78b2d2d86d80d9cba01a001de5173a464a9c938a Mon Sep 17 00:00:00 2001 From: Mark Carver Date: Thu, 17 Sep 2020 10:35:17 -0500 Subject: [PATCH] WIP: Intial emoji extension Signed-off-by: Mark Carver --- composer.json | 1 + src/Extension/Emoji/EmojiExtension.php | 48 +++++++++++++ src/Extension/Emoji/EmojiRenderer.php | 34 +++++++++ .../Emoji/Exception/InvalidEmojiException.php | 22 ++++++ .../Emoji/Listener/EmojiProcessorListener.php | 69 +++++++++++++++++++ src/Extension/Emoji/Node/Emoji.php | 33 +++++++++ .../Emoji/Parser/EmojiParserInterface.php | 29 ++++++++ .../Emoji/Parser/UnicornFailEmojiParser.php | 61 ++++++++++++++++ .../Extension/Emoji/EmojiExtensionTest.php | 33 +++++++++ 9 files changed, 330 insertions(+) create mode 100644 src/Extension/Emoji/EmojiExtension.php create mode 100644 src/Extension/Emoji/EmojiRenderer.php create mode 100644 src/Extension/Emoji/Exception/InvalidEmojiException.php create mode 100644 src/Extension/Emoji/Listener/EmojiProcessorListener.php create mode 100644 src/Extension/Emoji/Node/Emoji.php create mode 100644 src/Extension/Emoji/Parser/EmojiParserInterface.php create mode 100644 src/Extension/Emoji/Parser/UnicornFailEmojiParser.php create mode 100644 tests/functional/Extension/Emoji/EmojiExtensionTest.php diff --git a/composer.json b/composer.json index a69ce09114..14fbf7136c 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "scrutinizer/ocular": "^1.5", "symfony/finder": "^5.1", "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0", + "unicorn-fail/emoji": "1.0.x-dev", "unleashedtech/php-coding-standard": "^2.5", "vimeo/psalm": "^3.14" }, diff --git a/src/Extension/Emoji/EmojiExtension.php b/src/Extension/Emoji/EmojiExtension.php new file mode 100644 index 0000000000..049596604b --- /dev/null +++ b/src/Extension/Emoji/EmojiExtension.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Emoji; + +use League\CommonMark\Environment\ConfigurableEnvironmentInterface; +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\Emoji\Listener\EmojiProcessorListener; +use League\CommonMark\Extension\Emoji\Node\Emoji; +use League\CommonMark\Extension\Emoji\Parser\EmojiParserInterface; +use League\CommonMark\Extension\Emoji\Parser\UnicornFailEmojiParser; +use League\CommonMark\Extension\ExtensionInterface; + +final class EmojiExtension implements ExtensionInterface +{ + /** + * @var EmojiParserInterface + * + * @psalm-readonly + */ + private $parser; + + public function __construct(?EmojiParserInterface $parser = null) + { + $this->parser = $parser ?? new UnicornFailEmojiParser(); + } + + public function getEmojiParser(): EmojiParserInterface + { + return $this->parser; + } + + public function register(ConfigurableEnvironmentInterface $environment): void + { + $environment->addEventListener(DocumentParsedEvent::class, new EmojiProcessorListener($this->parser), -100); + $environment->addRenderer(Emoji::class, new EmojiRenderer()); + } +} diff --git a/src/Extension/Emoji/EmojiRenderer.php b/src/Extension/Emoji/EmojiRenderer.php new file mode 100644 index 0000000000..94dfe35946 --- /dev/null +++ b/src/Extension/Emoji/EmojiRenderer.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Emoji; + +use League\CommonMark\Extension\Emoji\Node\Emoji; +use League\CommonMark\Node\Node; +use League\CommonMark\Renderer\ChildNodeRendererInterface; +use League\CommonMark\Renderer\NodeRendererInterface; + +final class EmojiRenderer implements NodeRendererInterface +{ + /** + * {@inheritdoc} + */ + public function render(Node $node, ChildNodeRendererInterface $childRenderer) + { + if (! ($node instanceof Emoji)) { + throw new \InvalidArgumentException('Incompatible node type: ' . \get_class($node)); + } + + return (string) $node->getToken(); + } +} diff --git a/src/Extension/Emoji/Exception/InvalidEmojiException.php b/src/Extension/Emoji/Exception/InvalidEmojiException.php new file mode 100644 index 0000000000..eba4d9aea8 --- /dev/null +++ b/src/Extension/Emoji/Exception/InvalidEmojiException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Emoji\Exception; + +final class InvalidEmojiException extends \RuntimeException +{ + public static function wrap(\Throwable $throwable): self + { + return new InvalidEmojiException('Failed to parse emojis: ' . $throwable->getMessage(), 0, $throwable); + } +} diff --git a/src/Extension/Emoji/Listener/EmojiProcessorListener.php b/src/Extension/Emoji/Listener/EmojiProcessorListener.php new file mode 100644 index 0000000000..45bae5bf49 --- /dev/null +++ b/src/Extension/Emoji/Listener/EmojiProcessorListener.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Emoji\Listener; + +use League\CommonMark\Configuration\ConfigurationAwareInterface; +use League\CommonMark\Configuration\ConfigurationInterface; +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\Emoji\Parser\EmojiParserInterface; +use League\CommonMark\Node\Inline\Text; + +/** + * Searches the Document for Text elements and parses it into distinct Emoji nodes. + */ +final class EmojiProcessorListener implements ConfigurationAwareInterface +{ + /** + * @var EmojiParserInterface + * + * @psalm-readonly + */ + private $parser; + + public function __construct(EmojiParserInterface $parser) + { + $this->parser = $parser; + } + + public function __invoke(DocumentParsedEvent $e): void + { + $walker = $e->getDocument()->walker(); + while ($event = $walker->next()) { + if (! $event->isEntering()) { + continue; + } + + $text = $event->getNode(); + if (! ($text instanceof Text)) { + continue; + } + + $nodes = $this->parser->parse($text->getLiteral()); + if (! $nodes) { + continue; + } + + foreach ($nodes as $node) { + $text->insertBefore($node); + } + + $text->detach(); + } + } + + public function setConfiguration(ConfigurationInterface $configuration): void + { + $this->parser->setConfiguration($configuration); + } +} diff --git a/src/Extension/Emoji/Node/Emoji.php b/src/Extension/Emoji/Node/Emoji.php new file mode 100644 index 0000000000..be80a6a6a9 --- /dev/null +++ b/src/Extension/Emoji/Node/Emoji.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Emoji\Node; + +use League\CommonMark\Node\Inline\AbstractInline; +use UnicornFail\Emoji\Token\AbstractEmojiToken; + +final class Emoji extends AbstractInline +{ + /** @var AbstractEmojiToken */ + protected $token; + + public function __construct(AbstractEmojiToken $token) + { + $this->token = $token; + } + + public function getToken(): ?AbstractEmojiToken + { + return $this->token; + } +} diff --git a/src/Extension/Emoji/Parser/EmojiParserInterface.php b/src/Extension/Emoji/Parser/EmojiParserInterface.php new file mode 100644 index 0000000000..d083acbce1 --- /dev/null +++ b/src/Extension/Emoji/Parser/EmojiParserInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Emoji\Parser; + +use League\CommonMark\Configuration\ConfigurationAwareInterface; +use League\CommonMark\Extension\Emoji\Exception\InvalidEmojiException; +use League\CommonMark\Node\Inline\AbstractInline; + +interface EmojiParserInterface extends ConfigurationAwareInterface +{ + /** + * @return AbstractInline[] + * + * @throws InvalidEmojiException if parsing fails + * @throws \RuntimeException if other errors occur + */ + public function parse(string $string): array; +} diff --git a/src/Extension/Emoji/Parser/UnicornFailEmojiParser.php b/src/Extension/Emoji/Parser/UnicornFailEmojiParser.php new file mode 100644 index 0000000000..dc4db18983 --- /dev/null +++ b/src/Extension/Emoji/Parser/UnicornFailEmojiParser.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Emoji\Parser; + +use League\CommonMark\Configuration\ConfigurationInterface; +use League\CommonMark\Extension\Emoji\Node\Emoji; +use League\CommonMark\Node\Inline\Text; +use UnicornFail\Emoji\Parser; +use UnicornFail\Emoji\Token\AbstractEmojiToken; + +final class UnicornFailEmojiParser implements EmojiParserInterface +{ + /** @var ConfigurationInterface */ + private $config; + + /** @var Parser */ + private $parser; + + public function getParser(): Parser + { + if (! isset($this->parser)) { + if (! \class_exists(Parser::class)) { + throw new \RuntimeException('Failed to parse emojis: "unicorn-fail/emoji" library is missing'); + } + + $this->parser = new Parser($this->config->get('emoji/configuration', [])); + } + + return $this->parser; + } + + /** + * {@inheritDoc} + */ + public function parse(string $string): array + { + $nodes = []; + $tokens = $this->getParser()->parse($string); + foreach ($tokens as $token) { + $nodes[] = $token instanceof AbstractEmojiToken ? new Emoji($token) : new Text((string) $token); + } + + return $nodes; + } + + public function setConfiguration(ConfigurationInterface $configuration): void + { + $this->config = $configuration; + } +} diff --git a/tests/functional/Extension/Emoji/EmojiExtensionTest.php b/tests/functional/Extension/Emoji/EmojiExtensionTest.php new file mode 100644 index 0000000000..010ffdf45e --- /dev/null +++ b/tests/functional/Extension/Emoji/EmojiExtensionTest.php @@ -0,0 +1,33 @@ +environment = Environment::createCommonMarkEnvironment(); + $this->environment->addExtension(new EmojiExtension()); + } + + public function testWithSampleData(): void + { + $markdown = '🙍🏿‍♂️ is leaving on a ✈️. Going to 🇦🇺. Might see some :kangaroo:! <3 Remember to 📱 :D'; + $expected = "

🙍🏿‍♂️ is leaving on a ✈️. Going to 🇦🇺. Might see some 🦘! ❤️ Remember to 📱 😀

\n"; + + $converter = new CommonMarkConverter([], $this->environment); + $result = $converter->convertToHtml($markdown); + + $this->assertSame($expected, (string) $result); + } +}