@avocadostudio-ai/site-sdk 0.1.0 → 0.2.1
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 +212 -2
- package/dist/cli/register.js +23 -1
- package/dist/create-site-page.d.ts +38 -8
- package/dist/create-site-page.js +59 -8
- package/dist/draft-common.d.ts +32 -0
- package/dist/draft-common.js +58 -0
- package/dist/draft-context-core.js +39 -6
- package/dist/draft-context-core.test.d.ts +10 -0
- package/dist/draft-context-core.test.js +146 -0
- package/dist/draft-fetch.d.ts +9 -10
- package/dist/draft-fetch.js +71 -5
- package/dist/draft-fetch.test.d.ts +1 -0
- package/dist/draft-fetch.test.js +87 -0
- package/dist/editor-cors.d.ts +12 -0
- package/dist/editor-cors.js +31 -6
- package/dist/editor-cors.test.d.ts +1 -0
- package/dist/editor-cors.test.js +66 -0
- package/dist/editor-manifest.d.ts +2 -3
- package/dist/editor-manifest.js +12 -64
- package/dist/editor-matcher.d.ts +27 -0
- package/dist/editor-matcher.js +34 -0
- package/dist/editor-query.js +7 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integration-check.js +11 -1
- package/dist/manifest-utils.d.ts +13 -0
- package/dist/manifest-utils.js +30 -3
- package/dist/manifest-utils.test.d.ts +1 -0
- package/dist/manifest-utils.test.js +72 -0
- package/dist/middleware.d.ts +21 -19
- package/dist/middleware.js +19 -22
- package/dist/next-config.test.d.ts +1 -0
- package/dist/next-config.test.js +355 -0
- package/dist/page-metadata.d.ts +66 -0
- package/dist/page-metadata.js +110 -0
- package/dist/page-metadata.test.d.ts +1 -0
- package/dist/page-metadata.test.js +105 -0
- package/dist/proxy.d.ts +95 -0
- package/dist/proxy.js +76 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +123 -0
- package/dist/publish/field-diff.d.ts +191 -0
- package/dist/publish/field-diff.js +252 -0
- package/dist/publish/field-diff.test.d.ts +1 -0
- package/dist/publish/field-diff.test.js +286 -0
- package/dist/server/orchestrator.d.ts +1 -117
- package/dist/server/orchestrator.js +14 -733
- package/next-config.d.ts +78 -0
- package/next-config.mjs +468 -0
- package/package.json +63 -19
package/README.md
CHANGED
|
@@ -17,8 +17,9 @@ npm install @avocadostudio-ai/site-sdk
|
|
|
17
17
|
import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
|
|
18
18
|
import { getPage, getSlugs, getSiteConfig } from "../../lib/my-cms"
|
|
19
19
|
|
|
20
|
-
const { Page, generateStaticParams } = createSitePage({
|
|
20
|
+
const { Page, generateStaticParams, generateMetadata } = createSitePage({
|
|
21
21
|
siteId: "my-site",
|
|
22
|
+
siteName: "My Site", // optional, used for og:site_name
|
|
22
23
|
getPage, // (slug: string) => Promise<PageDoc | null>
|
|
23
24
|
getSlugs, // () => Promise<string[]>
|
|
24
25
|
getSiteConfig, // () => Promise<SiteConfig> (optional)
|
|
@@ -29,9 +30,14 @@ const { Page, generateStaticParams } = createSitePage({
|
|
|
29
30
|
})
|
|
30
31
|
|
|
31
32
|
export default Page
|
|
32
|
-
export { generateStaticParams }
|
|
33
|
+
export { generateStaticParams, generateMetadata }
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
Export `generateMetadata` too, or every page inherits the root layout's
|
|
37
|
+
`<title>` with no description and no social card. The factory derives all three
|
|
38
|
+
from the page and calls `notFound()` for an unknown slug, so add an
|
|
39
|
+
`app/not-found.tsx` if you want your own chrome around the 404.
|
|
40
|
+
|
|
35
41
|
### 3. Create the editor API route
|
|
36
42
|
|
|
37
43
|
```tsx
|
|
@@ -101,6 +107,208 @@ Your site exposes these endpoints via `createEditorApiHandler`:
|
|
|
101
107
|
| `/api/editor/pages` | GET | Return all published pages |
|
|
102
108
|
| `/api/editor/publish` | POST | Receive pages from editor, persist to CMS |
|
|
103
109
|
|
|
110
|
+
## Library mode needs a credential
|
|
111
|
+
|
|
112
|
+
`createOrchestrator()` mounts on your own domain and can edit and publish your
|
|
113
|
+
content, so it needs to know who is calling. There are three ways to tell it,
|
|
114
|
+
in precedence order.
|
|
115
|
+
|
|
116
|
+
**1. Reuse the auth your app already has.** The hook receives the `Request` and
|
|
117
|
+
returns a boolean, or an object to attach context:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
export const { GET, POST, OPTIONS } = createOrchestrator({
|
|
121
|
+
auth: async (request) => {
|
|
122
|
+
const session = await getSession(request) // your own session, your own rules
|
|
123
|
+
return session?.role === "editor"
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Throwing counts as a refusal, so a hook that awaits a session lookup does not
|
|
129
|
+
have to catch its own errors.
|
|
130
|
+
|
|
131
|
+
**2. Use the built-in token gate.** Set either variable and it turns on:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# a password the editor prompts for; store the SHA-256, never the password
|
|
135
|
+
ACCESS_PASSWORD_HASH=$(printf '%s' 'your-password' | shasum -a 256 | cut -d' ' -f1)
|
|
136
|
+
# and/or a static token for scripts, CI, and MCP clients
|
|
137
|
+
ORCHESTRATOR_ACCESS_TOKEN=…
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`POST /auth/verify` exchanges the password for a 12-hour token; every other
|
|
141
|
+
route then requires it as `x-access-token`, `Authorization: Bearer`, or
|
|
142
|
+
`?accessToken=` (the last because `EventSource` cannot send headers). This is
|
|
143
|
+
the same gate the standalone orchestrator enforces, so one editor build talks to
|
|
144
|
+
both.
|
|
145
|
+
|
|
146
|
+
**3. Neither — and then production is closed.** With no hook and no credential,
|
|
147
|
+
the mount is open in development and **refuses every request** under
|
|
148
|
+
`NODE_ENV=production`. Not a warning: a 401. An unauthenticated publish endpoint
|
|
149
|
+
on a customer's domain is not something anyone should reach by forgetting a step.
|
|
150
|
+
|
|
151
|
+
To run open in production on purpose, say so in one line — `auth: () => true`.
|
|
152
|
+
|
|
153
|
+
Two paths stay reachable whatever the gate says, because a caller cannot hold a
|
|
154
|
+
credential yet or at all: `/auth/status` and `/auth/verify` (the exchange that
|
|
155
|
+
produces one), and `GET /generated-images/*` (an `<img>` tag on your rendered
|
|
156
|
+
page cannot attach a header).
|
|
157
|
+
|
|
158
|
+
### CORS
|
|
159
|
+
|
|
160
|
+
`corsOrigins` unset means *reflect the caller's origin* in development and *send
|
|
161
|
+
no CORS headers* in production — same-origin works, cross-origin has to be named:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
createOrchestrator({ corsOrigins: ["https://editor.example.com"] })
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Pass `"*"` to reflect deliberately, or `null` to turn the SDK's CORS handling off
|
|
168
|
+
and let Next middleware own it.
|
|
169
|
+
|
|
170
|
+
## Library mode needs `better-sqlite3`
|
|
171
|
+
|
|
172
|
+
`createOrchestrator()` keeps draft pages, undo stacks and the version log in
|
|
173
|
+
SQLite, so the app that mounts it must be able to load the driver:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm install better-sqlite3
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
It is an **optional** peer dependency: a site that only renders published content
|
|
180
|
+
never touches it. Mount the orchestrator without it and every edit still applies
|
|
181
|
+
— to memory alone, lost on the next restart or instance recycle, after which the
|
|
182
|
+
session silently re-seeds from your CMS. `GET /status/planner` reports
|
|
183
|
+
`persistence: { ok: false, reason }` when that is the situation, and every `/ops`
|
|
184
|
+
response carries `persisted: false` with the cause.
|
|
185
|
+
|
|
186
|
+
A workspace link to `@avocadostudio-ai/orchestrator-core` is **not** enough. A
|
|
187
|
+
linked package's own dependencies are never materialised in the host's tree, and
|
|
188
|
+
a native module has to be resolvable from there.
|
|
189
|
+
|
|
190
|
+
## Telling the orchestrator where your site is
|
|
191
|
+
|
|
192
|
+
An agent's only way to see what it just edited is `POST /preview/screenshot`,
|
|
193
|
+
which needs two things a site knows about itself and the orchestrator does not:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
createOrchestrator({
|
|
197
|
+
adapter,
|
|
198
|
+
previewUrl: "http://localhost:3000", // where this site is reachable
|
|
199
|
+
draftPath: "/preview-draft" // the route that renders drafts
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`draftPath` is either a prefix the page slug is appended to (`/avocado` →
|
|
204
|
+
`/avocado/de/events`) or a template naming where the slug goes
|
|
205
|
+
(`/preview/{slug}/draft`). It defaults to `/preview-draft`, which is what
|
|
206
|
+
`create-ai-site-editor` scaffolds — **if you wired Avocado into a site you
|
|
207
|
+
already had, set this**, or every draft screenshot comes back a cheerful 200 and
|
|
208
|
+
a picture of your 404 page.
|
|
209
|
+
|
|
210
|
+
Both can also be set at runtime with `POST /sites/register` (the MCP
|
|
211
|
+
`avocado-register-site` tool), which wins over the values above. `GET /sites`
|
|
212
|
+
reports the one site the process serves either way.
|
|
213
|
+
|
|
214
|
+
## Publishing back to a real CMS
|
|
215
|
+
|
|
216
|
+
`onPublish(pages, config)` reads as "here are the pages, store them". That works
|
|
217
|
+
when the CMS shape *is* the editor shape — a JSON file — and not otherwise.
|
|
218
|
+
Every real CMS read is a projection: an asset reference flattened to a URL
|
|
219
|
+
string, a document reference resolved to one language's href, rich text
|
|
220
|
+
flattened to markdown. Writing the projection back replaces the reference with
|
|
221
|
+
the flattening and destroys the document.
|
|
222
|
+
|
|
223
|
+
So publish a **diff**, not a snapshot. `@ai-site-editor/site-sdk/publish` owns
|
|
224
|
+
the walk:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { diffPage, groupPatches, describeUnsupported } from "@ai-site-editor/site-sdk/publish"
|
|
228
|
+
|
|
229
|
+
const diff = diffPage({
|
|
230
|
+
page,
|
|
231
|
+
ctx: { lang },
|
|
232
|
+
locate: (block) => {
|
|
233
|
+
const source = block.props.__source // what the CMS held when you read it
|
|
234
|
+
if (!source) return null // a block the editor added — reported, not guessed
|
|
235
|
+
return {
|
|
236
|
+
documentId: page.id,
|
|
237
|
+
prefix: `pageBuilder[_key=="${block.id}"].`,
|
|
238
|
+
source,
|
|
239
|
+
specs: {
|
|
240
|
+
heading: { rehydrate: (props) => props.heading },
|
|
241
|
+
image: {
|
|
242
|
+
// `before` is the stored value, so an inversion can be partial: keep
|
|
243
|
+
// the asset reference the projection dropped, replace only the alt.
|
|
244
|
+
rehydrate: (props, before) => ({ ...before, alt: props.imageAlt }),
|
|
245
|
+
write: ({ before, after, path, emit, reject }) => {
|
|
246
|
+
if (before?.alt !== after.alt) emit(`${path}.alt`, after.alt)
|
|
247
|
+
if (before?.url !== after.url) {
|
|
248
|
+
reject("the image was replaced", "Upload it as an asset first.")
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
for (const { documentId, set } of groupPatches(diff.patches)) {
|
|
258
|
+
await client.patch(documentId).set(set).commit()
|
|
259
|
+
}
|
|
260
|
+
return { ok: true, unsupported: describeUnsupported(diff.unsupported) }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Two things worth knowing before you write one.
|
|
264
|
+
|
|
265
|
+
**List items match on their own key, not their position.** An index-addressed
|
|
266
|
+
patch lands on the wrong row the moment anything reorders the array upstream.
|
|
267
|
+
`sanityPaths` (the default) addresses `field[_key=="…"]`; `indexPaths` exists for
|
|
268
|
+
stores with no element identity and is only correct when your publish is the
|
|
269
|
+
only writer.
|
|
270
|
+
|
|
271
|
+
**Refusing is a result, not an error.** Four things a field diff cannot express
|
|
272
|
+
— a replaced image, a retyped link, a list item added or removed, a new block —
|
|
273
|
+
are reported through `unsupported` and returned to the caller. A publisher that
|
|
274
|
+
drops them silently reports success for content the site will never show; one
|
|
275
|
+
that guesses writes a URL where a reference belongs.
|
|
276
|
+
|
|
277
|
+
If you did not embed your own source snapshot in block props, `onPublish`'s
|
|
278
|
+
third argument carries `context.published` — the pages as your adapter last
|
|
279
|
+
reported them. It is the copy taken at bootstrap, not a fresh read, so it is
|
|
280
|
+
absent after a restart that reloaded the draft from storage. Treat `undefined`
|
|
281
|
+
as "no baseline available", never as "the site was empty": publishing every
|
|
282
|
+
field on that assumption is the overwrite all of this exists to prevent.
|
|
283
|
+
|
|
284
|
+
## Telling the orchestrator which blocks you render
|
|
285
|
+
|
|
286
|
+
Importing anything from `@avocadostudio-ai/shared` registers Avocado's 18
|
|
287
|
+
built-in block types, transitively and unavoidably — that is how the block
|
|
288
|
+
library works. A site that brought its own blocks therefore advertises both
|
|
289
|
+
sets, and an agent reading `/blocks/manifest` can add a `Hero` that applies
|
|
290
|
+
cleanly and renders nothing.
|
|
291
|
+
|
|
292
|
+
If you render Avocado's blocks (a scaffolded site does), skip this. If your site
|
|
293
|
+
brought its own, declare them:
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
export const { GET, POST } = createOrchestrator({
|
|
297
|
+
adapter,
|
|
298
|
+
blockTypes: ["acme_hero", "acme_splitSection", "acme_pricing"]
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The declaration is exclusive. The manifest narrows to it, the planner is only
|
|
303
|
+
told about those types, and `add_block` refuses anything outside it with a
|
|
304
|
+
message naming what the site does render. Nothing is removed from the registry,
|
|
305
|
+
so stored content of an excluded type keeps validating and rendering — it simply
|
|
306
|
+
is not offered for insertion.
|
|
307
|
+
|
|
308
|
+
Declaring a type you never registered is not silently dropped: it has no schema
|
|
309
|
+
to describe, so it cannot reach the manifest, and the orchestrator logs a warning
|
|
310
|
+
naming it the first time the manifest is served.
|
|
311
|
+
|
|
104
312
|
## Environment Variables
|
|
105
313
|
|
|
106
314
|
| Variable | Required | Description |
|
|
@@ -109,6 +317,8 @@ Your site exposes these endpoints via `createEditorApiHandler`:
|
|
|
109
317
|
| `DRAFT_MODE_SECRET` | Yes | Secret for enabling Next.js draft mode. Must match editor's `VITE_SITE_DRAFT_SECRET`. |
|
|
110
318
|
| `PUBLISH_TOKEN` | No | If set, publish requests must include this token in `x-publish-token` header. |
|
|
111
319
|
| `EDITOR_CORS_ORIGINS` | No | Comma-separated origins allowed for editor API CORS. Defaults to `http://localhost:4100`. |
|
|
320
|
+
| `ACCESS_PASSWORD_HASH` | No | SHA-256 of the editor password. Turns on the built-in gate; `POST /auth/verify` exchanges the password for a token. |
|
|
321
|
+
| `ORCHESTRATOR_ACCESS_TOKEN` | No | Static token for callers that cannot complete a password exchange (CI, scripts, MCP clients). |
|
|
112
322
|
|
|
113
323
|
## Types
|
|
114
324
|
|
package/dist/cli/register.js
CHANGED
|
@@ -67,6 +67,10 @@ function parseArgs(argv) {
|
|
|
67
67
|
case "--preview-url":
|
|
68
68
|
out.previewUrl = next();
|
|
69
69
|
break;
|
|
70
|
+
case "--token":
|
|
71
|
+
case "--access-token":
|
|
72
|
+
out.token = next();
|
|
73
|
+
break;
|
|
70
74
|
case "-h":
|
|
71
75
|
case "--help":
|
|
72
76
|
out.help = true;
|
|
@@ -98,6 +102,9 @@ OPTIONAL
|
|
|
98
102
|
--session <string> Orchestrator session (default: dev)
|
|
99
103
|
--purpose <string> One-line site description for AI context
|
|
100
104
|
--preview-url <url> Preview URL (default: http://localhost:<port>)
|
|
105
|
+
--token <string> Orchestrator access token (default: $ORCHESTRATOR_ACCESS_TOKEN).
|
|
106
|
+
Required by any orchestrator that is credentialed —
|
|
107
|
+
which a library-mode mount must be in production.
|
|
101
108
|
--cwd <path> Project directory (default: current working directory)
|
|
102
109
|
-h, --help Show this help
|
|
103
110
|
|
|
@@ -218,6 +225,13 @@ async function main() {
|
|
|
218
225
|
const port = args.port ?? detectPortFromPackageJson(pkg) ?? 3000;
|
|
219
226
|
// Resolve orchestrator URL
|
|
220
227
|
const orchestrator = (args.orchestrator ?? process.env.ORCHESTRATOR_URL ?? "http://localhost:4200").replace(/\/+$/, "");
|
|
228
|
+
/*
|
|
229
|
+
* A credentialed orchestrator refuses `/sites/register` like any other route,
|
|
230
|
+
* and this CLI had no way to present a token — so the documented path for
|
|
231
|
+
* "wire up your site with your own coding agent" ended at a 401 for exactly
|
|
232
|
+
* the deployments the docs insist on securing.
|
|
233
|
+
*/
|
|
234
|
+
const accessToken = (args.token ?? process.env.ORCHESTRATOR_ACCESS_TOKEN ?? "").trim();
|
|
221
235
|
// Resolve / generate the draft secret
|
|
222
236
|
const envPath = join(cwd, ".env.local");
|
|
223
237
|
const existingEnv = existsSync(envPath) ? parseEnvFile(readFileSync(envPath, "utf-8")) : {};
|
|
@@ -246,7 +260,10 @@ async function main() {
|
|
|
246
260
|
try {
|
|
247
261
|
response = await fetch(`${orchestrator}/sites/register`, {
|
|
248
262
|
method: "POST",
|
|
249
|
-
headers: {
|
|
263
|
+
headers: {
|
|
264
|
+
"content-type": "application/json",
|
|
265
|
+
...(accessToken ? { "x-access-token": accessToken } : {}),
|
|
266
|
+
},
|
|
250
267
|
body: JSON.stringify({
|
|
251
268
|
siteId,
|
|
252
269
|
name,
|
|
@@ -272,6 +289,11 @@ async function main() {
|
|
|
272
289
|
if (!response.ok) {
|
|
273
290
|
const text = await response.text();
|
|
274
291
|
process.stderr.write(`\nOrchestrator responded ${response.status}:\n ${text}\n`);
|
|
292
|
+
if (response.status === 401 || response.status === 403) {
|
|
293
|
+
process.stderr.write(accessToken
|
|
294
|
+
? `\nA token was sent and rejected. Check it matches ORCHESTRATOR_ACCESS_TOKEN\non the orchestrator, or the password behind ACCESS_PASSWORD_HASH.\n`
|
|
295
|
+
: `\nThis orchestrator is credentialed and no token was sent.\nPass --token <value>, or set ORCHESTRATOR_ACCESS_TOKEN.\n`);
|
|
296
|
+
}
|
|
275
297
|
process.exit(1);
|
|
276
298
|
}
|
|
277
299
|
const result = (await response.json());
|
|
@@ -2,6 +2,7 @@ import type { JSX } from "react";
|
|
|
2
2
|
import type { BlockInstance } from "./types.ts";
|
|
3
3
|
import type { SiteConfig } from "@avocadostudio-ai/shared";
|
|
4
4
|
import type { PageDoc } from "@avocadostudio-ai/shared";
|
|
5
|
+
import { type PageMetadata } from "./page-metadata.ts";
|
|
5
6
|
/**
|
|
6
7
|
* Configuration for creating a site page component.
|
|
7
8
|
*
|
|
@@ -18,7 +19,7 @@ import type { PageDoc } from "@avocadostudio-ai/shared";
|
|
|
18
19
|
* import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
|
|
19
20
|
* import { getPage, getSlugs, getSiteConfig } from "../../lib/my-cms"
|
|
20
21
|
*
|
|
21
|
-
* const { Page, generateStaticParams } = createSitePage({
|
|
22
|
+
* const { Page, generateStaticParams, generateMetadata } = createSitePage({
|
|
22
23
|
* siteId: "my-site",
|
|
23
24
|
* getPage,
|
|
24
25
|
* getSlugs,
|
|
@@ -26,7 +27,7 @@ import type { PageDoc } from "@avocadostudio-ai/shared";
|
|
|
26
27
|
* })
|
|
27
28
|
*
|
|
28
29
|
* export default Page
|
|
29
|
-
* export { generateStaticParams }
|
|
30
|
+
* export { generateStaticParams, generateMetadata }
|
|
30
31
|
* ```
|
|
31
32
|
*
|
|
32
33
|
* @example Split-route setup — fully static published path + dynamic preview path
|
|
@@ -36,14 +37,15 @@ import type { PageDoc } from "@avocadostudio-ai/shared";
|
|
|
36
37
|
* export const { middleware, config } = createEditorMiddleware()
|
|
37
38
|
*
|
|
38
39
|
* // app/[[...slug]]/page.tsx (statically generated)
|
|
39
|
-
* const { Page, generateStaticParams } = createSitePage({ mode: "static", ... })
|
|
40
|
+
* const { Page, generateStaticParams, generateMetadata } = createSitePage({ mode: "static", ... })
|
|
40
41
|
* export default Page
|
|
41
|
-
* export { generateStaticParams }
|
|
42
|
+
* export { generateStaticParams, generateMetadata }
|
|
42
43
|
*
|
|
43
44
|
* // app/preview-draft/[[...slug]]/page.tsx (dynamic editor route)
|
|
44
45
|
* export const dynamic = "force-dynamic"
|
|
45
|
-
* const { Page } = createSitePage({ mode: "preview", ... })
|
|
46
|
+
* const { Page, generateMetadata } = createSitePage({ mode: "preview", ... })
|
|
46
47
|
* export default Page
|
|
48
|
+
* export { generateMetadata }
|
|
47
49
|
* ```
|
|
48
50
|
*/
|
|
49
51
|
export type SitePageConfig = {
|
|
@@ -63,12 +65,31 @@ export type SitePageConfig = {
|
|
|
63
65
|
footer?: BlockInstance;
|
|
64
66
|
/** Render site header/footer chrome. Set false when the host layout provides its own. Defaults to true. */
|
|
65
67
|
chrome?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Site name for `og:site_name`. A plain string rather than a lookup on
|
|
70
|
+
* `getSiteConfig` deliberately: `generateMetadata` runs as a separate pass
|
|
71
|
+
* from the render, so reading it from the CMS would double every request's
|
|
72
|
+
* config fetch to decorate one Open Graph field.
|
|
73
|
+
*/
|
|
74
|
+
siteName?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Last word on a page's metadata. Receives what the SDK derived and the page
|
|
77
|
+
* it derived it from (`null` when the slug has no page), and returns what to
|
|
78
|
+
* emit. Use it for canonical URLs, per-section title templates, or anything
|
|
79
|
+
* that needs host knowledge the SDK does not have.
|
|
80
|
+
*/
|
|
81
|
+
metadata?: (derived: PageMetadata, page: PageDoc | null) => PageMetadata;
|
|
66
82
|
/**
|
|
67
83
|
* Rendering mode. Defaults to `"auto"`.
|
|
68
84
|
*
|
|
69
|
-
* - `"auto"`: single-route — handles published and editor modes in one file
|
|
70
|
-
*
|
|
71
|
-
*
|
|
85
|
+
* - `"auto"`: single-route — handles published and editor modes in one file, and works without middleware or a proxy. **Not statically rendered:** deciding
|
|
86
|
+
* between the two modes means reading `searchParams` and draft mode, which
|
|
87
|
+
* opts the route into dynamic rendering for every visitor, not just the
|
|
88
|
+
* editor. `generateStaticParams` is still returned, and still useless here.
|
|
89
|
+
* Convenient for getting started; `"static"` + `"preview"` is what a
|
|
90
|
+
* production site wants.
|
|
91
|
+
* - `"static"`: published-only — no searchParams, no editor logic, genuinely static. Pair with `createEditorProxy()` (Next 16) or `createEditorMiddleware()` (Next 15) and a separate `mode: "preview"` route.
|
|
92
|
+
* - `"preview"`: editor-only — always reads searchParams + draft content, and always `noindex`. Set `export const dynamic = "force-dynamic"` on the route file.
|
|
72
93
|
*/
|
|
73
94
|
mode?: "auto" | "static" | "preview";
|
|
74
95
|
};
|
|
@@ -80,6 +101,14 @@ type StaticPageProps = {
|
|
|
80
101
|
type DynamicPageProps = StaticPageProps & {
|
|
81
102
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
82
103
|
};
|
|
104
|
+
/**
|
|
105
|
+
* Next passes `searchParams` to `generateMetadata` for dynamic routes and omits
|
|
106
|
+
* it for static ones, so the parameter is optional here rather than split
|
|
107
|
+
* across two signatures.
|
|
108
|
+
*/
|
|
109
|
+
type MetadataProps = StaticPageProps & {
|
|
110
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
111
|
+
};
|
|
83
112
|
/**
|
|
84
113
|
* Create a Next.js page component with full editor integration.
|
|
85
114
|
*
|
|
@@ -91,5 +120,6 @@ export declare function createSitePage(config: SitePageConfig): {
|
|
|
91
120
|
generateStaticParams: () => Promise<{
|
|
92
121
|
slug: string[] | undefined;
|
|
93
122
|
}[]>;
|
|
123
|
+
generateMetadata: ({ params, searchParams }: MetadataProps) => Promise<PageMetadata>;
|
|
94
124
|
};
|
|
95
125
|
export {};
|
package/dist/create-site-page.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
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
2
|
import { draftMode } from "next/headers";
|
|
3
|
+
import { connection } from "next/server";
|
|
4
|
+
import { notFound } from "next/navigation";
|
|
4
5
|
import { SharedBlockRenderer, BlocksHydrator } from "@avocadostudio-ai/blocks";
|
|
5
6
|
import { buildSlug } from "./index.js";
|
|
6
7
|
import { resolveEditorContext, fetchEditorPage, fetchEditorSlugs } from "./draft.js";
|
|
7
8
|
import { renderBlocks, EditorOverlay } from "./editor.js";
|
|
8
9
|
import { buildNavItems, buildSiteHeaderBlock } from "./navigation.js";
|
|
10
|
+
import { buildPageMetadata } from "./page-metadata.js";
|
|
9
11
|
function resolve(config) {
|
|
10
12
|
return {
|
|
11
13
|
siteId: config.siteId,
|
|
@@ -16,6 +18,8 @@ function resolve(config) {
|
|
|
16
18
|
defaultLogo: config.defaultLogo ?? "/logo.svg",
|
|
17
19
|
footer: config.footer,
|
|
18
20
|
chrome: config.chrome ?? true,
|
|
21
|
+
siteName: config.siteName,
|
|
22
|
+
metadata: config.metadata,
|
|
19
23
|
};
|
|
20
24
|
}
|
|
21
25
|
function makeGenerateStaticParams(cmsGetSlugs) {
|
|
@@ -32,6 +36,17 @@ async function renderStatic(slug, c) {
|
|
|
32
36
|
c.cmsGetSlugs(),
|
|
33
37
|
c.cmsGetSiteConfig ? c.cmsGetSiteConfig() : Promise.resolve({}),
|
|
34
38
|
]);
|
|
39
|
+
/*
|
|
40
|
+
* `notFound()`, not a rendered 404 body. The previous version returned an
|
|
41
|
+
* <h1>404</h1> with HTTP 200, which is a soft 404: crawlers index it as a
|
|
42
|
+
* real page, and the host app's own `not-found.tsx` never runs. Consumers who
|
|
43
|
+
* want the site chrome around their 404 get it by adding `app/not-found.tsx`,
|
|
44
|
+
* 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
|
+
*/
|
|
48
|
+
if (!page)
|
|
49
|
+
notFound();
|
|
35
50
|
const { navItems, siteName, siteLogo } = buildNavItems({
|
|
36
51
|
navSlugs,
|
|
37
52
|
currentSlug: slug,
|
|
@@ -41,13 +56,48 @@ async function renderStatic(slug, c) {
|
|
|
41
56
|
defaultLogo: c.defaultLogo,
|
|
42
57
|
});
|
|
43
58
|
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
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] }));
|
|
48
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Metadata for a page, mirroring the render's own choice of source.
|
|
63
|
+
*
|
|
64
|
+
* Editor and draft-mode requests get `noindex` and nothing else: a preview URL
|
|
65
|
+
* that leaks into an index is a bug, and the draft fetch that would produce a
|
|
66
|
+
* better title belongs to the render pass, not to a second round-trip here.
|
|
67
|
+
*/
|
|
68
|
+
const NOINDEX = { robots: { index: false, follow: false } };
|
|
69
|
+
function makeGenerateMetadata(c, mode) {
|
|
70
|
+
const decorate = (derived, page) => c.metadata ? c.metadata(derived, page) : derived;
|
|
71
|
+
return async function generateMetadata({ params, searchParams }) {
|
|
72
|
+
const { slug: slugParts } = await params;
|
|
73
|
+
const slug = buildSlug(slugParts);
|
|
74
|
+
// Every response from a preview route is draft content, by definition.
|
|
75
|
+
if (mode === "preview")
|
|
76
|
+
return decorate(NOINDEX, null);
|
|
77
|
+
if (mode === "auto") {
|
|
78
|
+
const search = searchParams ? await searchParams : {};
|
|
79
|
+
const editorCtx = await resolveEditorContext(search, {
|
|
80
|
+
defaultSession: c.defaultSession,
|
|
81
|
+
defaultSiteId: c.siteId,
|
|
82
|
+
});
|
|
83
|
+
const draft = await draftMode();
|
|
84
|
+
if (draft.isEnabled || editorCtx)
|
|
85
|
+
return decorate(NOINDEX, null);
|
|
86
|
+
}
|
|
87
|
+
const page = await c.cmsGetPage(slug);
|
|
88
|
+
if (!page)
|
|
89
|
+
return decorate({}, null);
|
|
90
|
+
return decorate(buildPageMetadata(page, { siteName: c.siteName }), page);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
49
93
|
async function renderPreview(slug, search, c) {
|
|
50
|
-
|
|
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();
|
|
51
101
|
const editorCtx = await resolveEditorContext(search, {
|
|
52
102
|
defaultSession: c.defaultSession,
|
|
53
103
|
defaultSiteId: c.siteId,
|
|
@@ -102,13 +152,14 @@ export function createSitePage(config) {
|
|
|
102
152
|
const c = resolve(config);
|
|
103
153
|
const mode = config.mode ?? "auto";
|
|
104
154
|
const generateStaticParams = makeGenerateStaticParams(c.cmsGetSlugs);
|
|
155
|
+
const generateMetadata = makeGenerateMetadata(c, mode);
|
|
105
156
|
if (mode === "static") {
|
|
106
157
|
async function Page({ params }) {
|
|
107
158
|
const { slug: slugParts } = await params;
|
|
108
159
|
const slug = buildSlug(slugParts);
|
|
109
160
|
return renderStatic(slug, c);
|
|
110
161
|
}
|
|
111
|
-
return { Page, generateStaticParams };
|
|
162
|
+
return { Page, generateStaticParams, generateMetadata };
|
|
112
163
|
}
|
|
113
164
|
if (mode === "preview") {
|
|
114
165
|
async function Page({ params, searchParams }) {
|
|
@@ -116,12 +167,12 @@ export function createSitePage(config) {
|
|
|
116
167
|
const slug = buildSlug(slugParts);
|
|
117
168
|
return renderPreview(slug, search, c);
|
|
118
169
|
}
|
|
119
|
-
return { Page, generateStaticParams };
|
|
170
|
+
return { Page, generateStaticParams, generateMetadata };
|
|
120
171
|
}
|
|
121
172
|
async function Page({ params, searchParams }) {
|
|
122
173
|
const [{ slug: slugParts }, search] = await Promise.all([params, searchParams]);
|
|
123
174
|
const slug = buildSlug(slugParts);
|
|
124
175
|
return renderAuto(slug, search, c);
|
|
125
176
|
}
|
|
126
|
-
return { Page, generateStaticParams };
|
|
177
|
+
return { Page, generateStaticParams, generateMetadata };
|
|
127
178
|
}
|
package/dist/draft-common.d.ts
CHANGED
|
@@ -2,3 +2,35 @@ 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
4
|
export declare function normalizeOrigin(value: string | null | undefined): string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Editor origins this site will talk to.
|
|
7
|
+
*
|
|
8
|
+
* `normalizeOrigin` above answers "is this a syntactically valid http(s)
|
|
9
|
+
* origin", which is a parsing question, not a trust question — every attacker's
|
|
10
|
+
* origin passes it. The editor origin becomes the `targetOrigin` of the
|
|
11
|
+
* postMessage bridge and the frame allowed to drive inline edits, so it has to
|
|
12
|
+
* come from configuration the site's operator controls, not from the URL that
|
|
13
|
+
* asked for the page.
|
|
14
|
+
*
|
|
15
|
+
* Configured via AVOCADO_EDITOR_ORIGINS (comma-separated) and
|
|
16
|
+
* NEXT_PUBLIC_EDITOR_ORIGIN. Both are read at call time rather than at module
|
|
17
|
+
* load so a test, or a host that assigns env late, sees the current value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function editorOriginAllowlist(env?: Record<string, string | undefined>, extra?: string): string[];
|
|
20
|
+
/** True for a loopback origin on any port — the shape a local editor takes. */
|
|
21
|
+
export declare function isLoopbackOrigin(origin: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Decide which editor origin to trust for this request.
|
|
24
|
+
*
|
|
25
|
+
* A caller-supplied origin is honoured only when the operator listed it. In
|
|
26
|
+
* development any loopback origin passes, because the editor's port moves
|
|
27
|
+
* around and a page served from your own machine is not the threat here.
|
|
28
|
+
* Anything else falls back to the configured default, so a request carrying a
|
|
29
|
+
* hostile origin degrades to "no bridge" rather than "bridge to the attacker".
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveTrustedEditorOrigin(options: {
|
|
32
|
+
candidate: string | undefined;
|
|
33
|
+
fallback: string;
|
|
34
|
+
isDev: boolean;
|
|
35
|
+
env?: Record<string, string | undefined>;
|
|
36
|
+
}): string;
|
package/dist/draft-common.js
CHANGED
|
@@ -15,3 +15,61 @@ export function normalizeOrigin(value) {
|
|
|
15
15
|
return undefined;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Editor origins this site will talk to.
|
|
20
|
+
*
|
|
21
|
+
* `normalizeOrigin` above answers "is this a syntactically valid http(s)
|
|
22
|
+
* origin", which is a parsing question, not a trust question — every attacker's
|
|
23
|
+
* origin passes it. The editor origin becomes the `targetOrigin` of the
|
|
24
|
+
* postMessage bridge and the frame allowed to drive inline edits, so it has to
|
|
25
|
+
* come from configuration the site's operator controls, not from the URL that
|
|
26
|
+
* asked for the page.
|
|
27
|
+
*
|
|
28
|
+
* Configured via AVOCADO_EDITOR_ORIGINS (comma-separated) and
|
|
29
|
+
* NEXT_PUBLIC_EDITOR_ORIGIN. Both are read at call time rather than at module
|
|
30
|
+
* load so a test, or a host that assigns env late, sees the current value.
|
|
31
|
+
*/
|
|
32
|
+
export function editorOriginAllowlist(env = process.env, extra) {
|
|
33
|
+
const raw = [
|
|
34
|
+
...(env.AVOCADO_EDITOR_ORIGINS ?? "").split(","),
|
|
35
|
+
...(env.NEXT_PUBLIC_EDITOR_ORIGIN ?? "").split(","),
|
|
36
|
+
extra ?? ""
|
|
37
|
+
];
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const candidate of raw) {
|
|
40
|
+
const origin = normalizeOrigin(candidate);
|
|
41
|
+
if (origin && !out.includes(origin))
|
|
42
|
+
out.push(origin);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/** True for a loopback origin on any port — the shape a local editor takes. */
|
|
47
|
+
export function isLoopbackOrigin(origin) {
|
|
48
|
+
try {
|
|
49
|
+
const { hostname } = new URL(origin);
|
|
50
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Decide which editor origin to trust for this request.
|
|
58
|
+
*
|
|
59
|
+
* A caller-supplied origin is honoured only when the operator listed it. In
|
|
60
|
+
* development any loopback origin passes, because the editor's port moves
|
|
61
|
+
* around and a page served from your own machine is not the threat here.
|
|
62
|
+
* Anything else falls back to the configured default, so a request carrying a
|
|
63
|
+
* hostile origin degrades to "no bridge" rather than "bridge to the attacker".
|
|
64
|
+
*/
|
|
65
|
+
export function resolveTrustedEditorOrigin(options) {
|
|
66
|
+
const { candidate, fallback, isDev, env } = options;
|
|
67
|
+
if (!candidate)
|
|
68
|
+
return fallback;
|
|
69
|
+
const allowed = editorOriginAllowlist(env ?? process.env, fallback);
|
|
70
|
+
if (allowed.includes(candidate))
|
|
71
|
+
return candidate;
|
|
72
|
+
if (isDev && isLoopbackOrigin(candidate))
|
|
73
|
+
return candidate;
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|