@coraltravelcenter/b2c-landing-builder 2.7.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ 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
27
28
  b2c-landing-vite deploy --assets-only
28
29
  b2c-landing-vite block:add hero
29
30
  b2c-landing-vite block:rename hero main-hero
@@ -49,6 +50,12 @@ b2c-landing-vite update
49
50
  Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
50
51
  - `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
51
52
  - `deploy --update` обновляет сохранённую версию страницы без создания новой.
53
+ - `deploy --relink` восстанавливает или меняет привязку проекта к существующей
54
+ CMS-странице. Builder предлагает application, поиск страницы по названию или
55
+ URL, версию, HTML-зону и точную позицию. Обычный запуск создаёт checkout для
56
+ опубликованной версии; вместе с `--update` выбранная версия меняется на месте.
57
+ - `deploy --relink --dry-run` проходит тот же интерактивный выбор и показывает
58
+ план, но не сохраняет новую привязку и не изменяет CMS.
52
59
  - `block:add` создаёт файлы в выбранных форматах разметки и стилей.
53
60
  - `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
54
61
  - `update` проверяет npm registry и показывает команду ручного обновления.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@coraltravelcenter/b2c-landing-builder",
9
- "version": "2.7.1",
9
+ "version": "2.8.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clack/prompts": "1.7.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "description": "CLI and build toolkit for B2C landing projects",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.mjs CHANGED
@@ -10,6 +10,7 @@ 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
13
14
  deploy --assets-only Build and sync assets with the B2C CDN
14
15
  check Validate project configuration
15
16
  block:add <key> Create a block
@@ -78,11 +79,11 @@ export async function runCli(args) {
78
79
  return;
79
80
  }
80
81
  if (command === "deploy") {
81
- const allowed = new Set(["--assets-only", "--update", "--dry-run"]);
82
+ const allowed = new Set(["--assets-only", "--update", "--dry-run", "--relink"]);
82
83
  const flags = new Set(rest);
83
84
  if (rest.some((argument) => !allowed.has(argument)) || flags.size !== rest.length ||
84
- (flags.has("--assets-only") && flags.has("--update"))) {
85
- throw new Error("Usage: b2c-landing-vite deploy [--assets-only | --update] [--dry-run]");
85
+ (flags.has("--assets-only") && (flags.has("--update") || flags.has("--relink")))) {
86
+ throw new Error("Usage: b2c-landing-vite deploy [--assets-only | --update] [--relink] [--dry-run]");
86
87
  }
87
88
  const config = await loadConfig();
88
89
  validateProject(config);
@@ -98,10 +99,17 @@ export async function runCli(args) {
98
99
  token,
99
100
  update: flags.has("--update"),
100
101
  assetsOnly: flags.has("--assets-only"),
102
+ relink: flags.has("--relink"),
101
103
  });
102
104
  }
103
105
  if (rest.includes("--assets-only")) return deployAssetsOnly({config, preset, token});
104
- return deployProject({config, preset, token, update: rest.includes("--update")});
106
+ return deployProject({
107
+ config,
108
+ preset,
109
+ token,
110
+ update: flags.has("--update"),
111
+ relink: flags.has("--relink"),
112
+ });
105
113
  }
106
114
  if (command === "block:add" || command === "block:rename") {
107
115
  const config = await loadConfig();
@@ -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
  }
@@ -2,7 +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 {createPagePlacement, prepareExistingPage, selectNewPagePlacement} from "./page.mjs";
5
+ import {createPagePlacement, prepareExistingPage, selectExistingPagePlacement, selectNewPagePlacement} from "./page.mjs";
6
6
  import {planWidgetChanges, syncWidgets} from "./widgets.mjs";
7
7
 
8
8
  function paths(items) {
@@ -21,6 +21,7 @@ export async function planDeployment({
21
21
  client,
22
22
  update = false,
23
23
  assetsOnly = false,
24
+ relink = false,
24
25
  prompts,
25
26
  log = console.log,
26
27
  }) {
@@ -43,7 +44,13 @@ export async function planDeployment({
43
44
  let placement;
44
45
  let pageContent;
45
46
  let page;
46
- if (previous.placement) {
47
+ if (relink) {
48
+ const selected = await selectExistingPagePlacement({client: api, domain: preset.domain, prompts});
49
+ placement = selected.placement;
50
+ pageContent = selected.pageContent;
51
+ const checkout = !update && [1, 4].includes(pageContent.status);
52
+ page = {action: checkout ? "relink and create checkout version" : "relink and update selected version", pageContentId: placement.pageContentId};
53
+ } else if (previous.placement) {
47
54
  placement = previous.placement;
48
55
  pageContent = await api.getContent(placement.pageContentId);
49
56
  const checkout = !update && [1, 4].includes(pageContent.status);
@@ -106,6 +113,7 @@ export async function deployProject({
106
113
  token,
107
114
  client,
108
115
  update = false,
116
+ relink = false,
109
117
  prompts,
110
118
  log = console.log,
111
119
  }) {
@@ -121,7 +129,10 @@ export async function deployProject({
121
129
  uploadFile: api.uploadFile.bind(api),
122
130
  listFiles: api.listFiles?.bind(api),
123
131
  });
124
- const page = previous.placement
132
+ const linked = relink ? await selectExistingPagePlacement({client: api, domain: preset.domain, prompts}) : null;
133
+ const page = linked
134
+ ? await prepareExistingPage({client: api, placement: linked.placement, update})
135
+ : previous.placement
125
136
  ? await prepareExistingPage({client: api, placement: previous.placement, update})
126
137
  : await createPagePlacement({client: api, manifest, prompts});
127
138
  const widgets = await syncWidgets({
@@ -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;