在生成 RSS 的时候,我发现 Quartz 并不能正确地生成 RSS,它会先按首字母顺序提取所有文档,然后按照设置的参数提取前 rssLimit 篇文章然后生成 RSS Feed。但很明显我们希望它能生成最新的文章的 Feed,因此我先去 issue 中看了一下,找到了一个最像的,简单留了个言就去看代码了。

这一看就觉得不大对劲,在渲染 RSS 的过程中,作者是这样提取文章的:

quartz/plugins/emitters/contentIndex.ts
  const items = Array.from(idx)
    .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
    .slice(0, limit ?? idx.size)
    .join("")

这样似乎没有对 RSS 按时间排序,但咱也不懂 typescript 啊,只好求助 ChatGPT 帮忙生成了一段按时间排序的版本:

  const items = Array.from(idx)
    .sort(([slugA, contentA], [slugB, contentB]) => {
      const dateA = contentA.date ?? new Date(0);
      const dateB = contentB.date ?? new Date(0);
      return dateB.getTime() - dateA.getTime();
    })
    .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
    .slice(0, limit ?? idx.size)
    .join("");

虽然不会写,但至少咱还是会读的,结合上下文,简单猜测这一段代码的意思就是看文章的属性中有没有 date 这个参数,有的话就使用,否则的话就使用当前时间进行比较。那么 date 是在哪获得的呢?简单往上跟一下能找到:

quartz\plugins\emitters\contentIndex.ts
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
  opts = { ...defaultOptions, ...opts }
  return {
    name: "ContentIndex",
    async emit(ctx, content, _resources, emit) {
      ... ...
      for (const [tree, file] of content) {
        const slug = file.data.slug!
        const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
        ... ...

这里调用了 getDate 函数:

quartz\components\Date.tsx
export function getDate(cfg: GlobalConfiguration, data: QuartzPluginData): Date | undefined {
  ... ...
  return data.dates?.[cfg.defaultDateType]
}

这里 dates 是一个数据结构:

quartz\plugins\transformers\lastmod.ts
declare module "vfile" {
  interface DataMap {
    dates: {
      created: Date
      modified: Date
      published: Date
    }
  }
}

简单来说就是选取文件三个属性中的一个,cfg.defaultDateType 默认的取值是:

quartz.config.ts
const config: QuartzConfig = {
  configuration: {
    ... ...
    defaultDateType: "created",
    ... ...

那奇怪了,quartz 是在哪里识别到我 frontmatter 里面的 date 呢?找到:

quartz\plugins\transformers\lastmod.ts
export const CreatedModifiedDate: QuartzTransformerPlugin<Partial<Options> | undefined> = (
  userOpts,
) => {
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "CreatedModifiedDate",
    markdownPlugins() {
      return [
        () => {
          let repo: Repository | undefined = undefined
          return async (_tree, file) => {
            let created: MaybeDate = undefined
            let modified: MaybeDate = undefined
            let published: MaybeDate = undefined
 
            const fp = file.data.filePath!
            const fullFp = path.posix.join(file.cwd, fp)
            for (const source of opts.priority) {
              if (source === "filesystem") {
                const st = await fs.promises.stat(fullFp)
                created ||= st.birthtimeMs
                modified ||= st.mtimeMs
              } else if (source === "frontmatter" && file.data.frontmatter) {
                created ||= file.data.frontmatter.date
                modified ||= file.data.frontmatter.lastmod
                modified ||= file.data.frontmatter.updated
                modified ||= file.data.frontmatter["last-modified"]
                published ||= file.data.frontmatter.publishDate
              } else if (source === "git") {
                if (!repo) {
                  repo = new Repository(file.cwd)
                }
 
                modified ||= await repo.getFileLatestModifiedDateAsync(file.data.filePath!)
              }
            }
 
            file.data.dates = {
              created: coerceDate(fp, created),
              modified: coerceDate(fp, modified),
              published: coerceDate(fp, published),
            ... ...

原来是在这里提取时间属性的,而且默认配置:

const defaultOptions: Options = {
  priority: ["frontmatter", "git", "filesystem"],
}

默认的优先级就是 frontmatter 优先。

好了,虽然绕的有点远,但我还是发现了问题所在,于是把这一段代码加上就提了个 PR。代码没看懂就去交 PR 属实有点离谱,下次别这么干了。作者给了个意见更是没看懂,那就看看作者的操作吧:

diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts
index 69d0d37..2c70368 100644
--- a/quartz/plugins/emitters/contentIndex.ts
+++ b/quartz/plugins/emitters/contentIndex.ts
@@ -6,6 +6,7 @@ import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path"
 import { QuartzEmitterPlugin } from "../types"
 import { toHtml } from "hast-util-to-html"
 import path from "path"
+import { byDateAndAlphabetical } from "../../components/PageList"
 
 export type ContentIndex = Map<FullSlug, ContentDetails>
 export type ContentDetails = {
@@ -59,6 +60,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu
   </item>`
 
   const items = Array.from(idx)
+    .sort((a, b) => byDateAndAlphabetical(cfg)(a[1], b[1]))
     .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
     .slice(0, limit ?? idx.size)
     .join("")

他导入了一个 byDateAndAlphabetical 然后用这个函数去 sort,但是并没有解决最开始提到的问题,RSS 生成时还是只生成按首字母排序的 feed。那为什么作者的 commit 不行呢?TS 小白抱着困惑去翻代码了。byDateAndAlphabetical 函数长这个样子:

quartz\components\PageList.tsx
export function byDateAndAlphabetical(
  cfg: GlobalConfiguration,
): (f1: QuartzPluginData, f2: QuartzPluginData) => number {
  return (f1, f2) => {
    if (f1.dates && f2.dates) {
      // sort descending
      return getDate(cfg, f2)!.getTime() - getDate(cfg, f1)!.getTime()
    } else if (f1.dates && !f2.dates) {
      // prioritize files with dates
      return -1
    } else if (!f1.dates && f2.dates) {
      return 1
    }
 
    // otherwise, sort lexographically by title
    const f1Title = f1.frontmatter?.title.toLowerCase() ?? ""
    const f2Title = f2.frontmatter?.title.toLowerCase() ?? ""
    return f1Title.localeCompare(f2Title)
  }
}

不对劲的地方出现在参数属性这里,在 byDateAndAlphabetical 函数中,函数参数的属性是 QuartzPluginData,但实际上传入的参数属性是 ContentDetails,这就导致这个函数没有效果了。还好作者最后的 commit 解决了问题:

   const items = Array.from(idx)
-    .sort((a, b) => byDateAndAlphabetical(cfg)(a[1], b[1]))
+    .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("")

作者的思路是先按照时间比较,不成功则按照 title 比较,但是实际上我们看之前的代码 date 属性是一定存在的:

quartz\plugins\emitters\contentIndex.ts
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (opts) => {
  opts = { ...defaultOptions, ...opts }
  return {
    name: "ContentIndex",
    async emit(ctx, content, _resources, emit) {
      ... ...
      for (const [tree, file] of content) {
        const slug = file.data.slug!
        const date = getDate(ctx.cfg.configuration, file.data) ?? new Date()
        ... ...

所以最后加的那个 return f1.title.localeCompare(f2.title) 感觉有点画蛇添足。不过说不定也许以后真遇上了呢?

虽然不够优雅,但还是顺利解决啦,鼓掌鼓掌👏👏。