@coraltravelcenter/b2c-landing-builder 2.6.2 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,7 @@ b2c-landing-vite check
22
22
  b2c-landing-vite dev
23
23
  b2c-landing-vite build
24
24
  b2c-landing-vite deploy
25
+ b2c-landing-vite deploy --dry-run
25
26
  b2c-landing-vite deploy --update
26
27
  b2c-landing-vite deploy --assets-only
27
28
  b2c-landing-vite block:add hero
@@ -40,6 +41,10 @@ b2c-landing-vite update
40
41
  - `deploy` при первом запуске предлагает выбрать application, layout и HTML-зону,
41
42
  создаёт CMS-страницу и размещает блоки в порядке `order.json`. При повторном
42
43
  запуске для опубликованной страницы создаётся checkout-версия.
44
+ - `deploy --dry-run` выполняет свежую локальную сборку, читает состояние CMS и
45
+ показывает точный план загрузки ассетов, создания версии и изменений виджетов.
46
+ Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
47
+ - `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
43
48
  - `deploy --update` обновляет сохранённую версию страницы без создания новой.
44
49
  - `block:add` создаёт файлы в выбранных форматах разметки и стилей.
45
50
  - `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@coraltravelcenter/b2c-landing-builder",
9
- "version": "2.6.2",
9
+ "version": "2.7.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clack/prompts": "1.7.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.6.2",
3
+ "version": "2.7.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
@@ -8,6 +8,7 @@ Commands:
8
8
  dev Start the Vite development server
9
9
  build Build CMS-ready HTML files
10
10
  deploy Build, sync assets, and deploy CMS widgets
11
+ deploy --dry-run Show the exact deploy plan without changing CMS
11
12
  deploy --update Update the current CMS version in place
12
13
  deploy --assets-only Build and sync assets with the B2C CDN
13
14
  check Validate project configuration
@@ -77,9 +78,11 @@ export async function runCli(args) {
77
78
  return;
78
79
  }
79
80
  if (command === "deploy") {
80
- const allowed = new Set(["--assets-only", "--update"]);
81
- if (rest.some((argument) => !allowed.has(argument)) || rest.length > 1) {
82
- throw new Error("Usage: b2c-landing-vite deploy [--assets-only | --update]");
81
+ const allowed = new Set(["--assets-only", "--update", "--dry-run"]);
82
+ const flags = new Set(rest);
83
+ if (rest.some((argument) => !allowed.has(argument)) || flags.size !== rest.length ||
84
+ (flags.has("--assets-only") && flags.has("--update"))) {
85
+ throw new Error("Usage: b2c-landing-vite deploy [--assets-only | --update] [--dry-run]");
83
86
  }
84
87
  const config = await loadConfig();
85
88
  validateProject(config);
@@ -87,7 +90,16 @@ export async function runCli(args) {
87
90
  await runCli(["build"]);
88
91
  const {getBackofficeToken} = await import("../deploy/auth.mjs");
89
92
  const token = await getBackofficeToken({brand: preset.id});
90
- const {deployAssetsOnly, deployProject} = await import("../deploy/deploy.mjs");
93
+ const {deployAssetsOnly, deployProject, planDeployment} = await import("../deploy/deploy.mjs");
94
+ if (flags.has("--dry-run")) {
95
+ return planDeployment({
96
+ config,
97
+ preset,
98
+ token,
99
+ update: flags.has("--update"),
100
+ assetsOnly: flags.has("--assets-only"),
101
+ });
102
+ }
91
103
  if (rest.includes("--assets-only")) return deployAssetsOnly({config, preset, token});
92
104
  return deployProject({config, preset, token, update: rest.includes("--update")});
93
105
  }
@@ -4,22 +4,57 @@ export function changedAssets(manifest, state) {
4
4
  return manifest.assets.filter((asset) => state.assets?.[asset.path]?.sha256 !== asset.sha256);
5
5
  }
6
6
 
7
+ function filesFromListing(listing) {
8
+ const files = Array.isArray(listing) ? listing : listing?.files || [];
9
+ return new Set(files.map((file) => typeof file === "string" ? path.posix.basename(file) : file.name));
10
+ }
11
+
12
+ export async function planAssetChanges({manifest, state, listFiles, remoteRoot = "/content"}) {
13
+ const pending = changedAssets(manifest, state);
14
+ if (!listFiles) {
15
+ return {upload: pending, skip: [], unchanged: manifest.assets.length - pending.length};
16
+ }
17
+ const directories = new Map();
18
+ const namesInDirectory = (directory) => {
19
+ if (!directories.has(directory)) {
20
+ directories.set(directory, Promise.resolve(listFiles(directory)).then(filesFromListing));
21
+ }
22
+ return directories.get(directory);
23
+ };
24
+ const upload = [];
25
+ const skip = [];
26
+ await Promise.all(pending.map(async (asset) => {
27
+ const remotePath = path.posix.join("/", remoteRoot, manifest.assetsPrefix, asset.path);
28
+ const names = await namesInDirectory(path.posix.dirname(remotePath));
29
+ (names.has(path.posix.basename(remotePath)) ? skip : upload).push(asset);
30
+ }));
31
+ const byPath = (left, right) => left.path.localeCompare(right.path);
32
+ return {
33
+ upload: upload.sort(byPath),
34
+ skip: skip.sort(byPath),
35
+ unchanged: manifest.assets.length - pending.length,
36
+ };
37
+ }
38
+
7
39
  export async function uploadChangedAssets({
8
40
  manifest,
9
41
  state,
10
42
  root = process.cwd(),
11
43
  uploadFile,
44
+ listFiles,
12
45
  remoteRoot = "/content",
13
46
  concurrency = 5,
14
47
  }) {
15
- const pending = changedAssets(manifest, state);
48
+ const plan = await planAssetChanges({manifest, state, listFiles, remoteRoot});
16
49
  const assets = {...state.assets};
17
50
  const uploaded = [];
51
+ const skipped = plan.skip.map((asset) => asset.path);
52
+ for (const asset of plan.skip) assets[asset.path] = {...asset, immutable: true};
18
53
  let cursor = 0;
19
54
 
20
55
  async function worker() {
21
- while (cursor < pending.length) {
22
- const asset = pending[cursor++];
56
+ while (cursor < plan.upload.length) {
57
+ const asset = plan.upload[cursor++];
23
58
  const localPath = path.join(root, ...(manifest.assetsDirectory || "public").split("/"), ...asset.path.split("/"));
24
59
  const remotePath = path.posix.join("/", remoteRoot, manifest.assetsPrefix, asset.path);
25
60
  const result = await uploadFile(remotePath, localPath);
@@ -28,11 +63,12 @@ export async function uploadChangedAssets({
28
63
  }
29
64
  }
30
65
 
31
- const workerCount = Math.min(Math.max(1, concurrency), pending.length || 1);
66
+ const workerCount = Math.min(Math.max(1, concurrency), plan.upload.length || 1);
32
67
  await Promise.all(Array.from({length: workerCount}, () => worker()));
33
68
  return {
34
69
  state: {...state, assets},
35
70
  uploaded: uploaded.sort(),
36
- unchanged: manifest.assets.length - pending.length,
71
+ skipped: skipped.sort(),
72
+ unchanged: plan.unchanged,
37
73
  };
38
74
  }
@@ -100,10 +100,17 @@ export class BackofficeClient {
100
100
  return this.request("PUT", "/Content/UpdateDocumentStatus", {body: {pageContentId, documentStatus}});
101
101
  }
102
102
 
103
+ async listFiles(remoteDirectory) {
104
+ const result = await this.request("GET", "/FileManagement/ListFiles", {
105
+ query: {path: remoteDirectory.replace(/^\/+/, "")},
106
+ });
107
+ return result?.result || result;
108
+ }
109
+
103
110
  async uploadFile(remotePath, localPath) {
104
111
  const parsed = path.posix.parse(remotePath);
105
112
  const form = new FormData();
106
- form.append("Body.OverWrite", "true");
113
+ form.append("Body.OverWrite", "false");
107
114
  form.append("Body.Path", parsed.dir.replace(/^\/+/, ""));
108
115
  form.append("Body.Files", new Blob([fs.readFileSync(localPath)]), parsed.base);
109
116
  const result = await this.request("POST", "/FileManagement/UploadFile", {form});
@@ -1,9 +1,68 @@
1
- import {uploadChangedAssets} from "./assets.mjs";
1
+ 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 {createPagePlacement, prepareExistingPage} from "./page.mjs";
6
- import {syncWidgets} from "./widgets.mjs";
5
+ import {createPagePlacement, prepareExistingPage, selectNewPagePlacement} from "./page.mjs";
6
+ import {planWidgetChanges, syncWidgets} from "./widgets.mjs";
7
+
8
+ function paths(items) {
9
+ return items.map((item) => item.path);
10
+ }
11
+
12
+ function logList(log, label, items) {
13
+ log(`[dry-run] ${label}: ${items.length ? items.join(", ") : "none"}`);
14
+ }
15
+
16
+ export async function planDeployment({
17
+ config,
18
+ preset,
19
+ root = process.cwd(),
20
+ token,
21
+ client,
22
+ update = false,
23
+ assetsOnly = false,
24
+ prompts,
25
+ log = console.log,
26
+ }) {
27
+ const manifest = readBuildManifest(root);
28
+ const api = client || new BackofficeClient({brand: preset.id, token});
29
+ const previous = readDeploymentState(preset.id, manifest.folder, root);
30
+ const assets = await planAssetChanges({
31
+ manifest,
32
+ state: previous,
33
+ remoteRoot: new URL(preset.assetsBase).pathname,
34
+ listFiles: api.listFiles?.bind(api),
35
+ });
36
+ log(`[dry-run] no CMS data will be changed`);
37
+ log(`[dry-run] target: ${preset.assetsBase}/${manifest.assetsPrefix}/`);
38
+ logList(log, "assets to upload", paths(assets.upload));
39
+ logList(log, "existing assets to skip", paths(assets.skip));
40
+ log(`[dry-run] unchanged assets: ${assets.unchanged}`);
41
+ if (assetsOnly) return {assets};
42
+
43
+ let placement;
44
+ let pageContent;
45
+ let page;
46
+ if (previous.placement) {
47
+ placement = previous.placement;
48
+ pageContent = await api.getContent(placement.pageContentId);
49
+ const checkout = !update && [1, 4].includes(pageContent.status);
50
+ page = {action: checkout ? "create checkout version" : "update current version", pageContentId: placement.pageContentId};
51
+ } else {
52
+ const selected = await selectNewPagePlacement({client: api, manifest, prompts});
53
+ placement = selected.placement;
54
+ pageContent = {pageContents: []};
55
+ page = {action: "create page", ...selected.draft};
56
+ }
57
+ const widgets = planWidgetChanges({manifest, placement, pageContent, root});
58
+ log(`[dry-run] page: ${page.action}${page.pageName ? ` “${page.pageName}”` : ""}`);
59
+ log(`[dry-run] area: ${placement.layoutAreaId}, insertion position: ${Math.max(0, placement.orderIndex) + 1}`);
60
+ logList(log, "widgets to add", widgets.add);
61
+ logList(log, "widgets to update", widgets.update);
62
+ logList(log, "widgets to remove", widgets.remove);
63
+ logList(log, "widgets to reorder", widgets.reorder.map((item) => `${item.title} (${item.from} → ${item.to})`));
64
+ return {assets, page, placement, widgets};
65
+ }
7
66
 
8
67
  export async function deployAssetsOnly({
9
68
  config,
@@ -30,9 +89,13 @@ export async function deployAssetsOnly({
30
89
  root,
31
90
  remoteRoot: new URL(preset.assetsBase).pathname,
32
91
  uploadFile: api.uploadFile.bind(api),
92
+ listFiles: api.listFiles?.bind(api),
33
93
  });
34
94
  writeDeploymentState(preset.id, manifest.folder, result.state, root);
35
- log(`[deploy] assets: uploaded=${result.uploaded.length}, unchanged=${result.unchanged}`);
95
+ log(`[deploy] assets: uploaded=${result.uploaded.length}, skipped=${result.skipped.length}, unchanged=${result.unchanged}`);
96
+ if (result.skipped.length) {
97
+ log(`[deploy] existing assets were not overwritten: ${result.skipped.join(", ")}. Rename changed files to publish new URLs.`);
98
+ }
36
99
  return {...result, target: String(cdnUrl)};
37
100
  }
38
101
 
@@ -56,6 +119,7 @@ export async function deployProject({
56
119
  root,
57
120
  remoteRoot: new URL(preset.assetsBase).pathname,
58
121
  uploadFile: api.uploadFile.bind(api),
122
+ listFiles: api.listFiles?.bind(api),
59
123
  });
60
124
  const page = previous.placement
61
125
  ? await prepareExistingPage({client: api, placement: previous.placement, update})
@@ -72,7 +136,10 @@ export async function deployProject({
72
136
  const preview = page.pageContent.uniqueId
73
137
  ? `https://${preset.domain}/preview/${page.pageContent.uniqueId}/`
74
138
  : null;
75
- log(`[deploy] assets: uploaded=${assets.uploaded.length}, unchanged=${assets.unchanged}`);
139
+ log(`[deploy] assets: uploaded=${assets.uploaded.length}, skipped=${assets.skipped.length}, unchanged=${assets.unchanged}`);
140
+ if (assets.skipped.length) {
141
+ log(`[deploy] existing assets were not overwritten: ${assets.skipped.join(", ")}. Rename changed files to publish new URLs.`);
142
+ }
76
143
  log(`[deploy] widgets: added=${widgets.added}, updated=${widgets.updated}, removed=${widgets.removed}`);
77
144
  if (preview) log(`[deploy] preview: ${preview}`);
78
145
  return {assets, widgets, placement: page.placement, preview};
@@ -37,7 +37,7 @@ async function defaultPrompts() {
37
37
  };
38
38
  }
39
39
 
40
- export async function createPagePlacement({client, manifest, prompts}) {
40
+ export async function selectNewPagePlacement({client, manifest, prompts}) {
41
41
  prompts ||= await defaultPrompts();
42
42
  const pageName = await prompts.text({
43
43
  message: "New CMS page name",
@@ -88,11 +88,24 @@ export async function createPagePlacement({client, manifest, prompts}) {
88
88
  value: index,
89
89
  })),
90
90
  });
91
- const created = await client.createPage({applicationId, layoutId, name: pageName.trim()});
91
+ return {
92
+ draft: {applicationId, layoutId, pageName: pageName.trim()},
93
+ placement: {pageContentId: null, layoutAreaId, orderIndex},
94
+ };
95
+ }
96
+
97
+ export async function createPagePlacement(options) {
98
+ const selected = await selectNewPagePlacement(options);
99
+ const {client} = options;
100
+ const created = await client.createPage({
101
+ applicationId: selected.draft.applicationId,
102
+ layoutId: selected.draft.layoutId,
103
+ name: selected.draft.pageName,
104
+ });
92
105
  if (!created?.pageContentId) throw new Error("Backoffice did not return pageContentId for the new page");
93
106
  return {
94
107
  pageContent: await client.getContent(created.pageContentId),
95
- placement: {pageContentId: created.pageContentId, layoutAreaId, orderIndex},
108
+ placement: {...selected.placement, pageContentId: created.pageContentId},
96
109
  };
97
110
  }
98
111
 
@@ -23,6 +23,24 @@ function widgetsForPlacement(pageContent, layoutAreaId, folder) {
23
23
  );
24
24
  }
25
25
 
26
+ export function planWidgetChanges({manifest, placement, pageContent, root = process.cwd()}) {
27
+ const bundles = readDeployBundles(manifest, root);
28
+ const existing = widgetsForPlacement(pageContent || {}, placement.layoutAreaId, manifest.folder);
29
+ const bundleTitles = new Set(bundles.map((bundle) => bundle.title));
30
+ return {
31
+ add: bundles.filter((bundle) => !existing.some((widget) => widget.cmsTitle === bundle.title))
32
+ .map((bundle) => bundle.title),
33
+ update: bundles.filter((bundle) => existing.some((widget) => widget.cmsTitle === bundle.title))
34
+ .map((bundle) => bundle.title),
35
+ reorder: bundles.flatMap((bundle, index) => {
36
+ const widget = existing.find((candidate) => candidate.cmsTitle === bundle.title);
37
+ const to = Math.max(0, placement.orderIndex) + 1 + index;
38
+ return widget && widget.order !== to ? [{title: bundle.title, from: widget.order, to}] : [];
39
+ }),
40
+ remove: existing.filter((widget) => !bundleTitles.has(widget.cmsTitle)).map((widget) => widget.cmsTitle),
41
+ };
42
+ }
43
+
26
44
  export async function syncWidgets({client, manifest, placement, pageContent, root = process.cwd()}) {
27
45
  const bundles = readDeployBundles(manifest, root);
28
46
  const existing = widgetsForPlacement(pageContent, placement.layoutAreaId, manifest.folder);