ブログを Hugo から Next に移行する
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 コンポーネントはこんな感じで使います。
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
クライアントサイドからサーバサイドのコードを呼びたいときはこうするしかないみたいです。
コードはこんな感じで、
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 に直せばいけるのでは?
サーバサイドで使っているコードの問題?
'I' は Internal Server Error の I でしたw
つまり /var/task/
の部分が https://midorimici.com/
になればいいのではないかという見通し。
環境変数 BASE_URL
に https://midorimici.com
をセットし、コードでも process.env.BASE_URL
を使用してデプロイしてみます。
え?
Error: ENOENT: no such file or directory, scandir 'https:/midorimici.com/public/posts'
なんで //
が /
になってんねん。
ドメインだけでいいらしい。それにすでに VERCEL_URL
というのが用意されていました。
嘘です。ドメインだけではいけません。😇
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 でやってたよりもむしろシンプルなぐらいなのでどうってことはない😇
...
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 は裏でいろいろやってくれてたのか…!
(実は最初はカバー画像 index.jpg
が出てきてました。_index.jpg
に改名すると mdx が出てくるようになりました。)
Static Export を使うべきだったようです🥺
build command を next build && next export
にします。
output directory は変更しなくていいそうです(out
に変更したらビルド失敗しました)。
vercel/now-next-routes-manifest.md at main · vercel/vercel · GitHub
うーん、まだ 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
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 で管理するかはどっちでもいいかなと思っています。
Contentful と microCMS で迷います…
Contentful は記事のインポートができない…?
Forestry みたいに GitHub リポジトリからインポートするのを想定してたんだけど…
microCMS ではできるみたいですね…
Git からじゃないですけど
コンテンツのインポートができるようになりました! | microCMSブログ
API Driven っていうのと Git-based っていうのがあって、その違いかもしれない。
Headless CMS - Top Content Management Systems | Jamstack
楽なのは Git-based ですね~
一応 API Driven でも外部から PUT とか PATCH とかできるっぽいですね。
Content Management API | Contentful
PUT /api/v1/{endpoint}/{content_id}
ただインポートするにはスクリプトを書かないといけない😇
せっかくなので 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)
移行コード
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 を使います。
コードはこんな感じです
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 にスケジュール設定もあるので、静的サイトでも予約投稿とかできそうですね~
_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 じゃない要素に適用するのもなんだかなあ
と思いつつもこれでがっつり減りました🤣
MDX Remote より remark だけのほうが断然早いですね…
正規表現で置換するか…
SSG は黙っててもスコア 80 は下らないと思ってたのになんでこんな遅いんだ😭
画像消しても遅い。(なお体感では十分速い)
Hugo は 90 超えてたのに… Go が速いというのもあるのかしら。
Contentful のリージョンがアメリカだからとか関係あるのかな…
Contentful + Next の公式サンプルのこの人はめっちゃ成績いいのに。
Homepage
いや、SSG はビルド時に HTML を全部作ってしまって、アクセスのときには HTML が返ってくるだけのはずだから、Contentful がどこにあろうと関係ないはず…
というかそもそもなぜこんなに JS が動く?
WebPageTest で From: Tokyo, Japan - EC2 - Chrome - Emulated Google Pixel - 3GFast - Mobile で比較してみても Next が速かったです。それならもういいか…笑
リージョンをアメリカにすると遅めに出ますね。PageSpeed Insights はおそらく日本からではないのでしょう(言い訳)
Zenn との記事連携は、Zenn CLI を使います。
Zenn リポジトリへ push → GitHub Actions でスクリプト実行 → Contentful に Draft 出現!
という流れです。
こちらを参考にさせていただきました🙏
Zenn リポジトリに .github/workflows/contentful.yml
みたいなのを作って GitHub Actions を定義します。
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 post
で main.js
を動かします。
...
"scripts": {
"post": "node main.js"
},
...
main.js
では Contentful の Management API を使って記事を登録します。
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 にしました。
#!/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
- 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 を書かなければならず、そっちのほうは参考にできるリソースも多くないので苦労するかも。