#2558·quartz

v5: folder/tag virtual pages render their listing twice (tree populated with the body's own HTML)

Author: spaceoiCreated Sep 16, 2026Updated Sep 16, 2026

Description

On Quartz v5.0.0 (vanilla config from quartz.config.default.yaml, folder-page + tag-page enabled by default), folder pages and tag pages render their page listing twice: once inside the body <article> and once as a sibling div.page-listing below it.

To Reproduce

  1. Start from a fresh Quartz 5.0.0 setup with the default plugin config (nothing custom needed).
  2. Create content/myfolder/a.md and content/myfolder/b.md.
  3. Open /myfolder/ — the folder page shows "2 items under this folder." and the two entries twice.

Reproduced with both folder-page @ 0bb653d and the new 1.0.0 stable (cb98119), so it is not fixed by the recent plugin release.

Rendered structure

xml
<div class="center">
  <div class="page-header"></div>
  <div class="popover-hint">
    <article class>
      <div class="popover-hint">          <!-- FolderContent, embedded as *content* -->
        <article class></article>
        <div class="page-listing"></div>  <!-- listing #1 -->
      </div>
    </article>
    <div class="page-listing"></div>      <!-- listing #2 (duplicate) -->
  </div>

Root cause

populateVirtualPageHtmlAst in quartz/plugins/pageTypes/dispatcher.ts renders each virtual page's body component and stores the resulting hast back into the page's own tree:

typescript
const htmlString = render(BodyComponent(componentData))
const htmlAst = fromHtml(htmlString, { fragment: true })
ve.tree.children = htmlAst.children   // ← self-reference
ve.vfile.data.htmlAst = htmlAst

That tree is meant for transclusion (![[folder/index]]), but listing-style bodies (FolderContent, TagContent) embed tree as their page content:

typescript
const content = hastRoot.children.length === 0 ? description : htmlToJsx(hastRoot)

So when the virtual page itself is emitted, the body renders its listing and embeds its own pre-rendered listing as content → duplicate.

Suggested fix

Keep htmlAst populated for transclusion, but skip the self-referential tree for listing-style page types. Minimal patch (applies to both the full and partial-rebuild virtualEntries.push sites):

diff
       const virtualEntries: Array<{
         tree: ProcessedContent[0]
         vfile: ProcessedContent[1]
         layout: FullPageLayout
         vpSlug: FullSlug
+        embedsOwnTree?: boolean
       }> = []
       …
-          virtualEntries.push({ tree, vfile, layout, vpSlug })
+          // Listing-style bodies re-embed their tree as page content (see populateVirtualPageHtmlAst)
+          const embedsOwnTree = pt.name === "FolderPage" || pt.name === "TagPage"
+          virtualEntries.push({ tree, vfile, layout, vpSlug, embedsOwnTree })
diff
       const htmlString = render(BodyComponent(componentData))
       const htmlAst = fromHtml(htmlString, { fragment: true }) as HtmlRoot
-      ve.tree.children = htmlAst.children
+      // Listing-style bodies (folder/tag) embed their tree as content; feeding
+      // them their own rendered HTML duplicates the listing on the page itself.
+      // htmlAst stays populated so transclusion from other pages still works.
+      if (!ve.embedsOwnTree) {
+        ve.tree.children = htmlAst.children
+      }
       ve.vfile.data.htmlAst = htmlAst

Alternatives that would also work:

  • FolderContent/TagContent could refuse to embed a tree that itself contains a .page-listing (self-detection), or
  • the dispatcher could keep the transclusion hast only on vfile.data.htmlAst for all virtual pages and never write it back to ve.tree (if no body actually needs the roundtrip).

I've been running the patch above in production and folder/tag/content pages all render correctly (folder indexes transcluded from other pages still work via the title-only fallback / htmlAst path).

Environment

  • Quartz: v5.0.0
  • Plugins: folder-page 1.0.0 (also reproduced on 0bb653d), tag-page @ lock default
  • Node: 22 / 26