sb-mig 6.0.0 → 6.1.0-beta.1

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.
@@ -1,15 +1,25 @@
1
1
  import Logger from "../utils/logger.js";
2
+ import { buildUrl } from "../utils/url-utils.js";
2
3
  const getAllStories = async (args, config) => {
3
4
  const { spaceId, storiesFilename } = args;
4
5
  Logger.warning("Trying to get all stories from Content Hub...");
5
- const queryParams = `spaceId=${spaceId}&storiesFilename=${storiesFilename}`;
6
- const url = `${config.contentHubOriginUrl}/getStories?${queryParams}`;
6
+ if (!config.contentHubOriginUrl) {
7
+ throw new Error("contentHubOriginUrl is required to fetch stories.");
8
+ }
9
+ const url = buildUrl({
10
+ baseUrl: config.contentHubOriginUrl,
11
+ pathname: "getStories",
12
+ searchParams: {
13
+ spaceId,
14
+ ...(storiesFilename ? { storiesFilename } : {}),
15
+ },
16
+ });
7
17
  const authorizationToken = config.contentHubAuthorizationToken;
8
18
  if (config.debug) {
9
- console.log("This is url: ", url);
19
+ console.log("This is url: ", url.toString());
10
20
  }
11
21
  try {
12
- const response = await fetch(url, {
22
+ const response = await fetch(url.toString(), {
13
23
  method: "GET",
14
24
  headers: {
15
25
  Authorization: authorizationToken,
@@ -48,6 +48,7 @@ interface MigrateItems {
48
48
  publishLanguages?: PublishLanguagesOption;
49
49
  fromFilePath?: string;
50
50
  fileName?: string;
51
+ languagePublishStatePath?: string;
51
52
  preparedMigrationConfigs?: PreparedMigrationConfig[];
52
53
  }
53
54
  export type MapperDefinition = (data: any) => any;
@@ -70,8 +71,8 @@ export declare const runMigrationPipelineInMemory: ({ itemType, itemsToMigrate,
70
71
  itemsToMigrate: any[];
71
72
  preparedMigrationConfigs: PreparedMigrationConfig[];
72
73
  }) => MigrationPipelineResult;
73
- export declare const migrateAllComponentsDataInStories: ({ itemType, migrationConfig, migrateFrom, from, to, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, migrationComponentAliases, migrationComponentOverrides, }: Omit<MigrateItems, "componentsToMigrate" | "preparedMigrationConfigs">, config: RequestBaseConfig) => Promise<void>;
74
- export declare const doTheMigration: ({ itemType, from, itemsToMigrate, migrationConfig, migrationConfigs, to, dryRun, publish, publishLanguages, migrateFrom, fromFilePath, fileName, }: {
74
+ export declare const migrateAllComponentsDataInStories: ({ itemType, migrationConfig, migrateFrom, from, to, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, languagePublishStatePath, migrationComponentAliases, migrationComponentOverrides, }: Omit<MigrateItems, "componentsToMigrate" | "preparedMigrationConfigs">, config: RequestBaseConfig) => Promise<void>;
75
+ export declare const doTheMigration: ({ itemType, from, itemsToMigrate, migrationConfig, migrationConfigs, to, dryRun, publish, publishLanguages, migrateFrom, fromFilePath, fileName, languagePublishStatePath, }: {
75
76
  itemType?: "story" | "preset";
76
77
  from: string;
77
78
  itemsToMigrate: any[];
@@ -84,6 +85,7 @@ export declare const doTheMigration: ({ itemType, from, itemsToMigrate, migratio
84
85
  migrateFrom: MigrateFrom;
85
86
  fromFilePath?: string;
86
87
  fileName?: string;
88
+ languagePublishStatePath?: string;
87
89
  }, config: RequestBaseConfig) => Promise<void>;
88
- export declare const migrateProvidedComponentsDataInStories: ({ itemType, migrationConfig, migrateFrom, from, to, componentsToMigrate, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, preparedMigrationConfigs, migrationComponentAliases, migrationComponentOverrides, }: MigrateItems, config: RequestBaseConfig) => Promise<void>;
90
+ export declare const migrateProvidedComponentsDataInStories: ({ itemType, migrationConfig, migrateFrom, from, to, componentsToMigrate, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, languagePublishStatePath, preparedMigrationConfigs, migrationComponentAliases, migrationComponentOverrides, }: MigrateItems, config: RequestBaseConfig) => Promise<void>;
89
91
  export {};
@@ -7,6 +7,7 @@ import Logger from "../../utils/logger.js";
7
7
  import { modifyOrCreateAppliedMigrationsFile } from "../../utils/migrations.js";
8
8
  import { isObjectEmpty } from "../../utils/object-utils.js";
9
9
  import { managementApi } from "../managementApi.js";
10
+ import { loadLanguagePublishStateMap } from "../stories/language-publish-state.js";
10
11
  import { buildPreMigrationBackupBaseName, resolveOutputFileBaseName, shouldUseDatestampForArtifacts, } from "./file-naming.js";
11
12
  import { extendMigrationMapperWithAliases, resolveMigrationComponentsToMigrate, } from "./migration-component-scope.js";
12
13
  import { saveMigrationRunLog } from "./migration-run-log.js";
@@ -295,7 +296,7 @@ export const runMigrationPipelineInMemory = ({ itemType, itemsToMigrate, prepare
295
296
  totalItems: workingItems.length,
296
297
  };
297
298
  };
298
- const savePipelineSummary = async ({ artifactBaseName, useDatestamp, from, itemType, dryRun, publish, publishLanguages, migrateFrom, fromFilePath, pipelineResult, }, config) => {
299
+ const savePipelineSummary = async ({ artifactBaseName, useDatestamp, from, itemType, dryRun, publish, publishLanguages, migrateFrom, fromFilePath, languagePublishStatePath, pipelineResult, }, config) => {
299
300
  await createAndSaveToFile({
300
301
  datestamp: useDatestamp,
301
302
  ext: "json",
@@ -307,6 +308,7 @@ const savePipelineSummary = async ({ artifactBaseName, useDatestamp, from, itemT
307
308
  migrateFrom,
308
309
  from,
309
310
  fromFilePath: fromFilePath || null,
311
+ languagePublishStatePath: languagePublishStatePath || null,
310
312
  },
311
313
  writeMode: itemType === "story" && publish ? "publish" : "save",
312
314
  publishLanguages: itemType === "story" && publish
@@ -384,7 +386,7 @@ const loadItemsToMigrate = async ({ itemType, migrateFrom, from, filters, fromFi
384
386
  spaceId: from,
385
387
  });
386
388
  };
387
- export const migrateAllComponentsDataInStories = async ({ itemType, migrationConfig, migrateFrom, from, to, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, migrationComponentAliases, migrationComponentOverrides, }, config) => {
389
+ export const migrateAllComponentsDataInStories = async ({ itemType, migrationConfig, migrateFrom, from, to, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, languagePublishStatePath, migrationComponentAliases, migrationComponentOverrides, }, config) => {
388
390
  Logger.warning(`Trying to migrate all ${itemType} from ${migrateFrom}, ${from} to ${to}...`);
389
391
  const preparedMigrationConfigs = prepareMigrationConfigs({
390
392
  migrationConfig,
@@ -407,10 +409,11 @@ export const migrateAllComponentsDataInStories = async ({ itemType, migrationCon
407
409
  publishLanguages,
408
410
  fromFilePath,
409
411
  fileName,
412
+ languagePublishStatePath,
410
413
  preparedMigrationConfigs,
411
414
  }, config);
412
415
  };
413
- export const doTheMigration = async ({ itemType = "story", from, itemsToMigrate, migrationConfig, migrationConfigs, to, dryRun, publish, publishLanguages, migrateFrom, fromFilePath, fileName, }, config) => {
416
+ export const doTheMigration = async ({ itemType = "story", from, itemsToMigrate, migrationConfig, migrationConfigs, to, dryRun, publish, publishLanguages, migrateFrom, fromFilePath, fileName, languagePublishStatePath, }, config) => {
414
417
  const preparedMigrationConfigs = migrationConfigs ||
415
418
  prepareMigrationConfigs({
416
419
  migrationConfig: migrationConfig || [],
@@ -484,6 +487,7 @@ export const doTheMigration = async ({ itemType = "story", from, itemsToMigrate,
484
487
  publishLanguages,
485
488
  migrateFrom,
486
489
  fromFilePath,
490
+ languagePublishStatePath,
487
491
  pipelineResult,
488
492
  }, config);
489
493
  if (dryRun) {
@@ -500,6 +504,9 @@ export const doTheMigration = async ({ itemType = "story", from, itemsToMigrate,
500
504
  }
501
505
  let writeResults = [];
502
506
  let resolvedPublishLanguages;
507
+ const languagePublishStateMap = languagePublishStatePath
508
+ ? loadLanguagePublishStateMap(languagePublishStatePath)
509
+ : undefined;
503
510
  if (itemType === "story") {
504
511
  if (publish && publishLanguages !== undefined) {
505
512
  resolvedPublishLanguages =
@@ -515,6 +522,7 @@ export const doTheMigration = async ({ itemType = "story", from, itemsToMigrate,
515
522
  publish: Boolean(publish),
516
523
  publishLanguages: resolvedPublishLanguages,
517
524
  preservePublishState: Boolean(publish),
525
+ languagePublishStateMap,
518
526
  },
519
527
  }, config);
520
528
  }
@@ -539,6 +547,7 @@ export const doTheMigration = async ({ itemType = "story", from, itemsToMigrate,
539
547
  resolvedPublishLanguages,
540
548
  migrateFrom,
541
549
  fromFilePath,
550
+ languagePublishStatePath,
542
551
  pipelineResult,
543
552
  writeResults,
544
553
  writeSummary,
@@ -570,7 +579,7 @@ const saveBackupToFile = async ({ itemType, res, folder, filename }, config) =>
570
579
  res: res,
571
580
  }, config);
572
581
  };
573
- export const migrateProvidedComponentsDataInStories = async ({ itemType, migrationConfig, migrateFrom, from, to, componentsToMigrate, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, preparedMigrationConfigs, migrationComponentAliases, migrationComponentOverrides, }, config) => {
582
+ export const migrateProvidedComponentsDataInStories = async ({ itemType, migrationConfig, migrateFrom, from, to, componentsToMigrate, filters, dryRun, publish, publishLanguages, fromFilePath, fileName, languagePublishStatePath, preparedMigrationConfigs, migrationComponentAliases, migrationComponentOverrides, }, config) => {
574
583
  const resolvedMigrationConfigs = preparedMigrationConfigs ||
575
584
  prepareMigrationConfigs({
576
585
  migrationConfig,
@@ -610,5 +619,6 @@ export const migrateProvidedComponentsDataInStories = async ({ itemType, migrati
610
619
  migrateFrom,
611
620
  fromFilePath,
612
621
  fileName,
622
+ languagePublishStatePath,
613
623
  }, config);
614
624
  };
@@ -12,6 +12,7 @@ export interface MigrationRunLogRecord {
12
12
  migrateFrom: MigrateFrom;
13
13
  from: string;
14
14
  fromFilePath: string | null;
15
+ languagePublishStatePath?: string | null;
15
16
  };
16
17
  target: {
17
18
  to: string;
@@ -67,11 +68,12 @@ interface SaveMigrationRunLogArgs {
67
68
  resolvedPublishLanguages?: string[];
68
69
  migrateFrom: MigrateFrom;
69
70
  fromFilePath?: string;
71
+ languagePublishStatePath?: string;
70
72
  pipelineResult: MigrationPipelineResult;
71
73
  writeResults: PromiseSettledResult<MutationWriteResult>[];
72
74
  writeSummary: MutationWriteSummary;
73
75
  }
74
- export declare const buildMigrationRunLogRecords: ({ from, to, itemType, dryRun, publish, publishLanguages, resolvedPublishLanguages, migrateFrom, fromFilePath, pipelineResult, writeResults, writeSummary, }: Omit<SaveMigrationRunLogArgs, "artifactBaseName" | "useDatestamp">) => MigrationRunLogRecord[];
76
+ export declare const buildMigrationRunLogRecords: ({ from, to, itemType, dryRun, publish, publishLanguages, resolvedPublishLanguages, migrateFrom, fromFilePath, languagePublishStatePath, pipelineResult, writeResults, writeSummary, }: Omit<SaveMigrationRunLogArgs, "artifactBaseName" | "useDatestamp">) => MigrationRunLogRecord[];
75
77
  export declare const recordsToJsonl: (records: MigrationRunLogRecord[]) => string;
76
78
  export declare const saveMigrationRunLog: (args: SaveMigrationRunLogArgs, config: RequestBaseConfig) => Promise<void>;
77
79
  export {};
@@ -31,7 +31,7 @@ const resolveWriteResultValue = (result) => {
31
31
  error: result.reason,
32
32
  };
33
33
  };
34
- export const buildMigrationRunLogRecords = ({ from, to, itemType, dryRun, publish, publishLanguages, resolvedPublishLanguages, migrateFrom, fromFilePath, pipelineResult, writeResults, writeSummary, }) => {
34
+ export const buildMigrationRunLogRecords = ({ from, to, itemType, dryRun, publish, publishLanguages, resolvedPublishLanguages, migrateFrom, fromFilePath, languagePublishStatePath, pipelineResult, writeResults, writeSummary, }) => {
35
35
  const timestamp = new Date().toISOString();
36
36
  const runId = `${itemType}-${timestamp}`;
37
37
  const baseRecord = {
@@ -42,6 +42,7 @@ export const buildMigrationRunLogRecords = ({ from, to, itemType, dryRun, publis
42
42
  migrateFrom,
43
43
  from,
44
44
  fromFilePath: fromFilePath || null,
45
+ languagePublishStatePath: languagePublishStatePath || null,
45
46
  },
46
47
  target: {
47
48
  to,
@@ -1,3 +1,4 @@
1
+ import * as stories from "./stories/index.js";
1
2
  export declare const managementApi: {
2
3
  assets: {
3
4
  getAllAssets: import("./assets/assets.types.js").GetAllAssets;
@@ -77,6 +78,19 @@ export declare const managementApi: {
77
78
  removeAllStories: import("./stories/stories.types.js").RemoveAllStories;
78
79
  upsertStory: import("./stories/stories.types.js").UpsertStory;
79
80
  backupStories: import("./stories/stories.types.js").BackupStories;
81
+ buildLanguagePublishStateMap: (args: import("./stories/language-publish-state.js").BuildLanguagePublishStateMapArgs, config: import("./utils/request.js").RequestBaseConfig) => Promise<{
82
+ generatedAt: string;
83
+ source: {
84
+ spaceId: string;
85
+ startsWith: string | null;
86
+ withSlug: string[] | null;
87
+ };
88
+ languages: string[];
89
+ storyCount: number;
90
+ statesByLanguage: Record<string, any>;
91
+ stories: any;
92
+ }>;
93
+ loadLanguagePublishStateMap: (languagePublishStatePath: string) => stories.LanguagePublishStateMap;
80
94
  };
81
95
  spaces: {
82
96
  getAllSpaces: import("./spaces/spaces.types.js").GetAllSpaces;
@@ -1,2 +1,4 @@
1
1
  export { createStory, updateStory, getStoryById, removeStory, getStoryBySlug, updateStories, publishStoryLanguages, parsePublishLanguagesOption, resolvePublishLanguageCodes, getAllStories, removeAllStories, upsertStory, } from "./stories.js";
2
2
  export { backupStories } from "./backup.js";
3
+ export { buildLanguagePublishStateMap, loadLanguagePublishStateMap, } from "./language-publish-state.js";
4
+ export type { LanguagePublishState, LanguagePublishStateMap, LanguagePublishStateMapEntry, } from "./language-publish-state.js";
@@ -1,2 +1,3 @@
1
1
  export { createStory, updateStory, getStoryById, removeStory, getStoryBySlug, updateStories, publishStoryLanguages, parsePublishLanguagesOption, resolvePublishLanguageCodes, getAllStories, removeAllStories, upsertStory, } from "./stories.js";
2
2
  export { backupStories } from "./backup.js";
3
+ export { buildLanguagePublishStateMap, loadLanguagePublishStateMap, } from "./language-publish-state.js";
@@ -0,0 +1,48 @@
1
+ import type { RequestBaseConfig } from "../utils/request.js";
2
+ export type LanguagePublishState = "published_clean" | "published_with_unpublished_changes" | "draft_never_published" | "unpublished_historical" | "draft_or_unpublished" | "missing" | "published_unknown" | "error";
3
+ export interface BuildLanguagePublishStateMapArgs {
4
+ from: string;
5
+ accessToken?: string;
6
+ languages?: string;
7
+ startsWith?: string;
8
+ withSlug?: string[];
9
+ fileName?: string;
10
+ outputPath?: string;
11
+ }
12
+ export interface LanguagePublishStateMapEntry {
13
+ id?: number | string;
14
+ uuid?: string;
15
+ name?: string;
16
+ fullSlug?: string;
17
+ languages?: Record<string, {
18
+ state?: LanguagePublishState;
19
+ [key: string]: unknown;
20
+ }>;
21
+ cleanPublishedLanguages?: string[];
22
+ [key: string]: unknown;
23
+ }
24
+ export interface LanguagePublishStateMap {
25
+ generatedAt?: string;
26
+ source?: {
27
+ spaceId?: string;
28
+ startsWith?: string | null;
29
+ withSlug?: string[] | null;
30
+ };
31
+ languages?: string[];
32
+ storyCount?: number;
33
+ statesByLanguage?: Record<string, Record<string, number>>;
34
+ stories?: Record<string, LanguagePublishStateMapEntry>;
35
+ }
36
+ export declare const loadLanguagePublishStateMap: (languagePublishStatePath: string) => LanguagePublishStateMap;
37
+ export declare const buildLanguagePublishStateMap: (args: BuildLanguagePublishStateMapArgs, config: RequestBaseConfig) => Promise<{
38
+ generatedAt: string;
39
+ source: {
40
+ spaceId: string;
41
+ startsWith: string | null;
42
+ withSlug: string[] | null;
43
+ };
44
+ languages: string[];
45
+ storyCount: number;
46
+ statesByLanguage: Record<string, any>;
47
+ stories: any;
48
+ }>;
@@ -0,0 +1,230 @@
1
+ import { createHash } from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { mapWithConcurrency } from "../../utils/async-utils.js";
5
+ import { createAndSaveToFile } from "../../utils/files.js";
6
+ import Logger from "../../utils/logger.js";
7
+ import { getAllStories, getStoryBySlug } from "./stories.js";
8
+ const DEFAULT_LANGUAGE = "[default]";
9
+ const DELIVERY_CHECK_CONCURRENCY = 5;
10
+ const cleanStoryblokContent = (value) => {
11
+ if (Array.isArray(value)) {
12
+ return value.map(cleanStoryblokContent);
13
+ }
14
+ if (value && typeof value === "object") {
15
+ return Object.fromEntries(Object.keys(value)
16
+ .filter((key) => key !== "_editable")
17
+ .sort()
18
+ .map((key) => [key, cleanStoryblokContent(value[key])]));
19
+ }
20
+ return value;
21
+ };
22
+ const hashContent = (value) => createHash("sha256")
23
+ .update(JSON.stringify(cleanStoryblokContent(value)))
24
+ .digest("hex");
25
+ const normalizeLanguages = (languages) => {
26
+ if (!languages || languages.trim().length === 0) {
27
+ return "all";
28
+ }
29
+ if (languages.trim().toLowerCase() === "all") {
30
+ return "all";
31
+ }
32
+ const normalized = languages
33
+ .split(",")
34
+ .map((language) => language.trim())
35
+ .filter(Boolean)
36
+ .map((language) => language.toLowerCase() === "default" ? DEFAULT_LANGUAGE : language);
37
+ return Array.from(new Set(normalized));
38
+ };
39
+ const readSpaceLanguageCodes = (spaceResponse) => {
40
+ const space = spaceResponse?.data?.space || spaceResponse?.space || {};
41
+ const languages = space.languages;
42
+ if (!Array.isArray(languages)) {
43
+ return [];
44
+ }
45
+ return languages
46
+ .map((language) => typeof language === "string" ? language : language?.code)
47
+ .filter((language) => Boolean(language));
48
+ };
49
+ const resolveLanguages = async (languages, config) => {
50
+ const normalized = normalizeLanguages(languages);
51
+ if (normalized !== "all") {
52
+ return normalized;
53
+ }
54
+ const response = await config.sbApi.get(`spaces/${config.spaceId}`);
55
+ return [DEFAULT_LANGUAGE, ...readSpaceLanguageCodes(response)];
56
+ };
57
+ const classifyDefaultLanguageState = (story) => {
58
+ if (story?.published === true && story?.unpublished_changes === false) {
59
+ return "published_clean";
60
+ }
61
+ if (story?.published === true && story?.unpublished_changes === true) {
62
+ return "published_with_unpublished_changes";
63
+ }
64
+ if (story?.published === false && story?.published_at) {
65
+ return "unpublished_historical";
66
+ }
67
+ if (story?.published === false) {
68
+ return "draft_never_published";
69
+ }
70
+ return "published_unknown";
71
+ };
72
+ const classifyTranslatedLanguageState = ({ published, draft, }) => {
73
+ if (published.status === 200 && draft.status === 200) {
74
+ if (!published.contentHash || !draft.contentHash) {
75
+ return "published_unknown";
76
+ }
77
+ return published.contentHash === draft.contentHash
78
+ ? "published_clean"
79
+ : "published_with_unpublished_changes";
80
+ }
81
+ if (published.status === 404 && draft.status === 200) {
82
+ return "draft_or_unpublished";
83
+ }
84
+ if (published.status === 404 && draft.status === 404) {
85
+ return "missing";
86
+ }
87
+ if (published.status === 200) {
88
+ return "published_unknown";
89
+ }
90
+ return "error";
91
+ };
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()));
97
+ if (language && language !== DEFAULT_LANGUAGE) {
98
+ url.searchParams.set("language", language);
99
+ }
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
+ };
111
+ const loadStoriesForLanguageState = async ({ startsWith, withSlug, }, config) => {
112
+ if (withSlug && withSlug.length > 0) {
113
+ const stories = await Promise.all(withSlug.map((slug) => getStoryBySlug(slug, config)));
114
+ return stories.filter(Boolean);
115
+ }
116
+ if (startsWith) {
117
+ return getAllStories({ options: { starts_with: startsWith } }, config);
118
+ }
119
+ return getAllStories({}, config);
120
+ };
121
+ export const loadLanguagePublishStateMap = (languagePublishStatePath) => {
122
+ const resolvedPath = path.isAbsolute(languagePublishStatePath)
123
+ ? languagePublishStatePath
124
+ : path.resolve(process.cwd(), languagePublishStatePath);
125
+ const fileContent = fs.readFileSync(resolvedPath, "utf8");
126
+ const parsed = JSON.parse(fileContent);
127
+ if (!parsed || typeof parsed !== "object" || !parsed.stories) {
128
+ throw new Error(`Language publish-state file '${languagePublishStatePath}' is missing a stories map.`);
129
+ }
130
+ return parsed;
131
+ };
132
+ export const buildLanguagePublishStateMap = async (args, config) => {
133
+ const accessToken = args.accessToken || config.accessToken;
134
+ if (!args.from) {
135
+ throw new Error("--from is required.");
136
+ }
137
+ if (!accessToken) {
138
+ throw new Error("--accessToken is required when config accessToken is empty.");
139
+ }
140
+ const sourceConfig = { ...config, spaceId: args.from };
141
+ const deliveryApiUrl = config.storyblokDeliveryApiUrl || "https://api.storyblok.com/v2";
142
+ const languages = await resolveLanguages(args.languages, sourceConfig);
143
+ const uniqueLanguages = Array.from(new Set(languages));
144
+ const stories = (await loadStoriesForLanguageState({
145
+ startsWith: args.startsWith,
146
+ withSlug: args.withSlug,
147
+ }, sourceConfig)).filter((item) => item?.story && item.story.is_folder !== true);
148
+ Logger.success(`Fetched ${stories.length} source stories from space '${args.from}'.`);
149
+ const storyEntries = await mapWithConcurrency(stories, DELIVERY_CHECK_CONCURRENCY, async (item) => {
150
+ const story = item.story;
151
+ const languagesByCode = {};
152
+ for (const language of uniqueLanguages) {
153
+ if (language === DEFAULT_LANGUAGE) {
154
+ languagesByCode[language] = {
155
+ state: classifyDefaultLanguageState(story),
156
+ source: "management",
157
+ published: story.published,
158
+ unpublishedChanges: story.unpublished_changes,
159
+ publishedAt: story.published_at || null,
160
+ firstPublishedAt: story.first_published_at || null,
161
+ };
162
+ continue;
163
+ }
164
+ const [published, draft] = await Promise.all([
165
+ fetchDeliveryStory({
166
+ deliveryApiUrl,
167
+ accessToken,
168
+ slug: story.full_slug,
169
+ version: "published",
170
+ language,
171
+ }),
172
+ fetchDeliveryStory({
173
+ deliveryApiUrl,
174
+ accessToken,
175
+ slug: story.full_slug,
176
+ version: "draft",
177
+ language,
178
+ }),
179
+ ]);
180
+ languagesByCode[language] = {
181
+ state: classifyTranslatedLanguageState({
182
+ published,
183
+ draft,
184
+ }),
185
+ source: "delivery",
186
+ published,
187
+ draft,
188
+ };
189
+ }
190
+ return {
191
+ id: story.id,
192
+ uuid: story.uuid,
193
+ name: story.name,
194
+ fullSlug: story.full_slug,
195
+ languages: languagesByCode,
196
+ cleanPublishedLanguages: Object.entries(languagesByCode)
197
+ .filter(([, value]) => value.state === "published_clean")
198
+ .map(([language]) => language),
199
+ };
200
+ });
201
+ const result = {
202
+ generatedAt: new Date().toISOString(),
203
+ source: {
204
+ spaceId: args.from,
205
+ startsWith: args.startsWith || null,
206
+ withSlug: args.withSlug || null,
207
+ },
208
+ languages: uniqueLanguages,
209
+ storyCount: storyEntries.length,
210
+ statesByLanguage: uniqueLanguages.reduce((acc, language) => {
211
+ acc[language] = storyEntries.reduce((stateAcc, story) => {
212
+ const state = story.languages[language]?.state || "missing";
213
+ stateAcc[state] = (stateAcc[state] || 0) + 1;
214
+ return stateAcc;
215
+ }, {});
216
+ return acc;
217
+ }, {}),
218
+ stories: Object.fromEntries(storyEntries.map((story) => [story.fullSlug, story])),
219
+ };
220
+ await createAndSaveToFile({
221
+ ext: "json",
222
+ datestamp: !args.fileName && !args.outputPath,
223
+ filename: args.fileName || `${args.from}---language-publish-state-map`,
224
+ folder: "language-publish-state",
225
+ path: args.outputPath,
226
+ res: result,
227
+ }, config);
228
+ Logger.success(`Language publish-state map created for ${storyEntries.length} stories.`);
229
+ return result;
230
+ };
@@ -121,6 +121,31 @@ export const resolvePublishLanguageCodes = async (publishLanguages, config) => {
121
121
  ...spaceLanguageCodes,
122
122
  ]);
123
123
  };
124
+ const isPublishLanguageStateClean = (languagePublishStateMap, story, language) => {
125
+ const storyEntry = languagePublishStateMap?.stories?.[story.full_slug];
126
+ const languageState = storyEntry?.languages?.[language]?.state;
127
+ if (!languageState) {
128
+ return undefined;
129
+ }
130
+ return languageState === "published_clean";
131
+ };
132
+ const resolvePublishLanguagesForStory = ({ story, publishState, publishLanguages, languagePublishStateMap, }) => {
133
+ if (!languagePublishStateMap) {
134
+ return publishState && !publishState.shouldPublish
135
+ ? []
136
+ : publishLanguages;
137
+ }
138
+ return publishLanguages.filter((language) => {
139
+ if (language === DEFAULT_PUBLISH_LANGUAGE) {
140
+ return !publishState || publishState.shouldPublish;
141
+ }
142
+ const languageStateAllowsPublish = isPublishLanguageStateClean(languagePublishStateMap, story, language);
143
+ if (languageStateAllowsPublish !== undefined) {
144
+ return languageStateAllowsPublish;
145
+ }
146
+ return !publishState || publishState.shouldPublish;
147
+ });
148
+ };
124
149
  const resolveStoryblokErrorResponse = (err) => {
125
150
  if (typeof err?.response === "string" && err.response.trim().length > 0) {
126
151
  return err.response.trim();
@@ -348,33 +373,47 @@ export const updateStories = async (args, config) => {
348
373
  const publishState = shouldPreservePublishState
349
374
  ? resolveStoryPublishState(story)
350
375
  : undefined;
376
+ const storyPublishLanguages = publishLanguages
377
+ ? resolvePublishLanguagesForStory({
378
+ story,
379
+ publishState,
380
+ publishLanguages,
381
+ languagePublishStateMap: options.languagePublishStateMap,
382
+ })
383
+ : undefined;
351
384
  const shouldPublishStory = options.publish &&
352
- (!publishState || publishState.shouldPublish);
385
+ (!publishState || publishState.shouldPublish) &&
386
+ (!shouldPublishLanguages ||
387
+ storyPublishLanguages?.includes(DEFAULT_PUBLISH_LANGUAGE));
353
388
  const updateResult = await updateStory(story, story.id, {
354
389
  publish: shouldPublishStory && !shouldPublishLanguages,
355
390
  force_update: options.force_update,
356
391
  }, { ...config, spaceId });
357
392
  if (options.publish &&
358
393
  updateResult?.ok &&
359
- isSkippedStoryPublishState(publishState)) {
394
+ isSkippedStoryPublishState(publishState) &&
395
+ (!storyPublishLanguages || storyPublishLanguages.length === 0)) {
360
396
  return withSkippedPublish({
361
397
  updateResult,
362
398
  story,
363
399
  storyId: story.id,
364
400
  spaceId,
365
- publishLanguages,
401
+ publishLanguages: options.languagePublishStateMap
402
+ ? storyPublishLanguages
403
+ : publishLanguages,
366
404
  publishState,
367
405
  });
368
406
  }
369
407
  if (!shouldPublishLanguages ||
370
- !publishLanguages ||
408
+ !storyPublishLanguages ||
409
+ storyPublishLanguages.length === 0 ||
371
410
  !updateResult?.ok) {
372
411
  return updateResult;
373
412
  }
374
413
  return publishStoryLanguages({
375
414
  storyId: story.id,
376
415
  story,
377
- languages: publishLanguages,
416
+ languages: storyPublishLanguages,
378
417
  }, { ...config, spaceId });
379
418
  }));
380
419
  };
@@ -8,8 +8,19 @@ interface ModifyStoryOptions {
8
8
  force_update?: boolean;
9
9
  publishLanguages?: PublishLanguagesOption;
10
10
  preservePublishState?: boolean;
11
+ languagePublishStateMap?: LanguagePublishStateMap;
11
12
  }
12
13
  export type PublishLanguagesOption = "default" | "all" | string[];
14
+ export interface LanguagePublishStateMap {
15
+ stories?: Record<string, {
16
+ languages?: Record<string, {
17
+ state?: string;
18
+ [key: string]: unknown;
19
+ }>;
20
+ cleanPublishedLanguages?: string[];
21
+ [key: string]: unknown;
22
+ }>;
23
+ }
13
24
  export type RemoveStory = (args: {
14
25
  storyId: string;
15
26
  }, config: RequestBaseConfig) => Promise<any>;
@@ -1,3 +1,4 @@
1
+ import * as stories from "./stories/index.js";
1
2
  export declare const testApi: {
2
3
  assets: {
3
4
  getAllAssets: import("./assets/assets.types.js").GetAllAssets;
@@ -77,6 +78,19 @@ export declare const testApi: {
77
78
  removeAllStories: import("./stories/stories.types.js").RemoveAllStories;
78
79
  upsertStory: import("./stories/stories.types.js").UpsertStory;
79
80
  backupStories: import("./stories/stories.types.js").BackupStories;
81
+ buildLanguagePublishStateMap: (args: import("./stories/language-publish-state.js").BuildLanguagePublishStateMapArgs, config: import("./utils/request.js").RequestBaseConfig) => Promise<{
82
+ generatedAt: string;
83
+ source: {
84
+ spaceId: string;
85
+ startsWith: string | null;
86
+ withSlug: string[] | null;
87
+ };
88
+ languages: string[];
89
+ storyCount: number;
90
+ statesByLanguage: Record<string, any>;
91
+ stories: any;
92
+ }>;
93
+ loadLanguagePublishStateMap: (languagePublishStatePath: string) => stories.LanguagePublishStateMap;
80
94
  };
81
95
  spaces: {
82
96
  getAllSpaces: import("./spaces/spaces.types.js").GetAllSpaces;
@@ -1,7 +1,8 @@
1
- export declare const mainDescription = "\n USAGE\n $ sb-mig [command]\n \n COMMANDS\n sync Synchronize components, datasources, roles, stories, assets with Storyblok space.\n discover Discover components, migration configs and write to file or stdout.\n backup Command for backing up anything related to Storyblok\n migrate Migrate content from space to space, or from file to space.\n debug Output extra debugging information\n help This screen\n \n Examples\n $ sb-mig sync components --all\n $ sb-mig debug \n";
1
+ export declare const mainDescription = "\n USAGE\n $ sb-mig [command]\n \n COMMANDS\n sync Synchronize components, datasources, roles, stories, assets with Storyblok space.\n discover Discover components, migration configs and write to file or stdout.\n backup Command for backing up anything related to Storyblok\n migrate Migrate content from space to space, or from file to space.\n language-publish-state\n Build a read-only Storyblok story language publish-state map.\n debug Output extra debugging information\n help This screen\n \n Examples\n $ sb-mig sync components --all\n $ sb-mig debug \n";
2
+ export declare const languagePublishStateDescription = "\n Usage\n $ sb-mig language-publish-state --from [spaceId]\n\n Description\n Read stories from a source Storyblok space and write a JSON map of default and translated language publication states.\n This command is read-only against Storyblok. It uses Management API for story listing and default-language state, and Delivery API for translated language published/draft comparisons.\n\n FLAGS\n --from - Source space ID to inspect\n --accessToken - Optional source space Delivery API access token override. Falls back to configured accessToken.\n --languages - Languages to inspect: all, default,fr,de. Default: all\n --withSlug - Exact story full_slug to inspect. Can be repeated.\n --startsWith - Filter stories by starts_with prefix\n --fileName - Stable output base name under sbmig/language-publish-state\n --outputPath - Explicit output path for the generated JSON file\n\n EXAMPLES\n $ sb-mig language-publish-state --from 12345 --startsWith about-ef --languages all --fileName about-ef-prod\n $ sb-mig language-publish-state --from 12345 --accessToken xxx --withSlug about-ef/testimonials --languages default,fr\n";
2
3
  export declare const syncDescription = "\n Usage\n $ sb-mig sync [components|roles|datasources|plugins|content] [space separated file names] or --all\n \n Description\n Synchronize components, roles, datasources, plugins, content with Storyblok space.\n \n COMMANDS\n components - sync components\n roles - sync roles\n datasources - sync datasources\n plugins - sync plugins\n content - sync content (stories, assets) - ! right now destructive, it will move content from 1 space to another, completelly overwriting it\n \n FLAGS\n --all - Sync all components, roles, datasources [components, roles, datasources]\n --presets - Pass it, if u want to sync also with presets (will take longer) [components only]\n --dry-run - Preview planned changes without making writes [components, roles, datasources, plugins, content]\n \n --yes - Skip ask for confirmation (dangerous, but useful in CI/CD) [content only]\n --from - Space ID from which you want to sync content [content only]\n --to - Space ID to which you want to sync content [content only]\n --syncDirection [fromSpaceToFile|fromFileToSpace|fromSpaceToSpace|fromAWStoSpace] \n - Sync direction (from, to) [content only]\n \n EXAMPLES\n $ sb-mig sync components --all\n $ sb-mig sync components --all --dry-run\n $ sb-mig sync components --all --presets\n $ sb-mig sync components accordion accordion-item\n $ sb-mig sync components accordion accordion-item --presets\n \n $ sb-mig sync roles --all\n $ sb-mig sync roles --all --dry-run\n \n $ sb-mig sync datasources --all\n $ sb-mig sync datasources --all --dry-run\n \n $ sb-mig sync plugins my-awesome-plugin - (you have to be in catalog which has ./dist/export.js file with compiled plugin)\n \n $ sb-mig sync content --all --from 12345 --to 12345\n $ sb-mig sync content --stories --from 12345 --to 12345\n $ sb-mig sync content --assets --from 12345 --to 12345\n";
3
4
  export declare const copyDescription = "\n Usage\n $ sb-mig copy\n \n Description\n Copy stuff\n \n COMMANDS\n ?\n \n FLAGS\n ?\n \n EXAMPLES\n $ sb-mig copy ?\n";
4
- export declare const migrateDescription = "\n Usage\n $ sb-mig migrate [content] [space separated file names] or --all --from [spaceId] --to [spaceId] --migration [migration-config-filename]\n $ sb-mig migrate content --all --migration migration-a --migration migration-b --migration migration-c\n \n Description\n Migrate content from space to space, or from file to space. It's potentially dangerous command, so it will ask for confirmation.\n Use with care.\n \n COMMANDS\n content - migrate content \n \n FLAGS\n --from - Space ID from which you want to migrate / or file name if passed '--migrate-from file'\n --fromFilePath - Direct path to stories JSON file when using '--migrate-from file'\n --to - Space ID to which you want to migrate\n --migrate-from - Migrate from (space, file) default: space\n --migration - File name of migration file (without extension). Can be repeated for ordered pipeline in content migration.\n --migrationComponentAlias - Add extra component aliases for a migration. Repeatable. Format: <migration>:<source>=<alias1>,<alias2>\n --migrationComponents - Override the exact component scope for a migration. Repeatable. Format: <migration>:<component1>,<component2>\n --withSlug - Filter stories by full slug (can be repeated)\n --startsWith - Filter stories by starts_with prefix\n --yes - Skip ask for confirmation (dangerous, but useful in CI/CD)\n --dry-run - Preview what would be migrated without making any API changes\n --publish - Publish changed stories after migration only if they were clean-published before migration. Default: save draft. [content only]\n --publishLanguages - Languages to publish when --publish is set. Values: default, all, or comma-separated Storyblok language codes. Skips stories that were draft-only or had unpublished changes before migration. [content only]\n --fileName - Stable base name for migration output files (disables timestamp suffix for migration artifacts)\n\n EXAMPLES\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration migration-a --migration migration-b --migration migration-c\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration colorPickerModeValues --migrationComponentAlias colorPickerModeValues:sb-button=sb-open-drift-button\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration colorPickerModeValues --migrationComponentAlias colorPickerModeValues:sb-section=sb-tour-page-section --migrationComponents colorPickerModeValues:sb-section,sb-tour-page-section\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --withSlug blog/home --withSlug docs/getting-started\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --startsWith blog/\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --yes\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages all --yes\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages default,fr,de --yes\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration v3toV4AllMigrations --dry-run --fileName brand-hub-v3-v4-run\n $ sb-mig migrate content --all --migrate-from file --from file-with-stories --to 12345 --migration file-with-migration\n $ sb-mig migrate content --all --migrate-from file --fromFilePath sbmig/migrations/dry-run--123---story-to-migrate__2026-2-9_20-51.json --to 12345 --migration migration-a --migration migration-b\n $ sb-mig migrate content my-component-1 my-component-2 --from 12345 --to 12345 --migration file-with-migration\n $ sb-mig migrate content my-component-1 my-component-2 --migrate-from file --from file-with-stories --to 12345 --migration file-with-migration \n";
5
+ export declare const migrateDescription = "\n Usage\n $ sb-mig migrate [content] [space separated file names] or --all --from [spaceId] --to [spaceId] --migration [migration-config-filename]\n $ sb-mig migrate content --all --migration migration-a --migration migration-b --migration migration-c\n \n Description\n Migrate content from space to space, or from file to space. It's potentially dangerous command, so it will ask for confirmation.\n Use with care.\n \n COMMANDS\n content - migrate content \n \n FLAGS\n --from - Space ID from which you want to migrate / or file name if passed '--migrate-from file'\n --fromFilePath - Direct path to stories JSON file when using '--migrate-from file'\n --to - Space ID to which you want to migrate\n --migrate-from - Migrate from (space, file) default: space\n --migration - File name of migration file (without extension). Can be repeated for ordered pipeline in content migration.\n --migrationComponentAlias - Add extra component aliases for a migration. Repeatable. Format: <migration>:<source>=<alias1>,<alias2>\n --migrationComponents - Override the exact component scope for a migration. Repeatable. Format: <migration>:<component1>,<component2>\n --withSlug - Filter stories by full slug (can be repeated)\n --startsWith - Filter stories by starts_with prefix\n --yes - Skip ask for confirmation (dangerous, but useful in CI/CD)\n --dry-run - Preview what would be migrated without making any API changes\n --publish - Publish changed stories after migration only if they were clean-published before migration. Default: save draft. [content only]\n --publishLanguages - Languages to publish when --publish is set. Values: default, all, or comma-separated Storyblok language codes. Skips stories that were draft-only or had unpublished changes before migration. [content only]\n --languagePublishStatePath - JSON file generated by 'language-publish-state'. Matching non-default language entries override language publishing; missing entries fall back to normal publish behavior. [content only]\n --fileName - Stable base name for migration output files (disables timestamp suffix for migration artifacts)\n\n EXAMPLES\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration migration-a --migration migration-b --migration migration-c\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration colorPickerModeValues --migrationComponentAlias colorPickerModeValues:sb-button=sb-open-drift-button\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration colorPickerModeValues --migrationComponentAlias colorPickerModeValues:sb-section=sb-tour-page-section --migrationComponents colorPickerModeValues:sb-section,sb-tour-page-section\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --withSlug blog/home --withSlug docs/getting-started\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --startsWith blog/\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --yes\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages all --yes\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages all --languagePublishStatePath sbmig/language-publish-state/prod-language-state.json --yes\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages default,fr,de --yes\n $ sb-mig migrate content --all --from 12345 --to 12345 --migration v3toV4AllMigrations --dry-run --fileName brand-hub-v3-v4-run\n $ sb-mig migrate content --all --migrate-from file --from file-with-stories --to 12345 --migration file-with-migration\n $ sb-mig migrate content --all --migrate-from file --fromFilePath sbmig/migrations/dry-run--123---story-to-migrate__2026-2-9_20-51.json --to 12345 --migration migration-a --migration migration-b\n $ sb-mig migrate content my-component-1 my-component-2 --from 12345 --to 12345 --migration file-with-migration\n $ sb-mig migrate content my-component-1 my-component-2 --migrate-from file --from file-with-stories --to 12345 --migration file-with-migration \n";
5
6
  export declare const revertDescription = "\n Usage\n $ sb-mig revert [content] --migration\n \n Description\n Revert content migration\n \n COMMANDS\n content - revert content migration \n \n FLAGS\n --migration - ???\n --yes - Skip ask for confirmation (dangerous, but useful in CI/CD) \n \n EXAMPLES\n $ sb-mig revert content --migration \n";
6
7
  export declare const discoverDescription = "\n Usage\n $ sb-mig discover [components|migrations] --all --write\n\n Description\n Discover all components or migration configs and write to file or stdout\n\n COMMANDS\n components - discover components\n migrations - discover migration config files\n\n FLAGS\n --all - Discover all components or migration configs\n --write - Write to file\n\n EXAMPLES\n $ sb-mig discover components --all\n $ sb-mig discover components --all --write\n $ sb-mig discover migrations --all\n";
7
8
  export declare const migrationsDescription = "\n Usage\n $ sb-mig migrations recognize\n \n Description\n Recognize migrations you need to apply\n \n COMMANDS\n recognize - recognize migrations\n \n FLAGS \n \n EXAMPLES\n $ sb-mig migrations recognize\n\n";
@@ -7,6 +7,8 @@ export const mainDescription = `
7
7
  discover Discover components, migration configs and write to file or stdout.
8
8
  backup Command for backing up anything related to Storyblok
9
9
  migrate Migrate content from space to space, or from file to space.
10
+ language-publish-state
11
+ Build a read-only Storyblok story language publish-state map.
10
12
  debug Output extra debugging information
11
13
  help This screen
12
14
 
@@ -14,6 +16,27 @@ export const mainDescription = `
14
16
  $ sb-mig sync components --all
15
17
  $ sb-mig debug
16
18
  `;
19
+ export const languagePublishStateDescription = `
20
+ Usage
21
+ $ sb-mig language-publish-state --from [spaceId]
22
+
23
+ Description
24
+ Read stories from a source Storyblok space and write a JSON map of default and translated language publication states.
25
+ This command is read-only against Storyblok. It uses Management API for story listing and default-language state, and Delivery API for translated language published/draft comparisons.
26
+
27
+ FLAGS
28
+ --from - Source space ID to inspect
29
+ --accessToken - Optional source space Delivery API access token override. Falls back to configured accessToken.
30
+ --languages - Languages to inspect: all, default,fr,de. Default: all
31
+ --withSlug - Exact story full_slug to inspect. Can be repeated.
32
+ --startsWith - Filter stories by starts_with prefix
33
+ --fileName - Stable output base name under sbmig/language-publish-state
34
+ --outputPath - Explicit output path for the generated JSON file
35
+
36
+ EXAMPLES
37
+ $ sb-mig language-publish-state --from 12345 --startsWith about-ef --languages all --fileName about-ef-prod
38
+ $ sb-mig language-publish-state --from 12345 --accessToken xxx --withSlug about-ef/testimonials --languages default,fr
39
+ `;
17
40
  export const syncDescription = `
18
41
  Usage
19
42
  $ sb-mig sync [components|roles|datasources|plugins|content] [space separated file names] or --all
@@ -100,6 +123,7 @@ export const migrateDescription = `
100
123
  --dry-run - Preview what would be migrated without making any API changes
101
124
  --publish - Publish changed stories after migration only if they were clean-published before migration. Default: save draft. [content only]
102
125
  --publishLanguages - Languages to publish when --publish is set. Values: default, all, or comma-separated Storyblok language codes. Skips stories that were draft-only or had unpublished changes before migration. [content only]
126
+ --languagePublishStatePath - JSON file generated by 'language-publish-state'. Matching non-default language entries override language publishing; missing entries fall back to normal publish behavior. [content only]
103
127
  --fileName - Stable base name for migration output files (disables timestamp suffix for migration artifacts)
104
128
 
105
129
  EXAMPLES
@@ -111,6 +135,7 @@ export const migrateDescription = `
111
135
  $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --startsWith blog/
112
136
  $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --yes
113
137
  $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages all --yes
138
+ $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages all --languagePublishStatePath sbmig/language-publish-state/prod-language-state.json --yes
114
139
  $ sb-mig migrate content --all --from 12345 --to 12345 --migration file-with-migration --publish --publishLanguages default,fr,de --yes
115
140
  $ sb-mig migrate content --all --from 12345 --to 12345 --migration v3toV4AllMigrations --dry-run --fileName brand-hub-v3-v4-run
116
141
  $ sb-mig migrate content --all --migrate-from file --from file-with-stories --to 12345 --migration file-with-migration
@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid";
4
4
  import { managementApi } from "../../api/managementApi.js";
5
5
  import { storyblokApiMapping } from "../../config/constants.js";
6
6
  import Logger from "../../utils/logger.js";
7
+ import { buildUrl } from "../../utils/url-utils.js";
7
8
  import { apiConfig } from "../api-config.js";
8
9
  const INIT_COMMANDS = {
9
10
  project: "project",
@@ -59,10 +60,18 @@ export const init = async (props) => {
59
60
  console.log(e);
60
61
  }
61
62
  try {
63
+ const previewDomainUrl = buildUrl({
64
+ baseUrl: "https://localhost:3000",
65
+ pathname: "api/preview/preview",
66
+ searchParams: {
67
+ secret: STORYBLOK_PREVIEW_SECRET,
68
+ slug: "",
69
+ },
70
+ });
62
71
  await managementApi.spaces.updateSpace({
63
72
  spaceId,
64
73
  params: {
65
- domain: `https://localhost:3000/api/preview/preview?secret=${STORYBLOK_PREVIEW_SECRET}&slug=`,
74
+ domain: previewDomainUrl.toString(),
66
75
  },
67
76
  }, { ...apiConfig, sbApi: localSbApi });
68
77
  Logger.success("Successfully updated space domain");
@@ -0,0 +1,2 @@
1
+ import type { CLIOptions } from "../../utils/interfaces.js";
2
+ export declare const languagePublishState: ({ flags }: CLIOptions) => Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { managementApi } from "../../api/managementApi.js";
2
+ import { apiConfig } from "../api-config.js";
3
+ export const languagePublishState = async ({ flags }) => {
4
+ const withSlugFlag = flags["withSlug"];
5
+ const withSlug = Array.isArray(withSlugFlag)
6
+ ? withSlugFlag
7
+ : withSlugFlag
8
+ ? [withSlugFlag]
9
+ : undefined;
10
+ await managementApi.stories.buildLanguagePublishStateMap({
11
+ from: flags["from"],
12
+ accessToken: flags["accessToken"],
13
+ languages: flags["languages"],
14
+ startsWith: flags["startsWith"],
15
+ withSlug,
16
+ fileName: flags["fileName"],
17
+ outputPath: flags["outputPath"],
18
+ }, apiConfig);
19
+ };
@@ -44,6 +44,7 @@ export const migrate = async (props) => {
44
44
  "dryRun",
45
45
  "publish",
46
46
  "publishLanguages",
47
+ "languagePublishStatePath",
47
48
  "fileName",
48
49
  ]);
49
50
  Logger.warning(`This feature is in BETA. Use it at your own risk. The API might change in the future. (Probably in a standard Prisma like migration way)`);
@@ -65,6 +66,7 @@ export const migrate = async (props) => {
65
66
  const dryRun = flags["dryRun"];
66
67
  const publish = Boolean(flags["publish"]);
67
68
  const publishLanguagesFlag = flags["publishLanguages"];
69
+ const languagePublishStatePath = flags["languagePublishStatePath"];
68
70
  const publishLanguages = publishLanguagesFlag
69
71
  ? parsePublishLanguagesOption(publishLanguagesFlag)
70
72
  : undefined;
@@ -82,6 +84,10 @@ export const migrate = async (props) => {
82
84
  if (!publish && publishLanguagesFlag) {
83
85
  throw new Error("--publishLanguages requires --publish for 'migrate content'.");
84
86
  }
87
+ if (languagePublishStatePath &&
88
+ (!publish || !publishLanguagesFlag)) {
89
+ throw new Error("--languagePublishStatePath requires --publish and --publishLanguages for 'migrate content'.");
90
+ }
85
91
  if (isIt("empty")) {
86
92
  const componentsToMigrate = unpackElements(input) || [""];
87
93
  const migrateFrom = "space";
@@ -112,6 +118,7 @@ export const migrate = async (props) => {
112
118
  publishLanguages,
113
119
  fromFilePath,
114
120
  fileName,
121
+ languagePublishStatePath,
115
122
  }, apiConfig);
116
123
  };
117
124
  if (dryRun) {
@@ -141,6 +148,7 @@ export const migrate = async (props) => {
141
148
  publishLanguages,
142
149
  fromFilePath,
143
150
  fileName,
151
+ languagePublishStatePath,
144
152
  }, apiConfig);
145
153
  };
146
154
  if (dryRun) {
@@ -175,6 +183,7 @@ export const migrate = async (props) => {
175
183
  const to = getTo(flags, apiConfig);
176
184
  const publish = Boolean(flags["publish"]);
177
185
  const publishLanguagesFlag = flags["publishLanguages"];
186
+ const languagePublishStatePath = flags["languagePublishStatePath"];
178
187
  if (migrationConfigs.length === 0) {
179
188
  throw new Error("Missing migration config. Pass exactly one --migration value for presets.");
180
189
  }
@@ -184,6 +193,9 @@ export const migrate = async (props) => {
184
193
  if (publishLanguagesFlag) {
185
194
  throw new Error("--publishLanguages is only supported for 'migrate content'. Presets cannot be published.");
186
195
  }
196
+ if (languagePublishStatePath) {
197
+ throw new Error("--languagePublishStatePath is only supported for 'migrate content'. Presets cannot be published.");
198
+ }
187
199
  if (migrationConfigs.length > 1) {
188
200
  throw new Error("Multiple --migration values are currently supported only for 'migrate content'. Presets support a single migration config.");
189
201
  }
package/dist/cli/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #! /usr/bin/env node
2
2
  import meow from "meow";
3
- import { backupDescription, debugDescription, mainDescription, syncDescription, removeDescription, initDescription, discoverDescription, migrateDescription, revertDescription, migrationsDescription, copyDescription, } from "./cli-descriptions.js";
3
+ import { backupDescription, debugDescription, mainDescription, syncDescription, removeDescription, initDescription, discoverDescription, migrateDescription, languagePublishStateDescription, revertDescription, migrationsDescription, copyDescription, } from "./cli-descriptions.js";
4
4
  import { backup } from "./commands/backup.js";
5
5
  import { copyCommand } from "./commands/copy.js";
6
6
  import { debug } from "./commands/debug.js";
7
7
  import { discover } from "./commands/discover.js";
8
8
  import { init } from "./commands/init.js";
9
+ import { languagePublishState } from "./commands/language-publish-state.js";
9
10
  import { migrate } from "./commands/migrate.js";
10
11
  import { migrations } from "./commands/migrations.js";
11
12
  import { remove } from "./commands/remove.js";
@@ -87,6 +88,9 @@ app.migrate = () => ({
87
88
  publishLanguages: {
88
89
  type: "string",
89
90
  },
91
+ languagePublishStatePath: {
92
+ type: "string",
93
+ },
90
94
  fileName: {
91
95
  type: "string",
92
96
  },
@@ -96,6 +100,38 @@ app.migrate = () => ({
96
100
  migrate(cli);
97
101
  },
98
102
  });
103
+ app["language-publish-state"] = () => ({
104
+ cli: meow(languagePublishStateDescription, {
105
+ importMeta: import.meta,
106
+ booleanDefault: undefined,
107
+ flags: {
108
+ from: {
109
+ type: "string",
110
+ },
111
+ accessToken: {
112
+ type: "string",
113
+ },
114
+ languages: {
115
+ type: "string",
116
+ default: "all",
117
+ },
118
+ withSlug: {
119
+ type: "string",
120
+ isMultiple: true,
121
+ },
122
+ startsWith: {
123
+ type: "string",
124
+ },
125
+ fileName: {
126
+ type: "string",
127
+ },
128
+ outputPath: {
129
+ type: "string",
130
+ },
131
+ },
132
+ }),
133
+ action: (cli) => languagePublishState(cli),
134
+ });
99
135
  app.revert = () => ({
100
136
  cli: meow(revertDescription, {
101
137
  importMeta: import.meta,
@@ -43,7 +43,8 @@ export const defaultConfig = (pkg, path, env) => {
43
43
  oauthToken: env["STORYBLOK_OAUTH_TOKEN"] ?? "",
44
44
  openaiToken: env["OPENAI_API_KEY"] ?? "",
45
45
  spaceId: env["STORYBLOK_SPACE_ID"] ?? "",
46
- accessToken: env["GATSBY_STORYBLOK_ACCESS_TOKEN"] ||
46
+ accessToken: env["STORYBLOK_ACCESS_TOKEN"] ||
47
+ env["GATSBY_STORYBLOK_ACCESS_TOKEN"] ||
47
48
  env["NEXT_PUBLIC_STORYBLOK_ACCESS_TOKEN"] ||
48
49
  "",
49
50
  boilerplateSpaceId: "172677", // this is id of Content seed for nextjs boilerplate space
@@ -22,6 +22,10 @@ export const extractComponentName = (filePath) => {
22
22
  const lastElement = normalized.substring(normalized.lastIndexOf("/") + 1);
23
23
  return lastElement.replace(/\.ts$/, "");
24
24
  };
25
+ const getFileName = (filePath) => {
26
+ const normalized = filePath.replace(/\\/g, "/");
27
+ return normalized.substring(normalized.lastIndexOf("/") + 1);
28
+ };
25
29
  /**
26
30
  * Normalizes an array of directory segments for glob pattern usage.
27
31
  * Handles the glob.sync quirk where single segments don't need braces,
@@ -102,13 +106,13 @@ export const exactFilesPatterns = ({ mainDirectory, componentDirectories, fileNa
102
106
  export const compare = (request) => {
103
107
  const { local, external } = request;
104
108
  const splittedLocal = local.map((p) => ({
105
- name: path.basename(p),
109
+ name: getFileName(p),
106
110
  p,
107
111
  }));
108
112
  const localNames = new Set(splittedLocal.map((file) => file.name));
109
113
  const splittedExternal = external
110
114
  .map((p) => ({
111
- name: path.basename(p),
115
+ name: getFileName(p),
112
116
  p,
113
117
  }))
114
118
  .filter((file) => {
@@ -0,0 +1,8 @@
1
+ export type SearchParamValue = string | number | boolean | null | undefined;
2
+ type BuildUrlArgs = {
3
+ baseUrl: string;
4
+ pathname?: string;
5
+ searchParams?: Record<string, SearchParamValue>;
6
+ };
7
+ export declare const buildUrl: ({ baseUrl, pathname, searchParams, }: BuildUrlArgs) => URL;
8
+ export {};
@@ -0,0 +1,12 @@
1
+ const withTrailingSlash = (value) => value.endsWith("/") ? value : `${value}/`;
2
+ const normalizePathname = (pathname = "") => pathname.replace(/^\/+/, "");
3
+ export const buildUrl = ({ baseUrl, pathname, searchParams = {}, }) => {
4
+ const url = new URL(normalizePathname(pathname), withTrailingSlash(baseUrl));
5
+ for (const [key, value] of Object.entries(searchParams)) {
6
+ if (value === undefined || value === null) {
7
+ continue;
8
+ }
9
+ url.searchParams.set(key, String(value));
10
+ }
11
+ return url;
12
+ };
@@ -130,6 +130,31 @@ const resolvePublishLanguageCodes = async (publishLanguages, config) => {
130
130
  ]);
131
131
  };
132
132
  exports.resolvePublishLanguageCodes = resolvePublishLanguageCodes;
133
+ const isPublishLanguageStateClean = (languagePublishStateMap, story, language) => {
134
+ const storyEntry = languagePublishStateMap?.stories?.[story.full_slug];
135
+ const languageState = storyEntry?.languages?.[language]?.state;
136
+ if (!languageState) {
137
+ return undefined;
138
+ }
139
+ return languageState === "published_clean";
140
+ };
141
+ const resolvePublishLanguagesForStory = ({ story, publishState, publishLanguages, languagePublishStateMap, }) => {
142
+ if (!languagePublishStateMap) {
143
+ return publishState && !publishState.shouldPublish
144
+ ? []
145
+ : publishLanguages;
146
+ }
147
+ return publishLanguages.filter((language) => {
148
+ if (language === DEFAULT_PUBLISH_LANGUAGE) {
149
+ return !publishState || publishState.shouldPublish;
150
+ }
151
+ const languageStateAllowsPublish = isPublishLanguageStateClean(languagePublishStateMap, story, language);
152
+ if (languageStateAllowsPublish !== undefined) {
153
+ return languageStateAllowsPublish;
154
+ }
155
+ return !publishState || publishState.shouldPublish;
156
+ });
157
+ };
133
158
  const resolveStoryblokErrorResponse = (err) => {
134
159
  if (typeof err?.response === "string" && err.response.trim().length > 0) {
135
160
  return err.response.trim();
@@ -365,33 +390,47 @@ const updateStories = async (args, config) => {
365
390
  const publishState = shouldPreservePublishState
366
391
  ? (0, exports.resolveStoryPublishState)(story)
367
392
  : undefined;
393
+ const storyPublishLanguages = publishLanguages
394
+ ? resolvePublishLanguagesForStory({
395
+ story,
396
+ publishState,
397
+ publishLanguages,
398
+ languagePublishStateMap: options.languagePublishStateMap,
399
+ })
400
+ : undefined;
368
401
  const shouldPublishStory = options.publish &&
369
- (!publishState || publishState.shouldPublish);
402
+ (!publishState || publishState.shouldPublish) &&
403
+ (!shouldPublishLanguages ||
404
+ storyPublishLanguages?.includes(DEFAULT_PUBLISH_LANGUAGE));
370
405
  const updateResult = await (0, exports.updateStory)(story, story.id, {
371
406
  publish: shouldPublishStory && !shouldPublishLanguages,
372
407
  force_update: options.force_update,
373
408
  }, { ...config, spaceId });
374
409
  if (options.publish &&
375
410
  updateResult?.ok &&
376
- isSkippedStoryPublishState(publishState)) {
411
+ isSkippedStoryPublishState(publishState) &&
412
+ (!storyPublishLanguages || storyPublishLanguages.length === 0)) {
377
413
  return withSkippedPublish({
378
414
  updateResult,
379
415
  story,
380
416
  storyId: story.id,
381
417
  spaceId,
382
- publishLanguages,
418
+ publishLanguages: options.languagePublishStateMap
419
+ ? storyPublishLanguages
420
+ : publishLanguages,
383
421
  publishState,
384
422
  });
385
423
  }
386
424
  if (!shouldPublishLanguages ||
387
- !publishLanguages ||
425
+ !storyPublishLanguages ||
426
+ storyPublishLanguages.length === 0 ||
388
427
  !updateResult?.ok) {
389
428
  return updateResult;
390
429
  }
391
430
  return (0, exports.publishStoryLanguages)({
392
431
  storyId: story.id,
393
432
  story,
394
- languages: publishLanguages,
433
+ languages: storyPublishLanguages,
395
434
  }, { ...config, spaceId });
396
435
  }));
397
436
  };
@@ -29,6 +29,10 @@ const extractComponentName = (filePath) => {
29
29
  return lastElement.replace(/\.ts$/, "");
30
30
  };
31
31
  exports.extractComponentName = extractComponentName;
32
+ const getFileName = (filePath) => {
33
+ const normalized = filePath.replace(/\\/g, "/");
34
+ return normalized.substring(normalized.lastIndexOf("/") + 1);
35
+ };
32
36
  /**
33
37
  * Normalizes an array of directory segments for glob pattern usage.
34
38
  * Handles the glob.sync quirk where single segments don't need braces,
@@ -112,13 +116,13 @@ exports.exactFilesPatterns = exactFilesPatterns;
112
116
  const compare = (request) => {
113
117
  const { local, external } = request;
114
118
  const splittedLocal = local.map((p) => ({
115
- name: path_1.default.basename(p),
119
+ name: getFileName(p),
116
120
  p,
117
121
  }));
118
122
  const localNames = new Set(splittedLocal.map((file) => file.name));
119
123
  const splittedExternal = external
120
124
  .map((p) => ({
121
- name: path_1.default.basename(p),
125
+ name: getFileName(p),
122
126
  p,
123
127
  }))
124
128
  .filter((file) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sb-mig",
3
- "version": "6.0.0",
3
+ "version": "6.1.0-beta.1",
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",