IT Admin Blog>Contentfulで作るコーポレートサイト Contentfulで作るコーポレートサイト
近年、多くの個人・企業で採用されることも多くなってきた技術である、ヘッドレスCMSを使ってコーポレートサイト及び本ブログをリニューアルしました。今回はヘッドレスCMSの中でも採用した「Contentful」と実装の裏側の話をしていきます。
ヘッドレスCMSとは
ヘッドレスCMSとは、Webページの表示部分(ヘッド)を持たず(レス)、コンテンツ管理のみを行うCMS (Contents Management System)のことを指します。
ブログなどでよく用いられるWordPressなどでは、メインページ・固定ページなどの表示部分とコンテンツの管理機能がセットで準備されますが、ヘッドレスCMSでは表示部分が用意されず、以下の3つの機能のみの提供となるので表示部分を自分で制作する必要があります。
この場合、表示部分を自分で一から作ることができるので、利用できる言語やフレームワークの幅が広がったり、「このお知らせの部分にCMSを組み込もう」などといった柔軟なページ作成を行うことができます。また、別のプラットフォームで同じものを埋め込みたい場合であってもAPIへアクセスするだけで同様のことができ、コンテンツの再利用性が高まります。
実際に弊社のコーポレートサイトでは、一例ですが
SaaSの販売と運用 の「取扱製品」の部分で使用しています。コードを編集する必要がないため、どなたでも取扱製品のフィールドに対して編集を加えることが可能です。
Contentfulとは
Contentfulはドイツ発(2013年創業)の有名なヘッドレスCMSの一種です。柔軟なコンテンツモデル設計が可能、配信画像のリサイズAPIが使えたりと、必要十分な機能を備えています。
Markdownはもちろんのこと、Rich Textという画像やオブジェクトの埋め込みが容易にできるエディタが用意されており、直感的に操作ができます。
よく比較される MicroCMS との違いは、無料プランでも本番と開発環境で分離してコンテンツの管理が可能な点であると思います。開発環境下であれば、急なオブジェクトの追加やレスポンススキーマの変更であっても安心して行うことができます。
更に、下書き中の記事を実際のページで表示させて確認できる
Content Preview機能もあり、ビジュアルに内容の修正が可能です。(画像は
特徴の Live Preview 及び Inspector Mode を有効にしてあります)
構成
技術スタックは以下の通りです。表示の都合で外部向けと内部向けでビルドの方法を分けています。
以下は実装で詰まったり工夫した点をちょこちょこまとめてみました。
SSGとSSRの出し分け
先程ご紹介したプレビュー機能を実装するに当たって、環境に応じてSSG/SSRを分けてビルドする必要がありました。
そのままではSSRとして動かしたい環境でもSSGとしてビルドされてしまうので、プレビュー環境かどうかを判別する変数を用意し、module.exports
内で設定を分けることで解決しました。(ソースコードは一部抜粋)
1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = () => {
// Preview Modeが無効の場合はexportする
if (
process.env.NEXT_PUBLIC_PREVIEW_MODE === "false"
|| process.env.NEXT_PUBLIC_PREVIEW_MODE === undefined) {
return {
output: "export",
};
}
// Preview Modeが有効の場合はstandaloneで出力する
return {
output: "standalone",
};
};
上の問題に付随して、かなり特殊ではあると思いますが、App Routerで動かしているページ(SSRでレンダリングしたい)でSSG回避するにはnotfound()
で404ページを出力することで回避できます。
1
2
3
4
5
6
7
// SSGの場合は404ページを表示する
if (
process.env.NEXT_PUBLIC_PREVIEW_MODE === undefined ||
process.env.NEXT_PUBLIC_PREVIEW_MODE === "false"
) {
return notFound();
}
Live Preview使用時に埋め込んでいるアセットを読み込めなくなる
Content Preview機能のLivePreviewを使っている場合で、画像やオブジェクトを差し替えると、今まで読み込んでいた要素が急に
undefined
になってしまう問題がありました。
そこで記事の取得時に、記事内に存在するオブジェクトを保持しておき、undefined
になってしまったオブジェクトはそこから参照するようにすることで解決しました。 (ソースコードは一部抜粋)
1
2
3
4
5
6
7
8
9
10
// bodyの中にある画像のURLを取得する
const images = post?.fields.body?.content
.filter((content) => content.nodeType === "embedded-asset-block")
.map((content) => {
return {
imageId: content.data.target.sys.id,
imageUrl: content.data.target.fields.file.url,
};
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const imageUrl = images?.find(
(image) => image.imageId === node.data.target.sys.id
)?.imageUrl;
// プレビュー環境下(imageUrlが存在する) の場合
if (isPreview && imageUrl && node.data?.target) {
const { description, file } = node.data.target.fields;
return (
<div className={styles.embeddedImage}>
<Image
src={file?.url ? file.url : "https://" + imageUrl}
alt={description}
quality={60}
priority={true}
width={800}
height={600}
style={{
maxWidth: "100%",
height: "auto",
}}
/>
</div>
);
}
ブログカードの実装
以下の例のような、QiitaやZennなどのエンジニア向け情報共有コミュニティサイトで用いられているブログカードを生成する際に、どうメタタグを取得しようか悩んでいましたが、
Littleforestさんの執筆された「
外部サイトのOGPを取得する」が非常に参考になりました!
特定の外部サービスの場合で、oEmbed
の形式で埋め込み情報が提供されている場合は、各サービスの提供しているoEmbedAPI
エンドポイントより投稿情報を取得しています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
export async function getMetaTags(
urlData: string[] | undefined
): Promise<MetaTag[] | undefined> {
if (urlData == undefined) {
return;
}
return await Promise.all(
urlData.map(async (url) => {
const urlHostname = new URL(url).hostname;
// ホスト名で分岐
switch (urlHostname) {
case "www.youtube.com": {
const videoId = new URL(url).searchParams.get("v");
// videoIdが規定の形式になっているかチェック
if (!videoId?.match(/[a-zA-Z0-9_-]*/)) {
return {
url: url,
error: true,
};
}
return {
url: url,
serviceName: "YouTube",
embed: videoId,
};
}
case "x.com":
case "twitter.com": {
const postId = new URL(url).pathname.split("/")[3];
// 投稿IDが規定の形式になっているかチェック
if (!postId) {
return {
url: url,
error: true,
};
}
return {
url: url,
serviceName: "X",
embed: postId,
};
}
case "speakerdeck.com": {
const res = await fetch(
`https://speakerdeck.com/oembed.json?url=${url}`
).then((res) => res.json());
if (res.error) {
return {
url: url,
error: true,
};
}
return {
url: url,
serviceName: "SpeakerDeck",
embed: String(res.html),
};
}
case "vimeo.com": {
const res = await fetch(
`https://vimeo.com/api/oembed.json?url=${url}`
).then((res) => res.json());
if (res.error) {
return {
url: url,
error: true,
};
}
return {
url: url,
serviceName: "Vimeo",
embed: String(res.html),
};
}
case "www.linkedin.com": {
// パス名から投稿IDを抽出
const pathName = new URL(url).pathname.split("/")[3];
// 投稿IDが規定の形式になっているかチェック
if (!pathName.match(/^urn:li:(ugcPost|share):\d+$/u)) {
return {
url: url,
error: true,
};
}
const baseUrl = `https://www.linkedin.com/embed/feed/update/${pathName}`;
return {
url: url,
serviceName: "LinkedIn",
embed: `<div style="position:absolute; top:0px; left:0px; bottom:0px; right:0px; width:100%; height:100%; border:none; margin:0; padding:0; overflow:hidden;"><iframe style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" src="${baseUrl}" loading="lazy" frameborder="0" title="LinkedIn" sandbox="allow-scripts"></iframe></div>`,
};
}
default:
try {
const res = await fetch(url).then((res) => res.text());
const dom = new JSDOM(res);
// metaタグから,title, description, og:imageを取得
const title =
dom.window.document.querySelector("title")?.textContent;
const description = dom.window.document
.querySelector("meta[name='description']")
?.getAttribute("content");
const ogImage = dom.window.document
.querySelector("meta[property='og:image']")
?.getAttribute("content");
return {
url: url,
title: title ? title : "",
description: description ? description : "",
ogImage: ogImage ? ogImage : "",
};
} catch (e: unknown) {
if (e instanceof Error) {
console.error(e.message);
}
return {
url: url,
error: true,
};
}
}
})
);
}
今後の課題
以下が今後対応すべき課題であると考えています。
App Routerへの完全移行
OGP画像の自動生成
ZUNDA では、自由で安全なデジタル・ワークプレイスを皆さまに提供すべく仲間を募集しています。皆さまのエントリーをお待ちしております。