@cmssy/core 10.6.1 → 10.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/testing.cjs CHANGED
@@ -1,5 +1,146 @@
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/;
@@ -7,6 +148,33 @@ async function html(url) {
7
148
  const response = await fetch(url, { redirect: "manual" });
8
149
  return { status: response.status, body: await response.text() };
9
150
  }
151
+ var LAYOUT_BLOCKS_QUERY = `query CmssySmokeLayouts($workspaceSlug: String!, $pageSlug: String!) {
152
+ public {
153
+ page {
154
+ layouts(workspaceSlug: $workspaceSlug, pageSlug: $pageSlug) {
155
+ position
156
+ blocks { id isActive }
157
+ }
158
+ }
159
+ }
160
+ }`;
161
+ async function workspaceHasLayoutBlocks(workspace, pageSlug) {
162
+ try {
163
+ const data = await graphqlRequest(
164
+ workspace,
165
+ LAYOUT_BLOCKS_QUERY,
166
+ { workspaceSlug: workspace.workspaceSlug, pageSlug },
167
+ { public: true, retry: {} },
168
+ "edit smoke: layout blocks"
169
+ );
170
+ const groups = data.public?.page?.layouts ?? [];
171
+ return groups.some(
172
+ (group) => (group.blocks ?? []).some((block) => block.isActive !== false)
173
+ );
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
10
178
  async function checkCmssyEditMode(options) {
11
179
  const { baseUrl, secret, path = "/" } = options;
12
180
  const failures = [];
@@ -19,6 +187,12 @@ async function checkCmssyEditMode(options) {
19
187
  failures.push(`public ${path}: the editor is mounted on a public page`);
20
188
  }
21
189
  const hasServerLayoutBlocks = SERVER_LAYOUT_BLOCKS.test(publicPage.body);
190
+ const configured = options.workspace ? await workspaceHasLayoutBlocks(options.workspace, path) : null;
191
+ if (configured === true && !hasServerLayoutBlocks) {
192
+ failures.push(
193
+ `public ${path}: the workspace defines layout blocks and the page renders none - is a layout slot mounted? (10.0 removed CmssyLayoutSlot; see docs/wiring.md \xA75)`
194
+ );
195
+ }
22
196
  const unverified = await html(url(`${path}?cmssyEdit=1`));
23
197
  if (EDITOR_MARKER.test(unverified.body)) {
24
198
  failures.push(
@@ -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,3 +1,144 @@
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/;
@@ -5,6 +146,33 @@ async function html(url) {
5
146
  const response = await fetch(url, { redirect: "manual" });
6
147
  return { status: response.status, body: await response.text() };
7
148
  }
149
+ var LAYOUT_BLOCKS_QUERY = `query CmssySmokeLayouts($workspaceSlug: String!, $pageSlug: String!) {
150
+ public {
151
+ page {
152
+ layouts(workspaceSlug: $workspaceSlug, pageSlug: $pageSlug) {
153
+ position
154
+ blocks { id isActive }
155
+ }
156
+ }
157
+ }
158
+ }`;
159
+ async function workspaceHasLayoutBlocks(workspace, pageSlug) {
160
+ try {
161
+ const data = await graphqlRequest(
162
+ workspace,
163
+ LAYOUT_BLOCKS_QUERY,
164
+ { workspaceSlug: workspace.workspaceSlug, pageSlug },
165
+ { public: true, retry: {} },
166
+ "edit smoke: layout blocks"
167
+ );
168
+ const groups = data.public?.page?.layouts ?? [];
169
+ return groups.some(
170
+ (group) => (group.blocks ?? []).some((block) => block.isActive !== false)
171
+ );
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
8
176
  async function checkCmssyEditMode(options) {
9
177
  const { baseUrl, secret, path = "/" } = options;
10
178
  const failures = [];
@@ -17,6 +185,12 @@ async function checkCmssyEditMode(options) {
17
185
  failures.push(`public ${path}: the editor is mounted on a public page`);
18
186
  }
19
187
  const hasServerLayoutBlocks = SERVER_LAYOUT_BLOCKS.test(publicPage.body);
188
+ const configured = options.workspace ? await workspaceHasLayoutBlocks(options.workspace, path) : null;
189
+ if (configured === true && !hasServerLayoutBlocks) {
190
+ failures.push(
191
+ `public ${path}: the workspace defines layout blocks and the page renders none - is a layout slot mounted? (10.0 removed CmssyLayoutSlot; see docs/wiring.md \xA75)`
192
+ );
193
+ }
20
194
  const unverified = await html(url(`${path}?cmssyEdit=1`));
21
195
  if (EDITOR_MARKER.test(unverified.body)) {
22
196
  failures.push(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmssy/core",
3
- "version": "10.6.1",
3
+ "version": "10.7.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",