Normalize Headings Extension

This extension rewrites the heading levels in your document so that the resulting HTML is valid and sits wherever you need it to.

It always repairs headings which skip a level - an <h1> followed by an <h3> is invalid HTML, and becomes an <h1> followed by an <h2>. Documents whose headings are already valid are left alone.

It can also constrain headings to a configured range of levels. For example, if you configure the allowed levels as 2 through 4:

And it can optionally rebase each document so that its headings always begin at that lowest level, regardless of which level the author started at.

Installation

This extension is bundled with league/commonmark. This library can be installed via Composer:

composer require league/commonmark

See the installation section for more details.

Usage

This extension can be added to any new Environment:

use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\NormalizeHeadings\NormalizeHeadingsExtension;
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 = [
    'normalize_headings' => [
        'min_level' => 1,
        'max_level' => 6,
        'rebase_to_min_level' => false,
    ],
];

// Configure the Environment with all the CommonMark parsers/renderers
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());

// Add this extension
$environment->addExtension(new NormalizeHeadingsExtension());

// Instantiate the converter engine and start converting some Markdown!
$converter = new MarkdownConverter($environment);
echo $converter->convert("# Heading 1\n\n## Heading 2");

Repairing Skipped Levels

The W3C validator reports an error when a heading descends by more than one level at a time - an <h1> may be followed by an <h2>, but not by an <h3>. (Ascending by any amount is fine.)

This extension always rewrites headings so that each one descends at most one level below the heading it’s nested under. A heading’s new level is based on how deeply it’s nested, so headings which were siblings in the original document remain siblings:

# Header 1

#### Header 4a

#### Header 4b

# Header 1a

### Header 3

produces <h1>, <h2>, <h2>, <h1>, <h2>.

Documents whose headings are already valid come out unchanged.

Configuration

This extension can be configured by providing a normalize_headings array with three nested options.

min_level and max_level

These two settings control the allowed heading range. By default, all six levels (1 to 6) are allowed. The min_level value must be less than or equal to max_level.

When a heading falls outside the configured range, it is converted to the nearest boundary:

Setting min_level and max_level to the same value will therefore flatten every heading to that level.

rebase_to_min_level

By default, top-level headings keep the level they were written at, so a document which deliberately starts at <h3> stays there and only its skipped levels are repaired. This respects an author who starts at <h2> because the surrounding page template already provides the <h1>.

When rebase_to_min_level is enabled, top-level headings are moved to min_level instead, so that every document begins at a known level regardless of how it was written. Consider a document which starts at <h3>:

### Introduction

##### A Nested Heading

### Conclusion

With the default min_level of 1:

rebase_to_min_level Output
false (default) <h3>, <h4>, <h3>
true <h1>, <h2>, <h1>

This works in both directions: with a min_level of 2, a document starting at <h1> would be moved down so that its top-level headings become <h2>.


Edit this page