From 47433a9068635842f3c9228075fffc12a6c8fe18 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai Date: Sat, 17 Jan 2026 21:53:18 +0530 Subject: [PATCH 1/8] feat(contentIndex): add optional tag-based RSS feed generation - Add 'includeTags' and 'rssTagsLimit' options to ContentIndex plugin - Implement logic to generate RSS feeds for unique tags - Output feeds to 'tags//index.xml' - Support limiting the number of generated tag feeds by frequency --- quartz/plugins/emitters/contentIndex.tsx | 43 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..e00a87f58 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -2,7 +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, getAllSegmentPrefixes, joinSegments, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" @@ -28,6 +28,8 @@ interface Options { rssFullHtml: boolean rssSlug: string includeEmptyFiles: boolean + includeTags: boolean + rssTagsLimit: number } const defaultOptions: Options = { @@ -37,6 +39,8 @@ const defaultOptions: Options = { rssFullHtml: false, rssSlug: "index", includeEmptyFiles: true, + includeTags: false, + rssTagsLimit: 15, } function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { @@ -84,8 +88,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: ${escapeHTML(cfg.pageTitle)} https://${base} ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( - cfg.pageTitle, - )} + cfg.pageTitle, + )} Quartz -- quartz.jzhao.xyz ${items} @@ -135,6 +139,39 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { slug: (opts?.rssSlug ?? "index") as FullSlug, ext: ".xml", }) + + if (opts?.includeTags && (opts.rssTagsLimit ?? 0) > 0) { + const tagCounts: Map = new Map() + + // Count tags from all non-empty files (unless includeEmptyFiles is true) + for (const [_, content] of linkIndex) { + const tags = content.tags.flatMap(getAllSegmentPrefixes) + for (const tag of new Set(tags)) { // Use Set to avoid double counting per file + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1) + } + } + + const sortedTags = Array.from(tagCounts.entries()) + .sort((a, b) => b[1] - a[1]) // Sort by frequency descending + .slice(0, opts.rssTagsLimit) + .map(([tag]) => tag) + + for (const tag of sortedTags) { + const tagFilteredIndex = new Map( + Array.from(linkIndex).filter(([_, content]) => { + const fileTags = new Set(content.tags.flatMap(getAllSegmentPrefixes)) + return fileTags.has(tag) + }) + ) + + yield write({ + ctx, + content: generateRSSFeed(cfg, tagFilteredIndex, opts.rssLimit), + slug: joinSegments("tags", tag, "index") as FullSlug, + ext: ".xml", + }) + } + } } const fp = joinSegments("static", "contentIndex") as FullSlug From 7bfbb6016aa23628e91d61ad30fc035c737531b7 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai Date: Sat, 17 Jan 2026 22:24:20 +0530 Subject: [PATCH 2/8] feat(contentIndex): add flexible tag-based RSS feed generation Enable generating RSS feeds for specific tags via `rssTags` option --- quartz/plugins/emitters/contentIndex.tsx | 32 +++++++++++++++--------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index e00a87f58..ef51a7fa4 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -30,6 +30,7 @@ interface Options { includeEmptyFiles: boolean includeTags: boolean rssTagsLimit: number + rssTags: string[] } const defaultOptions: Options = { @@ -41,6 +42,7 @@ const defaultOptions: Options = { includeEmptyFiles: true, includeTags: false, rssTagsLimit: 15, + rssTags: [], } function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { @@ -140,21 +142,27 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { ext: ".xml", }) - if (opts?.includeTags && (opts.rssTagsLimit ?? 0) > 0) { - const tagCounts: Map = new Map() + if (opts?.includeTags) { + let sortedTags: string[] = [] - // Count tags from all non-empty files (unless includeEmptyFiles is true) - for (const [_, content] of linkIndex) { - const tags = content.tags.flatMap(getAllSegmentPrefixes) - for (const tag of new Set(tags)) { // Use Set to avoid double counting per file - tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1) + if (opts.rssTags && opts.rssTags.length > 0) { + sortedTags = opts.rssTags + } else if ((opts.rssTagsLimit ?? 0) > 0) { + const tagCounts: Map = new Map() + + // Count tags from all non-empty files (unless includeEmptyFiles is true) + for (const [_, content] of linkIndex) { + const tags = content.tags.flatMap(getAllSegmentPrefixes) + for (const tag of new Set(tags)) { // Use Set to avoid double counting per file + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1) + } } - } - const sortedTags = Array.from(tagCounts.entries()) - .sort((a, b) => b[1] - a[1]) // Sort by frequency descending - .slice(0, opts.rssTagsLimit) - .map(([tag]) => tag) + sortedTags = Array.from(tagCounts.entries()) + .sort((a, b) => b[1] - a[1]) // Sort by frequency descending + .slice(0, opts.rssTagsLimit) + .map(([tag]) => tag) + } for (const tag of sortedTags) { const tagFilteredIndex = new Map( From d80a863a77ce56308de055bfb5be6d8bbc8bee80 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai Date: Sat, 17 Jan 2026 22:32:07 +0530 Subject: [PATCH 3/8] prettified --- quartz/plugins/emitters/contentIndex.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index ef51a7fa4..7b0f98cd3 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -2,7 +2,14 @@ import { Root } from "hast" import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" -import { FilePath, FullSlug, SimpleSlug, getAllSegmentPrefixes, joinSegments, simplifySlug } from "../../util/path" +import { + FilePath, + FullSlug, + SimpleSlug, + getAllSegmentPrefixes, + joinSegments, + simplifySlug, +} from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" @@ -90,8 +97,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: ${escapeHTML(cfg.pageTitle)} https://${base} ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( - cfg.pageTitle, - )} + cfg.pageTitle, + )} Quartz -- quartz.jzhao.xyz ${items} @@ -153,7 +160,8 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { // Count tags from all non-empty files (unless includeEmptyFiles is true) for (const [_, content] of linkIndex) { const tags = content.tags.flatMap(getAllSegmentPrefixes) - for (const tag of new Set(tags)) { // Use Set to avoid double counting per file + for (const tag of new Set(tags)) { + // Use Set to avoid double counting per file tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1) } } @@ -169,7 +177,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { Array.from(linkIndex).filter(([_, content]) => { const fileTags = new Set(content.tags.flatMap(getAllSegmentPrefixes)) return fileTags.has(tag) - }) + }), ) yield write({ From 1d1c1f1ca9d0c1c24b5e072d1786ca7be7762ee5 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai <102371942+suryaanshrai@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:34:53 +0530 Subject: [PATCH 4/8] Update quartz/plugins/emitters/contentIndex.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 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 7b0f98cd3..a40bcd3b1 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -157,7 +157,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { } else if ((opts.rssTagsLimit ?? 0) > 0) { const tagCounts: Map = new Map() - // Count tags from all non-empty files (unless includeEmptyFiles is true) + // Count tag occurrences across all files in the index for (const [_, content] of linkIndex) { const tags = content.tags.flatMap(getAllSegmentPrefixes) for (const tag of new Set(tags)) { From bb23824ab3c4892bdf10a94288458d9967c5a1d2 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai <102371942+suryaanshrai@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:35:45 +0530 Subject: [PATCH 5/8] Update quartz/plugins/emitters/contentIndex.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- quartz/plugins/emitters/contentIndex.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index a40bcd3b1..0215c2048 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -172,6 +172,16 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { .map(([tag]) => tag) } + if ( + sortedTags.length === 0 && + (!opts.rssTags || opts.rssTags.length === 0) && + (opts.rssTagsLimit ?? 0) <= 0 + ) { + console.warn( + "[contentIndex] includeTags is enabled, but no tag-based RSS feeds will be generated. " + + "Either provide non-empty `rssTags` or set `rssTagsLimit` to a positive number.", + ) + } for (const tag of sortedTags) { const tagFilteredIndex = new Map( Array.from(linkIndex).filter(([_, content]) => { From b91651f9a7738b51a6dca68c172a8b79bbf57b90 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai <102371942+suryaanshrai@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:36:10 +0530 Subject: [PATCH 6/8] Update quartz/plugins/emitters/contentIndex.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- quartz/plugins/emitters/contentIndex.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 0215c2048..6ffc96ed4 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -153,7 +153,15 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { let sortedTags: string[] = [] if (opts.rssTags && opts.rssTags.length > 0) { - sortedTags = opts.rssTags + // Only include user-specified tags that actually exist in the content + const availableTags = new Set() + for (const [_, content] of linkIndex) { + const tags = content.tags.flatMap(getAllSegmentPrefixes) + for (const tag of tags) { + availableTags.add(tag) + } + } + sortedTags = opts.rssTags.filter((tag) => availableTags.has(tag)) } else if ((opts.rssTagsLimit ?? 0) > 0) { const tagCounts: Map = new Map() From 84ff4012d10df599df23bfebadfd5ab5b31945c3 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai Date: Sat, 17 Jan 2026 22:47:01 +0530 Subject: [PATCH 7/8] fix: deduplication and slugiication of tags based on copilot's review --- quartz/plugins/emitters/contentIndex.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 6ffc96ed4..cac738677 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -9,6 +9,7 @@ import { getAllSegmentPrefixes, joinSegments, simplifySlug, + slugTag, } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" @@ -153,6 +154,9 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { let sortedTags: string[] = [] if (opts.rssTags && opts.rssTags.length > 0) { + // Deduplicate and slugify user-provided tags + const userTags = new Set(opts.rssTags.map((tag) => slugTag(tag))) + // Only include user-specified tags that actually exist in the content const availableTags = new Set() for (const [_, content] of linkIndex) { @@ -161,7 +165,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { availableTags.add(tag) } } - sortedTags = opts.rssTags.filter((tag) => availableTags.has(tag)) + sortedTags = Array.from(userTags).filter((tag) => availableTags.has(tag)) } else if ((opts.rssTagsLimit ?? 0) > 0) { const tagCounts: Map = new Map() From 50ce65a2b42b1014a864fb7d4605f5c8962eb431 Mon Sep 17 00:00:00 2001 From: Suryaansh Rai Date: Sat, 17 Jan 2026 22:56:41 +0530 Subject: [PATCH 8/8] fix: redundant code Copilot reviews --- quartz/plugins/emitters/contentIndex.tsx | 63 +++++++++++------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index cac738677..e422599dc 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -151,56 +151,51 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { }) if (opts?.includeTags) { + // Optimization: Build a map of tag -> content list once (reverse index) + const tagsInput: Map = new Map() + + // Iterate over the content index once to populate the reverse index + for (const [_, content] of linkIndex) { + const tags = content.tags.flatMap(getAllSegmentPrefixes) + for (const tag of new Set(tags)) { + // Use Set to avoid double counting per file + if (!tagsInput.has(tag)) { + tagsInput.set(tag, []) + } + tagsInput.get(tag)!.push(content) + } + } + let sortedTags: string[] = [] if (opts.rssTags && opts.rssTags.length > 0) { // Deduplicate and slugify user-provided tags const userTags = new Set(opts.rssTags.map((tag) => slugTag(tag))) - // Only include user-specified tags that actually exist in the content - const availableTags = new Set() - for (const [_, content] of linkIndex) { - const tags = content.tags.flatMap(getAllSegmentPrefixes) - for (const tag of tags) { - availableTags.add(tag) - } - } - sortedTags = Array.from(userTags).filter((tag) => availableTags.has(tag)) + // Filter user tags to only those that exist in the content + sortedTags = Array.from(userTags).filter((tag) => tagsInput.has(tag)) } else if ((opts.rssTagsLimit ?? 0) > 0) { - const tagCounts: Map = new Map() - - // Count tag occurrences across all files in the index - for (const [_, content] of linkIndex) { - const tags = content.tags.flatMap(getAllSegmentPrefixes) - for (const tag of new Set(tags)) { - // Use Set to avoid double counting per file - tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1) - } - } - - sortedTags = Array.from(tagCounts.entries()) - .sort((a, b) => b[1] - a[1]) // Sort by frequency descending + // Sort available tags by frequency (number of content items) + sortedTags = Array.from(tagsInput.entries()) + .sort((a, b) => b[1].length - a[1].length) // Sort by frequency descending .slice(0, opts.rssTagsLimit) .map(([tag]) => tag) } - if ( - sortedTags.length === 0 && - (!opts.rssTags || opts.rssTags.length === 0) && - (opts.rssTagsLimit ?? 0) <= 0 - ) { + if (sortedTags.length === 0) { console.warn( "[contentIndex] includeTags is enabled, but no tag-based RSS feeds will be generated. " + - "Either provide non-empty `rssTags` or set `rssTagsLimit` to a positive number.", + "Either provide non-empty `rssTags` matching content tags or set `rssTagsLimit` to a positive number.", ) } + for (const tag of sortedTags) { - const tagFilteredIndex = new Map( - Array.from(linkIndex).filter(([_, content]) => { - const fileTags = new Set(content.tags.flatMap(getAllSegmentPrefixes)) - return fileTags.has(tag) - }), - ) + const tagContent = tagsInput.get(tag) + if (!tagContent) continue // Should not happen given logic above + + // Reconstruct a map for generateRSSFeed (it expects a ContentIndexMap) + // We can optimize this by making generateRSSFeed accept an array, but for now we conform to the interface + const tagFilteredIndex = new Map(tagContent.map((content) => [content.slug, content])) yield write({ ctx,