| | |
| | | import { Root } from "hast" |
| | | import { GlobalConfiguration } from "../../cfg" |
| | | import { CanonicalSlug, ClientSlug, FilePath, ServerSlug, canonicalizeServer } from "../../path" |
| | | import { getDate } from "../../components/Date" |
| | | import { escapeHTML } from "../../util/escape" |
| | | import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import path from "path" |
| | | import { toHtml } from "hast-util-to-html" |
| | | import { write } from "./helpers" |
| | | import { i18n } from "../../i18n" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | export type ContentIndex = Map<CanonicalSlug, ContentDetails> |
| | | export type ContentIndex = Map<FullSlug, ContentDetails> |
| | | export type ContentDetails = { |
| | | title: string |
| | | links: CanonicalSlug[] |
| | | links: SimpleSlug[] |
| | | tags: string[] |
| | | content: string |
| | | richContent?: string |
| | | date?: Date |
| | | description?: string |
| | | } |
| | |
| | | interface Options { |
| | | enableSiteMap: boolean |
| | | enableRSS: boolean |
| | | rssLimit?: number |
| | | rssFullHtml: boolean |
| | | rssSlug: string |
| | | includeEmptyFiles: boolean |
| | | } |
| | | |
| | | const defaultOptions: Options = { |
| | | enableSiteMap: true, |
| | | enableRSS: true, |
| | | includeEmptyFiles: false, |
| | | rssLimit: 10, |
| | | rssFullHtml: false, |
| | | rssSlug: "index", |
| | | includeEmptyFiles: true, |
| | | } |
| | | |
| | | function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { |
| | | const base = cfg.baseUrl ?? "" |
| | | const createURLEntry = (slug: CanonicalSlug, content: ContentDetails): string => `<url> |
| | | <loc>https://${base}/${slug}</loc> |
| | | <lastmod>${content.date?.toISOString()}</lastmod> |
| | | const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> |
| | | <loc>https://${joinSegments(base, encodeURI(slug))}</loc> |
| | | ${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`} |
| | | </url>` |
| | | const urls = Array.from(idx) |
| | | .map(([slug, content]) => createURLEntry(slug, content)) |
| | | .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) |
| | | .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): string { |
| | | function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { |
| | | const base = cfg.baseUrl ?? "" |
| | | const root = `https://${base}` as ClientSlug |
| | | |
| | | const createURLEntry = (slug: CanonicalSlug, content: ContentDetails): string => `<items> |
| | | <title>${content.title}</title> |
| | | <link>${root}/${slug}</link> |
| | | <guid>${root}/${slug}</guid> |
| | | <description>${content.description}</description> |
| | | const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<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?.toUTCString()}</pubDate> |
| | | </items>` |
| | | </item>` |
| | | |
| | | const items = Array.from(idx) |
| | | .map(([slug, content]) => createURLEntry(slug, content)) |
| | | .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 `<rss xmlns:atom="http://www.w3.org/2005/atom" version="2.0"> |
| | | |
| | | return `<?xml version="1.0" encoding="UTF-8" ?> |
| | | <rss version="2.0"> |
| | | <channel> |
| | | <title>${cfg.pageTitle}</title> |
| | | <link>${root}</link> |
| | | <description>Recent content on ${cfg.pageTitle}</description> |
| | | <title>${escapeHTML(cfg.pageTitle)}</title> |
| | | <link>https://${base}</link> |
| | | <description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( |
| | | cfg.pageTitle, |
| | | )}</description> |
| | | <generator>Quartz -- quartz.jzhao.xyz</generator> |
| | | <atom:link href="${root}/index.xml" rel="self" type="application/rss+xml"/> |
| | | ${items} |
| | | </channel> |
| | | ${items} |
| | | </rss>` |
| | | } |
| | | |
| | |
| | | opts = { ...defaultOptions, ...opts } |
| | | return { |
| | | name: "ContentIndex", |
| | | async emit(ctx, content, _resources, emit) { |
| | | async getDependencyGraph(ctx, content, _resources) { |
| | | const graph = new DepGraph<FilePath>() |
| | | |
| | | for (const [_tree, file] of content) { |
| | | const sourcePath = file.data.filePath! |
| | | |
| | | graph.addEdge( |
| | | sourcePath, |
| | | joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath, |
| | | ) |
| | | if (opts?.enableSiteMap) { |
| | | graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath) |
| | | } |
| | | if (opts?.enableRSS) { |
| | | graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath) |
| | | } |
| | | } |
| | | |
| | | return graph |
| | | }, |
| | | async emit(ctx, content, _resources) { |
| | | const cfg = ctx.cfg.configuration |
| | | const emitted: FilePath[] = [] |
| | | const linkIndex: ContentIndex = new Map() |
| | | for (const [_tree, file] of content) { |
| | | const slug = canonicalizeServer(file.data.slug!) |
| | | const date = file.data.dates?.modified ?? new Date() |
| | | 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 ServerSlug, |
| | | ext: ".xml", |
| | | })) |
| | | emitted.push( |
| | | await write({ |
| | | ctx, |
| | | content: generateSiteMap(cfg, linkIndex), |
| | | slug: "sitemap" as FullSlug, |
| | | ext: ".xml", |
| | | }), |
| | | ) |
| | | } |
| | | |
| | | if (opts?.enableRSS) { |
| | | emitted.push(await emit({ |
| | | content: generateRSSFeed(cfg, linkIndex), |
| | | slug: "index" as ServerSlug, |
| | | ext: ".xml", |
| | | })) |
| | | emitted.push( |
| | | await write({ |
| | | ctx, |
| | | content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), |
| | | slug: (opts?.rssSlug ?? "index") as FullSlug, |
| | | ext: ".xml", |
| | | }), |
| | | ) |
| | | } |
| | | |
| | | const fp = path.join("static", "contentIndex") as ServerSlug |
| | | 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 |
| | |
| | | }), |
| | | ) |
| | | |
| | | emitted.push(await emit({ |
| | | content: JSON.stringify(simplifiedIndex), |
| | | slug: fp, |
| | | ext: ".json", |
| | | })) |
| | | emitted.push( |
| | | await write({ |
| | | ctx, |
| | | content: JSON.stringify(simplifiedIndex), |
| | | slug: fp, |
| | | ext: ".json", |
| | | }), |
| | | ) |
| | | |
| | | return emitted |
| | | }, |