0043. A heading carries an id made from its words, and a table of contents is a block the author places¶
Status: Accepted
Date: 2026-11-01
Context¶
Before this record a page carried no id at all. docs/rendering.md section 2 said that a block's id is never emitted and that no page ever carries an identifier an author did not write, and section 6 listed the block's id among what is never emitted; GoldenRendererTest asserted that no fixture's HTML holds an id attribute. A long document therefore had no link target inside it, and nothing could build a table of contents from it.
Sites moving from CKEditor rely on in-page targets. anchor_link, installed on about 35,000 sites, writes <a id> targets; toc_filter and toc_api build a table of contents from a page's headings and give each one an id; core's own Basic HTML format allows <h2 id>. The HTML converter (0038) drops a heading's id and an <a id> without reporting either, as docs/converting.md section 6 says, so a converted page loses every target a link with a fragment pointed at, and nobody is told.
docs/rendering.md section 11 counts emitting anything section 6 lists as a breaking change. Adding ids after 1.0.0 would break the contract's own promise; before it, the contract may change freely.
Three facts about the code bound the design. A block plugin's build($block, $context) (0005) sees its own block and the BlockRenderContext, never the rest of the document, so neither a heading that has to number a repeat of an earlier heading's words nor a table of contents that has to list headings after it could be written against the contract as it stood. DocumentRenderer::buildBlocks() builds the blocks of one list in order, filtering out anything that is not an array, and skips a block whose type no plugin provides or the format does not enable, children included; every other block builds its children through the context, while it is being built. And core's transliteration service (Drupal\Component\Transliteration\TransliterationInterface::transliterate()) turns text in any script into ASCII, with per-language overrides, such as German รค becoming ae, read from the language it is given.
Decision¶
Every heading the page renders carries an id generated at render from its text. The text is the plain text of the heading's inline content, every run of it, a link's and a custom inline item's own words included, as InlineContentRenderer::plainText() reads it. The id is that text transliterated to ASCII by core's transliteration service in the language the document is rendered in, a character it has no ASCII for counting as a separator; lowercased; with every run of characters other than a to z and 0 to 9 joined into one hyphen and a hyphen at either end dropped; and cut at 60 characters, a hyphen the cut leaves at the end dropped too. A heading whose text leaves nothing is heading. There is no prefix. \Drupal\blocknote\Render\HeadingIds::slug() is the rule.
A repeat within one document is numbered. Headings are taken in document order, a block before its children, and an id already taken by an earlier heading is followed by -2, or by the first of -3, -4 and on that is free, so a heading whose own words give intro-2 counts as taking it. The headings counted are the ones the page renders: a heading inside a block the renderer skips, because no plugin provides its type or the format does not enable it, is not on the page and takes no id.
The id goes on the heading element itself, after its class: <h2 class="blocknote-block blocknote-block--heading" id="intro">, and inside a collapsible heading's disclosure, on the <hN> in the <summary>, not on the <details>. The heading block's template prints it from attributes in the plain shape and from content_attributes in the disclosure, which carries the blocknote-heading class there too.
Ids are computed per translation. They come from the words a page renders and the language it renders them in, so each translation renders its own; nothing is stored, and two documents holding the same words in the same language still produce the same HTML.
The plugin contract grows by one member and one method. DocumentRenderer::build() walks the document once before it builds any block, with the same skip rule it builds by, and hands the result to the context: BlockRenderContext::$outline is a DocumentOutline, whose headings() lists the headings the page renders, in document order, each an OutlineHeading with its level, its text with whitespace collapsed and trimmed, and its id. BlockRenderContext::headingId() answers the id of the heading being built, which the renderer knows because it records the position of the block it is building, and NULL in a context that was not built by DocumentRenderer::build() or for a block that is not a heading. docs/extending.md section 1 documents both. Amended 2026-11-01. It grows by a second method, BlockRenderContext::buildChildren(), which builds the children of the block being built, whole and in order, and is how a plugin renders them: the outline knows a heading by its position among its block's children, so a plugin that handed buildBlocks() a part of them, or reordered them, gave their headings the ids of others. buildBlocks() stays, and must be given a block's whole children list in order. Amended 2026-11-02. Which blocks are the outline's headings is their plugins' to say, where the walk took every block of type heading and read its level from Heading::renderedLevel(), a path no other block could take (0005). A plugin implementing BlockNoteBlockOutlineEntryInterface answers outlineEntry($block, $inline_content) with a level and a text, or NULL for a block that is no heading, and the walk asks every block that renders whose plugin implements it; Heading is one such plugin, answering as this record describes a heading's level and text.
A table of contents is a block the author places. The base module's tableOfContents block holds no content and declares no properties. It renders <nav class="blocknote-block blocknote-block--table-of-contents" aria-label="Table of contents"> holding a <ul> of the outline's headings that have text, each an <li> holding <a href="#id"> with the heading's plain text, never its styled content, because a heading may hold a link and a link may not sit inside another. Every level is listed, and a heading is nested, in a <ul> inside the <li>, under the closest heading before it of a lower level, so skipping a level nests by one and a document that starts below its top level keeps the shallower heading that follows at the top. The aria-label is translated into the language the document is rendered in. The list's own elements carry no class. Blocks nested under it follow the </nav>. A document with no heading that has text gives the block nothing to list, and it renders nothing of its own, logged at debug level, with its children rendered in its place. Where the author places it is where it renders. Amended 2026-11-02. A document holds one, enforced as the author edits (0010): the slash menu offers no table of contents while the document holds one, and a save refuses every one after the first, as table_of_contents_repeated in the block's own words (0023). Each lists every heading, so a document of 2,400 blocks alternating between the two rendered 45 MB of HTML inside the document limits. BlockValidationContext::$occurrence, which block of its type a block is in the document, is how the plugin knows. A document stored with more, without being validated, lists the headings in the first only (amended 2026-11-02): BlockRenderContext::claimFirst() answers TRUE once per block type in a document, in build order, and a later table of contents renders the blocks nested under it in its place, logged at debug level.
The editor draws it as a read-only box that names itself. The client specification is registered by the editor bundle against its own runtime the way the content link is, so the plugin declares no library, and is offered in the slash menu. It draws no control, so it needs no keyboard route of its own beyond the block selection every block without content has (0011); it does not preview the headings.
The shipped blocknote format enables it, as it enables every block the base module provides; the blocknote_snippet format, which holds one paragraph (0030), does not.
Author-named anchors stay out. There is no inline content type, block or property that lets an author name a target.
The converter reports a heading id it drops when it differs from the one the page generates. HeadingRule notes the id a heading element carried, and once the document's blocks are final the converter computes their outline the way the renderer will, in the language of the entity it reads from, and reports heading_id_not_carried, degraded, keyed by the old id, for every heading whose old id is not the generated one and for every heading that did not stay a heading. An <a id> and an id on any other element are still dropped without a report, as docs/converting.md section 6 says. Amended 2026-11-01. An <a>'s id or name is reported too, as anchor_not_carried, degraded, keyed by the id or the name, unless the page gives a heading the same id, which a link with that fragment then still reaches, so that a site sees every old fragment that breaks; an id on any other element is still dropped without a report. Amended 2026-11-01. A conversion that names no entity, as drush blocknote:convert-html without --entity, computes the outline in the site's default language, which a page renders such a document in until it belongs to an entity in another.
The contract changes with this record: docs/rendering.md sections 2, 3, 5, 6, 7, 10 and 11 say what a heading and a table of contents render as, and the golden fixtures pin the ids.
Alternatives considered¶
A prefix on every id, such as blocknote- or h-, to avoid clashing with an id the theme writes. Rejected. It makes every fragment an author or a visitor copies longer and uglier, and a theme's own ids, such as main-content or block-olivero-content, are unlikely to be the slug of a heading's words.
Ids stored in the document, written by the editor. Rejected. A stored id goes stale the moment the author changes the words, the editor would need a way to show and edit it, which is an author-named anchor by another route, and two documents with the same words would no longer produce the same HTML.
Ids taken from the source language's words, so that every translation shares them. Rejected. Each translation renders its own words in its own language, and an id spelled in another language's words is a fragment no reader of that page would guess.
A table of contents as a formatter option. Rejected. A formatter option puts the list at one place for every document a field holds; a block lets the author decide where it goes, and whether a document has one at all.
Author-named anchors as custom inline content, now that 0027 gives inline content the same terms as a block. Left out. Generated ids cover the use the sites above make of anchors, a link to a section, and an anchor an author names is markup with no visible content, which nothing else in the editor has.
Matching a heading to its id by the order in which headings are built, instead of by the position of the block being built. Rejected. A plugin of another module that does not build its children would shift every later heading onto the id of the one it dropped. With the position, the cost of such a plugin is limited to a link in the table of contents that points at a heading the page does not show.
Building the table of contents after the rest of the document, from the ids the headings actually took. Rejected. A render array is built before anything is rendered, so it would need a placeholder or a callback run at render time, and the walk before building gives the same answer with neither.
An ordered list, <ol>. Rejected. A browser numbers an <ol>, and numbers in the list that the headings themselves do not carry would describe a document that does not exist.
Consequences¶
The public HTML changes: every heading gains an id, so the expected HTML of every golden fixture that holds a heading changes with this record. A theme that overrode blocknote-block--heading.html.twig keeps its own markup and emits no id until it prints attributes and content_attributes the way the shipped template does.
An id is as stable as the heading's words and nothing more. Changing a heading's text changes its id, and adding a heading with the same words before it renumbers it, so a link with a fragment breaks when either happens; the editor shows no id and gives no warning. A content link cannot carry a fragment yet (0028), so a link to a heading in another document is a plain link to its address.
Ids are unique within one document, not within a page. Two documents on one page, such as two fields or a listing of teasers, may both hold an intro, and a heading's slug may equal an id the theme writes; a fragment then reaches the first.
A third-party block whose build() does not build its children leaves the headings under it off the page while the outline still counts them, so the table of contents lists a heading the page does not show, and the numbering of later repeats counts it.
Every render walks the document once more and transliterates each heading's text. Both happen while the filter's result is built, so the render cache keeps them with it.
The search index, which follows the public output (0030), indexes a table of contents' entries as well, so a heading's words are indexed twice in a document that has one. The plain_text property and the tokens read the stored document, which holds no entries, and are unchanged.
A converted page reports each heading target it loses. A site that linked to those fragments from elsewhere sees which links now land at the top of the page instead of at the heading; it does not see the targets an <a id> gave, which are still dropped silently. Amended 2026-11-01. It sees those too: an <a>'s id or name is reported unless a heading on the page takes the same id.
References¶
- Drupal core 11.4:
core/lib/Drupal/Component/Transliteration/TransliterationInterface.php,core/lib/Drupal/Component/Transliteration/PhpTransliteration.php, thetransliterationservice incore/core.services.yml. - This module:
src/BlockNoteBlockOutlineEntryInterface.php,src/Render/HeadingIds.php,src/Render/DocumentOutline.php,src/Render/OutlineHeading.php,src/Render/BlockRenderContext.php,src/Render/DocumentRenderer.php,src/Plugin/BlockNoteBlock/Heading.php,src/Plugin/BlockNoteBlock/TableOfContents.php,templates/blocknote-block--heading.html.twig,templates/blocknote-block--table-of-contents.html.twig,js/src/table-of-contents.tsx,src/Convert/Rule/HeadingRule.php,src/Convert/HeadingAnchors.php,src/Convert/HtmlConverter.php. - Tests:
tests/src/Unit/HeadingIdsTest.php,tests/src/Kernel/HeadingIdRenderTest.php,tests/src/Kernel/TableOfContentsRenderTest.php,tests/src/Kernel/GoldenRendererTest.phpandtests/fixtures/documents/headings-and-contents.json,js/src/__tests__/table-of-contents.test.tsx,tests/src/Kernel/Convert/HeadingIdConversionTest.php. - 0004, 0005, 0011, 0027, 0028, 0030, 0038.