@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
package/dist/editor.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Editor UI components
|
|
2
|
+
export { EditorOverlay } from "./editor-overlay.js";
|
|
3
|
+
// Editor query param utilities
|
|
4
|
+
export { buildEditorQuerySuffix } from "./editor-query.js";
|
|
5
|
+
// Block wrapper props for preview mode
|
|
6
|
+
export function getPreviewWrapperProps(editorMode, blockId, blockType) {
|
|
7
|
+
if (!editorMode)
|
|
8
|
+
return {};
|
|
9
|
+
return {
|
|
10
|
+
"data-block-id": blockId,
|
|
11
|
+
"data-block-type": blockType,
|
|
12
|
+
className: "editor-selectable",
|
|
13
|
+
style: { viewTransitionName: `block-${blockId}` }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
// Block rendering helper
|
|
17
|
+
export { renderBlocks } from "./render-blocks.js";
|
|
18
|
+
// Live-preview store renderer (streams field drafts through React)
|
|
19
|
+
export { RenderedBlocks, PreviewBlock } from "./live-preview-blocks.js";
|
|
20
|
+
export { LivePreviewProvider } from "@avocadostudio-ai/preview-adapter";
|
|
21
|
+
// Editor CORS utilities
|
|
22
|
+
export { getEditorCorsOrigins, applyEditorCors, createEditorCorsOptionsHandler } from "./editor-cors.js";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { PageDoc, PageMeta, BlockInstance, DraftContext, SearchParamsRecord } from "./types.ts";
|
|
2
|
+
export type { SiteConfig } from "@avocadostudio-ai/shared";
|
|
3
|
+
export { pageDocSchema } from "./types.ts";
|
|
4
|
+
export { buildSlug } from "./editor-query.ts";
|
|
5
|
+
export { renderBlocks } from "./render-blocks.tsx";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getConfiguredDraftSecret } from "@avocadostudio-ai/shared";
|
|
2
|
+
let checked = false;
|
|
3
|
+
/**
|
|
4
|
+
* Run once on first editor API request to validate essential integration config.
|
|
5
|
+
* Logs warnings to console — does not throw or block requests.
|
|
6
|
+
*/
|
|
7
|
+
export function checkIntegrationOnce() {
|
|
8
|
+
if (checked)
|
|
9
|
+
return;
|
|
10
|
+
checked = true;
|
|
11
|
+
const warnings = [];
|
|
12
|
+
// 1. Draft mode secret
|
|
13
|
+
const draftSecret = getConfiguredDraftSecret(process.env);
|
|
14
|
+
if (!draftSecret) {
|
|
15
|
+
warnings.push("DRAFT_MODE_SECRET is not set — editor draft mode will not work. " +
|
|
16
|
+
"Set DRAFT_MODE_SECRET (or VITE_SITE_DRAFT_SECRET) in your .env file.");
|
|
17
|
+
}
|
|
18
|
+
// 2. Orchestrator URL
|
|
19
|
+
const orchestratorUrl = process.env.ORCHESTRATOR_URL?.trim();
|
|
20
|
+
if (!orchestratorUrl) {
|
|
21
|
+
warnings.push("ORCHESTRATOR_URL is not set — defaults to http://localhost:4200. " +
|
|
22
|
+
"Set this in production to point to your deployed orchestrator.");
|
|
23
|
+
}
|
|
24
|
+
if (warnings.length > 0) {
|
|
25
|
+
console.warn("\n[ai-site-editor] Integration warnings:\n" +
|
|
26
|
+
warnings.map((w) => ` ⚠ ${w}`).join("\n") +
|
|
27
|
+
"\n");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Client renderer for the live-preview draft store.
|
|
5
|
+
*
|
|
6
|
+
* `RenderedBlocks` subscribes to the store's effective blocks (committed ⊕
|
|
7
|
+
* streamed drafts) and renders them through the REAL block renderers. Each block
|
|
8
|
+
* is memoized on its own object identity, so when one block's draft changes only
|
|
9
|
+
* that block re-renders — unchanged blocks keep their DOM nodes, which is what
|
|
10
|
+
* keeps the editor overlay's selection/handles/shimmer attached during a stream.
|
|
11
|
+
*
|
|
12
|
+
* Only used on built-in-block sites (EditorPageWrapper gates on the absence of
|
|
13
|
+
* custom renderers), so SharedBlockRenderer's standard-renderer path is enough —
|
|
14
|
+
* no client-side custom-renderer (globalThis) resolution needed.
|
|
15
|
+
*/
|
|
16
|
+
import { memo } from "react";
|
|
17
|
+
import { SharedBlockRenderer, BlockErrorBoundary } from "@avocadostudio-ai/blocks";
|
|
18
|
+
import { getChromeTypes } from "@avocadostudio-ai/shared";
|
|
19
|
+
import { useLivePreviewBlocks } from "@avocadostudio-ai/preview-adapter";
|
|
20
|
+
import { getPreviewWrapperProps } from "./editor.js";
|
|
21
|
+
const CHROME_BLOCK_TYPES = new Set(getChromeTypes());
|
|
22
|
+
export const PreviewBlock = memo(function PreviewBlock({ block }) {
|
|
23
|
+
return (_jsx("div", { id: block.id, ...getPreviewWrapperProps(true, block.id, block.type), children: _jsx(BlockErrorBoundary, { blockId: block.id, blockType: block.type, resetKey: block, children: _jsx(SharedBlockRenderer, { block: block }) }) }));
|
|
24
|
+
});
|
|
25
|
+
export function RenderedBlocks() {
|
|
26
|
+
const blocks = useLivePreviewBlocks() ?? [];
|
|
27
|
+
return (_jsx(_Fragment, { children: blocks
|
|
28
|
+
.filter((b) => !CHROME_BLOCK_TYPES.has(b.type))
|
|
29
|
+
.map((block) => (_jsx(PreviewBlock, { block: block }, block.id))) }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type BlockManifest } from "@avocadostudio-ai/shared";
|
|
2
|
+
export type ManifestFieldInfo = {
|
|
3
|
+
/** Top-level image fields per block type (e.g. Hero → {"imageUrl"}) */
|
|
4
|
+
imageFields: Map<string, Set<string>>;
|
|
5
|
+
/** Image fields within list items per block type + list key (e.g. CardGrid → cards → {"imageUrl"}) */
|
|
6
|
+
listImageFields: Map<string, Map<string, Set<string>>>;
|
|
7
|
+
/** All list field names per block type (e.g. CardGrid → {"cards"}, FeatureGrid → {"features"}) */
|
|
8
|
+
listFieldNames: Map<string, Set<string>>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Derive field metadata per block type from the manifest's propsSchema.
|
|
12
|
+
* Cached by manifest reference — safe to call from multiple modules.
|
|
13
|
+
*
|
|
14
|
+
* Works for both default blocks (via buildBlockManifest()) and custom blocks
|
|
15
|
+
* (via developer-provided getManifest()). No shared block registry needed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getManifestImageFields(manifest: BlockManifest): ManifestFieldInfo;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { deriveFieldMetaFromSchema } from "@avocadostudio-ai/shared";
|
|
2
|
+
const _cache = new WeakMap();
|
|
3
|
+
/**
|
|
4
|
+
* Derive field metadata per block type from the manifest's propsSchema.
|
|
5
|
+
* Cached by manifest reference — safe to call from multiple modules.
|
|
6
|
+
*
|
|
7
|
+
* Works for both default blocks (via buildBlockManifest()) and custom blocks
|
|
8
|
+
* (via developer-provided getManifest()). No shared block registry needed.
|
|
9
|
+
*/
|
|
10
|
+
export function getManifestImageFields(manifest) {
|
|
11
|
+
const cached = _cache.get(manifest);
|
|
12
|
+
if (cached)
|
|
13
|
+
return cached;
|
|
14
|
+
const imageFields = new Map();
|
|
15
|
+
const listImageFields = new Map();
|
|
16
|
+
const listFieldNames = new Map();
|
|
17
|
+
for (const block of manifest.blocks) {
|
|
18
|
+
const { fields, listFields } = deriveFieldMetaFromSchema(block.propsSchema);
|
|
19
|
+
const imgs = new Set();
|
|
20
|
+
for (const [key, meta] of Object.entries(fields)) {
|
|
21
|
+
if (meta.kind === "image")
|
|
22
|
+
imgs.add(key);
|
|
23
|
+
}
|
|
24
|
+
imageFields.set(block.type, imgs);
|
|
25
|
+
const listNames = new Set(Object.keys(listFields));
|
|
26
|
+
if (listNames.size > 0)
|
|
27
|
+
listFieldNames.set(block.type, listNames);
|
|
28
|
+
const listImgs = new Map();
|
|
29
|
+
for (const [listKey, listMeta] of Object.entries(listFields)) {
|
|
30
|
+
const itemImgs = new Set();
|
|
31
|
+
for (const [itemKey, itemMeta] of Object.entries(listMeta.itemFields)) {
|
|
32
|
+
if (itemMeta.kind === "image")
|
|
33
|
+
itemImgs.add(itemKey);
|
|
34
|
+
}
|
|
35
|
+
if (itemImgs.size > 0)
|
|
36
|
+
listImgs.set(listKey, itemImgs);
|
|
37
|
+
}
|
|
38
|
+
if (listImgs.size > 0)
|
|
39
|
+
listImageFields.set(block.type, listImgs);
|
|
40
|
+
}
|
|
41
|
+
const result = { imageFields, listImageFields, listFieldNames };
|
|
42
|
+
_cache.set(manifest, result);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
/**
|
|
3
|
+
* Options for the editor middleware factory.
|
|
4
|
+
*/
|
|
5
|
+
export type EditorMiddlewareOptions = {
|
|
6
|
+
/**
|
|
7
|
+
* The internal route prefix that the dynamic editor/draft page lives under.
|
|
8
|
+
* @default "/preview-draft"
|
|
9
|
+
*/
|
|
10
|
+
previewRoute?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Query parameter that signals an editor iframe request.
|
|
13
|
+
* @default "__editor"
|
|
14
|
+
*/
|
|
15
|
+
editorParam?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Name of the Next.js draft-mode bypass cookie.
|
|
18
|
+
* @default "__prerender_bypass"
|
|
19
|
+
*/
|
|
20
|
+
draftCookie?: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Create a Next.js middleware function that rewrites editor/draft requests
|
|
24
|
+
* to a dynamic preview route, keeping the main page route fully static.
|
|
25
|
+
*
|
|
26
|
+
* Usage in `middleware.ts`:
|
|
27
|
+
* ```ts
|
|
28
|
+
* import { createEditorMiddleware } from "@avocadostudio-ai/site-sdk/middleware"
|
|
29
|
+
* export const { middleware, config } = createEditorMiddleware()
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function createEditorMiddleware(options?: EditorMiddlewareOptions): {
|
|
33
|
+
middleware: (request: NextRequest) => NextResponse<unknown>;
|
|
34
|
+
config: {
|
|
35
|
+
matcher: string[];
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
/**
|
|
3
|
+
* Create a Next.js middleware function that rewrites editor/draft requests
|
|
4
|
+
* to a dynamic preview route, keeping the main page route fully static.
|
|
5
|
+
*
|
|
6
|
+
* Usage in `middleware.ts`:
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { createEditorMiddleware } from "@avocadostudio-ai/site-sdk/middleware"
|
|
9
|
+
* export const { middleware, config } = createEditorMiddleware()
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export function createEditorMiddleware(options) {
|
|
13
|
+
const previewRoute = options?.previewRoute ?? "/preview-draft";
|
|
14
|
+
const editorParam = options?.editorParam ?? "__editor";
|
|
15
|
+
const draftCookie = options?.draftCookie ?? "__prerender_bypass";
|
|
16
|
+
function middleware(request) {
|
|
17
|
+
const isEditor = request.nextUrl.searchParams.get(editorParam) === "1";
|
|
18
|
+
const hasDraftCookie = request.cookies.has(draftCookie);
|
|
19
|
+
if (isEditor || hasDraftCookie) {
|
|
20
|
+
const url = request.nextUrl.clone();
|
|
21
|
+
url.pathname = `${previewRoute}${url.pathname}`;
|
|
22
|
+
return NextResponse.rewrite(url);
|
|
23
|
+
}
|
|
24
|
+
return NextResponse.next();
|
|
25
|
+
}
|
|
26
|
+
// Escape special regex chars in the route prefix (strip leading slash for the pattern)
|
|
27
|
+
const escapedRoute = previewRoute.slice(1).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
const config = {
|
|
29
|
+
matcher: [
|
|
30
|
+
`/((?!_next|${escapedRoute}|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)`
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
return { middleware, config };
|
|
34
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare function siteNameFallback(siteId: string): string;
|
|
2
|
+
export declare function slugToLabel(route: string): string;
|
|
3
|
+
export type NavItem = {
|
|
4
|
+
href?: string;
|
|
5
|
+
label: string;
|
|
6
|
+
isActive: boolean;
|
|
7
|
+
children?: NavItem[];
|
|
8
|
+
};
|
|
9
|
+
type NavLinkProp = {
|
|
10
|
+
label: string;
|
|
11
|
+
href?: string;
|
|
12
|
+
children?: NavLinkProp[];
|
|
13
|
+
};
|
|
14
|
+
export type SiteHeaderBlock = {
|
|
15
|
+
id: string;
|
|
16
|
+
type: "SiteHeader";
|
|
17
|
+
props: {
|
|
18
|
+
siteName: string;
|
|
19
|
+
logoUrl: string;
|
|
20
|
+
links: NavLinkProp[];
|
|
21
|
+
activePath?: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export declare function buildSiteHeaderBlock(opts: {
|
|
25
|
+
navItems: NavItem[];
|
|
26
|
+
siteName: string;
|
|
27
|
+
siteLogo: string;
|
|
28
|
+
activePath: string;
|
|
29
|
+
}): SiteHeaderBlock;
|
|
30
|
+
export declare function buildNavItems(opts: {
|
|
31
|
+
navSlugs: string[];
|
|
32
|
+
currentSlug: string;
|
|
33
|
+
siteConfig: {
|
|
34
|
+
name?: string;
|
|
35
|
+
logo?: string;
|
|
36
|
+
navLabels?: Record<string, string>;
|
|
37
|
+
navGroups?: Record<string, string[]>;
|
|
38
|
+
};
|
|
39
|
+
siteId: string;
|
|
40
|
+
editorQuery: string;
|
|
41
|
+
defaultLogo?: string;
|
|
42
|
+
}): {
|
|
43
|
+
navItems: NavItem[];
|
|
44
|
+
siteName: string;
|
|
45
|
+
siteLogo: string;
|
|
46
|
+
homeHref: string;
|
|
47
|
+
};
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export function siteNameFallback(siteId) {
|
|
2
|
+
return siteId
|
|
3
|
+
.split("-")
|
|
4
|
+
.filter(Boolean)
|
|
5
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
6
|
+
.join(" ");
|
|
7
|
+
}
|
|
8
|
+
export function slugToLabel(route) {
|
|
9
|
+
if (route === "/")
|
|
10
|
+
return "Home";
|
|
11
|
+
return route
|
|
12
|
+
.slice(1)
|
|
13
|
+
.split("/")
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.map((part) => part.replace(/[-_]/g, " "))
|
|
16
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
17
|
+
.join(" / ");
|
|
18
|
+
}
|
|
19
|
+
function mapNavItemToLink(item) {
|
|
20
|
+
const link = { label: item.label };
|
|
21
|
+
if (item.href)
|
|
22
|
+
link.href = item.href;
|
|
23
|
+
if (item.children?.length)
|
|
24
|
+
link.children = item.children.map(mapNavItemToLink);
|
|
25
|
+
return link;
|
|
26
|
+
}
|
|
27
|
+
export function buildSiteHeaderBlock(opts) {
|
|
28
|
+
return {
|
|
29
|
+
id: "chrome_site-header",
|
|
30
|
+
type: "SiteHeader",
|
|
31
|
+
props: {
|
|
32
|
+
siteName: opts.siteName,
|
|
33
|
+
logoUrl: opts.siteLogo,
|
|
34
|
+
links: opts.navItems.map(mapNavItemToLink),
|
|
35
|
+
activePath: opts.activePath,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function buildNavItems(opts) {
|
|
40
|
+
const { navSlugs, currentSlug, siteConfig, siteId, editorQuery } = opts;
|
|
41
|
+
const siteName = siteConfig.name || siteNameFallback(siteId) || "Site";
|
|
42
|
+
const siteLogo = siteConfig.logo || opts.defaultLogo || "";
|
|
43
|
+
const allSlugs = Array.from(new Set([...(navSlugs.length > 0 ? navSlugs : ["/"]), currentSlug]));
|
|
44
|
+
const orderedSlugs = allSlugs.includes("/")
|
|
45
|
+
? ["/", ...allSlugs.filter((r) => r !== "/")]
|
|
46
|
+
: allSlugs;
|
|
47
|
+
// Build flat items first
|
|
48
|
+
const flatItems = orderedSlugs.map((route) => ({
|
|
49
|
+
href: `${route}${editorQuery}`,
|
|
50
|
+
label: siteConfig.navLabels?.[route] ?? slugToLabel(route),
|
|
51
|
+
isActive: route === currentSlug,
|
|
52
|
+
_slug: route, // internal, stripped before return
|
|
53
|
+
}));
|
|
54
|
+
// Apply navGroups to collapse slugs into parent dropdown items
|
|
55
|
+
const navGroups = siteConfig.navGroups;
|
|
56
|
+
let navItems;
|
|
57
|
+
if (navGroups && Object.keys(navGroups).length > 0) {
|
|
58
|
+
// Build reverse lookup: slug → group label
|
|
59
|
+
const slugToGroup = new Map();
|
|
60
|
+
for (const [groupLabel, slugs] of Object.entries(navGroups)) {
|
|
61
|
+
for (const slug of slugs)
|
|
62
|
+
slugToGroup.set(slug, groupLabel);
|
|
63
|
+
}
|
|
64
|
+
const emittedGroups = new Set();
|
|
65
|
+
navItems = [];
|
|
66
|
+
for (const item of flatItems) {
|
|
67
|
+
const groupLabel = slugToGroup.get(item._slug);
|
|
68
|
+
if (groupLabel) {
|
|
69
|
+
if (emittedGroups.has(groupLabel))
|
|
70
|
+
continue; // already emitted as part of parent
|
|
71
|
+
emittedGroups.add(groupLabel);
|
|
72
|
+
// Collect all children in this group (preserving order from navGroups definition)
|
|
73
|
+
const groupSlugs = navGroups[groupLabel];
|
|
74
|
+
const children = groupSlugs
|
|
75
|
+
.map((slug) => flatItems.find((fi) => fi._slug === slug))
|
|
76
|
+
.filter((fi) => !!fi)
|
|
77
|
+
.map(({ _slug: _, ...rest }) => rest);
|
|
78
|
+
navItems.push({
|
|
79
|
+
label: groupLabel,
|
|
80
|
+
isActive: children.some((c) => c.isActive),
|
|
81
|
+
children,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const { _slug: _, ...rest } = item;
|
|
86
|
+
navItems.push(rest);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
navItems = flatItems.map(({ _slug: _, ...rest }) => rest);
|
|
92
|
+
}
|
|
93
|
+
const homeHref = `/${editorQuery}`;
|
|
94
|
+
return { navItems, siteName, siteLogo, homeHref };
|
|
95
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OnPublishFn } from "../editor-routes.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Publish handler that writes PageDoc[] to a local JSON file.
|
|
4
|
+
*
|
|
5
|
+
* When `publicDir` is provided, inline assets (base64 images from the
|
|
6
|
+
* orchestrator) are written to disk and their localhost URLs are rewritten
|
|
7
|
+
* to relative paths in the JSON output.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createJsonFilePublishHandler } from "@avocadostudio-ai/site-sdk/publish-handlers/json-file"
|
|
12
|
+
*
|
|
13
|
+
* createEditorApiHandler({
|
|
14
|
+
* getPages: () => [...],
|
|
15
|
+
* onPublish: createJsonFilePublishHandler("/path/to/published-content.json", {
|
|
16
|
+
* publicDir: "/path/to/public/generated-images",
|
|
17
|
+
* }),
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function createJsonFilePublishHandler(filePath: string, options?: {
|
|
22
|
+
publicDir?: string;
|
|
23
|
+
imagePathPrefix?: string;
|
|
24
|
+
}): OnPublishFn;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Publish handler that writes PageDoc[] to a local JSON file.
|
|
5
|
+
*
|
|
6
|
+
* When `publicDir` is provided, inline assets (base64 images from the
|
|
7
|
+
* orchestrator) are written to disk and their localhost URLs are rewritten
|
|
8
|
+
* to relative paths in the JSON output.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createJsonFilePublishHandler } from "@avocadostudio-ai/site-sdk/publish-handlers/json-file"
|
|
13
|
+
*
|
|
14
|
+
* createEditorApiHandler({
|
|
15
|
+
* getPages: () => [...],
|
|
16
|
+
* onPublish: createJsonFilePublishHandler("/path/to/published-content.json", {
|
|
17
|
+
* publicDir: "/path/to/public/generated-images",
|
|
18
|
+
* }),
|
|
19
|
+
* })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function createJsonFilePublishHandler(filePath, options) {
|
|
23
|
+
return async (pages, config, context) => {
|
|
24
|
+
let output = pages;
|
|
25
|
+
if (options?.publicDir && context?.assets && Object.keys(context.assets).length > 0) {
|
|
26
|
+
const prefix = options.imagePathPrefix ?? "/generated-images/";
|
|
27
|
+
await mkdir(options.publicDir, { recursive: true });
|
|
28
|
+
let json = JSON.stringify(pages, null, 2);
|
|
29
|
+
for (const [originalUrl, asset] of Object.entries(context.assets)) {
|
|
30
|
+
const dest = resolve(options.publicDir, asset.fileName);
|
|
31
|
+
await writeFile(dest, Buffer.from(asset.data, "base64"));
|
|
32
|
+
json = json.replaceAll(originalUrl, `${prefix}${asset.fileName}`);
|
|
33
|
+
}
|
|
34
|
+
output = JSON.parse(json);
|
|
35
|
+
}
|
|
36
|
+
const payload = JSON.stringify({ pages: output, siteConfig: config }, null, 2) + "\n";
|
|
37
|
+
await writeFile(filePath, payload, "utf8");
|
|
38
|
+
return { ok: true };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InlineAsset } from "./editor-routes.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Reject URLs pointing at private/loopback addresses (SSRF protection).
|
|
4
|
+
* Used by CMS publish handlers to validate image URLs before fetching.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isSafeImageUrl(raw: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* CMS-specific image upload function.
|
|
9
|
+
* Receives raw bytes + metadata, returns a CMS asset reference (or null on failure).
|
|
10
|
+
*/
|
|
11
|
+
export type ImageUploader<T> = (bytes: Buffer, fileName: string, mimeType: string) => Promise<T | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Creates a cached image resolver for use during publish.
|
|
14
|
+
*
|
|
15
|
+
* Handles the full resolution pipeline that every CMS integration needs:
|
|
16
|
+
* 1. Check inline assets (base64 from orchestrator for localhost/generated URLs)
|
|
17
|
+
* 2. SSRF validation
|
|
18
|
+
* 3. Fetch external URL
|
|
19
|
+
* 4. Call CMS-specific upload function
|
|
20
|
+
* 5. Cache results to avoid duplicate uploads within a single publish
|
|
21
|
+
*
|
|
22
|
+
* @param upload CMS-specific upload function (Sanity asset upload, Contentful asset create, Strapi media upload, etc.)
|
|
23
|
+
* @param assets Inline assets from the publish context (base64-encoded images)
|
|
24
|
+
* @returns A `resolve(imageUrl)` function that returns the CMS asset reference or null
|
|
25
|
+
*/
|
|
26
|
+
export declare function createImageResolver<T>(upload: ImageUploader<T>, assets?: Record<string, InlineAsset>): {
|
|
27
|
+
resolve: (imageUrl: string) => Promise<T | null>;
|
|
28
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reject URLs pointing at private/loopback addresses (SSRF protection).
|
|
3
|
+
* Used by CMS publish handlers to validate image URLs before fetching.
|
|
4
|
+
*/
|
|
5
|
+
export function isSafeImageUrl(raw) {
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = new URL(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
14
|
+
return false;
|
|
15
|
+
const h = parsed.hostname;
|
|
16
|
+
if (h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "0.0.0.0")
|
|
17
|
+
return false;
|
|
18
|
+
if (h.startsWith("10.") || h.startsWith("192.168.") || h.startsWith("169.254."))
|
|
19
|
+
return false;
|
|
20
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Creates a cached image resolver for use during publish.
|
|
26
|
+
*
|
|
27
|
+
* Handles the full resolution pipeline that every CMS integration needs:
|
|
28
|
+
* 1. Check inline assets (base64 from orchestrator for localhost/generated URLs)
|
|
29
|
+
* 2. SSRF validation
|
|
30
|
+
* 3. Fetch external URL
|
|
31
|
+
* 4. Call CMS-specific upload function
|
|
32
|
+
* 5. Cache results to avoid duplicate uploads within a single publish
|
|
33
|
+
*
|
|
34
|
+
* @param upload CMS-specific upload function (Sanity asset upload, Contentful asset create, Strapi media upload, etc.)
|
|
35
|
+
* @param assets Inline assets from the publish context (base64-encoded images)
|
|
36
|
+
* @returns A `resolve(imageUrl)` function that returns the CMS asset reference or null
|
|
37
|
+
*/
|
|
38
|
+
export function createImageResolver(upload, assets) {
|
|
39
|
+
const cache = new Map();
|
|
40
|
+
function resolve(imageUrl) {
|
|
41
|
+
const cached = cache.get(imageUrl);
|
|
42
|
+
if (cached)
|
|
43
|
+
return cached;
|
|
44
|
+
const promise = (async () => {
|
|
45
|
+
if (!imageUrl.startsWith("http"))
|
|
46
|
+
return null;
|
|
47
|
+
const inlineAsset = assets?.[imageUrl];
|
|
48
|
+
if (inlineAsset) {
|
|
49
|
+
try {
|
|
50
|
+
const buf = Buffer.from(inlineAsset.data, "base64");
|
|
51
|
+
return await upload(buf, inlineAsset.fileName, inlineAsset.mimeType);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!isSafeImageUrl(imageUrl))
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(imageUrl);
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
return null;
|
|
63
|
+
const blob = await res.blob();
|
|
64
|
+
const buf = Buffer.from(await blob.arrayBuffer());
|
|
65
|
+
const fileName = imageUrl.split("/").pop()?.split("?")[0] || "image.jpg";
|
|
66
|
+
const mimeType = blob.type || guessContentType(imageUrl);
|
|
67
|
+
return await upload(buf, fileName, mimeType);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
cache.set(imageUrl, promise);
|
|
74
|
+
return promise;
|
|
75
|
+
}
|
|
76
|
+
return { resolve };
|
|
77
|
+
}
|
|
78
|
+
function guessContentType(url) {
|
|
79
|
+
try {
|
|
80
|
+
const ext = new URL(url).pathname.split(".").pop()?.toLowerCase();
|
|
81
|
+
if (ext === "png")
|
|
82
|
+
return "image/png";
|
|
83
|
+
if (ext === "webp")
|
|
84
|
+
return "image/webp";
|
|
85
|
+
if (ext === "gif")
|
|
86
|
+
return "image/gif";
|
|
87
|
+
if (ext === "svg")
|
|
88
|
+
return "image/svg+xml";
|
|
89
|
+
}
|
|
90
|
+
catch { /* invalid URL — fall through */ }
|
|
91
|
+
return "image/jpeg";
|
|
92
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { SharedBlockRenderer, BlockErrorBoundary, getCustomRenderer } from "@avocadostudio-ai/blocks";
|
|
3
|
+
import { getChromeTypes } from "@avocadostudio-ai/shared";
|
|
4
|
+
import { getPreviewWrapperProps } from "./editor.js";
|
|
5
|
+
/**
|
|
6
|
+
* Renders a list of blocks with error boundaries.
|
|
7
|
+
* When `editable` is true, adds preview wrapper attributes for editor overlay selection.
|
|
8
|
+
*
|
|
9
|
+
* Custom renderers (registered via registerCustomRenderer) are resolved here on the
|
|
10
|
+
* server side, then passed directly to the client-side BlockErrorBoundary. This avoids
|
|
11
|
+
* the RSC boundary issue where the customRenderers Map is empty on the client.
|
|
12
|
+
*/
|
|
13
|
+
// Chrome blocks (SiteHeader, Footer) are rendered by createSitePage — skip if present in page blocks
|
|
14
|
+
const CHROME_BLOCK_TYPES = new Set(getChromeTypes());
|
|
15
|
+
export function renderBlocks(blocks, options) {
|
|
16
|
+
const editable = options?.editable ?? false;
|
|
17
|
+
return blocks.filter(b => !CHROME_BLOCK_TYPES.has(b.type)).map((block) => {
|
|
18
|
+
// Resolve custom renderer on the server side (where registerCustomRenderer ran).
|
|
19
|
+
// Custom renderers are "use client" components — passing them as JSX from a server
|
|
20
|
+
// component works correctly across the RSC boundary (React serializes the reference).
|
|
21
|
+
const CustomRenderer = getCustomRenderer(block.type);
|
|
22
|
+
return (_jsx("div", { id: block.id, ...(editable ? getPreviewWrapperProps(true, block.id, block.type) : {}), children: _jsx(BlockErrorBoundary, { blockId: block.id, blockType: block.type, children: CustomRenderer
|
|
23
|
+
? _jsx(CustomRenderer, { ...block.props })
|
|
24
|
+
: _jsx(SharedBlockRenderer, { block: block }) }) }, block.id));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PageDoc } from "./types.ts";
|
|
2
|
+
export type RevalidateHandlerConfig = {
|
|
3
|
+
/** Environment variable name containing the webhook secret (e.g. "SANITY_WEBHOOK_SECRET") */
|
|
4
|
+
secretEnvVar: string;
|
|
5
|
+
/**
|
|
6
|
+
* Where to look for the webhook secret. Accepts:
|
|
7
|
+
* - A header name string (default: "x-revalidate-secret")
|
|
8
|
+
* - null to check only query param "secret"
|
|
9
|
+
* - An array to check multiple sources in order (e.g. query param then header)
|
|
10
|
+
*/
|
|
11
|
+
secretHeader?: string | string[] | null;
|
|
12
|
+
/** Extract the page slug from the webhook body. Return null to revalidate "/". */
|
|
13
|
+
extractSlug: (body: unknown) => string | null;
|
|
14
|
+
/** Fetch all published pages (for orchestrator bootstrap after CMS changes). */
|
|
15
|
+
getPages: () => Promise<PageDoc[]>;
|
|
16
|
+
/** Site identifier passed to orchestrator bootstrap. */
|
|
17
|
+
siteId: string;
|
|
18
|
+
/** Session name passed to orchestrator bootstrap. Defaults to "dev". */
|
|
19
|
+
session?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Factory for CMS webhook → ISR revalidation + orchestrator bootstrap routes.
|
|
23
|
+
*
|
|
24
|
+
* All three CMS integrations follow the same pattern:
|
|
25
|
+
* 1. Validate webhook secret
|
|
26
|
+
* 2. Extract slug from CMS-specific body shape
|
|
27
|
+
* 3. Call Next.js revalidatePath()
|
|
28
|
+
* 4. Re-bootstrap orchestrator with fresh content
|
|
29
|
+
*
|
|
30
|
+
* This factory extracts that into a single configurable handler.
|
|
31
|
+
*
|
|
32
|
+
* Usage:
|
|
33
|
+
* ```ts
|
|
34
|
+
* export const POST = createRevalidateHandler({
|
|
35
|
+
* secretEnvVar: "SANITY_WEBHOOK_SECRET",
|
|
36
|
+
* extractSlug: (body) => (body as any)?.slug?.current ?? null,
|
|
37
|
+
* getPages: getSanityPages,
|
|
38
|
+
* siteId: "sanity-site",
|
|
39
|
+
* })
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function createRevalidateHandler(config: RevalidateHandlerConfig): (request: Request) => Promise<Response>;
|