emoji

Topに戻る

ブログのCMSにNotionを使うようにした(技術編)

emoji
2022/05/07 07:58
emoji
2022/05/07 09:00
Tech
emojiブログのCMSにNotionを使うようにした(理由編) のつづき
今回は技術的なところをメインに書きます。

構成

これは単純で、Next.js を利用してます。コンテンツの生成はSSRで行ってます。
SSGも選択できたのですが、コンテンツ作成のタイミングをフックしてSSGをするようするのが面倒だったのでSSRにしました。

辛い部分

主に Notion SDK が辛い。notion-client という非公式のクライアントがあり、Reactのコンポーネント等も提供されているがこっちはインターナルなAPIを叩いてるのであまり使いたくありませんでした。
辛いところは https://blog.lacolaco.net/2022/02/notion-headless-cms-1/ を参考にさせてもらいました 🙇

@notionhq/client の型が微妙

Notion公式から出ているpackageの型が微妙なんです。一度使ったことのある人はなんとなく分かるはず。データを取ってきてオンデマンドに処理を書くとか型にそこまで依存しないとかで書くならいいですが、型で良しなにやっていきたいみたいなモチベがあると厳しいです。
例えばページを取得するためのメソッドの返り値
export declare type GetPageResponse = {
    ... // 長いので省略
    object: "page";
    id: string;
    created_time: string;
    last_edited_time: string;
    archived: boolean;
    url: string;
} | {
    object: "page";
    id: string;
};
こんな感じの型になってます。TS を結構書いたことがある人はパット見で分かると思うのですが、ユニオン型でどちらの型か簡単に同定するプロパティが無いです。そのため、リクエストを処理するたびに TypeGuard を通す必要があります。
この型だけだったら問題無いのですが、至るところにこのような型が存在しているので前述したブログを参考に型を再定義する方針で実装を勧めました。
// ref: https://blog.lacolaco.net/2022/02/notion-headless-cms-1/
declare type MatchType<T, U, V = never> = T extends U ? T : V;

export type PageObject = MatchType<
  Awaited<ReturnType<Client['pages']['retrieve']>>,
  {
    properties: unknown;
  }
>;
これで情報量の少ないほうが削れる

リストが独立したブロックになっている

APIから返ってくるデータだと
  • 順番が無い
  • リスト
とか
  1. 順番がある
  2. リスト
が何個か連なっていても独立したブロックになっています。
これだと、ブロックごとにコンポーネントを割り当てていくとしてもリスト要素が独立しているため
<div>
  <ul>
    <li>順番がない</li>
  </ul>
  <ul>
    <li>リスト</li>
  </ul>
</div>
というようなマークアップになってしまいます。 ul はこれでもいいんですが、 ol はこれだと正しい順所が振られないためまとめる必要がありました。
まず、まとめるために BlockObject 型を拡張します。
export type BlockObject = (
  | MatchType<
      ElementType<
        Awaited<ReturnType<Client['blocks']['children']['list']>>['results']
      >,
      {
        type: unknown;
      }
    >
  | {
      type: 'bulleted_list';
      id: string;
      bulleted_list: BlockObject[];
      has_children: false;
    }
  | {
      type: 'numbered_list';
      id: string;
      numbered_list: BlockObject[];
      has_children: false;
    }
) & { children?: BlockObject[] };
bulleted_list_itemnumbered_list_item をまとめるための型を用意します。
Notionの型は、およそ type の値がキーになったプロパティがついているのでそれに習います。
次にこの型に合うようにリスト要素をまとめ上げます。
const reduceList = (blocks: BlockObject[]): BlockObject[] => {
  const { st, stack, isUnorderList } = blocks.reduce(
    (acc, x) => {
      // ulな要素が来ていたら
      if (acc.isUnorderList) {
        if (x.type === 'bulleted_list_item') {
          acc.stack.push(x);
          return acc;
        } else {
          acc.st.push({
            type: 'bulleted_list',
            bulleted_list: [...acc.stack],
            has_children: false,
            id: acc.stack.map((x) => x.id.slice(0, 3)).join('-'),
          });
          acc.stack = [];
          acc.isUnorderList = false;
        }
      }

      // olな要素が来ていたら
      if (acc.isOrderList) {
        if (x.type === 'numbered_list_item') {
          acc.stack.push(x);
          return acc;
        } else {
          acc.st.push({
            type: 'numbered_list',
            numbered_list: [...acc.stack],
            has_children: false,
            id: acc.stack.map((x) => x.id.slice(0, 3)).join('-'),
          });
          acc.stack = [];
          acc.isOrderList = false;
        }
      }

      if (x.type === 'bulleted_list_item') {
        acc.isUnorderList = true;
        acc.stack.push(x);

        return acc;
      }

      if (x.type === 'numbered_list_item') {
        acc.isOrderList = true;
        acc.stack.push(x);

        return acc;
      }

      acc.st.push(x);
      return acc;
    },
    {
      st: [] as BlockObject[],
      stack: [] as BlockObject[],
      isUnorderList: false,
      isOrderList: false,
    }
  );

  // stackに余った要素をリストにする
  const rest: BlockObject[] =
    stack.length !== 0
      ? isUnorderList
        ? [
            {
              type: 'bulleted_list' as const,
              bulleted_list: stack,
              has_children: false,
              id: stack.map((x) => x.id.slice(0, 3)).join('-'),
            },
          ]
        : [
            {
              type: 'numbered_list' as const,
              numbered_list: stack,
              has_children: false,
              id: stack.map((x) => x.id.slice(0, 3)).join('-'),
            },
          ]
      : [];

  return [...st, ...rest];
};
だいぶゴリ押しですが、これで bulleted_listnumbered_list が作成されます。

ページのプロパティ取得がダルい

Notionにはページにプロパティという付加情報(このブログだと、公開フラグや作成・更新日時、タグ)をつけれます。それを取得するのがちょっとダルいです。
type Propertis = Record<string, {
    type: "title";
    title: RichTextItemResponse[];
    id: string;
} | {
  ...
}>
というような型になっているので
  1. propertis[key] の要素があるか調べる
  2. それがなんの type かを見る
という流れを取らないといけません。1つ2つならこれでも問題無いのですが、プロパティは複数使うことがほとんどなのでもっと簡単に取得できると嬉しいです。
そこで、今回はこんな hooks を作って対応しました。
export const useProperty = <T extends Property['type']>(
  properties: PageObject['properties'],
  key: string,
  type: T
): MatchType<Property, { type: T }> | null => {
  const property = properties[key];

  return property.type === type
    ? (property as MatchType<Property, { type: T }>)
    : null;
};
取り出すプロパティとキーとそのプロパティに期待する type を入れます。
そうすることでこの hooks から出てきた値は type であることが保証されます。
使うときはこんな感じ
const Page: NextPage<Props> = ({ page, content }) => {
  const title = useProperty(page.properties, 'Name', 'title');
  const tags = useProperty(page.properties, 'Tags', 'multi_select');
  const created = useProperty(page.properties, 'Created', 'created_time');
  const updated = useProperty(page.properties, 'Updated', 'last_edited_time');
  const cover = page.cover;
  
  ...
}
これによって記述が少なく、良しなに型がついてくれます。

おわり

この他にもいろいろと辛い部分はありましたが、今回書いた部分がある程度まとまってきたらあとは Block に対応した View を書いて全体のレイアウトを整えるという感じでした。
リポジトリは https://github.com/uzimaru0000/blog にあるので実装が気になる人は見てみてください。
それでは 👋
B!
emoji

Topに戻る

このサイトではアクセス解析のためにcookieを使用したGoogle Analyticsを使用しています。

© 2022

uzimaru