astro-notion-blogのカスタマイズ

Article Cover Image

背景

本ブログはこちらのastro-notion-blogをforkさせて作成しました。

その際にかなりの変更と魔改造を行ったので、主に加えて変更点についてそれらを紹介できればと思います。

私と同様にエンジニアで、自分なりにastro-notion-blogをカスタマイズしたい方向けの内容になっています。以下の2つの章に分けて紹介していきます。

  • Frontend: UIデザインなど
  • Backend: notion api の呼び出しなど

1. Frontend

この様に本サイトでは元のastro-notion-blogのデザインから大幅に変更を加えています。

Tailwind CSSとPreline UIの導入

当初はReactコンポーネントを提供するMaterial UIを使用しようかと思いましたが、astroをサポートしていないないため、Tailwind CSSによって記述されるPreline UIを導入しました。Preline UIが提供するコンポーネントをさらにカスタマイズしながら使用しています。

目次を導入

記事ページではHeadingを元に自動で目次の生成を行い、それを表示させるようにしました。

大きい画面

以下の様に大きい画面では右端に目次が表示され、スクロールと共に現在表示されている部分がハイライトされる様にしました。

スクロールの際にWindowの位置を確認し、対象の要素にactive classを追加することで実現しています。

<script>
document.addEventListener("scroll", function() {
  const wrapper = document.querySelector(".article-with-toc") as Element;

  // contents lenght should be same as tocLinks length
  const contents = document
    .querySelectorAll("a.adjust-sticky-header-toggle, a.adjust-sticky-header");
  const tocLinks = document.querySelectorAll("a.toc-link");

  const contentPositions: any[] = [];
  contents.forEach((content, i) => {
    const startPosition =
      content.getBoundingClientRect().top;
    const endPosition = contents.item(i + 1)
      ? contents.item(i + 1).getBoundingClientRect().top
      : wrapper.scrollHeight;
    contentPositions.push({ startPosition, endPosition });
  });

  tocLinks.forEach((tocLink, i) => {
    const { startPosition, endPosition } = contentPositions[i];
    if (
      startPosition <= 0 &&
      endPosition > 0
    ) {
      tocLink.classList.add('active');
    } else {
      tocLink.classList.remove('active');
    }
  });
});
</script>
通常サイズ以下の画面

また通常サイズ以下の画面ではアコーディオン型の目次となるようにしました。

gsapをを導入して以下のコードの様に挙動を実装しました。

<script>
import { gsap } from "gsap";

document.addEventListener("DOMContentLoaded", () => {
  setUpAccordion();
});

/**
 * ライブラリ(GSAP)を使ってアコーディオンのアニメーションを制御します
 */
const setUpAccordion = () => {
  const details = document.querySelectorAll(".js-details");
  const IS_OPENED_CLASS = "is-opened"; // アイコン操作用のクラス名

  details.forEach((element) => {
    const summary = element.querySelector(".js-summary") as Element;
    const content = element.querySelector(".js-content") as Element;

    summary.addEventListener("click", (event) => {
      // デフォルトの挙動を無効化
      event.preventDefault();

      // is-openedクラスの有無で判定(detailsのopen属性の判定だと、アニメーション完了を待つ必要がありタイミング的に不安定になるため)
      if (element.classList.contains(IS_OPENED_CLASS)) {
        // アコーディオンを閉じるときの処理
        // アイコン操作用クラスを切り替える(クラスを取り除く)
        element.classList.toggle(IS_OPENED_CLASS);

        // アニメーション実行
        closingAnim(content, element).restart();
      } else {
        // アコーディオンを開くときの処理
        // アイコン操作用クラスを切り替える(クラスを付与)
        element.classList.toggle(IS_OPENED_CLASS);

        // open属性を付与
        element.setAttribute("open", "true");

        // アニメーション実行
        openingAnim(content).restart();
      }
    });
  });
}

/**
 * アコーディオンを閉じる時のアニメーション
 */
const closingAnim = (content: Element, element: Element) => gsap.to(content, {
  height: 0,
  opacity: 0,
  duration: 0.4,
  ease: "power3.out",
  overwrite: true,
  onComplete: () => {
    // アニメーションの完了後にopen属性を取り除く
    element.removeAttribute("open");
  },
});

/**
 * アコーディオンを開く時のアニメーション
 */
const openingAnim = (content: Element) => gsap.fromTo(
  content,
  {
    height: 0,
    opacity: 0,
  },
  {
    height: "auto",
    opacity: 1,
    duration: 0.4,
    ease: "power3.out",
    overwrite: true,
  });

</script>

2. Backend

投稿のコンテンツデータの取得ロジックを変更

astro-notion-blogでは各投稿ページのコンテンツのデータを取得する際に、実は全ての投稿を一旦取得します。そしてその中から対象の投稿のPageIdを取得して、notion apiにはそのPageIdを渡して実際の投稿のコンテンツ(コードではBlocks)を取得します。

このページを例としますと、このページのurlはまず以下の様になっています。

https:// … /posts/customize-zhong-notion-blog

このcustomize-zhong-notion-blogの部分はastro-notion-blogではSlugと呼ばれ、投稿者によって定義されている各投稿のidの様なものです。投稿者が独自に定義したものなので、当然notion apiからこのSlugを使用して対応する投稿のコンテンツを取得することはできません。

そこでastro-notion-blogでは以下の様に一旦全ての投稿を取得します。

export async function getPostBySlug(slug: string): Promise<Post | null> {
  const allPosts = await getAllPosts();
  return allPosts.find((post) => post.Slug === slug) || null;
}

ここでのallPostsの中身は以下のように全ての投稿のメタデータを含んでいる構造となっています。(各投稿の実際のコンテンツデータまでは入っていません。ホームのブログ一覧はこちらを使用しています。)

このメタデータにはPageId, Slugがあり、そこで上のコードの様にSlugに対応するPageIdを取得します。

[
    {
      PageId: 'a411f13b-d816-41dc-925c-ab12cd45ef56',
      Title: '',
      Icon: null,
      Cover: null,
      Slug: 'customize-zhong-notion-blog',
      Date: '2024-03-18',
      Tags: [Array],
      Excerpt: '...',
      FeaturedImage: [Object],
      Rank: 0
    },
    ...
]

そしてこのPageIdgetAllBlocksByBlockId関数に渡すことで対応する投稿のコンテンツを取得します。(ByBlockIdとありますが、これは入れ子構造を再帰的に取得する関数でPageIdBlockIdの1つとな見なせます。)

個人的には毎回allPostsを呼び出して、その中から対応する投稿を探すというのは本来非効率的なことであると思っているのでここを変更しました。以下の様に各投稿のSlugに対応するPageIdのmapを保持することにしました。

interface SlugPageIdMapping {
  [key: string]: string | undefined;
}

export const SLUG_PAGE_ID_MAPPING: SlugPageIdMapping = {
  'customize-zhong-notion-blog': 'c209d20a140446e1a8c0d116bc10a4d7',
};

各投稿のPageIdは実は簡単に取得できます。以下の様に新しいページで投稿のコンテンツをまず開きましょう。

するとそのurlはこの様な形になっているかと思います。そして末尾のこの赤い部分がPageIdとなります。

https://www.notion.so/astro-notion-blog-a123d20a140446e1a8c0d116bc10a4d7

これをPageIdを生のデータとしてGithubで管理してしまうのはどうかと考えましたが、結局はただのuuidなのでよしとしました。

これに伴ってgetPostBySlug関数とgetPostByPageId以下の様に変更しました。(元々のgetPostByPageIdロジックはversion2としました。)

export async function getPostBySlug(slug: string): Promise<Post | null> {
  const pageId = SLUG_PAGE_ID_MAPPING[slug];

  if (pageId) {
    return getPostByPageId(pageId);
  }

  const allPosts = await getAllPosts();
  return allPosts.find((post) => post.Slug === slug) || null;
}

export async function getPostByPageId(pageId: string, version = 1): Promise<Post | null> {
  switch (version) {
    case 1:
      return client.pages.retrieve({ page_id: pageId })
        .then((res) => buildPost(res as responses.PageObject))
        .catch(() => null);
    case 2:
      return getAllPosts().then((posts) => posts.find((post) => post.PageId === pageId) || null);
    default:
      return null;
  }
}

実は非効率と言いながら従来のastro-notion-blogではallPostsをキャッシュするロジックが書かれています。また投稿数は多くてもたかだか数百程度なので、filterもfindも大したオーバーヘッドとはなりません。それでも個人的にはしっくりするという理由からこの様に変更しました。

ちなにみに📄Arrow icon of a page linkastro-notion-blogとは? でも記述してますが、staticモードではbuildのタイミングでのみこれらの関数が実行され静的コンテンツを作成します。よってbuildの時間がほんの少しだけ短くなるくらいしか効果がありません。

関連記事