monolithic kernel

Astro でバックリンクを解析して表示する

Astro で再構築したこのブログに Scrapbox や Obsidian で知られているバックリンクを表示する機能を実装した。ナレッジベース的なものではないブログでは意図としては少し異なるのだが、個人的にはある記事の続報的なことを書く際に新しい記事から古い記事に対してリンクすることが多いので、その逆方向にも辿れるようになっていれば、記事を読んだあとスムーズに続報に辿り着けて便利だろうと考えた。

例えば、この記事からブログを Astro で再構築したという記事にリンクしているので、リンク先の記事からもこの記事に飛べるリンクが表示される。

GatsbyJS を使っていたころからやりたかったが、何度か挑戦して挫折していた。Astro でも結構苦戦した。というのも、この手の静的サイトジェネレータのユースケースとして考慮されていない使い方であるために、極端にやりづらくなっているのである。

ある記事に対するバックリンクのリストを生成しようとした場合、他のすべての記事を解析する必要がある。これを記事ごとにやっていくのであれば、フレームワークのセマンティクスから外れることも無いのだが、その場合、記事数を n とすると、計算量としては O(n^2) となってしまい、記事数が増えれば増えるほど苦しくなる。

これを解決するためには、すべての記事のバックリンクのリストをまとめて生成すればよい。この場合は、(リンク元, リンク先) ペアをすべて抽出した上で転置する作業となるため、すべての記事を一度ずつ解析していくだけでよく、計算量は O(n) となる。すべての記事をレンダリングする前段階で一度すべての記事を解析するので、レンダリングが2パスになるとも考えられる。

このような仕組みは Astro には用意されていない。Integrations の API にもそのようなフックポイントはない。

ただ、レンダリング前にフックを挟むこと自体はできるので、そこで Astro の Content Collections などの仕組みは一切使えない状態で自前でファイルを読んで解析することはできる。

そんな発想で実装したのがこちら。astro:build:start のフックですべての記事を解析してリンクの関係性をメモリ上に保持する Astro Integrations 部分と、リンクの関係性を Frontmatter に埋め込む Remark プラグインの連携で成り立っている。astro:build:start でやっている都合上、プロダクションのビルド時にしか動かず、開発サーバでは機能しない。他のフックでやれば解決できるかもしれないが、パフォーマンスの懸念があるのと、ホットリロードとの兼ね合いなどまだ理解できていない部分も多いのとで見送っている。

先述の通り Astro の Content Collections のセマンティクスに乗っかれないので、記事を glob して引いてくる部分や Markdown をパースする部分、slug を導出する部分は自分のサイトに合わせたハードコーディングになっている。

src/remark/remark-references.ts
import type { AstroIntegration } from "astro"
import fs from "fs"
import { glob } from "glob"
import matter from "gray-matter"
import type { Link } from "mdast"
import remarkParse from "remark-parse"
import { unified } from "unified"
import { visit } from "unist-util-visit"

type References = {
  backlinks?: Map<string, Set<string>>
}

export const references = (): References => {
  return {}
}

export const analyzeReferences = (references: References): AstroIntegration => {
  return {
    name: "analyze-references",
    hooks: {
      "astro:build:start": async () => {
        const files = await Promise.all(
          (
            await glob("src/content/blog/**/*.mdx")
          ).map(async (path) => {
            const slug = relativePathToSlug(path)
            const { data, content } = matter(await fs.promises.readFile(path, "utf-8"))
            return {
              path,
              slug,
              data,
              content,
            }
          })
        )

        const fileBySlug = files.reduce((map, file) => {
          map.set(file.slug, file)
          return map
        }, new Map<string, (typeof files)[0]>())

        const linksBySlug = new Map<string, Set<string>>()

        const parser = unified().use(remarkParse)
        for (const { slug, content } of files) {
          const tree = parser.parse(content)
          visit(tree, "link", (node) => {
            const { url } = node as Link
            const link = url.replace(/^\/(?:blog\/)?(.+?)\/$/, "$1")
            if (!fileBySlug.has(link)) {
              return
            }
            const links = linksBySlug.get(slug)
            if (links) {
              links.add(link)
            } else {
              linksBySlug.set(slug, new Set([link]))
            }
          })
        }

        const backlinksBySlug = new Map<string, Set<string>>()
        linksBySlug.forEach((links, slug) => {
          for (const link of links) {
            const backlinks = backlinksBySlug.get(link)
            if (backlinks) {
              backlinks.add(slug)
            } else {
              backlinksBySlug.set(link, new Set([slug]))
            }
          }
        })

        references.backlinks = backlinksBySlug
      },
    },
  }
}

export default function remarkReferences({ references }: { references: References }) {
  return (tree: any, file: any) => {
    const slug = relativePathToSlug(file.history[0].replace(`${file.cwd}/`, ""))
    const result = new Set<string>()
    const backlinks = references.backlinks?.get(slug)
    if (backlinks) {
      backlinks.forEach((link) => result.add(link))
    }
    file.data.astro.frontmatter.backlinks = Array.from(result)
  }
}

const relativePathToSlug = (path: string): string => {
  return path.replace(/^src\/content\/blog\/(.+?)\/index\.mdx$/, "$1")
}
astro.config.mjs
import mdx from "@astrojs/mdx"
import { defineConfig } from "astro/config"

import remarkReferences, {
  analyzeReferences,
  references as createReferences,
} from "./src/remark/remark-references"

const references = createReferences()

export default defineConfig({
  integrations: [
    mdx(),
    analyzeReferences(references),
  ],
  markdown: {
    remarkPlugins: [
      [
        remarkReferences,
        {
          references,
        },
      ],
    ],
  },
})

レンダリングにあたっては、zod のスキーマを定義していい感じにやってる。

src/schemas.ts
import { reference, z } from "astro:content"

export const blogFrontmatterSchema = z.object({
  backlinks: z.array(reference("blog")).optional().default([]),
})
src/pages/[...slug].astro
---
import { getEntries } from "astro:content"

import { blogFrontmatterSchema } from "../schemas"

const { entry } = Astro.props
const { Content, remarkPluginFrontmatter } await entry.render()
const frontmatter = await blogFrontmatterSchema.parseAsync(remarkPluginFrontmatter)
const backlinkEntries = await getEntries(backlinks)
---

<ul>
  {backlinkEntries.map((entry) => (
    <li>
      <a href={blogEntryUrl(entry)}>{entry.title}</a>
    </li>
  ))}
</ul>

バックリンク以外でも、すべての記事の embedding を計算しておいて関連記事の表示に使う、といったことも考えられる。そのような場合でも、あらかじめすべての記事について計算、ということが素直にはできないので、今のところはこの記事のようなやり方になるのかなと思う。あるいは、ビルドプロセスに組み込むのではなくて外部のシステムとして動的に取り込んだほうが楽なのかもしれない。


2024-09-19 追記

Astro の最近のバージョンだとエラーになっていたため、スキーマでバリデーションする際に parse ではなく parseAsync を使うようにした。


Related articles