@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 +23 -0
- package/npm-shrinkwrap.json +42 -13
- package/package.json +2 -1
- package/src/cli/index.mjs +40 -14
- 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 +17 -0
- package/src/deploy/deploy.mjs +77 -12
- package/src/deploy/lock.mjs +63 -0
- package/src/deploy/page.mjs +140 -1
- package/src/lib/processMarkup.mjs +11 -1
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 и показывает команду ручного обновления.
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coraltravelcenter/b2c-landing-builder",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.
|
|
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.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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
}
|
|
@@ -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
|
}
|
package/src/deploy/deploy.mjs
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 =
|
|
125
|
-
? await prepareExistingPage({client: api, placement:
|
|
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 = {
|
|
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 =
|
|
137
|
-
? `https://${preset.domain}/preview/${
|
|
198
|
+
const preview = finalPageContent.uniqueId
|
|
199
|
+
? `https://${preset.domain}/preview/${finalPageContent.uniqueId}/`
|
|
138
200
|
: null;
|
|
139
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/deploy/page.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|