@cmssy/core 10.6.1 → 10.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.cjs CHANGED
@@ -1,12 +1,181 @@
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
+
3
144
  // src/testing/edit-smoke.ts
4
145
  var EDITOR_MARKER = /data-cmssy-editor/;
5
146
  var SERVER_LAYOUT_BLOCKS = /<header|<footer/;
147
+ var EDITABLE_LAYOUT_SLOT = /data-cmssy-layout-slot/;
6
148
  async function html(url) {
7
149
  const response = await fetch(url, { redirect: "manual" });
8
150
  return { status: response.status, body: await response.text() };
9
151
  }
152
+ var LAYOUT_BLOCKS_QUERY = `query CmssySmokeLayouts($workspaceSlug: String!, $pageSlug: String!) {
153
+ public {
154
+ page {
155
+ layouts(workspaceSlug: $workspaceSlug, pageSlug: $pageSlug) {
156
+ position
157
+ blocks { id isActive }
158
+ }
159
+ }
160
+ }
161
+ }`;
162
+ async function workspaceHasLayoutBlocks(workspace, pageSlug) {
163
+ try {
164
+ const data = await graphqlRequest(
165
+ workspace,
166
+ LAYOUT_BLOCKS_QUERY,
167
+ { workspaceSlug: workspace.workspaceSlug, pageSlug },
168
+ { public: true, retry: {} },
169
+ "edit smoke: layout blocks"
170
+ );
171
+ const groups = data.public?.page?.layouts ?? [];
172
+ return groups.some(
173
+ (group) => (group.blocks ?? []).some((block) => block.isActive !== false)
174
+ );
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
10
179
  async function checkCmssyEditMode(options) {
11
180
  const { baseUrl, secret, path = "/" } = options;
12
181
  const failures = [];
@@ -19,6 +188,7 @@ async function checkCmssyEditMode(options) {
19
188
  failures.push(`public ${path}: the editor is mounted on a public page`);
20
189
  }
21
190
  const hasServerLayoutBlocks = SERVER_LAYOUT_BLOCKS.test(publicPage.body);
191
+ const configured = options.workspace ? await workspaceHasLayoutBlocks(options.workspace, path) : null;
22
192
  const unverified = await html(url(`${path}?cmssyEdit=1`));
23
193
  if (EDITOR_MARKER.test(unverified.body)) {
24
194
  failures.push(
@@ -41,6 +211,11 @@ async function checkCmssyEditMode(options) {
41
211
  `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?)`
42
212
  );
43
213
  }
214
+ if (configured === true && !EDITABLE_LAYOUT_SLOT.test(verified.body)) {
215
+ failures.push(
216
+ `edit ${path}: the workspace defines layout blocks and no editable layout slot is mounted - the header and footer cannot be edited (10.0 removed CmssyLayoutSlot; see docs/wiring.md \xA75)`
217
+ );
218
+ }
44
219
  const { localizedPath } = options;
45
220
  if (localizedPath) {
46
221
  const locale = options.localizedLocale ?? localizedPath.split("/").filter(Boolean)[0];
@@ -18,6 +18,21 @@ interface EditSmokeOptions {
18
18
  * path segment, which IS the language on a prefixed site.
19
19
  */
20
20
  localizedLocale?: string;
21
+ /**
22
+ * The workspace the app is pointed at. Given it, the check asks the delivery
23
+ * API whether the workspace HAS header/footer blocks - and if it does, an app
24
+ * that renders none is a failure rather than a site that simply has none.
25
+ *
26
+ * That distinction is the whole point. `cmssy init` once scaffolded the
27
+ * editable-layout wrapper with nothing mounting it, so the app rendered no
28
+ * header at all - and this check, unable to tell "none configured" from "none
29
+ * rendered", stayed green.
30
+ */
31
+ workspace?: {
32
+ org: string;
33
+ workspaceSlug: string;
34
+ apiUrl?: string;
35
+ };
21
36
  }
22
37
  interface EditSmokeResult {
23
38
  ok: boolean;
@@ -27,12 +42,15 @@ interface EditSmokeResult {
27
42
  * Proves a consumer app's EDIT path still works - the path a build cannot check,
28
43
  * because the site compiles and serves fine while being uneditable.
29
44
  *
30
- * It asserts three independent things:
45
+ * It asserts four independent things:
31
46
  * 1. the public page renders WITHOUT the editor, header and footer server-rendered;
32
47
  * 2. a bare `?cmssyEdit=1` does NOT enter edit mode (an unverified pair must
33
48
  * not open the door - CMS-948);
34
49
  * 3. a verified `cmssyEdit=1` + `cmssySecret` renders the editor AND moves the
35
- * header and footer onto the edit bridge.
50
+ * header and footer onto the edit bridge;
51
+ * 4. given `workspace`, that an app whose workspace HAS layout blocks renders
52
+ * them at all - the one thing "no header anywhere" and "no header
53
+ * configured" otherwise look alike.
36
54
  *
37
55
  * Run it against a started production build:
38
56
  *
package/dist/testing.d.ts CHANGED
@@ -18,6 +18,21 @@ interface EditSmokeOptions {
18
18
  * path segment, which IS the language on a prefixed site.
19
19
  */
20
20
  localizedLocale?: string;
21
+ /**
22
+ * The workspace the app is pointed at. Given it, the check asks the delivery
23
+ * API whether the workspace HAS header/footer blocks - and if it does, an app
24
+ * that renders none is a failure rather than a site that simply has none.
25
+ *
26
+ * That distinction is the whole point. `cmssy init` once scaffolded the
27
+ * editable-layout wrapper with nothing mounting it, so the app rendered no
28
+ * header at all - and this check, unable to tell "none configured" from "none
29
+ * rendered", stayed green.
30
+ */
31
+ workspace?: {
32
+ org: string;
33
+ workspaceSlug: string;
34
+ apiUrl?: string;
35
+ };
21
36
  }
22
37
  interface EditSmokeResult {
23
38
  ok: boolean;
@@ -27,12 +42,15 @@ interface EditSmokeResult {
27
42
  * Proves a consumer app's EDIT path still works - the path a build cannot check,
28
43
  * because the site compiles and serves fine while being uneditable.
29
44
  *
30
- * It asserts three independent things:
45
+ * It asserts four independent things:
31
46
  * 1. the public page renders WITHOUT the editor, header and footer server-rendered;
32
47
  * 2. a bare `?cmssyEdit=1` does NOT enter edit mode (an unverified pair must
33
48
  * not open the door - CMS-948);
34
49
  * 3. a verified `cmssyEdit=1` + `cmssySecret` renders the editor AND moves the
35
- * header and footer onto the edit bridge.
50
+ * header and footer onto the edit bridge;
51
+ * 4. given `workspace`, that an app whose workspace HAS layout blocks renders
52
+ * them at all - the one thing "no header anywhere" and "no header
53
+ * configured" otherwise look alike.
36
54
  *
37
55
  * Run it against a started production build:
38
56
  *
package/dist/testing.js CHANGED
@@ -1,10 +1,179 @@
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
+
1
142
  // src/testing/edit-smoke.ts
2
143
  var EDITOR_MARKER = /data-cmssy-editor/;
3
144
  var SERVER_LAYOUT_BLOCKS = /<header|<footer/;
145
+ var EDITABLE_LAYOUT_SLOT = /data-cmssy-layout-slot/;
4
146
  async function html(url) {
5
147
  const response = await fetch(url, { redirect: "manual" });
6
148
  return { status: response.status, body: await response.text() };
7
149
  }
150
+ var LAYOUT_BLOCKS_QUERY = `query CmssySmokeLayouts($workspaceSlug: String!, $pageSlug: String!) {
151
+ public {
152
+ page {
153
+ layouts(workspaceSlug: $workspaceSlug, pageSlug: $pageSlug) {
154
+ position
155
+ blocks { id isActive }
156
+ }
157
+ }
158
+ }
159
+ }`;
160
+ async function workspaceHasLayoutBlocks(workspace, pageSlug) {
161
+ try {
162
+ const data = await graphqlRequest(
163
+ workspace,
164
+ LAYOUT_BLOCKS_QUERY,
165
+ { workspaceSlug: workspace.workspaceSlug, pageSlug },
166
+ { public: true, retry: {} },
167
+ "edit smoke: layout blocks"
168
+ );
169
+ const groups = data.public?.page?.layouts ?? [];
170
+ return groups.some(
171
+ (group) => (group.blocks ?? []).some((block) => block.isActive !== false)
172
+ );
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
8
177
  async function checkCmssyEditMode(options) {
9
178
  const { baseUrl, secret, path = "/" } = options;
10
179
  const failures = [];
@@ -17,6 +186,7 @@ async function checkCmssyEditMode(options) {
17
186
  failures.push(`public ${path}: the editor is mounted on a public page`);
18
187
  }
19
188
  const hasServerLayoutBlocks = SERVER_LAYOUT_BLOCKS.test(publicPage.body);
189
+ const configured = options.workspace ? await workspaceHasLayoutBlocks(options.workspace, path) : null;
20
190
  const unverified = await html(url(`${path}?cmssyEdit=1`));
21
191
  if (EDITOR_MARKER.test(unverified.body)) {
22
192
  failures.push(
@@ -39,6 +209,11 @@ async function checkCmssyEditMode(options) {
39
209
  `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?)`
40
210
  );
41
211
  }
212
+ if (configured === true && !EDITABLE_LAYOUT_SLOT.test(verified.body)) {
213
+ failures.push(
214
+ `edit ${path}: the workspace defines layout blocks and no editable layout slot is mounted - the header and footer cannot be edited (10.0 removed CmssyLayoutSlot; see docs/wiring.md \xA75)`
215
+ );
216
+ }
42
217
  const { localizedPath } = options;
43
218
  if (localizedPath) {
44
219
  const locale = options.localizedLocale ?? localizedPath.split("/").filter(Boolean)[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmssy/core",
3
- "version": "10.6.1",
3
+ "version": "10.8.0",
4
4
  "description": "Framework-agnostic cmssy gateway: GraphQL client, block/field system, editor protocol. No React, no Next.",
5
5
  "keywords": [
6
6
  "cmssy",