Skip to content

Headings and cross-references

Section wrapping, id derivation and normalization, implicit and collapsed heading references.

Generated from resources/examples/edge-cases.md, resources/examples/core.md and resources/examples/extensions.md - edit the cases there, not here. Each case links the conformance fixture it produces.

Single-line headings

5 conformance fixtures

A heading ends at the newline. Nothing folds into it: the next line begins whatever block it begins, exactly as after any other closed block. This diverges from djot deliberately — djot folds a following plain line into the heading, which is a silent corruption for anyone arriving from Markdown, and divergence-from-djot §7 already broke from djot on the mirror case. A ^ … caption line is no exception: it does not fold in, and it does not attach either, because a heading is not one of §4's captionable hosts - it opens an ordinary paragraph. The heading id is built from the single line. (Setext underline headings remain intentionally excluded.)

carve
# Title
outside
html
<section id="Title">
  <h1>Title</h1>
  <p>outside</p>
</section>

Repeated headings are simply separate headings — the # count no longer decides whether one folds into another.

carve
## A
## still A
# B
html
<section id="A">
  <h2>A</h2>
</section>
<section id="still-A">
  <h2>still A</h2>
</section>
<section id="B">
  <h1>B</h1>
</section>

A list marker — bullet or ordered — ends the heading and starts a sibling list.

carve
# Title
- item
html
<section id="Title">
  <h1>Title</h1>
  <ul>
    <li>item</li>
  </ul>
</section>

An ordered marker ends the heading the same way (symmetric with the bullet).

carve
# Title
1. one
html
<section id="Title">
  <h1>Title</h1>
  <ol>
    <li>one</li>
  </ol>
</section>

A marker followed by whitespace only is not a heading — the content after the required space must carry at least one non-whitespace character, so the trailing spaces leave the line as paragraph text.

carve
#
html
<p>#</p>

Heading marker column zero

2 conformance fixtures

A heading marker must sit at column 0; an indented #-line is paragraph text — carve does not accept CommonMark's 0-3 space indent. (Within a container the column is measured after the container markers, so > # H is still a quoted heading.)

carve
   # H
html
<p># H</p>

An indented marker with more hashes is likewise paragraph text, not a heading.

carve
  ## H
html
<p>## H</p>

Cyclic cross-reference resolves to one level

3 conformance fixtures

A </#id> cross-reference resolves to ONE level: it links to the target and adopts the target's text, flattening any nested cross-reference in that text (PART 9 §19). This makes a self-reference or a mutual cycle safe -- no infinite expansion.

A self-reference resolves once:

carve
# A </#a>
html
<section id="A">
  <h1>A <a href="#A">A </a></h1>
</section>

A mutual cycle resolves to one level on each side:

carve
# A </#b>

# B </#a>
html
<section id="A">
  <h1>A <a href="#B">B </a></h1>
</section>
<section id="B">
  <h1>B <a href="#A">A </a></h1>
</section>

A normal (non-cyclic) cross-reference still resolves:

carve
# Intro

See </#intro>.
html
<section id="Intro">
  <h1>Intro</h1>
  <p>See <a href="#Intro">Intro</a>.</p>
</section>

Only the id hoists to the section wrapper

1 conformance fixture

On a top-level heading the id moves to the <section> and every other attribute stays on the <h*> - identically whether the id was slugged from the heading text or written as {#id}. Worth pinning because djot resolved the same question the other way (all attributes migrate) and then implemented that resolution only for the explicit-id case, so its two cases disagree (jgm/djot.js#144). Carve's agree, and that is what keeps the rule stable when the wrapper is switched off: the id returns to the <h*> and nothing else moves.

carve
{a=b .c}
# Auto slug

{a=b .c #explicit}
# Written id
html
<section id="Auto-slug">
  <h1 a="b" class="c">Auto slug</h1>
</section>
<section id="explicit">
  <h1 a="b" class="c">Written id</h1>
</section>

Headings inside containers are not wrapped

1 conformance fixture

A <section> models this document's own outline, so only top-level headings open one. A heading inside a blockquote, div, or list item emits a bare <h*> with its id on the heading itself. The ids are still assigned, still share the one document-order dedup namespace with top-level headings, and are still </#id> crossref targets - only the wrapper and the id's emission site differ.

carve
> # Quoted
>
> Quoted body.

:::
# Divved
:::

- # In an item

  Item body.
html
<blockquote>
  <h1 id="Quoted">Quoted</h1>
  <p>Quoted body.</p>
</blockquote>
<div>
  <h1 id="Divved">Divved</h1>
</div>
<ul>
  <li>
    <h1 id="In-an-item">In an item</h1>
    <p>Item body.</p>
  </li>
</ul>

Attribute order on an unwrapped heading

1 conformance fixture

A heading that carries no <section> wrapper emits its id on the <h*>, which puts a generated attribute next to authored ones. The author's order is never rearranged; the engine-minted id joins at the end. An id the author wrote is not generated, so it keeps its authored position instead.

Worth pinning because the combination was previously unreachable except through a container, and no case gave such a heading attributes - so all three engines picked different answers here and every one of them stayed green (PART 10 §1).

carve
> {a=b .c}
> # Auto

> {#x a=b}
> # Written

:::
{a=b .c}
# Divved
:::
html
<blockquote>
  <h1 a="b" class="c" id="Auto">Auto</h1>
</blockquote>
<blockquote>
  <h1 id="x" a="b">Written</h1>
</blockquote>
<div>
  <h1 a="b" class="c" id="Divved">Divved</h1>
</div>

Implicit heading references with no definition

1 conformance fixture

A [text][] that matches no link definition falls back to the document's headings by their rendered text (PART 11 R1). The match is looser than the exact, case-sensitive link-definition match in the same rule: it trims, collapses whitespace and folds case, because a definition label is an identifier the author wrote twice while a heading reference is prose quoted from elsewhere in the document.

A heading under a blockquote is declined - quoted text names the quoted document's headings, not this one's - while a list item resolves, because that is the author's own grouping. An unmatched label stays literal, and a real link definition wins the tie.

Worth pinning because every case that existed paired [X][] with an [X]: url definition, so the fallback branch had no coverage at all and the executable spec had never implemented it (carve#453).

carve
# Getting Started

See [getting started][] and [Missing][].

> # Quoted

See [Quoted][].

- # In an item

See [In an item][].

# Defined

[Defined]: /wins

See [Defined][].
html
<section id="Getting-Started">
  <h1>Getting Started</h1>
  <p>See <a href="#Getting-Started">getting started</a> and [Missing][].</p>
  <blockquote>
    <h1 id="Quoted">Quoted</h1>
  </blockquote>
  <p>See [Quoted][].</p>
  <ul>
    <li>
      <h1 id="In-an-item">In an item</h1>
    </li>
  </ul>
  <p>See <a href="#In-an-item">In an item</a>.</p>
</section>
<section id="Defined">
  <h1>Defined</h1>
  <p>See <a href="/wins">Defined</a>.</p>
</section>

A heading id keeps a non-ASCII space

1 conformance fixture

The id is the heading's text with each run of non-alphanumeric ASCII replaced by -; non-ASCII characters pass through unchanged. A no-break space in the text is non-ASCII, so it survives into the id rather than becoming a separator - and the id carries the character itself, not an entity. (The marker's own separator must be an ASCII space: # Title is a paragraph, not a heading.)

carve
#  Title
html
<section id=" Title">
  <h1>&nbsp;Title</h1>
</section>

A heading reference folds Unicode normalization, but not compatibility

1 conformance fixture

The heading index is matched loosely on purpose (PART 9R R1): trimmed, internal whitespace collapsed, NFC-normalized, then compared case-insensitively. NFC has to be in that list because the ID side already normalizes (§25) - without it a document publishes id="Café" and then declines [Café][] against the very heading that produced it, and the two spellings look identical on screen so the miss has no visible cause. The heading below is written Cafe + U+0301 and the reference is precomposed U+00E9.

Compatibility folding is NOT in the list: [file][] does not reach # file. NFKC would change which text the author is quoting rather than how it is spelled. carve-rs folded NFC and the other three did not (carve#725).

carve
# Café

see [Café][] and [file][]

# file
html
<section id="Café">
  <h1>Café</h1>
  <p>see <a href="#Café">Café</a> and [file][]</p>
</section>
<section id="file">
  <h1>file</h1>
</section>

A collapsed reference reaches a heading by the heading's rendered text

11 conformance fixtures

PART 9R R1's implicit heading fallback keys the index by each heading's RENDERED PLAIN TEXT, so # *bold* heading is registered as bold heading. R1 said the label and the heading text are "both" trimmed, collapsed, NFC-normalized and case-folded, but it never said which string the label side contributes - its source run or its rendered plain text. Read as the source run, the asterisks survive all four normalizations and no heading containing markup is reachable by its collapsed spelling; read as rendered plain text, it is. Nothing pinned either answer, and carve-js took the first reading while carve-rs, carve-php and the executable spec took the second (markup-carve/carve#648).

It is settled as rendered plain text on this path: the heading side of the comparison is already rendered plain text, and two strings of different kinds can never meet.

carve
# *bold* heading

[*bold* heading][]
html
<section id="bold-heading">
  <h1><strong>bold</strong> heading</h1>
  <p><a href="#bold-heading"><strong>bold</strong> heading</a></p>
</section>

A code span in the heading is the same row, and worth pinning separately because an implementation that strips a fixed list of emphasis characters can pass the first one and fail this one:

carve
# `code()` heading

[`code()` heading][]
html
<section id="code-heading">
  <h1><code>code()</code> heading</h1>
  <p><a href="#code-heading"><code>code()</code> heading</a></p>
</section>

Those two were the whole sample for a long time, and both are on the list a character-class strip would carry, so the list itself went unmeasured. These are the shapes such a strip cannot reach (markup-carve/carve#1011). Carve's emphasis delimiter is /, and no strip can remove it without eating every path and URL an author might quote:

carve
# an /em/ heading

[an /em/ heading][]
html
<section id="an-em-heading">
  <h1>an <em>em</em> heading</h1>
  <p><a href="#an-em-heading">an <em>em</em> heading</a></p>
</section>

An ESCAPE is the shape where the two sides meet at neither spelling: the heading renders a_b, the label as written is a\_b, and deleting the underscore from the label leaves the backslash behind.

carve
# a\_b heading

[a\_b heading][]
html
<section id="a-b-heading">
  <h1>a_b heading</h1>
  <p><a href="#a-b-heading">a_b heading</a></p>
</section>

A NESTED LINK in the label contributes its text and not its destination, which is the heading side's own rule; dropping the brackets alone leaves (/y) standing. The resolved reference carries no nested anchor, because links never nest (PART 12 §3a).

carve
# a [x](/y) b

[a [x](/y) b][]
html
<section id="a-x-b">
  <h1>a <a href="/y">x</a> b</h1>
  <p><a href="#a-x-b">a x b</a></p>
</section>

SMART TYPOGRAPHY is the shape with no markup characters in it at all: the heading holds the curly apostrophe the substitution produced and the label holds the one the author typed, so only a comparison made after rendering relates them.

carve
# it's a heading

[it's a heading][]
html
<section id="it-s-a-heading">
  <h1>it’s a heading</h1>
  <p><a href="#it-s-a-heading">it’s a heading</a></p>
</section>

An INLINE LITERAL contributes its content, the same as the code span above (§27 renders it as visible prose):

carve
# a !`Cat` b

[a !`Cat` b][]
html
<section id="a-Cat-b">
  <h1>a Cat b</h1>
  <p><a href="#a-Cat-b">a Cat b</a></p>
</section>

A SYMBOL SHORTCODE is the one shape where the two sides meet by both contributing NOTHING. The slug rule (syntax.md §4.1 step 1) takes the heading's rendered plain text "inline markup removed; symbols :name: and footnote references excluded", so # a :smile: b is a-b and is keyed a b. The exclusion is by CONSTRUCT and not by what the symbol renders as, which is what makes the id hold still: a symbol resolves through processor configuration - an inline-renderer handler, else the renderer's symbols map, else the literal :name: - while an id is assigned in a parse pass no renderer option reaches. An id keyed on the shortcode NAME would name a spelling the document stops rendering the moment a host configures a map, and one keyed on the RESOLVED value would move every such id at that same moment. The corpus renders with no map, so the heading below prints :smile: and is still a-b.

carve
# a :smile: b

[a :smile: b][]
html
<section id="a-b">
  <h1>a :smile: b</h1>
  <p><a href="#a-b">a :smile: b</a></p>
</section>

The exclusion reaches the INDEX KEY as well, and it has to: the index is keyed by the same rendered plain text, so a heading that excludes the shortcode is keyed a b and is reachable by that spelling too. Excluding it from the id alone would leave the id and the key describing two different strings.

carve
# a :smile: b

[a b][]
html
<section id="a-b">
  <h1>a :smile: b</h1>
  <p><a href="#a-b">a b</a></p>
</section>

The two exclusions compose: a heading holding both a symbol and emphasis contributes the emphasis text and not the shortcode.

carve
# a :smile: /b/ c

[a :smile: /b/ c][]
html
<section id="a-b-c">
  <h1>a :smile: <em>b</em> c</h1>
  <p><a href="#a-b-c">a :smile: <em>b</em> c</a></p>
</section>

The strip is SCOPED TO THE HEADING INDEX. An authored definition is still matched by the label as written - 193-a-collapsed-reference-is-matched-by-the-label-the-author-wrote pins both directions of that, and a change that reaches it is a deviation from R1 rather than a generalization of it. The tie-break is unaffected too: linkDefs wins, so a definition whose label carries the same markup beats the heading the fallback would otherwise find.

carve
[*bold* heading]: /x

# *bold* heading

[*bold* heading][]
html
<section id="bold-heading">
  <h1><strong>bold</strong> heading</h1>
  <p><a href="/x"><strong>bold</strong> heading</a></p>
</section>

Heading-index plain text covers visible leaves and rejects an empty key

4 conformance fixtures

The heading index uses the same rendered-plain-text projection on both sides. Autolink display text and image alternative text are visible leaves and remain; symbols and footnote references are the named exclusions. Resolving the outer link does not flatten a parsed non-link tag span. When all content is excluded, the heading still receives the fallback id s, but no empty index key exists.

carve
# a <https://e.com> b

[a <https://e.com> b][]
html
<section id="a-https-e-com-b">
  <h1>a <a href="https://e.com">https://e.com</a> b</h1>
  <p><a href="#a-https-e-com-b">a https://e.com b</a></p>
</section>
carve
# a ![alt](/i.png) b

[a ![alt](/i.png) b][]
html
<section id="a-alt-b">
  <h1>a <img src="/i.png" alt="alt"> b</h1>
  <p><a href="#a-alt-b">a <img src="/i.png" alt="alt"> b</a></p>
</section>
carve
# a &#65; b

[a &#65; b][]
html
<section id="a-65-b">
  <h1>a &amp;<span class="tag"><strong>#65</strong></span>; b</h1>
  <p><a href="#a-65-b">a &amp;<span class="tag"><strong>#65</strong></span>; b</a></p>
</section>
carve
# :smile:

[:smile:][]
html
<section id="s">
  <h1>:smile:</h1>
  <p>[:smile:][]</p>
</section>

Which inline content a heading id is derived from

12 conformance fixtures

The id comes from the heading's TEXT CONTENT: every inline contributes the literal text it carries, and an inline carrying no text of its own contributes nothing (markup-carve/carve#1283). The section stated what happens to case, to non-ASCII characters, to typography and to a leading digit, and never which content those rules were applied to - which is how one engine could leave math out while keeping the code span beside it.

A math run contributes its text, exactly as the code span below it does. The two are the same shape of node holding the same kind of verbatim text, and no rule can keep one and drop the other - only a list can:

carve
# a $`x` b
html
<section id="a-x-b">
  <h1>a <span class="math inline">\(x\)</span> b</h1>
</section>

So a heading that is ONLY math has text, and does not reach the empty-text fallback:

carve
# $`x`
html
<section id="x">
  <h1><span class="math inline">\(x\)</span></h1>
</section>

Display math is the same run with a wider delimiter, and contributes the same way:

carve
# a $$`x` b
html
<section id="a-x-b">
  <h1>a <span class="math display">\[x\]</span> b</h1>
</section>

The control the ruling turned on: a code span already contributed its text in every engine, and it still does. Its answer is what makes the math answer a rule rather than a preference:

carve
# a `c` b
html
<section id="a-c-b">
  <h1>a <code>c</code> b</h1>
</section>

An image contributes its ALT TEXT, which is the text it carries:

carve
# a ![alt](i.png) b
html
<section id="a-alt-b">
  <h1>a <img src="i.png" alt="alt"> b</h1>
</section>

A link contributes its label, not its destination:

carve
# a [link](/u) b
html
<section id="a-link-b">
  <h1>a <a href="/u">link</a> b</h1>
</section>

An abbreviation definition written on the heading line is not a definition there at all, so its text is heading text, verbatim:

carve
# a *[HTML]: x b
html
<section id="a-HTML-x-b">
  <h1>a *[HTML]: x b</h1>
</section>

A superscript contributes its content, like every other inline that wraps text:

carve
# a {^up^} b
html
<section id="a-up-b">
  <h1>a <sup>up</sup> b</h1>
</section>

The other half of the rule. An inline footnote carries no text of its own - the body belongs to the note, not to the heading - so it contributes nothing, and the marker it renders as contributes nothing either:

carve
# a ^[note] b
html
<section id="a-b">
  <h1>a <a id="fnref1" href="#fn1" role="doc-noteref"><sup>1</sup></a> b</h1>
</section>
<section role="doc-endnotes">
  <hr>
  <ol>
    <li id="fn1">
      <p>note<a href="#fnref1" role="doc-backlink"></a></p>
    </li>
  </ol>
</section>

A cross-reference contributes nothing even when it RESOLVES and renders the target's text. This is where "the construct, not the output" is load-bearing: the id is assigned before the reference is resolved, so the rule cannot depend on what the link ends up saying:

carve
## Target

# a </#Target> b
html
<section id="Target">
  <h2>Target</h2>
</section>
<section id="a-b">
  <h1>a <a href="#Target">Target</a> b</h1>
</section>

A symbol shortcode contributes nothing for the same reason, read from the other side: a symbol resolves through processor configuration, and with no map it renders as its own literal text - which is still not heading text:

carve
# a :smile: b
html
<section id="a-b">
  <h1>a :smile: b</h1>
</section>

And a line comment contributes nothing by ending the line: everything after it is comment, so the id is derived from what is left:

carve
# a %% c
html
<section id="a">
  <h1>a</h1>
</section>

Two shapes are deliberately NOT settled by the list above, because the engines still disagree about them and no ruling covers either. An EDITORIAL COMMENT ({# … #}) carries literal text and renders it inside a critic-comment span, and a RAW INLINE (`…`{=html}) carries a payload that is emitted verbatim: measured on carve-js 620def4e and the executable spec, # a {# hidden #} b gives a-b in the engine and a-hidden-b in the oracle, and the raw inline splits the same way. Both are named here rather than pinned so the sentence in Heading IDs is not read as having answered them.

Headings

2 conformance fixtures

A # at line start without a following space is a tag, not a heading. By default it renders as a styled inline token, not an invented link target.

carve
#notaheading
html
<p><span class="tag"><strong>#notaheading</strong></span></p>

A heading that skips an intermediate level still nests by section: # H1 followed by ### H3 places H3's <section> inside H1's, and Carve does not synthesize an intervening <h2>/<section> (§13 — the stack closes only sections at level >= N).

carve
# H1

### H3

content
html
<section id="H1">
  <h1>H1</h1>
  <section id="H3">
    <h3>H3</h3>
    <p>content</p>
  </section>
</section>

Numbered cross-references

2 conformance fixtures

A #word stays a tag, never a number placeholder.

carve
![chart](c.jpg)
^ See #data for details
html
<figure>
  <img src="c.jpg" alt="chart">
  <figcaption>See <span class="tag"><strong>#data</strong></span> for details</figcaption>
</figure>

An escaped \# is a literal number sign, never a placeholder.

carve
![price](p.jpg)
^ Costs \# units
html
<figure>
  <img src="p.jpg" alt="price">
  <figcaption>Costs # units</figcaption>
</figure>

Heading IDs

3 conformance fixtures

Smart-typography substitutions (curly quotes, dashes, ellipsis, arrows, and the like) are reversed to their ASCII source before the id is computed, so an id never depends on presentational typography.

carve
# Don't repeat yourself

# Step 1 -> done...
html
<section id="Don-t-repeat-yourself">
  <h1>Don’t repeat yourself</h1>
</section>
<section id="Step-1-done">
  <h1>Step 1 → done…</h1>
</section>

A slug that begins with any Unicode number (Arabic-Indic digits, superscripts, Roman numerals) is prefixed with s-, because a leading digit is a valid HTML id but not a bare CSS selector.

carve
# ١٢٣ heading

# ²super

# Ⅷ chapter
html
<section id="s-١٢٣-heading">
  <h1>١٢٣ heading</h1>
</section>
<section id="s-²super">
  <h1>²super</h1>
</section>
<section id="s-Ⅷ-chapter">
  <h1>Ⅷ chapter</h1>
</section>

A heading whose text yields no identifier characters falls back to s.

carve
# ( )
html
<section id="s">
  <h1>( )</h1>
</section>

Released under the MIT License.