create-ampless 1.0.0-alpha.62 → 1.0.0-alpha.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ import { admin } from '@/lib/admin'
2
+ import { createPluginsPage } from '@ampless/admin/pages'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+ export default createPluginsPage(admin)
@@ -14,7 +14,15 @@ export async function generateMetadata(): Promise<Metadata> {
14
14
 
15
15
  export default async function RootLayout({ children }: { children: React.ReactNode }) {
16
16
  const h = await headers()
17
- const theme = await ampless.loadThemeConfig()
17
+ // Resolve theme, plugin head + body in parallel. `publicHead` and
18
+ // `publicBodyEnd` both fetch the S3 site-settings cache to bind
19
+ // `ctx.setting()`; Next.js fetch dedupe collapses that into a
20
+ // single round trip when the two run on the same request.
21
+ const [theme, pluginHead, pluginBodyEnd] = await Promise.all([
22
+ ampless.loadThemeConfig(),
23
+ ampless.publicHead(),
24
+ ampless.publicBodyEnd(),
25
+ ])
18
26
  const themeCss = renderThemeCss(theme.cssVars)
19
27
  const locale = admin.locale
20
28
  const dict = getDictionary(locale)
@@ -45,12 +53,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
45
53
  against the static defaults. Validated values only — see
46
54
  ampless `validateThemeValue`. */}
47
55
  {themeCss && <style dangerouslySetInnerHTML={{ __html: themeCss }} />}
48
- {/* Descriptor-based plugin head injection (Phase 1). Each
56
+ {/* Descriptor-based plugin head injection (Phase 1+2). Each
49
57
  active plugin's `publicHead()` runs validation here; the
50
58
  output is a `<Fragment>` of `<script>` / `<meta>` / `<link>`
51
59
  / `<noscript>` elements. Placed after the theme style so
52
- plugin-emitted overrides win on the rare collision. */}
53
- {ampless.publicHead()}
60
+ plugin-emitted overrides win on the rare collision.
61
+ Awaited via Promise.all above so admin-managed
62
+ `settings.public` values flow into ctx.setting() before
63
+ render. */}
64
+ {pluginHead}
54
65
  </head>
55
66
  {/* `data-theme` selects which theme's `tokens.css` block matches.
56
67
  The active theme is resolved from `theme.active` site setting,
@@ -61,10 +72,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
61
72
  {children}
62
73
  </I18nProvider>
63
74
  </Providers>
64
- {/* Descriptor-based body-end injection (Phase 1). GTM
75
+ {/* Descriptor-based body-end injection (Phase 1+2). GTM
65
76
  no-script iframe / chat widgets / analytics tail snippets
66
- land here. */}
67
- {ampless.publicBodyEnd()}
77
+ land here. Awaited above so the same ctx.setting()
78
+ snapshot powers both head + body. */}
79
+ {pluginBodyEnd}
68
80
  </body>
69
81
  </html>
70
82
  )
@@ -0,0 +1,500 @@
1
+ <!--
2
+ Source of truth lives in packages/ampless/docs/plugin-author-guide.md.
3
+ Keep both copies in sync — the scaffold copy at
4
+ templates/_shared/docs/plugin-author-guide.ja.md must mirror this file
5
+ byte-for-byte until we add a CI check.
6
+ -->
7
+
8
+ > English: [plugin-author-guide.md](./plugin-author-guide.md)
9
+
10
+ # ampless プラグインの書き方
11
+
12
+ このガイドは、初めての `definePlugin()` 呼び出しから admin 編集可能な設定パネル、そして npm 公開に至るまで、ampless プラグインを ship するために必要な手順を一通りカバーします。Phase 1 + Phase 2 の機能を網羅 — descriptor ベースの `<head>` / `<body>` 注入、非同期イベントフック、admin 管理の `settings.public` 値です。
13
+
14
+ 設計の経緯と背景は [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md) に集約。本ページはその実装ハンドブック側です。
15
+
16
+ ---
17
+
18
+ ## 1. プラグインで何ができるか
19
+
20
+ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScript モジュールです。以下のうち 1 つ以上のサーフェスにフックします:
21
+
22
+ | サーフェス | 実行場所 | 同期 / 非同期 | Phase |
23
+ |---|---|---|---|
24
+ | `metadata(post, site)` | 投稿の `generateMetadata()` | 同期 | 既存 |
25
+ | `siteMetadata(site)` | root layout の `generateMetadata()` | 同期 | 既存 |
26
+ | `publicHead(ctx)` | root layout の `<head>` | 同期 (async layout から呼ばれる) | 1 |
27
+ | `publicBodyEnd(ctx)` | root layout の `<body>` 末尾 | 同期 | 1 |
28
+ | `ogImage` | `/og/[slug]` ルート | リクエスト時、公開 Lambda 内 | 既存 |
29
+ | `hooks` | trust_level に応じた processor Lambda | 非同期、SQS イベントで起動 | 既存 |
30
+ | `settings.public` | `/admin/plugins` フォーム | 宣言的なマニフェスト | 2 |
31
+
32
+ **現時点でできないこと** (将来の privileged 層実装に依存、ロードマップ参照):
33
+
34
+ - 任意の `ReactNode` をページに注入 — descriptor 変種のみ許可
35
+ - 公開 Next.js プロセス内で TCP ソケットを開く。trusted Lambda も外向き HTTP のみ
36
+ - admin ルート / server ルート / コンテンツフィールドの追加 — Phase 6b 予約
37
+ - secret の読み書き。`secretSettings` capability は Phase 6a 予約
38
+
39
+ ---
40
+
41
+ ## 2. 最小ファイル構成
42
+
43
+ プラグインは小さな npm パッケージとして公開しても、サイトのモノレポ内に置いてもよいです。on-disk の構成はどちらでも同じ:
44
+
45
+ ```
46
+ my-plugin/
47
+ package.json
48
+ tsconfig.json
49
+ tsup.config.ts
50
+ src/
51
+ index.ts
52
+ index.test.ts
53
+ ```
54
+
55
+ 本レポ内の `packages/plugin-rss/` と `packages/plugin-analytics-ga4/` が動作する参考実装です。
56
+
57
+ 最小の `src/index.ts`:
58
+
59
+ ```ts
60
+ import { definePlugin, type AmplessPlugin } from 'ampless'
61
+
62
+ export default function myPlugin(): AmplessPlugin {
63
+ return definePlugin({
64
+ name: 'my-plugin',
65
+ apiVersion: 1,
66
+ trust_level: 'untrusted',
67
+ capabilities: ['publicHead'],
68
+ publicHead() {
69
+ return [{ type: 'meta', name: 'x-plugin', content: 'hi' }]
70
+ },
71
+ })
72
+ }
73
+ ```
74
+
75
+ `cms.config.ts` に差し込み:
76
+
77
+ ```ts
78
+ import myPlugin from 'my-plugin'
79
+
80
+ export default defineConfig({
81
+ site: { name: 'My Blog', url: 'https://example.com' },
82
+ plugins: [myPlugin()],
83
+ })
84
+ ```
85
+
86
+ これで完了。`npm run dev` を再起動して任意のページのソースを表示すると `<meta name="x-plugin" content="hi" />` が `<head>` に出ています。
87
+
88
+ ---
89
+
90
+ ## 3. `AmplessPlugin` マニフェスト
91
+
92
+ ```ts
93
+ interface AmplessPlugin {
94
+ name: string // パッケージ風の識別子。例: 'analytics-ga4'
95
+ apiVersion: 1 // 契約が変わるときだけ bump
96
+ trust_level: 'untrusted' | 'trusted' | 'privileged'
97
+ instanceId?: string // 複数インストール時の namespace
98
+ displayName?: LocalizedString // admin UI ラベル
99
+ capabilities?: readonly PluginCapability[]
100
+ hooks?: { ... } // 非同期イベント
101
+ metadata?(post, site): PluginMetadata
102
+ siteMetadata?(site): PluginMetadata
103
+ publicHead?(ctx): readonly PublicHeadDescriptor[]
104
+ publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
105
+ ogImage?: OgImageConfig
106
+ settings?: { public?: readonly PluginSettingField[] }
107
+ }
108
+ ```
109
+
110
+ ### `name`
111
+
112
+ 短い識別子 (`'analytics-ga4'`、`'rss'`、`'webhook'` など)。デフォルトの `instanceId` および trusted processor が S3 に書き出す `public/plugins/<name>/` のプレフィックスに使われます。`/^[a-zA-Z0-9_-]+$/` 必須 — 「命名規則」セクション参照。
113
+
114
+ ### `apiVersion: 1`
115
+
116
+ 今日は 1 のみ。将来の互換性破壊バージョンが出たらこの数字が bump され、runtime は未知の値を黙って bind せず拒否します。
117
+
118
+ ### `instanceId`
119
+
120
+ optional、デフォルトは `name`。同じプラグインを 1 サイトで複数 instance 動かせる作り (例: 2 つの GA4 measurement ID、チャットプラットフォーム毎の webhook) では、ホストに `instanceId` を指定させて各々独立した namespace を持たせる:
121
+
122
+ ```ts
123
+ analyticsGa4Plugin({ instanceId: 'marketing' })
124
+ analyticsGa4Plugin({ instanceId: 'product' })
125
+ ```
126
+
127
+ `instanceId` も `name` も `/^[a-zA-Z0-9_-]+$/` を満たす必要があります。`.` は `pk='siteconfig', sk='plugins.<id>.<key>'` の区切りを壊します。scope (`@foo/bar`) やスラッシュは予約済みです。
128
+
129
+ ### `displayName`
130
+
131
+ `/admin/plugins` のパネル見出し。単一ロケールのプラグインなら平文の文字列で十分。`{ en: 'GA4', ja: 'GA4' }` 形式の per-locale map にすると admin のアクティブロケールに応じて読み分けられます。
132
+
133
+ ---
134
+
135
+ ## 4. `trust_level` の選び方
136
+
137
+ 3 階層、選択基準は **イベントフック (hooks) が何を必要とするか** で決まります (sync サーフェスは IAM に触れない):
138
+
139
+ | 階層 | IAM | 用途 |
140
+ |---|---|---|
141
+ | `untrusted` | なし (SQS consume のみ) | head/body descriptor、webhook 配送、コンテンツ変換 |
142
+ | `trusted` | 投稿読み出し、`public/plugins/<name>/...` への書き込み | RSS フィード、sitemap、計算済み JSON インデックス |
143
+ | `privileged` | 予約 | 将来: SES、secret、private S3 |
144
+
145
+ 決め方の目安:
146
+
147
+ - **`publicHead` / `publicBodyEnd` / `metadata` だけ必要** → `untrusted`
148
+ - **hooks から投稿を読みたい (publish 時にフィードを再生成等)** → `trusted`
149
+ - **`public/plugins/*` 以外への S3 PutObject や他の AWS API が必要** → 今はプラグインで ship せず、privileged 層を待つかプラグイン外で実装
150
+
151
+ trust level がズレているプラグインは「権限不足で sliently fail」または「不要に強い権限を持つ」のどちらか。階層を切り替えて再デプロイすれば直ります。
152
+
153
+ ---
154
+
155
+ ## 5. 同期サーフェス
156
+
157
+ **公開 Next.js プロセス** (サイト訪問者のリクエストスレッド) 内で実行されます。AWS に対しては純 (IAM・ネットワーク無し) で、同期実行です。
158
+
159
+ | サーフェス | 戻り値 | 用途 |
160
+ |---|---|---|
161
+ | `metadata(post, site)` | `PluginMetadata` (Next.js `Metadata` 形) | 投稿単位の `<title>` / OGP / Twitter / canonical |
162
+ | `siteMetadata(site)` | `PluginMetadata` | サイト全体の `<title>` / favicon / RSS `<link rel="alternate">` |
163
+ | `publicHead(ctx)` | `PublicHeadDescriptor[]` | 解析ローダー、フォント、jsonld、hreflang |
164
+ | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script フレーム、チャットウィジェット、末尾スニペット |
165
+
166
+ `ctx` オブジェクトの中身:
167
+
168
+ ```ts
169
+ {
170
+ site: Config['site'] // name / url / description
171
+ setting<T>(key: string): T | undefined
172
+ }
173
+ ```
174
+
175
+ `ctx.setting()` は Phase 2 で追加された admin 管理値アクセッサ — §8 参照。
176
+
177
+ ---
178
+
179
+ ## 6. Descriptor リファレンス
180
+
181
+ `publicHead` と `publicBodyEnd` は **descriptor オブジェクト** を返します。`ReactNode` ではありません。runtime が validation してから React 要素を組み立てます。これが untrusted コードを公開描画パスで動かすための安全境界です。
182
+
183
+ ### 共通の variant
184
+
185
+ ```ts
186
+ // 外部 script
187
+ {
188
+ type: 'script',
189
+ id: 'ga4-loader-analytics-ga4',
190
+ src: 'https://www.googletagmanager.com/gtag/js?id=G-XXX',
191
+ strategy: 'afterInteractive', // または 'lazyOnload'
192
+ async: true, // optional; strategy が暗黙的に付ける
193
+ defer: false,
194
+ attrs: { crossorigin: 'anonymous' },
195
+ }
196
+
197
+ // inline script — id は必須 (重複検知に使う)
198
+ {
199
+ type: 'inlineScript',
200
+ id: 'ga4-init-analytics-ga4',
201
+ body: "/* 一行ブートストラップ */",
202
+ strategy: 'afterInteractive',
203
+ }
204
+
205
+ // Meta / link / noscript
206
+ { type: 'meta', name: 'theme-color', content: '#fff' }
207
+ { type: 'meta', property: 'og:image', content: 'https://…' }
208
+ { type: 'link', rel: 'preconnect', href: 'https://cdn.example.com' }
209
+ { type: 'noscript', id: 'gtm-fallback-msg', html: '<p>JS required</p>' }
210
+ ```
211
+
212
+ ### body 専用の variant
213
+
214
+ ```ts
215
+ // iframe — GTM の no-script フォールバック、チャットウィジェット等
216
+ {
217
+ type: 'iframe',
218
+ id: 'gtm-fallback',
219
+ src: 'https://www.googletagmanager.com/ns.html?id=GTM-XYZ',
220
+ height: 0,
221
+ width: 0,
222
+ attrs: { sandbox: 'allow-scripts' },
223
+ }
224
+ ```
225
+
226
+ ### Validation ルール
227
+
228
+ - **URL scheme allowlist**: `http`、`https`、または相対パス。`javascript:`、`data:`、`vbscript:`、`blob:`、`file:` は要素描画前に拒否されます
229
+ - **`attrs` allowlist**: `data-*`、`crossorigin`、`referrerpolicy`、`integrity`、`fetchpriority`、`loading`、`sandbox`、`allow`、`allowfullscreen`。それ以外は dev warn 付きで drop
230
+ - **`inlineScript.id` は必須**。無いとプラグイン同士が似たスニペットを emit したときに dedup できず、dev warning も index 番号を指すだけで原因プラグインを特定できません
231
+ - **id 重複**: 最後の出現が勝ち。dev warning でどの key が重複したか表示されます
232
+ - **CSP nonce**: Phase 1 では伝搬しません。`nonce` attr は型上は宣言してありますが今のところ無視されます
233
+ - **strategy**: `afterInteractive` は外部 script に `async` を付ける。`lazyOnload` は `defer`。明示的な `async` / `defer` が常に勝ち。`beforeInteractive` は非対応
234
+
235
+ ### runtime が描画する形
236
+
237
+ 各プラグインについて runtime が `publicHead(ctx)` (resp. `publicBodyEnd`) を呼び、descriptor を validate して reject を drop、残ったものを `<Fragment>` でラップ。root layout はその Fragment を直接埋め込みます:
238
+
239
+ ```tsx
240
+ <head>{pluginHead}</head>
241
+ {/* … */}
242
+ <body>… {pluginBodyEnd}</body>
243
+ ```
244
+
245
+ `cms.config.plugins` の順序は集約後も保たれます。
246
+
247
+ ---
248
+
249
+ ## 7. 非同期イベントフック
250
+
251
+ `hooks` は SQS から到着したイベントを trust_level に対応する processor Lambda が受けて実行します。runtime context (`ctx`) の中身:
252
+
253
+ ```ts
254
+ interface PluginRuntimeContext {
255
+ site: Config['site']
256
+ listPublishedPosts(): Promise<Post[]> // trusted のみ
257
+ writePublicAsset(key: string, body, contentType): Promise<string> // trusted のみ
258
+ }
259
+ ```
260
+
261
+ 例: RSS プラグイン ([`packages/plugin-rss/src/index.ts`](https://github.com/heavymoons/ampless/blob/main/packages/plugin-rss/src/index.ts) 参照):
262
+
263
+ ```ts
264
+ hooks: {
265
+ 'content.published': async (_event, ctx) => {
266
+ const posts = await ctx.listPublishedPosts()
267
+ const xml = buildRssFeed(posts, ctx.site)
268
+ await ctx.writePublicAsset('feed.xml', xml, 'application/rss+xml')
269
+ },
270
+ 'content.unpublished': /* 同じ */,
271
+ 'content.deleted': /* 同じ */,
272
+ 'content.updated': /* 同じ */,
273
+ }
274
+ ```
275
+
276
+ ### ベストプラクティス
277
+
278
+ - **冪等にする**。SQS は at-least-once 配送 — 同じイベントが 2 回 fire する可能性があります。同じ入力で同じ出力 (決定論的なフィード等) を生成するようにしてください
279
+ - **宣言していない `event.payload.*` を読まない**。形は [`docs/architecture/05-event-system.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/05-event-system.md) に文書化されています。形がドリフトすると暗黙の読み出しが silent に壊れます
280
+ - **エラーは DLQ に行く**。フック内で throw すると最終的にメッセージは dead-letter queue に届きます。失敗のサーフェスは通常の CloudWatch ダッシュボードで
281
+
282
+ ---
283
+
284
+ ## 8. `settings.public` — admin 管理の値 (Phase 2)
285
+
286
+ `settings.public` マニフェストを宣言すると、ホストには `/admin/plugins` の編集 UI が自動で生えます。
287
+
288
+ ```ts
289
+ settings: {
290
+ public: [
291
+ {
292
+ type: 'text',
293
+ key: 'measurementId',
294
+ label: { en: 'Measurement ID', ja: '測定 ID' },
295
+ description: { en: 'GA4 ID, blank to disable', ja: '空で無効化' },
296
+ pattern: '^$|^G-[A-Z0-9]+$',
297
+ placeholder: 'G-XXXXXXXX',
298
+ default: 'G-XXXXXXXX',
299
+ },
300
+ ],
301
+ }
302
+ ```
303
+
304
+ ### 利用可能な field タイプ
305
+
306
+ | Type | 保存形 | 補足 |
307
+ |---|---|---|
308
+ | `text` | string | `pattern`、`maxLength`、`placeholder` |
309
+ | `textarea` | string | `rows`、`maxLength` |
310
+ | `url` | string | save 時に scheme チェック、`allowRelative` |
311
+ | `code` | string | `language` ラベル (表示用)、Phase 2.5 で専用エディタに差し替え予定 |
312
+ | `boolean` | boolean | チェックボックスで描画 |
313
+ | `number` | number | `min` / `max` / `step` |
314
+ | `select` | string (`options[i].value` のいずれかと一致必須) | `options` 必須 |
315
+ | `json` | decoded value (object / array / number / boolean) | admin form は save 前に `JSON.parse` |
316
+
317
+ ### 保存形
318
+
319
+ 保存される値は DynamoDB の以下に landing:
320
+
321
+ ```
322
+ pk = 'siteconfig'
323
+ sk = 'plugins.<instanceId>.<fieldKey>'
324
+ ```
325
+
326
+ trusted processor がこの行を S3 の `public/site-settings.json` にミラー。`publicHead` / `publicBodyEnd` 実行時に公開 runtime がこの file を fetch (60s `revalidate`、`site-settings` cache tag) し、描画パス内から同期読み出しできるようになります。
327
+
328
+ ### required / 無効化 / 未設定 の違い
329
+
330
+ - `required: true` は save 時に empty / undefined を reject、admin form にエラー表示
331
+ - **string 系 field** (`text` / `textarea` / `url` / `code`) は、`required` が falsy のとき **空文字保存が valid**。これが「無効化」シグナル — 例えば GA4 では `measurementId` を空文字保存することで、プラグインを削除せず解析を停止できます
332
+ - **非 string 系 field** (`number` / `boolean` / `json` / `select`) は常に空文字 reject。保存値をクリアしたい場合はユーザが **デフォルトに戻す** を押す — DDB 行が削除され、次のリクエストから `manifest.default` にフォールバックします
333
+
334
+ ---
335
+
336
+ ## 9. 設定値の読み出し: `ctx.setting<T>(key)`
337
+
338
+ `publicHead` / `publicBodyEnd` 内で解決済みの値を読み出すには `ctx.setting`:
339
+
340
+ ```ts
341
+ publicHead(ctx) {
342
+ const id = ctx.setting<string>('measurementId') ?? ''
343
+ if (!id) return []
344
+ return [/* id を使った descriptor */]
345
+ }
346
+ ```
347
+
348
+ リクエスト毎の解決順序:
349
+
350
+ ```
351
+ stored 値 (validated)
352
+ ↳ manifest.default (これも validated)
353
+ ↳ undefined
354
+ ```
355
+
356
+ 両側で validation を通すので、手で DDB 行を編集した結果 out-of-range になった値 (あるいは constructor 引数として渡された壊れた default) がページに漏れません。renderer は invalid 値を「存在しない」かのように扱い、次の valid 層が引き継ぎます。
357
+
358
+ スナップショットがいつ更新されるか: trusted processor が `public/site-settings.json` の再生成を完了した後、次の Next.js fetch cache TTL (60s) を過ぎたリクエストから新値を読みます。admin form は cache invalidation を ~8s 遅延発火するので、processor の S3 rebuild が完了する前に公開側が古い JSON を fetch して詰めてしまう race を避けています。
359
+
360
+ ### 複数インスタンス
361
+
362
+ 各プラグインインスタンスは `instanceId` でスコープされた独立 namespace を持ちます。`cms.config.ts` 内の 2 回の `analyticsGa4Plugin({ instanceId: 'a' })` と `analyticsGa4Plugin({ instanceId: 'b' })` は別々の DDB 行を見ます。`ctx.setting()` は自プラグインの `instanceId` に自動スコープします。
363
+
364
+ ---
365
+
366
+ ## 10. ウォークスルー: GA4 を Phase 1 から Phase 2 に移行する
367
+
368
+ Phase 1 の GA4 プラグインは measurement ID を constructor 引数で受けていました。Phase 2 では後方互換のためにその引数を残しつつ、値は `ctx.setting()` 経由で読みます。
369
+
370
+ **Before** (Phase 1):
371
+
372
+ ```ts
373
+ export default function analyticsGa4Plugin(opts: { measurementId: string }) {
374
+ const { measurementId } = opts
375
+ return definePlugin({
376
+ name: 'analytics-ga4',
377
+ apiVersion: 1,
378
+ trust_level: 'untrusted',
379
+ capabilities: ['publicHead'],
380
+ publicHead() {
381
+ if (!measurementId) return []
382
+ return [/* measurementId を使う descriptor */]
383
+ },
384
+ })
385
+ }
386
+ ```
387
+
388
+ **After** (Phase 2):
389
+
390
+ ```ts
391
+ export default function analyticsGa4Plugin(opts: { measurementId?: string } = {}) {
392
+ const { measurementId = '', instanceId = 'analytics-ga4' } = opts
393
+ return definePlugin({
394
+ name: 'analytics-ga4',
395
+ instanceId,
396
+ apiVersion: 1,
397
+ trust_level: 'untrusted',
398
+ capabilities: ['publicHead', 'adminSettings'],
399
+ settings: {
400
+ public: [{
401
+ type: 'text',
402
+ key: 'measurementId',
403
+ label: { en: 'Measurement ID', ja: '測定 ID' },
404
+ pattern: '^$|^G-[A-Z0-9]+$',
405
+ default: measurementId,
406
+ }],
407
+ },
408
+ publicHead(ctx) {
409
+ const id = ctx.setting<string>('measurementId') ?? ''
410
+ if (!id) return []
411
+ return [/* id を使う descriptor */]
412
+ },
413
+ })
414
+ }
415
+ ```
416
+
417
+ constructor 引数は `manifest.default` の seed になります。`cms.config.ts` で既に `analyticsGa4Plugin({ measurementId: 'G-X' })` を渡している運用者は挙動変化なし。新規デプロイは空にして admin UI 側で設定する運用が推奨です。
418
+
419
+ ---
420
+
421
+ ## 11. テスト
422
+
423
+ ampless は vitest を使っています。典型的なプラグインテストはこんな形:
424
+
425
+ ```ts
426
+ import { describe, it, expect } from 'vitest'
427
+ import type { PluginPublicRenderContext, AmplessPlugin } from 'ampless'
428
+ import { resolvePluginSettings } from 'ampless'
429
+ import myPlugin from './index.js'
430
+
431
+ function makeCtx(plugin: AmplessPlugin, stored: Record<string, unknown> = {}): PluginPublicRenderContext {
432
+ const resolved = resolvePluginSettings(plugin.settings, stored)
433
+ return {
434
+ site: { name: 'Test', url: 'https://example.com/' },
435
+ setting: (k) => resolved[k],
436
+ }
437
+ }
438
+
439
+ it('measurementId 設定時に descriptor を吐く', () => {
440
+ const plugin = myPlugin({ measurementId: 'G-XXX' })
441
+ const descriptors = plugin.publicHead?.(makeCtx(plugin)) ?? []
442
+ expect(descriptors).toHaveLength(2)
443
+ })
444
+
445
+ it('admin が空文字保存した場合は空配列', () => {
446
+ const plugin = myPlugin({ measurementId: 'G-XXX' })
447
+ const descriptors = plugin.publicHead?.(makeCtx(plugin, { measurementId: '' })) ?? []
448
+ expect(descriptors).toEqual([])
449
+ })
450
+ ```
451
+
452
+ マニフェスト + 描画挙動をテストすればよいです。runtime の descriptor validator は `@ampless/runtime` 側でテストされているので、プラグインテストは「何の状態でどの descriptor を返すか」に集中してください。
453
+
454
+ イベントフックのテストは `ctx.listPublishedPosts` と `ctx.writePublicAsset` を単純なスタブ関数で差し替えます。
455
+
456
+ ---
457
+
458
+ ## 12. npm publish
459
+
460
+ ファーストパーティ / モノレポ内部のプラグインは本レポの既存 changeset フローに従ってください。外部プラグインは通常の npm パッケージ:
461
+
462
+ - **パッケージ名**: `@your-scope/plugin-foo`。`@ampless/plugin-*` スコープは本モノレポから ship する公式プラグイン用に予約
463
+ - **エントリ**: ESM のみ、default export (factory) + 設定インターフェイス (ユーザの `cms.config.ts` から型付きで引数を渡せるように) を export
464
+ - **`apiVersion`**: 契約が変わるときだけ bump。既存フィールドの型が変わったら major、新フィールド追加なら minor (既存インストールは動き続ける)
465
+ - **Dist-tag**: ampless 自体が alpha のうちは `@alpha`。`@latest` は ampless v1.0 まで予約
466
+
467
+ 参考実装:
468
+
469
+ - [`packages/plugin-analytics-ga4`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-analytics-ga4) — descriptor ベース、Phase 2 settings
470
+ - [`packages/plugin-rss`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-rss) — trusted、非同期 hooks + `writePublicAsset`
471
+ - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`
472
+ - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook + 外向き HTTP
473
+ - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` ルートレンダラ
474
+
475
+ ---
476
+
477
+ ## 13. 命名規則とよくある落とし穴
478
+
479
+ ### 命名規則
480
+
481
+ - `name`、`instanceId`、`settings.public.key` のすべて: `/^[a-zA-Z0-9_-]+$/` 必須。違反した plugin / field は dev console warning 付きで runtime が drop します
482
+ - `plugins.<instanceId>.<fieldKey>` のドット区切りが保存形式の唯一の構造です。key 側にネストしたドットを入れて凝らないでください
483
+
484
+ ### よくある落とし穴
485
+
486
+ - **capability と実装の不一致**。`capabilities: ['publicHead']` を宣言したのに `publicHead` を未定義 (またはその逆) にすると、起動時に console warning が出ます。capability を外すか関数を追加してください
487
+ - **`instanceId` 重複**。同じ namespace を共有する 2 つのインスタンスは起動時に警告が出ます。後者の保存設定は前者と衝突します
488
+ - **`inlineScript` の `id` 忘れ**。production では silent に drop、dev では warn。inline script の dedup は id 無しではできません
489
+ - **`publicHead` から `ReactNode` を返す**。TypeScript で弾かれます — `publicHead` の戻り値型は descriptor のみ。任意 `ReactNode` が必要なら、それは Phase 6b の `developer.headElements` capability 待ち
490
+ - **admin form から `manifest.default` を保存してしまう**。resolved default を「明示値」として書き戻さないでください — admin form が touched フィールドのみ書き込む設計はまさにこのため。default 値を保存すると future のパッケージ更新で default が変わってもそのフィールドだけ反映されなくなります
491
+
492
+ ---
493
+
494
+ ## 14. 質問先
495
+
496
+ - アーキテクチャ / 設計の質問 → [`docs/architecture/08-plugin-architecture.ja.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.ja.md)
497
+ - ファーストパーティプラグインの bug → `heavymoons/ampless` にプラグインの package 名つきで issue
498
+ - プラグインランタイム / admin form の bug → 同じレポ、ラベル `area:plugins`
499
+
500
+ ampless レポは v1.0 RC まで非公開なので、上記のリンクは現時点では package tarball 内の `node_modules/ampless/docs/` をローカルで見るための参照です。public 化後は実 GitHub URL に解決されます。
@@ -0,0 +1,630 @@
1
+ <!--
2
+ Source of truth lives in packages/ampless/docs/plugin-author-guide.md.
3
+ Keep both copies in sync — the scaffold copy at
4
+ templates/_shared/docs/plugin-author-guide.md must mirror this file
5
+ byte-for-byte until we add a CI check.
6
+ -->
7
+
8
+ > 日本語版: [plugin-author-guide.ja.md](./plugin-author-guide.ja.md)
9
+
10
+ # Writing an ampless plugin
11
+
12
+ This guide walks through everything a plugin author needs to ship a
13
+ working ampless plugin, from first `definePlugin()` call to the
14
+ admin-editable settings panel and an npm publish. It covers the
15
+ Phase 1 + Phase 2 surfaces — descriptor-based `<head>` / `<body>`
16
+ injection, the async event hooks, and admin-managed
17
+ `settings.public` values.
18
+
19
+ The design rationale is in [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md);
20
+ this page is the hands-on companion.
21
+
22
+ ---
23
+
24
+ ## 1. What a plugin can do
25
+
26
+ An ampless plugin is a TypeScript module that returns an
27
+ `AmplessPlugin` object. It plugs into one or more of the following
28
+ surfaces:
29
+
30
+ | Surface | Where | Sync / async | Phase |
31
+ |---|---|---|---|
32
+ | `metadata(post, site)` | `generateMetadata()` per post | sync | Existing |
33
+ | `siteMetadata(site)` | root layout `generateMetadata()` | sync | Existing |
34
+ | `publicHead(ctx)` | root layout `<head>` | sync (called from async layout) | 1 |
35
+ | `publicBodyEnd(ctx)` | root layout end of `<body>` | sync | 1 |
36
+ | `ogImage` | `/og/[slug]` route | request-time, in public Lambda | Existing |
37
+ | `hooks` | trust_level-matched processor Lambda | async, on SQS event | Existing |
38
+ | `settings.public` | `/admin/plugins` form | declarative manifest | 2 |
39
+
40
+ What a plugin **cannot** do today (without privileged tier work, see
41
+ roadmap):
42
+
43
+ - Inject arbitrary `ReactNode` into pages — descriptor variants only.
44
+ - Open a TCP socket in the public Next.js process. Trusted Lambdas
45
+ have outbound HTTP only.
46
+ - Add admin routes / server routes / content fields — those
47
+ capabilities are reserved for Phase 6b.
48
+ - Read or write secrets. The `secretSettings` capability is reserved
49
+ for Phase 6a.
50
+
51
+ ---
52
+
53
+ ## 2. Minimum file layout
54
+
55
+ A plugin can ship as a tiny npm package or live in your site's
56
+ monorepo. The on-disk shape is the same either way:
57
+
58
+ ```
59
+ my-plugin/
60
+ package.json
61
+ tsconfig.json
62
+ tsup.config.ts
63
+ src/
64
+ index.ts
65
+ index.test.ts
66
+ ```
67
+
68
+ See `packages/plugin-rss/` and `packages/plugin-analytics-ga4/` in
69
+ this repo for working examples.
70
+
71
+ The bare-minimum `src/index.ts`:
72
+
73
+ ```ts
74
+ import { definePlugin, type AmplessPlugin } from 'ampless'
75
+
76
+ export default function myPlugin(): AmplessPlugin {
77
+ return definePlugin({
78
+ name: 'my-plugin',
79
+ apiVersion: 1,
80
+ trust_level: 'untrusted',
81
+ capabilities: ['publicHead'],
82
+ publicHead() {
83
+ return [{ type: 'meta', name: 'x-plugin', content: 'hi' }]
84
+ },
85
+ })
86
+ }
87
+ ```
88
+
89
+ Drop it into `cms.config.ts`:
90
+
91
+ ```ts
92
+ import myPlugin from 'my-plugin'
93
+
94
+ export default defineConfig({
95
+ site: { name: 'My Blog', url: 'https://example.com' },
96
+ plugins: [myPlugin()],
97
+ })
98
+ ```
99
+
100
+ That's it. Restart `npm run dev`, view source on any page, and the
101
+ `<meta name="x-plugin" content="hi" />` is in the rendered `<head>`.
102
+
103
+ ---
104
+
105
+ ## 3. The `AmplessPlugin` manifest
106
+
107
+ ```ts
108
+ interface AmplessPlugin {
109
+ name: string // package-like identifier, e.g. 'analytics-ga4'
110
+ apiVersion: 1 // bump only when the contract changes
111
+ trust_level: 'untrusted' | 'trusted' | 'privileged'
112
+ instanceId?: string // namespace for multi-instance installs
113
+ displayName?: LocalizedString // admin UI label
114
+ capabilities?: readonly PluginCapability[]
115
+ hooks?: { ... } // async events
116
+ metadata?(post, site): PluginMetadata
117
+ siteMetadata?(site): PluginMetadata
118
+ publicHead?(ctx): readonly PublicHeadDescriptor[]
119
+ publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
120
+ ogImage?: OgImageConfig
121
+ settings?: { public?: readonly PluginSettingField[] }
122
+ }
123
+ ```
124
+
125
+ ### `name`
126
+
127
+ A short identifier (`'analytics-ga4'`, `'rss'`, `'webhook'`). Used
128
+ as the default `instanceId` and as the trusted-processor S3 prefix
129
+ under `public/plugins/<name>/`. Must match `/^[a-zA-Z0-9_-]+$/` —
130
+ see "Naming rules" below.
131
+
132
+ ### `apiVersion: 1`
133
+
134
+ There's only one version today. Future breaking-change versions will
135
+ bump this number; the runtime rejects unknown values rather than
136
+ silently mis-binding.
137
+
138
+ ### `instanceId`
139
+
140
+ Optional, defaults to `name`. When you ship a plugin that can run
141
+ twice on the same site (e.g. two GA4 measurement IDs, or one
142
+ webhook endpoint per chat platform), let the host customise
143
+ `instanceId` so each install gets its own storage namespace:
144
+
145
+ ```ts
146
+ analyticsGa4Plugin({ instanceId: 'marketing' })
147
+ analyticsGa4Plugin({ instanceId: 'product' })
148
+ ```
149
+
150
+ Both `instanceId` and `name` must satisfy `/^[a-zA-Z0-9_-]+$/`. Dots
151
+ break the `pk='siteconfig', sk='plugins.<id>.<key>'` separator;
152
+ scopes (`@foo/bar`) and slashes are reserved.
153
+
154
+ ### `displayName`
155
+
156
+ What `/admin/plugins` shows as the panel heading. A plain string is
157
+ fine for single-locale plugins; the per-locale map form
158
+ (`{ en: 'GA4', ja: 'GA4' }`) reads from the admin's active locale.
159
+
160
+ ---
161
+
162
+ ## 4. Picking a `trust_level`
163
+
164
+ Three tiers, picked by what the plugin needs to do **inside
165
+ event hooks** (the sync surfaces — metadata, head, body — don't
166
+ touch IAM):
167
+
168
+ | Tier | IAM | Used by |
169
+ |---|---|---|
170
+ | `untrusted` | none (SQS consume only) | head/body descriptors, webhook delivery, content transforms |
171
+ | `trusted` | read posts, write `public/plugins/<name>/...` | RSS feed, sitemap, computed JSON indexes |
172
+ | `privileged` | reserved | future: SES, secrets, private S3 |
173
+
174
+ Rule of thumb:
175
+
176
+ - **You only need `publicHead` / `publicBodyEnd` / `metadata`** →
177
+ `untrusted`.
178
+ - **You need to read posts from a hook (e.g. rebuild a feed on
179
+ publish)** → `trusted`.
180
+ - **You need to call AWS APIs other than S3 PutObject on
181
+ `public/plugins/*`** → don't ship a plugin yet; wait for the
182
+ privileged tier or write the integration outside the plugin
183
+ system.
184
+
185
+ A plugin running at the wrong trust level either has no permission
186
+ to do its job (silent failure inside the Lambda) or carries
187
+ permissions it didn't need. Both are fixable by switching tiers and
188
+ redeploying.
189
+
190
+ ---
191
+
192
+ ## 5. Sync surfaces
193
+
194
+ These run inside the **public Next.js process** (the site visitor's
195
+ request thread). They are pure with respect to AWS — no IAM, no
196
+ network — and execute synchronously.
197
+
198
+ | Surface | Returns | Use case |
199
+ |---|---|---|
200
+ | `metadata(post, site)` | `PluginMetadata` (Next.js `Metadata`-shaped) | Per-post `<title>` / OGP / Twitter / canonical |
201
+ | `siteMetadata(site)` | `PluginMetadata` | Site-wide `<title>` / favicon / RSS `<link rel="alternate">` |
202
+ | `publicHead(ctx)` | `PublicHeadDescriptor[]` | Analytics loader, fonts, jsonld, hreflang |
203
+ | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script frame, chat widgets, tail snippets |
204
+
205
+ The `ctx` object carries:
206
+
207
+ ```ts
208
+ {
209
+ site: Config['site'] // name / url / description
210
+ setting<T>(key: string): T | undefined
211
+ }
212
+ ```
213
+
214
+ `ctx.setting()` is the Phase 2 admin-managed values accessor — see
215
+ §8.
216
+
217
+ ---
218
+
219
+ ## 6. Descriptor reference
220
+
221
+ `publicHead` and `publicBodyEnd` return **descriptor objects**, not
222
+ `ReactNode`. The runtime validates them, then builds the React
223
+ elements itself. This is the safety boundary that lets ampless run
224
+ untrusted code in the public render path.
225
+
226
+ ### Common variants
227
+
228
+ ```ts
229
+ // External script
230
+ {
231
+ type: 'script',
232
+ id: 'ga4-loader-analytics-ga4',
233
+ src: 'https://www.googletagmanager.com/gtag/js?id=G-XXX',
234
+ strategy: 'afterInteractive', // or 'lazyOnload'
235
+ async: true, // optional; strategy implies it
236
+ defer: false,
237
+ attrs: { crossorigin: 'anonymous' },
238
+ }
239
+
240
+ // Inline script — id REQUIRED so two plugins emitting near-identical
241
+ // snippets are dedup-able
242
+ {
243
+ type: 'inlineScript',
244
+ id: 'ga4-init-analytics-ga4',
245
+ body: "/* one-line bootstrap */",
246
+ strategy: 'afterInteractive',
247
+ }
248
+
249
+ // Meta / link / noscript
250
+ { type: 'meta', name: 'theme-color', content: '#fff' }
251
+ { type: 'meta', property: 'og:image', content: 'https://…' }
252
+ { type: 'link', rel: 'preconnect', href: 'https://cdn.example.com' }
253
+ { type: 'noscript', id: 'gtm-fallback-msg', html: '<p>JS required</p>' }
254
+ ```
255
+
256
+ ### Body-only variant
257
+
258
+ ```ts
259
+ // iframe — for GTM no-script fallback, chat widgets, etc.
260
+ {
261
+ type: 'iframe',
262
+ id: 'gtm-fallback',
263
+ src: 'https://www.googletagmanager.com/ns.html?id=GTM-XYZ',
264
+ height: 0,
265
+ width: 0,
266
+ attrs: { sandbox: 'allow-scripts' },
267
+ }
268
+ ```
269
+
270
+ ### Validation rules
271
+
272
+ - **URL scheme allowlist**: `http`, `https`, or relative paths.
273
+ `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` are rejected
274
+ before the element is rendered.
275
+ - **`attrs` allowlist**: `data-*`, `crossorigin`, `referrerpolicy`,
276
+ `integrity`, `fetchpriority`, `loading`, `sandbox`, `allow`,
277
+ `allowfullscreen`. Anything else is dropped with a dev warning.
278
+ - **`inlineScript.id` required**. Without it, two plugins emitting
279
+ the same snippet can't be dedup'd, and dev warnings would point at
280
+ index numbers no one can map back to a plugin.
281
+ - **Duplicate `id`**: the last occurrence wins. A dev warning prints
282
+ which key was duplicated.
283
+ - **CSP nonce**: not propagated in Phase 1. The `nonce` attr is
284
+ declared in the type for forward-compat but discarded today.
285
+ - **Strategy**: `afterInteractive` adds `async` to external scripts.
286
+ `lazyOnload` adds `defer`. Explicit `async` / `defer` always
287
+ wins. `beforeInteractive` is not supported.
288
+
289
+ ### What the runtime renders
290
+
291
+ For each plugin, the runtime calls `publicHead(ctx)` (resp.
292
+ `publicBodyEnd`), validates every descriptor, drops the rejects,
293
+ and wraps the survivors into a `<Fragment>`. The root layout
294
+ interpolates that fragment directly:
295
+
296
+ ```tsx
297
+ <head>{pluginHead}</head>
298
+ {/* … */}
299
+ <body>… {pluginBodyEnd}</body>
300
+ ```
301
+
302
+ `cms.config.plugins` iteration order is preserved across the
303
+ collected list.
304
+
305
+ ---
306
+
307
+ ## 7. Async event hooks
308
+
309
+ `hooks` runs inside the trust_level-matched processor Lambda when an
310
+ event arrives via SQS. The runtime context (`ctx`) carries:
311
+
312
+ ```ts
313
+ interface PluginRuntimeContext {
314
+ site: Config['site']
315
+ listPublishedPosts(): Promise<Post[]> // trusted only
316
+ writePublicAsset(key: string, body, contentType): Promise<string> // trusted only
317
+ }
318
+ ```
319
+
320
+ Example: RSS plugin (see [`packages/plugin-rss/src/index.ts`](https://github.com/heavymoons/ampless/blob/main/packages/plugin-rss/src/index.ts)):
321
+
322
+ ```ts
323
+ hooks: {
324
+ 'content.published': async (_event, ctx) => {
325
+ const posts = await ctx.listPublishedPosts()
326
+ const xml = buildRssFeed(posts, ctx.site)
327
+ await ctx.writePublicAsset('feed.xml', xml, 'application/rss+xml')
328
+ },
329
+ 'content.unpublished': /* same */,
330
+ 'content.deleted': /* same */,
331
+ 'content.updated': /* same */,
332
+ }
333
+ ```
334
+
335
+ ### Best practices
336
+
337
+ - **Be idempotent.** SQS delivery is at-least-once: the same event
338
+ may fire twice. Writing the same output twice should produce the
339
+ same effect (e.g. a deterministic feed).
340
+ - **Don't read events.payload.\* fields you didn't declare.** The
341
+ shape is documented in [`docs/architecture/05-event-system.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/05-event-system.md);
342
+ drifting consumers break silently when the shape moves.
343
+ - **Errors propagate to DLQ.** Throwing inside a hook eventually
344
+ parks the message in the dead-letter queue. Use the normal
345
+ CloudWatch dashboards to surface failures.
346
+
347
+ ---
348
+
349
+ ## 8. `settings.public` — admin-managed values (Phase 2)
350
+
351
+ Declare a `settings.public` manifest and the host gets a
352
+ `/admin/plugins` editor for those values automatically.
353
+
354
+ ```ts
355
+ settings: {
356
+ public: [
357
+ {
358
+ type: 'text',
359
+ key: 'measurementId',
360
+ label: { en: 'Measurement ID', ja: '測定 ID' },
361
+ description: { en: 'GA4 ID, blank to disable', ja: '空で無効化' },
362
+ pattern: '^$|^G-[A-Z0-9]+$',
363
+ placeholder: 'G-XXXXXXXX',
364
+ default: 'G-XXXXXXXX',
365
+ },
366
+ ],
367
+ }
368
+ ```
369
+
370
+ ### Available field types
371
+
372
+ | Type | Stored as | Notes |
373
+ |---|---|---|
374
+ | `text` | string | `pattern`, `maxLength`, `placeholder` |
375
+ | `textarea` | string | `rows`, `maxLength` |
376
+ | `url` | string | scheme-checked at save time, `allowRelative` |
377
+ | `code` | string | `language` label (display only), Phase 2.5 will swap in a dedicated editor |
378
+ | `boolean` | boolean | rendered as checkbox |
379
+ | `number` | number | `min` / `max` / `step` |
380
+ | `select` | string (matches one `options[i].value`) | required to be in `options` |
381
+ | `json` | decoded value (object / array / number / boolean) | admin form `JSON.parse`s before saving |
382
+
383
+ ### Storage shape
384
+
385
+ Each saved value lands in DynamoDB at:
386
+
387
+ ```
388
+ pk = 'siteconfig'
389
+ sk = 'plugins.<instanceId>.<fieldKey>'
390
+ ```
391
+
392
+ The trusted processor mirrors the row to `public/site-settings.json`
393
+ in S3. The public runtime fetches that file (60 s `revalidate`,
394
+ `site-settings` cache tag) when `publicHead` / `publicBodyEnd` run,
395
+ giving you cheap synchronous reads from inside the render path.
396
+
397
+ ### Required vs disabled vs unset
398
+
399
+ - `required: true` rejects empty / undefined values at save time
400
+ and surfaces an admin form error.
401
+ - For **string-like fields** (`text` / `textarea` / `url` / `code`),
402
+ saving an empty string is **valid** when `required` is falsy.
403
+ It's the "disable" sentinel — e.g. GA4 saves an empty
404
+ `measurementId` to turn analytics off without removing the
405
+ plugin.
406
+ - For **non-string fields** (`number` / `boolean` / `json` /
407
+ `select`), empty string is always rejected. To clear a stored
408
+ value entirely, the user clicks **Reset to default** — that
409
+ deletes the DDB row so the next request falls back to
410
+ `manifest.default`.
411
+
412
+ ---
413
+
414
+ ## 9. Reading settings: `ctx.setting<T>(key)`
415
+
416
+ Inside `publicHead` / `publicBodyEnd`, read the resolved value via
417
+ `ctx.setting`:
418
+
419
+ ```ts
420
+ publicHead(ctx) {
421
+ const id = ctx.setting<string>('measurementId') ?? ''
422
+ if (!id) return []
423
+ return [/* descriptors using id */]
424
+ }
425
+ ```
426
+
427
+ Resolution order, per request:
428
+
429
+ ```
430
+ stored value (validated)
431
+ ↳ manifest.default (also validated)
432
+ ↳ undefined
433
+ ```
434
+
435
+ Validation runs on both sides so a manually-edited DDB row that's
436
+ out of range (or a malformed constructor argument seeded as
437
+ `default`) doesn't leak into the page. The renderer treats invalid
438
+ values as if they didn't exist; the next valid layer takes over.
439
+
440
+ When does the snapshot update? When the trusted processor finishes
441
+ rebuilding `public/site-settings.json`, the next request inside the
442
+ ~60 s Next.js fetch cache window still serves the old version. The
443
+ admin form deliberately delays its cache-invalidation call by ~8 s
444
+ so the rebuild has time to finish before the public side hits
445
+ origin.
446
+
447
+ ### Multiple instances
448
+
449
+ Each plugin instance has its own settings namespace keyed by
450
+ `instanceId`. Two `analyticsGa4Plugin({ instanceId: 'a' })` and
451
+ `analyticsGa4Plugin({ instanceId: 'b' })` calls in `cms.config.ts`
452
+ get distinct DDB rows; `ctx.setting()` automatically scopes to the
453
+ plugin's own `instanceId`.
454
+
455
+ ---
456
+
457
+ ## 10. Walk-through: migrating GA4 from Phase 1 to Phase 2
458
+
459
+ The Phase 1 GA4 plugin took the measurement ID through a
460
+ constructor argument. Phase 2 keeps the argument for backward
461
+ compatibility but reads the live value from `ctx.setting()`.
462
+
463
+ **Before** (Phase 1):
464
+
465
+ ```ts
466
+ export default function analyticsGa4Plugin(opts: { measurementId: string }) {
467
+ const { measurementId } = opts
468
+ return definePlugin({
469
+ name: 'analytics-ga4',
470
+ apiVersion: 1,
471
+ trust_level: 'untrusted',
472
+ capabilities: ['publicHead'],
473
+ publicHead() {
474
+ if (!measurementId) return []
475
+ return [/* descriptors using measurementId */]
476
+ },
477
+ })
478
+ }
479
+ ```
480
+
481
+ **After** (Phase 2):
482
+
483
+ ```ts
484
+ export default function analyticsGa4Plugin(opts: { measurementId?: string } = {}) {
485
+ const { measurementId = '', instanceId = 'analytics-ga4' } = opts
486
+ return definePlugin({
487
+ name: 'analytics-ga4',
488
+ instanceId,
489
+ apiVersion: 1,
490
+ trust_level: 'untrusted',
491
+ capabilities: ['publicHead', 'adminSettings'],
492
+ settings: {
493
+ public: [{
494
+ type: 'text',
495
+ key: 'measurementId',
496
+ label: { en: 'Measurement ID', ja: '測定 ID' },
497
+ pattern: '^$|^G-[A-Z0-9]+$',
498
+ default: measurementId,
499
+ }],
500
+ },
501
+ publicHead(ctx) {
502
+ const id = ctx.setting<string>('measurementId') ?? ''
503
+ if (!id) return []
504
+ return [/* descriptors using id */]
505
+ },
506
+ })
507
+ }
508
+ ```
509
+
510
+ The constructor argument now seeds `manifest.default`. Operators
511
+ that already set `analyticsGa4Plugin({ measurementId: 'G-X' })` in
512
+ `cms.config.ts` keep their current behaviour; new deployments
513
+ should leave it empty and configure the value from the admin UI.
514
+
515
+ ---
516
+
517
+ ## 11. Testing
518
+
519
+ ampless uses vitest. Plugin tests typically look like:
520
+
521
+ ```ts
522
+ import { describe, it, expect } from 'vitest'
523
+ import type { PluginPublicRenderContext, AmplessPlugin } from 'ampless'
524
+ import { resolvePluginSettings } from 'ampless'
525
+ import myPlugin from './index.js'
526
+
527
+ function makeCtx(plugin: AmplessPlugin, stored: Record<string, unknown> = {}): PluginPublicRenderContext {
528
+ const resolved = resolvePluginSettings(plugin.settings, stored)
529
+ return {
530
+ site: { name: 'Test', url: 'https://example.com/' },
531
+ setting: (k) => resolved[k],
532
+ }
533
+ }
534
+
535
+ it('emits descriptors when measurementId is set', () => {
536
+ const plugin = myPlugin({ measurementId: 'G-XXX' })
537
+ const descriptors = plugin.publicHead?.(makeCtx(plugin)) ?? []
538
+ expect(descriptors).toHaveLength(2)
539
+ })
540
+
541
+ it('returns empty when admin saved empty string', () => {
542
+ const plugin = myPlugin({ measurementId: 'G-XXX' })
543
+ const descriptors = plugin.publicHead?.(makeCtx(plugin, { measurementId: '' })) ?? []
544
+ expect(descriptors).toEqual([])
545
+ })
546
+ ```
547
+
548
+ Test the manifest + the rendering behaviour. The runtime's
549
+ descriptor validator is exercised in `@ampless/runtime` already —
550
+ plugin tests focus on the *what* (which descriptors get returned for
551
+ which state), not the validation surface.
552
+
553
+ For event hooks, mock `ctx.listPublishedPosts` and
554
+ `ctx.writePublicAsset` with simple stub functions.
555
+
556
+ ---
557
+
558
+ ## 12. Publishing to npm
559
+
560
+ For first-party / monorepo-internal plugins, follow the changeset
561
+ flow already in this repo. For external plugins, the shape is just
562
+ a normal npm package:
563
+
564
+ - **Name**: `@your-scope/plugin-foo`. The `@ampless/plugin-*`
565
+ scope is reserved for first-party plugins shipped from this
566
+ monorepo.
567
+ - **Entry**: ESM only, exports default (the factory) and the
568
+ config interface (for typed args in user `cms.config.ts`).
569
+ - **`apiVersion`**: bump only when the contract changes. Bump
570
+ major when an existing field's type changes; bump minor when you
571
+ add a new field (existing installs keep working).
572
+ - **Dist-tag**: `@alpha` while ampless itself is in alpha. The
573
+ `@latest` tag stays reserved until ampless v1.0.
574
+
575
+ Worked examples to crib from:
576
+
577
+ - [`packages/plugin-analytics-ga4`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-analytics-ga4) — descriptor-based, Phase 2 settings.
578
+ - [`packages/plugin-rss`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-rss) — trusted, async event hooks + `writePublicAsset`.
579
+ - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`.
580
+ - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook with outbound HTTP.
581
+ - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` route renderer.
582
+
583
+ ---
584
+
585
+ ## 13. Naming rules + common pitfalls
586
+
587
+ ### Naming rules
588
+
589
+ - `name`, `instanceId`, every `settings.public.key`: must match
590
+ `/^[a-zA-Z0-9_-]+$/`. The runtime drops plugins / fields that
591
+ violate this with a dev console warning.
592
+ - The dot separator in `plugins.<instanceId>.<fieldKey>` is the
593
+ only structure the storage format relies on. Don't try to be
594
+ clever with nested dots in your keys.
595
+
596
+ ### Common pitfalls
597
+
598
+ - **Capability + implementation mismatch.** Declaring
599
+ `capabilities: ['publicHead']` but never defining `publicHead` (or
600
+ vice versa) prints a console warning at startup. Either drop the
601
+ capability or add the function.
602
+ - **Duplicate `instanceId`.** Two plugin instances sharing the same
603
+ namespace get a startup warning. The second one's stored settings
604
+ collide with the first.
605
+ - **Forgetting `id` on `inlineScript`.** The descriptor is dropped
606
+ silently in production and warned in dev. There is no way for
607
+ the runtime to dedup inline scripts without an id.
608
+ - **Returning `ReactNode` from `publicHead`.** TypeScript catches
609
+ this — `publicHead` is typed to return descriptors only. If you
610
+ find yourself needing arbitrary `ReactNode`, that's the Phase 6b
611
+ `developer.headElements` capability waiting to land.
612
+ - **Saving `manifest.default` through the admin form.** Don't write
613
+ the resolved default back as if it were an explicit value — the
614
+ admin form only writes touched fields for exactly this reason.
615
+ Saving a default value freezes it: future package updates that
616
+ change the default won't take effect for that field anymore.
617
+
618
+ ---
619
+
620
+ ## 14. Where to ask
621
+
622
+ - Architecture / design questions → [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md)
623
+ - Bugs in a first-party plugin → file an issue against
624
+ `heavymoons/ampless` referencing the plugin's package name.
625
+ - Bugs in the plugin runtime / admin form → same repo, label
626
+ `area:plugins`.
627
+
628
+ The ampless repo stays private until v1.0 RC. Once it's public, the
629
+ links above resolve to the actual GitHub URLs; today they live in
630
+ the package tarball directly under `node_modules/ampless/docs/`.
@@ -25,15 +25,15 @@
25
25
  "@tiptap/pm": "^3.23.6",
26
26
  "@tiptap/react": "^3.23.6",
27
27
  "@tiptap/starter-kit": "^3.23.6",
28
- "@ampless/plugin-og-image": "^0.2.0-alpha.17",
29
- "@ampless/plugin-rss": "^0.2.0-alpha.17",
30
- "@ampless/plugin-seo": "^0.2.0-alpha.17",
31
- "@ampless/plugin-webhook": "^0.2.0-alpha.17",
32
- "@ampless/admin": "^1.0.0-alpha.41",
33
- "@ampless/backend": "^1.0.0-alpha.32",
34
- "@ampless/runtime": "^1.0.0-alpha.23",
28
+ "@ampless/plugin-og-image": "^0.2.0-alpha.18",
29
+ "@ampless/plugin-rss": "^0.2.0-alpha.18",
30
+ "@ampless/plugin-seo": "^0.2.0-alpha.18",
31
+ "@ampless/plugin-webhook": "^0.2.0-alpha.18",
32
+ "@ampless/admin": "^1.0.0-alpha.42",
33
+ "@ampless/backend": "^1.0.0-alpha.33",
34
+ "@ampless/runtime": "^1.0.0-alpha.24",
35
35
  "@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
36
- "ampless": "^1.0.0-alpha.17",
36
+ "ampless": "^1.0.0-alpha.18",
37
37
  "aws-amplify": "^6.17.0",
38
38
  "class-variance-authority": "^0.7.1",
39
39
  "clsx": "^2.1.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ampless",
3
- "version": "1.0.0-alpha.62",
3
+ "version": "1.0.0-alpha.63",
4
4
  "description": "Create a new ampless project",
5
5
  "license": "MIT",
6
6
  "type": "module",