@avocadostudio-ai/site-sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -2
- package/dist/create-site-page.d.ts +38 -8
- package/dist/create-site-page.js +59 -8
- package/dist/draft-common.d.ts +32 -0
- package/dist/draft-common.js +58 -0
- package/dist/draft-context-core.js +39 -6
- package/dist/draft-context-core.test.d.ts +10 -0
- package/dist/draft-context-core.test.js +146 -0
- package/dist/editor-cors.d.ts +12 -0
- package/dist/editor-cors.js +31 -6
- package/dist/editor-cors.test.d.ts +1 -0
- package/dist/editor-cors.test.js +66 -0
- package/dist/editor-manifest.d.ts +2 -3
- package/dist/editor-manifest.js +12 -64
- package/dist/editor-matcher.d.ts +27 -0
- package/dist/editor-matcher.js +34 -0
- package/dist/editor-query.js +7 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integration-check.js +11 -1
- package/dist/manifest-utils.d.ts +13 -0
- package/dist/manifest-utils.js +30 -3
- package/dist/manifest-utils.test.d.ts +1 -0
- package/dist/manifest-utils.test.js +72 -0
- package/dist/middleware.d.ts +21 -19
- package/dist/middleware.js +19 -22
- package/dist/next-config.test.d.ts +1 -0
- package/dist/next-config.test.js +253 -0
- package/dist/page-metadata.d.ts +66 -0
- package/dist/page-metadata.js +110 -0
- package/dist/page-metadata.test.d.ts +1 -0
- package/dist/page-metadata.test.js +105 -0
- package/dist/proxy.d.ts +58 -0
- package/dist/proxy.js +50 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +72 -0
- package/dist/publish/field-diff.d.ts +191 -0
- package/dist/publish/field-diff.js +252 -0
- package/dist/publish/field-diff.test.d.ts +1 -0
- package/dist/publish/field-diff.test.js +286 -0
- package/dist/server/orchestrator.d.ts +1 -117
- package/dist/server/orchestrator.js +14 -733
- package/next-config.d.ts +68 -0
- package/next-config.mjs +358 -0
- package/package.json +63 -19
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may see unpublished content, and which frame may drive the editor.
|
|
3
|
+
*
|
|
4
|
+
* Both questions used to be answered by the query string. `?__editor=1` alone
|
|
5
|
+
* turned on the draft content store — in production, on the customer's own
|
|
6
|
+
* domain — and `editorOrigin` was accepted from the same URL after a check that
|
|
7
|
+
* only asked whether it parsed as http(s). One GET rendered another session's
|
|
8
|
+
* drafts and pointed the postMessage bridge wherever the caller said.
|
|
9
|
+
*/
|
|
10
|
+
import { test, describe, afterEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { resolveDraftContextCore } from "./draft-context-core.js";
|
|
13
|
+
import { resolveTrustedEditorOrigin, editorOriginAllowlist, isLoopbackOrigin } from "./draft-common.js";
|
|
14
|
+
const SECRET = "s3cr3t-draft-key";
|
|
15
|
+
const saved = {};
|
|
16
|
+
const KEYS = ["NODE_ENV", "DRAFT_MODE_SECRET", "NEXT_PUBLIC_EDITOR_ORIGIN", "AVOCADO_EDITOR_ORIGINS"];
|
|
17
|
+
function setEnv(values) {
|
|
18
|
+
for (const key of KEYS) {
|
|
19
|
+
if (!(key in saved))
|
|
20
|
+
saved[key] = process.env[key];
|
|
21
|
+
}
|
|
22
|
+
for (const [key, value] of Object.entries(values)) {
|
|
23
|
+
if (value === undefined)
|
|
24
|
+
delete process.env[key];
|
|
25
|
+
else
|
|
26
|
+
process.env[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
for (const key of KEYS) {
|
|
31
|
+
if (saved[key] === undefined)
|
|
32
|
+
delete process.env[key];
|
|
33
|
+
else
|
|
34
|
+
process.env[key] = saved[key];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
function adapter(overrides) {
|
|
38
|
+
return { isDraftMode: false, getCookie: () => undefined, ...overrides };
|
|
39
|
+
}
|
|
40
|
+
describe("resolveDraftContextCore — production gate", () => {
|
|
41
|
+
test("__editor=1 alone no longer opens the content store", async () => {
|
|
42
|
+
setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
|
|
43
|
+
const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", session: "victim" }, adapter());
|
|
44
|
+
assert.equal(ctx, null);
|
|
45
|
+
});
|
|
46
|
+
test("a wrong secret is refused", async () => {
|
|
47
|
+
setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
|
|
48
|
+
const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", secret: "guess" }, adapter());
|
|
49
|
+
assert.equal(ctx, null);
|
|
50
|
+
});
|
|
51
|
+
test("the configured secret opens it — the cookie-blocked iframe case", async () => {
|
|
52
|
+
setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
|
|
53
|
+
const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", secret: SECRET }, adapter());
|
|
54
|
+
assert.equal(ctx?.siteId, "acme");
|
|
55
|
+
});
|
|
56
|
+
test("draft mode already enabled opens it — the secret-gated cookie path", async () => {
|
|
57
|
+
setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: SECRET });
|
|
58
|
+
const ctx = await resolveDraftContextCore({ siteId: "acme" }, adapter({ isDraftMode: true }));
|
|
59
|
+
assert.equal(ctx?.siteId, "acme");
|
|
60
|
+
});
|
|
61
|
+
test("with no secret configured, production stays shut rather than open", async () => {
|
|
62
|
+
setEnv({ NODE_ENV: "production", DRAFT_MODE_SECRET: undefined });
|
|
63
|
+
const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme", secret: "anything" }, adapter());
|
|
64
|
+
assert.equal(ctx, null);
|
|
65
|
+
});
|
|
66
|
+
test("development is unchanged — the parameter alone still works", async () => {
|
|
67
|
+
setEnv({ NODE_ENV: "development", DRAFT_MODE_SECRET: undefined });
|
|
68
|
+
const ctx = await resolveDraftContextCore({ __editor: "1", siteId: "acme" }, adapter());
|
|
69
|
+
assert.equal(ctx?.siteId, "acme");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("resolveDraftContextCore — editor origin", () => {
|
|
73
|
+
test("an unlisted origin from the URL is ignored in favour of the configured one", async () => {
|
|
74
|
+
setEnv({
|
|
75
|
+
NODE_ENV: "production",
|
|
76
|
+
DRAFT_MODE_SECRET: SECRET,
|
|
77
|
+
NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com"
|
|
78
|
+
});
|
|
79
|
+
const ctx = await resolveDraftContextCore({ siteId: "acme", secret: SECRET, editorOrigin: "https://attacker.example" }, adapter());
|
|
80
|
+
assert.equal(ctx?.editorOrigin, "https://editor.example.com");
|
|
81
|
+
});
|
|
82
|
+
test("a listed origin from the URL is honoured", async () => {
|
|
83
|
+
setEnv({
|
|
84
|
+
NODE_ENV: "production",
|
|
85
|
+
DRAFT_MODE_SECRET: SECRET,
|
|
86
|
+
NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com",
|
|
87
|
+
AVOCADO_EDITOR_ORIGINS: "https://staging-editor.example.com, https://other.example.com"
|
|
88
|
+
});
|
|
89
|
+
const ctx = await resolveDraftContextCore({ siteId: "acme", secret: SECRET, editorOrigin: "https://staging-editor.example.com" }, adapter());
|
|
90
|
+
assert.equal(ctx?.editorOrigin, "https://staging-editor.example.com");
|
|
91
|
+
});
|
|
92
|
+
test("a hostile origin in the cookie is ignored too", async () => {
|
|
93
|
+
setEnv({
|
|
94
|
+
NODE_ENV: "production",
|
|
95
|
+
DRAFT_MODE_SECRET: SECRET,
|
|
96
|
+
NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com"
|
|
97
|
+
});
|
|
98
|
+
const ctx = await resolveDraftContextCore({ siteId: "acme", secret: SECRET }, adapter({ getCookie: (name) => (name === "editor_origin" ? "https%3A%2F%2Fattacker.example" : undefined) }));
|
|
99
|
+
assert.equal(ctx?.editorOrigin, "https://editor.example.com");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("resolveTrustedEditorOrigin", () => {
|
|
103
|
+
test("keeps the fallback when there is no candidate", () => {
|
|
104
|
+
assert.equal(resolveTrustedEditorOrigin({ candidate: undefined, fallback: "https://a.example", isDev: false, env: {} }), "https://a.example");
|
|
105
|
+
});
|
|
106
|
+
test("accepts loopback in development, at any port", () => {
|
|
107
|
+
for (const candidate of ["http://localhost:4100", "http://localhost:4101", "http://127.0.0.1:5173"]) {
|
|
108
|
+
assert.equal(resolveTrustedEditorOrigin({ candidate, fallback: "http://localhost:4100", isDev: true, env: {} }), candidate);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
test("refuses loopback in production — an operator must list it", () => {
|
|
112
|
+
assert.equal(resolveTrustedEditorOrigin({
|
|
113
|
+
candidate: "http://localhost:4100",
|
|
114
|
+
fallback: "https://editor.example.com",
|
|
115
|
+
isDev: false,
|
|
116
|
+
env: {}
|
|
117
|
+
}), "https://editor.example.com");
|
|
118
|
+
});
|
|
119
|
+
test("the fallback itself is always trusted", () => {
|
|
120
|
+
assert.equal(resolveTrustedEditorOrigin({
|
|
121
|
+
candidate: "https://editor.example.com",
|
|
122
|
+
fallback: "https://editor.example.com",
|
|
123
|
+
isDev: false,
|
|
124
|
+
env: {}
|
|
125
|
+
}), "https://editor.example.com");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("editorOriginAllowlist", () => {
|
|
129
|
+
test("merges both variables, normalises, and de-duplicates", () => {
|
|
130
|
+
const list = editorOriginAllowlist({
|
|
131
|
+
AVOCADO_EDITOR_ORIGINS: "https://a.example/, https://b.example",
|
|
132
|
+
NEXT_PUBLIC_EDITOR_ORIGIN: "https://a.example"
|
|
133
|
+
});
|
|
134
|
+
assert.deepEqual(list, ["https://a.example", "https://b.example"]);
|
|
135
|
+
});
|
|
136
|
+
test("drops entries that are not http(s) origins", () => {
|
|
137
|
+
const list = editorOriginAllowlist({ AVOCADO_EDITOR_ORIGINS: "javascript:alert(1), not-a-url, ftp://x.example" });
|
|
138
|
+
assert.deepEqual(list, []);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
test("isLoopbackOrigin recognises the local spellings and nothing else", () => {
|
|
142
|
+
assert.equal(isLoopbackOrigin("http://localhost:4100"), true);
|
|
143
|
+
assert.equal(isLoopbackOrigin("http://127.0.0.1:3000"), true);
|
|
144
|
+
assert.equal(isLoopbackOrigin("https://localhost.attacker.example"), false);
|
|
145
|
+
assert.equal(isLoopbackOrigin("https://example.com"), false);
|
|
146
|
+
});
|
package/dist/editor-cors.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS for the editor API routes a host app mounts.
|
|
3
|
+
*
|
|
4
|
+
* The dev editor runs on its own origin (`localhost:4100`), so the routes have
|
|
5
|
+
* to answer cross-origin requests from it. That default used to be added
|
|
6
|
+
* unconditionally, which meant a deployed site kept answering CORS-approved
|
|
7
|
+
* requests to a `localhost` origin: any page a visitor had open could read that
|
|
8
|
+
* site's draft content out of its own browser. Now the development default is
|
|
9
|
+
* only assumed in development, and a deployment says what it allows.
|
|
10
|
+
*/
|
|
1
11
|
export declare function getEditorCorsOrigins(): Set<string>;
|
|
12
|
+
/** Test seam — the cache is a module global, and env changes must be able to take effect. */
|
|
13
|
+
export declare function resetEditorCorsOrigins(): void;
|
|
2
14
|
export declare function applyEditorCors(response: Response, requestOrigin: string | null): Response;
|
|
3
15
|
export declare function createEditorCorsOptionsHandler(): (request: Request) => Response;
|
package/dist/editor-cors.js
CHANGED
|
@@ -1,15 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS for the editor API routes a host app mounts.
|
|
3
|
+
*
|
|
4
|
+
* The dev editor runs on its own origin (`localhost:4100`), so the routes have
|
|
5
|
+
* to answer cross-origin requests from it. That default used to be added
|
|
6
|
+
* unconditionally, which meant a deployed site kept answering CORS-approved
|
|
7
|
+
* requests to a `localhost` origin: any page a visitor had open could read that
|
|
8
|
+
* site's draft content out of its own browser. Now the development default is
|
|
9
|
+
* only assumed in development, and a deployment says what it allows.
|
|
10
|
+
*/
|
|
11
|
+
const DEV_EDITOR_ORIGIN = "http://localhost:4100";
|
|
1
12
|
let cachedOrigins;
|
|
13
|
+
function parseOrigins(value) {
|
|
14
|
+
return (value ?? "")
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((entry) => entry.trim().replace(/\/+$/, ""))
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
}
|
|
2
19
|
export function getEditorCorsOrigins() {
|
|
3
20
|
if (cachedOrigins)
|
|
4
21
|
return cachedOrigins;
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
22
|
+
const configured = parseOrigins(process.env.EDITOR_CORS_ORIGINS);
|
|
23
|
+
/*
|
|
24
|
+
* `NEXT_PUBLIC_EDITOR_ORIGIN` is what the app already tells the browser to
|
|
25
|
+
* talk to, so a deployment that set it has already named its editor and
|
|
26
|
+
* should not have to name it twice.
|
|
27
|
+
*/
|
|
28
|
+
const declaredEditor = parseOrigins(process.env.NEXT_PUBLIC_EDITOR_ORIGIN);
|
|
29
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
30
|
+
const defaults = isProduction ? [] : [DEV_EDITOR_ORIGIN];
|
|
31
|
+
cachedOrigins = new Set([...defaults, ...declaredEditor, ...configured]);
|
|
11
32
|
return cachedOrigins;
|
|
12
33
|
}
|
|
34
|
+
/** Test seam — the cache is a module global, and env changes must be able to take effect. */
|
|
35
|
+
export function resetEditorCorsOrigins() {
|
|
36
|
+
cachedOrigins = undefined;
|
|
37
|
+
}
|
|
13
38
|
export function applyEditorCors(response, requestOrigin) {
|
|
14
39
|
const vary = response.headers.get("Vary") ?? "";
|
|
15
40
|
const hasOrigin = vary.split(",").map((v) => v.trim().toLowerCase()).includes("origin");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { applyEditorCors, getEditorCorsOrigins, resetEditorCorsOrigins } from "./editor-cors.js";
|
|
4
|
+
function withEnv(env, fn) {
|
|
5
|
+
const previous = {};
|
|
6
|
+
for (const [key, value] of Object.entries(env)) {
|
|
7
|
+
previous[key] = process.env[key];
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
delete process.env[key];
|
|
10
|
+
else
|
|
11
|
+
process.env[key] = value;
|
|
12
|
+
}
|
|
13
|
+
resetEditorCorsOrigins();
|
|
14
|
+
try {
|
|
15
|
+
fn();
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
delete process.env[key];
|
|
21
|
+
else
|
|
22
|
+
process.env[key] = value;
|
|
23
|
+
}
|
|
24
|
+
resetEditorCorsOrigins();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const CLEAN = { EDITOR_CORS_ORIGINS: undefined, NEXT_PUBLIC_EDITOR_ORIGIN: undefined };
|
|
28
|
+
test("the dev editor origin is allowed in development", () => {
|
|
29
|
+
withEnv({ ...CLEAN, NODE_ENV: "development" }, () => {
|
|
30
|
+
assert.ok(getEditorCorsOrigins().has("http://localhost:4100"));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
test("the dev editor origin is not allowed in production", () => {
|
|
34
|
+
/*
|
|
35
|
+
* The bug this replaces: a deployed site answered CORS-approved requests to
|
|
36
|
+
* localhost:4100, so any page a visitor had open could read the site's drafts
|
|
37
|
+
* out of that visitor's browser.
|
|
38
|
+
*/
|
|
39
|
+
withEnv({ ...CLEAN, NODE_ENV: "production" }, () => {
|
|
40
|
+
assert.equal(getEditorCorsOrigins().has("http://localhost:4100"), false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
test("a production deployment allows exactly what it names", () => {
|
|
44
|
+
withEnv({ ...CLEAN, NODE_ENV: "production", EDITOR_CORS_ORIGINS: "https://editor.example.com" }, () => {
|
|
45
|
+
const origins = getEditorCorsOrigins();
|
|
46
|
+
assert.ok(origins.has("https://editor.example.com"));
|
|
47
|
+
assert.equal(origins.size, 1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
test("the editor origin the app already advertises does not have to be named twice", () => {
|
|
51
|
+
withEnv({ ...CLEAN, NODE_ENV: "production", NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com/" }, () => {
|
|
52
|
+
// Trailing slash stripped — an Origin header never has one, so a config
|
|
53
|
+
// that carries one would silently match nothing.
|
|
54
|
+
assert.ok(getEditorCorsOrigins().has("https://editor.example.com"));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
test("an allowed origin gets the CORS headers and a disallowed one does not", () => {
|
|
58
|
+
withEnv({ ...CLEAN, NODE_ENV: "production", EDITOR_CORS_ORIGINS: "https://editor.example.com" }, () => {
|
|
59
|
+
const allowed = applyEditorCors(new Response(null), "https://editor.example.com");
|
|
60
|
+
assert.equal(allowed.headers.get("Access-Control-Allow-Origin"), "https://editor.example.com");
|
|
61
|
+
const denied = applyEditorCors(new Response(null), "https://evil.example.com");
|
|
62
|
+
assert.equal(denied.headers.get("Access-Control-Allow-Origin"), null);
|
|
63
|
+
// Vary is set either way, or a cache would serve one origin's answer to another.
|
|
64
|
+
assert.equal(denied.headers.get("Vary"), "Origin");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
import { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest } from "@avocadostudio-ai/shared";
|
|
2
|
-
export { blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest };
|
|
3
|
-
export declare function buildBlockManifest(): BlockManifest;
|
|
1
|
+
import { buildBlockManifest, blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest } from "@avocadostudio-ai/shared";
|
|
2
|
+
export { buildBlockManifest, blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps, type BlockDefinition, type BlockManifest };
|
package/dist/editor-manifest.js
CHANGED
|
@@ -1,65 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
.map((type) => {
|
|
12
|
-
const meta = getBlockMeta(type);
|
|
13
|
-
const propsSchema = getBlockJsonSchema(type);
|
|
14
|
-
if (!propsSchema)
|
|
15
|
-
return null; // skip blocks without schema (shouldn't happen but be safe)
|
|
16
|
-
const finalSchema = applyMetaEnumsToSchema(propsSchema, meta);
|
|
17
|
-
// Only attach defaultProps that actually satisfy the block's schema.
|
|
18
|
-
// defaultPropsForType() hands custom (non-core) blocks a generic CTA-shaped
|
|
19
|
-
// fallback; if that violates a custom schema (e.g. a richtext field expecting
|
|
20
|
-
// a doc object, not a string) it would fail validateManifestDefaultProps and
|
|
21
|
-
// poison the WHOLE manifest, making every block read as "unknown" in the editor.
|
|
22
|
-
const candidateDefaults = defaultPropsForType(type);
|
|
23
|
-
const defaultProps = validateByJsonSchemaLike(finalSchema, candidateDefaults)
|
|
24
|
-
? candidateDefaults
|
|
25
|
-
: undefined;
|
|
26
|
-
return {
|
|
27
|
-
type,
|
|
28
|
-
displayName: meta?.displayName ?? type,
|
|
29
|
-
propsSchema: finalSchema,
|
|
30
|
-
defaultProps
|
|
31
|
-
};
|
|
32
|
-
})
|
|
33
|
-
.filter((b) => b !== null);
|
|
34
|
-
return { version: 1, blocks };
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Fold enum options declared in the block's registry `meta.fields` into the
|
|
38
|
-
* derived JSON schema. A site can declare `f.enum("Gap", ["sm","md","lg"])` in
|
|
39
|
-
* its block meta while the Zod prop stays a plain `z.string()` (so any value
|
|
40
|
-
* still validates). Without this, manifest-driven custom blocks lose the enum
|
|
41
|
-
* and the editor renders a free-text input instead of a dropdown. We only add
|
|
42
|
-
* `enum` (the editor reads it to pick a select control); validation is type-only
|
|
43
|
-
* (see validateByJsonSchemaLike), so widening here never rejects stored content.
|
|
1
|
+
/*
|
|
2
|
+
* The manifest builder itself now lives in `@avocadostudio-ai/shared`.
|
|
3
|
+
*
|
|
4
|
+
* It moved because the SDK was no longer its only caller: the orchestrator
|
|
5
|
+
* serves the same manifest at `GET /blocks/manifest` so an MCP agent can
|
|
6
|
+
* discover the *target site's* block vocabulary instead of whatever happens to
|
|
7
|
+
* be registered in its own process. `shared` is the one package both the SDK
|
|
8
|
+
* and orchestrator-core already depend on, so it is where a single answer can
|
|
9
|
+
* live. This module stays as the SDK's public entry point — every site
|
|
10
|
+
* template, example and doc imports `buildBlockManifest` from here.
|
|
44
11
|
*/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return schema;
|
|
48
|
-
const properties = schema.properties;
|
|
49
|
-
if (!properties || typeof properties !== "object")
|
|
50
|
-
return schema;
|
|
51
|
-
let next;
|
|
52
|
-
for (const [key, fieldMeta] of Object.entries(meta.fields)) {
|
|
53
|
-
if (fieldMeta.kind !== "enum" || !fieldMeta.options?.length)
|
|
54
|
-
continue;
|
|
55
|
-
const prop = properties[key];
|
|
56
|
-
if (!prop || typeof prop !== "object" || Array.isArray(prop))
|
|
57
|
-
continue;
|
|
58
|
-
const propObj = prop;
|
|
59
|
-
if (Array.isArray(propObj.enum))
|
|
60
|
-
continue; // schema already carries an enum
|
|
61
|
-
next ??= { ...schema, properties: { ...properties } };
|
|
62
|
-
next.properties[key] = { ...propObj, type: "string", enum: [...fieldMeta.options] };
|
|
63
|
-
}
|
|
64
|
-
return next ?? schema;
|
|
65
|
-
}
|
|
12
|
+
import { buildBlockManifest, blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps } from "@avocadostudio-ai/shared";
|
|
13
|
+
export { buildBlockManifest, blockDefinitionSchema, blockManifestSchema, validateByJsonSchemaLike, validateManifestDefaultProps };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route matcher that decides which requests the editor rewrite even sees.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `proxy.ts` because it is pure string logic with no dependency
|
|
5
|
+
* on Next: the rewrite itself needs `NextResponse`, but the matcher is just a
|
|
6
|
+
* pattern, and Next 16 requires it to be inlined at the call site anyway. Its
|
|
7
|
+
* own module means a caller that only wants the pattern — the scaffolder
|
|
8
|
+
* templates, a test, a non-Next host — does not have to pull in `next/server`.
|
|
9
|
+
*/
|
|
10
|
+
/** Default route prefix that the dynamic editor/draft page lives under. */
|
|
11
|
+
export declare const DEFAULT_PREVIEW_ROUTE = "/preview-draft";
|
|
12
|
+
/**
|
|
13
|
+
* Build the `config.matcher` pattern that skips Next internals, the API routes,
|
|
14
|
+
* static assets, and the preview route itself.
|
|
15
|
+
*
|
|
16
|
+
* Next.js 16 requires the `config` export in a `proxy.ts` to be a **static
|
|
17
|
+
* object literal** — it is read by static analysis, so it cannot be a factory
|
|
18
|
+
* return value, a spread, or an imported constant. Use this helper at authoring
|
|
19
|
+
* time (or copy the string it produces) to write that literal by hand:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* export const config = {
|
|
23
|
+
* matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildEditorMatcher(previewRoute?: string): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route matcher that decides which requests the editor rewrite even sees.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `proxy.ts` because it is pure string logic with no dependency
|
|
5
|
+
* on Next: the rewrite itself needs `NextResponse`, but the matcher is just a
|
|
6
|
+
* pattern, and Next 16 requires it to be inlined at the call site anyway. Its
|
|
7
|
+
* own module means a caller that only wants the pattern — the scaffolder
|
|
8
|
+
* templates, a test, a non-Next host — does not have to pull in `next/server`.
|
|
9
|
+
*/
|
|
10
|
+
/** Default route prefix that the dynamic editor/draft page lives under. */
|
|
11
|
+
export const DEFAULT_PREVIEW_ROUTE = "/preview-draft";
|
|
12
|
+
/**
|
|
13
|
+
* Build the `config.matcher` pattern that skips Next internals, the API routes,
|
|
14
|
+
* static assets, and the preview route itself.
|
|
15
|
+
*
|
|
16
|
+
* Next.js 16 requires the `config` export in a `proxy.ts` to be a **static
|
|
17
|
+
* object literal** — it is read by static analysis, so it cannot be a factory
|
|
18
|
+
* return value, a spread, or an imported constant. Use this helper at authoring
|
|
19
|
+
* time (or copy the string it produces) to write that literal by hand:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* export const config = {
|
|
23
|
+
* matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function buildEditorMatcher(previewRoute = DEFAULT_PREVIEW_ROUTE) {
|
|
28
|
+
// Escape special regex chars in the route prefix. The leading slash is
|
|
29
|
+
// dropped rather than sliced off blindly: `slice(1)` on a route written
|
|
30
|
+
// without one turned "preview" into "review" and matched nothing anybody
|
|
31
|
+
// could see.
|
|
32
|
+
const escapedRoute = previewRoute.replace(/^\/+/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
|
+
return `/((?!_next|${escapedRoute}|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)`;
|
|
34
|
+
}
|
package/dist/editor-query.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/*
|
|
2
|
+
* `secret` is carried alongside these so an in-site navigation stays in editor
|
|
3
|
+
* mode when the draft cookie is blocked. It is deliberately last: everything
|
|
4
|
+
* here ends up in hrefs the preview renders, so keep the list to what editor
|
|
5
|
+
* mode actually needs to survive a click.
|
|
6
|
+
*/
|
|
7
|
+
const EDITOR_KEYS = ["session", "siteId", "editorOrigin", "__editor", "secret"];
|
|
2
8
|
export function buildEditorQuerySuffix(searchParams) {
|
|
3
9
|
const params = new URLSearchParams();
|
|
4
10
|
for (const key of EDITOR_KEYS) {
|
package/dist/index.d.ts
CHANGED
|
@@ -3,3 +3,5 @@ export type { SiteConfig } from "@avocadostudio-ai/shared";
|
|
|
3
3
|
export { pageDocSchema } from "./types.ts";
|
|
4
4
|
export { buildSlug } from "./editor-query.ts";
|
|
5
5
|
export { renderBlocks } from "./render-blocks.tsx";
|
|
6
|
+
export { buildPageMetadata, derivePageDescription, derivePageTitle, stripMarkdown, truncateForMeta, DEFAULT_SITE_DESCRIPTION, } from "./page-metadata.ts";
|
|
7
|
+
export type { PageMetadata, BuildPageMetadataOptions } from "./page-metadata.ts";
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,5 @@ export { pageDocSchema } from "./types.js";
|
|
|
3
3
|
export { buildSlug } from "./editor-query.js";
|
|
4
4
|
// Block rendering
|
|
5
5
|
export { renderBlocks } from "./render-blocks.js";
|
|
6
|
+
// SEO / metadata
|
|
7
|
+
export { buildPageMetadata, derivePageDescription, derivePageTitle, stripMarkdown, truncateForMeta, DEFAULT_SITE_DESCRIPTION, } from "./page-metadata.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getConfiguredDraftSecret } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { editorOriginAllowlist } from "./draft-common.js";
|
|
2
3
|
let checked = false;
|
|
3
4
|
/**
|
|
4
5
|
* Run once on first editor API request to validate essential integration config.
|
|
@@ -13,7 +14,16 @@ export function checkIntegrationOnce() {
|
|
|
13
14
|
const draftSecret = getConfiguredDraftSecret(process.env);
|
|
14
15
|
if (!draftSecret) {
|
|
15
16
|
warnings.push("DRAFT_MODE_SECRET is not set — editor draft mode will not work. " +
|
|
16
|
-
"Set DRAFT_MODE_SECRET (or VITE_SITE_DRAFT_SECRET) in your .env file."
|
|
17
|
+
"Set DRAFT_MODE_SECRET (or VITE_SITE_DRAFT_SECRET) in your .env file. " +
|
|
18
|
+
"In production this is what authorizes a request to see unpublished content.");
|
|
19
|
+
}
|
|
20
|
+
// 1b. Editor origin allowlist — the postMessage target and the frame allowed
|
|
21
|
+
// to drive inline edits. Without one, a caller-supplied origin has nothing to
|
|
22
|
+
// be checked against and editor mode silently has no bridge.
|
|
23
|
+
if (process.env.NODE_ENV === "production" && editorOriginAllowlist().length === 0) {
|
|
24
|
+
warnings.push("No editor origin is configured — set NEXT_PUBLIC_EDITOR_ORIGIN (and " +
|
|
25
|
+
"AVOCADO_EDITOR_ORIGINS for any additional editor deployments). Origins " +
|
|
26
|
+
"arriving in the URL are not trusted unless they are listed.");
|
|
17
27
|
}
|
|
18
28
|
// 2. Orchestrator URL
|
|
19
29
|
const orchestratorUrl = process.env.ORCHESTRATOR_URL?.trim();
|
package/dist/manifest-utils.d.ts
CHANGED
|
@@ -6,6 +6,19 @@ export type ManifestFieldInfo = {
|
|
|
6
6
|
listImageFields: Map<string, Map<string, Set<string>>>;
|
|
7
7
|
/** All list field names per block type (e.g. CardGrid → {"cards"}, FeatureGrid → {"features"}) */
|
|
8
8
|
listFieldNames: Map<string, Set<string>>;
|
|
9
|
+
/**
|
|
10
|
+
* Rich-text fields stored as a ProseMirror *document* rather than a markdown
|
|
11
|
+
* string, per block type.
|
|
12
|
+
*
|
|
13
|
+
* An integration whose CMS has real rich text (Sanity Portable Text,
|
|
14
|
+
* Contentful Rich Text) maps those fields to a document so nothing is
|
|
15
|
+
* flattened. It needs to know which props those are at both boundaries — to
|
|
16
|
+
* convert on read and to convert back on publish — and hand-maintaining the
|
|
17
|
+
* list is how the two ends drift apart.
|
|
18
|
+
*/
|
|
19
|
+
documentFields: Map<string, Set<string>>;
|
|
20
|
+
/** The same, for fields inside list items (e.g. FAQ → items → {"answer"}). */
|
|
21
|
+
listDocumentFields: Map<string, Map<string, Set<string>>>;
|
|
9
22
|
};
|
|
10
23
|
/**
|
|
11
24
|
* Derive field metadata per block type from the manifest's propsSchema.
|
package/dist/manifest-utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveManifestFieldMeta, isProseMirrorDocSchema } from "@avocadostudio-ai/shared";
|
|
2
2
|
const _cache = new WeakMap();
|
|
3
3
|
/**
|
|
4
4
|
* Derive field metadata per block type from the manifest's propsSchema.
|
|
@@ -14,14 +14,27 @@ export function getManifestImageFields(manifest) {
|
|
|
14
14
|
const imageFields = new Map();
|
|
15
15
|
const listImageFields = new Map();
|
|
16
16
|
const listFieldNames = new Map();
|
|
17
|
+
const documentFields = new Map();
|
|
18
|
+
const listDocumentFields = new Map();
|
|
17
19
|
for (const block of manifest.blocks) {
|
|
18
|
-
const { fields, listFields } =
|
|
20
|
+
const { fields, listFields } = resolveManifestFieldMeta(block);
|
|
21
|
+
const props = (block.propsSchema.properties ?? {});
|
|
19
22
|
const imgs = new Set();
|
|
20
23
|
for (const [key, meta] of Object.entries(fields)) {
|
|
21
24
|
if (meta.kind === "image")
|
|
22
25
|
imgs.add(key);
|
|
23
26
|
}
|
|
24
27
|
imageFields.set(block.type, imgs);
|
|
28
|
+
// `kind` alone cannot tell the two apart — a markdown string and a document
|
|
29
|
+
// are both "richtext" to the editor. The schema can: a document self-
|
|
30
|
+
// identifies by pinning its `type` to the literal "doc".
|
|
31
|
+
const docs = new Set();
|
|
32
|
+
for (const key of Object.keys(fields)) {
|
|
33
|
+
if (isProseMirrorDocSchema(props[key]))
|
|
34
|
+
docs.add(key);
|
|
35
|
+
}
|
|
36
|
+
if (docs.size > 0)
|
|
37
|
+
documentFields.set(block.type, docs);
|
|
25
38
|
const listNames = new Set(Object.keys(listFields));
|
|
26
39
|
if (listNames.size > 0)
|
|
27
40
|
listFieldNames.set(block.type, listNames);
|
|
@@ -37,8 +50,22 @@ export function getManifestImageFields(manifest) {
|
|
|
37
50
|
}
|
|
38
51
|
if (listImgs.size > 0)
|
|
39
52
|
listImageFields.set(block.type, listImgs);
|
|
53
|
+
const listDocs = new Map();
|
|
54
|
+
for (const [listKey, listMeta] of Object.entries(listFields)) {
|
|
55
|
+
const listSchema = props[listKey];
|
|
56
|
+
const itemProps = (listSchema?.items?.properties ?? {});
|
|
57
|
+
const itemDocs = new Set();
|
|
58
|
+
for (const itemKey of Object.keys(listMeta.itemFields)) {
|
|
59
|
+
if (isProseMirrorDocSchema(itemProps[itemKey]))
|
|
60
|
+
itemDocs.add(itemKey);
|
|
61
|
+
}
|
|
62
|
+
if (itemDocs.size > 0)
|
|
63
|
+
listDocs.set(listKey, itemDocs);
|
|
64
|
+
}
|
|
65
|
+
if (listDocs.size > 0)
|
|
66
|
+
listDocumentFields.set(block.type, listDocs);
|
|
40
67
|
}
|
|
41
|
-
const result = { imageFields, listImageFields, listFieldNames };
|
|
68
|
+
const result = { imageFields, listImageFields, listFieldNames, documentFields, listDocumentFields };
|
|
42
69
|
_cache.set(manifest, result);
|
|
43
70
|
return result;
|
|
44
71
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { getManifestImageFields } from "./manifest-utils.js";
|
|
4
|
+
/**
|
|
5
|
+
* A rich-text prop can be stored two ways, and an integration has to tell them
|
|
6
|
+
* apart at both boundaries — convert on read, convert back on publish. The
|
|
7
|
+
* editor's `FieldKind` cannot help: a markdown string and a ProseMirror
|
|
8
|
+
* document are both `richtext` to it. The schema can, because a document pins
|
|
9
|
+
* its `type` to the literal "doc".
|
|
10
|
+
*/
|
|
11
|
+
const MANIFEST = {
|
|
12
|
+
version: 1,
|
|
13
|
+
blocks: [
|
|
14
|
+
{
|
|
15
|
+
type: "RichText",
|
|
16
|
+
propsSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
title: { type: "string" },
|
|
20
|
+
body: { type: "string", contentMediaType: "text/markdown" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: "ArticleBody",
|
|
26
|
+
propsSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
title: { type: "string" },
|
|
30
|
+
body: { type: "object", properties: { type: { const: "doc" }, content: { type: "array" } } }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
type: "FAQ",
|
|
36
|
+
propsSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
items: {
|
|
40
|
+
type: "array",
|
|
41
|
+
items: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
q: { type: "string" },
|
|
45
|
+
answer: { type: "object", properties: { type: { const: "doc" }, content: { type: "array" } } }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
const info = getManifestImageFields(MANIFEST);
|
|
55
|
+
test("a document-valued rich-text prop is reported under documentFields", () => {
|
|
56
|
+
assert.deepEqual([...(info.documentFields.get("ArticleBody") ?? [])], ["body"]);
|
|
57
|
+
});
|
|
58
|
+
test("a markdown-string rich-text prop is not", () => {
|
|
59
|
+
assert.equal(info.documentFields.has("RichText"), false);
|
|
60
|
+
});
|
|
61
|
+
test("a document-valued field inside a list item is reported too", () => {
|
|
62
|
+
assert.deepEqual([...(info.listDocumentFields.get("FAQ")?.get("items") ?? [])], ["answer"]);
|
|
63
|
+
});
|
|
64
|
+
test("a block with no document fields is absent rather than present-and-empty", () => {
|
|
65
|
+
// Callers do `documentFields.get(type) ?? new Set()`, so an empty entry would
|
|
66
|
+
// work — but keeping the map to blocks that actually have one makes a
|
|
67
|
+
// debugging dump of it readable.
|
|
68
|
+
assert.equal(info.documentFields.has("FAQ"), false);
|
|
69
|
+
});
|
|
70
|
+
test("the derivation is cached by manifest reference", () => {
|
|
71
|
+
assert.equal(getManifestImageFields(MANIFEST), info);
|
|
72
|
+
});
|