静的サイトをMarkdownで運用していると、だいたい一度は思うはずです。記事を書くのにエディタとターミナルを開くのは、自分はいいとして人には頼めないな、と。
先日、Astroで作った店舗サイトに、店主がスマホから「お知らせ」を投稿できる画面を付けました。使ったのがSveltia CMSです。データベースを持たず、書いた内容をGitHubにコミットする仕組みなので、静的サイトの身軽さを崩さずに更新画面だけを足せます。
導入自体は2ファイルで終わりました。ただ、実際に運用に載せるまでで詰まったのは設定の書き方ではなく、画像の置き場所と、設定が反映されるタイミングでした。この記事では手順と、実装で踏んだ落とし穴を書きます。
- Sveltia CMSの導入手順(置くのは2ファイル)
- media_folderを相対パスにしないと画像が最適化されないこと
- 設定を変えても、開いたままのタブには効かないこと
- 公式の案内とは逆に、バージョンを固定したほうがいい理由
- 非エンジニアに渡すときに詰まる、トークンの有効期限
Sveltia CMSとは何か
ひとことで言うと、Gitリポジトリを直接編集する管理画面です。
普通のCMSは記事をデータベースに保存します。Sveltia CMSは違って、ブラウザで書いた内容をそのままGitHubにコミットします。今回の構成なら、/admin/ で入力 → GitHubにコミット → Cloudflare Pagesが自動で再ビルド → 1〜2分で公開、という流れです。CloudflareとGitHubの連携がすでにあるなら、その仕組みにそのまま乗ります。
こういう仕組みをGitベースCMSと呼びます。以前からNetlify CMSというものがあり、名前を変えてDecap CMSになりました。ただ長く更新が滞っていて、Sveltia CMSはその書き直しという位置づけです。
対応しているGitサービスはGitHub、GitLab、Gitea/Forgejo。それとお試し用のTestバックエンドです。
導入は2ファイルで終わる
静的ファイルを置くフォルダに admin/ を作って、2つ入れるだけです。Astroなら public/admin/ になります。
public/
└─ admin/
├─ index.html
└─ config.yml
index.html は読み込むスクリプト1本だけ。CSSの追加も type="module" も要りません(公式が明記しています)。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<title>Sveltia CMS</title>
</head>
<body>
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
</body>
</html>
config.yml に、どのリポジトリのどのフォルダを、どんな項目で編集するかを書きます。
backend:
name: github
repo: user/repo
collections:
- name: news
label: お知らせ
folder: src/content/news
fields:
- { label: タイトル, name: title, widget: string }
- { label: 日付, name: date, widget: datetime }
- { label: 本文, name: body, widget: markdown }
pushしてデプロイされれば https://自分のドメイン/admin/ が開きます。ここまでは詰まりません。問題はこの先です。
落とし穴1:media_folderを相対パスにしないと画像が最適化されない
ここを外すと、アップロードした写真が一切最適化されません。
保存先は全体設定とコレクション単位の両方で指定できます。ポイントは、コレクション側を相対パスにすると本文にも相対パスが書かれること。
collections:
- name: news
folder: src/content/news
# src/content/news から見た相対パス = src/assets/images/news
media_folder: ../../assets/images/news
public_folder: ../../assets/images/news
こう書くと本文は  になります。この形ならAstroの astro:assets が画像を拾えるので、webp変換・複数幅のsrcset・width/height・loading="lazy" が全部自動で付きます。
public/uploads のような絶対パス側に置くと  になり、astro:assets を通りません。素の <img> が出るだけなので、スマホでも原寸をそのまま落とすことになります。
当初「CMSからは src/assets/ を扱えない」と思い込んで public/uploads/ を正式な置き場に決め、プロジェクトのルール文書にまで「最適化し忘れと誤解して直さないこと」と書いていました。この前提が誤りでした。1920pxのカレンダー画像を335pxの枠に表示していて、移行後の実測で474.6KB→218.1KB。54%減です。
Astro側にも1行要ります。これが無いとMarkdownの画像にsrcsetが付きません。
// astro.config.mjs
image: { layout: 'constrained' },
responsiveStyles は既定の false のままで大丈夫です。既存の <Image> が widths と sizes を明示していれば出力は変わりません。
ひとつ残っている課題として、自動生成される sizes は 100vw 前提になります。本文カラムに最大幅がある場合は実際より大きい画像が来るので、rehypeで書き換えが要ります。うちの場合、1440px幅で680pxの枠に1668pxの画像が来ていました。
落とし穴2:設定を変えても、開いたままのタブには効かない
これで事故りました。
config.yml を相対パスに直したのに、保存された本文は /uploads/… のままだったんです。devサーバーが配信している config.yml は新しいのに、です。
原因は単純で、Sveltiaは起動時に一度だけ設定を読みます。変更前から開きっぱなしのタブは、古い設定のまま動き続ける。タブを完全に閉じて開き直したら直りました。
厄介なのが、本文だけが書かれて画像ファイルはどこにも保存されなかった点です。参照だけが残って404になります。設定を変えたら必ずタブを開き直してください。
落とし穴3:バージョンは固定したほうがいい
公式はバージョン指定なしのCDN URLを案内しています。ただ、うちは固定しました。
<!-- 公式の案内 -->
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
<!-- 実際に採用した形 -->
<script src="https://unpkg.com/@sveltia/cms@0.206.1/dist/sveltia-cms.js"></script>
理由は、この画面が店主にとって唯一の投稿手段だからです。固定しないと上流の更新である日突然更新できなくなる可能性があり、しかも気づくのは店主が投稿しようとした瞬間になります。壊れるなら「こちらが版を上げたとき」に限定したい。
上げるときは、実際に1件投稿して確かめるところまでをセットにしています。
自分ひとりで使う管理画面なら、最新に追従したほうが機能改善を受け取れて合理的だと思います。人に渡すかどうかで判断が変わる部分です。
落とし穴4:トークンの有効期限
認証プロキシを立てない限り、GitHub認証はアクセストークンを貼る方式になります。
これ自体はよくできていて、「アクセストークンを使用してログイン」を押すと、必要な権限が最初から選ばれたGitHubのトークン作成ページへのリンクが出ます。非エンジニアが権限を自分で選ぶ必要はありません。
落とし穴は有効期限のほうです。既定の90日のままにすると、3か月後に何の前触れもなくサインインできなくなります。 しかも原因が分からない。手順書には「No expiration を選ぶ」を太字で書きました。
トークンは端末のブラウザのローカルストレージに入ります。端末ごとに1回必要で、サイトデータを消すとやり直しです。
非エンジニアに渡すときに説明したこと
運用を任せるにあたって、先に伝えておかないと混乱する仕様がいくつかありました。
| 仕様 | 説明したこと |
|---|---|
| 下書きが無い | publish_mode を設定していなければ下書き状態は存在せず、「公開」を押した時点で公開される |
| 予約投稿も無い | 未来の日付を入れてもその日に出るわけではない。日付は並び順にしか使われない |
| ホーム画面アイコン | オレンジのCMSマークになる。店のロゴにはできない |
アイコンは変えられません。index.html に書いても無駄で、Sveltiaが起動時に自前のmanifestとapple-touch-iconを注入し、document.title まで書き換えます。唯一効いたのは apple-mobile-web-app-title で、iOSのアイコン名だけは変えられました。
公開サイトのアイコンと見分けがつくので、実用上はむしろ好都合だと割り切っています。効かない指定をファイルに残さないことのほうが大事でした。直したつもりで直っていない状態になるので。
書かせたくない語を弾く
このサイトは価格と在庫を一切載せない方針だったので、本文欄に検査を入れました。
- label: 本文
name: body
widget: markdown
pattern:
- '^(?![\s\S]*([¥¥]\s*[\d,]+|[\d,]+\s*円|在庫|SOLD|売り切れ|売切|完売))[\s\S]*$'
- '値段や在庫のことは書けません。金額や「在庫」「売り切れ」を消してください。'
「〜を含まない」を正規表現で書くので、否定の先読み(?!)を使います。
そして分かったのが、この管理画面側の検査が実質的な最後の砦だということ。ビルド側のrehypeプラグインで同じ検査をして例外を投げても、Astro 5のglob-loaderが捕まえてログに出すだけでビルドは止まりません(終了コード0)。その記事は「データはあるが本文が無い」状態で保存され、見出しと日付だけ出て本文が空のまま公開されます。
確実に止めたいなら、astro:build:start などMarkdownの変換とは別の場所で検査する必要があります。
まとめ
| 項目 | 内容 |
|---|---|
| 置くもの | admin/index.html と admin/config.yml の2つ |
| 画像 | コレクション単位で media_folder を相対パスに。絶対パスだと最適化されない |
| Astro側 | image: { layout: 'constrained' } が無いとsrcsetが付かない |
| 設定変更後 | タブを完全に閉じて開き直す。開いたままだと古い設定で動く |
| バージョン | 人に渡すなら固定する。壊れるタイミングを自分で決められる |
| トークン | 有効期限は「No expiration」。既定の90日だと3か月後に止まる |
導入だけなら2ファイルなので、記事の前半だけ読めば動きます。詰まるのはその後です。
いちばん効いたのは画像の置き場所で、ここを最初に決めておけば後の作業が楽になります。後から移すと、本文の書き換えとファイル移動が同時に発生するので。全体設定の media_folder はコレクション側に上書きされるため、新しいコレクションを足すときはそちらにも必ず書く、というのも覚えておくといいです。
あわせて読みたい
参考
- Sveltia CMS(GitHub) — 位置づけ、対応バックエンドを参照
- Start Guide(Sveltia CMS公式ドキュメント) — 設置するファイルと最小構成を参照
- GitHub backend(Sveltia CMS公式ドキュメント) — 認証方法を参照
