@coraltravelcenter/b2c-landing-builder 2.7.0 → 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
@@ -38,6 +39,9 @@ b2c-landing-vite update
38
39
  скрытый пароль через ADFS. На диск сохраняется только токен (не пароль), токен
39
40
  автоматически используется до истечения. Для CI можно передать
40
41
  `B2C_BACKOFFICE_TOKEN`.
42
+ - Backoffice-запросы ограничены 30 секундами. Безопасные GET-запросы повторяются
43
+ до двух раз при сетевом сбое, timeout, HTTP 429, 502, 503 или 504. Изменяющие
44
+ CMS запросы автоматически не повторяются, чтобы не создать дубликаты.
41
45
  - `deploy` при первом запуске предлагает выбрать application, layout и HTML-зону,
42
46
  создаёт CMS-страницу и размещает блоки в порядке `order.json`. При повторном
43
47
  запуске для опубликованной страницы создаётся checkout-версия.
@@ -46,6 +50,12 @@ b2c-landing-vite update
46
50
  Страница, виджеты, ассеты и локальный deploy-state при этом не изменяются.
47
51
  - `deploy --assets-only --dry-run` показывает только план синхронизации ассетов.
48
52
  - `deploy --update` обновляет сохранённую версию страницы без создания новой.
53
+ - `deploy --relink` восстанавливает или меняет привязку проекта к существующей
54
+ CMS-странице. Builder предлагает application, поиск страницы по названию или
55
+ URL, версию, HTML-зону и точную позицию. Обычный запуск создаёт checkout для
56
+ опубликованной версии; вместе с `--update` выбранная версия меняется на месте.
57
+ - `deploy --relink --dry-run` проходит тот же интерактивный выбор и показывает
58
+ план, но не сохраняет новую привязку и не изменяет CMS.
49
59
  - `block:add` создаёт файлы в выбранных форматах разметки и стилей.
50
60
  - `block:rename` атомарно переименовывает все файлы блока и обновляет порядок.
51
61
  - `update` проверяет npm registry и показывает команду ручного обновления.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.7.0",
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.0",
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.0",
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();
@@ -4,15 +4,62 @@ import path from "node:path";
4
4
  export const RU_LANGUAGE_ID = "1049";
5
5
  export const HTML_WIDGET_ID = "d400b0e7-918d-4d4c-bea3-8522253b6544";
6
6
  export const STATUS = Object.freeze({published: 1, checkout: 2, unpublished: 4});
7
+ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
8
+
9
+ export class BackofficeRequestError extends Error {
10
+ constructor(message, {code, status, method, endpoint, retryable = false, cause} = {}) {
11
+ super(message, {cause});
12
+ this.name = "BackofficeRequestError";
13
+ this.code = code;
14
+ this.status = status;
15
+ this.method = method;
16
+ this.endpoint = endpoint;
17
+ this.retryable = retryable;
18
+ }
19
+ }
20
+
21
+ function errorCode(status) {
22
+ if (status === 400 || status === 422) return "VALIDATION";
23
+ if (status === 401 || status === 403) return "AUTH";
24
+ if (status === 404) return "NOT_FOUND";
25
+ if (status === 429) return "RATE_LIMIT";
26
+ if (status >= 500) return "SERVER";
27
+ return "HTTP";
28
+ }
29
+
30
+ function retryDelay(response, attempt) {
31
+ const header = response?.headers.get("retry-after");
32
+ if (header) {
33
+ const seconds = Number(header);
34
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
35
+ const timestamp = Date.parse(header);
36
+ if (Number.isFinite(timestamp)) return Math.max(0, timestamp - Date.now());
37
+ }
38
+ return 500 * (2 ** attempt);
39
+ }
40
+
41
+ const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
7
42
 
8
43
  export class BackofficeClient {
9
- constructor({brand, token, fetchImpl = globalThis.fetch}) {
44
+ constructor({
45
+ brand,
46
+ token,
47
+ fetchImpl = globalThis.fetch,
48
+ timeoutMs = 30_000,
49
+ maxRetries = 2,
50
+ sleep = defaultSleep,
51
+ log = console.warn,
52
+ }) {
10
53
  if (!new Set(["coral", "sunmar"]).has(brand)) throw new Error(`Unsupported backoffice brand: ${brand}`);
11
54
  if (!token) throw new Error("Backoffice token is required");
12
55
  if (typeof fetchImpl !== "function") throw new Error("Fetch API is unavailable");
13
56
  this.baseUrl = `https://b2capi.${brand}.ru/BackOffice`;
14
57
  this.token = token;
15
58
  this.fetchImpl = fetchImpl;
59
+ this.timeoutMs = timeoutMs;
60
+ this.maxRetries = maxRetries;
61
+ this.sleep = sleep;
62
+ this.log = log;
16
63
  }
17
64
 
18
65
  async request(method, endpoint, {query, body, form} = {}) {
@@ -20,17 +67,53 @@ export class BackofficeClient {
20
67
  for (const [key, value] of Object.entries(query || {})) url.searchParams.set(key, value);
21
68
  const headers = {token: this.token, authorization: `Bearer ${this.token}`};
22
69
  if (body !== undefined) headers["content-type"] = "application/json";
23
- const response = await this.fetchImpl(url, {
24
- method,
25
- headers,
26
- body: form || (body === undefined ? undefined : JSON.stringify(body)),
27
- });
28
- if (!response.ok) {
29
- const detail = await response.text().catch(() => "");
30
- throw new Error(`Backoffice ${method} ${endpoint} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
70
+ const canRetry = method === "GET";
71
+ for (let attempt = 0; ; attempt += 1) {
72
+ let response;
73
+ try {
74
+ response = await this.fetchImpl(url, {
75
+ method,
76
+ headers,
77
+ body: form || (body === undefined ? undefined : JSON.stringify(body)),
78
+ signal: AbortSignal.timeout(this.timeoutMs),
79
+ });
80
+ } catch (cause) {
81
+ const timeout = cause?.name === "TimeoutError" || cause?.name === "AbortError";
82
+ const retryable = canRetry && attempt < this.maxRetries;
83
+ if (retryable) {
84
+ const delay = 500 * (2 ** attempt);
85
+ this.log(`[backoffice] ${timeout ? "timeout" : "network error"}; retrying GET ${endpoint} in ${delay}ms`);
86
+ await this.sleep(delay);
87
+ continue;
88
+ }
89
+ throw new BackofficeRequestError(
90
+ `Backoffice ${method} ${endpoint} ${timeout ? `timed out after ${this.timeoutMs}ms` : `failed: ${cause?.message || "network error"}`}`,
91
+ {code: timeout ? "TIMEOUT" : "NETWORK", method, endpoint, retryable: canRetry, cause}
92
+ );
93
+ }
94
+ if (!response.ok) {
95
+ const detail = await response.text().catch(() => "");
96
+ const retryableStatus = RETRYABLE_STATUSES.has(response.status);
97
+ if (canRetry && retryableStatus && attempt < this.maxRetries) {
98
+ const delay = retryDelay(response, attempt);
99
+ this.log(`[backoffice] HTTP ${response.status}; retrying GET ${endpoint} in ${delay}ms`);
100
+ await this.sleep(delay);
101
+ continue;
102
+ }
103
+ throw new BackofficeRequestError(
104
+ `Backoffice ${method} ${endpoint} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
105
+ {
106
+ code: errorCode(response.status),
107
+ status: response.status,
108
+ method,
109
+ endpoint,
110
+ retryable: canRetry && retryableStatus,
111
+ }
112
+ );
113
+ }
114
+ const payload = await response.json();
115
+ return payload?.result;
31
116
  }
32
- const payload = await response.json();
33
- return payload?.result;
34
117
  }
35
118
 
36
119
  getContent(contentId) {
@@ -57,6 +140,17 @@ export class BackofficeClient {
57
140
  return result?.versions || [];
58
141
  }
59
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
+
60
154
  createPage(body) {
61
155
  return this.request("POST", "/Page/Create", {body});
62
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;