@avocadostudio-ai/astro 0.8.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/LICENSE +201 -0
- package/README.md +126 -0
- package/dist/bridge.d.ts +30 -0
- package/dist/bridge.js +125 -0
- package/dist/draft-adapter.d.ts +19 -0
- package/dist/draft-adapter.js +67 -0
- package/dist/draft-cookie.d.ts +26 -0
- package/dist/draft-cookie.js +86 -0
- package/dist/editor-api-route.d.ts +2 -0
- package/dist/editor-api-route.js +20 -0
- package/dist/editor-api.d.ts +30 -0
- package/dist/editor-api.js +36 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +147 -0
- package/dist/markers.d.ts +31 -0
- package/dist/markers.js +49 -0
- package/dist/middleware-entry.d.ts +1 -0
- package/dist/middleware-entry.js +12 -0
- package/dist/middleware.d.ts +22 -0
- package/dist/middleware.js +55 -0
- package/dist/runtime.d.ts +48 -0
- package/dist/runtime.js +73 -0
- package/package.json +91 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/*
|
|
3
|
+
* Astro's draft cookie, which Astro does not have.
|
|
4
|
+
*
|
|
5
|
+
* On Next, `draftMode().enable()` sets `__prerender_bypass` — a cookie the
|
|
6
|
+
* framework signs, sets and checks, and which the preview rewrite keys on so
|
|
7
|
+
* that clicking a link inside the editor iframe stays in the preview. Astro has
|
|
8
|
+
* no equivalent: there is no draft mode, so there is no cookie, so the second
|
|
9
|
+
* page a user visits is the published one and the editor's own navigation dies
|
|
10
|
+
* one click in.
|
|
11
|
+
*
|
|
12
|
+
* So the integration names and signs its own. The value is not a secret and
|
|
13
|
+
* carries nothing private — it is the draft session and site, which already
|
|
14
|
+
* travel in the URL. What the signature buys is that a visitor cannot mint one:
|
|
15
|
+
* the cookie is what makes a request eligible for an on-demand draft render, and
|
|
16
|
+
* an unsigned one would let anybody who guessed a session id read unpublished
|
|
17
|
+
* content by setting a cookie in their own browser.
|
|
18
|
+
*
|
|
19
|
+
* It is deliberately the same secret `/api/editor/draft` already validates
|
|
20
|
+
* (`DRAFT_MODE_SECRET`). A second secret is a second thing to configure and to
|
|
21
|
+
* get wrong, and the two grant exactly the same access.
|
|
22
|
+
*/
|
|
23
|
+
export const ASTRO_DRAFT_COOKIE = "avocado_draft";
|
|
24
|
+
/** How long a draft cookie stays valid. An editing session outlives a short one, and a long one outlives the tab. */
|
|
25
|
+
export const DRAFT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 12;
|
|
26
|
+
function base64url(input) {
|
|
27
|
+
return Buffer.from(input, "utf8").toString("base64url");
|
|
28
|
+
}
|
|
29
|
+
function sign(payload, secret) {
|
|
30
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A cookie value for this draft session, or `null` when no secret is configured
|
|
34
|
+
* — in which case the integration must not set one at all, rather than set an
|
|
35
|
+
* unsigned one that verification would have to accept.
|
|
36
|
+
*/
|
|
37
|
+
export function mintDraftCookie(payload, secret, now = Date.now()) {
|
|
38
|
+
if (!secret)
|
|
39
|
+
return null;
|
|
40
|
+
const body = {
|
|
41
|
+
...payload,
|
|
42
|
+
exp: Math.floor(now / 1000) + DRAFT_COOKIE_MAX_AGE_SECONDS,
|
|
43
|
+
};
|
|
44
|
+
const encoded = base64url(JSON.stringify(body));
|
|
45
|
+
return `${encoded}.${sign(encoded, secret)}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The session a cookie authorizes, or `null` if it does not.
|
|
49
|
+
*
|
|
50
|
+
* Returns null rather than throwing for every failure — malformed, unsigned,
|
|
51
|
+
* wrongly signed, expired — because every one of them means the same thing to
|
|
52
|
+
* the caller: this request is not authorized to see drafts.
|
|
53
|
+
*/
|
|
54
|
+
export function verifyDraftCookie(value, secret, now = Date.now()) {
|
|
55
|
+
if (!value || !secret)
|
|
56
|
+
return null;
|
|
57
|
+
const dot = value.lastIndexOf(".");
|
|
58
|
+
if (dot <= 0)
|
|
59
|
+
return null;
|
|
60
|
+
const encoded = value.slice(0, dot);
|
|
61
|
+
const provided = Buffer.from(value.slice(dot + 1));
|
|
62
|
+
const expected = Buffer.from(sign(encoded, secret));
|
|
63
|
+
/*
|
|
64
|
+
* Compared in constant time, and only after the lengths match:
|
|
65
|
+
* `timingSafeEqual` throws on a length mismatch rather than returning false,
|
|
66
|
+
* and a thrown error here would be a 500 on every request carrying a
|
|
67
|
+
* truncated cookie.
|
|
68
|
+
*/
|
|
69
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected))
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
73
|
+
if (typeof payload?.session !== "string" || typeof payload?.siteId !== "string")
|
|
74
|
+
return null;
|
|
75
|
+
if (typeof payload.exp !== "number" || payload.exp * 1000 <= now)
|
|
76
|
+
return null;
|
|
77
|
+
return payload;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The secret both this cookie and `/api/editor/draft` are checked against. */
|
|
84
|
+
export function draftSecret(env = process.env) {
|
|
85
|
+
return env.DRAFT_MODE_SECRET?.trim() || env.NEXT_DRAFT_MODE_SECRET?.trim() || undefined;
|
|
86
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import content from "avocado:content";
|
|
2
|
+
import { createAvocadoEditorApi } from "./editor-api.js";
|
|
3
|
+
/*
|
|
4
|
+
* The editor API route, injected by the integration rather than written by the
|
|
5
|
+
* site.
|
|
6
|
+
*
|
|
7
|
+
* It has to be on-demand — it answers per request — and on a static build
|
|
8
|
+
* Astro refuses an on-demand route without an adapter. A site whose whole point
|
|
9
|
+
* is a static build (AstroWind is one: see its
|
|
10
|
+
* `.agents/skills/content-at-build-time.md`) therefore cannot keep this route
|
|
11
|
+
* file in its own `src/pages`, because its mere existence fails
|
|
12
|
+
* `astro build` with `NoAdapterInstalled`.
|
|
13
|
+
*
|
|
14
|
+
* Injecting it means the integration decides when it exists: during `astro
|
|
15
|
+
* dev`, always, and in a build only when the site has an adapter to serve it.
|
|
16
|
+
* Editing then happens against the dev server, publishing writes the site's
|
|
17
|
+
* content files, and the production build never sees this route.
|
|
18
|
+
*/
|
|
19
|
+
export const prerender = false;
|
|
20
|
+
export const { GET, POST, OPTIONS } = createAvocadoEditorApi(content);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { type EditorApiHandlerConfig } from "@avocadostudio-ai/site-sdk/routes/core";
|
|
3
|
+
export type AvocadoEditorApiConfig = EditorApiHandlerConfig & {
|
|
4
|
+
/** Where the catch-all is mounted. @default "/api/editor" */
|
|
5
|
+
basePath?: string;
|
|
6
|
+
secret?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The editor API as Astro endpoints.
|
|
10
|
+
*
|
|
11
|
+
* Mount at `src/pages/api/editor/[...path].ts`:
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { createAvocadoEditorApi } from "@avocadostudio-ai/astro/editor-api"
|
|
14
|
+
* export const prerender = false
|
|
15
|
+
* export const { GET, POST, OPTIONS } = createAvocadoEditorApi({ getPages, onPublish })
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* `prerender = false` is not optional and not defaultable: these routes answer
|
|
19
|
+
* per request, and Astro would otherwise build them once and serve the answer
|
|
20
|
+
* as a file.
|
|
21
|
+
*
|
|
22
|
+
* Astro gives `params.path` as a single joined string for a rest parameter,
|
|
23
|
+
* where Next gives an array. The core takes either — or derives the path from
|
|
24
|
+
* the URL when neither is available.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createAvocadoEditorApi(config: AvocadoEditorApiConfig): {
|
|
27
|
+
GET: APIRoute;
|
|
28
|
+
POST: APIRoute;
|
|
29
|
+
OPTIONS: APIRoute;
|
|
30
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createEditorApiHandlerCore } from "@avocadostudio-ai/site-sdk/routes/core";
|
|
2
|
+
import { createAstroDraftAdapter } from "./draft-adapter.js";
|
|
3
|
+
/**
|
|
4
|
+
* The editor API as Astro endpoints.
|
|
5
|
+
*
|
|
6
|
+
* Mount at `src/pages/api/editor/[...path].ts`:
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { createAvocadoEditorApi } from "@avocadostudio-ai/astro/editor-api"
|
|
9
|
+
* export const prerender = false
|
|
10
|
+
* export const { GET, POST, OPTIONS } = createAvocadoEditorApi({ getPages, onPublish })
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* `prerender = false` is not optional and not defaultable: these routes answer
|
|
14
|
+
* per request, and Astro would otherwise build them once and serve the answer
|
|
15
|
+
* as a file.
|
|
16
|
+
*
|
|
17
|
+
* Astro gives `params.path` as a single joined string for a rest parameter,
|
|
18
|
+
* where Next gives an array. The core takes either — or derives the path from
|
|
19
|
+
* the URL when neither is available.
|
|
20
|
+
*/
|
|
21
|
+
export function createAvocadoEditorApi(config) {
|
|
22
|
+
const core = createEditorApiHandlerCore({
|
|
23
|
+
...config,
|
|
24
|
+
basePath: config.basePath ?? "/api/editor",
|
|
25
|
+
draftAdapter: createAstroDraftAdapter({ secret: config.secret }),
|
|
26
|
+
});
|
|
27
|
+
const segments = (params) => {
|
|
28
|
+
const path = params.path;
|
|
29
|
+
return typeof path === "string" ? path.split("/").filter(Boolean) : undefined;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
GET: ({ request, params }) => core.GET(request, segments(params)),
|
|
33
|
+
POST: ({ request, params }) => core.POST(request, segments(params)),
|
|
34
|
+
OPTIONS: ({ request }) => core.OPTIONS(request),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
2
|
+
export type AvocadoIntegrationOptions = {
|
|
3
|
+
/** Site identifier the orchestrator keys drafts by. Also readable from `AVOCADO_SITE_ID`. */
|
|
4
|
+
siteId: string;
|
|
5
|
+
/**
|
|
6
|
+
* Editor origins this site will talk to.
|
|
7
|
+
*
|
|
8
|
+
* The editor origin becomes a `postMessage` target and names the frame
|
|
9
|
+
* permitted to drive inline edits, so it cannot be whatever the URL says. A
|
|
10
|
+
* candidate not on this list degrades to the first entry. In development an
|
|
11
|
+
* unlisted origin is accepted, because the editor's port moves and refusing it
|
|
12
|
+
* looks like the integration is broken.
|
|
13
|
+
*/
|
|
14
|
+
editorOrigins?: string[];
|
|
15
|
+
/** Orchestrator draft session. @default "dev" */
|
|
16
|
+
session?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Where the editor API is mounted.
|
|
19
|
+
* @default "/api/editor"
|
|
20
|
+
*/
|
|
21
|
+
editorApiPath?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Path to the module that says where this site's content lives — its default
|
|
24
|
+
* export is an {@link AvocadoEditorApiConfig}: `getPages`, `onPublish`, and
|
|
25
|
+
* optionally `registerBlocks` and `blockTypes`.
|
|
26
|
+
*
|
|
27
|
+
* Relative to the project root. Given this, the integration injects the
|
|
28
|
+
* editor API route itself; see below for why the site cannot own that file.
|
|
29
|
+
*/
|
|
30
|
+
content?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The pages the editor may preview, as project-relative component paths —
|
|
33
|
+
* `["src/pages/index.astro"]`. `*` matches within a path segment, `**` across
|
|
34
|
+
* segments.
|
|
35
|
+
*
|
|
36
|
+
* These render on demand during `astro dev` and are prerendered in a build,
|
|
37
|
+
* which is the only arrangement that satisfies both halves of the problem.
|
|
38
|
+
*
|
|
39
|
+
* A prerendered route has no request: `Astro.request.headers` is empty and
|
|
40
|
+
* Astro warns, so the middleware sees no `__editor` parameter and no draft
|
|
41
|
+
* cookie and resolves every request as a visitor's. The preview then renders
|
|
42
|
+
* the published page, correctly and with no markers — which looks exactly
|
|
43
|
+
* like an integration nobody wired up.
|
|
44
|
+
*
|
|
45
|
+
* The site cannot fix that itself. `export const prerender = false` on the
|
|
46
|
+
* page makes `astro build` fail with `NoAdapterInstalled`, and
|
|
47
|
+
* `!import.meta.env.DEV` is not a literal by the time Astro's route analysis
|
|
48
|
+
* reads it, so the route stays prerendered regardless.
|
|
49
|
+
*
|
|
50
|
+
* It is a list rather than "every page" because a route built from
|
|
51
|
+
* `getStaticPaths` cannot render on demand at all: its `Astro.props` come
|
|
52
|
+
* from the path it was generated for, so on demand they are `undefined` and
|
|
53
|
+
* the route throws on the first property it reads. Naming the pages leaves
|
|
54
|
+
* paginated and collection routes exactly as they were.
|
|
55
|
+
*/
|
|
56
|
+
editablePages?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Load the live-preview bridge on every page.
|
|
59
|
+
*
|
|
60
|
+
* The script is a few hundred bytes and its first action is to check whether
|
|
61
|
+
* it is inside the editor's iframe — outside one it attaches nothing. Turn it
|
|
62
|
+
* off only for a site that must ship zero Avocado bytes to visitors, and mount
|
|
63
|
+
* the bridge from a preview-only layout instead.
|
|
64
|
+
* @default true
|
|
65
|
+
*/
|
|
66
|
+
bridge?: boolean;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Avocado Studio for Astro.
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* // astro.config.ts
|
|
73
|
+
* import avocado from "@avocadostudio-ai/astro"
|
|
74
|
+
* export default defineConfig({
|
|
75
|
+
* integrations: [avocado({ siteId: "my-site" })],
|
|
76
|
+
* })
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* **The site stays static.** This integration adds no adapter and changes no
|
|
80
|
+
* route's rendering mode. Astro decides that per route at build time, and a
|
|
81
|
+
* prerendered page is a file on disk that no middleware can turn into a
|
|
82
|
+
* function — so the integration marks requests rather than rewriting them, and
|
|
83
|
+
* a site that wants on-demand preview in production marks the route it wants
|
|
84
|
+
* previewable with `export const prerender = false` and installs an adapter of
|
|
85
|
+
* its own choosing. Under `astro dev` every route already renders on demand, so
|
|
86
|
+
* a site whose content lives in its own source needs neither.
|
|
87
|
+
*
|
|
88
|
+
* What it does add: middleware that resolves the editor session onto
|
|
89
|
+
* `Astro.locals.avocado`, and the preview bridge.
|
|
90
|
+
*/
|
|
91
|
+
export default function avocado(options: AvocadoIntegrationOptions): AstroIntegration;
|
|
92
|
+
export type { AvocadoLocals } from "./runtime.ts";
|
|
93
|
+
export { resolveEditorRequest } from "./runtime.ts";
|
|
94
|
+
export { ASTRO_DRAFT_COOKIE } from "./draft-cookie.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ASTRO_DRAFT_COOKIE } from "./draft-cookie.js";
|
|
2
|
+
/**
|
|
3
|
+
* Avocado Studio for Astro.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* // astro.config.ts
|
|
7
|
+
* import avocado from "@avocadostudio-ai/astro"
|
|
8
|
+
* export default defineConfig({
|
|
9
|
+
* integrations: [avocado({ siteId: "my-site" })],
|
|
10
|
+
* })
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* **The site stays static.** This integration adds no adapter and changes no
|
|
14
|
+
* route's rendering mode. Astro decides that per route at build time, and a
|
|
15
|
+
* prerendered page is a file on disk that no middleware can turn into a
|
|
16
|
+
* function — so the integration marks requests rather than rewriting them, and
|
|
17
|
+
* a site that wants on-demand preview in production marks the route it wants
|
|
18
|
+
* previewable with `export const prerender = false` and installs an adapter of
|
|
19
|
+
* its own choosing. Under `astro dev` every route already renders on demand, so
|
|
20
|
+
* a site whose content lives in its own source needs neither.
|
|
21
|
+
*
|
|
22
|
+
* What it does add: middleware that resolves the editor session onto
|
|
23
|
+
* `Astro.locals.avocado`, and the preview bridge.
|
|
24
|
+
*/
|
|
25
|
+
export default function avocado(options) {
|
|
26
|
+
const bridgeEnabled = options.bridge ?? true;
|
|
27
|
+
/** `src/pages/**` + `/*.astro` as a regex over the project-relative path. */
|
|
28
|
+
const GLOBSTAR = "__AVOCADO_GLOBSTAR__";
|
|
29
|
+
const editable = (options.editablePages ?? []).map((pattern) => new RegExp(`^${pattern
|
|
30
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
31
|
+
.replace(/\*\*\//g, GLOBSTAR)
|
|
32
|
+
.replace(/\*/g, "[^/]*")
|
|
33
|
+
.replace(GLOBSTAR, "(?:.*/)?")}$`));
|
|
34
|
+
let isDev = false;
|
|
35
|
+
return {
|
|
36
|
+
name: "@avocadostudio-ai/astro",
|
|
37
|
+
hooks: {
|
|
38
|
+
"astro:route:setup": ({ route, logger }) => {
|
|
39
|
+
if (!isDev || editable.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
if (!editable.some((pattern) => pattern.test(route.component)))
|
|
42
|
+
return;
|
|
43
|
+
/*
|
|
44
|
+
* Only while developing, and only for a page the site named. In a build
|
|
45
|
+
* this hook does nothing at all, so every route is prerendered and the
|
|
46
|
+
* output is as static as it was before Avocado was installed.
|
|
47
|
+
*/
|
|
48
|
+
route.prerender = false;
|
|
49
|
+
logger.debug(`${route.component} renders on demand so the editor can preview it`);
|
|
50
|
+
},
|
|
51
|
+
"astro:config:setup": ({ addMiddleware, injectRoute, injectScript, updateConfig, config: astroConfig, command, logger }) => {
|
|
52
|
+
isDev = command === "dev";
|
|
53
|
+
const virtualModuleId = "avocado:config";
|
|
54
|
+
const resolvedVirtualModuleId = `\0${virtualModuleId}`;
|
|
55
|
+
const avocadoConfig = {
|
|
56
|
+
siteId: options.siteId,
|
|
57
|
+
session: options.session ?? "dev",
|
|
58
|
+
editorOrigins: options.editorOrigins ?? [],
|
|
59
|
+
editorApiPath: options.editorApiPath ?? "/api/editor",
|
|
60
|
+
draftCookie: ASTRO_DRAFT_COOKIE,
|
|
61
|
+
};
|
|
62
|
+
const contentModuleId = "avocado:content";
|
|
63
|
+
const resolvedContentModuleId = `\0${contentModuleId}`;
|
|
64
|
+
const contentPath = options.content ? new URL(options.content, astroConfig.root).pathname : null;
|
|
65
|
+
updateConfig({
|
|
66
|
+
vite: {
|
|
67
|
+
plugins: [
|
|
68
|
+
{
|
|
69
|
+
name: "vite-plugin-avocado-config",
|
|
70
|
+
resolveId(id) {
|
|
71
|
+
if (id === virtualModuleId)
|
|
72
|
+
return resolvedVirtualModuleId;
|
|
73
|
+
if (id === contentModuleId)
|
|
74
|
+
return resolvedContentModuleId;
|
|
75
|
+
return undefined;
|
|
76
|
+
},
|
|
77
|
+
load(id) {
|
|
78
|
+
if (id === resolvedContentModuleId) {
|
|
79
|
+
if (!contentPath) {
|
|
80
|
+
throw new Error("avocado(): the editor API route was injected without a `content` option. This is a bug in the integration.");
|
|
81
|
+
}
|
|
82
|
+
return `export { default } from ${JSON.stringify(contentPath)}`;
|
|
83
|
+
}
|
|
84
|
+
if (id !== resolvedVirtualModuleId)
|
|
85
|
+
return undefined;
|
|
86
|
+
/*
|
|
87
|
+
* Serialised into the module rather than read from
|
|
88
|
+
* `process.env` at runtime, because the bridge half of it runs
|
|
89
|
+
* in the browser where there is no environment to read. The
|
|
90
|
+
* values here are all public — a site id, an origin allowlist
|
|
91
|
+
* and a route prefix. The draft secret is not among them and
|
|
92
|
+
* must never be: it is read server-side, from the environment,
|
|
93
|
+
* at the point it is checked.
|
|
94
|
+
*/
|
|
95
|
+
return `export default ${JSON.stringify(avocadoConfig)}`;
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
/*
|
|
102
|
+
* `order: "pre"` so `Astro.locals.avocado` is populated before the
|
|
103
|
+
* site's own middleware runs. A site gating analytics on it — which is
|
|
104
|
+
* the main reason to read it in middleware at all — would otherwise see
|
|
105
|
+
* `undefined` on every request and mount the trackers into the editor's
|
|
106
|
+
* iframe.
|
|
107
|
+
*/
|
|
108
|
+
addMiddleware({ entrypoint: "@avocadostudio-ai/astro/middleware-entry", order: "pre" });
|
|
109
|
+
if (bridgeEnabled) {
|
|
110
|
+
/*
|
|
111
|
+
* `"page"` rather than `"head-inline"`: the bridge needs the document
|
|
112
|
+
* body to exist before it can attach, and a `head-inline` script runs
|
|
113
|
+
* against an empty one. It is also a module, so it defers by default.
|
|
114
|
+
*/
|
|
115
|
+
injectScript("page", `import { startAvocadoBridge } from "@avocadostudio-ai/astro/bridge";startAvocadoBridge();`);
|
|
116
|
+
}
|
|
117
|
+
/*
|
|
118
|
+
* The editor API answers per request, and Astro refuses an on-demand
|
|
119
|
+
* route in a static build with no adapter — so on a static site the
|
|
120
|
+
* route file cannot live in `src/pages` at all: its existence alone
|
|
121
|
+
* fails `astro build` with `NoAdapterInstalled`, whether or not anyone
|
|
122
|
+
* intends to serve it.
|
|
123
|
+
*
|
|
124
|
+
* Injecting it puts that decision here. It exists during `astro dev`,
|
|
125
|
+
* where every route is on-demand anyway, and in a build only when the
|
|
126
|
+
* site has an adapter to run it. A static site is then edited against
|
|
127
|
+
* the dev server and rebuilt from what publishing wrote, and its
|
|
128
|
+
* production output contains no Avocado route.
|
|
129
|
+
*/
|
|
130
|
+
const canServeOnDemand = command === "dev" || Boolean(astroConfig.adapter);
|
|
131
|
+
if (options.content && canServeOnDemand) {
|
|
132
|
+
injectRoute({
|
|
133
|
+
pattern: `${avocadoConfig.editorApiPath}/[...path]`,
|
|
134
|
+
entrypoint: "@avocadostudio-ai/astro/editor-api-route",
|
|
135
|
+
prerender: false,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (options.content && !canServeOnDemand) {
|
|
139
|
+
logger.info("Static build with no adapter: the editor API is not included. Edit against `astro dev`.");
|
|
140
|
+
}
|
|
141
|
+
logger.info(`Avocado Studio wired for site "${avocadoConfig.siteId}"${options.content && canServeOnDemand ? `, editor API at ${avocadoConfig.editorApiPath}` : ""}.`);
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export { resolveEditorRequest } from "./runtime.js";
|
|
147
|
+
export { ASTRO_DRAFT_COOKIE } from "./draft-cookie.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FieldKind } from "@avocadostudio-ai/shared";
|
|
2
|
+
export { editableProps, editableScopeProps } from "@avocadostudio-ai/site-sdk/markers";
|
|
3
|
+
/**
|
|
4
|
+
* Mark the element that is one block, so the editor can select it.
|
|
5
|
+
*
|
|
6
|
+
* Selection is built entirely on `[data-block-id]`: a click in the preview
|
|
7
|
+
* resolves through `closest("[data-block-id]")`, and no match reads as "clicked
|
|
8
|
+
* outside any block", which *clears* the selection. Marking fields without this
|
|
9
|
+
* gives a preview that frames, renders and scrolls correctly and deselects on
|
|
10
|
+
* every click — indistinguishable from selection mode being switched off.
|
|
11
|
+
*
|
|
12
|
+
* Returns `{}` outside editor mode, so the published page renders precisely the
|
|
13
|
+
* markup it rendered before.
|
|
14
|
+
*
|
|
15
|
+
* ```astro
|
|
16
|
+
* <div {...blockProps(editable, block.id, block.type)}>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function blockProps(editorMode: boolean, blockId: string, blockType: string): {
|
|
20
|
+
readonly "data-block-id"?: undefined;
|
|
21
|
+
readonly "data-block-type"?: undefined;
|
|
22
|
+
class?: undefined;
|
|
23
|
+
readonly style?: undefined;
|
|
24
|
+
} | {
|
|
25
|
+
"data-block-id": string;
|
|
26
|
+
"data-block-type": string;
|
|
27
|
+
class: string;
|
|
28
|
+
style: string;
|
|
29
|
+
};
|
|
30
|
+
/** The attribute an image field's wrapper carries. Never put it on the `<img>`. */
|
|
31
|
+
export declare const IMAGE_KIND: FieldKind;
|
package/dist/markers.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The preview markers, in Astro's spelling.
|
|
3
|
+
*
|
|
4
|
+
* `editableProps` and `editableScopeProps` in
|
|
5
|
+
* `@avocadostudio-ai/site-sdk/markers` return plain data attributes and spread
|
|
6
|
+
* into an `.astro` element unchanged, so they are re-exported here rather than
|
|
7
|
+
* rewritten — one implementation, and a site imports all three markers from one
|
|
8
|
+
* place.
|
|
9
|
+
*
|
|
10
|
+
* `getPreviewWrapperProps` is the exception: it returns React's spelling,
|
|
11
|
+
* `className` and a `style` object. Astro wants `class` and a style string, and
|
|
12
|
+
* spreading React's version produces an element with a literal `className`
|
|
13
|
+
* attribute that no stylesheet matches and an object stringified into `style`.
|
|
14
|
+
* Both are silent — the preview renders, and nothing can be selected.
|
|
15
|
+
*/
|
|
16
|
+
export { editableProps, editableScopeProps } from "@avocadostudio-ai/site-sdk/markers";
|
|
17
|
+
/**
|
|
18
|
+
* Mark the element that is one block, so the editor can select it.
|
|
19
|
+
*
|
|
20
|
+
* Selection is built entirely on `[data-block-id]`: a click in the preview
|
|
21
|
+
* resolves through `closest("[data-block-id]")`, and no match reads as "clicked
|
|
22
|
+
* outside any block", which *clears* the selection. Marking fields without this
|
|
23
|
+
* gives a preview that frames, renders and scrolls correctly and deselects on
|
|
24
|
+
* every click — indistinguishable from selection mode being switched off.
|
|
25
|
+
*
|
|
26
|
+
* Returns `{}` outside editor mode, so the published page renders precisely the
|
|
27
|
+
* markup it rendered before.
|
|
28
|
+
*
|
|
29
|
+
* ```astro
|
|
30
|
+
* <div {...blockProps(editable, block.id, block.type)}>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function blockProps(editorMode, blockId, blockType) {
|
|
34
|
+
if (!editorMode)
|
|
35
|
+
return {};
|
|
36
|
+
return {
|
|
37
|
+
"data-block-id": blockId,
|
|
38
|
+
"data-block-type": blockType,
|
|
39
|
+
class: "editor-selectable",
|
|
40
|
+
/*
|
|
41
|
+
* Named for view transitions so a block keeps its identity across the
|
|
42
|
+
* `<main>` swap a refresh performs — without it a re-render of the list
|
|
43
|
+
* cross-fades the whole page instead of the block that changed.
|
|
44
|
+
*/
|
|
45
|
+
style: `view-transition-name: block-${blockId}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** The attribute an image field's wrapper carries. Never put it on the `<img>`. */
|
|
49
|
+
export const IMAGE_KIND = "image";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const onRequest: import("astro").MiddlewareHandler;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import avocadoConfig from "avocado:config";
|
|
2
|
+
import { createAvocadoMiddleware } from "./middleware.js";
|
|
3
|
+
/*
|
|
4
|
+
* The module `addMiddleware` points at. Astro takes an entrypoint specifier,
|
|
5
|
+
* not a function, so the options cannot be passed in directly — they arrive
|
|
6
|
+
* through the virtual module the integration writes.
|
|
7
|
+
*/
|
|
8
|
+
export const onRequest = createAvocadoMiddleware({
|
|
9
|
+
siteId: avocadoConfig.siteId,
|
|
10
|
+
session: avocadoConfig.session,
|
|
11
|
+
editorOrigins: avocadoConfig.editorOrigins,
|
|
12
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "astro";
|
|
2
|
+
import { type ResolveEditorRequestOptions } from "./runtime.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Astro middleware that tells every render whether it is the editor's.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately *not* a rewrite. Next needs one because its static and dynamic
|
|
7
|
+
* renders are different routes, so a preview has to be routed somewhere else
|
|
8
|
+
* entirely. Astro decides per route, at build time, with `prerender` — a route
|
|
9
|
+
* is either a file on disk or a function, and no middleware can turn one into
|
|
10
|
+
* the other. Rewriting a prerendered page to a preview route would mean the
|
|
11
|
+
* integration owning a catch-all that renders the site's own components, which
|
|
12
|
+
* it cannot know.
|
|
13
|
+
*
|
|
14
|
+
* So the request is marked rather than moved, and the site marks the route it
|
|
15
|
+
* wants previewable with `export const prerender = false`. Everything else stays
|
|
16
|
+
* a static file — which is the point of using Astro, and which the editor does
|
|
17
|
+
* not need to change to do its job.
|
|
18
|
+
*
|
|
19
|
+
* In `astro dev` every route is rendered on demand already, so a site whose
|
|
20
|
+
* content lives in its own source needs nothing further.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createAvocadoMiddleware(options?: ResolveEditorRequestOptions): MiddlewareHandler;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolveEditorRequest } from "./runtime.js";
|
|
2
|
+
/**
|
|
3
|
+
* Astro middleware that tells every render whether it is the editor's.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately *not* a rewrite. Next needs one because its static and dynamic
|
|
6
|
+
* renders are different routes, so a preview has to be routed somewhere else
|
|
7
|
+
* entirely. Astro decides per route, at build time, with `prerender` — a route
|
|
8
|
+
* is either a file on disk or a function, and no middleware can turn one into
|
|
9
|
+
* the other. Rewriting a prerendered page to a preview route would mean the
|
|
10
|
+
* integration owning a catch-all that renders the site's own components, which
|
|
11
|
+
* it cannot know.
|
|
12
|
+
*
|
|
13
|
+
* So the request is marked rather than moved, and the site marks the route it
|
|
14
|
+
* wants previewable with `export const prerender = false`. Everything else stays
|
|
15
|
+
* a static file — which is the point of using Astro, and which the editor does
|
|
16
|
+
* not need to change to do its job.
|
|
17
|
+
*
|
|
18
|
+
* In `astro dev` every route is rendered on demand already, so a site whose
|
|
19
|
+
* content lives in its own source needs nothing further.
|
|
20
|
+
*/
|
|
21
|
+
export function createAvocadoMiddleware(options) {
|
|
22
|
+
/*
|
|
23
|
+
* Emit editor markup for every render, including prerendered ones.
|
|
24
|
+
*
|
|
25
|
+
* `editableCoverage` compares the block manifest against the markers a
|
|
26
|
+
* preview actually draws, and it can only be run against editor-mode HTML —
|
|
27
|
+
* pointed at a published page it finds no blocks and reports zero, which
|
|
28
|
+
* reads identically to an integration that marks nothing. A site served on
|
|
29
|
+
* demand can be asked for a preview over HTTP. A static one has no server to
|
|
30
|
+
* ask, so the only way to produce that HTML is to build it.
|
|
31
|
+
*
|
|
32
|
+
* Read from the environment rather than taken as an option so that a CI job
|
|
33
|
+
* can turn it on for one build without the site's committed config mentioning
|
|
34
|
+
* it, and so that nothing in a normal build can set it by accident.
|
|
35
|
+
*/
|
|
36
|
+
const forced = process.env.AVOCADO_FORCE_EDITOR === "1";
|
|
37
|
+
return async (context, next) => {
|
|
38
|
+
const locals = forced
|
|
39
|
+
? { isEditor: true, session: options?.session ?? "dev", siteId: options?.siteId ?? "", editorOrigin: "", editorQuery: "" }
|
|
40
|
+
: resolveEditorRequest(context.request, options);
|
|
41
|
+
context.locals.avocado = locals;
|
|
42
|
+
const response = await next();
|
|
43
|
+
if (locals.isEditor) {
|
|
44
|
+
/*
|
|
45
|
+
* A preview must never be cached, by the browser or by anything between:
|
|
46
|
+
* the whole point is that it reflects a draft that changes as somebody
|
|
47
|
+
* types. It must never be indexed either — a preview URL in a search
|
|
48
|
+
* index is a bug, and it carries a session id.
|
|
49
|
+
*/
|
|
50
|
+
response.headers.set("Cache-Control", "no-store, must-revalidate");
|
|
51
|
+
response.headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
52
|
+
}
|
|
53
|
+
return response;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a page learns about the request it is rendering.
|
|
3
|
+
*
|
|
4
|
+
* Reachable from any `.astro` file as `Astro.locals.avocado`, and from a layout
|
|
5
|
+
* — which is the half that matters. A layout is where a site mounts its consent
|
|
6
|
+
* banner, its analytics and its tag manager, and the natural implementation
|
|
7
|
+
* mounts all three inside the editor iframe. Measured on one integration: a
|
|
8
|
+
* cookie banner covering the page being edited, and a pageview written to the
|
|
9
|
+
* site's own analytics for every block someone clicked.
|
|
10
|
+
*/
|
|
11
|
+
export type AvocadoLocals = {
|
|
12
|
+
/** Whether this render is the editor's preview. */
|
|
13
|
+
isEditor: boolean;
|
|
14
|
+
/** The draft session, when this is an editor render. */
|
|
15
|
+
session?: string;
|
|
16
|
+
siteId?: string;
|
|
17
|
+
/** `postMessage` target and the frame permitted to drive inline edits. */
|
|
18
|
+
editorOrigin?: string;
|
|
19
|
+
/**
|
|
20
|
+
* `?session=…&siteId=…&editorOrigin=…` — append to every link the page
|
|
21
|
+
* renders, or the first in-preview navigation lands on the published page.
|
|
22
|
+
*/
|
|
23
|
+
editorQuery?: string;
|
|
24
|
+
};
|
|
25
|
+
export type ResolveEditorRequestOptions = {
|
|
26
|
+
/** Falls back to `AVOCADO_SITE_ID`. */
|
|
27
|
+
siteId?: string;
|
|
28
|
+
/** @default "dev" */
|
|
29
|
+
session?: string;
|
|
30
|
+
/** Trusted editor origins. A candidate not listed degrades to the first entry. */
|
|
31
|
+
editorOrigins?: string[];
|
|
32
|
+
secret?: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Resolve what a request means, for Astro's middleware and for anything else
|
|
36
|
+
* holding a `Request`.
|
|
37
|
+
*
|
|
38
|
+
* Two things can mark a request as the editor's: the `__editor=1` parameter the
|
|
39
|
+
* editor puts on the iframe URL, and the signed draft cookie that keeps
|
|
40
|
+
* navigation *inside* the iframe in preview. The parameter alone authorizes
|
|
41
|
+
* nothing on its own — it is a routing hint with no secret in it — so a request
|
|
42
|
+
* carrying only the parameter is treated as an editor render but the session it
|
|
43
|
+
* reports comes from the URL, and the content it may see is decided by
|
|
44
|
+
* `/api/editor/draft` having minted a cookie or by a valid `secret`. This
|
|
45
|
+
* mirrors `resolveDraftContextCore`, deliberately.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveEditorRequest(request: Request, options?: ResolveEditorRequestOptions): AvocadoLocals;
|
|
48
|
+
export { ASTRO_DRAFT_COOKIE } from "./draft-cookie.ts";
|