sb-mig 6.1.0-beta.2 → 6.1.0-beta.3

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.
@@ -39,6 +39,7 @@ export interface LanguagePublishStateMap {
39
39
  statesByLanguage?: Record<string, Record<string, number>>;
40
40
  stories?: Record<string, LanguagePublishStateMapEntry>;
41
41
  }
42
+ export declare const createStatusPreservingFetch: (baseFetch?: typeof fetch) => typeof fetch;
42
43
  export declare const loadLanguagePublishStateMap: (languagePublishStatePath: string) => LanguagePublishStateMap;
43
44
  export declare const buildLanguagePublishStateMap: (args: BuildLanguagePublishStateMapArgs, config: RequestBaseConfig) => Promise<LanguagePublishStateMap>;
44
45
  export declare const buildLanguagePublishStateMapFromStories: (args: BuildLanguagePublishStateMapFromStoriesArgs, config: RequestBaseConfig) => Promise<LanguagePublishStateMap>;
@@ -1,12 +1,93 @@
1
1
  import { createHash } from "crypto";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
+ import StoryblokClient from "storyblok-js-client";
4
5
  import { mapWithConcurrency } from "../../utils/async-utils.js";
5
6
  import { createAndSaveToFile } from "../../utils/files.js";
6
7
  import Logger from "../../utils/logger.js";
7
8
  import { getAllStories, getStoryBySlug } from "./stories.js";
8
9
  const DEFAULT_LANGUAGE = "[default]";
9
10
  const DELIVERY_CHECK_CONCURRENCY = 5;
11
+ const DELIVERY_API_DEFAULT_RATE_LIMIT = 5;
12
+ const DELIVERY_API_MAX_RETRIES = 10;
13
+ const DELIVERY_API_TIMEOUT_SECONDS = 60;
14
+ export const createStatusPreservingFetch = (baseFetch = fetch) => async (input, init) => {
15
+ const response = await baseFetch(input, init);
16
+ if (response.ok) {
17
+ return response;
18
+ }
19
+ const responseText = await response
20
+ .clone()
21
+ .text()
22
+ .catch(() => "");
23
+ if (responseText.trim().length === 0) {
24
+ return new Response("{}", {
25
+ status: response.status,
26
+ statusText: response.statusText,
27
+ headers: response.headers,
28
+ });
29
+ }
30
+ try {
31
+ JSON.parse(responseText);
32
+ return response;
33
+ }
34
+ catch {
35
+ return new Response(JSON.stringify({ error: responseText }), {
36
+ status: response.status,
37
+ statusText: response.statusText,
38
+ headers: response.headers,
39
+ });
40
+ }
41
+ };
42
+ const createDeliveryClient = ({ accessToken, deliveryApiUrl, rateLimit, }) => new StoryblokClient({
43
+ accessToken,
44
+ rateLimit: rateLimit ?? DELIVERY_API_DEFAULT_RATE_LIMIT,
45
+ maxRetries: DELIVERY_API_MAX_RETRIES,
46
+ timeout: DELIVERY_API_TIMEOUT_SECONDS,
47
+ fetch: createStatusPreservingFetch(),
48
+ cache: {
49
+ clear: "auto",
50
+ type: "none",
51
+ },
52
+ }, deliveryApiUrl);
53
+ const resolveStoryblokErrorStatus = (error) => error?.status ??
54
+ error?.response?.status ??
55
+ error?.response?.response?.status ??
56
+ error?.response?.data?.status ??
57
+ error?.message?.status ??
58
+ error?.message?.response?.status ??
59
+ error?.message?.response?.response?.status ??
60
+ error?.message?.response?.data?.status ??
61
+ 0;
62
+ const resolveStoryblokErrorMessage = (error) => {
63
+ const responseData = error?.response?.data;
64
+ if (Array.isArray(responseData)) {
65
+ return responseData.filter(Boolean).join(", ") || null;
66
+ }
67
+ if (typeof responseData === "string") {
68
+ return responseData;
69
+ }
70
+ if (responseData && typeof responseData === "object") {
71
+ return (responseData.error ||
72
+ responseData.message ||
73
+ error?.response?.message ||
74
+ error?.message?.message ||
75
+ error?.message ||
76
+ null);
77
+ }
78
+ if (error?.message && typeof error.message === "object") {
79
+ return (error.message.message ||
80
+ error.message.response?.message ||
81
+ error.message.response?.data?.error ||
82
+ null);
83
+ }
84
+ return error?.message || null;
85
+ };
86
+ const isStoryblokNotFoundMessage = (message) => {
87
+ const normalized = message?.trim().toLowerCase();
88
+ return (normalized === "not found" ||
89
+ normalized === "this record could not be found");
90
+ };
10
91
  const cleanStoryblokContent = (value) => {
11
92
  if (Array.isArray(value)) {
12
93
  return value.map(cleanStoryblokContent);
@@ -89,24 +170,45 @@ const classifyTranslatedLanguageState = ({ published, draft, }) => {
89
170
  }
90
171
  return "error";
91
172
  };
92
- const fetchDeliveryStory = async ({ deliveryApiUrl, accessToken, slug, version, language, }) => {
93
- const url = new URL(`${deliveryApiUrl.replace(/\/$/, "")}/cdn/stories/${slug}`);
94
- url.searchParams.set("token", accessToken);
95
- url.searchParams.set("version", version);
96
- url.searchParams.set("cv", String(Date.now()));
173
+ const fetchDeliveryStory = async ({ deliveryClient, slug, version, language, }) => {
174
+ const params = {
175
+ version,
176
+ cv: String(Date.now()),
177
+ };
97
178
  if (language && language !== DEFAULT_LANGUAGE) {
98
- url.searchParams.set("language", language);
179
+ params.language = language;
180
+ }
181
+ try {
182
+ const response = await deliveryClient.get(`cdn/stories/${slug}`, params);
183
+ const story = response?.data?.story;
184
+ return {
185
+ status: 200,
186
+ ok: true,
187
+ fullSlug: story?.full_slug || null,
188
+ publishedAt: story?.published_at || null,
189
+ contentHash: story?.content ? hashContent(story.content) : null,
190
+ };
191
+ }
192
+ catch (error) {
193
+ const errorMessage = resolveStoryblokErrorMessage(error);
194
+ const status = resolveStoryblokErrorStatus(error) ||
195
+ (isStoryblokNotFoundMessage(errorMessage) ? 404 : 0);
196
+ const story = error?.response?.data?.story;
197
+ if (status === 429) {
198
+ throw new Error(`Storyblok Delivery API rate limit did not recover after retries for '${slug}' (${version}${language ? `, ${language}` : ""}).`);
199
+ }
200
+ if (status === 0) {
201
+ throw new Error(`Storyblok Delivery API request failed without an HTTP status for '${slug}' (${version}${language ? `, ${language}` : ""})${errorMessage ? `: ${errorMessage}` : "."}`);
202
+ }
203
+ return {
204
+ status,
205
+ ok: false,
206
+ fullSlug: story?.full_slug || null,
207
+ publishedAt: story?.published_at || null,
208
+ contentHash: story?.content ? hashContent(story.content) : null,
209
+ errorMessage,
210
+ };
99
211
  }
100
- const response = await fetch(url);
101
- const data = await response.json().catch(() => null);
102
- const story = data?.story;
103
- return {
104
- status: response.status,
105
- ok: response.ok,
106
- fullSlug: story?.full_slug || null,
107
- publishedAt: story?.published_at || null,
108
- contentHash: story?.content ? hashContent(story.content) : null,
109
- };
110
212
  };
111
213
  const loadStoriesForLanguageState = async ({ startsWith, withSlug, }, config) => {
112
214
  if (withSlug && withSlug.length > 0) {
@@ -184,7 +286,20 @@ export const buildLanguagePublishStateMapFromStories = async (args, config) => {
184
286
  const deliveryAccessToken = accessToken || "";
185
287
  const deliveryApiUrl = config.storyblokDeliveryApiUrl || "https://api.storyblok.com/v2";
186
288
  const uniqueLanguages = Array.from(new Set(args.languages));
289
+ const translatedLanguages = uniqueLanguages.filter((language) => language !== DEFAULT_LANGUAGE);
187
290
  const stories = args.stories.filter((item) => item?.story && item.story.is_folder !== true);
291
+ const deliveryClient = needsDeliveryApi
292
+ ? createDeliveryClient({
293
+ accessToken: deliveryAccessToken,
294
+ deliveryApiUrl,
295
+ rateLimit: config.rateLimit,
296
+ })
297
+ : null;
298
+ const totalDeliveryChecks = stories.length * translatedLanguages.length * 2;
299
+ if (needsDeliveryApi) {
300
+ Logger.log(`Resolving translated language publish state for ${stories.length} stories and ${translatedLanguages.length} language(s). Up to ${totalDeliveryChecks} Delivery API check(s) will run through StoryblokClient at ${config.rateLimit ?? DELIVERY_API_DEFAULT_RATE_LIMIT} req/s.`);
301
+ }
302
+ let processedStories = 0;
188
303
  const storyEntries = await mapWithConcurrency(stories, DELIVERY_CHECK_CONCURRENCY, async (item) => {
189
304
  const story = item.story;
190
305
  const languagesByCode = {};
@@ -202,15 +317,13 @@ export const buildLanguagePublishStateMapFromStories = async (args, config) => {
202
317
  }
203
318
  const [published, draft] = await Promise.all([
204
319
  fetchDeliveryStory({
205
- deliveryApiUrl,
206
- accessToken: deliveryAccessToken,
320
+ deliveryClient: deliveryClient,
207
321
  slug: story.full_slug,
208
322
  version: "published",
209
323
  language,
210
324
  }),
211
325
  fetchDeliveryStory({
212
- deliveryApiUrl,
213
- accessToken: deliveryAccessToken,
326
+ deliveryClient: deliveryClient,
214
327
  slug: story.full_slug,
215
328
  version: "draft",
216
329
  language,
@@ -226,7 +339,7 @@ export const buildLanguagePublishStateMapFromStories = async (args, config) => {
226
339
  draft,
227
340
  };
228
341
  }
229
- return {
342
+ const storyEntry = {
230
343
  id: story.id,
231
344
  uuid: story.uuid,
232
345
  name: story.name,
@@ -236,6 +349,13 @@ export const buildLanguagePublishStateMapFromStories = async (args, config) => {
236
349
  .filter(([, value]) => value.state === "published_clean")
237
350
  .map(([language]) => language),
238
351
  };
352
+ processedStories++;
353
+ if (needsDeliveryApi &&
354
+ (processedStories % 10 === 0 ||
355
+ processedStories === stories.length)) {
356
+ Logger.success(`Resolved translated language publish state for ${processedStories}/${stories.length} stories.`);
357
+ }
358
+ return storyEntry;
239
359
  });
240
360
  return {
241
361
  generatedAt: new Date().toISOString(),
@@ -166,10 +166,21 @@ const subtractLanguages = (allLanguages, publishLanguages) => {
166
166
  return allLanguages.filter((language) => !publishSet.has(language));
167
167
  };
168
168
  const formatLanguageList = (languages) => languages && languages.length > 0 ? languages.join(",") : "none";
169
+ const resolveStoryblokErrorStatus = (err) => err?.status ??
170
+ err?.response?.status ??
171
+ err?.response?.response?.status ??
172
+ err?.message?.status ??
173
+ err?.message?.response?.status;
169
174
  const resolveStoryblokErrorResponse = (err) => {
170
175
  if (typeof err?.response === "string" && err.response.trim().length > 0) {
171
176
  return err.response.trim();
172
177
  }
178
+ if (Array.isArray(err?.response?.data)) {
179
+ const message = err.response.data.filter(Boolean).join(", ").trim();
180
+ if (message.length > 0) {
181
+ return message;
182
+ }
183
+ }
173
184
  if (typeof err?.response?.data === "string" &&
174
185
  err.response.data.trim().length > 0) {
175
186
  return err.response.data.trim();
@@ -178,6 +189,23 @@ const resolveStoryblokErrorResponse = (err) => {
178
189
  err.response.message.trim().length > 0) {
179
190
  return err.response.message.trim();
180
191
  }
192
+ if (typeof err?.message?.message === "string" &&
193
+ err.message.message.trim().length > 0) {
194
+ return err.message.message.trim();
195
+ }
196
+ if (Array.isArray(err?.message?.response?.data)) {
197
+ const message = err.message.response.data
198
+ .filter(Boolean)
199
+ .join(", ")
200
+ .trim();
201
+ if (message.length > 0) {
202
+ return message;
203
+ }
204
+ }
205
+ if (typeof err?.message?.response?.data === "string" &&
206
+ err.message.response.data.trim().length > 0) {
207
+ return err.message.response.data.trim();
208
+ }
181
209
  if (typeof err?.message === "string" && err.message.trim().length > 0) {
182
210
  return err.message.trim();
183
211
  }
@@ -257,7 +285,16 @@ export const getStoryById = (storyId, config) => {
257
285
  }
258
286
  return res.data;
259
287
  })
260
- .catch((err) => Logger.error(err));
288
+ .catch((err) => {
289
+ const status = resolveStoryblokErrorStatus(err);
290
+ const responseMessage = resolveStoryblokErrorResponse(err);
291
+ const statusLabel = status ? `status ${status}` : "unknown status";
292
+ const responseLabel = responseMessage
293
+ ? ` Response: ${responseMessage}`
294
+ : "";
295
+ Logger.error(`Failed to fetch story '${storyId}' with full content from space '${spaceId}' (${statusLabel}).${responseLabel}`);
296
+ return undefined;
297
+ });
261
298
  };
262
299
  export const getStoryBySlug = async (slug, config) => {
263
300
  const { spaceId, sbApi } = config;
@@ -175,10 +175,21 @@ const subtractLanguages = (allLanguages, publishLanguages) => {
175
175
  return allLanguages.filter((language) => !publishSet.has(language));
176
176
  };
177
177
  const formatLanguageList = (languages) => languages && languages.length > 0 ? languages.join(",") : "none";
178
+ const resolveStoryblokErrorStatus = (err) => err?.status ??
179
+ err?.response?.status ??
180
+ err?.response?.response?.status ??
181
+ err?.message?.status ??
182
+ err?.message?.response?.status;
178
183
  const resolveStoryblokErrorResponse = (err) => {
179
184
  if (typeof err?.response === "string" && err.response.trim().length > 0) {
180
185
  return err.response.trim();
181
186
  }
187
+ if (Array.isArray(err?.response?.data)) {
188
+ const message = err.response.data.filter(Boolean).join(", ").trim();
189
+ if (message.length > 0) {
190
+ return message;
191
+ }
192
+ }
182
193
  if (typeof err?.response?.data === "string" &&
183
194
  err.response.data.trim().length > 0) {
184
195
  return err.response.data.trim();
@@ -187,6 +198,23 @@ const resolveStoryblokErrorResponse = (err) => {
187
198
  err.response.message.trim().length > 0) {
188
199
  return err.response.message.trim();
189
200
  }
201
+ if (typeof err?.message?.message === "string" &&
202
+ err.message.message.trim().length > 0) {
203
+ return err.message.message.trim();
204
+ }
205
+ if (Array.isArray(err?.message?.response?.data)) {
206
+ const message = err.message.response.data
207
+ .filter(Boolean)
208
+ .join(", ")
209
+ .trim();
210
+ if (message.length > 0) {
211
+ return message;
212
+ }
213
+ }
214
+ if (typeof err?.message?.response?.data === "string" &&
215
+ err.message.response.data.trim().length > 0) {
216
+ return err.message.response.data.trim();
217
+ }
190
218
  if (typeof err?.message === "string" && err.message.trim().length > 0) {
191
219
  return err.message.trim();
192
220
  }
@@ -269,7 +297,16 @@ const getStoryById = (storyId, config) => {
269
297
  }
270
298
  return res.data;
271
299
  })
272
- .catch((err) => logger_js_1.default.error(err));
300
+ .catch((err) => {
301
+ const status = resolveStoryblokErrorStatus(err);
302
+ const responseMessage = resolveStoryblokErrorResponse(err);
303
+ const statusLabel = status ? `status ${status}` : "unknown status";
304
+ const responseLabel = responseMessage
305
+ ? ` Response: ${responseMessage}`
306
+ : "";
307
+ logger_js_1.default.error(`Failed to fetch story '${storyId}' with full content from space '${spaceId}' (${statusLabel}).${responseLabel}`);
308
+ return undefined;
309
+ });
273
310
  };
274
311
  exports.getStoryById = getStoryById;
275
312
  const getStoryBySlug = async (slug, config) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sb-mig",
3
- "version": "6.1.0-beta.2",
3
+ "version": "6.1.0-beta.3",
4
4
  "description": "CLI to rule the world. (and handle stuff related to Storyblok CMS)",
5
5
  "author": "Marcin Krawczyk <marckraw@icloud.com>",
6
6
  "license": "MIT",