@cmssy/core 11.6.0 → 11.7.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/{content-client-CJQ-nafm.d.cts → content-client-BPpRfq0h.d.cts} +1 -30
- package/dist/{content-client-CJQ-nafm.d.ts → content-client-BPpRfq0h.d.ts} +1 -30
- package/dist/{csp-CpiqfM03.d.ts → csp-DjFT5SmH.d.ts} +1 -65
- package/dist/{csp-0D9pcuDp.d.cts → csp-ZchQ-sC7.d.cts} +1 -65
- package/dist/index.cjs +0 -7
- package/dist/index.d.cts +3 -32
- package/dist/index.d.ts +3 -32
- package/dist/index.js +0 -7
- package/dist/internal/locale.cjs +0 -20
- package/dist/internal/locale.d.cts +2 -6
- package/dist/internal/locale.d.ts +2 -6
- package/dist/internal/locale.js +1 -18
- package/dist/internal.cjs +3 -44
- package/dist/internal.d.cts +6 -53
- package/dist/internal.d.ts +6 -53
- package/dist/internal.js +4 -38
- package/dist/locale-BS61R-fF.d.cts +30 -0
- package/dist/locale-DQZ80cew.d.ts +30 -0
- package/dist/preflight.d.cts +1 -1
- package/dist/preflight.d.ts +1 -1
- package/dist/testing.cjs +25 -173
- package/dist/testing.d.cts +2 -50
- package/dist/testing.d.ts +2 -50
- package/dist/testing.js +25 -173
- package/package.json +41 -16
- package/dist/locale-Clouq4s4.d.ts +0 -68
- package/dist/locale-XMylB_kz.d.cts +0 -68
package/dist/testing.cjs
CHANGED
|
@@ -1,146 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// src/data/http.ts
|
|
4
|
-
var CmssyRequestError = class extends Error {
|
|
5
|
-
status;
|
|
6
|
-
/**
|
|
7
|
-
* How long the server asked us to wait, in ms, when it said so. Present on a
|
|
8
|
-
* 429 that carried `Retry-After`; a caller can serve stale content for that
|
|
9
|
-
* long instead of guessing.
|
|
10
|
-
*/
|
|
11
|
-
retryAfterMs;
|
|
12
|
-
constructor(message, status, retryAfterMs2) {
|
|
13
|
-
super(message);
|
|
14
|
-
this.name = "CmssyRequestError";
|
|
15
|
-
this.status = status;
|
|
16
|
-
if (retryAfterMs2 !== void 0) this.retryAfterMs = retryAfterMs2;
|
|
17
|
-
}
|
|
18
|
-
};
|
|
19
|
-
var DEFAULT_RETRY_STATUSES = [429, 503];
|
|
20
|
-
function retryAfterMs(response) {
|
|
21
|
-
const raw = response.headers?.get("retry-after");
|
|
22
|
-
if (!raw) return null;
|
|
23
|
-
const seconds = Number(raw);
|
|
24
|
-
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
25
|
-
const date = Date.parse(raw);
|
|
26
|
-
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
function sleep(ms, signal) {
|
|
30
|
-
return new Promise((resolve, reject) => {
|
|
31
|
-
if (signal?.aborted) {
|
|
32
|
-
reject(new Error("cmssy: request aborted"));
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
const timer = setTimeout(() => {
|
|
36
|
-
signal?.removeEventListener("abort", onAbort);
|
|
37
|
-
resolve();
|
|
38
|
-
}, ms);
|
|
39
|
-
function onAbort() {
|
|
40
|
-
clearTimeout(timer);
|
|
41
|
-
reject(new Error("cmssy: request aborted"));
|
|
42
|
-
}
|
|
43
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
async function fetchWithRetry(doFetch, url, init, retry) {
|
|
47
|
-
if (retry === false || retry === void 0) {
|
|
48
|
-
return doFetch(url, init);
|
|
49
|
-
}
|
|
50
|
-
const maxRetries = retry.maxRetries ?? 3;
|
|
51
|
-
const baseDelayMs = retry.baseDelayMs ?? 300;
|
|
52
|
-
const maxDelayMs = retry.maxDelayMs ?? 3e3;
|
|
53
|
-
const maxRetryAfterMs = retry.maxRetryAfterMs ?? 1e4;
|
|
54
|
-
const retryStatuses = retry.retryStatuses ?? DEFAULT_RETRY_STATUSES;
|
|
55
|
-
let response = await doFetch(url, init);
|
|
56
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
57
|
-
if (response.ok || !retryStatuses.includes(response.status)) {
|
|
58
|
-
return response;
|
|
59
|
-
}
|
|
60
|
-
const asked = retryAfterMs(response);
|
|
61
|
-
if (asked !== null && asked > maxRetryAfterMs) return response;
|
|
62
|
-
const backoff = baseDelayMs * 2 ** attempt;
|
|
63
|
-
const wait = asked !== null ? asked : Math.min(backoff, maxDelayMs);
|
|
64
|
-
await sleep(wait, init.signal);
|
|
65
|
-
response = await doFetch(url, init);
|
|
66
|
-
}
|
|
67
|
-
return response;
|
|
68
|
-
}
|
|
69
|
-
async function postGraphql(url, query, variables, options) {
|
|
70
|
-
const doFetch = options.fetch ?? globalThis.fetch;
|
|
71
|
-
if (typeof doFetch !== "function") {
|
|
72
|
-
throw new Error(
|
|
73
|
-
"cmssy: no fetch implementation available - pass options.fetch"
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
const response = await fetchWithRetry(
|
|
77
|
-
doFetch,
|
|
78
|
-
url,
|
|
79
|
-
{
|
|
80
|
-
method: "POST",
|
|
81
|
-
headers: { "content-type": "application/json", ...options.headers },
|
|
82
|
-
body: JSON.stringify({ query, variables }),
|
|
83
|
-
signal: options.signal
|
|
84
|
-
},
|
|
85
|
-
options.retry
|
|
86
|
-
);
|
|
87
|
-
if (!response.ok) {
|
|
88
|
-
let detail = "";
|
|
89
|
-
try {
|
|
90
|
-
const body = await response.json();
|
|
91
|
-
if (body.errors && body.errors.length > 0) {
|
|
92
|
-
detail = ` - ${body.errors.map((error) => error.message ?? "GraphQL error").join("; ")}`;
|
|
93
|
-
}
|
|
94
|
-
} catch {
|
|
95
|
-
detail = "";
|
|
96
|
-
}
|
|
97
|
-
const asked = retryAfterMs(response);
|
|
98
|
-
const wait = asked !== null ? ` - retry after ${Math.ceil(asked / 1e3)}s` : "";
|
|
99
|
-
throw new CmssyRequestError(
|
|
100
|
-
`cmssy: ${options.label} failed (${response.status})${detail}${wait}`,
|
|
101
|
-
response.status,
|
|
102
|
-
asked ?? void 0
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
let json;
|
|
106
|
-
try {
|
|
107
|
-
json = await response.json();
|
|
108
|
-
} catch {
|
|
109
|
-
throw new Error(`cmssy: invalid JSON response from the ${options.label}`);
|
|
110
|
-
}
|
|
111
|
-
if (json.errors && json.errors.length > 0) {
|
|
112
|
-
const message = json.errors.map((error) => error.message ?? "GraphQL error").join("; ");
|
|
113
|
-
throw new Error(`cmssy: ${options.label} error - ${message}`);
|
|
114
|
-
}
|
|
115
|
-
return json.data;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// src/content/content-client.ts
|
|
119
|
-
var DEFAULT_CMSSY_API_URL = "https://api.cmssy.io/graphql";
|
|
120
|
-
function resolveApiUrl(apiUrl) {
|
|
121
|
-
const explicit = apiUrl?.trim();
|
|
122
|
-
if (explicit) return explicit;
|
|
123
|
-
const env = globalThis.process?.env;
|
|
124
|
-
const fromEnv = env?.CMSSY_API_URL?.trim() ?? "";
|
|
125
|
-
return fromEnv.length > 0 ? fromEnv : DEFAULT_CMSSY_API_URL;
|
|
126
|
-
}
|
|
127
|
-
function resolvePublicUrl(config) {
|
|
128
|
-
const base = resolveApiUrl(config.apiUrl).replace(/\/graphql\/?$/, "");
|
|
129
|
-
return `${base}/public/${config.org}/${config.workspaceSlug}/graphql`;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// src/data/graphql-request.ts
|
|
133
|
-
async function graphqlRequest(config, query, variables, options = {}, label = "request") {
|
|
134
|
-
const url = options.public ? resolvePublicUrl(config) : resolveApiUrl(config.apiUrl);
|
|
135
|
-
return postGraphql(url, query, variables, {
|
|
136
|
-
fetch: options.fetch,
|
|
137
|
-
signal: options.signal,
|
|
138
|
-
headers: options.headers,
|
|
139
|
-
retry: options.retry,
|
|
140
|
-
label
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
3
|
// src/preflight.ts
|
|
145
4
|
var ALLOWED_FRAME_HOSTS = ["cmssy.io", "www.cmssy.io"];
|
|
146
5
|
function frameAncestorSources(cspHeaderValue) {
|
|
@@ -209,36 +68,10 @@ function localeOf(path) {
|
|
|
209
68
|
function langOf(body) {
|
|
210
69
|
return /<html[^>]*\slang=["']([^"']+)["']/i.exec(body)?.[1];
|
|
211
70
|
}
|
|
212
|
-
var LAYOUT_BLOCKS_QUERY = `query CmssySmokeLayouts($workspaceSlug: String!, $pageSlug: String!) {
|
|
213
|
-
public {
|
|
214
|
-
page {
|
|
215
|
-
layouts(workspaceSlug: $workspaceSlug, pageSlug: $pageSlug) {
|
|
216
|
-
position
|
|
217
|
-
blocks { id isActive }
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}`;
|
|
222
|
-
async function workspaceHasLayoutBlocks(workspace, pageSlug) {
|
|
223
|
-
try {
|
|
224
|
-
const data = await graphqlRequest(
|
|
225
|
-
workspace,
|
|
226
|
-
LAYOUT_BLOCKS_QUERY,
|
|
227
|
-
{ workspaceSlug: workspace.workspaceSlug, pageSlug },
|
|
228
|
-
{ public: true, retry: {} },
|
|
229
|
-
"edit smoke: layout blocks"
|
|
230
|
-
);
|
|
231
|
-
const groups = data.public?.page?.layouts ?? [];
|
|
232
|
-
return groups.some(
|
|
233
|
-
(group) => (group.blocks ?? []).some((block) => block.isActive !== false)
|
|
234
|
-
);
|
|
235
|
-
} catch {
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
71
|
async function checkCmssyEditMode(options) {
|
|
240
72
|
const { baseUrl, secret, path = "/" } = options;
|
|
241
73
|
const failures = [];
|
|
74
|
+
const skipped = [];
|
|
242
75
|
const url = (suffix) => `${baseUrl.replace(/\/+$/, "")}${suffix}`;
|
|
243
76
|
const publicPage = await html(url(path));
|
|
244
77
|
if (publicPage.status !== 200) {
|
|
@@ -248,7 +81,17 @@ async function checkCmssyEditMode(options) {
|
|
|
248
81
|
failures.push(`public ${path}: the editor is mounted on a public page`);
|
|
249
82
|
}
|
|
250
83
|
const hasServerLayoutBlocks = SERVER_LAYOUT_BLOCKS.test(publicPage.body);
|
|
251
|
-
|
|
84
|
+
if (!hasServerLayoutBlocks) {
|
|
85
|
+
skipped.push(
|
|
86
|
+
`layout bridge: the public ${path} server-rendered no <header> or <footer>, so nothing proved they move onto the edit bridge in edit mode`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const expectLayoutBlocks = options.expectLayoutBlocks === true;
|
|
90
|
+
if (!expectLayoutBlocks) {
|
|
91
|
+
skipped.push(
|
|
92
|
+
"layout slot: expectLayoutBlocks is not set, so neither the mounted slot nor the content it resolved for the editor was asserted"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
252
95
|
const unverified = await html(url(`${path}?cmssyEdit=1`));
|
|
253
96
|
if (EDITOR_MARKER.test(unverified.body)) {
|
|
254
97
|
failures.push(
|
|
@@ -271,12 +114,12 @@ async function checkCmssyEditMode(options) {
|
|
|
271
114
|
`edit ${path}: the header and footer are still server-rendered - the editor will let you select them but show no fields (is CMSSY_EDIT_HEADER set on the rewrite?)`
|
|
272
115
|
);
|
|
273
116
|
}
|
|
274
|
-
if (
|
|
117
|
+
if (expectLayoutBlocks && !EDITABLE_LAYOUT_SLOT.test(verified.body)) {
|
|
275
118
|
failures.push(
|
|
276
|
-
`edit ${path}:
|
|
119
|
+
`edit ${path}: expectLayoutBlocks is set and no editable layout slot is mounted - the header and footer cannot be edited (mount CmssyLayoutSlot from @cmssy/next/server, or resolveCmssyLayoutSlot on the other adapters; see docs/wiring.md \xA75)`
|
|
277
120
|
);
|
|
278
121
|
}
|
|
279
|
-
if (
|
|
122
|
+
if (expectLayoutBlocks && EDITABLE_LAYOUT_SLOT.test(verified.body)) {
|
|
280
123
|
const counts = resolvedContentCounts(verified.body);
|
|
281
124
|
if (counts.length === 0) {
|
|
282
125
|
failures.push(
|
|
@@ -290,6 +133,11 @@ async function checkCmssyEditMode(options) {
|
|
|
290
133
|
}
|
|
291
134
|
const query = `cmssyEdit=1&cmssySecret=${encodeURIComponent(secret)}`;
|
|
292
135
|
const { localizedPath } = options;
|
|
136
|
+
if (!localizedPath) {
|
|
137
|
+
skipped.push(
|
|
138
|
+
"language: no localizedPath, so nothing checked that a prefixed URL previews in its own language"
|
|
139
|
+
);
|
|
140
|
+
}
|
|
293
141
|
if (localizedPath) {
|
|
294
142
|
const locale = options.localizedLocale ?? localeOf(localizedPath);
|
|
295
143
|
const localized = await html(url(`${localizedPath}?${query}`));
|
|
@@ -314,8 +162,12 @@ async function checkCmssyEditMode(options) {
|
|
|
314
162
|
}
|
|
315
163
|
if (options.editRoute !== false) {
|
|
316
164
|
await checkDirectEditRoute(url, path, query, void 0, failures);
|
|
165
|
+
} else {
|
|
166
|
+
skipped.push(
|
|
167
|
+
"edit route: this adapter serves the editor from the page (editRoute: false), so there is no /cmssy-edit route to reach directly and its frame-ancestors were not checked - nothing to fix unless you expected one"
|
|
168
|
+
);
|
|
317
169
|
}
|
|
318
|
-
return { ok: failures.length === 0, failures };
|
|
170
|
+
return { ok: failures.length === 0, failures, skipped };
|
|
319
171
|
}
|
|
320
172
|
async function checkDirectEditRoute(url, routedPath, query, locale, failures) {
|
|
321
173
|
const withoutPrefix = withoutEditPrefix(routedPath);
|
package/dist/testing.d.cts
CHANGED
|
@@ -1,65 +1,17 @@
|
|
|
1
1
|
interface EditSmokeOptions {
|
|
2
|
-
/** A running build of the consumer app, e.g. http://localhost:3000. */
|
|
3
2
|
baseUrl: string;
|
|
4
|
-
/** The site's CMSSY_DRAFT_SECRET. Without it nothing can be verified. */
|
|
5
3
|
secret: string;
|
|
6
|
-
/** A published page to exercise. Defaults to "/". */
|
|
7
4
|
path?: string;
|
|
8
|
-
/**
|
|
9
|
-
* The same page under a language prefix, e.g. "/no". The caller states it
|
|
10
|
-
* because only the caller knows how its site spells a language; asking a
|
|
11
|
-
* workspace would tie this check to content that can change or be deleted.
|
|
12
|
-
*
|
|
13
|
-
* Given it, the check also proves the preview renders in THAT language rather
|
|
14
|
-
* than the default one - by reading `<html lang>`, which is a contract,
|
|
15
|
-
* unlike a word from the page's copy that an editor can change at any time.
|
|
16
|
-
*/
|
|
17
5
|
localizedPath?: string;
|
|
18
|
-
/**
|
|
19
|
-
* The language `localizedPath` must render, e.g. "no". Defaults to the first
|
|
20
|
-
* path segment, which IS the language on a prefixed site.
|
|
21
|
-
*/
|
|
22
6
|
localizedLocale?: string;
|
|
23
|
-
|
|
24
|
-
* The workspace the app is pointed at. Given it, the check asks the delivery
|
|
25
|
-
* API whether the workspace HAS header/footer blocks - and if it does, an app
|
|
26
|
-
* that renders none is a failure rather than a site that simply has none.
|
|
27
|
-
*
|
|
28
|
-
* That distinction is the whole point. `cmssy init` once scaffolded the
|
|
29
|
-
* editable-layout wrapper with nothing mounting it, so the app rendered no
|
|
30
|
-
* header at all - and this check, unable to tell "none configured" from "none
|
|
31
|
-
* rendered", stayed green.
|
|
32
|
-
*/
|
|
33
|
-
workspace?: {
|
|
34
|
-
org: string;
|
|
35
|
-
workspaceSlug: string;
|
|
36
|
-
apiUrl?: string;
|
|
37
|
-
};
|
|
7
|
+
expectLayoutBlocks?: boolean;
|
|
38
8
|
editRoute?: boolean;
|
|
39
9
|
}
|
|
40
10
|
interface EditSmokeResult {
|
|
41
11
|
ok: boolean;
|
|
42
12
|
failures: string[];
|
|
13
|
+
skipped: string[];
|
|
43
14
|
}
|
|
44
|
-
/**
|
|
45
|
-
* Proves a consumer app's EDIT path still works - the path a build cannot check,
|
|
46
|
-
* because the site compiles and serves fine while being uneditable.
|
|
47
|
-
*
|
|
48
|
-
* It asserts four independent things:
|
|
49
|
-
* 1. the public page renders WITHOUT the editor, header and footer server-rendered;
|
|
50
|
-
* 2. a bare `?cmssyEdit=1` does NOT enter edit mode (an unverified pair must
|
|
51
|
-
* not open the door - CMS-948);
|
|
52
|
-
* 3. a verified `cmssyEdit=1` + `cmssySecret` renders the editor AND moves the
|
|
53
|
-
* header and footer onto the edit bridge;
|
|
54
|
-
* 4. given `workspace`, that an app whose workspace HAS layout blocks renders
|
|
55
|
-
* them at all - the one thing "no header anywhere" and "no header
|
|
56
|
-
* configured" otherwise look alike.
|
|
57
|
-
*
|
|
58
|
-
* Run it against a started production build:
|
|
59
|
-
*
|
|
60
|
-
* const result = await checkCmssyEditMode({ baseUrl, secret });
|
|
61
|
-
* expect(result.failures).toEqual([]);
|
|
62
|
-
*/
|
|
63
15
|
declare function checkCmssyEditMode(options: EditSmokeOptions): Promise<EditSmokeResult>;
|
|
64
16
|
|
|
65
17
|
export { type EditSmokeOptions, type EditSmokeResult, checkCmssyEditMode };
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,65 +1,17 @@
|
|
|
1
1
|
interface EditSmokeOptions {
|
|
2
|
-
/** A running build of the consumer app, e.g. http://localhost:3000. */
|
|
3
2
|
baseUrl: string;
|
|
4
|
-
/** The site's CMSSY_DRAFT_SECRET. Without it nothing can be verified. */
|
|
5
3
|
secret: string;
|
|
6
|
-
/** A published page to exercise. Defaults to "/". */
|
|
7
4
|
path?: string;
|
|
8
|
-
/**
|
|
9
|
-
* The same page under a language prefix, e.g. "/no". The caller states it
|
|
10
|
-
* because only the caller knows how its site spells a language; asking a
|
|
11
|
-
* workspace would tie this check to content that can change or be deleted.
|
|
12
|
-
*
|
|
13
|
-
* Given it, the check also proves the preview renders in THAT language rather
|
|
14
|
-
* than the default one - by reading `<html lang>`, which is a contract,
|
|
15
|
-
* unlike a word from the page's copy that an editor can change at any time.
|
|
16
|
-
*/
|
|
17
5
|
localizedPath?: string;
|
|
18
|
-
/**
|
|
19
|
-
* The language `localizedPath` must render, e.g. "no". Defaults to the first
|
|
20
|
-
* path segment, which IS the language on a prefixed site.
|
|
21
|
-
*/
|
|
22
6
|
localizedLocale?: string;
|
|
23
|
-
|
|
24
|
-
* The workspace the app is pointed at. Given it, the check asks the delivery
|
|
25
|
-
* API whether the workspace HAS header/footer blocks - and if it does, an app
|
|
26
|
-
* that renders none is a failure rather than a site that simply has none.
|
|
27
|
-
*
|
|
28
|
-
* That distinction is the whole point. `cmssy init` once scaffolded the
|
|
29
|
-
* editable-layout wrapper with nothing mounting it, so the app rendered no
|
|
30
|
-
* header at all - and this check, unable to tell "none configured" from "none
|
|
31
|
-
* rendered", stayed green.
|
|
32
|
-
*/
|
|
33
|
-
workspace?: {
|
|
34
|
-
org: string;
|
|
35
|
-
workspaceSlug: string;
|
|
36
|
-
apiUrl?: string;
|
|
37
|
-
};
|
|
7
|
+
expectLayoutBlocks?: boolean;
|
|
38
8
|
editRoute?: boolean;
|
|
39
9
|
}
|
|
40
10
|
interface EditSmokeResult {
|
|
41
11
|
ok: boolean;
|
|
42
12
|
failures: string[];
|
|
13
|
+
skipped: string[];
|
|
43
14
|
}
|
|
44
|
-
/**
|
|
45
|
-
* Proves a consumer app's EDIT path still works - the path a build cannot check,
|
|
46
|
-
* because the site compiles and serves fine while being uneditable.
|
|
47
|
-
*
|
|
48
|
-
* It asserts four independent things:
|
|
49
|
-
* 1. the public page renders WITHOUT the editor, header and footer server-rendered;
|
|
50
|
-
* 2. a bare `?cmssyEdit=1` does NOT enter edit mode (an unverified pair must
|
|
51
|
-
* not open the door - CMS-948);
|
|
52
|
-
* 3. a verified `cmssyEdit=1` + `cmssySecret` renders the editor AND moves the
|
|
53
|
-
* header and footer onto the edit bridge;
|
|
54
|
-
* 4. given `workspace`, that an app whose workspace HAS layout blocks renders
|
|
55
|
-
* them at all - the one thing "no header anywhere" and "no header
|
|
56
|
-
* configured" otherwise look alike.
|
|
57
|
-
*
|
|
58
|
-
* Run it against a started production build:
|
|
59
|
-
*
|
|
60
|
-
* const result = await checkCmssyEditMode({ baseUrl, secret });
|
|
61
|
-
* expect(result.failures).toEqual([]);
|
|
62
|
-
*/
|
|
63
15
|
declare function checkCmssyEditMode(options: EditSmokeOptions): Promise<EditSmokeResult>;
|
|
64
16
|
|
|
65
17
|
export { type EditSmokeOptions, type EditSmokeResult, checkCmssyEditMode };
|
package/dist/testing.js
CHANGED
|
@@ -1,144 +1,3 @@
|
|
|
1
|
-
// src/data/http.ts
|
|
2
|
-
var CmssyRequestError = class extends Error {
|
|
3
|
-
status;
|
|
4
|
-
/**
|
|
5
|
-
* How long the server asked us to wait, in ms, when it said so. Present on a
|
|
6
|
-
* 429 that carried `Retry-After`; a caller can serve stale content for that
|
|
7
|
-
* long instead of guessing.
|
|
8
|
-
*/
|
|
9
|
-
retryAfterMs;
|
|
10
|
-
constructor(message, status, retryAfterMs2) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.name = "CmssyRequestError";
|
|
13
|
-
this.status = status;
|
|
14
|
-
if (retryAfterMs2 !== void 0) this.retryAfterMs = retryAfterMs2;
|
|
15
|
-
}
|
|
16
|
-
};
|
|
17
|
-
var DEFAULT_RETRY_STATUSES = [429, 503];
|
|
18
|
-
function retryAfterMs(response) {
|
|
19
|
-
const raw = response.headers?.get("retry-after");
|
|
20
|
-
if (!raw) return null;
|
|
21
|
-
const seconds = Number(raw);
|
|
22
|
-
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
23
|
-
const date = Date.parse(raw);
|
|
24
|
-
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
function sleep(ms, signal) {
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
if (signal?.aborted) {
|
|
30
|
-
reject(new Error("cmssy: request aborted"));
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const timer = setTimeout(() => {
|
|
34
|
-
signal?.removeEventListener("abort", onAbort);
|
|
35
|
-
resolve();
|
|
36
|
-
}, ms);
|
|
37
|
-
function onAbort() {
|
|
38
|
-
clearTimeout(timer);
|
|
39
|
-
reject(new Error("cmssy: request aborted"));
|
|
40
|
-
}
|
|
41
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
async function fetchWithRetry(doFetch, url, init, retry) {
|
|
45
|
-
if (retry === false || retry === void 0) {
|
|
46
|
-
return doFetch(url, init);
|
|
47
|
-
}
|
|
48
|
-
const maxRetries = retry.maxRetries ?? 3;
|
|
49
|
-
const baseDelayMs = retry.baseDelayMs ?? 300;
|
|
50
|
-
const maxDelayMs = retry.maxDelayMs ?? 3e3;
|
|
51
|
-
const maxRetryAfterMs = retry.maxRetryAfterMs ?? 1e4;
|
|
52
|
-
const retryStatuses = retry.retryStatuses ?? DEFAULT_RETRY_STATUSES;
|
|
53
|
-
let response = await doFetch(url, init);
|
|
54
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
55
|
-
if (response.ok || !retryStatuses.includes(response.status)) {
|
|
56
|
-
return response;
|
|
57
|
-
}
|
|
58
|
-
const asked = retryAfterMs(response);
|
|
59
|
-
if (asked !== null && asked > maxRetryAfterMs) return response;
|
|
60
|
-
const backoff = baseDelayMs * 2 ** attempt;
|
|
61
|
-
const wait = asked !== null ? asked : Math.min(backoff, maxDelayMs);
|
|
62
|
-
await sleep(wait, init.signal);
|
|
63
|
-
response = await doFetch(url, init);
|
|
64
|
-
}
|
|
65
|
-
return response;
|
|
66
|
-
}
|
|
67
|
-
async function postGraphql(url, query, variables, options) {
|
|
68
|
-
const doFetch = options.fetch ?? globalThis.fetch;
|
|
69
|
-
if (typeof doFetch !== "function") {
|
|
70
|
-
throw new Error(
|
|
71
|
-
"cmssy: no fetch implementation available - pass options.fetch"
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
const response = await fetchWithRetry(
|
|
75
|
-
doFetch,
|
|
76
|
-
url,
|
|
77
|
-
{
|
|
78
|
-
method: "POST",
|
|
79
|
-
headers: { "content-type": "application/json", ...options.headers },
|
|
80
|
-
body: JSON.stringify({ query, variables }),
|
|
81
|
-
signal: options.signal
|
|
82
|
-
},
|
|
83
|
-
options.retry
|
|
84
|
-
);
|
|
85
|
-
if (!response.ok) {
|
|
86
|
-
let detail = "";
|
|
87
|
-
try {
|
|
88
|
-
const body = await response.json();
|
|
89
|
-
if (body.errors && body.errors.length > 0) {
|
|
90
|
-
detail = ` - ${body.errors.map((error) => error.message ?? "GraphQL error").join("; ")}`;
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
detail = "";
|
|
94
|
-
}
|
|
95
|
-
const asked = retryAfterMs(response);
|
|
96
|
-
const wait = asked !== null ? ` - retry after ${Math.ceil(asked / 1e3)}s` : "";
|
|
97
|
-
throw new CmssyRequestError(
|
|
98
|
-
`cmssy: ${options.label} failed (${response.status})${detail}${wait}`,
|
|
99
|
-
response.status,
|
|
100
|
-
asked ?? void 0
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
let json;
|
|
104
|
-
try {
|
|
105
|
-
json = await response.json();
|
|
106
|
-
} catch {
|
|
107
|
-
throw new Error(`cmssy: invalid JSON response from the ${options.label}`);
|
|
108
|
-
}
|
|
109
|
-
if (json.errors && json.errors.length > 0) {
|
|
110
|
-
const message = json.errors.map((error) => error.message ?? "GraphQL error").join("; ");
|
|
111
|
-
throw new Error(`cmssy: ${options.label} error - ${message}`);
|
|
112
|
-
}
|
|
113
|
-
return json.data;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// src/content/content-client.ts
|
|
117
|
-
var DEFAULT_CMSSY_API_URL = "https://api.cmssy.io/graphql";
|
|
118
|
-
function resolveApiUrl(apiUrl) {
|
|
119
|
-
const explicit = apiUrl?.trim();
|
|
120
|
-
if (explicit) return explicit;
|
|
121
|
-
const env = globalThis.process?.env;
|
|
122
|
-
const fromEnv = env?.CMSSY_API_URL?.trim() ?? "";
|
|
123
|
-
return fromEnv.length > 0 ? fromEnv : DEFAULT_CMSSY_API_URL;
|
|
124
|
-
}
|
|
125
|
-
function resolvePublicUrl(config) {
|
|
126
|
-
const base = resolveApiUrl(config.apiUrl).replace(/\/graphql\/?$/, "");
|
|
127
|
-
return `${base}/public/${config.org}/${config.workspaceSlug}/graphql`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// src/data/graphql-request.ts
|
|
131
|
-
async function graphqlRequest(config, query, variables, options = {}, label = "request") {
|
|
132
|
-
const url = options.public ? resolvePublicUrl(config) : resolveApiUrl(config.apiUrl);
|
|
133
|
-
return postGraphql(url, query, variables, {
|
|
134
|
-
fetch: options.fetch,
|
|
135
|
-
signal: options.signal,
|
|
136
|
-
headers: options.headers,
|
|
137
|
-
retry: options.retry,
|
|
138
|
-
label
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
1
|
// src/preflight.ts
|
|
143
2
|
var ALLOWED_FRAME_HOSTS = ["cmssy.io", "www.cmssy.io"];
|
|
144
3
|
function frameAncestorSources(cspHeaderValue) {
|
|
@@ -207,36 +66,10 @@ function localeOf(path) {
|
|
|
207
66
|
function langOf(body) {
|
|
208
67
|
return /<html[^>]*\slang=["']([^"']+)["']/i.exec(body)?.[1];
|
|
209
68
|
}
|
|
210
|
-
var LAYOUT_BLOCKS_QUERY = `query CmssySmokeLayouts($workspaceSlug: String!, $pageSlug: String!) {
|
|
211
|
-
public {
|
|
212
|
-
page {
|
|
213
|
-
layouts(workspaceSlug: $workspaceSlug, pageSlug: $pageSlug) {
|
|
214
|
-
position
|
|
215
|
-
blocks { id isActive }
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}`;
|
|
220
|
-
async function workspaceHasLayoutBlocks(workspace, pageSlug) {
|
|
221
|
-
try {
|
|
222
|
-
const data = await graphqlRequest(
|
|
223
|
-
workspace,
|
|
224
|
-
LAYOUT_BLOCKS_QUERY,
|
|
225
|
-
{ workspaceSlug: workspace.workspaceSlug, pageSlug },
|
|
226
|
-
{ public: true, retry: {} },
|
|
227
|
-
"edit smoke: layout blocks"
|
|
228
|
-
);
|
|
229
|
-
const groups = data.public?.page?.layouts ?? [];
|
|
230
|
-
return groups.some(
|
|
231
|
-
(group) => (group.blocks ?? []).some((block) => block.isActive !== false)
|
|
232
|
-
);
|
|
233
|
-
} catch {
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
69
|
async function checkCmssyEditMode(options) {
|
|
238
70
|
const { baseUrl, secret, path = "/" } = options;
|
|
239
71
|
const failures = [];
|
|
72
|
+
const skipped = [];
|
|
240
73
|
const url = (suffix) => `${baseUrl.replace(/\/+$/, "")}${suffix}`;
|
|
241
74
|
const publicPage = await html(url(path));
|
|
242
75
|
if (publicPage.status !== 200) {
|
|
@@ -246,7 +79,17 @@ async function checkCmssyEditMode(options) {
|
|
|
246
79
|
failures.push(`public ${path}: the editor is mounted on a public page`);
|
|
247
80
|
}
|
|
248
81
|
const hasServerLayoutBlocks = SERVER_LAYOUT_BLOCKS.test(publicPage.body);
|
|
249
|
-
|
|
82
|
+
if (!hasServerLayoutBlocks) {
|
|
83
|
+
skipped.push(
|
|
84
|
+
`layout bridge: the public ${path} server-rendered no <header> or <footer>, so nothing proved they move onto the edit bridge in edit mode`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const expectLayoutBlocks = options.expectLayoutBlocks === true;
|
|
88
|
+
if (!expectLayoutBlocks) {
|
|
89
|
+
skipped.push(
|
|
90
|
+
"layout slot: expectLayoutBlocks is not set, so neither the mounted slot nor the content it resolved for the editor was asserted"
|
|
91
|
+
);
|
|
92
|
+
}
|
|
250
93
|
const unverified = await html(url(`${path}?cmssyEdit=1`));
|
|
251
94
|
if (EDITOR_MARKER.test(unverified.body)) {
|
|
252
95
|
failures.push(
|
|
@@ -269,12 +112,12 @@ async function checkCmssyEditMode(options) {
|
|
|
269
112
|
`edit ${path}: the header and footer are still server-rendered - the editor will let you select them but show no fields (is CMSSY_EDIT_HEADER set on the rewrite?)`
|
|
270
113
|
);
|
|
271
114
|
}
|
|
272
|
-
if (
|
|
115
|
+
if (expectLayoutBlocks && !EDITABLE_LAYOUT_SLOT.test(verified.body)) {
|
|
273
116
|
failures.push(
|
|
274
|
-
`edit ${path}:
|
|
117
|
+
`edit ${path}: expectLayoutBlocks is set and no editable layout slot is mounted - the header and footer cannot be edited (mount CmssyLayoutSlot from @cmssy/next/server, or resolveCmssyLayoutSlot on the other adapters; see docs/wiring.md \xA75)`
|
|
275
118
|
);
|
|
276
119
|
}
|
|
277
|
-
if (
|
|
120
|
+
if (expectLayoutBlocks && EDITABLE_LAYOUT_SLOT.test(verified.body)) {
|
|
278
121
|
const counts = resolvedContentCounts(verified.body);
|
|
279
122
|
if (counts.length === 0) {
|
|
280
123
|
failures.push(
|
|
@@ -288,6 +131,11 @@ async function checkCmssyEditMode(options) {
|
|
|
288
131
|
}
|
|
289
132
|
const query = `cmssyEdit=1&cmssySecret=${encodeURIComponent(secret)}`;
|
|
290
133
|
const { localizedPath } = options;
|
|
134
|
+
if (!localizedPath) {
|
|
135
|
+
skipped.push(
|
|
136
|
+
"language: no localizedPath, so nothing checked that a prefixed URL previews in its own language"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
291
139
|
if (localizedPath) {
|
|
292
140
|
const locale = options.localizedLocale ?? localeOf(localizedPath);
|
|
293
141
|
const localized = await html(url(`${localizedPath}?${query}`));
|
|
@@ -312,8 +160,12 @@ async function checkCmssyEditMode(options) {
|
|
|
312
160
|
}
|
|
313
161
|
if (options.editRoute !== false) {
|
|
314
162
|
await checkDirectEditRoute(url, path, query, void 0, failures);
|
|
163
|
+
} else {
|
|
164
|
+
skipped.push(
|
|
165
|
+
"edit route: this adapter serves the editor from the page (editRoute: false), so there is no /cmssy-edit route to reach directly and its frame-ancestors were not checked - nothing to fix unless you expected one"
|
|
166
|
+
);
|
|
315
167
|
}
|
|
316
|
-
return { ok: failures.length === 0, failures };
|
|
168
|
+
return { ok: failures.length === 0, failures, skipped };
|
|
317
169
|
}
|
|
318
170
|
async function checkDirectEditRoute(url, routedPath, query, locale, failures) {
|
|
319
171
|
const withoutPrefix = withoutEditPrefix(routedPath);
|