Skip to content

Commit

Permalink
fix(dates): rss date fixes
Browse files Browse the repository at this point in the history
Explicitly define Content Index types to improve type checking.

Make rss feed and sitemap use the appropriate date type:
published and modified, respectively.
  • Loading branch information
baodrate committed Dec 21, 2024
1 parent 0f26965 commit 03d10ad
Showing 1 changed file with 55 additions and 26 deletions.
81 changes: 55 additions & 26 deletions quartz/plugins/emitters/contentIndex.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
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"
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<FullSlug, ContentDetails>
export type ContentDetails = {
title: string
links: SimpleSlug[]
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<FullSlug, FullContentDetails>
type FullContentDetails = ContentDetails & {
dates: QuartzPluginData["dates"]
description: string
}

interface Options {
Expand All @@ -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 => `<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>
${content.date && `<lastmod>${content.date.toISO()}</lastmod>`}
${modified == null ? "" : `<lastmod>${modified.toISO()}</lastmod>`}
</url>`
}
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content.dates?.modified))
.join("")
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 createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
const buildDate = DateTime.now()

const createRSSItem = (
slug: SimpleSlug,
content: FullContentDetails,
pubDate?: DateTime,
): string => {
return `<item>
<title>${escapeHTML(content.title)}</title>
<link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description>${content.richContent ?? content.description}</description>
<pubDate>${content.date?.toRFC2822()}</pubDate>
${pubDate == null ? "" : `<pubDate>${pubDate.toRFC2822()}</pubDate>`}
</item>`
}
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 `<?xml version="1.0" encoding="UTF-8" ?>
Expand All @@ -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(
cfg.pageTitle,
)}</description>
<lastBuildDate>${buildDate.toRFC2822()}</lastBuildDate>
<generator>Quartz -- quartz.jzhao.xyz</generator>
${items}
</channel>
Expand Down Expand Up @@ -116,10 +146,9 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (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!,
Expand All @@ -129,7 +158,7 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
richContent: opts?.rssFullHtml
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
: undefined,
date: date,
dates: file.data.dates,
description: file.data.description ?? "",
})
}
Expand Down Expand Up @@ -158,21 +187,21 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (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]
}),
)

emitted.push(
await write({
ctx,
content: JSON.stringify(simplifiedIndex),
content: JSON.stringify(Object.fromEntries(simplifiedIndex)),
slug: fp,
ext: ".json",
}),
Expand Down

0 comments on commit 03d10ad

Please sign in to comment.