Skip to content

文章列表

839 words
4 min

概述

本文记录了我是如何在开发主题时, 获取文章列表的.

封装 lodaer 方法

一开始我直接使用了 vitepress 官方文档推荐的方式, 也就是在 构建时数据加载-createContentLoader 这一章, 调用了createContentLoader 方法, 但不能完全满足我的需求, 我还需要获取每个文章上次 git 提交的时间, 如果没有使用 git, 则使用上次编辑的时间来兜底.

所以我干脆自己封装了一个loader方法, 与 vitepress 提供的方法功能类似, 但比它更丰富:

ts
import path from "node:path";
import fs from "fs-extra";
import matter from "gray-matter";
import { glob, type GlobOptions } from "tinyglobby";
import { createMarkdownRenderer, type SiteConfig } from "vitepress";
import { dateToUnixTimestamp } from "./date";
import { getLastCommitInfo } from "./git";
import { getPattern, normalizePath } from "./path";

export interface ContentData {
  url: string;
  src: string | undefined;
  html: string | undefined;
  frontmatter: Record<string, any>;
  excerpt: string | undefined;
  // fileModifiedTime: number
}
export interface ContentOptions<T = ContentData[]> {
  /**
   * Include src?
   * @default false
   */
  includeSrc?: boolean;

  /**
   * Render src to HTML and include in data?
   * @default false
   */
  render?: boolean;

  /**
   * If `boolean`, whether to parse and include excerpt? (rendered as HTML)
   *
   * If `function`, control how the excerpt is extracted from the content.
   *
   * If `string`, define a custom separator to be used for extracting the
   * excerpt. Default separator is `---` if `excerpt` is `true`.
   *
   * @see https://github.com/jonschlinkert/gray-matter#optionsexcerpt
   * @see https://github.com/jonschlinkert/gray-matter#optionsexcerpt_separator
   *
   * @default false
   */
  excerpt?:
    | boolean
    | ((
        file: {
          data: { [key: string]: any };
          content: string;
          excerpt?: string;
        },
        options?: any
      ) => void)
    | string;

  /**
   * Transform the data. Note the data will be inlined as JSON in the client
   * bundle if imported from components or markdown files.
   */
  transform?: (data: ContentData[]) => T | Promise<T>;

  /**
   * Options to pass to `tinyglobby`.
   * You'll need to manually specify `node_modules` and `dist` in
   * `globOptions.ignore` if you've overridden it.
   */
  globOptions?: GlobOptions;
}

export function createArticlesListLoader<T = ContentData[]>({
  includeSrc,
  render,
  excerpt: renderExcerpt,
  transform,
}: ContentOptions<T> = {}): {
  watch: string | string[];
  load: () => Promise<T>;
} {
  const config: SiteConfig = (global as any).VITEPRESS_CONFIG;

  if (!config) {
    throw new Error(
      "content loader invoked without an active vitepress process, " +
        "or before vitepress config is resolved."
    );
  }
  const pattern = getPattern(config.srcDir);
  const cache = new Map<string, { data: any; timestamp: number }>();

  return {
    watch: pattern,
    async load(files?: string[]) {
      files = await glob(pattern, {
        ignore: ["**/node_modules/**", "**/dist/**", "**/README.md"],
        expandDirectories: false,
        absolute: true,
      });
      const md = await createMarkdownRenderer(
        config.srcDir,
        config.markdown,
        config.site.base,
        config.logger
      );
      const raw: ContentData[] = [];
      for (const file of files) {
        if (!file.endsWith(".md")) {
          continue;
        }
        const timestamp = fs.statSync(file).mtimeMs;
        const cached = cache.get(file);
        if (cached && timestamp === cached.timestamp) {
          raw.push(cached.data);
        } else {
          const src = fs.readFileSync(file, "utf-8");
          const { data: frontmatter, excerpt } = matter(
            src,
            typeof renderExcerpt === "string"
              ? // eslint-disable-next-line camelcase
                { excerpt_separator: renderExcerpt as any }
              : { excerpt: renderExcerpt as any }
          );

          if (frontmatter.date) {
            frontmatter.date = dateToUnixTimestamp(frontmatter.date);
          } else {
            const lastCommitInfo = await getLastCommitInfo(
              path.relative(config.srcDir, file)
            );
            const lastCommitDate = lastCommitInfo?.date
              ? dateToUnixTimestamp(new Date(lastCommitInfo.date))
              : null;
            frontmatter.date = lastCommitDate || timestamp;
          }

          if (
            typeof frontmatter.sticky === "boolean" ||
            typeof frontmatter.sticky === "number"
          ) {
            frontmatter.sticky = Number(frontmatter.sticky);
          } else {
            frontmatter.sticky = 0;
          }

          if (typeof frontmatter.order !== "number") {
            frontmatter.order = 0;
          }
          const url = `/${normalizePath(path.relative(config.srcDir, file))
            .replace(/(^|\/)index\.md$/, "$1")
            .replace(/\.md$/, config.cleanUrls ? "" : ".html")}`;

          const html = render ? md.render(src) : undefined;
          // const fileModifiedTime = timestamp;
          const renderedExcerpt = renderExcerpt
            ? excerpt && md.render(excerpt)
            : undefined;
          const data: ContentData = {
            // fileModifiedTime,

            src: includeSrc ? src : undefined,
            html,
            frontmatter,
            excerpt: renderedExcerpt,
            url,
          };
          cache.set(file, { data, timestamp });
          raw.push(data);
        }
      }
      return (transform ? transform(raw) : raw) as any;
    },
  };
}

之后在 data 中可以这样调用:

数据加载

ts
import matter from "gray-matter";
import { readingTime } from "reading-time-estimator";
import { NOT_ARTICLE_LAYOUTS } from "../constants";
import { getTextDescription } from "../utils/common";
import { createArticlesListLoader } from "../utils/node/articles";

export interface SidebarFrontmatter {
  text?: string;
  collapsed?: boolean;
  order?: number;
  title?: string;
  hide?: boolean;
}

export interface ArticlesData {
  title: string;
  path: string;
  description: string;
  date: number;
  tags: string[];
  words: number;
  minutes: number;
  category: string;
  order: number;
  sidebar: boolean | SidebarFrontmatter;
}

export default createArticlesListLoader({
  includeSrc: true,
  render: true,
  excerpt: true,
  transform(rawData) {
    const data = rawData
      .filter((item) => !NOT_ARTICLE_LAYOUTS.includes(item.frontmatter.layout))
      .sort((a, b) => {
        if (a.frontmatter.sticky !== b.frontmatter.sticky) {
          return b.frontmatter.sticky - a.frontmatter.sticky;
        }
        return b.frontmatter.date - a.frontmatter.date;
      })
      .map((item) => {
        const filename =
          item.url.split("/")[item.url.split("/").length - 1].split(".")[0] ||
          "index";
        const content = matter(item.src || "").content;
        const { words, minutes } = readingTime(content, 200);
        const match = content.match(/^(#+)\s+(.+)/m);
        const title = match?.[2] || filename;
        let { date, description = content, ...frontmatter } = item.frontmatter;

        description = getTextDescription(description);
        return {
          path: item.url,
          description,
          title,
          words,
          minutes,
          date,
          ...frontmatter,
        };
      });
    return data;
  },
});

declare const data: ArticlesData[];
export { data };

在拿到文章相关数据时顺便根据date来排序, 然后获取了文章字数和阅读时间.