diff --git a/docs/features/private pages.md b/docs/features/private pages.md index eed6d3c1a..1ee6311ec 100644 --- a/docs/features/private pages.md +++ b/docs/features/private pages.md @@ -1,12 +1,21 @@ --- -title: Private Pages +title: Private and Unlisted Pages tags: - 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. @@ -15,7 +24,7 @@ If you'd like to only publish a select number of notes, you can instead use [[Ex > [!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. -## `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. @@ -26,8 +35,34 @@ Common examples include: - `some/folder`: exclude the entire of `some/folder` - `*.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 > [!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. + +--- + +## 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. diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..de17574fa 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -18,6 +18,7 @@ const config: QuartzConfig = { locale: "en-US", baseUrl: "quartz.jzhao.xyz", ignorePatterns: ["private", "templates", ".obsidian"], + unlistedPatterns: [], defaultDateType: "modified", theme: { fontOrigin: "googleFonts", diff --git a/quartz/cfg.ts b/quartz/cfg.ts index c97d613bb..fdcaa2168 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -67,6 +67,8 @@ export interface GlobalConfiguration { analytics: Analytics /** Glob patterns to not search */ 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 */ defaultDateType: ValidDateType /** Base URL to use for CNAME files, sitemaps, and RSS feeds that require an absolute URL. diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx index 5144a314d..17a32104e 100644 --- a/quartz/components/Breadcrumbs.tsx +++ b/quartz/components/Breadcrumbs.tsx @@ -50,7 +50,7 @@ export default ((opts?: Partial) => { displayClass, ctx, }: QuartzComponentProps) => { - const trie = (ctx.trie ??= trieFromAllFiles(allFiles)) + const trie = (ctx.trie ??= trieFromAllFiles(allFiles, ctx.cfg)) const slugParts = fileData.slug!.split("/") const pathNodes = trie.ancestryChain(slugParts) diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx index afd4f5d7e..16cfad202 100644 --- a/quartz/components/pages/FolderContent.tsx +++ b/quartz/components/pages/FolderContent.tsx @@ -30,7 +30,7 @@ export default ((opts?: Partial) => { const FolderContent: QuartzComponent = (props: QuartzComponentProps) => { 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("/")) if (!folder) { return null diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..779fc0210 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -7,6 +7,7 @@ import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" +import { isUnlisted } from "../filters/unlisted" export type ContentIndexMap = Map export type ContentDetails = { @@ -102,6 +103,11 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { for (const [tree, file] of content) { const slug = file.data.slug! 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 !== "")) { linkIndex.set(slug, { slug, diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index c3410ecc3..d8d39546d 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -14,6 +14,7 @@ import { BuildCtx } from "../../util/ctx" import { Node } from "unist" import { StaticResources } from "../../util/resources" import { QuartzPluginData } from "../vfile" +import { isUnlisted } from "../filters/unlisted" async function processContent( ctx: BuildCtx, @@ -74,7 +75,9 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp ] }, 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 for (const [tree, file] of content) { @@ -98,7 +101,9 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp } }, 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 const changedSlugs = new Set() diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index f9b181dff..4fb47c54f 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -20,6 +20,7 @@ import { write } from "./helpers" import { i18n, TRANSLATIONS } from "../../i18n" import { BuildCtx } from "../../util/ctx" import { StaticResources } from "../../util/resources" +import { isUnlisted } from "../filters/unlisted" interface FolderPageOptions extends FullPageLayout { sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number } @@ -129,7 +130,9 @@ export const FolderPage: QuartzEmitterPlugin> = (user ] }, 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 folders: Set = new Set( @@ -146,7 +149,9 @@ export const FolderPage: QuartzEmitterPlugin> = (user yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources) }, 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 // Find all folders that need to be updated based on changed files diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 5f238932d..bb58d1586 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -12,6 +12,7 @@ import { write } from "./helpers" import { i18n, TRANSLATIONS } from "../../i18n" import { BuildCtx } from "../../util/ctx" import { StaticResources } from "../../util/resources" +import { isUnlisted } from "../filters/unlisted" interface TagPageOptions extends FullPageLayout { sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number @@ -122,7 +123,9 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) ] }, 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 [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale) @@ -131,7 +134,9 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) } }, 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 // Find all tags that need to be updated based on changed files diff --git a/quartz/plugins/filters/unlisted.ts b/quartz/plugins/filters/unlisted.ts new file mode 100644 index 000000000..2e42af348 --- /dev/null +++ b/quartz/plugins/filters/unlisted.ts @@ -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 +} diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index db1cf4213..97aa5250f 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -147,6 +147,7 @@ declare module "vfile" { socialDescription: string publish: boolean | string draft: boolean | string + unlisted: boolean | string lang: string enableToc: string cssclasses: string[] diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index 80115ec27..d0bbbf6a6 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -1,4 +1,5 @@ import { QuartzConfig } from "../cfg" +import { isUnlisted } from "../plugins/filters/unlisted" import { QuartzPluginData } from "../plugins/vfile" import { FileTrieNode } from "./fileTrie" import { FilePath, FullSlug } from "./path" @@ -31,10 +32,16 @@ export interface BuildCtx { incremental: boolean } -export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode { +export function trieFromAllFiles( + allFiles: QuartzPluginData[], + cfg?: QuartzConfig, +): FileTrieNode { const trie = new FileTrieNode([]) allFiles.forEach((file) => { if (file.frontmatter) { + if (cfg && isUnlisted(file, cfg.configuration)) { + return + } trie.add({ ...file, slug: file.slug!,