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 arrayHello, 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
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
\nnew 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\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 arrayinside a blockquote
\n