diff --git a/CHANGELOG.md b/CHANGELOG.md index af103ff6e4..8f0bf9a5f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi ## [Unreleased][unreleased] +### Added + +- Added a new `LimitHeadingsExtension` to constrain headings to a configured level range (#989) + ## [2.8.2] - 2026-03-19 This is a **security release** to address an issue where the `allowed_domains` setting for the `Embed` extension can be bypassed, resulting in a possible SSRF and XSS vulnerabilities. diff --git a/docs/2.x/extensions/limit-headings.md b/docs/2.x/extensions/limit-headings.md new file mode 100644 index 0000000000..5e5e2df64f --- /dev/null +++ b/docs/2.x/extensions/limit-headings.md @@ -0,0 +1,71 @@ +--- +layout: default +title: Limit Headings Extension +description: The LimitHeadingsExtension constrains heading levels to a configured range +redirect_from: + - /extensions/limit-headings/ +--- + +# Limit Headings Extension + +This extension lets you constrain heading levels to a configured range. + +For example, if you configure the allowed levels as `2` through `4`: + +- `#` headings (`

`) will be converted to `

` +- `#####` and `######` headings (`

` / `
`) will be converted to `

` + +## 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\LimitHeadings\LimitHeadingsExtension; +use League\CommonMark\MarkdownConverter; + +// Extension defaults are shown below +// If you're happy with the defaults, feel free to remove them from this array +$config = [ + 'limit_headings' => [ + 'min_heading_level' => 1, + 'max_heading_level' => 6, + ], +]; + +// Configure the Environment with all the CommonMark parsers/renderers +$environment = new Environment($config); +$environment->addExtension(new CommonMarkCoreExtension()); + +// Add this extension +$environment->addExtension(new LimitHeadingsExtension()); + +// Instantiate the converter engine and start converting some Markdown! +$converter = new MarkdownConverter($environment); +echo $converter->convert("# Heading 1\n\n## Heading 2"); +``` + +## Configuration + +This extension can be configured by providing a `limit_headings` array with two nested options. + +### `min_heading_level` and `max_heading_level` + +These two settings control the allowed heading range. By default, all six levels (`1` to `6`) are allowed. +The `min_heading_level` value must be less than or equal to `max_heading_level`. + +When a parsed heading level falls outside the configured range, it is converted to the nearest boundary: + +- below `min_heading_level` -> `min_heading_level` +- above `max_heading_level` -> `max_heading_level` diff --git a/docs/2.x/extensions/overview.md b/docs/2.x/extensions/overview.md index 8050986b1d..fd1ec1fd78 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. | [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` | | +| [Limit Headings] | Constrains heading levels to a configured min/max range | `2.9.0` | | | [Mentions] | Easy parsing of `@mention` and `#123`-style references | `1.5.0` | | | [Strikethrough] | Allows using tilde characters (`~~`) for ~strikethrough~ formatting | `1.3.0` | | | [Tables] | Enables you to create HTML tables | `1.3.0` | | @@ -122,6 +123,7 @@ See the [Custom Extensions](/2.x/customization/extensions/) page for details on [Heading Permalinks]: /2.x/extensions/heading-permalinks/ [Highlight]: /2.x/extensions/highlight/ [Inlines Only]: /2.x/extensions/inlines-only/ +[Limit Headings]: /2.x/extensions/limit-headings/ [Mentions]: /2.x/extensions/mentions/ [Strikethrough]: /2.x/extensions/strikethrough/ [Tables]: /2.x/extensions/tables/ diff --git a/docs/_data/menu.yml b/docs/_data/menu.yml index a7b482b6b3..887afa886b 100644 --- a/docs/_data/menu.yml +++ b/docs/_data/menu.yml @@ -27,6 +27,7 @@ version: 'Heading Permalinks': '/2.x/extensions/heading-permalinks/' 'Highlight': '/2.x/extensions/highlight/' 'Inlines Only': '/2.x/extensions/inlines-only/' + 'Limit Headings': '/2.x/extensions/limit-headings/' 'Mentions': '/2.x/extensions/mentions/' 'Smart Punctuation': '/2.x/extensions/smart-punctuation/' 'Strikethrough': '/2.x/extensions/strikethrough/' diff --git a/src/Extension/LimitHeadings/LimitHeadingsExtension.php b/src/Extension/LimitHeadings/LimitHeadingsExtension.php new file mode 100644 index 0000000000..304dd59a76 --- /dev/null +++ b/src/Extension/LimitHeadings/LimitHeadingsExtension.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\LimitHeadings; + +use League\CommonMark\Environment\EnvironmentBuilderInterface; +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\ConfigurableExtensionInterface; +use League\Config\ConfigurationBuilderInterface; +use Nette\Schema\Expect; + +final class LimitHeadingsExtension implements ConfigurableExtensionInterface +{ + public function configureSchema(ConfigurationBuilderInterface $builder): void + { + $builder->addSchema('limit_headings', Expect::structure([ + 'min_heading_level' => Expect::int()->min(1)->max(6)->default(1), + 'max_heading_level' => Expect::int()->min(1)->max(6)->default(6), + ])->assert( + static function (\stdClass $config): bool { + $headingLevels = (array) $config; + + return $headingLevels['min_heading_level'] <= $headingLevels['max_heading_level']; + }, + '"min_heading_level" must be less than or equal to "max_heading_level"' + )); + } + + public function register(EnvironmentBuilderInterface $environment): void + { + $environment->addEventListener(DocumentParsedEvent::class, new LimitHeadingsProcessor(), -99); + } +} diff --git a/src/Extension/LimitHeadings/LimitHeadingsProcessor.php b/src/Extension/LimitHeadings/LimitHeadingsProcessor.php new file mode 100644 index 0000000000..2229500bd7 --- /dev/null +++ b/src/Extension/LimitHeadings/LimitHeadingsProcessor.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\LimitHeadings; + +use League\CommonMark\Environment\EnvironmentAwareInterface; +use League\CommonMark\Environment\EnvironmentInterface; +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\CommonMark\Node\Block\Heading; +use League\CommonMark\Node\NodeIterator; +use League\Config\ConfigurationInterface; + +final class LimitHeadingsProcessor implements EnvironmentAwareInterface +{ + /** @psalm-readonly-allow-private-mutation */ + private ConfigurationInterface $config; + + public function setEnvironment(EnvironmentInterface $environment): void + { + $this->config = $environment->getConfiguration(); + } + + public function __invoke(DocumentParsedEvent $event): void + { + $minHeadingLevel = (int) $this->config->get('limit_headings/min_heading_level'); + $maxHeadingLevel = (int) $this->config->get('limit_headings/max_heading_level'); + + foreach ($event->getDocument()->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { + if (! $node instanceof Heading) { + continue; + } + + if ($node->getLevel() < $minHeadingLevel) { + $node->setLevel($minHeadingLevel); + } elseif ($node->getLevel() > $maxHeadingLevel) { + $node->setLevel($maxHeadingLevel); + } + } + } +} diff --git a/tests/functional/Extension/LimitHeadings/LimitHeadingsExtensionTest.php b/tests/functional/Extension/LimitHeadings/LimitHeadingsExtensionTest.php new file mode 100644 index 0000000000..c052fa6ff8 --- /dev/null +++ b/tests/functional/Extension/LimitHeadings/LimitHeadingsExtensionTest.php @@ -0,0 +1,101 @@ + + * + * 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\LimitHeadings; + +use League\CommonMark\ConverterInterface; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Exception\CommonMarkException; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension; +use League\CommonMark\Extension\LimitHeadings\LimitHeadingsExtension; +use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension; +use League\CommonMark\MarkdownConverter; +use League\CommonMark\Tests\Functional\AbstractLocalDataTestCase; +use League\Config\Exception\ConfigurationExceptionInterface; +use League\Config\Exception\InvalidConfigurationException; + +final class LimitHeadingsExtensionTest extends AbstractLocalDataTestCase +{ + /** + * @param array $config + */ + protected function createConverter(array $config = []): ConverterInterface + { + $environment = new Environment($config); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new LimitHeadingsExtension()); + + return new MarkdownConverter($environment); + } + + /** + * {@inheritDoc} + */ + public static function dataProvider(): iterable + { + yield from self::loadTests(__DIR__ . '/md'); + } + + /** + * @throws CommonMarkException + * @throws \PHPUnit\Framework\ExpectationFailedException + */ + public function testRunsBeforeTableOfContentsAndHeadingPermalinks(): void + { + $environment = new Environment([ + 'limit_headings' => [ + 'min_heading_level' => 2, + 'max_heading_level' => 2, + ], + 'table_of_contents' => [ + 'min_heading_level' => 2, + 'max_heading_level' => 2, + ], + 'heading_permalink' => [ + 'min_heading_level' => 2, + 'max_heading_level' => 2, + ], + ]); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new LimitHeadingsExtension()); + $environment->addExtension(new HeadingPermalinkExtension()); + $environment->addExtension(new TableOfContentsExtension()); + + $converter = new MarkdownConverter($environment); + + $input = '# Clamped Heading'; + $expected = ' +

Clamped Heading

'; + + $this->assertSame($expected, \trim((string) $converter->convert($input))); + } + + /** + * @throws CommonMarkException + * @throws ConfigurationExceptionInterface + */ + public function testThrowsExceptionWhenMinHeadingLevelExceedsMaxHeadingLevel(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->createConverter([ + 'limit_headings' => [ + 'min_heading_level' => 4, + 'max_heading_level' => 2, + ], + ])->convert('# This should fail'); + } +} diff --git a/tests/functional/Extension/LimitHeadings/md/clamps-levels.html b/tests/functional/Extension/LimitHeadings/md/clamps-levels.html new file mode 100644 index 0000000000..0b284818c6 --- /dev/null +++ b/tests/functional/Extension/LimitHeadings/md/clamps-levels.html @@ -0,0 +1,6 @@ +

Level 1

+

Level 2

+

Level 3

+

Level 4

+

Level 5

+

Level 6

diff --git a/tests/functional/Extension/LimitHeadings/md/clamps-levels.md b/tests/functional/Extension/LimitHeadings/md/clamps-levels.md new file mode 100644 index 0000000000..818ee0666c --- /dev/null +++ b/tests/functional/Extension/LimitHeadings/md/clamps-levels.md @@ -0,0 +1,17 @@ +--- +limit_headings: + min_heading_level: 2 + max_heading_level: 4 +--- + +# Level 1 + +## Level 2 + +### Level 3 + +#### Level 4 + +##### Level 5 + +###### Level 6 diff --git a/tests/functional/Extension/LimitHeadings/md/clamps-setext-and-atx.html b/tests/functional/Extension/LimitHeadings/md/clamps-setext-and-atx.html new file mode 100644 index 0000000000..6589996826 --- /dev/null +++ b/tests/functional/Extension/LimitHeadings/md/clamps-setext-and-atx.html @@ -0,0 +1,4 @@ +

Setext Level 1

+

Setext Level 2

+

ATX Level 3

+

ATX Level 4

diff --git a/tests/functional/Extension/LimitHeadings/md/clamps-setext-and-atx.md b/tests/functional/Extension/LimitHeadings/md/clamps-setext-and-atx.md new file mode 100644 index 0000000000..3e33b5e252 --- /dev/null +++ b/tests/functional/Extension/LimitHeadings/md/clamps-setext-and-atx.md @@ -0,0 +1,15 @@ +--- +limit_headings: + min_heading_level: 2 + max_heading_level: 3 +--- + +Setext Level 1 +============== + +Setext Level 2 +-------------- + +### ATX Level 3 + +#### ATX Level 4 diff --git a/tests/unit/Extension/LimitHeadings/LimitHeadingsProcessorTest.php b/tests/unit/Extension/LimitHeadings/LimitHeadingsProcessorTest.php new file mode 100644 index 0000000000..9f4186b08b --- /dev/null +++ b/tests/unit/Extension/LimitHeadings/LimitHeadingsProcessorTest.php @@ -0,0 +1,112 @@ + + * + * 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\LimitHeadings; + +use League\CommonMark\Environment\Environment; +use League\CommonMark\Environment\EnvironmentInterface; +use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Extension\CommonMark\Node\Block\Heading; +use League\CommonMark\Extension\LimitHeadings\LimitHeadingsExtension; +use League\CommonMark\Extension\LimitHeadings\LimitHeadingsProcessor; +use League\CommonMark\Node\Block\Document; +use League\CommonMark\Node\NodeIterator; +use League\Config\Exception\InvalidConfigurationException; +use PHPUnit\Framework\TestCase; + +final class LimitHeadingsProcessorTest extends TestCase +{ + /** + * @throws \PHPUnit\Framework\ExpectationFailedException + */ + public function testClampsHeadingLevelsWithinConfiguredRange(): void + { + $processor = new LimitHeadingsProcessor(); + $processor->setEnvironment($this->createEnvironment([ + 'limit_headings' => [ + 'min_heading_level' => 2, + 'max_heading_level' => 4, + ], + ])); + + $document = new Document(); + foreach ([1, 2, 3, 4, 5, 6] as $level) { + $document->appendChild(new Heading($level)); + } + + $processor(new DocumentParsedEvent($document)); + + $levels = []; + foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { + if ($node instanceof Heading) { + $levels[] = $node->getLevel(); + } + } + + $this->assertSame([2, 2, 3, 4, 4, 4], $levels); + } + + /** + * @throws \PHPUnit\Framework\ExpectationFailedException + */ + public function testLeavesHeadingsUntouchedWithDefaultConfig(): void + { + $processor = new LimitHeadingsProcessor(); + $processor->setEnvironment($this->createEnvironment()); + + $document = new Document(); + foreach ([1, 2, 3, 4, 5, 6] as $level) { + $document->appendChild(new Heading($level)); + } + + $processor(new DocumentParsedEvent($document)); + + $levels = []; + foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { + if ($node instanceof Heading) { + $levels[] = $node->getLevel(); + } + } + + $this->assertSame([1, 2, 3, 4, 5, 6], $levels); + } + + public function testThrowsExceptionWhenMinHeadingLevelExceedsMaxHeadingLevel(): void + { + $this->expectException(InvalidConfigurationException::class); + + $processor = new LimitHeadingsProcessor(); + $processor->setEnvironment($this->createEnvironment([ + 'limit_headings' => [ + 'min_heading_level' => 4, + 'max_heading_level' => 2, + ], + ])); + + $document = new Document(); + $document->appendChild(new Heading(3)); + + $processor(new DocumentParsedEvent($document)); + } + + /** + * @param array $values + */ + private function createEnvironment(array $values = []): EnvironmentInterface + { + $environment = new Environment($values); + $environment->addExtension(new LimitHeadingsExtension()); + + return $environment; + } +}