diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bd241309..6dffe49e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi ## [Unreleased][unreleased] +### Added +- Added a new `HighlightExtension` for marking important text using `==` syntax (#1100) + ## [2.7.0] This is a **security release** to address a potential cross-site scripting (XSS) vulnerability when using the `AttributesExtension` with untrusted user input. diff --git a/docs/2.x/extensions/highlight.md b/docs/2.x/extensions/highlight.md new file mode 100644 index 0000000000..dd7ea49337 --- /dev/null +++ b/docs/2.x/extensions/highlight.md @@ -0,0 +1,58 @@ +--- +layout: default +title: Highlight Extension +description: The HighlightExtension allows marking important text. +redirect_from: + - /extensions/highlight/ +--- + +# Highlight Extension + +This extension adds support for highlighting important text using the `==` syntax. For example, the Markdown: + +```markdown +I need to highlight these ==very important words==. +``` + +Would be rendered to HTML as: + +```html +

I need to highlight these very important words.

+``` + +Which could then be styled using CSS to produce a highlighter effect. + +## Installation + +This extension is bundled with `league/commonmark`. This library can be installed via Composer: + +```bash +composer require league/commonmark +``` + +See the [installation](/2.x/installation/) section for more details. + +## Usage + +This extension can be added to any new `Environment`: + +```php +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Highlight\HighlightExtension; +use League\CommonMark\MarkdownConverter; + +// Define your configuration, if needed +$config = []; + +// Configure the Environment with all the CommonMark parsers/renderers +$environment = new Environment($config); +$environment->addExtension(new CommonMarkCoreExtension()); + +// Add this extension +$environment->addExtension(new HighlightExtension()); + +// Instantiate the converter engine and start converting some Markdown! +$converter = new MarkdownConverter($environment); +echo $converter->convert('I need to highlight these ==very important words==.'); +``` diff --git a/docs/2.x/extensions/overview.md b/docs/2.x/extensions/overview.md index 743a4326d6..18306964b0 100644 --- a/docs/2.x/extensions/overview.md +++ b/docs/2.x/extensions/overview.md @@ -40,6 +40,7 @@ to enhance your experience out-of-the-box depending on your specific use-cases. | [Front Matter] | Parses YAML front matter from your Markdown input | `2.0.0` | | | **[GitHub Flavored Markdown]** | Enables full support for GFM. Automatically includes the extensions noted in the `GFM` column (though you can certainly add them individually if you wish): | `1.3.0` | | | [Heading Permalinks] | Makes heading elements linkable | `1.4.0` | | +| [Highlight] | Mark text as being highlighted for reference or notation purposes | `2.8.0` | | | [Inlines Only] | Only includes standard CommonMark inline elements - perfect for handling comments and other short bits of text where you only want bold, italic, links, etc. | `1.3.0` | | | [Mentions] | Easy parsing of `@mention` and `#123`-style references | `1.5.0` | | | [Strikethrough] | Allows using tilde characters (`~~`) for ~strikethrough~ formatting | `1.3.0` | | @@ -121,6 +122,7 @@ See the [Custom Extensions](/2.x/customization/extensions/) page for details on [Front Matter]: /2.x/extensions/front-matter/ [GitHub Flavored Markdown]: /2.x/extensions/github-flavored-markdown/ [Heading Permalinks]: /2.x/extensions/heading-permalinks/ +[Highlight]: /2.x/extensions/highlight/ [Inlines Only]: /2.x/extensions/inlines-only/ [Mentions]: /2.x/extensions/mentions/ [Strikethrough]: /2.x/extensions/strikethrough/ diff --git a/docs/_data/menu.yml b/docs/_data/menu.yml index 315d09bf2b..a7b482b6b3 100644 --- a/docs/_data/menu.yml +++ b/docs/_data/menu.yml @@ -25,6 +25,7 @@ version: 'Footnotes': '/2.x/extensions/footnotes/' 'Front Matter': '/2.x/extensions/front-matter/' 'Heading Permalinks': '/2.x/extensions/heading-permalinks/' + 'Highlight': '/2.x/extensions/highlight/' 'Inlines Only': '/2.x/extensions/inlines-only/' 'Mentions': '/2.x/extensions/mentions/' 'Smart Punctuation': '/2.x/extensions/smart-punctuation/' diff --git a/src/Extension/Highlight/HighlightExtension.php b/src/Extension/Highlight/HighlightExtension.php new file mode 100644 index 0000000000..ec244587ae --- /dev/null +++ b/src/Extension/Highlight/HighlightExtension.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Highlight; + +use League\CommonMark\Environment\EnvironmentBuilderInterface; +use League\CommonMark\Extension\ExtensionInterface; + +class HighlightExtension implements ExtensionInterface +{ + public function register(EnvironmentBuilderInterface $environment): void + { + $environment->addDelimiterProcessor(new MarkDelimiterProcessor()); + $environment->addRenderer(Mark::class, new MarkRenderer()); + } +} diff --git a/src/Extension/Highlight/Mark.php b/src/Extension/Highlight/Mark.php new file mode 100644 index 0000000000..c5e226b766 --- /dev/null +++ b/src/Extension/Highlight/Mark.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Highlight; + +use League\CommonMark\Node\Inline\AbstractInline; +use League\CommonMark\Node\Inline\DelimitedInterface; + +final class Mark extends AbstractInline implements DelimitedInterface +{ + private string $delimiter; + + public function __construct(string $delimiter = '==') + { + parent::__construct(); + + $this->delimiter = $delimiter; + } + + public function getOpeningDelimiter(): string + { + return $this->delimiter; + } + + public function getClosingDelimiter(): string + { + return $this->delimiter; + } +} diff --git a/src/Extension/Highlight/MarkDelimiterProcessor.php b/src/Extension/Highlight/MarkDelimiterProcessor.php new file mode 100644 index 0000000000..9df124b2dd --- /dev/null +++ b/src/Extension/Highlight/MarkDelimiterProcessor.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\Highlight; + +use League\CommonMark\Delimiter\DelimiterInterface; +use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface; +use League\CommonMark\Node\Inline\AbstractStringContainer; + +class MarkDelimiterProcessor implements DelimiterProcessorInterface +{ + public function getOpeningCharacter(): string + { + return '='; + } + + public function getClosingCharacter(): string + { + return '='; + } + + public function getMinLength(): int + { + return 2; + } + + public function getDelimiterUse(DelimiterInterface $opener, DelimiterInterface $closer): int + { + if ($opener->getLength() > 2 && $closer->getLength() > 2) { + return 0; + } + + if ($opener->getLength() !== $closer->getLength()) { + return 0; + } + + // $opener and $closer are the same length so we just return one of them + return $opener->getLength(); + } + + public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void + { + $mark = new Mark(\str_repeat('=', $delimiterUse)); + + $next = $opener->next(); + while ($next !== null && $next !== $closer) { + $tmp = $next->next(); + $mark->appendChild($next); + $next = $tmp; + } + + $opener->insertAfter($mark); + } + + public function getCacheKey(DelimiterInterface $closer): string + { + return '=' . $closer->getLength(); + } +} diff --git a/src/Extension/Highlight/MarkRenderer.php b/src/Extension/Highlight/MarkRenderer.php new file mode 100644 index 0000000000..59c6bf320d --- /dev/null +++ b/src/Extension/Highlight/MarkRenderer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\Highlight; + +use League\CommonMark\Node\Node; +use League\CommonMark\Renderer\ChildNodeRendererInterface; +use League\CommonMark\Renderer\NodeRendererInterface; +use League\CommonMark\Util\HtmlElement; +use League\CommonMark\Xml\XmlNodeRendererInterface; + +final class MarkRenderer implements NodeRendererInterface, XmlNodeRendererInterface +{ + /** + * @param Mark $node + * + * {@inheritDoc} + * + * @psalm-suppress MoreSpecificImplementedParamType + */ + public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable + { + Mark::assertInstanceOf($node); + + return new HtmlElement('mark', $node->data->get('attributes'), $childRenderer->renderNodes($node->children())); + } + + public function getXmlTagName(Node $node): string + { + return 'mark'; + } + + /** + * {@inheritDoc} + */ + public function getXmlAttributes(Node $node): array + { + return []; + } +} diff --git a/tests/benchmark/benchmark.php b/tests/benchmark/benchmark.php index 5b4e77fdfd..475fef16f3 100755 --- a/tests/benchmark/benchmark.php +++ b/tests/benchmark/benchmark.php @@ -24,6 +24,7 @@ use League\CommonMark\Extension\Footnote\FootnoteExtension; use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension; +use League\CommonMark\Extension\Highlight\HighlightExtension; use League\CommonMark\Extension\Mention\MentionExtension; use League\CommonMark\Extension\SmartPunct\SmartPunctExtension; use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; @@ -194,6 +195,7 @@ $environment->addExtension(new FootnoteExtension()); $environment->addExtension(new FrontMatterExtension()); $environment->addExtension(new HeadingPermalinkExtension()); + $environment->addExtension(new HighlightExtension()); $environment->addExtension(new MentionExtension()); $environment->addExtension(new SmartPunctExtension()); $environment->addExtension(new StrikethroughExtension()); diff --git a/tests/functional/Extension/Highlight/HighlightExtensionTest.php b/tests/functional/Extension/Highlight/HighlightExtensionTest.php new file mode 100644 index 0000000000..a81f2cadec --- /dev/null +++ b/tests/functional/Extension/Highlight/HighlightExtensionTest.php @@ -0,0 +1,81 @@ + and uAfrica.com (http://uafrica.com) + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Tests\Functional\Extension\Highlight; + +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Highlight\HighlightExtension; +use League\CommonMark\Parser\MarkdownParser; +use League\CommonMark\Renderer\HtmlRenderer; +use PHPUnit\Framework\TestCase; + +final class HighlightExtensionTest extends TestCase +{ + /** + * @dataProvider dataForIntegrationTest + */ + public function testMark(string $string, string $expected): void + { + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new HighlightExtension()); + + $parser = new MarkdownParser($environment); + $renderer = new HtmlRenderer($environment); + + $document = $parser->parse($string); + + $html = (string) $renderer->renderDocument($document); + + $this->assertSame($expected, $html); + } + + /** + * @return array> + */ + public static function dataForIntegrationTest(): array + { + return [ + ['Hello, ==world!==', "

Hello, world!

\n"], + ['This is a test without any marks', "

This is a test without any marks

\n"], + ['This is a test with ==valid== marks', "

This is a test with valid marks

\n"], + ['This is a test `with` ==valid== marks', "

This is a test with valid marks

\n"], + ['This is a ==unit== integration test', "

This is a unit integration test

\n"], + ['==Mark== on the left', "

Mark on the left

\n"], + ['Mark on the ==right==', "

Mark on the right

\n"], + ['==Mark everywhere==', "

Mark everywhere

\n"], + ['This ==test has no ending match', "

This ==test has no ending match

\n"], + ['This ==test=== has mismatched equal signs', "

This ==test=== has mismatched equal signs

\n"], + ['This ===test== also has mismatched equal signs', "

This ===test== also has mismatched equal signs

\n"], + ['This one has ===three=== equal signs', "

This one has ===three=== equal signs

\n"], + ["This ==has a\n\nnew paragraph==.", "

This ==has a

\n

new paragraph==.

\n"], + ['Hello == == world', "

Hello == == world

\n"], + ['This **is ==a little** test of mismatched delimiters==', "

This is ==a little test of mismatched delimiters==

\n"], + ['Из: твоя ==тест== ветка', "

Из: твоя тест ветка

\n"], + ['This one combines ==nested ==mark== text==', "

This one combines nested mark text

\n"], + ['Here we have **emphasized text containing a ==mark==**', "

Here we have emphasized text containing a mark

\n"], + ['Four trailing equal signs ====', "

Four trailing equal signs ====

\n"], + ['==Unmatched left', "

==Unmatched left

\n"], + ['Unmatched right==', "

Unmatched right==

\n"], + ['==foo=bar==', "

foo=bar

\n"], + ['==foo==bar==', "

foobar==

\n"], + ['==foo===bar==', "

foo===bar

\n"], + ['==foo====bar==', "

foo====bar

\n"], + ['==foo=====bar==', "

foo=====bar

\n"], + ['==foo======bar==', "

foo======bar

\n"], + ['==foo=======bar==', "

foo=======bar

\n"], + ['> inside a ==blockquote==', "
\n

inside a blockquote

\n
\n"], + ]; + } +} diff --git a/tests/functional/Extension/Highlight/HighlightXmlTest.php b/tests/functional/Extension/Highlight/HighlightXmlTest.php new file mode 100644 index 0000000000..c76f5f2cee --- /dev/null +++ b/tests/functional/Extension/Highlight/HighlightXmlTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Tests\Functional\Extension\Highlight; + +use League\CommonMark\ConverterInterface; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Highlight\HighlightExtension; +use League\CommonMark\Tests\Functional\AbstractLocalDataTestCase; +use League\CommonMark\Xml\MarkdownToXmlConverter; + +final class HighlightXmlTest extends AbstractLocalDataTestCase +{ + /** + * @param array $config + */ + protected function createConverter(array $config = []): ConverterInterface + { + $environment = new Environment($config); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new HighlightExtension()); + + return new MarkdownToXmlConverter($environment); + } + + /** + * {@inheritDoc} + */ + public static function dataProvider(): iterable + { + yield from self::loadTests(__DIR__ . '/xml', '*', '.md', '.xml'); + } +} diff --git a/tests/functional/Extension/Highlight/xml/mark.md b/tests/functional/Extension/Highlight/xml/mark.md new file mode 100644 index 0000000000..961173f381 --- /dev/null +++ b/tests/functional/Extension/Highlight/xml/mark.md @@ -0,0 +1 @@ +This paragraph has ==two words== highlighted. diff --git a/tests/functional/Extension/Highlight/xml/mark.xml b/tests/functional/Extension/Highlight/xml/mark.xml new file mode 100644 index 0000000000..47c3113461 --- /dev/null +++ b/tests/functional/Extension/Highlight/xml/mark.xml @@ -0,0 +1,10 @@ + + + + This paragraph has + + two words + + highlighted. + + diff --git a/tests/unit/Extension/Highlight/MarkRendererTest.php b/tests/unit/Extension/Highlight/MarkRendererTest.php new file mode 100644 index 0000000000..3a5c2c6856 --- /dev/null +++ b/tests/unit/Extension/Highlight/MarkRendererTest.php @@ -0,0 +1,57 @@ + and uAfrica.com (http://uafrica.com) + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Tests\Unit\Extension\Highlight; + +use League\CommonMark\Exception\InvalidArgumentException; +use League\CommonMark\Extension\Highlight\Mark; +use League\CommonMark\Extension\Highlight\MarkRenderer; +use League\CommonMark\Node\Inline\Text; +use League\CommonMark\Tests\Unit\Renderer\FakeChildNodeRenderer; +use League\CommonMark\Util\HtmlElement; +use PHPUnit\Framework\TestCase; + +final class MarkRendererTest extends TestCase +{ + private MarkRenderer $renderer; + + protected function setUp(): void + { + $this->renderer = new MarkRenderer(); + } + + public function testRender(): void + { + $inline = new Mark('=='); + $inline->data->set('attributes/id', 'some"&id'); + $fakeRenderer = new FakeChildNodeRenderer(); + $fakeRenderer->pretendChildrenExist(); + + $result = $this->renderer->render($inline, $fakeRenderer); + + $this->assertTrue($result instanceof HtmlElement); + $this->assertEquals('mark', $result->getTagName()); + $this->assertStringContainsString('::children::', $result->getContents(true)); + $this->assertEquals(['id' => 'some"&id'], $result->getAllAttributes()); + } + + public function testRenderWithInvalidNodeType(): void + { + $this->expectException(InvalidArgumentException::class); + + $inline = new Text('ruh roh'); + $fakeRenderer = new FakeChildNodeRenderer(); + + $this->renderer->render($inline, $fakeRenderer); + } +} diff --git a/tests/unit/Extension/Highlight/MarkTest.php b/tests/unit/Extension/Highlight/MarkTest.php new file mode 100644 index 0000000000..3bdc92f7e6 --- /dev/null +++ b/tests/unit/Extension/Highlight/MarkTest.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\Tests\Unit\Extension\Highlight; + +use League\CommonMark\Extension\Highlight\Mark; +use PHPUnit\Framework\TestCase; + +final class MarkTest extends TestCase +{ + public function testEmptyConstructor(): void + { + $emphasis = new Mark(); + $this->assertSame('==', $emphasis->getOpeningDelimiter()); + $this->assertSame('==', $emphasis->getClosingDelimiter()); + } + + public function testConstructor(): void + { + $emphasis = new Mark('==='); + $this->assertSame('===', $emphasis->getOpeningDelimiter()); + $this->assertSame('===', $emphasis->getClosingDelimiter()); + } +}