diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 56392b358..e422599dc 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -2,7 +2,15 @@ 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, + slugTag, +} from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" @@ -28,6 +36,9 @@ interface Options { rssFullHtml: boolean rssSlug: string includeEmptyFiles: boolean + includeTags: boolean + rssTagsLimit: number + rssTags: string[] } const defaultOptions: Options = { @@ -37,6 +48,9 @@ const defaultOptions: Options = { rssFullHtml: false, rssSlug: "index", includeEmptyFiles: true, + includeTags: false, + rssTagsLimit: 15, + rssTags: [], } function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { @@ -135,6 +149,62 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { slug: (opts?.rssSlug ?? "index") as FullSlug, ext: ".xml", }) + + 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))) + + // 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) { + // 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) { + console.warn( + "[contentIndex] includeTags is enabled, but no tag-based RSS feeds will be generated. " + + "Either provide non-empty `rssTags` matching content tags or set `rssTagsLimit` to a positive number.", + ) + } + + for (const tag of sortedTags) { + 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, + content: generateRSSFeed(cfg, tagFilteredIndex, opts.rssLimit), + slug: joinSegments("tags", tag, "index") as FullSlug, + ext: ".xml", + }) + } + } } const fp = joinSegments("static", "contentIndex") as FullSlug