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;
+ }
+}