@coraltravelcenter/b2c-landing-builder 2.8.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.
package/README.md CHANGED
@@ -25,7 +25,9 @@ b2c-landing-vite deploy
25
25
  b2c-landing-vite deploy --dry-run
26
26
  b2c-landing-vite deploy --update
27
27
  b2c-landing-vite deploy --relink
28
+ b2c-landing-vite deploy --publish
28
29
  b2c-landing-vite deploy --assets-only
30
+ b2c-landing-vite deploy --assets-only --prune-assets
29
31
  b2c-landing-vite block:add hero
30
32
  b2c-landing-vite block:rename hero main-hero
31
33
  b2c-landing-vite update
@@ -56,6 +58,20 @@ b2c-landing-vite update
56
58
  опубликованной версии; вместе с `--update` выбранная версия меняется на месте.
57
59
  - `deploy --relink --dry-run` проходит тот же интерактивный выбор и показывает
58
60
  план, но не сохраняет новую привязку и не изменяет CMS.
61
+ - `deploy --publish` после успешной синхронизации виджетов публикует выбранную
62
+ версию. Публикация блокируется, если в Backoffice не заполнены title, URL,
63
+ SEO title или SEO description; builder не придумывает метаданные сам.
64
+ - `deploy --assets-only --prune-assets` удаляет из CMS только отсутствующие в
65
+ текущей сборке файлы, которые этот builder сам загрузил и записал как
66
+ управляемые в deploy-state проекта. Уже существовавшие в CMS файлы не
67
+ считаются управляемыми и не удаляются. Команда
68
+ намеренно отделена от обычного deploy и никогда не удаляет неизвестные файлы.
69
+ Сначала рекомендуется запустить её с `--dry-run`.
70
+ - Перед любым deploy сохранённые pageContentId и layoutAreaId сверяются с CMS.
71
+ Устаревшая привязка останавливает операцию до загрузки файлов и предлагает
72
+ `deploy --relink`.
73
+ - Одновременные deploy одного проекта на компьютере блокируются lock-файлом в
74
+ `.b2c/deploy/`; аварийно оставленный lock автоматически признаётся устаревшим.
59
75
  - `block:add` создаёт файлы в выбранных форматах разметки и стилей.
60
76
  - `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
61
77
  - `update` проверяет npm registry и показывает команду ручного обновления.
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.8.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.8.0",
9
+ "version": "2.10.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
+ "@babel/parser": "7.28.6",
12
13
  "@clack/prompts": "1.7.0",
13
14
  "sharp": "0.35.3",
14
15
  "typograf": "7.7.0",
@@ -32,7 +33,6 @@
32
33
  "version": "7.29.7",
33
34
  "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
34
35
  "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
35
- "dev": true,
36
36
  "license": "MIT",
37
37
  "engines": {
38
38
  "node": ">=6.9.0"
@@ -42,20 +42,18 @@
42
42
  "version": "7.29.7",
43
43
  "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
44
44
  "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
45
- "dev": true,
46
45
  "license": "MIT",
47
46
  "engines": {
48
47
  "node": ">=6.9.0"
49
48
  }
50
49
  },
51
50
  "node_modules/@babel/parser": {
52
- "version": "7.29.8",
53
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
54
- "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
55
- "dev": true,
51
+ "version": "7.28.6",
52
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
53
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
56
54
  "license": "MIT",
57
55
  "dependencies": {
58
- "@babel/types": "^7.29.8"
56
+ "@babel/types": "^7.28.6"
59
57
  },
60
58
  "bin": {
61
59
  "parser": "bin/babel-parser.js"
@@ -68,7 +66,6 @@
68
66
  "version": "7.29.8",
69
67
  "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
70
68
  "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
71
- "dev": true,
72
69
  "license": "MIT",
73
70
  "dependencies": {
74
71
  "@babel/helper-string-parser": "^7.29.7",
@@ -1289,6 +1286,22 @@
1289
1286
  "source-map-js": "^1.2.1"
1290
1287
  }
1291
1288
  },
1289
+ "node_modules/@vue/compiler-core/node_modules/@babel/parser": {
1290
+ "version": "7.29.8",
1291
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
1292
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
1293
+ "dev": true,
1294
+ "license": "MIT",
1295
+ "dependencies": {
1296
+ "@babel/types": "^7.29.8"
1297
+ },
1298
+ "bin": {
1299
+ "parser": "bin/babel-parser.js"
1300
+ },
1301
+ "engines": {
1302
+ "node": ">=6.0.0"
1303
+ }
1304
+ },
1292
1305
  "node_modules/@vue/compiler-core/node_modules/entities": {
1293
1306
  "version": "7.0.1",
1294
1307
  "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -1331,6 +1344,22 @@
1331
1344
  "source-map-js": "^1.2.1"
1332
1345
  }
1333
1346
  },
1347
+ "node_modules/@vue/compiler-sfc/node_modules/@babel/parser": {
1348
+ "version": "7.29.8",
1349
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
1350
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
1351
+ "dev": true,
1352
+ "license": "MIT",
1353
+ "dependencies": {
1354
+ "@babel/types": "^7.29.8"
1355
+ },
1356
+ "bin": {
1357
+ "parser": "bin/babel-parser.js"
1358
+ },
1359
+ "engines": {
1360
+ "node": ">=6.0.0"
1361
+ }
1362
+ },
1334
1363
  "node_modules/@vue/compiler-ssr": {
1335
1364
  "version": "3.5.41",
1336
1365
  "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz",
@@ -1396,9 +1425,9 @@
1396
1425
  "license": "MIT"
1397
1426
  },
1398
1427
  "node_modules/acorn": {
1399
- "version": "8.17.0",
1400
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
1401
- "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
1428
+ "version": "8.15.0",
1429
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
1430
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
1402
1431
  "license": "MIT",
1403
1432
  "bin": {
1404
1433
  "acorn": "bin/acorn"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "CLI and build toolkit for B2C landing projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "pack:check": "npm pack --dry-run"
31
31
  },
32
32
  "dependencies": {
33
+ "@babel/parser": "7.28.6",
33
34
  "@clack/prompts": "1.7.0",
34
35
  "sharp": "0.35.3",
35
36
  "typograf": "7.7.0",
package/src/cli/index.mjs CHANGED
@@ -11,6 +11,12 @@ Commands:
11
11
  deploy --dry-run Show the exact deploy plan without changing CMS
12
12
  deploy --update Update the current CMS version in place
13
13
  deploy --relink Attach the project to an existing CMS page
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
18
+ deploy --assets-only --prune-assets
19
+ Remove obsolete project assets recorded in deploy-state
14
20
  deploy --assets-only Build and sync assets with the B2C CDN
15
21
  check Validate project configuration
16
22
  block:add <key> Create a block
@@ -79,36 +85,57 @@ export async function runCli(args) {
79
85
  return;
80
86
  }
81
87
  if (command === "deploy") {
82
- const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink"]);
83
- const flags = new Set(rest);
84
- if (rest.some((argument) => !allowed.has(argument)) || flags.size !== rest.length ||
85
- (flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink")))) {
86
- throw new Error("Usage: b2c-landing-vite deploy [--assets-only | --update] [--relink] [--dry-run]");
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")) ||
95
+ (flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink") || flags.has("--publish"))) ||
96
+ (flags.has("--prune-assets") && !flags.has("--assets-only"))) {
97
+ throw new Error(
98
+ "Usage: b2c-landing-vite deploy [--section <key>] [--force | --remote] [--update] [--relink] [--publish] [--dry-run]"
99
+ );
87
100
  }
88
101
  const config = await loadConfig();
89
- validateProject(config);
102
+ const project = validateProject(config);
90
103
  const preset = resolvePreset(config.site.preset, {requireAssets: true});
91
- await runCli(["build"]);
92
- const {getBackofficeToken} = await import("../deploy/auth.mjs");
93
- const token = await getBackofficeToken({brand: preset.id});
94
- const {deployAssetsOnly, deployProject, planDeployment} = await import("../deploy/deploy.mjs");
95
- if (flags.has("--dry-run")) {
96
- return planDeployment({
104
+ const {withDeploymentLock} = await import("../deploy/lock.mjs");
105
+ return withDeploymentLock({site: preset.id, folder: project.folder}, async () => {
106
+ await runCli(["build"]);
107
+ const {getBackofficeToken} = await import("../deploy/auth.mjs");
108
+ const token = await getBackofficeToken({brand: preset.id});
109
+ const {deployAssetsOnly, deployProject, planDeployment} = await import("../deploy/deploy.mjs");
110
+ if (flags.has("--dry-run")) {
111
+ return planDeployment({
112
+ config,
113
+ preset,
114
+ token,
115
+ update: flags.has("--update"),
116
+ assetsOnly: flags.has("--assets-only"),
117
+ relink: flags.has("--relink"),
118
+ publish: flags.has("--publish"),
119
+ pruneAssets: flags.has("--prune-assets"),
120
+ });
121
+ }
122
+ if (flags.has("--assets-only")) return deployAssetsOnly({
123
+ config,
124
+ preset,
125
+ token,
126
+ pruneAssets: flags.has("--prune-assets"),
127
+ });
128
+ return deployProject({
97
129
  config,
98
130
  preset,
99
131
  token,
100
132
  update: flags.has("--update"),
101
- assetsOnly: flags.has("--assets-only"),
102
133
  relink: flags.has("--relink"),
134
+ publish: flags.has("--publish"),
135
+ pruneAssets: flags.has("--prune-assets"),
136
+ section,
137
+ strategy: flags.has("--force") ? "force" : flags.has("--remote") ? "remote" : "ask",
103
138
  });
104
- }
105
- if (rest.includes("--assets-only")) return deployAssetsOnly({config, preset, token});
106
- return deployProject({
107
- config,
108
- preset,
109
- token,
110
- update: flags.has("--update"),
111
- relink: flags.has("--relink"),
112
139
  });
113
140
  }
114
141
  if (command === "block:add" || command === "block:rename") {
@@ -3,10 +3,10 @@ import {createRequire} from "node:module";
3
3
  import path from "node:path";
4
4
 
5
5
  import {resolveScriptStack} from "./script-stack.mjs";
6
+ import {hasDefaultInitializer} from "./validate-script-contract.mjs";
6
7
 
7
8
  const BLOCK_KEY_RE = /^[a-z0-9][a-z0-9-_]*$/i;
8
9
  const DEPLOY_FOLDER_RE = /^[a-z0-9][a-z0-9-]*$/;
9
- const DEFAULT_EXPORT_RE = /\bexport\s+default\s+(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>|[A-Za-z_$][\w$]*\s*;?)/;
10
10
  const STACK_DEPENDENCIES = {
11
11
  markup: {pug: ["pug"]},
12
12
  styles: {less: ["less"], scss: ["sass"]},
@@ -78,7 +78,7 @@ export function validateProject(config, root = process.cwd()) {
78
78
  const scriptPath = path.resolve(root, "src/scripts", `${key}.${scriptStack.extension}`);
79
79
  if (!fs.existsSync(scriptPath)) continue;
80
80
  const source = fs.readFileSync(scriptPath, "utf8");
81
- if (!DEFAULT_EXPORT_RE.test(source)) {
81
+ if (!hasDefaultInitializer({source, filePath: scriptPath, extension: scriptStack.extension, root})) {
82
82
  throw new Error(
83
83
  `src/scripts/${key}.${scriptStack.extension} must export its initializer as default function`
84
84
  );
@@ -0,0 +1,31 @@
1
+ import {parse} from "@babel/parser";
2
+
3
+ function callableDefaultExport(program) {
4
+ const bindings = new Map();
5
+ for (const node of program.body) {
6
+ if (node.type === "FunctionDeclaration" && node.id) bindings.set(node.id.name, node);
7
+ if (node.type === "VariableDeclaration") {
8
+ for (const declaration of node.declarations) {
9
+ if (declaration.id.type === "Identifier") bindings.set(declaration.id.name, declaration.init);
10
+ }
11
+ }
12
+ }
13
+ const exported = program.body.find((node) => node.type === "ExportDefaultDeclaration")?.declaration;
14
+ const target = exported?.type === "Identifier" ? bindings.get(exported.name) : exported;
15
+ return target?.type === "FunctionDeclaration" || target?.type === "FunctionExpression" ||
16
+ target?.type === "ArrowFunctionExpression";
17
+ }
18
+
19
+ export function hasDefaultInitializer({source, filePath, extension}) {
20
+ let program;
21
+ try {
22
+ program = parse(source, {
23
+ sourceType: "module",
24
+ plugins: extension === "ts" ? ["typescript"] : [],
25
+ }).program;
26
+ } catch (error) {
27
+ const language = extension === "ts" ? "TypeScript" : "JavaScript";
28
+ throw new Error(`${filePath} contains invalid ${language}: ${error.message}`, {cause: error});
29
+ }
30
+ return callableDefaultExport(program);
31
+ }
@@ -4,15 +4,28 @@ export function changedAssets(manifest, state) {
4
4
  return manifest.assets.filter((asset) => state.assets?.[asset.path]?.sha256 !== asset.sha256);
5
5
  }
6
6
 
7
+ export function obsoleteAssets(manifest, state) {
8
+ const current = new Set(manifest.assets.map((asset) => asset.path));
9
+ return Object.entries(state.assets || {})
10
+ .filter(([assetPath, asset]) => asset?.managed === true && !current.has(assetPath))
11
+ .map(([assetPath]) => assetPath)
12
+ .sort();
13
+ }
14
+
7
15
  function filesFromListing(listing) {
8
16
  const files = Array.isArray(listing) ? listing : listing?.files || [];
9
17
  return new Set(files.map((file) => typeof file === "string" ? path.posix.basename(file) : file.name));
10
18
  }
11
19
 
12
- export async function planAssetChanges({manifest, state, listFiles, remoteRoot = "/content"}) {
20
+ export async function planAssetChanges({manifest, state, listFiles, remoteRoot = "/content", prune = false}) {
13
21
  const pending = changedAssets(manifest, state);
14
22
  if (!listFiles) {
15
- return {upload: pending, skip: [], unchanged: manifest.assets.length - pending.length};
23
+ return {
24
+ upload: pending,
25
+ skip: [],
26
+ unchanged: manifest.assets.length - pending.length,
27
+ remove: prune ? obsoleteAssets(manifest, state) : [],
28
+ };
16
29
  }
17
30
  const directories = new Map();
18
31
  const namesInDirectory = (directory) => {
@@ -33,6 +46,7 @@ export async function planAssetChanges({manifest, state, listFiles, remoteRoot =
33
46
  upload: upload.sort(byPath),
34
47
  skip: skip.sort(byPath),
35
48
  unchanged: manifest.assets.length - pending.length,
49
+ remove: prune ? obsoleteAssets(manifest, state) : [],
36
50
  };
37
51
  }
38
52
 
@@ -44,12 +58,14 @@ export async function uploadChangedAssets({
44
58
  listFiles,
45
59
  remoteRoot = "/content",
46
60
  concurrency = 5,
61
+ prune = false,
62
+ deleteFile,
47
63
  }) {
48
- const plan = await planAssetChanges({manifest, state, listFiles, remoteRoot});
64
+ const plan = await planAssetChanges({manifest, state, listFiles, remoteRoot, prune});
49
65
  const assets = {...state.assets};
50
66
  const uploaded = [];
51
67
  const skipped = plan.skip.map((asset) => asset.path);
52
- for (const asset of plan.skip) assets[asset.path] = {...asset, immutable: true};
68
+ for (const asset of plan.skip) assets[asset.path] = {...asset, immutable: true, managed: false};
53
69
  let cursor = 0;
54
70
 
55
71
  async function worker() {
@@ -58,17 +74,28 @@ export async function uploadChangedAssets({
58
74
  const localPath = path.join(root, ...(manifest.assetsDirectory || "public").split("/"), ...asset.path.split("/"));
59
75
  const remotePath = path.posix.join("/", remoteRoot, manifest.assetsPrefix, asset.path);
60
76
  const result = await uploadFile(remotePath, localPath);
61
- assets[asset.path] = {...asset, url: result?.files?.[0]?.url};
77
+ assets[asset.path] = {...asset, url: result?.files?.[0]?.url, managed: true};
62
78
  uploaded.push(asset.path);
63
79
  }
64
80
  }
65
81
 
66
82
  const workerCount = Math.min(Math.max(1, concurrency), plan.upload.length || 1);
67
83
  await Promise.all(Array.from({length: workerCount}, () => worker()));
84
+ const removed = [];
85
+ if (prune && plan.remove.length) {
86
+ if (!deleteFile) throw new Error("CMS asset deletion is unavailable");
87
+ for (const assetPath of plan.remove) {
88
+ const remotePath = path.posix.join("/", remoteRoot, manifest.assetsPrefix, assetPath);
89
+ await deleteFile(remotePath);
90
+ delete assets[assetPath];
91
+ removed.push(assetPath);
92
+ }
93
+ }
68
94
  return {
69
95
  state: {...state, assets},
70
96
  uploaded: uploaded.sort(),
71
97
  skipped: skipped.sort(),
72
98
  unchanged: plan.unchanged,
99
+ removed,
73
100
  };
74
101
  }
@@ -210,4 +210,10 @@ export class BackofficeClient {
210
210
  const result = await this.request("POST", "/FileManagement/UploadFile", {form});
211
211
  return result?.result;
212
212
  }
213
+
214
+ deleteFile(remotePath) {
215
+ return this.request("POST", "/FileManagement/DeleteFile", {
216
+ body: {path: remotePath.replace(/^\/+/, "")},
217
+ });
218
+ }
213
219
  }
@@ -2,8 +2,18 @@ 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, selectExistingPagePlacement, selectNewPagePlacement} from "./page.mjs";
6
- import {planWidgetChanges, syncWidgets} from "./widgets.mjs";
5
+ import {resolveSections, summarizeChanges} from "./reconcile.mjs";
6
+ import {
7
+ createPagePlacement,
8
+ prepareExistingPage,
9
+ pageRevision,
10
+ publicationIssues,
11
+ publishPage,
12
+ selectExistingPagePlacement,
13
+ selectNewPagePlacement,
14
+ validateSavedPlacement,
15
+ } from "./page.mjs";
16
+ import {localSections, planWidgetChanges, remoteSections, syncWidgets} from "./widgets.mjs";
7
17
 
8
18
  function paths(items) {
9
19
  return items.map((item) => item.path);
@@ -22,22 +32,27 @@ export async function planDeployment({
22
32
  update = false,
23
33
  assetsOnly = false,
24
34
  relink = false,
35
+ pruneAssets = false,
36
+ publish = false,
25
37
  prompts,
26
38
  log = console.log,
27
39
  }) {
28
40
  const manifest = readBuildManifest(root);
29
41
  const api = client || new BackofficeClient({brand: preset.id, token});
30
42
  const previous = readDeploymentState(preset.id, manifest.folder, root);
43
+ log("[dry-run] checking CDN and CMS state...");
31
44
  const assets = await planAssetChanges({
32
45
  manifest,
33
46
  state: previous,
34
47
  remoteRoot: new URL(preset.assetsBase).pathname,
35
48
  listFiles: api.listFiles?.bind(api),
49
+ prune: pruneAssets,
36
50
  });
37
51
  log(`[dry-run] no CMS data will be changed`);
38
52
  log(`[dry-run] target: ${preset.assetsBase}/${manifest.assetsPrefix}/`);
39
53
  logList(log, "assets to upload", paths(assets.upload));
40
54
  logList(log, "existing assets to skip", paths(assets.skip));
55
+ logList(log, "managed assets to remove", assets.remove);
41
56
  log(`[dry-run] unchanged assets: ${assets.unchanged}`);
42
57
  if (assetsOnly) return {assets};
43
58
 
@@ -52,7 +67,7 @@ export async function planDeployment({
52
67
  page = {action: checkout ? "relink and create checkout version" : "relink and update selected version", pageContentId: placement.pageContentId};
53
68
  } else if (previous.placement) {
54
69
  placement = previous.placement;
55
- pageContent = await api.getContent(placement.pageContentId);
70
+ pageContent = await validateSavedPlacement({client: api, placement});
56
71
  const checkout = !update && [1, 4].includes(pageContent.status);
57
72
  page = {action: checkout ? "create checkout version" : "update current version", pageContentId: placement.pageContentId};
58
73
  } else {
@@ -68,7 +83,11 @@ export async function planDeployment({
68
83
  logList(log, "widgets to update", widgets.update);
69
84
  logList(log, "widgets to remove", widgets.remove);
70
85
  logList(log, "widgets to reorder", widgets.reorder.map((item) => `${item.title} (${item.from} → ${item.to})`));
71
- return {assets, page, placement, widgets};
86
+ if (publish) {
87
+ const issues = publicationIssues(pageContent);
88
+ log(`[dry-run] publish: ${issues.length ? `blocked (${issues.join(", ")})` : "page after widget sync"}`);
89
+ }
90
+ return {assets, page, placement, widgets, publish};
72
91
  }
73
92
 
74
93
  export async function deployAssetsOnly({
@@ -78,6 +97,7 @@ export async function deployAssetsOnly({
78
97
  token = process.env.B2C_BACKOFFICE_TOKEN,
79
98
  client,
80
99
  log = console.log,
100
+ pruneAssets = false,
81
101
  }) {
82
102
  if (!token && !client) {
83
103
  throw new Error("Backoffice token is required. Set B2C_BACKOFFICE_TOKEN before deploy.");
@@ -90,6 +110,7 @@ export async function deployAssetsOnly({
90
110
  const state = readDeploymentState(preset.id, manifest.folder, root);
91
111
  const api = client || new BackofficeClient({brand: preset.id, token});
92
112
  log(`[deploy] target: ${cdnUrl}`);
113
+ log("[deploy] checking CDN state...");
93
114
  const result = await uploadChangedAssets({
94
115
  manifest,
95
116
  state,
@@ -97,9 +118,11 @@ export async function deployAssetsOnly({
97
118
  remoteRoot: new URL(preset.assetsBase).pathname,
98
119
  uploadFile: api.uploadFile.bind(api),
99
120
  listFiles: api.listFiles?.bind(api),
121
+ prune: pruneAssets,
122
+ deleteFile: api.deleteFile?.bind(api),
100
123
  });
101
124
  writeDeploymentState(preset.id, manifest.folder, result.state, root);
102
- log(`[deploy] assets: uploaded=${result.uploaded.length}, skipped=${result.skipped.length}, unchanged=${result.unchanged}`);
125
+ log(`[deploy] assets: uploaded=${result.uploaded.length}, skipped=${result.skipped.length}, removed=${result.removed.length}, unchanged=${result.unchanged}`);
103
126
  if (result.skipped.length) {
104
127
  log(`[deploy] existing assets were not overwritten: ${result.skipped.join(", ")}. Rename changed files to publish new URLs.`);
105
128
  }
@@ -114,13 +137,77 @@ export async function deployProject({
114
137
  client,
115
138
  update = false,
116
139
  relink = false,
140
+ pruneAssets = false,
141
+ publish = false,
117
142
  prompts,
143
+ section,
144
+ strategy = "ask",
118
145
  log = console.log,
119
146
  }) {
120
147
  const manifest = readBuildManifest(root);
121
148
  const api = client || new BackofficeClient({brand: preset.id, token});
122
149
  const previous = readDeploymentState(preset.id, manifest.folder, root);
123
150
  log(`[deploy] target: ${preset.assetsBase}/${manifest.assetsPrefix}/`);
151
+ log(previous.placement && !relink
152
+ ? "[deploy] validating saved CMS placement..."
153
+ : relink ? "[deploy] selecting an existing CMS page..." : "[deploy] a new CMS page will be created");
154
+ let selectedPage = null;
155
+ let remoteBefore = null;
156
+ if (relink) selectedPage = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
157
+ else if (previous.placement) {
158
+ selectedPage = {
159
+ placement: previous.placement,
160
+ pageContent: await validateSavedPlacement({client: api, placement: previous.placement}),
161
+ };
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
+ }
210
+ log("[deploy] synchronizing CDN assets...");
124
211
  const assets = await uploadChangedAssets({
125
212
  manifest,
126
213
  state: previous,
@@ -128,30 +215,55 @@ export async function deployProject({
128
215
  remoteRoot: new URL(preset.assetsBase).pathname,
129
216
  uploadFile: api.uploadFile.bind(api),
130
217
  listFiles: api.listFiles?.bind(api),
218
+ prune: pruneAssets,
219
+ deleteFile: api.deleteFile?.bind(api),
131
220
  });
132
- const linked = relink ? await selectExistingPagePlacement({client: api, domain: preset.domain, prompts}) : null;
133
- const page = linked
134
- ? await prepareExistingPage({client: api, placement: linked.placement, update})
135
- : previous.placement
136
- ? await prepareExistingPage({client: api, placement: previous.placement, update})
221
+ const page = selectedPage
222
+ ? await prepareExistingPage({client: api, placement: selectedPage.placement, update})
137
223
  : await createPagePlacement({client: api, manifest, prompts});
224
+ if (selectedPage && page.placement.pageContentId === selectedPage.placement.pageContentId &&
225
+ pageRevision(page.pageContent) !== pageRevision(selectedPage.pageContent)) {
226
+ throw new Error("CMS page changed during deploy. No widgets were modified; run deploy again.");
227
+ }
138
228
  const widgets = await syncWidgets({
139
229
  client: api,
140
230
  manifest,
141
231
  placement: page.placement,
142
232
  pageContent: page.pageContent,
143
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,
144
238
  });
145
- const state = {...assets.state, placement: page.placement};
239
+ const state = {
240
+ ...assets.state,
241
+ placement: page.placement,
242
+ cms: {
243
+ verifiedAt: new Date().toISOString(),
244
+ sections: remoteSections(await api.getContent(page.placement.pageContentId), page.placement, manifest.folder),
245
+ },
246
+ };
247
+ let published = false;
248
+ let finalPageContent = await api.getContent(page.placement.pageContentId);
249
+ if (publish) {
250
+ log("[deploy] validating CMS metadata and publishing...");
251
+ await publishPage({client: api, pageContent: finalPageContent});
252
+ published = true;
253
+ finalPageContent = await api.getContent(page.placement.pageContentId);
254
+ }
146
255
  writeDeploymentState(preset.id, manifest.folder, state, root);
147
- const preview = page.pageContent.uniqueId
148
- ? `https://${preset.domain}/preview/${page.pageContent.uniqueId}/`
256
+ const preview = finalPageContent.uniqueId
257
+ ? `https://${preset.domain}/preview/${finalPageContent.uniqueId}/`
149
258
  : null;
150
- log(`[deploy] assets: uploaded=${assets.uploaded.length}, skipped=${assets.skipped.length}, unchanged=${assets.unchanged}`);
259
+ const ru = finalPageContent.pageContents?.find((content) => String(content.languageId) === "1049");
260
+ const live = published && ru?.perma ? `https://${preset.domain}/${ru.perma.replace(/^\/+/, "")}` : null;
261
+ log(`[deploy] assets: uploaded=${assets.uploaded.length}, skipped=${assets.skipped.length}, removed=${assets.removed.length}, unchanged=${assets.unchanged}`);
151
262
  if (assets.skipped.length) {
152
263
  log(`[deploy] existing assets were not overwritten: ${assets.skipped.join(", ")}. Rename changed files to publish new URLs.`);
153
264
  }
154
265
  log(`[deploy] widgets: added=${widgets.added}, updated=${widgets.updated}, removed=${widgets.removed}`);
155
266
  if (preview) log(`[deploy] preview: ${preview}`);
156
- return {assets, widgets, placement: page.placement, preview};
267
+ if (live) log(`[deploy] live: ${live}`);
268
+ return {assets, widgets, placement: page.placement, preview, live, published};
157
269
  }
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
5
+
6
+ export function deploymentLockPath(site, folder, root = process.cwd()) {
7
+ return path.join(root, ".b2c", "deploy", `${site}--${folder}.lock`);
8
+ }
9
+
10
+ function processIsRunning(pid) {
11
+ if (!Number.isInteger(pid) || pid <= 0) return false;
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ } catch (error) {
16
+ return error.code === "EPERM";
17
+ }
18
+ }
19
+
20
+ export function acquireDeploymentLock(site, folder, root = process.cwd(), now = Date.now()) {
21
+ const filePath = deploymentLockPath(site, folder, root);
22
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
23
+ try {
24
+ const existing = JSON.parse(fs.readFileSync(filePath, "utf8"));
25
+ const age = now - Date.parse(existing.startedAt);
26
+ if (processIsRunning(existing.pid) && age < STALE_AFTER_MS) {
27
+ throw new Error(
28
+ `Another deploy is already running for ${site}/${folder} (PID ${existing.pid}, since ${existing.startedAt})`
29
+ );
30
+ }
31
+ fs.rmSync(filePath, {force: true});
32
+ } catch (error) {
33
+ if (error.code !== "ENOENT" && !/Another deploy/.test(error.message)) {
34
+ fs.rmSync(filePath, {force: true});
35
+ } else if (/Another deploy/.test(error.message)) {
36
+ throw error;
37
+ }
38
+ }
39
+ const descriptor = {pid: process.pid, startedAt: new Date(now).toISOString()};
40
+ const handle = fs.openSync(filePath, "wx", 0o600);
41
+ fs.writeFileSync(handle, `${JSON.stringify(descriptor)}\n`);
42
+ fs.closeSync(handle);
43
+ let released = false;
44
+ return () => {
45
+ if (released) return;
46
+ released = true;
47
+ try {
48
+ const current = JSON.parse(fs.readFileSync(filePath, "utf8"));
49
+ if (current.pid === descriptor.pid && current.startedAt === descriptor.startedAt) fs.rmSync(filePath, {force: true});
50
+ } catch (error) {
51
+ if (error.code !== "ENOENT") throw error;
52
+ }
53
+ };
54
+ }
55
+
56
+ export async function withDeploymentLock(options, callback) {
57
+ const release = acquireDeploymentLock(options.site, options.folder, options.root);
58
+ try {
59
+ return await callback();
60
+ } finally {
61
+ release();
62
+ }
63
+ }
@@ -204,3 +204,58 @@ export async function prepareExistingPage({client, placement, update = false}) {
204
204
  }
205
205
  return {pageContent, placement};
206
206
  }
207
+
208
+ export function publicationIssues(pageContent) {
209
+ const issues = [];
210
+ for (const content of pageContent.pageContents || []) {
211
+ const language = content.languageId || "unknown";
212
+ if (!content.title?.trim()) issues.push(`language ${language}: title`);
213
+ if (!content.perma?.trim()) issues.push(`language ${language}: URL`);
214
+ if (!content.seo?.metaTitle?.trim()) issues.push(`language ${language}: SEO title`);
215
+ if (!content.seo?.description?.trim()) issues.push(`language ${language}: SEO description`);
216
+ }
217
+ if (!pageContent.pageContents?.length) issues.push("page language content");
218
+ return issues;
219
+ }
220
+
221
+ export async function publishPage({client, pageContent}) {
222
+ if (pageContent.status === STATUS.published || pageContent.isPublished) return pageContent;
223
+ const issues = publicationIssues(pageContent);
224
+ if (issues.length) {
225
+ throw new Error(`CMS page cannot be published; fill in ${issues.join(", ")} in Backoffice`);
226
+ }
227
+ const result = await client.updateStatus(pageContent._id || pageContent.pageContentId, STATUS.published);
228
+ return result;
229
+ }
230
+
231
+ export async function validateSavedPlacement({client, placement}) {
232
+ let pageContent;
233
+ try {
234
+ pageContent = await client.getContent(placement.pageContentId);
235
+ } catch (error) {
236
+ if (error.code === "NOT_FOUND" || error.status === 404) {
237
+ throw new Error("Saved deploy-state points to a missing CMS page version. Run deploy --relink.", {cause: error});
238
+ }
239
+ throw error;
240
+ }
241
+ const schema = pageContent.layout?.schema;
242
+ if (Array.isArray(schema) && !schema.flatMap((row) => row.children || [])
243
+ .some((area) => (area.layoutAreaId || area.id) === placement.layoutAreaId)) {
244
+ throw new Error("Saved deploy-state points to an HTML area that no longer exists. Run deploy --relink.");
245
+ }
246
+ return pageContent;
247
+ }
248
+
249
+ export function pageRevision(pageContent) {
250
+ return JSON.stringify({
251
+ id: pageContent._id || pageContent.pageContentId,
252
+ modifiedTime: pageContent.modifiedTime || pageContent.lastOperationDateTime || null,
253
+ widgets: pageWidgets(pageContent).map((widget) => ({
254
+ id: widget.contentWidgetId,
255
+ area: widget.layoutAreaId,
256
+ order: widget.order,
257
+ title: widget.cmsTitle,
258
+ content: widget.data?.content ?? widget.content ?? null,
259
+ })),
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,
@@ -4,6 +4,16 @@ const typograf = new Typograf({
4
4
  locale: ["ru", "en-US"],
5
5
  });
6
6
 
7
+ const PROTECTED_TAGS = /<(script|style|pre|code|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi;
8
+
7
9
  export function processMarkup(html) {
8
- return typograf.execute(String(html || ""));
10
+ const protectedContents = [];
11
+ const source = String(html || "").replace(PROTECTED_TAGS, (fragment) => {
12
+ const index = protectedContents.push(fragment) - 1;
13
+ return `<b2c-typograf-protected data-index="${index}"></b2c-typograf-protected>`;
14
+ });
15
+ return typograf.execute(source).replace(
16
+ /<b2c-typograf-protected data-index="(\d+)"><\/b2c-typograf-protected>/g,
17
+ (_, index) => protectedContents[Number(index)]
18
+ );
9
19
  }