diff --git a/docs/features/RSS Feed.md b/docs/features/RSS Feed.md index ed4138dfc..c879bb825 100644 --- a/docs/features/RSS Feed.md +++ b/docs/features/RSS Feed.md @@ -1,5 +1,11 @@ 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 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 `index.xml` 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/docs/plugins/ContentIndex.md b/docs/plugins/ContentIndex.md index eb7265d47..61940f38b 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,9 +15,14 @@ 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. - `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.ts b/quartz/plugins/emitters/contentIndex.ts index c0fef86d2..1d64bcec1 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -2,14 +2,24 @@ 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 ContentIndex = Map +type ContentIndex = Tree export type ContentDetails = { title: string links: SimpleSlug[] @@ -18,49 +28,50 @@ 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 includeEmptyFiles: boolean + titlePattern?: (cfg: GlobalConfiguration, dir: FullSlug, dirIndex?: ContentDetails) => string } const defaultOptions: Options = { + bypassIndexCheck: false, enableSiteMap: true, enableRSS: true, rssLimit: 10, rssFullHtml: false, includeEmptyFiles: true, + titlePattern: (cfg, dir, dirIndex) => + `${cfg.pageTitle} - ${dirIndex != null ? dirIndex.title : dir.split("/").pop()}`, } function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): 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: ContentIndex, 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 - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - ${escapeHTML(content.title)} - https://${joinSegments(base, encodeURI(slug))} - https://${joinSegments(base, encodeURI(slug))} - ${content.richContent ?? content.description} - ${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) { @@ -71,22 +82,39 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu 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) => { @@ -114,32 +142,80 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { 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 emitted: FilePath[] = [] - const linkIndex: ContentIndex = 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, { - 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) { + // 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) { emitted.push( - await write({ + write({ ctx, - content: generateSiteMap(cfg, linkIndex), + content: generateSiteMap(cfg, indexTree), slug: "sitemap" as FullSlug, ext: ".xml", }), @@ -147,30 +223,47 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { } if (opts?.enableRSS) { + 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( - await write({ + write({ ctx, - content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), + content: finishRSSFeed(cfg, opts, feedTree.data as Feed), slug: "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( - await write({ + write({ ctx, content: JSON.stringify(simplifiedIndex), slug: fp, @@ -178,8 +271,187 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { }), ) - return emitted + return await Promise.all(emitted) }, getQuartzComponents: () => [], } } + +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 e00c700e0..95fba84a4 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -110,6 +110,7 @@ declare module "vfile" { publish: boolean | string draft: boolean | string lang: string + noRSS: boolean enableToc: string cssclasses: string[] socialImage: string