Open5

[WIP] Astro+Contentfulでブログ用独自エディター作ってリアルタイムプレビューしたい

eyemono.moeeyemono.moe

動機

  • 個人ブログをastroで立てた
  • 現在はgitベースでコンテンツ(markdownと添付画像)を管理しているが、適当なCMSに置くようにしたい
    • 画像/動画ファイルをgitに置くことに抵抗がある

    • 新しいブログ記事を書く際に、

      1. ローカルに新規ファイルを作成
      2. プレビュー用にpnpm devでローカルで動かす
      3. 記事が完成したらpush

      の手順を踏むのが面倒

      • 理想的には、ブログにadminページを作って、ブラウザ上で執筆, プレビュー, 投稿ができると嬉しい

Contentfulなどのheadless CMSにコンテンツを置いて、ブラウザ上でコンテンツの編集, 保存をできるようにしたい。

要件

  • ブラウザ上でコンテンツを作成・編集・削除(?)できる
    • Monaco Editorのようなエディタが使いたい
    • 画像のDnDアップロードが使いたい
    • clipboadにコピーした画像をアップロードできる
  • ブラウザ上でコンテンツをプレビューできる
    • リアルタイムプレビューだとなお良い
  • postsのpublished状態が変化したら、自動でビルドが走る
  • ブログ本体はこれまで通りSSGして、記事管理ページだけCSRあるいはSSGする

特に重視しているのが「Monaco Editorのようなエディタが使いたい」の部分。VSCodeでのMarkdown編集に慣れすぎて、Contentful標準のエディタで書くのが辛い。
「じゃあローカルでVSCode使って書けよ」と言われると、「gitに画像置いたりいちいちpushするのが嫌じゃ~~」となる。わがまま。

eyemono.moeeyemono.moe

調査

Contentful Monacoeditor 検索 🔍

それっぽい検索結果は出てこない。こんなにわがままなのは俺だけらしい。

むしろUI ExtensionsでよりWYSIWYGなエディターにしている方がいらっしゃった。

https://dev.classmethod.jp/articles/contentful-ui-extension-editor/

UI Extensions

https://www.contentful.com/developers/docs/extensibility/ui-extensions/

Contentful側に独自のエディタを組み込む方法があるらしい。これでContentful側にMonaco Editorを入れるのもアリか?
ただリアルタイムプレビューのことを考えると、現状.astroファイルで作っているブログ側のデザインを、UI Extensions側に持っていくのがめんどくさそう。

UI Extensionsの利用は一旦保留。

ContentfulのコンテンツをローカルのVSCodeで編集する

https://blog.datsukan.me/create-contentful-markdown-article-cli/

「Contentful側のコンテンツをローカルに同期して、ローカルで好きなエディタ使って編集しちゃおうぜ」の発想。なるほど頭がいい。

ただ今回の要件的には微妙かも(結局プレビューのためにローカルで動かす必要がある, 画像/動画ファイルなどの同期ができるかちゃんと実装読めてなくて不明)。

他のCMSの検討

Contentful以外のCMSのエディタがどんな感じかもざっと調べてみる。MonacoEditor使えるCMSがあるかもしれない。

結果:無さそう

多くのCMSでカスタムプラグインみたいな形で自作エディタを使用できるようになっていたが、結局ContentfulでのUI Extensionsと同じ理由でめんどくさそう。

ただ、Front Matter CMSはかなり面白いなと思った。

https://frontmatter.codes
https://n-e-r-d.jp/notes/front-matter-cms/
https://zenn.dev/naopoyo/articles/front-matter-cms-for-zenn

エディタ自作が無理そうだったらこれを採用したい。

OAuthアプリケーションとしてブログ側にContentfulエディタを実装

https://www.contentful.com/developers/docs/extensibility/oauth/

ContentfulではOAuth2.0がサポートされている。じゃあOAuthアプリケーションとしてブログのadminページにエディタを作れば良さそう。

以下のようなAuth.jsプロバイダーを作ることでContentfulのOAuthを利用することができた。

import type { OAuthConfig, OAuthUserConfig } from "@auth/core/providers";

// https://www.contentful.com/developers/docs/references/user-management-api/#/reference/users
interface ContentfulProfile {
  firstName: string;
  lastName: string;
  avatarUrl: string;
  email: string;
  activated: boolean;
  signInCount: number;
  confirmed: boolean;
  "2faEnabled": boolean;
  sys: {
    type: string;
    id: string;
    version: number;
    createdAt: string;
    updatedAt: string;
  };
}

export const ContentfulProvider = (
  config: OAuthUserConfig<ContentfulProfile> & {
    redirectUri: string;
  },
): OAuthConfig<ContentfulProfile> => {
  return {
    id: "contentful",
    name: "Contentful",
    type: "oauth",
    authorization: {
      url: "https://be.contentful.com/oauth/authorize",
      params: {
        client_id: config.clientId,
        redirect_uri: config.redirectUri,
        scope: "content_management_manage",
      },
    },
    token: {
      url: "https://be.contentful.com/oauth/token",
      params: {
        redirect_uri: config.redirectUri,
      },
    },
    userinfo: "https://api.contentful.com/users/me",
    profile: (profile) => ({
      id: profile.sys.id,
      email: profile.email,
      name: `${profile.firstName} ${profile.lastName}`,
      image: profile.avatarUrl,
    }),
    options: config,
  };
};

後はGET /spaces/{spaceId}/users/{userId}で特定spaceにそのユーザーがいるか確認して、確認出来たらadminページを開ける形にすれば良さそう。

https://www.contentful.com/developers/docs/references/user-management-api/#get-a-single-user

Personal Access Token

https://www.contentful.com/help/token-management/personal-access-tokens/

OAuthでええやんと思ってたけど、よく考えたらビルドはユーザーのログインに関係なく発生しうるので、access tokenを環境変数に持っておいて、それをエディタ画面でも使っちゃう実装にした方がシンプルで簡単そう。CMS乗り換える場合もaccess tokenだけ変えればいい。

そもそもContentfulはOAuthアプリケーションの作成をあまりお勧めしてなさそう(ドキュメントの薄さからヒシヒシと感じる)で、対してPATの利用をめちゃくちゃ推している。

ということで事前に取得したaccess tokenをサーバー側で持っておき、ビルド/編集時はこれを使うようにしたい。
そうすると、今度はブログ編集画面を開けるユーザーを制限するために、Contentfulとは独立してまた認証処理が必要になる。

Astroの場合、Clerk公式のSDKがあるのでこれでサクッと作れそう。

https://clerk.com/docs/references/astro/overview

将来仮にブログを複数人で運営するようになった場合(clerkのuserが複数人になった場合)、一つのaccess tokenをみんなで使いまわす運用になるのが、あまりお行儀良くなさそうだけど、一旦受け入れる。

eyemono.moeeyemono.moe

インターフェース設計

CMS↔Astro間のコンテンツのやり取りに必要なインターフェースを考える。
今後Contentful以外のCMSを使う可能性もあるので、Contentfulへの依存部分は最小限にしたい。

まず本ブログで扱うコンテンツは以下の二種類

  • 本文のMarkdownテキスト
  • 本文で参照される画像/動画ファイルなどのasset

記事のタイトルやタグ, 公開/非公開状態はMarkdownのfrontmatterとして持つようにする。
Contentfulにはpublish/draftの概念やタグの概念があるが、他のCMSにこれがあるかわからないため、記事の内容/状態はできる限りMarkdown内で持つようにしたい。
流石にcreatedAtとupdatedAtはどのCMSでも取得できるはず...

とりあえず以下があれば十分そう。

export type Post = {
	slug: string;
	content: string;
	createdAt: string;
	updatedAt: string;
};

export interface CMSClient {
	getPost: (slug: string) => Promise<Post>;
	getPosts: (filter: {
		// filter posts created/modified after this date
		after?: Date;
	}) => Promise<Post[]>;
	updatePost: (slug: string, content: string) => Promise<Post>;
	/** create empty post */
	createPost: (slug: string) => Promise<Post>;
	/**
	 * upload file to storage
	 *
	 * @param file file to upload
	 * @returns url of uploaded file
	 */
	uploadFile: (file: File) => Promise<string>;
}

で、実装した

Contentful用CMSClient
import { CONTENTFUL_CMA_TOKEN, CONTENTFUL_SPACE_ID } from "astro:env/server";
import contentful from "contentful-management";
import type { CMSClient } from "..";

const CONTENT_TYPE_ID = "blogPost";

type ContentfulEntryFields = {
	slug: {
		ja: string;
	};
	postContent: {
		ja: string;
	};
};

export const createContentfulClient = (): CMSClient => {
	const client = contentful.createClient(
		{
			accessToken: CONTENTFUL_CMA_TOKEN,
		},
		{
			type: "plain",
			defaults: {
				spaceId: CONTENTFUL_SPACE_ID,
				environmentId: "master",
			},
		},
	);

	return {
		async createPost(slug, content) {
			const entry = await client.entry.create<ContentfulEntryFields>(
				{
					contentTypeId: CONTENT_TYPE_ID,
				},
				{
					fields: {
						slug: {
							ja: slug,
						},
						postContent: {
							ja: content,
						},
					},
				},
			);

			// publish the entry
			await client.entry.publish(
				{ entryId: entry.sys.id },
				{
					fields: entry.fields,
					sys: entry.sys,
				},
			);

			return {
				slug: entry.fields.slug.ja,
				content: entry.fields.postContent.ja,
				createdAt: entry.sys.createdAt,
				updatedAt: entry.sys.updatedAt,
			};
		},
		async getPost(slug) {
			const entry = await client.entry.getMany<ContentfulEntryFields>({
				query: {
					content_type: CONTENT_TYPE_ID,
					select: "fields.slug,fields.postContent,sys.createdAt,sys.updatedAt",
					"fields.slug": slug,
					limit: 1,
				},
			});
			if (entry.total === 0) {
				throw new Error("Post not found");
			}

			return {
				slug: entry.items[0].fields.slug.ja,
				content: entry.items[0].fields.postContent.ja,
				createdAt: entry.items[0].sys.createdAt,
				updatedAt: entry.items[0].sys.updatedAt,
			};
		},
		async getPosts(filter) {
			// see: https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters/ranges
			const f = filter?.after
				? {
						"sys.createdAt[gte]": filter.after.toISOString(),
					}
				: {};
			const entries = await client.entry.getMany<ContentfulEntryFields>({
				query: {
					content_type: CONTENT_TYPE_ID,
					select: "fields.slug,fields.postContent,sys.createdAt,sys.updatedAt",
					order: "-sys.createdAt", // newest first
					...f,
				},
			});
			return entries.items.map((entry) => {
				return {
					slug: entry.fields.slug.ja,
					content: entry.fields.postContent.ja,
					createdAt: entry.sys.createdAt,
					updatedAt: entry.sys.updatedAt,
				};
			});
		},
		async updatePost(slug, content) {
			const entry = await client.entry.getMany({
				query: {
					content_type: CONTENT_TYPE_ID,
					select: "sys",
					"fields.slug": slug,
					limit: 1,
				},
			});
			if (entry.total === 0) {
				throw new Error("Post not found");
			}

			try {
				const updated = await client.entry.update<ContentfulEntryFields>(
					{ entryId: entry.items[0].sys.id },
					{
						fields: {
							slug: {
								ja: slug,
							},
							postContent: {
								ja: content,
							},
						},
						sys: entry.items[0].sys,
					},
				);

				// publish the entry
				await client.entry.publish(
					{ entryId: updated.sys.id },
					{
						fields: updated.fields,
						sys: updated.sys,
					},
				);
			} catch (e) {
				console.error("failed to update", e);
				throw e;
			}

			return {
				slug,
				content,
				createdAt: entry.items[0].sys.createdAt,
				updatedAt: entry.items[0].sys.updatedAt,
			};
		},
		async uploadFile(file) {
			const arrayBuffer = await file.arrayBuffer();
			const uploadedFile = await client.upload.create(
				{},
				{
					file: arrayBuffer,
				},
			);

			const asset = await client.asset.create(
				{},
				{
					fields: {
						title: {
							ja: file.name,
						},
						file: {
							ja: {
								contentType: file.type,
								fileName: file.name,
								uploadFrom: {
									sys: {
										type: "Link",
										linkType: "Upload",
										id: uploadedFile.sys.id,
									},
								},
							},
						},
					},
				},
			);

			// process asset
			const processedAsset = await client.asset.processForAllLocales(
				{},
				{
					fields: asset.fields,
					sys: asset.sys,
				},
			);

			// publish asset
			const published = await client.asset.publish(
				{ assetId: processedAsset.sys.id },
				{
					fields: processedAsset.fields,
					sys: processedAsset.sys,
				},
			);

			const url = published.fields.file.ja.url;
			if (!url) {
				throw new Error("failed to upload file");
			}
			return url;
		},
	};
};
eyemono.moeeyemono.moe

Content Loader

https://docs.astro.build/en/reference/content-loader-reference/

エディタ部分実装前に、そもそもCMSのコンテンツを読み込んでビルドするためのloaderを実装する必要がある。
Contentful用のLoaderは特に無さそうだったので、これも実装する。

ドキュメントにあるように、シンプルに作るならInline loadersにすればよく、前述のCMSClient interfaceを使うなら以下のようになる

const posts = defineCollection({
	loader: async () => {
		const posts = await cmsClient.getPosts();
		// Must return an array of entries with an id property
    // or an object with IDs as keys and entries as values
		return posts.map((post) => ({
			id: post.slug,
			...post,
		}));
	},
});

しかし、

Whenever the loader is invoked, it will clear the store and reload all the entries.

とあるように、この方法だとフェッチしたコンテンツはキャッシュされないらしい。うまくキャッシュさせるには、Object loadersとして実装し、直接DataStoreとやり取りしなければならなさそう。
実装が長くなったので詳細は割愛。
build-inのglob loaderや、pawcoding/astro-loader-pocketbaseでの実装をがっつり参考にさせていただいた。

https://github.com/eyemono-moe/log/blob/feat/preview/src/libs/cms/loader/index.ts

eyemono.moeeyemono.moe

[WIP] エディタ画面実装

「Monaco Editorを使いたい!!!!」と言っていたが、実際にはMonaco Editor単体で扱うのは難しいため、monaco-vscode-apiを使って実装していく。

https://github.com/CodinGame/monaco-vscode-api

これはMonacoEditorをVSCodeのAPIで扱えるようにするラッパーのようなライブラリであり、簡単にVSCodeっぽい画面(workbench)の実装ができてしまう。WikiのGetting Startedに従って実装を進める。

https://github.com/CodinGame/monaco-vscode-api/tree/main/demo のデモコードを参考にWorkbenchをセットアップしてみた。

https://x.com/eyemono_moe/status/1879813068492030285

vscode公式の拡張機能サンプルの一つにSource Control Sampleがあったため、これを参考に

  • remoteのCMSから記事を取得
  • 編集中ファイルとremote記事の差分を表示
  • (wip)編集ファイルのcommit

を可能にする機能を実装している。

https://github.com/microsoft/vscode-extension-samples/tree/main/source-control-sample

MonacoEditor単体ではなくmonaco-vscode-apiを使うことで、VSCodeのapiが使用できるようになる→VSCodeの拡張機能を参考に実装できる ため、比較的簡単に開発を進められる。

また、直接vsixファイルを読み込むこともできるため、既存の拡張機能をそのまま使うことも可能。上記動画内ではmarkdownlint自作のテーマをvsixから起動している。