ampless 1.0.0-alpha.20 → 1.0.0-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -288,6 +288,10 @@ type TrustLevel = 'untrusted' | 'trusted' | 'privileged';
288
288
  * - `metadata` / `eventHooks`: name-only declaration for existing surfaces.
289
289
  * - `adminSettings`: admin-managed public settings manifest.
290
290
  * - `writePublicAsset`: trusted hook context can write namespaced public assets.
291
+ * - `schema`: per-post body injection via `publicBodyForPost`,
292
+ * scoped to JSON-LD `<script type="application/ld+json">`. Themes
293
+ * render the descriptors by calling `ampless.publicBodyForPost(post)`
294
+ * in their post template.
291
295
  *
292
296
  * Reserved capabilities are accepted by the type so that plugins can
293
297
  * declare future intent, but the runtime does nothing with them yet —
@@ -363,6 +367,26 @@ type PublicHeadDescriptor = {
363
367
  id: string;
364
368
  body: string;
365
369
  strategy?: ScriptStrategy;
370
+ /**
371
+ * Optional MIME-like script type. Phase 4 allows
372
+ * `'application/ld+json'` only — when set, the runtime emits
373
+ * `<script type="application/ld+json">` and **auto-escapes** the
374
+ * `body` (`<`, `>`, `&`, U+2028, U+2029 → `\uXXXX`) so plugin
375
+ * authors cannot accidentally let a value break out of the
376
+ * script tag. This invariant is applied across all three
377
+ * surfaces (`publicHead` / `publicBodyEnd` / `publicBodyForPost`).
378
+ *
379
+ * Unsupported values are dropped (descriptor and warning).
380
+ * Surface-dependent strictness:
381
+ * - `publicHead` / `publicBodyEnd`: `undefined` (= default JS,
382
+ * backwards compatible) or `'application/ld+json'`.
383
+ * - `publicBodyForPost`: `'application/ld+json'` REQUIRED —
384
+ * the per-post body surface is scoped to JSON-LD only so
385
+ * the schema capability does not become a per-post arbitrary
386
+ * inline-JS channel. A future capability would open that
387
+ * explicitly.
388
+ */
389
+ scriptType?: 'application/ld+json';
366
390
  /**
367
391
  * Type-only reservation. CSP nonce resolution (including the
368
392
  * planned `'auto'` mode) is deferred to a future RFP; Phase 1
@@ -404,6 +428,27 @@ type PublicBodyDescriptor = Extract<PublicHeadDescriptor, {
404
428
  height?: number;
405
429
  attrs?: Record<string, string | boolean>;
406
430
  };
431
+ /**
432
+ * Descriptor returned by `publicBodyForPost()` (Phase 4). Limited to
433
+ * the `inlineScript` variant with `scriptType: 'application/ld+json'`
434
+ * REQUIRED. This narrowing is deliberate:
435
+ * - The per-post body surface is for JSON-LD / structured data only.
436
+ * We do not want the `schema` capability to become a per-post
437
+ * arbitrary inline-JS channel.
438
+ * - `meta` / `link` belong in `<head>`; theme post pages render
439
+ * these descriptors inside `<body>`, so meta/link are excluded.
440
+ *
441
+ * If a future use case needs per-post arbitrary inline JS (e.g.
442
+ * Microsoft Clarity per-page tagging), open it through a new
443
+ * capability such as `publicPostScript` rather than relaxing this.
444
+ */
445
+ type PublicPostBodyDescriptor = Extract<PublicHeadDescriptor, {
446
+ type: 'inlineScript';
447
+ }> & {
448
+ /** Required for `publicBodyForPost`; the runtime drops any descriptor
449
+ * whose scriptType is not 'application/ld+json'. */
450
+ scriptType: 'application/ld+json';
451
+ };
407
452
  /**
408
453
  * Metadata-like object that maps cleanly onto Next.js `Metadata`. We keep
409
454
  * the shape framework-agnostic so the core type doesn't depend on Next.js.
@@ -571,6 +616,39 @@ interface PluginJsonField extends PluginFieldBase<unknown> {
571
616
  rows?: number;
572
617
  }
573
618
  type PluginSettingField = PluginTextField | PluginTextareaField | PluginBooleanField | PluginNumberField | PluginSelectField | PluginUrlField | PluginCodeField | PluginJsonField;
619
+ /**
620
+ * Static manifest declared in a plugin package's `package.json` under
621
+ * the `amplessPlugin` key. The plugin package must also expose
622
+ * `package.json` itself via `exports`:
623
+ *
624
+ * "exports": {
625
+ * ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
626
+ * "./package.json": "./package.json"
627
+ * }
628
+ *
629
+ * Without the subpath export, Node's package-exports gating rejects
630
+ * `import.meta.resolve('<pkg>/package.json')` and the runtime cannot
631
+ * load the manifest. Without `amplessPlugin`, the runtime skips
632
+ * cross-check silently and falls back to the existing per-factory
633
+ * capability mismatch checks (backward compatible).
634
+ */
635
+ interface PluginPackageManifest {
636
+ /** Must match `AmplessPlugin.apiVersion`. Mismatch throws at runtime
637
+ * to prevent loading a plugin built against a different ampless API. */
638
+ apiVersion: 1;
639
+ /** Should match `AmplessPlugin.name`. Mismatch warns. */
640
+ name: string;
641
+ /** Should match `AmplessPlugin.trust_level`. Mismatch warns. */
642
+ trustLevel: TrustLevel;
643
+ /** Should match `AmplessPlugin.capabilities`. Disagreement warns. */
644
+ capabilities: readonly PluginCapability[];
645
+ /** Optional admin UI label. */
646
+ displayName?: LocalizedString;
647
+ /** Optional short description (1 line). */
648
+ description?: LocalizedString;
649
+ /** Optional docs / repo URL. */
650
+ homepage?: string;
651
+ }
574
652
  /**
575
653
  * Per-plugin settings declaration. Phase 2 implements `public`;
576
654
  * `secret` is reserved for Phase 6a (admin-only storage; never reaches
@@ -583,6 +661,25 @@ interface AmplessPlugin {
583
661
  name: string;
584
662
  /** Plugin API version. Currently 1; future versions will be additive. */
585
663
  apiVersion: 1;
664
+ /**
665
+ * Optional npm package name (e.g. `'@scope/ampless-plugin-foo'` or
666
+ * `'ampless-plugin-foo'`). When set, the runtime resolves
667
+ * `<packageName>/package.json` at `createPluginHead` construction
668
+ * time and cross-checks the static `amplessPlugin` manifest against
669
+ * the factory return value (`apiVersion` mismatch throws,
670
+ * `name` / `trustLevel` / `capabilities` mismatch warns).
671
+ *
672
+ * Plugins that omit this field — site-local plugins, first-party
673
+ * plugins predating Phase 5, etc. — skip cross-check entirely and
674
+ * fall back to the existing per-factory capability mismatch checks.
675
+ * Backward compatible: existing plugins continue to work unchanged.
676
+ *
677
+ * For external `npm publish` plugins, `packageName` MUST match the
678
+ * package's actual `name` and the package's `exports` MUST expose
679
+ * `./package.json`. The scaffold tool (`npx create-ampless plugin`)
680
+ * handles both automatically.
681
+ */
682
+ packageName?: string;
586
683
  trust_level: TrustLevel;
587
684
  /**
588
685
  * Stable per-install namespace. Defaults to `name` when omitted.
@@ -642,6 +739,31 @@ interface AmplessPlugin {
642
739
  * (GTM no-script fallback frame, chat widgets, ...).
643
740
  */
644
741
  publicBodyEnd?(ctx: PluginPublicRenderContext): readonly PublicBodyDescriptor[];
742
+ /**
743
+ * Per-post body descriptors (Phase 4). Themes render the result by
744
+ * calling `ampless.publicBodyForPost(post)` in their post template;
745
+ * the descriptors emit inside `<body>` (not `<head>`, which the
746
+ * Next.js Metadata API cannot do for `<script>` tags). Primary use
747
+ * case: JSON-LD `<script type="application/ld+json">` Article
748
+ * schema. Restricted to inline-script descriptors with
749
+ * `scriptType: 'application/ld+json'` — see
750
+ * `PublicPostBodyDescriptor` for the rationale.
751
+ *
752
+ * The runtime auto-escapes `<`, `>`, `&`, U+2028, and U+2029 in the
753
+ * body so plugin authors cannot accidentally let a value break out
754
+ * of the script tag. The same escape is applied to any
755
+ * `'application/ld+json'` descriptor returned from `publicHead` or
756
+ * `publicBodyEnd`.
757
+ *
758
+ * Theme integration: first-party themes (blog / corporate / dads /
759
+ * docs / landing / minimal) all render the result automatically.
760
+ * Plugin authors who target custom themes should document the
761
+ * theme-side render call in their plugin's README — when the theme
762
+ * does not call `publicBodyForPost`, the plugin silently no-ops.
763
+ *
764
+ * Plugins implementing this should declare the `schema` capability.
765
+ */
766
+ publicBodyForPost?(post: Post, ctx: PluginPublicRenderContext): readonly PublicPostBodyDescriptor[];
645
767
  /**
646
768
  * Dynamic OG image renderer. The dispatcher route (e.g.
647
769
  * `app/og/[slug]/route.ts`) reads this and feeds the element into
@@ -1189,4 +1311,4 @@ declare function pickDefaultEntrypoint(files: readonly {
1189
1311
 
1190
1312
  declare const VERSION = "0.0.1";
1191
1313
 
1192
- export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type LocalizedString, MAX_BUNDLE_BYTES, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, PLUGIN_KEY_PATTERN, type Page, type PluginBooleanField, type PluginCapability, type PluginCodeField, type PluginEventHandler, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPublicRenderContext, type PluginRuntimeContext, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TrustLevel, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, isValidPluginKey, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
1314
+ export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type LocalizedString, MAX_BUNDLE_BYTES, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, PLUGIN_KEY_PATTERN, type Page, type PluginBooleanField, type PluginCapability, type PluginCodeField, type PluginEventHandler, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPackageManifest, type PluginPublicRenderContext, type PluginRuntimeContext, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type PublicPostBodyDescriptor, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TrustLevel, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, isValidPluginKey, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
@@ -9,7 +9,7 @@
9
9
 
10
10
  # ampless プラグインの書き方
11
11
 
12
- このガイドは、初めての `definePlugin()` 呼び出しから admin 編集可能な設定パネル、そして npm 公開に至るまで、ampless プラグインを ship するために必要な手順を一通りカバーします。Phase 1 + Phase 2 の機能を網羅 — descriptor ベースの `<head>` / `<body>` 注入、非同期イベントフック、admin 管理の `settings.public` 値です。
12
+ このガイドは、初めての `definePlugin()` 呼び出しから admin 編集可能な設定パネル、そして npm 公開に至るまで、ampless プラグインを ship するために必要な手順を一通りカバーします。Phase 1〜4 の機能を網羅 — descriptor ベースの `<head>` / `<body>` / 投稿単位 body 注入、非同期イベントフック、admin 管理の `settings.public` 値です。
13
13
 
14
14
  設計の経緯と背景は [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md) に集約。本ページはその実装ハンドブック側です。
15
15
 
@@ -25,6 +25,7 @@ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScrip
25
25
  | `siteMetadata(site)` | root layout の `generateMetadata()` | 同期 | 既存 |
26
26
  | `publicHead(ctx)` | root layout の `<head>` | 同期 (async layout から呼ばれる) | 1 |
27
27
  | `publicBodyEnd(ctx)` | root layout の `<body>` 末尾 | 同期 | 1 |
28
+ | `publicBodyForPost(post, ctx)` | テーマの post ページテンプレート(投稿単位) | 同期 | 4 |
28
29
  | `ogImage` | `/og/[slug]` ルート | リクエスト時、公開 Lambda 内 | 既存 |
29
30
  | `hooks` | trust_level に応じた processor Lambda | 非同期、SQS イベントで起動 | 既存 |
30
31
  | `settings.public` | `/admin/plugins` フォーム | 宣言的なマニフェスト | 2 |
@@ -102,6 +103,7 @@ interface AmplessPlugin {
102
103
  siteMetadata?(site): PluginMetadata
103
104
  publicHead?(ctx): readonly PublicHeadDescriptor[]
104
105
  publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
106
+ publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
105
107
  ogImage?: OgImageConfig
106
108
  settings?: { public?: readonly PluginSettingField[] }
107
109
  }
@@ -162,6 +164,7 @@ trust level がズレているプラグインは「権限不足で sliently fail
162
164
  | `siteMetadata(site)` | `PluginMetadata` | サイト全体の `<title>` / favicon / RSS `<link rel="alternate">` |
163
165
  | `publicHead(ctx)` | `PublicHeadDescriptor[]` | 解析ローダー、フォント、jsonld、hreflang |
164
166
  | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script フレーム、チャットウィジェット、末尾スニペット |
167
+ | `publicBodyForPost(post, ctx)` | `PublicPostBodyDescriptor[]` | 投稿単位の body 注入 — JSON-LD 構造化データ。テーマの post ページテンプレートが render する |
165
168
 
166
169
  `ctx` オブジェクトの中身:
167
170
 
@@ -202,6 +205,15 @@ trust level がズレているプラグインは「権限不足で sliently fail
202
205
  strategy: 'afterInteractive',
203
206
  }
204
207
 
208
+ // inline script — JSON-LD variant (publicHead / publicBodyEnd / publicBodyForPost で使用可能)
209
+ // runtime が body を自動 escape するので、生の JSON 文字列を返せばよい
210
+ {
211
+ type: 'inlineScript',
212
+ id: 'schema-article',
213
+ scriptType: 'application/ld+json',
214
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
215
+ }
216
+
205
217
  // Meta / link / noscript
206
218
  { type: 'meta', name: 'theme-color', content: '#fff' }
207
219
  { type: 'meta', property: 'og:image', content: 'https://…' }
@@ -223,6 +235,36 @@ trust level がズレているプラグインは「権限不足で sliently fail
223
235
  }
224
236
  ```
225
237
 
238
+ ### `PublicPostBodyDescriptor`(Phase 4)
239
+
240
+ `publicBodyForPost` は `PublicPostBodyDescriptor[]` を返します。これは `inlineScript` の制限サブセットで、`scriptType` が必須かつ `'application/ld+json'` のみ有効です:
241
+
242
+ ```ts
243
+ // publicBodyForPost で返せる唯一の形:
244
+ {
245
+ type: 'inlineScript',
246
+ id: 'schema-article',
247
+ scriptType: 'application/ld+json', // 必須 — これ以外は drop + warn
248
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
249
+ }
250
+ ```
251
+
252
+ `meta` / `link` を除いている理由:投稿単位のメタデータは Next.js `generateMetadata()` 経由の `metadata()` サーフェスが担い、フレームワークの deduplication・streaming と統合されている。`publicBodyForPost` は `generateMetadata` が生成できない構造化データ(`<script type="application/ld+json">`)のためだけに存在する。
253
+
254
+ ### JSON-LD 自動 escape
255
+
256
+ `scriptType === 'application/ld+json'` のとき、runtime は描画前に **`body` 文字列を自動 escape** する — `<` → `<`、`>` → `>`、`&` → `&`、U+2028 → ` `、U+2029 → ` `。この処理は `inlineScript` を受け付ける 3 つのサーフェス(`publicHead` / `publicBodyEnd` / `publicBodyForPost`)すべてで行われる。プラグイン作者は生の JSON 文字列を返せばよく、自前で escape しなくてよい。
257
+
258
+ サポート外の `scriptType` を持つ descriptor は **console warning 付きで drop** される。
259
+
260
+ ### サーフェス別 scriptType ルール
261
+
262
+ | サーフェス | `scriptType` |
263
+ |---|---|
264
+ | `publicHead` | `undefined`(デフォルト JS)または `'application/ld+json'` |
265
+ | `publicBodyEnd` | `publicHead` と同じ |
266
+ | `publicBodyForPost` | `'application/ld+json'` **必須**。他の値(省略含む)は drop + warn |
267
+
226
268
  ### Validation ルール
227
269
 
228
270
  - **URL scheme allowlist**: `http`、`https`、または相対パス。`javascript:`、`data:`、`vbscript:`、`blob:`、`file:` は要素描画前に拒否されます
@@ -244,6 +286,39 @@ trust level がズレているプラグインは「権限不足で sliently fail
244
286
 
245
287
  `cms.config.plugins` の順序は集約後も保たれます。
246
288
 
289
+ ### `publicBodyForPost` の使用例(Phase 4)
290
+
291
+ `schema` capability を宣言してサーフェスを実装します:
292
+
293
+ ```typescript
294
+ import { definePlugin } from 'ampless'
295
+
296
+ export default function schemaJsonldPlugin() {
297
+ return definePlugin({
298
+ name: 'schema-jsonld',
299
+ apiVersion: 1,
300
+ trust_level: 'untrusted',
301
+ capabilities: ['schema'],
302
+ publicBodyForPost(post, ctx) {
303
+ return [{
304
+ type: 'inlineScript',
305
+ id: 'schema-article',
306
+ scriptType: 'application/ld+json',
307
+ body: JSON.stringify({
308
+ '@context': 'https://schema.org',
309
+ '@type': 'Article',
310
+ headline: post.title,
311
+ url: `${ctx.site.url}/${post.slug}`,
312
+ datePublished: post.publishedAt,
313
+ }),
314
+ }]
315
+ },
316
+ })
317
+ }
318
+ ```
319
+
320
+ テーマの `pages/post.tsx` が `ampless.publicBodyForPost(post)` を呼び、返された descriptor を描画します。runtime は自動 escape した body を持つ `<script type="application/ld+json">` 要素をページに挿入します。
321
+
247
322
  ---
248
323
 
249
324
  ## 7. 非同期イベントフック
@@ -493,6 +568,7 @@ it('admin が空文字保存した場合は空配列', () => {
493
568
  - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`
494
569
  - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook + 外向き HTTP
495
570
  - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` ルートレンダラ
571
+ - [`packages/plugin-schema-jsonld`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-schema-jsonld) — `publicBodyForPost` + `schema` capability、投稿単位 Article JSON-LD。(Phase 4)
496
572
 
497
573
  ---
498
574
 
@@ -510,6 +586,8 @@ it('admin が空文字保存した場合は空配列', () => {
510
586
  - **`inlineScript` の `id` 忘れ**。production では silent に drop、dev では warn。inline script の dedup は id 無しではできません
511
587
  - **`publicHead` から `ReactNode` を返す**。TypeScript で弾かれます — `publicHead` の戻り値型は descriptor のみ。任意 `ReactNode` が必要なら、それは Phase 6b の `developer.headElements` capability 待ち
512
588
  - **admin form から `manifest.default` を保存してしまう**。resolved default を「明示値」として書き戻さないでください — admin form が touched フィールドのみ書き込む設計はまさにこのため。default 値を保存すると future のパッケージ更新で default が変わってもそのフィールドだけ反映されなくなります
589
+ - **`publicBodyForPost` で `scriptType: 'application/ld+json'` を省略**。`publicBodyForPost` が返す descriptor で `scriptType` を省略するか他の値を指定すると、production では silent に drop、dev では warn されます。このサーフェスで有効なのは `'application/ld+json'` だけです
590
+ - **`schema` capability と `publicBodyForPost` の不一致**。`capabilities: ['schema']` を宣言して `publicBodyForPost` を未実装(またはその逆)にすると起動時に warning が出ます。宣言と実装は同期させてください
513
591
 
514
592
  ---
515
593
 
@@ -12,8 +12,8 @@
12
12
  This guide walks through everything a plugin author needs to ship a
13
13
  working ampless plugin, from first `definePlugin()` call to the
14
14
  admin-editable settings panel and an npm publish. It covers the
15
- Phase 1 + Phase 2 surfaces — descriptor-based `<head>` / `<body>`
16
- injection, the async event hooks, and admin-managed
15
+ Phase 1–4 surfaces — descriptor-based `<head>` / `<body>` /
16
+ per-post body injection, the async event hooks, and admin-managed
17
17
  `settings.public` values.
18
18
 
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);
@@ -33,6 +33,7 @@ surfaces:
33
33
  | `siteMetadata(site)` | root layout `generateMetadata()` | sync | Existing |
34
34
  | `publicHead(ctx)` | root layout `<head>` | sync (called from async layout) | 1 |
35
35
  | `publicBodyEnd(ctx)` | root layout end of `<body>` | sync | 1 |
36
+ | `publicBodyForPost(post, ctx)` | theme post page template (per-post) | sync | 4 |
36
37
  | `ogImage` | `/og/[slug]` route | request-time, in public Lambda | Existing |
37
38
  | `hooks` | trust_level-matched processor Lambda | async, on SQS event | Existing |
38
39
  | `settings.public` | `/admin/plugins` form | declarative manifest | 2 |
@@ -117,6 +118,7 @@ interface AmplessPlugin {
117
118
  siteMetadata?(site): PluginMetadata
118
119
  publicHead?(ctx): readonly PublicHeadDescriptor[]
119
120
  publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
121
+ publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
120
122
  ogImage?: OgImageConfig
121
123
  settings?: { public?: readonly PluginSettingField[] }
122
124
  }
@@ -201,6 +203,7 @@ network — and execute synchronously.
201
203
  | `siteMetadata(site)` | `PluginMetadata` | Site-wide `<title>` / favicon / RSS `<link rel="alternate">` |
202
204
  | `publicHead(ctx)` | `PublicHeadDescriptor[]` | Analytics loader, fonts, jsonld, hreflang |
203
205
  | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script frame, chat widgets, tail snippets |
206
+ | `publicBodyForPost(post, ctx)` | `PublicPostBodyDescriptor[]` | Per-post body injection — JSON-LD structured data; rendered by the theme's post page template |
204
207
 
205
208
  The `ctx` object carries:
206
209
 
@@ -246,6 +249,15 @@ untrusted code in the public render path.
246
249
  strategy: 'afterInteractive',
247
250
  }
248
251
 
252
+ // Inline script — JSON-LD variant (valid in publicHead, publicBodyEnd, publicBodyForPost)
253
+ // The runtime auto-escapes the body; return raw JSON, not pre-escaped.
254
+ {
255
+ type: 'inlineScript',
256
+ id: 'schema-article',
257
+ scriptType: 'application/ld+json',
258
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
259
+ }
260
+
249
261
  // Meta / link / noscript
250
262
  { type: 'meta', name: 'theme-color', content: '#fff' }
251
263
  { type: 'meta', property: 'og:image', content: 'https://…' }
@@ -267,6 +279,49 @@ untrusted code in the public render path.
267
279
  }
268
280
  ```
269
281
 
282
+ ### `PublicPostBodyDescriptor` (Phase 4)
283
+
284
+ `publicBodyForPost` returns `PublicPostBodyDescriptor[]`. This is a
285
+ restricted subset of `inlineScript` where `scriptType` is required
286
+ and must be `'application/ld+json'`:
287
+
288
+ ```ts
289
+ // The only valid form for publicBodyForPost:
290
+ {
291
+ type: 'inlineScript',
292
+ id: 'schema-article',
293
+ scriptType: 'application/ld+json', // required — only value accepted
294
+ body: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', ... }),
295
+ }
296
+ ```
297
+
298
+ Why `meta` and `link` are excluded: per-post metadata is already
299
+ handled by Next.js `generateMetadata()` via the `metadata()` surface,
300
+ which integrates with the framework's deduplication and streaming
301
+ behaviour. `publicBodyForPost` exists solely for the structured data
302
+ use-case (`<script type="application/ld+json">`) that `generateMetadata`
303
+ cannot produce.
304
+
305
+ ### JSON-LD auto-escape
306
+
307
+ When `scriptType === 'application/ld+json'`, the runtime **automatically
308
+ escapes the `body` string** before rendering — `<` → `<`,
309
+ `>` → `>`, `&` → `&`, U+2028 → `
`, U+2029 → `
`.
310
+ This applies across all three surfaces that accept `inlineScript`
311
+ (`publicHead`, `publicBodyEnd`, `publicBodyForPost`). Return the raw
312
+ JSON string; do not pre-escape it.
313
+
314
+ Descriptors with an unsupported `scriptType` value are **dropped with
315
+ a console warning** and never rendered.
316
+
317
+ ### Surface-dependent `scriptType` rules
318
+
319
+ | Surface | `scriptType` |
320
+ |---|---|
321
+ | `publicHead` | `undefined` (default JS) or `'application/ld+json'` |
322
+ | `publicBodyEnd` | same as `publicHead` |
323
+ | `publicBodyForPost` | `'application/ld+json'` **required**; any other value (including omitted) is dropped + warned |
324
+
270
325
  ### Validation rules
271
326
 
272
327
  - **URL scheme allowlist**: `http`, `https`, or relative paths.
@@ -302,6 +357,42 @@ interpolates that fragment directly:
302
357
  `cms.config.plugins` iteration order is preserved across the
303
358
  collected list.
304
359
 
360
+ ### `publicBodyForPost` example (Phase 4)
361
+
362
+ Declare the `schema` capability and implement the surface:
363
+
364
+ ```typescript
365
+ import { definePlugin } from 'ampless'
366
+
367
+ export default function schemaJsonldPlugin() {
368
+ return definePlugin({
369
+ name: 'schema-jsonld',
370
+ apiVersion: 1,
371
+ trust_level: 'untrusted',
372
+ capabilities: ['schema'],
373
+ publicBodyForPost(post, ctx) {
374
+ return [{
375
+ type: 'inlineScript',
376
+ id: 'schema-article',
377
+ scriptType: 'application/ld+json',
378
+ body: JSON.stringify({
379
+ '@context': 'https://schema.org',
380
+ '@type': 'Article',
381
+ headline: post.title,
382
+ url: `${ctx.site.url}/${post.slug}`,
383
+ datePublished: post.publishedAt,
384
+ }),
385
+ }]
386
+ },
387
+ })
388
+ }
389
+ ```
390
+
391
+ The theme's `pages/post.tsx` calls `ampless.publicBodyForPost(post)`
392
+ and renders the returned descriptors. The runtime inserts a
393
+ `<script type="application/ld+json">` element with the auto-escaped
394
+ body into the page.
395
+
305
396
  ---
306
397
 
307
398
  ## 7. Async event hooks
@@ -619,6 +710,7 @@ Worked examples to crib from:
619
710
  - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`.
620
711
  - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook with outbound HTTP.
621
712
  - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` route renderer.
713
+ - [`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)
622
714
 
623
715
  ---
624
716
 
@@ -654,6 +746,14 @@ Worked examples to crib from:
654
746
  admin form only writes touched fields for exactly this reason.
655
747
  Saving a default value freezes it: future package updates that
656
748
  change the default won't take effect for that field anymore.
749
+ - **`publicBodyForPost` without `scriptType: 'application/ld+json'`.** Descriptors
750
+ returned from `publicBodyForPost` that omit `scriptType` or use any
751
+ other value are silently dropped in production and warned in dev.
752
+ Only `scriptType: 'application/ld+json'` is valid in that surface.
753
+ - **`schema` capability + `publicBodyForPost` mismatch.** Declaring
754
+ `capabilities: ['schema']` without implementing `publicBodyForPost`
755
+ (or the reverse) prints a startup warning. Keep the declaration and
756
+ implementation in sync.
657
757
 
658
758
  ---
659
759
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampless",
3
- "version": "1.0.0-alpha.20",
3
+ "version": "1.0.0-alpha.22",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",