Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 71 additions & 0 deletions docs/2.x/extensions/limit-headings.md
Original file line number Diff line number Diff line change
@@ -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 (`<h1>`) will be converted to `<h2>`
- `#####` and `######` headings (`<h5>` / `<h6>`) will be converted to `<h4>`

## 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`
2 changes: 2 additions & 0 deletions docs/2.x/extensions/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | <i class="fab fa-github"></i> |
| [Tables] | Enables you to create HTML tables | `1.3.0` | <i class="fab fa-github"></i> |
Expand Down Expand Up @@ -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/
Expand Down
1 change: 1 addition & 0 deletions docs/_data/menu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down
43 changes: 43 additions & 0 deletions src/Extension/LimitHeadings/LimitHeadingsExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* 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);
}
}
50 changes: 50 additions & 0 deletions src/Extension/LimitHeadings/LimitHeadingsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.com>
*
* 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

/*
* This file is part of the league/commonmark package.
*
* (c) Colin O'Dell <colinodell@gmail.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\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<string, mixed> $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 = '<ul class="table-of-contents">
<li><a href="#content-clamped-heading">Clamped Heading</a></li>
</ul>
<h2><a id="content-clamped-heading" href="#content-clamped-heading" class="heading-permalink" aria-hidden="true" title="Permalink">¶</a>Clamped Heading</h2>';

$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');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<h2>Level 1</h2>
<h2>Level 2</h2>
<h3>Level 3</h3>
<h4>Level 4</h4>
<h4>Level 5</h4>
<h4>Level 6</h4>
17 changes: 17 additions & 0 deletions tests/functional/Extension/LimitHeadings/md/clamps-levels.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<h2>Setext Level 1</h2>
<h2>Setext Level 2</h2>
<h3>ATX Level 3</h3>
<h3>ATX Level 4</h3>
Original file line number Diff line number Diff line change
@@ -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
Loading