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 { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import path from "path" export type ContentIndex = Map export type ContentDetails = { title: string links: SimpleSlug[] tags: string[] content: string richContent?: string date?: Date description?: string } interface Options { enableSiteMap: boolean enableRSS: boolean rssLimit?: number rssFullHtml: boolean includeEmptyFiles: boolean } const defaultOptions: Options = { enableSiteMap: true, enableRSS: true, rssLimit: 10, rssFullHtml: false, includeEmptyFiles: true, } function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` https://${joinSegments(base, encodeURI(slug))} ${content.date?.toISOString()} ` const urls = Array.from(idx) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .join("") return `${urls}` } function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { const base = cfg.baseUrl ?? "" 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]) => { if (f1.date && f2.date) { return f2.date.getTime() - f1.date.getTime() } else if (f1.date && !f2.date) { return -1 } else if (!f1.date && f2.date) { return 1 } return f1.title.localeCompare(f2.title) }) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) .slice(0, limit ?? idx.size) .join("") return ` ${escapeHTML(cfg.pageTitle)} https://${base} ${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML( cfg.pageTitle, )} Quartz -- quartz.jzhao.xyz ${items} ` } export const ContentIndex: QuartzEmitterPlugin> = (opts) => { opts = { ...defaultOptions, ...opts } return { name: "ContentIndex", async emit(ctx, content, _resources, emit) { 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 ?? "", }) } } if (opts?.enableSiteMap) { emitted.push( await emit({ content: generateSiteMap(cfg, linkIndex), slug: "sitemap" as FullSlug, ext: ".xml", }), ) } if (opts?.enableRSS) { emitted.push( await emit({ content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), slug: "index" as FullSlug, ext: ".xml", }), ) } const fp = joinSegments("static", "contentIndex") as FullSlug const simplifiedIndex = Object.fromEntries( Array.from(linkIndex).map(([slug, 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 return [slug, content] }), ) emitted.push( await emit({ content: JSON.stringify(simplifiedIndex), slug: fp, ext: ".json", }), ) return emitted }, getQuartzComponents: () => [], } }