create-ampless 0.2.0-alpha.9 → 1.0.0-alpha.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +77 -0
- package/README.md +6 -3
- package/dist/index.js +743 -84
- package/dist/templates/_shared/AGENTS.ja.md +94 -0
- package/dist/templates/_shared/AGENTS.md +94 -0
- package/dist/templates/_shared/README.ja.md +239 -0
- package/dist/templates/_shared/README.md +239 -0
- package/dist/templates/_shared/RUNBOOK.ja.md +120 -0
- package/dist/templates/_shared/RUNBOOK.md +32 -58
- package/dist/templates/_shared/THEMES.ja.md +495 -0
- package/dist/templates/_shared/THEMES.md +523 -0
- package/dist/templates/_shared/amplify/auth/post-confirmation/resource.ts +7 -0
- package/dist/templates/_shared/amplify/backend.ts +10 -0
- package/dist/templates/_shared/amplify/data/get-media-by-src.js +45 -0
- package/dist/templates/_shared/amplify/data/get-published-post.js +9 -12
- package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +6 -9
- package/dist/templates/_shared/amplify/data/list-published-posts.js +10 -11
- package/dist/templates/_shared/amplify/data/resource.custom.ts +1 -2
- package/dist/templates/_shared/amplify/data/resource.ts +28 -7
- package/dist/templates/_shared/amplify/events/dispatcher/resource.ts +1 -0
- package/dist/templates/_shared/amplify/events/processor-trusted/resource.ts +1 -0
- package/dist/templates/_shared/amplify/events/processor-untrusted/resource.ts +5 -0
- package/dist/templates/_shared/amplify/functions/api-key-renewer/resource.ts +1 -0
- package/dist/templates/_shared/amplify/functions/mcp-handler/handler.ts +1 -0
- package/dist/templates/_shared/amplify/functions/mcp-handler/resource.ts +12 -0
- package/dist/templates/_shared/amplify/functions/plugin-secret-handler/handler.ts +11 -0
- package/dist/templates/_shared/amplify/functions/plugin-secret-handler/resource.ts +12 -0
- package/dist/templates/_shared/amplify/functions/user-admin/resource.ts +7 -0
- package/dist/templates/_shared/amplify/secrets/.gitkeep +0 -0
- package/dist/templates/_shared/amplify/secrets/encryption-key.ts +9 -0
- package/dist/templates/_shared/app/(admin)/admin/mcp-tokens/page.tsx +5 -0
- package/dist/templates/_shared/app/(admin)/admin/plugins/page.tsx +5 -0
- package/dist/templates/_shared/app/globals.css +55 -39
- package/dist/templates/_shared/app/layout.tsx +51 -16
- package/dist/templates/_shared/app/providers.tsx +0 -2
- package/dist/templates/_shared/app/raw/[slug]/route.ts +10 -0
- package/dist/templates/_shared/app/static/[slug]/[[...path]]/route.ts +14 -0
- package/dist/templates/_shared/cms.config.ts +64 -23
- package/dist/templates/_shared/components/site-chrome/site-sidebar.tsx +3 -4
- package/dist/templates/_shared/components/tag-list.tsx +9 -2
- package/dist/templates/_shared/components.json +1 -1
- package/dist/templates/_shared/docs/plugin-author-guide.ja.md +934 -0
- package/dist/templates/_shared/docs/plugin-author-guide.md +1336 -0
- package/dist/templates/_shared/lib/admin.ts +12 -12
- package/dist/templates/_shared/lib/ampless.ts +2 -8
- package/dist/templates/_shared/lib/amplify.ts +0 -5
- package/dist/templates/_shared/package.json +51 -43
- package/dist/templates/_shared/plugins/README.ja.md +139 -0
- package/dist/templates/_shared/plugins/README.md +143 -0
- package/dist/templates/_shared/proxy.ts +23 -8
- package/dist/templates/blog/README.ja.md +22 -0
- package/dist/templates/blog/README.md +17 -47
- package/dist/templates/blog/manifest.ts +1 -1
- package/dist/templates/blog/pages/feed.ts +4 -5
- package/dist/templates/blog/pages/home.tsx +10 -15
- package/dist/templates/blog/pages/post.tsx +42 -14
- package/dist/templates/blog/pages/sitemap.ts +4 -5
- package/dist/templates/blog/pages/tag.tsx +8 -10
- package/dist/templates/blog/tokens.css +26 -40
- package/dist/templates/corporate/README.ja.md +18 -0
- package/dist/templates/corporate/README.md +12 -14
- package/dist/templates/corporate/pages/feed.ts +3 -4
- package/dist/templates/corporate/pages/home.tsx +7 -12
- package/dist/templates/corporate/pages/post.tsx +20 -14
- package/dist/templates/corporate/pages/sitemap.ts +3 -4
- package/dist/templates/corporate/pages/tag.tsx +8 -10
- package/dist/templates/corporate/tokens.css +17 -39
- package/dist/templates/dads/README.ja.md +31 -0
- package/dist/templates/dads/README.md +13 -17
- package/dist/templates/dads/pages/feed.ts +3 -4
- package/dist/templates/dads/pages/home.tsx +7 -12
- package/dist/templates/dads/pages/post.tsx +20 -14
- package/dist/templates/dads/pages/sitemap.ts +3 -4
- package/dist/templates/dads/pages/tag.tsx +8 -10
- package/dist/templates/dads/tokens.css +22 -42
- package/dist/templates/docs/README.ja.md +24 -0
- package/dist/templates/docs/README.md +10 -13
- package/dist/templates/docs/pages/feed.ts +3 -4
- package/dist/templates/docs/pages/home.tsx +6 -9
- package/dist/templates/docs/pages/post.tsx +19 -13
- package/dist/templates/docs/pages/sitemap.ts +3 -4
- package/dist/templates/docs/pages/tag.tsx +8 -10
- package/dist/templates/docs/tokens.css +17 -39
- package/dist/templates/landing/README.ja.md +20 -0
- package/dist/templates/landing/README.md +14 -19
- package/dist/templates/landing/pages/feed.ts +4 -5
- package/dist/templates/landing/pages/home.tsx +7 -12
- package/dist/templates/landing/pages/post.tsx +20 -14
- package/dist/templates/landing/pages/sitemap.ts +3 -4
- package/dist/templates/landing/pages/tag.tsx +8 -10
- package/dist/templates/landing/tokens.css +17 -39
- package/dist/templates/minimal/README.ja.md +14 -0
- package/dist/templates/minimal/README.md +9 -47
- package/dist/templates/minimal/pages/feed.ts +4 -5
- package/dist/templates/minimal/pages/home.tsx +6 -8
- package/dist/templates/minimal/pages/post.tsx +19 -12
- package/dist/templates/minimal/pages/sitemap.ts +4 -5
- package/dist/templates/minimal/pages/tag.tsx +7 -8
- package/dist/templates/minimal/tokens.css +17 -39
- package/dist/templates/plugin-local/README.md +34 -0
- package/dist/templates/plugin-local/index.ts +39 -0
- package/dist/templates/plugin-standalone/CHANGELOG.md +1 -0
- package/dist/templates/plugin-standalone/README.ja.md +43 -0
- package/dist/templates/plugin-standalone/README.md +43 -0
- package/dist/templates/plugin-standalone/package.json +47 -0
- package/dist/templates/plugin-standalone/src/index.test.ts +16 -0
- package/dist/templates/plugin-standalone/src/index.ts +29 -0
- package/dist/templates/plugin-standalone/tsconfig.json +16 -0
- package/dist/templates/plugin-standalone/tsup.config.ts +8 -0
- package/package.json +1 -1
- package/dist/templates/_shared/app/(admin)/admin/sites/page.tsx +0 -5
- package/dist/templates/_shared/app/site/[siteId]/raw/[slug]/route.ts +0 -5
- package/dist/templates/_shared/components/i18n-provider.tsx +0 -15
- package/dist/templates/_shared/lib/admin-site-client.ts +0 -10
- package/dist/templates/_shared/lib/admin-site.ts +0 -12
- package/dist/templates/_shared/lib/amplify-server.ts +0 -7
- package/dist/templates/_shared/lib/auth-server.ts +0 -15
- package/dist/templates/_shared/lib/cn.ts +0 -5
- package/dist/templates/_shared/lib/i18n.ts +0 -35
- package/dist/templates/_shared/lib/kv-provider.ts +0 -7
- package/dist/templates/_shared/lib/media.ts +0 -6
- package/dist/templates/_shared/lib/posts-provider.ts +0 -7
- package/dist/templates/_shared/lib/posts-public.ts +0 -27
- package/dist/templates/_shared/lib/posts.ts +0 -12
- package/dist/templates/_shared/lib/seo.ts +0 -11
- package/dist/templates/_shared/lib/site-settings.ts +0 -11
- package/dist/templates/_shared/lib/storage.ts +0 -10
- package/dist/templates/_shared/lib/theme-actions.ts +0 -5
- package/dist/templates/_shared/lib/theme-active.ts +0 -10
- package/dist/templates/_shared/lib/theme-config.ts +0 -12
- package/dist/templates/_shared/lib/upload.ts +0 -6
- /package/dist/templates/_shared/app/{site/[siteId]/[slug] → [slug]}/page.tsx +0 -0
- /package/dist/templates/_shared/app/{site/[siteId]/feed.xml → feed.xml}/route.ts +0 -0
- /package/dist/templates/_shared/app/{site/[siteId]/og → og}/[slug]/route.ts +0 -0
- /package/dist/templates/_shared/app/{site/[siteId]/page.tsx → page.tsx} +0 -0
- /package/dist/templates/_shared/app/{site/[siteId]/sitemap.xml → sitemap.xml}/route.ts +0 -0
- /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/`.
|