create-ampless 0.2.0-alpha.9 → 1.0.0-alpha.101

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.
Files changed (137) hide show
  1. package/README.ja.md +77 -0
  2. package/README.md +6 -3
  3. package/dist/index.js +743 -84
  4. package/dist/templates/_shared/AGENTS.ja.md +94 -0
  5. package/dist/templates/_shared/AGENTS.md +94 -0
  6. package/dist/templates/_shared/README.ja.md +239 -0
  7. package/dist/templates/_shared/README.md +239 -0
  8. package/dist/templates/_shared/RUNBOOK.ja.md +120 -0
  9. package/dist/templates/_shared/RUNBOOK.md +32 -58
  10. package/dist/templates/_shared/THEMES.ja.md +495 -0
  11. package/dist/templates/_shared/THEMES.md +523 -0
  12. package/dist/templates/_shared/amplify/auth/post-confirmation/resource.ts +7 -0
  13. package/dist/templates/_shared/amplify/backend.ts +10 -0
  14. package/dist/templates/_shared/amplify/data/get-media-by-src.js +45 -0
  15. package/dist/templates/_shared/amplify/data/get-published-post.js +9 -12
  16. package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +6 -9
  17. package/dist/templates/_shared/amplify/data/list-published-posts.js +10 -11
  18. package/dist/templates/_shared/amplify/data/resource.custom.ts +1 -2
  19. package/dist/templates/_shared/amplify/data/resource.ts +28 -7
  20. package/dist/templates/_shared/amplify/events/dispatcher/resource.ts +1 -0
  21. package/dist/templates/_shared/amplify/events/processor-trusted/resource.ts +1 -0
  22. package/dist/templates/_shared/amplify/events/processor-untrusted/resource.ts +5 -0
  23. package/dist/templates/_shared/amplify/functions/api-key-renewer/resource.ts +1 -0
  24. package/dist/templates/_shared/amplify/functions/mcp-handler/handler.ts +1 -0
  25. package/dist/templates/_shared/amplify/functions/mcp-handler/resource.ts +12 -0
  26. package/dist/templates/_shared/amplify/functions/plugin-secret-handler/handler.ts +11 -0
  27. package/dist/templates/_shared/amplify/functions/plugin-secret-handler/resource.ts +12 -0
  28. package/dist/templates/_shared/amplify/functions/user-admin/resource.ts +7 -0
  29. package/dist/templates/_shared/amplify/secrets/.gitkeep +0 -0
  30. package/dist/templates/_shared/amplify/secrets/encryption-key.ts +9 -0
  31. package/dist/templates/_shared/app/(admin)/admin/mcp-tokens/page.tsx +5 -0
  32. package/dist/templates/_shared/app/(admin)/admin/plugins/page.tsx +5 -0
  33. package/dist/templates/_shared/app/globals.css +55 -39
  34. package/dist/templates/_shared/app/layout.tsx +51 -16
  35. package/dist/templates/_shared/app/providers.tsx +0 -2
  36. package/dist/templates/_shared/app/raw/[slug]/route.ts +10 -0
  37. package/dist/templates/_shared/app/static/[slug]/[[...path]]/route.ts +14 -0
  38. package/dist/templates/_shared/cms.config.ts +64 -23
  39. package/dist/templates/_shared/components/site-chrome/site-sidebar.tsx +3 -4
  40. package/dist/templates/_shared/components/tag-list.tsx +9 -2
  41. package/dist/templates/_shared/components.json +1 -1
  42. package/dist/templates/_shared/docs/plugin-author-guide.ja.md +934 -0
  43. package/dist/templates/_shared/docs/plugin-author-guide.md +1336 -0
  44. package/dist/templates/_shared/lib/admin.ts +12 -12
  45. package/dist/templates/_shared/lib/ampless.ts +2 -8
  46. package/dist/templates/_shared/lib/amplify.ts +0 -5
  47. package/dist/templates/_shared/package.json +51 -43
  48. package/dist/templates/_shared/plugins/README.ja.md +139 -0
  49. package/dist/templates/_shared/plugins/README.md +143 -0
  50. package/dist/templates/_shared/proxy.ts +23 -8
  51. package/dist/templates/blog/README.ja.md +22 -0
  52. package/dist/templates/blog/README.md +17 -47
  53. package/dist/templates/blog/manifest.ts +1 -1
  54. package/dist/templates/blog/pages/feed.ts +4 -5
  55. package/dist/templates/blog/pages/home.tsx +10 -15
  56. package/dist/templates/blog/pages/post.tsx +42 -14
  57. package/dist/templates/blog/pages/sitemap.ts +4 -5
  58. package/dist/templates/blog/pages/tag.tsx +8 -10
  59. package/dist/templates/blog/tokens.css +26 -40
  60. package/dist/templates/corporate/README.ja.md +18 -0
  61. package/dist/templates/corporate/README.md +12 -14
  62. package/dist/templates/corporate/pages/feed.ts +3 -4
  63. package/dist/templates/corporate/pages/home.tsx +7 -12
  64. package/dist/templates/corporate/pages/post.tsx +20 -14
  65. package/dist/templates/corporate/pages/sitemap.ts +3 -4
  66. package/dist/templates/corporate/pages/tag.tsx +8 -10
  67. package/dist/templates/corporate/tokens.css +17 -39
  68. package/dist/templates/dads/README.ja.md +31 -0
  69. package/dist/templates/dads/README.md +13 -17
  70. package/dist/templates/dads/pages/feed.ts +3 -4
  71. package/dist/templates/dads/pages/home.tsx +7 -12
  72. package/dist/templates/dads/pages/post.tsx +20 -14
  73. package/dist/templates/dads/pages/sitemap.ts +3 -4
  74. package/dist/templates/dads/pages/tag.tsx +8 -10
  75. package/dist/templates/dads/tokens.css +22 -42
  76. package/dist/templates/docs/README.ja.md +24 -0
  77. package/dist/templates/docs/README.md +10 -13
  78. package/dist/templates/docs/pages/feed.ts +3 -4
  79. package/dist/templates/docs/pages/home.tsx +6 -9
  80. package/dist/templates/docs/pages/post.tsx +19 -13
  81. package/dist/templates/docs/pages/sitemap.ts +3 -4
  82. package/dist/templates/docs/pages/tag.tsx +8 -10
  83. package/dist/templates/docs/tokens.css +17 -39
  84. package/dist/templates/landing/README.ja.md +20 -0
  85. package/dist/templates/landing/README.md +14 -19
  86. package/dist/templates/landing/pages/feed.ts +4 -5
  87. package/dist/templates/landing/pages/home.tsx +7 -12
  88. package/dist/templates/landing/pages/post.tsx +20 -14
  89. package/dist/templates/landing/pages/sitemap.ts +3 -4
  90. package/dist/templates/landing/pages/tag.tsx +8 -10
  91. package/dist/templates/landing/tokens.css +17 -39
  92. package/dist/templates/minimal/README.ja.md +14 -0
  93. package/dist/templates/minimal/README.md +9 -47
  94. package/dist/templates/minimal/pages/feed.ts +4 -5
  95. package/dist/templates/minimal/pages/home.tsx +6 -8
  96. package/dist/templates/minimal/pages/post.tsx +19 -12
  97. package/dist/templates/minimal/pages/sitemap.ts +4 -5
  98. package/dist/templates/minimal/pages/tag.tsx +7 -8
  99. package/dist/templates/minimal/tokens.css +17 -39
  100. package/dist/templates/plugin-local/README.md +34 -0
  101. package/dist/templates/plugin-local/index.ts +39 -0
  102. package/dist/templates/plugin-standalone/CHANGELOG.md +1 -0
  103. package/dist/templates/plugin-standalone/README.ja.md +43 -0
  104. package/dist/templates/plugin-standalone/README.md +43 -0
  105. package/dist/templates/plugin-standalone/package.json +47 -0
  106. package/dist/templates/plugin-standalone/src/index.test.ts +16 -0
  107. package/dist/templates/plugin-standalone/src/index.ts +29 -0
  108. package/dist/templates/plugin-standalone/tsconfig.json +16 -0
  109. package/dist/templates/plugin-standalone/tsup.config.ts +8 -0
  110. package/package.json +1 -1
  111. package/dist/templates/_shared/app/(admin)/admin/sites/page.tsx +0 -5
  112. package/dist/templates/_shared/app/site/[siteId]/raw/[slug]/route.ts +0 -5
  113. package/dist/templates/_shared/components/i18n-provider.tsx +0 -15
  114. package/dist/templates/_shared/lib/admin-site-client.ts +0 -10
  115. package/dist/templates/_shared/lib/admin-site.ts +0 -12
  116. package/dist/templates/_shared/lib/amplify-server.ts +0 -7
  117. package/dist/templates/_shared/lib/auth-server.ts +0 -15
  118. package/dist/templates/_shared/lib/cn.ts +0 -5
  119. package/dist/templates/_shared/lib/i18n.ts +0 -35
  120. package/dist/templates/_shared/lib/kv-provider.ts +0 -7
  121. package/dist/templates/_shared/lib/media.ts +0 -6
  122. package/dist/templates/_shared/lib/posts-provider.ts +0 -7
  123. package/dist/templates/_shared/lib/posts-public.ts +0 -27
  124. package/dist/templates/_shared/lib/posts.ts +0 -12
  125. package/dist/templates/_shared/lib/seo.ts +0 -11
  126. package/dist/templates/_shared/lib/site-settings.ts +0 -11
  127. package/dist/templates/_shared/lib/storage.ts +0 -10
  128. package/dist/templates/_shared/lib/theme-actions.ts +0 -5
  129. package/dist/templates/_shared/lib/theme-active.ts +0 -10
  130. package/dist/templates/_shared/lib/theme-config.ts +0 -12
  131. package/dist/templates/_shared/lib/upload.ts +0 -6
  132. /package/dist/templates/_shared/app/{site/[siteId]/[slug] → [slug]}/page.tsx +0 -0
  133. /package/dist/templates/_shared/app/{site/[siteId]/feed.xml → feed.xml}/route.ts +0 -0
  134. /package/dist/templates/_shared/app/{site/[siteId]/og → og}/[slug]/route.ts +0 -0
  135. /package/dist/templates/_shared/app/{site/[siteId]/page.tsx → page.tsx} +0 -0
  136. /package/dist/templates/_shared/app/{site/[siteId]/sitemap.xml → sitemap.xml}/route.ts +0 -0
  137. /package/dist/templates/_shared/app/{site/[siteId]/tag → tag}/[tag]/page.tsx +0 -0
@@ -0,0 +1,1336 @@
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–4 surfaces — descriptor-based `<head>` / `<body>` /
16
+ per-post body 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
+ ## 0. Theme vs Plugin Boundary
25
+
26
+ ampless ships both themes and plugins. Picking the right tool keeps
27
+ your code where future-you (and other site authors) will look for it.
28
+
29
+ | Want to change... | Use a theme | Use a plugin |
30
+ |---|---|---|
31
+ | Layout, typography, colour, per-route UI | ✓ | |
32
+ | Custom components on home / post / tag pages | ✓ | |
33
+ | Admin-editable settings exposed to non-developers | | ✓ (`adminSettings`) |
34
+ | Background work after content events (RSS, search index, webhooks) | | ✓ (`eventHooks`) |
35
+ | Trusted side effects (S3 writes, external API push) | | ✓ (`writePublicAsset` + `trusted`) |
36
+ | Theme-independent `<head>` / `<body>` injection (analytics, consent) | | ✓ (`publicHead` / `publicBodyEnd`) |
37
+ | Per-post machine-readable metadata (JSON-LD, etc.) | | ✓ (`schema` via `publicBodyForPost`) |
38
+ | Per-post visible HTML around the body (reading-time, breadcrumb, share) | | ✓ (`publicHtmlForPost`) |
39
+ | Code you want to share across multiple ampless sites | | ✓ (publish as npm package) |
40
+
41
+ Rule of thumb:
42
+
43
+ - Theme = **what the page looks like**. Read-only at render time.
44
+ - Plugin = **what happens beyond rendering**: admin-editable config,
45
+ background processing, theme-agnostic injection, machine-readable
46
+ metadata, cross-site reusability.
47
+
48
+ The two boundaries that frequently catch new authors:
49
+
50
+ - **Storage / DynamoDB / external API writes belong in a plugin**, never
51
+ a theme. Themes only read.
52
+ - **A feature you want admins to toggle from `/admin/plugins` belongs
53
+ in a plugin**, even if its visible effect is purely cosmetic. Themes
54
+ carry their own settings, but those are theme-display settings, not
55
+ site-operational ones.
56
+
57
+ Some features genuinely sit at the boundary — see [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md)
58
+ for the longer discussion.
59
+
60
+ ---
61
+
62
+ ## 1. What a plugin can do
63
+
64
+ ampless plugins are written in one of three places — pick the one
65
+ that matches how widely you want the code shared:
66
+
67
+ | Where | Use when | Lives in |
68
+ |---|---|---|
69
+ | **First-party** | Building on the ampless core for everyone | `packages/plugin-*/` in the ampless monorepo |
70
+ | **Site-local** | Site-specific customisation, no separate publish | `plugins/<name>/` inside your site repo |
71
+ | **External npm package** | Sharing with other sites, ready to `npm publish` | A standalone repo (`@scope/ampless-plugin-foo`) |
72
+
73
+ All three forms call the same `definePlugin({...})` factory and use the
74
+ same surfaces. The differences are packaging, distribution, and the
75
+ opt-in install-time validation that the static `package.json#amplessPlugin`
76
+ manifest enables (see §3 below).
77
+
78
+ §14 below has a one-command scaffold (`npx create-ampless plugin <name>`)
79
+ that produces ready-to-use boilerplate for either of the latter two.
80
+
81
+ An ampless plugin is a TypeScript module that returns an
82
+ `AmplessPlugin` object. It plugs into one or more of the following
83
+ surfaces:
84
+
85
+ | Surface | Where | Sync / async | Phase |
86
+ |---|---|---|---|
87
+ | `metadata(post, site)` | `generateMetadata()` per post | sync | Existing |
88
+ | `siteMetadata(site)` | root layout `generateMetadata()` | sync | Existing |
89
+ | `publicHead(ctx)` | root layout `<head>` | sync (called from async layout) | 1 |
90
+ | `publicBodyEnd(ctx)` | root layout end of `<body>` | sync | 1 |
91
+ | `publicBodyForPost(post, ctx)` | theme post page template (per-post) | sync | 4 |
92
+ | `publicHtmlForPost(post, ctx)` | theme post page template (per-post, visible HTML) | sync | 6d |
93
+ | `ogImage` | `/og/[slug]` route | request-time, in public Lambda | Existing |
94
+ | `hooks` | trust_level-matched processor Lambda | async, on SQS event | Existing |
95
+ | `settings.public` | `/admin/plugins` form | declarative manifest | 2 |
96
+ | `settings.secret` | `/admin/plugins` secret section | declarative manifest, trusted Lambda only | 6a |
97
+
98
+ A few surfaces don't exist yet — they're reserved for later phases
99
+ and aren't shaped by `definePlugin` today:
100
+
101
+ - **Arbitrary `ReactNode` injection into pages.** The sync render
102
+ surfaces (`publicHead`, `publicBodyEnd`, `publicBodyForPost`) only
103
+ return descriptor variants. The descriptor validator is the safety
104
+ boundary for what the runtime renders into the page; it isn't a JS
105
+ sandbox around the plugin itself. (Plugins run as ordinary
106
+ TypeScript in the same Node process as the rest of the site.)
107
+ - **Network inside the sync render surfaces.** The sync surfaces are
108
+ shaped for declarative output — they don't take a Promise and there's
109
+ no async result path, so doing `await fetch(...)` inside `publicHead`
110
+ blocks SSR with no way to surface a deadline. Trusted Lambdas
111
+ (`hooks`) are where the runtime expects outbound HTTP. Background
112
+ work belongs there.
113
+ - **Admin routes / server routes / content fields.** Reserved for
114
+ Phase 6b.
115
+
116
+ ---
117
+
118
+ ## 2. Minimum file layout
119
+
120
+ The fastest way to create a plugin is to scaffold one:
121
+
122
+ ```bash
123
+ # Site-local (writes plugins/<name>/index.ts in the current ampless site)
124
+ npx create-ampless@latest plugin my-thing
125
+
126
+ # Standalone npm package (writes ./<name>/ ready for `npm publish`)
127
+ npx create-ampless@latest plugin @myscope/ampless-plugin-my-thing --standalone
128
+ ```
129
+
130
+ §14 covers the full flow. The rest of this section explains what the
131
+ generated files mean, so you can author one by hand if you prefer.
132
+
133
+ ### Site-local
134
+
135
+ ```
136
+ plugins/
137
+ my-thing/
138
+ index.ts # the factory; that's the whole plugin
139
+ ```
140
+
141
+ The site's `package.json` / `tsconfig.json` already cover compilation —
142
+ nothing else needs to ship. Register it from `cms.config.ts` with a
143
+ relative import.
144
+
145
+ ### Standalone npm package
146
+
147
+ ```
148
+ ampless-plugin-my-thing/
149
+ package.json
150
+ tsconfig.json
151
+ tsup.config.ts
152
+ README.md
153
+ CHANGELOG.md
154
+ src/
155
+ index.ts
156
+ index.test.ts
157
+ ```
158
+
159
+ See `packages/plugin-rss/` and `packages/plugin-analytics-ga4/` in
160
+ this repo for working first-party examples — the standalone scaffold
161
+ mirrors their layout.
162
+
163
+ The bare-minimum `src/index.ts`:
164
+
165
+ ```ts
166
+ import { definePlugin, type AmplessPlugin } from 'ampless'
167
+
168
+ export default function myPlugin(): AmplessPlugin {
169
+ return definePlugin({
170
+ name: 'my-plugin',
171
+ apiVersion: 1,
172
+ trust_level: 'untrusted',
173
+ capabilities: ['publicHead'],
174
+ publicHead() {
175
+ return [{ type: 'meta', name: 'x-plugin', content: 'hi' }]
176
+ },
177
+ })
178
+ }
179
+ ```
180
+
181
+ Drop it into `cms.config.ts`:
182
+
183
+ ```ts
184
+ import myPlugin from 'my-plugin'
185
+
186
+ export default defineConfig({
187
+ site: { name: 'My Blog', url: 'https://example.com' },
188
+ plugins: [myPlugin()],
189
+ })
190
+ ```
191
+
192
+ That's it. Restart `npm run dev`, view source on any page, and the
193
+ `<meta name="x-plugin" content="hi" />` is in the rendered `<head>`.
194
+
195
+ ---
196
+
197
+ ## 3. The `AmplessPlugin` manifest
198
+
199
+ ```ts
200
+ interface AmplessPlugin {
201
+ name: string // package-like identifier, e.g. 'analytics-ga4'
202
+ packageName?: string // npm package name for install-time cross-check
203
+ apiVersion: 1 // bump only when the contract changes
204
+ trust_level: 'untrusted' | 'trusted' | 'privileged'
205
+ instanceId?: string // namespace for multi-instance installs
206
+ displayName?: LocalizedString // admin UI label
207
+ capabilities?: readonly PluginCapability[]
208
+ hooks?: { ... } // async events
209
+ metadata?(post, site): PluginMetadata
210
+ siteMetadata?(site): PluginMetadata
211
+ publicHead?(ctx): readonly PublicHeadDescriptor[]
212
+ publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
213
+ publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
214
+ publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
215
+ ogImage?: OgImageConfig
216
+ settings?: { public?: readonly PluginSettingField[] }
217
+ }
218
+ ```
219
+
220
+ ### `name`
221
+
222
+ A short identifier (`'analytics-ga4'`, `'rss'`, `'webhook'`). Used
223
+ as the default `instanceId` and as the trusted-processor S3 prefix
224
+ under `public/plugins/<name>/`. Must match `/^[a-zA-Z0-9_-]+$/` —
225
+ see "Naming rules" below.
226
+
227
+ ### `apiVersion: 1`
228
+
229
+ There's only one version today. Future breaking-change versions will
230
+ bump this number; the runtime rejects unknown values rather than
231
+ silently mis-binding.
232
+
233
+ ### `instanceId`
234
+
235
+ Optional, defaults to `name`. When you ship a plugin that can run
236
+ twice on the same site (e.g. two GA4 measurement IDs, or one
237
+ webhook endpoint per chat platform), let the host customise
238
+ `instanceId` so each install gets its own storage namespace:
239
+
240
+ ```ts
241
+ analyticsGa4Plugin({ instanceId: 'marketing' })
242
+ analyticsGa4Plugin({ instanceId: 'product' })
243
+ ```
244
+
245
+ Both `instanceId` and `name` must satisfy `/^[a-zA-Z0-9_-]+$/`. Dots
246
+ break the `pk='siteconfig', sk='plugins.<id>.<key>'` separator;
247
+ scopes (`@foo/bar`) and slashes are reserved.
248
+
249
+ ### `displayName`
250
+
251
+ What `/admin/plugins` shows as the panel heading. A plain string is
252
+ fine for single-locale plugins; the per-locale map form
253
+ (`{ en: 'GA4', ja: 'GA4' }`) reads from the admin's active locale.
254
+
255
+ ### `packageName`
256
+
257
+ Optional. When set, the runtime resolves
258
+ `<packageName>/package.json` at startup and cross-checks the static
259
+ `amplessPlugin` block there against the factory return value. This
260
+ catches install-time mistakes a plugin author would otherwise only see
261
+ at runtime (or never — capability mismatches don't crash, they just
262
+ quietly skip the surface).
263
+
264
+ For standalone plugins, set this to the npm package name your
265
+ `package.json#name` declares:
266
+
267
+ ```ts
268
+ return definePlugin({
269
+ name: 'site-verification',
270
+ packageName: '@ishinao/ampless-plugin-site-verification',
271
+ apiVersion: 1,
272
+ // ...
273
+ })
274
+ ```
275
+
276
+ Site-local plugins don't need it — leave it unset and the cross-check
277
+ is skipped (backward compatible, identical to plugins predating Phase
278
+ 5).
279
+
280
+ ### Static manifest in `package.json` (standalone plugins only)
281
+
282
+ For the cross-check to find the static manifest, two things must be
283
+ true about your published package:
284
+
285
+ 1. `package.json#amplessPlugin` declares the same fields the factory
286
+ returns:
287
+
288
+ ```json
289
+ "amplessPlugin": {
290
+ "apiVersion": 1,
291
+ "name": "site-verification",
292
+ "trustLevel": "untrusted",
293
+ "capabilities": ["publicHead", "adminSettings"],
294
+ "displayName": { "en": "Site verification", "ja": "サイト所有権確認" }
295
+ }
296
+ ```
297
+
298
+ 2. `package.json#exports` explicitly exposes `./package.json`:
299
+
300
+ ```json
301
+ "exports": {
302
+ ".": {
303
+ "import": "./dist/index.js",
304
+ "types": "./dist/index.d.ts"
305
+ },
306
+ "./package.json": "./package.json"
307
+ }
308
+ ```
309
+
310
+ Without this, Node's package-exports gating rejects
311
+ `import.meta.resolve('<pkg>/package.json')` with
312
+ `ERR_PACKAGE_PATH_NOT_EXPORTED` and the runtime silently skips the
313
+ cross-check (your plugin still runs; you just don't get the
314
+ install-time guard).
315
+
316
+ The `create-ampless plugin --standalone` scaffold ships both correctly.
317
+ Add a `"ampless-plugin"` entry to your `package.json#keywords` while
318
+ you're there — it's the convention used by npm searches surfacing
319
+ ampless plugins.
320
+
321
+ What the runtime checks:
322
+
323
+ | Field | Mismatch behaviour |
324
+ |---|---|
325
+ | `apiVersion` (factory vs manifest) | **Throws** at startup |
326
+ | `apiVersion` (newer than runtime supports) | **Throws** at startup |
327
+ | `name` | Warns in dev |
328
+ | `trustLevel` | Warns in dev |
329
+ | `capabilities` (set comparison) | Warns in dev |
330
+
331
+ The two `apiVersion` cases are the only failure that aborts startup —
332
+ they protect against loading a plugin built for an ampless API the
333
+ runtime can't speak. Everything else is a developer-visible warning,
334
+ not a runtime block.
335
+
336
+ ---
337
+
338
+ ## 4. Picking a `trust_level`
339
+
340
+ Three tiers, picked by what the plugin needs to do **inside
341
+ event hooks** (the sync surfaces — metadata, head, body — don't
342
+ touch IAM):
343
+
344
+ | Tier | IAM | Used by |
345
+ |---|---|---|
346
+ | `untrusted` | none (SQS consume only) | head/body descriptors, webhook delivery, content transforms |
347
+ | `trusted` | read posts, write `public/plugins/<instanceId ?? name>/...` | RSS feed, sitemap, computed JSON indexes |
348
+ | `privileged` | reserved | future: SES, secrets, private S3 |
349
+
350
+ Rule of thumb:
351
+
352
+ - **You only need `publicHead` / `publicBodyEnd` / `metadata`** →
353
+ `untrusted`.
354
+ - **You need to read posts from a hook (e.g. rebuild a feed on
355
+ publish)** → `trusted`.
356
+ - **You need to call AWS APIs other than S3 PutObject on
357
+ `public/plugins/*`** → don't ship a plugin yet; wait for the
358
+ privileged tier or write the integration outside the plugin
359
+ system.
360
+
361
+ A plugin running at the wrong trust level either has no permission
362
+ to do its job (silent failure inside the Lambda) or carries
363
+ permissions it didn't need. Both are fixable by switching tiers and
364
+ redeploying.
365
+
366
+ ---
367
+
368
+ ## 5. Sync surfaces
369
+
370
+ These run inside the **public Next.js process** (the site visitor's
371
+ request thread) and execute synchronously. They aren't designed to
372
+ do network I/O: there's no async result path, so an `await fetch(...)`
373
+ inside `publicHead` would block SSR with no deadline. Side effects
374
+ that need network calls go in `hooks` (trusted Lambdas, async).
375
+ Public-process plugin code runs with the public-page IAM role — no
376
+ elevated AWS access.
377
+
378
+ | Surface | Returns | Use case |
379
+ |---|---|---|
380
+ | `metadata(post, site)` | `PluginMetadata` (Next.js `Metadata`-shaped) | Per-post `<title>` / OGP / Twitter / canonical |
381
+ | `siteMetadata(site)` | `PluginMetadata` | Site-wide `<title>` / favicon / RSS `<link rel="alternate">` |
382
+ | `publicHead(ctx)` | `PublicHeadDescriptor[]` | Analytics loader, fonts, jsonld, hreflang |
383
+ | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script frame, chat widgets, tail snippets |
384
+ | `publicBodyForPost(post, ctx)` | `PublicPostBodyDescriptor[]` | Per-post body injection — JSON-LD structured data; rendered by the theme's post page template |
385
+ | `publicHtmlForPost(post, ctx)` | `PublicPostHtmlDescriptor[]` | Per-post visible HTML at `beforeContent` / `afterContent` — reading-time badge, breadcrumb, share links. Bodies are sanitized by the runtime under a strict `sanitize-html` allowlist |
386
+
387
+ The `ctx` object carries:
388
+
389
+ ```ts
390
+ {
391
+ site: Config['site'] // name / url / description
392
+ setting<T>(key: string): T | undefined
393
+ }
394
+ ```
395
+
396
+ `ctx.setting()` is the Phase 2 admin-managed values accessor — see
397
+ §8.
398
+
399
+ ---
400
+
401
+ ## 6. Descriptor reference
402
+
403
+ `publicHead` and `publicBodyEnd` return **descriptor objects**, not
404
+ `ReactNode`. The runtime validates them (URL scheme denylist, attrs
405
+ allowlist, dedup by id) and then builds the React elements itself.
406
+ This is the safety boundary for the **HTML output** a plugin can
407
+ contribute — it bounds what the runtime emits, not what the plugin's
408
+ own code does. Plugins run as ordinary TypeScript in the same Node
409
+ process as the rest of the site; the descriptor pipeline keeps the
410
+ public-page surface narrow and auditable without depending on a JS
411
+ sandbox.
412
+
413
+ ### Common variants
414
+
415
+ ```ts
416
+ // External script
417
+ {
418
+ type: 'script',
419
+ id: 'ga4-loader-analytics-ga4',
420
+ src: 'https://www.googletagmanager.com/gtag/js?id=G-XXX',
421
+ strategy: 'afterInteractive', // or 'lazyOnload'
422
+ async: true, // optional; strategy implies it
423
+ defer: false,
424
+ attrs: { crossorigin: 'anonymous' },
425
+ }
426
+
427
+ // Inline script — id REQUIRED so two plugins emitting near-identical
428
+ // snippets are dedup-able
429
+ {
430
+ type: 'inlineScript',
431
+ id: 'ga4-init-analytics-ga4',
432
+ body: "/* one-line bootstrap */",
433
+ strategy: 'afterInteractive',
434
+ }
435
+
436
+ // Inline script — JSON-LD variant (valid in publicHead, publicBodyEnd, publicBodyForPost)
437
+ // The runtime auto-escapes the body; return raw JSON, not pre-escaped.
438
+ {
439
+ type: 'inlineScript',
440
+ id: 'schema-article',
441
+ scriptType: 'application/ld+json',
442
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
443
+ }
444
+
445
+ // Meta / link / noscript
446
+ { type: 'meta', name: 'theme-color', content: '#fff' }
447
+ { type: 'meta', property: 'og:image', content: 'https://…' }
448
+ { type: 'link', rel: 'preconnect', href: 'https://cdn.example.com' }
449
+ { type: 'noscript', id: 'gtm-fallback-msg', html: '<p>JS required</p>' }
450
+ ```
451
+
452
+ ### Body-only variant
453
+
454
+ ```ts
455
+ // iframe — for GTM no-script fallback, chat widgets, etc.
456
+ {
457
+ type: 'iframe',
458
+ id: 'gtm-fallback',
459
+ src: 'https://www.googletagmanager.com/ns.html?id=GTM-XYZ',
460
+ height: 0,
461
+ width: 0,
462
+ attrs: { sandbox: 'allow-scripts' },
463
+ }
464
+ ```
465
+
466
+ ### `PublicPostBodyDescriptor` (Phase 4)
467
+
468
+ `publicBodyForPost` returns `PublicPostBodyDescriptor[]`. This is a
469
+ restricted subset of `inlineScript` where `scriptType` is required
470
+ and must be `'application/ld+json'`:
471
+
472
+ ```ts
473
+ // The only valid form for publicBodyForPost:
474
+ {
475
+ type: 'inlineScript',
476
+ id: 'schema-article',
477
+ scriptType: 'application/ld+json', // required — only value accepted
478
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
479
+ }
480
+ ```
481
+
482
+ Why `meta` and `link` are excluded: per-post metadata is already
483
+ handled by Next.js `generateMetadata()` via the `metadata()` surface,
484
+ which integrates with the framework's deduplication and streaming
485
+ behaviour. `publicBodyForPost` exists solely for the structured data
486
+ use-case (`<script type="application/ld+json">`) that `generateMetadata`
487
+ cannot produce.
488
+
489
+ ### `PublicPostHtmlDescriptor` (Phase 6d)
490
+
491
+ `publicHtmlForPost` returns `PublicPostHtmlDescriptor[]`:
492
+
493
+ ```ts
494
+ {
495
+ type: 'html',
496
+ id: 'display', // plugin-local short identifier (≤ 64 chars, no control chars)
497
+ position: 'beforeContent' | 'afterContent',
498
+ body: '<p class="reading-time">~3 min read</p>',
499
+ }
500
+ ```
501
+
502
+ The runtime sanitizes `body` with `sanitize-html` under a strict
503
+ allowlist (see the `publicHtmlForPost` example above for the full
504
+ allowlist + dropped-tag list) and wraps the result in
505
+ `<div data-ampless-plugin="${namespace}" data-ampless-position="${position}">`.
506
+ Themes embed `{html.beforeContent}` / `{html.afterContent}` in
507
+ `pages/post.tsx`; they never call `dangerouslySetInnerHTML` on plugin
508
+ output themselves.
509
+
510
+ ### JSON-LD auto-escape
511
+
512
+ When `scriptType === 'application/ld+json'`, the runtime **automatically
513
+ escapes the `body` string** before rendering — `<` → `<`,
514
+ `>` → `>`, `&` → `&`, U+2028 → `
`, U+2029 → `
`.
515
+ This applies across all three surfaces that accept `inlineScript`
516
+ (`publicHead`, `publicBodyEnd`, `publicBodyForPost`). Return the raw
517
+ JSON string; do not pre-escape it.
518
+
519
+ Descriptors with an unsupported `scriptType` value are **dropped with
520
+ a console warning** and never rendered.
521
+
522
+ ### Surface-dependent `scriptType` rules
523
+
524
+ | Surface | `scriptType` |
525
+ |---|---|
526
+ | `publicHead` | `undefined` (default JS) or `'application/ld+json'` |
527
+ | `publicBodyEnd` | same as `publicHead` |
528
+ | `publicBodyForPost` | `'application/ld+json'` **required**; any other value (including omitted) is dropped + warned |
529
+
530
+ ### Validation rules
531
+
532
+ - **URL scheme allowlist**: `http`, `https`, or relative paths.
533
+ `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` are rejected
534
+ before the element is rendered.
535
+ - **`attrs` allowlist**: `data-*`, `crossorigin`, `referrerpolicy`,
536
+ `integrity`, `fetchpriority`, `loading`, `sandbox`, `allow`,
537
+ `allowfullscreen`. Anything else is dropped with a dev warning.
538
+ - **`inlineScript.id` required**. Without it, two plugins emitting
539
+ the same snippet can't be dedup'd, and dev warnings would point at
540
+ index numbers no one can map back to a plugin.
541
+ - **Duplicate `id`**: the last occurrence wins. A dev warning prints
542
+ which key was duplicated.
543
+ - **CSP nonce**: not propagated in Phase 1. The `nonce` attr is
544
+ declared in the type for forward-compat but discarded today.
545
+ - **Strategy**: `afterInteractive` adds `async` to external scripts.
546
+ `lazyOnload` adds `defer`. Explicit `async` / `defer` always
547
+ wins. `beforeInteractive` is not supported.
548
+
549
+ ### What the runtime renders
550
+
551
+ For each plugin, the runtime calls `publicHead(ctx)` (resp.
552
+ `publicBodyEnd`), validates every descriptor, drops the rejects,
553
+ and wraps the survivors into a `<Fragment>`. The root layout
554
+ interpolates that fragment directly:
555
+
556
+ ```tsx
557
+ <head>{pluginHead}</head>
558
+ {/* … */}
559
+ <body>… {pluginBodyEnd}</body>
560
+ ```
561
+
562
+ `cms.config.plugins` iteration order is preserved across the
563
+ collected list.
564
+
565
+ ### `publicBodyForPost` example (Phase 4)
566
+
567
+ Declare the `schema` capability and implement the surface:
568
+
569
+ ```typescript
570
+ import { definePlugin } from 'ampless'
571
+
572
+ export default function schemaJsonldPlugin() {
573
+ return definePlugin({
574
+ name: 'schema-jsonld',
575
+ apiVersion: 1,
576
+ trust_level: 'untrusted',
577
+ capabilities: ['schema'],
578
+ publicBodyForPost(post, ctx) {
579
+ return [{
580
+ type: 'inlineScript',
581
+ id: 'schema-article',
582
+ scriptType: 'application/ld+json',
583
+ body: JSON.stringify({
584
+ '@context': 'https://schema.org',
585
+ '@type': 'Article',
586
+ headline: post.title,
587
+ url: `${ctx.site.url}/${post.slug}`,
588
+ datePublished: post.publishedAt,
589
+ }),
590
+ }]
591
+ },
592
+ })
593
+ }
594
+ ```
595
+
596
+ The theme's `pages/post.tsx` calls `ampless.publicBodyForPost(post)`
597
+ and renders the returned descriptors. The runtime inserts a
598
+ `<script type="application/ld+json">` element with the auto-escaped
599
+ body into the page.
600
+
601
+ ### `publicHtmlForPost` example (Phase 6d)
602
+
603
+ Use `publicHtmlForPost` when you need to render **visible HTML**
604
+ around a post — reading-time badge, breadcrumb, share links,
605
+ micro-format annotations, etc. The runtime sanitizes the body before
606
+ rendering and embeds the result at the `beforeContent` or
607
+ `afterContent` slot, so themes never call `dangerouslySetInnerHTML`
608
+ on plugin output.
609
+
610
+ ```typescript
611
+ import { definePlugin } from 'ampless'
612
+
613
+ export default function readingTimePlugin() {
614
+ return definePlugin({
615
+ name: 'reading-time',
616
+ apiVersion: 1,
617
+ trust_level: 'untrusted',
618
+ capabilities: ['publicHtmlForPost'],
619
+ publicHtmlForPost(post, _ctx) {
620
+ const words = countWords(post)
621
+ const minutes = Math.max(1, Math.round(words / 200))
622
+ return [{
623
+ type: 'html',
624
+ id: 'display',
625
+ position: 'beforeContent',
626
+ body: `<p class="reading-time" data-words="${words}" data-minutes="${minutes}">~${minutes} min read</p>`,
627
+ }]
628
+ },
629
+ })
630
+ }
631
+ ```
632
+
633
+ The theme's `pages/post.tsx` calls
634
+ `const html = await ampless.publicHtmlForPost(post)` once and embeds
635
+ the slots:
636
+
637
+ ```tsx
638
+ {postBody} {/* publicBodyForPost — JSON-LD */}
639
+ {html.beforeContent} {/* publicHtmlForPost — beforeContent slot */}
640
+ <div className="prose" dangerouslySetInnerHTML={{ __html: renderBody(post) }} />
641
+ {html.afterContent} {/* publicHtmlForPost — afterContent slot */}
642
+ ```
643
+
644
+ **Slot positions** (v1): `'beforeContent'` and `'afterContent'`.
645
+
646
+ **Sanitizer (strict, identical across trust levels):**
647
+
648
+ - Allowed tags: `p` · `span` · `strong` · `em` · `a` · `code` · `br` · `ul` · `ol` · `li`
649
+ - Allowed global attributes: `class` · `data-words` · `data-minutes` · `data-ampless-*`
650
+ - Allowed `<a>` attributes: `href` · `rel` · `target`. `target="_blank"` triggers auto-injection of `rel="noopener noreferrer"`.
651
+ - Allowed URL schemes on `href`: `http` / `https`. Relative URLs (`./path`, `../path`, `/path`, `#anchor`) pass through. `javascript:` / `data:` / `mailto:` / `tel:` / `vbscript:` are dropped.
652
+ - Dropped tags / attributes: `<img>` · `<iframe>` · `<video>` · `<audio>` · `<object>` · `<embed>` · `<form>` · `<style>` · inline `style` · all event handlers (`on*`).
653
+
654
+ If you need a tag outside this list, open an issue — the allowlist
655
+ expands by design, not by escape hatch.
656
+
657
+ **`id` is plugin-local.** Use a short identifier (e.g. `'display'`).
658
+ The runtime resolves it to `${instanceId ?? name}:${id}` when building
659
+ the React `key` and the wrapper `<div>`'s `data-ampless-plugin` /
660
+ `data-ampless-position` attributes. Do not embed your own namespace
661
+ in `id`. The validator drops descriptors whose `id` is empty,
662
+ contains control characters, or exceeds 64 characters.
663
+
664
+ **Dedupe is per-position.** Returning the same `id` to both
665
+ `beforeContent` and `afterContent` from a single plugin instance is
666
+ fine — they live in independent dedupe scopes. Returning the same
667
+ `id` twice to the same position keeps only the first occurrence and
668
+ warns.
669
+
670
+ **Multiple instances.** Two `reading-time` plugin instances with
671
+ distinct `instanceId` (e.g. `reading-time-en` / `reading-time-jp`)
672
+ can both emit `id: 'display'` to the same position; the runtime
673
+ keeps both because the namespaces differ.
674
+
675
+ ### Client-side DOM mutation: don't
676
+
677
+ Inline scripts you return from `publicHead` or `publicBodyEnd` execute
678
+ during HTML parsing, before React hydrates the page. **They must not
679
+ mutate visible DOM inside a React-managed subtree** — when hydration
680
+ runs, React sees a tree that doesn't match its virtual DOM, throws a
681
+ `Hydration failed because the server rendered HTML didn't match the
682
+ client` error, and regenerates the subtree from scratch. Your inserted
683
+ nodes get wiped.
684
+
685
+ React 19 additionally refuses to execute `<script>` tags it
686
+ encounters while rendering a client component, so a script that did
687
+ something like `document.body.append(myNewElement)` may not even fire.
688
+
689
+ **Safe patterns**:
690
+
691
+ - **Global state / non-DOM side effects**: push to `window.dataLayer`,
692
+ set a config object, instantiate an analytics SDK. This is what
693
+ `@ampless/plugin-analytics-ga4`, `@ampless/plugin-gtm`, and
694
+ `@ampless/plugin-plausible` do.
695
+ - **External widget loaders**: load a third-party script that manages
696
+ its own isolated container (Crisp, Intercom, Drift). The widget's
697
+ shadow DOM / fixed-position overlay lives outside React's tree and
698
+ doesn't conflict with hydration.
699
+ - **SSR-only descriptors**: return `meta` / `link` / `noscript` (and,
700
+ on `publicBodyEnd`, `iframe`) — the runtime renders these
701
+ server-side and they're part of React's virtual DOM from the start.
702
+
703
+ **Patterns to avoid**:
704
+
705
+ - `document.createElement('div')` + `document.body.append(...)`
706
+ - Modifying classes / attributes / text content of elements rendered
707
+ by the theme
708
+ - Inserting per-post HTML by reading something like `#post-body`
709
+ client-side — there's no `publicHead`-for-post analogue today, and
710
+ any client-side rewrite of a server-rendered subtree races against
711
+ hydration
712
+
713
+ For visible per-post output, use `publicHtmlForPost` (Phase 6d — see
714
+ the example above and §6's `PublicPostHtmlDescriptor`). The runtime
715
+ emits server-side HTML at fixed slots around the post body, so
716
+ nothing races against hydration.
717
+
718
+ ---
719
+
720
+ ## 7. Async event hooks
721
+
722
+ `hooks` runs inside the trust_level-matched processor Lambda when an
723
+ event arrives via SQS. The runtime context (`ctx`) carries:
724
+
725
+ ```ts
726
+ interface PluginRuntimeContext {
727
+ site: Config['site']
728
+ listPublishedPosts(): Promise<Post[]> // trusted only
729
+ writePublicAsset(key: string, body, contentType): Promise<string> // trusted only
730
+ }
731
+ ```
732
+
733
+ Example: RSS plugin (see [`packages/plugin-rss/src/index.ts`](https://github.com/heavymoons/ampless/blob/main/packages/plugin-rss/src/index.ts)):
734
+
735
+ ```ts
736
+ hooks: {
737
+ 'content.published': async (_event, ctx) => {
738
+ const posts = await ctx.listPublishedPosts()
739
+ const xml = buildRssFeed(posts, ctx.site)
740
+ await ctx.writePublicAsset('feed.xml', xml, 'application/rss+xml')
741
+ },
742
+ 'content.unpublished': /* same */,
743
+ 'content.deleted': /* same */,
744
+ 'content.updated': /* same */,
745
+ }
746
+ ```
747
+
748
+ ### `writePublicAsset`
749
+
750
+ Trusted plugins that write public generated files should declare the
751
+ capability:
752
+
753
+ ```ts
754
+ capabilities: ['eventHooks', 'writePublicAsset']
755
+ ```
756
+
757
+ If the same plugin implements `metadata()` or `siteMetadata()`, also
758
+ declare `metadata`. That capability name covers both metadata
759
+ functions; there is no separate `siteMetadata` capability.
760
+
761
+ The trusted processor writes under:
762
+
763
+ ```txt
764
+ public/plugins/<instanceId ?? name>/<key>
765
+ ```
766
+
767
+ `key` must match the allowlist `[A-Za-z0-9._/-]+`. Anything outside
768
+ that — spaces, URL-reserved characters (`#`, `?`, `&`, `=`, `+`),
769
+ non-ASCII (`日本語.xml`), empty strings, absolute paths (leading `/`),
770
+ `.` / `..` path segments, backslashes, control characters, or keys
771
+ over 256 characters — is rejected before S3 is called. Nested paths
772
+ such as `indexes/posts.json` and dotted extensions such as
773
+ `feed.v2.xml` are allowed. The allowlist is deliberately tight so
774
+ the returned public URL and the underlying S3 key are byte-identical
775
+ strings; URL-reserved characters would survive S3 but reshape the URL
776
+ when a consumer parses it. If you need to include user-supplied
777
+ characters in a key, sanitize first (e.g. hash, slugify) before
778
+ calling `ctx.writePublicAsset()`. The return value is the public URL
779
+ for the written object.
780
+
781
+ During the migration period, plugins with no `capabilities` field
782
+ keep working. Plugins that do declare `capabilities` but omit
783
+ `writePublicAsset` warn once when they actually call
784
+ `ctx.writePublicAsset()`.
785
+
786
+ ### Best practices
787
+
788
+ - **Be idempotent.** SQS delivery is at-least-once: the same event
789
+ may fire twice. Writing the same output twice should produce the
790
+ same effect (e.g. a deterministic feed).
791
+ - **Don't read events.payload.\* fields you didn't declare.** The
792
+ shape is documented in [`docs/architecture/05-event-system.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/05-event-system.md);
793
+ drifting consumers break silently when the shape moves.
794
+ - **Errors propagate to DLQ.** Throwing inside a hook eventually
795
+ parks the message in the dead-letter queue. Use the normal
796
+ CloudWatch dashboards to surface failures.
797
+
798
+ ---
799
+
800
+ ## 8. `settings.public` — admin-managed values (Phase 2)
801
+
802
+ Declare a `settings.public` manifest and the host gets a
803
+ `/admin/plugins` editor for those values automatically.
804
+
805
+ ```ts
806
+ settings: {
807
+ public: [
808
+ {
809
+ type: 'text',
810
+ key: 'measurementId',
811
+ label: { en: 'Measurement ID', ja: '測定 ID' },
812
+ description: { en: 'GA4 ID, blank to disable', ja: '空で無効化' },
813
+ pattern: '^$|^G-[A-Z0-9]+$',
814
+ placeholder: 'G-XXXXXXXX',
815
+ default: 'G-XXXXXXXX',
816
+ },
817
+ ],
818
+ }
819
+ ```
820
+
821
+ ### Available field types
822
+
823
+ | Type | Stored as | Notes |
824
+ |---|---|---|
825
+ | `text` | string | `pattern`, `maxLength`, `placeholder` |
826
+ | `textarea` | string | `rows`, `maxLength` |
827
+ | `url` | string | scheme-checked at save time, `allowRelative` |
828
+ | `code` | string | `language` label (display only), Phase 2.5 will swap in a dedicated editor |
829
+ | `boolean` | boolean | rendered as checkbox |
830
+ | `number` | number | `min` / `max` / `step` |
831
+ | `select` | string (matches one `options[i].value`) | required to be in `options` |
832
+ | `json` | decoded value (object / array / number / boolean) | admin form `JSON.parse`s before saving |
833
+
834
+ ### Storage shape
835
+
836
+ Each saved value lands in DynamoDB at:
837
+
838
+ ```
839
+ pk = 'siteconfig'
840
+ sk = 'plugins.<instanceId>.<fieldKey>'
841
+ ```
842
+
843
+ The trusted processor mirrors the row to `public/site-settings.json`
844
+ in S3. The public runtime fetches that file (60 s `revalidate`,
845
+ `site-settings` cache tag) when `publicHead` / `publicBodyEnd` run,
846
+ giving you cheap synchronous reads from inside the render path.
847
+
848
+ ### Required vs disabled vs unset
849
+
850
+ - `required: true` rejects empty / undefined values at save time
851
+ and surfaces an admin form error.
852
+ - For **string-like fields** (`text` / `textarea` / `url` / `code`),
853
+ saving an empty string is **valid** when `required` is falsy.
854
+ It's the "disable" sentinel — e.g. GA4 saves an empty
855
+ `measurementId` to turn analytics off without removing the
856
+ plugin.
857
+ - For **non-string fields** (`number` / `boolean` / `json` /
858
+ `select`), empty string is always rejected. To clear a stored
859
+ value entirely, the user clicks **Reset to default** — that
860
+ deletes the DDB row so the next request falls back to
861
+ `manifest.default`.
862
+
863
+ ---
864
+
865
+ ## 9. Reading settings: `ctx.setting<T>(key)`
866
+
867
+ Inside `publicHead` / `publicBodyEnd`, read the resolved value via
868
+ `ctx.setting`:
869
+
870
+ ```ts
871
+ publicHead(ctx) {
872
+ const id = ctx.setting<string>('measurementId') ?? ''
873
+ if (!id) return []
874
+ return [/* descriptors using id */]
875
+ }
876
+ ```
877
+
878
+ Resolution order, per request:
879
+
880
+ ```
881
+ stored value (validated)
882
+ ↳ manifest.default (also validated)
883
+ ↳ undefined
884
+ ```
885
+
886
+ Validation runs on both sides so a manually-edited DDB row that's
887
+ out of range (or a malformed constructor argument seeded as
888
+ `default`) doesn't leak into the page. The renderer treats invalid
889
+ values as if they didn't exist; the next valid layer takes over.
890
+
891
+ When does the snapshot update? When the trusted processor finishes
892
+ rebuilding `public/site-settings.json`, the next request inside the
893
+ ~60 s Next.js fetch cache window still serves the old version. The
894
+ admin form deliberately delays its cache-invalidation call by ~8 s
895
+ so the rebuild has time to finish before the public side hits
896
+ origin.
897
+
898
+ ### Multiple instances
899
+
900
+ Each plugin instance has its own settings namespace keyed by
901
+ `instanceId`. Two `analyticsGa4Plugin({ instanceId: 'a' })` and
902
+ `analyticsGa4Plugin({ instanceId: 'b' })` calls in `cms.config.ts`
903
+ get distinct DDB rows; `ctx.setting()` automatically scopes to the
904
+ plugin's own `instanceId`.
905
+
906
+ ---
907
+
908
+ ## 9a. Secret settings: `ctx.secret<T>(key)` (Phase 6a)
909
+
910
+ Secret settings let trusted plugins store and rotate credentials
911
+ (webhook signing secrets, SMTP passwords, external API tokens)
912
+ through the admin UI **without exposing them to the public site or
913
+ browser-side code**.
914
+
915
+ ### Why a separate API from `settings.public`?
916
+
917
+ Values in `settings.public` are designed to flow to the public
918
+ runtime: they're mirrored to `public/site-settings.json` and read
919
+ by `ctx.setting()` inside sync render surfaces. That flow is
920
+ intentional for analytics measurement IDs, consent category names,
921
+ etc. — but completely wrong for a webhook signing secret.
922
+
923
+ `settings.secret` has a structurally different storage model:
924
+
925
+ - Stored in the `PluginSecret` DynamoDB table (separate from KvStore).
926
+ Admin/editor Cognito users have **no direct AppSync access** to this
927
+ table — all writes go through the `setPluginSecret` mutation backed
928
+ by the `plugin-secret-handler` Lambda.
929
+ - Values are **AES-256-GCM encrypted** before reaching DynamoDB.
930
+ The admin browser sends the plaintext to the Lambda via AppSync
931
+ (TLS); the Lambda validates, reads the 32-byte encryption key from
932
+ `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (injected by CDK from
933
+ `amplify/secrets/encryption-key.ts`), and writes only the ciphertext.
934
+ The plaintext never rests in DynamoDB and never flows back to the browser.
935
+ - **Threat model (Phase 6a v2.2)**:
936
+
937
+ | Threat | Status |
938
+ |---|---|
939
+ | AWS Console operator browsing PluginSecret table | ✓ defeated — ciphertext only, no key in DDB |
940
+ | Source repo / deploy artifact access | ⚠ NOT defeated — key is in `amplify/secrets/encryption-key.ts`. Private repo + restricted artifact access expected. |
941
+ | Malicious trusted plugin in same Lambda | ✗ NOT defeated — `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` reachable from plugin code. True isolation = per-plugin Lambda (privileged tier, roadmap). |
942
+ | S3 mirror leak | ✓ defeated — PluginSecret table never mirrored. |
943
+
944
+ - The trusted-processor Lambda decrypts with `node:crypto` on read.
945
+ `ctx.secret<T>(key)` returns the plaintext string, never ciphertext.
946
+ - Never queried by the site-settings mirror path.
947
+ - Never passed to any public-render surface
948
+ (`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
949
+ `publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
950
+ - **Field-manifest validation scope**: the admin client validates
951
+ `pattern` / `maxLength` / `required` for UX feedback, but the
952
+ Lambda only enforces a generic 10,000-character hard cap +
953
+ safe-character sanitizer. An admin/editor calling the AppSync
954
+ mutation directly can bypass field-level constraints — by design,
955
+ since admin/editor are trusted operators authorised to set
956
+ secrets. The manifest checks are UX guidance, not a security
957
+ boundary.
958
+
959
+ ### Requirements
960
+
961
+ `settings.secret` requires:
962
+
963
+ 1. `trust_level: 'trusted'` — untrusted Lambdas have no DDB read
964
+ access to the PluginSecret table. `definePlugin()` throws if you
965
+ declare `settings.secret` with any other trust level.
966
+ 2. `'secretSettings'` in `capabilities` — required so admin UI and
967
+ future allow-lists can gate the capability. Omitting it produces
968
+ a console warning (same pattern as `'schema'` vs `publicBodyForPost`).
969
+ 3. **One-time key setup** — run from your project root:
970
+ ```sh
971
+ npx create-ampless setup-encryption-key
972
+ ```
973
+ Generates a cryptographically random 32-byte key and writes it to
974
+ `amplify/secrets/encryption-key.ts`. No AWS credentials required.
975
+ Then import it in `amplify/backend.ts` and pass it to
976
+ `defineAmplessBackend({ pluginSecretEncryptionKey })`. Redeploy (or
977
+ restart the sandbox) to inject the key into Lambda env vars.
978
+ For public repos, add `--gitignore` to exclude from version control.
979
+
980
+ ### Dual-write integrity
981
+
982
+ The `setPluginSecret` and `clearPluginSecret` operations each write to
983
+ **two DynamoDB tables** in sequence. If the second write fails, the
984
+ tables are left in a predictable state:
985
+
986
+ | Failure | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
987
+ |---|---|---|---|---|
988
+ | **set**: indicator PutItem fails | ciphertext present | absent | returns plaintext ✓ | `false` (UI: "not saved") |
989
+ | **clear**: indicator DeleteItem fails | absent | stale | `undefined` ✓ | `true` (UI: "saved") |
990
+
991
+ The clear-path failure is the "safe side": the secret stops firing.
992
+
993
+ ### Declaring secret fields
994
+
995
+ ```ts
996
+ import { definePlugin } from 'ampless'
997
+
998
+ export default function webhookPlugin(opts?: { signingSecret?: string }) {
999
+ // Keep any constructor-provided secret as a closure-private fallback.
1000
+ // It is NEVER exposed in the manifest or the stored descriptor.
1001
+ const constructorSecret = opts?.signingSecret
1002
+
1003
+ return definePlugin({
1004
+ name: 'webhook',
1005
+ apiVersion: 1,
1006
+ trust_level: 'trusted',
1007
+ capabilities: ['eventHooks', 'secretSettings'],
1008
+ settings: {
1009
+ secret: [
1010
+ {
1011
+ type: 'text',
1012
+ key: 'signingSecret',
1013
+ label: { en: 'Webhook signing secret', ja: 'Webhook 署名 secret' },
1014
+ maxLength: 256,
1015
+ required: false,
1016
+ // NO `default` — secret fields forbid it at the type level.
1017
+ // Use a closure-private fallback instead (see below).
1018
+ },
1019
+ ],
1020
+ },
1021
+ hooks: {
1022
+ async 'content.published'(event, ctx) {
1023
+ const storedSecret = await ctx.secret<string>('signingSecret')
1024
+ const secret = storedSecret ?? constructorSecret
1025
+ if (!secret) return
1026
+ // ... use secret to sign and POST
1027
+ },
1028
+ },
1029
+ })
1030
+ }
1031
+ ```
1032
+
1033
+ ### Important: no `default` on secret fields
1034
+
1035
+ The type `PluginSecretField` is defined as `Omit<PluginTextField,
1036
+ 'default'> | Omit<PluginTextareaField, 'default'>` — the `default`
1037
+ property is **removed at the type level**. Use a closure-private
1038
+ fallback variable instead; never put credentials in the manifest.
1039
+
1040
+ ### Reading secrets: `ctx.secret<T>(key)`
1041
+
1042
+ `ctx.secret<T>(key)` is only available in trusted hook handlers.
1043
+ Returns `undefined` when no value has been saved by admin. Returns
1044
+ the **decrypted plaintext** — never the ciphertext blob.
1045
+ Results are per-invocation cached (plaintext, not ciphertext).
1046
+ The encryption key is decoded from the Lambda env var at cold-start
1047
+ (no extra DDB fetch).
1048
+ Cache keys are namespaced by `${instanceId ?? name}:${fieldKey}`.
1049
+
1050
+ ### Admin UI
1051
+
1052
+ When `settings.secret` is declared, the admin plugin settings page
1053
+ renders a **Secret settings** section below the public fields.
1054
+ Admins see `••••••••` for stored values and can Replace or Clear
1055
+ without deploying. Rotation takes effect within ~5–10 seconds.
1056
+
1057
+ ---
1058
+
1059
+ ## 10. Walk-through: migrating GA4 from Phase 1 to Phase 2
1060
+
1061
+ The Phase 1 GA4 plugin took the measurement ID through a
1062
+ constructor argument. Phase 2 keeps the argument for backward
1063
+ compatibility but reads the live value from `ctx.setting()`.
1064
+
1065
+ **Before** (Phase 1):
1066
+
1067
+ ```ts
1068
+ export default function analyticsGa4Plugin(opts: { measurementId: string }) {
1069
+ const { measurementId } = opts
1070
+ return definePlugin({
1071
+ name: 'analytics-ga4',
1072
+ apiVersion: 1,
1073
+ trust_level: 'untrusted',
1074
+ capabilities: ['publicHead'],
1075
+ publicHead() {
1076
+ if (!measurementId) return []
1077
+ return [/* descriptors using measurementId */]
1078
+ },
1079
+ })
1080
+ }
1081
+ ```
1082
+
1083
+ **After** (Phase 2):
1084
+
1085
+ ```ts
1086
+ export default function analyticsGa4Plugin(opts: { measurementId?: string } = {}) {
1087
+ const { measurementId = '', instanceId = 'analytics-ga4' } = opts
1088
+ return definePlugin({
1089
+ name: 'analytics-ga4',
1090
+ instanceId,
1091
+ apiVersion: 1,
1092
+ trust_level: 'untrusted',
1093
+ capabilities: ['publicHead', 'adminSettings'],
1094
+ settings: {
1095
+ public: [{
1096
+ type: 'text',
1097
+ key: 'measurementId',
1098
+ label: { en: 'Measurement ID', ja: '測定 ID' },
1099
+ pattern: '^$|^G-[A-Z0-9]+$',
1100
+ default: measurementId,
1101
+ }],
1102
+ },
1103
+ publicHead(ctx) {
1104
+ const id = ctx.setting<string>('measurementId') ?? ''
1105
+ if (!id) return []
1106
+ return [/* descriptors using id */]
1107
+ },
1108
+ })
1109
+ }
1110
+ ```
1111
+
1112
+ The constructor argument now seeds `manifest.default`. Operators
1113
+ that already set `analyticsGa4Plugin({ measurementId: 'G-X' })` in
1114
+ `cms.config.ts` keep their current behaviour; new deployments
1115
+ should leave it empty and configure the value from the admin UI.
1116
+
1117
+ ---
1118
+
1119
+ ## 11. Testing
1120
+
1121
+ ampless uses vitest. Plugin tests typically look like:
1122
+
1123
+ ```ts
1124
+ import { describe, it, expect } from 'vitest'
1125
+ import type { PluginPublicRenderContext, AmplessPlugin } from 'ampless'
1126
+ import { resolvePluginSettings } from 'ampless'
1127
+ import myPlugin from './index.js'
1128
+
1129
+ function makeCtx(plugin: AmplessPlugin, stored: Record<string, unknown> = {}): PluginPublicRenderContext {
1130
+ const resolved = resolvePluginSettings(plugin.settings, stored)
1131
+ return {
1132
+ site: { name: 'Test', url: 'https://example.com/' },
1133
+ setting: (k) => resolved[k],
1134
+ }
1135
+ }
1136
+
1137
+ it('emits descriptors when measurementId is set', () => {
1138
+ const plugin = myPlugin({ measurementId: 'G-XXX' })
1139
+ const descriptors = plugin.publicHead?.(makeCtx(plugin)) ?? []
1140
+ expect(descriptors).toHaveLength(2)
1141
+ })
1142
+
1143
+ it('returns empty when admin saved empty string', () => {
1144
+ const plugin = myPlugin({ measurementId: 'G-XXX' })
1145
+ const descriptors = plugin.publicHead?.(makeCtx(plugin, { measurementId: '' })) ?? []
1146
+ expect(descriptors).toEqual([])
1147
+ })
1148
+ ```
1149
+
1150
+ Test the manifest + the rendering behaviour. The runtime's
1151
+ descriptor validator is exercised in `@ampless/runtime` already —
1152
+ plugin tests focus on the *what* (which descriptors get returned for
1153
+ which state), not the validation surface.
1154
+
1155
+ For event hooks, mock `ctx.listPublishedPosts` and
1156
+ `ctx.writePublicAsset` with simple stub functions.
1157
+
1158
+ ---
1159
+
1160
+ ## 12. Publishing to npm
1161
+
1162
+ For first-party / monorepo-internal plugins, follow the changeset
1163
+ flow already in this repo. For external plugins, the shape is just
1164
+ a normal npm package:
1165
+
1166
+ - **Name**: `@your-scope/plugin-foo`. The `@ampless/plugin-*`
1167
+ scope is reserved for first-party plugins shipped from this
1168
+ monorepo.
1169
+ - **Entry**: ESM only, exports default (the factory) and the
1170
+ config interface (for typed args in user `cms.config.ts`).
1171
+ - **`apiVersion`**: bump only when the contract changes. Bump
1172
+ major when an existing field's type changes; bump minor when you
1173
+ add a new field (existing installs keep working).
1174
+ - **Dist-tag**: `@alpha` while ampless itself is in alpha. The
1175
+ `@latest` tag stays reserved until ampless v1.0.
1176
+
1177
+ Worked examples to crib from:
1178
+
1179
+ - [`packages/plugin-analytics-ga4`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-analytics-ga4) — descriptor-based, Phase 2 settings.
1180
+ - [`packages/plugin-gtm`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-gtm) — uses both `publicHead` (loader inline script) and `publicBodyEnd` (`<noscript>` iframe fallback) with the container ID admin-edited.
1181
+ - [`packages/plugin-plausible`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-plausible) — single `<script>` descriptor with `data-*` attrs and a required URL field (self-hosted Plausible override).
1182
+ - [`packages/plugin-rss`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-rss) — trusted, async event hooks + `writePublicAsset`.
1183
+ - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`.
1184
+ - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook with outbound HTTP.
1185
+ - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` route renderer.
1186
+ - [`packages/plugin-schema-jsonld`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-schema-jsonld) — `publicBodyForPost` + `schema` capability; per-post Article JSON-LD. (Phase 4)
1187
+
1188
+ ---
1189
+
1190
+ ## 13. Naming rules + common pitfalls
1191
+
1192
+ ### Naming rules
1193
+
1194
+ - `name`, `instanceId`, every `settings.public.key`: must match
1195
+ `/^[a-zA-Z0-9_-]+$/`. The runtime drops plugins / fields that
1196
+ violate this with a dev console warning.
1197
+ - The dot separator in `plugins.<instanceId>.<fieldKey>` is the
1198
+ only structure the storage format relies on. Don't try to be
1199
+ clever with nested dots in your keys.
1200
+
1201
+ ### Common pitfalls
1202
+
1203
+ - **Capability + implementation mismatch.** Declaring
1204
+ `capabilities: ['publicHead']` but never defining `publicHead` (or
1205
+ vice versa) prints a console warning at startup. Either drop the
1206
+ capability or add the function.
1207
+ - **Duplicate `instanceId`.** Two plugin instances sharing the same
1208
+ namespace get a startup warning. The second one's stored settings
1209
+ collide with the first.
1210
+ - **Forgetting `id` on `inlineScript`.** The descriptor is dropped
1211
+ silently in production and warned in dev. There is no way for
1212
+ the runtime to dedup inline scripts without an id.
1213
+ - **Returning `ReactNode` from `publicHead`.** TypeScript catches
1214
+ this — `publicHead` is typed to return descriptors only. If you
1215
+ find yourself needing arbitrary `ReactNode`, that's the Phase 6b
1216
+ `developer.headElements` capability waiting to land.
1217
+ - **Saving `manifest.default` through the admin form.** Don't write
1218
+ the resolved default back as if it were an explicit value — the
1219
+ admin form only writes touched fields for exactly this reason.
1220
+ Saving a default value freezes it: future package updates that
1221
+ change the default won't take effect for that field anymore.
1222
+ - **`publicBodyForPost` without `scriptType: 'application/ld+json'`.** Descriptors
1223
+ returned from `publicBodyForPost` that omit `scriptType` or use any
1224
+ other value are silently dropped in production and warned in dev.
1225
+ Only `scriptType: 'application/ld+json'` is valid in that surface.
1226
+ - **`schema` capability + `publicBodyForPost` mismatch.** Declaring
1227
+ `capabilities: ['schema']` without implementing `publicBodyForPost`
1228
+ (or the reverse) prints a startup warning. Keep the declaration and
1229
+ implementation in sync.
1230
+
1231
+ ---
1232
+
1233
+ ## 14. Quickstart: scaffolding with `create-ampless`
1234
+
1235
+ For a fast path from idea to working plugin, the `create-ampless`
1236
+ CLI ships a `plugin <name>` subcommand:
1237
+
1238
+ ```bash
1239
+ # Site-local: scaffolds plugins/<name>/index.ts inside the current
1240
+ # ampless site. Run from the site repo root.
1241
+ npx create-ampless@latest plugin my-thing \
1242
+ --trust-level untrusted \
1243
+ --capabilities publicHead,adminSettings
1244
+
1245
+ # Standalone npm package: scaffolds ./<dir>/ with package.json,
1246
+ # tsconfig.json, tsup.config.ts, README + .ja, CHANGELOG, .gitignore,
1247
+ # and src/index.ts + src/index.test.ts. Run from wherever you want
1248
+ # the new package directory to land.
1249
+ npx create-ampless@latest plugin @myscope/ampless-plugin-thing \
1250
+ --standalone \
1251
+ --trust-level untrusted \
1252
+ --capabilities publicHead,adminSettings \
1253
+ --description "What this plugin does"
1254
+ ```
1255
+
1256
+ Standalone scaffolds include everything Phase 5's cross-check needs:
1257
+ `package.json#amplessPlugin`, the `./package.json` subpath export,
1258
+ the `packageName` factory field, the `ampless-plugin` discovery
1259
+ keyword, and a minimal vitest sample so `pnpm install && pnpm test &&
1260
+ pnpm build` runs clean on the freshly generated directory.
1261
+
1262
+ Both modes also accept a positional flag-less invocation (`npx
1263
+ create-ampless@latest plugin`) that walks you through the same
1264
+ questions interactively via the @clack prompt UI.
1265
+
1266
+ ### Publishing a standalone plugin
1267
+
1268
+ ```bash
1269
+ cd ampless-plugin-thing
1270
+ pnpm install
1271
+ pnpm test
1272
+ pnpm build
1273
+ pnpm publish --access public --tag alpha
1274
+ ```
1275
+
1276
+ `--access public` is mandatory for scoped names (`@scope/...`).
1277
+ `--tag alpha` matches the current ampless pre-release cadence — drop
1278
+ it once the package reaches a stable major.
1279
+
1280
+ There's a publish-to-install lag of "seconds to minutes" before
1281
+ `npm install <pkg>@alpha` picks up a fresh publish (CDN + registry
1282
+ replica propagation). If `npm install` 404s right after `npm publish`
1283
+ returns, wait 1-2 minutes and retry — `npm view <pkg>@alpha version`
1284
+ visible in the registry is necessary but sometimes not sufficient.
1285
+
1286
+ ### Naming the package
1287
+
1288
+ By npm convention, scope and the conventional `ampless-plugin-`
1289
+ prefix collapse to a short identifier for `AmplessPlugin.name`:
1290
+
1291
+ | npm package | `AmplessPlugin.name` |
1292
+ |---|---|
1293
+ | `@ampless/plugin-gtm` | `gtm` |
1294
+ | `@scope/ampless-plugin-clarity` | `clarity` |
1295
+ | `ampless-plugin-readme-toc` | `readme-toc` |
1296
+ | `weird-name-no-prefix` | `weird-name-no-prefix` |
1297
+
1298
+ The scaffold does this stripping automatically. Hand-author the same
1299
+ mapping if you skip the scaffold; the install-time cross-check warns
1300
+ on a mismatch between the package's static manifest and the factory.
1301
+
1302
+ ### Site-local follow-up
1303
+
1304
+ After a site-local scaffold:
1305
+
1306
+ ```ts
1307
+ // cms.config.ts
1308
+ import myThingPlugin from './plugins/my-thing'
1309
+
1310
+ export default defineConfig({
1311
+ // ...
1312
+ plugins: [
1313
+ myThingPlugin(),
1314
+ ],
1315
+ })
1316
+ ```
1317
+
1318
+ The scaffold prints this snippet at the end of its run — copy it into
1319
+ `cms.config.ts` to activate the plugin.
1320
+
1321
+ `update-ampless` never touches the `plugins/` directory (it's
1322
+ PROTECTED), so the scaffolded code is safe across ampless upgrades.
1323
+
1324
+ ---
1325
+
1326
+ ## 15. Where to ask
1327
+
1328
+ - Architecture / design questions → [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md)
1329
+ - Bugs in a first-party plugin → file an issue against
1330
+ `heavymoons/ampless` referencing the plugin's package name.
1331
+ - Bugs in the plugin runtime / admin form → same repo, label
1332
+ `area:plugins`.
1333
+
1334
+ The ampless repo stays private until v1.0 RC. Once it's public, the
1335
+ links above resolve to the actual GitHub URLs; today they live in
1336
+ the package tarball directly under `node_modules/ampless/docs/`.