@coraltravelcenter/b2c-landing-builder 2.7.1 → 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
@@ -24,7 +24,10 @@ b2c-landing-vite build
24
24
  b2c-landing-vite deploy
25
25
  b2c-landing-vite deploy --dry-run
26
26
  b2c-landing-vite deploy --update
27
+ b2c-landing-vite deploy --relink
28
+ b2c-landing-vite deploy --publish
27
29
  b2c-landing-vite deploy --assets-only
30
+ b2c-landing-vite deploy --assets-only --prune-assets
28
31
  b2c-landing-vite block:add hero
29
32
  b2c-landing-vite block:rename hero main-hero
30
33
  b2c-landing-vite update
@@ -49,6 +52,26 @@ b2c-landing-vite update
49
52
  Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
50
53
  - `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
51
54
  - `deploy --update` обновляет сохранённую версию страницы без создания новой.
55
+ - `deploy --relink` восстанавливает или меняет привязку проекта к существующей
56
+ CMS-странице. Builder предлагает application, поиск страницы по названию или
57
+ URL, версию, HTML-зону и точную позицию. Обычный запуск создаёт checkout для
58
+ опубликованной версии; вместе с `--update` выбранная версия меняется на месте.
59
+ - `deploy --relink --dry-run` проходит тот же интерактивный выбор и показывает
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 автоматически признаётся устаревшим.
52
75
  - `block:add` создаёт файлы в выбранных форматах разметки и стилей.
53
76
  - `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
54
77
  - `update` проверяет npm registry и показывает команду ручного обновления.
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.7.1",
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.7.1",
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.7.1",
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
@@ -10,6 +10,10 @@ Commands:
10
10
  deploy Build, sync assets, and deploy CMS widgets
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
+ 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
13
17
  deploy --assets-only Build and sync assets with the B2C CDN
14
18
  check Validate project configuration
15
19
  block:add <key> Create a block
@@ -78,30 +82,52 @@ export async function runCli(args) {
78
82
  return;
79
83
  }
80
84
  if (command === "deploy") {
81
- const allowed = new Set(["--assets-only", "--update", "--dry-run"]);
85
+ const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink", "--publish", "--prune-assets"]);
82
86
  const flags = new Set(rest);
83
87
  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]");
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
+ );
86
93
  }
87
94
  const config = await loadConfig();
88
- validateProject(config);
95
+ const project = validateProject(config);
89
96
  const preset = resolvePreset(config.site.preset, {requireAssets: true});
90
- await runCli(["build"]);
91
- const {getBackofficeToken} = await import("../deploy/auth.mjs");
92
- const token = await getBackofficeToken({brand: preset.id});
93
- const {deployAssetsOnly, deployProject, planDeployment} = await import("../deploy/deploy.mjs");
94
- if (flags.has("--dry-run")) {
95
- 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({
96
122
  config,
97
123
  preset,
98
124
  token,
99
125
  update: flags.has("--update"),
100
- assetsOnly: flags.has("--assets-only"),
126
+ relink: flags.has("--relink"),
127
+ publish: flags.has("--publish"),
128
+ pruneAssets: flags.has("--prune-assets"),
101
129
  });
102
- }
103
- if (rest.includes("--assets-only")) return deployAssetsOnly({config, preset, token});
104
- return deployProject({config, preset, token, update: rest.includes("--update")});
130
+ });
105
131
  }
106
132
  if (command === "block:add" || command === "block:rename") {
107
133
  const config = await loadConfig();
@@ -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
  }
@@ -140,6 +140,17 @@ export class BackofficeClient {
140
140
  return result?.versions || [];
141
141
  }
142
142
 
143
+ async getPages(applicationId, {keyword = "", url = ""} = {}) {
144
+ const result = await this.request("GET", "/Page/GetAll", {query: {
145
+ pageIndex: 1,
146
+ pageSize: 100,
147
+ ApplicationId: applicationId,
148
+ Keyword: keyword,
149
+ Url: url,
150
+ }});
151
+ return result?.result || result || [];
152
+ }
153
+
143
154
  createPage(body) {
144
155
  return this.request("POST", "/Page/Create", {body});
145
156
  }
@@ -199,4 +210,10 @@ export class BackofficeClient {
199
210
  const result = await this.request("POST", "/FileManagement/UploadFile", {form});
200
211
  return result?.result;
201
212
  }
213
+
214
+ deleteFile(remotePath) {
215
+ return this.request("POST", "/FileManagement/DeleteFile", {
216
+ body: {path: remotePath.replace(/^\/+/, "")},
217
+ });
218
+ }
202
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, 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) {
@@ -21,31 +30,43 @@ export async function planDeployment({
21
30
  client,
22
31
  update = false,
23
32
  assetsOnly = false,
33
+ relink = false,
34
+ pruneAssets = false,
35
+ publish = false,
24
36
  prompts,
25
37
  log = console.log,
26
38
  }) {
27
39
  const manifest = readBuildManifest(root);
28
40
  const api = client || new BackofficeClient({brand: preset.id, token});
29
41
  const previous = readDeploymentState(preset.id, manifest.folder, root);
42
+ log("[dry-run] checking CDN and CMS state...");
30
43
  const assets = await planAssetChanges({
31
44
  manifest,
32
45
  state: previous,
33
46
  remoteRoot: new URL(preset.assetsBase).pathname,
34
47
  listFiles: api.listFiles?.bind(api),
48
+ prune: pruneAssets,
35
49
  });
36
50
  log(`[dry-run] no CMS data will be changed`);
37
51
  log(`[dry-run] target: ${preset.assetsBase}/${manifest.assetsPrefix}/`);
38
52
  logList(log, "assets to upload", paths(assets.upload));
39
53
  logList(log, "existing assets to skip", paths(assets.skip));
54
+ logList(log, "managed assets to remove", assets.remove);
40
55
  log(`[dry-run] unchanged assets: ${assets.unchanged}`);
41
56
  if (assetsOnly) return {assets};
42
57
 
43
58
  let placement;
44
59
  let pageContent;
45
60
  let page;
46
- if (previous.placement) {
61
+ if (relink) {
62
+ const selected = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
63
+ placement = selected.placement;
64
+ pageContent = selected.pageContent;
65
+ const checkout = !update && [1, 4].includes(pageContent.status);
66
+ page = {action: checkout ? "relink and create checkout version" : "relink and update selected version", pageContentId: placement.pageContentId};
67
+ } else if (previous.placement) {
47
68
  placement = previous.placement;
48
- pageContent = await api.getContent(placement.pageContentId);
69
+ pageContent = await validateSavedPlacement({client: api, placement});
49
70
  const checkout = !update && [1, 4].includes(pageContent.status);
50
71
  page = {action: checkout ? "create checkout version" : "update current version", pageContentId: placement.pageContentId};
51
72
  } else {
@@ -61,7 +82,11 @@ export async function planDeployment({
61
82
  logList(log, "widgets to update", widgets.update);
62
83
  logList(log, "widgets to remove", widgets.remove);
63
84
  logList(log, "widgets to reorder", widgets.reorder.map((item) => `${item.title} (${item.from} → ${item.to})`));
64
- 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};
65
90
  }
66
91
 
67
92
  export async function deployAssetsOnly({
@@ -71,6 +96,7 @@ export async function deployAssetsOnly({
71
96
  token = process.env.B2C_BACKOFFICE_TOKEN,
72
97
  client,
73
98
  log = console.log,
99
+ pruneAssets = false,
74
100
  }) {
75
101
  if (!token && !client) {
76
102
  throw new Error("Backoffice token is required. Set B2C_BACKOFFICE_TOKEN before deploy.");
@@ -83,6 +109,7 @@ export async function deployAssetsOnly({
83
109
  const state = readDeploymentState(preset.id, manifest.folder, root);
84
110
  const api = client || new BackofficeClient({brand: preset.id, token});
85
111
  log(`[deploy] target: ${cdnUrl}`);
112
+ log("[deploy] checking CDN state...");
86
113
  const result = await uploadChangedAssets({
87
114
  manifest,
88
115
  state,
@@ -90,9 +117,11 @@ export async function deployAssetsOnly({
90
117
  remoteRoot: new URL(preset.assetsBase).pathname,
91
118
  uploadFile: api.uploadFile.bind(api),
92
119
  listFiles: api.listFiles?.bind(api),
120
+ prune: pruneAssets,
121
+ deleteFile: api.deleteFile?.bind(api),
93
122
  });
94
123
  writeDeploymentState(preset.id, manifest.folder, result.state, root);
95
- 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}`);
96
125
  if (result.skipped.length) {
97
126
  log(`[deploy] existing assets were not overwritten: ${result.skipped.join(", ")}. Rename changed files to publish new URLs.`);
98
127
  }
@@ -106,6 +135,9 @@ export async function deployProject({
106
135
  token,
107
136
  client,
108
137
  update = false,
138
+ relink = false,
139
+ pruneAssets = false,
140
+ publish = false,
109
141
  prompts,
110
142
  log = console.log,
111
143
  }) {
@@ -113,6 +145,18 @@ export async function deployProject({
113
145
  const api = client || new BackofficeClient({brand: preset.id, token});
114
146
  const previous = readDeploymentState(preset.id, manifest.folder, root);
115
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...");
116
160
  const assets = await uploadChangedAssets({
117
161
  manifest,
118
162
  state: previous,
@@ -120,10 +164,16 @@ export async function deployProject({
120
164
  remoteRoot: new URL(preset.assetsBase).pathname,
121
165
  uploadFile: api.uploadFile.bind(api),
122
166
  listFiles: api.listFiles?.bind(api),
167
+ prune: pruneAssets,
168
+ deleteFile: api.deleteFile?.bind(api),
123
169
  });
124
- const page = previous.placement
125
- ? await prepareExistingPage({client: api, placement: previous.placement, update})
170
+ const page = selectedPage
171
+ ? await prepareExistingPage({client: api, placement: selectedPage.placement, update})
126
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
+ }
127
177
  const widgets = await syncWidgets({
128
178
  client: api,
129
179
  manifest,
@@ -131,16 +181,31 @@ export async function deployProject({
131
181
  pageContent: page.pageContent,
132
182
  root,
133
183
  });
134
- 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
+ }
135
197
  writeDeploymentState(preset.id, manifest.folder, state, root);
136
- const preview = page.pageContent.uniqueId
137
- ? `https://${preset.domain}/preview/${page.pageContent.uniqueId}/`
198
+ const preview = finalPageContent.uniqueId
199
+ ? `https://${preset.domain}/preview/${finalPageContent.uniqueId}/`
138
200
  : null;
139
- 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}`);
140
204
  if (assets.skipped.length) {
141
205
  log(`[deploy] existing assets were not overwritten: ${assets.skipped.join(", ")}. Rename changed files to publish new URLs.`);
142
206
  }
143
207
  log(`[deploy] widgets: added=${widgets.added}, updated=${widgets.updated}, removed=${widgets.removed}`);
144
208
  if (preview) log(`[deploy] preview: ${preview}`);
145
- return {assets, widgets, placement: page.placement, preview};
209
+ if (live) log(`[deploy] live: ${live}`);
210
+ return {assets, widgets, placement: page.placement, preview, live, published};
146
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
+ }
@@ -1,4 +1,4 @@
1
- import {HTML_WIDGET_ID, STATUS} from "./backoffice-client.mjs";
1
+ import {HTML_WIDGET_ID, RU_LANGUAGE_ID, STATUS} from "./backoffice-client.mjs";
2
2
 
3
3
  function suitableAreas(scheme) {
4
4
  return scheme.flatMap((row, rowIndex) => (row.children || []).map((column, columnIndex) => ({
@@ -10,6 +10,10 @@ function suitableAreas(scheme) {
10
10
  );
11
11
  }
12
12
 
13
+ function pageWidgets(pageContent) {
14
+ return pageContent.pageContents?.find((content) => String(content.languageId) === RU_LANGUAGE_ID)?.widgets || [];
15
+ }
16
+
13
17
  function widgetNames(area) {
14
18
  return (area.usedWidgets || []).map((widget, index) =>
15
19
  widget.widgetName || widget.cmsTitle || widget.name || `widget ${index + 1}`
@@ -24,6 +28,31 @@ function areaLabel({area, rowIndex, columnIndex}) {
24
28
  return `Row ${rowIndex + 1}, column ${columnIndex + 1}${width}: ${name} — ${summary}`;
25
29
  }
26
30
 
31
+
32
+ function existingAreaLabel(descriptor, widgets) {
33
+ const areaId = descriptor.area.layoutAreaId || descriptor.area.id;
34
+ const actual = widgets.filter((widget) => widget.layoutAreaId === areaId);
35
+ return areaLabel({
36
+ ...descriptor,
37
+ area: {...descriptor.area, usedWidgets: actual.map((widget) => ({widgetName: widget.cmsTitle || widget.widgetName}))},
38
+ });
39
+ }
40
+
41
+ function pageLabel(page, domain) {
42
+ const preview = page.contents?.[0]?.uniqueId;
43
+ const published = page.publishedUrls?.[RU_LANGUAGE_ID];
44
+ const link = published
45
+ ? `https://${domain}/${String(published).replace(/^\/+/, "")}`
46
+ : preview ? `https://${domain}/preview/${preview}/` : "no link";
47
+ return `${page.pageTitle || page.name || page.pageId} — ${link}`;
48
+ }
49
+
50
+ function versionLabel(content) {
51
+ const details = [content.statusName, content.version != null ? `v${content.version}` : null, content.author]
52
+ .filter(Boolean).join(", ");
53
+ return `${details || content.pageContentId}${content.modifiedTime ? ` — ${content.modifiedTime}` : ""}`;
54
+ }
55
+
27
56
  async function defaultPrompts() {
28
57
  const {isCancel, select, text} = await import("@clack/prompts");
29
58
  const answer = async (promise) => {
@@ -94,6 +123,62 @@ export async function selectNewPagePlacement({client, manifest, prompts}) {
94
123
  };
95
124
  }
96
125
 
126
+ export async function selectExistingPagePlacement({client, domain, prompts}) {
127
+ prompts ||= await defaultPrompts();
128
+ const applications = (await client.getApplications()).filter((app) => !/backoffice/i.test(app.name));
129
+ if (!applications.length) throw new Error("No backoffice applications are available");
130
+ const applicationId = await prompts.select({
131
+ message: "Application",
132
+ options: applications.map((app) => ({label: app.name, value: app.id})),
133
+ });
134
+ const searchBy = await prompts.select({
135
+ message: "Find an existing page by",
136
+ options: [{label: "Title", value: "title"}, {label: "URL", value: "url"}],
137
+ });
138
+ const query = await prompts.text({
139
+ message: searchBy === "title" ? "Page title (at least 3 characters)" : "Page URL (at least 3 characters)",
140
+ validate: (value) => value?.trim().length >= 3 ? undefined : "Enter at least 3 characters",
141
+ });
142
+ const pages = await client.getPages(applicationId, searchBy === "title"
143
+ ? {keyword: query.trim()}
144
+ : {url: query.trim()});
145
+ if (!pages.length) throw new Error(`No CMS pages found for ${JSON.stringify(query.trim())}`);
146
+ const pageId = await prompts.select({
147
+ message: "Existing CMS page",
148
+ options: pages.map((page) => ({label: pageLabel(page, domain), value: page.pageId || page.id, page})),
149
+ });
150
+ const page = pages.find((candidate) => (candidate.pageId || candidate.id) === pageId);
151
+ const versions = page.contents?.length ? page.contents : await client.getContentVersions(pageId);
152
+ if (!versions?.length) throw new Error("The selected CMS page has no versions");
153
+ const pageContentId = await prompts.select({
154
+ message: "Page version",
155
+ options: versions.map((content) => ({label: versionLabel(content), value: content.pageContentId || content.contentId})),
156
+ });
157
+ const pageContent = await client.getContent(pageContentId);
158
+ const areas = suitableAreas(pageContent.layout?.schema || []);
159
+ if (!areas.length) throw new Error("The selected page layout has no HTML widget area");
160
+ const widgets = pageWidgets(pageContent);
161
+ const layoutAreaId = await prompts.select({
162
+ message: "Where on the existing page should the landing blocks go?",
163
+ options: areas.map((descriptor) => ({
164
+ label: existingAreaLabel(descriptor, widgets),
165
+ value: descriptor.area.layoutAreaId || descriptor.area.id,
166
+ })),
167
+ });
168
+ const names = widgets.filter((widget) => widget.layoutAreaId === layoutAreaId)
169
+ .map((widget, index) => widget.cmsTitle || widget.widgetName || `widget ${index + 1}`);
170
+ const orderIndex = await prompts.select({
171
+ message: "Exact insertion point",
172
+ options: Array.from({length: names.length + 1}, (_, index) => ({
173
+ label: index === 0
174
+ ? names.length ? `At the beginning, before “${names[0]}”` : "Into the empty area"
175
+ : `After “${names[index - 1]}”`,
176
+ value: index,
177
+ })),
178
+ });
179
+ return {pageContent, placement: {pageContentId, layoutAreaId, orderIndex}};
180
+ }
181
+
97
182
  export async function createPagePlacement(options) {
98
183
  const selected = await selectNewPagePlacement(options);
99
184
  const {client} = options;
@@ -119,3 +204,57 @@ export async function prepareExistingPage({client, placement, update = false}) {
119
204
  }
120
205
  return {pageContent, placement};
121
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
  }