ampless 1.0.0-alpha.41 → 1.0.0-alpha.43

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
@@ -1,3 +1,5 @@
1
+ import { ReactNode } from 'react';
2
+
1
3
  type ContentEventType = 'content.created' | 'content.updated' | 'content.published' | 'content.unpublished' | 'content.deleted';
2
4
  type MediaEventType = 'media.uploaded' | 'media.deleted';
3
5
  type SiteSettingsEventType = 'site.settings.updated';
@@ -302,7 +304,7 @@ type TrustLevel = 'untrusted' | 'trusted' | 'privileged';
302
304
  * capability today is harmless, but the runtime won't expose any new
303
305
  * surface for it until the matching phase ships.
304
306
  */
305
- type PluginCapability = 'publicHead' | 'publicBody' | 'metadata' | 'eventHooks' | 'adminSettings' | 'writePublicAsset' | 'schema' | 'publicHtmlForPost' | 'secretSettings' | 'contentFields' | 'adminPage' | 'serverRoute' | 'network' | 'scheduler' | 'storageWrite' | 'privilegedSystem' | 'cspReady';
307
+ type PluginCapability = 'publicHead' | 'publicBody' | 'metadata' | 'eventHooks' | 'adminSettings' | 'writePublicAsset' | 'schema' | 'publicHtmlForPost' | 'secretSettings' | 'contentFields' | 'publicPostScript' | 'adminPage' | 'serverRoute' | 'network' | 'scheduler' | 'storageWrite' | 'privilegedSystem' | 'cspReady';
306
308
  /**
307
309
  * Loading strategy for `script` / `inlineScript` descriptors.
308
310
  *
@@ -971,6 +973,80 @@ interface PluginSettingsManifest {
971
973
  */
972
974
  version?: number;
973
975
  }
976
+ /**
977
+ * Minimal tiptap node shape passed to a `contentFields` `tiptap` renderer.
978
+ * Mirrors the structural fields plugins typically read (`type`, `attrs`,
979
+ * `content`, `marks`, `text`) without coupling to a specific tiptap
980
+ * version. Plugins should treat unknown fields conservatively.
981
+ */
982
+ interface TiptapRenderNode {
983
+ type: string;
984
+ attrs?: Record<string, unknown>;
985
+ content?: readonly TiptapRenderNode[];
986
+ marks?: readonly {
987
+ type: string;
988
+ attrs?: Record<string, unknown>;
989
+ }[];
990
+ text?: string;
991
+ }
992
+ /**
993
+ * Match object passed to a `contentFields` `markdown-url` renderer. The
994
+ * runtime walks the markdown body via `marked.lexer`, tests the trimmed
995
+ * single-line URL of each `paragraph` token against the registered
996
+ * pattern, and on match calls `render({ match, raw }, ctx)` so the
997
+ * plugin can extract capture groups (e.g. the YouTube video id) and
998
+ * return a ReactNode for that paragraph.
999
+ */
1000
+ interface MarkdownEmbedMatch {
1001
+ match: RegExpMatchArray;
1002
+ raw: string;
1003
+ }
1004
+ /**
1005
+ * Renderer registered via `AmplessPlugin.contentFields`. Two variants:
1006
+ *
1007
+ * - `tiptap`: keyed by `nodeType` (e.g. `'amplessYoutube'`). The runtime
1008
+ * walks tiptap docs and, when the walker encounters a node whose
1009
+ * `type` matches `nodeType`, calls `render(node, ctx)` to produce a
1010
+ * ReactNode in place of the default switch-based HTML emission.
1011
+ *
1012
+ * - `markdown-url`: keyed by a fully-anchored `RegExp` (the pattern
1013
+ * should use `^...$`). The runtime tokenizes markdown via
1014
+ * `marked.lexer`, looks for `paragraph` tokens whose entire content
1015
+ * is a single URL (autolink / bare URL / `[text](url)` with matching
1016
+ * text), tests the URL against `pattern`, and on match calls
1017
+ * `render({ match, raw }, ctx)` for that paragraph.
1018
+ *
1019
+ * Each `nodeType` / `pattern.source` may be registered by at most one
1020
+ * plugin — duplicate registration throws at `createPluginHead` time.
1021
+ */
1022
+ type ContentFieldRenderer = {
1023
+ kind: 'tiptap';
1024
+ nodeType: string;
1025
+ render(node: TiptapRenderNode, ctx: PluginPublicRenderContext): ReactNode;
1026
+ } | {
1027
+ kind: 'markdown-url';
1028
+ pattern: RegExp;
1029
+ render(m: MarkdownEmbedMatch, ctx: PluginPublicRenderContext): ReactNode;
1030
+ };
1031
+ /**
1032
+ * Descriptor returned by `AmplessPlugin.publicPostScript(post, ctx)`.
1033
+ * The runtime aggregates descriptors across every post passed to
1034
+ * `ampless.publicPostScriptsForPage(posts)` and emits each unique `id`
1035
+ * once as `<script src={src} async defer />`.
1036
+ *
1037
+ * `id` must be a stable, plugin-defined identifier (e.g.
1038
+ * `'amplessTweet:widgets'`). Multiple posts on the same page that all
1039
+ * need the same script collapse into a single tag.
1040
+ *
1041
+ * `src` must be an absolute `http://` or `https://` URL — relative
1042
+ * paths and other schemes are dropped with a warning.
1043
+ */
1044
+ interface PublicPostScriptDescriptor {
1045
+ id: string;
1046
+ src: string;
1047
+ async?: boolean;
1048
+ defer?: boolean;
1049
+ }
974
1050
  interface AmplessPlugin {
975
1051
  name: string;
976
1052
  /**
@@ -1152,6 +1228,40 @@ interface AmplessPlugin {
1152
1228
  * capability.
1153
1229
  */
1154
1230
  publicHtmlForPost?(post: Post, ctx: PluginPublicRenderContext): readonly PublicPostHtmlDescriptor[];
1231
+ /**
1232
+ * In-body content renderers (Phase 7 `contentFields` capability).
1233
+ * Plugins register tiptap node renderers (keyed by `nodeType`) and/or
1234
+ * markdown URL renderers (keyed by `RegExp`) here. The runtime walks
1235
+ * post bodies during `ampless.renderBody(post)` and, on match, calls
1236
+ * the renderer to obtain a `ReactNode` for that fragment in place of
1237
+ * the default HTML output.
1238
+ *
1239
+ * Duplicate `nodeType` / `pattern.source` across plugins throws at
1240
+ * `createPluginHead` construction time (eager config-time error). v1
1241
+ * does not support multi-instance plugins registering the same
1242
+ * renderer twice.
1243
+ *
1244
+ * Plugins implementing this should declare the `'contentFields'`
1245
+ * capability.
1246
+ */
1247
+ contentFields?: readonly ContentFieldRenderer[];
1248
+ /**
1249
+ * Page-level script descriptors (Phase 7 `publicPostScript`
1250
+ * capability). Themes call `ampless.publicPostScriptsForPage(posts)`
1251
+ * after rendering post body / featured body; the runtime invokes
1252
+ * `publicPostScript(post, ctx)` for each plugin × post pair, collects
1253
+ * the descriptors, dedupes by `id`, and emits each unique entry as
1254
+ * `<script src={src} async defer />`.
1255
+ *
1256
+ * Primary use case: x.com (Twitter) `widgets.js` to hydrate
1257
+ * `<blockquote class="twitter-tweet">` blocks emitted by
1258
+ * `contentFields`. YouTube embeds don't need this (the iframe loads
1259
+ * its own script).
1260
+ *
1261
+ * Plugins implementing this should declare the `'publicPostScript'`
1262
+ * capability.
1263
+ */
1264
+ publicPostScript?(post: Post, ctx: PluginPublicRenderContext): readonly PublicPostScriptDescriptor[];
1155
1265
  /**
1156
1266
  * Dynamic OG image renderer. The dispatcher route (e.g.
1157
1267
  * `app/og/[slug]/route.ts`) reads this and feeds the element into
@@ -1766,4 +1876,4 @@ declare function pickDefaultEntrypoint(files: readonly {
1766
1876
 
1767
1877
  declare const VERSION = "0.0.1";
1768
1878
 
1769
- 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 HistoryConfig, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type ListPostHistoryOptions, 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 PluginHookResult, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPackageManifest, type PluginPublicRenderContext, type PluginRepeatableField, type PluginRepeatableSubField, type PluginRuntimeContext, type PluginSecretField, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUninstallContext, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostRevision, type PostRevisionConnection, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type PublicPostBodyDescriptor, type PublicPostHtmlDescriptor, type PublicPostHtmlPosition, 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, type TrustedPluginRuntimeContext, 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, listPostHistory, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
1879
+ export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFieldRenderer, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type HistoryConfig, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type ListPostHistoryOptions, type LocalizedString, MAX_BUNDLE_BYTES, type MarkdownEmbedMatch, 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 PluginHookResult, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPackageManifest, type PluginPublicRenderContext, type PluginRepeatableField, type PluginRepeatableSubField, type PluginRuntimeContext, type PluginSecretField, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUninstallContext, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostRevision, type PostRevisionConnection, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type PublicPostBodyDescriptor, type PublicPostHtmlDescriptor, type PublicPostHtmlPosition, type PublicPostScriptDescriptor, 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 TiptapRenderNode, type TrustLevel, type TrustedPluginRuntimeContext, 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, listPostHistory, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
@@ -546,7 +546,7 @@ export default function readingTimePlugin() {
546
546
  ```tsx
547
547
  {postBody} {/* publicBodyForPost — JSON-LD */}
548
548
  {html.beforeContent} {/* publicHtmlForPost — beforeContent スロット */}
549
- <div className="prose" dangerouslySetInnerHTML={{ __html: renderBody(post) }} />
549
+ <div className="prose">{await ampless.renderBody(post)}</div>
550
550
  {html.afterContent} {/* publicHtmlForPost — afterContent スロット */}
551
551
  ```
552
552
 
@@ -684,6 +684,188 @@ npm 公開のスタンドアロンプラグインの場合は、`package.json#am
684
684
 
685
685
  ---
686
686
 
687
+ ## 6b. 投稿本文の差し替え: `contentFields` (Phase 7)
688
+
689
+ `contentFields` capability は、投稿本文の一部(tiptap ノード、または markdown の単独行 URL)を、プラグインが管理する React サブツリーで差し替える機能。`@ampless/plugin-youtube` / `@ampless/plugin-x-embed` が `https://youtu.be/...` URL を iframe プレーヤーに、`https://x.com/<handle>/status/...` URL を tweet blockquote に展開するために使う。
690
+
691
+ ### 形
692
+
693
+ ```ts
694
+ import { definePlugin, type ContentFieldRenderer } from 'ampless'
695
+
696
+ definePlugin({
697
+ // ...
698
+ capabilities: ['contentFields'],
699
+ contentFields: [
700
+ {
701
+ kind: 'tiptap',
702
+ nodeType: 'amplessYoutube',
703
+ render: (node, ctx) => <YouTubeEmbed videoId={String(node.attrs?.videoId)} />,
704
+ },
705
+ {
706
+ kind: 'markdown-url',
707
+ pattern: /^https:\/\/youtu\.be\/([\w-]{11})$/,
708
+ render: ({ match }, ctx) => <YouTubeEmbed videoId={match[1]!} />,
709
+ },
710
+ ],
711
+ })
712
+ ```
713
+
714
+ 各 renderer は `ampless.renderBody(post)` 内で本文を歩く runtime から server-side で呼び出される。戻り値は `ReactNode`。`PluginPublicRenderContext` (`ctx`) は `publicHead` / `publicBodyEnd` と同じものなので、`ctx.setting<T>(key)` も同じように使える。
715
+
716
+ ### 2 種類の kind
717
+
718
+ - **`tiptap`** — `nodeType` (例: `'amplessYoutube'`) でキー付け。runtime の tiptap walker が `type` 一致のノードを見つけたら renderer を呼ぶ。デフォルトの switch-case レンダリングはバイパスされ、プラグインがサブツリー全体を所有する。
719
+ - **`markdown-url`** — anchored な `RegExp` (`^...$`) でキー付け。runtime は `marked.lexer` で markdown をトークン化し、内容全体が単独 URL の paragraph トークン (autolink / bare URL / `[text](url)`) のみについて pattern を試す。最初にマッチしたものが勝ち。capture group は `match[1]`, `match[2]`, ... でアクセス可能。
720
+
721
+ ### 命名と一意性
722
+
723
+ - first-party プラグインは `ampless...` の camelCase プレフィックス(`amplessYoutube`, `amplessTweet` 等)を使い、コミュニティ製の `nodeType` と衝突しないようにしている。
724
+ - runtime は同じ `nodeType` / `pattern.source` の重複登録を起動時に throw で拒否する。先勝ち。v1 は multi-instance 非対応。
725
+
726
+ ### markdown URL pattern のルール
727
+
728
+ - **必ず `^...$` で anchor する**。anchor なしだと段落内の URL が誤マッチし、周辺テキストが壊れる。
729
+ - runtime はマッチ前に前後の whitespace を trim するので、pattern 側で `\s*` を書く必要はない。
730
+ - 段落の唯一のトークンが `[caption](url)` の markdown link なら受理する。`[caption with link](url)` のように前後にテキストが混在するケースは対象外(正しい挙動: prose は本文に残すべきで、video embed にすべきではない)。
731
+
732
+ ---
733
+
734
+ ## 6c. ページレベルスクリプト: `publicPostScript` (Phase 7)
735
+
736
+ `publicPostScript` capability は、ページ上の投稿が必要とする `<script>` タグをプラグインから出力するためのもの。runtime は安定 `id` で dedupe するので、同一ページ内の複数 embed は 1 つの script タグに集約される。`@ampless/plugin-x-embed` がページに tweet embed がある場合のみ `https://platform.twitter.com/widgets.js` を 1 度だけ注入するのに使われている。
737
+
738
+ ### 形
739
+
740
+ ```ts
741
+ definePlugin({
742
+ capabilities: ['contentFields', 'publicPostScript'],
743
+ publicPostScript(post, ctx) {
744
+ if (!hasTweetIn(post)) return []
745
+ return [
746
+ {
747
+ id: 'amplessTweet:widgets',
748
+ src: 'https://platform.twitter.com/widgets.js',
749
+ async: true,
750
+ },
751
+ ]
752
+ },
753
+ })
754
+ ```
755
+
756
+ ### テーマからの呼び出し
757
+
758
+ テーマは投稿本文の出力後に `{await ampless.publicPostScriptsForPage(posts)}` を呼ぶ。first-party テーマは post 詳細ページ / home ページ (featured 表示時) で自動的に呼び出す:
759
+
760
+ ```tsx
761
+ <div>{await ampless.renderBody(post)}</div>
762
+ {await ampless.publicPostScriptsForPage([post])}
763
+ ```
764
+
765
+ runtime は:
766
+
767
+ 1. プラグイン × post 組ごとに `publicPostScript(post, ctx)` を呼ぶ。
768
+ 2. `id` が空 / 非 string、`src` が http(s) でない、descriptor がオブジェクトでない、等を drop。
769
+ 3. `id` で dedupe(先勝ち)。
770
+ 4. `<Fragment>` で `<script src={src} async defer />` を出力。
771
+
772
+ ### CSP の扱い
773
+
774
+ runtime は `src` のホスト allowlist を強制しない。CSP はサイト側エンジニアの責任(`next.config.ts` / middleware で `script-src` に script ホスト(例: `platform.twitter.com`)を追加)。各プラグインの README に書いてある。
775
+
776
+ ---
777
+
778
+ ## 6d. Admin エディタ拡張の配線 (Phase 7)
779
+
780
+ admin エディタに tiptap Node 拡張を提供するプラグインは、別途 `./editor` subpath の client-side エントリを出荷する。テンプレート側で `_editor-bootstrap.tsx` から配線する:
781
+
782
+ ```tsx
783
+ // templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx
784
+ 'use client'
785
+ import { installAdminEditorExtensions } from '@ampless/admin/editor'
786
+ import { youtubeEditor } from '@ampless/plugin-youtube/editor'
787
+ import { tweetEditor } from '@ampless/plugin-x-embed/editor'
788
+
789
+ export function EditorBootstrap({ children }: { children: React.ReactNode }) {
790
+ installAdminEditorExtensions([
791
+ youtubeEditor.extension,
792
+ tweetEditor.extension,
793
+ ])
794
+ return <>{children}</>
795
+ }
796
+ ```
797
+
798
+ その上で layout に渡す:
799
+
800
+ ```tsx
801
+ // templates/_shared/app/(admin)/admin/layout.tsx
802
+ import { createAdminLayout } from '@ampless/admin/pages'
803
+ import { EditorBootstrap } from './_editor-bootstrap'
804
+ export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
805
+ ```
806
+
807
+ `installAdminEditorExtensions` は idempotent で、render 時に client component 内で呼ばれる。admin の `<TiptapEditor>` は毎回 render 時に登録済みリストを built-in extensions の末尾に spread する。
808
+
809
+ ### エディタプレビューのパイプライン
810
+
811
+ admin の edit / new post フォームは preview ペインを `<iframe sandbox="allow-scripts">` で表示する。`srcDoc` はテンプレート側 preview Route Handler が返す HTML。テンプレート scaffold は handler を `app/(admin)/admin/preview/route.tsx` に同梱する:
812
+
813
+ ```tsx
814
+ // templates/_shared/app/(admin)/admin/preview/route.tsx
815
+ import type { Post } from 'ampless'
816
+ import { admin } from '@/lib/admin'
817
+
818
+ export async function POST(req: Request): Promise<Response> {
819
+ const session = await admin.getServerSession()
820
+ if (!admin.isEditor(session)) {
821
+ return new Response('Forbidden', { status: 403 })
822
+ }
823
+ let draft: Post
824
+ try {
825
+ draft = (await req.json()) as Post
826
+ } catch {
827
+ return new Response('Bad Request', { status: 400 })
828
+ }
829
+ const ampless = await admin.getAmpless()
830
+ const node = (
831
+ <>
832
+ {await ampless.renderBody(draft)}
833
+ {await ampless.publicPostScriptsForPage([draft])}
834
+ </>
835
+ )
836
+ // dynamic import: 下の「Server Action ではなく Route Handler を採用した理由」参照。
837
+ const { renderToStaticMarkup } = await import('react-dom/server')
838
+ return new Response(renderToStaticMarkup(node), {
839
+ headers: {
840
+ 'Content-Type': 'text/html; charset=utf-8',
841
+ 'Cache-Control': 'no-store',
842
+ },
843
+ })
844
+ }
845
+ ```
846
+
847
+ `<PostForm>` / `<PostHistoryPanel>` は素のままで `/admin/preview` に draft を POST する — call site で追加の wiring は不要:
848
+
849
+ ```tsx
850
+ // templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
851
+ import { admin } from '@/lib/admin'
852
+ import { createEditPostPage } from '@ampless/admin/pages'
853
+
854
+ export default createEditPostPage(admin)
855
+ ```
856
+
857
+ admin が非デフォルトパス(Next.js `basePath` やカスタム prefix)にマウントされている場合は、page factory の `previewEndpoint` option で endpoint を上書きできる:
858
+
859
+ ```tsx
860
+ export default createEditPostPage(admin, { previewEndpoint: '/cms/admin/preview' })
861
+ ```
862
+
863
+ Server Action ではなく Route Handler を採用した理由: `'use server'` モジュール内部で `react-dom/server` 由来の rendering を行うと、Next.js 15+ が edit-post page の compile に失敗する。build 時に Client Component から Server Action モジュール経由で import graph を辿るため、その経路上で `react-dom/server` に到達した時点で「You're importing a component that imports react-dom/server」チェックが発火する。Route Handler に切り出すことで rendering を import graph から完全に切り離せる — `<PostForm>` は plain な HTTP endpoint を fetch するだけで、bundler は form から handler 側へ踏み込まない。さらに handler 自身が `admin.isEditor()` を明示的にチェックすることで、将来 `(admin)` route-group gate が誤設定された場合の安全側のフェールセーフになる。`react-dom/server` の import 自体を dynamic にしているのは Next.js 16 の Turbopack が、Route Handler を含む app router build 経路で reach できる top-level の `react-dom/server` static import を一律で flag するため。request 時に解決することで build-time import-graph walker から外しつつ、runtime では同じ Node.js subpath からロードする。
864
+
865
+ iframe の `sandbox="allow-scripts"` は `allow-same-origin` を含まないので、preview の中身は opaque origin で動作する → widget script は admin の DOM に触れない。トレードオフ: widget が自身の origin の storage にアクセスできない場合に正しく動かない可能性がある。Phase 7 時点の dogfood では YouTube iframe / x.com widgets.js とも opaque origin で動作することを確認済み。将来 non-opaque origin が必要な widget が出てきた場合、escape hatch は別 subdomain の preview route + 適切な CSP — sandbox flag を緩めるのは取らない。
866
+
867
+ ---
868
+
687
869
  ## 7. 非同期イベントフック
688
870
 
689
871
  `hooks` は SQS から到着したイベントを trust_level に対応する processor Lambda が受けて実行します。
@@ -678,7 +678,7 @@ the slots:
678
678
  ```tsx
679
679
  {postBody} {/* publicBodyForPost — JSON-LD */}
680
680
  {html.beforeContent} {/* publicHtmlForPost — beforeContent slot */}
681
- <div className="prose" dangerouslySetInnerHTML={{ __html: renderBody(post) }} />
681
+ <div className="prose">{await ampless.renderBody(post)}</div>
682
682
  {html.afterContent} {/* publicHtmlForPost — afterContent slot */}
683
683
  ```
684
684
 
@@ -894,6 +894,259 @@ manifest and the factory return value:
894
894
 
895
895
  ---
896
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. The
1030
+ template wires it up in `_editor-bootstrap.tsx`:
1031
+
1032
+ ```tsx
1033
+ // templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx
1034
+ 'use client'
1035
+ import { installAdminEditorExtensions } from '@ampless/admin/editor'
1036
+ import { youtubeEditor } from '@ampless/plugin-youtube/editor'
1037
+ import { tweetEditor } from '@ampless/plugin-x-embed/editor'
1038
+
1039
+ export function EditorBootstrap({ children }: { children: React.ReactNode }) {
1040
+ installAdminEditorExtensions([
1041
+ youtubeEditor.extension,
1042
+ tweetEditor.extension,
1043
+ ])
1044
+ return <>{children}</>
1045
+ }
1046
+ ```
1047
+
1048
+ Then thread it into the layout:
1049
+
1050
+ ```tsx
1051
+ // templates/_shared/app/(admin)/admin/layout.tsx
1052
+ import { createAdminLayout } from '@ampless/admin/pages'
1053
+ import { EditorBootstrap } from './_editor-bootstrap'
1054
+ export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
1055
+ ```
1056
+
1057
+ `installAdminEditorExtensions` is idempotent and runs at render time
1058
+ inside a client component. The admin's `<TiptapEditor>` spreads the
1059
+ registered list onto its built-in extensions on every render.
1060
+
1061
+ ### Editor preview pipeline
1062
+
1063
+ The admin's edit / new post forms render the preview pane in an
1064
+ `<iframe sandbox="allow-scripts">` whose `srcDoc` is the HTML returned
1065
+ by the template's preview Route Handler. The template scaffold ships
1066
+ the handler at `app/(admin)/admin/preview/route.tsx`:
1067
+
1068
+ ```tsx
1069
+ // templates/_shared/app/(admin)/admin/preview/route.tsx
1070
+ import type { Post } from 'ampless'
1071
+ import { admin } from '@/lib/admin'
1072
+
1073
+ export async function POST(req: Request): Promise<Response> {
1074
+ const session = await admin.getServerSession()
1075
+ if (!admin.isEditor(session)) {
1076
+ return new Response('Forbidden', { status: 403 })
1077
+ }
1078
+ let draft: Post
1079
+ try {
1080
+ draft = (await req.json()) as Post
1081
+ } catch {
1082
+ return new Response('Bad Request', { status: 400 })
1083
+ }
1084
+ const ampless = await admin.getAmpless()
1085
+ const node = (
1086
+ <>
1087
+ {await ampless.renderBody(draft)}
1088
+ {await ampless.publicPostScriptsForPage([draft])}
1089
+ </>
1090
+ )
1091
+ // Dynamic import: see "Why a Route Handler" below.
1092
+ const { renderToStaticMarkup } = await import('react-dom/server')
1093
+ return new Response(renderToStaticMarkup(node), {
1094
+ headers: {
1095
+ 'Content-Type': 'text/html; charset=utf-8',
1096
+ 'Cache-Control': 'no-store',
1097
+ },
1098
+ })
1099
+ }
1100
+ ```
1101
+
1102
+ `<PostForm>` / `<PostHistoryPanel>` POST the draft to `/admin/preview`
1103
+ out of the box — no extra wiring at the call site:
1104
+
1105
+ ```tsx
1106
+ // templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
1107
+ import { admin } from '@/lib/admin'
1108
+ import { createEditPostPage } from '@ampless/admin/pages'
1109
+
1110
+ export default createEditPostPage(admin)
1111
+ ```
1112
+
1113
+ If the admin is mounted at a non-default path (Next.js `basePath` or
1114
+ a custom prefix), override the endpoint via the page factory's
1115
+ `previewEndpoint` option:
1116
+
1117
+ ```tsx
1118
+ export default createEditPostPage(admin, { previewEndpoint: '/cms/admin/preview' })
1119
+ ```
1120
+
1121
+ Why a Route Handler rather than a Server Action: putting
1122
+ `react-dom/server`-driven rendering inside a `'use server'` module
1123
+ makes Next.js 15+ refuse to compile the edit-post page, because the
1124
+ build traces the import graph from Client Components through Server
1125
+ Action modules and any reach to `react-dom/server` along that path
1126
+ trips the "You're importing a component that imports
1127
+ react-dom/server" check. A Route Handler decouples the rendering
1128
+ from that graph entirely — `<PostForm>` fetches a plain HTTP
1129
+ endpoint and the bundler never walks from the form into here. The
1130
+ handler's explicit `admin.isEditor()` check also defends against
1131
+ accidental exposure if the `(admin)` route-group gate is ever
1132
+ misconfigured. The `react-dom/server` import itself is dynamic
1133
+ because Next.js 16's Turbopack flags any top-level static import of
1134
+ `react-dom/server` reached from the app router build, Route Handlers
1135
+ included; deferring it to request time keeps the module outside the
1136
+ build-time import-graph walker while still loading it from the same
1137
+ Node.js subpath at runtime.
1138
+
1139
+ The iframe's `sandbox="allow-scripts"` (without `allow-same-origin`)
1140
+ keeps preview content in an opaque origin so widget scripts cannot
1141
+ reach the admin's DOM. The trade-off is that some widgets may misbehave
1142
+ when they can't access their own origin's storage; the YouTube iframe
1143
+ embed and x.com widgets.js both work under these constraints in
1144
+ dogfood as of Phase 7. If a future widget needs a non-opaque preview
1145
+ origin, the escape hatch is a separate preview route on a different
1146
+ subdomain with appropriate CSP — not relaxing the sandbox flag.
1147
+
1148
+ ---
1149
+
897
1150
  ## 7. Async event hooks
898
1151
 
899
1152
  `hooks` runs inside the trust_level-matched processor Lambda when an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampless",
3
- "version": "1.0.0-alpha.41",
3
+ "version": "1.0.0-alpha.43",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,17 @@
32
32
  "dependencies": {
33
33
  "@jsquash/webp": "^1.5.0"
34
34
  },
35
+ "peerDependencies": {
36
+ "@types/react": "^19"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@types/react": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "^19.2.15"
45
+ },
35
46
  "keywords": [
36
47
  "cms",
37
48
  "aws",