@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 +1 -1
- package/dist/create-site-page.d.ts +14 -0
- package/dist/create-site-page.js +18 -1
- package/dist/draft-context-core.js +56 -0
- package/dist/page-metadata.d.ts +21 -0
- package/dist/page-metadata.js +23 -1
- package/package.json +11 -6
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-
|
|
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
|
package/dist/create-site-page.js
CHANGED
|
@@ -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, {
|
|
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
|
package/dist/page-metadata.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/page-metadata.js
CHANGED
|
@@ -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
|
|
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.
|
|
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/
|
|
146
|
-
"@avocadostudio-ai/
|
|
147
|
-
"@avocadostudio-ai/richtext": "^0.
|
|
148
|
-
"@avocadostudio-ai/shared": "^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.
|
|
160
|
+
"@avocadostudio-ai/orchestrator-core": "^0.10.0"
|
|
156
161
|
},
|
|
157
162
|
"peerDependenciesMeta": {
|
|
158
163
|
"@avocadostudio-ai/orchestrator-core": {
|