Skip to content

Assets from unpublished notes are emitted when explicit-publish is enabled #2531

Description

@archifont

Describe the bug

explicit-publish keeps unpublished notes off the site, but attachments belonging to those notes are still copied into public/ and served. A vault used with explicit-publish therefore publishes every image, PDF and video it contains, including those only ever referenced from notes marked publish: false.

This is independent of encrypted-pages — the asset is emitted with that plugin disabled entirely, so the cause is explicit-publish alone. But the two together make the gap wider: pages are encrypted, attachments are not, so the files are readable without the password that gates everything else.

I do not think this is a misconfiguration — the Assets plugin docs say it emits "all non-Markdown static assets in your content folder" and has no configuration options. But when a filter plugin is enabled, "all" is probably not what the user is asking for.

To Reproduce

Two files and an image:

content/published.md
---
title: Published Note
publish: true
---
This note is published.

content/private-note.md
---
title: Private Note
publish: false
---
This note is not published. But the image below is.

![[secret-diagram.png]]

content/secret-diagram.png   (any image)

With ExplicitPublish enabled and no other plugins required, run npx quartz build and look at public/:

published.html          ✓ expected
private-note.html       ✓ correctly absent
secret-diagram.png      ✗ present

The image is copied even though the only note referencing it was filtered out.

Expected behavior

An attachment that is only referenced by filtered-out content should not be emitted. Either of these would do it:

  1. Copy only assets referenced by pages that survived filtering.
  2. Skip assets whose referencing notes were all filtered out.

The first is stricter and also removes assets nothing references at all.

Screenshots and Source

The relevant code is quartz/plugins/emitters/assets.ts:

const filesToCopy = async (argv: Argv, cfg: QuartzConfig, excludeExtensions: Set<string>) => {
  const excludePatterns = ["**/*.md", ...cfg.configuration.ignorePatterns]
  for (const ext of excludeExtensions) {
    excludePatterns.push(`**/*${ext}`)
  }
  return await glob("**", argv.directory, excludePatterns)
}

It globs the content directory directly, so it never sees the filtered content and cannot know which assets survived. emit(ctx) also ignores the content argument that other emitters use.

filterContent runs before emitContent (quartz/build.ts:95), so by emit time the filtered set is available — an emitter that walked those pages for img, video, audio and iframe sources would have exactly the referenced set. Happy to attempt a PR along those lines if that direction seems right.

Two things worth noting for whoever picks this up:

  • Image references are not in file.data.links. crawl-links rewrites node.properties.src but only pushes <a href> targets into data.links, so the tree has to be walked directly.
  • Assets() is registered as a builtin in quartz/plugins/loader/config-loader.ts, so its behaviour can't currently be changed from quartz.config.yaml.

Environment

  • Quartz v5.0.0
  • Node v26
  • macOS

Impact

For a public digital garden this is mostly wasted bandwidth. For anyone using explicit-publish to publish a subset of a private vault, every attachment in that vault is on the internet. Nothing in the configuration hints at it, and the files are served unencrypted even when encrypted-pages is on.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions