From 52a2f32273921df89c1c57c95640d5eee0db5a78 Mon Sep 17 00:00:00 2001 From: Bao Trinh Date: Wed, 4 Dec 2024 20:20:15 -0600 Subject: [PATCH] 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. --- quartz/plugins/emitters/contentIndex.ts | 79 +++++++++++++++++-------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index acd553f53..f961109b4 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,7 +1,6 @@ import { Root } from "hast" import { DateTime } from "luxon" import { GlobalConfiguration } from "../../cfg" -import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" @@ -9,7 +8,9 @@ import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" 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 export type ContentDetails = { title: string @@ -17,8 +18,13 @@ export type ContentDetails = { tags: string[] content: 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 +type FullContentDetails = ContentDetails & { + dates: QuartzPluginData["dates"] + description: string } interface Options { @@ -37,42 +43,65 @@ const defaultOptions: Options = { includeEmptyFiles: true, } -function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { +function generateSiteMap(cfg: GlobalConfiguration, idx: FullContentIndex): string { const base = cfg.baseUrl ?? "" - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + 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 ` https://${joinSegments(base, encodeURI(slug))} - ${content.date && `${content.date.toISO()}`} + ${modified == null ? "" : `${modified.toISO()}`} ` + } const urls = Array.from(idx) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .map(([slug, content]) => createURLEntry(simplifySlug(slug), content.dates?.modified)) .join("") return `${urls}` } -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { +function generateRSSFeed(cfg: GlobalConfiguration, idx: FullContentIndex, limit?: number): string { const base = cfg.baseUrl ?? "" + const buildDate = DateTime.now() - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` + const createRSSItem = ( + slug: SimpleSlug, + content: FullContentDetails, + pubDate?: DateTime, + ): string => { + return ` ${escapeHTML(content.title)} https://${joinSegments(base, encodeURI(slug))} https://${joinSegments(base, encodeURI(slug))} ${content.richContent ?? content.description} - ${content.date?.toRFC2822()} + ${pubDate == null ? "" : `${pubDate.toRFC2822()}`} ` + } const items = Array.from(idx) - .sort(([_, f1], [__, f2]) => { - if (f1.date && f2.date) { - return f2.date.toMillis() - f1.date.toMillis() - } else if (f1.date && !f2.date) { + .map(([slug, content]): [FullSlug, DateTime | undefined, FullContentDetails] => { + // rss clients use pubDate to determine the order of items, and which items are newly-published + // so to keep new items at the front, we use the explicitly set published date and fall back + // 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 - } else if (!f1.date && f2.date) { + } else if (!d1 && d2) { return 1 } - return f1.title.localeCompare(f2.title) }) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .slice(0, limit ?? idx.size) + .map(([slug, pubDate, content]) => createRSSItem(simplifySlug(slug), content, pubDate)) .join("") return ` @@ -83,6 +112,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( cfg.pageTitle, )} + ${buildDate.toRFC2822()} Quartz -- quartz.jzhao.xyz ${items} @@ -116,10 +146,9 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { async emit(ctx, content, _resources) { const cfg = ctx.cfg.configuration const emitted: FilePath[] = [] - const linkIndex: ContentIndex = new Map() + const linkIndex: FullContentIndex = new Map() for (const [tree, file] of content) { const slug = file.data.slug! - const date = getDate(ctx.cfg.configuration, file.data) ?? DateTime.now() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { linkIndex.set(slug, { title: file.data.frontmatter?.title!, @@ -129,7 +158,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { richContent: opts?.rssFullHtml ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) : undefined, - date: date, + dates: file.data.dates, description: file.data.description ?? "", }) } @@ -158,13 +187,13 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { } const fp = joinSegments("static", "contentIndex") as FullSlug - const simplifiedIndex = Object.fromEntries( - Array.from(linkIndex).map(([slug, content]) => { + // explicitly annotate the type of simplifiedIndex to typecheck output file contents + const simplifiedIndex: ContentIndex = new Map( + Array.from(linkIndex, ([slug, fullContent]) => { // 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 + const { description, dates, ...content } = fullContent return [slug, content] }), ) @@ -172,7 +201,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { emitted.push( await write({ ctx, - content: JSON.stringify(simplifiedIndex), + content: JSON.stringify(Object.fromEntries(simplifiedIndex)), slug: fp, ext: ".json", }),