create-ampless 1.0.0-alpha.99 → 1.0.0-beta.149
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/README.ja.md +4 -4
- package/README.md +4 -4
- package/dist/index.js +198 -29
- package/dist/templates/_shared/README.ja.md +4 -4
- package/dist/templates/_shared/README.md +4 -4
- package/dist/templates/_shared/amplify/backend.ts +1 -1
- package/dist/templates/_shared/amplify/data/get-published-post.js +13 -1
- package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +15 -3
- package/dist/templates/_shared/amplify/data/list-published-posts.js +26 -15
- package/dist/templates/_shared/amplify/events/dispatcher/handler.ts +11 -1
- package/dist/templates/_shared/amplify/secrets/encryption-key.ts +2 -2
- package/dist/templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx +13 -0
- package/dist/templates/_shared/app/(admin)/admin/layout.tsx +2 -1
- package/dist/templates/_shared/app/(admin)/admin/preview/route.tsx +4 -0
- package/dist/templates/_shared/cms.config.ts +3 -0
- package/dist/templates/_shared/docs/plugin-author-guide.ja.md +841 -31
- package/dist/templates/_shared/docs/plugin-author-guide.md +1147 -80
- package/dist/templates/_shared/package.json +17 -16
- package/dist/templates/_shared/plugins/README.ja.md +1 -1
- package/dist/templates/_shared/plugins/README.md +1 -1
- package/dist/templates/blog/pages/home.tsx +5 -5
- package/dist/templates/blog/pages/post.tsx +5 -3
- package/dist/templates/corporate/pages/home.tsx +5 -5
- package/dist/templates/corporate/pages/post.tsx +5 -3
- package/dist/templates/dads/pages/home.tsx +5 -5
- package/dist/templates/dads/pages/post.tsx +5 -3
- package/dist/templates/docs/pages/post.tsx +5 -3
- package/dist/templates/landing/pages/home.tsx +5 -5
- package/dist/templates/landing/pages/post.tsx +5 -3
- package/dist/templates/minimal/pages/post.tsx +5 -3
- package/dist/templates/plugin-local/README.md +1 -1
- package/dist/templates/plugin-standalone/README.ja.md +3 -3
- package/dist/templates/plugin-standalone/README.md +3 -3
- package/package.json +1 -1
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
設計の経緯と背景は [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md) に集約。本ページはその実装ハンドブック側です。
|
|
15
15
|
|
|
16
|
+
> **ポジショニング**: ampless はエンジニア向けのカスタマイズベース CMS です — プラグインは**サイトエンジニアが `cms.config.ts` でインポート + 設定する npm dep** です。エンジニアは他の npm ライブラリと同様にインストール前に各 dep を審査します(Astro integration / Next.js plugin パターン)。このガイドで説明する trust framework(`trust_level`、capabilities、IAM スコープ付き Lambda)は v1 において**ファーストパーティプラグインの code organization**として実装されています — どの trust 階層の Lambda が各イベントフックを実行するか、各階層が保有する IAM 権限、そして狭い範囲のポイントでのみ runtime hard gate を適用する(最も重要: `settings.secret` は `trust_level: 'trusted'` を要求、シークレット読み取りに trusted Lambda の IAM 権限が必要なため)。ほとんどの capability 宣言は runtime の hard gate ではなく soft warning + admin ラベル + 将来の allow-list surface です。任意の未審査サードパーティ untrusted プラグインを自動的に安全に動かすための marketplace-grade automatic sandbox としては**設計されていません**。マーケットプレイス + ランタイムサンドボックスは v2.0+ の探索であり、v1 の保証ではありません。詳細な trust model は [`docs/architecture/08-plugin-architecture.md#trust-model-v1-scope`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#trust-model-v1-scope) を参照してください。
|
|
17
|
+
|
|
16
18
|
---
|
|
17
19
|
|
|
18
20
|
## 0. テーマとプラグインの境界線
|
|
@@ -57,7 +59,7 @@ ampless プラグインは 3 つのいずれかの場所に書きます — コ
|
|
|
57
59
|
|
|
58
60
|
3 つの形式はすべて同じ `definePlugin({...})` ファクトリを呼び出し、同じサーフェスを使います。違いはパッケージング・配布方法、および静的 `package.json#amplessPlugin` マニフェストが有効にするインストール時バリデーションのオプトインです(§3 参照)。
|
|
59
61
|
|
|
60
|
-
§14 には一行のスキャフォールドコマンド (`npx create-ampless plugin <name>`) があり、後者 2 つのどちらにも即使えるボイラープレートを生成します。
|
|
62
|
+
§14 には一行のスキャフォールドコマンド (`npx create-ampless@beta plugin <name>`) があり、後者 2 つのどちらにも即使えるボイラープレートを生成します。
|
|
61
63
|
|
|
62
64
|
ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScript モジュールです。以下のうち 1 つ以上のサーフェスにフックします:
|
|
63
65
|
|
|
@@ -99,10 +101,10 @@ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScrip
|
|
|
99
101
|
|
|
100
102
|
```bash
|
|
101
103
|
# サイトローカル (現在の ampless サイトに plugins/<name>/index.ts を生成)
|
|
102
|
-
npx create-ampless@
|
|
104
|
+
npx create-ampless@beta plugin my-thing
|
|
103
105
|
|
|
104
106
|
# スタンドアロン npm パッケージ (`npm publish` 向けの ./<name>/ を生成)
|
|
105
|
-
npx create-ampless@
|
|
107
|
+
npx create-ampless@beta plugin @myscope/ampless-plugin-my-thing --standalone
|
|
106
108
|
```
|
|
107
109
|
|
|
108
110
|
全体の手順は §14 を参照してください。このセクションの残りでは生成されるファイルの意味を説明します — 手書きしたい場合はここを読めば把握できます。
|
|
@@ -172,7 +174,7 @@ export default defineConfig({
|
|
|
172
174
|
interface AmplessPlugin {
|
|
173
175
|
name: string // パッケージ風の識別子。例: 'analytics-ga4'
|
|
174
176
|
packageName?: string // インストール時のクロスチェック用 npm パッケージ名
|
|
175
|
-
apiVersion: 1 //
|
|
177
|
+
apiVersion: 1 // 現状唯一の有効値
|
|
176
178
|
trust_level: 'untrusted' | 'trusted' | 'privileged'
|
|
177
179
|
instanceId?: string // 複数インストール時の namespace
|
|
178
180
|
displayName?: LocalizedString // admin UI ラベル
|
|
@@ -185,7 +187,11 @@ interface AmplessPlugin {
|
|
|
185
187
|
publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
|
|
186
188
|
publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
|
|
187
189
|
ogImage?: OgImageConfig
|
|
188
|
-
settings?: {
|
|
190
|
+
settings?: {
|
|
191
|
+
public?: readonly PluginSettingField[]
|
|
192
|
+
secret?: readonly PluginSecretField[]
|
|
193
|
+
version?: number // Phase 1 reservation; runtime ignores
|
|
194
|
+
}
|
|
189
195
|
}
|
|
190
196
|
```
|
|
191
197
|
|
|
@@ -197,6 +203,12 @@ interface AmplessPlugin {
|
|
|
197
203
|
|
|
198
204
|
今日は 1 のみ。将来の互換性破壊バージョンが出たらこの数字が bump され、runtime は未知の値を黙って bind せず拒否します。
|
|
199
205
|
|
|
206
|
+
**現状唯一の有効値は `apiVersion: 1` です。** literal type は他の値を compile-time に拒否し、`package.json#amplessPlugin.apiVersion` が `definePlugin()` の戻り値と異なる場合、または runtime の `SUPPORTED_API_VERSION` を超える場合、runtime は hard-throw します。
|
|
207
|
+
|
|
208
|
+
Phase 1 の compat-break reservation すべて(PR #220、#222、#230、#232、#234)は `apiVersion: 1` 内に収まります。これらをプラグインで declare するかどうかは契約バージョンに影響しません。
|
|
209
|
+
|
|
210
|
+
将来 `apiVersion: 2` が導入される場合は、changeset とこのガイドおよび architecture doc のセクション更新を通じてアナウンスされます。それまでは、**`apiVersion: 1` でプラグインを publish し、唯一の有効値として扱ってください**。v2 bump の trigger となるもの(ならないもの)の全基準については、architecture doc の [apiVersion bump policy](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#apiversion-bump-policy) セクションを参照してください。
|
|
211
|
+
|
|
200
212
|
### `instanceId`
|
|
201
213
|
|
|
202
214
|
optional、デフォルトは `name`。同じプラグインを 1 サイトで複数 instance 動かせる作り (例: 2 つの GA4 measurement ID、チャットプラットフォーム毎の webhook) では、ホストに `instanceId` を指定させて各々独立した namespace を持たせる:
|
|
@@ -277,13 +289,26 @@ runtime がチェックする内容:
|
|
|
277
289
|
|
|
278
290
|
## 4. `trust_level` の選び方
|
|
279
291
|
|
|
292
|
+
trust 階層は v1 において**ファーストパーティプラグインの code organization**として実装されています — イベントフックがどの IAM スコープ付き Lambda で実行されるか、その Lambda が保有する権限を決定します。これはエンジニアが審査した npm dep 向けの code organization surface であり、任意のサードパーティ未審査プラグインを自動的に安全に動かすための marketplace-grade automatic sandbox ではありません(上記の[ポジショニング注記](#ampless-プラグインの書き方)と[詳細な trust model](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#trust-model-v1-scope) を参照)。
|
|
293
|
+
|
|
280
294
|
3 階層、選択基準は **イベントフック (hooks) が何を必要とするか** で決まります (sync サーフェスは IAM に触れない):
|
|
281
295
|
|
|
282
|
-
| 階層 | IAM | 用途 |
|
|
283
|
-
|
|
284
|
-
| `untrusted` | なし (SQS consume のみ) | head/body descriptor、webhook 配送、コンテンツ変換 |
|
|
285
|
-
| `trusted` | 投稿読み出し、`public/plugins/<instanceId ?? name>/...` への書き込み | RSS フィード、sitemap、計算済み JSON インデックス |
|
|
286
|
-
| `privileged` |
|
|
296
|
+
| 階層 | IAM | 今日動くもの | 用途 |
|
|
297
|
+
|---|---|---|---|
|
|
298
|
+
| `untrusted` | なし (SQS consume のみ) | 同期サーフェス + イベントフック | head/body descriptor、webhook 配送、コンテンツ変換 |
|
|
299
|
+
| `trusted` | 投稿読み出し、`public/plugins/<instanceId ?? name>/...` への書き込み | 同期サーフェス + イベントフック | RSS フィード、sitemap、計算済み JSON インデックス |
|
|
300
|
+
| `privileged` | 予約(v2.0+ 探索のみ) | 同期サーフェスのみ — イベントフックはサイレントフィルタ(警告ログあり) | 将来のマーケットプレイス探索: SES、secret、private S3 |
|
|
301
|
+
|
|
302
|
+
> **`privileged` プラグイン作者へ:** `trust_level: 'privileged'` とイベント
|
|
303
|
+
> `hooks` を宣言した場合、**現時点でフックは実行されません**。両 processor が
|
|
304
|
+
> privileged プラグインをフィルタアウトし、一致する SQS イベントごとに
|
|
305
|
+
> `console.warn` を出力するため、サイレントドロップは可視化されます。
|
|
306
|
+
> 同期サーフェス(`publicHead`、`publicBodyEnd`、`metadata`、`publicBodyForPost`、
|
|
307
|
+
> `publicHtmlForPost`)は `trust_level` に関わらず正常に動き、警告も出ません。
|
|
308
|
+
> privileged Lambda プロビジョニングは v2.0+ の探索項目です — ampless が
|
|
309
|
+
> プラグインマーケットプレイスを構築する場合、すでに `'privileged'` を宣言した
|
|
310
|
+
> プラグインは自動的に新しい tier を使えるようになります。
|
|
311
|
+
> v1 ファーストパーティプラグインは trusted Lambda の現行 IAM スコープに収まる用途で `trust_level: 'trusted'` を使ってください。すなわち Post / KvStore / PluginSecret / PostTag の読み取り、`public/plugins/*` への S3 書き込み、外向き HTTP (AWS IAM 認証を必要としないもの) です。このスコープ外の要件 — SES、private S3 プレフィックス、自前の IAM プリンシパルを必要とする AWS API 呼び出し — は v2.0+ privileged Lambda 探索の対象で、v1 `trusted` には収まりません。
|
|
287
312
|
|
|
288
313
|
決め方の目安:
|
|
289
314
|
|
|
@@ -291,7 +316,7 @@ runtime がチェックする内容:
|
|
|
291
316
|
- **hooks から投稿を読みたい (publish 時にフィードを再生成等)** → `trusted`
|
|
292
317
|
- **`public/plugins/*` 以外への S3 PutObject や他の AWS API が必要** → 今はプラグインで ship せず、privileged 層を待つかプラグイン外で実装
|
|
293
318
|
|
|
294
|
-
trust level
|
|
319
|
+
trust level がズレているプラグインは「権限不足でサイレントに fail」または「不要に強い権限を持つ」のどちらか。階層を切り替えて再デプロイすれば直ります。
|
|
295
320
|
|
|
296
321
|
---
|
|
297
322
|
|
|
@@ -441,7 +466,7 @@ runtime は `body` を `sanitize-html` の厳格 allowlist で sanitize し(
|
|
|
441
466
|
- **`attrs` allowlist**: `data-*`、`crossorigin`、`referrerpolicy`、`integrity`、`fetchpriority`、`loading`、`sandbox`、`allow`、`allowfullscreen`。それ以外は dev warn 付きで drop
|
|
442
467
|
- **`inlineScript.id` は必須**。無いとプラグイン同士が似たスニペットを emit したときに dedup できず、dev warning も index 番号を指すだけで原因プラグインを特定できません
|
|
443
468
|
- **id 重複**: 最後の出現が勝ち。dev warning でどの key が重複したか表示されます
|
|
444
|
-
- **CSP nonce**: Phase 1
|
|
469
|
+
- **CSP nonce**: `inlineScript.nonce` と `script.nonce` は型として受け入れられます(`'auto'` は将来の runtime スタンプ用 sentinel。文字列リテラルも可)。Phase 1 予約: runtime はフィールドを受け入れますが描画要素には伝搬しません。詳細は下記の [CSP nonce(Phase 1 予約)](#csp-nonce-phase-1-) を参照。
|
|
445
470
|
- **strategy**: `afterInteractive` は外部 script に `async` を付ける。`lazyOnload` は `defer`。明示的な `async` / `defer` が常に勝ち。`beforeInteractive` は非対応
|
|
446
471
|
|
|
447
472
|
### runtime が描画する形
|
|
@@ -521,7 +546,7 @@ export default function readingTimePlugin() {
|
|
|
521
546
|
```tsx
|
|
522
547
|
{postBody} {/* publicBodyForPost — JSON-LD */}
|
|
523
548
|
{html.beforeContent} {/* publicHtmlForPost — beforeContent スロット */}
|
|
524
|
-
<div className="prose"
|
|
549
|
+
<div className="prose">{await ampless.renderBody(post)}</div>
|
|
525
550
|
{html.afterContent} {/* publicHtmlForPost — afterContent スロット */}
|
|
526
551
|
```
|
|
527
552
|
|
|
@@ -565,9 +590,513 @@ React 19 はさらに、クライアントコンポーネントのレンダー
|
|
|
565
590
|
|
|
566
591
|
---
|
|
567
592
|
|
|
593
|
+
## 6a. 予約投稿とコンテンツイベント
|
|
594
|
+
|
|
595
|
+
ampless は**予約投稿**をサポートしています。`status: 'published'` かつ `publishedAt` が未来の投稿は、その時刻まで公開読み出しから隠されます。`publishedAt` を過ぎると、サイトの自然なキャッシュ有効期限(デフォルト ≤ ~5 分)の範囲内で公開されます — 秒単位の正確なトリガーはありません。
|
|
596
|
+
|
|
597
|
+
### イベントは保存時に発火する(`publishedAt` のタイミングではない)
|
|
598
|
+
|
|
599
|
+
`content.published`(および `content.updated`)は DynamoDB Streams 経由で**投稿が保存されたとき**に emit されます。`publishedAt` が到来したときではありません。つまり、`content.published` に反応するプラグインは、投稿が未来日時の場合、**その投稿が公開される前**に実行されます。
|
|
600
|
+
|
|
601
|
+
現在の投稿一覧から公開アセットを再構築する trusted プラグイン(RSS、sitemap、JSON インデックスなど)にとってはこれは問題ありません — `listPublishedPosts()` が未来日時の投稿をすでに除外しているため、再生成されたアセットはその投稿が公開されるまでリストに含まれません。
|
|
602
|
+
|
|
603
|
+
一方、**外部通知プラグイン**(webhook、プッシュ通知、SNS 投稿など)には影響があります。投稿の URL が 404 を返しているまたはホームページにリダイレクトされている間に通知が配信されてしまいます。
|
|
604
|
+
|
|
605
|
+
### 推奨パターン: `publishedAt` でゲートする
|
|
606
|
+
|
|
607
|
+
ディスパッチの前に `event.payload.publishedAt` を確認します。投稿が未来日時の場合はスキップまたは保留します:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
hooks: {
|
|
611
|
+
async 'content.published'(event, ctx) {
|
|
612
|
+
const { publishedAt } = event.payload
|
|
613
|
+
|
|
614
|
+
// 予約投稿には通知を送らない。イベントは保存時に発火するが、
|
|
615
|
+
// 投稿が公開されるのは publishedAt になってから。
|
|
616
|
+
if (publishedAt && new Date(publishedAt) > new Date()) {
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 投稿は今すぐ公開状態 — 通知しても安全。
|
|
621
|
+
await sendWebhook(event.payload, ctx)
|
|
622
|
+
},
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
イベントペイロード内の `publishedAt` は UTC ISO 8601 文字列(`...Z`)です。`Date.now()` と比較する前に `new Date()` またはお好みの日付ライブラリでパースしてください。
|
|
627
|
+
|
|
628
|
+
### 今後の対応
|
|
629
|
+
|
|
630
|
+
`content.published` の発火を保存時ではなく `publishedAt` のタイミングに合わせる機能 — EventBridge Scheduler または DynamoDB TTL トリガー Lambda によるスケジューラーコンポーネントが必要 — は計画中の機能強化です。現リリースのスコープには含まれていません。それまでの間は上記のパターンが通知プラグインにおける推奨ガードです。
|
|
631
|
+
|
|
632
|
+
オペレーター視点からの `publishedAt` セマンティクスの全体像は [`docs/scheduled-publishing.md`](https://github.com/heavymoons/ampless/blob/main/docs/scheduled-publishing.ja.md) を参照してください。
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
### CSP nonce(Phase 1 予約)
|
|
637
|
+
|
|
638
|
+
CSP(Content Security Policy)は本番公開サイトのほぼ必須要件です。nonce 伝搬を後から追加した場合に既存プラグインが一斉に動かなくなるのを防ぐため、ampless は今のうちに API サーフェスだけ予約しておきます(Phase 1 は完全 no-op)。
|
|
639
|
+
|
|
640
|
+
3 層設計:
|
|
641
|
+
|
|
642
|
+
1. **`ctx.cspNonce?: string`** — `PluginPublicRenderContext` の型に予約済み。今のところ常に `undefined`。runtime はこのフィールドをまだ populate しません。middleware/SSR nonce threading は将来の CSP RFP とともに landing します。
|
|
643
|
+
|
|
644
|
+
2. **`descriptor.nonce: 'auto' | string`** — `inlineScript` と `script` の両 descriptor variant の型で受け入れられます。`'auto'` は将来の runtime スタンプ用 sentinel。その他の文字列は明示的リテラル。`undefined` は `nonce` 属性を emit しない(デフォルト、非 CSP サイトと後方互換)。Phase 1: runtime は受け入れますが伝搬しません。`nonce: 'auto'` を今日宣言することは前方互換性のヒントであり、描画 HTML を変更しません。
|
|
645
|
+
|
|
646
|
+
3. **`'cspReady'` capability** — name-only の declarative バッジ。将来の admin UI / サニティチェックの対象になります。Phase 1 では runtime の cross-check や enforcement は一切なし。
|
|
647
|
+
|
|
648
|
+
**対応方法:**
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
// src/index.ts
|
|
652
|
+
definePlugin({
|
|
653
|
+
name: 'my-plugin',
|
|
654
|
+
apiVersion: 1,
|
|
655
|
+
trust_level: 'untrusted',
|
|
656
|
+
capabilities: ['publicHead', 'cspReady'],
|
|
657
|
+
publicHead: () => [{
|
|
658
|
+
type: 'inlineScript',
|
|
659
|
+
id: 'my-snippet',
|
|
660
|
+
body: '...',
|
|
661
|
+
nonce: 'auto', // 前方互換性ヒント。Phase 1 では効果なし
|
|
662
|
+
}],
|
|
663
|
+
})
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
npm 公開のスタンドアロンプラグインの場合は、`package.json#amplessPlugin.capabilities` も合わせて更新してください — static manifest と factory return value が一致しない場合、runtime cross-check が警告を出します:
|
|
667
|
+
|
|
668
|
+
```json
|
|
669
|
+
{
|
|
670
|
+
"amplessPlugin": {
|
|
671
|
+
"apiVersion": 1,
|
|
672
|
+
"name": "my-plugin",
|
|
673
|
+
"trustLevel": "untrusted",
|
|
674
|
+
"capabilities": ["publicHead", "cspReady"]
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
**`'cspReady'` の意味:**
|
|
680
|
+
|
|
681
|
+
- サイト全体の CSP 適合は middleware / レスポンスヘッダー / runtime が制御しない他の inline コンテンツにも依存します。
|
|
682
|
+
- middleware-driven nonce threading PR が landing した後は、`nonce: 'auto'` を持つ plugin-supplied script が runtime nonce スタンプの候補になります。
|
|
683
|
+
- `'cspReady'` は `create-ampless plugin --capabilities` には表示されません — reserved capability であり、scaffold はアクティブな enforcement を示唆しないよう除外しています。
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## 6b. 投稿本文の差し替え: `contentFields` (Phase 7)
|
|
688
|
+
|
|
689
|
+
`contentFields` capability は、投稿本文の一部(tiptap ノード、または markdown の単独行 URL)を、プラグインが管理する React サブツリーで差し替える機能。`@ampless/plugin-youtube` / `@ampless/plugin-x-embed` が `https://youtu.be/...` URL を iframe プレーヤーに、`https://x.com/<handle>/status/...` URL を tweet blockquote に展開するために使う。
|
|
690
|
+
|
|
691
|
+
### 形
|
|
692
|
+
|
|
693
|
+
```ts
|
|
694
|
+
import { definePlugin, type ContentFieldRenderer } from 'ampless'
|
|
695
|
+
|
|
696
|
+
definePlugin({
|
|
697
|
+
// ...
|
|
698
|
+
capabilities: ['contentFields'],
|
|
699
|
+
contentFields: [
|
|
700
|
+
{
|
|
701
|
+
kind: 'tiptap',
|
|
702
|
+
nodeType: 'amplessYoutube',
|
|
703
|
+
render: (node, ctx) => <YouTubeEmbed videoId={String(node.attrs?.videoId)} />,
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
kind: 'markdown-url',
|
|
707
|
+
pattern: /^https:\/\/youtu\.be\/([\w-]{11})$/,
|
|
708
|
+
render: ({ match }, ctx) => <YouTubeEmbed videoId={match[1]!} />,
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
})
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
各 renderer は `ampless.renderBody(post)` 内で本文を歩く runtime から server-side で呼び出される。戻り値は `ReactNode`。`PluginPublicRenderContext` (`ctx`) は `publicHead` / `publicBodyEnd` と同じものなので、`ctx.setting<T>(key)` も同じように使える。
|
|
715
|
+
|
|
716
|
+
### 2 種類の kind
|
|
717
|
+
|
|
718
|
+
- **`tiptap`** — `nodeType` (例: `'amplessYoutube'`) でキー付け。runtime の tiptap walker が `type` 一致のノードを見つけたら renderer を呼ぶ。デフォルトの switch-case レンダリングはバイパスされ、プラグインがサブツリー全体を所有する。
|
|
719
|
+
- **`markdown-url`** — anchored な `RegExp` (`^...$`) でキー付け。runtime は `marked.lexer` で markdown をトークン化し、内容全体が単独 URL の paragraph トークン (autolink / bare URL / `[text](url)`) のみについて pattern を試す。最初にマッチしたものが勝ち。capture group は `match[1]`, `match[2]`, ... でアクセス可能。
|
|
720
|
+
|
|
721
|
+
### 命名と一意性
|
|
722
|
+
|
|
723
|
+
- first-party プラグインは `ampless...` の camelCase プレフィックス(`amplessYoutube`, `amplessTweet` 等)を使い、コミュニティ製の `nodeType` と衝突しないようにしている。
|
|
724
|
+
- runtime は同じ `nodeType` / `pattern.source` の重複登録を起動時に throw で拒否する。先勝ち。v1 は multi-instance 非対応。
|
|
725
|
+
|
|
726
|
+
### markdown URL pattern のルール
|
|
727
|
+
|
|
728
|
+
- **必ず `^...$` で anchor する**。anchor なしだと段落内の URL が誤マッチし、周辺テキストが壊れる。
|
|
729
|
+
- runtime はマッチ前に前後の whitespace を trim するので、pattern 側で `\s*` を書く必要はない。
|
|
730
|
+
- 段落の唯一のトークンが `[caption](url)` の markdown link なら受理する。`[caption with link](url)` のように前後にテキストが混在するケースは対象外(正しい挙動: prose は本文に残すべきで、video embed にすべきではない)。
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
## 6c. ページレベルスクリプト: `publicPostScript` (Phase 7)
|
|
735
|
+
|
|
736
|
+
`publicPostScript` capability は、ページ上の投稿が必要とする `<script>` タグをプラグインから出力するためのもの。runtime は安定 `id` で dedupe するので、同一ページ内の複数 embed は 1 つの script タグに集約される。`@ampless/plugin-x-embed` がページに tweet embed がある場合のみ `https://platform.twitter.com/widgets.js` を 1 度だけ注入するのに使われている。
|
|
737
|
+
|
|
738
|
+
### 形
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
definePlugin({
|
|
742
|
+
capabilities: ['contentFields', 'publicPostScript'],
|
|
743
|
+
publicPostScript(post, ctx) {
|
|
744
|
+
if (!hasTweetIn(post)) return []
|
|
745
|
+
return [
|
|
746
|
+
{
|
|
747
|
+
id: 'amplessTweet:widgets',
|
|
748
|
+
src: 'https://platform.twitter.com/widgets.js',
|
|
749
|
+
async: true,
|
|
750
|
+
},
|
|
751
|
+
]
|
|
752
|
+
},
|
|
753
|
+
})
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### テーマからの呼び出し
|
|
757
|
+
|
|
758
|
+
テーマは投稿本文の出力後に `{await ampless.publicPostScriptsForPage(posts)}` を呼ぶ。first-party テーマは post 詳細ページ / home ページ (featured 表示時) で自動的に呼び出す:
|
|
759
|
+
|
|
760
|
+
```tsx
|
|
761
|
+
<div>{await ampless.renderBody(post)}</div>
|
|
762
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
runtime は:
|
|
766
|
+
|
|
767
|
+
1. プラグイン × post 組ごとに `publicPostScript(post, ctx)` を呼ぶ。
|
|
768
|
+
2. `id` が空 / 非 string、`src` が http(s) でない、descriptor がオブジェクトでない、等を drop。
|
|
769
|
+
3. `id` で dedupe(先勝ち)。
|
|
770
|
+
4. `<Fragment>` で `<script src={src} async defer />` を出力。
|
|
771
|
+
|
|
772
|
+
### CSP の扱い
|
|
773
|
+
|
|
774
|
+
runtime は `src` のホスト allowlist を強制しない。CSP はサイト側エンジニアの責任(`next.config.ts` / middleware で `script-src` に script ホスト(例: `platform.twitter.com`)を追加)。各プラグインの README に書いてある。
|
|
775
|
+
|
|
776
|
+
---
|
|
777
|
+
|
|
778
|
+
## 6d. Admin エディタ拡張の配線 (Phase 7)
|
|
779
|
+
|
|
780
|
+
admin エディタに tiptap Node 拡張を提供するプラグインは、別途 `./editor` subpath の client-side エントリを出荷する。
|
|
781
|
+
`app/(admin)/admin/_editor-bootstrap.tsx` は `npm run update-ampless` によって**自動生成**される — 手動で編集してはならない。
|
|
782
|
+
|
|
783
|
+
### プラグイン著者向け
|
|
784
|
+
|
|
785
|
+
`package.json#amplessPlugin` に `editorExports` を宣言する:
|
|
786
|
+
|
|
787
|
+
```jsonc
|
|
788
|
+
// packages/plugin-youtube/package.json
|
|
789
|
+
"amplessPlugin": {
|
|
790
|
+
"apiVersion": 1,
|
|
791
|
+
"name": "youtube",
|
|
792
|
+
"trustLevel": "trusted",
|
|
793
|
+
"capabilities": ["contentFields"],
|
|
794
|
+
"editorExports": "./editor" // ← editor module のある subpath
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
その subpath から `editorExtension` を named export する:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
802
|
+
export const editorExtension = AmplessYoutubeNode // tiptap Node または Extension
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
`package.json#exports` にも同 subpath を宣言すること(codegen がこれを検証する — 未宣言の場合は warning を出してそのプラグインをスキップする):
|
|
806
|
+
|
|
807
|
+
```jsonc
|
|
808
|
+
"exports": {
|
|
809
|
+
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
|
|
810
|
+
"./editor": { "import": "./dist/editor.js", "types": "./dist/editor.d.ts" }
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
### プラグインユーザー(サイトエンジニア)向け
|
|
815
|
+
|
|
816
|
+
1. `npm i @ampless/plugin-youtube@beta` — plugin を dependency として追加する。
|
|
817
|
+
2. `cms.config.ts` に登録してサーバーサイド renderer を有効化する。
|
|
818
|
+
3. `npm run update-ampless` — インストール済み plugin の manifest から `_editor-bootstrap.tsx` を自動再生成する。
|
|
819
|
+
|
|
820
|
+
生成されたファイルはリポジトリにコミットされ、以下のような内容になる:
|
|
821
|
+
|
|
822
|
+
```tsx
|
|
823
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — 編集不可)
|
|
824
|
+
'use client'
|
|
825
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit — your changes
|
|
826
|
+
// will be overwritten on the next run.
|
|
827
|
+
import { installAdminEditorExtensions } from '@ampless/admin/editor'
|
|
828
|
+
import { editorExtension as __ampless_plugin_x_embed_editor } from '@ampless/plugin-x-embed/editor'
|
|
829
|
+
import { editorExtension as __ampless_plugin_youtube_editor } from '@ampless/plugin-youtube/editor'
|
|
830
|
+
|
|
831
|
+
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
832
|
+
installAdminEditorExtensions([
|
|
833
|
+
__ampless_plugin_x_embed_editor,
|
|
834
|
+
__ampless_plugin_youtube_editor,
|
|
835
|
+
])
|
|
836
|
+
return <>{children}</>
|
|
837
|
+
}
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
このファイルは admin layout に渡される(テンプレートに既に組み込まれているので追加の作業は不要):
|
|
841
|
+
|
|
842
|
+
```tsx
|
|
843
|
+
// templates/_shared/app/(admin)/admin/layout.tsx
|
|
844
|
+
import { createAdminLayout } from '@ampless/admin/pages'
|
|
845
|
+
import { EditorBootstrap } from './_editor-bootstrap'
|
|
846
|
+
export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
`installAdminEditorExtensions` は idempotent で、render 時に client component 内で呼ばれる。admin の `<TiptapEditor>` は毎回 render 時に登録済みリストを built-in extensions の末尾に spread する。
|
|
850
|
+
|
|
851
|
+
`update-ampless` が生成する `_editor-bootstrap.tsx` は extensions、markdown アダプター、html アダプターの3つの install 呼び出しを含む:
|
|
852
|
+
|
|
853
|
+
```tsx
|
|
854
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — 編集不可)
|
|
855
|
+
'use client'
|
|
856
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit ...
|
|
857
|
+
import { installAdminEditorExtensions, installAdminTiptapNodeMarkdown, installAdminTiptapNodeHtml } from '@ampless/admin/editor'
|
|
858
|
+
import * as __ampless_plugin_x_embed_editor from '@ampless/plugin-x-embed/editor'
|
|
859
|
+
import * as __ampless_plugin_youtube_editor from '@ampless/plugin-youtube/editor'
|
|
860
|
+
|
|
861
|
+
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
862
|
+
installAdminEditorExtensions([
|
|
863
|
+
__ampless_plugin_x_embed_editor.editorExtension,
|
|
864
|
+
__ampless_plugin_youtube_editor.editorExtension,
|
|
865
|
+
])
|
|
866
|
+
installAdminTiptapNodeMarkdown([
|
|
867
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToMarkdown ?? {},
|
|
868
|
+
__ampless_plugin_youtube_editor.tiptapNodeToMarkdown ?? {},
|
|
869
|
+
])
|
|
870
|
+
installAdminTiptapNodeHtml([
|
|
871
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToHtml ?? {},
|
|
872
|
+
__ampless_plugin_youtube_editor.tiptapNodeToHtml ?? {},
|
|
873
|
+
])
|
|
874
|
+
return <>{children}</>
|
|
875
|
+
}
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### フォーマット切り替えの可逆アダプター(`tiptapNodeToMarkdown` + `tiptapNodeToHtml`)
|
|
879
|
+
|
|
880
|
+
operator が admin UI でポストのフォーマットを切り替える場合(例: `tiptap → markdown`、`tiptap → html`)、admin はボディコンテンツを変換する必要がある。通常の prose node は tiptap の built-in レンダラーで処理されるが、**atom node**(`amplessYoutube` のような embed ブロック)は子要素を持たず、children が空のまま fallthrough し、embed が無音で消えてしまう。
|
|
881
|
+
|
|
882
|
+
2 つのアダプターで両方向を修正する:
|
|
883
|
+
|
|
884
|
+
| アダプター | 方向 | 出力 |
|
|
885
|
+
| --------------------- | --------------------------------- | ---- |
|
|
886
|
+
| `tiptapNodeToMarkdown`| `tiptap → markdown` | bare URL 行(例: `https://youtu.be/<id>`) |
|
|
887
|
+
| `tiptapNodeToHtml` | `tiptap → html`、`markdown → html`| 正規プレースホルダー div |
|
|
888
|
+
|
|
889
|
+
フォーマット切替で扱う 3 種類の正規 body 表現:
|
|
890
|
+
|
|
891
|
+
| フォーマット | 正規形 |
|
|
892
|
+
| ------------ | ------ |
|
|
893
|
+
| tiptap | `{ type: 'amplessYoutube', attrs: { videoId, start } }` Node |
|
|
894
|
+
| markdown | bare `https://youtu.be/<id>` URL 行 |
|
|
895
|
+
| html | `<div data-ampless-youtube data-video-id="<id>" …>…</div>` |
|
|
896
|
+
|
|
897
|
+
プレースホルダー div は **admin format-switch 相互運用専用**の正規 HTML 形式: `Node.renderHTML` が emit し、`Node.parseHTML` の `tag: 'div[data-ampless-*]'` rule が復元することで、admin での `tiptap ↔ markdown ↔ html` 切替が embed を保ったまま round-trip できる。
|
|
898
|
+
|
|
899
|
+
**`format: 'html'` の公開描画は、plugin が `htmlPlaceholder` を宣言していればプレースホルダーを展開する。** 公開描画の 3 経路すべてが同じ `contentFields.tiptap` renderer に到達する: tiptap 投稿は React walker が `contentFields.tiptap` registry を参照(= `amplessYoutube` Node を実際の iframe に変換)、markdown 投稿は markdown walker が `contentFields.markdownUrl` を参照(= bare URL paragraph を実際の iframe に変換)、html 投稿は **public html walker** が `contentFields.htmlPlaceholder` を参照(= top-level の `<div data-ampless-youtube …>` プレースホルダーを実際の iframe に変換)。上の `tiptapNodeToHtml` アダプターは **admin format-switch** の交換形式のみを司り、その形式を公開描画時に展開させるのは `htmlPlaceholder` の宣言である(契約は下記の `htmlPlaceholder` セクションを参照)。`publicHtmlForPost` は従来どおり `beforeContent` / `afterContent` slot のみ提供する capability であり、body を変換しない。
|
|
900
|
+
|
|
901
|
+
embed node を持つが `htmlPlaceholder` を**宣言しない** plugin は従来挙動を維持する: `format: 'html'` 投稿の公開描画ではプレースホルダー div がリテラルにそのまま出力される(内側の正規 URL link は clickable なので graceful に degrade する)。その場合に iframe を得るには `tiptap` または `markdown` 形式で保存する。
|
|
902
|
+
|
|
903
|
+
#### アダプターの契約
|
|
904
|
+
|
|
905
|
+
両アダプターは同じシグネチャを持つ:
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
(node: TiptapRenderNode) => string | null
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
**文字列**(空文字 `''` も含む)を返すと、その出力を使う。**`null`** を返すとデフォルトの switch へ fallthrough する(対応しない node や video id が欠損している場合などに使う)。
|
|
912
|
+
|
|
913
|
+
```ts
|
|
914
|
+
import type { TiptapNodeMarkdownAdapters, TiptapNodeHtmlAdapters } from 'ampless'
|
|
915
|
+
|
|
916
|
+
export const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters = {
|
|
917
|
+
amplessYoutube: (node) => {
|
|
918
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
919
|
+
if (!videoId) return null
|
|
920
|
+
return `https://youtu.be/${videoId}`
|
|
921
|
+
},
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
|
|
925
|
+
amplessYoutube: (node) => {
|
|
926
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
927
|
+
if (!videoId) return null
|
|
928
|
+
const attrs = placeholderAttrs(node.attrs ?? {})
|
|
929
|
+
// 内側のコンテンツは editor 用視覚 label (`<span>YouTube: id</span>`、
|
|
930
|
+
// Node.renderHTML 専用) ではなく URL link にする。`format: 'html'` の
|
|
931
|
+
// 公開描画ではこの body をリテラル表示するため editor 用 label が漏れる
|
|
932
|
+
// のを防ぐ。viewer 側で iframe 展開されない場合でもクリック可能な link が
|
|
933
|
+
// 残り graceful degradation する。
|
|
934
|
+
const url = `https://youtu.be/${videoId}`
|
|
935
|
+
return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`
|
|
936
|
+
},
|
|
937
|
+
}
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
#### 配線
|
|
941
|
+
|
|
942
|
+
`update-ampless` が各プラグインの `./editor` モジュールから `tiptapNodeToMarkdown` と `tiptapNodeToHtml` の named export を読み取り、両方の install に自動で配線する。**手動での配線は不要。** プラグインがいずれかのマップを export しない場合、生成ファイルの `?? {}` fallback が no-op になる。
|
|
943
|
+
|
|
944
|
+
#### `markdown → html` 2-hop
|
|
945
|
+
|
|
946
|
+
`markdown → html` の方向は `tiptapNodeToHtml` アダプターを 2-hop 経由で再利用する:
|
|
947
|
+
|
|
948
|
+
1. `markdownToHtml(body)` — marked が markdown を HTML に変換。bare URL 行は `<p><a href="URL">URL</a></p>` になる。
|
|
949
|
+
2. `generateJSON(html, extensions)` — tiptap が HTML を parse。プラグインの `Node.parseHTML` `tag: 'p'` rule が bare URL の paragraph を embed Node に昇格する。
|
|
950
|
+
3. `tiptapToHtml(doc, { nodeAdapters })` — html アダプターが embed Node をプレースホルダー div に serialize する。
|
|
951
|
+
|
|
952
|
+
プラグインは `tiptap → html` アダプターを 1 つ export するだけでよく、`markdown → html` の方向は tiptap の parse rule を通して自動的に再利用される。**重複ロジックは不要。**
|
|
953
|
+
|
|
954
|
+
#### Public html walker: `htmlPlaceholder`
|
|
955
|
+
|
|
956
|
+
`format: 'html'` 投稿のプレースホルダー div を公開ページで実 embed として描画するには、既存の `contentFields` の `tiptap` entry に **`htmlPlaceholder`** 宣言を追加する。**新しい renderer は書かない** — walker は tiptap entry が既に使う `render(node, ctx)` をそのまま呼ぶので、3 形式(tiptap / markdown / html)すべてが 1 個の renderer に到達し、描画が divergence しない。
|
|
957
|
+
|
|
958
|
+
```ts
|
|
959
|
+
// packages/plugin-youtube/src/index.tsx
|
|
960
|
+
contentFields: [
|
|
961
|
+
{
|
|
962
|
+
kind: 'tiptap',
|
|
963
|
+
nodeType: 'amplessYoutube',
|
|
964
|
+
render: (node) => {
|
|
965
|
+
const videoId = String(node.attrs?.videoId ?? '')
|
|
966
|
+
const startRaw = node.attrs?.start
|
|
967
|
+
const start =
|
|
968
|
+
typeof startRaw === 'number' && Number.isFinite(startRaw) ? startRaw : undefined
|
|
969
|
+
return <YouTubeEmbed videoId={videoId} start={start} />
|
|
970
|
+
},
|
|
971
|
+
htmlPlaceholder: {
|
|
972
|
+
// top-level プレースホルダー div を識別する marker attribute。
|
|
973
|
+
flagAttr: 'data-ampless-youtube',
|
|
974
|
+
// div の HTML 属性を render が期待する tiptap node attrs に変換する
|
|
975
|
+
// — 型も正しく。walker は型を知らないのでここで変換する
|
|
976
|
+
// (例: data-start string → number)。
|
|
977
|
+
attrsFromElement: (attribs) => {
|
|
978
|
+
const start = Number(attribs['data-start'])
|
|
979
|
+
return {
|
|
980
|
+
videoId: attribs['data-video-id'] ?? '',
|
|
981
|
+
start: Number.isFinite(start) ? start : undefined,
|
|
982
|
+
}
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
// …markdown-url entry は変更なし…
|
|
987
|
+
]
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
`format: 'html'` 投稿の公開描画時、runtime は body を server-side で parse し(`htmlparser2`)、`flagAttr` を持つ **top-level** element ごとに `{ type: nodeType, attrs: attrsFromElement(attribs) }` node を組んで `render(node, ctx)` を呼ぶ。それ以外はすべて **元文字列の slice**(byte 単位で完全保存、DOM の再シリアライズなし)として passthrough する。
|
|
991
|
+
|
|
992
|
+
把握すべき制約:
|
|
993
|
+
|
|
994
|
+
- **top-level のみ。** `<blockquote>` / `<li>` 等の内側に nest したプレースホルダー div はリテラルのまま — editor の body-level-only な `parseHTML` fallback と整合。展開されるのは depth-0 の element のみ。
|
|
995
|
+
- **`flagAttr` は case-insensitive で照合される。** htmlparser2 は parse 時に HTML 属性名を小文字化し、runtime は登録時に `flagAttr` を小文字化するため、`<div DATA-AMPLESS-YOUTUBE …>` も `<div data-ampless-youtube …>` も展開される。`flagAttr` は任意の属性名でよい — site-local plugin は `data-my-embed` を使える。固定の `data-ampless` prefix 要件はない。
|
|
996
|
+
- **graceful degradation。** `attrsFromElement` または `render` が throw した場合、runtime は `console.warn` を出力し **元のプレースホルダー slice**(内側の正規 URL link は clickable のまま)に fallback する。engineer が書いた本文を消さない。
|
|
997
|
+
- **page-level script。** embed が third-party script を要する場合(例: x.com の `widgets.js`)、`publicPostScript` / 検知ヘルパーもプレースホルダー形式を認識する必要がある。`plugin-x-embed` の `hasTweetIn` は `format: 'html'` body 内で `twitter-tweet` と `data-ampless-tweet` の両方を match させ、展開された blockquote を hydrate するため widgets.js を注入する。
|
|
998
|
+
|
|
999
|
+
**wrapper 境界の変化。** プレースホルダーを含まない `format: 'html'` 投稿は単一の wrapper `<div>` として出力される(fast path — 従来の raw passthrough と markup 完全一致)。プレースホルダーを**含む**投稿は **複数の wrapper div と React embed の兄弟列が交互に並ぶ**構造になる: 各 raw chunk 内の bytes は正確に保存されるが、wrapper 境界は移動する。body 全体を 1 個の wrapper で囲む前提の direct-child / adjacent-sibling CSS selector は従来と同じには効かない。これは embed を in-place 展開するための許容トレードオフ。
|
|
1000
|
+
|
|
1001
|
+
### markdown → tiptap の復元
|
|
1002
|
+
|
|
1003
|
+
editor Node が markdown へ bare URL line として serialise される場合、逆方向の復元は paste rule ではなく `Node.parseHTML()` で扱う必要がある。admin の `markdown → tiptap` format switch は、まず markdown を HTML に変換する。GFM autolink により bare URL line は `<p><a href="https://...">https://...</a></p>` になり、その HTML を tiptap が document として parse する。paste rule は user paste / typing event 用なので、この HTML parse 経路では発火しない。
|
|
1004
|
+
|
|
1005
|
+
embed 系 Node は、上記 paragraph shape 用の high-priority parse rule を追加し、必要に応じて他の HTML-to-tiptap 経路向けに `a[href]` rule も追加する。paragraph rule は「paragraph が単一 link だけを含み、その link text が `href` と同じ」場合だけ match させる。これにより parser は paragraph 全体を block embed Node に置換でき、embed の前に空 paragraph が残らない。`getAttrs` は URL を検証し、match しない link では `false` を返して通常の Link mark にフォールバックさせる。
|
|
1006
|
+
|
|
1007
|
+
### active source と完全無効化
|
|
1008
|
+
|
|
1009
|
+
**エディタ配線の active source は `package.json#dependencies`**(= `node_modules`)であり、`cms.config.ts` ではない。
|
|
1010
|
+
|
|
1011
|
+
- plugin を `cms.config.ts` から外しても `package.json` に dep が残っていれば、editor の paste rule は**有効のまま**になる。editor は tiptap document にそのノード型を挿入できるが、公開 renderer はレンダリングしない(不整合状態 — 避けること)。
|
|
1012
|
+
- 完全に無効化するには: `cms.config.ts` から削除 **かつ** `npm uninstall @ampless/plugin-...` を実行し、`npm run update-ampless` で bootstrap ファイルを再生成する。
|
|
1013
|
+
|
|
1014
|
+
### エディタプレビューのパイプライン
|
|
1015
|
+
|
|
1016
|
+
admin の edit / new post フォームは preview ペインを `<iframe sandbox="allow-scripts allow-same-origin">` で表示する。`srcDoc` はテンプレート側 preview Route Handler が返す HTML。テンプレート scaffold は handler を `app/(admin)/admin/preview/route.tsx` に同梱する:
|
|
1017
|
+
|
|
1018
|
+
```tsx
|
|
1019
|
+
// templates/_shared/app/(admin)/admin/preview/route.tsx
|
|
1020
|
+
import type { Post } from 'ampless'
|
|
1021
|
+
import { admin } from '@/lib/admin'
|
|
1022
|
+
|
|
1023
|
+
export async function POST(req: Request): Promise<Response> {
|
|
1024
|
+
const session = await admin.getServerSession()
|
|
1025
|
+
if (!admin.isEditor(session)) {
|
|
1026
|
+
return new Response('Forbidden', { status: 403 })
|
|
1027
|
+
}
|
|
1028
|
+
let draft: Post
|
|
1029
|
+
try {
|
|
1030
|
+
draft = (await req.json()) as Post
|
|
1031
|
+
} catch {
|
|
1032
|
+
return new Response('Bad Request', { status: 400 })
|
|
1033
|
+
}
|
|
1034
|
+
const ampless = await admin.getAmpless()
|
|
1035
|
+
const node = (
|
|
1036
|
+
<>
|
|
1037
|
+
{await ampless.renderBody(draft)}
|
|
1038
|
+
{await ampless.publicPostScriptsForPage([draft])}
|
|
1039
|
+
</>
|
|
1040
|
+
)
|
|
1041
|
+
// dynamic import: 下の「Server Action ではなく Route Handler を採用した理由」参照。
|
|
1042
|
+
const { renderToStaticMarkup } = await import('react-dom/server')
|
|
1043
|
+
return new Response(renderToStaticMarkup(node), {
|
|
1044
|
+
headers: {
|
|
1045
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1046
|
+
'Cache-Control': 'no-store',
|
|
1047
|
+
},
|
|
1048
|
+
})
|
|
1049
|
+
}
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
`<PostForm>` / `<PostHistoryPanel>` は素のままで `/admin/preview` に draft を POST する — call site で追加の wiring は不要:
|
|
1053
|
+
|
|
1054
|
+
```tsx
|
|
1055
|
+
// templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
|
|
1056
|
+
import { admin } from '@/lib/admin'
|
|
1057
|
+
import { createEditPostPage } from '@ampless/admin/pages'
|
|
1058
|
+
|
|
1059
|
+
export default createEditPostPage(admin)
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
admin が非デフォルトパス(Next.js `basePath` やカスタム prefix)にマウントされている場合は、page factory の `previewEndpoint` option で endpoint を上書きできる:
|
|
1063
|
+
|
|
1064
|
+
```tsx
|
|
1065
|
+
export default createEditPostPage(admin, { previewEndpoint: '/cms/admin/preview' })
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
Server Action ではなく Route Handler を採用した理由: `'use server'` モジュール内部で `react-dom/server` 由来の rendering を行うと、Next.js 15+ が edit-post page の compile に失敗する。build 時に Client Component から Server Action モジュール経由で import graph を辿るため、その経路上で `react-dom/server` に到達した時点で「You're importing a component that imports react-dom/server」チェックが発火する。Route Handler に切り出すことで rendering を import graph から完全に切り離せる — `<PostForm>` は plain な HTTP endpoint を fetch するだけで、bundler は form から handler 側へ踏み込まない。さらに handler 自身が `admin.isEditor()` を明示的にチェックすることで、将来 `(admin)` route-group gate が誤設定された場合の安全側のフェールセーフになる。`react-dom/server` の import 自体を dynamic にしているのは Next.js 16 の Turbopack が、Route Handler を含む app router build 経路で reach できる top-level の `react-dom/server` static import を一律で flag するため。request 時に解決することで build-time import-graph walker から外しつつ、runtime では同じ Node.js subpath からロードする。
|
|
1069
|
+
|
|
1070
|
+
**Preview iframe sandbox — v1 trust boundary 拡張:** iframe は `sandbox="allow-scripts allow-same-origin"` を使う。`srcDoc` とともに使用すると iframe は admin の origin を継承し、サードパーティ embed widget(YouTube SDK、x.com `widgets.js`)が動作できるようになる — opaque-origin(`allow-scripts` のみ)の iframe では動作を拒否する(非 HttpOnly storage / cache へのアクセスと real-origin requests が必要なため)。同時に same-origin により preview スクリプトは admin の auth state / 非 HttpOnly storage / DOM へのアクセスと authenticated same-origin XHR / fetch の発行が可能になる。
|
|
1071
|
+
|
|
1072
|
+
これは単なる sandbox 緩和ではなく、**v1 の明示的な設計判断**です: **ampless v1 は admin preview content / plugin script を全部 trusted とみなす**。エンジニアは npm install 前に plugin を審査するし(カスタマイズベース CMS モデル)、body content はこのサイトの trusted editor が作成する。`<PostHistoryPanel>` では別の editor が作成した過去 revision(revision author ≠ preview viewer)をプレビューするケースも通常発生するが、v1 ではそれも明示的に trust ring 内と位置づける。より安全な代替案(別 origin preview route + CSP / COEP / COOP)は v2.0+ でリアルなプラグインマーケットプレイスが必要になった場合に再検討する。
|
|
1073
|
+
|
|
1074
|
+
---
|
|
1075
|
+
|
|
568
1076
|
## 7. 非同期イベントフック
|
|
569
1077
|
|
|
570
|
-
`hooks` は SQS から到着したイベントを trust_level に対応する processor Lambda が受けて実行します。
|
|
1078
|
+
`hooks` は SQS から到着したイベントを trust_level に対応する processor Lambda が受けて実行します。
|
|
1079
|
+
|
|
1080
|
+
### 戻り値の予約
|
|
1081
|
+
|
|
1082
|
+
`PluginEventHandler` の戻り値型は `Promise<void | PluginHookResult>`。
|
|
1083
|
+
runtime は現状この戻り値を完全に無視する — 既存 plugin が
|
|
1084
|
+
`Promise<void>` を返す形は migration 不要で動き続ける。
|
|
1085
|
+
`PluginHookResult` は将来の directive (最初の用例として有力:
|
|
1086
|
+
`metrics?: Record<string, number>` による observability emission) のた
|
|
1087
|
+
めの予約で、今宣言するのは forward-compatibility のヒントとして
|
|
1088
|
+
の扱い。注意: rewrite / cancel 系 directive は本 widening だけで
|
|
1089
|
+
は有効化されない — `before:*` event の plugin 配線と payload 拡張
|
|
1090
|
+
が別 PR で必要。
|
|
1091
|
+
|
|
1092
|
+
`PluginHookResult` には private な `__amplessPluginHookResult`
|
|
1093
|
+
marker が付いており、union が `Promise<string>` / `Promise<number>`
|
|
1094
|
+
等の無関係な promise を silently 受け入れないようになっている —
|
|
1095
|
+
plugin 作者がこの marker を明示的に設定する必要は無い。
|
|
1096
|
+
|
|
1097
|
+
### Runtime context
|
|
1098
|
+
|
|
1099
|
+
runtime context (`ctx`) の中身:
|
|
571
1100
|
|
|
572
1101
|
```ts
|
|
573
1102
|
interface PluginRuntimeContext {
|
|
@@ -706,14 +1235,295 @@ stored 値 (validated)
|
|
|
706
1235
|
|
|
707
1236
|
Secret settings を使うと、trusted プラグインが認証情報 (Webhook 署名 secret・SMTP パスワード・外部 API トークン等) を admin UI 経由で保存・ローテーションできます。**公開サイトやブラウザ側コードに値が流れることはありません**。
|
|
708
1237
|
|
|
709
|
-
|
|
1238
|
+
### なぜ `settings.public` と API が違うのか
|
|
1239
|
+
|
|
1240
|
+
`settings.public` の値は公開 runtime に流れる設計です。`public/site-settings.json` にミラーされ、`ctx.setting()` で sync render surface から読めます。analytics の measurementId などには適切ですが、Webhook 署名 secret には絶対に使えません。
|
|
1241
|
+
|
|
1242
|
+
`settings.secret` はストレージモデルが構造的に異なります:
|
|
1243
|
+
|
|
1244
|
+
- KvStore とは **別テーブル** の `PluginSecret` DynamoDB テーブルに保存。admin/editor Cognito ユーザーは AppSync 経由でこのテーブルに**直接アクセスできない** — すべての書き込みは `setPluginSecret` mutation を通じて `plugin-secret-handler` Lambda 経由で行われる。
|
|
1245
|
+
- 値は保存前に **AES-256-GCM 暗号化**される。admin ブラウザが plaintext を Lambda に TLS 経由で送信 → Lambda がバリデーション + `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`(CDK が `amplify/secrets/encryption-key.ts` から注入)から鍵を読み取り + 暗号化 → ciphertext のみを DDB に書き込む。平文は DynamoDB に保存されず、ブラウザにも返らない。
|
|
1246
|
+
- **脅威モデル(Phase 6a v2.2)**:
|
|
1247
|
+
|
|
1248
|
+
| 脅威 | 状態 |
|
|
1249
|
+
|---|---|
|
|
1250
|
+
| PluginSecret テーブルを閲覧する AWS Console オペレータ | ✓ 対策済み — ciphertext のみ、DDB に鍵なし |
|
|
1251
|
+
| ソースリポジトリ / デプロイアーティファクトへのアクセス | ⚠ 対策なし — 鍵は `amplify/secrets/encryption-key.ts` に存在。public repo では `npx create-ampless@beta setup-encryption-key --gitignore` などで鍵を version control から外し、デプロイアーティファクトアクセスを制限すること |
|
|
1252
|
+
| 同一 Lambda 内の悪意ある trusted plugin | ✗ 対策なし — `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` はプラグインコードから読める。真の分離 = per-plugin Lambda(privileged tier, ロードマップ) |
|
|
1253
|
+
| S3 mirror 漏洩 | ✓ 対策済み — PluginSecret テーブルは mirror されない |
|
|
1254
|
+
|
|
1255
|
+
- trusted-processor Lambda が `node:crypto` で復号する。`ctx.secret<T>(key)` は平文 string を返す(ciphertext ではない)。
|
|
1256
|
+
- S3 mirror 経路に絶対に流れない(mirror は KvStore のみを query する)。
|
|
1257
|
+
- 公開 render surface (`publicHead` など) からは読めない。
|
|
1258
|
+
- **field manifest 検証の範囲**: admin クライアントは UX フィードバック目的で `pattern` / `maxLength` / `required` を検証するが、Lambda 側は **汎用の 10,000 文字ハードキャップと安全文字サニタイザのみ**を強制する。admin/editor が AppSync mutation を直接呼ぶと field 単位の制約は迂回できる。設計上意図したもので、admin/editor は secret 設定を許可された信頼されたオペレータと位置づけている。manifest チェックは UX ガイダンスであり、セキュリティ境界ではない。
|
|
1259
|
+
|
|
1260
|
+
### 要件と `definePlugin()` の挙動
|
|
1261
|
+
|
|
1262
|
+
`settings.secret` は `definePlugin()` 時に 4 つの observable な挙動を持ちます(これは v1 のファーストパーティ organization のシークレットアクセス hard gate; [plugin.ts:1004-1019](https://github.com/heavymoons/ampless/blob/main/packages/ampless/src/plugin.ts#L1004-L1019) 参照):
|
|
1263
|
+
|
|
1264
|
+
1. **`settings.secret` 非空 + `trust_level !== 'trusted'`** → `definePlugin()` が **throw**。untrusted と privileged Lambda は `PluginSecret` テーブルへの IAM read アクセスを持たない; trusted Lambda の IAM 権限が必要。
|
|
1265
|
+
2. **`settings.secret` 非空 + `capabilities` 宣言済み + `capabilities` に `'secretSettings'` が含まれない** → **soft 不一致 warning**。`'schema'` / `'publicHtmlForPost'` の既存 capability-mismatch パターンと同じ。
|
|
1266
|
+
3. **`settings.secret` 非空 + `capabilities` 未定義**(`capabilities` 配列を持たない legacy プラグイン)→ **warning なし**。`capabilities` が `undefined` のとき不一致チェックをスキップ、後方互換のため。
|
|
1267
|
+
4. **`capabilities: ['secretSettings']` 宣言だけで `settings.secret` フィールドなし** → **no-op**。warning も throw もなし。
|
|
1268
|
+
|
|
1269
|
+
`settings.secret` を使うには以下も必要です:
|
|
1270
|
+
1. `trust_level: 'trusted'`(上記 #1 の要件; それ以外は `definePlugin()` が throw)。
|
|
1271
|
+
2. `capabilities` に `'secretSettings'` を含める(`capabilities` が定義されているとき省略すると console.warn)。
|
|
1272
|
+
3. **鍵の初回セットアップ** — プロジェクトルートで実行:
|
|
1273
|
+
```sh
|
|
1274
|
+
npx create-ampless@beta setup-encryption-key
|
|
1275
|
+
```
|
|
1276
|
+
32 バイトのランダムな鍵を生成し、`amplify/secrets/encryption-key.ts` に書き込む。AWS 認証情報不要 — ローカルファイル操作のみ。
|
|
1277
|
+
|
|
1278
|
+
次に `amplify/backend.ts` でその定数を import し、`defineAmplessBackend({ pluginSecretEncryptionKey })` に渡す。その後デプロイ(またはサンドボックス再起動)して Lambda env var に注入する。
|
|
1279
|
+
|
|
1280
|
+
public リポジトリの場合は `--gitignore` を渡してバージョン管理から除外し、鍵を別途配布する。
|
|
1281
|
+
|
|
1282
|
+
### Dual-write 整合性
|
|
1283
|
+
|
|
1284
|
+
`setPluginSecret` と `clearPluginSecret` は各操作で **2 テーブル**に連続して書き込む: `PluginSecret`(ciphertext)と `PluginSecretIndicator`(存在タイムスタンプ)。2 回目の書き込みが失敗した場合、テーブルは以下の予測可能な状態になる:
|
|
1285
|
+
|
|
1286
|
+
| 障害ポイント | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
|
|
1287
|
+
|---|---|---|---|---|
|
|
1288
|
+
| **set**: indicator PutItem 失敗 | ciphertext 存在 | 不在 | plaintext を返す ✓ | `false`(UI: 「未保存」) |
|
|
1289
|
+
| **clear**: indicator DeleteItem 失敗 | 不在 | stale(古いタイムスタンプ) | `undefined` ✓ | `true`(UI: 「保存済み」) |
|
|
1290
|
+
|
|
1291
|
+
clear パスの失敗は「安全側」: secret は発火しなくなるが、UI は一時的に「保存済み」と表示する。set パスの失敗は軽微な UI 不整合: secret は機能するが、存在インジケータはリトライが成功するまで不在となる。
|
|
1292
|
+
|
|
1293
|
+
### secret フィールドの宣言
|
|
1294
|
+
|
|
1295
|
+
```ts
|
|
1296
|
+
import { definePlugin } from 'ampless'
|
|
1297
|
+
|
|
1298
|
+
export default function webhookPlugin(opts?: { signingSecret?: string }) {
|
|
1299
|
+
// constructor から渡された secret は closure-private な fallback として保持。
|
|
1300
|
+
// manifest にも descriptor にも出さない。
|
|
1301
|
+
const constructorSecret = opts?.signingSecret
|
|
1302
|
+
|
|
1303
|
+
return definePlugin({
|
|
1304
|
+
name: 'webhook',
|
|
1305
|
+
apiVersion: 1,
|
|
1306
|
+
trust_level: 'trusted',
|
|
1307
|
+
capabilities: ['eventHooks', 'secretSettings'],
|
|
1308
|
+
settings: {
|
|
1309
|
+
secret: [
|
|
1310
|
+
{
|
|
1311
|
+
type: 'text',
|
|
1312
|
+
key: 'signingSecret',
|
|
1313
|
+
label: { en: 'Webhook signing secret', ja: 'Webhook 署名 secret' },
|
|
1314
|
+
maxLength: 256,
|
|
1315
|
+
required: false,
|
|
1316
|
+
// `default` は型レベルで除外されている。closure-private fallback を使うこと。
|
|
1317
|
+
},
|
|
1318
|
+
],
|
|
1319
|
+
},
|
|
1320
|
+
hooks: {
|
|
1321
|
+
async 'content.published'(event, ctx) {
|
|
1322
|
+
// ctx.secret() は PluginSecret DDB table から読む。
|
|
1323
|
+
// admin が未保存なら undefined を返す。
|
|
1324
|
+
const storedSecret = await ctx.secret<string>('signingSecret')
|
|
1325
|
+
|
|
1326
|
+
// closure-private fallback: admin が未保存の場合 constructor 引数を使う。
|
|
1327
|
+
// これで既存サイトとの後方互換を維持できる。
|
|
1328
|
+
const secret = storedSecret ?? constructorSecret
|
|
1329
|
+
if (!secret) return
|
|
1330
|
+
|
|
1331
|
+
// ... secret で署名して POST
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
### 重要: secret フィールドに `default` を書かない
|
|
1339
|
+
|
|
1340
|
+
`PluginSecretField` 型は `Omit<PluginTextField, 'default'> | Omit<PluginTextareaField, 'default'>` として定義されており、**`default` プロパティは型レベルで除去**されています。追加しようとすると TypeScript がエラーを出します。
|
|
1341
|
+
|
|
1342
|
+
理由: `default` は admin UI のフォーム props (ブラウザに送出される)、静的 manifest の cross-check、JS bundle など複数の経路で漏洩します。認証情報に使えない設計です。
|
|
1343
|
+
|
|
1344
|
+
fallback 値がある場合は、プラグイン factory 関数の closure-private 変数として保持してください:
|
|
1345
|
+
|
|
1346
|
+
```ts
|
|
1347
|
+
// ✓ 正解 — closure-private、manifest に出さない
|
|
1348
|
+
const constructorSecret = opts?.signingSecret
|
|
1349
|
+
|
|
1350
|
+
// ✗ 誤り — TypeScript エラー、さらに browser にも漏れる
|
|
1351
|
+
settings: {
|
|
1352
|
+
secret: [{
|
|
1353
|
+
type: 'text',
|
|
1354
|
+
key: 'signingSecret',
|
|
1355
|
+
label: 'Secret',
|
|
1356
|
+
default: opts?.signingSecret, // ← TS compile error
|
|
1357
|
+
}],
|
|
1358
|
+
}
|
|
1359
|
+
```
|
|
1360
|
+
|
|
1361
|
+
### secret の読み出し: `ctx.secret<T>(key)`
|
|
1362
|
+
|
|
1363
|
+
`ctx.secret<T>(key)` は trusted hook handler 内でのみ利用できます (`processor-trusted.ts` が注入)。シグネチャ:
|
|
1364
|
+
|
|
1365
|
+
```ts
|
|
1366
|
+
ctx.secret<T = string>(key: string): Promise<T | undefined>
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
- admin が未保存なら `undefined` を返す。
|
|
1370
|
+
- `T` は convenience cast (ctx.setting と同じ)。値は常に string として保存される。
|
|
1371
|
+
- 結果は per-invocation キャッシュされる。同 batch 内で同キーを 2 回呼んでも DDB 呼び出しと復号処理は 1 回ずつ。暗号化キーは Lambda env var から cold-start 時に decode される(DDB への余分な fetch は不要)。
|
|
1372
|
+
- キャッシュされる値は **復号済みの平文** — ciphertext ではない。2 回目の呼び出しで再復号は発生しない。
|
|
1373
|
+
- cache key は namespace 化される: `${instanceId ?? name}:${fieldKey}`。異なる plugin instance が同名フィールドを持っても混線しない。
|
|
1374
|
+
|
|
1375
|
+
### admin UI
|
|
1376
|
+
|
|
1377
|
+
`settings.secret` を宣言すると、admin plugin settings ページの public フィールドの下に **Secret settings** セクションが表示されます。各フィールド:
|
|
1378
|
+
|
|
1379
|
+
- **未保存**: 通常テキスト入力 + Save ボタン。
|
|
1380
|
+
- **保存済み**: マスク表示 `••••••••` + Replace + Clear ボタン。値は絶対に取得・表示されない。
|
|
1381
|
+
- **編集中**: Replace クリック後 — 新値入力 + Save + Cancel。
|
|
1382
|
+
|
|
1383
|
+
admin は再デプロイなしにいつでも secret をローテーションできます。保存後 ~5〜10 秒以内に次の trusted Lambda 実行から新値が使われます。
|
|
1384
|
+
|
|
1385
|
+
---
|
|
1386
|
+
|
|
1387
|
+
## 9b. プラグインのデータの保存場所
|
|
1388
|
+
|
|
1389
|
+
プラグインが所有するデータは以下の 5 つのストレージ領域に置かれる可能性があり、**現状の書き込み経路は領域ごとに異なります**。3 つのファミリに分かれます:
|
|
1390
|
+
|
|
1391
|
+
- **KvStore** — admin/editor が AppSync 経由で書き込みます。プラグインの hook には KvStore write helper は提供されていません。
|
|
1392
|
+
- **PluginSecret + PluginSecretIndicator** — `plugin-secret-handler` Lambda が書き込みます。admin/editor が `setPluginSecret` / `clearPluginSecret` AppSync mutation を呼ぶと、handler Lambda が DDB に書く流れです。trusted processor は `ctx.secret<T>()` で `PluginSecret` を読み取りますが、どちらの secret テーブルにも書き込みません。
|
|
1393
|
+
- **S3 `public/plugins/{instanceId ?? name}/*`** — trusted Lambda の hook context (`ctx.writePublicAsset(...)`) から書き込みます。プラグインの hook が直接書き込めるのはこの領域だけです。
|
|
1394
|
+
|
|
1395
|
+
それ以外 — `Post`、`Page`、`Media`、`PostTag` DynamoDB テーブル、`public/site-settings.json` S3 ミラー、他プラグインの namespace — への書き込みは禁止です。現状 runtime が強制しているわけではなく、信頼(および将来の IAM 強化)によって担保されます。
|
|
1396
|
+
|
|
1397
|
+
| 領域 | パス / 識別子 | アクセスレベル | Phase |
|
|
1398
|
+
|---|---|---|---|
|
|
1399
|
+
| KvStore(admin 設定) | DynamoDB `pk='siteconfig'`、`sk='plugins.<instanceId>.<fieldKey>'` | admin/editor が AppSync 経由で書く(プラグインの hook context には KvStore write helper は現状提供されていない) | Phase 2 |
|
|
1400
|
+
| KvStore(runtime 状態/キャッシュ) | DynamoDB `pk='pluginstate:<plugin>:...'`(TTL 任意) | admin/editor が AppSync 経由で書く(プラグインの hook context には KvStore write helper は現状提供されていない) | 現行 |
|
|
1401
|
+
| PluginSecret | DynamoDB `PluginSecret` テーブル、`sk='plugins.<instanceId>.<fieldKey>'` | `trusted` 限定(IAM 専用 AppSync 認証) | Phase 6a |
|
|
1402
|
+
| PluginSecretIndicator | DynamoDB `PluginSecretIndicator` テーブル、`sk='plugins.<instanceId>.<fieldKey>'` | `trusted` + admin/editor(indicator 読み取り) | Phase 6a |
|
|
1403
|
+
| S3 プラグイン成果物 | `public/plugins/{instanceId ?? name}/*` | `trusted` 限定(`writePublicAsset`) | Phase 3 |
|
|
1404
|
+
|
|
1405
|
+
**cleanup は自動ではありません。** `cms.config.ts` からプラグインを外しても、5 領域のデータは自動削除されません。将来の lifecycle-dispatch PR が `uninstall` フックの起動メカニズムを追加するまで(§9c 参照)、オペレータによる手動削除が必要です。
|
|
1406
|
+
|
|
1407
|
+
**独自 DynamoDB テーブル。** プラグインが ampless スキーマ外に独自の DynamoDB テーブルを持つ場合、lifecycle 管理(アンインストール時の cleanup を含む)はプラグイン著者の責任です。ampless は外部テーブルを把握しておらず、将来の `uninstall` cleanup grant は上記 5 領域のみをカバーします。
|
|
1408
|
+
|
|
1409
|
+
詳細な設計根拠と IAM grant 設計については [`docs/architecture/08-plugin-architecture.ja.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.ja.md#プラグインが所有するデータ領域) を参照してください。
|
|
1410
|
+
|
|
1411
|
+
---
|
|
1412
|
+
|
|
1413
|
+
## 9c. `uninstall` フック(Phase 1 予約)
|
|
1414
|
+
|
|
1415
|
+
`AmplessPlugin.uninstall` は **Phase 1 型予約** です — 現在 runtime はこれを呼び出しません。hook 名とシグネチャをプラグインコードが出回る前に確定し、将来の lifecycle-dispatch PR が名前・形を変更せずに起動を配線できるようにするのが目的です。
|
|
1416
|
+
|
|
1417
|
+
**Phase 1 スコープ**: hook 名とシグネチャのみ予約。`ctx` には cleanup helper (`deletePublicAsset` / `deletePluginSetting` / `deletePluginSecret`) がまだありません — 今日 `await ctx.deletePublicAsset(...)` と書くと TypeScript エラーです。これらの helper が lifecycle-dispatch PR で追加される際は `PluginUninstallContext` への追加(additive)であり、空のボディを宣言済みのプラグインへの破壊変更はありません。
|
|
1418
|
+
|
|
1419
|
+
**今日の推奨宣言** — 空のボディ:
|
|
1420
|
+
|
|
1421
|
+
```ts
|
|
1422
|
+
// 例: S3 成果物と secret を書く trusted plugin
|
|
1423
|
+
definePlugin({
|
|
1424
|
+
name: 'my-trusted-plugin',
|
|
1425
|
+
apiVersion: 1,
|
|
1426
|
+
trust_level: 'trusted',
|
|
1427
|
+
capabilities: ['eventHooks', 'writePublicAsset', 'secretSettings'],
|
|
1428
|
+
hooks: { 'content.published': async (_evt, ctx) => { /* ... */ } },
|
|
1429
|
+
uninstall: async (_ctx) => {
|
|
1430
|
+
// Phase 1 予約: runtime は今日このフックを呼び出しません。
|
|
1431
|
+
// また `ctx` には cleanup helper
|
|
1432
|
+
// (`deletePublicAsset` / `deletePluginSetting` /
|
|
1433
|
+
// `deletePluginSecret`) がまだありません。
|
|
1434
|
+
// 空ボディを宣言しておくのが forward-compat の推奨形です —
|
|
1435
|
+
// 将来 lifecycle-dispatch PR がリリースされたとき、helper が
|
|
1436
|
+
// `PluginUninstallContext` に追加され、そこで cleanup ボディを
|
|
1437
|
+
// 実装します。今日の空宣言を再パブリッシュしなくても呼び出し
|
|
1438
|
+
// イベントは受け取れますが、実際の cleanup ボディを追加するには
|
|
1439
|
+
// 再パブリッシュが必要です。
|
|
1440
|
+
},
|
|
1441
|
+
})
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
**冪等性。** lifecycle-dispatch PR がリリースされると、`uninstall` フックは trusted Lambda の IAM コンテキストで実行されます。SQS 配送は at-least-once なので cleanup ボディが複数回実行される可能性があります。安全にリトライできるよう設計してください(S3 の `deleteObject` は冪等、DDB の conditional delete も key が消えていれば safe)。
|
|
1445
|
+
|
|
1446
|
+
---
|
|
1447
|
+
|
|
1448
|
+
## 9d. settings の形状を変えるとき(Phase 1 予約)
|
|
1449
|
+
|
|
1450
|
+
### 今日の挙動: `public` と `secret` は別の経路を辿る
|
|
1451
|
+
|
|
1452
|
+
形状変更は今日の runtime で silently 吸収されますが、実際の挙動は `settings.public` と `settings.secret` で異なります。両者は完全に別の write/read パスを使っているためです。
|
|
1453
|
+
|
|
1454
|
+
#### `settings.public`(`resolvePluginSettings` の寛容な resolver)
|
|
1455
|
+
|
|
1456
|
+
`resolvePluginSettings`([packages/ampless/src/plugin-settings.ts](packages/ampless/src/plugin-settings.ts))は `manifest.public` のみをイテレートし、field ごとに `field.default` にフォールバックします。resolver は `manifest.secret` を一切見ません。
|
|
1457
|
+
|
|
1458
|
+
| 変更 | 今日の挙動(public フィールド) |
|
|
1459
|
+
|---|---|
|
|
1460
|
+
| **フィールド追加** | 新フィールドは `manifest.default` から解決されます。ストレージに値がないため default が使われます。 |
|
|
1461
|
+
| **フィールド削除** | KvStore に orphan row が残ります。`resolvePluginSettings` は現在の manifest にない key を silently skip します。 |
|
|
1462
|
+
| **フィールド改名**(`endpoint` → `url`) | 削除 + 追加として扱われます。旧値は到達不能(orphan)になり、新フィールドは `default` から解決されます。 |
|
|
1463
|
+
| **型を非互換に変更** | 新しい validator がストレージの値に対して実行されます。通過すれば値が使われ、失敗すれば `default`(または `undefined`)にフォールバックします。 |
|
|
1464
|
+
|
|
1465
|
+
#### `settings.secret`(admin UI + `PluginSecret` + `ctx.secret()`、寛容な resolver なし)
|
|
1466
|
+
|
|
1467
|
+
Secret フィールドは `resolvePluginSettings` から一切読まれません。別の経路を辿ります:
|
|
1468
|
+
|
|
1469
|
+
- admin UI が `setPluginSecret` AppSync mutation 経由で値を 1 つずつ書き、`plugin-secret-handler` Lambda が暗号化して `PluginSecret` DynamoDB テーブルに格納
|
|
1470
|
+
- trusted hook が `ctx.secret<T>(key)` で key 単位で直接 `PluginSecret` から復号読み取り
|
|
1471
|
+
- `PluginSecretField` 型は `default` を持てない(型レベルで禁止)。manifest レベルのフォールバックは存在しない
|
|
1472
|
+
|
|
1473
|
+
| 変更 | 今日の挙動(secret フィールド) |
|
|
1474
|
+
|---|---|
|
|
1475
|
+
| **フィールド追加** | admin UI に新フィールドが表示されます。admin が値を設定するまで `ctx.secret<T>(key)` は `undefined` を返します。 |
|
|
1476
|
+
| **フィールド削除** | admin UI から消えますが、`PluginSecret` 内の暗号化された row は orphan として残ります。resolver は走らないため、operator が手動で削除する必要があります。 |
|
|
1477
|
+
| **フィールド改名** | 旧 key の暗号化された row は orphan になります(resolver / cleanup なし)。新 key は未設定として表示され、admin が新 key に値を入れ直す必要があります。 |
|
|
1478
|
+
| **型を非互換に変更** | `validatePluginSettingValue` は write 時のみ実行されます。既存の暗号化値は read 時には影響を受けず、`ctx.secret<T>(key)` は最後に書かれた値を返します。新しい入力を admin が保存しようとすると新 validator で reject される、というだけです。 |
|
|
1479
|
+
|
|
1480
|
+
これらのケースでエラーや警告は発生しません。形状変更後の挙動を確認するには、プラグイン著者が手動でストレージの値を確認する必要があります。
|
|
1481
|
+
|
|
1482
|
+
### `version` 予約について
|
|
1483
|
+
|
|
1484
|
+
`PluginSettingsManifest.version?: number` は **Phase 1 型予約** です。runtime は今日このフィールドを読みません。宣言しても上記の寛容な resolver の挙動は変わりません。
|
|
1485
|
+
|
|
1486
|
+
この予約は、将来の migration PR がストレージの値に manifest の version をどこかに保存し、resolve 時に `manifest.version` と比較してミスマッチを検出できるようにするためのものです。ミスマッチ時の応答(warn / skip / in-place migration / admin 主導フローなど)はその future PR の設計領域です。
|
|
1487
|
+
|
|
1488
|
+
**`version` を宣言しても今日保証されないこと:**
|
|
1489
|
+
|
|
1490
|
+
- migration ボディは実行されません。
|
|
1491
|
+
- ストレージの値の再検証・再 default も行われません。
|
|
1492
|
+
- `migrate` hook のシグネチャは予約されていません(それは別の future 設計)。
|
|
1493
|
+
|
|
1494
|
+
**`version` を今日宣言することで得られるもの:**
|
|
1495
|
+
|
|
1496
|
+
- その PR がリリースされた後に `version` フィールドを追加するためだけの再パブリッシュが不要になります(将来の migration 検出パスに最初から参加できます)。
|
|
1497
|
+
- manifest に versioned な形状であることを将来のメンテナーに伝えます。
|
|
1498
|
+
|
|
1499
|
+
### 推奨パターン
|
|
1500
|
+
|
|
1501
|
+
| シナリオ | 推奨 |
|
|
1502
|
+
|---|---|
|
|
1503
|
+
| 追加のみの変更(新しいオプションフィールド、default あり) | `version` の bump 不要。寛容な resolver が吸収します。 |
|
|
1504
|
+
| 非互換変更(改名、型変更、意味的変化) | `version` を 1 増やします。 |
|
|
1505
|
+
| 既存 manifest に初めて `version` を追加する | `version: 1` から始めます。 |
|
|
1506
|
+
|
|
1507
|
+
**正の整数、1 始まりを使用してください。** `0`、負数、小数は使わないでください。`number` 型はそれらを受け入れますが、将来の migration PR が `0` / undefined に特別な意味(legacy / pre-v1 との混同を避けるため)を予約する可能性があります。
|
|
1508
|
+
|
|
1509
|
+
### コード例
|
|
1510
|
+
|
|
1511
|
+
```ts
|
|
1512
|
+
definePlugin({
|
|
1513
|
+
name: 'my-plugin',
|
|
1514
|
+
apiVersion: 1,
|
|
1515
|
+
trust_level: 'untrusted',
|
|
1516
|
+
capabilities: ['adminSettings'],
|
|
1517
|
+
settings: {
|
|
1518
|
+
version: 2, // ← Phase 1 reservation。今日の runtime は無視します。
|
|
1519
|
+
public: [
|
|
1520
|
+
{ type: 'url', key: 'webhookUrl', label: 'Webhook URL', required: true },
|
|
1521
|
+
],
|
|
1522
|
+
},
|
|
1523
|
+
})
|
|
1524
|
+
```
|
|
710
1525
|
|
|
711
|
-
|
|
712
|
-
- フィールド型は `PluginSecretField` = `Omit<PluginTextField, 'default'> | Omit<PluginTextareaField, 'default'>`。`default` は型レベルで禁止。fallback は closure-private 変数で保持。
|
|
713
|
-
- admin ブラウザは AppSync mutation (`setPluginSecret`) 経由で書き込む → `plugin-secret-handler` Lambda が env var から鍵取得・AES-256-GCM 暗号化・DDB PutItem。平文は DDB に保存されずブラウザにも返らない。`ctx.secret<T>(key)` は trusted Lambda が復号した平文を返す(`process.env.PLUGIN_SECRET_ENCRYPTION_KEY` を使用; DDB に鍵は保存しない)。per-invocation でキャッシュ。
|
|
714
|
-
- admin UI が「保存済み」表示 (`••••••••`) + Replace + Clear を提供。値は取得・表示されない。
|
|
715
|
-
- **Dual-write 整合性**: set/clear は 2 テーブルに連続して書く。2 回目失敗時 — set パス部分失敗は「secret 機能する・indicator 不在(UI: 未保存誤表示)」; clear パス部分失敗は「secret 削除済・indicator stale(UI: 保存済み誤表示、secret は発火しない安全側)」。
|
|
716
|
-
- **field manifest 検証の範囲**: admin クライアントは UX フィードバック用に `pattern` / `maxLength` / `required` を検証するが、Lambda は **汎用 10,000 文字キャップと安全文字サニタイザのみ**を強制。admin/editor が AppSync mutation を直接呼ぶと field 単位の制約は迂回可能 — admin/editor は信頼されたオペレータと扱う設計のため、manifest チェックはセキュリティ境界ではなく UX ガイダンス。
|
|
1526
|
+
将来の migration PR がリリースされると、すでに `version` を宣言しているプラグインは自動的に検出パスに乗ります。実際の migration ボディを提供したいプラグインは、その PR がリリースされてから再パブリッシュしてボディを追加する必要があります。`version` を省略したプラグインは引き続き現在の寛容な resolver の挙動のままで変化ありません。
|
|
717
1527
|
|
|
718
1528
|
---
|
|
719
1529
|
|
|
@@ -815,8 +1625,8 @@ it('admin が空文字保存した場合は空配列', () => {
|
|
|
815
1625
|
|
|
816
1626
|
- **パッケージ名**: `@your-scope/plugin-foo`。`@ampless/plugin-*` スコープは本モノレポから ship する公式プラグイン用に予約
|
|
817
1627
|
- **エントリ**: ESM のみ、default export (factory) + 設定インターフェイス (ユーザの `cms.config.ts` から型付きで引数を渡せるように) を export
|
|
818
|
-
- **`apiVersion`**:
|
|
819
|
-
- **Dist-tag**: ampless 自体が
|
|
1628
|
+
- **`apiVersion`**: 現状は `1` を declare してください — 唯一の有効値で、literal type が他の値を compile-time に reject します。`apiVersion` はプラグイン契約の **breaking-change marker** であって semver 風のチャンネルではありません。additive な追加 (optional field、reserved capability など) は `apiVersion: 1` 内に収まり、bump は不要です。詳細は architecture doc の [apiVersion bump policy](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#apiversion-bump-policy) を参照
|
|
1629
|
+
- **Dist-tag**: ampless 自体が beta のうちは `@beta`。`@latest` は ampless v1.0 まで予約
|
|
820
1630
|
|
|
821
1631
|
参考実装:
|
|
822
1632
|
|
|
@@ -825,7 +1635,7 @@ it('admin が空文字保存した場合は空配列', () => {
|
|
|
825
1635
|
- [`packages/plugin-plausible`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-plausible) — `data-*` attrs 付きの単一 `<script>` descriptor、`required` な URL field(self-hosted Plausible 上書き対応)
|
|
826
1636
|
- [`packages/plugin-rss`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-rss) — trusted、非同期 hooks + `writePublicAsset`
|
|
827
1637
|
- [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`
|
|
828
|
-
- [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) —
|
|
1638
|
+
- [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — trusted hook + 外向き HTTP + `secretSettings` (admin 管理の signing secret、Phase 6a)
|
|
829
1639
|
- [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` ルートレンダラ
|
|
830
1640
|
- [`packages/plugin-schema-jsonld`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-schema-jsonld) — `publicBodyForPost` + `schema` capability、投稿単位 Article JSON-LD。(Phase 4)
|
|
831
1641
|
|
|
@@ -857,7 +1667,7 @@ it('admin が空文字保存した場合は空配列', () => {
|
|
|
857
1667
|
```bash
|
|
858
1668
|
# サイトローカル: 現在の ampless サイトのルートで実行
|
|
859
1669
|
# plugins/<name>/index.ts を生成する
|
|
860
|
-
npx create-ampless@
|
|
1670
|
+
npx create-ampless@beta plugin my-thing \
|
|
861
1671
|
--trust-level untrusted \
|
|
862
1672
|
--capabilities publicHead,adminSettings
|
|
863
1673
|
|
|
@@ -865,7 +1675,7 @@ npx create-ampless@latest plugin my-thing \
|
|
|
865
1675
|
# package.json / tsconfig.json / tsup.config.ts / README + .ja /
|
|
866
1676
|
# CHANGELOG / .gitignore / src/index.ts + src/index.test.ts を含む
|
|
867
1677
|
# 新しいパッケージディレクトリを置きたい場所で実行する
|
|
868
|
-
npx create-ampless@
|
|
1678
|
+
npx create-ampless@beta plugin @myscope/ampless-plugin-thing \
|
|
869
1679
|
--standalone \
|
|
870
1680
|
--trust-level untrusted \
|
|
871
1681
|
--capabilities publicHead,adminSettings \
|
|
@@ -874,7 +1684,7 @@ npx create-ampless@latest plugin @myscope/ampless-plugin-thing \
|
|
|
874
1684
|
|
|
875
1685
|
スタンドアロンスキャフォールドには Phase 5 のクロスチェックに必要なものがすべて含まれます: `package.json#amplessPlugin`、`./package.json` サブパスエクスポート、`packageName` ファクトリフィールド、`ampless-plugin` 検索キーワード、そして `pnpm install && pnpm test && pnpm build` が生成直後にクリーンに通る最小の vitest サンプル。
|
|
876
1686
|
|
|
877
|
-
どちらのモードも、フラグなしの位置引数呼び出し (`npx create-ampless@
|
|
1687
|
+
どちらのモードも、フラグなしの位置引数呼び出し (`npx create-ampless@beta plugin`) で @clack のプロンプト UI を使ったインタラクティブモードに切り替えられます。
|
|
878
1688
|
|
|
879
1689
|
### スタンドアロンプラグインの公開
|
|
880
1690
|
|
|
@@ -883,12 +1693,12 @@ cd ampless-plugin-thing
|
|
|
883
1693
|
pnpm install
|
|
884
1694
|
pnpm test
|
|
885
1695
|
pnpm build
|
|
886
|
-
pnpm publish --access public --tag
|
|
1696
|
+
pnpm publish --access public --tag beta
|
|
887
1697
|
```
|
|
888
1698
|
|
|
889
|
-
スコープ付き名前 (`@scope/...`) には `--access public` が必須です。`--tag
|
|
1699
|
+
スコープ付き名前 (`@scope/...`) には `--access public` が必須です。`--tag beta` は現在の ampless プレリリースサイクルに合わせています — 安定 major に達したら外してください。
|
|
890
1700
|
|
|
891
|
-
`npm publish` が返った直後に `npm install <pkg>@
|
|
1701
|
+
`npm publish` が返った直後に `npm install <pkg>@beta` で 404 が出ることがあります(CDN とレジストリレプリカの伝播遅延)。その場合は 1〜2 分待ってリトライしてください — `npm view <pkg>@beta version` がレジストリで見えていることは必要条件ですが十分条件ではありません。
|
|
892
1702
|
|
|
893
1703
|
### パッケージの命名
|
|
894
1704
|
|
|
@@ -931,4 +1741,4 @@ export default defineConfig({
|
|
|
931
1741
|
- ファーストパーティプラグインの bug → `heavymoons/ampless` にプラグインの package 名つきで issue
|
|
932
1742
|
- プラグインランタイム / admin form の bug → 同じレポ、ラベル `area:plugins`
|
|
933
1743
|
|
|
934
|
-
|
|
1744
|
+
上記の GitHub URL は public beta repo で解決されます。同じ docs は package tarball 内の `node_modules/ampless/docs/` にも同梱されているので、この repo を checkout しなくてもローカルで読めます。
|