@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.
@@ -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,2 @@
1
+ export declare const prerender = false;
2
+ export declare const GET: import("astro").APIRoute, POST: import("astro").APIRoute, OPTIONS: import("astro").APIRoute;
@@ -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
+ }
@@ -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;
@@ -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";