amplesscms

article

テーマとプラグインの使い分け

ampless でカスタムテーマとプラグインをどう使い分けるか。公開ページの見た目を変えるテーマ、投稿イベントや計測タグを扱うプラグイン、という境界から紹介します。

ampless でサイトを拡張するとき、最初に迷いやすいのは「これはカスタムテーマで直す話か、プラグインとして足す話か」だと思います。

ampless の設計では、テーマは公開ページを組み立てる層です。トップページにどのセクションを置くか。記事一覧をカードにするか、リストにするか。本文の余白、見出し、サイドバー、タグ一覧をどう見せるか。ページを開いた人が直接見るものは、まずテーマ側で考えます。

<head> に meta や script を追加するだけなら、テーマでもできます。たとえば themes/my-docs/pages/post.tsx の metadata 生成や、共通レイアウトに直接タグを書く方法でも、特定サイトの特定テーマには反映できます。

それでもプラグインに分ける利点があります。GA4 の測定 ID、GTM のコンテナ ID、Plausible のドメインのように、テーマを切り替えても同じ設定を使いたいもの。複数サイトへ同じ仕組みを持っていきたいもの。あるいは /admin/plugins から値だけ変えたいもの。そういう処理は、テーマファイルに直接書くより、cms.config.tsplugins 配列に登録しておくほうが管理しやすくなります。

RSS や sitemap の再生成、Webhook 配送のように、投稿イベントのあとで非同期に処理したいものもプラグイン向きです。これはテーマだけではできません。テーマはリクエストされたページを描画しますが、プラグインの hook は投稿が変わったあとに processor Lambda で起動し、公開済み投稿を読み直したり、S3 に生成ファイルを書き出したりできます。

この記事では、この境界から始めて、ampless のプラグイン機構を 2026 年 5 月 30 日時点 の実装ベースで整理します。マーケットプレイス型のインストールや privileged プラグインはまだ途中です。ここで書く内容は、いま使える範囲を説明するためのものとして読んでください。

テーマとプラグインの違い

テーマは、公開サイトの画面を決めます。themes/my-* の中にページコンポーネント、CSS トークン、記事ページ、タグページ、ホーム画面を置きます。管理画面でテーマを切り替えると、同じ投稿データでも見え方が変わります。

プラグインは、テーマを替えても残したい処理を登録します。たとえば @ampless/plugin-rss は、投稿が公開されたあとに公開済み投稿を読み直し、RSS XML を public/plugins/rss/feed.xml に書き出します。この処理は、ブログテーマでも docs テーマでも同じです。だからテーマの中ではなく、プラグインとして分けています。

やりたいこと 置く場所
トップページの構成を変える カスタムテーマ
記事ページの余白、見出し、フォントを変える カスタムテーマ
サイドバーや記事カードの並べ方を変える カスタムテーマ
本文中に読了時間や広告枠のような可視 UI を置く カスタムテーマ
特定テーマだけで使う meta / script を固定で入れる カスタムテーマ
GA4 / GTM / Plausible の設定をテーマ変更後も同じ画面で管理する プラグイン
sitemap / RSS / JSON index を生成する プラグイン
投稿公開時に外部サービスへ通知する プラグイン
/admin/plugins から測定 ID やドメインだけ変えたい プラグイン
同じ機能を別サイトにも持っていきたい プラグイン

判断に迷ったら、次の順番で見るとだいたい整理できます。

  • 画面の配置や見た目を変えるだけなら、テーマ。
  • そのテーマだけに固定で入れる head タグなら、テーマでも十分。
  • 投稿公開後に S3 へファイルを書き出す、外部 API を呼ぶ、Webhook を送るなら、プラグイン。
  • 管理画面から設定値だけ変えたいなら、プラグイン。
  • その機能を別テーマや別サイトでも使いたいなら、プラグイン。

ampless は、テーマ非依存の汎用 before-content slot を持っていません。タイトルと本文の間に何かを表示したい、本文中に読了時間を出したい、といった配置はテーマコンポーネントが決めます。

また、inline script で React 管理下の可視 DOM をあとから差し込む書き方は避けます。Next.js の hydration と衝突し、挿入した要素が消えたり、ページの一部が作り直されたりします。プラグインの script は、window.dataLayer に値を積む処理や、外部ウィジェット自身が持つ独立したコンテナの初期化に留めるほうが安全です。

プラグインが使う入口

ampless のプラグインは、definePlugin() が返す plain object です。プラグインごとに、どの入口を使うかを宣言します。

入口 何をするか 実行される場所
metadata(post, site) 投稿ごとの title、OGP、canonical などを返す 公開 Next.js プロセス
siteMetadata(site) サイト全体の RSS link や metadata を返す 公開 Next.js プロセス
publicHead(ctx) <head> に meta、link、script などを追加する 公開 Next.js プロセス
publicBodyEnd(ctx) <body> 末尾に script、noscript、iframe を追加する 公開 Next.js プロセス
publicBodyForPost(post, ctx) 投稿ページに JSON-LD script を追加する テーマの post ページ
hooks 投稿イベントを受けて RSS 再生成や Webhook 配送を行う processor Lambda
ogImage /og/<slug> の画像を描画する 公開 Next.js ルート
settings.public /admin/plugins に公開設定フォームを出す 管理画面 + 公開設定キャッシュ

publicHead()publicBodyEnd() は、テーマでも書ける head/body 追加を、プラグインとして共通管理するための入口です。プラグインは descriptor を返し、runtime が URL や属性を検証してから HTML に変換します。測定 ID やドメインを /admin/plugins から変えたい場合や、複数テーマで同じタグを使いたい場合に効いてきます。

RSS や sitemap のように、公開済み投稿を読み直して S3 にファイルを書き出す場合は、hooks を使います。ここがカスタムテーマとの大きな違いです。テーマはリクエストが来たページを描画しますが、trusted プラグインの hook は投稿更新のあとに非同期で起動し、必要なデータを読み直して、公開サイトがあとで読むファイルを保存します。

イベントで起動するプラグイン

SEO と RSS は、どちらも投稿イベントを購読します。購読しているのは次の四つです。

イベント いつ出るか RSS / sitemap で必要な理由
content.published 下書きが公開になった、または公開状態の投稿が作られた 新しい URL を feed / sitemap に入れる
content.unpublished 公開済み投稿が下書きに戻った、または削除前に公開状態から外れた feed / sitemap から外す
content.deleted 投稿行が削除された 残っている公開投稿だけで作り直す
content.updated 投稿が更新された 公開済み投稿の title、slug、excerpt、publishedAt などが変わった場合に反映する

流れは次のようになります。

管理 UI / MCP が Post を作成・更新・削除
  ↓
DynamoDB Stream
  ↓
event-dispatcher Lambda
  ↓
SQS: TrustedEventsQueue / UntrustedEventsQueue
  ↓
processor-trusted / processor-untrusted
  ↓
該当する hooks を持つプラグインを呼ぶ

イベント payload には、本文全体ではなく小さな投稿情報だけが入ります。postIdslugtitlestatuspublishedAttags などです。Webhook のように「何が起きたか」を外部へ知らせるだけなら、この payload をそのまま送れます。

RSS や sitemap は、それだけでは足りません。feed には最新の公開投稿をまとめて入れる必要があり、sitemap も公開中の URL 一覧として作り直す必要があります。そのため trusted hook の中で ctx.listPublishedPosts() を呼びます。これは Post テーブルの byStatus GSI を status = published で Query し、公開済み投稿だけを新しい順に返します。戻ってくる投稿には postIdslugtitleexcerptformatbodypublishedAttags が含まれます。

生成した結果は ctx.writePublicAsset(key, body, contentType) で保存します。保存先は必ず、プラグインごとの namespace の下です。

プラグイン 呼び出し 実際の保存先
SEO ctx.writePublicAsset('sitemap.xml', xml, 'application/xml; charset=utf-8') public/plugins/seo/sitemap.xml
RSS ctx.writePublicAsset('feed.xml', xml, 'application/rss+xml; charset=utf-8') public/plugins/rss/feed.xml
custom index ctx.writePublicAsset('indexes/posts.json', json, 'application/json; charset=utf-8') public/plugins/<plugin-name>/indexes/posts.json

writePublicAsset() は S3 の PutObject を行います。key には絶対パス、. / .. segment、backslash、制御文字、長すぎる文字列を使えません。runtime が S3 へ送る前に検証します。返り値は、保存された object の public URL です。

この仕組みにすると、公開ページのリクエスト中に毎回 RSS を組み立てる必要がありません。投稿が変わったタイミングで XML を作り直し、/feed.xml/sitemap.xml の route handler は、その保存済みファイルを読むだけで済みます。

trust_level

プラグインには trust_level があります。これは「そのプラグインの hooks が、どの Lambda で実行されるか」を決めるための値です。公開ページで publicHead() を呼ぶときの権限ではありません。

trust_level いまの用途
untrusted head/body 注入、metadata、Webhook 配送など。CMS データへ直接触る AWS 権限は渡さない
trusted 公開済み投稿を読む、public/plugins/<name>/... に生成ファイルを書く
privileged 予約済み。SES、secret、private S3、独自テーブルなどはこの層の候補

サンドボックスは V8 isolate や vm.Script ではありません。ampless は Lambda の IAM 実行ロールで境界を作ります。untrusted Lambda には CMS データへ触る権限を渡さず、trusted Lambda には公開ファイル生成に必要な権限を渡します。

privileged は型としては存在しますが、現時点では専用 Lambda がありません。外部 API の秘密鍵を安全に持つ、ユーザー投稿フォームを独自テーブルに保存する、といった処理は、まだプラグインとしての本番ルートが決まっていません。必要な場合は、通常の Next.js / Amplify 実装としてサイト側に置くほうが読みやすいです。

公式プラグイン

いま用意している公式プラグインは、次のような役割です。

パッケージ trust_level 何をするか
@ampless/plugin-seo trusted 投稿・サイトの SEO metadata を返し、公開イベント時に sitemap.xml を再生成する
@ampless/plugin-rss trusted 公開済み投稿から RSS フィードを作り、public/plugins/rss/feed.xml に書き出す
@ampless/plugin-og-image untrusted /og/<slug> で投稿ごとの OGP 画像を描画する
@ampless/plugin-webhook untrusted content.published などのイベントを外部 URL へ POST する。HMAC 署名にも対応
@ampless/plugin-analytics-ga4 untrusted GA4 の loader と init script を <head> に追加する。測定 ID は /admin/plugins で編集できる
@ampless/plugin-gtm untrusted Google Tag Manager の loader と noscript fallback を追加する。コンテナ ID は admin から変更できる
@ampless/plugin-plausible untrusted Plausible Analytics の script を追加する。self-hosted の script URL も設定できる
@ampless/plugin-schema-jsonld untrusted 投稿ページに Article / BlogPosting などの JSON-LD を追加する

scaffold 直後の cms.config.ts では、SEO と RSS が有効です。GA4、GTM、Plausible、Schema JSON-LD、Webhook、OG image はコメント付きの例として並びます。必要になったら import のコメントを外し、plugins 配列に追加して再デプロイします。

import { defineConfig } from 'ampless'
import seoPlugin from '@ampless/plugin-seo'
import rssPlugin from '@ampless/plugin-rss'
import analyticsGa4Plugin from '@ampless/plugin-analytics-ga4'

export default defineConfig({
  site: {
    name: 'My Site',
    url: 'https://example.com',
  },
  plugins: [
    seoPlugin(),
    rssPlugin({ limit: 20 }),
    analyticsGa4Plugin({
      measurementId: '', // 後から /admin/plugins で設定する
    }),
  ],
})

GA4、GTM、Plausible、Schema JSON-LD は settings.public を持っています。一度 cms.config.ts に登録してデプロイすれば、測定 ID やドメイン、schema.org の種類などは /admin/plugins から変更できます。

サイトローカルなプラグイン

公開 npm パッケージにするほどではない拡張なら、サイトのリポジトリ内に置きます。たとえば plugins/local-meta.ts を作り、cms.config.ts から import します。

// plugins/local-meta.ts
import { definePlugin, type AmplessPlugin } from 'ampless'

export default function localMetaPlugin(): AmplessPlugin {
  return definePlugin({
    name: 'local-meta',
    apiVersion: 1,
    trust_level: 'untrusted',
    capabilities: ['publicHead'],
    publicHead() {
      return [
        {
          type: 'meta',
          name: 'x-site',
          content: 'heavymoons',
        },
      ]
    },
  })
}
// cms.config.ts
import localMetaPlugin from './plugins/local-meta'

export default defineConfig({
  site: { name: 'My Site', url: 'https://example.com' },
  plugins: [localMetaPlugin()],
})

このくらいの小さなプラグインなら、サイト固有の meta/link、軽い JSON-LD、特定サービス向けのタグ追加に向いています。公開 runtime は descriptor を検証するため、javascript: URL や許可されていない属性は HTML に出ません。

admin で変えられる設定

測定 ID や公開ドメインのように、デプロイ後も値だけ変えたい場合は settings.public を宣言します。すると /admin/plugins にフォームが出ます。小さな例として、ブラウザ UI に渡す theme-color を管理画面から変えられるプラグインを書くと、次のようになります。

import { definePlugin, type AmplessPlugin } from 'ampless'

export default function themeColorPlugin(defaultColor = '#0f172a'): AmplessPlugin {
  return definePlugin({
    name: 'theme-color',
    apiVersion: 1,
    trust_level: 'untrusted',
    displayName: { ja: 'テーマカラー', en: 'Theme color' },
    capabilities: ['publicHead', 'adminSettings'],
    settings: {
      public: [
        {
          type: 'text',
          key: 'color',
          label: { ja: 'テーマカラー', en: 'Theme color' },
          description: {
            ja: 'ブラウザ UI に渡す theme-color。例: #0f172a',
            en: 'Theme color passed to browser UI. Example: #0f172a',
          },
          pattern: '^#[0-9a-fA-F]{6}$',
          default: defaultColor,
        },
      ],
    },
    publicHead(ctx) {
      const color = (ctx.setting<string>('color') ?? '').trim()
      if (!color) return []
      return [{ type: 'meta', name: 'theme-color', content: color }]
    },
  })
}

settings.public は公開サイトから読める値です。測定 ID、公開ドメイン、表示文言のようなものに使います。API キーや署名 secret は置きません。secret settings は後続フェーズの領域です。

イベントフック

投稿公開後に外部へ通知する。RSS のような生成ファイルを作る。独自の JSON インデックスを更新する。こうした処理は hooks に書きます。

import { definePlugin, type AmplessPlugin } from 'ampless'

export default function simpleIndexPlugin(): AmplessPlugin {
  return definePlugin({
    name: 'simple-index',
    apiVersion: 1,
    trust_level: 'trusted',
    capabilities: ['eventHooks', 'writePublicAsset'],
    hooks: {
      'content.published': rebuild,
      'content.updated': rebuild,
      'content.unpublished': rebuild,
      'content.deleted': rebuild,
    },
  })
}

async function rebuild(_event: unknown, ctx: import('ampless').PluginRuntimeContext) {
  const posts = await ctx.listPublishedPosts()
  const body = JSON.stringify(
    posts.map((post) => ({
      title: post.title,
      slug: post.slug,
      excerpt: post.excerpt,
    })),
    null,
    2
  )
  await ctx.writePublicAsset('posts.json', body, 'application/json; charset=utf-8')
}

writePublicAsset() で書いたファイルは public/plugins/<plugin-name>/<key> に保存されます。キーには絶対パスや .. を使えません。runtime が書き込み前に検証します。

SQS は at-least-once です。同じイベントが複数回届いても壊れないように、hooks は冪等に書きます。RSS や index のように毎回すべて作り直して同じキーへ上書きする処理は、この性質と相性がいいです。

公開プラグインとして配る

ほかのサイトでも使うなら、通常の npm パッケージにします。現行モデルでは、管理画面からワンクリックでインストールするのではありません。サイト運営者がリポジトリに依存を追加し、cms.config.ts に登録して、Amplify Hosting で再デプロイします。

最小の構成は次のようなものです。

my-ampless-plugin/
  package.json
  tsconfig.json
  tsup.config.ts
  src/
    index.ts

package.json は ESM の default export を出します。

{
  "name": "ampless-plugin-example",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist", "README.md"],
  "dependencies": {
    "ampless": "^0.2.0-alpha.0"
  }
}

外部プラグインでも、基本は公式プラグインと同じです。

  • factory を default export する
  • apiVersion: 1 を返す
  • trust_levelcapabilities を実装に合わせる
  • module top-level でネットワーク呼び出しやファイル書き込みをしない
  • hooks は冪等にする
  • name / instanceId / setting key は /^[a-zA-Z0-9_-]+$/ に収める

マーケットプレイス型の配布はまだありません。任意 JS を実行時にロードするには、trusted 相当の権限をどう分けるか、capability から IAM をどう作るか、失敗したプラグインをどう隔離するかを決める必要があります。v1.0 の必須条件には入れていません。

いま向いている拡張

現状のプラグイン機構で書きやすいのは、次のようなものです。

  • analytics / tag manager / cookie consent のような head/body 注入
  • SEO metadata、canonical、JSON-LD
  • RSS、sitemap、公開 JSON index の生成
  • 投稿公開・更新イベントを外部へ投げる Webhook
  • サイト固有の小さな meta/link 追加

逆に、次のものはまだ慎重に見たほうがよさそうです。

  • secret を扱う外部 API 連携
  • admin の新規画面や server route の追加
  • 独自 DynamoDB テーブルへの書き込み
  • ユーザー投稿フォームや決済のように、CMS とは別の権限境界が必要な処理

ここは privileged 層や secret settings が入ってから整理する領域です。いま必要なら、プラグインに押し込まず、サイト側の通常の Next.js / Amplify 実装として切り出すほうが読みやすく保てます。

関連記事