Closed76

ブログを Hugo から Next に移行する

Hugo でブログを作っていて、特に不満とかはないのですが、
最近 Next をよく見るので何か作りたい!でも題材がない!というのと、
今のブログの記事構成を整理したいというので、
ついでにブログをまるごと移行してしまおうと思い立ちました。

長くなってきたので主なものの TOC 貼っときます~🤗
試行錯誤しているのでまとまっておらず読みにくくなっていますのでご了承ください。
後日いくつかまとめようと思います。

CMS でのコンテンツ管理、技術記事だけ Zenn と記事共有を考えています。

ディレクトリ構成はこんな感じ。だいたい starter と同じ構成で、そこに追加したり。starter はたぶんこれ。
next.js/examples/blog-starter-typescript at master · vercel/next.js · GitHub

▼ 📂 components
  ▶ 📁 mdx  // MDX コンポーネント用
  ...
▼ 📂 lib
  📄 api.ts
  📄 constants.ts
  📄 markdownToHtml.ts
  📄 topics.ts
▼ 📂 pages   // レンダリングされるページ
  ▶ 📁 api   // ページ内で NodeJS が欲しくなったときに使う
  ▶ 📁 page  // ページネーション用
  ▼ 📂 posts
    📄 [slug].tsx  // 個別の記事
  ▶ 📁 tags  // タグ関連
  📄 _app.tsx
  📄 _document.tsx
  📄 about.tsx  // about ページ
  📄 index.tsx  // トップページ
▼ 📂 public
  ▶ 📁 about
  ▶ 📁 favicon
  ▶ 📁 fonts   // カスタムフォント
  ▶ 📁 images  // どのページでも使われ得る画像
  ▼ 📂 posts
    ▼ 📂 xxx
      🖼️ index.jpg  // カバー画像・OGP 画像
      🖼️ image.jpg
      📄 index.mdx
    ▶ 📁 yyy
    ▶ 📁 zzz
    ...
...

移行元のディレクトリ構成はこんな感じでした。

▼ 📂 content
  ▶ 📁 about
  ▼ 📂 posts
    ▼ 📂 xxx
      🖼️ image.jpg
      📄 index.md
    ▶ 📁 yyy
    ▶ 📁 zzz
▼ 📂 static
  ▼ 📂 images  // カバー画像・OGP 画像。ファイル名が記事と対応。
    ▶ 📁 gazo  // どのページでも使われ得る画像
    🖼️ xxx.jpg
    🖼️ yyy.jpg
    🖼️ zzz.jpg
    ...
  ...
...

ここから 100 以上あるカバー画像だけを移すというコマンドを考えるのが大変でした。
static/images/ 以下の画像のファイル名と同じ名前のフォルダにそれぞれを移す」という。
手動で移すわけにはいかないですし…

cp に正規表現を使えたら、

cp "/path/to/hugo-project/static/images/(.+).jpg$" "public/posts/$1/index.jpg"

とかできないのかな~とか思ったんですが、まあやむなし!

find /path/to/hugo-project/static/images/ -maxdepth 1 -regex ".+jpg\$" | sed -e "s/.\+\/images\/\(.\+\)\.jpg/\1/" | xargs -i cp /path/to/hugo-project/static/images/{}.jpg public/posts/{}/index.jpg

find で得たファイルパスの文字列を、sed で正規表現を使い変換して、それを xargs を使って cp に展開しました。
こういうコマンド打つときってちょっと怖いですよね(何回か失敗した)

このコマンドは WSL だったから使えたのですが、Git Bash とか PowerShell からだともうどうしたらいいかわかんないです🙌

(あとから思ったけど CMS で記事管理するなら別にやらなくてよかったのでは)

md から mdx に変えたので、br タグとか閉じてないタグが一気に怒りの声を上げます。
それをちまちまと置換をしました。地味につらい。

そして今回の移行作業で一番大きなイベントが、Hugo のショートコードから MDX コンポーネントへの移行でした。
ショートコードは Markdown ファイル内で {{< mycode abc >}} とか書くと <img class="xxx" src="abc"> とかに展開してくれる便利な機能です。
同じことが MDX コンポーネントを使えばできるのですが、書き方や仕組みが違うので結構大変でした。

next-mdx-remote の MDX コンポーネントはこんな感じで使います。

components/post/post-body.tsx
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
import { MDXRemote } from 'next-mdx-remote'

import Heading from '../mdx/heading'
import PostImage, { PostImageProps } from '../mdx/post-image'
import CodeBlock, { CodeBlockProps } from '../mdx/code-block'

type Props = {
  source: MDXRemoteSerializeResult<Record<string, unknown>>
  slug: string
}

const PostBody = ({ source, slug }: Props) => {
  const components = {
    h2: ({ children }: { children: any }) => <Heading content={children} />,
    postimage: (props: Omit<PostImageProps, 'slug'>) => <PostImage slug={slug} {...props} />,
    code: (props: CodeBlockProps) => <CodeBlock {...props} />,
  }

  return <MDXRemote {...source} components={components} />
}

export default PostBody

components で MDX ファイルの中で書かれたタグがどう展開されるかを定義しています。
カスタマイズ性の制約がほとんどないのはいいと思います👍

コードブロックはかなりカスタマイズしました。

シンタックスハイライトは prism-react-renderer を使いました。
shiki とかも気になっていたのですが、カスタマイズが大変そうだったので Prism です。

行番号や行ハイライトやコードのコピー・拡大などの機能をつけました。

MDXRemote 通した後に、prop にマークダウン通したいみたいなこともあったので、remark も使っています。

あと、関連記事の MDX コンポーネントを作ったのですが、MDX ファイルで書いた情報をもとに他の記事のタイトルを参照したいとなって pages/api/ に API を作りました。
参考:API Routes: Introduction | Next.js

クライアントサイドからサーバサイドのコードを呼びたいときはこうするしかないみたいです。

コードはこんな感じで、

pages/api/post_titles/[slug].ts
import type { NextApiRequest, NextApiResponse } from 'next'

import { getPostBySlug } from 'lib/api'

export default function handler(req: NextApiRequest, res: NextApiResponse<{ title: string }>) {
  const { slug } = req.query
  const post = getPostBySlug(slug as string, ['title'])
  res.status(200).json({ title: post.title ?? '' })
}

呼び出し側はこんな感じです。

import useSWR from 'swr'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSun } from '@fortawesome/free-solid-svg-icons'

export type RelatedPostProps = {
  link: string
}

const fetcher = async (url: string) => {
  const res = await fetch(url)
  const data = await res.json()

  if (res.status !== 200) {
    throw new Error(data.message)
  }
  return data
}

const RelatedPost = ({ link }: RelatedPostProps) => {
  const { data, error } = useSWR(`/api/post_titles/${link}`, fetcher)

  if (error) return <div>{error.message}</div>
  if (!data) return <FontAwesomeIcon icon={faSun} className="text-yellow-500 animate-spin" />
  
  return (
...

if (!data) を書かないとエラーになってデータが取れなくなりました。

できたので Vercel にデプロイすると、Tailwind のスタイルが適用されていなかったりする…
あとページをリロードすると raw の mdx が出てくる🤣
API も 500 エラーだし…

1. API 500 エラー

Vercel の Function Logs を見ると、

Error: ENOENT: no such file or directory, open '/var/task/public/posts/chess-app-devel-16/index.mdx'

なんでやねん。

API を使っている記事のみエラーが出る模様。
リロードで raw mdx を出すのとは関係ないっぽい。

const fetcher = async (url: string) => {
  const res = await fetch(url)
  console.log(`res: ${res}`)  // ここは出力する
  const data = await res.json()
  console.log(`data: ${data}`)  // ここは出力しない

  if (res.status !== 200) {
    throw new Error(data.message)  // data.message -> Unexpected token I in JSON at position 0
  }
  return data
}

res.json() でこけているらしい。
Who is 'I'?

もしかして process.cwd() を URL に直せばいけるのでは?
サーバサイドで使っているコードの問題?

つまり /var/task/ の部分が https://midorimici.com/になればいいのではないかという見通し。
環境変数 BASE_URLhttps://midorimici.com をセットし、コードでも process.env.BASE_URL を使用してデプロイしてみます。

え?

Error: ENOENT: no such file or directory, scandir 'https:/midorimici.com/public/posts'

なんで /// になってんねん。

嘘です。ドメインだけではいけません。😇

process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : process.cwd()

みたいにしてみました。

だめでした!

Error: ENOENT: no such file or directory, scandir 'https:/nextjs-blog-xxxxxxxxx-midori-mici.vercel.app/public/posts'

だからなんで / ひとつになるん。

(Vercel、よく見たら URL がいっぱいあるんだなあ)
結局この線ではないようです。

process.cwd() に戻すとビルド通るようになりましたが、
console.log(fs.readdirSync(process.cwd())) すると

[
  '.next',
  '___next_launcher.js',
  '___vc_bridge.js',
  'node_modules',
  'package.json'
]

が返ってきました。
ディレクトリ構成どうなってるんだ?🤔

しかもビルド時には通常のディレクトリ構成なのに、API を呼んだ時にはこっちの構成になっている。

ということは API を呼ぶときにはクライアントサイドの情報はやはり https:// に置かれているのか。

これって本来はビルド時にすべてデータを取ってくるものなんじゃないのかな…
ビルド後にクライアントとサーバでやり取りがあるのは違う気がする。
と思ったけど API ってことはまあそういうことなんだろう。

ただそれは望むことではないので戦略を変えます。
正規表現を使ってすべて getStaticProps の中におしこめてしまう。

正規表現で MDX 用のタグを取得して属性値を取得し、それをもとに記事のタイトルを取ってくるようにしました。
これぐらいのことは Hugo でもやってたし Hugo でやってたよりもむしろシンプルなぐらいなのでどうってことはない😇

pages/posts/[slug].tsx
 ...
 export async function getStaticProps({ params }: Params) {
   const post = getPostBySlug(params.slug, necessaryFieldsForPost)
   const postContent = post.content ?? ''

+  let relatedPosts: Record<string, string> = {}
+  const relatedPostSlugsMatches = postContent.matchAll(/<relpos link="(.+?)" \/>/g)
+  for (const match of relatedPostSlugsMatches) {
+    const slug = match[1]
+    const title = getPostBySlug(slug, ['title']).title ?? ''
+    relatedPosts[slug] = title
+  }

   const postWithTOC = (postContent).replace(/## .+/, '<toc />\n\n$&')
   const content = await serialize(postWithTOC, {
     mdxOptions: {
       remarkPlugins: [remarkMath],
       rehypePlugins: [rehypeKatex],
     }
   })
   const toc = await serialize(markdownTOC(post.content).content)

   return {
     props: {
       post: {
         ...post,
       },
+      relatedPosts,
       source: content,
       tocSource: toc,
     },
   }
 }
 ...

この方法でうまく表示できました!

ちなみに Hugo では Page Variables.LinkTitle というのがあって、リンクさえわかればそこから直接タイトルを取得できていました。
Hugo は裏でいろいろやってくれてたのか…!

2. ページをリロードすると raw の mdx が出てくる

Chrome だとわからなかったけど Firefox だとこんなポップアップが出てきた。
mdx ファイルだから起こる不具合…?

Image from Gyazo

本来であれば静的にビルドされた HTML を表示するはずだと思うんだけど…

(実は最初はカバー画像 index.jpg が出てきてました。_index.jpg に改名すると mdx が出てくるようになりました。)

うーん、まだ mdx を見せようとしてきます。

public に mdx を置くなということか?
画像は public に置かないとうまく読み込めないし、mdx と画像分けるの嫌だなあ。

問題は index という名前だと踏んで、index.mdx をすべて _index.mdx に改名してみました。
これで隠れていた index.html ?が現れるのではないかと期待…

やった!できたぞ!🎉
後ろに隠れてたのね…!
結局ディレクトリ構成は変えずに済みました!

3. Tailwind のスタイルが一部適用されていない

具体的には md: のレスポンシブが効いていない(sm: とかは効いてるのに)。
でも全ての md: が効いていないわけではない。

該当部分は md:text-${size} のようになっている。
size が展開されるタイミングが DOM 描画後なのか、
Tailwind が適用されるタイミングが DOM 描画前なのか…

localhost では効いていたわけだから、そのへんのタイミングが前後してしまってうまくいかなくなったのではと思ったけど、
開発者ツールで動的にクラス名を変えてもちゃんと適用されるしなあ…

どうやら production build のときに不要なスタイルは削除されているらしい。
削除されずに残ったものの中に適用されないスタイルがあったということらしいです。
Optimizing for Production - Tailwind CSS

削除してほしくないスタイルを設定できます。
Optimizing for Production - Tailwind CSS

tailwind.config.js
  module.exports = {
-   purge: ['./components/**/*.tsx', './pages/**/*.tsx'],
+   purge: {
+     content: ['./components/**/*.tsx', './pages/**/*.tsx'],
+     safelist: ['xs' , 'sm' , 'base' , 'lg' , 'xl' , '2xl'].map(size => `md:text-${size}`),
+    },
  ...

解決!

こういうことが起きてもすぐに修正できるように最初からデプロイして定期的に push しておくとスムーズかも🤔

デプロイできたので、次は CMS 連携していきます!

要件は

  • Markdown が書ける(MDX 対応は別になくてもいい)
  • GitHub リポジトリと連携できる
  • 無料

とりあえずこれぐらいです。
CMS から記事データを API で取得するか、GitHub で管理するかはどっちでもいいかなと思っています。

ContentfulmicroCMS で迷います…

Contentful は記事のインポートができない…?
Forestry みたいに GitHub リポジトリからインポートするのを想定してたんだけど…

せっかくなので Contentful 使おうかな!(あえて苦労しそうなほうに行くw)

まずは記事の移行ですが、このへん使えばできそうです。
contentful-management.js - v7.31.0

具体的な内容はそれだけで記事にできそうなので今度まとめたいな📝

てすてすコード

const { createClient } = require('contentful-management')

const client = createClient({
  accessToken: 'xxx'
})

client.getSpace('yyy')
  .then((space) => space.getEnvironment('master'))
  .then((environment) => environment.createEntry('blogPost', {
    fields: {
      slug: { ja: 'test' },
      title: { ja: 'test-title' },
      date: { ja: '2021-08-13' },
      lastmod: { ja: '2021-08-15' },
      topics: { ja: ['test', 'abc'] },
      published: { ja: false },
      content: { ja: 'これはテストだよ!' },
    }
  }))
  .then((entry) => console.log(entry))
  .catch(console.error)
移行コード
cms/main.js
const fs = require('fs')
const { join } = require('path')
const matter = require('gray-matter')
const { createClient } = require('contentful-management')

const postsDirectory = join(process.cwd(), '../public/posts')

const getPostBySlug = (slug) => {
  const fullPath = join(postsDirectory, `${slug}/_index.mdx`)
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const { data, content } = matter(fileContents)

  const items = { slug, content }

  for (const [k, v] of Object.entries(data)) {
    if (k === 'date' || k === 'lastmod') {
      items[k] = v.toISOString()
    } else if (v) {
      items[k] = v
    }
  }

  return items
}

const getPosts = () => {
  const slugs = fs.readdirSync(postsDirectory)
  const posts = slugs
    .map((slug) => getPostBySlug(slug))
    .sort((post1, post2) => (post1.date && post2.date && post1.date > post2.date ? -1 : 1))

  return posts
}

const posts = getPosts()

const client = createClient({
  accessToken: 'xxx'
});

(async () => {
  const space = await client.getSpace('yyy')
  const env = await space.getEnvironment('master')

  for (const post of posts) {
    try {
      const entry = await env.createEntry('blogPost', {
        fields: {
          slug: { ja: post.slug },
          title: { ja: post.title },
          date: { ja: post.date },
          lastmod: { ja: post.lastmod },
          topics: { ja: post.topics },
          katex: { ja: post.katex },
          published: { ja: post.published },
          summary: { ja: post.summary },
          content: { ja: post.content },
        }
      })
      console.log(entry.fields.slug)
    } catch (e) {
      console.log(e)
      break
    }
  }
})()

移行完了!

メディアファイルのことを忘れていました!
画像とかは Media というところで一元管理するみたいです。
一応記事にリンクすることはできるので散逸はしない😌

メディアファイルを移すにはまず Media にファイルを登録→プロセス→公開してから、記事にリンクを貼ることになります。
Images | Contentful

メディアファイルの移行&記事とのリンク
const fs = require('fs')
const filetype = require('file-type')
const { join } = require('path')
const { createClient } = require('contentful-management')

const postsDirectory = join(process.cwd(), '../public/posts')

const getAssetMap = () => {
  const assetMap = {}
  const slugs = fs.readdirSync(postsDirectory)
  for (const slug of slugs) {
    const assets = fs.readdirSync(join(postsDirectory, slug))
      .filter(filename => filename !== '_index.mdx')
    assetMap[slug] = assets
  }
  return assetMap
}

const assetMap = getAssetMap()

const client = createClient({
  accessToken: 'xxx'
})

const main = async () => {
  const space = await client.getSpace('yyy')
  const env = await space.getEnvironment('master')
  const entries = await env.getEntries()
  for (const entry of entries.items) {
    const slug = entry.fields.slug.ja
    const assetsForEntry = []
    try {
      for (const assetFilename of assetMap[slug]) {
        const path = join(postsDirectory, slug, assetFilename)
        const type = await filetype.fromFile(path)
        const asset = await env.createAssetFromFiles({
          fields: {
            title: {
              ja: `${slug}/${assetFilename}`
            },
            description: {
              ja: ''
            },
            file: {
              ja: {
                contentType: type.mime,
                fileName: assetFilename,
                file: fs.createReadStream(path),
              }
            }
          }
        })
        const processedAsset = await asset.processForLocale('ja')
        const publishedAsset = await processedAsset.publish()
        console.log(publishedAsset.fields.file.ja)
        assetsForEntry.push({ sys: { type: 'Link', linkType: 'Asset', id: publishedAsset.sys.id }})
      }
      entry.fields.assets = { ja: assetsForEntry }
      const updatedEntry = await entry.update()
      console.log(`\u001b[32m✅ Updated successfully: ${updatedEntry.fields.slug.ja}\u001b[0m`)
    } catch (e) {
      console.log(`\u001b[31m❌ Update failed: ${entry.fields.slug.ja}\u001b[0m`)
      console.log(e)
    }
  }
}

main()

contentType というのを求められるので file-type 使うと便利です。
createReadStream は直接渡さないとだめでした…挙動がようわからん🥺

結構時間かかるので進行度とか成功数とかカウントして表示すればよかった~
メディアファイルは 300 以上あり 20 分かかりました😇

これ移行ツールとかないのかな?

次に Contentful から記事の情報を取得します。
JS で自前で実装していたフィルタリングやソートをパラメータを渡すだけでやってくれます!
Content Delivery API | Contentful

さっきは Content Management API でしたが、今度は Content Delivery API を使います。

コードはこんな感じです
lib/api.ts
import { createClient} from 'contentful'
import type { Entry, EntryCollection } from 'contentful'

import type { ContentfulPostFields } from '../types/api'
import {
  CTF_ACCESS_TOKEN,
  CTF_SPACE_ID,
  CTF_ENV_ID,
  CTF_POST_CONTENT_TYPE_ID,
  PAGINATION_PER_PAGE,
} from './constants'

const client = createClient({
  space: CTF_SPACE_ID,
  environment: CTF_ENV_ID,
  accessToken: CTF_ACCESS_TOKEN,
})

export async function getPosts(
  fields: (keyof ContentfulPostFields)[],
  { offset = 0, limit = PAGINATION_PER_PAGE }: {
    offset?: number,
    limit?: number,
  } = {}
) {
  const entries: EntryCollection<
    Pick<ContentfulPostFields, (typeof fields)[number]>
  > = await client.getEntries({
    content_type: CTF_POST_CONTENT_TYPE_ID,
    select: fields.map(field => `fields.${field}`).join(','),
    order: 'fields.date',
    limit,
    skip: offset,
  })
  return entries.items.map(item => item.fields)
}

なぜか取得できない!😱と思ったら Contentful のほうで Publish していなかったのが原因でした。

アセットが移しきれていませんでした…
原因は getEntries() のデフォルトの limit が 100 に設定されていて、100 件しか記事を取得しないため。
↓こうすべきでした

- const entries = await env.getEntries()
+ const entries = await env.getEntries({ content_type: 'blogPost', limit: 500 })

追い移行しましたw

ついでにタグも移行しました。
topic という content model を新たに作成して、記事から reference を貼っています。

公式のドキュメントにも書いてありますが select をクエリとして使ったうえで update すると他のフィールドが消えてしまいます!
Content Management API | Contentful

タグ移行のときにどうせこのフィールドしか情報として使わないからと select してしまい他のフィールドが全部消えました😱

しかし snapshot (publish するときに作られる)というのが残っていたので、それを使って復元できました😇

const snapshot = ((await entry.getSnapshots()).items[0].snapshot.fields)
entry.fields = snapshot

Contentful への移行完了!
public/posts はごっそり消しましたw
まあデータを GitHub に置くか Contentful に置くかの違いなんですけど、Contentful におくと API 叩かないと記事の検索に正規表現が使えないとか大変な部分もありますね。
一応 Git の歴史に public/posts が残っているので昔のは checkout して検索などはできるけど、当然新しい変更は反映されてない。
まあ正規表現で検索なんて大規模な移行のときぐらいにしか使わないんですが笑

ローカルでは Contentful で Draft になっているものも取得できるように Preview API を使いました。
access token と hostname が違うだけで使い方は Delivery API といっしょです。

ちなみに Next では dotenv を入れなくてもビルドインでそれっぽいことができるようになっています。
.env.local ファイルに dotenv と同じように書くだけで process.env から呼び出せます!
Basic Features: Environment Variables | Next.js

Webhook を使えば Contentful で publish したのをフックとして Vercel にデプロイしたりできます。
Contentful にスケジュール設定もあるので、静的サイトでも予約投稿とかできそうですね~

なんかゴミ遅いので一旦最適化したいですね。
Image from Gyazo

_app.tsx で書いていた PrismJS の処理を post-body.tsx に落としました。
コードブロックは記事ページにしか現れないので、無駄な JS でした。

そして MDX コンポーネントを dynamic import にしました。
Advanced Features: Dynamic Import | Next.js

まだ減らせそう…

next build したときに表示される First Load JS というのがなぜか大きい。
@next/bundle-analyzer で見てみると node_modules が大きい…

MDX のための next-mdx-remote と Markdown を parse するための remark を使っているの、統一したいなとは思っていたけど難しい。
MDX がないと MDX コンポーネントが取得しづらいし、remark がないと MDX コンポーネントの props を Markdown として解析しづらい。
正規表現で置換する手もあるけど…

dynamic import 使えばだいたい減るけど、below the fold じゃない要素に適用するのもなんだかなあ
と思いつつもこれでがっつり減りました🤣

😭
Image from Gyazo

やはり通信が遅いのでしょうか。
サーチクエリで API から取得するより、一回全部取得して JS でフィルタリングとかしたほうがパフォーマンスいい気がしてきた。

MDX Remote より remark だけのほうが断然早いですね…
正規表現で置換するか…

SSG は黙っててもスコア 80 は下らないと思ってたのになんでこんな遅いんだ😭
画像消しても遅い。(なお体感では十分速い)
Hugo は 90 超えてたのに… Go が速いというのもあるのかしら。

Contentful のリージョンがアメリカだからとか関係あるのかな…

Contentful + Next の公式サンプルのこの人はめっちゃ成績いいのに。
Homepage

いや、SSG はビルド時に HTML を全部作ってしまって、アクセスのときには HTML が返ってくるだけのはずだから、Contentful がどこにあろうと関係ないはず…
というかそもそもなぜこんなに JS が動く?

Hugo のほうは JS が全然ないですね。
Image from Gyazo

Next のほうは頭のほうにぎゅっとしてます。
Image from Gyazo

Next のほうが速いように見えるんだけど…?

Network: Fast 3G, CPU: 4x slowdown して同じぐらいになった。
CPU が遅ければ遅いほど JS の処理に時間がかかるため、JS が多い Next が遅くなるということらしい。
Lighthouse のモバイルってそんな劣悪な環境で測ってるのか…

WebPageTest で From: Tokyo, Japan - EC2 - Chrome - Emulated Google Pixel - 3GFast - Mobile で比較してみても Next が速かったです。それならもういいか…笑

リージョンをアメリカにすると遅めに出ますね。PageSpeed Insights はおそらく日本からではないのでしょう(言い訳)

AMP 対応してみました。

Image from Gyazo

こっちが AMP してないほうです。

Image from Gyazo

変わらんやんけ!

ただ AMP 対応の過程でいらないクライアントサイド JS とか結構あぶりだせたのでよかったかも。

検索機能とかは API 通すよりもクライアントサイドで JS 走らせたほうが高速に動作しますね。(AMP だとクライアントサイド JS が動かない)

Vercel にデプロイするときに Team を作るように勧められるけど、作ると PRO プランになって課金されるので Skip しましょう(一回やらかしたわ🤣)

Image from Gyazo

Zenn との記事連携は、Zenn CLI を使います。
Zenn リポジトリへ push → GitHub Actions でスクリプト実行 → Contentful に Draft 出現!
という流れです。

こちらを参考にさせていただきました🙏

Zenn リポジトリに .github/workflows/contentful.yml みたいなのを作って GitHub Actions を定義します。

contentful.yml
name: CI

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 2  # 最新 2 つ分の差分を fetch してくる

      - uses: actions/setup-node@v2
        with:
          node-version: '14'

      - name: Install dependencies
        run: yarn

      # 追加または変更の差分があった md ファイルのパスそれぞれを引数としてスクリプトを走らせる
      - name: Make draft post in Contentful
        run: for md in $( git diff --name-only --diff-filter=AM ${{ github.event.before }} ${{ github.sha }} | grep .md$ ); do yarn post $md; done
        env:
          CF_ACCESS_TOKEN: ${{secrets.CF_ACCESS_TOKEN}}  # Contentful のアクセストークンを環境変数に設定

${{ github.event.before }} が push する前の最新のコミットハッシュ、${{ github.sha }} が push したコミットハッシュです。

yarn postmain.js を動かします。

package.json
...
"scripts": {
  "post": "node main.js"
},
...

main.js では Contentful の Management API を使って記事を登録します。

main.js
import fs from 'fs';
import matter from 'gray-matter';
import contentful from 'contentful-management';

const green = '\u001b[32m';
const red = '\u001b[31m';
const reset = '\u001b[0m';

const postPath = process.argv[2];

if (!postPath) {
  console.log('No updated article found.');
  process.exit();
}

const getPost = () => {
  const fileContents = fs.readFileSync(postPath, 'utf8');
  const { data, content } = matter(fileContents);
  const newContent = content.replace(/(```[a-z ]+):(.+)/, '$1 name=$2');
  const slug = postPath.replace(/articles\/(.+)\.md$/, '$1');

  return { slug, content: newContent, title: data.title };
};

const client = contentful.createClient({
  accessToken: process.env.CF_ACCESS_TOKEN,
});

const main = async () => {
  const space = await client.getSpace('<space-id>');
  const env = await space.getEnvironment('master');

  try {
    const newPost = getPost();
    const entry = await env.createEntry('blogPost', {
      fields: Object.fromEntries(Object.entries(newPost).map(([k, v]) => [k, { ja: v }])),
    });
    console.log(`${green}✅ Created successfully: ${entry.fields.slug.ja}${reset}`);
  } catch (e) {
    console.log(`${red}❌ Error: ${e}${reset}`);
  }
};

main();

Zenn とブログのほうでフロントマターに必要な情報が違うので、共通のものだけ取り出して登録しています。
あとは手動で Contentful のダッシュボードから追加したりしています。
もうちょっとがんばればコードブロックの部分とかもっと自動化できそう…

これでもいいんですが、GitHub Actions で見たときに差分がないときの出力が全くなかったので shellscript にしました。

post.sh
#!/bin/bash

mds=$( git diff --name-only --diff-filter=AM $1 $2 | grep .md$ )

if [ $? -eq 0 ]
then
  for md in $mds
  do
    yarn post $md
  done
else
  echo "No updated article found."
fi

exit 0
contentful.yml
- name: Make draft post in Contentful
  run: sh post.sh ${{ github.event.before }} ${{ github.sha }}
  env:
    CF_ACCESS_TOKEN: ${{secrets.CF_ACCESS_TOKEN}}

new のときの処理はいいけど update のときの処理が書けてなかった~
あとついでに日付フィールドを追加~

  const postPath = process.argv[2];
+ const slug = postPath.replace(/articles\/(.+)\.md$/, '$1');

  const getPost = () => {
    ...
-   return { slug, content: newContent, title: data.title };
+   return {
+     slug,
+     content: newContent,
+     title: data.title,
+     date: new Date(),
+     lastmod: new Date(),
+   };
  }
  ...
  const main = async () => {
    ...
    try {
      const newPost = getPost();
+     const existingPost = await env.getEntries({
+       content_type: 'blogPost',
+       'fields.slug': slug,
+     });
+     if (existingPost.total === 0) {
        const entry = await env.createEntry('blogPost', {
          fields: Object.fromEntries(Object.entries(newPost).map(([k, v]) => [k, { ja: v }])),
        });
        core.info(`${green}✅ Created successfully: ${entry.fields.slug.ja}${reset}`);
+     } else {
+       const entry = existingPost.items[0];
+       for (const [k, v] of Object.entries(newPost)) {
+         if (k !== 'date') {
+           entry.fields[k] = { ja: v };
+         }
+       }
+       const updatedEntry = await entry.update();
+       core.info(`${green}✅ Updated successfully: ${updatedEntry.fields.slug.ja}${reset}`);
+     }
    } catch (e) {
      ...

これで移行はひと段落です。
空き時間でやってましたが 1.5 か月ぐらいかかりましたね。

改めてみると Hugo のほうが Variables がたくさん用意してあって初心者向けかなあと思いました。
関連記事のところとか、Next だといろいろ自分でコーディングしなきゃいけない。

ただ Next のほうが JS が書けるのでカスタマイズしやすいかなと思いました。パッケージもインストールして使えるし。
Hugo は Go Template を書かなければならず、そっちのほうは参考にできるリソースも多くないので苦労するかも。

このスクラップは5ヶ月前にクローズされました
ログインするとコメントできます