Skip to content

Heading inside content-hidden when-format="llms-txt" loses its <section> and id, breaking TOC scroll #14562

@mcanouil

Description

@mcanouil

I have

  • searched the issue tracker for similar issues
  • installed the latest version of Quarto CLI
  • formatted my issue following the Bug Reports guide

Bug description

In a website project with llms-txt: true, a heading placed inside
::: {.content-hidden when-format="llms-txt"} is rendered in the HTML as a bare
<h2 ... data-anchor-id="..."> with no wrapping <section id="..."> and no real id.
Top-level headings render correctly inside <section id="..." class="level2">.
Because the heading has no id, the auto-generated TOC link
(data-scroll-target="#the-slug") matches nothing, so clicking it does not scroll, and direct
anchors and the hover anchor link are broken too.

Root cause (confirmed from the filter pipeline and the website llms finalizer):

  1. For a block that is visible in HTML but hidden from llms output, the
    pre-llms-conditional-content filter replaces the ConditionalBlock with a plain
    <div class="llms-hidden-content"> that wraps the inner content, keeping the heading nested
    inside that div for the rest of the pipeline.
  2. sections() (pandoc.structure.make_sections) runs in the crossref phase while the heading is
    still nested inside the llms-hidden-content div, so the heading is not treated as a top-level
    block and gets no <section> wrapper or id (only data-anchor-id).
  3. After rendering, the website llms HTML finalizer calls cleanupConditionalContent, which
    unwraps .llms-hidden-content in the HTML DOM (keeps the children, removes the wrapper div),
    leaving the heading as a bare <h2> with no enclosing section and no id.

Source references:

  • { name = "pre-llms-conditional-content",
    : the pre-llms-conditional-content filter is registered in the pre phase.
  • ConditionalBlock = function(tbl)
    local llms_visible = is_llms_visible(tbl)
    if llms_visible == nil then return nil end
    local html_visible = is_visible(tbl) -- from content-hidden.lua
    if llms_visible == html_visible then return nil end -- no intervention needed
    local div = tbl.original_node:clone()
    if llms_visible then
    div.classes:insert("llms-conditional-content")
    else
    div.classes:insert("llms-hidden-content")
    end
    return div
    end
    : the handler replaces the ConditionalBlock with a <div class="llms-hidden-content"> wrapping the content.
  • : sections() runs in the crossref phase, after the wrapper exists and before it is unwrapped.
  • llmsHtmlFinalizer(source, project, format),
    : the HTML finalizer llmsHtmlFinalizer is invoked.
  • function cleanupConditionalContent(doc: Document): void {
    // Remove llms-only content from HTML output
    for (const el of doc.querySelectorAll(".llms-conditional-content")) {
    (el as Element).remove();
    }
    // Unwrap llms-hidden markers (keep content, remove wrapper div)
    for (const el of doc.querySelectorAll(".llms-hidden-content")) {
    const parent = (el as Element).parentElement;
    if (parent) {
    const element = el as Element;
    while (element.firstChild) {
    parent.insertBefore(element.firstChild as Node, element as Node);
    }
    element.remove();
    }
    }
    }
    : cleanupConditionalContent unwraps .llms-hidden-content in the DOM after section wrapping.

I discovered this issue with llms-txt and conditional content features on the Quarto Wizard website where I wanted to hide the LLM prompt from the llms-txt output:

From my testing, it's really specific to llms-txt feature which badly interacts with conditional content.

Steps to reproduce

Create a minimal website project with two files, then render the whole project.

_quarto.yml:

project:
  type: website
website:
  title: "llms-txt repro"
  llms-txt: true
format:
  html:
    toc: true

index.qmd:

---
title: "Home"
---

## Normal Section

{{< lipsum 3 >}}

## Another Section

{{< lipsum 3 >}}

::: {.content-hidden when-format="llms-txt"}

## Conditional Section

Content

:::

Render the project and inspect the output:

quarto render
grep -c 'id="normal-section" class="level2"' _site/index.html       # 1
grep -c 'id="another-section" class="level2"' _site/index.html      # 1
grep -c 'id="conditional-section" class="level2"' _site/index.html  # 0 (bug)

Actual behavior

Normal Section is wrapped:

<section id="normal-section" class="level2">
  <h2 class="anchored" data-anchor-id="normal-section">Normal Section</h2>
  ...
</section>

Conditional Section, although visible in HTML, has no section and no id:

<h2 class="anchored" data-anchor-id="conditional-section">Conditional Section</h2>
...

The TOC entry is generated with data-scroll-target="#conditional-section", which matches no
element, so the TOC link does not scroll.
A live example is the last TOC entry on
https://m.canouil.dev/quarto-wizard/reference/schema-specification.html.

Expected behavior

A heading visible in the HTML output is wrapped in its own <section id="..."> and receives an
id, even when it sits inside content-hidden when-format="llms-txt", so TOC links, direct
anchors, the hover anchor link, and cross-references all work.

Your environment

  • IDE: VSCode.
  • OS: macOS (Darwin 25.5.0).
  • Quarto: 99.9.9 (local development build from source, commit 5748ba2).

Quarto check output

Quarto 99.9.9
[✓] Checking environment information...
      Quarto cache location: /Users/mcanouil/Library/Caches/quarto
[✓] Checking versions of quarto binary dependencies...
      Pandoc version 3.8.3: OK
      Dart Sass version 1.87.0: OK
      Deno version 2.7.14: OK
      Typst version 0.14.2: OK
[✓] Checking versions of quarto dependencies......OK
[✓] Checking Quarto installation......OK
      Version: 99.9.9
      commit: 5748ba2eb171c4906ce124f5c61c3246045e44a7
      Path: /Users/mcanouil/Projects/quarto-dev/quarto-cli/package/dist/bin

[✓] Checking tools....................OK
      TinyTeX: v2026.05
      Chrome Headless Shell: (not installed)
      VeraPDF: (not installed)

[✓] Checking LaTeX....................OK
      Using: TinyTex
      Path: /Users/mcanouil/Library/TinyTeX/bin/universal-darwin
      Version: 2026

[✓] Checking Chrome Headless....................OK
      Using: Chrome from QUARTO_CHROMIUM
      Path: /Applications/Brave Browser.app/Contents/MacOS/Brave Browser

[✓] Checking basic markdown render....OK

(|) Checking R installation...........ℹ R version 4.6.0 (2026-04-24)
! Config '~/.Rprofile' was loaded!
[✓] Checking R installation...........OK
      Version: 4.6.0
      Path: /Library/Frameworks/R.framework/Resources
      LibPaths:
        - /Library/Frameworks/R.framework/Versions/4.6/Resources/library
      knitr: 1.51
      rmarkdown: 2.31

[✓] Checking Knitr engine render......OK

[✓] Checking Python 3 installation....OK
      Version: 3.14.5
      Path: /opt/homebrew/opt/python@3.14/bin/python3.14
      Jupyter: (None)

      Jupyter is not available in this Python installation.
      Install with python3 -m pip install jupyter

      There is an unactivated Python environment in .venv. Did you forget to activate it?

[✓] Checking Julia installation...

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingllms-txt

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions