@coraltravelcenter/b2c-landing-builder 2.9.0 → 2.10.1
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 +21 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli/index.mjs +13 -4
- package/src/deploy/deploy.mjs +60 -2
- package/src/deploy/page.mjs +1 -0
- package/src/deploy/reconcile.mjs +47 -0
- package/src/deploy/widgets.mjs +21 -2
package/README.md
CHANGED
|
@@ -26,6 +26,9 @@ b2c-landing-vite deploy --dry-run
|
|
|
26
26
|
b2c-landing-vite deploy --update
|
|
27
27
|
b2c-landing-vite deploy --relink
|
|
28
28
|
b2c-landing-vite deploy --publish
|
|
29
|
+
b2c-landing-vite deploy --section hero
|
|
30
|
+
b2c-landing-vite deploy --remote
|
|
31
|
+
b2c-landing-vite deploy --force
|
|
29
32
|
b2c-landing-vite deploy --assets-only
|
|
30
33
|
b2c-landing-vite deploy --assets-only --prune-assets
|
|
31
34
|
b2c-landing-vite block:add hero
|
|
@@ -52,6 +55,20 @@ b2c-landing-vite update
|
|
|
52
55
|
Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
|
|
53
56
|
- `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
|
|
54
57
|
- `deploy --update` обновляет сохранённую версию страницы без создания новой.
|
|
58
|
+
- `deploy --section <key>` меняет только блок с ключом из `order.json`. Builder
|
|
59
|
+
берёт за основу актуальную remote-страницу, поэтому локальные копии соседних
|
|
60
|
+
секций не попадают в payload и не могут затереть изменения из CMS.
|
|
61
|
+
- После успешного deploy builder сохраняет снимок секций в `.b2c/deploy/` как
|
|
62
|
+
достоверный `base`. Следующий deploy структурно сравнивает секции `local`,
|
|
63
|
+
актуальный `remote` и `base`. Изменения разных секций объединяются; изменение
|
|
64
|
+
одной секции с обеих сторон считается конфликтом.
|
|
65
|
+
- Если `base` ещё отсутствует, отличие local от remote считается потенциальным
|
|
66
|
+
конфликтом. Обычный deploy не выполняет silent overwrite и предлагает явно
|
|
67
|
+
сохранить CMS-версию, применить local или отменить операцию.
|
|
68
|
+
- `deploy --remote` не перезаписывает конфликтующие секции локальной версией.
|
|
69
|
+
`deploy --force` осознанно выбирает local. Для section deploy обе стратегии
|
|
70
|
+
ограничены выбранной секцией: `deploy --section hero --remote` или
|
|
71
|
+
`deploy --section hero --force`; соседние remote-секции сохраняются.
|
|
55
72
|
- `deploy --relink` восстанавливает или меняет привязку проекта к существующей
|
|
56
73
|
CMS-странице. Builder предлагает application, поиск страницы по названию или
|
|
57
74
|
URL, версию, HTML-зону и точную позицию. Обычный запуск создаёт checkout для
|
|
@@ -72,6 +89,8 @@ b2c-landing-vite update
|
|
|
72
89
|
`deploy --relink`.
|
|
73
90
|
- Одновременные deploy одного проекта на компьютере блокируются lock-файлом в
|
|
74
91
|
`.b2c/deploy/`; аварийно оставленный lock автоматически признаётся устаревшим.
|
|
92
|
+
- Непосредственно перед первой записью CMS-состояние читается повторно. Если оно
|
|
93
|
+
изменилось во время проверки/выбора стратегии, deploy останавливается.
|
|
75
94
|
- `block:add` создаёт файлы в выбранных форматах разметки и стилей.
|
|
76
95
|
- `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
|
|
77
96
|
- `update` проверяет npm registry и показывает команду ручного обновления.
|
|
@@ -105,7 +124,7 @@ npm create @coraltravelcenter/b2c-landing-vite
|
|
|
105
124
|
export default {
|
|
106
125
|
schemaVersion: 2,
|
|
107
126
|
builder: {
|
|
108
|
-
minVersion: "2.
|
|
127
|
+
minVersion: "2.10.0",
|
|
109
128
|
},
|
|
110
129
|
project: {
|
|
111
130
|
name: "uae-promo",
|
|
@@ -162,6 +181,7 @@ src/
|
|
|
162
181
|
welcome.css | welcome.scss | welcome.less
|
|
163
182
|
scripts/
|
|
164
183
|
welcome.js | welcome.ts
|
|
184
|
+
utils/ # общие JavaScript-утилиты проекта
|
|
165
185
|
components/ # только для Vue
|
|
166
186
|
public/
|
|
167
187
|
pug.rc # только для Pug
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coraltravelcenter/b2c-landing-builder",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.1",
|
|
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.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@babel/parser": "7.28.6",
|
package/package.json
CHANGED
package/src/cli/index.mjs
CHANGED
|
@@ -12,6 +12,9 @@ Commands:
|
|
|
12
12
|
deploy --update Update the current CMS version in place
|
|
13
13
|
deploy --relink Attach the project to an existing CMS page
|
|
14
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
|
|
15
18
|
deploy --assets-only --prune-assets
|
|
16
19
|
Remove obsolete project assets recorded in deploy-state
|
|
17
20
|
deploy --assets-only Build and sync assets with the B2C CDN
|
|
@@ -82,13 +85,17 @@ export async function runCli(args) {
|
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
if (command === "deploy") {
|
|
85
|
-
const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink", "--publish", "--prune-assets"]);
|
|
86
|
-
const flags = new Set(rest);
|
|
87
|
-
|
|
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")) ||
|
|
88
95
|
(flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink") || flags.has("--publish"))) ||
|
|
89
96
|
(flags.has("--prune-assets") && !flags.has("--assets-only"))) {
|
|
90
97
|
throw new Error(
|
|
91
|
-
"Usage: b2c-landing-vite deploy [--
|
|
98
|
+
"Usage: b2c-landing-vite deploy [--section <key>] [--force | --remote] [--update] [--relink] [--publish] [--dry-run]"
|
|
92
99
|
);
|
|
93
100
|
}
|
|
94
101
|
const config = await loadConfig();
|
|
@@ -126,6 +133,8 @@ export async function runCli(args) {
|
|
|
126
133
|
relink: flags.has("--relink"),
|
|
127
134
|
publish: flags.has("--publish"),
|
|
128
135
|
pruneAssets: flags.has("--prune-assets"),
|
|
136
|
+
section,
|
|
137
|
+
strategy: flags.has("--force") ? "force" : flags.has("--remote") ? "remote" : "ask",
|
|
129
138
|
});
|
|
130
139
|
});
|
|
131
140
|
}
|
package/src/deploy/deploy.mjs
CHANGED
|
@@ -2,6 +2,7 @@ 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 {resolveSections, summarizeChanges} from "./reconcile.mjs";
|
|
5
6
|
import {
|
|
6
7
|
createPagePlacement,
|
|
7
8
|
prepareExistingPage,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
selectNewPagePlacement,
|
|
13
14
|
validateSavedPlacement,
|
|
14
15
|
} from "./page.mjs";
|
|
15
|
-
import {planWidgetChanges, syncWidgets} from "./widgets.mjs";
|
|
16
|
+
import {localSections, planWidgetChanges, remoteSections, syncWidgets} from "./widgets.mjs";
|
|
16
17
|
|
|
17
18
|
function paths(items) {
|
|
18
19
|
return items.map((item) => item.path);
|
|
@@ -139,6 +140,8 @@ export async function deployProject({
|
|
|
139
140
|
pruneAssets = false,
|
|
140
141
|
publish = false,
|
|
141
142
|
prompts,
|
|
143
|
+
section,
|
|
144
|
+
strategy = "ask",
|
|
142
145
|
log = console.log,
|
|
143
146
|
}) {
|
|
144
147
|
const manifest = readBuildManifest(root);
|
|
@@ -149,6 +152,7 @@ export async function deployProject({
|
|
|
149
152
|
? "[deploy] validating saved CMS placement..."
|
|
150
153
|
: relink ? "[deploy] selecting an existing CMS page..." : "[deploy] a new CMS page will be created");
|
|
151
154
|
let selectedPage = null;
|
|
155
|
+
let remoteBefore = null;
|
|
152
156
|
if (relink) selectedPage = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
|
|
153
157
|
else if (previous.placement) {
|
|
154
158
|
selectedPage = {
|
|
@@ -156,6 +160,53 @@ export async function deployProject({
|
|
|
156
160
|
pageContent: await validateSavedPlacement({client: api, placement: previous.placement}),
|
|
157
161
|
};
|
|
158
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
|
+
}
|
|
159
210
|
log("[deploy] synchronizing CDN assets...");
|
|
160
211
|
const assets = await uploadChangedAssets({
|
|
161
212
|
manifest,
|
|
@@ -180,11 +231,18 @@ export async function deployProject({
|
|
|
180
231
|
placement: page.placement,
|
|
181
232
|
pageContent: page.pageContent,
|
|
182
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,
|
|
183
238
|
});
|
|
184
239
|
const state = {
|
|
185
240
|
...assets.state,
|
|
186
241
|
placement: page.placement,
|
|
187
|
-
cms: {
|
|
242
|
+
cms: {
|
|
243
|
+
verifiedAt: new Date().toISOString(),
|
|
244
|
+
sections: remoteSections(await api.getContent(page.placement.pageContentId), page.placement, manifest.folder),
|
|
245
|
+
},
|
|
188
246
|
};
|
|
189
247
|
let published = false;
|
|
190
248
|
let finalPageContent = await api.getContent(page.placement.pageContentId);
|
package/src/deploy/page.mjs
CHANGED
|
@@ -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");
|
|
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,
|