ampless 1.0.0-alpha.22 → 1.0.0-alpha.24

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.
package/dist/index.d.ts CHANGED
@@ -615,7 +615,33 @@ interface PluginJsonField extends PluginFieldBase<unknown> {
615
615
  placeholder?: string;
616
616
  rows?: number;
617
617
  }
618
- type PluginSettingField = PluginTextField | PluginTextareaField | PluginBooleanField | PluginNumberField | PluginSelectField | PluginUrlField | PluginCodeField | PluginJsonField;
618
+ /**
619
+ * Sub-fields allowed inside a `PluginRepeatableField`. Restricted to
620
+ * the scalar-shape field types so the v1 admin editor can render each
621
+ * cell with the existing `renderScalarInput` seam. Code / json /
622
+ * repeatable are excluded — code is rarely useful per-item, json
623
+ * recurses into "json inside json" UX that we explicitly want to
624
+ * avoid, and nested repeatable is deferred.
625
+ */
626
+ type PluginRepeatableSubField = PluginTextField | PluginTextareaField | PluginBooleanField | PluginNumberField | PluginSelectField | PluginUrlField;
627
+ interface PluginRepeatableField extends PluginFieldBase<ReadonlyArray<Readonly<Record<string, unknown>>>> {
628
+ type: 'repeatable';
629
+ /** Shape of each item — every item is a flat object keyed by sub-field `key`. */
630
+ fields: ReadonlyArray<PluginRepeatableSubField>;
631
+ /** Hard cap on item count (default 50). Exceeding rejects the whole field. */
632
+ maxItems?: number;
633
+ /** Minimum item count (default 0). Below rejects the whole field. */
634
+ minItems?: number;
635
+ /** Label for the "+ Add item" button in the admin editor. */
636
+ addLabel?: LocalizedString;
637
+ /**
638
+ * Sub-field key used as the per-item heading in the admin editor
639
+ * (e.g. `'id'` so categories[0] reads "analytics" not "Item 1").
640
+ * Falls back to "Item N" when absent or empty.
641
+ */
642
+ itemLabelKey?: string;
643
+ }
644
+ type PluginSettingField = PluginTextField | PluginTextareaField | PluginBooleanField | PluginNumberField | PluginSelectField | PluginUrlField | PluginCodeField | PluginJsonField | PluginRepeatableField;
619
645
  /**
620
646
  * Static manifest declared in a plugin package's `package.json` under
621
647
  * the `amplessPlugin` key. The plugin package must also expose
@@ -1186,7 +1212,7 @@ declare function isValidPluginKey(key: string): boolean;
1186
1212
  * `boolean`, `json` stores the decoded value (object / array /
1187
1213
  * primitive — never the source string), etc.
1188
1214
  */
1189
- declare function validatePluginSettingValue(field: PluginSettingField, raw: unknown): unknown | null;
1215
+ declare function validatePluginSettingValue(field: PluginSettingField, raw: unknown, mode?: 'strict' | 'lenient'): unknown | null;
1190
1216
  /**
1191
1217
  * Merge stored values on top of manifest defaults for one plugin
1192
1218
  * instance. Both sides are validated through `validatePluginSettingValue`
package/dist/index.js CHANGED
@@ -239,7 +239,7 @@ var PLUGIN_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;
239
239
  function isValidPluginKey(key) {
240
240
  return typeof key === "string" && PLUGIN_KEY_PATTERN.test(key);
241
241
  }
242
- function validatePluginSettingValue(field, raw) {
242
+ function validatePluginSettingValue(field, raw, mode = "lenient") {
243
243
  if (raw === void 0) return null;
244
244
  switch (field.type) {
245
245
  case "text":
@@ -318,6 +318,60 @@ function validatePluginSettingValue(field, raw) {
318
318
  if (typeof raw === "string") return null;
319
319
  return raw;
320
320
  }
321
+ case "repeatable": {
322
+ if (!Array.isArray(raw)) return null;
323
+ const maxItems = typeof field.maxItems === "number" ? field.maxItems : 50;
324
+ const minItems = typeof field.minItems === "number" ? field.minItems : 0;
325
+ if (raw.length > maxItems) return null;
326
+ if (raw.length < minItems) return null;
327
+ const subFieldMap = /* @__PURE__ */ new Map();
328
+ for (const sf of field.fields) {
329
+ subFieldMap.set(sf.key, sf);
330
+ }
331
+ const validatedItems = [];
332
+ for (const item of raw) {
333
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
334
+ if (mode === "strict") return null;
335
+ continue;
336
+ }
337
+ const rawItem = item;
338
+ let itemValid = true;
339
+ const validatedItem = {};
340
+ for (const sf of field.fields) {
341
+ const rawSubVal = Object.prototype.hasOwnProperty.call(rawItem, sf.key) ? rawItem[sf.key] : void 0;
342
+ if (rawSubVal === void 0) {
343
+ if (sf.required) {
344
+ if (mode === "strict") return null;
345
+ itemValid = false;
346
+ break;
347
+ }
348
+ if (sf.default !== void 0) {
349
+ const validatedDefault = validatePluginSettingValue(sf, sf.default, mode);
350
+ if (validatedDefault !== null) {
351
+ validatedItem[sf.key] = validatedDefault;
352
+ }
353
+ }
354
+ continue;
355
+ }
356
+ const validatedSubVal = validatePluginSettingValue(sf, rawSubVal, mode);
357
+ if (validatedSubVal === null) {
358
+ if (sf.required) {
359
+ if (mode === "strict") return null;
360
+ itemValid = false;
361
+ break;
362
+ }
363
+ continue;
364
+ }
365
+ validatedItem[sf.key] = validatedSubVal;
366
+ }
367
+ if (!itemValid) {
368
+ continue;
369
+ }
370
+ validatedItems.push(validatedItem);
371
+ }
372
+ if (validatedItems.length < minItems) return null;
373
+ return validatedItems;
374
+ }
321
375
  }
322
376
  }
323
377
  function resolvePluginSettings(manifest, stored) {
@@ -15,8 +15,49 @@
15
15
 
16
16
  ---
17
17
 
18
+ ## 0. テーマとプラグインの境界線
19
+
20
+ ampless はテーマとプラグインの両方を提供します。用途に合ったものを選ぶことで、未来の自分や他のサイト作者が迷わずコードを見つけられます。
21
+
22
+ | やりたいこと | テーマを使う | プラグインを使う |
23
+ |---|---|---|
24
+ | レイアウト・タイポグラフィ・配色・ルート単位の UI | ✓ | |
25
+ | home / post / tag ページのカスタムコンポーネント | ✓ | |
26
+ | 非開発者が admin から編集できる設定 | | ✓ (`adminSettings`) |
27
+ | コンテンツイベント後のバックグラウンド処理(RSS・検索インデックス・webhook) | | ✓ (`eventHooks`) |
28
+ | 信頼できる副作用(S3 書き込み・外部 API 送信) | | ✓ (`writePublicAsset` + `trusted`) |
29
+ | テーマに依存しない `<head>` / `<body>` 注入(アナリティクス・同意バナー) | | ✓ (`publicHead` / `publicBodyEnd`) |
30
+ | 投稿単位の機械可読メタデータ(JSON-LD 等) | | ✓ (`schema` via `publicBodyForPost`) |
31
+ | 複数の ampless サイトで共有したいコード | | ✓ (npm パッケージとして公開) |
32
+
33
+ 判断の目安:
34
+
35
+ - テーマ = **ページの見た目**。render 時は読み取り専用。
36
+ - プラグイン = **render を超えて起こること**: admin 編集可能な設定、バックグラウンド処理、テーマ非依存の注入、機械可読メタデータ、サイト間の再利用。
37
+
38
+ 新しいプラグイン作者がよく踏む 2 つの境界線:
39
+
40
+ - **ストレージ / DynamoDB / 外部 API 書き込みはプラグインに置く**。テーマは読み取り専用です。
41
+ - **admin が `/admin/plugins` からオン・オフしたい機能はプラグインに置く**。表面上の効果が純粋に見た目だけであっても同様です。テーマも独自の設定を持てますが、それはテーマ表示設定であり、サイト運用設定ではありません。
42
+
43
+ 境界線上に本当に乗っている機能については [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md) で詳しく議論しています。
44
+
45
+ ---
46
+
18
47
  ## 1. プラグインで何ができるか
19
48
 
49
+ ampless プラグインは 3 つのいずれかの場所に書きます — コードをどのくらい広く共有したいかに応じて選んでください:
50
+
51
+ | どこに置くか | 使い時 | 置き場所 |
52
+ |---|---|---|
53
+ | **ファーストパーティ** | ampless コアへの全員向け貢献 | ampless モノレポ内の `packages/plugin-*/` |
54
+ | **サイトローカル** | サイト固有のカスタマイズ、個別 publish 不要 | サイトリポジトリ内の `plugins/<name>/` |
55
+ | **外部 npm パッケージ** | 他のサイトと共有したい、`npm publish` 想定 | スタンドアロンリポジトリ (`@scope/ampless-plugin-foo`) |
56
+
57
+ 3 つの形式はすべて同じ `definePlugin({...})` ファクトリを呼び出し、同じサーフェスを使います。違いはパッケージング・配布方法、および静的 `package.json#amplessPlugin` マニフェストが有効にするインストール時バリデーションのオプトインです(§3 参照)。
58
+
59
+ §14 には一行のスキャフォールドコマンド (`npx create-ampless plugin <name>`) があり、後者 2 つのどちらにも即使えるボイラープレートを生成します。
60
+
20
61
  ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScript モジュールです。以下のうち 1 つ以上のサーフェスにフックします:
21
62
 
22
63
  | サーフェス | 実行場所 | 同期 / 非同期 | Phase |
@@ -30,30 +71,65 @@ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScrip
30
71
  | `hooks` | trust_level に応じた processor Lambda | 非同期、SQS イベントで起動 | 既存 |
31
72
  | `settings.public` | `/admin/plugins` フォーム | 宣言的なマニフェスト | 2 |
32
73
 
33
- **現時点でできないこと** (将来の privileged 層実装に依存、ロードマップ参照):
34
-
35
- - 任意の `ReactNode` をページに注入 — descriptor 変種のみ許可
36
- - 公開 Next.js プロセス内で TCP ソケットを開く。trusted Lambda も外向き HTTP のみ
37
- - admin ルート / server ルート / コンテンツフィールドの追加 — Phase 6b 予約
38
- - secret の読み書き。`secretSettings` capability Phase 6a 予約
74
+ 後続フェーズに残してある surface もいくつかあって、現状の `definePlugin`
75
+ では形にできません:
76
+
77
+ - **任意の `ReactNode` のページ注入**。同期描画 surface (`publicHead` /
78
+ `publicBodyEnd` / `publicBodyForPost`) descriptor 変種を返すだけ。
79
+ descriptor validator**runtime が描画する HTML 出力の安全境界**
80
+ であって、プラグイン本体のコードを縛る JS sandbox ではない (プラグインは
81
+ 普通の TypeScript としてサイトと同一の Node プロセス内で動く)
82
+ - **同期描画 surface 内でのネットワーク**。これらの surface は宣言的な
83
+ 出力を返す設計で、Promise を受け取らないし async result path も無い。
84
+ `publicHead` の中で `await fetch(...)` を書くと SSR が無期限ブロックする
85
+ (デッドラインを返す手段がない)。外向き HTTP が必要な処理は trusted
86
+ Lambda (`hooks`) でやる
87
+ - **admin ルート / server ルート / コンテンツフィールドの追加** —
88
+ Phase 6b 予約
89
+ - **secret の読み書き**。`secretSettings` capability は Phase 6a 予約。
90
+ `settings.public` に credential を置かないこと
39
91
 
40
92
  ---
41
93
 
42
94
  ## 2. 最小ファイル構成
43
95
 
44
- プラグインは小さな npm パッケージとして公開しても、サイトのモノレポ内に置いてもよいです。on-disk の構成はどちらでも同じ:
96
+ プラグインを作る最速の方法はスキャフォールドです:
97
+
98
+ ```bash
99
+ # サイトローカル (現在の ampless サイトに plugins/<name>/index.ts を生成)
100
+ npx create-ampless@latest plugin my-thing
45
101
 
102
+ # スタンドアロン npm パッケージ (`npm publish` 向けの ./<name>/ を生成)
103
+ npx create-ampless@latest plugin @myscope/ampless-plugin-my-thing --standalone
46
104
  ```
47
- my-plugin/
105
+
106
+ 全体の手順は §14 を参照してください。このセクションの残りでは生成されるファイルの意味を説明します — 手書きしたい場合はここを読めば把握できます。
107
+
108
+ ### サイトローカル
109
+
110
+ ```
111
+ plugins/
112
+ my-thing/
113
+ index.ts # ファクトリ関数のみ。これがプラグインの全体
114
+ ```
115
+
116
+ サイトの `package.json` / `tsconfig.json` がコンパイルを担うため、追加で ship するものはありません。`cms.config.ts` から相対 import で登録します。
117
+
118
+ ### スタンドアロン npm パッケージ
119
+
120
+ ```
121
+ ampless-plugin-my-thing/
48
122
  package.json
49
123
  tsconfig.json
50
124
  tsup.config.ts
125
+ README.md
126
+ CHANGELOG.md
51
127
  src/
52
128
  index.ts
53
129
  index.test.ts
54
130
  ```
55
131
 
56
- 本レポ内の `packages/plugin-rss/` と `packages/plugin-analytics-ga4/` が動作する参考実装です。
132
+ 本レポ内の `packages/plugin-rss/` と `packages/plugin-analytics-ga4/` が動作するファーストパーティの参考実装です — スタンドアロンスキャフォールドはこれらのレイアウトを踏襲します。
57
133
 
58
134
  最小の `src/index.ts`:
59
135
 
@@ -93,6 +169,7 @@ export default defineConfig({
93
169
  ```ts
94
170
  interface AmplessPlugin {
95
171
  name: string // パッケージ風の識別子。例: 'analytics-ga4'
172
+ packageName?: string // インストール時のクロスチェック用 npm パッケージ名
96
173
  apiVersion: 1 // 契約が変わるときだけ bump
97
174
  trust_level: 'untrusted' | 'trusted' | 'privileged'
98
175
  instanceId?: string // 複数インストール時の namespace
@@ -132,6 +209,67 @@ analyticsGa4Plugin({ instanceId: 'product' })
132
209
 
133
210
  `/admin/plugins` のパネル見出し。単一ロケールのプラグインなら平文の文字列で十分。`{ en: 'GA4', ja: 'GA4' }` 形式の per-locale map にすると admin のアクティブロケールに応じて読み分けられます。
134
211
 
212
+ ### `packageName`
213
+
214
+ 省略可能。設定すると、runtime は起動時に `<packageName>/package.json` を解決し、そこにある静的な `amplessPlugin` ブロックをファクトリの戻り値とクロスチェックします。これにより、runtime で初めて気づく(あるいは永遠に気づかない)インストール時のミスを検出できます — capability の不一致はクラッシュせず、該当サーフェスが静かにスキップされるだけです。
215
+
216
+ スタンドアロンプラグインでは、`package.json#name` で宣言している npm パッケージ名をここに設定します:
217
+
218
+ ```ts
219
+ return definePlugin({
220
+ name: 'site-verification',
221
+ packageName: '@ishinao/ampless-plugin-site-verification',
222
+ apiVersion: 1,
223
+ // ...
224
+ })
225
+ ```
226
+
227
+ サイトローカルプラグインは不要です — 未設定のままにするとクロスチェックはスキップされます(Phase 5 より前のプラグインとの後方互換)。
228
+
229
+ ### `package.json` の静的マニフェスト(スタンドアロンプラグインのみ)
230
+
231
+ クロスチェックが静的マニフェストを見つけるには、公開パッケージに 2 つの条件が必要です:
232
+
233
+ 1. `package.json#amplessPlugin` がファクトリの戻り値と同じフィールドを宣言している:
234
+
235
+ ```json
236
+ "amplessPlugin": {
237
+ "apiVersion": 1,
238
+ "name": "site-verification",
239
+ "trustLevel": "untrusted",
240
+ "capabilities": ["publicHead", "adminSettings"],
241
+ "displayName": { "en": "Site verification", "ja": "サイト所有権確認" }
242
+ }
243
+ ```
244
+
245
+ 2. `package.json#exports` が `./package.json` を明示的に公開している:
246
+
247
+ ```json
248
+ "exports": {
249
+ ".": {
250
+ "import": "./dist/index.js",
251
+ "types": "./dist/index.d.ts"
252
+ },
253
+ "./package.json": "./package.json"
254
+ }
255
+ ```
256
+
257
+ これがないと、Node のパッケージエクスポートの制約により `import.meta.resolve('<pkg>/package.json')` が `ERR_PACKAGE_PATH_NOT_EXPORTED` で拒否され、runtime はクロスチェックを静かにスキップします(プラグインは動きますが、インストール時ガードは機能しません)。
258
+
259
+ `create-ampless plugin --standalone` スキャフォールドは両方を正しく生成します。また `package.json#keywords` に `"ampless-plugin"` を加えておくことをお勧めします — npm 検索で ampless プラグインを探す際の慣例です。
260
+
261
+ runtime がチェックする内容:
262
+
263
+ | フィールド | 不一致時の動作 |
264
+ |---|---|
265
+ | `apiVersion`(ファクトリ vs マニフェスト) | 起動時に **throws** |
266
+ | `apiVersion`(runtime がサポートするバージョンより新しい) | 起動時に **throws** |
267
+ | `name` | dev で warn |
268
+ | `trustLevel` | dev で warn |
269
+ | `capabilities`(集合比較) | dev で warn |
270
+
271
+ 起動を中断するのは 2 つの `apiVersion` ケースのみです — これは runtime が対応していない ampless API でビルドされたプラグインのロードを防ぎます。その他はすべて開発者向けの警告であり、runtime のブロックではありません。
272
+
135
273
  ---
136
274
 
137
275
  ## 4. `trust_level` の選び方
@@ -156,7 +294,13 @@ trust level がズレているプラグインは「権限不足で sliently fail
156
294
 
157
295
  ## 5. 同期サーフェス
158
296
 
159
- **公開 Next.js プロセス** (サイト訪問者のリクエストスレッド) 内で実行されます。AWS に対しては純 (IAM・ネットワーク無し) で、同期実行です。
297
+ **公開 Next.js プロセス** (サイト訪問者のリクエストスレッド) 内で同期実行
298
+ されます。これらの surface はネットワーク I/O を意図して設計していません。
299
+ async result path が無いので `publicHead` 内で `await fetch(...)` すると
300
+ SSR がデッドラインなしでブロックします。ネットワーク呼び出しが必要な副作用
301
+ は `hooks` (trusted Lambda、async) でやってください。公開プロセス内の
302
+ プラグインコードは公開ページ用の IAM ロールで動きます — 特別な AWS 権限は
303
+ ありません。
160
304
 
161
305
  | サーフェス | 戻り値 | 用途 |
162
306
  |---|---|---|
@@ -181,7 +325,14 @@ trust level がズレているプラグインは「権限不足で sliently fail
181
325
 
182
326
  ## 6. Descriptor リファレンス
183
327
 
184
- `publicHead` と `publicBodyEnd` は **descriptor オブジェクト** を返します。`ReactNode` ではありません。runtime が validation してから React 要素を組み立てます。これが untrusted コードを公開描画パスで動かすための安全境界です。
328
+ `publicHead` と `publicBodyEnd` は **descriptor オブジェクト** を返します。
329
+ `ReactNode` ではありません。runtime が validation (URL scheme denylist /
330
+ attrs allowlist / id dedup) してから React 要素を組み立てます。これは
331
+ プラグインが寄与できる **HTML 出力** の安全境界であって、プラグイン本体の
332
+ コード実行を縛るものではない、という点に注意 — プラグインは普通の
333
+ TypeScript としてサイトと同一の Node プロセスで動きます。descriptor
334
+ パイプラインは、JS sandbox に頼らずに公開ページの面を狭く / 監査可能に
335
+ 保つための仕組みです。
185
336
 
186
337
  ### 共通の variant
187
338
 
@@ -319,6 +470,26 @@ export default function schemaJsonldPlugin() {
319
470
 
320
471
  テーマの `pages/post.tsx` が `ampless.publicBodyForPost(post)` を呼び、返された descriptor を描画します。runtime は自動 escape した body を持つ `<script type="application/ld+json">` 要素をページに挿入します。
321
472
 
473
+ ### クライアントサイドの DOM 操作はしない
474
+
475
+ `publicHead` または `publicBodyEnd` から返したインラインスクリプトは、React がページを hydrate する前、HTML のパース中に実行されます。**React が管理するサブツリー内の見える DOM を操作してはいけません** — hydration が走ると React は仮想 DOM と合わないツリーを検出し、`Hydration failed because the server rendered HTML didn't match the client` エラーを投げてサブツリーをゼロから再生成します。挿入したノードは消えてしまいます。
476
+
477
+ React 19 はさらに、クライアントコンポーネントのレンダー中に出会った `<script>` タグの実行を拒否するため、`document.body.append(myNewElement)` のようなスクリプトはそもそも発火しないことがあります。
478
+
479
+ **安全なパターン**:
480
+
481
+ - **グローバル状態 / 非 DOM の副作用**: `window.dataLayer` への push、設定オブジェクトのセット、アナリティクス SDK のインスタンス化。`@ampless/plugin-analytics-ga4`・`@ampless/plugin-gtm`・`@ampless/plugin-plausible` はこの方法を使っています。
482
+ - **外部ウィジェットローダー**: 自前の独立したコンテナを管理するサードパーティスクリプトの読み込み(Crisp・Intercom・Drift など)。ウィジェットの shadow DOM / fixed-position オーバーレイは React のツリーの外にあり、hydration と競合しません。
483
+ - **SSR 専用の descriptor**: `meta` / `link` / `noscript`(`publicBodyEnd` では `iframe` も)を返す — runtime がサーバーサイドで描画するため、最初から React の仮想 DOM の一部になります。
484
+
485
+ **避けるべきパターン**:
486
+
487
+ - `document.createElement('div')` + `document.body.append(...)`
488
+ - テーマが描画した要素のクラス / 属性 / テキストコンテンツの変更
489
+ - クライアントサイドで `#post-body` のような要素を読み取って投稿単位の HTML を挿入する — 現在 `publicHead`-for-post に相当するサーフェスはなく、サーバーレンダリング済みのサブツリーをクライアントサイドで書き換えると hydration と競合します
490
+
491
+ 投稿単位の見える出力には、`publicBodyForPost` 経由で JSON-LD を返す(§6 の `PublicPostBodyDescriptor` 参照)か、テーマ自身のテンプレートを使ってください。サーバーサイドでの投稿単位 HTML 注入はロードマップに載っています。
492
+
322
493
  ---
323
494
 
324
495
  ## 7. 非同期イベントフック
@@ -591,7 +762,82 @@ it('admin が空文字保存した場合は空配列', () => {
591
762
 
592
763
  ---
593
764
 
594
- ## 14. 質問先
765
+ ## 14. クイックスタート: `create-ampless` でスキャフォールド
766
+
767
+ アイデアから動くプラグインへの最速ルートとして、`create-ampless` CLI には `plugin <name>` サブコマンドが用意されています:
768
+
769
+ ```bash
770
+ # サイトローカル: 現在の ampless サイトのルートで実行
771
+ # plugins/<name>/index.ts を生成する
772
+ npx create-ampless@latest plugin my-thing \
773
+ --trust-level untrusted \
774
+ --capabilities publicHead,adminSettings
775
+
776
+ # スタンドアロン npm パッケージ: ./<dir>/ を生成
777
+ # package.json / tsconfig.json / tsup.config.ts / README + .ja /
778
+ # CHANGELOG / .gitignore / src/index.ts + src/index.test.ts を含む
779
+ # 新しいパッケージディレクトリを置きたい場所で実行する
780
+ npx create-ampless@latest plugin @myscope/ampless-plugin-thing \
781
+ --standalone \
782
+ --trust-level untrusted \
783
+ --capabilities publicHead,adminSettings \
784
+ --description "このプラグインが何をするか"
785
+ ```
786
+
787
+ スタンドアロンスキャフォールドには Phase 5 のクロスチェックに必要なものがすべて含まれます: `package.json#amplessPlugin`、`./package.json` サブパスエクスポート、`packageName` ファクトリフィールド、`ampless-plugin` 検索キーワード、そして `pnpm install && pnpm test && pnpm build` が生成直後にクリーンに通る最小の vitest サンプル。
788
+
789
+ どちらのモードも、フラグなしの位置引数呼び出し (`npx create-ampless@latest plugin`) で @clack のプロンプト UI を使ったインタラクティブモードに切り替えられます。
790
+
791
+ ### スタンドアロンプラグインの公開
792
+
793
+ ```bash
794
+ cd ampless-plugin-thing
795
+ pnpm install
796
+ pnpm test
797
+ pnpm build
798
+ pnpm publish --access public --tag alpha
799
+ ```
800
+
801
+ スコープ付き名前 (`@scope/...`) には `--access public` が必須です。`--tag alpha` は現在の ampless プレリリースサイクルに合わせています — 安定 major に達したら外してください。
802
+
803
+ `npm publish` が返った直後に `npm install <pkg>@alpha` で 404 が出ることがあります(CDN とレジストリレプリカの伝播遅延)。その場合は 1〜2 分待ってリトライしてください — `npm view <pkg>@alpha version` がレジストリで見えていることは必要条件ですが十分条件ではありません。
804
+
805
+ ### パッケージの命名
806
+
807
+ npm の慣例として、スコープと `ampless-plugin-` プレフィックスを除いた短い識別子が `AmplessPlugin.name` になります:
808
+
809
+ | npm パッケージ | `AmplessPlugin.name` |
810
+ |---|---|
811
+ | `@ampless/plugin-gtm` | `gtm` |
812
+ | `@scope/ampless-plugin-clarity` | `clarity` |
813
+ | `ampless-plugin-readme-toc` | `readme-toc` |
814
+ | `weird-name-no-prefix` | `weird-name-no-prefix` |
815
+
816
+ スキャフォールドはこのストリッピングを自動で行います。スキャフォールドを使わない場合も同じマッピングで手書きしてください。パッケージの静的マニフェストとファクトリで `name` が一致しない場合はインストール時クロスチェックが警告します。
817
+
818
+ ### サイトローカルの後作業
819
+
820
+ サイトローカルのスキャフォールド後:
821
+
822
+ ```ts
823
+ // cms.config.ts
824
+ import myThingPlugin from './plugins/my-thing'
825
+
826
+ export default defineConfig({
827
+ // ...
828
+ plugins: [
829
+ myThingPlugin(),
830
+ ],
831
+ })
832
+ ```
833
+
834
+ スキャフォールドの最後にこのスニペットが表示されます — `cms.config.ts` にコピーしてプラグインを有効化してください。
835
+
836
+ `update-ampless` は `plugins/` ディレクトリを決して変更しません(PROTECTED 扱い)。ampless のアップグレードをまたいでも安全に残ります。
837
+
838
+ ---
839
+
840
+ ## 15. 質問先
595
841
 
596
842
  - アーキテクチャ / 設計の質問 → [`docs/architecture/08-plugin-architecture.ja.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.ja.md)
597
843
  - ファーストパーティプラグインの bug → `heavymoons/ampless` にプラグインの package 名つきで issue
@@ -21,8 +21,62 @@ this page is the hands-on companion.
21
21
 
22
22
  ---
23
23
 
24
+ ## 0. Theme vs Plugin Boundary
25
+
26
+ ampless ships both themes and plugins. Picking the right tool keeps
27
+ your code where future-you (and other site authors) will look for it.
28
+
29
+ | Want to change... | Use a theme | Use a plugin |
30
+ |---|---|---|
31
+ | Layout, typography, colour, per-route UI | ✓ | |
32
+ | Custom components on home / post / tag pages | ✓ | |
33
+ | Admin-editable settings exposed to non-developers | | ✓ (`adminSettings`) |
34
+ | Background work after content events (RSS, search index, webhooks) | | ✓ (`eventHooks`) |
35
+ | Trusted side effects (S3 writes, external API push) | | ✓ (`writePublicAsset` + `trusted`) |
36
+ | Theme-independent `<head>` / `<body>` injection (analytics, consent) | | ✓ (`publicHead` / `publicBodyEnd`) |
37
+ | Per-post machine-readable metadata (JSON-LD, etc.) | | ✓ (`schema` via `publicBodyForPost`) |
38
+ | Code you want to share across multiple ampless sites | | ✓ (publish as npm package) |
39
+
40
+ Rule of thumb:
41
+
42
+ - Theme = **what the page looks like**. Read-only at render time.
43
+ - Plugin = **what happens beyond rendering**: admin-editable config,
44
+ background processing, theme-agnostic injection, machine-readable
45
+ metadata, cross-site reusability.
46
+
47
+ The two boundaries that frequently catch new authors:
48
+
49
+ - **Storage / DynamoDB / external API writes belong in a plugin**, never
50
+ a theme. Themes only read.
51
+ - **A feature you want admins to toggle from `/admin/plugins` belongs
52
+ in a plugin**, even if its visible effect is purely cosmetic. Themes
53
+ carry their own settings, but those are theme-display settings, not
54
+ site-operational ones.
55
+
56
+ Some features genuinely sit at the boundary — see [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md)
57
+ for the longer discussion.
58
+
59
+ ---
60
+
24
61
  ## 1. What a plugin can do
25
62
 
63
+ ampless plugins are written in one of three places — pick the one
64
+ that matches how widely you want the code shared:
65
+
66
+ | Where | Use when | Lives in |
67
+ |---|---|---|
68
+ | **First-party** | Building on the ampless core for everyone | `packages/plugin-*/` in the ampless monorepo |
69
+ | **Site-local** | Site-specific customisation, no separate publish | `plugins/<name>/` inside your site repo |
70
+ | **External npm package** | Sharing with other sites, ready to `npm publish` | A standalone repo (`@scope/ampless-plugin-foo`) |
71
+
72
+ All three forms call the same `definePlugin({...})` factory and use the
73
+ same surfaces. The differences are packaging, distribution, and the
74
+ opt-in install-time validation that the static `package.json#amplessPlugin`
75
+ manifest enables (see §3 below).
76
+
77
+ §14 below has a one-command scaffold (`npx create-ampless plugin <name>`)
78
+ that produces ready-to-use boilerplate for either of the latter two.
79
+
26
80
  An ampless plugin is a TypeScript module that returns an
27
81
  `AmplessPlugin` object. It plugs into one or more of the following
28
82
  surfaces:
@@ -38,36 +92,72 @@ surfaces:
38
92
  | `hooks` | trust_level-matched processor Lambda | async, on SQS event | Existing |
39
93
  | `settings.public` | `/admin/plugins` form | declarative manifest | 2 |
40
94
 
41
- What a plugin **cannot** do today (without privileged tier work, see
42
- roadmap):
43
-
44
- - Inject arbitrary `ReactNode` into pages descriptor variants only.
45
- - Open a TCP socket in the public Next.js process. Trusted Lambdas
46
- have outbound HTTP only.
47
- - Add admin routes / server routes / content fields those
48
- capabilities are reserved for Phase 6b.
49
- - Read or write secrets. The `secretSettings` capability is reserved
50
- for Phase 6a.
95
+ A few surfaces don't exist yet they're reserved for later phases
96
+ and aren't shaped by `definePlugin` today:
97
+
98
+ - **Arbitrary `ReactNode` injection into pages.** The sync render
99
+ surfaces (`publicHead`, `publicBodyEnd`, `publicBodyForPost`) only
100
+ return descriptor variants. The descriptor validator is the safety
101
+ boundary for what the runtime renders into the page; it isn't a JS
102
+ sandbox around the plugin itself. (Plugins run as ordinary
103
+ TypeScript in the same Node process as the rest of the site.)
104
+ - **Network inside the sync render surfaces.** The sync surfaces are
105
+ shaped for declarative output — they don't take a Promise and there's
106
+ no async result path, so doing `await fetch(...)` inside `publicHead`
107
+ blocks SSR with no way to surface a deadline. Trusted Lambdas
108
+ (`hooks`) are where the runtime expects outbound HTTP. Background
109
+ work belongs there.
110
+ - **Admin routes / server routes / content fields.** Reserved for
111
+ Phase 6b.
112
+ - **Secrets.** The `secretSettings` capability is reserved for
113
+ Phase 6a; nothing in `settings.public` should be a credential.
51
114
 
52
115
  ---
53
116
 
54
117
  ## 2. Minimum file layout
55
118
 
56
- A plugin can ship as a tiny npm package or live in your site's
57
- monorepo. The on-disk shape is the same either way:
119
+ The fastest way to create a plugin is to scaffold one:
120
+
121
+ ```bash
122
+ # Site-local (writes plugins/<name>/index.ts in the current ampless site)
123
+ npx create-ampless@latest plugin my-thing
124
+
125
+ # Standalone npm package (writes ./<name>/ ready for `npm publish`)
126
+ npx create-ampless@latest plugin @myscope/ampless-plugin-my-thing --standalone
127
+ ```
128
+
129
+ §14 covers the full flow. The rest of this section explains what the
130
+ generated files mean, so you can author one by hand if you prefer.
131
+
132
+ ### Site-local
133
+
134
+ ```
135
+ plugins/
136
+ my-thing/
137
+ index.ts # the factory; that's the whole plugin
138
+ ```
139
+
140
+ The site's `package.json` / `tsconfig.json` already cover compilation —
141
+ nothing else needs to ship. Register it from `cms.config.ts` with a
142
+ relative import.
143
+
144
+ ### Standalone npm package
58
145
 
59
146
  ```
60
- my-plugin/
147
+ ampless-plugin-my-thing/
61
148
  package.json
62
149
  tsconfig.json
63
150
  tsup.config.ts
151
+ README.md
152
+ CHANGELOG.md
64
153
  src/
65
154
  index.ts
66
155
  index.test.ts
67
156
  ```
68
157
 
69
158
  See `packages/plugin-rss/` and `packages/plugin-analytics-ga4/` in
70
- this repo for working examples.
159
+ this repo for working first-party examples — the standalone scaffold
160
+ mirrors their layout.
71
161
 
72
162
  The bare-minimum `src/index.ts`:
73
163
 
@@ -108,6 +198,7 @@ That's it. Restart `npm run dev`, view source on any page, and the
108
198
  ```ts
109
199
  interface AmplessPlugin {
110
200
  name: string // package-like identifier, e.g. 'analytics-ga4'
201
+ packageName?: string // npm package name for install-time cross-check
111
202
  apiVersion: 1 // bump only when the contract changes
112
203
  trust_level: 'untrusted' | 'trusted' | 'privileged'
113
204
  instanceId?: string // namespace for multi-instance installs
@@ -159,6 +250,87 @@ What `/admin/plugins` shows as the panel heading. A plain string is
159
250
  fine for single-locale plugins; the per-locale map form
160
251
  (`{ en: 'GA4', ja: 'GA4' }`) reads from the admin's active locale.
161
252
 
253
+ ### `packageName`
254
+
255
+ Optional. When set, the runtime resolves
256
+ `<packageName>/package.json` at startup and cross-checks the static
257
+ `amplessPlugin` block there against the factory return value. This
258
+ catches install-time mistakes a plugin author would otherwise only see
259
+ at runtime (or never — capability mismatches don't crash, they just
260
+ quietly skip the surface).
261
+
262
+ For standalone plugins, set this to the npm package name your
263
+ `package.json#name` declares:
264
+
265
+ ```ts
266
+ return definePlugin({
267
+ name: 'site-verification',
268
+ packageName: '@ishinao/ampless-plugin-site-verification',
269
+ apiVersion: 1,
270
+ // ...
271
+ })
272
+ ```
273
+
274
+ Site-local plugins don't need it — leave it unset and the cross-check
275
+ is skipped (backward compatible, identical to plugins predating Phase
276
+ 5).
277
+
278
+ ### Static manifest in `package.json` (standalone plugins only)
279
+
280
+ For the cross-check to find the static manifest, two things must be
281
+ true about your published package:
282
+
283
+ 1. `package.json#amplessPlugin` declares the same fields the factory
284
+ returns:
285
+
286
+ ```json
287
+ "amplessPlugin": {
288
+ "apiVersion": 1,
289
+ "name": "site-verification",
290
+ "trustLevel": "untrusted",
291
+ "capabilities": ["publicHead", "adminSettings"],
292
+ "displayName": { "en": "Site verification", "ja": "サイト所有権確認" }
293
+ }
294
+ ```
295
+
296
+ 2. `package.json#exports` explicitly exposes `./package.json`:
297
+
298
+ ```json
299
+ "exports": {
300
+ ".": {
301
+ "import": "./dist/index.js",
302
+ "types": "./dist/index.d.ts"
303
+ },
304
+ "./package.json": "./package.json"
305
+ }
306
+ ```
307
+
308
+ Without this, Node's package-exports gating rejects
309
+ `import.meta.resolve('<pkg>/package.json')` with
310
+ `ERR_PACKAGE_PATH_NOT_EXPORTED` and the runtime silently skips the
311
+ cross-check (your plugin still runs; you just don't get the
312
+ install-time guard).
313
+
314
+ The `create-ampless plugin --standalone` scaffold ships both correctly.
315
+ Add a `"ampless-plugin"` entry to your `package.json#keywords` while
316
+ you're there — it's the convention used by npm searches surfacing
317
+ ampless plugins.
318
+
319
+ What the runtime checks:
320
+
321
+ | Field | Mismatch behaviour |
322
+ |---|---|
323
+ | `apiVersion` (factory vs manifest) | **Throws** at startup |
324
+ | `apiVersion` (newer than runtime supports) | **Throws** at startup |
325
+ | `name` | Warns in dev |
326
+ | `trustLevel` | Warns in dev |
327
+ | `capabilities` (set comparison) | Warns in dev |
328
+
329
+ The two `apiVersion` cases are the only failure that aborts startup —
330
+ they protect against loading a plugin built for an ampless API the
331
+ runtime can't speak. Everything else is a developer-visible warning,
332
+ not a runtime block.
333
+
162
334
  ---
163
335
 
164
336
  ## 4. Picking a `trust_level`
@@ -194,8 +366,12 @@ redeploying.
194
366
  ## 5. Sync surfaces
195
367
 
196
368
  These run inside the **public Next.js process** (the site visitor's
197
- request thread). They are pure with respect to AWS — no IAM, no
198
- network and execute synchronously.
369
+ request thread) and execute synchronously. They aren't designed to
370
+ do network I/O: there's no async result path, so an `await fetch(...)`
371
+ inside `publicHead` would block SSR with no deadline. Side effects
372
+ that need network calls go in `hooks` (trusted Lambdas, async).
373
+ Public-process plugin code runs with the public-page IAM role — no
374
+ elevated AWS access.
199
375
 
200
376
  | Surface | Returns | Use case |
201
377
  |---|---|---|
@@ -222,9 +398,14 @@ The `ctx` object carries:
222
398
  ## 6. Descriptor reference
223
399
 
224
400
  `publicHead` and `publicBodyEnd` return **descriptor objects**, not
225
- `ReactNode`. The runtime validates them, then builds the React
226
- elements itself. This is the safety boundary that lets ampless run
227
- untrusted code in the public render path.
401
+ `ReactNode`. The runtime validates them (URL scheme denylist, attrs
402
+ allowlist, dedup by id) and then builds the React elements itself.
403
+ This is the safety boundary for the **HTML output** a plugin can
404
+ contribute — it bounds what the runtime emits, not what the plugin's
405
+ own code does. Plugins run as ordinary TypeScript in the same Node
406
+ process as the rest of the site; the descriptor pipeline keeps the
407
+ public-page surface narrow and auditable without depending on a JS
408
+ sandbox.
228
409
 
229
410
  ### Common variants
230
411
 
@@ -393,6 +574,49 @@ and renders the returned descriptors. The runtime inserts a
393
574
  `<script type="application/ld+json">` element with the auto-escaped
394
575
  body into the page.
395
576
 
577
+ ### Client-side DOM mutation: don't
578
+
579
+ Inline scripts you return from `publicHead` or `publicBodyEnd` execute
580
+ during HTML parsing, before React hydrates the page. **They must not
581
+ mutate visible DOM inside a React-managed subtree** — when hydration
582
+ runs, React sees a tree that doesn't match its virtual DOM, throws a
583
+ `Hydration failed because the server rendered HTML didn't match the
584
+ client` error, and regenerates the subtree from scratch. Your inserted
585
+ nodes get wiped.
586
+
587
+ React 19 additionally refuses to execute `<script>` tags it
588
+ encounters while rendering a client component, so a script that did
589
+ something like `document.body.append(myNewElement)` may not even fire.
590
+
591
+ **Safe patterns**:
592
+
593
+ - **Global state / non-DOM side effects**: push to `window.dataLayer`,
594
+ set a config object, instantiate an analytics SDK. This is what
595
+ `@ampless/plugin-analytics-ga4`, `@ampless/plugin-gtm`, and
596
+ `@ampless/plugin-plausible` do.
597
+ - **External widget loaders**: load a third-party script that manages
598
+ its own isolated container (Crisp, Intercom, Drift). The widget's
599
+ shadow DOM / fixed-position overlay lives outside React's tree and
600
+ doesn't conflict with hydration.
601
+ - **SSR-only descriptors**: return `meta` / `link` / `noscript` (and,
602
+ on `publicBodyEnd`, `iframe`) — the runtime renders these
603
+ server-side and they're part of React's virtual DOM from the start.
604
+
605
+ **Patterns to avoid**:
606
+
607
+ - `document.createElement('div')` + `document.body.append(...)`
608
+ - Modifying classes / attributes / text content of elements rendered
609
+ by the theme
610
+ - Inserting per-post HTML by reading something like `#post-body`
611
+ client-side — there's no `publicHead`-for-post analogue today, and
612
+ any client-side rewrite of a server-rendered subtree races against
613
+ hydration
614
+
615
+ For visible per-post output, return JSON-LD via `publicBodyForPost`
616
+ (see §6's `PublicPostBodyDescriptor`) or stick with the theme's own
617
+ templates. A future ampless capability for server-side per-post HTML
618
+ injection is on the roadmap.
619
+
396
620
  ---
397
621
 
398
622
  ## 7. Async event hooks
@@ -757,7 +981,100 @@ Worked examples to crib from:
757
981
 
758
982
  ---
759
983
 
760
- ## 14. Where to ask
984
+ ## 14. Quickstart: scaffolding with `create-ampless`
985
+
986
+ For a fast path from idea to working plugin, the `create-ampless`
987
+ CLI ships a `plugin <name>` subcommand:
988
+
989
+ ```bash
990
+ # Site-local: scaffolds plugins/<name>/index.ts inside the current
991
+ # ampless site. Run from the site repo root.
992
+ npx create-ampless@latest plugin my-thing \
993
+ --trust-level untrusted \
994
+ --capabilities publicHead,adminSettings
995
+
996
+ # Standalone npm package: scaffolds ./<dir>/ with package.json,
997
+ # tsconfig.json, tsup.config.ts, README + .ja, CHANGELOG, .gitignore,
998
+ # and src/index.ts + src/index.test.ts. Run from wherever you want
999
+ # the new package directory to land.
1000
+ npx create-ampless@latest plugin @myscope/ampless-plugin-thing \
1001
+ --standalone \
1002
+ --trust-level untrusted \
1003
+ --capabilities publicHead,adminSettings \
1004
+ --description "What this plugin does"
1005
+ ```
1006
+
1007
+ Standalone scaffolds include everything Phase 5's cross-check needs:
1008
+ `package.json#amplessPlugin`, the `./package.json` subpath export,
1009
+ the `packageName` factory field, the `ampless-plugin` discovery
1010
+ keyword, and a minimal vitest sample so `pnpm install && pnpm test &&
1011
+ pnpm build` runs clean on the freshly generated directory.
1012
+
1013
+ Both modes also accept a positional flag-less invocation (`npx
1014
+ create-ampless@latest plugin`) that walks you through the same
1015
+ questions interactively via the @clack prompt UI.
1016
+
1017
+ ### Publishing a standalone plugin
1018
+
1019
+ ```bash
1020
+ cd ampless-plugin-thing
1021
+ pnpm install
1022
+ pnpm test
1023
+ pnpm build
1024
+ pnpm publish --access public --tag alpha
1025
+ ```
1026
+
1027
+ `--access public` is mandatory for scoped names (`@scope/...`).
1028
+ `--tag alpha` matches the current ampless pre-release cadence — drop
1029
+ it once the package reaches a stable major.
1030
+
1031
+ There's a publish-to-install lag of "seconds to minutes" before
1032
+ `npm install <pkg>@alpha` picks up a fresh publish (CDN + registry
1033
+ replica propagation). If `npm install` 404s right after `npm publish`
1034
+ returns, wait 1-2 minutes and retry — `npm view <pkg>@alpha version`
1035
+ visible in the registry is necessary but sometimes not sufficient.
1036
+
1037
+ ### Naming the package
1038
+
1039
+ By npm convention, scope and the conventional `ampless-plugin-`
1040
+ prefix collapse to a short identifier for `AmplessPlugin.name`:
1041
+
1042
+ | npm package | `AmplessPlugin.name` |
1043
+ |---|---|
1044
+ | `@ampless/plugin-gtm` | `gtm` |
1045
+ | `@scope/ampless-plugin-clarity` | `clarity` |
1046
+ | `ampless-plugin-readme-toc` | `readme-toc` |
1047
+ | `weird-name-no-prefix` | `weird-name-no-prefix` |
1048
+
1049
+ The scaffold does this stripping automatically. Hand-author the same
1050
+ mapping if you skip the scaffold; the install-time cross-check warns
1051
+ on a mismatch between the package's static manifest and the factory.
1052
+
1053
+ ### Site-local follow-up
1054
+
1055
+ After a site-local scaffold:
1056
+
1057
+ ```ts
1058
+ // cms.config.ts
1059
+ import myThingPlugin from './plugins/my-thing'
1060
+
1061
+ export default defineConfig({
1062
+ // ...
1063
+ plugins: [
1064
+ myThingPlugin(),
1065
+ ],
1066
+ })
1067
+ ```
1068
+
1069
+ The scaffold prints this snippet at the end of its run — copy it into
1070
+ `cms.config.ts` to activate the plugin.
1071
+
1072
+ `update-ampless` never touches the `plugins/` directory (it's
1073
+ PROTECTED), so the scaffolded code is safe across ampless upgrades.
1074
+
1075
+ ---
1076
+
1077
+ ## 15. Where to ask
761
1078
 
762
1079
  - Architecture / design questions → [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md)
763
1080
  - Bugs in a first-party plugin → file an issue against
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampless",
3
- "version": "1.0.0-alpha.22",
3
+ "version": "1.0.0-alpha.24",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",