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