@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 +16 -0
- package/npm-shrinkwrap.json +42 -13
- package/package.json +2 -1
- package/src/cli/index.mjs +48 -21
- package/src/config/validate-project.mjs +2 -2
- package/src/config/validate-script-contract.mjs +31 -0
- package/src/deploy/assets.mjs +32 -5
- package/src/deploy/backoffice-client.mjs +6 -0
- package/src/deploy/deploy.mjs +127 -15
- package/src/deploy/lock.mjs +63 -0
- package/src/deploy/page.mjs +55 -0
- package/src/deploy/reconcile.mjs +47 -0
- package/src/deploy/widgets.mjs +21 -2
- package/src/lib/processMarkup.mjs +11 -1
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 и показывает команду ручного обновления.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coraltravelcenter/b2c-landing-builder",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@coraltravelcenter/b2c-landing-builder",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"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.
|
|
53
|
-
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.
|
|
54
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
1400
|
-
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.
|
|
1401
|
-
"integrity": "sha512-
|
|
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.
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 (!
|
|
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
|
+
}
|
package/src/deploy/assets.mjs
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/deploy/deploy.mjs
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
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 = {
|
|
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 =
|
|
148
|
-
? `https://${preset.domain}/preview/${
|
|
256
|
+
const preview = finalPageContent.uniqueId
|
|
257
|
+
? `https://${preset.domain}/preview/${finalPageContent.uniqueId}/`
|
|
149
258
|
: null;
|
|
150
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/deploy/page.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/deploy/widgets.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|