@avocadostudio-ai/site-sdk 0.1.0
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/LICENSE +201 -0
- package/README.md +145 -0
- package/dist/cli/register.d.ts +33 -0
- package/dist/cli/register.js +315 -0
- package/dist/create-site-page.d.ts +95 -0
- package/dist/create-site-page.js +127 -0
- package/dist/draft-common.d.ts +4 -0
- package/dist/draft-common.js +17 -0
- package/dist/draft-context-core.d.ts +11 -0
- package/dist/draft-context-core.js +26 -0
- package/dist/draft-context.d.ts +8 -0
- package/dist/draft-context.js +14 -0
- package/dist/draft-fetch.d.ts +14 -0
- package/dist/draft-fetch.js +115 -0
- package/dist/draft-routes-core.d.ts +11 -0
- package/dist/draft-routes-core.js +41 -0
- package/dist/draft-routes.d.ts +2 -0
- package/dist/draft-routes.js +27 -0
- package/dist/draft.d.ts +3 -0
- package/dist/draft.js +6 -0
- package/dist/editor-api-handler.d.ts +59 -0
- package/dist/editor-api-handler.js +89 -0
- package/dist/editor-cors.d.ts +3 -0
- package/dist/editor-cors.js +31 -0
- package/dist/editor-manifest.d.ts +3 -0
- package/dist/editor-manifest.js +65 -0
- package/dist/editor-overlay-inner.d.ts +5 -0
- package/dist/editor-overlay-inner.js +7 -0
- package/dist/editor-overlay.d.ts +4 -0
- package/dist/editor-overlay.js +14 -0
- package/dist/editor-query.d.ts +2 -0
- package/dist/editor-query.js +16 -0
- package/dist/editor-routes.d.ts +35 -0
- package/dist/editor-routes.js +66 -0
- package/dist/editor.d.ts +20 -0
- package/dist/editor.js +22 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/integration-check.d.ts +5 -0
- package/dist/integration-check.js +29 -0
- package/dist/live-preview-blocks.d.ts +5 -0
- package/dist/live-preview-blocks.js +30 -0
- package/dist/manifest-utils.d.ts +17 -0
- package/dist/manifest-utils.js +44 -0
- package/dist/middleware.d.ts +37 -0
- package/dist/middleware.js +34 -0
- package/dist/navigation.d.ts +48 -0
- package/dist/navigation.js +95 -0
- package/dist/publish-handlers/json-file.d.ts +24 -0
- package/dist/publish-handlers/json-file.js +40 -0
- package/dist/publish-utils.d.ts +28 -0
- package/dist/publish-utils.js +92 -0
- package/dist/render-blocks.d.ts +4 -0
- package/dist/render-blocks.js +26 -0
- package/dist/revalidate-handler.d.ts +42 -0
- package/dist/revalidate-handler.js +77 -0
- package/dist/routes.d.ts +10 -0
- package/dist/routes.js +12 -0
- package/dist/server/orchestrator.d.ts +117 -0
- package/dist/server/orchestrator.js +733 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/package.json +104 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { pageDocSchemaLenient, siteConfigSchema } from "@avocadostudio-ai/shared";
|
|
2
|
+
export function getOrchestratorUrl() {
|
|
3
|
+
const value = process.env.ORCHESTRATOR_URL?.trim();
|
|
4
|
+
if (value)
|
|
5
|
+
return value.replace(/\/$/, "");
|
|
6
|
+
if (process.env.NODE_ENV !== "production")
|
|
7
|
+
return "http://127.0.0.1:4200";
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
function buildCandidateBaseUrls(configuredBaseUrl) {
|
|
11
|
+
const candidates = [configuredBaseUrl];
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(configuredBaseUrl);
|
|
14
|
+
if (parsed.hostname === "localhost") {
|
|
15
|
+
parsed.hostname = "127.0.0.1";
|
|
16
|
+
candidates.push(parsed.toString().replace(/\/$/, ""));
|
|
17
|
+
}
|
|
18
|
+
else if (parsed.hostname === "127.0.0.1") {
|
|
19
|
+
parsed.hostname = "localhost";
|
|
20
|
+
candidates.push(parsed.toString().replace(/\/$/, ""));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Keep the configured URL as-is.
|
|
25
|
+
}
|
|
26
|
+
return candidates;
|
|
27
|
+
}
|
|
28
|
+
async function fetchWithTimeout(url, timeoutMs) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(url, { cache: "no-store", signal: controller.signal });
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timeoutId);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function fetchEditorPage(slug, session, siteId, options) {
|
|
40
|
+
const configuredBaseUrl = options?.orchestratorUrl ?? getOrchestratorUrl();
|
|
41
|
+
if (!configuredBaseUrl)
|
|
42
|
+
return null;
|
|
43
|
+
const timeout = options?.timeoutMs ?? 5000;
|
|
44
|
+
const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
|
|
45
|
+
for (const baseUrl of baseUrls) {
|
|
46
|
+
try {
|
|
47
|
+
const url = `${baseUrl}/draft/pages?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}&slug=${encodeURIComponent(slug)}`;
|
|
48
|
+
const res = await fetchWithTimeout(url, timeout);
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
continue;
|
|
51
|
+
const payload = (await res.json());
|
|
52
|
+
const parsed = pageDocSchemaLenient.safeParse(payload);
|
|
53
|
+
if (parsed.success)
|
|
54
|
+
return parsed.data;
|
|
55
|
+
// Schema parse failed — surface the issues so this doesn't show up as a
|
|
56
|
+
// mysterious "Draft unavailable" in the UI. A common cause is the
|
|
57
|
+
// orchestrator returning a page doc that's missing required fields
|
|
58
|
+
// (e.g. id/updatedAt on pages created via the agent create_page tool).
|
|
59
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
60
|
+
console.warn(`[site-sdk/draft] fetchEditorPage: schema parse failed for slug=${slug} session=${session} siteId=${siteId} — ${issues}`);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Try the next candidate.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
export async function fetchEditorSlugs(session, siteId, options) {
|
|
69
|
+
const configuredBaseUrl = options?.orchestratorUrl ?? getOrchestratorUrl();
|
|
70
|
+
if (!configuredBaseUrl)
|
|
71
|
+
return [];
|
|
72
|
+
const timeout = options?.timeoutMs ?? 5000;
|
|
73
|
+
const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
|
|
74
|
+
for (const baseUrl of baseUrls) {
|
|
75
|
+
try {
|
|
76
|
+
const url = `${baseUrl}/draft/slugs?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}`;
|
|
77
|
+
const res = await fetchWithTimeout(url, timeout);
|
|
78
|
+
if (!res.ok)
|
|
79
|
+
continue;
|
|
80
|
+
const payload = (await res.json());
|
|
81
|
+
if (!Array.isArray(payload.slugs))
|
|
82
|
+
continue;
|
|
83
|
+
const slugs = payload.slugs.filter((item) => typeof item === "string" && item.length > 0);
|
|
84
|
+
if (slugs.length > 0)
|
|
85
|
+
return slugs;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Try the next candidate.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
export async function fetchEditorSiteConfig(session, siteId, options) {
|
|
94
|
+
const configuredBaseUrl = options?.orchestratorUrl ?? getOrchestratorUrl();
|
|
95
|
+
if (!configuredBaseUrl)
|
|
96
|
+
return {};
|
|
97
|
+
const timeout = options?.timeoutMs ?? 5000;
|
|
98
|
+
const baseUrls = buildCandidateBaseUrls(configuredBaseUrl);
|
|
99
|
+
for (const baseUrl of baseUrls) {
|
|
100
|
+
try {
|
|
101
|
+
const url = `${baseUrl}/draft/site-config?session=${encodeURIComponent(session)}&siteId=${encodeURIComponent(siteId)}`;
|
|
102
|
+
const res = await fetchWithTimeout(url, timeout);
|
|
103
|
+
if (!res.ok)
|
|
104
|
+
continue;
|
|
105
|
+
const payload = (await res.json());
|
|
106
|
+
const parsed = siteConfigSchema.safeParse(payload);
|
|
107
|
+
if (parsed.success)
|
|
108
|
+
return parsed.data;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Try the next candidate.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type DraftRouteAdapter = {
|
|
2
|
+
enableDraftMode: () => Promise<void>;
|
|
3
|
+
disableDraftMode: () => Promise<void>;
|
|
4
|
+
createRedirect: (url: URL, cookies?: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
value: string;
|
|
7
|
+
delete?: boolean;
|
|
8
|
+
}>) => Response;
|
|
9
|
+
};
|
|
10
|
+
export declare function createDraftEnableHandlerCore(adapter: DraftRouteAdapter): (request: Request) => Promise<Response>;
|
|
11
|
+
export declare function createDraftDisableHandlerCore(adapter: DraftRouteAdapter): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { validateDraftSecret, getSafeInternalRedirectPath } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE, normalizeOrigin } from "./draft-common.js";
|
|
3
|
+
export function createDraftEnableHandlerCore(adapter) {
|
|
4
|
+
return async (request) => {
|
|
5
|
+
const url = new URL(request.url);
|
|
6
|
+
const validation = validateDraftSecret(url.searchParams.get("secret"), process.env);
|
|
7
|
+
if (!validation.ok && validation.reason === "missing_config") {
|
|
8
|
+
return new Response(JSON.stringify({ ok: false, error: "Draft mode secret is not configured. Set DRAFT_MODE_SECRET or NEXT_DRAFT_MODE_SECRET." }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
9
|
+
}
|
|
10
|
+
if (!validation.ok && validation.reason === "invalid_secret") {
|
|
11
|
+
return new Response(JSON.stringify({ ok: false, error: "Invalid draft mode secret." }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
12
|
+
}
|
|
13
|
+
const redirectPath = getSafeInternalRedirectPath(url.searchParams.get("redirect") ?? url.searchParams.get("slug"));
|
|
14
|
+
await adapter.enableDraftMode();
|
|
15
|
+
const redirectUrl = new URL(redirectPath, url);
|
|
16
|
+
const cookies = [];
|
|
17
|
+
const session = redirectUrl.searchParams.get("session")?.trim();
|
|
18
|
+
const siteId = redirectUrl.searchParams.get("siteId")?.trim();
|
|
19
|
+
const editorOrigin = normalizeOrigin(redirectUrl.searchParams.get("editorOrigin"));
|
|
20
|
+
if (session)
|
|
21
|
+
cookies.push({ name: DRAFT_SESSION_COOKIE, value: session });
|
|
22
|
+
if (siteId)
|
|
23
|
+
cookies.push({ name: DRAFT_SITE_COOKIE, value: siteId });
|
|
24
|
+
if (editorOrigin)
|
|
25
|
+
cookies.push({ name: EDITOR_ORIGIN_COOKIE, value: encodeURIComponent(editorOrigin) });
|
|
26
|
+
return adapter.createRedirect(redirectUrl, cookies);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function createDraftDisableHandlerCore(adapter) {
|
|
30
|
+
return async (request) => {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
await adapter.disableDraftMode();
|
|
33
|
+
const redirectPath = getSafeInternalRedirectPath(url.searchParams.get("redirect") ?? url.searchParams.get("slug"));
|
|
34
|
+
const redirectUrl = new URL(redirectPath, url);
|
|
35
|
+
return adapter.createRedirect(redirectUrl, [
|
|
36
|
+
{ name: DRAFT_SESSION_COOKIE, value: "", delete: true },
|
|
37
|
+
{ name: DRAFT_SITE_COOKIE, value: "", delete: true },
|
|
38
|
+
{ name: EDITOR_ORIGIN_COOKIE, value: "", delete: true }
|
|
39
|
+
]);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { draftMode } from "next/headers";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { createDraftEnableHandlerCore, createDraftDisableHandlerCore } from "./draft-routes-core.js";
|
|
4
|
+
function createNextAdapter() {
|
|
5
|
+
return {
|
|
6
|
+
enableDraftMode: async () => { (await draftMode()).enable(); },
|
|
7
|
+
disableDraftMode: async () => { (await draftMode()).disable(); },
|
|
8
|
+
createRedirect: (url, cookies) => {
|
|
9
|
+
const response = NextResponse.redirect(url);
|
|
10
|
+
for (const c of cookies ?? []) {
|
|
11
|
+
if (c.delete) {
|
|
12
|
+
response.cookies.delete(c.name);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
response.cookies.set(c.name, c.value, { path: "/", sameSite: "lax" });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return response;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function createDraftEnableHandler() {
|
|
23
|
+
return createDraftEnableHandlerCore(createNextAdapter());
|
|
24
|
+
}
|
|
25
|
+
export function createDraftDisableHandler() {
|
|
26
|
+
return createDraftDisableHandlerCore(createNextAdapter());
|
|
27
|
+
}
|
package/dist/draft.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { resolveEditorContext, single } from "./draft-context.ts";
|
|
2
|
+
export { getOrchestratorUrl, fetchEditorPage, fetchEditorSlugs, fetchEditorSiteConfig } from "./draft-fetch.ts";
|
|
3
|
+
export { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE } from "./draft-common.ts";
|
package/dist/draft.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Editor context resolution
|
|
2
|
+
export { resolveEditorContext, single } from "./draft-context.js";
|
|
3
|
+
// Editor content fetching
|
|
4
|
+
export { getOrchestratorUrl, fetchEditorPage, fetchEditorSlugs, fetchEditorSiteConfig } from "./draft-fetch.js";
|
|
5
|
+
// Draft cookie constants
|
|
6
|
+
export { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE } from "./draft-common.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { OnPublishFn } from "./editor-routes.ts";
|
|
2
|
+
import { type BlockManifest } from "./editor-manifest.ts";
|
|
3
|
+
import type { PageDoc } from "./types.ts";
|
|
4
|
+
import type { SiteConfig } from "@avocadostudio-ai/shared";
|
|
5
|
+
export interface EditorApiHandlerConfig {
|
|
6
|
+
getPages: () => PageDoc[] | Promise<PageDoc[]>;
|
|
7
|
+
/**
|
|
8
|
+
* Optional site-config getter. When provided, `/api/editor/pages` returns
|
|
9
|
+
* `{ pages, siteConfig }` so the orchestrator's publish-diff can show
|
|
10
|
+
* header-chrome changes (name/logo/navLabels/navGroups) alongside page diffs.
|
|
11
|
+
*/
|
|
12
|
+
getSiteConfig?: () => SiteConfig | undefined | Promise<SiteConfig | undefined>;
|
|
13
|
+
getManifest?: () => BlockManifest;
|
|
14
|
+
/**
|
|
15
|
+
* Register the site's block schemas before each manifest build. Use this
|
|
16
|
+
* instead of relying on side-effect import order — Next.js's dev bundler
|
|
17
|
+
* does not reliably honor source-order side effects across RSC / SSR / API
|
|
18
|
+
* route layers, so a canonical re-registration from `@avocadostudio-ai/shared`
|
|
19
|
+
* can silently clobber the site's overrides. Called inside `getManifest`,
|
|
20
|
+
* so registration is guaranteed to be last at request time. `registerBlock`
|
|
21
|
+
* is idempotent, so repeated calls are cheap.
|
|
22
|
+
*
|
|
23
|
+
* Composes with `getManifest`: when both are set, `registerBlocks` runs
|
|
24
|
+
* first, then `getManifest` is invoked.
|
|
25
|
+
*/
|
|
26
|
+
registerBlocks?: () => void;
|
|
27
|
+
onPublish?: OnPublishFn;
|
|
28
|
+
/** Secret token required for publish requests. Checked against x-publish-token header. */
|
|
29
|
+
publishSecret?: string;
|
|
30
|
+
}
|
|
31
|
+
type NextRouteContext = {
|
|
32
|
+
params: Promise<{
|
|
33
|
+
path: string[];
|
|
34
|
+
}>;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Creates a single catch-all route handler that serves all editor API endpoints.
|
|
38
|
+
*
|
|
39
|
+
* Mount at `app/api/editor/[...path]/route.ts`:
|
|
40
|
+
* ```ts
|
|
41
|
+
* export const { GET, POST, OPTIONS } = createEditorApiHandler({
|
|
42
|
+
* getPages: () => [...],
|
|
43
|
+
* onPublish: async (pages, config) => { ... return { ok: true } }
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* Routes:
|
|
48
|
+
* - `/api/editor/draft` → enable draft mode
|
|
49
|
+
* - `/api/editor/draft/disable` → disable draft mode
|
|
50
|
+
* - `/api/editor/blocks` → block manifest
|
|
51
|
+
* - `/api/editor/pages` → published pages
|
|
52
|
+
* - `/api/editor/publish` → publish content (POST)
|
|
53
|
+
*/
|
|
54
|
+
export declare function createEditorApiHandler(config: EditorApiHandlerConfig): {
|
|
55
|
+
GET: (request: Request, context: NextRouteContext) => Promise<Response>;
|
|
56
|
+
POST: (request: Request, context: NextRouteContext) => Promise<Response>;
|
|
57
|
+
OPTIONS: (request: Request, context: NextRouteContext) => Response;
|
|
58
|
+
};
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createDraftEnableHandler, createDraftDisableHandler } from "./draft-routes.js";
|
|
2
|
+
import { createBlocksHandler, createPagesHandler, createPublishHandler } from "./editor-routes.js";
|
|
3
|
+
import { applyEditorCors } from "./editor-cors.js";
|
|
4
|
+
import { checkIntegrationOnce } from "./integration-check.js";
|
|
5
|
+
import { buildBlockManifest } from "./editor-manifest.js";
|
|
6
|
+
/**
|
|
7
|
+
* Creates a single catch-all route handler that serves all editor API endpoints.
|
|
8
|
+
*
|
|
9
|
+
* Mount at `app/api/editor/[...path]/route.ts`:
|
|
10
|
+
* ```ts
|
|
11
|
+
* export const { GET, POST, OPTIONS } = createEditorApiHandler({
|
|
12
|
+
* getPages: () => [...],
|
|
13
|
+
* onPublish: async (pages, config) => { ... return { ok: true } }
|
|
14
|
+
* })
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Routes:
|
|
18
|
+
* - `/api/editor/draft` → enable draft mode
|
|
19
|
+
* - `/api/editor/draft/disable` → disable draft mode
|
|
20
|
+
* - `/api/editor/blocks` → block manifest
|
|
21
|
+
* - `/api/editor/pages` → published pages
|
|
22
|
+
* - `/api/editor/publish` → publish content (POST)
|
|
23
|
+
*/
|
|
24
|
+
export function createEditorApiHandler(config) {
|
|
25
|
+
const draftEnable = createDraftEnableHandler();
|
|
26
|
+
const draftDisable = createDraftDisableHandler();
|
|
27
|
+
const manifestBuilder = config.registerBlocks
|
|
28
|
+
? () => {
|
|
29
|
+
config.registerBlocks();
|
|
30
|
+
return (config.getManifest ?? buildBlockManifest)();
|
|
31
|
+
}
|
|
32
|
+
: config.getManifest;
|
|
33
|
+
const blocksHandler = createBlocksHandler(manifestBuilder ? { getManifest: manifestBuilder } : undefined);
|
|
34
|
+
const pagesHandler = createPagesHandler(config.getPages, config.getSiteConfig);
|
|
35
|
+
const publishHandler = config.onPublish
|
|
36
|
+
? createPublishHandler(config.onPublish, { publishSecret: config.publishSecret })
|
|
37
|
+
: null;
|
|
38
|
+
function matchRoute(path) {
|
|
39
|
+
const key = path.join("/");
|
|
40
|
+
if (key === "draft")
|
|
41
|
+
return "draft-enable";
|
|
42
|
+
if (key === "draft/disable")
|
|
43
|
+
return "draft-disable";
|
|
44
|
+
if (key === "blocks")
|
|
45
|
+
return "blocks";
|
|
46
|
+
if (key === "pages")
|
|
47
|
+
return "pages";
|
|
48
|
+
if (key === "publish")
|
|
49
|
+
return "publish";
|
|
50
|
+
return "not-found";
|
|
51
|
+
}
|
|
52
|
+
function notFound(request) {
|
|
53
|
+
return applyEditorCors(new Response(JSON.stringify({ error: "Not found" }), {
|
|
54
|
+
status: 404,
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
}), request.headers.get("origin"));
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
async GET(request, context) {
|
|
60
|
+
checkIntegrationOnce();
|
|
61
|
+
const { path } = await context.params;
|
|
62
|
+
const route = matchRoute(path);
|
|
63
|
+
switch (route) {
|
|
64
|
+
case "draft-enable":
|
|
65
|
+
return draftEnable(request);
|
|
66
|
+
case "draft-disable":
|
|
67
|
+
return draftDisable(request);
|
|
68
|
+
case "blocks":
|
|
69
|
+
return blocksHandler.GET(request);
|
|
70
|
+
case "pages":
|
|
71
|
+
return pagesHandler.GET(request);
|
|
72
|
+
default:
|
|
73
|
+
return notFound(request);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async POST(request, context) {
|
|
77
|
+
const { path } = await context.params;
|
|
78
|
+
const route = matchRoute(path);
|
|
79
|
+
if (route === "publish" && publishHandler) {
|
|
80
|
+
return publishHandler.POST(request);
|
|
81
|
+
}
|
|
82
|
+
return notFound(request);
|
|
83
|
+
},
|
|
84
|
+
OPTIONS(request, context) {
|
|
85
|
+
// OPTIONS doesn't need async params resolution for CORS preflight
|
|
86
|
+
return blocksHandler.OPTIONS(request);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
let cachedOrigins;
|
|
2
|
+
export function getEditorCorsOrigins() {
|
|
3
|
+
if (cachedOrigins)
|
|
4
|
+
return cachedOrigins;
|
|
5
|
+
const defaults = ["http://localhost:4100"];
|
|
6
|
+
const extra = (process.env.EDITOR_CORS_ORIGINS ?? "")
|
|
7
|
+
.split(",")
|
|
8
|
+
.map((value) => value.trim())
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
cachedOrigins = new Set([...defaults, ...extra]);
|
|
11
|
+
return cachedOrigins;
|
|
12
|
+
}
|
|
13
|
+
export function applyEditorCors(response, requestOrigin) {
|
|
14
|
+
const vary = response.headers.get("Vary") ?? "";
|
|
15
|
+
const hasOrigin = vary.split(",").map((v) => v.trim().toLowerCase()).includes("origin");
|
|
16
|
+
if (!hasOrigin)
|
|
17
|
+
response.headers.append("Vary", "Origin");
|
|
18
|
+
if (!requestOrigin)
|
|
19
|
+
return response;
|
|
20
|
+
if (!getEditorCorsOrigins().has(requestOrigin))
|
|
21
|
+
return response;
|
|
22
|
+
response.headers.set("Access-Control-Allow-Origin", requestOrigin);
|
|
23
|
+
response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
24
|
+
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
25
|
+
return response;
|
|
26
|
+
}
|
|
27
|
+
export function createEditorCorsOptionsHandler() {
|
|
28
|
+
return (request) => {
|
|
29
|
+
return applyEditorCors(new Response(null, { status: 204 }), request.headers.get("origin"));
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest } from "@avocadostudio-ai/shared";
|
|
2
|
+
export { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest };
|
|
3
|
+
export declare function buildBlockManifest(): BlockManifest;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getBlockJsonSchema, getBlockMeta, allowedBlockTypes, defaultPropsForType } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps } from "@avocadostudio-ai/shared";
|
|
3
|
+
export { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps };
|
|
4
|
+
// --- Manifest builder ---
|
|
5
|
+
// Built fresh each time to include custom blocks registered after initial load.
|
|
6
|
+
// Custom site blocks call registerBlock() in their schema.ts (imported via blocks/register.ts),
|
|
7
|
+
// which adds them to allowedBlockTypes. The manifest must reflect ALL registered blocks,
|
|
8
|
+
// not just the standard library ones.
|
|
9
|
+
export function buildBlockManifest() {
|
|
10
|
+
const blocks = allowedBlockTypes
|
|
11
|
+
.map((type) => {
|
|
12
|
+
const meta = getBlockMeta(type);
|
|
13
|
+
const propsSchema = getBlockJsonSchema(type);
|
|
14
|
+
if (!propsSchema)
|
|
15
|
+
return null; // skip blocks without schema (shouldn't happen but be safe)
|
|
16
|
+
const finalSchema = applyMetaEnumsToSchema(propsSchema, meta);
|
|
17
|
+
// Only attach defaultProps that actually satisfy the block's schema.
|
|
18
|
+
// defaultPropsForType() hands custom (non-core) blocks a generic CTA-shaped
|
|
19
|
+
// fallback; if that violates a custom schema (e.g. a richtext field expecting
|
|
20
|
+
// a doc object, not a string) it would fail validateManifestDefaultProps and
|
|
21
|
+
// poison the WHOLE manifest, making every block read as "unknown" in the editor.
|
|
22
|
+
const candidateDefaults = defaultPropsForType(type);
|
|
23
|
+
const defaultProps = validateByJsonSchemaLike(finalSchema, candidateDefaults)
|
|
24
|
+
? candidateDefaults
|
|
25
|
+
: undefined;
|
|
26
|
+
return {
|
|
27
|
+
type,
|
|
28
|
+
displayName: meta?.displayName ?? type,
|
|
29
|
+
propsSchema: finalSchema,
|
|
30
|
+
defaultProps
|
|
31
|
+
};
|
|
32
|
+
})
|
|
33
|
+
.filter((b) => b !== null);
|
|
34
|
+
return { version: 1, blocks };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Fold enum options declared in the block's registry `meta.fields` into the
|
|
38
|
+
* derived JSON schema. A site can declare `f.enum("Gap", ["sm","md","lg"])` in
|
|
39
|
+
* its block meta while the Zod prop stays a plain `z.string()` (so any value
|
|
40
|
+
* still validates). Without this, manifest-driven custom blocks lose the enum
|
|
41
|
+
* and the editor renders a free-text input instead of a dropdown. We only add
|
|
42
|
+
* `enum` (the editor reads it to pick a select control); validation is type-only
|
|
43
|
+
* (see validateByJsonSchemaLike), so widening here never rejects stored content.
|
|
44
|
+
*/
|
|
45
|
+
function applyMetaEnumsToSchema(schema, meta) {
|
|
46
|
+
if (!meta?.fields)
|
|
47
|
+
return schema;
|
|
48
|
+
const properties = schema.properties;
|
|
49
|
+
if (!properties || typeof properties !== "object")
|
|
50
|
+
return schema;
|
|
51
|
+
let next;
|
|
52
|
+
for (const [key, fieldMeta] of Object.entries(meta.fields)) {
|
|
53
|
+
if (fieldMeta.kind !== "enum" || !fieldMeta.options?.length)
|
|
54
|
+
continue;
|
|
55
|
+
const prop = properties[key];
|
|
56
|
+
if (!prop || typeof prop !== "object" || Array.isArray(prop))
|
|
57
|
+
continue;
|
|
58
|
+
const propObj = prop;
|
|
59
|
+
if (Array.isArray(propObj.enum))
|
|
60
|
+
continue; // schema already carries an enum
|
|
61
|
+
next ??= { ...schema, properties: { ...properties } };
|
|
62
|
+
next.properties[key] = { ...propObj, type: "string", enum: [...fieldMeta.options] };
|
|
63
|
+
}
|
|
64
|
+
return next ?? schema;
|
|
65
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import "@avocadostudio-ai/preview-adapter/styles.css";
|
|
4
|
+
import { PreviewBridge } from "@avocadostudio-ai/preview-adapter";
|
|
5
|
+
export function EditorOverlayInner({ slug, editorOrigin }) {
|
|
6
|
+
return _jsx(PreviewBridge, { slug: slug, editorOrigin: editorOrigin });
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
import { useState, useEffect } from "react";
|
|
5
|
+
const PreviewBridgeLoader = dynamic(() => import("./editor-overlay-inner.js").then((m) => ({ default: m.EditorOverlayInner })), { ssr: false });
|
|
6
|
+
export function EditorOverlay({ slug, editorOrigin }) {
|
|
7
|
+
const [inIframe, setInIframe] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
setInIframe(window.parent !== window);
|
|
10
|
+
}, []);
|
|
11
|
+
if (!inIframe)
|
|
12
|
+
return null;
|
|
13
|
+
return _jsx(PreviewBridgeLoader, { slug: slug, editorOrigin: editorOrigin });
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const EDITOR_KEYS = ["session", "siteId", "editorOrigin", "__editor"];
|
|
2
|
+
export function buildEditorQuerySuffix(searchParams) {
|
|
3
|
+
const params = new URLSearchParams();
|
|
4
|
+
for (const key of EDITOR_KEYS) {
|
|
5
|
+
const val = searchParams.get(key);
|
|
6
|
+
if (val)
|
|
7
|
+
params.set(key, val);
|
|
8
|
+
}
|
|
9
|
+
const str = params.toString();
|
|
10
|
+
return str ? `?${str}` : "";
|
|
11
|
+
}
|
|
12
|
+
export function buildSlug(parts) {
|
|
13
|
+
if (!parts || parts.length === 0)
|
|
14
|
+
return "/";
|
|
15
|
+
return `/${parts.join("/")}`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type BlockManifest } from "./editor-manifest.ts";
|
|
2
|
+
import type { PageDoc } from "./types.ts";
|
|
3
|
+
import type { SiteConfig } from "@avocadostudio-ai/shared";
|
|
4
|
+
export type InlineAsset = {
|
|
5
|
+
/** base64-encoded image bytes */
|
|
6
|
+
data: string;
|
|
7
|
+
/** MIME type, e.g. "image/png" */
|
|
8
|
+
mimeType: string;
|
|
9
|
+
/** Original filename */
|
|
10
|
+
fileName: string;
|
|
11
|
+
};
|
|
12
|
+
export type PublishContext = {
|
|
13
|
+
/** Base64-encoded images for localhost/generated URLs that can't be fetched remotely */
|
|
14
|
+
assets?: Record<string, InlineAsset>;
|
|
15
|
+
};
|
|
16
|
+
export type OnPublishFn = (pages: PageDoc[], config: SiteConfig, context?: PublishContext) => Promise<{
|
|
17
|
+
ok: boolean;
|
|
18
|
+
error?: string;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function createBlocksHandler(options?: {
|
|
21
|
+
getManifest?: () => BlockManifest;
|
|
22
|
+
}): {
|
|
23
|
+
GET: (request: Request) => Response;
|
|
24
|
+
OPTIONS: (request: Request) => Response;
|
|
25
|
+
};
|
|
26
|
+
export declare function createPagesHandler(getPages: () => PageDoc[] | Promise<PageDoc[]>, getSiteConfig?: () => SiteConfig | undefined | Promise<SiteConfig | undefined>): {
|
|
27
|
+
GET: (request: Request) => Promise<Response>;
|
|
28
|
+
OPTIONS: (request: Request) => Response;
|
|
29
|
+
};
|
|
30
|
+
export declare function createPublishHandler(onPublish: OnPublishFn, options?: {
|
|
31
|
+
publishSecret?: string;
|
|
32
|
+
}): {
|
|
33
|
+
POST: (request: Request) => Promise<Response>;
|
|
34
|
+
OPTIONS: (request: Request) => Response;
|
|
35
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { applyEditorCors, createEditorCorsOptionsHandler } from "./editor-cors.js";
|
|
2
|
+
import { buildBlockManifest } from "./editor-manifest.js";
|
|
3
|
+
export function createBlocksHandler(options) {
|
|
4
|
+
const getManifest = options?.getManifest ?? buildBlockManifest;
|
|
5
|
+
return {
|
|
6
|
+
OPTIONS: createEditorCorsOptionsHandler(),
|
|
7
|
+
GET(request) {
|
|
8
|
+
const manifest = getManifest();
|
|
9
|
+
const response = new Response(JSON.stringify(manifest), {
|
|
10
|
+
status: 200,
|
|
11
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }
|
|
12
|
+
});
|
|
13
|
+
return applyEditorCors(response, request.headers.get("origin"));
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function createPagesHandler(getPages, getSiteConfig) {
|
|
18
|
+
return {
|
|
19
|
+
OPTIONS: createEditorCorsOptionsHandler(),
|
|
20
|
+
async GET(request) {
|
|
21
|
+
const pages = await getPages();
|
|
22
|
+
const siteConfig = getSiteConfig ? await getSiteConfig() : undefined;
|
|
23
|
+
const body = siteConfig ? { pages, siteConfig } : { pages };
|
|
24
|
+
const response = new Response(JSON.stringify(body), {
|
|
25
|
+
status: 200,
|
|
26
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }
|
|
27
|
+
});
|
|
28
|
+
return applyEditorCors(response, request.headers.get("origin"));
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function createPublishHandler(onPublish, options) {
|
|
33
|
+
return {
|
|
34
|
+
OPTIONS: createEditorCorsOptionsHandler(),
|
|
35
|
+
async POST(request) {
|
|
36
|
+
// Verify publish token if configured
|
|
37
|
+
const secret = options?.publishSecret;
|
|
38
|
+
if (secret) {
|
|
39
|
+
const provided = request.headers.get("x-publish-token")?.trim();
|
|
40
|
+
if (!provided || provided !== secret) {
|
|
41
|
+
const res = new Response(JSON.stringify({ ok: false, error: "Invalid or missing publish token" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
42
|
+
return applyEditorCors(res, request.headers.get("origin"));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const body = (await request.json());
|
|
47
|
+
if (!Array.isArray(body.pages)) {
|
|
48
|
+
const res = new Response(JSON.stringify({ ok: false, error: "pages must be an array" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
49
|
+
return applyEditorCors(res, request.headers.get("origin"));
|
|
50
|
+
}
|
|
51
|
+
const pages = body.pages;
|
|
52
|
+
const config = (body.siteConfig ?? {});
|
|
53
|
+
const context = { assets: body.assets };
|
|
54
|
+
const result = await onPublish(pages, config, context);
|
|
55
|
+
const status = result.ok ? 200 : 500;
|
|
56
|
+
const res = new Response(JSON.stringify({ ok: result.ok, slugs: pages.map((p) => p.slug), error: result.error }), { status, headers: { "Content-Type": "application/json" } });
|
|
57
|
+
return applyEditorCors(res, request.headers.get("origin"));
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const message = err instanceof Error ? err.message : "publish failed";
|
|
61
|
+
const res = new Response(JSON.stringify({ ok: false, error: message }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
62
|
+
return applyEditorCors(res, request.headers.get("origin"));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/editor.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { EditorOverlay } from "./editor-overlay.tsx";
|
|
2
|
+
export { buildEditorQuerySuffix } from "./editor-query.ts";
|
|
3
|
+
export declare function getPreviewWrapperProps(editorMode: boolean, blockId: string, blockType: string): {
|
|
4
|
+
readonly "data-block-id"?: undefined;
|
|
5
|
+
readonly "data-block-type"?: undefined;
|
|
6
|
+
readonly className?: undefined;
|
|
7
|
+
readonly style?: undefined;
|
|
8
|
+
} | {
|
|
9
|
+
readonly "data-block-id": string;
|
|
10
|
+
readonly "data-block-type": string;
|
|
11
|
+
readonly className: "editor-selectable";
|
|
12
|
+
readonly style: {
|
|
13
|
+
readonly viewTransitionName: `block-${string}`;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export { renderBlocks } from "./render-blocks.tsx";
|
|
17
|
+
export { RenderedBlocks, PreviewBlock } from "./live-preview-blocks.tsx";
|
|
18
|
+
export { LivePreviewProvider } from "@avocadostudio-ai/preview-adapter";
|
|
19
|
+
export type { LivePreviewPage, LivePreviewBridgeApi } from "@avocadostudio-ai/preview-adapter";
|
|
20
|
+
export { getEditorCorsOrigins, applyEditorCors, createEditorCorsOptionsHandler } from "./editor-cors.ts";
|