@avocadostudio-ai/site-sdk 0.9.0 → 0.11.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.
@@ -72,6 +72,20 @@ export type SitePageConfig = {
72
72
  * config fetch to decorate one Open Graph field.
73
73
  */
74
74
  siteName?: string;
75
+ /**
76
+ * The site's public origin, e.g. `https://example.com`.
77
+ *
78
+ * Supplying it turns on the three tags a page cannot derive from its own
79
+ * content, because none of them is knowable without knowing where the site
80
+ * lives: `<link rel="canonical">`, `og:url`, and an `og:image` resolved to an
81
+ * absolute URL. Without it the SDK emits none of them rather than guessing —
82
+ * a wrong canonical is worse than an absent one.
83
+ *
84
+ * Reading it from an environment variable at the call site is the intended
85
+ * shape (`process.env.NEXT_PUBLIC_SITE_URL`), so preview deployments
86
+ * self-describe instead of all claiming to be production.
87
+ */
88
+ siteUrl?: string;
75
89
  /**
76
90
  * Last word on a page's metadata. Receives what the SDK derived and the page
77
91
  * it derived it from (`null` when the slug has no page), and returns what to
@@ -19,9 +19,22 @@ function resolve(config) {
19
19
  footer: config.footer,
20
20
  chrome: config.chrome ?? true,
21
21
  siteName: config.siteName,
22
+ // Trailing slash stripped once, here, so `${siteUrl}${slug}` is well-formed
23
+ // at every call site rather than producing `https://x.com//about`.
24
+ siteUrl: config.siteUrl?.trim().replace(/\/+$/, "") || undefined,
22
25
  metadata: config.metadata,
23
26
  };
24
27
  }
28
+ /**
29
+ * The absolute URL of one page, or undefined when the site has not said where
30
+ * it lives. `buildSlug` yields "/" for the index and "/a/b" otherwise, so a
31
+ * plain concatenation is already correct.
32
+ */
33
+ function canonicalFor(c, slug) {
34
+ if (!c.siteUrl)
35
+ return undefined;
36
+ return slug === "/" ? `${c.siteUrl}/` : `${c.siteUrl}${slug}`;
37
+ }
25
38
  function makeGenerateStaticParams(cmsGetSlugs) {
26
39
  return async function generateStaticParams() {
27
40
  const slugs = await cmsGetSlugs();
@@ -84,7 +97,11 @@ function makeGenerateMetadata(c, mode) {
84
97
  const page = await c.cmsGetPage(slug);
85
98
  if (!page)
86
99
  return decorate({}, null);
87
- return decorate(buildPageMetadata(page, { siteName: c.siteName }), page);
100
+ return decorate(buildPageMetadata(page, {
101
+ siteName: c.siteName,
102
+ canonical: canonicalFor(c, slug),
103
+ baseUrl: c.siteUrl,
104
+ }), page);
88
105
  };
89
106
  }
90
107
  async function render(slug, search, c, mode) {
@@ -1,6 +1,34 @@
1
1
  import { validateDraftSecret } from "@avocadostudio-ai/shared";
2
2
  import { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE, normalizeOrigin, resolveTrustedEditorOrigin, single } from "./draft-common.js";
3
3
  export { single } from "./draft-common.js";
4
+ /**
5
+ * Query keys that mean "this request came from the editor".
6
+ *
7
+ * `siteId` and `session` are what the editor puts on the iframe URL and what
8
+ * `editorQuery` re-appends to every link the preview renders, so in-preview
9
+ * navigation keeps them. `__editor` is the routing hint the middleware and
10
+ * proxy add. `editorOrigin` names the frame to talk back to, which only the
11
+ * editor has a reason to send.
12
+ */
13
+ const EDITOR_QUERY_KEYS = ["siteId", "session", "editorOrigin", "__editor"];
14
+ /**
15
+ * True when the request carries some evidence that the editor sent it.
16
+ *
17
+ * Deliberately not "is the content store enabled" — see the call site. Draft
18
+ * mode is authoritative on its own: it is only ever turned on by the
19
+ * secret-gated `/api/draft` handler, which sets the two cookies below in the
20
+ * same response, so they are a signal in their own right for the rare case a
21
+ * host reports draft mode differently.
22
+ */
23
+ function hasEditorIntent(searchParams, adapter, hasValidSecret) {
24
+ if (adapter.isDraftMode || hasValidSecret)
25
+ return true;
26
+ for (const key of EDITOR_QUERY_KEYS) {
27
+ if (single(searchParams[key])?.trim())
28
+ return true;
29
+ }
30
+ return Boolean(adapter.getCookie(DRAFT_SESSION_COOKIE)?.trim() || adapter.getCookie(DRAFT_SITE_COOKIE)?.trim());
31
+ }
4
32
  export async function resolveDraftContextCore(searchParams, adapter, options) {
5
33
  const isDev = process.env.NODE_ENV !== "production";
6
34
  /*
@@ -30,6 +58,34 @@ export async function resolveDraftContextCore(searchParams, adapter, options) {
30
58
  const isContentStoreEnabled = isDev || adapter.isDraftMode || hasValidSecret;
31
59
  if (!isContentStoreEnabled)
32
60
  return null;
61
+ /*
62
+ * Whether this request *may* see drafts and whether it is *asking* to are two
63
+ * questions, and until now only the first was asked.
64
+ *
65
+ * The `isDev` term above answers "may", and in development it is
66
+ * unconditional. So in dev the only remaining requirement was a siteId — and
67
+ * the fallback chain below resolves one from `defaultSiteId`, which every
68
+ * integration configures because it is the site's own identity. Every
69
+ * anonymous `curl localhost:3000/` therefore came back with a context, and
70
+ * the caller read that as "this is the editor".
71
+ *
72
+ * The consequences were entirely invisible in dev, which is where they
73
+ * happened: `generateMetadata` short-circuits to noindex-only for an editor
74
+ * render, so no page in development emitted a title, a description, or an
75
+ * Open Graph tag; and the render took the draft path, where an unknown slug
76
+ * is "draft unavailable" at HTTP 200 rather than `notFound()`. A clean-room
77
+ * reviewer found their titles, social cards, and 404 all unobservable in the
78
+ * only mode they were running.
79
+ *
80
+ * So the request has to say so. Every real editor entry point already does:
81
+ * the iframe URL carries `?siteId=&session=`, the middleware and proxy add
82
+ * `__editor=1`, `/api/draft` sets draft mode and the cookies, and a
83
+ * cross-origin preview carries `secret`. Nothing that reaches the site
84
+ * without one of those is the editor, and in production nothing changes at
85
+ * all — every way of passing the gate above is itself a signal.
86
+ */
87
+ if (!hasEditorIntent(searchParams, adapter, hasValidSecret))
88
+ return null;
33
89
  const defaultSession = options?.defaultSession ?? process.env.DRAFT_DEFAULT_SESSION?.trim() ?? "dev";
34
90
  const defaultSiteId = options?.defaultSiteId ?? process.env.DRAFT_DEFAULT_SITE_ID?.trim() ?? "";
35
91
  const defaultEditorOrigin = options?.defaultEditorOrigin
@@ -27,6 +27,24 @@ export declare function createPagesHandler(getPages: () => PageDoc[] | Promise<P
27
27
  GET: (request: Request) => Promise<Response>;
28
28
  OPTIONS: (request: Request) => Response;
29
29
  };
30
+ /**
31
+ * Why an unconfigured publish endpoint is refused in production rather than
32
+ * left open.
33
+ *
34
+ * `publishSecret` was optional, and every scaffold and example wired it as
35
+ * `process.env.PUBLISH_TOKEN?.trim() || undefined` — a variable none of them
36
+ * generated, mentioned in their `.env.local`, or required. So the value was
37
+ * always `undefined`, `if (secret)` was always false, and `POST
38
+ * /api/editor/publish` accepted any caller's `pages` array and overwrote the
39
+ * site's content with it. A deployment that had followed its own README to the
40
+ * letter, `ACCESS_PASSWORD_HASH` and all, was gated on `/api/avocado/*` and
41
+ * wide open here: the two route groups are different handlers, and only one of
42
+ * them had ever been asked about auth.
43
+ *
44
+ * An optional guard on a write endpoint is not a guard. This one now fails
45
+ * closed wherever it matters and says which variable opens it.
46
+ */
47
+ export declare const OPEN_PUBLISH_HINT: string;
30
48
  export declare function createPublishHandler(onPublish: OnPublishFn, options?: {
31
49
  publishSecret?: string;
32
50
  }): {
@@ -29,12 +29,45 @@ export function createPagesHandler(getPages, getSiteConfig) {
29
29
  }
30
30
  };
31
31
  }
32
+ /**
33
+ * Why an unconfigured publish endpoint is refused in production rather than
34
+ * left open.
35
+ *
36
+ * `publishSecret` was optional, and every scaffold and example wired it as
37
+ * `process.env.PUBLISH_TOKEN?.trim() || undefined` — a variable none of them
38
+ * generated, mentioned in their `.env.local`, or required. So the value was
39
+ * always `undefined`, `if (secret)` was always false, and `POST
40
+ * /api/editor/publish` accepted any caller's `pages` array and overwrote the
41
+ * site's content with it. A deployment that had followed its own README to the
42
+ * letter, `ACCESS_PASSWORD_HASH` and all, was gated on `/api/avocado/*` and
43
+ * wide open here: the two route groups are different handlers, and only one of
44
+ * them had ever been asked about auth.
45
+ *
46
+ * An optional guard on a write endpoint is not a guard. This one now fails
47
+ * closed wherever it matters and says which variable opens it.
48
+ */
49
+ export const OPEN_PUBLISH_HINT = "This publish endpoint overwrites the site's content and has no publishSecret configured, " +
50
+ "so it refuses every request under NODE_ENV=production. Set PUBLISH_TOKEN (the same value the " +
51
+ "orchestrator sends as x-publish-token), or pass publishSecret to createEditorApiHandler().";
52
+ let warnedOpenPublish = false;
32
53
  export function createPublishHandler(onPublish, options) {
33
54
  return {
34
55
  OPTIONS: createEditorCorsOptionsHandler(),
35
56
  async POST(request) {
36
57
  // Verify publish token if configured
37
58
  const secret = options?.publishSecret;
59
+ if (!secret) {
60
+ if (process.env.NODE_ENV === "production") {
61
+ const res = new Response(JSON.stringify({ ok: false, error: "unauthorized", reason: OPEN_PUBLISH_HINT }), { status: 401, headers: { "Content-Type": "application/json" } });
62
+ return applyEditorCors(res, request.headers.get("origin"));
63
+ }
64
+ // Development: publishing to your own machine is the point, so this
65
+ // stays open — but it is the same code path that ships, so say so once.
66
+ if (!warnedOpenPublish) {
67
+ warnedOpenPublish = true;
68
+ console.warn(`[avocado] ${OPEN_PUBLISH_HINT}`);
69
+ }
70
+ }
38
71
  if (secret) {
39
72
  const provided = request.headers.get("x-publish-token")?.trim();
40
73
  if (!provided || provided !== secret) {
@@ -55,7 +55,28 @@ export type BuildPageMetadataOptions = {
55
55
  siteName?: string;
56
56
  /** Absolute URL of this page, used for the canonical link and `og:url`. */
57
57
  canonical?: string;
58
+ /**
59
+ * The site's own origin, used to turn a relative `ogImage` into the absolute
60
+ * URL a crawler can actually fetch.
61
+ *
62
+ * Content stores relative paths because that is what the page renders from —
63
+ * `/generated-images/hero.webp` is correct in an `<img src>` and useless in
64
+ * an `og:image`, where Facebook, Slack, and X all decline to resolve it
65
+ * against the page. The demo shipped eight pages whose images are exactly
66
+ * that shape, so its social cards were blank while every check passed: the
67
+ * build gate asserts "has og:image exactly when the page declares one", and
68
+ * declaring an unusable one satisfies it.
69
+ */
70
+ baseUrl?: string;
58
71
  };
72
+ /**
73
+ * Resolve an image reference against the site's origin.
74
+ *
75
+ * Returns the input unchanged when there is nothing to resolve against, which
76
+ * keeps the no-`baseUrl` behaviour exactly as it was: a relative path still
77
+ * goes out relative rather than becoming a broken absolute one.
78
+ */
79
+ export declare function absolutizeImage(image: string, baseUrl: string | undefined): string;
59
80
  /**
60
81
  * Build the full metadata object for a page.
61
82
  *
@@ -77,6 +77,27 @@ export function derivePageDescription(page) {
77
77
  export function derivePageTitle(page) {
78
78
  return page.meta?.title?.trim() || page.title;
79
79
  }
80
+ /** Absolute already, a protocol-relative URL, or a data URI — leave it alone. */
81
+ function isAbsoluteUrl(value) {
82
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(value);
83
+ }
84
+ /**
85
+ * Resolve an image reference against the site's origin.
86
+ *
87
+ * Returns the input unchanged when there is nothing to resolve against, which
88
+ * keeps the no-`baseUrl` behaviour exactly as it was: a relative path still
89
+ * goes out relative rather than becoming a broken absolute one.
90
+ */
91
+ export function absolutizeImage(image, baseUrl) {
92
+ if (!baseUrl || isAbsoluteUrl(image))
93
+ return image;
94
+ try {
95
+ return new URL(image, baseUrl).toString();
96
+ }
97
+ catch {
98
+ return image;
99
+ }
100
+ }
80
101
  /**
81
102
  * Build the full metadata object for a page.
82
103
  *
@@ -87,7 +108,8 @@ export function derivePageTitle(page) {
87
108
  export function buildPageMetadata(page, options = {}) {
88
109
  const title = derivePageTitle(page);
89
110
  const description = derivePageDescription(page);
90
- const image = page.meta?.ogImage?.trim();
111
+ const rawImage = page.meta?.ogImage?.trim();
112
+ const image = rawImage ? absolutizeImage(rawImage, options.baseUrl) : undefined;
91
113
  const images = image ? [image] : undefined;
92
114
  return {
93
115
  title,
@@ -2,6 +2,21 @@ import type { OnPublishFn } from "../editor-routes.ts";
2
2
  /**
3
3
  * Publish handler that writes PageDoc[] to a local JSON file.
4
4
  *
5
+ * `shape` decides what lands in the file, and it has to match whatever reads
6
+ * it back. It used to be neither a parameter nor a choice: the handler always
7
+ * wrote `{ pages, siteConfig }` while this docstring, three of the four call
8
+ * sites, and `jsonFileAdapter.onPublish` all said `PageDoc[]`. Publishing then
9
+ * broke the site that had just published — the scaffold's `lib/content.ts`
10
+ * does `JSON.parse(...) as PageDoc[]` and its `.find` threw on an object, so
11
+ * every page answered 500, and `examples/sample-site` reads a slug-keyed
12
+ * object and quietly lost all nine pages. Only `apps/site` survived, because
13
+ * its reader had already grown a branch for both shapes.
14
+ *
15
+ * So: `array` is the default, because it is what the readers and the adapter
16
+ * expect. Pass `wrapper` when the reader wants `siteConfig` in the same file —
17
+ * a plain array has nowhere to put it, so site-level settings (name, logo,
18
+ * nav) are dropped on publish.
19
+ *
5
20
  * When `publicDir` is provided, inline assets (base64 images from the
6
21
  * orchestrator) are written to disk and their localhost URLs are rewritten
7
22
  * to relative paths in the JSON output.
@@ -21,4 +36,6 @@ import type { OnPublishFn } from "../editor-routes.ts";
21
36
  export declare function createJsonFilePublishHandler(filePath: string, options?: {
22
37
  publicDir?: string;
23
38
  imagePathPrefix?: string;
39
+ /** `array` writes `PageDoc[]` (default). `wrapper` writes `{ pages, siteConfig }`. */
40
+ shape?: "array" | "wrapper";
24
41
  }): OnPublishFn;
@@ -3,6 +3,21 @@ import { resolve } from "node:path";
3
3
  /**
4
4
  * Publish handler that writes PageDoc[] to a local JSON file.
5
5
  *
6
+ * `shape` decides what lands in the file, and it has to match whatever reads
7
+ * it back. It used to be neither a parameter nor a choice: the handler always
8
+ * wrote `{ pages, siteConfig }` while this docstring, three of the four call
9
+ * sites, and `jsonFileAdapter.onPublish` all said `PageDoc[]`. Publishing then
10
+ * broke the site that had just published — the scaffold's `lib/content.ts`
11
+ * does `JSON.parse(...) as PageDoc[]` and its `.find` threw on an object, so
12
+ * every page answered 500, and `examples/sample-site` reads a slug-keyed
13
+ * object and quietly lost all nine pages. Only `apps/site` survived, because
14
+ * its reader had already grown a branch for both shapes.
15
+ *
16
+ * So: `array` is the default, because it is what the readers and the adapter
17
+ * expect. Pass `wrapper` when the reader wants `siteConfig` in the same file —
18
+ * a plain array has nowhere to put it, so site-level settings (name, logo,
19
+ * nav) are dropped on publish.
20
+ *
6
21
  * When `publicDir` is provided, inline assets (base64 images from the
7
22
  * orchestrator) are written to disk and their localhost URLs are rewritten
8
23
  * to relative paths in the JSON output.
@@ -33,8 +48,8 @@ export function createJsonFilePublishHandler(filePath, options) {
33
48
  }
34
49
  output = JSON.parse(json);
35
50
  }
36
- const payload = JSON.stringify({ pages: output, siteConfig: config }, null, 2) + "\n";
37
- await writeFile(filePath, payload, "utf8");
51
+ const body = options?.shape === "wrapper" ? { pages: output, siteConfig: config } : output;
52
+ await writeFile(filePath, JSON.stringify(body, null, 2) + "\n", "utf8");
38
53
  return { ok: true };
39
54
  };
40
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -147,17 +147,17 @@
147
147
  ],
148
148
  "dependencies": {
149
149
  "zod": "^4.3.6",
150
- "@avocadostudio-ai/blocks": "^0.9.0",
151
- "@avocadostudio-ai/preview-adapter": "^0.9.0",
152
- "@avocadostudio-ai/richtext": "^0.9.0",
153
- "@avocadostudio-ai/shared": "^0.9.0"
150
+ "@avocadostudio-ai/preview-adapter": "^0.11.0",
151
+ "@avocadostudio-ai/shared": "^0.11.0",
152
+ "@avocadostudio-ai/richtext": "^0.11.0",
153
+ "@avocadostudio-ai/blocks": "^0.11.0"
154
154
  },
155
155
  "peerDependencies": {
156
156
  "next": ">=15.0.0",
157
157
  "react": ">=19.0.0",
158
158
  "react-dom": ">=19.0.0",
159
159
  "better-sqlite3": ">=12.0.0",
160
- "@avocadostudio-ai/orchestrator-core": "^0.9.0"
160
+ "@avocadostudio-ai/orchestrator-core": "^0.11.0"
161
161
  },
162
162
  "peerDependenciesMeta": {
163
163
  "@avocadostudio-ai/orchestrator-core": {