create-ampless 1.0.0-alpha.99 → 1.0.0-beta.149
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +4 -4
- package/README.md +4 -4
- package/dist/index.js +198 -29
- package/dist/templates/_shared/README.ja.md +4 -4
- package/dist/templates/_shared/README.md +4 -4
- package/dist/templates/_shared/amplify/backend.ts +1 -1
- package/dist/templates/_shared/amplify/data/get-published-post.js +13 -1
- package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +15 -3
- package/dist/templates/_shared/amplify/data/list-published-posts.js +26 -15
- package/dist/templates/_shared/amplify/events/dispatcher/handler.ts +11 -1
- package/dist/templates/_shared/amplify/secrets/encryption-key.ts +2 -2
- package/dist/templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx +13 -0
- package/dist/templates/_shared/app/(admin)/admin/layout.tsx +2 -1
- package/dist/templates/_shared/app/(admin)/admin/preview/route.tsx +4 -0
- package/dist/templates/_shared/cms.config.ts +3 -0
- package/dist/templates/_shared/docs/plugin-author-guide.ja.md +841 -31
- package/dist/templates/_shared/docs/plugin-author-guide.md +1147 -80
- package/dist/templates/_shared/package.json +17 -16
- package/dist/templates/_shared/plugins/README.ja.md +1 -1
- package/dist/templates/_shared/plugins/README.md +1 -1
- package/dist/templates/blog/pages/home.tsx +5 -5
- package/dist/templates/blog/pages/post.tsx +5 -3
- package/dist/templates/corporate/pages/home.tsx +5 -5
- package/dist/templates/corporate/pages/post.tsx +5 -3
- package/dist/templates/dads/pages/home.tsx +5 -5
- package/dist/templates/dads/pages/post.tsx +5 -3
- package/dist/templates/docs/pages/post.tsx +5 -3
- package/dist/templates/landing/pages/home.tsx +5 -5
- package/dist/templates/landing/pages/post.tsx +5 -3
- package/dist/templates/minimal/pages/post.tsx +5 -3
- package/dist/templates/plugin-local/README.md +1 -1
- package/dist/templates/plugin-standalone/README.ja.md +3 -3
- package/dist/templates/plugin-standalone/README.md +3 -3
- package/package.json +1 -1
|
@@ -19,6 +19,8 @@ per-post body injection, the async event hooks, and admin-managed
|
|
|
19
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
20
|
this page is the hands-on companion.
|
|
21
21
|
|
|
22
|
+
> **Positioning**: ampless is a customization-based CMS for engineers — plugins are **npm dependencies that the site engineer imports + configures in `cms.config.ts`**. The engineer audits each dep before installing, the way they would for any other npm library (Astro integration / Next.js plugin pattern). The trust framework described in this guide (`trust_level`, capabilities, IAM-scoped Lambdas) is implemented in v1 as **first-party plugin organization** — it decides which trust tier's Lambda runs each event hook, which IAM permissions each tier holds, and applies hard runtime gates only at narrowly-scoped points (most notably: `settings.secret` requires `trust_level: 'trusted'` because secret read needs the trusted Lambda's IAM permission). Most capability declarations are soft warnings + admin labels + future allow-list surfaces, not hard runtime gates. It is **not** designed as a marketplace-grade automatic sandbox that safely runs arbitrary untrusted third-party plugins. Marketplace + runtime sandbox is a v2.0+ exploration, not a v1 guarantee. See [`docs/architecture/08-plugin-architecture.md#trust-model-v1-scope`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#trust-model-v1-scope) for the full trust model.
|
|
23
|
+
|
|
22
24
|
---
|
|
23
25
|
|
|
24
26
|
## 0. Theme vs Plugin Boundary
|
|
@@ -75,7 +77,7 @@ same surfaces. The differences are packaging, distribution, and the
|
|
|
75
77
|
opt-in install-time validation that the static `package.json#amplessPlugin`
|
|
76
78
|
manifest enables (see §3 below).
|
|
77
79
|
|
|
78
|
-
§14 below has a one-command scaffold (`npx create-ampless plugin <name>`)
|
|
80
|
+
§14 below has a one-command scaffold (`npx create-ampless@beta plugin <name>`)
|
|
79
81
|
that produces ready-to-use boilerplate for either of the latter two.
|
|
80
82
|
|
|
81
83
|
An ampless plugin is a TypeScript module that returns an
|
|
@@ -121,10 +123,10 @@ The fastest way to create a plugin is to scaffold one:
|
|
|
121
123
|
|
|
122
124
|
```bash
|
|
123
125
|
# Site-local (writes plugins/<name>/index.ts in the current ampless site)
|
|
124
|
-
npx create-ampless@
|
|
126
|
+
npx create-ampless@beta plugin my-thing
|
|
125
127
|
|
|
126
128
|
# Standalone npm package (writes ./<name>/ ready for `npm publish`)
|
|
127
|
-
npx create-ampless@
|
|
129
|
+
npx create-ampless@beta plugin @myscope/ampless-plugin-my-thing --standalone
|
|
128
130
|
```
|
|
129
131
|
|
|
130
132
|
§14 covers the full flow. The rest of this section explains what the
|
|
@@ -200,7 +202,7 @@ That's it. Restart `npm run dev`, view source on any page, and the
|
|
|
200
202
|
interface AmplessPlugin {
|
|
201
203
|
name: string // package-like identifier, e.g. 'analytics-ga4'
|
|
202
204
|
packageName?: string // npm package name for install-time cross-check
|
|
203
|
-
apiVersion: 1 //
|
|
205
|
+
apiVersion: 1 // the only valid value today
|
|
204
206
|
trust_level: 'untrusted' | 'trusted' | 'privileged'
|
|
205
207
|
instanceId?: string // namespace for multi-instance installs
|
|
206
208
|
displayName?: LocalizedString // admin UI label
|
|
@@ -213,7 +215,11 @@ interface AmplessPlugin {
|
|
|
213
215
|
publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
|
|
214
216
|
publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
|
|
215
217
|
ogImage?: OgImageConfig
|
|
216
|
-
settings?: {
|
|
218
|
+
settings?: {
|
|
219
|
+
public?: readonly PluginSettingField[]
|
|
220
|
+
secret?: readonly PluginSecretField[]
|
|
221
|
+
version?: number // Phase 1 reservation; runtime ignores
|
|
222
|
+
}
|
|
217
223
|
}
|
|
218
224
|
```
|
|
219
225
|
|
|
@@ -230,6 +236,23 @@ There's only one version today. Future breaking-change versions will
|
|
|
230
236
|
bump this number; the runtime rejects unknown values rather than
|
|
231
237
|
silently mis-binding.
|
|
232
238
|
|
|
239
|
+
**Today only `apiVersion: 1` is valid.** The literal type accepts
|
|
240
|
+
no other value, and the runtime hard-throws if `package.json#amplessPlugin.apiVersion`
|
|
241
|
+
disagrees with what `definePlugin()` returns or exceeds the runtime's
|
|
242
|
+
`SUPPORTED_API_VERSION`.
|
|
243
|
+
|
|
244
|
+
All Phase 1 compat-break reservations (PRs #220, #222, #230, #232,
|
|
245
|
+
#234) live within `apiVersion: 1` — declaring or not declaring them
|
|
246
|
+
in your plugin does not affect the contract version.
|
|
247
|
+
|
|
248
|
+
If a future `apiVersion: 2` is ever introduced, it will be announced
|
|
249
|
+
through a changeset and a section update in this guide and the
|
|
250
|
+
architecture doc. Until then, **publish your plugin with
|
|
251
|
+
`apiVersion: 1` and treat it as the only valid value**. See the
|
|
252
|
+
[apiVersion bump policy](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#apiversion-bump-policy)
|
|
253
|
+
section in the architecture doc for the full criteria around what
|
|
254
|
+
would (and would not) trigger a v2 bump.
|
|
255
|
+
|
|
233
256
|
### `instanceId`
|
|
234
257
|
|
|
235
258
|
Optional, defaults to `name`. When you ship a plugin that can run
|
|
@@ -337,15 +360,29 @@ not a runtime block.
|
|
|
337
360
|
|
|
338
361
|
## 4. Picking a `trust_level`
|
|
339
362
|
|
|
363
|
+
The trust tiers are implemented in v1 as **first-party plugin organization** — they decide which IAM-scoped Lambda runs your event hooks and which permissions that Lambda holds. This is a code organization surface for engineer-audited npm deps, not a marketplace-grade automatic sandbox for arbitrary third-party untrusted plugins (see the [Positioning note](#writing-an-ampless-plugin) above and the [full trust model](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#trust-model-v1-scope)).
|
|
364
|
+
|
|
340
365
|
Three tiers, picked by what the plugin needs to do **inside
|
|
341
366
|
event hooks** (the sync surfaces — metadata, head, body — don't
|
|
342
367
|
touch IAM):
|
|
343
368
|
|
|
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 |
|
|
369
|
+
| Tier | IAM | What runs today | Used by |
|
|
370
|
+
|---|---|---|---|
|
|
371
|
+
| `untrusted` | none (SQS consume only) | sync surfaces + event hooks | head/body descriptors, webhook delivery, content transforms |
|
|
372
|
+
| `trusted` | read posts, write `public/plugins/<instanceId ?? name>/...` | sync surfaces + event hooks | RSS feed, sitemap, computed JSON indexes |
|
|
373
|
+
| `privileged` | reserved (v2.0+ exploration only) | sync surfaces only — event hooks are silently filtered out (warning logged) | future marketplace exploration: SES, secrets, private S3 |
|
|
374
|
+
|
|
375
|
+
> **Warning for `privileged` plugin authors:** If you declare
|
|
376
|
+
> `trust_level: 'privileged'` with event `hooks` today, **your hooks
|
|
377
|
+
> WILL NOT EXECUTE**. Both event processors filter out privileged
|
|
378
|
+
> plugins and emit a `console.warn` on every matching SQS event so
|
|
379
|
+
> the drop is visible. Sync render surfaces (`publicHead`,
|
|
380
|
+
> `publicBodyEnd`, `metadata`, `publicBodyForPost`, `publicHtmlForPost`)
|
|
381
|
+
> work normally regardless of `trust_level` and emit no warning.
|
|
382
|
+
> `privileged` Lambda provisioning is a v2.0+ exploration item — if
|
|
383
|
+
> AmpLess later builds a plugin marketplace, that PR will automatically
|
|
384
|
+
> pick up plugins that already declared `'privileged'`.
|
|
385
|
+
> v1 first-party plugins should use `trust_level: 'trusted'` for capabilities that fit within the trusted Lambda's existing IAM scope: Post / KvStore / PluginSecret / PostTag read, `public/plugins/*` S3 write, and outbound HTTP (no AWS-IAM-authenticated calls). Requirements that fall outside that scope — SES, private S3 prefixes, calling AWS APIs that need an IAM principal of their own — are v2.0+ privileged-Lambda exploration material and don't fit into v1 `trusted`.
|
|
349
386
|
|
|
350
387
|
Rule of thumb:
|
|
351
388
|
|
|
@@ -540,8 +577,12 @@ a console warning** and never rendered.
|
|
|
540
577
|
index numbers no one can map back to a plugin.
|
|
541
578
|
- **Duplicate `id`**: the last occurrence wins. A dev warning prints
|
|
542
579
|
which key was duplicated.
|
|
543
|
-
- **CSP nonce**:
|
|
544
|
-
|
|
580
|
+
- **CSP nonce**: `inlineScript.nonce` and `script.nonce` are accepted
|
|
581
|
+
by the type (`'auto'` is the sentinel for future runtime stamping;
|
|
582
|
+
any string is an explicit literal). Phase 1 reservation: the runtime
|
|
583
|
+
accepts the field but does not propagate it to the rendered element.
|
|
584
|
+
See [CSP nonce (Phase 1 reservation)](#csp-nonce-phase-1-reservation)
|
|
585
|
+
below.
|
|
545
586
|
- **Strategy**: `afterInteractive` adds `async` to external scripts.
|
|
546
587
|
`lazyOnload` adds `defer`. Explicit `async` / `defer` always
|
|
547
588
|
wins. `beforeInteractive` is not supported.
|
|
@@ -637,7 +678,7 @@ the slots:
|
|
|
637
678
|
```tsx
|
|
638
679
|
{postBody} {/* publicBodyForPost — JSON-LD */}
|
|
639
680
|
{html.beforeContent} {/* publicHtmlForPost — beforeContent slot */}
|
|
640
|
-
<div className="prose"
|
|
681
|
+
<div className="prose">{await ampless.renderBody(post)}</div>
|
|
641
682
|
{html.afterContent} {/* publicHtmlForPost — afterContent slot */}
|
|
642
683
|
```
|
|
643
684
|
|
|
@@ -717,10 +758,731 @@ nothing races against hydration.
|
|
|
717
758
|
|
|
718
759
|
---
|
|
719
760
|
|
|
761
|
+
## 6a. Scheduled posts and content events
|
|
762
|
+
|
|
763
|
+
ampless supports **scheduled publishing**: a post with `status:
|
|
764
|
+
'published'` and a future `publishedAt` is hidden from all public
|
|
765
|
+
reads until that time. Once `publishedAt` arrives, the post becomes
|
|
766
|
+
visible within the site's natural cache window (≤ ~5 minutes by
|
|
767
|
+
default) — there is no exact-time trigger.
|
|
768
|
+
|
|
769
|
+
### Events fire at save time, not at `publishedAt`
|
|
770
|
+
|
|
771
|
+
`content.published` (and `content.updated`) are emitted via DynamoDB
|
|
772
|
+
Streams **when the post is saved**, not when `publishedAt` arrives.
|
|
773
|
+
This means a plugin that reacts to `content.published` will run
|
|
774
|
+
**before the post is publicly visible** when the post is future-dated.
|
|
775
|
+
|
|
776
|
+
For trusted plugins that rebuild public assets from the current post
|
|
777
|
+
list (RSS, sitemap, JSON indexes), this is harmless — `listPublishedPosts()`
|
|
778
|
+
already filters out future-dated posts, so the regenerated asset
|
|
779
|
+
simply omits the scheduled post until it goes live.
|
|
780
|
+
|
|
781
|
+
For **outbound-notification plugins** (webhook, push notification,
|
|
782
|
+
social post) the early fire matters: the notification will be
|
|
783
|
+
delivered to subscribers while the post URL still returns 404 or
|
|
784
|
+
redirects to the home page.
|
|
785
|
+
|
|
786
|
+
### Recommended pattern: gate on `publishedAt`
|
|
787
|
+
|
|
788
|
+
Check `event.payload.publishedAt` before dispatching. Skip or defer
|
|
789
|
+
when the post is future-dated:
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
hooks: {
|
|
793
|
+
async 'content.published'(event, ctx) {
|
|
794
|
+
const { publishedAt } = event.payload
|
|
795
|
+
|
|
796
|
+
// Skip notification for future-scheduled posts. The event fires
|
|
797
|
+
// at save time, but the post won't be public until publishedAt.
|
|
798
|
+
if (publishedAt && new Date(publishedAt) > new Date()) {
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Post is live now — safe to notify.
|
|
803
|
+
await sendWebhook(event.payload, ctx)
|
|
804
|
+
},
|
|
805
|
+
}
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
The `publishedAt` value in the event payload is a UTC ISO 8601 string
|
|
809
|
+
(`...Z`). Parse it with `new Date()` or your preferred date library
|
|
810
|
+
before comparing against `Date.now()`.
|
|
811
|
+
|
|
812
|
+
### Future work
|
|
813
|
+
|
|
814
|
+
Aligning event emission with the scheduled time — so `content.published`
|
|
815
|
+
fires at `publishedAt` rather than at save time — is a planned
|
|
816
|
+
enhancement. It requires a scheduler component (EventBridge Scheduler
|
|
817
|
+
or a DynamoDB TTL-triggered Lambda) and is not yet in scope for the
|
|
818
|
+
current release. Until then, the pattern above is the recommended
|
|
819
|
+
guard for notification plugins.
|
|
820
|
+
|
|
821
|
+
For a full description of `publishedAt` semantics from the operator's
|
|
822
|
+
perspective, see [`docs/scheduled-publishing.md`](https://github.com/heavymoons/ampless/blob/main/docs/scheduled-publishing.md).
|
|
823
|
+
|
|
824
|
+
---
|
|
825
|
+
|
|
826
|
+
### CSP nonce (Phase 1 reservation)
|
|
827
|
+
|
|
828
|
+
Content Security Policy (CSP) is a near-mandatory requirement for production
|
|
829
|
+
sites. To avoid breaking every plugin at once when nonce propagation lands,
|
|
830
|
+
ampless reserves the API surface today (Phase 1 no-op) so plugins can opt in
|
|
831
|
+
ahead of time.
|
|
832
|
+
|
|
833
|
+
The 3-layer design:
|
|
834
|
+
|
|
835
|
+
1. **`ctx.cspNonce?: string`** on `PluginPublicRenderContext` — type-reserved
|
|
836
|
+
on the interface; always `undefined` today. The runtime does not populate
|
|
837
|
+
this field yet; reads resolve to `undefined`. Middleware/SSR nonce threading
|
|
838
|
+
lands with the future CSP RFP.
|
|
839
|
+
|
|
840
|
+
2. **`descriptor.nonce: 'auto' | string`** — accepted by the type on both
|
|
841
|
+
`inlineScript` and `script` descriptor variants. `'auto'` is the sentinel
|
|
842
|
+
for future runtime stamping; any other string is an explicit literal;
|
|
843
|
+
`undefined` emits no `nonce` attribute (default, backward-compatible).
|
|
844
|
+
Phase 1: the runtime accepts but does not propagate it. Declaring
|
|
845
|
+
`nonce: 'auto'` today is a forward-compatibility hint and does not change
|
|
846
|
+
the rendered HTML.
|
|
847
|
+
|
|
848
|
+
3. **`'cspReady'` capability** — a name-only declarative badge. Declaring it
|
|
849
|
+
signals intent; future admin UI / sanity checks may surface it. No runtime
|
|
850
|
+
cross-check or enforcement exists in Phase 1.
|
|
851
|
+
|
|
852
|
+
**How to be ready:**
|
|
853
|
+
|
|
854
|
+
```ts
|
|
855
|
+
// src/index.ts
|
|
856
|
+
definePlugin({
|
|
857
|
+
name: 'my-plugin',
|
|
858
|
+
apiVersion: 1,
|
|
859
|
+
trust_level: 'untrusted',
|
|
860
|
+
capabilities: ['publicHead', 'cspReady'],
|
|
861
|
+
publicHead: () => [{
|
|
862
|
+
type: 'inlineScript',
|
|
863
|
+
id: 'my-snippet',
|
|
864
|
+
body: '...',
|
|
865
|
+
nonce: 'auto', // forward-compat hint; no effect in Phase 1
|
|
866
|
+
}],
|
|
867
|
+
})
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
For standalone npm-published plugins, also update `package.json#amplessPlugin.capabilities`
|
|
871
|
+
to match — the runtime cross-check warns on disagreement between the static
|
|
872
|
+
manifest and the factory return value:
|
|
873
|
+
|
|
874
|
+
```json
|
|
875
|
+
{
|
|
876
|
+
"amplessPlugin": {
|
|
877
|
+
"apiVersion": 1,
|
|
878
|
+
"name": "my-plugin",
|
|
879
|
+
"trustLevel": "untrusted",
|
|
880
|
+
"capabilities": ["publicHead", "cspReady"]
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
**What "cspReady" means:**
|
|
886
|
+
|
|
887
|
+
- Site-level CSP compliance depends on middleware / response headers / other
|
|
888
|
+
inline content the runtime does not control.
|
|
889
|
+
- Once the middleware-driven nonce threading PR lands, plugin-supplied scripts
|
|
890
|
+
that carry `nonce: 'auto'` will become candidates for runtime nonce stamping.
|
|
891
|
+
- `'cspReady'` does **not** appear in `create-ampless plugin --capabilities`
|
|
892
|
+
output — it is a reserved capability and the scaffold excludes it to avoid
|
|
893
|
+
implying active enforcement.
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
## 6b. In-body content renderers: `contentFields` (Phase 7)
|
|
898
|
+
|
|
899
|
+
The `contentFields` capability lets a plugin replace specific fragments
|
|
900
|
+
of a post body — tiptap nodes or single-line markdown URLs — with a
|
|
901
|
+
React subtree it controls. Used by the first-party `@ampless/plugin-youtube`
|
|
902
|
+
and `@ampless/plugin-x-embed` packages to expand `https://youtu.be/...`
|
|
903
|
+
URLs into iframe players and `https://x.com/<handle>/status/...` URLs
|
|
904
|
+
into tweet blockquotes.
|
|
905
|
+
|
|
906
|
+
### Shape
|
|
907
|
+
|
|
908
|
+
```ts
|
|
909
|
+
import { definePlugin, type ContentFieldRenderer } from 'ampless'
|
|
910
|
+
|
|
911
|
+
definePlugin({
|
|
912
|
+
// ...
|
|
913
|
+
capabilities: ['contentFields'],
|
|
914
|
+
contentFields: [
|
|
915
|
+
{
|
|
916
|
+
kind: 'tiptap',
|
|
917
|
+
nodeType: 'amplessYoutube',
|
|
918
|
+
render: (node, ctx) => <YouTubeEmbed videoId={String(node.attrs?.videoId)} />,
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
kind: 'markdown-url',
|
|
922
|
+
pattern: /^https:\/\/youtu\.be\/([\w-]{11})$/,
|
|
923
|
+
render: ({ match }, ctx) => <YouTubeEmbed videoId={match[1]!} />,
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
})
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
Each renderer is called server-side by the runtime when it walks a post
|
|
930
|
+
body during `ampless.renderBody(post)`. The return value must be a
|
|
931
|
+
`ReactNode`. The plugin's `PluginPublicRenderContext` (`ctx`) is the
|
|
932
|
+
same context handed to `publicHead` / `publicBodyEnd`, so `ctx.setting<T>(key)`
|
|
933
|
+
works exactly the same way.
|
|
934
|
+
|
|
935
|
+
### The two kinds
|
|
936
|
+
|
|
937
|
+
- **`tiptap`** — keyed by `nodeType` (a string, e.g. `'amplessYoutube'`).
|
|
938
|
+
The runtime's tiptap walker calls the renderer whenever it encounters
|
|
939
|
+
a node whose `type` matches `nodeType`. Default switch-case rendering
|
|
940
|
+
is bypassed; the plugin owns the subtree.
|
|
941
|
+
- **`markdown-url`** — keyed by an anchored `RegExp` (`^...$`). The
|
|
942
|
+
runtime tokenizes markdown with `marked.lexer` and, for any
|
|
943
|
+
`paragraph` token whose entire content is a single URL (autolink,
|
|
944
|
+
bare URL, or `[text](url)` markdown link), tests the URL against
|
|
945
|
+
each registered pattern. The first match wins; capture groups are
|
|
946
|
+
exposed via `match[1]`, `match[2]`, etc.
|
|
947
|
+
|
|
948
|
+
### Naming and uniqueness
|
|
949
|
+
|
|
950
|
+
- First-party plugins use the `ampless...` camelCase prefix
|
|
951
|
+
(`amplessYoutube`, `amplessTweet`) so the namespace stays clear of
|
|
952
|
+
community-contributed `nodeType`s.
|
|
953
|
+
- The runtime rejects duplicate `nodeType` / `pattern.source` at
|
|
954
|
+
startup with a thrown error. The first plugin to register a given key
|
|
955
|
+
wins; the second one fails fast. Multi-instance v1 is not supported.
|
|
956
|
+
|
|
957
|
+
### Markdown URL pattern rules
|
|
958
|
+
|
|
959
|
+
- **Always anchor with `^...$`** so the pattern only matches paragraphs
|
|
960
|
+
whose entire content is a single URL. A pattern without anchors would
|
|
961
|
+
match URLs embedded mid-paragraph and break the surrounding text.
|
|
962
|
+
- The runtime trims leading/trailing whitespace before matching, so
|
|
963
|
+
patterns don't need to account for `\s*` around the URL.
|
|
964
|
+
- Inline `[caption](url)` markdown links are accepted when the link is
|
|
965
|
+
the paragraph's only token. `[caption with link](url)` mixed with
|
|
966
|
+
surrounding text is NOT matched (correct behaviour: the prose
|
|
967
|
+
belongs in the post, not a video embed).
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
971
|
+
## 6c. Page-level scripts: `publicPostScript` (Phase 7)
|
|
972
|
+
|
|
973
|
+
The `publicPostScript` capability lets a plugin emit a `<script>` tag
|
|
974
|
+
that any post on the page needs. The runtime dedupes by stable `id`
|
|
975
|
+
so multiple embeds in one or several posts collapse to one script tag.
|
|
976
|
+
Used by `@ampless/plugin-x-embed` to inject
|
|
977
|
+
`https://platform.twitter.com/widgets.js` once per page that has any
|
|
978
|
+
tweet embed.
|
|
979
|
+
|
|
980
|
+
### Shape
|
|
981
|
+
|
|
982
|
+
```ts
|
|
983
|
+
definePlugin({
|
|
984
|
+
capabilities: ['contentFields', 'publicPostScript'],
|
|
985
|
+
publicPostScript(post, ctx) {
|
|
986
|
+
if (!hasTweetIn(post)) return []
|
|
987
|
+
return [
|
|
988
|
+
{
|
|
989
|
+
id: 'amplessTweet:widgets',
|
|
990
|
+
src: 'https://platform.twitter.com/widgets.js',
|
|
991
|
+
async: true,
|
|
992
|
+
},
|
|
993
|
+
]
|
|
994
|
+
},
|
|
995
|
+
})
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### Theme integration
|
|
999
|
+
|
|
1000
|
+
Themes call `{await ampless.publicPostScriptsForPage(posts)}` after
|
|
1001
|
+
rendering each post body. First-party themes do this automatically in
|
|
1002
|
+
their post detail page and home page (when a featured post is shown):
|
|
1003
|
+
|
|
1004
|
+
```tsx
|
|
1005
|
+
<div>{await ampless.renderBody(post)}</div>
|
|
1006
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
The runtime:
|
|
1010
|
+
|
|
1011
|
+
1. Calls `publicPostScript(post, ctx)` for each plugin × post pair.
|
|
1012
|
+
2. Drops descriptors with empty/non-string `id`, with `src` that fails
|
|
1013
|
+
the http(s) allowlist, or that aren't objects.
|
|
1014
|
+
3. Dedupes by `id` (first arrival wins).
|
|
1015
|
+
4. Emits a `<Fragment>` of `<script src={src} async defer />` elements.
|
|
1016
|
+
|
|
1017
|
+
### CSP considerations
|
|
1018
|
+
|
|
1019
|
+
The runtime does **not** enforce a host allowlist on `src`. CSP is the
|
|
1020
|
+
site engineer's responsibility — add the script host (e.g.
|
|
1021
|
+
`platform.twitter.com`) to `script-src` in `next.config.ts` /
|
|
1022
|
+
middleware. This is documented in each plugin's README.
|
|
1023
|
+
|
|
1024
|
+
---
|
|
1025
|
+
|
|
1026
|
+
## 6d. Admin editor extension wiring (Phase 7)
|
|
1027
|
+
|
|
1028
|
+
Plugins that contribute tiptap Node extensions to the admin editor
|
|
1029
|
+
ship a separate client-side entry under the `./editor` subpath.
|
|
1030
|
+
`app/(admin)/admin/_editor-bootstrap.tsx` is **auto-generated** by
|
|
1031
|
+
`npm run update-ampless` — you should not edit it by hand.
|
|
1032
|
+
|
|
1033
|
+
### For plugin authors
|
|
1034
|
+
|
|
1035
|
+
Declare `editorExports` in `package.json#amplessPlugin`:
|
|
1036
|
+
|
|
1037
|
+
```jsonc
|
|
1038
|
+
// packages/plugin-youtube/package.json
|
|
1039
|
+
"amplessPlugin": {
|
|
1040
|
+
"apiVersion": 1,
|
|
1041
|
+
"name": "youtube",
|
|
1042
|
+
"trustLevel": "trusted",
|
|
1043
|
+
"capabilities": ["contentFields"],
|
|
1044
|
+
"editorExports": "./editor" // ← subpath where the editor module lives
|
|
1045
|
+
}
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
Export `editorExtension` as a named symbol from that subpath:
|
|
1049
|
+
|
|
1050
|
+
```ts
|
|
1051
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
1052
|
+
export const editorExtension = AmplessYoutubeNode // tiptap Node or Extension
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
The subpath must be declared in `package.json#exports` as well (the
|
|
1056
|
+
codegen validates this — a missing `exports` entry triggers a warning
|
|
1057
|
+
and the plugin is skipped):
|
|
1058
|
+
|
|
1059
|
+
```jsonc
|
|
1060
|
+
"exports": {
|
|
1061
|
+
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
|
|
1062
|
+
"./editor": { "import": "./dist/editor.js", "types": "./dist/editor.d.ts" }
|
|
1063
|
+
}
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
### For plugin users (site engineers)
|
|
1067
|
+
|
|
1068
|
+
1. `npm i @ampless/plugin-youtube@beta` — add the plugin as a dependency.
|
|
1069
|
+
2. Register it in `cms.config.ts` for the server-side renderer.
|
|
1070
|
+
3. `npm run update-ampless` — regenerates `_editor-bootstrap.tsx`
|
|
1071
|
+
automatically from the installed plugin manifests.
|
|
1072
|
+
|
|
1073
|
+
The generated file is committed to your repo and looks like:
|
|
1074
|
+
|
|
1075
|
+
```tsx
|
|
1076
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — do not edit)
|
|
1077
|
+
'use client'
|
|
1078
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit — your changes
|
|
1079
|
+
// will be overwritten on the next run.
|
|
1080
|
+
import { installAdminEditorExtensions } from '@ampless/admin/editor'
|
|
1081
|
+
import { editorExtension as __ampless_plugin_x_embed_editor } from '@ampless/plugin-x-embed/editor'
|
|
1082
|
+
import { editorExtension as __ampless_plugin_youtube_editor } from '@ampless/plugin-youtube/editor'
|
|
1083
|
+
|
|
1084
|
+
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
1085
|
+
installAdminEditorExtensions([
|
|
1086
|
+
__ampless_plugin_x_embed_editor,
|
|
1087
|
+
__ampless_plugin_youtube_editor,
|
|
1088
|
+
])
|
|
1089
|
+
return <>{children}</>
|
|
1090
|
+
}
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
The file is then wired into the admin layout (already in the template — no
|
|
1094
|
+
extra work required):
|
|
1095
|
+
|
|
1096
|
+
```tsx
|
|
1097
|
+
// templates/_shared/app/(admin)/admin/layout.tsx
|
|
1098
|
+
import { createAdminLayout } from '@ampless/admin/pages'
|
|
1099
|
+
import { EditorBootstrap } from './_editor-bootstrap'
|
|
1100
|
+
export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
`installAdminEditorExtensions` is idempotent and runs at render time
|
|
1104
|
+
inside a client component. The admin's `<TiptapEditor>` spreads the
|
|
1105
|
+
registered list onto its built-in extensions on every render.
|
|
1106
|
+
|
|
1107
|
+
The `_editor-bootstrap.tsx` generated by `update-ampless` wires three
|
|
1108
|
+
install calls — extensions, markdown adapters, and html adapters:
|
|
1109
|
+
|
|
1110
|
+
```tsx
|
|
1111
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — do not edit)
|
|
1112
|
+
'use client'
|
|
1113
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit ...
|
|
1114
|
+
import { installAdminEditorExtensions, installAdminTiptapNodeMarkdown, installAdminTiptapNodeHtml } from '@ampless/admin/editor'
|
|
1115
|
+
import * as __ampless_plugin_x_embed_editor from '@ampless/plugin-x-embed/editor'
|
|
1116
|
+
import * as __ampless_plugin_youtube_editor from '@ampless/plugin-youtube/editor'
|
|
1117
|
+
|
|
1118
|
+
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
1119
|
+
installAdminEditorExtensions([
|
|
1120
|
+
__ampless_plugin_x_embed_editor.editorExtension,
|
|
1121
|
+
__ampless_plugin_youtube_editor.editorExtension,
|
|
1122
|
+
])
|
|
1123
|
+
installAdminTiptapNodeMarkdown([
|
|
1124
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToMarkdown ?? {},
|
|
1125
|
+
__ampless_plugin_youtube_editor.tiptapNodeToMarkdown ?? {},
|
|
1126
|
+
])
|
|
1127
|
+
installAdminTiptapNodeHtml([
|
|
1128
|
+
__ampless_plugin_x_embed_editor.tiptapNodeToHtml ?? {},
|
|
1129
|
+
__ampless_plugin_youtube_editor.tiptapNodeToHtml ?? {},
|
|
1130
|
+
])
|
|
1131
|
+
return <>{children}</>
|
|
1132
|
+
}
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
### Lossless format-switch adapters (`tiptapNodeToMarkdown` + `tiptapNodeToHtml`)
|
|
1136
|
+
|
|
1137
|
+
When the operator switches the post format in the admin UI (e.g. `tiptap →
|
|
1138
|
+
markdown` or `tiptap → html`), the admin must convert the body content. For
|
|
1139
|
+
standard prose nodes tiptap's built-in renderers handle this, but **atom
|
|
1140
|
+
nodes** (embed blocks like `amplessYoutube`) have no children — they fall
|
|
1141
|
+
through with empty output, silently dropping the embed.
|
|
1142
|
+
|
|
1143
|
+
Two adapters fix both directions:
|
|
1144
|
+
|
|
1145
|
+
| Adapter | Direction | Output |
|
|
1146
|
+
| --------------------- | --------------------------------- | ------ |
|
|
1147
|
+
| `tiptapNodeToMarkdown`| `tiptap → markdown` | bare URL line (e.g. `https://youtu.be/<id>`) |
|
|
1148
|
+
| `tiptapNodeToHtml` | `tiptap → html`, `markdown → html`| canonical placeholder div |
|
|
1149
|
+
|
|
1150
|
+
The three canonical body representations across format-switch are:
|
|
1151
|
+
|
|
1152
|
+
| Format | Canonical form |
|
|
1153
|
+
| -------- | -------------- |
|
|
1154
|
+
| tiptap | `{ type: 'amplessYoutube', attrs: { videoId, start } }` Node |
|
|
1155
|
+
| markdown | bare `https://youtu.be/<id>` URL line |
|
|
1156
|
+
| html | `<div data-ampless-youtube data-video-id="<id>" …>…</div>` |
|
|
1157
|
+
|
|
1158
|
+
The placeholder div is the canonical HTML form for **admin format-switch
|
|
1159
|
+
interop only**: it is what `Node.renderHTML` emits, and what
|
|
1160
|
+
`Node.parseHTML`'s `tag: 'div[data-ampless-*]'` rule restores from, so
|
|
1161
|
+
that switching `tiptap ↔ markdown ↔ html` in the admin preserves embeds
|
|
1162
|
+
losslessly.
|
|
1163
|
+
|
|
1164
|
+
**Public render of `format: 'html'` posts expands the placeholder when
|
|
1165
|
+
the plugin declares `htmlPlaceholder`.** All three public render paths
|
|
1166
|
+
reach the same `contentFields.tiptap` renderer: tiptap posts go through
|
|
1167
|
+
the React walker that consults the `contentFields.tiptap` registry
|
|
1168
|
+
(= real iframe for `amplessYoutube` Nodes); markdown posts go through the
|
|
1169
|
+
markdown walker that consults `contentFields.markdownUrl` (= real iframe
|
|
1170
|
+
for bare URL paragraphs); html posts go through the **public html walker**
|
|
1171
|
+
that consults `contentFields.htmlPlaceholder` (= real iframe for
|
|
1172
|
+
top-level `<div data-ampless-youtube …>` placeholders). The
|
|
1173
|
+
`tiptapNodeToHtml` adapter above only governs the **admin format-switch**
|
|
1174
|
+
interchange form — declaring `htmlPlaceholder` is what makes the public
|
|
1175
|
+
html walker expand that form on render. (See the dedicated
|
|
1176
|
+
`htmlPlaceholder` section below for the contract.) `publicHtmlForPost`
|
|
1177
|
+
still only emits `beforeContent` / `afterContent` slots and does not
|
|
1178
|
+
transform the body.
|
|
1179
|
+
|
|
1180
|
+
A plugin that ships an embed node but does **not** declare
|
|
1181
|
+
`htmlPlaceholder` keeps the old behaviour: the placeholder div is shipped
|
|
1182
|
+
literally on public render of `format: 'html'` posts (the inner canonical
|
|
1183
|
+
URL link is still clickable, so it degrades gracefully). Save as `tiptap`
|
|
1184
|
+
or `markdown` to get the iframe in that case.
|
|
1185
|
+
|
|
1186
|
+
#### Adapter contract
|
|
1187
|
+
|
|
1188
|
+
Both adapters have the same signature:
|
|
1189
|
+
|
|
1190
|
+
```ts
|
|
1191
|
+
(node: TiptapRenderNode) => string | null
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
Return a **string** (including empty string `''`) to use your output.
|
|
1195
|
+
Return **`null`** to fall through to the default switch (useful for nodes
|
|
1196
|
+
you don't handle or for degenerate inputs like a missing video id).
|
|
1197
|
+
|
|
1198
|
+
```ts
|
|
1199
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
1200
|
+
import type { TiptapNodeMarkdownAdapters, TiptapNodeHtmlAdapters } from 'ampless'
|
|
1201
|
+
|
|
1202
|
+
export const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters = {
|
|
1203
|
+
amplessYoutube: (node) => {
|
|
1204
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
1205
|
+
if (!videoId) return null // fall through
|
|
1206
|
+
return `https://youtu.be/${videoId}`
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
|
|
1211
|
+
amplessYoutube: (node) => {
|
|
1212
|
+
const videoId = String(node.attrs?.videoId ?? '').trim()
|
|
1213
|
+
if (!videoId) return null // fall through
|
|
1214
|
+
const attrs = placeholderAttrs(node.attrs ?? {})
|
|
1215
|
+
// Inner content is the canonical URL as a clickable link, not the
|
|
1216
|
+
// editor's visual label `<span>YouTube: id</span>`. Public render of
|
|
1217
|
+
// `format: 'html'` posts shows this content literally; an editor
|
|
1218
|
+
// label would leak. The URL link gracefully degrades — viewers
|
|
1219
|
+
// without iframe expansion still get a clickable link, and it
|
|
1220
|
+
// mirrors the markdown canonical form. parseHTML reads the
|
|
1221
|
+
// `data-video-id` attribute, so the inner content is irrelevant
|
|
1222
|
+
// for round-trip.
|
|
1223
|
+
const url = `https://youtu.be/${videoId}`
|
|
1224
|
+
return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`
|
|
1225
|
+
},
|
|
1226
|
+
}
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
#### Wiring
|
|
1230
|
+
|
|
1231
|
+
`update-ampless` reads the `tiptapNodeToMarkdown` and `tiptapNodeToHtml`
|
|
1232
|
+
named exports from each plugin's `./editor` module (via namespace import `*
|
|
1233
|
+
as`) and wires them into both installs automatically — **no hand-wiring
|
|
1234
|
+
required**. If your plugin does not export one of the maps, the `?? {}`
|
|
1235
|
+
fallback in the generated file is a no-op.
|
|
1236
|
+
|
|
1237
|
+
#### `markdown → html` 2-hop
|
|
1238
|
+
|
|
1239
|
+
The `markdown → html` direction reuses the `tiptapNodeToHtml` adapter via
|
|
1240
|
+
a 2-hop:
|
|
1241
|
+
|
|
1242
|
+
1. `markdownToHtml(body)` — marked converts the markdown body to HTML. Bare
|
|
1243
|
+
URL lines become `<p><a href="URL">URL</a></p>`.
|
|
1244
|
+
2. `generateJSON(html, extensions)` — tiptap parses the HTML. Your plugin's
|
|
1245
|
+
`Node.parseHTML` `tag: 'p'` rule promotes the bare-URL paragraph to the
|
|
1246
|
+
embed Node.
|
|
1247
|
+
3. `tiptapToHtml(doc, { nodeAdapters })` — the html adapter serialises the
|
|
1248
|
+
embed Node to the placeholder div.
|
|
1249
|
+
|
|
1250
|
+
This means your plugin only needs to export the `tiptap → html` adapter once;
|
|
1251
|
+
the `markdown → html` direction reuses it automatically through tiptap's parse
|
|
1252
|
+
rules. **No duplicate logic is needed.**
|
|
1253
|
+
|
|
1254
|
+
#### Public html walker: `htmlPlaceholder`
|
|
1255
|
+
|
|
1256
|
+
To make `format: 'html'` posts render placeholder divs as real embeds on
|
|
1257
|
+
the public page, add an **`htmlPlaceholder`** declaration to your existing
|
|
1258
|
+
`contentFields` `tiptap` entry. You write **no new renderer** — the walker
|
|
1259
|
+
calls the same `render(node, ctx)` your tiptap entry already uses, so all
|
|
1260
|
+
three formats (tiptap / markdown / html) reach one renderer with no
|
|
1261
|
+
divergence.
|
|
1262
|
+
|
|
1263
|
+
```ts
|
|
1264
|
+
// packages/plugin-youtube/src/index.tsx
|
|
1265
|
+
contentFields: [
|
|
1266
|
+
{
|
|
1267
|
+
kind: 'tiptap',
|
|
1268
|
+
nodeType: 'amplessYoutube',
|
|
1269
|
+
render: (node) => {
|
|
1270
|
+
const videoId = String(node.attrs?.videoId ?? '')
|
|
1271
|
+
const startRaw = node.attrs?.start
|
|
1272
|
+
const start =
|
|
1273
|
+
typeof startRaw === 'number' && Number.isFinite(startRaw) ? startRaw : undefined
|
|
1274
|
+
return <YouTubeEmbed videoId={videoId} start={start} />
|
|
1275
|
+
},
|
|
1276
|
+
htmlPlaceholder: {
|
|
1277
|
+
// The marker attribute that flags a top-level placeholder div.
|
|
1278
|
+
flagAttr: 'data-ampless-youtube',
|
|
1279
|
+
// Convert the div's HTML attributes into the tiptap node `attrs`
|
|
1280
|
+
// your `render` expects — WITH the right types. The walker is
|
|
1281
|
+
// type-agnostic, so coerce here (e.g. data-start string → number).
|
|
1282
|
+
attrsFromElement: (attribs) => {
|
|
1283
|
+
const start = Number(attribs['data-start'])
|
|
1284
|
+
return {
|
|
1285
|
+
videoId: attribs['data-video-id'] ?? '',
|
|
1286
|
+
start: Number.isFinite(start) ? start : undefined,
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
},
|
|
1291
|
+
// …markdown-url entry unchanged…
|
|
1292
|
+
]
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
On public render of a `format: 'html'` post, the runtime parses the body
|
|
1296
|
+
server-side (`htmlparser2`), finds each **top-level** element carrying
|
|
1297
|
+
`flagAttr`, builds a `{ type: nodeType, attrs: attrsFromElement(attribs) }`
|
|
1298
|
+
node, and calls `render(node, ctx)`. Everything else passes through as the
|
|
1299
|
+
**original-string slices** (byte-for-byte; no DOM re-serialisation).
|
|
1300
|
+
|
|
1301
|
+
Constraints worth knowing:
|
|
1302
|
+
|
|
1303
|
+
- **Top-level only.** Placeholder divs nested inside `<blockquote>`,
|
|
1304
|
+
`<li>`, etc. stay literal — consistent with the editor's body-level-only
|
|
1305
|
+
`parseHTML` fallback. Only depth-0 elements expand.
|
|
1306
|
+
- **`flagAttr` is matched case-insensitively.** htmlparser2 lowercases
|
|
1307
|
+
HTML attribute names while parsing, and the runtime lowercases your
|
|
1308
|
+
`flagAttr` at registration, so `<div DATA-AMPLESS-YOUTUBE …>` and
|
|
1309
|
+
`<div data-ampless-youtube …>` both expand. `flagAttr` can be any
|
|
1310
|
+
attribute name — a site-local plugin may use `data-my-embed`; there is
|
|
1311
|
+
no fixed `data-ampless` prefix requirement.
|
|
1312
|
+
- **Graceful degradation.** If `attrsFromElement` or `render` throws, the
|
|
1313
|
+
runtime logs a `console.warn` and falls back to the **raw placeholder
|
|
1314
|
+
slice** (the inner canonical URL link stays clickable) rather than
|
|
1315
|
+
dropping the engineer-authored content.
|
|
1316
|
+
- **Page-level scripts.** If your embed needs a third-party script (e.g.
|
|
1317
|
+
x.com's `widgets.js`), your `publicPostScript` / detection helper must
|
|
1318
|
+
also recognise the placeholder form. `plugin-x-embed`'s `hasTweetIn`
|
|
1319
|
+
matches both `twitter-tweet` and `data-ampless-tweet` in `format: 'html'`
|
|
1320
|
+
bodies so widgets.js is injected for the expanded blockquote to hydrate.
|
|
1321
|
+
|
|
1322
|
+
**Wrapper-boundary change.** A placeholder-free `format: 'html'` post is
|
|
1323
|
+
emitted as a single wrapper `<div>` (the fast path — markup-identical to
|
|
1324
|
+
the previous raw passthrough). A post **with** placeholders becomes
|
|
1325
|
+
**multiple wrapper divs interleaved with the React embed siblings**: the
|
|
1326
|
+
bytes inside each raw chunk are preserved exactly, but the wrapper
|
|
1327
|
+
boundaries shift. Direct-child / adjacent-sibling CSS selectors that
|
|
1328
|
+
assumed one wrapper around the whole body will not match the same way.
|
|
1329
|
+
This is the accepted trade-off for expanding embeds in-place.
|
|
1330
|
+
|
|
1331
|
+
### Markdown to tiptap restoration
|
|
1332
|
+
|
|
1333
|
+
If an editor Node serializes to markdown as a bare URL line, the reverse
|
|
1334
|
+
direction must be handled by `Node.parseHTML()`, not by paste rules. The
|
|
1335
|
+
admin's `markdown → tiptap` format switch first converts markdown to HTML;
|
|
1336
|
+
GFM autolinks emit a bare URL line as
|
|
1337
|
+
`<p><a href="https://...">https://...</a></p>`, then tiptap parses that
|
|
1338
|
+
HTML into a document. Paste rules only run for user paste / typing events,
|
|
1339
|
+
so they do not fire on this HTML parse path.
|
|
1340
|
+
|
|
1341
|
+
Embed-style Nodes should add a high-priority parse rule for the paragraph
|
|
1342
|
+
shape above, and may also add an `a[href]` rule for other HTML-to-tiptap
|
|
1343
|
+
paths. The paragraph rule should only match when the paragraph contains a
|
|
1344
|
+
single link whose text equals its `href`; that lets the parser replace the
|
|
1345
|
+
whole paragraph with the block embed Node instead of leaving an empty
|
|
1346
|
+
paragraph before the embed. `getAttrs` should validate the URL and return
|
|
1347
|
+
`false` for non-matching links so the normal Link mark remains in place.
|
|
1348
|
+
|
|
1349
|
+
### Active source and full disable
|
|
1350
|
+
|
|
1351
|
+
**The active source for editor wiring is `package.json#dependencies`**
|
|
1352
|
+
(= `node_modules`), not `cms.config.ts`. This means:
|
|
1353
|
+
|
|
1354
|
+
- Removing a plugin from `cms.config.ts` but keeping it in
|
|
1355
|
+
`package.json` leaves the editor paste rule active. The editor can
|
|
1356
|
+
still insert the node type into the tiptap document, but the public
|
|
1357
|
+
renderer won't render it (inconsistent state — avoid).
|
|
1358
|
+
- To fully disable a plugin's editor extension: remove it from
|
|
1359
|
+
`cms.config.ts` **and** run `npm uninstall @ampless/plugin-...`,
|
|
1360
|
+
then `npm run update-ampless` to regenerate the bootstrap file.
|
|
1361
|
+
|
|
1362
|
+
### Editor preview pipeline
|
|
1363
|
+
|
|
1364
|
+
The admin's edit / new post forms render the preview pane in an
|
|
1365
|
+
`<iframe sandbox="allow-scripts allow-same-origin">` whose `srcDoc` is the HTML returned
|
|
1366
|
+
by the template's preview Route Handler. The template scaffold ships
|
|
1367
|
+
the handler at `app/(admin)/admin/preview/route.tsx`:
|
|
1368
|
+
|
|
1369
|
+
```tsx
|
|
1370
|
+
// templates/_shared/app/(admin)/admin/preview/route.tsx
|
|
1371
|
+
import type { Post } from 'ampless'
|
|
1372
|
+
import { admin } from '@/lib/admin'
|
|
1373
|
+
|
|
1374
|
+
export async function POST(req: Request): Promise<Response> {
|
|
1375
|
+
const session = await admin.getServerSession()
|
|
1376
|
+
if (!admin.isEditor(session)) {
|
|
1377
|
+
return new Response('Forbidden', { status: 403 })
|
|
1378
|
+
}
|
|
1379
|
+
let draft: Post
|
|
1380
|
+
try {
|
|
1381
|
+
draft = (await req.json()) as Post
|
|
1382
|
+
} catch {
|
|
1383
|
+
return new Response('Bad Request', { status: 400 })
|
|
1384
|
+
}
|
|
1385
|
+
const ampless = await admin.getAmpless()
|
|
1386
|
+
const node = (
|
|
1387
|
+
<>
|
|
1388
|
+
{await ampless.renderBody(draft)}
|
|
1389
|
+
{await ampless.publicPostScriptsForPage([draft])}
|
|
1390
|
+
</>
|
|
1391
|
+
)
|
|
1392
|
+
// Dynamic import: see "Why a Route Handler" below.
|
|
1393
|
+
const { renderToStaticMarkup } = await import('react-dom/server')
|
|
1394
|
+
return new Response(renderToStaticMarkup(node), {
|
|
1395
|
+
headers: {
|
|
1396
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1397
|
+
'Cache-Control': 'no-store',
|
|
1398
|
+
},
|
|
1399
|
+
})
|
|
1400
|
+
}
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
`<PostForm>` / `<PostHistoryPanel>` POST the draft to `/admin/preview`
|
|
1404
|
+
out of the box — no extra wiring at the call site:
|
|
1405
|
+
|
|
1406
|
+
```tsx
|
|
1407
|
+
// templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
|
|
1408
|
+
import { admin } from '@/lib/admin'
|
|
1409
|
+
import { createEditPostPage } from '@ampless/admin/pages'
|
|
1410
|
+
|
|
1411
|
+
export default createEditPostPage(admin)
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
If the admin is mounted at a non-default path (Next.js `basePath` or
|
|
1415
|
+
a custom prefix), override the endpoint via the page factory's
|
|
1416
|
+
`previewEndpoint` option:
|
|
1417
|
+
|
|
1418
|
+
```tsx
|
|
1419
|
+
export default createEditPostPage(admin, { previewEndpoint: '/cms/admin/preview' })
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
Why a Route Handler rather than a Server Action: putting
|
|
1423
|
+
`react-dom/server`-driven rendering inside a `'use server'` module
|
|
1424
|
+
makes Next.js 15+ refuse to compile the edit-post page, because the
|
|
1425
|
+
build traces the import graph from Client Components through Server
|
|
1426
|
+
Action modules and any reach to `react-dom/server` along that path
|
|
1427
|
+
trips the "You're importing a component that imports
|
|
1428
|
+
react-dom/server" check. A Route Handler decouples the rendering
|
|
1429
|
+
from that graph entirely — `<PostForm>` fetches a plain HTTP
|
|
1430
|
+
endpoint and the bundler never walks from the form into here. The
|
|
1431
|
+
handler's explicit `admin.isEditor()` check also defends against
|
|
1432
|
+
accidental exposure if the `(admin)` route-group gate is ever
|
|
1433
|
+
misconfigured. The `react-dom/server` import itself is dynamic
|
|
1434
|
+
because Next.js 16's Turbopack flags any top-level static import of
|
|
1435
|
+
`react-dom/server` reached from the app router build, Route Handlers
|
|
1436
|
+
included; deferring it to request time keeps the module outside the
|
|
1437
|
+
build-time import-graph walker while still loading it from the same
|
|
1438
|
+
Node.js subpath at runtime.
|
|
1439
|
+
|
|
1440
|
+
**Preview iframe sandbox — v1 trust boundary expansion:** The iframe uses
|
|
1441
|
+
`sandbox="allow-scripts allow-same-origin"`. With `srcDoc`, this gives
|
|
1442
|
+
the iframe the admin's origin, which 3rd-party embed widgets (YouTube SDK,
|
|
1443
|
+
x.com `widgets.js`) require — they refuse to initialise in an opaque-origin
|
|
1444
|
+
(`allow-scripts`-only) iframe because they need access to non-HttpOnly
|
|
1445
|
+
storage / cache and real-origin requests. Same-origin also gives the
|
|
1446
|
+
preview script access to the admin's auth state / non-HttpOnly storage /
|
|
1447
|
+
DOM and lets it issue authenticated same-origin XHR / fetch.
|
|
1448
|
+
|
|
1449
|
+
This is an explicit v1 design decision, not a no-op sandbox relax:
|
|
1450
|
+
**ampless v1 treats admin preview content / plugin script as trusted**.
|
|
1451
|
+
The engineer audits plugins before npm-installing them (customization-based
|
|
1452
|
+
CMS model), and body content is produced by trusted editors of this site.
|
|
1453
|
+
`<PostHistoryPanel>` can also surface a past revision authored by a
|
|
1454
|
+
different editor (revision author ≠ preview viewer) — v1 explicitly puts
|
|
1455
|
+
both inside the trust ring. The safer alternative — separate-origin preview
|
|
1456
|
+
route + CSP / COEP / COOP — is parked for v2.0+ if/when a real plugin
|
|
1457
|
+
marketplace lands.
|
|
1458
|
+
|
|
1459
|
+
---
|
|
1460
|
+
|
|
720
1461
|
## 7. Async event hooks
|
|
721
1462
|
|
|
722
1463
|
`hooks` runs inside the trust_level-matched processor Lambda when an
|
|
723
|
-
event arrives via SQS.
|
|
1464
|
+
event arrives via SQS.
|
|
1465
|
+
|
|
1466
|
+
### Return value reservation
|
|
1467
|
+
|
|
1468
|
+
`PluginEventHandler` returns `Promise<void | PluginHookResult>`. The
|
|
1469
|
+
runtime currently ignores the return value entirely — existing
|
|
1470
|
+
plugins returning `Promise<void>` keep working without migration.
|
|
1471
|
+
`PluginHookResult` is reserved for a future directive (likely first:
|
|
1472
|
+
`metrics?: Record<string, number>` for observability emission);
|
|
1473
|
+
declaring it today is a forward-compatibility hint. Note: rewrite
|
|
1474
|
+
or cancel directives are NOT enabled by this widening alone — they
|
|
1475
|
+
require additional `before:*` event support and payload extensions
|
|
1476
|
+
in separate PRs.
|
|
1477
|
+
|
|
1478
|
+
`PluginHookResult` carries a private `__amplessPluginHookResult`
|
|
1479
|
+
marker so that the union does not silently accept unrelated promise
|
|
1480
|
+
types (`Promise<string>` / `Promise<number>` etc.) — plugin authors
|
|
1481
|
+
do not need to set this field.
|
|
1482
|
+
|
|
1483
|
+
### Runtime context
|
|
1484
|
+
|
|
1485
|
+
The runtime context (`ctx`) carries:
|
|
724
1486
|
|
|
725
1487
|
```ts
|
|
726
1488
|
interface PluginRuntimeContext {
|
|
@@ -924,71 +1686,82 @@ etc. — but completely wrong for a webhook signing secret.
|
|
|
924
1686
|
|
|
925
1687
|
- Stored in the `PluginSecret` DynamoDB table (separate from KvStore).
|
|
926
1688
|
Admin/editor Cognito users have **no direct AppSync access** to this
|
|
927
|
-
table — all writes go through the `setPluginSecret` mutation
|
|
928
|
-
by the `plugin-secret-handler` Lambda.
|
|
1689
|
+
table — all writes go through the `setPluginSecret` mutation, which
|
|
1690
|
+
is backed by the `plugin-secret-handler` Lambda.
|
|
929
1691
|
- Values are **AES-256-GCM encrypted** before reaching DynamoDB.
|
|
930
1692
|
The admin browser sends the plaintext to the Lambda via AppSync
|
|
931
|
-
(TLS); the Lambda validates, reads the 32-byte
|
|
932
|
-
`process.env.PLUGIN_SECRET_ENCRYPTION_KEY`
|
|
933
|
-
`amplify/secrets/encryption-key.ts`),
|
|
934
|
-
|
|
1693
|
+
(TLS in transit); the Lambda validates it, reads the 32-byte
|
|
1694
|
+
encryption key from `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`
|
|
1695
|
+
(injected by CDK from `amplify/secrets/encryption-key.ts`), encrypts
|
|
1696
|
+
the value, and writes only the ciphertext to DDB. The plaintext
|
|
1697
|
+
never rests in DynamoDB and never flows back to the browser.
|
|
935
1698
|
- **Threat model (Phase 6a v2.2)**:
|
|
936
1699
|
|
|
937
1700
|
| Threat | Status |
|
|
938
1701
|
|---|---|
|
|
939
1702
|
| 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`.
|
|
1703
|
+
| Source repo / deploy artifact access | ⚠ NOT defeated — key is in `amplify/secrets/encryption-key.ts`. For public repos, keep the key out of source control (for example with `npx create-ampless@beta setup-encryption-key --gitignore`) and restrict deploy artifact access. |
|
|
941
1704
|
| 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
1705
|
| S3 mirror leak | ✓ defeated — PluginSecret table never mirrored. |
|
|
943
1706
|
|
|
944
1707
|
- The trusted-processor Lambda decrypts with `node:crypto` on read.
|
|
945
|
-
`ctx.secret<T>(key)` returns the plaintext string, never
|
|
1708
|
+
`ctx.secret<T>(key)` returns the plaintext string, never the
|
|
1709
|
+
ciphertext.
|
|
946
1710
|
- Never queried by the site-settings mirror path.
|
|
947
1711
|
- Never passed to any public-render surface
|
|
948
|
-
(`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
|
|
949
|
-
`publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
|
|
950
1712
|
- **Field-manifest validation scope**: the admin client validates
|
|
951
1713
|
`pattern` / `maxLength` / `required` for UX feedback, but the
|
|
952
|
-
Lambda only enforces a generic 10,000-character hard cap +
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1714
|
+
Lambda only enforces a generic 10,000-character hard cap + safe-
|
|
1715
|
+
character sanitizer. An admin/editor calling the AppSync mutation
|
|
1716
|
+
directly can bypass field-level constraints — by design, since
|
|
1717
|
+
admin/editor are trusted operators authorised to set secrets.
|
|
1718
|
+
The manifest checks are UX guidance, not a security boundary.
|
|
1719
|
+
(`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
|
|
1720
|
+
`publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
|
|
1721
|
+
|
|
1722
|
+
### Requirements and `definePlugin()` behaviour
|
|
1723
|
+
|
|
1724
|
+
`settings.secret` has four observable behaviours at `definePlugin()` time (this is the v1 first-party organization hard gate for secret access; see [plugin.ts:1004-1019](https://github.com/heavymoons/ampless/blob/main/packages/ampless/src/plugin.ts#L1004-L1019)):
|
|
1725
|
+
|
|
1726
|
+
1. **`settings.secret` non-empty + `trust_level !== 'trusted'`** → `definePlugin()` **throws**. Untrusted and privileged Lambdas have no IAM read access to the `PluginSecret` table; the trusted Lambda's IAM permission is required.
|
|
1727
|
+
2. **`settings.secret` non-empty + `capabilities` declared + `'secretSettings'` missing from `capabilities`** → **soft mismatch warning**. Matches the existing capability-mismatch pattern for `'schema'` / `'publicHtmlForPost'`.
|
|
1728
|
+
3. **`settings.secret` non-empty + `capabilities` undefined** (legacy plugin without a `capabilities` array) → **no warning**. The mismatch check is skipped when `capabilities` is `undefined`, for backward compatibility.
|
|
1729
|
+
4. **`capabilities: ['secretSettings']` declared with no `settings.secret` field** → **no-op**. Neither warning nor throw.
|
|
1730
|
+
|
|
1731
|
+
To use `settings.secret`, you also need:
|
|
1732
|
+
1. `trust_level: 'trusted'` (requirement #1 above; `definePlugin()` throws otherwise).
|
|
1733
|
+
2. `'secretSettings'` in `capabilities` (omitting it when `capabilities` is defined produces a console warning).
|
|
969
1734
|
3. **One-time key setup** — run from your project root:
|
|
970
1735
|
```sh
|
|
971
|
-
npx create-ampless setup-encryption-key
|
|
1736
|
+
npx create-ampless@beta setup-encryption-key
|
|
972
1737
|
```
|
|
973
|
-
|
|
974
|
-
`amplify/secrets/encryption-key.ts`. No AWS credentials
|
|
975
|
-
|
|
1738
|
+
This generates a cryptographically random 32-byte key and writes
|
|
1739
|
+
it to `amplify/secrets/encryption-key.ts`. No AWS credentials
|
|
1740
|
+
required — this is a local file operation only.
|
|
1741
|
+
|
|
1742
|
+
Then import the constant in `amplify/backend.ts` and pass it to
|
|
976
1743
|
`defineAmplessBackend({ pluginSecretEncryptionKey })`. Redeploy (or
|
|
977
|
-
restart the sandbox) to inject the key into Lambda env vars.
|
|
978
|
-
|
|
1744
|
+
restart the sandbox) to inject the key into the Lambda env vars.
|
|
1745
|
+
|
|
1746
|
+
For public repos, pass `--gitignore` to exclude the key file from
|
|
1747
|
+
version control and distribute the key separately.
|
|
979
1748
|
|
|
980
1749
|
### Dual-write integrity
|
|
981
1750
|
|
|
982
|
-
The `setPluginSecret` and `clearPluginSecret` operations each write
|
|
983
|
-
**two DynamoDB tables** in sequence
|
|
984
|
-
|
|
1751
|
+
The `setPluginSecret` and `clearPluginSecret` operations each write
|
|
1752
|
+
to **two DynamoDB tables** in sequence: `PluginSecret` (ciphertext)
|
|
1753
|
+
and `PluginSecretIndicator` (existence timestamp). If the second
|
|
1754
|
+
write fails, the tables are left in a predictable, documented state:
|
|
985
1755
|
|
|
986
|
-
| Failure | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
|
|
1756
|
+
| Failure point | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
|
|
987
1757
|
|---|---|---|---|---|
|
|
988
1758
|
| **set**: indicator PutItem fails | ciphertext present | absent | returns plaintext ✓ | `false` (UI: "not saved") |
|
|
989
|
-
| **clear**: indicator DeleteItem fails | absent | stale | `undefined` ✓ | `true` (UI: "saved") |
|
|
1759
|
+
| **clear**: indicator DeleteItem fails | absent | stale (old timestamp) | `undefined` ✓ | `true` (UI: "saved") |
|
|
990
1760
|
|
|
991
|
-
The clear-path failure is the "safe side": the secret stops firing
|
|
1761
|
+
The clear-path failure is the "safe side": the secret stops firing even
|
|
1762
|
+
though the UI briefly shows it as saved. The set-path failure is a minor
|
|
1763
|
+
UI inaccuracy: the secret is functional but the existence indicator is
|
|
1764
|
+
absent until a retry succeeds.
|
|
992
1765
|
|
|
993
1766
|
### Declaring secret fields
|
|
994
1767
|
|
|
@@ -1020,9 +1793,16 @@ export default function webhookPlugin(opts?: { signingSecret?: string }) {
|
|
|
1020
1793
|
},
|
|
1021
1794
|
hooks: {
|
|
1022
1795
|
async 'content.published'(event, ctx) {
|
|
1796
|
+
// ctx.secret() reads from PluginSecret DDB table.
|
|
1797
|
+
// Returns undefined when no value has been saved by admin yet.
|
|
1023
1798
|
const storedSecret = await ctx.secret<string>('signingSecret')
|
|
1799
|
+
|
|
1800
|
+
// Closure-private fallback: use the constructor argument when
|
|
1801
|
+
// the admin has not saved a value yet. This preserves backward
|
|
1802
|
+
// compatibility with sites that pass the secret at install time.
|
|
1024
1803
|
const secret = storedSecret ?? constructorSecret
|
|
1025
|
-
if (!secret) return
|
|
1804
|
+
if (!secret) return // no secret → skip signing
|
|
1805
|
+
|
|
1026
1806
|
// ... use secret to sign and POST
|
|
1027
1807
|
},
|
|
1028
1808
|
},
|
|
@@ -1034,25 +1814,306 @@ export default function webhookPlugin(opts?: { signingSecret?: string }) {
|
|
|
1034
1814
|
|
|
1035
1815
|
The type `PluginSecretField` is defined as `Omit<PluginTextField,
|
|
1036
1816
|
'default'> | Omit<PluginTextareaField, 'default'>` — the `default`
|
|
1037
|
-
property is **removed at the type level
|
|
1038
|
-
|
|
1817
|
+
property is **removed at the type level** and TypeScript will error
|
|
1818
|
+
if you try to add one.
|
|
1819
|
+
|
|
1820
|
+
This is intentional: `default` values propagate into admin UI form
|
|
1821
|
+
props (visible in the browser), static manifests cross-checked by
|
|
1822
|
+
the runtime, and JS bundles. For a credential, these are all leak
|
|
1823
|
+
paths.
|
|
1824
|
+
|
|
1825
|
+
If you have a fallback value (e.g. the constructor argument), keep
|
|
1826
|
+
it as a **closure-private variable** inside the plugin factory
|
|
1827
|
+
function — never in the manifest:
|
|
1828
|
+
|
|
1829
|
+
```ts
|
|
1830
|
+
// ✓ correct — closure-private, never in the manifest
|
|
1831
|
+
const constructorSecret = opts?.signingSecret
|
|
1832
|
+
|
|
1833
|
+
// ✗ wrong — TypeScript error, would also leak to browser
|
|
1834
|
+
settings: {
|
|
1835
|
+
secret: [{
|
|
1836
|
+
type: 'text',
|
|
1837
|
+
key: 'signingSecret',
|
|
1838
|
+
label: 'Secret',
|
|
1839
|
+
default: opts?.signingSecret, // ← TS compile error
|
|
1840
|
+
}],
|
|
1841
|
+
}
|
|
1842
|
+
```
|
|
1039
1843
|
|
|
1040
1844
|
### Reading secrets: `ctx.secret<T>(key)`
|
|
1041
1845
|
|
|
1042
|
-
`ctx.secret<T>(key)` is only available in trusted hook handlers
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1846
|
+
`ctx.secret<T>(key)` is only available in trusted hook handlers
|
|
1847
|
+
(injected by `processor-trusted.ts`). The signature is:
|
|
1848
|
+
|
|
1849
|
+
```ts
|
|
1850
|
+
ctx.secret<T = string>(key: string): Promise<T | undefined>
|
|
1851
|
+
```
|
|
1852
|
+
|
|
1853
|
+
- Returns `undefined` when no value has been saved by admin yet.
|
|
1854
|
+
- The generic `T` is a convenience cast (same as `ctx.setting<T>()`).
|
|
1855
|
+
Values are always stored as strings; `T` defaults to `string`.
|
|
1856
|
+
- Results are per-invocation cached. Calling `ctx.secret('key')`
|
|
1857
|
+
twice in the same hook batch costs one DDB round-trip (and one
|
|
1858
|
+
decrypt). The encryption key is decoded from the Lambda env var
|
|
1859
|
+
at cold-start (no extra DDB fetch).
|
|
1860
|
+
- Cache keys are namespaced: `${instanceId ?? name}:${fieldKey}`.
|
|
1861
|
+
Two plugin instances both declaring `'signingSecret'` never get
|
|
1862
|
+
each other's values.
|
|
1863
|
+
- The cached value is the **decrypted plaintext** — not the
|
|
1864
|
+
ciphertext. There is no redundant decrypt on repeated calls.
|
|
1049
1865
|
|
|
1050
1866
|
### Admin UI
|
|
1051
1867
|
|
|
1052
1868
|
When `settings.secret` is declared, the admin plugin settings page
|
|
1053
1869
|
renders a **Secret settings** section below the public fields.
|
|
1054
|
-
|
|
1055
|
-
|
|
1870
|
+
Each secret field shows:
|
|
1871
|
+
|
|
1872
|
+
- **Unset**: a plain text input + Save button.
|
|
1873
|
+
- **Stored**: a masked placeholder `••••••••` + Replace + Clear
|
|
1874
|
+
buttons. The actual value is never fetched or displayed.
|
|
1875
|
+
- **Editing**: after clicking Replace — new text input + Save + Cancel.
|
|
1876
|
+
|
|
1877
|
+
Admins can rotate a secret at any time without redeploying. The
|
|
1878
|
+
change takes effect on the next trusted-Lambda invocation (within
|
|
1879
|
+
~5–10 seconds of saving).
|
|
1880
|
+
|
|
1881
|
+
### Testing hooks that use `ctx.secret`
|
|
1882
|
+
|
|
1883
|
+
Mock `ctx.secret` alongside the other context methods:
|
|
1884
|
+
|
|
1885
|
+
```ts
|
|
1886
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
1887
|
+
import webhookPlugin from './index.js'
|
|
1888
|
+
import type { TrustedPluginRuntimeContext } from 'ampless'
|
|
1889
|
+
|
|
1890
|
+
function makeCtx(secrets: Record<string, string> = {}): TrustedPluginRuntimeContext {
|
|
1891
|
+
return {
|
|
1892
|
+
site: { name: 'Test', url: 'https://example.com' },
|
|
1893
|
+
listPublishedPosts: vi.fn().mockResolvedValue([]),
|
|
1894
|
+
writePublicAsset: vi.fn().mockResolvedValue(''),
|
|
1895
|
+
secret: vi.fn().mockImplementation(async (key: string) => secrets[key]),
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
describe('webhookPlugin signing', () => {
|
|
1900
|
+
it('uses admin-stored secret when available', async () => {
|
|
1901
|
+
const plugin = webhookPlugin()
|
|
1902
|
+
const ctx = makeCtx({ signingSecret: 'stored-secret' })
|
|
1903
|
+
await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
|
|
1904
|
+
// assert that the request was signed with 'stored-secret'
|
|
1905
|
+
})
|
|
1906
|
+
|
|
1907
|
+
it('falls back to constructor secret when admin has not saved one', async () => {
|
|
1908
|
+
const plugin = webhookPlugin({ signingSecret: 'fallback-secret' })
|
|
1909
|
+
const ctx = makeCtx({}) // no stored secret
|
|
1910
|
+
await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
|
|
1911
|
+
// assert that the request was signed with 'fallback-secret'
|
|
1912
|
+
})
|
|
1913
|
+
})
|
|
1914
|
+
```
|
|
1915
|
+
|
|
1916
|
+
---
|
|
1917
|
+
|
|
1918
|
+
## 9b. Where your plugin's data lives
|
|
1919
|
+
|
|
1920
|
+
Plugin-owned data may live in the five storage areas listed below; the
|
|
1921
|
+
**current write paths differ by area** and fall into three families:
|
|
1922
|
+
|
|
1923
|
+
- **KvStore** — admin/editor write through AppSync. Plugin hooks have no
|
|
1924
|
+
KvStore write helper today.
|
|
1925
|
+
- **PluginSecret + PluginSecretIndicator** — written by the
|
|
1926
|
+
`plugin-secret-handler` Lambda, which is invoked by admin/editor through
|
|
1927
|
+
the `setPluginSecret` / `clearPluginSecret` AppSync mutations. The
|
|
1928
|
+
trusted processor reads `PluginSecret` via `ctx.secret<T>()` but does
|
|
1929
|
+
NOT write to either secret table.
|
|
1930
|
+
- **S3 `public/plugins/{instanceId ?? name}/*`** — written by the trusted
|
|
1931
|
+
Lambda's hook context (`ctx.writePublicAsset(...)`). This is the only
|
|
1932
|
+
area a plugin hook writes to directly today.
|
|
1933
|
+
|
|
1934
|
+
All other areas — the `Post`, `Page`, `Media`, and `PostTag` DynamoDB
|
|
1935
|
+
tables, the `public/site-settings.json` S3 mirror, and any other plugin's
|
|
1936
|
+
namespace — are off-limits. The runtime does not enforce this today; it is
|
|
1937
|
+
a contract enforced by trust (and future IAM hardening).
|
|
1938
|
+
|
|
1939
|
+
| Area | Path / identifier | Access level | Phase |
|
|
1940
|
+
|---|---|---|---|
|
|
1941
|
+
| KvStore (admin settings) | DynamoDB `pk='siteconfig'`, `sk='plugins.<instanceId>.<fieldKey>'` | admin/editor via AppSync; plugin hook write helper is not provided today | Phase 2 |
|
|
1942
|
+
| KvStore (runtime state/cache) | DynamoDB `pk='pluginstate:<plugin>:...'` with optional TTL | admin/editor via AppSync; plugin hook write helper is not provided today | Current |
|
|
1943
|
+
| PluginSecret | DynamoDB `PluginSecret` table, `sk='plugins.<instanceId>.<fieldKey>'` | `trusted` only (IAM-only AppSync auth) | Phase 6a |
|
|
1944
|
+
| PluginSecretIndicator | DynamoDB `PluginSecretIndicator` table, `sk='plugins.<instanceId>.<fieldKey>'` | `trusted` + admin/editor (read indicator) | Phase 6a |
|
|
1945
|
+
| S3 plugin assets | `public/plugins/{instanceId ?? name}/*` | `trusted` only (`writePublicAsset`) | Phase 3 |
|
|
1946
|
+
|
|
1947
|
+
**Cleanup is not automatic.** Removing a plugin from `cms.config.ts` leaves
|
|
1948
|
+
orphan data in all five areas. Manual operator cleanup is required until the
|
|
1949
|
+
future lifecycle-dispatch PR ships the invocation mechanism for the `uninstall`
|
|
1950
|
+
hook (see §9c below).
|
|
1951
|
+
|
|
1952
|
+
**Custom DynamoDB tables.** If your plugin provisions its own DynamoDB table
|
|
1953
|
+
outside the ampless schema, lifecycle management (including cleanup on uninstall)
|
|
1954
|
+
is your responsibility. ampless has no visibility into external tables and the
|
|
1955
|
+
future `uninstall` cleanup grants cover only the five areas above.
|
|
1956
|
+
|
|
1957
|
+
See [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#plugin-owned-data-areas)
|
|
1958
|
+
for the full rationale and the IAM grant design.
|
|
1959
|
+
|
|
1960
|
+
---
|
|
1961
|
+
|
|
1962
|
+
## 9c. `uninstall` hook (Phase 1 reservation)
|
|
1963
|
+
|
|
1964
|
+
`AmplessPlugin.uninstall` is a **Phase 1 type reservation** — the runtime does
|
|
1965
|
+
not call it today. Its purpose is to lock in the hook name and signature before
|
|
1966
|
+
any plugin code ships, so the future lifecycle-dispatch PR can wire the
|
|
1967
|
+
invocation without renaming or reshaping anything.
|
|
1968
|
+
|
|
1969
|
+
**Phase 1 scope**: only the hook name and signature are reserved. The `ctx`
|
|
1970
|
+
does **not** yet carry cleanup helpers (`deletePublicAsset` /
|
|
1971
|
+
`deletePluginSetting` / `deletePluginSecret`) — writing
|
|
1972
|
+
`await ctx.deletePublicAsset(...)` today is a TypeScript error. When those
|
|
1973
|
+
helpers land (in the lifecycle-dispatch PR), they are added to
|
|
1974
|
+
`PluginUninstallContext` additively, with no breaking change to plugins that
|
|
1975
|
+
declared an empty body in advance.
|
|
1976
|
+
|
|
1977
|
+
**Recommended declaration today** — an empty body:
|
|
1978
|
+
|
|
1979
|
+
```ts
|
|
1980
|
+
// Example: a trusted plugin that writes assets and stores secrets
|
|
1981
|
+
definePlugin({
|
|
1982
|
+
name: 'my-trusted-plugin',
|
|
1983
|
+
apiVersion: 1,
|
|
1984
|
+
trust_level: 'trusted',
|
|
1985
|
+
capabilities: ['eventHooks', 'writePublicAsset', 'secretSettings'],
|
|
1986
|
+
hooks: { 'content.published': async (_evt, ctx) => { /* ... */ } },
|
|
1987
|
+
uninstall: async (_ctx) => {
|
|
1988
|
+
// Phase 1 reservation: the runtime does not invoke this hook
|
|
1989
|
+
// today, AND `ctx` does not yet carry cleanup helpers
|
|
1990
|
+
// (`deletePublicAsset` / `deletePluginSetting` /
|
|
1991
|
+
// `deletePluginSecret`). Declaring an empty body is the
|
|
1992
|
+
// recommended forward-compat shape — when the future
|
|
1993
|
+
// lifecycle-dispatch PR ships, the helpers land on
|
|
1994
|
+
// `PluginUninstallContext` additively, and you fill in the
|
|
1995
|
+
// body THEN. Plugins that shipped the empty declaration
|
|
1996
|
+
// today do not need to re-publish for the signature change,
|
|
1997
|
+
// but a re-publish is required to add the actual cleanup
|
|
1998
|
+
// body.
|
|
1999
|
+
},
|
|
2000
|
+
})
|
|
2001
|
+
```
|
|
2002
|
+
|
|
2003
|
+
**Idempotency.** When the lifecycle-dispatch PR ships, the `uninstall` hook
|
|
2004
|
+
runs in a trusted-Lambda IAM context. The SQS delivery is at-least-once, so
|
|
2005
|
+
your cleanup body may run more than once — design it to be safe to retry
|
|
2006
|
+
(e.g. `deleteObject` is idempotent on S3; a conditional `delete` on DDB is
|
|
2007
|
+
safe if the key is already gone).
|
|
2008
|
+
|
|
2009
|
+
---
|
|
2010
|
+
|
|
2011
|
+
## 9d. When you change settings shape (Phase 1 reservation)
|
|
2012
|
+
|
|
2013
|
+
### What happens today: `public` and `secret` travel through different paths
|
|
2014
|
+
|
|
2015
|
+
Shape changes are absorbed silently by today's runtime, but the actual
|
|
2016
|
+
behaviour differs between `settings.public` and `settings.secret` because
|
|
2017
|
+
they use entirely different write/read paths.
|
|
2018
|
+
|
|
2019
|
+
#### `settings.public` (lenient resolver via `resolvePluginSettings`)
|
|
2020
|
+
|
|
2021
|
+
`resolvePluginSettings` ([packages/ampless/src/plugin-settings.ts](packages/ampless/src/plugin-settings.ts))
|
|
2022
|
+
iterates `manifest.public` and falls back to `field.default` per field. The
|
|
2023
|
+
resolver never looks at `manifest.secret`.
|
|
2024
|
+
|
|
2025
|
+
| Change | Behaviour today (public fields) |
|
|
2026
|
+
|---|---|
|
|
2027
|
+
| **Field added** | New field resolves via `manifest.default` — stored value is absent, default takes over. |
|
|
2028
|
+
| **Field deleted** | Orphan row remains in KvStore. `resolvePluginSettings` silently skips keys that are not in the current manifest. |
|
|
2029
|
+
| **Field renamed** (`endpoint` → `url`) | Treated as deletion + addition: old value is unreachable (orphan), new field resolves via `default`. |
|
|
2030
|
+
| **Type changed incompatibly** | New validator runs on the stored value. If it passes, the value is used. If it fails, falls through to `default` (or `undefined`). |
|
|
2031
|
+
|
|
2032
|
+
#### `settings.secret` (admin UI + `PluginSecret` + `ctx.secret()`, no lenient resolver)
|
|
2033
|
+
|
|
2034
|
+
Secret fields are never read by `resolvePluginSettings`. They travel a
|
|
2035
|
+
separate path:
|
|
2036
|
+
|
|
2037
|
+
- The admin UI writes individual values through the `setPluginSecret`
|
|
2038
|
+
AppSync mutation, which the `plugin-secret-handler` Lambda encrypts and
|
|
2039
|
+
stores in the `PluginSecret` DynamoDB table.
|
|
2040
|
+
- Trusted hooks read them individually by key via `ctx.secret<T>(key)`,
|
|
2041
|
+
which goes directly to `PluginSecret` and decrypts.
|
|
2042
|
+
- The `PluginSecretField` type forbids `default` — there is no
|
|
2043
|
+
manifest-level fallback for secret values.
|
|
2044
|
+
|
|
2045
|
+
| Change | Behaviour today (secret fields) |
|
|
2046
|
+
|---|---|
|
|
2047
|
+
| **Field added** | New field appears in the admin UI. Until an admin sets a value, `ctx.secret<T>(key)` returns `undefined`. |
|
|
2048
|
+
| **Field deleted** | The field disappears from the admin UI, but the encrypted row in `PluginSecret` is orphaned. No resolver runs over it; an operator must delete it manually. |
|
|
2049
|
+
| **Field renamed** | Old key's encrypted row is orphaned (no resolver / cleanup). The new key shows up unset. Admin must re-enter the value under the new key. |
|
|
2050
|
+
| **Type changed incompatibly** | `validatePluginSettingValue` only runs at write time, so an existing stored ciphertext is unaffected on read; `ctx.secret<T>(key)` returns whatever was last written. A re-validate on admin save would reject incompatible new input. |
|
|
2051
|
+
|
|
2052
|
+
No error or warning is produced in any of these cases. Plugin authors must
|
|
2053
|
+
inspect their stored values manually if they need to verify behaviour
|
|
2054
|
+
after a shape change.
|
|
2055
|
+
|
|
2056
|
+
### The `version` reservation
|
|
2057
|
+
|
|
2058
|
+
`PluginSettingsManifest.version?: number` is a **Phase 1 type
|
|
2059
|
+
reservation** — the runtime does NOT read it today. Declaring it has no
|
|
2060
|
+
effect on the lenient resolver above.
|
|
2061
|
+
|
|
2062
|
+
The reservation exists so that a future migration PR may persist the
|
|
2063
|
+
active manifest version somewhere alongside stored values and compare it to
|
|
2064
|
+
`manifest.version` at resolve time to detect mismatch. The exact mismatch
|
|
2065
|
+
response (warn / skip / migrate in-place / trigger an admin-driven flow) is
|
|
2066
|
+
design territory for that future PR.
|
|
2067
|
+
|
|
2068
|
+
**What declaring `version` today does NOT promise:**
|
|
2069
|
+
|
|
2070
|
+
- It does NOT trigger any migration body.
|
|
2071
|
+
- It does NOT cause the runtime to re-validate or re-default stored values.
|
|
2072
|
+
- It does NOT reserve a `migrate` hook signature — that is a separate future
|
|
2073
|
+
design.
|
|
2074
|
+
|
|
2075
|
+
**What declaring `version` today does do:**
|
|
2076
|
+
|
|
2077
|
+
- It positions the plugin to be picked up by the future migration detection
|
|
2078
|
+
path, without requiring a re-publish just to add the `version` field once
|
|
2079
|
+
that PR ships.
|
|
2080
|
+
- It communicates intent to future maintainers that this manifest has a
|
|
2081
|
+
versioned shape.
|
|
2082
|
+
|
|
2083
|
+
### Recommended pattern
|
|
2084
|
+
|
|
2085
|
+
| Scenario | Recommendation |
|
|
2086
|
+
|---|---|
|
|
2087
|
+
| Additive change only (new optional field, default provided) | No `version` bump needed. Lenient resolver handles it. |
|
|
2088
|
+
| Non-additive change (rename, incompatible type change, semantic shift) | Bump `version` by 1. |
|
|
2089
|
+
| First time adding `version` to an existing manifest | Start at `version: 1`. |
|
|
2090
|
+
|
|
2091
|
+
**Use positive integers, starting at 1.** Do NOT use `0`, negative numbers,
|
|
2092
|
+
or floats — the `number` type accepts them but the future migration PR may
|
|
2093
|
+
reserve special semantics for `0` / undefined (legacy / pre-v1 conflation).
|
|
2094
|
+
|
|
2095
|
+
### Code example
|
|
2096
|
+
|
|
2097
|
+
```ts
|
|
2098
|
+
definePlugin({
|
|
2099
|
+
name: 'my-plugin',
|
|
2100
|
+
apiVersion: 1,
|
|
2101
|
+
trust_level: 'untrusted',
|
|
2102
|
+
capabilities: ['adminSettings'],
|
|
2103
|
+
settings: {
|
|
2104
|
+
version: 2, // ← Phase 1 reservation. Today runtime ignores.
|
|
2105
|
+
public: [
|
|
2106
|
+
{ type: 'url', key: 'webhookUrl', label: 'Webhook URL', required: true },
|
|
2107
|
+
],
|
|
2108
|
+
},
|
|
2109
|
+
})
|
|
2110
|
+
```
|
|
2111
|
+
|
|
2112
|
+
When a future migration PR ships, plugins that have already declared
|
|
2113
|
+
`version` will be on the detection path automatically. Plugins that want to
|
|
2114
|
+
provide an actual migration body will need to re-publish after that PR ships
|
|
2115
|
+
to add the body. Plugins that omit `version` entirely continue with the
|
|
2116
|
+
current lenient-resolver behaviour — no change.
|
|
1056
2117
|
|
|
1057
2118
|
---
|
|
1058
2119
|
|
|
@@ -1168,10 +2229,15 @@ a normal npm package:
|
|
|
1168
2229
|
monorepo.
|
|
1169
2230
|
- **Entry**: ESM only, exports default (the factory) and the
|
|
1170
2231
|
config interface (for typed args in user `cms.config.ts`).
|
|
1171
|
-
- **`apiVersion`**:
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
2232
|
+
- **`apiVersion`**: declare `1` today — it is the only valid
|
|
2233
|
+
value, and the literal type rejects others at compile time.
|
|
2234
|
+
`apiVersion` is the breaking-change marker on the plugin
|
|
2235
|
+
contract, not a semver-style channel: additive changes (new
|
|
2236
|
+
optional fields, new reserved capabilities) stay within
|
|
2237
|
+
`apiVersion: 1` and do NOT require a bump. See the [apiVersion
|
|
2238
|
+
bump policy](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#apiversion-bump-policy)
|
|
2239
|
+
in the architecture doc for the full criteria.
|
|
2240
|
+
- **Dist-tag**: `@beta` while ampless itself is in beta. The
|
|
1175
2241
|
`@latest` tag stays reserved until ampless v1.0.
|
|
1176
2242
|
|
|
1177
2243
|
Worked examples to crib from:
|
|
@@ -1181,7 +2247,7 @@ Worked examples to crib from:
|
|
|
1181
2247
|
- [`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
2248
|
- [`packages/plugin-rss`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-rss) — trusted, async event hooks + `writePublicAsset`.
|
|
1183
2249
|
- [`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) —
|
|
2250
|
+
- [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — trusted hook with outbound HTTP + `secretSettings` (admin-managed signing secret, Phase 6a).
|
|
1185
2251
|
- [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` route renderer.
|
|
1186
2252
|
- [`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
2253
|
|
|
@@ -1238,7 +2304,7 @@ CLI ships a `plugin <name>` subcommand:
|
|
|
1238
2304
|
```bash
|
|
1239
2305
|
# Site-local: scaffolds plugins/<name>/index.ts inside the current
|
|
1240
2306
|
# ampless site. Run from the site repo root.
|
|
1241
|
-
npx create-ampless@
|
|
2307
|
+
npx create-ampless@beta plugin my-thing \
|
|
1242
2308
|
--trust-level untrusted \
|
|
1243
2309
|
--capabilities publicHead,adminSettings
|
|
1244
2310
|
|
|
@@ -1246,7 +2312,7 @@ npx create-ampless@latest plugin my-thing \
|
|
|
1246
2312
|
# tsconfig.json, tsup.config.ts, README + .ja, CHANGELOG, .gitignore,
|
|
1247
2313
|
# and src/index.ts + src/index.test.ts. Run from wherever you want
|
|
1248
2314
|
# the new package directory to land.
|
|
1249
|
-
npx create-ampless@
|
|
2315
|
+
npx create-ampless@beta plugin @myscope/ampless-plugin-thing \
|
|
1250
2316
|
--standalone \
|
|
1251
2317
|
--trust-level untrusted \
|
|
1252
2318
|
--capabilities publicHead,adminSettings \
|
|
@@ -1260,7 +2326,7 @@ keyword, and a minimal vitest sample so `pnpm install && pnpm test &&
|
|
|
1260
2326
|
pnpm build` runs clean on the freshly generated directory.
|
|
1261
2327
|
|
|
1262
2328
|
Both modes also accept a positional flag-less invocation (`npx
|
|
1263
|
-
create-ampless@
|
|
2329
|
+
create-ampless@beta plugin`) that walks you through the same
|
|
1264
2330
|
questions interactively via the @clack prompt UI.
|
|
1265
2331
|
|
|
1266
2332
|
### Publishing a standalone plugin
|
|
@@ -1270,17 +2336,17 @@ cd ampless-plugin-thing
|
|
|
1270
2336
|
pnpm install
|
|
1271
2337
|
pnpm test
|
|
1272
2338
|
pnpm build
|
|
1273
|
-
pnpm publish --access public --tag
|
|
2339
|
+
pnpm publish --access public --tag beta
|
|
1274
2340
|
```
|
|
1275
2341
|
|
|
1276
2342
|
`--access public` is mandatory for scoped names (`@scope/...`).
|
|
1277
|
-
`--tag
|
|
2343
|
+
`--tag beta` matches the current ampless pre-release cadence — drop
|
|
1278
2344
|
it once the package reaches a stable major.
|
|
1279
2345
|
|
|
1280
2346
|
There's a publish-to-install lag of "seconds to minutes" before
|
|
1281
|
-
`npm install <pkg>@
|
|
2347
|
+
`npm install <pkg>@beta` picks up a fresh publish (CDN + registry
|
|
1282
2348
|
replica propagation). If `npm install` 404s right after `npm publish`
|
|
1283
|
-
returns, wait 1-2 minutes and retry — `npm view <pkg>@
|
|
2349
|
+
returns, wait 1-2 minutes and retry — `npm view <pkg>@beta version`
|
|
1284
2350
|
visible in the registry is necessary but sometimes not sufficient.
|
|
1285
2351
|
|
|
1286
2352
|
### Naming the package
|
|
@@ -1331,6 +2397,7 @@ PROTECTED), so the scaffolded code is safe across ampless upgrades.
|
|
|
1331
2397
|
- Bugs in the plugin runtime / admin form → same repo, label
|
|
1332
2398
|
`area:plugins`.
|
|
1333
2399
|
|
|
1334
|
-
The
|
|
1335
|
-
|
|
1336
|
-
|
|
2400
|
+
The GitHub URLs above resolve in the public beta repo. The same docs
|
|
2401
|
+
also live in the package tarball directly under
|
|
2402
|
+
`node_modules/ampless/docs/`, so plugin authors can read them locally
|
|
2403
|
+
without checking out this repo.
|