@avocadostudio-ai/site-sdk 0.7.0 → 0.8.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.
Files changed (55) hide show
  1. package/dist/create-site-page.js +47 -84
  2. package/dist/draft-common.d.ts +1 -0
  3. package/dist/draft-common.js +13 -0
  4. package/dist/draft-context-core.d.ts +1 -1
  5. package/dist/draft-context-core.js +2 -3
  6. package/dist/draft-context.d.ts +1 -1
  7. package/dist/draft-context.js +1 -3
  8. package/dist/draft-routes.d.ts +7 -0
  9. package/dist/draft-routes.js +8 -3
  10. package/dist/editor-api-handler-core.d.ts +85 -0
  11. package/dist/editor-api-handler-core.js +101 -0
  12. package/dist/editor-api-handler.d.ts +7 -49
  13. package/dist/editor-api-handler.js +12 -66
  14. package/dist/editor-rewrite-core.d.ts +71 -0
  15. package/dist/editor-rewrite-core.js +37 -0
  16. package/dist/lens/register.d.ts +6 -8
  17. package/dist/lens/register.js +203 -29
  18. package/dist/lens/types.d.ts +36 -0
  19. package/dist/page-metadata.d.ts +21 -0
  20. package/dist/page-metadata.js +64 -0
  21. package/dist/proxy.d.ts +9 -30
  22. package/dist/proxy.js +16 -22
  23. package/dist/resolve-page-render.d.ts +94 -0
  24. package/dist/resolve-page-render.js +80 -0
  25. package/dist/routes-core.d.ts +12 -0
  26. package/dist/routes-core.js +24 -0
  27. package/package.json +20 -15
  28. package/dist/cli/register-notice.test.d.ts +0 -1
  29. package/dist/cli/register-notice.test.js +0 -21
  30. package/dist/draft-context-core.test.d.ts +0 -10
  31. package/dist/draft-context-core.test.js +0 -146
  32. package/dist/draft-fetch.test.d.ts +0 -1
  33. package/dist/draft-fetch.test.js +0 -153
  34. package/dist/editor-api-blocks-catalogue.test.d.ts +0 -1
  35. package/dist/editor-api-blocks-catalogue.test.js +0 -73
  36. package/dist/editor-cors.test.d.ts +0 -1
  37. package/dist/editor-cors.test.js +0 -85
  38. package/dist/editor-render.test.d.ts +0 -1
  39. package/dist/editor-render.test.js +0 -44
  40. package/dist/lens/lens.test.d.ts +0 -1
  41. package/dist/lens/lens.test.js +0 -221
  42. package/dist/lens/register.test.d.ts +0 -1
  43. package/dist/lens/register.test.js +0 -81
  44. package/dist/manifest-utils.test.d.ts +0 -1
  45. package/dist/manifest-utils.test.js +0 -72
  46. package/dist/markers.test.d.ts +0 -1
  47. package/dist/markers.test.js +0 -35
  48. package/dist/next-config.test.d.ts +0 -1
  49. package/dist/next-config.test.js +0 -393
  50. package/dist/page-metadata.test.d.ts +0 -1
  51. package/dist/page-metadata.test.js +0 -105
  52. package/dist/proxy.test.d.ts +0 -1
  53. package/dist/proxy.test.js +0 -182
  54. package/dist/publish/field-diff.test.d.ts +0 -1
  55. package/dist/publish/field-diff.test.js +0 -350
@@ -1,12 +1,12 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { draftMode } from "next/headers";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { draftMode, cookies } from "next/headers";
3
3
  import { connection } from "next/server";
4
4
  import { notFound } from "next/navigation";
5
5
  import { SharedBlockRenderer, BlocksHydrator } from "@avocadostudio-ai/blocks";
6
6
  import { buildSlug } from "./index.js";
7
- import { resolveEditorContext, fetchEditorPage, fetchEditorSlugs } from "./draft.js";
7
+ import { resolveEditorContext } from "./draft.js";
8
8
  import { renderBlocks, EditorOverlay } from "./editor.js";
9
- import { buildNavItems, buildSiteHeaderBlock } from "./navigation.js";
9
+ import { resolvePageRender } from "./resolve-page-render.js";
10
10
  import { buildPageMetadata } from "./page-metadata.js";
11
11
  function resolve(config) {
12
12
  return {
@@ -30,33 +30,30 @@ function makeGenerateStaticParams(cmsGetSlugs) {
30
30
  }));
31
31
  };
32
32
  }
33
- async function renderStatic(slug, c) {
34
- const [page, navSlugs, siteConfig] = await Promise.all([
35
- c.cmsGetPage(slug),
36
- c.cmsGetSlugs(),
37
- c.cmsGetSiteConfig ? c.cmsGetSiteConfig() : Promise.resolve({}),
38
- ]);
33
+ function nextDraftAdapter(draft, getCookie) {
34
+ return { isDraftMode: draft.isEnabled, getCookie };
35
+ }
36
+ /** The site chrome around whatever the outcome turned out to be. */
37
+ function withChrome(chrome, c, body, after) {
38
+ return (_jsxs(_Fragment, { children: [c.chrome && _jsx(SharedBlockRenderer, { block: chrome.header }), body, c.chrome && c.footer ? _jsx(SharedBlockRenderer, { block: c.footer }) : null, after] }));
39
+ }
40
+ function renderOutcome(outcome, c) {
39
41
  /*
40
42
  * `notFound()`, not a rendered 404 body. The previous version returned an
41
43
  * <h1>404</h1> with HTTP 200, which is a soft 404: crawlers index it as a
42
44
  * real page, and the host app's own `not-found.tsx` never runs. Consumers who
43
45
  * want the site chrome around their 404 get it by adding `app/not-found.tsx`,
44
46
  * which is where Next puts that and where a layout can wrap it.
45
- *
46
- * Before building the nav, since none of it survives the throw.
47
47
  */
48
- if (!page)
48
+ if (outcome.kind === "not-found")
49
49
  notFound();
50
- const { navItems, siteName, siteLogo } = buildNavItems({
51
- navSlugs,
52
- currentSlug: slug,
53
- siteConfig,
54
- siteId: c.siteId,
55
- editorQuery: "",
56
- defaultLogo: c.defaultLogo,
57
- });
58
- const chromeHeader = buildSiteHeaderBlock({ navItems, siteName, siteLogo, activePath: slug });
59
- return (_jsxs(_Fragment, { children: [c.chrome && _jsx(SharedBlockRenderer, { block: chromeHeader }), _jsxs("main", { children: [renderBlocks(page.blocks), _jsx(BlocksHydrator, {})] }), c.chrome && c.footer ? _jsx(SharedBlockRenderer, { block: c.footer }) : null] }));
50
+ if (outcome.kind === "draft-unavailable") {
51
+ return withChrome(outcome.chrome, c, _jsxs("main", { style: { padding: "4rem", textAlign: "center" }, children: [_jsx("h1", { children: "Draft unavailable" }), _jsxs("p", { children: ["Could not load draft content for ", outcome.slug, "."] })] }));
52
+ }
53
+ if (!outcome.editor) {
54
+ return withChrome(outcome.chrome, c, _jsxs("main", { children: [renderBlocks(outcome.page.blocks), _jsx(BlocksHydrator, {})] }));
55
+ }
56
+ return withChrome(outcome.chrome, c, _jsx("main", { className: "editor-mode", children: renderBlocks(outcome.page.blocks, { editable: true }) }), _jsx(EditorOverlay, { slug: outcome.slug, editorOrigin: outcome.editor.editorOrigin }));
60
57
  }
61
58
  /**
62
59
  * Metadata for a page, mirroring the render's own choice of source.
@@ -90,57 +87,33 @@ function makeGenerateMetadata(c, mode) {
90
87
  return decorate(buildPageMetadata(page, { siteName: c.siteName }), page);
91
88
  };
92
89
  }
93
- async function renderPreview(slug, search, c) {
94
- /*
95
- * `connection()`, not `unstable_noStore()`. Same effect — this render depends
96
- * on a live request and must never be prerendered or cached — but it is the
97
- * stable primitive Next ships for saying so, and the SDK had exactly one
98
- * `unstable_*` import left in it.
99
- */
100
- await connection();
101
- const editorCtx = await resolveEditorContext(search, {
102
- defaultSession: c.defaultSession,
103
- defaultSiteId: c.siteId,
104
- });
105
- const session = editorCtx?.session ?? c.defaultSession;
106
- const currentSiteId = editorCtx?.siteId ?? c.siteId;
107
- const [page, navSlugs, siteConfig] = await Promise.all([
108
- fetchEditorPage(slug, session, currentSiteId).then((p) => p ?? c.cmsGetPage(slug)),
109
- fetchEditorSlugs(session, currentSiteId).then((s) => (s.length > 0 ? s : c.cmsGetSlugs())),
110
- c.cmsGetSiteConfig ? c.cmsGetSiteConfig() : Promise.resolve({}),
111
- ]);
112
- const editorOrigin = editorCtx?.editorOrigin ?? "";
113
- const editorQuery = (() => {
114
- const p = new URLSearchParams({ session, siteId: currentSiteId });
115
- if (editorOrigin)
116
- p.set("editorOrigin", editorOrigin);
117
- return `?${p.toString()}`;
118
- })();
119
- const { navItems, siteName, siteLogo } = buildNavItems({
120
- navSlugs,
121
- currentSlug: slug,
122
- siteConfig,
123
- siteId: c.siteId,
124
- editorQuery,
125
- defaultLogo: c.defaultLogo,
126
- });
127
- const chromeHeader = buildSiteHeaderBlock({ navItems, siteName, siteLogo, activePath: slug });
128
- if (!page) {
129
- return (_jsxs(_Fragment, { children: [c.chrome && _jsx(SharedBlockRenderer, { block: chromeHeader }), _jsxs("main", { style: { padding: "4rem", textAlign: "center" }, children: [_jsx("h1", { children: "Draft unavailable" }), _jsxs("p", { children: ["Could not load draft content for ", slug, "."] })] }), c.chrome && c.footer ? _jsx(SharedBlockRenderer, { block: c.footer }) : null] }));
90
+ async function render(slug, search, c, mode) {
91
+ if (mode !== "static") {
92
+ /*
93
+ * `connection()`, not `unstable_noStore()`. Same effect — this render
94
+ * depends on a live request and must never be prerendered or cached — but
95
+ * it is the stable primitive Next ships for saying so, and the SDK had
96
+ * exactly one `unstable_*` import left in it.
97
+ */
98
+ await connection();
130
99
  }
131
- return (_jsxs(_Fragment, { children: [c.chrome && _jsx(SharedBlockRenderer, { block: chromeHeader }), _jsx("main", { className: "editor-mode", children: renderBlocks(page.blocks, { editable: true }) }), c.chrome && c.footer ? _jsx(SharedBlockRenderer, { block: c.footer }) : null, _jsx(EditorOverlay, { slug: slug, editorOrigin: editorOrigin })] }));
132
- }
133
- async function renderAuto(slug, search, c) {
134
- const editorCtx = await resolveEditorContext(search, {
135
- defaultSession: c.defaultSession,
136
- defaultSiteId: c.siteId,
100
+ const jar = mode === "static" ? null : await cookies();
101
+ const draft = mode === "static" ? null : await draftMode();
102
+ const outcome = await resolvePageRender({
103
+ slug,
104
+ mode,
105
+ search,
106
+ config: {
107
+ siteId: c.siteId,
108
+ session: c.defaultSession,
109
+ getPage: c.cmsGetPage,
110
+ getSlugs: c.cmsGetSlugs,
111
+ getSiteConfig: c.cmsGetSiteConfig,
112
+ defaultLogo: c.defaultLogo,
113
+ },
114
+ adapter: draft && jar ? nextDraftAdapter(draft, (name) => jar.get(name)?.value) : undefined,
137
115
  });
138
- const draft = await draftMode();
139
- const editorMode = draft.isEnabled || !!editorCtx;
140
- if (editorMode) {
141
- return renderPreview(slug, search, c);
142
- }
143
- return renderStatic(slug, c);
116
+ return renderOutcome(outcome, c);
144
117
  }
145
118
  /**
146
119
  * Create a Next.js page component with full editor integration.
@@ -156,23 +129,13 @@ export function createSitePage(config) {
156
129
  if (mode === "static") {
157
130
  async function Page({ params }) {
158
131
  const { slug: slugParts } = await params;
159
- const slug = buildSlug(slugParts);
160
- return renderStatic(slug, c);
161
- }
162
- return { Page, generateStaticParams, generateMetadata };
163
- }
164
- if (mode === "preview") {
165
- async function Page({ params, searchParams }) {
166
- const [{ slug: slugParts }, search] = await Promise.all([params, searchParams]);
167
- const slug = buildSlug(slugParts);
168
- return renderPreview(slug, search, c);
132
+ return render(buildSlug(slugParts), undefined, c, "static");
169
133
  }
170
134
  return { Page, generateStaticParams, generateMetadata };
171
135
  }
172
136
  async function Page({ params, searchParams }) {
173
137
  const [{ slug: slugParts }, search] = await Promise.all([params, searchParams]);
174
- const slug = buildSlug(slugParts);
175
- return renderAuto(slug, search, c);
138
+ return render(buildSlug(slugParts), search, c, mode);
176
139
  }
177
140
  return { Page, generateStaticParams, generateMetadata };
178
141
  }
@@ -1,6 +1,7 @@
1
1
  export declare const DRAFT_SESSION_COOKIE = "editor_draft_session";
2
2
  export declare const DRAFT_SITE_COOKIE = "editor_draft_site_id";
3
3
  export declare const EDITOR_ORIGIN_COOKIE = "editor_origin";
4
+ export declare function single(value: string | string[] | undefined): string | undefined;
4
5
  export declare function normalizeOrigin(value: string | null | undefined): string | undefined;
5
6
  /**
6
7
  * Editor origins this site will talk to.
@@ -1,6 +1,19 @@
1
1
  export const DRAFT_SESSION_COOKIE = "editor_draft_session";
2
2
  export const DRAFT_SITE_COOKIE = "editor_draft_site_id";
3
3
  export const EDITOR_ORIGIN_COOKIE = "editor_origin";
4
+ /*
5
+ * A query value that arrived more than once is not a value. Next hands
6
+ * repeated params over as an array, and every caller here wants the single
7
+ * string or nothing — `?secret=a&secret=b` is not two secrets to try, it is a
8
+ * malformed request.
9
+ *
10
+ * It lives in this leaf module rather than beside `resolveEditorContext`
11
+ * because the framework-agnostic core needs it too, and importing it from the
12
+ * Next module pulled `next/headers` into every runtime that touched the core.
13
+ */
14
+ export function single(value) {
15
+ return typeof value === "string" ? value : undefined;
16
+ }
4
17
  export function normalizeOrigin(value) {
5
18
  const raw = value?.trim();
6
19
  if (!raw)
@@ -1,5 +1,5 @@
1
1
  import type { DraftContext, SearchParamsRecord } from "./types.ts";
2
- export { single } from "./draft-context.ts";
2
+ export { single } from "./draft-common.ts";
3
3
  export type DraftModeAdapter = {
4
4
  isDraftMode: boolean;
5
5
  getCookie: (name: string) => string | undefined;
@@ -1,7 +1,6 @@
1
1
  import { validateDraftSecret } from "@avocadostudio-ai/shared";
2
- import { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE, normalizeOrigin, resolveTrustedEditorOrigin } from "./draft-common.js";
3
- import { single } from "./draft-context.js";
4
- export { single } from "./draft-context.js";
2
+ import { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE, normalizeOrigin, resolveTrustedEditorOrigin, single } from "./draft-common.js";
3
+ export { single } from "./draft-common.js";
5
4
  export async function resolveDraftContextCore(searchParams, adapter, options) {
6
5
  const isDev = process.env.NODE_ENV !== "production";
7
6
  /*
@@ -1,6 +1,6 @@
1
1
  import type { SearchParamsRecord } from "./types.ts";
2
2
  import type { DraftContext } from "./types.ts";
3
- export declare function single(value: string | string[] | undefined): string | undefined;
3
+ export { single } from "./draft-common.ts";
4
4
  export declare function resolveEditorContext(searchParams: SearchParamsRecord, options?: {
5
5
  defaultSession?: string;
6
6
  defaultSiteId?: string;
@@ -1,8 +1,6 @@
1
1
  import { draftMode, cookies } from "next/headers";
2
2
  import { resolveDraftContextCore } from "./draft-context-core.js";
3
- export function single(value) {
4
- return typeof value === "string" ? value : undefined;
5
- }
3
+ export { single } from "./draft-common.js";
6
4
  export async function resolveEditorContext(searchParams, options) {
7
5
  const jar = await cookies();
8
6
  const draft = await draftMode();
@@ -1,2 +1,9 @@
1
+ import type { DraftRouteAdapter } from "./draft-routes-core.ts";
2
+ /**
3
+ * Next's answers to the three things the draft routes need from a framework.
4
+ * Exported because the editor API handler builds its own core and has to hand
5
+ * the same adapter down.
6
+ */
7
+ export declare function createNextDraftAdapter(): DraftRouteAdapter;
1
8
  export declare function createDraftEnableHandler(): (request: Request) => Promise<Response>;
2
9
  export declare function createDraftDisableHandler(): (request: Request) => Promise<Response>;
@@ -1,7 +1,12 @@
1
1
  import { draftMode } from "next/headers";
2
2
  import { NextResponse } from "next/server";
3
3
  import { createDraftEnableHandlerCore, createDraftDisableHandlerCore } from "./draft-routes-core.js";
4
- function createNextAdapter() {
4
+ /**
5
+ * Next's answers to the three things the draft routes need from a framework.
6
+ * Exported because the editor API handler builds its own core and has to hand
7
+ * the same adapter down.
8
+ */
9
+ export function createNextDraftAdapter() {
5
10
  return {
6
11
  enableDraftMode: async () => { (await draftMode()).enable(); },
7
12
  disableDraftMode: async () => { (await draftMode()).disable(); },
@@ -20,8 +25,8 @@ function createNextAdapter() {
20
25
  };
21
26
  }
22
27
  export function createDraftEnableHandler() {
23
- return createDraftEnableHandlerCore(createNextAdapter());
28
+ return createDraftEnableHandlerCore(createNextDraftAdapter());
24
29
  }
25
30
  export function createDraftDisableHandler() {
26
- return createDraftDisableHandlerCore(createNextAdapter());
31
+ return createDraftDisableHandlerCore(createNextDraftAdapter());
27
32
  }
@@ -0,0 +1,85 @@
1
+ import type { DraftRouteAdapter } from "./draft-routes-core.ts";
2
+ import type { OnPublishFn } from "./editor-routes.ts";
3
+ import { type BlockManifest } from "./editor-manifest.ts";
4
+ import type { PageDoc } from "./types.ts";
5
+ import { type SiteConfig } from "@avocadostudio-ai/shared";
6
+ export interface EditorApiHandlerConfig {
7
+ getPages: () => PageDoc[] | Promise<PageDoc[]>;
8
+ /**
9
+ * Optional site-config getter. When provided, `/api/editor/pages` returns
10
+ * `{ pages, siteConfig }` so the orchestrator's publish-diff can show
11
+ * header-chrome changes (name/logo/navLabels/navGroups) alongside page diffs.
12
+ */
13
+ getSiteConfig?: () => SiteConfig | undefined | Promise<SiteConfig | undefined>;
14
+ getManifest?: () => BlockManifest;
15
+ /**
16
+ * Register the site's block schemas before each manifest build. Use this
17
+ * instead of relying on side-effect import order — Next.js's dev bundler
18
+ * does not reliably honor source-order side effects across RSC / SSR / API
19
+ * route layers, so a canonical re-registration from `@avocadostudio-ai/shared`
20
+ * can silently clobber the site's overrides. Called inside `getManifest`,
21
+ * so registration is guaranteed to be last at request time. `registerBlock`
22
+ * is idempotent, so repeated calls are cheap.
23
+ *
24
+ * Composes with `getManifest`: when both are set, `registerBlocks` runs
25
+ * first, then `getManifest` is invoked.
26
+ */
27
+ registerBlocks?: () => void;
28
+ /**
29
+ * The block types this site renders, narrowing `/api/editor/blocks` to them.
30
+ *
31
+ * `createOrchestrator` takes the same option and declares the same catalogue,
32
+ * and for a while that was assumed to be enough for both routes: the
33
+ * declaration lives on `globalThis`, so one handler's call is visible to the
34
+ * other. What that reasoning missed is *when* it runs. A Next route module is
35
+ * evaluated on the first request to that route, so a site whose editor asks
36
+ * for `/api/editor/blocks` before anything has touched `/api/avocado/*` gets
37
+ * the manifest built before `createOrchestrator` has ever been called — and
38
+ * the answer is the site's own blocks plus all of Avocado's built-ins, which
39
+ * the site has no renderer for.
40
+ *
41
+ * Declaring it here closes that window, and passing it to both handlers is
42
+ * harmless: the declaration records names and the manifest is rebuilt per
43
+ * request.
44
+ */
45
+ blockTypes?: readonly string[];
46
+ onPublish?: OnPublishFn;
47
+ /** Secret token required for publish requests. Checked against x-publish-token header. */
48
+ publishSecret?: string;
49
+ }
50
+ export interface EditorApiCoreConfig extends EditorApiHandlerConfig {
51
+ /**
52
+ * How this runtime enables draft mode and builds a redirect. Next has
53
+ * `draftMode()` and `NextResponse.redirect`; every other host has to say so
54
+ * itself, which is the whole of what the draft routes need from a framework.
55
+ */
56
+ draftAdapter: DraftRouteAdapter;
57
+ /**
58
+ * Where the catch-all is mounted, used to derive the route from the URL when
59
+ * the host does not hand over path segments of its own. Next's catch-all
60
+ * supplies them; Astro's `[...path]` also does, but a plain `fetch` handler
61
+ * mounted by hand has only the URL.
62
+ */
63
+ basePath?: string;
64
+ }
65
+ /**
66
+ * The editor API, with nothing framework-shaped left in it.
67
+ *
68
+ * Each handler takes the request and, optionally, the path segments beneath the
69
+ * mount point. A host that has them (any catch-all route) passes them; one that
70
+ * does not gets them derived from the URL against `basePath`. Everything below
71
+ * this line — matching, CORS, the manifest, publish — was already portable; it
72
+ * was reachable only through a signature naming a Next route context.
73
+ *
74
+ * Routes:
75
+ * - `/api/editor/draft` → enable draft mode
76
+ * - `/api/editor/draft/disable` → disable draft mode
77
+ * - `/api/editor/blocks` → block manifest
78
+ * - `/api/editor/pages` → published pages
79
+ * - `/api/editor/publish` → publish content (POST)
80
+ */
81
+ export declare function createEditorApiHandlerCore(config: EditorApiCoreConfig): {
82
+ GET: (request: Request, path?: string[]) => Promise<Response>;
83
+ POST: (request: Request, path?: string[]) => Promise<Response>;
84
+ OPTIONS: (request: Request) => Response;
85
+ };
@@ -0,0 +1,101 @@
1
+ import { createDraftEnableHandlerCore, createDraftDisableHandlerCore } from "./draft-routes-core.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
+ import { declareBlockCatalogue } from "@avocadostudio-ai/shared";
7
+ /**
8
+ * The editor API, with nothing framework-shaped left in it.
9
+ *
10
+ * Each handler takes the request and, optionally, the path segments beneath the
11
+ * mount point. A host that has them (any catch-all route) passes them; one that
12
+ * does not gets them derived from the URL against `basePath`. Everything below
13
+ * this line — matching, CORS, the manifest, publish — was already portable; it
14
+ * was reachable only through a signature naming a Next route context.
15
+ *
16
+ * Routes:
17
+ * - `/api/editor/draft` → enable draft mode
18
+ * - `/api/editor/draft/disable` → disable draft mode
19
+ * - `/api/editor/blocks` → block manifest
20
+ * - `/api/editor/pages` → published pages
21
+ * - `/api/editor/publish` → publish content (POST)
22
+ */
23
+ export function createEditorApiHandlerCore(config) {
24
+ const draftEnable = createDraftEnableHandlerCore(config.draftAdapter);
25
+ const draftDisable = createDraftDisableHandlerCore(config.draftAdapter);
26
+ const basePath = (config.basePath ?? "/api/editor").replace(/\/+$/, "");
27
+ const needsPreamble = Boolean(config.registerBlocks || config.blockTypes);
28
+ const manifestBuilder = needsPreamble
29
+ ? () => {
30
+ config.registerBlocks?.();
31
+ if (config.blockTypes)
32
+ declareBlockCatalogue(config.blockTypes);
33
+ return (config.getManifest ?? buildBlockManifest)();
34
+ }
35
+ : config.getManifest;
36
+ const blocksHandler = createBlocksHandler(manifestBuilder ? { getManifest: manifestBuilder } : undefined);
37
+ const pagesHandler = createPagesHandler(config.getPages, config.getSiteConfig);
38
+ const publishHandler = config.onPublish
39
+ ? createPublishHandler(config.onPublish, { publishSecret: config.publishSecret })
40
+ : null;
41
+ /*
42
+ * `/api/editor/draft/disable` is two segments, so the fallback cannot simply
43
+ * take the last one. Trailing slashes and an exactly-`basePath` request both
44
+ * have to come out as no segments rather than as one empty one.
45
+ */
46
+ function pathFromUrl(request) {
47
+ const { pathname } = new URL(request.url);
48
+ const rest = pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
49
+ return rest.split("/").filter(Boolean);
50
+ }
51
+ function matchRoute(path) {
52
+ const key = path.join("/");
53
+ if (key === "draft")
54
+ return "draft-enable";
55
+ if (key === "draft/disable")
56
+ return "draft-disable";
57
+ if (key === "blocks")
58
+ return "blocks";
59
+ if (key === "pages")
60
+ return "pages";
61
+ if (key === "publish")
62
+ return "publish";
63
+ return "not-found";
64
+ }
65
+ function notFound(request) {
66
+ return applyEditorCors(new Response(JSON.stringify({ error: "Not found" }), {
67
+ status: 404,
68
+ headers: { "Content-Type": "application/json" },
69
+ }), request.headers.get("origin"));
70
+ }
71
+ return {
72
+ async GET(request, path) {
73
+ checkIntegrationOnce();
74
+ const route = matchRoute(path ?? pathFromUrl(request));
75
+ switch (route) {
76
+ case "draft-enable":
77
+ return draftEnable(request);
78
+ case "draft-disable":
79
+ return draftDisable(request);
80
+ case "blocks":
81
+ return blocksHandler.GET(request);
82
+ case "pages":
83
+ return pagesHandler.GET(request);
84
+ default:
85
+ return notFound(request);
86
+ }
87
+ },
88
+ async POST(request, path) {
89
+ const route = matchRoute(path ?? pathFromUrl(request));
90
+ if (route === "publish" && publishHandler) {
91
+ return publishHandler.POST(request);
92
+ }
93
+ return notFound(request);
94
+ },
95
+ OPTIONS(request) {
96
+ // A preflight is answered the same way whatever it is preflighting, so
97
+ // the route is never resolved here.
98
+ return blocksHandler.OPTIONS(request);
99
+ },
100
+ };
101
+ }
@@ -1,51 +1,5 @@
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
- /**
28
- * The block types this site renders, narrowing `/api/editor/blocks` to them.
29
- *
30
- * `createOrchestrator` takes the same option and declares the same catalogue,
31
- * and for a while that was assumed to be enough for both routes: the
32
- * declaration lives on `globalThis`, so one handler's call is visible to the
33
- * other. What that reasoning missed is *when* it runs. A Next route module is
34
- * evaluated on the first request to that route, so a site whose editor asks
35
- * for `/api/editor/blocks` before anything has touched `/api/avocado/*` gets
36
- * the manifest built before `createOrchestrator` has ever been called — and
37
- * the answer is the site's own blocks plus all of Avocado's built-ins, which
38
- * the site has no renderer for.
39
- *
40
- * Declaring it here closes that window, and passing it to both handlers is
41
- * harmless: the declaration records names and the manifest is rebuilt per
42
- * request.
43
- */
44
- blockTypes?: readonly string[];
45
- onPublish?: OnPublishFn;
46
- /** Secret token required for publish requests. Checked against x-publish-token header. */
47
- publishSecret?: string;
48
- }
1
+ import type { EditorApiHandlerConfig } from "./editor-api-handler-core.ts";
2
+ export type { EditorApiHandlerConfig } from "./editor-api-handler-core.ts";
49
3
  type NextRouteContext = {
50
4
  params: Promise<{
51
5
  path: string[];
@@ -68,10 +22,14 @@ type NextRouteContext = {
68
22
  * - `/api/editor/blocks` → block manifest
69
23
  * - `/api/editor/pages` → published pages
70
24
  * - `/api/editor/publish` → publish content (POST)
25
+ *
26
+ * The routing, CORS, manifest and publish behaviour all live in
27
+ * `createEditorApiHandlerCore`, which knows nothing about Next. This wrapper
28
+ * adds the two things that are actually Next's: `draftMode()` and the route
29
+ * context holding the matched path segments.
71
30
  */
72
31
  export declare function createEditorApiHandler(config: EditorApiHandlerConfig): {
73
32
  GET: (request: Request, context: NextRouteContext) => Promise<Response>;
74
33
  POST: (request: Request, context: NextRouteContext) => Promise<Response>;
75
34
  OPTIONS: (request: Request, context: NextRouteContext) => Response;
76
35
  };
77
- export {};
@@ -1,9 +1,5 @@
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
- import { declareBlockCatalogue } from "@avocadostudio-ai/shared";
1
+ import { createNextDraftAdapter } from "./draft-routes.js";
2
+ import { createEditorApiHandlerCore } from "./editor-api-handler-core.js";
7
3
  /**
8
4
  * Creates a single catch-all route handler that serves all editor API endpoints.
9
5
  *
@@ -21,73 +17,23 @@ import { declareBlockCatalogue } from "@avocadostudio-ai/shared";
21
17
  * - `/api/editor/blocks` → block manifest
22
18
  * - `/api/editor/pages` → published pages
23
19
  * - `/api/editor/publish` → publish content (POST)
20
+ *
21
+ * The routing, CORS, manifest and publish behaviour all live in
22
+ * `createEditorApiHandlerCore`, which knows nothing about Next. This wrapper
23
+ * adds the two things that are actually Next's: `draftMode()` and the route
24
+ * context holding the matched path segments.
24
25
  */
25
26
  export function createEditorApiHandler(config) {
26
- const draftEnable = createDraftEnableHandler();
27
- const draftDisable = createDraftDisableHandler();
28
- const needsPreamble = Boolean(config.registerBlocks || config.blockTypes);
29
- const manifestBuilder = needsPreamble
30
- ? () => {
31
- config.registerBlocks?.();
32
- if (config.blockTypes)
33
- declareBlockCatalogue(config.blockTypes);
34
- return (config.getManifest ?? buildBlockManifest)();
35
- }
36
- : config.getManifest;
37
- const blocksHandler = createBlocksHandler(manifestBuilder ? { getManifest: manifestBuilder } : undefined);
38
- const pagesHandler = createPagesHandler(config.getPages, config.getSiteConfig);
39
- const publishHandler = config.onPublish
40
- ? createPublishHandler(config.onPublish, { publishSecret: config.publishSecret })
41
- : null;
42
- function matchRoute(path) {
43
- const key = path.join("/");
44
- if (key === "draft")
45
- return "draft-enable";
46
- if (key === "draft/disable")
47
- return "draft-disable";
48
- if (key === "blocks")
49
- return "blocks";
50
- if (key === "pages")
51
- return "pages";
52
- if (key === "publish")
53
- return "publish";
54
- return "not-found";
55
- }
56
- function notFound(request) {
57
- return applyEditorCors(new Response(JSON.stringify({ error: "Not found" }), {
58
- status: 404,
59
- headers: { "Content-Type": "application/json" },
60
- }), request.headers.get("origin"));
61
- }
27
+ const core = createEditorApiHandlerCore({ ...config, draftAdapter: createNextDraftAdapter() });
62
28
  return {
63
29
  async GET(request, context) {
64
- checkIntegrationOnce();
65
- const { path } = await context.params;
66
- const route = matchRoute(path);
67
- switch (route) {
68
- case "draft-enable":
69
- return draftEnable(request);
70
- case "draft-disable":
71
- return draftDisable(request);
72
- case "blocks":
73
- return blocksHandler.GET(request);
74
- case "pages":
75
- return pagesHandler.GET(request);
76
- default:
77
- return notFound(request);
78
- }
30
+ return core.GET(request, (await context.params).path);
79
31
  },
80
32
  async POST(request, context) {
81
- const { path } = await context.params;
82
- const route = matchRoute(path);
83
- if (route === "publish" && publishHandler) {
84
- return publishHandler.POST(request);
85
- }
86
- return notFound(request);
33
+ return core.POST(request, (await context.params).path);
87
34
  },
88
- OPTIONS(request, context) {
89
- // OPTIONS doesn't need async params resolution for CORS preflight
90
- return blocksHandler.OPTIONS(request);
35
+ OPTIONS(request) {
36
+ return core.OPTIONS(request);
91
37
  },
92
38
  };
93
39
  }