Merge 41d2b6e3a6ef16f17bb94c0db7e3b10a3c2f9070 into dd6bd498db25344b2cccf56abfb656576a496d38

This commit is contained in:
bfahrenfort 2025-02-20 15:40:42 +03:00 committed by GitHub
commit 753338d350
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 343 additions and 59 deletions

View File

@ -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 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 ## Configuration
This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options. This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options.

View File

@ -4,9 +4,9 @@ tags:
- plugin/emitter - 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] > [!note]
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page. > 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. - `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. - `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`. - `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. - `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. - `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 ## API

View File

@ -2,14 +2,24 @@ import { Root } from "hast"
import { GlobalConfiguration } from "../../cfg" import { GlobalConfiguration } from "../../cfg"
import { getDate } from "../../components/Date" import { getDate } from "../../components/Date"
import { escapeHTML } from "../../util/escape" 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 { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html" import { toHtml } from "hast-util-to-html"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import { BuildCtx } from "../../util/ctx"
import DepGraph from "../../depgraph" import DepGraph from "../../depgraph"
import chalk from "chalk"
import { ProcessedContent } from "../vfile"
export type ContentIndex = Map<FullSlug, ContentDetails> type ContentIndex = Tree<TreeNode>
export type ContentDetails = { export type ContentDetails = {
title: string title: string
links: SimpleSlug[] links: SimpleSlug[]
@ -18,49 +28,50 @@ export type ContentDetails = {
richContent?: string richContent?: string
date?: Date date?: Date
description?: string description?: string
slug?: FullSlug
noRSS?: boolean
} }
interface Options { interface Options {
enableSiteMap: boolean enableSiteMap: boolean
enableRSS: boolean enableRSS: boolean
bypassIndexCheck: boolean
rssLimit?: number rssLimit?: number
rssFullHtml: boolean rssFullHtml: boolean
includeEmptyFiles: boolean includeEmptyFiles: boolean
titlePattern?: (cfg: GlobalConfiguration, dir: FullSlug, dirIndex?: ContentDetails) => string
} }
const defaultOptions: Options = { const defaultOptions: Options = {
bypassIndexCheck: false,
enableSiteMap: true, enableSiteMap: true,
enableRSS: true, enableRSS: true,
rssLimit: 10, rssLimit: 10,
rssFullHtml: false, rssFullHtml: false,
includeEmptyFiles: true, includeEmptyFiles: true,
titlePattern: (cfg, dir, dirIndex) =>
`${cfg.pageTitle} - ${dirIndex != null ? dirIndex.title : dir.split("/").pop()}`,
} }
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
const base = cfg.baseUrl ?? "" const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> const createURLEntry = (content: ContentDetails): string => `
<loc>https://${joinSegments(base, encodeURI(slug))}</loc> <url>
<loc>https://${joinSegments(base, encodeURI(simplifySlug(content.slug!)))}</loc>
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`} ${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
</url>` </url>`
const urls = Array.from(idx) let urls = (idx.spread() as ContentDetails[]).map((e) => createURLEntry(e)).join("")
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}
.join("") </urlset>`
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
} }
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { function finishRSSFeed(cfg: GlobalConfiguration, opts: Partial<Options>, entries: Feed): string {
const base = cfg.baseUrl ?? "" 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 => `<item> const sorted = entries.raw
<title>${escapeHTML(content.title)}</title> .sort((f1, f2) => {
<link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description>${content.richContent ?? content.description}</description>
<pubDate>${content.date?.toUTCString()}</pubDate>
</item>`
const items = Array.from(idx)
.sort(([_, f1], [__, f2]) => {
if (f1.date && f2.date) { if (f1.date && f2.date) {
return f2.date.getTime() - f1.date.getTime() return f2.date.getTime() - f1.date.getTime()
} else if (f1.date && !f2.date) { } else if (f1.date && !f2.date) {
@ -71,24 +82,41 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
return f1.title.localeCompare(f2.title) return f1.title.localeCompare(f2.title)
}) })
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .slice(0, limit)
.slice(0, limit ?? idx.size) .map((e) => e.item)
.join("")
return `<?xml version="1.0" encoding="UTF-8" ?> return `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
<title>${escapeHTML(cfg.pageTitle)}</title> <title>${feedTitle}</title>
<link>https://${base}</link> <link>https://${base}</link>
<description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( <description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
cfg.pageTitle, cfg.pageTitle,
)}</description> )}</description>
<generator>Quartz -- quartz.jzhao.xyz</generator> <generator>Quartz -- quartz.jzhao.xyz</generator>${sorted.join("")}
${items}
</channel> </channel>
</rss>` </rss>`
} }
function generateRSSEntry(cfg: GlobalConfiguration, details: ContentDetails): Entry {
const base = cfg.baseUrl ?? ""
let item = `
<item>
<title>${escapeHTML(details.title)}</title>
<link>https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))}</link>
<guid>https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))}</guid>
<description>${details.richContent ?? details.description}</description>
<pubDate>${details.date?.toUTCString()}</pubDate>
</item>`
return {
item: item,
date: details.date!, // Safety: guaranteed non-null by Tree<Stem> -> Tree<TreeNode>
title: details.title,
}
}
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
opts = { ...defaultOptions, ...opts } opts = { ...defaultOptions, ...opts }
return { return {
@ -114,14 +142,34 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
return graph return graph
}, },
async emit(ctx, content, _resources) { 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 cfg = ctx.cfg.configuration
const emitted: FilePath[] = [] const emitted: Promise<FilePath>[] = []
const linkIndex: ContentIndex = new Map() var indexTree = new Tree<TreeNode>(defaultFeed(), compareTreeNodes)
for (const [tree, file] of content) {
const slug = file.data.slug! // ProcessedContent[] -> Tree<TreeNode>
const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() // bfahrenfort: If I could finagle a Visitor pattern to cross
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { // different datatypes (TransformVisitor<T, K>?), half of this pass could be
linkIndex.set(slug, { // folded into the FeedGenerator postorder accept
const detailsOf = ([tree, file]: ProcessedContent): ContentDetails => {
return {
title: file.data.frontmatter?.title!, title: file.data.frontmatter?.title!,
links: file.data.links ?? [], links: file.data.links ?? [],
tags: file.data.frontmatter?.tags ?? [], tags: file.data.frontmatter?.tags ?? [],
@ -129,17 +177,45 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
richContent: opts?.rssFullHtml richContent: opts?.rssFullHtml
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
: undefined, : undefined,
date: date, date: getDate(ctx.cfg.configuration, file.data) ?? new Date(),
description: file.data.description ?? "", 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<Entry>(),
}
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) { if (opts?.enableSiteMap) {
emitted.push( emitted.push(
await write({ write({
ctx, ctx,
content: generateSiteMap(cfg, linkIndex), content: generateSiteMap(cfg, indexTree),
slug: "sitemap" as FullSlug, slug: "sitemap" as FullSlug,
ext: ".xml", ext: ".xml",
}), }),
@ -147,30 +223,47 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
} }
if (opts?.enableRSS) { if (opts?.enableRSS) {
var feedTree: Tree<TreeNode> = indexTree
// 1. In-place Tree<TreeNode> -> Tree<TreeNode>
// 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( emitted.push(
await write({ write({
ctx, ctx,
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), content: finishRSSFeed(cfg, opts, feedTree.data as Feed),
slug: "index" as FullSlug, slug: "index" as FullSlug,
ext: ".xml", ext: ".xml",
}), }),
) )
} }
// Generate ContentIndex
const fp = joinSegments("static", "contentIndex") as FullSlug const fp = joinSegments("static", "contentIndex") as FullSlug
const simplifiedIndex = Object.fromEntries( 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 // remove description and from content index as nothing downstream
// actually uses it. we only keep it in the index as we need it // actually uses it. we only keep it in the index as we need it
// for the RSS feed // for the RSS feed
delete content.description delete content.description
delete content.date delete content.date
delete content.noRSS
var slug = content.slug
delete content.slug
return [slug, content] return [slug, content]
}), }),
) )
emitted.push( emitted.push(
await write({ write({
ctx, ctx,
content: JSON.stringify(simplifiedIndex), content: JSON.stringify(simplifiedIndex),
slug: fp, slug: fp,
@ -178,8 +271,187 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
}), }),
) )
return emitted return await Promise.all(emitted)
}, },
getQuartzComponents: () => [], getQuartzComponents: () => [],
} }
} }
class Tree<T> {
children: Set<Tree<T>>
data: T
childComparator: (a: T, b: T) => boolean
constructor(data: T, childComparator: (a: T, b: T) => boolean, children?: Set<Tree<T>>) {
this.data = data
this.children = children ?? new Set<Tree<T>>()
this.childComparator = childComparator
}
// BFS insertion-order traversal
accept(visitor: Visitor<T>, parent?: Tree<T>) {
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<T>, parent?: Tree<T>) {
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<T> {
for (var child of this.children) {
if (this.childComparator(child.data, data)) {
return child
}
}
return this.childFromTree(new Tree<T>(data, this.childComparator))
}
childFromTree(child: Tree<T>): Tree<T> {
this.children.add(child)
return child
}
// Convert entire tree to array of only its leaves
// ex. Tree<TreeNode> -> ContentDetails[]
spread(): T[] {
var flattened: T[] = []
const flatten = (tree: Tree<T>) => {
for (let child of tree.children) {
if (child.children.size == 0) flattened.push(child.data)
else flatten(child)
}
}
flatten(this)
return flattened
}
}
interface Visitor<T> {
// Prefix action at each tree level
descend: (tree: Tree<T>) => Visitor<T>
// Action at each child of parent
visit: (parent: Tree<T>, tree: Tree<T>) => 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<Entry>(),
}
}
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<TreeNode> // ContentIndex in interface form
// Note: only use with acceptPostorder
class FeedGenerator implements IndexVisitor {
ctx: BuildCtx
cfg: GlobalConfiguration
opts: Partial<Options>
emitted: Promise<FilePath>[]
constructor(
ctx: BuildCtx,
cfg: GlobalConfiguration,
opts: Partial<Options>,
emitted: Promise<FilePath>[],
) {
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",
}),
)
}
}
}
}

View File

@ -110,6 +110,7 @@ declare module "vfile" {
publish: boolean | string publish: boolean | string
draft: boolean | string draft: boolean | string
lang: string lang: string
noRSS: boolean
enableToc: string enableToc: string
cssclasses: string[] cssclasses: string[]
socialImage: string socialImage: string