@coraltravelcenter/b2c-landing-builder 2.9.0 → 2.10.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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@coraltravelcenter/b2c-landing-builder",
9
- "version": "2.9.0",
9
+ "version": "2.10.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@babel/parser": "7.28.6",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "CLI and build toolkit for B2C landing projects",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.mjs CHANGED
@@ -12,6 +12,9 @@ Commands:
12
12
  deploy --update Update the current CMS version in place
13
13
  deploy --relink Attach the project to an existing CMS page
14
14
  deploy --publish Publish after a successful deploy
15
+ deploy --section <key> Deploy one section, preserving all other remote sections
16
+ deploy --force Explicitly overwrite remote changes in the current scope
17
+ deploy --remote Keep remote content when changes conflict
15
18
  deploy --assets-only --prune-assets
16
19
  Remove obsolete project assets recorded in deploy-state
17
20
  deploy --assets-only Build and sync assets with the B2C CDN
@@ -82,13 +85,17 @@ export async function runCli(args) {
82
85
  return;
83
86
  }
84
87
  if (command === "deploy") {
85
- const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink", "--publish", "--prune-assets"]);
86
- const flags = new Set(rest);
87
- if (rest.some((argument) => !allowed.has(argument)) || flags.size !== rest.length ||
88
+ const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink", "--publish", "--prune-assets", "--force", "--remote"]);
89
+ const flags = new Set(rest.filter((argument) => allowed.has(argument)));
90
+ const sectionIndex = rest.indexOf("--section");
91
+ const section = sectionIndex >= 0 ? rest[sectionIndex + 1] : null;
92
+ const consumed = new Set([...flags, ...(sectionIndex >= 0 ? ["--section", section] : [])]);
93
+ if (rest.some((argument) => !consumed.has(argument)) || (sectionIndex >= 0 && !section) ||
94
+ (flags.has("--force") && flags.has("--remote")) ||
88
95
  (flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink") || flags.has("--publish"))) ||
89
96
  (flags.has("--prune-assets") && !flags.has("--assets-only"))) {
90
97
  throw new Error(
91
- "Usage: b2c-landing-vite deploy [--assets-only | --update] [--relink] [--publish] [--prune-assets] [--dry-run]"
98
+ "Usage: b2c-landing-vite deploy [--section <key>] [--force | --remote] [--update] [--relink] [--publish] [--dry-run]"
92
99
  );
93
100
  }
94
101
  const config = await loadConfig();
@@ -126,6 +133,8 @@ export async function runCli(args) {
126
133
  relink: flags.has("--relink"),
127
134
  publish: flags.has("--publish"),
128
135
  pruneAssets: flags.has("--prune-assets"),
136
+ section,
137
+ strategy: flags.has("--force") ? "force" : flags.has("--remote") ? "remote" : "ask",
129
138
  });
130
139
  });
131
140
  }
@@ -2,6 +2,7 @@ import {planAssetChanges, uploadChangedAssets} from "./assets.mjs";
2
2
  import {BackofficeClient} from "./backoffice-client.mjs";
3
3
  import {readBuildManifest} from "./manifest.mjs";
4
4
  import {readDeploymentState, writeDeploymentState} from "./state.mjs";
5
+ import {resolveSections, summarizeChanges} from "./reconcile.mjs";
5
6
  import {
6
7
  createPagePlacement,
7
8
  prepareExistingPage,
@@ -12,7 +13,7 @@ import {
12
13
  selectNewPagePlacement,
13
14
  validateSavedPlacement,
14
15
  } from "./page.mjs";
15
- import {planWidgetChanges, syncWidgets} from "./widgets.mjs";
16
+ import {localSections, planWidgetChanges, remoteSections, syncWidgets} from "./widgets.mjs";
16
17
 
17
18
  function paths(items) {
18
19
  return items.map((item) => item.path);
@@ -139,6 +140,8 @@ export async function deployProject({
139
140
  pruneAssets = false,
140
141
  publish = false,
141
142
  prompts,
143
+ section,
144
+ strategy = "ask",
142
145
  log = console.log,
143
146
  }) {
144
147
  const manifest = readBuildManifest(root);
@@ -149,6 +152,7 @@ export async function deployProject({
149
152
  ? "[deploy] validating saved CMS placement..."
150
153
  : relink ? "[deploy] selecting an existing CMS page..." : "[deploy] a new CMS page will be created");
151
154
  let selectedPage = null;
155
+ let remoteBefore = null;
152
156
  if (relink) selectedPage = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
153
157
  else if (previous.placement) {
154
158
  selectedPage = {
@@ -156,6 +160,53 @@ export async function deployProject({
156
160
  pageContent: await validateSavedPlacement({client: api, placement: previous.placement}),
157
161
  };
158
162
  }
163
+ const availableSections = manifest.blocks.map((block) => block.path.replace(/\.html$/, ""));
164
+ if (section && !availableSections.includes(section)) {
165
+ throw new Error(`Section ${JSON.stringify(section)} not found. Available sections: ${availableSections.join(", ") || "none"}`);
166
+ }
167
+ let reconciliation = null;
168
+ if (selectedPage) {
169
+ log(`[deploy] scope: ${section ? `section ${JSON.stringify(section)}` : "page"}`);
170
+ const local = localSections(manifest, root);
171
+ const remote = remoteSections(selectedPage.pageContent, selectedPage.placement, manifest.folder);
172
+ remoteBefore = remote;
173
+ const base = previous.cms?.sections || null;
174
+ let chosen = strategy;
175
+ reconciliation = resolveSections({local, remote, base, scope: section ? [section] : undefined, strategy: chosen === "ask" ? "merge" : chosen});
176
+ if (reconciliation.changes.length) {
177
+ const summary = summarizeChanges(reconciliation.changes);
178
+ for (const [kind, ids] of Object.entries(summary)) if (ids.length) log(`[deploy] ${kind}: ${ids.join(", ")}`);
179
+ }
180
+ if (reconciliation.unsafe.length && chosen === "ask") {
181
+ prompts ||= await (async () => {
182
+ const {isCancel, select} = await import("@clack/prompts");
183
+ return {select: async (options) => {
184
+ const value = await select(options);
185
+ if (isCancel(value)) throw new Error("Deploy cancelled");
186
+ return value;
187
+ }};
188
+ })();
189
+ chosen = await prompts.select({
190
+ message: "Remote CMS changes detected",
191
+ options: [
192
+ {label: "Keep remote changes", value: "remote"},
193
+ {label: `Overwrite ${section ? "this section" : "the page"} with local changes`, value: "force"},
194
+ {label: "Cancel deploy", value: "cancel"},
195
+ ],
196
+ });
197
+ if (chosen === "cancel") throw new Error("Deploy cancelled; CMS was not modified");
198
+ reconciliation = resolveSections({local, remote, base, scope: section ? [section] : undefined, strategy: chosen});
199
+ } else if (reconciliation.unsafe.length) {
200
+ throw new Error(`Deploy stopped: remote changes require --force or --remote (${reconciliation.unsafe.map((item) => item.id).join(", ")})`);
201
+ }
202
+ }
203
+ // No remote mutation happens until the page state and the user's resolution are known.
204
+ if (selectedPage) {
205
+ const verified = await validateSavedPlacement({client: api, placement: selectedPage.placement});
206
+ if (pageRevision(verified) !== pageRevision(selectedPage.pageContent)) {
207
+ throw new Error("CMS page changed during deploy. Nothing was modified; run deploy again.");
208
+ }
209
+ }
159
210
  log("[deploy] synchronizing CDN assets...");
160
211
  const assets = await uploadChangedAssets({
161
212
  manifest,
@@ -180,11 +231,18 @@ export async function deployProject({
180
231
  placement: page.placement,
181
232
  pageContent: page.pageContent,
182
233
  root,
234
+ sectionIds: reconciliation ? [...new Set([
235
+ ...Object.keys(remoteBefore || {}),
236
+ ...Object.keys(reconciliation.sections || {}),
237
+ ])].filter((id) => reconciliation.sections?.[id] !== remoteBefore?.[id]) : null,
183
238
  });
184
239
  const state = {
185
240
  ...assets.state,
186
241
  placement: page.placement,
187
- cms: {verifiedAt: new Date().toISOString()},
242
+ cms: {
243
+ verifiedAt: new Date().toISOString(),
244
+ sections: remoteSections(await api.getContent(page.placement.pageContentId), page.placement, manifest.folder),
245
+ },
188
246
  };
189
247
  let published = false;
190
248
  let finalPageContent = await api.getContent(page.placement.pageContentId);
@@ -255,6 +255,7 @@ export function pageRevision(pageContent) {
255
255
  area: widget.layoutAreaId,
256
256
  order: widget.order,
257
257
  title: widget.cmsTitle,
258
+ content: widget.data?.content ?? widget.content ?? null,
258
259
  })),
259
260
  });
260
261
  }
@@ -0,0 +1,47 @@
1
+ export function compareSections({local, remote, base}) {
2
+ const ids = [...new Set([...Object.keys(local), ...Object.keys(remote), ...Object.keys(base || {})])].sort();
3
+ const changes = [];
4
+ for (const id of ids) {
5
+ const localValue = local[id];
6
+ const remoteValue = remote[id];
7
+ const baseValue = base?.[id];
8
+ if (localValue === remoteValue) continue;
9
+ if (!base) {
10
+ changes.push({id, kind: "unknown", local: localValue, remote: remoteValue});
11
+ } else if (remoteValue === baseValue) {
12
+ changes.push({id, kind: "local", local: localValue, remote: remoteValue});
13
+ } else if (localValue === baseValue) {
14
+ changes.push({id, kind: "remote", local: localValue, remote: remoteValue});
15
+ } else {
16
+ changes.push({id, kind: "conflict", local: localValue, remote: remoteValue});
17
+ }
18
+ }
19
+ return changes;
20
+ }
21
+
22
+ export function summarizeChanges(changes) {
23
+ return Object.fromEntries(["local", "remote", "conflict", "unknown"].map((kind) => [
24
+ kind,
25
+ changes.filter((change) => change.kind === kind).map((change) => change.id),
26
+ ]));
27
+ }
28
+
29
+ export function resolveSections({local, remote, base, scope, strategy = "merge"}) {
30
+ const selected = scope?.length ? new Set(scope) : new Set(Object.keys(local));
31
+ const scopedLocal = Object.fromEntries(Object.entries(local).filter(([id]) => selected.has(id)));
32
+ const scopedRemote = Object.fromEntries(Object.entries(remote).filter(([id]) => selected.has(id)));
33
+ const scopedBase = base && Object.fromEntries(Object.entries(base).filter(([id]) => selected.has(id)));
34
+ const changes = compareSections({local: scopedLocal, remote: scopedRemote, base: scopedBase});
35
+ const unsafe = changes.filter((change) => change.kind === "conflict" || change.kind === "unknown" || change.kind === "remote");
36
+ if (strategy === "merge" && unsafe.length) return {changes, unsafe, sections: null};
37
+ const sections = {...remote};
38
+ for (const id of selected) {
39
+ const change = changes.find((item) => item.id === id);
40
+ if (strategy === "remote" && change) continue;
41
+ if (strategy === "force" || !change || change.kind === "local") {
42
+ if (local[id] === undefined) delete sections[id];
43
+ else sections[id] = local[id];
44
+ }
45
+ }
46
+ return {changes, unsafe: [], sections};
47
+ }
@@ -14,6 +14,12 @@ export function readDeployBundles(manifest, root = process.cwd()) {
14
14
  }));
15
15
  }
16
16
 
17
+ export function localSections(manifest, root = process.cwd()) {
18
+ return Object.fromEntries(readDeployBundles(manifest, root).map((bundle) => [
19
+ bundle.title.slice(bundle.title.lastIndexOf(" | ") + 3), bundle.source,
20
+ ]));
21
+ }
22
+
17
23
  function widgetsForPlacement(pageContent, layoutAreaId, folder) {
18
24
  const widgets = pageContent.pageContents
19
25
  ?.find((content) => String(content.languageId) === RU_LANGUAGE_ID)?.widgets || [];
@@ -23,6 +29,12 @@ function widgetsForPlacement(pageContent, layoutAreaId, folder) {
23
29
  );
24
30
  }
25
31
 
32
+ export function remoteSections(pageContent, placement, folder) {
33
+ return Object.fromEntries(widgetsForPlacement(pageContent || {}, placement.layoutAreaId, folder).map((widget) => [
34
+ widget.cmsTitle.slice(widget.cmsTitle.lastIndexOf(" | ") + 3), widget.data?.content ?? widget.content ?? "",
35
+ ]));
36
+ }
37
+
26
38
  export function planWidgetChanges({manifest, placement, pageContent, root = process.cwd()}) {
27
39
  const bundles = readDeployBundles(manifest, root);
28
40
  const existing = widgetsForPlacement(pageContent || {}, placement.layoutAreaId, manifest.folder);
@@ -41,15 +53,20 @@ export function planWidgetChanges({manifest, placement, pageContent, root = proc
41
53
  };
42
54
  }
43
55
 
44
- export async function syncWidgets({client, manifest, placement, pageContent, root = process.cwd()}) {
56
+ export async function syncWidgets({client, manifest, placement, pageContent, root = process.cwd(), sectionIds = null}) {
45
57
  const bundles = readDeployBundles(manifest, root);
58
+ const selected = sectionIds && new Set(sectionIds);
59
+ const scopedBundles = selected
60
+ ? bundles.filter((bundle) => selected.has(bundle.title.slice(bundle.title.lastIndexOf(" | ") + 3)))
61
+ : bundles;
46
62
  const existing = widgetsForPlacement(pageContent, placement.layoutAreaId, manifest.folder);
47
63
  const activeIds = new Set();
48
64
  let added = 0;
49
65
  let updated = 0;
50
66
  let reordered = 0;
51
67
 
52
- for (const [index, bundle] of bundles.entries()) {
68
+ for (const bundle of scopedBundles) {
69
+ const index = bundles.indexOf(bundle);
53
70
  let widget = existing.find((candidate) => candidate.cmsTitle === bundle.title);
54
71
  if (widget) {
55
72
  await client.updateWidget(bundle.title, widget.contentWidgetId, bundle.source);
@@ -77,6 +94,8 @@ export async function syncWidgets({client, manifest, placement, pageContent, roo
77
94
 
78
95
  let removed = 0;
79
96
  for (const widget of existing) {
97
+ const id = widget.cmsTitle.slice(widget.cmsTitle.lastIndexOf(" | ") + 3);
98
+ if (selected && !selected.has(id)) continue;
80
99
  if (activeIds.has(widget.contentWidgetId)) continue;
81
100
  await client.removeWidget({
82
101
  contentWidgetId: widget.contentWidgetId,