mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-05-18 14:34:23 +02:00
Compare commits
3 Commits
01bb01de84
...
fbfdb41278
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fbfdb41278 | ||
![]() |
fbc45548f7 | ||
![]() |
41d2b6e3a6 |
@ -1,5 +1,11 @@
|
||||
Quartz emits an RSS feed for all the content on your site by generating an `index.xml` file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly.
|
||||
|
||||
Quartz also generates RSS feeds for all subdirectories on your site. Add `.rss` to the end of the directory link to download an RSS file limited to the content in that directory and its subdirectories.
|
||||
|
||||
- Subdirectories containing an `index.md` file with `noRSS: true` in the frontmatter will not generate an RSS feed.
|
||||
- The entries in that subdirectory will still be present in the `index.xml` feed.
|
||||
- You can hide a file from all RSS feeds by putting `noRSS: true` in that file's frontmatter.
|
||||
|
||||
## Configuration
|
||||
|
||||
This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options.
|
||||
|
@ -36,6 +36,7 @@ Component.Graph({
|
||||
opacityScale: 1, // how quickly do we fade out the labels when zooming out?
|
||||
removeTags: [], // what tags to remove from the graph
|
||||
showTags: true, // whether to show tags in the graph
|
||||
enableRadial: false, // whether to constrain the graph, similar to Obsidian
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
@ -49,6 +50,7 @@ Component.Graph({
|
||||
opacityScale: 1,
|
||||
removeTags: [], // what tags to remove from the graph
|
||||
showTags: true, // whether to show tags in the graph
|
||||
enableRadial: true, // whether to constrain the graph, similar to Obsidian
|
||||
},
|
||||
})
|
||||
```
|
||||
|
@ -4,9 +4,9 @@ tags:
|
||||
- plugin/emitter
|
||||
---
|
||||
|
||||
This plugin emits both RSS and an XML sitemap for your site. The [[RSS Feed]] allows users to subscribe to content on your site and the sitemap allows search engines to better index your site. The plugin also emits a `contentIndex.json` file which is used by dynamic frontend components like search and graph.
|
||||
This plugin emits both RSS feeds and an XML sitemap for your site. The [[RSS Feed]] allows users to subscribe to content on your site and the sitemap allows search engines to better index your site. The plugin also emits a `contentIndex.json` file which is used by dynamic frontend components like search and graph.
|
||||
|
||||
This plugin emits a comprehensive index of the site's content, generating additional resources such as a sitemap, an RSS feed, and a
|
||||
This plugin emits a comprehensive index of the site's content, generating additional resources such as a sitemap and RSS feeds for each directory.
|
||||
|
||||
> [!note]
|
||||
> For information on how to add, remove or configure plugins, see the [[configuration#Plugins|Configuration]] page.
|
||||
@ -15,9 +15,14 @@ This plugin accepts the following configuration options:
|
||||
|
||||
- `enableSiteMap`: If `true` (default), generates a sitemap XML file (`sitemap.xml`) listing all site URLs for search engines in content discovery.
|
||||
- `enableRSS`: If `true` (default), produces an RSS feed (`index.xml`) with recent content updates.
|
||||
- For a more fine-grained approach, use `noRSS: true` in a file to remove it from feeds, or set the same in a folder's `index.md` to remove the entire folder.
|
||||
- `rssLimit`: Defines the maximum number of entries to include in the RSS feed, helping to focus on the most recent or relevant content. Defaults to `10`.
|
||||
- `rssFullHtml`: If `true`, the RSS feed includes full HTML content. Otherwise it includes just summaries.
|
||||
- `includeEmptyFiles`: If `true` (default), content files with no body text are included in the generated index and resources.
|
||||
- `titlePattern`: custom title generator for RSS feeds based on the global configuration and the directory name of the relevant folder, and (**if it exists**) the data of the `index.md` file of the current folder.
|
||||
- ex.
|
||||
``titlePattern: (cfg, dir, dirIndex) => `A feed found at ${cfg.baseUrl}/${dir}.rss: ${dirIndex != null ? dirIndex.title : "(untitled)"}` ``
|
||||
- outputs: `"A feed found at my-site.com/directory.rss: Directory"`
|
||||
|
||||
## API
|
||||
|
||||
|
@ -18,6 +18,7 @@ export interface D3Config {
|
||||
removeTags: string[]
|
||||
showTags: boolean
|
||||
focusOnHover?: boolean
|
||||
enableRadial?: boolean
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
@ -39,6 +40,7 @@ const defaultOptions: GraphOptions = {
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: false,
|
||||
enableRadial: false,
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
@ -53,10 +55,11 @@ const defaultOptions: GraphOptions = {
|
||||
showTags: true,
|
||||
removeTags: [],
|
||||
focusOnHover: true,
|
||||
enableRadial: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default ((opts?: GraphOptions) => {
|
||||
export default ((opts?: Partial<GraphOptions>) => {
|
||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
forceCenter,
|
||||
forceLink,
|
||||
forceCollide,
|
||||
forceRadial,
|
||||
zoomIdentity,
|
||||
select,
|
||||
drag,
|
||||
@ -87,6 +88,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
|
||||
removeTags,
|
||||
showTags,
|
||||
focusOnHover,
|
||||
enableRadial,
|
||||
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
||||
|
||||
const data: Map<SimpleSlug, ContentDetails> = new Map(
|
||||
@ -161,15 +163,20 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
|
||||
})),
|
||||
}
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
|
||||
// we virtualize the simulation and use pixi to actually render it
|
||||
// Calculate the radius of the container circle
|
||||
const radius = Math.min(width, height) / 2 - 40 // 40px padding
|
||||
const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
|
||||
.force("charge", forceManyBody().strength(-100 * repelForce))
|
||||
.force("center", forceCenter().strength(centerForce))
|
||||
.force("link", forceLink(graphData.links).distance(linkDistance))
|
||||
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
|
||||
|
||||
const width = graph.offsetWidth
|
||||
const height = Math.max(graph.offsetHeight, 250)
|
||||
if (enableRadial)
|
||||
simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3))
|
||||
|
||||
// precompute style prop strings as pixi doesn't support css variables
|
||||
const cssVars = [
|
||||
|
@ -2,14 +2,24 @@ 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 {
|
||||
FilePath,
|
||||
FullSlug,
|
||||
SimpleSlug,
|
||||
joinSegments,
|
||||
simplifySlug,
|
||||
slugifyFilePath,
|
||||
} from "../../util/path"
|
||||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { toHtml } from "hast-util-to-html"
|
||||
import { write } from "./helpers"
|
||||
import { i18n } from "../../i18n"
|
||||
import { BuildCtx } from "../../util/ctx"
|
||||
import DepGraph from "../../depgraph"
|
||||
import chalk from "chalk"
|
||||
import { ProcessedContent } from "../vfile"
|
||||
|
||||
export type ContentIndex = Map<FullSlug, ContentDetails>
|
||||
type ContentIndex = Tree<TreeNode>
|
||||
export type ContentDetails = {
|
||||
title: string
|
||||
links: SimpleSlug[]
|
||||
@ -18,49 +28,50 @@ export type ContentDetails = {
|
||||
richContent?: string
|
||||
date?: Date
|
||||
description?: string
|
||||
slug?: FullSlug
|
||||
noRSS?: boolean
|
||||
}
|
||||
|
||||
interface Options {
|
||||
enableSiteMap: boolean
|
||||
enableRSS: boolean
|
||||
bypassIndexCheck: boolean
|
||||
rssLimit?: number
|
||||
rssFullHtml: boolean
|
||||
includeEmptyFiles: boolean
|
||||
titlePattern?: (cfg: GlobalConfiguration, dir: FullSlug, dirIndex?: ContentDetails) => string
|
||||
}
|
||||
|
||||
const defaultOptions: Options = {
|
||||
bypassIndexCheck: false,
|
||||
enableSiteMap: true,
|
||||
enableRSS: true,
|
||||
rssLimit: 10,
|
||||
rssFullHtml: false,
|
||||
includeEmptyFiles: true,
|
||||
titlePattern: (cfg, dir, dirIndex) =>
|
||||
`${cfg.pageTitle} - ${dirIndex != null ? dirIndex.title : dir.split("/").pop()}`,
|
||||
}
|
||||
|
||||
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
|
||||
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
|
||||
const createURLEntry = (content: ContentDetails): string => `
|
||||
<url>
|
||||
<loc>https://${joinSegments(base, encodeURI(simplifySlug(content.slug!)))}</loc>
|
||||
${content.date && `<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>`
|
||||
let urls = (idx.spread() as ContentDetails[]).map((e) => createURLEntry(e)).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 finishRSSFeed(cfg: GlobalConfiguration, opts: Partial<Options>, entries: Feed): string {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
const feedTitle = opts.titlePattern!(cfg, entries.dir, entries.dirIndex)
|
||||
const limit = opts?.rssLimit ?? entries.raw.length
|
||||
|
||||
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>
|
||||
</item>`
|
||||
|
||||
const items = Array.from(idx)
|
||||
.sort(([_, f1], [__, f2]) => {
|
||||
const sorted = entries.raw
|
||||
.sort((f1, f2) => {
|
||||
if (f1.date && f2.date) {
|
||||
return f2.date.getTime() - f1.date.getTime()
|
||||
} else if (f1.date && !f2.date) {
|
||||
@ -71,24 +82,41 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
|
||||
|
||||
return f1.title.localeCompare(f2.title)
|
||||
})
|
||||
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
|
||||
.slice(0, limit ?? idx.size)
|
||||
.join("")
|
||||
.slice(0, limit)
|
||||
.map((e) => e.item)
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>${escapeHTML(cfg.pageTitle)}</title>
|
||||
<title>${feedTitle}</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>
|
||||
${items}
|
||||
<generator>Quartz -- quartz.jzhao.xyz</generator>${sorted.join("")}
|
||||
</channel>
|
||||
</rss>`
|
||||
}
|
||||
|
||||
function generateRSSEntry(cfg: GlobalConfiguration, details: ContentDetails): Entry {
|
||||
const base = cfg.baseUrl ?? ""
|
||||
|
||||
let item = `
|
||||
<item>
|
||||
<title>${escapeHTML(details.title)}</title>
|
||||
<link>https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))}</link>
|
||||
<guid>https://${joinSegments(base, encodeURI(simplifySlug(details.slug!)))}</guid>
|
||||
<description>${details.richContent ?? details.description}</description>
|
||||
<pubDate>${details.date?.toUTCString()}</pubDate>
|
||||
</item>`
|
||||
|
||||
return {
|
||||
item: item,
|
||||
date: details.date!, // Safety: guaranteed non-null by Tree<Stem> -> Tree<TreeNode>
|
||||
title: details.title,
|
||||
}
|
||||
}
|
||||
|
||||
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
opts = { ...defaultOptions, ...opts }
|
||||
return {
|
||||
@ -114,14 +142,34 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
return graph
|
||||
},
|
||||
async emit(ctx, content, _resources) {
|
||||
// If we're missing an index file, don't bother with sitemap/RSS gen
|
||||
if (
|
||||
!(
|
||||
opts?.bypassIndexCheck ||
|
||||
content.map(([_, c]) => c.data.slug!).includes("index" as FullSlug)
|
||||
)
|
||||
) {
|
||||
console.warn(
|
||||
chalk.yellow(`Warning: contentIndex:
|
||||
content/ folder is missing an index.md. RSS feeds and sitemap will not be generated.
|
||||
If you still wish to generate these files, add:
|
||||
bypassIndexCheck: true,
|
||||
to your configuration for Plugin.ContentIndex({...}) in quartz.config.ts.
|
||||
Don't do this unless you know what you're doing!`),
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
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, {
|
||||
const emitted: Promise<FilePath>[] = []
|
||||
var indexTree = new Tree<TreeNode>(defaultFeed(), compareTreeNodes)
|
||||
|
||||
// ProcessedContent[] -> Tree<TreeNode>
|
||||
// bfahrenfort: If I could finagle a Visitor pattern to cross
|
||||
// different datatypes (TransformVisitor<T, K>?), half of this pass could be
|
||||
// folded into the FeedGenerator postorder accept
|
||||
const detailsOf = ([tree, file]: ProcessedContent): ContentDetails => {
|
||||
return {
|
||||
title: file.data.frontmatter?.title!,
|
||||
links: file.data.links ?? [],
|
||||
tags: file.data.frontmatter?.tags ?? [],
|
||||
@ -129,17 +177,45 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
richContent: opts?.rssFullHtml
|
||||
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
|
||||
: undefined,
|
||||
date: date,
|
||||
date: getDate(ctx.cfg.configuration, file.data) ?? new Date(),
|
||||
description: file.data.description ?? "",
|
||||
})
|
||||
slug: slugifyFilePath(file.data.relativePath!, true),
|
||||
noRSS: file.data.frontmatter?.noRSS ?? false,
|
||||
}
|
||||
}
|
||||
for (const [tree, file] of content) {
|
||||
// Create tree strucutre
|
||||
var pointer = indexTree
|
||||
const dirs = file.data.relativePath?.split("/").slice(0, -1) ?? []
|
||||
|
||||
// Skips descent if file is top-level (ex. content/index.md)
|
||||
for (var i = 1; i <= dirs.length; i++) {
|
||||
// Initialize directories
|
||||
let feed = {
|
||||
dir: dirs!.slice(0, i).join("/") as FullSlug,
|
||||
raw: new Array<Entry>(),
|
||||
}
|
||||
pointer = pointer.child(feed)
|
||||
}
|
||||
|
||||
// Initialize children
|
||||
// a. parse ContentDetails of file
|
||||
// b. (if exists) add the dir index to the enclosing feed
|
||||
// c. terminate branch with file's ContentDetails
|
||||
let details = detailsOf([tree, file])
|
||||
if (file.stem == "index") {
|
||||
let feed = pointer.data as Feed
|
||||
|
||||
feed.dirIndex = details
|
||||
}
|
||||
pointer = pointer.child(details)
|
||||
}
|
||||
|
||||
if (opts?.enableSiteMap) {
|
||||
emitted.push(
|
||||
await write({
|
||||
write({
|
||||
ctx,
|
||||
content: generateSiteMap(cfg, linkIndex),
|
||||
content: generateSiteMap(cfg, indexTree),
|
||||
slug: "sitemap" as FullSlug,
|
||||
ext: ".xml",
|
||||
}),
|
||||
@ -147,30 +223,47 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
}
|
||||
|
||||
if (opts?.enableRSS) {
|
||||
var feedTree: Tree<TreeNode> = indexTree
|
||||
|
||||
// 1. In-place Tree<TreeNode> -> Tree<TreeNode>
|
||||
// TreeNode becomes either:
|
||||
// data Feed with children Feed | ContentDetails
|
||||
// ContentDetails with empty children
|
||||
// 2. Finish each Feed and emit
|
||||
// Each Feed now has an Entry[] of enclosed RSS items, to be composed
|
||||
// with the Entry[]s of child Feeds (bottom-up)
|
||||
// before wrapping with RSS tags to be emitted as one string
|
||||
feedTree.acceptPostorder(new FeedGenerator(ctx, cfg, opts, emitted))
|
||||
|
||||
// Generate index feed separately re-using the Entry[] composed upwards
|
||||
emitted.push(
|
||||
await write({
|
||||
write({
|
||||
ctx,
|
||||
content: generateRSSFeed(cfg, linkIndex, opts.rssLimit),
|
||||
content: finishRSSFeed(cfg, opts, feedTree.data as Feed),
|
||||
slug: "index" as FullSlug,
|
||||
ext: ".xml",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Generate ContentIndex
|
||||
const fp = joinSegments("static", "contentIndex") as FullSlug
|
||||
const simplifiedIndex = Object.fromEntries(
|
||||
Array.from(linkIndex).map(([slug, content]) => {
|
||||
(indexTree.spread() as ContentDetails[]).map((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
|
||||
delete content.noRSS
|
||||
|
||||
var slug = content.slug
|
||||
delete content.slug
|
||||
return [slug, content]
|
||||
}),
|
||||
)
|
||||
|
||||
emitted.push(
|
||||
await write({
|
||||
write({
|
||||
ctx,
|
||||
content: JSON.stringify(simplifiedIndex),
|
||||
slug: fp,
|
||||
@ -178,8 +271,187 @@ export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
|
||||
}),
|
||||
)
|
||||
|
||||
return emitted
|
||||
return await Promise.all(emitted)
|
||||
},
|
||||
getQuartzComponents: () => [],
|
||||
}
|
||||
}
|
||||
|
||||
class Tree<T> {
|
||||
children: Set<Tree<T>>
|
||||
data: T
|
||||
childComparator: (a: T, b: T) => boolean
|
||||
|
||||
constructor(data: T, childComparator: (a: T, b: T) => boolean, children?: Set<Tree<T>>) {
|
||||
this.data = data
|
||||
this.children = children ?? new Set<Tree<T>>()
|
||||
this.childComparator = childComparator
|
||||
}
|
||||
|
||||
// BFS insertion-order traversal
|
||||
accept(visitor: Visitor<T>, parent?: Tree<T>) {
|
||||
visitor.visit(parent ?? this, this) // Root has no parent
|
||||
|
||||
for (var child of this.children) {
|
||||
var childVisitor = visitor.descend(child)
|
||||
child.accept(childVisitor, this)
|
||||
}
|
||||
}
|
||||
|
||||
// Visit children before parent
|
||||
acceptPostorder(visitor: Visitor<T>, parent?: Tree<T>) {
|
||||
let branchesFirst = [...this.children].toSorted((_, c2) => (c2.children.size > 0 ? 1 : -1))
|
||||
|
||||
for (var child of branchesFirst) {
|
||||
var childVisitor = visitor.descend(child)
|
||||
child.acceptPostorder(childVisitor, this)
|
||||
}
|
||||
|
||||
visitor.visit(parent ?? this, this)
|
||||
}
|
||||
|
||||
child(data: T): Tree<T> {
|
||||
for (var child of this.children) {
|
||||
if (this.childComparator(child.data, data)) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
|
||||
return this.childFromTree(new Tree<T>(data, this.childComparator))
|
||||
}
|
||||
|
||||
childFromTree(child: Tree<T>): Tree<T> {
|
||||
this.children.add(child)
|
||||
return child
|
||||
}
|
||||
|
||||
// Convert entire tree to array of only its leaves
|
||||
// ex. Tree<TreeNode> -> ContentDetails[]
|
||||
spread(): T[] {
|
||||
var flattened: T[] = []
|
||||
|
||||
const flatten = (tree: Tree<T>) => {
|
||||
for (let child of tree.children) {
|
||||
if (child.children.size == 0) flattened.push(child.data)
|
||||
else flatten(child)
|
||||
}
|
||||
}
|
||||
|
||||
flatten(this)
|
||||
return flattened
|
||||
}
|
||||
}
|
||||
|
||||
interface Visitor<T> {
|
||||
// Prefix action at each tree level
|
||||
descend: (tree: Tree<T>) => Visitor<T>
|
||||
|
||||
// Action at each child of parent
|
||||
visit: (parent: Tree<T>, tree: Tree<T>) => void
|
||||
}
|
||||
|
||||
// Hierarchy of directories with metadata children
|
||||
// To be turned into a hierarchy of RSS text arrays generated from metadata children
|
||||
type TreeNode = ContentDetails | Feed
|
||||
|
||||
// All of the files in one folder, as RSS entries
|
||||
// Entry[] is the vehicle for composition while keeping content metadata intact
|
||||
type Feed = {
|
||||
dir: FullSlug
|
||||
raw: Entry[]
|
||||
dirIndex?: ContentDetails
|
||||
}
|
||||
function defaultFeed(): Feed {
|
||||
return {
|
||||
dir: "index" as FullSlug,
|
||||
raw: new Array<Entry>(),
|
||||
}
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
item: string
|
||||
// Must be maintained for sorting purposes
|
||||
date: Date
|
||||
title: string
|
||||
}
|
||||
|
||||
// Type guards
|
||||
function isFeed(feed: TreeNode): boolean {
|
||||
return Object.hasOwn(feed, "dir")
|
||||
}
|
||||
|
||||
function isContentDetails(details: TreeNode): boolean {
|
||||
return Object.hasOwn(details, "slug")
|
||||
}
|
||||
|
||||
function compareTreeNodes(a: TreeNode, b: TreeNode) {
|
||||
let feedComp = isFeed(a) && isFeed(b) && (a as Feed).dir == (b as Feed).dir
|
||||
let contentComp =
|
||||
isContentDetails(a) && isContentDetails(b) && (a as ContentDetails) == (b as ContentDetails)
|
||||
return feedComp || contentComp
|
||||
}
|
||||
|
||||
type IndexVisitor = Visitor<TreeNode> // ContentIndex in interface form
|
||||
|
||||
// Note: only use with acceptPostorder
|
||||
class FeedGenerator implements IndexVisitor {
|
||||
ctx: BuildCtx
|
||||
cfg: GlobalConfiguration
|
||||
opts: Partial<Options>
|
||||
emitted: Promise<FilePath>[]
|
||||
|
||||
constructor(
|
||||
ctx: BuildCtx,
|
||||
cfg: GlobalConfiguration,
|
||||
opts: Partial<Options>,
|
||||
emitted: Promise<FilePath>[],
|
||||
) {
|
||||
this.ctx = ctx
|
||||
this.cfg = cfg
|
||||
this.opts = opts
|
||||
this.emitted = emitted
|
||||
}
|
||||
|
||||
descend(_: ContentIndex): FeedGenerator {
|
||||
return this
|
||||
}
|
||||
|
||||
visit(parent: ContentIndex, tree: ContentIndex) {
|
||||
// Compose direct child Feeds' Entry[]s with the current level
|
||||
// Because this Visitor visits bottom up, works at every level
|
||||
if (isFeed(tree.data)) {
|
||||
let feed = tree.data as Feed
|
||||
tree.children.forEach((child, _) => {
|
||||
if (isFeed(child.data)) feed.raw.push(...(child.data as Feed).raw)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle the top-level Feed separately
|
||||
// bfahrenfort: this is really just a design choice to preserve "index.xml";
|
||||
// if desired we could generate it uniformly with the composition instead
|
||||
if (tree === parent) return
|
||||
|
||||
if (tree.children.size == 0 && !(tree.data as ContentDetails).noRSS) {
|
||||
// Generate RSS item and push to parent Feed's Entry[]
|
||||
let feed = parent.data as Feed
|
||||
feed.raw.push(generateRSSEntry(this.cfg, tree.data as ContentDetails))
|
||||
}
|
||||
|
||||
if (isFeed(tree.data)) {
|
||||
// Handle all non-index feeds
|
||||
let feed = tree.data as Feed
|
||||
if (!(feed.dirIndex?.noRSS ?? false)) {
|
||||
let ctx = this.ctx
|
||||
|
||||
this.emitted.push(
|
||||
write({
|
||||
ctx,
|
||||
content: finishRSSFeed(this.cfg, this.opts, feed),
|
||||
slug: feed.dir,
|
||||
ext: ".rss",
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -110,6 +110,7 @@ declare module "vfile" {
|
||||
publish: boolean | string
|
||||
draft: boolean | string
|
||||
lang: string
|
||||
noRSS: boolean
|
||||
enableToc: string
|
||||
cssclasses: string[]
|
||||
socialImage: string
|
||||
|
Loading…
x
Reference in New Issue
Block a user