@avocadostudio-ai/site-sdk 0.5.0 → 0.6.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/dist/cli/register-notice.d.ts +20 -0
- package/dist/cli/register-notice.js +34 -0
- package/dist/cli/register-notice.test.d.ts +1 -0
- package/dist/cli/register-notice.test.js +21 -0
- package/dist/cli/register.js +7 -2
- package/dist/editor.d.ts +1 -1
- package/dist/editor.js +1 -1
- package/dist/markers.d.ts +32 -0
- package/dist/markers.js +32 -0
- package/dist/middleware.d.ts +1 -0
- package/dist/middleware.js +6 -0
- package/dist/proxy.d.ts +26 -0
- package/dist/proxy.js +72 -26
- package/dist/proxy.test.js +61 -2
- package/dist/publish/field-diff.d.ts +12 -0
- package/dist/publish/field-diff.js +12 -0
- package/package.json +5 -5
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Where the standalone orchestrator listens, and what the editor assumes. */
|
|
2
|
+
export declare const DEFAULT_ORCHESTRATOR = "http://localhost:4200";
|
|
3
|
+
/**
|
|
4
|
+
* The warning that makes "the site should appear in the dashboard" honest, or
|
|
5
|
+
* `null` when it already is.
|
|
6
|
+
*
|
|
7
|
+
* Registration went to whichever orchestrator `--orchestrator` named. The
|
|
8
|
+
* editor reads exactly one, `VITE_ORCHESTRATOR_URL`, baked in at build time,
|
|
9
|
+
* and it defaults to :4200. A library-mode site registers with its own handler
|
|
10
|
+
* at, say, `:3002/api/avocado`, whose registry holds a completely different
|
|
11
|
+
* set of sites — so "the site should appear in the dashboard" is followed by a
|
|
12
|
+
* dashboard listing somebody else's sites, with nothing reporting a problem.
|
|
13
|
+
*
|
|
14
|
+
* Nothing *is* wrong. There are two registries and this command cannot know
|
|
15
|
+
* which one the editor was built against. It can say which one it just wrote
|
|
16
|
+
* to, and it says so only when the two visibly disagree: printing an
|
|
17
|
+
* environment variable after every default-target registration would be noise
|
|
18
|
+
* on the path almost everyone is on.
|
|
19
|
+
*/
|
|
20
|
+
export declare function orchestratorMismatchNotice(orchestrator: string): string | null;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Lives apart from `register.ts` because `register.ts` calls `main()` at module
|
|
3
|
+
* scope — importing it to reach one helper *registers a site*, which is a poor
|
|
4
|
+
* thing for a test to do and a worse thing for it to do by accident.
|
|
5
|
+
*/
|
|
6
|
+
/** Where the standalone orchestrator listens, and what the editor assumes. */
|
|
7
|
+
export const DEFAULT_ORCHESTRATOR = "http://localhost:4200";
|
|
8
|
+
/**
|
|
9
|
+
* The warning that makes "the site should appear in the dashboard" honest, or
|
|
10
|
+
* `null` when it already is.
|
|
11
|
+
*
|
|
12
|
+
* Registration went to whichever orchestrator `--orchestrator` named. The
|
|
13
|
+
* editor reads exactly one, `VITE_ORCHESTRATOR_URL`, baked in at build time,
|
|
14
|
+
* and it defaults to :4200. A library-mode site registers with its own handler
|
|
15
|
+
* at, say, `:3002/api/avocado`, whose registry holds a completely different
|
|
16
|
+
* set of sites — so "the site should appear in the dashboard" is followed by a
|
|
17
|
+
* dashboard listing somebody else's sites, with nothing reporting a problem.
|
|
18
|
+
*
|
|
19
|
+
* Nothing *is* wrong. There are two registries and this command cannot know
|
|
20
|
+
* which one the editor was built against. It can say which one it just wrote
|
|
21
|
+
* to, and it says so only when the two visibly disagree: printing an
|
|
22
|
+
* environment variable after every default-target registration would be noise
|
|
23
|
+
* on the path almost everyone is on.
|
|
24
|
+
*/
|
|
25
|
+
export function orchestratorMismatchNotice(orchestrator) {
|
|
26
|
+
if (orchestrator.replace(/\/+$/, "") === DEFAULT_ORCHESTRATOR)
|
|
27
|
+
return null;
|
|
28
|
+
return (`\nThis is not the default orchestrator (${DEFAULT_ORCHESTRATOR}), and the editor\n` +
|
|
29
|
+
`reads one URL baked in at build time. For the site to show up there, the\n` +
|
|
30
|
+
`editor has to be built against this one:\n\n` +
|
|
31
|
+
` VITE_ORCHESTRATOR_URL=${orchestrator}\n\n` +
|
|
32
|
+
`Set it in the editor's .env and restart it. A dashboard listing other\n` +
|
|
33
|
+
`sites, or none, is the symptom of it pointing somewhere else.\n`);
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { orchestratorMismatchNotice } from "./register-notice.js";
|
|
4
|
+
/*
|
|
5
|
+
* `avocado-register` ends with "the site should appear in the dashboard", and
|
|
6
|
+
* for a library-mode site that is a promise it cannot keep: the registration
|
|
7
|
+
* went to the site's own handler, and the editor reads one
|
|
8
|
+
* `VITE_ORCHESTRATOR_URL` baked in at build time. The dashboard then opens on
|
|
9
|
+
* a different registry's sites and reports no problem, because there is none —
|
|
10
|
+
* there are simply two registries.
|
|
11
|
+
*/
|
|
12
|
+
test("the default target says nothing — that is the path almost everyone is on", () => {
|
|
13
|
+
assert.equal(orchestratorMismatchNotice("http://localhost:4200"), null);
|
|
14
|
+
assert.equal(orchestratorMismatchNotice("http://localhost:4200/"), null, "a trailing slash is the same URL");
|
|
15
|
+
});
|
|
16
|
+
test("a library-mode target names the variable the editor needs", () => {
|
|
17
|
+
const notice = orchestratorMismatchNotice("http://localhost:3002/api/avocado");
|
|
18
|
+
assert.ok(notice);
|
|
19
|
+
assert.match(notice, /VITE_ORCHESTRATOR_URL=http:\/\/localhost:3002\/api\/avocado/);
|
|
20
|
+
assert.match(notice, /sites, or none/, "and names the symptom, which is what the reader is looking at");
|
|
21
|
+
});
|
package/dist/cli/register.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { randomBytes } from "node:crypto";
|
|
34
34
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
35
|
import { join, resolve } from "node:path";
|
|
36
|
+
import { DEFAULT_ORCHESTRATOR, orchestratorMismatchNotice } from "./register-notice.js";
|
|
36
37
|
function parseArgs(argv) {
|
|
37
38
|
const out = {};
|
|
38
39
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -224,7 +225,7 @@ async function main() {
|
|
|
224
225
|
// Resolve port
|
|
225
226
|
const port = args.port ?? detectPortFromPackageJson(pkg) ?? 3000;
|
|
226
227
|
// Resolve orchestrator URL
|
|
227
|
-
const orchestrator = (args.orchestrator ?? process.env.ORCHESTRATOR_URL ??
|
|
228
|
+
const orchestrator = (args.orchestrator ?? process.env.ORCHESTRATOR_URL ?? DEFAULT_ORCHESTRATOR).replace(/\/+$/, "");
|
|
228
229
|
/*
|
|
229
230
|
* A credentialed orchestrator refuses `/sites/register` like any other route,
|
|
230
231
|
* and this CLI had no way to present a token — so the documented path for
|
|
@@ -323,7 +324,11 @@ async function main() {
|
|
|
323
324
|
process.stdout.write(`\nNext steps:\n`);
|
|
324
325
|
process.stdout.write(` 1. Start your site: pnpm dev (in this directory)\n`);
|
|
325
326
|
process.stdout.write(` 2. Open the editor: http://localhost:4100\n`);
|
|
326
|
-
process.stdout.write(` 3. The site should appear in the dashboard. If not, refresh the page.\n
|
|
327
|
+
process.stdout.write(` 3. The site should appear in the dashboard. If not, refresh the page.\n`);
|
|
328
|
+
const mismatch = orchestratorMismatchNotice(orchestrator);
|
|
329
|
+
if (mismatch)
|
|
330
|
+
process.stdout.write(mismatch);
|
|
331
|
+
process.stdout.write(`\n`);
|
|
327
332
|
}
|
|
328
333
|
function humanize(s) {
|
|
329
334
|
return s
|
package/dist/editor.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { EditorOverlay } from "./editor-overlay.tsx";
|
|
2
2
|
export { buildEditorQuerySuffix } from "./editor-query.ts";
|
|
3
|
-
export { getPreviewWrapperProps, editableProps } from "./markers.ts";
|
|
3
|
+
export { getPreviewWrapperProps, editableProps, editableScopeProps } from "./markers.ts";
|
|
4
4
|
export { renderBlocks } from "./render-blocks.tsx";
|
|
5
5
|
export { RenderedBlocks, PreviewBlock } from "./live-preview-blocks.tsx";
|
|
6
6
|
export { LivePreviewProvider, useLivePreviewBlocks } from "@avocadostudio-ai/preview-adapter";
|
package/dist/editor.js
CHANGED
|
@@ -11,7 +11,7 @@ export { buildEditorQuerySuffix } from "./editor-query.js";
|
|
|
11
11
|
* for the two-line attribute helper from here drags the whole editor into the
|
|
12
12
|
* public bundle: +66 kB First Load JS, for identical markup.
|
|
13
13
|
*/
|
|
14
|
-
export { getPreviewWrapperProps, editableProps } from "./markers.js";
|
|
14
|
+
export { getPreviewWrapperProps, editableProps, editableScopeProps } from "./markers.js";
|
|
15
15
|
// Block rendering helper
|
|
16
16
|
export { renderBlocks } from "./render-blocks.js";
|
|
17
17
|
// Live-preview store renderer (streams field drafts through React)
|
package/dist/markers.d.ts
CHANGED
|
@@ -76,3 +76,35 @@ export declare function editableProps(path: string, options?: {
|
|
|
76
76
|
readonly "data-editable-target": string;
|
|
77
77
|
readonly "data-editable-target-label": string;
|
|
78
78
|
};
|
|
79
|
+
/**
|
|
80
|
+
* Mark an element as the scope its marked descendants sit inside.
|
|
81
|
+
*
|
|
82
|
+
* The field path is scoped from the block down — `items[3].question` — which
|
|
83
|
+
* a renderer can only write if it knows where it sits. That holds while one
|
|
84
|
+
* component draws the whole block, and stops holding the moment a list row is
|
|
85
|
+
* drawn by a component of its own: the child knows it has a `question` and
|
|
86
|
+
* cannot know it is `items[3]`. Without this, every component that can appear
|
|
87
|
+
* inside a list takes a prefix prop from its parent, and every parent passes
|
|
88
|
+
* one.
|
|
89
|
+
*
|
|
90
|
+
* Forgetting to is silent and *wrong*, not silent and absent. The child marks
|
|
91
|
+
* a bare `question`, the overlay resolves it against the enclosing block, and
|
|
92
|
+
* an edit to a headline inside a column patches a prop the section does not
|
|
93
|
+
* have.
|
|
94
|
+
*
|
|
95
|
+
* ```tsx
|
|
96
|
+
* {props.items.map((item, i) => (
|
|
97
|
+
* <div key={item.id} {...editableScopeProps(`items[${i}]`)}>
|
|
98
|
+
* <FaqRow item={item} /> // marks a bare "question"; needs no prefix
|
|
99
|
+
* </div>
|
|
100
|
+
* ))}
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* Scopes nest, and compose outermost first — a `left[1]` scope inside a
|
|
104
|
+
* `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
|
|
105
|
+
* A block boundary ends the composition, so a scope outside a block never
|
|
106
|
+
* reaches into it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function editableScopeProps(scope: string): {
|
|
109
|
+
readonly "data-editable-scope": string;
|
|
110
|
+
};
|
package/dist/markers.js
CHANGED
|
@@ -85,3 +85,35 @@ export function editableProps(path, options) {
|
|
|
85
85
|
...(options?.kind ? { "data-editable-kind": options.kind } : {})
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Mark an element as the scope its marked descendants sit inside.
|
|
90
|
+
*
|
|
91
|
+
* The field path is scoped from the block down — `items[3].question` — which
|
|
92
|
+
* a renderer can only write if it knows where it sits. That holds while one
|
|
93
|
+
* component draws the whole block, and stops holding the moment a list row is
|
|
94
|
+
* drawn by a component of its own: the child knows it has a `question` and
|
|
95
|
+
* cannot know it is `items[3]`. Without this, every component that can appear
|
|
96
|
+
* inside a list takes a prefix prop from its parent, and every parent passes
|
|
97
|
+
* one.
|
|
98
|
+
*
|
|
99
|
+
* Forgetting to is silent and *wrong*, not silent and absent. The child marks
|
|
100
|
+
* a bare `question`, the overlay resolves it against the enclosing block, and
|
|
101
|
+
* an edit to a headline inside a column patches a prop the section does not
|
|
102
|
+
* have.
|
|
103
|
+
*
|
|
104
|
+
* ```tsx
|
|
105
|
+
* {props.items.map((item, i) => (
|
|
106
|
+
* <div key={item.id} {...editableScopeProps(`items[${i}]`)}>
|
|
107
|
+
* <FaqRow item={item} /> // marks a bare "question"; needs no prefix
|
|
108
|
+
* </div>
|
|
109
|
+
* ))}
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* Scopes nest, and compose outermost first — a `left[1]` scope inside a
|
|
113
|
+
* `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
|
|
114
|
+
* A block boundary ends the composition, so a scope outside a block never
|
|
115
|
+
* reaches into it.
|
|
116
|
+
*/
|
|
117
|
+
export function editableScopeProps(scope) {
|
|
118
|
+
return { "data-editable-scope": scope };
|
|
119
|
+
}
|
package/dist/middleware.d.ts
CHANGED
package/dist/middleware.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { createEditorProxy } from "./proxy.js";
|
|
2
|
+
/*
|
|
3
|
+
* A Next 15 site reaches for this entry point, and a Next 15 site that already
|
|
4
|
+
* has a `middleware.ts` needs the two halves rather than the whole factory.
|
|
5
|
+
* Re-exported here so composing does not mean importing from two subpaths.
|
|
6
|
+
*/
|
|
7
|
+
export { trailingSlashRedirect, editorPreviewRewrite } from "./proxy.js";
|
|
2
8
|
/**
|
|
3
9
|
* Create a Next.js middleware function that rewrites editor/draft requests
|
|
4
10
|
* to a dynamic preview route, keeping the main page route fully static.
|
package/dist/proxy.d.ts
CHANGED
|
@@ -93,3 +93,29 @@ export declare function createEditorProxy(options?: EditorProxyOptions): {
|
|
|
93
93
|
matcher: string[];
|
|
94
94
|
};
|
|
95
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* Re-issue the trailing-slash redirect that `skipTrailingSlashRedirect` turned
|
|
98
|
+
* off, or `null` when the URL is already canonical.
|
|
99
|
+
*
|
|
100
|
+
* Exported because a site that already has a `middleware.ts` cannot use
|
|
101
|
+
* `createEditorProxy` as its whole middleware and has to compose — and the one
|
|
102
|
+
* line worth not writing twice is the first one below.
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* export function middleware(request: NextRequest) {
|
|
106
|
+
* const canonical = trailingSlashRedirect(request)
|
|
107
|
+
* if (canonical) return canonical
|
|
108
|
+
* return editorPreviewRewrite(request) ?? myOwnMiddleware(request)
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export declare function trailingSlashRedirect(request: NextRequest): NextResponse | null;
|
|
113
|
+
/**
|
|
114
|
+
* Rewrite an editor request to the dynamic preview route, or `null` when the
|
|
115
|
+
* request is not one — which is the caller's cue to run its own middleware
|
|
116
|
+
* rather than a decision to pass the request through.
|
|
117
|
+
*
|
|
118
|
+
* The half of `createEditorProxy` a site with its own middleware needs; see
|
|
119
|
+
* {@link trailingSlashRedirect} for the composition.
|
|
120
|
+
*/
|
|
121
|
+
export declare function editorPreviewRewrite(request: NextRequest, options?: Pick<EditorProxyOptions, "previewRoute" | "editorParam" | "draftCookie">): NextResponse | null;
|
package/dist/proxy.js
CHANGED
|
@@ -33,8 +33,6 @@ export { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.js";
|
|
|
33
33
|
*/
|
|
34
34
|
export function createEditorProxy(options) {
|
|
35
35
|
const previewRoute = options?.previewRoute ?? DEFAULT_PREVIEW_ROUTE;
|
|
36
|
-
const editorParam = options?.editorParam ?? "__editor";
|
|
37
|
-
const draftCookie = options?.draftCookie === undefined ? "__prerender_bypass" : options.draftCookie;
|
|
38
36
|
const trailingSlash = options?.trailingSlash ?? false;
|
|
39
37
|
function proxy(request) {
|
|
40
38
|
/*
|
|
@@ -45,32 +43,80 @@ export function createEditorProxy(options) {
|
|
|
45
43
|
* publishes only at `/about/`.
|
|
46
44
|
*/
|
|
47
45
|
if (trailingSlash) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* redirect built from a clone points at the URL it is trying to leave —
|
|
52
|
-
* a redirect loop, and one that only a browser would ever have shown us.
|
|
53
|
-
* A plain URL does no normalising. It also keeps `basePath`, which
|
|
54
|
-
* `nextUrl.pathname` has already stripped.
|
|
55
|
-
*/
|
|
56
|
-
const url = new URL(request.url);
|
|
57
|
-
if (url.pathname.length > 1 && !url.pathname.endsWith("/")) {
|
|
58
|
-
url.pathname = `${url.pathname}/`;
|
|
59
|
-
// 308, not 307: the method is preserved *and* the redirect is
|
|
60
|
-
// permanent, which is what Next's own trailing-slash redirect sends
|
|
61
|
-
// and what the site's existing search rankings were built on.
|
|
62
|
-
return NextResponse.redirect(url, 308);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
const isEditor = request.nextUrl.searchParams.get(editorParam) === "1";
|
|
66
|
-
const hasDraftCookie = draftCookie !== false && request.cookies.has(draftCookie);
|
|
67
|
-
if (isEditor || hasDraftCookie) {
|
|
68
|
-
const url = request.nextUrl.clone();
|
|
69
|
-
url.pathname = `${previewRoute}${url.pathname}`;
|
|
70
|
-
return NextResponse.rewrite(url);
|
|
46
|
+
const redirect = trailingSlashRedirect(request);
|
|
47
|
+
if (redirect)
|
|
48
|
+
return redirect;
|
|
71
49
|
}
|
|
72
|
-
return NextResponse.next();
|
|
50
|
+
return editorPreviewRewrite(request, options) ?? NextResponse.next();
|
|
73
51
|
}
|
|
74
52
|
const config = { matcher: [buildEditorMatcher(previewRoute)] };
|
|
75
53
|
return { proxy, config };
|
|
76
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Re-issue the trailing-slash redirect that `skipTrailingSlashRedirect` turned
|
|
57
|
+
* off, or `null` when the URL is already canonical.
|
|
58
|
+
*
|
|
59
|
+
* Exported because a site that already has a `middleware.ts` cannot use
|
|
60
|
+
* `createEditorProxy` as its whole middleware and has to compose — and the one
|
|
61
|
+
* line worth not writing twice is the first one below.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* export function middleware(request: NextRequest) {
|
|
65
|
+
* const canonical = trailingSlashRedirect(request)
|
|
66
|
+
* if (canonical) return canonical
|
|
67
|
+
* return editorPreviewRewrite(request) ?? myOwnMiddleware(request)
|
|
68
|
+
* }
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function trailingSlashRedirect(request) {
|
|
72
|
+
/*
|
|
73
|
+
* Built from `request.url`, not from `request.nextUrl.clone()`. NextURL
|
|
74
|
+
* normalises a trailing slash back *off* when it stringifies, so a redirect
|
|
75
|
+
* built from a clone points at the URL it is trying to leave — every page on
|
|
76
|
+
* the site enters an infinite redirect, and only in a browser: `curl`
|
|
77
|
+
* without `-L` sees one perfectly ordinary 308. A plain URL does no
|
|
78
|
+
* normalising. It also keeps `basePath`, which `nextUrl.pathname` has
|
|
79
|
+
* already stripped.
|
|
80
|
+
*/
|
|
81
|
+
const url = new URL(request.url);
|
|
82
|
+
const { pathname } = url;
|
|
83
|
+
if (pathname.length <= 1 || pathname.endsWith("/"))
|
|
84
|
+
return null;
|
|
85
|
+
/*
|
|
86
|
+
* The set that must never gain a trailing slash. `createEditorProxy`'s own
|
|
87
|
+
* matcher already excludes all of it, so these are no-ops there — they are
|
|
88
|
+
* for the hand-composed case, where the site's matcher is its own and is
|
|
89
|
+
* usually wider. Redirecting `/api/editor/blocks` to `/api/editor/blocks/`
|
|
90
|
+
* here would re-create, by hand, the exact 308 that
|
|
91
|
+
* `skipTrailingSlashRedirect` was turned on to stop.
|
|
92
|
+
*/
|
|
93
|
+
if (pathname.startsWith("/api/") || pathname.startsWith("/_next/"))
|
|
94
|
+
return null;
|
|
95
|
+
if (pathname.slice(pathname.lastIndexOf("/") + 1).includes("."))
|
|
96
|
+
return null;
|
|
97
|
+
url.pathname = `${pathname}/`;
|
|
98
|
+
// 308, not 307: the method is preserved *and* the redirect is permanent,
|
|
99
|
+
// which is what Next's own trailing-slash redirect sends and what the site's
|
|
100
|
+
// existing search rankings were built on.
|
|
101
|
+
return NextResponse.redirect(url, 308);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Rewrite an editor request to the dynamic preview route, or `null` when the
|
|
105
|
+
* request is not one — which is the caller's cue to run its own middleware
|
|
106
|
+
* rather than a decision to pass the request through.
|
|
107
|
+
*
|
|
108
|
+
* The half of `createEditorProxy` a site with its own middleware needs; see
|
|
109
|
+
* {@link trailingSlashRedirect} for the composition.
|
|
110
|
+
*/
|
|
111
|
+
export function editorPreviewRewrite(request, options) {
|
|
112
|
+
const previewRoute = options?.previewRoute ?? DEFAULT_PREVIEW_ROUTE;
|
|
113
|
+
const editorParam = options?.editorParam ?? "__editor";
|
|
114
|
+
const draftCookie = options?.draftCookie === undefined ? "__prerender_bypass" : options.draftCookie;
|
|
115
|
+
const isEditor = request.nextUrl.searchParams.get(editorParam) === "1";
|
|
116
|
+
const hasDraftCookie = draftCookie !== false && request.cookies.has(draftCookie);
|
|
117
|
+
if (!isEditor && !hasDraftCookie)
|
|
118
|
+
return null;
|
|
119
|
+
const url = request.nextUrl.clone();
|
|
120
|
+
url.pathname = `${previewRoute}${url.pathname}`;
|
|
121
|
+
return NextResponse.rewrite(url);
|
|
122
|
+
}
|
package/dist/proxy.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { NextRequest } from "next/server";
|
|
4
|
-
import { buildEditorMatcher, createEditorProxy, DEFAULT_PREVIEW_ROUTE } from "./proxy.js";
|
|
3
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
4
|
+
import { buildEditorMatcher, createEditorProxy, editorPreviewRewrite, trailingSlashRedirect, DEFAULT_PREVIEW_ROUTE } from "./proxy.js";
|
|
5
5
|
import { createEditorMiddleware } from "./middleware.js";
|
|
6
6
|
/*
|
|
7
7
|
* The rewrite that keeps the published route static and sends editor traffic to
|
|
@@ -121,3 +121,62 @@ test("draftCookie: false leaves Next's draft cookie to whoever else is using it"
|
|
|
121
121
|
assert.equal(rewriteOf(proxy(request("https://site.test/about", "__prerender_bypass=abc"))), null, "a Sanity or Contentful preview must not be hijacked into Avocado's route");
|
|
122
122
|
assert.equal(rewriteOf(proxy(request("https://site.test/about?__editor=1"))), "https://site.test/preview-draft/about?__editor=1", "the explicit editor parameter still works — that is the whole point of the opt-out");
|
|
123
123
|
});
|
|
124
|
+
/*
|
|
125
|
+
* The two halves, on their own.
|
|
126
|
+
*
|
|
127
|
+
* A site that already has a `middleware.ts` cannot hand the whole file to
|
|
128
|
+
* `createEditorProxy` and has to compose — which until now meant re-deriving
|
|
129
|
+
* the trailing-slash redirect from the docs. One integration did, and wrote
|
|
130
|
+
* `request.nextUrl.clone()`, because that is what every Next example uses for
|
|
131
|
+
* a URL you intend to modify. A `NextURL` re-applies trailing-slash
|
|
132
|
+
* normalisation when it serialises, so the redirect stripped the slash it had
|
|
133
|
+
* just added and pointed at the URL it came from: every page on the site in an
|
|
134
|
+
* infinite redirect, reproducing only in a browser. `curl` without `-L` sees
|
|
135
|
+
* one perfectly ordinary 308, which is how it survived a session and a half.
|
|
136
|
+
*/
|
|
137
|
+
test("the redirect is built from request.url, not from a NextURL that would undo it", () => {
|
|
138
|
+
const response = trailingSlashRedirect(request("https://site.test/bern"));
|
|
139
|
+
assert.equal(response?.status, 308);
|
|
140
|
+
assert.equal(response?.headers.get("location"), "https://site.test/bern/", "a location without the slash is the loop: the browser asks again and gets the same answer");
|
|
141
|
+
});
|
|
142
|
+
test("an already-canonical URL, the root, and a file are left alone", () => {
|
|
143
|
+
assert.equal(trailingSlashRedirect(request("https://site.test/bern/")), null);
|
|
144
|
+
assert.equal(trailingSlashRedirect(request("https://site.test/")), null, "redirecting / never terminates");
|
|
145
|
+
assert.equal(trailingSlashRedirect(request("https://site.test/logo.svg")), null);
|
|
146
|
+
});
|
|
147
|
+
test("the API routes are never redirected, whatever the caller's matcher says", () => {
|
|
148
|
+
/*
|
|
149
|
+
* The proxy's own matcher excludes these, but a hand-composed middleware
|
|
150
|
+
* brings its own matcher and it is usually wider. A slash added here
|
|
151
|
+
* re-creates by hand the exact 308 on `/api/editor/*` that
|
|
152
|
+
* `skipTrailingSlashRedirect` was turned on to stop — and the symptom is a
|
|
153
|
+
* CORS error that names nothing.
|
|
154
|
+
*/
|
|
155
|
+
assert.equal(trailingSlashRedirect(request("https://site.test/api/editor/blocks")), null);
|
|
156
|
+
assert.equal(trailingSlashRedirect(request("https://site.test/_next/static/chunk")), null);
|
|
157
|
+
});
|
|
158
|
+
test("the redirect keeps the query string when called directly too", () => {
|
|
159
|
+
const response = trailingSlashRedirect(request("https://site.test/bern?__editor=1"));
|
|
160
|
+
assert.equal(response?.headers.get("location"), "https://site.test/bern/?__editor=1");
|
|
161
|
+
});
|
|
162
|
+
test("editorPreviewRewrite answers null for a visitor, so the caller runs its own middleware", () => {
|
|
163
|
+
assert.equal(editorPreviewRewrite(request("https://site.test/about")), null, "null is 'not mine', not 'pass this through' — a composing caller has its own work to do");
|
|
164
|
+
assert.equal(rewriteOf(editorPreviewRewrite(request("https://site.test/about?__editor=1"))), "https://site.test/preview-draft/about?__editor=1");
|
|
165
|
+
});
|
|
166
|
+
test("the composed form behaves like the factory it was extracted from", () => {
|
|
167
|
+
const { proxy } = createEditorProxy({ trailingSlash: true });
|
|
168
|
+
const compose = (r) => trailingSlashRedirect(r) ?? editorPreviewRewrite(r) ?? NextResponse.next();
|
|
169
|
+
for (const url of [
|
|
170
|
+
"https://site.test/bern",
|
|
171
|
+
"https://site.test/bern/",
|
|
172
|
+
"https://site.test/bern?__editor=1",
|
|
173
|
+
"https://site.test/bern/?__editor=1",
|
|
174
|
+
"https://site.test/"
|
|
175
|
+
]) {
|
|
176
|
+
const fromFactory = proxy(request(url));
|
|
177
|
+
const fromParts = compose(request(url));
|
|
178
|
+
assert.equal(fromParts.status, fromFactory.status, url);
|
|
179
|
+
assert.equal(fromParts.headers.get("location"), fromFactory.headers.get("location"), url);
|
|
180
|
+
assert.equal(rewriteOf(fromParts), rewriteOf(fromFactory), url);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
@@ -198,3 +198,15 @@ export declare function diffPage<Ctx>(args: {
|
|
|
198
198
|
}): FieldDiff;
|
|
199
199
|
/** One line per refusal, in the editor's terms. For surfacing to a person. */
|
|
200
200
|
export declare function describeUnsupported(unsupported: UnsupportedChange[]): string[];
|
|
201
|
+
/**
|
|
202
|
+
* Avocado's own list-row stamps, removed.
|
|
203
|
+
*
|
|
204
|
+
* Re-exported here because this is the module an adapter already imports to
|
|
205
|
+
* compare a draft against upstream content, and the stamps are the reason a
|
|
206
|
+
* naive comparison reports every block that has a list as changed. `diffFields`
|
|
207
|
+
* never sees them — it walks the specs, and no spec declares `id` — but the
|
|
208
|
+
* integration that found this compared whole documents first, which is the
|
|
209
|
+
* obvious thing to do and took a field-level diff of two JSON blobs to
|
|
210
|
+
* unpick.
|
|
211
|
+
*/
|
|
212
|
+
export { withoutGeneratedItemIds, isGeneratedItemId } from "@avocadostudio-ai/shared";
|
|
@@ -264,3 +264,15 @@ export function diffPage(args) {
|
|
|
264
264
|
export function describeUnsupported(unsupported) {
|
|
265
265
|
return unsupported.map((u) => `${u.where}: ${u.change}${u.remedy ? ` — ${u.remedy}` : ""}`);
|
|
266
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* Avocado's own list-row stamps, removed.
|
|
269
|
+
*
|
|
270
|
+
* Re-exported here because this is the module an adapter already imports to
|
|
271
|
+
* compare a draft against upstream content, and the stamps are the reason a
|
|
272
|
+
* naive comparison reports every block that has a list as changed. `diffFields`
|
|
273
|
+
* never sees them — it walks the specs, and no spec declares `id` — but the
|
|
274
|
+
* integration that found this compared whole documents first, which is the
|
|
275
|
+
* obvious thing to do and took a field-level diff of two JSON blobs to
|
|
276
|
+
* unpick.
|
|
277
|
+
*/
|
|
278
|
+
export { withoutGeneratedItemIds, isGeneratedItemId } from "@avocadostudio-ai/shared";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/site-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -122,16 +122,16 @@
|
|
|
122
122
|
],
|
|
123
123
|
"dependencies": {
|
|
124
124
|
"zod": "^4.3.6",
|
|
125
|
-
"@avocadostudio-ai/blocks": "^0.
|
|
126
|
-
"@avocadostudio-ai/preview-adapter": "^0.
|
|
127
|
-
"@avocadostudio-ai/shared": "^0.
|
|
125
|
+
"@avocadostudio-ai/blocks": "^0.6.0",
|
|
126
|
+
"@avocadostudio-ai/preview-adapter": "^0.6.0",
|
|
127
|
+
"@avocadostudio-ai/shared": "^0.6.0"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
130
|
"next": ">=15.0.0",
|
|
131
131
|
"react": ">=19.0.0",
|
|
132
132
|
"react-dom": ">=19.0.0",
|
|
133
133
|
"better-sqlite3": ">=12.0.0",
|
|
134
|
-
"@avocadostudio-ai/orchestrator-core": "^0.
|
|
134
|
+
"@avocadostudio-ai/orchestrator-core": "^0.6.0"
|
|
135
135
|
},
|
|
136
136
|
"peerDependenciesMeta": {
|
|
137
137
|
"@avocadostudio-ai/orchestrator-core": {
|