Skip to content

Commit 6119968

Browse files
committed
whitelist rss feed
1 parent 96964bd commit 6119968

3 files changed

Lines changed: 262 additions & 1 deletion

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import path from "path"
2+
import fs from "fs/promises"
3+
import type { GlobalConfiguration } from "../../quartz/cfg"
4+
import type { BuildCtx } from "../../quartz/util/ctx"
5+
import type { QuartzEmitterPlugin } from "../../quartz/plugins/types"
6+
import type { ProcessedContent, QuartzPluginData } from "../../quartz/plugins/vfile"
7+
import { escapeHTML } from "../../quartz/util/escape"
8+
import {
9+
type FilePath,
10+
type FullSlug,
11+
type SimpleSlug,
12+
joinSegments,
13+
simplifySlug,
14+
} from "../../quartz/util/path"
15+
16+
export type ContentIndexMap = Map<FullSlug, ContentDetails>
17+
18+
export type ContentDetails = {
19+
slug: FullSlug
20+
filePath: FilePath
21+
title: string
22+
links: SimpleSlug[]
23+
tags: string[]
24+
content: string
25+
richContent?: string
26+
date?: Date
27+
description?: string
28+
}
29+
30+
type Options = {
31+
enableSiteMap: boolean
32+
enableRSS: boolean
33+
rssLimit?: number
34+
rssFullHtml: boolean
35+
rssSlug: string
36+
includeEmptyFiles: boolean
37+
rssIncludePrefixes: string[]
38+
rssRecentNotesText?: string
39+
rssLastFewNotesText?: (count: number) => string
40+
}
41+
42+
const defaultOptions: Options = {
43+
enableSiteMap: true,
44+
enableRSS: true,
45+
rssLimit: 10,
46+
rssFullHtml: false,
47+
rssSlug: "index",
48+
includeEmptyFiles: true,
49+
rssIncludePrefixes: [],
50+
rssRecentNotesText: "Recent notes",
51+
rssLastFewNotesText: (count) => `Last ${count} notes`,
52+
}
53+
54+
const write = async (args: {
55+
ctx: BuildCtx
56+
content: string
57+
slug: FullSlug
58+
ext: string
59+
}): Promise<FilePath> => {
60+
const pathToPage = joinSegments(args.ctx.argv.output, args.slug + args.ext) as FilePath
61+
const dir = path.dirname(pathToPage)
62+
await fs.mkdir(dir, { recursive: true })
63+
await fs.writeFile(pathToPage, args.content)
64+
return pathToPage
65+
}
66+
67+
function dateFor(data: QuartzPluginData): Date | undefined {
68+
const dateType = data.defaultDateType as keyof NonNullable<QuartzPluginData["dates"]> | undefined
69+
const value =
70+
(dateType ? data.dates?.[dateType] : undefined) ??
71+
data.dates?.modified ??
72+
data.dates?.created ??
73+
data.dates?.published
74+
75+
if (!value) return undefined
76+
const date = value instanceof Date ? value : new Date(value)
77+
return Number.isNaN(date.valueOf()) ? undefined : date
78+
}
79+
80+
function normalizePrefix(prefix: string): string {
81+
return prefix.replace(/^\/+/, "").replace(/\/?$/, "/")
82+
}
83+
84+
function rssIndex(idx: ContentIndexMap, rssIncludePrefixes: string[]): ContentIndexMap {
85+
const prefixes = rssIncludePrefixes.map(normalizePrefix).filter((prefix) => prefix.length > 1)
86+
if (prefixes.length === 0) return idx
87+
88+
return new Map(
89+
Array.from(idx).filter(([slug]) => {
90+
const normalized = simplifySlug(slug)
91+
return prefixes.some((prefix) => normalized.startsWith(prefix) && normalized !== prefix)
92+
}),
93+
)
94+
}
95+
96+
function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string {
97+
const base = cfg.baseUrl ?? ""
98+
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
99+
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
100+
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
101+
</url>`
102+
const urls = Array.from(idx)
103+
.map(([slug, content]) => createURLEntry(simplifySlug(slug) as SimpleSlug, content))
104+
.join("")
105+
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
106+
}
107+
108+
function generateRSSFeed(
109+
cfg: GlobalConfiguration,
110+
idx: ContentIndexMap,
111+
options: Options,
112+
limit?: number,
113+
): string {
114+
const base = cfg.baseUrl ?? ""
115+
const pageTitle = cfg.pageTitle ?? ""
116+
const recentNotesText = options.rssRecentNotesText ?? "Recent notes"
117+
const lastFewNotesText =
118+
options.rssLastFewNotesText ?? ((count: number) => `Last ${count} notes`)
119+
120+
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
121+
<title>${escapeHTML(content.title)}</title>
122+
<link>https://${joinSegments(base, encodeURI(slug))}</link>
123+
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
124+
<description><![CDATA[ ${content.richContent ?? content.description} ]]></description>
125+
<pubDate>${content.date?.toUTCString()}</pubDate>
126+
</item>`
127+
128+
const items = Array.from(idx)
129+
.sort(([_, f1], [__, f2]) => {
130+
if (f1.date && f2.date) return f2.date.getTime() - f1.date.getTime()
131+
if (f1.date && !f2.date) return -1
132+
if (!f1.date && f2.date) return 1
133+
return f1.title.localeCompare(f2.title)
134+
})
135+
.map(([slug, content]) => createURLEntry(simplifySlug(slug) as SimpleSlug, content))
136+
.slice(0, limit ?? idx.size)
137+
.join("")
138+
139+
const description = `${
140+
limit ? lastFewNotesText(limit) : recentNotesText
141+
} on ${escapeHTML(pageTitle)}`
142+
143+
return `<?xml version="1.0" encoding="UTF-8" ?>
144+
<rss version="2.0">
145+
<channel>
146+
<title>${escapeHTML(pageTitle)}</title>
147+
<link>https://${base}</link>
148+
<description>${description}</description>
149+
<generator>Quartz -- quartz.jzhao.xyz</generator>
150+
${items}
151+
</channel>
152+
</rss>`
153+
}
154+
155+
export const ContentIndexFiltered: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
156+
const options = { ...defaultOptions, ...opts }
157+
const emitAll = async (ctx: BuildCtx, content: ProcessedContent[]): Promise<FilePath[]> => {
158+
const cfg = ctx.cfg.configuration
159+
const linkIndex: ContentIndexMap = new Map()
160+
for (const [, file] of content) {
161+
const data = (file.data as QuartzPluginData & Record<string, unknown>) ?? {}
162+
if (data.unlisted === true) continue
163+
164+
const slug = data.slug as FullSlug
165+
const date = dateFor(data) ?? new Date()
166+
const text = data.text as string | undefined
167+
if (options.includeEmptyFiles || (text && text !== "")) {
168+
const frontmatter = (data.frontmatter as Record<string, unknown> | undefined) ?? {}
169+
const isEncrypted = data.encrypted === true
170+
linkIndex.set(slug, {
171+
slug,
172+
filePath: data.relativePath as FilePath,
173+
title: (frontmatter.title as string) ?? "",
174+
links: (data.links as SimpleSlug[] | undefined) ?? [],
175+
tags: (frontmatter.tags as string[] | undefined) ?? [],
176+
content: text ?? "",
177+
richContent:
178+
options.rssFullHtml && !isEncrypted && text ? escapeHTML(text) : undefined,
179+
date,
180+
description: (data.description as string | undefined) ?? "",
181+
})
182+
}
183+
}
184+
185+
const outputs: FilePath[] = []
186+
if (options.enableSiteMap) {
187+
outputs.push(
188+
await write({
189+
ctx,
190+
content: generateSiteMap(cfg, linkIndex),
191+
slug: "sitemap" as FullSlug,
192+
ext: ".xml",
193+
}),
194+
)
195+
}
196+
197+
if (options.enableRSS) {
198+
outputs.push(
199+
await write({
200+
ctx,
201+
content: generateRSSFeed(
202+
cfg,
203+
rssIndex(linkIndex, options.rssIncludePrefixes),
204+
options,
205+
options.rssLimit,
206+
),
207+
slug: (options.rssSlug ?? "index") as FullSlug,
208+
ext: ".xml",
209+
}),
210+
)
211+
}
212+
213+
const fp = joinSegments("static", "contentIndex") as FullSlug
214+
const simplifiedIndex = Object.fromEntries(
215+
Array.from(linkIndex).map(([slug, content]) => {
216+
delete content.description
217+
delete content.date
218+
return [slug, content]
219+
}),
220+
)
221+
222+
outputs.push(
223+
await write({
224+
ctx,
225+
content: JSON.stringify(simplifiedIndex),
226+
slug: fp,
227+
ext: ".json",
228+
}),
229+
)
230+
231+
return outputs
232+
}
233+
234+
return {
235+
name: "ContentIndexFiltered",
236+
emit: (ctx, content) => emitAll(ctx, content),
237+
partialEmit: (ctx, content) => emitAll(ctx, content),
238+
}
239+
}
240+
241+
export default ContentIndexFiltered
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "@hill/quartz-content-index-filtered",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"exports": {
6+
".": "./index.ts"
7+
},
8+
"quartz": {
9+
"name": "content-index-filtered",
10+
"displayName": "Filtered Content Index",
11+
"description": "Generate the sitemap, RSS feed, and content index with optional RSS path filtering.",
12+
"version": "1.0.0",
13+
"category": "emitter",
14+
"defaultOrder": 50
15+
}
16+
}

‎quartz.config.yaml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,15 @@ plugins:
115115
enabled: true
116116
- source: github:quartz-community/alias-redirects
117117
enabled: true
118-
- source: github:quartz-community/content-index
118+
- source: ./quartz-local/content-index-filtered
119119
enabled: true
120120
options:
121121
enableSiteMap: true
122122
enableRSS: true
123+
rssIncludePrefixes:
124+
- blog/
125+
- notes/
126+
- tools/
123127
- source: github:quartz-community/favicon
124128
enabled: true
125129
- source: github:quartz-community/og-image

0 commit comments

Comments
 (0)