create-ampless 1.0.0-alpha.126 → 1.0.0-alpha.128
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/templates/_shared/app/(admin)/admin/_actions/render-preview.tsx +32 -0
- package/dist/templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx +21 -0
- package/dist/templates/_shared/app/(admin)/admin/layout.tsx +2 -1
- package/dist/templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx +2 -1
- package/dist/templates/_shared/app/(admin)/admin/posts/new/page.tsx +2 -1
- package/dist/templates/_shared/docs/plugin-author-guide.ja.md +161 -1
- package/dist/templates/_shared/docs/plugin-author-guide.md +213 -1
- package/dist/templates/_shared/package.json +14 -14
- 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/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
// Phase 7 preview pipeline. <PostForm> / <PostHistoryPanel> calls this
|
|
4
|
+
// server action with the in-flight draft Post; we render the body +
|
|
5
|
+
// page-level scripts on the server (where async ReactNode +
|
|
6
|
+
// `contentFields` plugin renderers can run) and return a complete HTML
|
|
7
|
+
// string that the admin shows in an iframe (sandbox=`allow-scripts`).
|
|
8
|
+
//
|
|
9
|
+
// Server-side is the only place this can live: `ampless.renderBody`
|
|
10
|
+
// returns a Promise<ReactNode>, so client code can't call it inline.
|
|
11
|
+
// The action also has access to the `Ampless` instance via
|
|
12
|
+
// `admin.getAmpless()` which the client never sees.
|
|
13
|
+
|
|
14
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
15
|
+
import type { Post } from 'ampless'
|
|
16
|
+
import { admin } from '@/lib/admin'
|
|
17
|
+
|
|
18
|
+
export async function renderPreviewHtml(draft: Post): Promise<string> {
|
|
19
|
+
const ampless = await admin.getAmpless()
|
|
20
|
+
// IMPORTANT: include BOTH the body and the page-level scripts so
|
|
21
|
+
// widgets like x.com's `widgets.js` get a chance to hydrate in the
|
|
22
|
+
// iframe. Without `publicPostScriptsForPage([draft])` the embed
|
|
23
|
+
// blockquote would render but never load the widget JS, and authors
|
|
24
|
+
// wouldn't see what the public page actually looks like.
|
|
25
|
+
const node = (
|
|
26
|
+
<>
|
|
27
|
+
{await ampless.renderBody(draft)}
|
|
28
|
+
{await ampless.publicPostScriptsForPage([draft])}
|
|
29
|
+
</>
|
|
30
|
+
)
|
|
31
|
+
return renderToStaticMarkup(node)
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Phase 7 admin-editor extension slot. Templates wire first-party
|
|
4
|
+
// embed plugins' tiptap Node extensions here so the admin's
|
|
5
|
+
// <TiptapEditor> picks them up. Empty by default — uncomment the
|
|
6
|
+
// imports + the registration to enable YouTube / x.com embeds.
|
|
7
|
+
//
|
|
8
|
+
// Wired into the admin layout via:
|
|
9
|
+
// createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
10
|
+
|
|
11
|
+
import { installAdminEditorExtensions } from '@ampless/admin/editor'
|
|
12
|
+
// import { youtubeEditor } from '@ampless/plugin-youtube/editor'
|
|
13
|
+
// import { tweetEditor } from '@ampless/plugin-x-embed/editor'
|
|
14
|
+
|
|
15
|
+
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
16
|
+
installAdminEditorExtensions([
|
|
17
|
+
// youtubeEditor.extension,
|
|
18
|
+
// tweetEditor.extension,
|
|
19
|
+
])
|
|
20
|
+
return <>{children}</>
|
|
21
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { admin } from '@/lib/admin'
|
|
2
2
|
import { createAdminLayout } from '@ampless/admin/pages'
|
|
3
|
+
import { EditorBootstrap } from './_editor-bootstrap'
|
|
3
4
|
|
|
4
|
-
export default createAdminLayout(admin)
|
|
5
|
+
export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { admin } from '@/lib/admin'
|
|
2
2
|
import { createEditPostPage } from '@ampless/admin/pages'
|
|
3
|
+
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
3
4
|
|
|
4
|
-
export default createEditPostPage(admin)
|
|
5
|
+
export default createEditPostPage(admin, { renderPreviewAction: renderPreviewHtml })
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { admin } from '@/lib/admin'
|
|
2
2
|
import { createNewPostPage } from '@ampless/admin/pages'
|
|
3
|
+
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
3
4
|
|
|
4
|
-
export default createNewPostPage(admin)
|
|
5
|
+
export default createNewPostPage(admin, { renderPreviewAction: renderPreviewHtml })
|
|
@@ -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"
|
|
549
|
+
<div className="prose">{await ampless.renderBody(post)}</div>
|
|
550
550
|
{html.afterContent} {/* publicHtmlForPost — afterContent スロット */}
|
|
551
551
|
```
|
|
552
552
|
|
|
@@ -684,6 +684,166 @@ 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` はテンプレート側 server action が返す HTML:
|
|
812
|
+
|
|
813
|
+
```tsx
|
|
814
|
+
// templates/_shared/app/(admin)/admin/_actions/render-preview.tsx
|
|
815
|
+
'use server'
|
|
816
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
817
|
+
import type { Post } from 'ampless'
|
|
818
|
+
import { admin } from '@/lib/admin'
|
|
819
|
+
|
|
820
|
+
export async function renderPreviewHtml(draft: Post): Promise<string> {
|
|
821
|
+
const ampless = await admin.getAmpless()
|
|
822
|
+
const node = (
|
|
823
|
+
<>
|
|
824
|
+
{await ampless.renderBody(draft)}
|
|
825
|
+
{await ampless.publicPostScriptsForPage([draft])}
|
|
826
|
+
</>
|
|
827
|
+
)
|
|
828
|
+
return renderToStaticMarkup(node)
|
|
829
|
+
}
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
page factory は `renderPreviewAction` オプションでこの action を thread する:
|
|
833
|
+
|
|
834
|
+
```tsx
|
|
835
|
+
// templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
|
|
836
|
+
import { admin } from '@/lib/admin'
|
|
837
|
+
import { createEditPostPage } from '@ampless/admin/pages'
|
|
838
|
+
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
839
|
+
|
|
840
|
+
export default createEditPostPage(admin, { renderPreviewAction: renderPreviewHtml })
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
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 を緩めるのは取らない。
|
|
844
|
+
|
|
845
|
+
---
|
|
846
|
+
|
|
687
847
|
## 7. 非同期イベントフック
|
|
688
848
|
|
|
689
849
|
`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"
|
|
681
|
+
<div className="prose">{await ampless.renderBody(post)}</div>
|
|
682
682
|
{html.afterContent} {/* publicHtmlForPost — afterContent slot */}
|
|
683
683
|
```
|
|
684
684
|
|
|
@@ -894,6 +894,218 @@ 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 server action:
|
|
1066
|
+
|
|
1067
|
+
```tsx
|
|
1068
|
+
// templates/_shared/app/(admin)/admin/_actions/render-preview.tsx
|
|
1069
|
+
'use server'
|
|
1070
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
1071
|
+
import type { Post } from 'ampless'
|
|
1072
|
+
import { admin } from '@/lib/admin'
|
|
1073
|
+
|
|
1074
|
+
export async function renderPreviewHtml(draft: Post): Promise<string> {
|
|
1075
|
+
const ampless = await admin.getAmpless()
|
|
1076
|
+
const node = (
|
|
1077
|
+
<>
|
|
1078
|
+
{await ampless.renderBody(draft)}
|
|
1079
|
+
{await ampless.publicPostScriptsForPage([draft])}
|
|
1080
|
+
</>
|
|
1081
|
+
)
|
|
1082
|
+
return renderToStaticMarkup(node)
|
|
1083
|
+
}
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
Page factories thread the action down via the `renderPreviewAction`
|
|
1087
|
+
option:
|
|
1088
|
+
|
|
1089
|
+
```tsx
|
|
1090
|
+
// templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
|
|
1091
|
+
import { admin } from '@/lib/admin'
|
|
1092
|
+
import { createEditPostPage } from '@ampless/admin/pages'
|
|
1093
|
+
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
1094
|
+
|
|
1095
|
+
export default createEditPostPage(admin, { renderPreviewAction: renderPreviewHtml })
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
The iframe's `sandbox="allow-scripts"` (without `allow-same-origin`)
|
|
1099
|
+
keeps preview content in an opaque origin so widget scripts cannot
|
|
1100
|
+
reach the admin's DOM. The trade-off is that some widgets may misbehave
|
|
1101
|
+
when they can't access their own origin's storage; the YouTube iframe
|
|
1102
|
+
embed and x.com widgets.js both work under these constraints in
|
|
1103
|
+
dogfood as of Phase 7. If a future widget needs a non-opaque preview
|
|
1104
|
+
origin, the escape hatch is a separate preview route on a different
|
|
1105
|
+
subdomain with appropriate CSP — not relaxing the sandbox flag.
|
|
1106
|
+
|
|
1107
|
+
---
|
|
1108
|
+
|
|
897
1109
|
## 7. Async event hooks
|
|
898
1110
|
|
|
899
1111
|
`hooks` runs inside the trust_level-matched processor Lambda when an
|
|
@@ -25,21 +25,21 @@
|
|
|
25
25
|
"@tiptap/pm": "^3.23.6",
|
|
26
26
|
"@tiptap/react": "^3.23.6",
|
|
27
27
|
"@tiptap/starter-kit": "^3.23.6",
|
|
28
|
-
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.
|
|
29
|
-
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.
|
|
30
|
-
"@ampless/plugin-gtm": "^0.2.0-alpha.
|
|
31
|
-
"@ampless/plugin-og-image": "^0.2.0-alpha.
|
|
32
|
-
"@ampless/plugin-plausible": "^0.2.0-alpha.
|
|
33
|
-
"@ampless/plugin-reading-time": "^0.1.0-alpha.
|
|
34
|
-
"@ampless/plugin-rss": "^0.2.0-alpha.
|
|
35
|
-
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.
|
|
36
|
-
"@ampless/plugin-seo": "^0.2.0-alpha.
|
|
37
|
-
"@ampless/plugin-webhook": "^0.2.0-alpha.
|
|
38
|
-
"@ampless/admin": "^1.0.0-alpha.
|
|
39
|
-
"@ampless/backend": "^1.0.0-alpha.
|
|
40
|
-
"@ampless/runtime": "^1.0.0-alpha.
|
|
28
|
+
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.26",
|
|
29
|
+
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.17",
|
|
30
|
+
"@ampless/plugin-gtm": "^0.2.0-alpha.25",
|
|
31
|
+
"@ampless/plugin-og-image": "^0.2.0-alpha.42",
|
|
32
|
+
"@ampless/plugin-plausible": "^0.2.0-alpha.25",
|
|
33
|
+
"@ampless/plugin-reading-time": "^0.1.0-alpha.15",
|
|
34
|
+
"@ampless/plugin-rss": "^0.2.0-alpha.42",
|
|
35
|
+
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.21",
|
|
36
|
+
"@ampless/plugin-seo": "^0.2.0-alpha.42",
|
|
37
|
+
"@ampless/plugin-webhook": "^0.2.0-alpha.43",
|
|
38
|
+
"@ampless/admin": "^1.0.0-alpha.75",
|
|
39
|
+
"@ampless/backend": "^1.0.0-alpha.64",
|
|
40
|
+
"@ampless/runtime": "^1.0.0-alpha.51",
|
|
41
41
|
"@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
|
|
42
|
-
"ampless": "^1.0.0-alpha.
|
|
42
|
+
"ampless": "^1.0.0-alpha.42",
|
|
43
43
|
"aws-amplify": "^6.17.0",
|
|
44
44
|
"class-variance-authority": "^0.7.1",
|
|
45
45
|
"clsx": "^2.1.1",
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Link from 'next/link'
|
|
2
2
|
import { formatDate, parseLinkList, type ThemeRouteContext } from 'ampless'
|
|
3
|
-
import { renderBody } from '@ampless/runtime'
|
|
4
3
|
import { ampless } from '@/lib/ampless'
|
|
5
4
|
import { admin } from '@/lib/admin'
|
|
6
5
|
import { TagList } from '@/components/tag-list'
|
|
@@ -59,13 +58,14 @@ export default async function BlogHome(_: ThemeRouteContext) {
|
|
|
59
58
|
</time>
|
|
60
59
|
)}
|
|
61
60
|
</Link>
|
|
62
|
-
<div
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
/>
|
|
61
|
+
<div className="prose prose-neutral dark:prose-invert mt-4 max-w-none">
|
|
62
|
+
{await ampless.renderBody(featured)}
|
|
63
|
+
</div>
|
|
66
64
|
</article>
|
|
67
65
|
)}
|
|
68
66
|
|
|
67
|
+
{featured && (await ampless.publicPostScriptsForPage([featured]))}
|
|
68
|
+
|
|
69
69
|
{posts.length === 0 ? (
|
|
70
70
|
!featured && <p className="text-gray-500">{admin.t('public.noPosts')}</p>
|
|
71
71
|
) : (
|
|
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import Link from 'next/link'
|
|
3
3
|
import { notFound } from 'next/navigation'
|
|
4
4
|
import { formatDate, parseLinkList, type ThemeRouteContext } from 'ampless'
|
|
5
|
-
import { renderBody } from '@ampless/runtime'
|
|
6
5
|
import { ampless } from '@/lib/ampless'
|
|
7
6
|
import { admin } from '@/lib/admin'
|
|
8
7
|
import { LightboxBinder } from '@/components/lightbox-content'
|
|
@@ -86,11 +85,14 @@ export default async function BlogPost({ params }: PostCtx) {
|
|
|
86
85
|
id="post-body"
|
|
87
86
|
className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
|
|
88
87
|
style={proseStyle}
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
>
|
|
89
|
+
{await ampless.renderBody(post)}
|
|
90
|
+
</div>
|
|
91
91
|
|
|
92
92
|
{html.afterContent}
|
|
93
93
|
|
|
94
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
95
|
+
|
|
94
96
|
<TagList tags={post.tags} className="mt-8 border-t pt-6" />
|
|
95
97
|
|
|
96
98
|
<footer className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500">
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Link from 'next/link'
|
|
2
2
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
3
|
-
import { renderBody } from '@ampless/runtime'
|
|
4
3
|
import { ampless } from '@/lib/ampless'
|
|
5
4
|
import { SiteHeader } from '@/components/site-chrome/site-header'
|
|
6
5
|
import { SiteFooter } from '@/components/site-chrome/site-footer'
|
|
@@ -70,14 +69,15 @@ export default async function CorporateHome(_: ThemeRouteContext) {
|
|
|
70
69
|
{formatDate(featured.publishedAt, settings.dateFormat, settings.timezone)}
|
|
71
70
|
</time>
|
|
72
71
|
)}
|
|
73
|
-
<div
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
/>
|
|
72
|
+
<div className="prose prose-neutral dark:prose-invert mt-6 max-w-none">
|
|
73
|
+
{await ampless.renderBody(featured)}
|
|
74
|
+
</div>
|
|
77
75
|
</article>
|
|
78
76
|
</section>
|
|
79
77
|
)}
|
|
80
78
|
|
|
79
|
+
{featured && (await ampless.publicPostScriptsForPage([featured]))}
|
|
80
|
+
|
|
81
81
|
{posts.length > 0 && (
|
|
82
82
|
<section className="mx-auto max-w-4xl px-6 py-16">
|
|
83
83
|
<h2 className="mb-8 border-l-4 border-[var(--primary)] pl-4 text-2xl font-bold">
|
|
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import Link from 'next/link'
|
|
3
3
|
import { notFound } from 'next/navigation'
|
|
4
4
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
5
|
-
import { renderBody } from '@ampless/runtime'
|
|
6
5
|
import { ampless } from '@/lib/ampless'
|
|
7
6
|
import { admin } from '@/lib/admin'
|
|
8
7
|
import { LightboxBinder } from '@/components/lightbox-content'
|
|
@@ -75,11 +74,14 @@ export default async function CorporatePost({ params }: PostCtx) {
|
|
|
75
74
|
id="post-body"
|
|
76
75
|
className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
|
|
77
76
|
style={proseStyle}
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
>
|
|
78
|
+
{await ampless.renderBody(post)}
|
|
79
|
+
</div>
|
|
80
80
|
|
|
81
81
|
{html.afterContent}
|
|
82
82
|
|
|
83
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
84
|
+
|
|
83
85
|
<TagList tags={post.tags} className="mt-8 border-t pt-6" />
|
|
84
86
|
</article>
|
|
85
87
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Link from 'next/link'
|
|
2
2
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
3
|
-
import { renderBody } from '@ampless/runtime'
|
|
4
3
|
import { ampless } from '@/lib/ampless'
|
|
5
4
|
import { SiteHeader } from '@/components/site-chrome/site-header'
|
|
6
5
|
import { SiteFooter } from '@/components/site-chrome/site-footer'
|
|
@@ -63,14 +62,15 @@ export default async function DadsHome(_: ThemeRouteContext) {
|
|
|
63
62
|
{featured.title}
|
|
64
63
|
</Link>
|
|
65
64
|
</h2>
|
|
66
|
-
<div
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/>
|
|
65
|
+
<div className="prose prose-neutral dark:prose-invert mt-6 max-w-none">
|
|
66
|
+
{await ampless.renderBody(featured)}
|
|
67
|
+
</div>
|
|
70
68
|
</div>
|
|
71
69
|
</section>
|
|
72
70
|
)}
|
|
73
71
|
|
|
72
|
+
{featured && (await ampless.publicPostScriptsForPage([featured]))}
|
|
73
|
+
|
|
74
74
|
{posts.length > 0 && (
|
|
75
75
|
<section className="px-6 py-12">
|
|
76
76
|
<div className="mx-auto max-w-4xl">
|
|
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import Link from 'next/link'
|
|
3
3
|
import { notFound } from 'next/navigation'
|
|
4
4
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
5
|
-
import { renderBody } from '@ampless/runtime'
|
|
6
5
|
import { ampless } from '@/lib/ampless'
|
|
7
6
|
import { admin } from '@/lib/admin'
|
|
8
7
|
import { LightboxBinder } from '@/components/lightbox-content'
|
|
@@ -80,11 +79,14 @@ export default async function DadsPost({ params }: PostCtx) {
|
|
|
80
79
|
id="post-body"
|
|
81
80
|
className="prose prose-neutral dark:prose-invert max-w-none [&_a]:text-[var(--primary)] [&_a]:underline-offset-4 [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
|
|
82
81
|
style={proseStyle}
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
>
|
|
83
|
+
{await ampless.renderBody(post)}
|
|
84
|
+
</div>
|
|
85
85
|
|
|
86
86
|
{html.afterContent}
|
|
87
87
|
|
|
88
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
89
|
+
|
|
88
90
|
<TagList tags={post.tags} className="mt-10 border-t pt-6" />
|
|
89
91
|
</article>
|
|
90
92
|
|
|
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import Link from 'next/link'
|
|
3
3
|
import { notFound } from 'next/navigation'
|
|
4
4
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
5
|
-
import { renderBody } from '@ampless/runtime'
|
|
6
5
|
import { ampless } from '@/lib/ampless'
|
|
7
6
|
import { LightboxBinder } from '@/components/lightbox-content'
|
|
8
7
|
import { TagList } from '@/components/tag-list'
|
|
@@ -77,11 +76,14 @@ export default async function DocsPost({ params }: PostCtx) {
|
|
|
77
76
|
id="post-body"
|
|
78
77
|
className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
|
|
79
78
|
style={proseStyle}
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
>
|
|
80
|
+
{await ampless.renderBody(post)}
|
|
81
|
+
</div>
|
|
82
82
|
|
|
83
83
|
{html.afterContent}
|
|
84
84
|
|
|
85
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
86
|
+
|
|
85
87
|
<TagList tags={post.tags} className="mt-10 border-t pt-6" />
|
|
86
88
|
</article>
|
|
87
89
|
</main>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Link from 'next/link'
|
|
2
2
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
3
|
-
import { renderBody } from '@ampless/runtime'
|
|
4
3
|
import { ampless } from '@/lib/ampless'
|
|
5
4
|
import { SiteHeader } from '@/components/site-chrome/site-header'
|
|
6
5
|
import { SiteFooter } from '@/components/site-chrome/site-footer'
|
|
@@ -63,14 +62,15 @@ export default async function LandingHome(_: ThemeRouteContext) {
|
|
|
63
62
|
<section className="mx-auto max-w-3xl px-6 py-16">
|
|
64
63
|
<article>
|
|
65
64
|
<h2 className="text-3xl font-bold tracking-tight">{featured.title}</h2>
|
|
66
|
-
<div
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/>
|
|
65
|
+
<div className="prose prose-neutral dark:prose-invert mt-6 max-w-none">
|
|
66
|
+
{await ampless.renderBody(featured)}
|
|
67
|
+
</div>
|
|
70
68
|
</article>
|
|
71
69
|
</section>
|
|
72
70
|
)}
|
|
73
71
|
|
|
72
|
+
{featured && (await ampless.publicPostScriptsForPage([featured]))}
|
|
73
|
+
|
|
74
74
|
{posts.length > 0 && (
|
|
75
75
|
<section className="mx-auto max-w-5xl px-6 py-16">
|
|
76
76
|
<h2 className="mb-8 text-3xl font-bold">Latest</h2>
|
|
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import Link from 'next/link'
|
|
3
3
|
import { notFound } from 'next/navigation'
|
|
4
4
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
5
|
-
import { renderBody } from '@ampless/runtime'
|
|
6
5
|
import { ampless } from '@/lib/ampless'
|
|
7
6
|
import { admin } from '@/lib/admin'
|
|
8
7
|
import { LightboxBinder } from '@/components/lightbox-content'
|
|
@@ -77,11 +76,14 @@ export default async function LandingPost({ params }: PostCtx) {
|
|
|
77
76
|
id="post-body"
|
|
78
77
|
className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
|
|
79
78
|
style={proseStyle}
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
>
|
|
80
|
+
{await ampless.renderBody(post)}
|
|
81
|
+
</div>
|
|
82
82
|
|
|
83
83
|
{html.afterContent}
|
|
84
84
|
|
|
85
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
86
|
+
|
|
85
87
|
<TagList tags={post.tags} className="mt-10 border-t pt-6" />
|
|
86
88
|
</article>
|
|
87
89
|
|
|
@@ -2,7 +2,6 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import Link from 'next/link'
|
|
3
3
|
import { notFound } from 'next/navigation'
|
|
4
4
|
import { formatDate, type ThemeRouteContext } from 'ampless'
|
|
5
|
-
import { renderBody } from '@ampless/runtime'
|
|
6
5
|
import { ampless } from '@/lib/ampless'
|
|
7
6
|
import { admin } from '@/lib/admin'
|
|
8
7
|
import { LightboxBinder } from '@/components/lightbox-content'
|
|
@@ -58,11 +57,14 @@ export default async function MinimalPost({ params }: PostCtx) {
|
|
|
58
57
|
id="post-body"
|
|
59
58
|
className="prose prose-neutral dark:prose-invert max-w-none [&_img]:max-w-[var(--ampless-img-max-width)] [&_img]:mx-auto"
|
|
60
59
|
style={proseStyle}
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
>
|
|
61
|
+
{await ampless.renderBody(post)}
|
|
62
|
+
</div>
|
|
63
63
|
|
|
64
64
|
{html.afterContent}
|
|
65
65
|
|
|
66
|
+
{await ampless.publicPostScriptsForPage([post])}
|
|
67
|
+
|
|
66
68
|
<TagList tags={post.tags} className="mt-8 border-t pt-6" />
|
|
67
69
|
</article>
|
|
68
70
|
|