1. Home
  2. Tech
  3. ブログをAstro + Contentful + CF Pagesで作った

ブログをAstro + Contentful + CF Pagesで作った

2024-04-08

2024-04-07

構築環境について

以下の環境でブログを再構築した

環境選定の理由

以前より静的サイトジェネレーター(SSG)を使ったブログ構築を構想していた。
実現したかった要素としては以下の通りだ。

  • 完全無料での運用
  • ノーメンテ運用
  • MDX記法

MDXとは、MarkdownにReactのJSX形式のコンポーネントを記述できる仕組みである。
つまり、基本的な文章はMarkdownで記述しつつ、好きなパーツをコンポーネント化して埋め込んだりすることができる。

MDX形式で記述した.mdxファイルをコンテンツフォルダに配置することで、SSGのコレクション機能が読み取ってくれる、というのが基本的な使い方である。

以前、Gatsbyで構築を試みたが、ContentfulAPIで記事を取得すると、Contentful内で記述したMDXが展開されず、断念してしまった。
(以前は対応していたらしいが、ナーフされたらしい)

MDX対応について

AstroにもContentfulに対応するプラグインが存在するが、結論としてはこれもMDXには対応していなかった。
結局、Contentfulから全記事を取得し、.mdxをそれぞれ作ってからビルドを実行することでなんとか理想の環境を実現することができた。

Contentful→MDX変換ロジック

Astroの環境構築に関しては公式ドキュメントや有志の方々にお任せするとし、私はContentfulからMDXに変換するロジックについて説明したいと思う。

Contentfulパッケージの導入

下記コマンドを実行し、contentfulをインストール。

npm install --save contentful

必要に応じてdotenv等もインストールする。

import、変数の定義

fetchContents.ts(名前はなんでもいい)を作り、以下のように記述。

import contentful, { type Entry } from 'contentful'

import * as fs from 'fs'
import dayjs from 'dayjs'

const client = contentful.createClient({
    // This is the space ID. A space is like a project folder in Contentful terms
    space:  process.env.CONTENTFUL_SPACE || '',
    // This is the access token for this space. Normally you get both ID and the token in the Contentful web app
    accessToken: process.env.CONTENTFUL_TOKEN || ''
})

const contentDir = './src/content/posts'

私の場合、開発環境では環境変数をdotenvから取得しているのでdotenvも追加している。その辺はお好みで。

記事取得ロジック部分

async function fetchData() {
    try {
        //コンテンツフォルダ 削除 & 作成
        await fs.promises.rm(contentDir, { recursive: true, force: true });
        await fs.promises.mkdir(contentDir, { recursive: true })

        //エントリの取得
        const contentfulData = await client.getEntries({})
        let entries:Entry[] = []
        entries.push(...contentfulData.items)

        // 2024年2月時点 contentfulData.limit = 100 
        // 1回で全件取得できない場合はループを回す
        if(contentfulData.total >= contentfulData.limit) {            
            let skip = 0

            while(entries.length < contentfulData.total) {
                skip += contentfulData.limit
                const contentfulSkipedData = await client.getEntries({skip})
                entries.push(...contentfulSkipedData.items)
            }
        }

        for await (const entry of entries) {
            // ファイル名から使用不可文字を除去
            const fileName = String(entry.fields.slug).replace(/[\\\/:\*\?\"<>\|]/,'_')
            const writePath = contentDir + '/' + fileName + '.mdx'

            // frontmatterの情報を配列に格納
            const frontmatterArray:string[] = []

            if(typeof(entry.fields.title) === 'string' && entry.fields.title !== '')
                frontmatterArray.push('title: ' +  entry.fields.title)

            if(typeof(entry.fields.description) === 'string' && entry.fields.description !== '')
                frontmatterArray.push('description: ' +  entry.fields.description)

            //@ts-ignore
            if(typeof(entry.fields.image?.fields.file.url) === 'string')
                //@ts-ignore
                frontmatterArray.push('heroImage: ' +  entry.fields.image.fields.file.url || '')

            const createdAt = entry.sys.createdAt
            frontmatterArray.push('created_at: \'' +  dayjs(createdAt).format('YYYY-MM-DD') + '\'')

            const updatedAt = entry.sys.updatedAt
            frontmatterArray.push('updated_at: \'' +  dayjs(updatedAt).format('YYYY-MM-DD')+ '\'')

            const frontmatter = '---\n'+
                                frontmatterArray.join('\n') + '\n' + 
                                '---\n'

            //必要なコンポーネントを取得する変数
            const needComponentsArray:string[] = []

            //<Component 等、大文字から始まるタグにマッチ
            const componentsMatches = String(entry.fields.body).match(/<[A-Z][A-Za-z]+\s/)

            //マッチ結果からimport文を生成
            componentsMatches?.forEach(componentMatchStr => {
                const componentName = componentMatchStr.slice(1,-1)
                needComponentsArray.push(`import ${componentName} from '/src/components/content/${componentName}.astro'\n`)
            });

            //frontmatter、import、本文の順につなげてファイルに出力
            await fs.promises.writeFile(writePath,(frontmatter + needComponentsArray + '\n' + entry.fields.body))
            console.log(writePath.replace(contentDir,''), "successfully created!")
        }

    } catch(err) {
        console.error(err)
    }
}

fetchData();

entry.fields.imageのところが怒られるので、とりあえずts-ignoreしています。いい方法があればおしえてください。。。

私のブログの場合、Contentfulのタグ機能をディレクトリ分けに使っていたりするので、それも併せてfetchContents内で取得している。 気になる方はGitHubで。

ビルドコマンドに追加

package.jsonにコマンドを追加

"scripts": {
    "build": "tsx fetchContents.ts && astro check && astro build",
    (以下略)
 },

今後追加したい機能

  • GAと連携して人気記事の取得
  • 画像をCFから配信。ビルド時にサイズを最適化したり
  • 月別アーカイブ
  • プロフィールページ
  • ページャー機能 その他ブログサイトとして存在するべき各種機能
  • その他見た目の改善

思いついたら備忘録として追記していこうと思う。

title

ブログをAstro + Contentful + CF Pagesで作った


last update

2024-04-08

first draft

2024-04-07


share