@avocadostudio-ai/site-sdk 0.8.0 → 0.10.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/README.md CHANGED
@@ -604,7 +604,7 @@ createOrchestrator({
604
604
  `draftPath` is either a prefix the page slug is appended to (`/avocado` →
605
605
  `/avocado/de/events`) or a template naming where the slug goes
606
606
  (`/preview/{slug}/draft`). It defaults to `/preview-draft`, which is what
607
- `create-ai-site-editor` scaffolds — **if you wired Avocado into a site you
607
+ `create-avocado-site` scaffolds — **if you wired Avocado into a site you
608
608
  already had, set this**, or every draft screenshot comes back a cheerful 200 and
609
609
  a picture of your 404 page.
610
610
 
@@ -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
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -66,6 +66,11 @@
66
66
  "import": "./dist/draft-context-core.js",
67
67
  "default": "./dist/draft-context-core.js"
68
68
  },
69
+ "./draft/fetch": {
70
+ "types": "./dist/draft-fetch.d.ts",
71
+ "import": "./dist/draft-fetch.js",
72
+ "default": "./dist/draft-fetch.js"
73
+ },
69
74
  "./routes/core": {
70
75
  "types": "./dist/routes-core.d.ts",
71
76
  "import": "./dist/routes-core.js",
@@ -142,17 +147,17 @@
142
147
  ],
143
148
  "dependencies": {
144
149
  "zod": "^4.3.6",
145
- "@avocadostudio-ai/blocks": "^0.8.0",
146
- "@avocadostudio-ai/preview-adapter": "^0.8.0",
147
- "@avocadostudio-ai/richtext": "^0.8.0",
148
- "@avocadostudio-ai/shared": "^0.8.0"
150
+ "@avocadostudio-ai/preview-adapter": "^0.10.0",
151
+ "@avocadostudio-ai/blocks": "^0.10.0",
152
+ "@avocadostudio-ai/richtext": "^0.10.0",
153
+ "@avocadostudio-ai/shared": "^0.10.0"
149
154
  },
150
155
  "peerDependencies": {
151
156
  "next": ">=15.0.0",
152
157
  "react": ">=19.0.0",
153
158
  "react-dom": ">=19.0.0",
154
159
  "better-sqlite3": ">=12.0.0",
155
- "@avocadostudio-ai/orchestrator-core": "^0.8.0"
160
+ "@avocadostudio-ai/orchestrator-core": "^0.10.0"
156
161
  },
157
162
  "peerDependenciesMeta": {
158
163
  "@avocadostudio-ai/orchestrator-core": {