From cf7bb1fe835acbbbf9b1e2932386a0176aed8b82 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Sun, 24 Nov 2024 11:10:54 +1100 Subject: [PATCH 01/13] feat(contentIndex): Per-folder RSS feeds --- docs/plugins/ContentIndex.md | 9 +- quartz/plugins/emitters/contentIndex.tsx | 398 ++++++++++++++++++--- quartz/plugins/transformers/frontmatter.ts | 1 + 3 files changed, 356 insertions(+), 52 deletions(-) diff --git a/docs/plugins/ContentIndex.md b/docs/plugins/ContentIndex.md index 037f723bf..063701161 100644 --- a/docs/plugins/ContentIndex.md +++ b/docs/plugins/ContentIndex.md @@ -4,9 +4,9 @@ tags: - plugin/emitter --- -This plugin emits both RSS and an XML sitemap for your site. The [[RSS Feed]] allows users to subscribe to content on your site and the sitemap allows search engines to better index your site. The plugin also emits a `contentIndex.json` file which is used by dynamic frontend components like search and graph. +This plugin emits both RSS feeds and an XML sitemap for your site. The [[RSS Feed]] allows users to subscribe to content on your site and the sitemap allows search engines to better index your site. The plugin also emits a `contentIndex.json` file which is used by dynamic frontend components like search and graph. -This plugin emits a comprehensive index of the site's content, generating additional resources such as a sitemap, an RSS feed, and a +This plugin emits a comprehensive index of the site's content, generating additional resources such as a sitemap and RSS feeds for each directory. > [!note] > For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. @@ -15,10 +15,15 @@ This plugin accepts the following configuration options: - `enableSiteMap`: If `true` (default), generates a sitemap XML file (`sitemap.xml`) listing all site URLs for search engines in content discovery. - `enableRSS`: If `true` (default), produces an RSS feed (`index.xml`) with recent content updates. + - For a more fine-grained approach, use `noRSS: true` in a file to remove it from feeds, or set the same in a folder's `index.md` to remove the entire folder. - `rssLimit`: Defines the maximum number of entries to include in the RSS feed, helping to focus on the most recent or relevant content. Defaults to `10`. - `rssFullHtml`: If `true`, the RSS feed includes full HTML content. Otherwise it includes just summaries. - `rssSlug`: Slug to the generated RSS feed XML file. Defaults to `"index"`. - `includeEmptyFiles`: If `true` (default), content files with no body text are included in the generated index and resources. +- `titlePattern`: custom title generator for RSS feeds based on the global configuration and the directory name of the relevant folder, and (**if it exists**) the data of the `index.md` file of the current folder. + - ex. + ``titlePattern: (cfg, dir, dirIndex) => `A feed found at ${cfg.baseUrl}/${dir}.rss: ${dirIndex != null ? dirIndex.title : "(untitled)"}` `` + - outputs: `"A feed found at my-site.com/directory.rss: Directory"` ## API diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..a7686b12d 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -2,11 +2,22 @@ import { Root } from "hast" import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" -import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" +import { + FilePath, + FullSlug, + SimpleSlug, + joinSegments, + simplifySlug, + slugifyFilePath, +} from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" +import { BuildCtx } from "../../util/ctx" +import DepGraph from "../../depgraph" +import chalk from "chalk" +import { ProcessedContent } from "../vfile" export type ContentIndexMap = Map export type ContentDetails = { @@ -19,51 +30,52 @@ export type ContentDetails = { richContent?: string date?: Date description?: string + slug?: FullSlug + noRSS?: boolean } interface Options { enableSiteMap: boolean enableRSS: boolean + bypassIndexCheck: boolean rssLimit?: number rssFullHtml: boolean rssSlug: string includeEmptyFiles: boolean + titlePattern?: (cfg: GlobalConfiguration, dir: FullSlug, dirIndex?: ContentDetails) => string } const defaultOptions: Options = { + bypassIndexCheck: false, enableSiteMap: true, enableRSS: true, rssLimit: 10, rssFullHtml: false, rssSlug: "index", includeEmptyFiles: true, + titlePattern: (cfg, dir, dirIndex) => + `${cfg.pageTitle} - ${dirIndex != null ? dirIndex.title : dir.split("/").pop()}`, } function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { const base = cfg.baseUrl ?? "" - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${joinSegments(base, encodeURI(slug))} + const createURLEntry = (content: ContentDetails): string => ` + + https://${joinSegments(base, encodeURI(simplifySlug(content.slug!)))} ${content.date && `${content.date.toISOString()}`} ` - const urls = Array.from(idx) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) - .join("") - return `${urls}` + let urls = (idx.spread() as ContentDetails[]).map((e) => createURLEntry(e)).join("") + return `${urls} +` } function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string { const base = cfg.baseUrl ?? "" + const feedTitle = opts.titlePattern!(cfg, entries.dir, entries.dirIndex) + const limit = opts?.rssLimit ?? entries.raw.length - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - ${escapeHTML(content.title)} - https://${joinSegments(base, encodeURI(slug))} - https://${joinSegments(base, encodeURI(slug))} - - ${content.date?.toUTCString()} - ` - - const items = Array.from(idx) - .sort(([_, f1], [__, f2]) => { + const sorted = entries.raw + .sort((f1, f2) => { if (f1.date && f2.date) { return f2.date.getTime() - f1.date.getTime() } else if (f1.date && !f2.date) { @@ -74,29 +86,84 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: return f1.title.localeCompare(f2.title) }) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) - .slice(0, limit ?? idx.size) - .join("") + .slice(0, limit) + .map((e) => e.item) return ` - - ${escapeHTML(cfg.pageTitle)} - https://${base} - ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( - cfg.pageTitle, - )} - Quartz -- quartz.jzhao.xyz - ${items} - - ` + + ${feedTitle} + https://${base} + ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( + cfg.pageTitle, + )} + Quartz -- quartz.jzhao.xyz${sorted.join("")} + +` +} + +function generateRSSEntry(cfg: GlobalConfiguration, details: ContentDetails): Entry { + const base = cfg.baseUrl ?? "" + + let item = ` + + ${escapeHTML(details.title)} + https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))} + https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))} + ${details.richContent ?? details.description} + ${details.date?.toUTCString()} + ` + + return { + item: item, + date: details.date!, // Safety: guaranteed non-null by Tree -> Tree + title: details.title, + } } export const ContentIndex: QuartzEmitterPlugin> = (opts) => { opts = { ...defaultOptions, ...opts } return { name: "ContentIndex", - async *emit(ctx, content) { + async getDependencyGraph(ctx, content, _resources) { + const graph = new DepGraph() + + for (const [_tree, file] of content) { + const sourcePath = file.data.filePath! + + graph.addEdge( + sourcePath, + joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath, + ) + if (opts?.enableSiteMap) { + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath) + } + if (opts?.enableRSS) { + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath) + } + } + + return graph + }, + async emit(ctx, content, _resources) { + // If we're missing an index file, don't bother with sitemap/RSS gen + if ( + !( + opts?.bypassIndexCheck || + content.map(([_, c]) => c.data.slug!).includes("index" as FullSlug) + ) + ) { + console.warn( + chalk.yellow(`Warning: contentIndex: + content/ folder is missing an index.md. RSS feeds and sitemap will not be generated. + If you still wish to generate these files, add: + bypassIndexCheck: true, + to your configuration for Plugin.ContentIndex({...}) in quartz.config.ts. + Don't do this unless you know what you're doing!`), + ) + return [] + } + const cfg = ctx.cfg.configuration const linkIndex: ContentIndexMap = new Map() for (const [tree, file] of content) { @@ -118,43 +185,95 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { }) } } + for (const [tree, file] of content) { + // Create tree strucutre + var pointer = indexTree + const dirs = file.data.relativePath?.split("/").slice(0, -1) ?? [] + + // Skips descent if file is top-level (ex. content/index.md) + for (var i = 1; i <= dirs.length; i++) { + // Initialize directories + let feed = { + dir: dirs!.slice(0, i).join("/") as FullSlug, + raw: new Array(), + } + pointer = pointer.child(feed) + } + + // Initialize children + // a. parse ContentDetails of file + // b. (if exists) add the dir index to the enclosing feed + // c. terminate branch with file's ContentDetails + let details = detailsOf([tree, file]) + if (file.stem == "index") { + let feed = pointer.data as Feed + + feed.dirIndex = details + } + pointer = pointer.child(details) + } if (opts?.enableSiteMap) { - yield write({ - ctx, - content: generateSiteMap(cfg, linkIndex), - slug: "sitemap" as FullSlug, - ext: ".xml", - }) + emitted.push( + write({ + ctx, + content: generateSiteMap(cfg, indexTree), + slug: "sitemap" as FullSlug, + ext: ".xml", + }), + ) } if (opts?.enableRSS) { - yield write({ - ctx, - content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), - slug: (opts?.rssSlug ?? "index") as FullSlug, - ext: ".xml", - }) + var feedTree: Tree = indexTree + + // 1. In-place Tree -> Tree + // TreeNode becomes either: + // data Feed with children Feed | ContentDetails + // ContentDetails with empty children + // 2. Finish each Feed and emit + // Each Feed now has an Entry[] of enclosed RSS items, to be composed + // with the Entry[]s of child Feeds (bottom-up) + // before wrapping with RSS tags to be emitted as one string + feedTree.acceptPostorder(new FeedGenerator(ctx, cfg, opts, emitted)) + + // Generate index feed separately re-using the Entry[] composed upwards + emitted.push( + write({ + ctx, + content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), + slug: (opts?.rssSlug ?? "index") as FullSlug, + ext: ".xml", + }), + ) } + // Generate ContentIndex const fp = joinSegments("static", "contentIndex") as FullSlug const simplifiedIndex = Object.fromEntries( - Array.from(linkIndex).map(([slug, content]) => { + (indexTree.spread() as ContentDetails[]).map((content) => { // remove description and from content index as nothing downstream // actually uses it. we only keep it in the index as we need it // for the RSS feed delete content.description delete content.date + delete content.noRSS + + var slug = content.slug + delete content.slug return [slug, content] }), ) + emitted.push( + write({ + ctx, + content: JSON.stringify(simplifiedIndex), + slug: fp, + ext: ".json", + }), + ) - yield write({ - ctx, - content: JSON.stringify(simplifiedIndex), - slug: fp, - ext: ".json", - }) + return await Promise.all(emitted) }, externalResources: (ctx) => { if (opts?.enableRSS) { @@ -172,3 +291,182 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { }, } } + +class Tree { + children: Set> + data: T + childComparator: (a: T, b: T) => boolean + + constructor(data: T, childComparator: (a: T, b: T) => boolean, children?: Set>) { + this.data = data + this.children = children ?? new Set>() + this.childComparator = childComparator + } + + // BFS insertion-order traversal + accept(visitor: Visitor, parent?: Tree) { + visitor.visit(parent ?? this, this) // Root has no parent + + for (var child of this.children) { + var childVisitor = visitor.descend(child) + child.accept(childVisitor, this) + } + } + + // Visit children before parent + acceptPostorder(visitor: Visitor, parent?: Tree) { + let branchesFirst = [...this.children].toSorted((_, c2) => (c2.children.size > 0 ? 1 : -1)) + + for (var child of branchesFirst) { + var childVisitor = visitor.descend(child) + child.acceptPostorder(childVisitor, this) + } + + visitor.visit(parent ?? this, this) + } + + child(data: T): Tree { + for (var child of this.children) { + if (this.childComparator(child.data, data)) { + return child + } + } + + return this.childFromTree(new Tree(data, this.childComparator)) + } + + childFromTree(child: Tree): Tree { + this.children.add(child) + return child + } + + // Convert entire tree to array of only its leaves + // ex. Tree -> ContentDetails[] + spread(): T[] { + var flattened: T[] = [] + + const flatten = (tree: Tree) => { + for (let child of tree.children) { + if (child.children.size == 0) flattened.push(child.data) + else flatten(child) + } + } + + flatten(this) + return flattened + } +} + +interface Visitor { + // Prefix action at each tree level + descend: (tree: Tree) => Visitor + + // Action at each child of parent + visit: (parent: Tree, tree: Tree) => void +} + +// Hierarchy of directories with metadata children +// To be turned into a hierarchy of RSS text arrays generated from metadata children +type TreeNode = ContentDetails | Feed + +// All of the files in one folder, as RSS entries +// Entry[] is the vehicle for composition while keeping content metadata intact +type Feed = { + dir: FullSlug + raw: Entry[] + dirIndex?: ContentDetails +} +function defaultFeed(): Feed { + return { + dir: "index" as FullSlug, + raw: new Array(), + } +} + +type Entry = { + item: string + // Must be maintained for sorting purposes + date: Date + title: string +} + +// Type guards +function isFeed(feed: TreeNode): boolean { + return Object.hasOwn(feed, "dir") +} + +function isContentDetails(details: TreeNode): boolean { + return Object.hasOwn(details, "slug") +} + +function compareTreeNodes(a: TreeNode, b: TreeNode) { + let feedComp = isFeed(a) && isFeed(b) && (a as Feed).dir == (b as Feed).dir + let contentComp = + isContentDetails(a) && isContentDetails(b) && (a as ContentDetails) == (b as ContentDetails) + return feedComp || contentComp +} + +type IndexVisitor = Visitor // ContentIndex in interface form + +// Note: only use with acceptPostorder +class FeedGenerator implements IndexVisitor { + ctx: BuildCtx + cfg: GlobalConfiguration + opts: Partial + emitted: Promise[] + + constructor( + ctx: BuildCtx, + cfg: GlobalConfiguration, + opts: Partial, + emitted: Promise[], + ) { + this.ctx = ctx + this.cfg = cfg + this.opts = opts + this.emitted = emitted + } + + descend(_: ContentIndex): FeedGenerator { + return this + } + + visit(parent: ContentIndex, tree: ContentIndex) { + // Compose direct child Feeds' Entry[]s with the current level + // Because this Visitor visits bottom up, works at every level + if (isFeed(tree.data)) { + let feed = tree.data as Feed + tree.children.forEach((child, _) => { + if (isFeed(child.data)) feed.raw.push(...(child.data as Feed).raw) + }) + } + + // Handle the top-level Feed separately + // bfahrenfort: this is really just a design choice to preserve "index.xml"; + // if desired we could generate it uniformly with the composition instead + if (tree === parent) return + + if (tree.children.size == 0 && !(tree.data as ContentDetails).noRSS) { + // Generate RSS item and push to parent Feed's Entry[] + let feed = parent.data as Feed + feed.raw.push(generateRSSEntry(this.cfg, tree.data as ContentDetails)) + } + + if (isFeed(tree.data)) { + // Handle all non-index feeds + let feed = tree.data as Feed + if (!(feed.dirIndex?.noRSS ?? false)) { + let ctx = this.ctx + + this.emitted.push( + write({ + ctx, + content: finishRSSFeed(this.cfg, this.opts, feed), + slug: feed.dir, + ext: ".rss", + }), + ) + } + } + } +} diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 1103900c5..014e0da56 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -147,6 +147,7 @@ declare module "vfile" { publish: boolean | string draft: boolean | string lang: string + noRSS: boolean enableToc: string cssclasses: string[] socialImage: string From c65e6836b47395c0dc31d5bd536681e8c91dc120 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Sun, 24 Nov 2024 11:10:54 +1100 Subject: [PATCH 02/13] feat(contentIndex): Per-folder RSS feeds --- docs/features/RSS Feed.md | 8 +++- quartz/plugins/emitters/contentIndex.tsx | 52 ++++++++++++------------ 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/docs/features/RSS Feed.md b/docs/features/RSS Feed.md index 4b1a1bb3e..5fb609ee4 100644 --- a/docs/features/RSS Feed.md +++ b/docs/features/RSS Feed.md @@ -1,10 +1,16 @@ -Quartz emits an RSS feed for all the content on your site by generating an `index.xml` file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly. +Quartz emits an RSS feed for all the content on your site by generating an file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly. > [!info] > After deploying, the generated RSS link will be available at `https://${baseUrl}/index.xml` by default. > > The `index.xml` path can be customized by passing the `rssSlug` option to the [[ContentIndex]] plugin. +Quartz also generates RSS feeds for all subdirectories on your site. Add `.rss` to the end of the directory link to download an RSS file limited to the content in that directory and its subdirectories. + +- Subdirectories containing an `index.md` file with `noRSS: true` in the frontmatter will not generate an RSS feed. + - The entries in that subdirectory will still be present in the default feed. +- You can hide a file from all RSS feeds by putting `noRSS: true` in that file's frontmatter. + ## Configuration This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options. diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index a7686b12d..f94b7f1c0 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -19,7 +19,7 @@ import DepGraph from "../../depgraph" import chalk from "chalk" import { ProcessedContent } from "../vfile" -export type ContentIndexMap = Map +type ContentIndex = Tree export type ContentDetails = { slug: FullSlug filePath: FilePath @@ -40,7 +40,7 @@ interface Options { bypassIndexCheck: boolean rssLimit?: number rssFullHtml: boolean - rssSlug: string + rssSlug: FullSlug includeEmptyFiles: boolean titlePattern?: (cfg: GlobalConfiguration, dir: FullSlug, dirIndex?: ContentDetails) => string } @@ -51,13 +51,13 @@ const defaultOptions: Options = { enableRSS: true, rssLimit: 10, rssFullHtml: false, - rssSlug: "index", + rssSlug: "index" as FullSlug, includeEmptyFiles: true, titlePattern: (cfg, dir, dirIndex) => `${cfg.pageTitle} - ${dirIndex != null ? dirIndex.title : dir.split("/").pop()}`, } -function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { +function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (content: ContentDetails): string => ` @@ -69,7 +69,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string ` } -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string { +function finishRSSFeed(cfg: GlobalConfiguration, opts: Partial, entries: Feed): string { const base = cfg.baseUrl ?? "" const feedTitle = opts.titlePattern!(cfg, entries.dir, entries.dirIndex) const limit = opts?.rssLimit ?? entries.raw.length @@ -165,24 +165,26 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { } const cfg = ctx.cfg.configuration - 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, - filePath: file.data.relativePath!, - title: file.data.frontmatter?.title!, - links: file.data.links ?? [], - tags: file.data.frontmatter?.tags ?? [], - content: file.data.text ?? "", - richContent: opts?.rssFullHtml - ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) - : undefined, - date: date, - description: file.data.description ?? "", - }) + const emitted: Promise[] = [] + var indexTree = new Tree(defaultFeed(), compareTreeNodes) + + // ProcessedContent[] -> Tree + // bfahrenfort: If I could finagle a Visitor pattern to cross + // different datatypes (TransformVisitor?), half of this pass could be + // folded into the FeedGenerator postorder accept + const detailsOf = ([tree, file]: ProcessedContent): ContentDetails => { + return { + title: file.data.frontmatter?.title!, + links: file.data.links ?? [], + tags: file.data.frontmatter?.tags ?? [], + content: file.data.text ?? "", + richContent: opts?.rssFullHtml + ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + : undefined, + date: getDate(ctx.cfg.configuration, file.data) ?? new Date(), + description: file.data.description ?? "", + slug: slugifyFilePath(file.data.relativePath!, true), + noRSS: file.data.frontmatter?.noRSS ?? false, } } for (const [tree, file] of content) { @@ -241,8 +243,8 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { emitted.push( write({ ctx, - content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), - slug: (opts?.rssSlug ?? "index") as FullSlug, + content: finishRSSFeed(cfg, opts, feedTree.data as Feed), + slug: opts.rssSlug!, // Safety: defaults to "index" ext: ".xml", }), ) From 5ca088bb4494f9be2eb0ff089ab909818211aa9d Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Thu, 6 Mar 2025 08:02:21 -0600 Subject: [PATCH 03/13] rebase: a20110 --- quartz/plugins/emitters/contentIndex.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index f94b7f1c0..f4012782a 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -40,7 +40,7 @@ interface Options { bypassIndexCheck: boolean rssLimit?: number rssFullHtml: boolean - rssSlug: FullSlug + rssSlug: string includeEmptyFiles: boolean titlePattern?: (cfg: GlobalConfiguration, dir: FullSlug, dirIndex?: ContentDetails) => string } @@ -51,7 +51,7 @@ const defaultOptions: Options = { enableRSS: true, rssLimit: 10, rssFullHtml: false, - rssSlug: "index" as FullSlug, + rssSlug: "index", includeEmptyFiles: true, titlePattern: (cfg, dir, dirIndex) => `${cfg.pageTitle} - ${dirIndex != null ? dirIndex.title : dir.split("/").pop()}`, @@ -240,14 +240,27 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { feedTree.acceptPostorder(new FeedGenerator(ctx, cfg, opts, emitted)) // Generate index feed separately re-using the Entry[] composed upwards + let topFeed = finishRSSFeed(cfg, opts, feedTree.data as Feed) emitted.push( write({ ctx, - content: finishRSSFeed(cfg, opts, feedTree.data as Feed), - slug: opts.rssSlug!, // Safety: defaults to "index" + content: topFeed, + slug: opts.rssSlug! as FullSlug, // Safety: defaults to "index" ext: ".xml", }), ) + + // Reader compatibility, don't break existing readers if the path changes + if (opts.rssSlug !== defaultOptions.rssSlug) { + emitted.push( + write({ + ctx, + content: topFeed, + slug: "index" as FullSlug, + ext: ".xml", + }), + ) + } } // Generate ContentIndex From 14316557ab717c1b52cd93d8459edbd389b3442a Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Thu, 6 Mar 2025 14:10:59 -0600 Subject: [PATCH 04/13] lint(docs): grammar --- docs/features/RSS Feed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/RSS Feed.md b/docs/features/RSS Feed.md index 5fb609ee4..237e2c501 100644 --- a/docs/features/RSS Feed.md +++ b/docs/features/RSS Feed.md @@ -1,4 +1,4 @@ -Quartz emits an RSS feed for all the content on your site by generating an file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly. +Quartz emits an RSS feed for all the content on your site by generating a file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly. > [!info] > After deploying, the generated RSS link will be available at `https://${baseUrl}/index.xml` by default. From 7b90d4feb1966d925acc4e6b1db97b6333b0e8a1 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Thu, 6 Mar 2025 15:22:34 -0600 Subject: [PATCH 05/13] fix(contentIndex): respect rssSlug in head tags --- quartz/plugins/emitters/contentIndex.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index f4012782a..67d487fcd 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -298,7 +298,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { rel="alternate" type="application/rss+xml" title="RSS Feed" - href={`https://${ctx.cfg.configuration.baseUrl}/index.xml`} + href={`https://${ctx.cfg.configuration.baseUrl}/${opts.rssSlug!}.xml`} />, ], } From 7e852aa427a1ef033ae7d038f4111c08aee8d4b5 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Mon, 17 Mar 2025 13:09:00 -0500 Subject: [PATCH 06/13] rebase(contentIndex): refactor to align with new emit interface --- quartz/plugins/emitters/contentIndex.tsx | 33 +++++------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 67d487fcd..723e8b78c 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -8,20 +8,18 @@ import { SimpleSlug, joinSegments, simplifySlug, - slugifyFilePath, } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" import { BuildCtx } from "../../util/ctx" -import DepGraph from "../../depgraph" import chalk from "chalk" import { ProcessedContent } from "../vfile" type ContentIndex = Tree export type ContentDetails = { - slug: FullSlug + slug?: FullSlug filePath: FilePath title: string links: SimpleSlug[] @@ -30,7 +28,6 @@ export type ContentDetails = { richContent?: string date?: Date description?: string - slug?: FullSlug noRSS?: boolean } @@ -125,27 +122,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { opts = { ...defaultOptions, ...opts } return { name: "ContentIndex", - async getDependencyGraph(ctx, content, _resources) { - const graph = new DepGraph() - - for (const [_tree, file] of content) { - const sourcePath = file.data.filePath! - - graph.addEdge( - sourcePath, - joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath, - ) - if (opts?.enableSiteMap) { - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath) - } - if (opts?.enableRSS) { - graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath) - } - } - - return graph - }, - async emit(ctx, content, _resources) { + async *emit(ctx, content) { // If we're missing an index file, don't bother with sitemap/RSS gen if ( !( @@ -174,6 +151,8 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { // folded into the FeedGenerator postorder accept const detailsOf = ([tree, file]: ProcessedContent): ContentDetails => { return { + slug: file.data.slug!, + filePath: file.data.relativePath!, title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], @@ -183,7 +162,6 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { : undefined, date: getDate(ctx.cfg.configuration, file.data) ?? new Date(), description: file.data.description ?? "", - slug: slugifyFilePath(file.data.relativePath!, true), noRSS: file.data.frontmatter?.noRSS ?? false, } } @@ -288,7 +266,8 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { }), ) - return await Promise.all(emitted) + // Promise[] -> Promise + return Promise.all(emitted) }, externalResources: (ctx) => { if (opts?.enableRSS) { From 96a634eb4141deec7438beef76cbc06084f2c183 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Mon, 17 Mar 2025 13:09:32 -0500 Subject: [PATCH 07/13] lint(contentIndex): format --- quartz/plugins/emitters/contentIndex.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 723e8b78c..4d1fab11b 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -2,13 +2,7 @@ import { Root } from "hast" import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" -import { - FilePath, - FullSlug, - SimpleSlug, - joinSegments, - simplifySlug, -} from "../../util/path" +import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" From 53602442c0508fb1171903257d21bf9eb0b055bb Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Mon, 17 Mar 2025 13:11:09 -0500 Subject: [PATCH 08/13] fix(contentIndex): explorer compat --- quartz/plugins/emitters/contentIndex.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 4d1fab11b..39c9cbcce 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -13,7 +13,7 @@ import { ProcessedContent } from "../vfile" type ContentIndex = Tree export type ContentDetails = { - slug?: FullSlug + slug: FullSlug filePath: FilePath title: string links: SimpleSlug[] @@ -246,9 +246,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { delete content.date delete content.noRSS - var slug = content.slug - delete content.slug - return [slug, content] + return [content.slug, content] }), ) emitted.push( From fe245d920bdb51cea1700de753d256fc17bbf702 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Sun, 24 Aug 2025 09:38:59 +0200 Subject: [PATCH 09/13] fix(contentIndex): replace Chalk with picocolors --- package-lock.json | 1 + package.json | 1 + quartz/plugins/emitters/contentIndex.tsx | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d50e86d9..d0eaf0483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", "minimatch": "^10.0.3", + "picocolors": "^1.1.1", "pixi.js": "^8.12.0", "preact": "^10.27.0", "preact-render-to-string": "^6.5.13", diff --git a/package.json b/package.json index 790af38b0..132b6e204 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", "minimatch": "^10.0.3", + "picocolors": "^1.1.1", "pixi.js": "^8.12.0", "preact": "^10.27.0", "preact-render-to-string": "^6.5.13", diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 39c9cbcce..e29d860b8 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -8,7 +8,7 @@ import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" import { BuildCtx } from "../../util/ctx" -import chalk from "chalk" +import pc from "picocolors" import { ProcessedContent } from "../vfile" type ContentIndex = Tree @@ -125,7 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { ) ) { console.warn( - chalk.yellow(`Warning: contentIndex: + pc.yellow(`Warning: contentIndex: content/ folder is missing an index.md. RSS feeds and sitemap will not be generated. If you still wish to generate these files, add: bypassIndexCheck: true, From 3ea901334d6947ad32403d5348e946b0a6ee8509 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Sun, 24 Aug 2025 09:59:58 +0200 Subject: [PATCH 10/13] fix(contentIndex): restore empty file choice --- quartz/plugins/emitters/contentIndex.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index e29d860b8..cd2741212 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -164,6 +164,11 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { var pointer = indexTree const dirs = file.data.relativePath?.split("/").slice(0, -1) ?? [] + // If file is blank, don't include it unless specified + if (!opts?.includeEmptyFiles || (file.data.text && file.data.text === "")) { + continue + } + // Skips descent if file is top-level (ex. content/index.md) for (var i = 1; i <= dirs.length; i++) { // Initialize directories From fe65819cffe15bb7355f300b8af8df0e74ff0de1 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Sun, 24 Aug 2025 10:04:18 +0200 Subject: [PATCH 11/13] fix(contentIndex): restore cdata --- quartz/plugins/emitters/contentIndex.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index cd2741212..b878fedf8 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -101,7 +101,7 @@ function generateRSSEntry(cfg: GlobalConfiguration, details: ContentDetails): En ${escapeHTML(details.title)} https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))} https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))} - ${details.richContent ?? details.description} + ${details.date?.toUTCString()} ` From 2e00035d54cb4d2f7014615a055b16f865ac3234 Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Mon, 25 Aug 2025 16:18:53 -0500 Subject: [PATCH 12/13] contentIndex: remove picocolors --- package-lock.json | 1 - package.json | 1 - quartz/plugins/emitters/contentIndex.tsx | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d0eaf0483..2d50e86d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,6 @@ "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", "minimatch": "^10.0.3", - "picocolors": "^1.1.1", "pixi.js": "^8.12.0", "preact": "^10.27.0", "preact-render-to-string": "^6.5.13", diff --git a/package.json b/package.json index 132b6e204..790af38b0 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", "minimatch": "^10.0.3", - "picocolors": "^1.1.1", "pixi.js": "^8.12.0", "preact": "^10.27.0", "preact-render-to-string": "^6.5.13", diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index b878fedf8..f999ddb0b 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -8,7 +8,7 @@ import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" import { BuildCtx } from "../../util/ctx" -import pc from "picocolors" +import { styleText } from "util" import { ProcessedContent } from "../vfile" type ContentIndex = Tree @@ -125,7 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { ) ) { console.warn( - pc.yellow(`Warning: contentIndex: + styleText("yellow", `Warning: contentIndex: content/ folder is missing an index.md. RSS feeds and sitemap will not be generated. If you still wish to generate these files, add: bypassIndexCheck: true, From 8757edb88cc0e22ae13110faa6e567c7ec78e46d Mon Sep 17 00:00:00 2001 From: bfahrenfort Date: Mon, 25 Aug 2025 16:19:27 -0500 Subject: [PATCH 13/13] lint: format --- quartz/plugins/emitters/contentIndex.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index f999ddb0b..a0f442b48 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -125,12 +125,15 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { ) ) { console.warn( - styleText("yellow", `Warning: contentIndex: + styleText( + "yellow", + `Warning: contentIndex: content/ folder is missing an index.md. RSS feeds and sitemap will not be generated. If you still wish to generate these files, add: bypassIndexCheck: true, to your configuration for Plugin.ContentIndex({...}) in quartz.config.ts. - Don't do this unless you know what you're doing!`), + Don't do this unless you know what you're doing!`, + ), ) return [] }