fix(dates): rss date fixes

Explicitly define Content Index types to improve type checking.

Make rss feed and sitemap use the appropriate date type:
published and modified, respectively.
This commit is contained in:
Bao Trinh 2024-12-04 20:20:15 -06:00
parent beec5e0cbb
commit 52a2f32273
No known key found for this signature in database
GPG Key ID: 66B50C2AD65984B2

View File

@ -1,7 +1,6 @@
import { Root } from "hast" import { Root } from "hast"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { GlobalConfiguration } from "../../cfg" import { GlobalConfiguration } from "../../cfg"
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 } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
@ -9,7 +8,9 @@ import { toHtml } from "hast-util-to-html"
import { write } from "./helpers" import { write } from "./helpers"
import { i18n } from "../../i18n" import { i18n } from "../../i18n"
import DepGraph from "../../depgraph" import DepGraph from "../../depgraph"
import { QuartzPluginData } from "../vfile"
// Describes the content index (.json) that this plugin produces, to be consumed downstream
export type ContentIndex = Map<FullSlug, ContentDetails> export type ContentIndex = Map<FullSlug, ContentDetails>
export type ContentDetails = { export type ContentDetails = {
title: string title: string
@ -17,8 +18,13 @@ export type ContentDetails = {
tags: string[] tags: string[]
content: string content: string
richContent?: string richContent?: string
date?: DateTime }
description?: string
// The content index fields only used within this plugin and will not be written to the final index
type FullContentIndex = Map<FullSlug, FullContentDetails>
type FullContentDetails = ContentDetails & {
dates: QuartzPluginData["dates"]
description: string
} }
interface Options { interface Options {
@ -37,42 +43,65 @@ const defaultOptions: Options = {
includeEmptyFiles: true, includeEmptyFiles: true,
} }
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { function generateSiteMap(cfg: GlobalConfiguration, idx: FullContentIndex): string {
const base = cfg.baseUrl ?? "" const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> const createURLEntry = (slug: SimpleSlug, modified?: DateTime): string => {
// sitemap protocol specifies that lastmod should *not* be time of sitemap generation; see: https://sitemaps.org/protocol.html#lastmoddef
// so we only include explicitly set modified dates
return ` <url>
<loc>https://${joinSegments(base, encodeURI(slug))}</loc> <loc>https://${joinSegments(base, encodeURI(slug))}</loc>
${content.date && `<lastmod>${content.date.toISO()}</lastmod>`} ${modified == null ? "" : `<lastmod>${modified.toISO()}</lastmod>`}
</url>` </url>`
}
const urls = Array.from(idx) const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content.dates?.modified))
.join("") .join("")
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</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 generateRSSFeed(cfg: GlobalConfiguration, idx: FullContentIndex, limit?: number): string {
const base = cfg.baseUrl ?? "" const base = cfg.baseUrl ?? ""
const buildDate = DateTime.now()
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item> const createRSSItem = (
slug: SimpleSlug,
content: FullContentDetails,
pubDate?: DateTime,
): string => {
return `<item>
<title>${escapeHTML(content.title)}</title> <title>${escapeHTML(content.title)}</title>
<link>https://${joinSegments(base, encodeURI(slug))}</link> <link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid> <guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description>${content.richContent ?? content.description}</description> <description>${content.richContent ?? content.description}</description>
<pubDate>${content.date?.toRFC2822()}</pubDate> ${pubDate == null ? "" : `<pubDate>${pubDate.toRFC2822()}</pubDate>`}
</item>` </item>`
}
const items = Array.from(idx) const items = Array.from(idx)
.sort(([_, f1], [__, f2]) => { .map(([slug, content]): [FullSlug, DateTime | undefined, FullContentDetails] => {
if (f1.date && f2.date) { // rss clients use pubDate to determine the order of items, and which items are newly-published
return f2.date.toMillis() - f1.date.toMillis() // so to keep new items at the front, we use the explicitly set published date and fall back
} else if (f1.date && !f2.date) { // to the earliest other date known for the file
const { published, ...otherDates } = content.dates ?? {}
const pubDate =
published ??
Object.values(otherDates)
.sort((d1, d2) => d1.toMillis() - d2.toMillis())
.find((d) => d)
return [slug, pubDate, content]
})
.sort(([, d1, f1], [, d2, f2]) => {
// sort primarily by date (descending), then break ties with titles
if (d1 && d2) {
return d2.toMillis() - d1.toMillis() || f1.title.localeCompare(f2.title)
} else if (d1 && !d2) {
return -1 return -1
} else if (!f1.date && f2.date) { } else if (!d1 && d2) {
return 1 return 1
} }
return f1.title.localeCompare(f2.title) return f1.title.localeCompare(f2.title)
}) })
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.slice(0, limit ?? idx.size) .slice(0, limit ?? idx.size)
.map(([slug, pubDate, content]) => createRSSItem(simplifySlug(slug), content, pubDate))
.join("") .join("")
return `<?xml version="1.0" encoding="UTF-8" ?> return `<?xml version="1.0" encoding="UTF-8" ?>
@ -83,6 +112,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
<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>
<lastBuildDate>${buildDate.toRFC2822()}</lastBuildDate>
<generator>Quartz -- quartz.jzhao.xyz</generator> <generator>Quartz -- quartz.jzhao.xyz</generator>
${items} ${items}
</channel> </channel>
@ -116,10 +146,9 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
async emit(ctx, content, _resources) { async emit(ctx, content, _resources) {
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const emitted: FilePath[] = [] const emitted: FilePath[] = []
const linkIndex: ContentIndex = new Map() const linkIndex: FullContentIndex = new Map()
for (const [tree, file] of content) { for (const [tree, file] of content) {
const slug = file.data.slug! const slug = file.data.slug!
const date = getDate(ctx.cfg.configuration, file.data) ?? DateTime.now()
if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) {
linkIndex.set(slug, { linkIndex.set(slug, {
title: file.data.frontmatter?.title!, title: file.data.frontmatter?.title!,
@ -129,7 +158,7 @@ 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, dates: file.data.dates,
description: file.data.description ?? "", description: file.data.description ?? "",
}) })
} }
@ -158,13 +187,13 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
} }
const fp = joinSegments("static", "contentIndex") as FullSlug const fp = joinSegments("static", "contentIndex") as FullSlug
const simplifiedIndex = Object.fromEntries( // explicitly annotate the type of simplifiedIndex to typecheck output file contents
Array.from(linkIndex).map(([slug, content]) => { const simplifiedIndex: ContentIndex = new Map(
Array.from(linkIndex, ([slug, fullContent]) => {
// 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 const { description, dates, ...content } = fullContent
delete content.date
return [slug, content] return [slug, content]
}), }),
) )
@ -172,7 +201,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
emitted.push( emitted.push(
await write({ await write({
ctx, ctx,
content: JSON.stringify(simplifiedIndex), content: JSON.stringify(Object.fromEntries(simplifiedIndex)),
slug: fp, slug: fp,
ext: ".json", ext: ".json",
}), }),