v5: folder/tag virtual pages render their listing twice (tree populated with the body's own HTML)
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
- Start from a fresh Quartz 5.0.0 setup with the default plugin config (nothing custom needed).
- Create
content/myfolder/a.mdandcontent/myfolder/b.md. - 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
<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:
const htmlString = render(BodyComponent(componentData))
const htmlAst = fromHtml(htmlString, { fragment: true })
ve.tree.children = htmlAst.children // ← self-reference
ve.vfile.data.htmlAst = htmlAstThat tree is meant for transclusion (![[folder/index]]), but listing-style bodies (FolderContent, TagContent) embed tree as their page content:
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):
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 }) 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 = htmlAstAlternatives that would also work:
FolderContent/TagContentcould 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.htmlAstfor all virtual pages and never write it back tove.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-page1.0.0 (also reproduced on0bb653d),tag-page@ lock default - Node: 22 / 26
Source: jackyzha0/quartz