@coraltravelcenter/b2c-landing-builder 2.8.0 → 2.9.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.9.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.9.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.9.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,9 @@ 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 --assets-only --prune-assets
16
+ Remove obsolete project assets recorded in deploy-state
14
17
  deploy --assets-only Build and sync assets with the B2C CDN
15
18
  check Validate project configuration
16
19
  block:add <key> Create a block
@@ -79,36 +82,51 @@ export async function runCli(args) {
79
82
  return;
80
83
  }
81
84
  if (command === "deploy") {
82
- const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink"]);
85
+ const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink", "--publish", "--prune-assets"]);
83
86
  const flags = new Set(rest);
84
87
  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
+ (flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink") || flags.has("--publish"))) ||
89
+ (flags.has("--prune-assets") && !flags.has("--assets-only"))) {
90
+ throw new Error(
91
+ "Usage: b2c-landing-vite deploy [--assets-only | --update] [--relink] [--publish] [--prune-assets] [--dry-run]"
92
+ );
87
93
  }
88
94
  const config = await loadConfig();
89
- validateProject(config);
95
+ const project = validateProject(config);
90
96
  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({
97
+ const {withDeploymentLock} = await import("../deploy/lock.mjs");
98
+ return withDeploymentLock({site: preset.id, folder: project.folder}, async () => {
99
+ await runCli(["build"]);
100
+ const {getBackofficeToken} = await import("../deploy/auth.mjs");
101
+ const token = await getBackofficeToken({brand: preset.id});
102
+ const {deployAssetsOnly, deployProject, planDeployment} = await import("../deploy/deploy.mjs");
103
+ if (flags.has("--dry-run")) {
104
+ return planDeployment({
105
+ config,
106
+ preset,
107
+ token,
108
+ update: flags.has("--update"),
109
+ assetsOnly: flags.has("--assets-only"),
110
+ relink: flags.has("--relink"),
111
+ publish: flags.has("--publish"),
112
+ pruneAssets: flags.has("--prune-assets"),
113
+ });
114
+ }
115
+ if (flags.has("--assets-only")) return deployAssetsOnly({
116
+ config,
117
+ preset,
118
+ token,
119
+ pruneAssets: flags.has("--prune-assets"),
120
+ });
121
+ return deployProject({
97
122
  config,
98
123
  preset,
99
124
  token,
100
125
  update: flags.has("--update"),
101
- assetsOnly: flags.has("--assets-only"),
102
126
  relink: flags.has("--relink"),
127
+ publish: flags.has("--publish"),
128
+ pruneAssets: flags.has("--prune-assets"),
103
129
  });
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
130
  });
113
131
  }
114
132
  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,7 +2,16 @@ 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";
5
+ import {
6
+ createPagePlacement,
7
+ prepareExistingPage,
8
+ pageRevision,
9
+ publicationIssues,
10
+ publishPage,
11
+ selectExistingPagePlacement,
12
+ selectNewPagePlacement,
13
+ validateSavedPlacement,
14
+ } from "./page.mjs";
6
15
  import {planWidgetChanges, syncWidgets} from "./widgets.mjs";
7
16
 
8
17
  function paths(items) {
@@ -22,22 +31,27 @@ export async function planDeployment({
22
31
  update = false,
23
32
  assetsOnly = false,
24
33
  relink = false,
34
+ pruneAssets = false,
35
+ publish = false,
25
36
  prompts,
26
37
  log = console.log,
27
38
  }) {
28
39
  const manifest = readBuildManifest(root);
29
40
  const api = client || new BackofficeClient({brand: preset.id, token});
30
41
  const previous = readDeploymentState(preset.id, manifest.folder, root);
42
+ log("[dry-run] checking CDN and CMS state...");
31
43
  const assets = await planAssetChanges({
32
44
  manifest,
33
45
  state: previous,
34
46
  remoteRoot: new URL(preset.assetsBase).pathname,
35
47
  listFiles: api.listFiles?.bind(api),
48
+ prune: pruneAssets,
36
49
  });
37
50
  log(`[dry-run] no CMS data will be changed`);
38
51
  log(`[dry-run] target: ${preset.assetsBase}/${manifest.assetsPrefix}/`);
39
52
  logList(log, "assets to upload", paths(assets.upload));
40
53
  logList(log, "existing assets to skip", paths(assets.skip));
54
+ logList(log, "managed assets to remove", assets.remove);
41
55
  log(`[dry-run] unchanged assets: ${assets.unchanged}`);
42
56
  if (assetsOnly) return {assets};
43
57
 
@@ -52,7 +66,7 @@ export async function planDeployment({
52
66
  page = {action: checkout ? "relink and create checkout version" : "relink and update selected version", pageContentId: placement.pageContentId};
53
67
  } else if (previous.placement) {
54
68
  placement = previous.placement;
55
- pageContent = await api.getContent(placement.pageContentId);
69
+ pageContent = await validateSavedPlacement({client: api, placement});
56
70
  const checkout = !update && [1, 4].includes(pageContent.status);
57
71
  page = {action: checkout ? "create checkout version" : "update current version", pageContentId: placement.pageContentId};
58
72
  } else {
@@ -68,7 +82,11 @@ export async function planDeployment({
68
82
  logList(log, "widgets to update", widgets.update);
69
83
  logList(log, "widgets to remove", widgets.remove);
70
84
  logList(log, "widgets to reorder", widgets.reorder.map((item) => `${item.title} (${item.from} → ${item.to})`));
71
- return {assets, page, placement, widgets};
85
+ if (publish) {
86
+ const issues = publicationIssues(pageContent);
87
+ log(`[dry-run] publish: ${issues.length ? `blocked (${issues.join(", ")})` : "page after widget sync"}`);
88
+ }
89
+ return {assets, page, placement, widgets, publish};
72
90
  }
73
91
 
74
92
  export async function deployAssetsOnly({
@@ -78,6 +96,7 @@ export async function deployAssetsOnly({
78
96
  token = process.env.B2C_BACKOFFICE_TOKEN,
79
97
  client,
80
98
  log = console.log,
99
+ pruneAssets = false,
81
100
  }) {
82
101
  if (!token && !client) {
83
102
  throw new Error("Backoffice token is required. Set B2C_BACKOFFICE_TOKEN before deploy.");
@@ -90,6 +109,7 @@ export async function deployAssetsOnly({
90
109
  const state = readDeploymentState(preset.id, manifest.folder, root);
91
110
  const api = client || new BackofficeClient({brand: preset.id, token});
92
111
  log(`[deploy] target: ${cdnUrl}`);
112
+ log("[deploy] checking CDN state...");
93
113
  const result = await uploadChangedAssets({
94
114
  manifest,
95
115
  state,
@@ -97,9 +117,11 @@ export async function deployAssetsOnly({
97
117
  remoteRoot: new URL(preset.assetsBase).pathname,
98
118
  uploadFile: api.uploadFile.bind(api),
99
119
  listFiles: api.listFiles?.bind(api),
120
+ prune: pruneAssets,
121
+ deleteFile: api.deleteFile?.bind(api),
100
122
  });
101
123
  writeDeploymentState(preset.id, manifest.folder, result.state, root);
102
- log(`[deploy] assets: uploaded=${result.uploaded.length}, skipped=${result.skipped.length}, unchanged=${result.unchanged}`);
124
+ log(`[deploy] assets: uploaded=${result.uploaded.length}, skipped=${result.skipped.length}, removed=${result.removed.length}, unchanged=${result.unchanged}`);
103
125
  if (result.skipped.length) {
104
126
  log(`[deploy] existing assets were not overwritten: ${result.skipped.join(", ")}. Rename changed files to publish new URLs.`);
105
127
  }
@@ -114,6 +136,8 @@ export async function deployProject({
114
136
  client,
115
137
  update = false,
116
138
  relink = false,
139
+ pruneAssets = false,
140
+ publish = false,
117
141
  prompts,
118
142
  log = console.log,
119
143
  }) {
@@ -121,6 +145,18 @@ export async function deployProject({
121
145
  const api = client || new BackofficeClient({brand: preset.id, token});
122
146
  const previous = readDeploymentState(preset.id, manifest.folder, root);
123
147
  log(`[deploy] target: ${preset.assetsBase}/${manifest.assetsPrefix}/`);
148
+ log(previous.placement && !relink
149
+ ? "[deploy] validating saved CMS placement..."
150
+ : relink ? "[deploy] selecting an existing CMS page..." : "[deploy] a new CMS page will be created");
151
+ let selectedPage = null;
152
+ if (relink) selectedPage = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
153
+ else if (previous.placement) {
154
+ selectedPage = {
155
+ placement: previous.placement,
156
+ pageContent: await validateSavedPlacement({client: api, placement: previous.placement}),
157
+ };
158
+ }
159
+ log("[deploy] synchronizing CDN assets...");
124
160
  const assets = await uploadChangedAssets({
125
161
  manifest,
126
162
  state: previous,
@@ -128,13 +164,16 @@ export async function deployProject({
128
164
  remoteRoot: new URL(preset.assetsBase).pathname,
129
165
  uploadFile: api.uploadFile.bind(api),
130
166
  listFiles: api.listFiles?.bind(api),
167
+ prune: pruneAssets,
168
+ deleteFile: api.deleteFile?.bind(api),
131
169
  });
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})
170
+ const page = selectedPage
171
+ ? await prepareExistingPage({client: api, placement: selectedPage.placement, update})
137
172
  : await createPagePlacement({client: api, manifest, prompts});
173
+ if (selectedPage && page.placement.pageContentId === selectedPage.placement.pageContentId &&
174
+ pageRevision(page.pageContent) !== pageRevision(selectedPage.pageContent)) {
175
+ throw new Error("CMS page changed during deploy. No widgets were modified; run deploy again.");
176
+ }
138
177
  const widgets = await syncWidgets({
139
178
  client: api,
140
179
  manifest,
@@ -142,16 +181,31 @@ export async function deployProject({
142
181
  pageContent: page.pageContent,
143
182
  root,
144
183
  });
145
- const state = {...assets.state, placement: page.placement};
184
+ const state = {
185
+ ...assets.state,
186
+ placement: page.placement,
187
+ cms: {verifiedAt: new Date().toISOString()},
188
+ };
189
+ let published = false;
190
+ let finalPageContent = await api.getContent(page.placement.pageContentId);
191
+ if (publish) {
192
+ log("[deploy] validating CMS metadata and publishing...");
193
+ await publishPage({client: api, pageContent: finalPageContent});
194
+ published = true;
195
+ finalPageContent = await api.getContent(page.placement.pageContentId);
196
+ }
146
197
  writeDeploymentState(preset.id, manifest.folder, state, root);
147
- const preview = page.pageContent.uniqueId
148
- ? `https://${preset.domain}/preview/${page.pageContent.uniqueId}/`
198
+ const preview = finalPageContent.uniqueId
199
+ ? `https://${preset.domain}/preview/${finalPageContent.uniqueId}/`
149
200
  : null;
150
- log(`[deploy] assets: uploaded=${assets.uploaded.length}, skipped=${assets.skipped.length}, unchanged=${assets.unchanged}`);
201
+ const ru = finalPageContent.pageContents?.find((content) => String(content.languageId) === "1049");
202
+ const live = published && ru?.perma ? `https://${preset.domain}/${ru.perma.replace(/^\/+/, "")}` : null;
203
+ log(`[deploy] assets: uploaded=${assets.uploaded.length}, skipped=${assets.skipped.length}, removed=${assets.removed.length}, unchanged=${assets.unchanged}`);
151
204
  if (assets.skipped.length) {
152
205
  log(`[deploy] existing assets were not overwritten: ${assets.skipped.join(", ")}. Rename changed files to publish new URLs.`);
153
206
  }
154
207
  log(`[deploy] widgets: added=${widgets.added}, updated=${widgets.updated}, removed=${widgets.removed}`);
155
208
  if (preview) log(`[deploy] preview: ${preview}`);
156
- return {assets, widgets, placement: page.placement, preview};
209
+ if (live) log(`[deploy] live: ${live}`);
210
+ return {assets, widgets, placement: page.placement, preview, live, published};
157
211
  }
@@ -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,57 @@ 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
+ })),
259
+ });
260
+ }
@@ -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
  }