create-ampless 1.0.0-alpha.129 → 1.0.0-alpha.133
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.js +103 -2
- package/dist/templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx +5 -13
- package/dist/templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx +1 -2
- package/dist/templates/_shared/app/(admin)/admin/posts/new/page.tsx +1 -2
- package/dist/templates/_shared/app/(admin)/admin/preview/route.tsx +66 -0
- package/dist/templates/_shared/docs/plugin-author-guide.ja.md +87 -16
- package/dist/templates/_shared/docs/plugin-author-guide.md +118 -18
- package/dist/templates/_shared/package.json +14 -14
- package/package.json +1 -1
- package/dist/templates/_shared/app/(admin)/admin/_actions/render-preview.tsx +0 -32
package/dist/index.js
CHANGED
|
@@ -1885,7 +1885,18 @@ var AMPLESS_MANAGED_APP_PATHS = [
|
|
|
1885
1885
|
"app/login",
|
|
1886
1886
|
"app/site"
|
|
1887
1887
|
];
|
|
1888
|
-
var AMPLESS_RETIRED_PATHS = [
|
|
1888
|
+
var AMPLESS_RETIRED_PATHS = [
|
|
1889
|
+
// Phase 7 preview pipeline migrated from a `'use server'` action to a
|
|
1890
|
+
// Route Handler at `app/(admin)/admin/preview/route.tsx` (default
|
|
1891
|
+
// endpoint: `/admin/preview`). The old action made Next.js 15+ refuse
|
|
1892
|
+
// to compile the edit-post page
|
|
1893
|
+
// because the import graph traced `react-dom/server` from Client
|
|
1894
|
+
// Components through Server Action modules. Sites scaffolded before
|
|
1895
|
+
// this change pick the new endpoint up on their next `update-ampless`
|
|
1896
|
+
// — the new route file is seeded as part of the normal template
|
|
1897
|
+
// sync, and the old action file is retired by this entry.
|
|
1898
|
+
"app/(admin)/admin/_actions/render-preview.tsx"
|
|
1899
|
+
];
|
|
1889
1900
|
var AMPLESS_PACKAGES = /* @__PURE__ */ new Set([
|
|
1890
1901
|
"ampless",
|
|
1891
1902
|
"@ampless/admin",
|
|
@@ -2145,6 +2156,80 @@ function detectIndent(jsonStr) {
|
|
|
2145
2156
|
if (!m) return 2;
|
|
2146
2157
|
return m[1].length;
|
|
2147
2158
|
}
|
|
2159
|
+
function djb2Hash(s) {
|
|
2160
|
+
let h = 5381;
|
|
2161
|
+
for (let i = 0; i < s.length; i++) {
|
|
2162
|
+
h = h * 33 ^ s.charCodeAt(i) | 0;
|
|
2163
|
+
}
|
|
2164
|
+
return (h >>> 0).toString(16);
|
|
2165
|
+
}
|
|
2166
|
+
async function regenerateEditorBootstrap(destDir, projectPkg) {
|
|
2167
|
+
const targetPath = join3(destDir, "app", "(admin)", "admin", "_editor-bootstrap.tsx");
|
|
2168
|
+
const installedPlugins = [];
|
|
2169
|
+
const warnings = [];
|
|
2170
|
+
const deps = projectPkg.dependencies ?? {};
|
|
2171
|
+
for (const pkgName of Object.keys(deps)) {
|
|
2172
|
+
const pkgJsonPath = join3(destDir, "node_modules", pkgName, "package.json");
|
|
2173
|
+
if (!existsSync5(pkgJsonPath)) continue;
|
|
2174
|
+
let mf;
|
|
2175
|
+
try {
|
|
2176
|
+
mf = JSON.parse(await readFile3(pkgJsonPath, "utf-8"));
|
|
2177
|
+
} catch (e) {
|
|
2178
|
+
warnings.push(`${pkgName}: failed to read package.json (${e instanceof Error ? e.message : String(e)}). Skipping editor wiring.`);
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
const ep = mf?.amplessPlugin?.editorExports;
|
|
2182
|
+
if (typeof ep !== "string") continue;
|
|
2183
|
+
if (!ep.startsWith("./") || ep.includes("..") || ep.startsWith("/") || /[?#]/.test(ep)) {
|
|
2184
|
+
warnings.push(`${pkgName}: amplessPlugin.editorExports="${ep}" is invalid (must be a ./-prefixed relative subpath without .. or query/fragment). Skipping editor wiring.`);
|
|
2185
|
+
continue;
|
|
2186
|
+
}
|
|
2187
|
+
if (!mf?.exports || typeof mf.exports[ep] === "undefined") {
|
|
2188
|
+
warnings.push(`${pkgName}: amplessPlugin.editorExports="${ep}" is not declared in package.json#exports. Skipping editor wiring.`);
|
|
2189
|
+
continue;
|
|
2190
|
+
}
|
|
2191
|
+
installedPlugins.push({ packageName: pkgName, editorExports: ep });
|
|
2192
|
+
}
|
|
2193
|
+
installedPlugins.sort((a, b) => a.packageName.localeCompare(b.packageName));
|
|
2194
|
+
const lines = [];
|
|
2195
|
+
lines.push("'use client'", "");
|
|
2196
|
+
lines.push("// AUTO-GENERATED by `npm run update-ampless`. Do not edit \u2014 your changes");
|
|
2197
|
+
lines.push("// will be overwritten on the next run. To enable / disable a plugin's");
|
|
2198
|
+
lines.push("// editor extension, add / remove the corresponding @ampless/plugin-...");
|
|
2199
|
+
lines.push("// entry in your project's package.json (and register it in cms.config.ts");
|
|
2200
|
+
lines.push("// for runtime).", "");
|
|
2201
|
+
lines.push("import { installAdminEditorExtensions } from '@ampless/admin/editor'");
|
|
2202
|
+
const identifiers = [];
|
|
2203
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2204
|
+
for (const p3 of installedPlugins) {
|
|
2205
|
+
let ident = "__" + p3.packageName.replace(/^@/, "").replace(/[^a-zA-Z0-9]+/g, "_") + "_editor";
|
|
2206
|
+
if (seen.has(ident) && seen.get(ident) !== p3.packageName) {
|
|
2207
|
+
const suffix = djb2Hash(p3.packageName).slice(0, 8);
|
|
2208
|
+
const orig = ident;
|
|
2209
|
+
ident = `${ident}_${suffix}`;
|
|
2210
|
+
warnings.push(`editor-bootstrap codegen: sanitised identifier "${orig}" collides between "${seen.get(orig)}" and "${p3.packageName}". Using "${ident}" for the latter to disambiguate.`);
|
|
2211
|
+
}
|
|
2212
|
+
seen.set(ident, p3.packageName);
|
|
2213
|
+
const importSubpath = p3.editorExports.startsWith("./") ? `${p3.packageName}/${p3.editorExports.slice(2)}` : `${p3.packageName}/${p3.editorExports}`;
|
|
2214
|
+
lines.push(`import { editorExtension as ${ident} } from '${importSubpath}'`);
|
|
2215
|
+
identifiers.push(ident);
|
|
2216
|
+
}
|
|
2217
|
+
lines.push("");
|
|
2218
|
+
lines.push("export function EditorBootstrap({ children }: { children: React.ReactNode }) {");
|
|
2219
|
+
if (identifiers.length === 0) {
|
|
2220
|
+
lines.push(" installAdminEditorExtensions([])");
|
|
2221
|
+
} else {
|
|
2222
|
+
lines.push(" installAdminEditorExtensions([");
|
|
2223
|
+
for (const id of identifiers) lines.push(` ${id},`);
|
|
2224
|
+
lines.push(" ])");
|
|
2225
|
+
}
|
|
2226
|
+
lines.push(" return <>{children}</>");
|
|
2227
|
+
lines.push("}");
|
|
2228
|
+
lines.push("");
|
|
2229
|
+
await mkdir2(dirname(targetPath), { recursive: true });
|
|
2230
|
+
await writeFile3(targetPath, lines.join("\n"), "utf-8");
|
|
2231
|
+
return { path: targetPath, pluginCount: installedPlugins.length, warnings };
|
|
2232
|
+
}
|
|
2148
2233
|
async function runUpgradeIn(destDir, sharedDir, opts = {}) {
|
|
2149
2234
|
const derivedRoot = opts.templatesRoot ?? join3(sharedDir, "..");
|
|
2150
2235
|
const themeSyncEnabled = existsSync5(join3(derivedRoot, "_shared"));
|
|
@@ -2263,6 +2348,21 @@ async function runUpgradeIn(destDir, sharedDir, opts = {}) {
|
|
|
2263
2348
|
const pm = usePnpm ? "pnpm" : "npm";
|
|
2264
2349
|
await execa4(pm, ["install"], { cwd: destDir, stdio: "inherit" });
|
|
2265
2350
|
}
|
|
2351
|
+
const codegenProjectPkg = JSON.parse(
|
|
2352
|
+
await readFile3(join3(destDir, "package.json"), "utf-8")
|
|
2353
|
+
);
|
|
2354
|
+
const bootstrapResult = await regenerateEditorBootstrap(destDir, codegenProjectPkg);
|
|
2355
|
+
if (opts.noInstall) {
|
|
2356
|
+
bootstrapResult.warnings.push(
|
|
2357
|
+
"_editor-bootstrap.tsx was generated without running npm install \u2014 node_modules may be stale. Run `npm run update-ampless` (without --no-install) to pick up newly added plugin editor extensions."
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
for (const w of bootstrapResult.warnings) {
|
|
2361
|
+
log3.warn(w);
|
|
2362
|
+
}
|
|
2363
|
+
log3.info(
|
|
2364
|
+
`editor: ${pc3.cyan(`auto-wired ${bootstrapResult.pluginCount} editor extension(s) in _editor-bootstrap.tsx`)}`
|
|
2365
|
+
);
|
|
2266
2366
|
return {
|
|
2267
2367
|
added: replaceNew,
|
|
2268
2368
|
updated: replaceUpdate,
|
|
@@ -2272,7 +2372,8 @@ async function runUpgradeIn(destDir, sharedDir, opts = {}) {
|
|
|
2272
2372
|
themesPreserved: themeResult.preserved,
|
|
2273
2373
|
themesQuarantined: themeResult.quarantined,
|
|
2274
2374
|
packageJsonMerged: true,
|
|
2275
|
-
obsoleteRemoved: obsoleteFiles
|
|
2375
|
+
obsoleteRemoved: obsoleteFiles,
|
|
2376
|
+
editorExtensionsWired: bootstrapResult.pluginCount
|
|
2276
2377
|
};
|
|
2277
2378
|
}
|
|
2278
2379
|
async function runUpgrade(args) {
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// Wired into the admin layout via:
|
|
9
|
-
// createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
3
|
+
// Placeholder for fresh scaffolds before `update-ampless` runs.
|
|
4
|
+
// `npm run update-ampless` rewrites this file with auto-wired plugin
|
|
5
|
+
// editor extensions based on the project's installed `@ampless/plugin-*`
|
|
6
|
+
// packages.
|
|
10
7
|
|
|
11
8
|
import { installAdminEditorExtensions } from '@ampless/admin/editor'
|
|
12
|
-
// import { youtubeEditor } from '@ampless/plugin-youtube/editor'
|
|
13
|
-
// import { tweetEditor } from '@ampless/plugin-x-embed/editor'
|
|
14
9
|
|
|
15
10
|
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
16
|
-
installAdminEditorExtensions([
|
|
17
|
-
// youtubeEditor.extension,
|
|
18
|
-
// tweetEditor.extension,
|
|
19
|
-
])
|
|
11
|
+
installAdminEditorExtensions([])
|
|
20
12
|
return <>{children}</>
|
|
21
13
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { admin } from '@/lib/admin'
|
|
2
2
|
import { createEditPostPage } from '@ampless/admin/pages'
|
|
3
|
-
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
4
3
|
|
|
5
|
-
export default createEditPostPage(admin
|
|
4
|
+
export default createEditPostPage(admin)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { admin } from '@/lib/admin'
|
|
2
2
|
import { createNewPostPage } from '@ampless/admin/pages'
|
|
3
|
-
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
4
3
|
|
|
5
|
-
export default createNewPostPage(admin
|
|
4
|
+
export default createNewPostPage(admin)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Post } from 'ampless'
|
|
2
|
+
import { admin } from '@/lib/admin'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preview-only Route Handler. Client-side `<PostForm>` / `<PostHistoryPanel>`
|
|
6
|
+
* POST a draft Post to this endpoint while the preview tab is open; we
|
|
7
|
+
* render the body + page-level scripts via `ampless.renderBody` /
|
|
8
|
+
* `publicPostScriptsForPage` and return a fully-rendered HTML string
|
|
9
|
+
* that the admin shows in an iframe (`sandbox="allow-scripts"`).
|
|
10
|
+
*
|
|
11
|
+
* Why a Route Handler instead of a Server Action: Next.js 15+
|
|
12
|
+
* refuses to compile Client Components that reach `react-dom/server`
|
|
13
|
+
* through a `'use server'` module, because the build traces the
|
|
14
|
+
* import graph from Client Components through Server Action modules.
|
|
15
|
+
* Putting this rendering behind a Route Handler decouples it from
|
|
16
|
+
* that graph entirely — the form fetches a plain HTTP endpoint and
|
|
17
|
+
* the bundler never walks from `<PostForm>` into here. The endpoint
|
|
18
|
+
* also gets an explicit `admin.isEditor()` gate so a future change
|
|
19
|
+
* to the `(admin)` route-group middleware can't silently turn
|
|
20
|
+
* preview into a content-leak vector for unpublished drafts.
|
|
21
|
+
*
|
|
22
|
+
* The `react-dom/server` import itself is loaded via dynamic
|
|
23
|
+
* `import()` inside the handler. Next.js 16's Turbopack flags any
|
|
24
|
+
* top-level static `import 'react-dom/server'` reached from the app
|
|
25
|
+
* router build (Route Handlers included), so we deliberately defer
|
|
26
|
+
* the resolution to request time — the module is still pulled from
|
|
27
|
+
* the same Node.js subpath that `next start` ships, just not visible
|
|
28
|
+
* to the build-time import-graph walker.
|
|
29
|
+
*
|
|
30
|
+
* Auth: locked to authenticated editors. Anonymous + reader access is
|
|
31
|
+
* 403. This matches the rest of `/admin/**`, which is gated by the
|
|
32
|
+
* `(admin)` route group + middleware, but we add an explicit check
|
|
33
|
+
* here as defence-in-depth against the middleware gate being
|
|
34
|
+
* misconfigured.
|
|
35
|
+
*/
|
|
36
|
+
export async function POST(req: Request): Promise<Response> {
|
|
37
|
+
const session = await admin.getServerSession()
|
|
38
|
+
if (!admin.isEditor(session)) {
|
|
39
|
+
return new Response('Forbidden', { status: 403 })
|
|
40
|
+
}
|
|
41
|
+
let draft: Post
|
|
42
|
+
try {
|
|
43
|
+
draft = (await req.json()) as Post
|
|
44
|
+
} catch {
|
|
45
|
+
return new Response('Bad Request', { status: 400 })
|
|
46
|
+
}
|
|
47
|
+
const ampless = await admin.getAmpless()
|
|
48
|
+
// IMPORTANT: include BOTH the body and the page-level scripts so
|
|
49
|
+
// widgets like x.com's `widgets.js` get a chance to hydrate in the
|
|
50
|
+
// iframe.
|
|
51
|
+
const node = (
|
|
52
|
+
<>
|
|
53
|
+
{await ampless.renderBody(draft)}
|
|
54
|
+
{await ampless.publicPostScriptsForPage([draft])}
|
|
55
|
+
</>
|
|
56
|
+
)
|
|
57
|
+
// Dynamic import: see top-of-file comment. The module is server-only
|
|
58
|
+
// and resolved at request time.
|
|
59
|
+
const { renderToStaticMarkup } = await import('react-dom/server')
|
|
60
|
+
return new Response(renderToStaticMarkup(node), {
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
63
|
+
'Cache-Control': 'no-store',
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -777,25 +777,67 @@ runtime は `src` のホスト allowlist を強制しない。CSP はサイト
|
|
|
777
777
|
|
|
778
778
|
## 6d. Admin エディタ拡張の配線 (Phase 7)
|
|
779
779
|
|
|
780
|
-
admin エディタに tiptap Node 拡張を提供するプラグインは、別途 `./editor` subpath の client-side
|
|
780
|
+
admin エディタに tiptap Node 拡張を提供するプラグインは、別途 `./editor` subpath の client-side エントリを出荷する。
|
|
781
|
+
`app/(admin)/admin/_editor-bootstrap.tsx` は `npm run update-ampless` によって**自動生成**される — 手動で編集してはならない。
|
|
782
|
+
|
|
783
|
+
### プラグイン著者向け
|
|
784
|
+
|
|
785
|
+
`package.json#amplessPlugin` に `editorExports` を宣言する:
|
|
786
|
+
|
|
787
|
+
```jsonc
|
|
788
|
+
// packages/plugin-youtube/package.json
|
|
789
|
+
"amplessPlugin": {
|
|
790
|
+
"apiVersion": 1,
|
|
791
|
+
"name": "youtube",
|
|
792
|
+
"trustLevel": "trusted",
|
|
793
|
+
"capabilities": ["contentFields"],
|
|
794
|
+
"editorExports": "./editor" // ← editor module のある subpath
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
その subpath から `editorExtension` を named export する:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
802
|
+
export const editorExtension = AmplessYoutubeNode // tiptap Node または Extension
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
`package.json#exports` にも同 subpath を宣言すること(codegen がこれを検証する — 未宣言の場合は warning を出してそのプラグインをスキップする):
|
|
806
|
+
|
|
807
|
+
```jsonc
|
|
808
|
+
"exports": {
|
|
809
|
+
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
|
|
810
|
+
"./editor": { "import": "./dist/editor.js", "types": "./dist/editor.d.ts" }
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
### プラグインユーザー(サイトエンジニア)向け
|
|
815
|
+
|
|
816
|
+
1. `npm i @ampless/plugin-youtube@alpha` — plugin を dependency として追加する。
|
|
817
|
+
2. `cms.config.ts` に登録してサーバーサイド renderer を有効化する。
|
|
818
|
+
3. `npm run update-ampless` — インストール済み plugin の manifest から `_editor-bootstrap.tsx` を自動再生成する。
|
|
819
|
+
|
|
820
|
+
生成されたファイルはリポジトリにコミットされ、以下のような内容になる:
|
|
781
821
|
|
|
782
822
|
```tsx
|
|
783
|
-
//
|
|
823
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — 編集不可)
|
|
784
824
|
'use client'
|
|
825
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit — your changes
|
|
826
|
+
// will be overwritten on the next run.
|
|
785
827
|
import { installAdminEditorExtensions } from '@ampless/admin/editor'
|
|
786
|
-
import {
|
|
787
|
-
import {
|
|
828
|
+
import { editorExtension as __ampless_plugin_x_embed_editor } from '@ampless/plugin-x-embed/editor'
|
|
829
|
+
import { editorExtension as __ampless_plugin_youtube_editor } from '@ampless/plugin-youtube/editor'
|
|
788
830
|
|
|
789
831
|
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
790
832
|
installAdminEditorExtensions([
|
|
791
|
-
|
|
792
|
-
|
|
833
|
+
__ampless_plugin_x_embed_editor,
|
|
834
|
+
__ampless_plugin_youtube_editor,
|
|
793
835
|
])
|
|
794
836
|
return <>{children}</>
|
|
795
837
|
}
|
|
796
838
|
```
|
|
797
839
|
|
|
798
|
-
|
|
840
|
+
このファイルは admin layout に渡される(テンプレートに既に組み込まれているので追加の作業は不要):
|
|
799
841
|
|
|
800
842
|
```tsx
|
|
801
843
|
// templates/_shared/app/(admin)/admin/layout.tsx
|
|
@@ -806,18 +848,33 @@ export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
|
806
848
|
|
|
807
849
|
`installAdminEditorExtensions` は idempotent で、render 時に client component 内で呼ばれる。admin の `<TiptapEditor>` は毎回 render 時に登録済みリストを built-in extensions の末尾に spread する。
|
|
808
850
|
|
|
851
|
+
### active source と完全無効化
|
|
852
|
+
|
|
853
|
+
**エディタ配線の active source は `package.json#dependencies`**(= `node_modules`)であり、`cms.config.ts` ではない。
|
|
854
|
+
|
|
855
|
+
- plugin を `cms.config.ts` から外しても `package.json` に dep が残っていれば、editor の paste rule は**有効のまま**になる。editor は tiptap document にそのノード型を挿入できるが、公開 renderer はレンダリングしない(不整合状態 — 避けること)。
|
|
856
|
+
- 完全に無効化するには: `cms.config.ts` から削除 **かつ** `npm uninstall @ampless/plugin-...` を実行し、`npm run update-ampless` で bootstrap ファイルを再生成する。
|
|
857
|
+
|
|
809
858
|
### エディタプレビューのパイプライン
|
|
810
859
|
|
|
811
|
-
admin の edit / new post フォームは preview ペインを `<iframe sandbox="allow-scripts">` で表示する。`srcDoc` はテンプレート側
|
|
860
|
+
admin の edit / new post フォームは preview ペインを `<iframe sandbox="allow-scripts">` で表示する。`srcDoc` はテンプレート側 preview Route Handler が返す HTML。テンプレート scaffold は handler を `app/(admin)/admin/preview/route.tsx` に同梱する:
|
|
812
861
|
|
|
813
862
|
```tsx
|
|
814
|
-
// templates/_shared/app/(admin)/admin/
|
|
815
|
-
'use server'
|
|
816
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
863
|
+
// templates/_shared/app/(admin)/admin/preview/route.tsx
|
|
817
864
|
import type { Post } from 'ampless'
|
|
818
865
|
import { admin } from '@/lib/admin'
|
|
819
866
|
|
|
820
|
-
export async function
|
|
867
|
+
export async function POST(req: Request): Promise<Response> {
|
|
868
|
+
const session = await admin.getServerSession()
|
|
869
|
+
if (!admin.isEditor(session)) {
|
|
870
|
+
return new Response('Forbidden', { status: 403 })
|
|
871
|
+
}
|
|
872
|
+
let draft: Post
|
|
873
|
+
try {
|
|
874
|
+
draft = (await req.json()) as Post
|
|
875
|
+
} catch {
|
|
876
|
+
return new Response('Bad Request', { status: 400 })
|
|
877
|
+
}
|
|
821
878
|
const ampless = await admin.getAmpless()
|
|
822
879
|
const node = (
|
|
823
880
|
<>
|
|
@@ -825,21 +882,35 @@ export async function renderPreviewHtml(draft: Post): Promise<string> {
|
|
|
825
882
|
{await ampless.publicPostScriptsForPage([draft])}
|
|
826
883
|
</>
|
|
827
884
|
)
|
|
828
|
-
|
|
885
|
+
// dynamic import: 下の「Server Action ではなく Route Handler を採用した理由」参照。
|
|
886
|
+
const { renderToStaticMarkup } = await import('react-dom/server')
|
|
887
|
+
return new Response(renderToStaticMarkup(node), {
|
|
888
|
+
headers: {
|
|
889
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
890
|
+
'Cache-Control': 'no-store',
|
|
891
|
+
},
|
|
892
|
+
})
|
|
829
893
|
}
|
|
830
894
|
```
|
|
831
895
|
|
|
832
|
-
|
|
896
|
+
`<PostForm>` / `<PostHistoryPanel>` は素のままで `/admin/preview` に draft を POST する — call site で追加の wiring は不要:
|
|
833
897
|
|
|
834
898
|
```tsx
|
|
835
899
|
// templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
|
|
836
900
|
import { admin } from '@/lib/admin'
|
|
837
901
|
import { createEditPostPage } from '@ampless/admin/pages'
|
|
838
|
-
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
839
902
|
|
|
840
|
-
export default createEditPostPage(admin
|
|
903
|
+
export default createEditPostPage(admin)
|
|
841
904
|
```
|
|
842
905
|
|
|
906
|
+
admin が非デフォルトパス(Next.js `basePath` やカスタム prefix)にマウントされている場合は、page factory の `previewEndpoint` option で endpoint を上書きできる:
|
|
907
|
+
|
|
908
|
+
```tsx
|
|
909
|
+
export default createEditPostPage(admin, { previewEndpoint: '/cms/admin/preview' })
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
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 からロードする。
|
|
913
|
+
|
|
843
914
|
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
915
|
|
|
845
916
|
---
|
|
@@ -1026,26 +1026,72 @@ middleware. This is documented in each plugin's README.
|
|
|
1026
1026
|
## 6d. Admin editor extension wiring (Phase 7)
|
|
1027
1027
|
|
|
1028
1028
|
Plugins that contribute tiptap Node extensions to the admin editor
|
|
1029
|
-
ship a separate client-side entry under the `./editor` subpath.
|
|
1030
|
-
|
|
1029
|
+
ship a separate client-side entry under the `./editor` subpath.
|
|
1030
|
+
`app/(admin)/admin/_editor-bootstrap.tsx` is **auto-generated** by
|
|
1031
|
+
`npm run update-ampless` — you should not edit it by hand.
|
|
1032
|
+
|
|
1033
|
+
### For plugin authors
|
|
1034
|
+
|
|
1035
|
+
Declare `editorExports` in `package.json#amplessPlugin`:
|
|
1036
|
+
|
|
1037
|
+
```jsonc
|
|
1038
|
+
// packages/plugin-youtube/package.json
|
|
1039
|
+
"amplessPlugin": {
|
|
1040
|
+
"apiVersion": 1,
|
|
1041
|
+
"name": "youtube",
|
|
1042
|
+
"trustLevel": "trusted",
|
|
1043
|
+
"capabilities": ["contentFields"],
|
|
1044
|
+
"editorExports": "./editor" // ← subpath where the editor module lives
|
|
1045
|
+
}
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
Export `editorExtension` as a named symbol from that subpath:
|
|
1049
|
+
|
|
1050
|
+
```ts
|
|
1051
|
+
// packages/plugin-youtube/src/editor.tsx
|
|
1052
|
+
export const editorExtension = AmplessYoutubeNode // tiptap Node or Extension
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
The subpath must be declared in `package.json#exports` as well (the
|
|
1056
|
+
codegen validates this — a missing `exports` entry triggers a warning
|
|
1057
|
+
and the plugin is skipped):
|
|
1058
|
+
|
|
1059
|
+
```jsonc
|
|
1060
|
+
"exports": {
|
|
1061
|
+
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
|
|
1062
|
+
"./editor": { "import": "./dist/editor.js", "types": "./dist/editor.d.ts" }
|
|
1063
|
+
}
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
### For plugin users (site engineers)
|
|
1067
|
+
|
|
1068
|
+
1. `npm i @ampless/plugin-youtube@alpha` — add the plugin as a dependency.
|
|
1069
|
+
2. Register it in `cms.config.ts` for the server-side renderer.
|
|
1070
|
+
3. `npm run update-ampless` — regenerates `_editor-bootstrap.tsx`
|
|
1071
|
+
automatically from the installed plugin manifests.
|
|
1072
|
+
|
|
1073
|
+
The generated file is committed to your repo and looks like:
|
|
1031
1074
|
|
|
1032
1075
|
```tsx
|
|
1033
|
-
//
|
|
1076
|
+
// app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — do not edit)
|
|
1034
1077
|
'use client'
|
|
1078
|
+
// AUTO-GENERATED by `npm run update-ampless`. Do not edit — your changes
|
|
1079
|
+
// will be overwritten on the next run.
|
|
1035
1080
|
import { installAdminEditorExtensions } from '@ampless/admin/editor'
|
|
1036
|
-
import {
|
|
1037
|
-
import {
|
|
1081
|
+
import { editorExtension as __ampless_plugin_x_embed_editor } from '@ampless/plugin-x-embed/editor'
|
|
1082
|
+
import { editorExtension as __ampless_plugin_youtube_editor } from '@ampless/plugin-youtube/editor'
|
|
1038
1083
|
|
|
1039
1084
|
export function EditorBootstrap({ children }: { children: React.ReactNode }) {
|
|
1040
1085
|
installAdminEditorExtensions([
|
|
1041
|
-
|
|
1042
|
-
|
|
1086
|
+
__ampless_plugin_x_embed_editor,
|
|
1087
|
+
__ampless_plugin_youtube_editor,
|
|
1043
1088
|
])
|
|
1044
1089
|
return <>{children}</>
|
|
1045
1090
|
}
|
|
1046
1091
|
```
|
|
1047
1092
|
|
|
1048
|
-
|
|
1093
|
+
The file is then wired into the admin layout (already in the template — no
|
|
1094
|
+
extra work required):
|
|
1049
1095
|
|
|
1050
1096
|
```tsx
|
|
1051
1097
|
// templates/_shared/app/(admin)/admin/layout.tsx
|
|
@@ -1058,20 +1104,42 @@ export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
|
|
|
1058
1104
|
inside a client component. The admin's `<TiptapEditor>` spreads the
|
|
1059
1105
|
registered list onto its built-in extensions on every render.
|
|
1060
1106
|
|
|
1107
|
+
### Active source and full disable
|
|
1108
|
+
|
|
1109
|
+
**The active source for editor wiring is `package.json#dependencies`**
|
|
1110
|
+
(= `node_modules`), not `cms.config.ts`. This means:
|
|
1111
|
+
|
|
1112
|
+
- Removing a plugin from `cms.config.ts` but keeping it in
|
|
1113
|
+
`package.json` leaves the editor paste rule active. The editor can
|
|
1114
|
+
still insert the node type into the tiptap document, but the public
|
|
1115
|
+
renderer won't render it (inconsistent state — avoid).
|
|
1116
|
+
- To fully disable a plugin's editor extension: remove it from
|
|
1117
|
+
`cms.config.ts` **and** run `npm uninstall @ampless/plugin-...`,
|
|
1118
|
+
then `npm run update-ampless` to regenerate the bootstrap file.
|
|
1119
|
+
|
|
1061
1120
|
### Editor preview pipeline
|
|
1062
1121
|
|
|
1063
1122
|
The admin's edit / new post forms render the preview pane in an
|
|
1064
1123
|
`<iframe sandbox="allow-scripts">` whose `srcDoc` is the HTML returned
|
|
1065
|
-
by the template's
|
|
1124
|
+
by the template's preview Route Handler. The template scaffold ships
|
|
1125
|
+
the handler at `app/(admin)/admin/preview/route.tsx`:
|
|
1066
1126
|
|
|
1067
1127
|
```tsx
|
|
1068
|
-
// templates/_shared/app/(admin)/admin/
|
|
1069
|
-
'use server'
|
|
1070
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
1128
|
+
// templates/_shared/app/(admin)/admin/preview/route.tsx
|
|
1071
1129
|
import type { Post } from 'ampless'
|
|
1072
1130
|
import { admin } from '@/lib/admin'
|
|
1073
1131
|
|
|
1074
|
-
export async function
|
|
1132
|
+
export async function POST(req: Request): Promise<Response> {
|
|
1133
|
+
const session = await admin.getServerSession()
|
|
1134
|
+
if (!admin.isEditor(session)) {
|
|
1135
|
+
return new Response('Forbidden', { status: 403 })
|
|
1136
|
+
}
|
|
1137
|
+
let draft: Post
|
|
1138
|
+
try {
|
|
1139
|
+
draft = (await req.json()) as Post
|
|
1140
|
+
} catch {
|
|
1141
|
+
return new Response('Bad Request', { status: 400 })
|
|
1142
|
+
}
|
|
1075
1143
|
const ampless = await admin.getAmpless()
|
|
1076
1144
|
const node = (
|
|
1077
1145
|
<>
|
|
@@ -1079,22 +1147,54 @@ export async function renderPreviewHtml(draft: Post): Promise<string> {
|
|
|
1079
1147
|
{await ampless.publicPostScriptsForPage([draft])}
|
|
1080
1148
|
</>
|
|
1081
1149
|
)
|
|
1082
|
-
|
|
1150
|
+
// Dynamic import: see "Why a Route Handler" below.
|
|
1151
|
+
const { renderToStaticMarkup } = await import('react-dom/server')
|
|
1152
|
+
return new Response(renderToStaticMarkup(node), {
|
|
1153
|
+
headers: {
|
|
1154
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1155
|
+
'Cache-Control': 'no-store',
|
|
1156
|
+
},
|
|
1157
|
+
})
|
|
1083
1158
|
}
|
|
1084
1159
|
```
|
|
1085
1160
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1161
|
+
`<PostForm>` / `<PostHistoryPanel>` POST the draft to `/admin/preview`
|
|
1162
|
+
out of the box — no extra wiring at the call site:
|
|
1088
1163
|
|
|
1089
1164
|
```tsx
|
|
1090
1165
|
// templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
|
|
1091
1166
|
import { admin } from '@/lib/admin'
|
|
1092
1167
|
import { createEditPostPage } from '@ampless/admin/pages'
|
|
1093
|
-
import { renderPreviewHtml } from '../../_actions/render-preview'
|
|
1094
1168
|
|
|
1095
|
-
export default createEditPostPage(admin
|
|
1169
|
+
export default createEditPostPage(admin)
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
If the admin is mounted at a non-default path (Next.js `basePath` or
|
|
1173
|
+
a custom prefix), override the endpoint via the page factory's
|
|
1174
|
+
`previewEndpoint` option:
|
|
1175
|
+
|
|
1176
|
+
```tsx
|
|
1177
|
+
export default createEditPostPage(admin, { previewEndpoint: '/cms/admin/preview' })
|
|
1096
1178
|
```
|
|
1097
1179
|
|
|
1180
|
+
Why a Route Handler rather than a Server Action: putting
|
|
1181
|
+
`react-dom/server`-driven rendering inside a `'use server'` module
|
|
1182
|
+
makes Next.js 15+ refuse to compile the edit-post page, because the
|
|
1183
|
+
build traces the import graph from Client Components through Server
|
|
1184
|
+
Action modules and any reach to `react-dom/server` along that path
|
|
1185
|
+
trips the "You're importing a component that imports
|
|
1186
|
+
react-dom/server" check. A Route Handler decouples the rendering
|
|
1187
|
+
from that graph entirely — `<PostForm>` fetches a plain HTTP
|
|
1188
|
+
endpoint and the bundler never walks from the form into here. The
|
|
1189
|
+
handler's explicit `admin.isEditor()` check also defends against
|
|
1190
|
+
accidental exposure if the `(admin)` route-group gate is ever
|
|
1191
|
+
misconfigured. The `react-dom/server` import itself is dynamic
|
|
1192
|
+
because Next.js 16's Turbopack flags any top-level static import of
|
|
1193
|
+
`react-dom/server` reached from the app router build, Route Handlers
|
|
1194
|
+
included; deferring it to request time keeps the module outside the
|
|
1195
|
+
build-time import-graph walker while still loading it from the same
|
|
1196
|
+
Node.js subpath at runtime.
|
|
1197
|
+
|
|
1098
1198
|
The iframe's `sandbox="allow-scripts"` (without `allow-same-origin`)
|
|
1099
1199
|
keeps preview content in an opaque origin so widget scripts cannot
|
|
1100
1200
|
reach the admin's DOM. The trade-off is that some widgets may misbehave
|
|
@@ -26,21 +26,21 @@
|
|
|
26
26
|
"@tiptap/pm": "^3.23.6",
|
|
27
27
|
"@tiptap/react": "^3.23.6",
|
|
28
28
|
"@tiptap/starter-kit": "^3.23.6",
|
|
29
|
-
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.
|
|
30
|
-
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.
|
|
31
|
-
"@ampless/plugin-gtm": "^0.2.0-alpha.
|
|
32
|
-
"@ampless/plugin-og-image": "^0.2.0-alpha.
|
|
33
|
-
"@ampless/plugin-plausible": "^0.2.0-alpha.
|
|
34
|
-
"@ampless/plugin-reading-time": "^0.1.0-alpha.
|
|
35
|
-
"@ampless/plugin-rss": "^0.2.0-alpha.
|
|
36
|
-
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.
|
|
37
|
-
"@ampless/plugin-seo": "^0.2.0-alpha.
|
|
38
|
-
"@ampless/plugin-webhook": "^0.2.0-alpha.
|
|
39
|
-
"@ampless/admin": "^1.0.0-alpha.
|
|
40
|
-
"@ampless/backend": "^1.0.0-alpha.
|
|
41
|
-
"@ampless/runtime": "^1.0.0-alpha.
|
|
29
|
+
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.28",
|
|
30
|
+
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.19",
|
|
31
|
+
"@ampless/plugin-gtm": "^0.2.0-alpha.27",
|
|
32
|
+
"@ampless/plugin-og-image": "^0.2.0-alpha.44",
|
|
33
|
+
"@ampless/plugin-plausible": "^0.2.0-alpha.27",
|
|
34
|
+
"@ampless/plugin-reading-time": "^0.1.0-alpha.17",
|
|
35
|
+
"@ampless/plugin-rss": "^0.2.0-alpha.44",
|
|
36
|
+
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.23",
|
|
37
|
+
"@ampless/plugin-seo": "^0.2.0-alpha.44",
|
|
38
|
+
"@ampless/plugin-webhook": "^0.2.0-alpha.45",
|
|
39
|
+
"@ampless/admin": "^1.0.0-alpha.77",
|
|
40
|
+
"@ampless/backend": "^1.0.0-alpha.66",
|
|
41
|
+
"@ampless/runtime": "^1.0.0-alpha.53",
|
|
42
42
|
"@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
|
|
43
|
-
"ampless": "^1.0.0-alpha.
|
|
43
|
+
"ampless": "^1.0.0-alpha.44",
|
|
44
44
|
"aws-amplify": "^6.17.0",
|
|
45
45
|
"class-variance-authority": "^0.7.1",
|
|
46
46
|
"clsx": "^2.1.1",
|
package/package.json
CHANGED
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
}
|