| | |
| | | import { visit } from "unist-util-visit" |
| | | import { GlobalConfiguration } from "../../cfg" |
| | | import { getDate } from "../../components/Date" |
| | | import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | import { Element } from "hast" |
| | | import path from "path" |
| | | import { trimPathSuffix } from "../../path" |
| | | |
| | | export type ContentIndex = Map<FullSlug, ContentDetails> |
| | | export type ContentDetails = { |
| | | title: string |
| | | links: SimpleSlug[] |
| | | tags: string[] |
| | | content: string |
| | | date?: Date |
| | | description?: string |
| | | } |
| | | |
| | | interface Options { |
| | | indexAnchorLinks: boolean, |
| | | indexExternalLinks: boolean, |
| | | enableSiteMap: boolean |
| | | enableRSS: boolean |
| | | includeEmptyFiles: boolean |
| | | } |
| | | |
| | | const defaultOptions: Options = { |
| | | indexAnchorLinks: false, |
| | | indexExternalLinks: false, |
| | | enableSiteMap: true, |
| | | enableRSS: true, |
| | | includeEmptyFiles: true, |
| | | } |
| | | |
| | | type ContentIndex = Map<string, { |
| | | title: string, |
| | | links?: string[], |
| | | tags?: string[], |
| | | content: string, |
| | | }> |
| | | function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { |
| | | const base = cfg.baseUrl ?? "" |
| | | const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url> |
| | | <loc>https://${base}/${slug}</loc> |
| | | <lastmod>${content.date?.toISOString()}</lastmod> |
| | | </url>` |
| | | const urls = Array.from(idx) |
| | | .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>` |
| | | } |
| | | |
| | | export const ContentIndex: QuartzEmitterPlugin<Options> = (userOpts) => { |
| | | const opts = { ...userOpts, ...defaultOptions } |
| | | function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { |
| | | const base = cfg.baseUrl ?? "" |
| | | const root = `https://${base}` |
| | | |
| | | const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item> |
| | | <title>${content.title}</title> |
| | | <link>${root}/${slug}</link> |
| | | <guid>${root}/${slug}</guid> |
| | | <description>${content.description}</description> |
| | | <pubDate>${content.date?.toUTCString()}</pubDate> |
| | | </item>` |
| | | |
| | | const items = Array.from(idx) |
| | | .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) |
| | | .join("") |
| | | 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> |
| | | <generator>Quartz -- quartz.jzhao.xyz</generator> |
| | | ${items} |
| | | </channel> |
| | | </rss>` |
| | | } |
| | | |
| | | export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => { |
| | | opts = { ...defaultOptions, ...opts } |
| | | return { |
| | | name: "ContentIndex", |
| | | async emit(_contentDir, _cfg, content, _resources, emit) { |
| | | const fp = "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) { |
| | | let slug = trimPathSuffix(file.data.slug!) |
| | | |
| | | const outgoing: Set<string> = new Set() |
| | | visit(tree, 'element', (node: Element) => { |
| | | if (node.tagName === 'a' && node.properties && typeof node.properties.href === 'string') { |
| | | let dest = node.properties.href |
| | | if (dest.startsWith(".")) { |
| | | const normalizedPath = path.normalize(path.join(slug, dest)) |
| | | dest = trimPathSuffix(normalizedPath) |
| | | outgoing.add(dest) |
| | | } else if (dest.startsWith("#")) { |
| | | if (opts.indexAnchorLinks) { |
| | | outgoing.add(dest) |
| | | } |
| | | } else { |
| | | if (opts.indexExternalLinks) { |
| | | outgoing.add(dest) |
| | | } |
| | | } |
| | | } |
| | | }) |
| | | |
| | | linkIndex.set(slug, { |
| | | title: file.data.frontmatter?.title!, |
| | | links: [...outgoing], |
| | | tags: file.data.frontmatter?.tags, |
| | | content: file.data.text ?? "" |
| | | }) |
| | | 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 ?? "", |
| | | date: date, |
| | | description: file.data.description ?? "", |
| | | }) |
| | | } |
| | | } |
| | | |
| | | await emit({ |
| | | content: JSON.stringify(Object.fromEntries(linkIndex)), |
| | | slug: fp, |
| | | ext: ".json", |
| | | }) |
| | | if (opts?.enableSiteMap) { |
| | | emitted.push( |
| | | await emit({ |
| | | content: generateSiteMap(cfg, linkIndex), |
| | | slug: "sitemap" as FullSlug, |
| | | ext: ".xml", |
| | | }), |
| | | ) |
| | | } |
| | | |
| | | return [`${fp}.json`] |
| | | if (opts?.enableRSS) { |
| | | emitted.push( |
| | | await emit({ |
| | | content: generateRSSFeed(cfg, linkIndex), |
| | | slug: "index" as FullSlug, |
| | | ext: ".xml", |
| | | }), |
| | | ) |
| | | } |
| | | |
| | | const fp = path.join("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: () => [], |
| | | } |