@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.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +145 -0
  3. package/dist/cli/register.d.ts +33 -0
  4. package/dist/cli/register.js +315 -0
  5. package/dist/create-site-page.d.ts +95 -0
  6. package/dist/create-site-page.js +127 -0
  7. package/dist/draft-common.d.ts +4 -0
  8. package/dist/draft-common.js +17 -0
  9. package/dist/draft-context-core.d.ts +11 -0
  10. package/dist/draft-context-core.js +26 -0
  11. package/dist/draft-context.d.ts +8 -0
  12. package/dist/draft-context.js +14 -0
  13. package/dist/draft-fetch.d.ts +14 -0
  14. package/dist/draft-fetch.js +115 -0
  15. package/dist/draft-routes-core.d.ts +11 -0
  16. package/dist/draft-routes-core.js +41 -0
  17. package/dist/draft-routes.d.ts +2 -0
  18. package/dist/draft-routes.js +27 -0
  19. package/dist/draft.d.ts +3 -0
  20. package/dist/draft.js +6 -0
  21. package/dist/editor-api-handler.d.ts +59 -0
  22. package/dist/editor-api-handler.js +89 -0
  23. package/dist/editor-cors.d.ts +3 -0
  24. package/dist/editor-cors.js +31 -0
  25. package/dist/editor-manifest.d.ts +3 -0
  26. package/dist/editor-manifest.js +65 -0
  27. package/dist/editor-overlay-inner.d.ts +5 -0
  28. package/dist/editor-overlay-inner.js +7 -0
  29. package/dist/editor-overlay.d.ts +4 -0
  30. package/dist/editor-overlay.js +14 -0
  31. package/dist/editor-query.d.ts +2 -0
  32. package/dist/editor-query.js +16 -0
  33. package/dist/editor-routes.d.ts +35 -0
  34. package/dist/editor-routes.js +66 -0
  35. package/dist/editor.d.ts +20 -0
  36. package/dist/editor.js +22 -0
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.js +5 -0
  39. package/dist/integration-check.d.ts +5 -0
  40. package/dist/integration-check.js +29 -0
  41. package/dist/live-preview-blocks.d.ts +5 -0
  42. package/dist/live-preview-blocks.js +30 -0
  43. package/dist/manifest-utils.d.ts +17 -0
  44. package/dist/manifest-utils.js +44 -0
  45. package/dist/middleware.d.ts +37 -0
  46. package/dist/middleware.js +34 -0
  47. package/dist/navigation.d.ts +48 -0
  48. package/dist/navigation.js +95 -0
  49. package/dist/publish-handlers/json-file.d.ts +24 -0
  50. package/dist/publish-handlers/json-file.js +40 -0
  51. package/dist/publish-utils.d.ts +28 -0
  52. package/dist/publish-utils.js +92 -0
  53. package/dist/render-blocks.d.ts +4 -0
  54. package/dist/render-blocks.js +26 -0
  55. package/dist/revalidate-handler.d.ts +42 -0
  56. package/dist/revalidate-handler.js +77 -0
  57. package/dist/routes.d.ts +10 -0
  58. package/dist/routes.js +12 -0
  59. package/dist/server/orchestrator.d.ts +117 -0
  60. package/dist/server/orchestrator.js +733 -0
  61. package/dist/types.d.ts +8 -0
  62. package/dist/types.js +1 -0
  63. package/package.json +104 -0
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `avocado-register` — register a Next.js site with an Avocado Studio
4
+ * orchestrator without going through the editor UI.
5
+ *
6
+ * Designed to be the final step of the "bring your own coding agent" path:
7
+ * after Codex / Claude Code / Cursor / etc. has wired the @avocadostudio-ai/site-sdk
8
+ * into the user's Next.js project, this CLI is what makes the site appear in
9
+ * the editor's dashboard.
10
+ *
11
+ * What it does:
12
+ * 1. Parses CLI args.
13
+ * 2. Reads `.env.local` in the target project, looks for an existing
14
+ * DRAFT_MODE_SECRET. If missing, generates a cryptographically random
15
+ * secret (32 random bytes, hex-encoded) and writes it to `.env.local`.
16
+ * Also fills in ORCHESTRATOR_URL and the NEXT_PUBLIC_* vars if absent.
17
+ * 3. POSTs the site config to `<ORCHESTRATOR_URL>/sites/register`.
18
+ * 4. Prints next steps (and any warnings the orchestrator returned about
19
+ * secret mismatches with the editor's build-time config).
20
+ *
21
+ * Usage:
22
+ * npx @avocadostudio-ai/site-sdk register --name "My Site"
23
+ * npx @avocadostudio-ai/site-sdk register --id my-site --name "My Site" --port 3000
24
+ * npx @avocadostudio-ai/site-sdk register --help
25
+ *
26
+ * Defaults:
27
+ * --id kebab-case of --name (or the project's package.json `name`)
28
+ * --port parsed from `scripts.dev` in package.json, falls back to 3000
29
+ * --orchestrator $ORCHESTRATOR_URL or http://localhost:4200
30
+ * --session dev
31
+ * --cwd process.cwd()
32
+ */
33
+ import { randomBytes } from "node:crypto";
34
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { join, resolve } from "node:path";
36
+ function parseArgs(argv) {
37
+ const out = {};
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ const next = () => argv[++i];
41
+ switch (a) {
42
+ case "--id":
43
+ out.id = next();
44
+ break;
45
+ case "--name":
46
+ out.name = next();
47
+ break;
48
+ case "--port":
49
+ out.port = Number(next());
50
+ break;
51
+ case "--orchestrator":
52
+ case "--orchestrator-url":
53
+ out.orchestrator = next();
54
+ break;
55
+ case "--secret":
56
+ out.secret = next();
57
+ break;
58
+ case "--session":
59
+ out.session = next();
60
+ break;
61
+ case "--cwd":
62
+ out.cwd = next();
63
+ break;
64
+ case "--purpose":
65
+ out.purpose = next();
66
+ break;
67
+ case "--preview-url":
68
+ out.previewUrl = next();
69
+ break;
70
+ case "-h":
71
+ case "--help":
72
+ out.help = true;
73
+ break;
74
+ default:
75
+ if (a.startsWith("--")) {
76
+ process.stderr.write(`Unknown flag: ${a}\n`);
77
+ process.exit(2);
78
+ }
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ function printHelp() {
84
+ process.stdout.write(`
85
+ avocado-register — register a Next.js site with an Avocado Studio orchestrator
86
+
87
+ USAGE
88
+ npx @avocadostudio-ai/site-sdk register [options]
89
+
90
+ REQUIRED
91
+ --name <string> Human-readable site name (or read from package.json name)
92
+
93
+ OPTIONAL
94
+ --id <kebab-case> Site ID (default: kebab-case of name or package.json name)
95
+ --port <number> Dev server port (default: parsed from package.json scripts.dev, or 3000)
96
+ --orchestrator <url> Orchestrator URL (default: $ORCHESTRATOR_URL or http://localhost:4200)
97
+ --secret <string> DRAFT_MODE_SECRET (default: read from .env.local, or generate random)
98
+ --session <string> Orchestrator session (default: dev)
99
+ --purpose <string> One-line site description for AI context
100
+ --preview-url <url> Preview URL (default: http://localhost:<port>)
101
+ --cwd <path> Project directory (default: current working directory)
102
+ -h, --help Show this help
103
+
104
+ EXAMPLES
105
+ # Register the site in the current directory, auto-detect everything
106
+ npx @avocadostudio-ai/site-sdk register --name "Marketing Site"
107
+
108
+ # Register a site with explicit values
109
+ npx @avocadostudio-ai/site-sdk register \\
110
+ --id marketing-site \\
111
+ --name "Marketing Site" \\
112
+ --port 3000 \\
113
+ --orchestrator http://localhost:4200
114
+
115
+ NEXT STEPS
116
+ After running this command, refresh the editor at http://localhost:4100
117
+ and the site will appear in the dashboard.
118
+ `);
119
+ }
120
+ function kebabCase(s) {
121
+ return s
122
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
123
+ .replace(/[\s_]+/g, "-")
124
+ .replace(/[^a-zA-Z0-9-]/g, "")
125
+ .toLowerCase()
126
+ .replace(/-+/g, "-")
127
+ .replace(/^-|-$/g, "");
128
+ }
129
+ function readPackageJson(cwd) {
130
+ const path = join(cwd, "package.json");
131
+ if (!existsSync(path))
132
+ return null;
133
+ try {
134
+ return JSON.parse(readFileSync(path, "utf-8"));
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ function detectPortFromPackageJson(pkg) {
141
+ const dev = pkg?.scripts?.dev;
142
+ if (!dev)
143
+ return null;
144
+ // Look for `-p <port>` or `--port <port>` or `--port=<port>`
145
+ const m = dev.match(/(?:-p|--port)[\s=](\d+)/);
146
+ if (m)
147
+ return Number(m[1]);
148
+ return null;
149
+ }
150
+ function parseEnvFile(content) {
151
+ const out = {};
152
+ for (const line of content.split("\n")) {
153
+ const trimmed = line.trim();
154
+ if (!trimmed || trimmed.startsWith("#"))
155
+ continue;
156
+ const eq = trimmed.indexOf("=");
157
+ if (eq === -1)
158
+ continue;
159
+ const k = trimmed.slice(0, eq).trim();
160
+ let v = trimmed.slice(eq + 1).trim();
161
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
162
+ v = v.slice(1, -1);
163
+ }
164
+ out[k] = v;
165
+ }
166
+ return out;
167
+ }
168
+ /**
169
+ * Append-only env merge: read the file as raw text, append `KEY=value` lines
170
+ * for any keys not already present, and write back. Preserves the user's
171
+ * original formatting (comments, key order, blank lines, hand-tuned values).
172
+ *
173
+ * The previous round-trip approach (parse → merge → serialize) silently
174
+ * dropped comments and reordered keys, which is real data loss for users
175
+ * with hand-curated `.env.local` files.
176
+ */
177
+ function mergeEnvFile(envPath, existing, additions) {
178
+ const added = [];
179
+ const newLines = [];
180
+ for (const [k, v] of Object.entries(additions)) {
181
+ if (existing[k] === undefined) {
182
+ newLines.push(`${k}=${v}`);
183
+ added.push(k);
184
+ }
185
+ }
186
+ if (newLines.length === 0)
187
+ return added;
188
+ let original = "";
189
+ if (existsSync(envPath)) {
190
+ original = readFileSync(envPath, "utf-8");
191
+ if (original.length > 0 && !original.endsWith("\n"))
192
+ original += "\n";
193
+ }
194
+ writeFileSync(envPath, original + newLines.join("\n") + "\n", "utf-8");
195
+ return added;
196
+ }
197
+ async function main() {
198
+ const args = parseArgs(process.argv.slice(2));
199
+ if (args.help) {
200
+ printHelp();
201
+ return;
202
+ }
203
+ const cwd = resolve(args.cwd ?? process.cwd());
204
+ const pkg = readPackageJson(cwd);
205
+ // Resolve name
206
+ const name = args.name ?? (pkg?.name ? humanize(pkg.name) : null);
207
+ if (!name) {
208
+ process.stderr.write("Error: --name is required (or run from a directory with a package.json that has a `name` field).\n");
209
+ process.exit(1);
210
+ }
211
+ // Resolve siteId
212
+ const siteId = args.id ?? kebabCase(pkg?.name ?? name);
213
+ if (!siteId) {
214
+ process.stderr.write("Error: could not derive a site ID from --id, --name, or package.json. Pass --id explicitly.\n");
215
+ process.exit(1);
216
+ }
217
+ // Resolve port
218
+ const port = args.port ?? detectPortFromPackageJson(pkg) ?? 3000;
219
+ // Resolve orchestrator URL
220
+ const orchestrator = (args.orchestrator ?? process.env.ORCHESTRATOR_URL ?? "http://localhost:4200").replace(/\/+$/, "");
221
+ // Resolve / generate the draft secret
222
+ const envPath = join(cwd, ".env.local");
223
+ const existingEnv = existsSync(envPath) ? parseEnvFile(readFileSync(envPath, "utf-8")) : {};
224
+ let secret = args.secret ?? existingEnv.DRAFT_MODE_SECRET;
225
+ let secretGenerated = false;
226
+ if (!secret) {
227
+ secret = randomBytes(32).toString("hex");
228
+ secretGenerated = true;
229
+ }
230
+ // Append-only merge — only add keys that aren't already present, preserving
231
+ // the user's existing file formatting and comments.
232
+ const added = mergeEnvFile(envPath, existingEnv, {
233
+ ORCHESTRATOR_URL: orchestrator,
234
+ DRAFT_MODE_SECRET: secret,
235
+ NEXT_PUBLIC_DEFAULT_SITE_ID: siteId,
236
+ NEXT_PUBLIC_SITE_NAME: name,
237
+ NEXT_PUBLIC_EDITOR_ORIGIN: "http://localhost:4100",
238
+ });
239
+ // POST to the orchestrator with a 10s timeout — without it, a hung
240
+ // orchestrator (mid-handler stall, not just connection-refused) would freeze
241
+ // the CLI indefinitely with no feedback.
242
+ const previewUrl = args.previewUrl ?? `http://localhost:${port}`;
243
+ const ac = new AbortController();
244
+ const timeoutId = setTimeout(() => ac.abort(), 10_000);
245
+ let response;
246
+ try {
247
+ response = await fetch(`${orchestrator}/sites/register`, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({
251
+ siteId,
252
+ name,
253
+ port,
254
+ previewUrl,
255
+ purpose: args.purpose,
256
+ secret,
257
+ session: args.session ?? "dev",
258
+ }),
259
+ signal: ac.signal,
260
+ });
261
+ }
262
+ catch (err) {
263
+ const aborted = err.name === "AbortError";
264
+ process.stderr.write(`\nError: could not reach the orchestrator at ${orchestrator}\n`);
265
+ process.stderr.write(` ${aborted ? "Request timed out after 10 seconds." : err.message}\n`);
266
+ process.stderr.write(`\nMake sure the orchestrator is running:\n pnpm dev:orchestrator\n`);
267
+ process.exit(1);
268
+ }
269
+ finally {
270
+ clearTimeout(timeoutId);
271
+ }
272
+ if (!response.ok) {
273
+ const text = await response.text();
274
+ process.stderr.write(`\nOrchestrator responded ${response.status}:\n ${text}\n`);
275
+ process.exit(1);
276
+ }
277
+ const result = (await response.json());
278
+ // Friendly output
279
+ process.stdout.write(`\nRegistered "${name}" with the orchestrator.\n`);
280
+ process.stdout.write(` Site ID: ${siteId}\n`);
281
+ process.stdout.write(` Preview URL: ${previewUrl}\n`);
282
+ process.stdout.write(` Orchestrator: ${orchestrator}\n`);
283
+ if (secretGenerated) {
284
+ process.stdout.write(` Secret: generated (${secret.slice(0, 8)}…)\n`);
285
+ }
286
+ else {
287
+ process.stdout.write(` Secret: reused from .env.local\n`);
288
+ }
289
+ if (added.length > 0) {
290
+ process.stdout.write(`\n.env.local updated. Added: ${added.join(", ")}\n`);
291
+ }
292
+ else {
293
+ process.stdout.write(`\n.env.local unchanged (all keys already present).\n`);
294
+ }
295
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
296
+ process.stdout.write(`\nWarnings:\n`);
297
+ for (const w of result.warnings) {
298
+ process.stdout.write(` - ${w}\n`);
299
+ }
300
+ }
301
+ process.stdout.write(`\nNext steps:\n`);
302
+ process.stdout.write(` 1. Start your site: pnpm dev (in this directory)\n`);
303
+ process.stdout.write(` 2. Open the editor: http://localhost:4100\n`);
304
+ process.stdout.write(` 3. The site should appear in the dashboard. If not, refresh the page.\n\n`);
305
+ }
306
+ function humanize(s) {
307
+ return s
308
+ .replace(/[-_]/g, " ")
309
+ .replace(/\b\w/g, (c) => c.toUpperCase())
310
+ .trim();
311
+ }
312
+ main().catch((err) => {
313
+ process.stderr.write(`\nUnexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
314
+ process.exit(1);
315
+ });
@@ -0,0 +1,95 @@
1
+ import type { JSX } from "react";
2
+ import type { BlockInstance } from "./types.ts";
3
+ import type { SiteConfig } from "@avocadostudio-ai/shared";
4
+ import type { PageDoc } from "@avocadostudio-ai/shared";
5
+ /**
6
+ * Configuration for creating a site page component.
7
+ *
8
+ * Provide your CMS-specific data fetchers and the factory handles:
9
+ * - Draft/editor mode detection and switching
10
+ * - Navigation header and footer chrome
11
+ * - Editor overlay for live editing
12
+ * - Static params generation for build-time rendering
13
+ * - 404 and draft-unavailable fallbacks
14
+ *
15
+ * @example Single-route ("auto") setup — everything in one [[...slug]] route
16
+ * ```ts
17
+ * // app/[[...slug]]/page.tsx
18
+ * import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
19
+ * import { getPage, getSlugs, getSiteConfig } from "../../lib/my-cms"
20
+ *
21
+ * const { Page, generateStaticParams } = createSitePage({
22
+ * siteId: "my-site",
23
+ * getPage,
24
+ * getSlugs,
25
+ * getSiteConfig,
26
+ * })
27
+ *
28
+ * export default Page
29
+ * export { generateStaticParams }
30
+ * ```
31
+ *
32
+ * @example Split-route setup — fully static published path + dynamic preview path
33
+ * ```ts
34
+ * // middleware.ts
35
+ * import { createEditorMiddleware } from "@avocadostudio-ai/site-sdk/middleware"
36
+ * export const { middleware, config } = createEditorMiddleware()
37
+ *
38
+ * // app/[[...slug]]/page.tsx (statically generated)
39
+ * const { Page, generateStaticParams } = createSitePage({ mode: "static", ... })
40
+ * export default Page
41
+ * export { generateStaticParams }
42
+ *
43
+ * // app/preview-draft/[[...slug]]/page.tsx (dynamic editor route)
44
+ * export const dynamic = "force-dynamic"
45
+ * const { Page } = createSitePage({ mode: "preview", ... })
46
+ * export default Page
47
+ * ```
48
+ */
49
+ export type SitePageConfig = {
50
+ /** Unique site identifier (used for session scoping) */
51
+ siteId: string;
52
+ /** Default session name. Defaults to "dev". */
53
+ session?: string;
54
+ /** Fetch a single page by slug from your CMS */
55
+ getPage: (slug: string) => Promise<PageDoc | null>;
56
+ /** Fetch all page slugs from your CMS (for static generation) */
57
+ getSlugs: () => Promise<string[]>;
58
+ /** Fetch site-wide config (name, logo, nav labels) from your CMS */
59
+ getSiteConfig?: () => Promise<SiteConfig>;
60
+ /** Default logo path. Defaults to "/logo.svg". */
61
+ defaultLogo?: string;
62
+ /** Footer block to render below content. Omit to hide footer. */
63
+ footer?: BlockInstance;
64
+ /** Render site header/footer chrome. Set false when the host layout provides its own. Defaults to true. */
65
+ chrome?: boolean;
66
+ /**
67
+ * Rendering mode. Defaults to `"auto"`.
68
+ *
69
+ * - `"auto"`: single-route — handles published and editor modes in one file. Works without middleware.
70
+ * - `"static"`: published-only — no searchParams, no editor logic, fully static. Pair with `createEditorMiddleware()` and a separate `mode: "preview"` route.
71
+ * - `"preview"`: editor-only — always reads searchParams + draft content. Set `export const dynamic = "force-dynamic"` on the route file.
72
+ */
73
+ mode?: "auto" | "static" | "preview";
74
+ };
75
+ type StaticPageProps = {
76
+ params: Promise<{
77
+ slug?: string[];
78
+ }>;
79
+ };
80
+ type DynamicPageProps = StaticPageProps & {
81
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
82
+ };
83
+ /**
84
+ * Create a Next.js page component with full editor integration.
85
+ *
86
+ * Returns `{ Page, generateStaticParams }` — export them from your route file.
87
+ * See {@link SitePageConfig.mode} for the three rendering modes.
88
+ */
89
+ export declare function createSitePage(config: SitePageConfig): {
90
+ Page: ({ params, searchParams }: DynamicPageProps) => Promise<JSX.Element>;
91
+ generateStaticParams: () => Promise<{
92
+ slug: string[] | undefined;
93
+ }[]>;
94
+ };
95
+ export {};
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { unstable_noStore as noStore } from "next/cache";
3
+ import { draftMode } from "next/headers";
4
+ import { SharedBlockRenderer, BlocksHydrator } from "@avocadostudio-ai/blocks";
5
+ import { buildSlug } from "./index.js";
6
+ import { resolveEditorContext, fetchEditorPage, fetchEditorSlugs } from "./draft.js";
7
+ import { renderBlocks, EditorOverlay } from "./editor.js";
8
+ import { buildNavItems, buildSiteHeaderBlock } from "./navigation.js";
9
+ function resolve(config) {
10
+ return {
11
+ siteId: config.siteId,
12
+ defaultSession: config.session ?? "dev",
13
+ cmsGetPage: config.getPage,
14
+ cmsGetSlugs: config.getSlugs,
15
+ cmsGetSiteConfig: config.getSiteConfig,
16
+ defaultLogo: config.defaultLogo ?? "/logo.svg",
17
+ footer: config.footer,
18
+ chrome: config.chrome ?? true,
19
+ };
20
+ }
21
+ function makeGenerateStaticParams(cmsGetSlugs) {
22
+ return async function generateStaticParams() {
23
+ const slugs = await cmsGetSlugs();
24
+ return slugs.map((slug) => ({
25
+ slug: slug === "/" ? undefined : slug.replace(/^\//, "").split("/"),
26
+ }));
27
+ };
28
+ }
29
+ async function renderStatic(slug, c) {
30
+ const [page, navSlugs, siteConfig] = await Promise.all([
31
+ c.cmsGetPage(slug),
32
+ c.cmsGetSlugs(),
33
+ c.cmsGetSiteConfig ? c.cmsGetSiteConfig() : Promise.resolve({}),
34
+ ]);
35
+ const { navItems, siteName, siteLogo } = buildNavItems({
36
+ navSlugs,
37
+ currentSlug: slug,
38
+ siteConfig,
39
+ siteId: c.siteId,
40
+ editorQuery: "",
41
+ defaultLogo: c.defaultLogo,
42
+ });
43
+ const chromeHeader = buildSiteHeaderBlock({ navItems, siteName, siteLogo, activePath: slug });
44
+ if (!page) {
45
+ return (_jsxs(_Fragment, { children: [c.chrome && _jsx(SharedBlockRenderer, { block: chromeHeader }), _jsxs("main", { style: { padding: "4rem", textAlign: "center" }, children: [_jsx("h1", { children: "404" }), _jsx("p", { children: "Page not found." })] }), c.chrome && c.footer ? _jsx(SharedBlockRenderer, { block: c.footer }) : null] }));
46
+ }
47
+ 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] }));
48
+ }
49
+ async function renderPreview(slug, search, c) {
50
+ noStore();
51
+ const editorCtx = await resolveEditorContext(search, {
52
+ defaultSession: c.defaultSession,
53
+ defaultSiteId: c.siteId,
54
+ });
55
+ const session = editorCtx?.session ?? c.defaultSession;
56
+ const currentSiteId = editorCtx?.siteId ?? c.siteId;
57
+ const [page, navSlugs, siteConfig] = await Promise.all([
58
+ fetchEditorPage(slug, session, currentSiteId).then((p) => p ?? c.cmsGetPage(slug)),
59
+ fetchEditorSlugs(session, currentSiteId).then((s) => (s.length > 0 ? s : c.cmsGetSlugs())),
60
+ c.cmsGetSiteConfig ? c.cmsGetSiteConfig() : Promise.resolve({}),
61
+ ]);
62
+ const editorOrigin = editorCtx?.editorOrigin ?? "";
63
+ const editorQuery = (() => {
64
+ const p = new URLSearchParams({ session, siteId: currentSiteId });
65
+ if (editorOrigin)
66
+ p.set("editorOrigin", editorOrigin);
67
+ return `?${p.toString()}`;
68
+ })();
69
+ const { navItems, siteName, siteLogo } = buildNavItems({
70
+ navSlugs,
71
+ currentSlug: slug,
72
+ siteConfig,
73
+ siteId: c.siteId,
74
+ editorQuery,
75
+ defaultLogo: c.defaultLogo,
76
+ });
77
+ const chromeHeader = buildSiteHeaderBlock({ navItems, siteName, siteLogo, activePath: slug });
78
+ if (!page) {
79
+ 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] }));
80
+ }
81
+ 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 })] }));
82
+ }
83
+ async function renderAuto(slug, search, c) {
84
+ const editorCtx = await resolveEditorContext(search, {
85
+ defaultSession: c.defaultSession,
86
+ defaultSiteId: c.siteId,
87
+ });
88
+ const draft = await draftMode();
89
+ const editorMode = draft.isEnabled || !!editorCtx;
90
+ if (editorMode) {
91
+ return renderPreview(slug, search, c);
92
+ }
93
+ return renderStatic(slug, c);
94
+ }
95
+ /**
96
+ * Create a Next.js page component with full editor integration.
97
+ *
98
+ * Returns `{ Page, generateStaticParams }` — export them from your route file.
99
+ * See {@link SitePageConfig.mode} for the three rendering modes.
100
+ */
101
+ export function createSitePage(config) {
102
+ const c = resolve(config);
103
+ const mode = config.mode ?? "auto";
104
+ const generateStaticParams = makeGenerateStaticParams(c.cmsGetSlugs);
105
+ if (mode === "static") {
106
+ async function Page({ params }) {
107
+ const { slug: slugParts } = await params;
108
+ const slug = buildSlug(slugParts);
109
+ return renderStatic(slug, c);
110
+ }
111
+ return { Page, generateStaticParams };
112
+ }
113
+ if (mode === "preview") {
114
+ async function Page({ params, searchParams }) {
115
+ const [{ slug: slugParts }, search] = await Promise.all([params, searchParams]);
116
+ const slug = buildSlug(slugParts);
117
+ return renderPreview(slug, search, c);
118
+ }
119
+ return { Page, generateStaticParams };
120
+ }
121
+ async function Page({ params, searchParams }) {
122
+ const [{ slug: slugParts }, search] = await Promise.all([params, searchParams]);
123
+ const slug = buildSlug(slugParts);
124
+ return renderAuto(slug, search, c);
125
+ }
126
+ return { Page, generateStaticParams };
127
+ }
@@ -0,0 +1,4 @@
1
+ export declare const DRAFT_SESSION_COOKIE = "editor_draft_session";
2
+ export declare const DRAFT_SITE_COOKIE = "editor_draft_site_id";
3
+ export declare const EDITOR_ORIGIN_COOKIE = "editor_origin";
4
+ export declare function normalizeOrigin(value: string | null | undefined): string | undefined;
@@ -0,0 +1,17 @@
1
+ export const DRAFT_SESSION_COOKIE = "editor_draft_session";
2
+ export const DRAFT_SITE_COOKIE = "editor_draft_site_id";
3
+ export const EDITOR_ORIGIN_COOKIE = "editor_origin";
4
+ export function normalizeOrigin(value) {
5
+ const raw = value?.trim();
6
+ if (!raw)
7
+ return undefined;
8
+ try {
9
+ const parsed = new URL(decodeURIComponent(raw));
10
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
11
+ return undefined;
12
+ return parsed.origin;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { DraftContext, SearchParamsRecord } from "./types.ts";
2
+ export { single } from "./draft-context.ts";
3
+ export type DraftModeAdapter = {
4
+ isDraftMode: boolean;
5
+ getCookie: (name: string) => string | undefined;
6
+ };
7
+ export declare function resolveDraftContextCore(searchParams: SearchParamsRecord, adapter: DraftModeAdapter, options?: {
8
+ defaultSession?: string;
9
+ defaultSiteId?: string;
10
+ defaultEditorOrigin?: string;
11
+ }): Promise<DraftContext | null>;
@@ -0,0 +1,26 @@
1
+ import { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE, normalizeOrigin } from "./draft-common.js";
2
+ import { single } from "./draft-context.js";
3
+ export { single } from "./draft-context.js";
4
+ export async function resolveDraftContextCore(searchParams, adapter, options) {
5
+ const isDev = process.env.NODE_ENV !== "production";
6
+ const isEditorParam = single(searchParams.__editor) === "1";
7
+ const isContentStoreEnabled = isDev || adapter.isDraftMode || isEditorParam;
8
+ if (!isContentStoreEnabled)
9
+ return null;
10
+ const defaultSession = options?.defaultSession ?? process.env.DRAFT_DEFAULT_SESSION?.trim() ?? "dev";
11
+ const defaultSiteId = options?.defaultSiteId ?? process.env.DRAFT_DEFAULT_SITE_ID?.trim() ?? "";
12
+ const defaultEditorOrigin = options?.defaultEditorOrigin
13
+ ?? process.env.NEXT_PUBLIC_EDITOR_ORIGIN?.replace(/\/+$/, "")
14
+ ?? (isDev ? "http://localhost:4100" : "");
15
+ // Query param (from editor iframe) wins, then the site's own configured siteId,
16
+ // then cookie fallback. This prevents stale cookies from a different site
17
+ // overriding the current site's identity when accessed directly.
18
+ const siteId = single(searchParams.siteId) ?? (defaultSiteId || adapter.getCookie(DRAFT_SITE_COOKIE)?.trim()) ?? "";
19
+ if (!siteId)
20
+ return null;
21
+ const session = single(searchParams.session) ?? adapter.getCookie(DRAFT_SESSION_COOKIE)?.trim() ?? defaultSession;
22
+ const editorOrigin = normalizeOrigin(single(searchParams.editorOrigin))
23
+ ?? normalizeOrigin(adapter.getCookie(EDITOR_ORIGIN_COOKIE))
24
+ ?? defaultEditorOrigin;
25
+ return { session, siteId, editorOrigin };
26
+ }
@@ -0,0 +1,8 @@
1
+ import type { SearchParamsRecord } from "./types.ts";
2
+ import type { DraftContext } from "./types.ts";
3
+ export declare function single(value: string | string[] | undefined): string | undefined;
4
+ export declare function resolveEditorContext(searchParams: SearchParamsRecord, options?: {
5
+ defaultSession?: string;
6
+ defaultSiteId?: string;
7
+ defaultEditorOrigin?: string;
8
+ }): Promise<DraftContext | null>;
@@ -0,0 +1,14 @@
1
+ import { draftMode, cookies } from "next/headers";
2
+ import { resolveDraftContextCore } from "./draft-context-core.js";
3
+ export function single(value) {
4
+ return typeof value === "string" ? value : undefined;
5
+ }
6
+ export async function resolveEditorContext(searchParams, options) {
7
+ const jar = await cookies();
8
+ const draft = await draftMode();
9
+ const adapter = {
10
+ isDraftMode: draft.isEnabled,
11
+ getCookie: (name) => jar.get(name)?.value,
12
+ };
13
+ return resolveDraftContextCore(searchParams, adapter, options);
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
2
+ export declare function getOrchestratorUrl(): string | null;
3
+ export declare function fetchEditorPage(slug: string, session: string, siteId: string, options?: {
4
+ timeoutMs?: number;
5
+ orchestratorUrl?: string;
6
+ }): Promise<PageDoc | null>;
7
+ export declare function fetchEditorSlugs(session: string, siteId: string, options?: {
8
+ timeoutMs?: number;
9
+ orchestratorUrl?: string;
10
+ }): Promise<string[]>;
11
+ export declare function fetchEditorSiteConfig(session: string, siteId: string, options?: {
12
+ timeoutMs?: number;
13
+ orchestratorUrl?: string;
14
+ }): Promise<SiteConfig>;