ローカル管理のコンテンツを Contentful に移行する

CMS には Git ベースのものと API ベースのものがあり、Git ベースのものは GitHub などと連携して、CMS でのコンテンツの更新を GitHub にプッシュします。GitHub と Netlify や Vercel などのホスティングサービスが連携していれば、GitHub へのプッシュがデプロイを引き起こしてもとのサイトが更新されます。つまりコンテンツが同じリポジトリにあり、ローカルでは同じプロジェクトディレクトリにあります。

対して API ベースのものは、API によって記事の取得や更新を行うことができるタイプのものです。もとのサイトではコンテンツの取得も API で行うため、リポジトリ(プロジェクトディレクトリ)にコンテンツが存在しないのがふつうです。CMS での更新は Git の差分として現れないため、ホスティングサービスと直接連携することでもとのサイトを更新します。

コンテンツのある場所が違うので、Git ベースで管理していたコンテンツを API ベースの CMS に移す際には、API を利用してコンテンツを CMS 側に作成する必要があります。

この記事ではローカルで保管しているコンテンツを API ベースの Headless CMS のひとつである Contentful に移す手順を紹介します。

Content Management API

Contentful には Content Management API があるので、これを使っていきます。

プロジェクトにパッケージをインストールして、

yarn add contentful-management

よきところに main.jsts-node などを使うなら ts でも OK)を作成します。

基本的な使い方はこんな感じです。

main.ts
1
import { createClient } from 'contentful-management';
2
3
const client = createClient({
4
accessToken: '<access-token>' // Contentful のダッシュボードから取得したアクセストークンを指定
5
});
6
7
const main async () => {
8
const space = await client.getSpace('<space-id>'); // Space を取得
9
const env = await space.getEnvironment('<environment-id>'); // Environment を取得
10
const entry = await env.createEntry('<content-type-id>', {
11
fields: {
12
title: { ja: 'たいとる' },
13
content: { ja: 'てすと' },
14
}
15
});
16
};
17
18
main();

公式のドキュメントなどでは .then をつなげて書いていることが多いですが、async await を使って書くこともできます。

fields には Contentful であらかじめ作成しておいた Content Model に合わせたものを指定します。

このコードをコマンドで走らせれば、新たな記事が作成されていることが Contentful のダッシュボードから確認できます。

node main.js
ts-node main.ts

記事の移行

ローカルの .md (または .mdx)ファイルの内容を Contentful に移行します。

仮に記事のディレクトリ構成が以下のようになっているとします。

▼ 📂 posts
▼ 📂 xxx
🖼️ index.jpg
🖼️ image.jpg
📄 index.md
▶ 📁 yyy
▶ 📁 zzz

Markdown のフロントマターの情報を取得するために、gray-matter を使います。

yarn add gray-matter

posts 下のすべての記事を移行する場合、移行コードは次のようになります。

main.ts
1
import fs from 'fs';
2
import { join } from 'path';
3
import matter from 'gray-matter';
4
import { createClient } from 'contentful-management';
5
6
const postsDirectory = join(process.cwd(), 'posts'); // 記事ディレクトリのパス
7
8
const getPostBySlug = (slug: string) => {
9
const postPath = join(postDirectory, `${slug}/index.md`);
10
const fileContents = fs.readFileSync(postPath, 'utf8');
11
const { data, content } = matter(fileContents);
12
13
// data にフロントマターの情報が入る
14
// slug も必要なら加えておく
15
return { slug, content, ...data };
16
};
17
18
const getPosts = () => {
19
const slugs = fs.readdirSync(postsDirectory);
20
const posts = slugs.map((slug: string) => getPostBySlug(slug));
21
return posts;
22
};
23
24
const client = createClient({
25
accessToken: '<access-token>'
26
});
27
28
const main async () => {
29
const space = await client.getSpace('<space-id>');
30
const env = await space.getEnvironment('<environment-id>');
31
32
const posts = getPosts();
33
for (const post of posts) {
34
try {
35
const entry = await env.createEntry('<content-type-id>', {
36
fields: Object.fromEntries(
37
Object.entries(post).map(([k, v]) => [k, { ja: v }])
38
)
39
});
40
console.log(`\u001b[32m✅ Updated successfully: ${entry.fields.slug.ja}\u001b[0m`);
41
} catch (e) {
42
console.log(`\u001b[31m❌ Update failed: ${post.slug}\u001b[0m`);
43
console.log(e);
44
}
45
}
46
};
47
48
main();

メディアファイルの移行

画像や動画など、Markdown 以外のコンテンツを移行します。

Contentful ではメディアファイルは Media というところで管理します。

どう運用するかはいくつか方法があって、それによって移行の仕方が若干変わってきます。

タイトルなどのメタデータを記事内に書いておき、それを元に検索して asset を取得する(記事とリンクさせない)方法と、

記事に asset へのリンクを貼って、記事の取得時に asset の情報も一緒に取得する方法があります。

ひとつの記事内で複数の asset を扱う場合、どちらにしても Markdown 内にファイルのパスや名前などを書くことになると思います。

また、asset を登録するさいに contentType というのを求められるので file-type を使います。

yarn add file-type

記事とリンクさせない場合

記事とリンクをしない場合、Media にファイルを移行していくだけです。

注意点として、asset を表示させるときに例えば title をキーとして検索するなら、当然 title はユニークでなければなりません。

ディレクトリ構成が先述のと同じとすると移行コードは次のようになります。

main.ts
1
import fs from 'fs';
2
import { join } from 'path';
3
import filetype from 'file-type';
4
import { createClient } from 'contentful-management';
5
6
const postsDirectory = join(process.cwd(), 'posts');
7
8
const getAssets = () => {
9
const assets: Record<string, string[]> = {};
10
const slugs = fs.readdirSync(postsDirectory);
11
for (const slug of slugs) {
12
const postAssets = fs.readdirSync(join(postsDirectory, slug))
13
.filter(filename => filename !== 'index.md');
14
assets[slug] = postAssets;
15
}
16
return assets;
17
};
18
19
const client = createClient({
20
accessToken: '<access-token>'
21
});
22
23
const main async () => {
24
const space = await client.getSpace('<space-id>');
25
const env = await space.getEnvironment('<environment-id>');
26
27
const assets = getAssets();
28
for (const [slug, assetName] of Object.entries(assets)) {
29
try {
30
const path = join(postDirectory, slug, assetName);
31
const type = await filetype.fromFile(path);
32
const asset = await env.createAssetFromFiles({
33
fields: {
34
title: {
35
ja: assetName
36
},
37
description: {
38
ja: ''
39
},
40
file: {
41
ja: {
42
contentType: type.mime, // image/png など
43
fileName: assetName,
44
file: fs.createReadStream(path), // 変数代入ではなく直接渡さないとうまく動きませんでした
45
}
46
},
47
}
48
});
49
const processedAsset = await asset.processForLocale('ja'); // 画像をプロセスする
50
const publishedAsset = await processedAsset.publish(); // 画像を publish する
51
console.log(`\u001b[32m✅ Updated successfully: ${publishedAsset.fields.title}\u001b[0m`);
52
} catch (e) {
53
console.log(`\u001b[31m❌ Update failed: ${assetName}\u001b[0m`);
54
console.log(e);
55
}
56
}
57
};
58
59
main();

記事とリンクさせる場合

記事に Media というタイプの field を作成しておきます。

記事を作成するさいにその field に特定の形のデータを指定します。

以下は記事と一緒に作ってしまう例ですが、記事がすでに移行済の場合は API から取得した記事を使うことになります。

main.ts
1
const main async () => {
2
const space = await client.getSpace('<space-id>');
3
const env = await space.getEnvironment('<environment-id>');
4
5
const posts = getPosts();
6
for (const post of posts) {
7
const assetsForEntry = [];
8
for (const assetName of assets[post.slug]) {
9
try {
10
const path = join(postDirectory, slug, assetName);
11
...
12
assetsForEntry.push({
13
sys: {
14
type: 'Link',
15
linkType: 'Asset',
16
id: publishedAsset.sys.id,
17
}
18
});
19
} catch (e) {
20
...
21
}
22
}
23
post.assets = assetsForEntry;
24
try {
25
const entry = await env.createEntry('<content-type-id>', {
26
fields: Object.fromEntries(
27
Object.entries(post).map(([k, v]) => [k, { ja: v }])
28
)
29
});
30
console.log(`\u001b[32m✅ Updated successfully: ${entry.fields.slug.ja}\u001b[0m`);
31
} catch (e) {
32
console.log(`\u001b[31m❌ Update failed: ${post.slug}\u001b[0m`);
33
console.log(e);
34
}
35
}
36
};

これでうまくいけば移行したコンテンツが Contentful のダッシュボードに表示されるはずです。

この記事が参考になればうれしいです。

では 👋

  関連記事【Netlify + Heroku】Socket.IO アプリのデプロイ
【Netlify + Heroku】Socket.IO アプリのデプロイ