Edit Notice

2025-07-31

  • Add section about Exclusion Exceptions

I have some pages that are only meant for certain people. Nothing secret — just things I want to hide from search results or while browsing my site. By default, Quartz includes everything in search and other listings. There is, of course, an option to prevent pages from rendering, but I still want those pages to render — just not be easily discoverable.

I’m not a big fan of TypeScript, just like with regular JavaScript, but with Quartz, there’s no real alternative. However, I came up with this:

The Core Utility

I created a utility function that recursively walks up the parent slugs to check whether any ancestor has the frontmatter field searchExclude set (more on that later). I tried to make it as generalized as possible by introducing an EntryLookup type for flexibility:

quartz/util/exclusion.ts
import { ContentIndexMap, ContentDetails } from "../plugins/emitters/contentIndex"
import { FullSlug } from "./path"
 
const ancestorExcludeCache: Record<string, boolean> = {}
 
const debug = false
 
type EntryLookup = (slug: string) => { frontmatter?: { searchExclude?: boolean } } | ContentDetails | undefined
 
function getParentSlug(slug: string): string | undefined {
  const segments = slug.split("/")
  if (segments.length <= 1) return undefined
 
  if (segments[segments.length - 1] === "index") {
    segments.pop()
  }
 
  if (segments.length === 0) return undefined
 
  return [...segments.slice(0, -1), "index"].join("/")
}
 
export function hasExcludedAncestor(slug: string, getEntry: EntryLookup): boolean {
  if (slug in ancestorExcludeCache) return ancestorExcludeCache[slug]
 
  let currentSlug: string | undefined = slug
  const chain: string[] = []
 
  while (currentSlug) {
    if (debug) chain.push(currentSlug)
    const entry = getEntry(currentSlug)
    if (!entry) break
 
    const excluded = "frontmatter" in entry ? entry.frontmatter?.searchExclude : entry.searchExclude
    if (excluded === true) {
      if (debug) console.log(`[Exclusion] "${slug}" excluded due to ancestor: ${currentSlug} (checked path: ${chain.join(" → ")})`)
      ancestorExcludeCache[slug] = true
      return true
    }
 
    currentSlug = getParentSlug(currentSlug)
  }
 
  if (debug) console.log(`[Exclusion] "${slug}" is not excluded (checked path: ${chain.join(" → ")})`)
  ancestorExcludeCache[slug] = false
  return false
}
 
export function resetExcludedAncestorCache() {
  for (const key in ancestorExcludeCache) {
    delete ancestorExcludeCache[key]
  }
}

Prerequisites

Where does searchExclude come from? It’s defined directly in the note’s frontmatter like so:

---
searchExclude: true
---

The idea is that any page under this one (by slug path) will inherit the exclusion.

The searchExclude field is declared in quartz/plugins/emitters/contentIndex.tsx like this:

export type ContentDetails = {
  ...
  searchExclude?: boolean
  ...
}
 
...
const linkIndex: ContentIndexMap = new Map()
for (const [tree, file] of content) {
  const slug = file.data.slug!
  const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
  if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
    linkIndex.set(slug, {
      slug,
      ...
      searchExclude: file.data.frontmatter?.searchExclude ?? false,
      ...
    })
  }
}
...

It’s also declared in quartz/plugins/transformers/frontmatter.ts:

declare module "vfile" {
  interface DataMap {
    aliases: FullSlug[]
    frontmatter: { [key: string]: unknown } & {
      title: string
    } & Partial<{
        ...
        searchExclude: boolean | string
        ...
      }>
  }
}

How To Use It

I’m still relatively new to the Quartz architecture, so there may be better ways to implement this &mdash& but here’s what I’ve done so far.

I’ve added exclusion support in the following files:

  • quartz/components/Backlinks.tsx — for backlink generation

  • quartz/components/scripts/graph.inline.ts — for graph view generation

  • quartz/components/scripts/search.inline.ts — for search results

  • quartz/plugins/emitters/contentIndex.tsx — for sitemap and RSS generation

At the top of the file:

import { hasExcludedAncestor, resetExcludedAncestorCache } from "../util/exclusion"

Filtering backlinks:

-    const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
+    resetExcludedAncestorCache()
+    const backlinkFiles = allFiles.filter(
+      (file) =>
+        file.links?.includes(slug) &&
+        !hasExcludedAncestor(file.slug!, (s) => allFiles.find((f) => f.slug === s))
+    )

Graph View

At the top:

import { hasExcludedAncestor, resetExcludedAncestorCache } from "../util/exclusion"

Filtering logic:

-  const nodes = [...neighbourhood].map((url) => {
+  resetExcludedAncestorCache()
+  const filteredNeighbourhood = new Set([...neighbourhood]
+  .filter(
+    (slug) =>
+      !hasExcludedAncestor(slug,
+        (s) =>
+          data.get(s) ? data.get(s) : data.get(s.replace(/\/index$/, "/")
+  ))))
+
+  const nodes = [...filteredNeighbourhood].map((url) => {
...
-      .filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
+      .filter((l) => filteredNeighbourhood.has(l.source) && filteredNeighbourhood.has(l.target))

At the top:

import { hasExcludedAncestor, resetExcludedAncestorCache } from "../../util/exclusion"

Filtering logic:

+    resetExcludedAncestorCache()
     // order titles ahead of content
     const allIds: Set<number> = new Set([
       ...getByField("title"),
       ...getByField("content"),
       ...getByField("tags"),
-    ])
+    ].filter((id) => !hasExcludedAncestor(idDataMap[id], (slug) => data[slug])))

Sitemap and RSS

At the top:

import { hasExcludedAncestor, resetExcludedAncestorCache } from "../../util/exclusion"

Filtering sitemap and feed items:

+  resetExcludedAncestorCache()
   const urls = Array.from(idx)
+    .filter(([slug, content]) => !hasExcludedAncestor(slug, (s) => idx.get(s)))
     .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
...
+  resetExcludedAncestorCache()
   const items = Array.from(idx)
+    .filter(([slug, content]) => !hasExcludedAncestor(slug, (s) => idx.get(s)))
     .sort(([_, f1], [__, f2]) => {

Let me know if you’ve got suggestions for a cleaner way to integrate this! For now, it works well and keeps my less-relevant content out of sight while still rendering.

Exclusion Exceptions

If you have have a subtree in your site you want hidden from the main site, but still want search results showing up, this diff fixes that!

commit 86aec164d2e7bd70f8252552313896e292f3df83 (HEAD -> v4)
Author: xnoxz <xnoxz@nas>
Date:   Thu Jul 31 10:49:05 2025 +0200
 
    Add exception for search exclusion when in subtree
 
diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx
index 8133f12..a4d3079 100644
--- a/quartz/components/Backlinks.tsx
+++ b/quartz/components/Backlinks.tsx
@@ -29,7 +29,7 @@ export default ((opts?: Partial<BacklinksOptions>) => {
     const backlinkFiles = allFiles.filter(
       (file) =>
         file.links?.includes(slug) &&
-        !hasExcludedAncestor(file.slug!, (s) => allFiles.find((f) => f.slug === s))
+        !hasExcludedAncestor(file.slug!, (s) => allFiles.find((f) => f.slug === s), slug)
     )
 
     if (options.hideWhenEmpty && backlinkFiles.length == 0) {
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index 645158f..1afbfcb 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -169,7 +169,7 @@ async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
   }
 
   resetExcludedAncestorCache()
-  const filteredNeighbourhood = new Set([...neighbourhood].filter((slug) => !hasExcludedAncestor(slug, (s) => data.get(s) ? data.get(s) : data.get(s.replace(/
\/index$/, "/")))))
+  const filteredNeighbourhood = new Set([...neighbourhood].filter((linkSlug) => !hasExcludedAncestor(linkSlug, (s) => data.get(s) ? data.get(s) : data.get(s.r
eplace(/\/index$/, "/")), slug)))
 
   const nodes = [...filteredNeighbourhood].map((url) => {
     const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts
index b3373ab..9d41c44 100644
--- a/quartz/components/scripts/search.inline.ts
+++ b/quartz/components/scripts/search.inline.ts
@@ -447,7 +447,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
       ...getByField("title"),
       ...getByField("content"),
       ...getByField("tags"),
-    ].filter((id) => !hasExcludedAncestor(idDataMap[id], (slug) => data[slug])))
+    ].filter((id) => !hasExcludedAncestor(idDataMap[id], (slug) => data[slug], currentSlug)))
 
     const finalResults = [...allIds].map((id) => formatForDisplay(currentSearchTerm, id))
     await displayResults(finalResults)
diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx
index bd839ca..baf5730 100644
--- a/quartz/plugins/emitters/contentIndex.tsx
+++ b/quartz/plugins/emitters/contentIndex.tsx
@@ -51,7 +51,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string
   </url>`
   resetExcludedAncestorCache()
   const urls = Array.from(idx)
-    .filter(([slug, content]) => !hasExcludedAncestor(slug, (s) => idx.get(s)))
+    .filter(([slug, content]) => !hasExcludedAncestor(slug, (s) => idx.get(s), null))
     .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
     .join("")
   return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
@@ -70,7 +70,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?:
 
   resetExcludedAncestorCache()
   const items = Array.from(idx)
-    .filter(([slug, content]) => !hasExcludedAncestor(slug, (s) => idx.get(s)))
+    .filter(([slug, content]) => !hasExcludedAncestor(slug, (s) => idx.get(s), null))
     .sort(([_, f1], [__, f2]) => {
       if (f1.date && f2.date) {
         return f2.date.getTime() - f1.date.getTime()
diff --git a/quartz/util/exclusion.ts b/quartz/util/exclusion.ts
index 24ff053..9a5337a 100644
--- a/quartz/util/exclusion.ts
+++ b/quartz/util/exclusion.ts
@@ -20,25 +20,31 @@ function getParentSlug(slug: string): string | undefined {
   return [...segments.slice(0, -1), "index"].join("/")
 }
 
-export function hasExcludedAncestor(slug: string, getEntry: EntryLookup): boolean {
+export function hasExcludedAncestor(slug: string, getEntry: EntryLookup, currentSlug: string | null = null): boolean {
   if (slug in ancestorExcludeCache) return ancestorExcludeCache[slug]
 
-  let currentSlug: string | undefined = slug
+  let current: string | undefined = slug
   const chain: string[] = []
 
-  while (currentSlug) {
-    if (debug) chain.push(currentSlug)
-    const entry = getEntry(currentSlug)
+  while (current) {
+    if (debug) chain.push(current)
+    const entry = getEntry(current)
     if (!entry) break
 
     const excluded = "frontmatter" in entry ? entry.frontmatter?.searchExclude : entry.searchExclude
     if (excluded === true) {
-      if (debug) console.log(`[Exclusion] "${slug}" excluded due to ancestor: ${currentSlug} (checked path: ${chain.join(" → ")})`)
-      ancestorExcludeCache[slug] = true
-      return true
+      const shouldBypass = currentSlug && currentSlug.startsWith(current.replace(/\/index$/, ""))
+
+      if (!shouldBypass) {
+        if (debug) console.log(`[Exclusion] "${slug}" excluded due to ancestor: ${current} (checked path: ${chain.join(" → ")})`)
+        ancestorExcludeCache[slug] = true
+        return true
+      } else {
+        if (debug) console.log(`[Exclusion] "${slug}" would be excluded due to ancestor: ${current}, but allowed due to context slug: ${currentSlug}`)
+      }
     }
 
-    currentSlug = getParentSlug(currentSlug)
+    current = getParentSlug(current)
   }
 
   if (debug) console.log(`[Exclusion] "${slug}" is not excluded (checked path: ${chain.join(" → ")})`)
@@ -46,6 +52,7 @@ export function hasExcludedAncestor(slug: string, getEntry: EntryLookup): boolea
   return false
 }
 
+
 export function resetExcludedAncestorCache() {
   for (const key in ancestorExcludeCache) {
     delete ancestorExcludeCache[key]