Compare commits

...

3 Commits

Author SHA1 Message Date
bfahrenfort
fbfdb41278
Merge 41d2b6e3a6ef16f17bb94c0db7e3b10a3c2f9070 into fbc45548f7ee80715ec74d8c249c662a26f7feae 2025-02-03 09:44:24 +05:30
Aaron Pham
fbc45548f7
feat(graph): enable radial mode (#1738)
Some checks failed
Build and Test / publish-tag (push) Has been cancelled
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (ubuntu-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
Docker build & push image / build (push) Has been cancelled
2025-02-01 16:22:29 -05:00
bfahrenfort
41d2b6e3a6 feat(contentIndex): Per-folder RSS feeds 2024-11-24 11:10:54 +11:00
7 changed files with 358 additions and 62 deletions

View File

@ -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.

View File

@ -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
},
})
```

View File

@ -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

View File

@ -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 }

View File

@ -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 = [

View File

@ -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,22 +82,39 @@ 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>
<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}
</channel>
</rss>`
<channel>
<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>${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) => {
@ -114,32 +142,80 @@ 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, {
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 ?? "",
})
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 ?? [],
content: file.data.text ?? "",
richContent: opts?.rssFullHtml
? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true }))
: undefined,
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",
}),
)
}
}
}
}

View File

@ -110,6 +110,7 @@ declare module "vfile" {
publish: boolean | string
draft: boolean | string
lang: string
noRSS: boolean
enableToc: string
cssclasses: string[]
socialImage: string