This commit is contained in:
Harv 2025-12-09 19:49:08 -05:00 committed by GitHub
commit f3f2cb2662
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 103 additions and 14 deletions

View File

@ -1,12 +1,21 @@
--- ---
title: Private Pages title: Private and Unlisted Pages
tags: tags:
- feature/filter - feature/filter
--- ---
There may be some notes you want to avoid publishing as a website. Quartz supports this through two mechanisms which can be used in conjunction: You may want to control which notes appear publicly on your site. Quartz supports two complementary mechanisms to achieve this:
## Filter Plugins - **Private Pages** — fully exclude notes from the published site.
- **Unlisted Pages** — published and accessible via direct link, but hidden from navigation and listings.
---
## Private Pages
Quarts supports **Private Pages**, which allow you to **prevent certain notes from being published as a website**. There are two mechanisms for this which can be used in conjunction:
### Filter Plugins
[[making plugins#Filters|Filter plugins]] are plugins that filter out content based off of certain criteria. By default, Quartz uses the [[RemoveDrafts]] plugin which filters out any note that has `draft: true` in the frontmatter. [[making plugins#Filters|Filter plugins]] are plugins that filter out content based off of certain criteria. By default, Quartz uses the [[RemoveDrafts]] plugin which filters out any note that has `draft: true` in the frontmatter.
@ -15,7 +24,7 @@ If you'd like to only publish a select number of notes, you can instead use [[Ex
> [!warning] > [!warning]
> Regardless of the filter plugin used, **all non-markdown files will be emitted and available publically in the final build.** This includes files such as images, voice recordings, PDFs, etc. > Regardless of the filter plugin used, **all non-markdown files will be emitted and available publically in the final build.** This includes files such as images, voice recordings, PDFs, etc.
## `ignorePatterns` ### Quartz Config
This is a field in `quartz.config.ts` under the main [[configuration]] which allows you to specify a list of patterns to effectively exclude from parsing all together. Any valid [fast-glob](https://github.com/mrmlnc/fast-glob#pattern-syntax) pattern works here. This is a field in `quartz.config.ts` under the main [[configuration]] which allows you to specify a list of patterns to effectively exclude from parsing all together. Any valid [fast-glob](https://github.com/mrmlnc/fast-glob#pattern-syntax) pattern works here.
@ -26,8 +35,34 @@ Common examples include:
- `some/folder`: exclude the entire of `some/folder` - `some/folder`: exclude the entire of `some/folder`
- `*.md`: exclude all files with a `.md` extension - `*.md`: exclude all files with a `.md` extension
- `!(*.md)` exclude all files that _don't_ have a `.md` extension. Note that negations _must_ parenthesize the rest of the pattern! - `!(*.md)`: exclude all files that _don't_ have a `.md` extension. Note that negations _must_ parenthesize the rest of the pattern!
- `**/private`: exclude any files or folders named `private` at any level of nesting - `**/private`: exclude any files or folders named `private` at any level of nesting
> [!warning] > [!warning]
> Marking something as private via either a plugin or through the `ignorePatterns` pattern will only prevent a page from being included in the final built site. If your GitHub repository is public, also be sure to include an ignore for those in the `.gitignore` of your Quartz. See the `git` [documentation](https://git-scm.com/docs/gitignore#_pattern_format) for more information. > Marking something as private via either a plugin or through the `ignorePatterns` pattern will only prevent a page from being included in the final built site. If your GitHub repository is public, also be sure to include an ignore for those in the `.gitignore` of your Quartz. See the `git` [documentation](https://git-scm.com/docs/gitignore#_pattern_format) for more information.
---
## Unlisted Pages
Quartz supports **Unlisted Pages**, which allow you to publish notes that remain **accessible by direct link** but **hidden from navigation components** such as:
- the explorer sidebar
- recent notes lists
- tag or folder listings
This is useful for sharing content privately with collaborators, collecting feedback, or keeping drafts semi-private without fully unpublishing them.
There are two mechanisms provided to enable unlisted pages:
### Frontmatter Flags
To mark a single page as unlisted, add `unlisted: true` to its frontmatter.
### Quartz Config
If you want to apply this behavior to multiple files or folders, you can use the `unlistedPatterns` field in your `quartz.config.ts`.
This accepts an array of fast-glob patterns that identify which pages should be treated as unlisted.
> [!note]
> As with `ignorePatterns`, fast-glob syntax differs slightly from Bash glob syntax. Using Bash-style patterns may lead to unexpected results.

View File

@ -18,6 +18,7 @@ const config: QuartzConfig = {
locale: "en-US", locale: "en-US",
baseUrl: "quartz.jzhao.xyz", baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"], ignorePatterns: ["private", "templates", ".obsidian"],
unlistedPatterns: [],
defaultDateType: "modified", defaultDateType: "modified",
theme: { theme: {
fontOrigin: "googleFonts", fontOrigin: "googleFonts",

View File

@ -67,6 +67,8 @@ export interface GlobalConfiguration {
analytics: Analytics analytics: Analytics
/** Glob patterns to not search */ /** Glob patterns to not search */
ignorePatterns: string[] ignorePatterns: string[]
/** Glob patterns to mark files as unlisted (hidden from listings but still accessible via direct link) */
unlistedPatterns: string[]
/** Whether to use created, modified, or published as the default type of date */ /** Whether to use created, modified, or published as the default type of date */
defaultDateType: ValidDateType defaultDateType: ValidDateType
/** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL. /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL.

View File

@ -50,7 +50,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
displayClass, displayClass,
ctx, ctx,
}: QuartzComponentProps) => { }: QuartzComponentProps) => {
const trie = (ctx.trie ??= trieFromAllFiles(allFiles)) const trie = (ctx.trie ??= trieFromAllFiles(allFiles, ctx.cfg))
const slugParts = fileData.slug!.split("/") const slugParts = fileData.slug!.split("/")
const pathNodes = trie.ancestryChain(slugParts) const pathNodes = trie.ancestryChain(slugParts)

View File

@ -30,7 +30,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
const FolderContent: QuartzComponent = (props: QuartzComponentProps) => { const FolderContent: QuartzComponent = (props: QuartzComponentProps) => {
const { tree, fileData, allFiles, cfg } = props const { tree, fileData, allFiles, cfg } = props
const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles)) const trie = (props.ctx.trie ??= trieFromAllFiles(allFiles, props.ctx.cfg))
const folder = trie.findNode(fileData.slug!.split("/")) const folder = trie.findNode(fileData.slug!.split("/"))
if (!folder) { if (!folder) {
return null return null

View File

@ -7,6 +7,7 @@ import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { isUnlisted } from "../filters/unlisted"
export type ContentIndexMap = Map<FullSlug, ContentDetails> export type ContentIndexMap = Map<FullSlug, ContentDetails>
export type ContentDetails = { export type ContentDetails = {
@ -102,6 +103,11 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = file.data.slug! const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
if (isUnlisted(file.data, cfg)) {
continue
}
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, { linkIndex.set(slug, {
slug, slug,

View File

@ -14,6 +14,7 @@ import { BuildCtx } from "../../util/ctx"
import { Node } from "unist" import { Node } from "unist"
import { StaticResources } from "../../util/resources" import { StaticResources } from "../../util/resources"
import { QuartzPluginData } from "../vfile" import { QuartzPluginData } from "../vfile"
import { isUnlisted } from "../filters/unlisted"
async function processContent( async function processContent(
ctx: BuildCtx, ctx: BuildCtx,
@ -74,7 +75,9 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
] ]
}, },
async *emit(ctx, content, resources) { async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data) const allFiles = content
.map((c) => c[1].data)
.filter((f) => !isUnlisted(f, ctx.cfg.configuration))
let containsIndex = false let containsIndex = false
for (const [tree, file] of content) { for (const [tree, file] of content) {
@ -98,7 +101,9 @@ export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOp
} }
}, },
async *partialEmit(ctx, content, resources, changeEvents) { async *partialEmit(ctx, content, resources, changeEvents) {
const allFiles = content.map((c) => c[1].data) const allFiles = content
.map((c) => c[1].data)
.filter((f) => !isUnlisted(f, ctx.cfg.configuration))
// find all slugs that changed or were added // find all slugs that changed or were added
const changedSlugs = new Set<string>() const changedSlugs = new Set<string>()

View File

@ -20,6 +20,7 @@ import { write } from "./helpers"
import { i18n, TRANSLATIONS } from "../../i18n" import { i18n, TRANSLATIONS } from "../../i18n"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources" import { StaticResources } from "../../util/resources"
import { isUnlisted } from "../filters/unlisted"
interface FolderPageOptions extends FullPageLayout { interface FolderPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
} }
@ -129,7 +130,9 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
] ]
}, },
async *emit(ctx, content, resources) { async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data) const allFiles = content
.map((c) => c[1].data)
.filter((f) => !isUnlisted(f, ctx.cfg.configuration))
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const folders: Set<SimpleSlug> = new Set( const folders: Set<SimpleSlug> = new Set(
@ -146,7 +149,9 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
}, },
async *partialEmit(ctx, content, resources, changeEvents) { async *partialEmit(ctx, content, resources, changeEvents) {
const allFiles = content.map((c) => c[1].data) const allFiles = content
.map((c) => c[1].data)
.filter((f) => !isUnlisted(f, ctx.cfg.configuration))
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
// Find all folders that need to be updated based on changed files // Find all folders that need to be updated based on changed files

View File

@ -12,6 +12,7 @@ import { write } from "./helpers"
import { i18n, TRANSLATIONS } from "../../i18n" import { i18n, TRANSLATIONS } from "../../i18n"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources" import { StaticResources } from "../../util/resources"
import { isUnlisted } from "../filters/unlisted"
interface TagPageOptions extends FullPageLayout { interface TagPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
@ -122,7 +123,9 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
] ]
}, },
async *emit(ctx, content, resources) { async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data) const allFiles = content
.map((c) => c[1].data)
.filter((f) => !isUnlisted(f, ctx.cfg.configuration))
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale) const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
@ -131,7 +134,9 @@ export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts)
} }
}, },
async *partialEmit(ctx, content, resources, changeEvents) { async *partialEmit(ctx, content, resources, changeEvents) {
const allFiles = content.map((c) => c[1].data) const allFiles = content
.map((c) => c[1].data)
.filter((f) => !isUnlisted(f, ctx.cfg.configuration))
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
// Find all tags that need to be updated based on changed files // Find all tags that need to be updated based on changed files

View File

@ -0,0 +1,22 @@
import { minimatch } from "minimatch"
import { QuartzPluginData } from "../vfile"
import { GlobalConfiguration } from "../../cfg"
export function isUnlisted(fileData: QuartzPluginData, cfg: GlobalConfiguration): boolean {
const unlistedFlag: boolean =
fileData?.frontmatter?.unlisted === true || fileData?.frontmatter?.unlisted === "true"
if (unlistedFlag) return true
const patterns = cfg.unlistedPatterns
if (patterns && patterns.length > 0 && fileData.slug) {
const slug = fileData.slug
for (const pattern of patterns) {
if (minimatch(slug, pattern)) {
return true
}
}
}
return false
}

View File

@ -147,6 +147,7 @@ declare module "vfile" {
socialDescription: string socialDescription: string
publish: boolean | string publish: boolean | string
draft: boolean | string draft: boolean | string
unlisted: boolean | string
lang: string lang: string
enableToc: string enableToc: string
cssclasses: string[] cssclasses: string[]

View File

@ -1,4 +1,5 @@
import { QuartzConfig } from "../cfg" import { QuartzConfig } from "../cfg"
import { isUnlisted } from "../plugins/filters/unlisted"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { FileTrieNode } from "./fileTrie" import { FileTrieNode } from "./fileTrie"
import { FilePath, FullSlug } from "./path" import { FilePath, FullSlug } from "./path"
@ -31,10 +32,16 @@ export interface BuildCtx {
incremental: boolean incremental: boolean
} }
export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> { export function trieFromAllFiles(
allFiles: QuartzPluginData[],
cfg?: QuartzConfig,
): FileTrieNode<BuildTimeTrieData> {
const trie = new FileTrieNode<BuildTimeTrieData>([]) const trie = new FileTrieNode<BuildTimeTrieData>([])
allFiles.forEach((file) => { allFiles.forEach((file) => {
if (file.frontmatter) { if (file.frontmatter) {
if (cfg && isUnlisted(file, cfg.configuration)) {
return
}
trie.add({ trie.add({
...file, ...file,
slug: file.slug!, slug: file.slug!,