Open25

Directus + Astroでブログっぽいウェブを作ってみる

SLMNLLSLMNLL

これは何?

Headless CMSのDirectusをローカルで動かして、SSGフレームワーク[1]Astroと組み合わせて、ブログっぽいウェブを作ってみます。

いずれの技術についても、少しいじったことがある程度。初学者レベルの知識しかないので、紆余曲折を含めた過程を記録します。

ひとまず、ローカルで動くウェブサイトを作るところまでをゴールとします[2]

脚注
  1. 静的サイトジェネレータ ↩︎

  2. 実装までの紆余曲折を含むかなり雑多な内容なので、一区切りついた時点で記事化する予定です。 ↩︎

SLMNLLSLMNLL

しばらく放置してましたが、続きを実装していきます

2023-11-05追記

スクラップ記事投稿時点(2022年11月)では、DirectusもAstroもよくわからない状態だったので、とりあえず動かしてみて満足。その後しばらく放置してました。

ただ、Directusの使用感が良かったのと、ちゃんとしたサイトを作りたいので、1年越しに続きを頑張ってみることにします。

なお、2022年11月にこのスクラップを書き始めてから、いくつかのプロジェクトでAstroを採用したため、Astroは少しずつ使いこなせるようになってきました。一方、Directusはその良さを実感しつつも、外部のプロジェクトに採用することは難しかった[1]ので、初心者のままです。


本スクラップでは、追加で以下の項目について検証・学んでいきたいと思います:

脚注
  1. DirectusをSelf-Hostingで採用できるような条件のプロジェクトはなかなかないですね…。 ↩︎

SLMNLLSLMNLL

なぜAstro?

最近話題なので実際に少し試してみたところ、ビルドや生成されたウェブの動作が、軽快さで知られるHugo並みに速く、コードを書くにあたって学習コストが高くなさそうだったというのが主な理由です。

UIフレームワークを自由に選べるのも良いです。

なぜDirectus?

HeadlessCMSの選定にあたっては、Astroの公式ドキュメントに載っているものから選ぶことにしました。ここに載っていないCMSだと、他にmicroCMSやStrapiなどもAstroとの連携についての情報を公開しています。

条件1 : ローカルで動く

microCMSやContentfulは日本語の情報も多くセットアップも容易なのですが、ローカルで記事を管理したかったので、ローカルな環境で動くCMSに絞ります。Directus、PayloadCMS、Strapi、Ghostが候補です。

条件2 : SQLiteを使いたい

頻繁に記事を更新する予定はなく、管理するページ数も少なくなる予定なので、データベースは管理・運用が簡単[1]なSQLiteを使うことにします。この時点でPayloadCMS(MongoDB)、Ghost(MySQL)は候補から外れます。どちらもUIが綺麗で好みではありますが…残念。

結果、今のところDirectusが良さそう

以下の理由で、Directusにしました。

ただし、行き詰まった場合には、情報量が多いStrapiに浮気する可能性も全然あります。

脚注
  1. MySQLやMongoDBなどを使うと、ウェブの更新作業の際にCMS、データベース、Astroの全てをローカルサーバを走らせることになり、自分的にはちょっと面倒。macのDocker Desktopがあんまり好きじゃないので、Dockerも避けたい。 ↩︎

  2. 後述しますが、これが軽い罠でした。 ↩︎

SLMNLLSLMNLL

Directusのセットアップでいきなりつまずく

Directusの公式サイトにDirectus + Astroのセットアップ手順が書いてあるので、それに従います[1]
https://directus.io/guides/get-started-building-an-astro-website-with-directus/


ステップ 1 : サンプルリポジトリの取得

examples repoをまるっとダウンロードします。

ステップ 2 : リポジトリからDirectusのセットアップ

ダウンロードしたリポジトリの中のdirectusフォルダからDirectusをセットアップ。

  1. 依存するライブラリをインストールします。
cd directus
npm install
  1. Directusを動かしてみます。
npx directus start
  1. ブラウザでhttp://localhost:8055にアクセス
  2. Directusのログイン画面が表示されるので、ID: admin@example.comパスワード: d1r3ctu5でログイン。

異常発生(ログインができない)

ここまで進んだところ、自分の環境の問題なのか、ダウンロードしたリポジトリ[2]の問題なのか、ログイン試行時にUnexpected Errorエラーが表示され、ログイン画面から先に進めず。

ターミナルからログを確認すると、以下のようなエラーを吐いていました。

SQLITE_ERROR: table directus_sessions has no column named origin

"SQLiteデータベースのdirectus_sessionsテーブルにorigin列が存在しない" そう。

素のDirectusをnpm経由でインストールした際にはそのようなエラーは起きなかったため困惑しつつ、一旦作業中断。

Strapiに切り替えるか、SQLiteをいじるか、素の状態のDirectusを使って再挑戦するかを検討中。

脚注
  1. npmがインストールされていない場合は、事前に準備しておいてください。 ↩︎

  2. v9.20.4(13d8dde) ↩︎

SLMNLLSLMNLL

結局、素のDirectusをインストールすることに

気分と視野を変えるために、すこしStrapiを使ってみました。

Starpiは面倒くさい[1]

Strapiの公式にある、 Strapi + Astro + Tailwind 環境のセットアップの解説記事を読んでみるものの、ちょっとカスタマイズなどもしているためか、少々手順が多い。

そもそも、どうやら2022年11月現在、Strapiのセルフホスト版は全機能が無料で使えるわけではなく、この記事に書かれている手順の通りに実装するにはEnterpriseプランで課金が必要そう。

https://strapi.io/blog/how-to-build-a-blog-with-astro-strapi-and-tailwind-css

Directusは面倒くさくない[1:1]

シンプルな用途であれば、Directusはパーミッションの設定さえすれば、他にはほとんど何もせずともすぐにREST APIを叩けて、使えるJSONを取得可能。Strapiの方は、EnterpriseプランでなければDirectusほど簡単にはREST APIを活用できなそうです。

無料で使える範囲で簡単にREST APIを叩けて、セットアップも簡単なDirectusの方が今回の用途には相応しいと判断。何か大きな障害が顕在化しない限りはDirectusを使う方向性を変更せず進めることに。

ただし、example repoには頼らず、Astroのテンプレートも一から作ります。Astroは簡単なので、一から作る場合でもそこまで負担ではないと判断しました。

脚注
  1. 5分程度触っただけの素人の個人的な感想です。他の方の記事などを読む限り、恐らくどちらも大体同じくらい面倒くさくないです。Strapiの方がカスタマイズは色々できるが、使いたい機能がEnterpriseプランの契約を要することが多く、無料が条件の場合は厳しい印象。 ↩︎ ↩︎

SLMNLLSLMNLL

改めてDirectusのセットアップ

example repoのリポジトリは使わないので、今度はnpmでDirectusをセットアップします。

以下のコマンドでdirectusのセットアップができます。

npm init directus-project プロジェクト名

コマンドを入力後、しばらくすると自動的にセットアップウィザードが起動し、データベースの種類、データベースの設定、初回ログイン用のアカウント名とパスワードの入力を求められます。画面の指示に従って入力するだけなので、困ることはないと思います。

セットアップが完了したら、該当フォルダに移動し、Directusを起動してみましょう。

cd プロジェクト名
npx directus start

特に問題がなければDirectusが立ち上がるので、ブラウザでhttp://0.0.0.0:8055にアクセスして、先程設定したIDとパスワードでログインします。

SLMNLLSLMNLL

ポート番号が重複する場合

ポート番号が他のサービスと重複する場合は、.envファイルを編集して、ポート番号を変更しましょう。

日本語UIに変更する

Settings > Project Settings > Default LanguageからJapaneseを選択し、画面右上の✅をクリックすれば、反映されます。日本語は自然で、違和感なく使えます。

SLMNLLSLMNLL

データモデルを作成する

Directusは、そのままだと記事の投稿などはできません。Settings > Data Modelからデータモデルを作成することで記事作成用のテンプレートのようなものが作れます。
データモデルで、ブログ記事用のフィールドを作ってみた様子

タイトルやテキスト、タグなど、投稿に必要だと思われる項目を好きに選んでOK。選択できる入力フィールドの種類の一部を貼り付けました。Strapiと比較すると非常に多いです。
選択できる入力フィールドの一部

パーミッションの設定

今回はAstroからAPI経由で記事データを読み込むので、パーミッションを設定します。
Settings > 役割・権限 > Publicまで進んで、読み取り権限に✅を入れます。

SLMNLLSLMNLL

Directusを多言語(i18n)対応にする

Directusはコツさえ掴めば、多言語対応も楽に実装できます。
ここでは、pagesというデータモデルを多言語対応していきます。

1. 対応させたいデータモデル画面の操作

大切なポイントとしては、多言語対応したい項目は元々のデータモデルでは準備しないことです。今回はタイトルと本文を多言語対応したいので、それらは元々のデータモデルには追加しません。

  • データモデルを作成画面に、新規項目の追加の下に、目立たない色でアドバンスモードでフィールドを作成という項目があるので、クリック
  • 表示されたメニューの一番下にある翻訳をクリック

2. 翻訳項目の設定

(1.) の操作後に表示される設定画面から「インターフェース」と「ディスプレイ」を設定します。

  • インターフェースの項目で、「翻訳」をクリックし、ハイライトされた状態にする
  • 同じく、ディスプレイの項目で、「翻訳」をクリックし、ハイライトされた状態にする

3. データモデル一覧にある、翻訳モデルを選択

(1.) (2.) の操作後、データモデル一覧に、グレーアウトされた翻訳モデルが表示されます。通常は、モデル名_translationsという名称です。こちらを編集することで、対応データモデルを多言語対応することができます。


blog_postsblog_posts_translations, pagespages_translationsというモデルが追加されている。languageモデルは、その際に自動的に生成された(…はず)

4. 翻訳モデルに、多言語対応したい項目を追加する

以上の画像のpages_translationsを編集してみましょう。

  • id, pages_id, language_codeという項目がすでにあるので、これらはいじらず、新規項目の追加をクリック
  • 新規フィールド画面が表示されるので、タイトル用にinputを選択。キー名をtitleに設定
  • 同じ手順で、wysiwygを選択し、キー名をcontentに設定

    上記の手順で、translationsデータモデルにタイトルと、本文入力欄が追加されました。
SLMNLLSLMNLL

5. コンテンツ編集画面から、言語を設定

Directusでは上記手順で多言語対応する場合、英語、スペイン語、フランス語などが追加されます。これを英語と日本語に変更します。

  • まずはサイドバーから、一番上のコンテンツを選択
  • Languages項目を選択
  • 記事で扱える言語一覧が表示されるので、不要な言語を削除
  • 画面右上の+アイコンをクリックし、Languagesのアイテム作成画面を開く
  • Codeja-JPと入力し、☑️をクリック

    こんな感じで、日本語が一覧に追加できます
SLMNLLSLMNLL

6. 編集してみる

通常通り、記事編集画面を開きます。そうすると、以下の画像のような編集画面になります。
Translationsの項目があるので、その右端にあるアイコンをクリックします。

そうすると以下の画像のように、日英の入力項目が横に並んだ状態で編集できます。

以上で、多言語対応の手順は完了です。
アクセスコントロール画面から、多言語対応項目へのアクセスを許可することを忘れないようにしましょう。

SLMNLLSLMNLL

Astroのセットアップ

以下のコマンドで、Astroの最新版のセットアップウィザードが起動します。

npm create astro@latest

Directusよりも設定項目は多いですが、基本的にはrecommendと表示されている項目を選択していけば大丈夫です。

DirectusのSDKを準備

Astroのサーバを立ち上げる前に、Directus用のSDKもインストールしておきます。

npm install @directus/sdk

Astroを走らせる

これで、DirectusのAPIを叩いてデータを読み込む準備が完了しました。
以下のコマンドでAstroの開発用のサーバが立ち上がります。

npm run dev
SLMNLLSLMNLL

ポート番号を変更する

Astroはポート番号が重複している場合には自動的に別の番号を割り振ってくれるようですが、任意のポート番号を指定したい場合はastro.config.mjsファイル内に書き込みます。

以下の例ではポート番号12345を指定しています。

import { defineConfig } from 'astro/config';

export default defineConfig({
  server: { port: 12345 }
});
SLMNLLSLMNLL

REST APIでAstroでDirectusのデータを読み込めるかを確認してみる

こちらが、最低限の動作確認用のコード。AstroのDebugコンポーネントを使って、DirectusSDKで叩いたAPIの中身をそのまま出力します。

---
import { Debug } from 'astro/components' // デバッグ用のコンポーネント
import { Directus } from "@directus/sdk" // DirectusのSDK

const directus = new Directus('http://0.0.0.0:8055') // DirectusのURL
const blogPosts = await directus.items('blog').readByQuery({limit: -1}) // 'blog'データセットの記事一覧を取得
---

<Debug {blogPosts}/>

Directusで入力した内容と比較してみる

Directusの記事編集画面の入力内容をスクリーンショットしました。上記のコードでAPIを叩いて得られたデータと比較してみましょう。

出力結果は以下の通りとなっていて、Markdown形式で書き出されていたり、タグが配列になってたりすることがわかると思います。ここまで、トラブルがなければ最速で10分以内で実装できます。簡単。とても。

{
  "data": [
    {
      "id": 1,
      "status": "published",
      "date_created": "2022-11-09T00:20:30.000Z",
      "date_updated": "2022-11-09T00:20:45.000Z",
      "title": "記事のタイトル",
      "content": "# 見出し 1\n## 見出し2\n\nMarkdownで書かれたテキストのコンテンツ",
      "tags": [
        "gamedev"
      ]
    }
  ]
}
SLMNLLSLMNLL

REST APIを使って個別の記事を表示する

Directusのblogデータセットから記事を読み込んで、Astroで出力するための最低限のコード。

http://localhost:3000/posts/1のようなurlにアクセスすれば、Directusの記事が表示されます。
MarkdownテキストはそのままだとHTMLに変換されずに表示されるので、Markdownをパースするためのコンポーネントも読み込んでいます。

[id].astro
---
// DirectusのSDK
import { Directus } from '@directus/sdk'

// Markdownをパースするためのコンポーネント
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'

// 動的urlを処理するためのメソッド
export async function getStaticPaths() {
  type BlogPosts = {
    data: Array<any>
  }
  const directus = new Directus('http://0.0.0.0:8055')
  const postResponse = await directus.items('blog').readByQuery({limit: -1}) as BlogPosts
  const posts = postResponse.data

  const result = posts.map((post) => ({
    params:{id:post.id},
    props: {
      title: post.title,
      content: unified().use(remarkParse).use(remarkRehype).use(rehypeStringify).processSync(post.content).toString(),
    }
  }))
  return result
}

const { id } = Astro.params
const {title, content} = Astro.props

---
<html>
  <head>{title}</head>
  <body>
    <h1>{title}</h1>
    <article set:html={content} />
  </body>
</html>
SLMNLLSLMNLL

GraphQLでDirectusのコンテンツを取得してみる

さて、ここまではREST APIを使ってきましたが、今後はGraphQLを使ってみたいと思います。

まずは勉強のためdirectusSDKは使わず、GraphQLに直接リクエストを送って、Directusのコンテンツを取得してみたいと思います。
GraphQLについては今回初めて入門するので、Phindに聞きながら進めていきました。

Directus側の準備

特にありません。
ローカルでnpmを使ってセットアップした場合、Directus側のエンドポイントのURLはhttp://0.0.0.0:8055/graphqlです。

Astro側の準備

手早くGraphQLのリクエストを実行するためのモジュールをインストールします。

npm install graphql-request

準備としては以上です。
あとはコードを書いて、GraphQLを取得していきます。

SLMNLLSLMNLL

GraphQLのリクエストを送って、動作確認をする

以下のようなデータモデルの記事一覧を取得したいと思います[1]

コードでは以下のように書きました。Astroでページを生成する前に、コンソールへの出力を試みてみます。

import { request, gql } from "graphql-request";

let pages;
const query = gql`
  query {
    pages {
      id
      translations{
        title
        content
        languages_code{
          code
        }
      }
    }
  }
`

request("http://0.0.0.0:8055/graphql", query)
  .then((data) => {
    pages = data.pages;
    console.log(pages); // コンソールに取得したコンテンツを出力
  })
  .catch((error) => console.error(error));

記事を1つだけ書いた状態だと、コンソールには以下のように出力されます。

[ { id: '1', translations: [ [Object], [Object] ] } ]

console.log(pages)console.log(pages[0].translations)に変更し、translationsを展開して出力すると、以下のように出力され、きちんとGraphQL経由でコンテンツが取得できていること、多言語のコンテンツが入力・取得できていること確認できました。

[
  {
    title: 'title',
    content: '<p>content</p>',
    languages_code: { code: 'en-US' }
  },
  {
    title: 'タイトル',
    content: '<p>コンテンツ</p>',
    languages_code: { code: 'ja-JP' }
  }
]

GraphQLに関しては全くの無知な状態から始めたので、ここまで少し時間がかかりました。が、コードだけ見てみると、少ない行数でコンテンツの取得ができたことがわかります。

多言語対応をしない場合なら、慣れればDirectusのインストール > Directusのデータモデルの用意 > テスト記事の入力 > Astroのインストール > GraphQLの取得まで15分もあればできそうだなという印象です。

脚注
  1. i18n対応しているので、基本のデータセットと、多言語用のデータセットがあり、それぞれが対応関係となっています。 ↩︎

SLMNLLSLMNLL

ちなみに@directus/sdkを使って、GraphQLのリクエストを送ることもできます。

公式サイトに書いてあるサンプルコードは以下のとおりです。

import { createDirectus, graphql } from '@directus/sdk';

interface Article {
	id: number;
	title: string;
	content: string;
}

interface Schema {
	articles: Article[];
}

const client = createDirectus<Schema>('http://directus.example.com').with(graphql());

const result = await client.query<Article[]>(`
    query {
        articles {
            id
            title
            content
        }
    }
`);

次は、実際に@directus/sdk経由でGraphQLを利用してみることにしましょう。

SLMNLLSLMNLL

Directus SDKで取得した値をAstroで表示

GraphQLのテストが完了したので、今度は@directus/sdk + GraphQLを使ってみることにします。GraphQLの勉強をしたいので、コンテンツの取得は引き続きGraphQLで。

AstroにSDKのインストール

まずはAstroのプロジェクトに、@directus/sdkをインストールします。

npm install @directus/sdk

Astroのファイルを編集する

インストールが完了したら、Astroのsrc/pages/index.astroを編集します。
Directusの公式ドキュメントにあるコードをベースにして、自分のデータモデルとAstroのフォーマットに合うように対応箇所を書き換えていきます。

私のプロジェクトでは、多言語に対応する場合はGraphQLの返り値の構造が入り組んでるので、interfaceを2つ用意しました。

以下のコードでは、多言語対応ページのうち、言語がen-USに設定されているものだけを取得します[1][2]

index.astro
---
import { createDirectus, graphql } from '@directus/sdk';

interface I18nContent{
  title: string;
  content: string;
  languages_code: {
    code: string;
  };
}

interface PageContent {
  id: number;
  translations: I18nContent[];
}

const client = createDirectus<PageContent[]>('http://0.0.0.0:8055').with(graphql());
const result = await client.query(`
  query {
    pages {
      id
      translations {
        title
        content
        languages_code {
          code
        }
      }
    }
  }
`);
const pages:PageContent[] = await result.pages;

---

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
</head>
  <body>
    {pages.map(page => {
      const i18nContent = page.translations.find(item => {
        return item.languages_code.code === 'en-US';
      }) as I18nContent;
      if (i18nContent !== undefined) {
      return (
        <div>
          <h1>{i18nContent.title}</h1>
          <p>{i18nContent.content}</p>
        </div>
      )}
    })}
  </body>
</html>

結果は以下のとおりです。スタイルを一切当ててないので非常に味気ないですが、動きました[3]

http://0.0.0.0:8055をブラウザで表示した結果

脚注
  1. 多言語のコンテンツが設定されていない記事、例えばja-JPのコンテンツしかない記事などは処理を飛ばすようにしています。 ↩︎

  2. 後から気づきましたが、言語がen-USに該当する記事だけを抽出するのは、GraphQLでやった方がスマートですね。GraphQLに慣れてないところが出ました。 ↩︎

  3. HTMLタグについては一旦目を瞑ります。汎用性を考えて、DirectusではWYSIWYGではなく、Markdownでコンテンツを書いていく可能性もあるし… ↩︎

SLMNLLSLMNLL

前述のコードをen-USja-JPに書き換えてみました。ja-JPでのみ書かれた記事があるので、今度は2つ目の記事の情報も取得してくれていることがわかりますね。


http://0.0.0.0:8055をブラウザで表示した結果

SLMNLLSLMNLL

@directus/sdkGraphQLを利用した記事一覧の表示が完了しました[1]
ここまでの応用で、データモデルを増やして記事タイプの種類を充実させたり、記事タイプごとにインデックスを用意することができそうです。

DirectusもAstroもとても良い使用感です。

脚注
  1. 今回は1ファイルにベタ書きしていますが、GraphQL周りの処理は他でも使うことが想定されるので、適宜別ファイルにまとめて、importして使うようにするのがスマートかと思います。 ↩︎

SLMNLLSLMNLL

個別の記事を取得する

さて、今度は個別の記事を取得していきたいと思います。

GraphQL赤ちゃんなので、一覧でないデータを取得する方法がわからず。Phindに聞いたところ、クエリにwhereなどで引数を渡すと良いとのこと。SQLっぽい感覚ですね。

Phindが挙げてくれたサンプルのクエリはこちら[1]

query {
  pages(where: {id: {_eq: 1}}) {
    id
    translations {
      title
      content 
      languages_code {
        code
      }
    }
  } 
 }
脚注
  1. 元となるクエリはこちらセクション内のコードにあります。本当にwhereで条件を足しただけ。 ↩︎

SLMNLLSLMNLL

DirectusのGraphQL APIではwhereを使えなかったので、whereの部分をfilterに変更したら、動きました。


例えば、言語で絞り込む場合には以下のように書く。lang_codeには"en-US"や"ja-JP"などが代入されています。

query {
  pages(filter: {translations: {languages_code: {code: {_eq: "${lang_code}"}}}}) {
    id
    translations {
      title
      content 
      languages_code {
        code
      }
    }
  }  
}
SLMNLLSLMNLL

Tailwind CSSを使ってみる。

今まで「自分で書く方が自由が利くし実装も早い」という理由で、CSSフレームワークを敬遠していましたが、今回は後学のために使ってみることにしました。

Tailwind CSSを採用した理由は「なんだか流行ってるから」という単純な理由。htmlタグにずんずんTailwindのクラスを追加していくのは違和感があったものの、慣れると意外と良い感触です。

他にも言及している方が多いですが「クラス名を考えなくて良い」というのが快適です。ウェブサイト内で繰り返し使われている要素のスタイルを変更する際も、SSG + Tailwindだと比較的楽です。