Skip to content

TOC: list a heading wrapped in a div that carries an id - #656

Draft
gordonwoodhull wants to merge 1 commit into
mainfrom
bugfix/bd-toc-skips-headings-in-id-div-1jorg679
Draft

TOC: list a heading wrapped in a div that carries an id#656
gordonwoodhull wants to merge 1 commit into
mainfrom
bugfix/bd-toc-skips-headings-in-id-div-1jorg679

Conversation

@gordonwoodhull

Copy link
Copy Markdown
Member

Put an id on a fenced div and the heading inside it vanished from the page's table of contents:

::: {#download-hero}
### Recommended for you
:::

The heading rendered, its <section> was anchorable, and a reader could scroll to it — but there was no way to navigate to it. No warning, exit 0. Quarto 1 lists it.

The id is what triggers this. A div with no attributes, or with only a class, is fine, because sectionization folds that wrapper into the <section> it holds and the heading ends up an ordinary top-level section. A wrapper carrying an id can't be folded — its id would collide with the section's own — so the section stays nested one level down, and the TOC walk stopped at the first non-section div it met without looking inside.

The walk now sees through a wrapper whose sole content is another div, which is pandoc's rule (toTOCTree, jgm/pandoc#8402). It recurses rather than unwrapping a single level, so a section buried under several wrappers is reached too — Quarto 1 lists those as well.

What still ends the walk is a wrapper holding anything besides a lone div, which is where filter-built chrome stops — though not always at its outermost div, and the distinction is worth stating because it is thinner than it looks. A titled callout and a resolved tabset each put two blocks side by side (header beside body, nav list beside pane container), so the walk stops at the outer div. An untitled callout does not: it emits a lone .callout-body.d-flex, so the walk descends one level and stops there instead, on the icon container beside the body container. Pandoc descends that same level, so Quarto 1 stops in the same place. Both stops rest on a sibling block that another transform is free to move, so each now has a test, and panel_tabset_resolve.rs records that its nav Plain is load-bearing for the TOC walk rather than just for markup.

Headings inside a tab pane, a callout, or a blockquote therefore stay out of the table of contents, as does a heading with prose or a sibling section beside it inside the same wrapper — all matching both pandoc and Quarto 1.

Tests

The new tests in crates/pampa/tests/integration/test_toc_wrapper_divs.rs drive markdown through the real pair, readers::qmd::readsectionize_blocksgenerate_toc. That matters here: the bug was an interaction between the absorb rule and the walk, and toc.rs's unit tests hand-build the sectionized AST on both sides of it, so either half could change without the other's tests noticing. They pin the eight wrapper shapes, stacked wrappers, and the two ways a wrapper can hold more than one block. All three positive tests fail without the new match arm; the negative controls are invariant, as they should be.

test_non_section_div_terminates_the_walk pinned the old behaviour using a tabset shape PanelTabsetResolveTransform does not emit — that transform's own unit test asserts the outer div holds two blocks. It is now test_resolved_tabset_pane_is_not_reached and uses the real shape, so it guards what the transform actually produces. test_untitled_callout_body_is_not_reached is new and pins the other stop.

Verification

Checked against a fixture covering eight wrapper shapes: q2 now agrees with Quarto 1 on every one, positive and negative. Callout, tabset and single-tab-tabset shapes rendered through the binary produce tables of contents identical to Quarto 1's. On the Positron website's download page, which wraps its hero section in ::: {#download-hero .download-hero}, the "Recommended for you" entry is back and the page's table of contents matches Quarto 1's entry for entry.

One known consequence

DocumentProfile::extract_outline shares generate_toc but runs pre-transform, before callouts and tabsets are resolved into chrome. A single-child id-bearing callout or tabset therefore now reaches profile.outline although the rendered TOC still excludes it. That is the existing bd-ca17fck0 divergence — profile outline computed pre-transform disagreeing with the post-transform TOC — extended from anonymous wrappers to id-bearing ones. Nothing outside tests consumes profile.outline today; noted on that strand rather than fixed here.

Fixes bd-toc-skips-headings-in-id-div-1jorg679.

Fixes bd-toc-skips-headings-in-id-div-1jorg679.

A heading inside a fenced div that carries an id never reached the
table of contents. The heading rendered, its <section> was anchorable,
and a reader could scroll to it, but there was no way to navigate to
it. No diagnostic; the render exited 0. Quarto 1 lists it.

The id is the trigger. sectionize_blocks absorbs an anonymous wrapper
into the section it holds, so a div with no attributes or with only a
class is already a plain section by the time the TOC is built. An
id-bearing wrapper cannot be absorbed -- its id would collide with the
section's -- so the section stays one level down, and collect_toc_entries
ended the walk at the first non-section Div it met.

The walk now sees through a wrapper whose sole content is another Div,
which is pandoc's rule (toTOCTree, Text/Pandoc/Chunks.hs, jgm/pandoc#8402).
It recurses rather than unwrapping one level, so a section under stacked
wrappers is reached too -- Quarto 1 lists those.

A wrapper holding anything besides a lone Div still ends the walk, which
is where filter-built chrome stops -- though not always at its outermost
Div. A titled callout and a resolved tabset each put two blocks side by
side, header beside body and nav Plain beside pane container, so the walk
stops at the outer Div. An untitled callout does not: build_untitled_content
emits a lone .callout-body.d-flex, so the walk descends one level and
stops on the icon container beside the body container. pandoc descends
that same level, so Quarto 1 stops in the same place. Both stops rest on
a sibling block another transform is free to move, so each is now pinned
by a test, and panel_tabset_resolve.rs records that its nav Plain is
load-bearing for the TOC walk.

Headings inside a tab pane, a callout, or a blockquote therefore stay out
of the TOC, as does a heading with prose or a sibling section beside it
inside the same wrapper -- all matching both pandoc and Quarto 1.

TESTS

New pipeline-level tests in tests/integration/test_toc_wrapper_divs.rs
drive markdown through readers::qmd::read -> sectionize_blocks ->
generate_toc, because the bug was an interaction between the absorb rule
and the walk and toc.rs's unit tests hand-build the sectionized AST on
both sides of it. They pin the eight-case contract, stacked wrappers, and
the two ways a wrapper can hold more than one block (prose before the
heading, and two sibling sections). Confirmed to fail without the new arm.

test_non_section_div_terminates_the_walk pinned the old behaviour with a
synthetic tabset shape (.panel-tabset directly wrapping a .tab-pane) that
PanelTabsetResolveTransform does not emit -- its own unit test asserts
the outer Div holds two blocks. It is now test_resolved_tabset_pane_is_not_reached
and uses the real shape. test_untitled_callout_body_is_not_reached is new
and pins the other stop. test_blockquote_heading_is_not_collected
(bd-8yjvs3bj) is untouched and green.

Workspace: 13685 passed, 199 skipped (+6, all added here).

VERIFIED END TO END

The eight-case repro fixture: q2 now agrees with Quarto 1 on every case,
including both negative controls. Callout, tabset and single-tab-tabset
shapes rendered through the binary produce TOCs identical to Quarto 1's.
On the Positron website's download page, which wraps its hero in
::: {#download-hero .download-hero}, the "Recommended for you" entry is
present and the page's TOC matches Quarto 1's entry for entry.

extract_outline shares generate_toc and runs pre-transform, so a
single-child id-bearing callout or tabset now reaches profile.outline
though the rendered TOC still excludes it. That is the already-filed
bd-ca17fck0 divergence, extended from anonymous wrappers to id-bearing
ones; noted on that strand.
@posit-snyk-bot

posit-snyk-bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@cscheid

cscheid commented Sep 4, 2026

Copy link
Copy Markdown
Member

I think the omission is now intentional, though we can talk about it!

@gordonwoodhull
gordonwoodhull marked this pull request as draft September 4, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants