@escapenavigator/utils 1.10.145 → 1.10.147
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/dist/email-builder/from-html.d.ts +20 -113
- package/dist/email-builder/from-html.js +40 -796
- package/dist/email-builder/from-html.test.js +1 -608
- package/dist/email-builder/placeholders.js +7 -10
- package/dist/email-builder/strip-html.d.ts +1 -2
- package/dist/email-builder/strip-html.js +1 -2
- package/package.json +3 -4
|
@@ -1,507 +1,27 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* birthdayChild, custom-client-booking, scenario-emails) в формат
|
|
6
|
-
* `EmailContentJsonV2` нового редактора (Maily/TipTap).
|
|
3
|
+
* Нормализатор устаревших block-variable чипов в `EmailContentJsonV2`
|
|
4
|
+
* (Maily/TipTap) документах.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - Плоские плейсхолдеры `{varName}` → `variable`-ноды
|
|
14
|
-
* - Maily-style плейсхолдеры `{{varName}}` и `{{varName|"fallback"}}`
|
|
15
|
-
* — тоже превращаются в `variable`-ноды (с учётом fallback'а)
|
|
16
|
-
* - Управляющие конструкции:
|
|
17
|
-
* • `{{#if X}}…{{/if}}` → `section` нода с `showIfKey: 'X'`
|
|
18
|
-
* • `{{#manageBooking}}текст{{/manageBooking}}` → `button` нода
|
|
19
|
-
* c `isUrlVariable: true, url: 'bookingManagementLink'`
|
|
20
|
-
* - Блочные HTML-плейсхолдеры (`orderDetailsHtml`) — выносятся в
|
|
21
|
-
* отдельный `htmlCodeBlock` (их значение содержит `<div>`/`<table>`,
|
|
22
|
-
* нельзя оставить внутри `<p>`). `servicesList` тут не упоминается:
|
|
23
|
-
* функция up-sale-листа в письмах выпилена, см. `isServicesListVariableNode`
|
|
24
|
-
* - Цвета/фоны/font-size — выкидываются (как `clearQuill stripColors`)
|
|
6
|
+
* Раньше здесь жил полноценный конвертер legacy HTML (`feedbackText`) →
|
|
7
|
+
* v2 (`convertLegacyHtmlToEmailContent`). Он удалён: маркетинг мигрирует
|
|
8
|
+
* на `marketing-automation` «как есть» на v1 (legacy `feedbackText`), а
|
|
9
|
+
* переход на v2 идёт через базовый `createDefaultEmailContent`, а НЕ через
|
|
10
|
+
* авто-конвертацию старого HTML.
|
|
25
11
|
*
|
|
26
|
-
* Что
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* - Заголовки `<h1>`-`<h6>` — Quill их не использовал в шаблонах.
|
|
32
|
-
*
|
|
33
|
-
* Преобразование чистое и идемпотентное: повторный вызов на той же
|
|
34
|
-
* строке даёт тот же результат.
|
|
12
|
+
* Что осталось — `migratePhotosToImage`: идемпотентная нормализация уже
|
|
13
|
+
* сохранённых v2-доков (legacy `{photos}`-chip → редактируемая image-нода,
|
|
14
|
+
* выпиленный `{servicesList}` → удаление). Нужна и фронту
|
|
15
|
+
* (`hydrateEmailLocales` при открытии редактора), и бэку
|
|
16
|
+
* (`v2-renderer.util.ts` как последний рубеж перед отправкой).
|
|
35
17
|
*/
|
|
36
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
19
|
exports.normalizePlaceholderId = exports.LEGACY_TO_NAMESPACED_ID_MAP = exports.KNOWN_PLACEHOLDERS = exports.isKnownPlaceholder = exports.isBlockHtmlPlaceholder = exports.BLOCK_HTML_PLACEHOLDERS = void 0;
|
|
38
20
|
exports.migratePhotosToImage = migratePhotosToImage;
|
|
39
|
-
exports.convertLegacyHtmlToEmailContent = convertLegacyHtmlToEmailContent;
|
|
40
|
-
exports.findUnknownPlaceholders = findUnknownPlaceholders;
|
|
41
|
-
const email_builder_1 = require("@escapenavigator/types/dist/email-builder");
|
|
42
|
-
const node_html_parser_1 = require("node-html-parser");
|
|
43
|
-
const placeholders_1 = require("./placeholders");
|
|
44
|
-
Object.defineProperty(exports, "BLOCK_HTML_PLACEHOLDERS", { enumerable: true, get: function () { return placeholders_1.BLOCK_HTML_PLACEHOLDERS; } });
|
|
45
|
-
Object.defineProperty(exports, "isBlockHtmlPlaceholder", { enumerable: true, get: function () { return placeholders_1.isBlockHtmlPlaceholder; } });
|
|
46
|
-
Object.defineProperty(exports, "isKnownPlaceholder", { enumerable: true, get: function () { return placeholders_1.isKnownPlaceholder; } });
|
|
47
|
-
Object.defineProperty(exports, "KNOWN_PLACEHOLDERS", { enumerable: true, get: function () { return placeholders_1.KNOWN_PLACEHOLDERS; } });
|
|
48
|
-
Object.defineProperty(exports, "LEGACY_TO_NAMESPACED_ID_MAP", { enumerable: true, get: function () { return placeholders_1.LEGACY_TO_NAMESPACED_ID_MAP; } });
|
|
49
|
-
Object.defineProperty(exports, "normalizePlaceholderId", { enumerable: true, get: function () { return placeholders_1.normalizePlaceholderId; } });
|
|
50
|
-
const VARIABLE_NODE_DEFAULTS = {
|
|
51
|
-
fallback: null,
|
|
52
|
-
required: false,
|
|
53
|
-
hideDefaultValue: false,
|
|
54
|
-
};
|
|
55
|
-
/**
|
|
56
|
-
* Sentinel HTML-теги, которые мы вставляем на этапе preprocess'а
|
|
57
|
-
* (`{{#if}}`, `{var}` и т.д.). Используем `data-mly-*` атрибуты,
|
|
58
|
-
* потому что они валидные с точки зрения HTML-парсера и легко
|
|
59
|
-
* фильтруются на этапе walk.
|
|
60
|
-
*
|
|
61
|
-
* Префикс `mly-` намеренно не совпадает с реальными data-атрибутами
|
|
62
|
-
* Maily/TipTap (чтобы случайный collision был исключён).
|
|
63
|
-
*/
|
|
64
|
-
const MARKER_IF = 'data-mly-conv-if';
|
|
65
|
-
const MARKER_BUTTON = 'data-mly-conv-button';
|
|
66
|
-
const MARKER_VAR = 'data-mly-conv-var';
|
|
67
|
-
const MARKER_VAR_FALLBACK = 'data-mly-conv-fallback';
|
|
68
|
-
/**
|
|
69
|
-
* Pre-processing: переписываем text-level конструкции (`{{#if}}`,
|
|
70
|
-
* `{{#manageBooking}}`, `{var}`) в HTML-теги-маркеры. После этого
|
|
71
|
-
* стандартный HTML-парсер видит чистый HTML с обычными узлами,
|
|
72
|
-
* которые на этапе walk'а превращаются в TipTap-ноды нужного типа.
|
|
73
|
-
*
|
|
74
|
-
* Порядок применения важен:
|
|
75
|
-
* 1. `{{#if X}}…{{/if}}` — самый внешний контейнер, может содержать
|
|
76
|
-
* внутри другие конструкции
|
|
77
|
-
* 2. `{{#manageBooking}}текст{{/manageBooking}}` — содержит текст
|
|
78
|
-
* кнопки (тоже может быть с переменными)
|
|
79
|
-
* 3. `{{var|"fallback"}}` — двойные с fallback (maily-style)
|
|
80
|
-
* 4. `{{var}}` — двойные без fallback (maily-style)
|
|
81
|
-
* 5. `{var}` — одинарные (Quill-style)
|
|
82
|
-
*
|
|
83
|
-
* Шаги 3-5 не должны зацеплять служебные `{{#…}}`/`{{/…}}` — поэтому
|
|
84
|
-
* сначала перерабатываем (1) и (2).
|
|
85
|
-
*/
|
|
86
|
-
function preprocessHtml(html) {
|
|
87
|
-
let out = html;
|
|
88
|
-
// (1) {{#if X}}…{{/if}} → <div data-mly-conv-if="X">…</div>
|
|
89
|
-
//
|
|
90
|
-
// Reverse-passes до тех пор, пока есть совпадения — на случай
|
|
91
|
-
// вложенных IF (редко, но в кастомных шаблонах возможны).
|
|
92
|
-
const IF_RE = /\{\{#if\s+(\w[\w.]*)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
|
93
|
-
let prev = '';
|
|
94
|
-
while (prev !== out) {
|
|
95
|
-
prev = out;
|
|
96
|
-
out = out.replace(IF_RE, (_m, key, inner) => `<div ${MARKER_IF}="${escapeAttr(key)}">${inner}</div>`);
|
|
97
|
-
}
|
|
98
|
-
// (2) {{#manageBooking}}текст{{/manageBooking}} → <span data-mly-conv-button="bookingManagementLink">текст</span>
|
|
99
|
-
out = out.replace(/\{\{#manageBooking\s*\}\}([\s\S]*?)\{\{\/manageBooking\}\}/g, (_m, inner) => `<span ${MARKER_BUTTON}="bookingManagementLink">${inner.trim()}</span>`);
|
|
100
|
-
// (3) {{var|"fallback"}} — двойные с fallback
|
|
101
|
-
out = out.replace(/\{\{\s*([\w.]+)\s*\|\s*"([^"]*)"\s*\}\}/g, (_m, name, fallback) => `<span ${MARKER_VAR}="${escapeAttr(name)}" ${MARKER_VAR_FALLBACK}="${escapeAttr(fallback)}"></span>`);
|
|
102
|
-
// (4) {{var}} — двойные без fallback. Исключаем оставшиеся
|
|
103
|
-
// служебные блоки `{{#…}}` и `{{/…}}` (теоретически их не должно
|
|
104
|
-
// быть после шагов 1-2, но защищаемся).
|
|
105
|
-
out = out.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, name) => `<span ${MARKER_VAR}="${escapeAttr(name)}"></span>`);
|
|
106
|
-
// (5) {var} — одинарные (Quill-style). Имя — только буквы/цифры,
|
|
107
|
-
// чтобы не цеплять случайные `{` в тексте писем (например цифровые
|
|
108
|
-
// диапазоны "{5,10}" в правилах брони, хотя в marketing их не бывает).
|
|
109
|
-
out = out.replace(/\{([\w]+)\}/g, (_m, name) => `<span ${MARKER_VAR}="${escapeAttr(name)}"></span>`);
|
|
110
|
-
return out;
|
|
111
|
-
}
|
|
112
|
-
function escapeAttr(s) {
|
|
113
|
-
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Карта inline-тэгов → имя TipTap-mark'а. Только теги, которые реально
|
|
117
|
-
* используются в legacy шаблонах. Span/font/style/color — игнорируем
|
|
118
|
-
* (стили вычищены `clearQuill stripColors` на момент сохранения, а
|
|
119
|
-
* если что-то осталось — теряем сознательно, см. dock'а вверху файла).
|
|
120
|
-
*/
|
|
121
|
-
const INLINE_MARK_BY_TAG = {
|
|
122
|
-
strong: 'bold',
|
|
123
|
-
b: 'bold',
|
|
124
|
-
em: 'italic',
|
|
125
|
-
i: 'italic',
|
|
126
|
-
u: 'underline',
|
|
127
|
-
s: 'strike',
|
|
128
|
-
strike: 'strike',
|
|
129
|
-
del: 'strike',
|
|
130
|
-
};
|
|
131
|
-
/**
|
|
132
|
-
* Имя TipTap-блока для тэга. `null` — значит «прозрачный контейнер»,
|
|
133
|
-
* у которого мы только рекурсивно собираем содержимое.
|
|
134
|
-
*/
|
|
135
|
-
function blockTypeForTag(tag) {
|
|
136
|
-
switch (tag) {
|
|
137
|
-
case 'p':
|
|
138
|
-
return 'paragraph';
|
|
139
|
-
case 'div':
|
|
140
|
-
return 'paragraph';
|
|
141
|
-
default:
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Уровень `<h1>`/`<h2>`/`<h3>` → `attrs.level` для TipTap heading-ноды.
|
|
147
|
-
* `<h4>`-`<h6>` намеренно ОТСУТСТВУЮТ: Maily-renderer знает только три
|
|
148
|
-
* первых уровня (с собственной типографикой: 36/30/24 px), остальные
|
|
149
|
-
* у него падают на дефолтный h1-стиль. Чтобы такого визуального
|
|
150
|
-
* сюрприза не случилось, h4-h6 уходят в transparent-fallback и
|
|
151
|
-
* рендерятся как обычный inline-текст.
|
|
152
|
-
*/
|
|
153
|
-
const HEADING_LEVELS = { h1: 1, h2: 2, h3: 3 };
|
|
154
|
-
function walk(root, ctx) {
|
|
155
|
-
const out = [];
|
|
156
|
-
for (const child of root.childNodes) {
|
|
157
|
-
const nodes = walkBlock(child, ctx);
|
|
158
|
-
for (const n of nodes)
|
|
159
|
-
out.push(n);
|
|
160
|
-
}
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
163
|
-
function walkBlock(node, ctx) {
|
|
164
|
-
// Голый текст на верхнем уровне (без обёртки в <p>): оборачиваем
|
|
165
|
-
// в параграф, чтобы получить валидный TipTap-doc. Пустые/whitespace
|
|
166
|
-
// текстовые ноды на блочном уровне игнорируем — это переносы строк
|
|
167
|
-
// между тегами в исходнике, в редакторе они не нужны.
|
|
168
|
-
if (node.nodeType === node_html_parser_1.NodeType.TEXT_NODE) {
|
|
169
|
-
const text = node.rawText;
|
|
170
|
-
if (!text?.trim())
|
|
171
|
-
return [];
|
|
172
|
-
return [paragraphFromInline([{ type: 'text', text: decodeText(text) }])];
|
|
173
|
-
}
|
|
174
|
-
if (node.nodeType !== node_html_parser_1.NodeType.ELEMENT_NODE)
|
|
175
|
-
return [];
|
|
176
|
-
const el = node;
|
|
177
|
-
const tag = el.tagName?.toLowerCase() || '';
|
|
178
|
-
// (a) IF-блок — превращаем в section с showIfKey
|
|
179
|
-
if (el.getAttribute(MARKER_IF) != null) {
|
|
180
|
-
const key = el.getAttribute(MARKER_IF) || '';
|
|
181
|
-
const innerBlocks = walk(el, ctx);
|
|
182
|
-
return [
|
|
183
|
-
{
|
|
184
|
-
type: 'section',
|
|
185
|
-
attrs: {
|
|
186
|
-
showIfKey: key,
|
|
187
|
-
/*
|
|
188
|
-
* Всё обнулено, чтобы section не давала визуального
|
|
189
|
-
* «ящика» вокруг IF-контента — кроме `marginBottom`.
|
|
190
|
-
*
|
|
191
|
-
* `marginBottom: 20` — повторяем поведение обычного
|
|
192
|
-
* `<p>` (Maily эмитит `<p style="margin-bottom: 20px">`).
|
|
193
|
-
* Если оставить 0, в превью соседние IF-секции и
|
|
194
|
-
* следующие за ними параграфы слипаются: внутренний
|
|
195
|
-
* `<p style="margin-bottom: 20">` рендерится внутри
|
|
196
|
-
* `<table data-type="section">` и его margin не
|
|
197
|
-
* прорывается наружу через table-ячейку (margin-
|
|
198
|
-
* collapse в email-клиентах работает нестабильно).
|
|
199
|
-
* Внешний `marginBottom` на самой section даёт
|
|
200
|
-
* предсказуемые 20px gap снаружи.
|
|
201
|
-
*/
|
|
202
|
-
borderRadius: 0,
|
|
203
|
-
backgroundColor: 'transparent',
|
|
204
|
-
borderWidth: 0,
|
|
205
|
-
borderColor: 'transparent',
|
|
206
|
-
paddingTop: 0,
|
|
207
|
-
paddingRight: 0,
|
|
208
|
-
paddingBottom: 0,
|
|
209
|
-
paddingLeft: 0,
|
|
210
|
-
marginTop: 0,
|
|
211
|
-
marginRight: 0,
|
|
212
|
-
marginBottom: 20,
|
|
213
|
-
marginLeft: 0,
|
|
214
|
-
align: 'left',
|
|
215
|
-
},
|
|
216
|
-
content: innerBlocks,
|
|
217
|
-
},
|
|
218
|
-
];
|
|
219
|
-
}
|
|
220
|
-
// (b) Button-блок (бывшая кнопка бронирования)
|
|
221
|
-
if (el.getAttribute(MARKER_BUTTON)) {
|
|
222
|
-
const urlVar = el.getAttribute(MARKER_BUTTON) || 'bookingManagementLink';
|
|
223
|
-
const text = el.text?.trim() || 'Управлять бронью';
|
|
224
|
-
/*
|
|
225
|
-
* Минимальный набор attrs — см. развёрнутый комментарий в
|
|
226
|
-
* `bookingLinkButtonNode()`. Maily-дефолты:
|
|
227
|
-
* `alignment:'left'`, `variant:'filled'`, `borderRadius:'smooth'`,
|
|
228
|
-
* `padding*:null` (берётся из `theme.button` → фирменный оранжевый).
|
|
229
|
-
* Зашивать сюда явные значения нельзя — bubble-menu toolbar
|
|
230
|
-
* (выровнять влево/центр/вправо) тогда не реагирует.
|
|
231
|
-
*/
|
|
232
|
-
return [
|
|
233
|
-
{
|
|
234
|
-
type: 'button',
|
|
235
|
-
attrs: {
|
|
236
|
-
text,
|
|
237
|
-
isTextVariable: false,
|
|
238
|
-
url: urlVar,
|
|
239
|
-
isUrlVariable: true,
|
|
240
|
-
},
|
|
241
|
-
},
|
|
242
|
-
];
|
|
243
|
-
}
|
|
244
|
-
// (c) Изображение
|
|
245
|
-
if (tag === 'img') {
|
|
246
|
-
const src = el.getAttribute('src') || '';
|
|
247
|
-
if (!src)
|
|
248
|
-
return [];
|
|
249
|
-
return [
|
|
250
|
-
{
|
|
251
|
-
type: 'image',
|
|
252
|
-
attrs: {
|
|
253
|
-
src,
|
|
254
|
-
alt: el.getAttribute('alt') || '',
|
|
255
|
-
title: el.getAttribute('title') || '',
|
|
256
|
-
width: 'auto',
|
|
257
|
-
height: 'auto',
|
|
258
|
-
alignment: 'left',
|
|
259
|
-
borderRadius: 0,
|
|
260
|
-
externalLink: '',
|
|
261
|
-
},
|
|
262
|
-
},
|
|
263
|
-
];
|
|
264
|
-
}
|
|
265
|
-
// (d) Heading — `<h1>`/`<h2>`/`<h3>`. Maily-renderer знает уровни
|
|
266
|
-
// 1..3 (см. `ne` в minified исходнике: h1=36px/40lh/800w,
|
|
267
|
-
// h2=30px/36lh/700w, h3=24px/38lh/600w); `<h4>`-`<h6>` рендерятся
|
|
268
|
-
// по дефолту как h1 (level<1 || >3 → 1) — мы их не маппим, пусть
|
|
269
|
-
// падают в общий transparent-fallback (е).
|
|
270
|
-
//
|
|
271
|
-
// Зачем: CDN-шаблоны хотят давать структурную иерархию через
|
|
272
|
-
// <h3>Лейбл</h3><p>значение</p> вместо <p><strong>Лейбл:</strong></p>
|
|
273
|
-
// <p>значение</p> — второй вариант визуально не отличается от
|
|
274
|
-
// соседних блоков (одинаковый margin-bottom: 20px у всех <p>).
|
|
275
|
-
const headingLevel = HEADING_LEVELS[tag];
|
|
276
|
-
if (headingLevel) {
|
|
277
|
-
const inline = collectInline(el, [], ctx);
|
|
278
|
-
if (inline.length === 0)
|
|
279
|
-
return [];
|
|
280
|
-
const hasMeaningful = inline.some((n) => n.type !== 'hardBreak' && (n.type !== 'text' || (n.text && n.text.trim() !== '')));
|
|
281
|
-
if (!hasMeaningful)
|
|
282
|
-
return [];
|
|
283
|
-
return [
|
|
284
|
-
{
|
|
285
|
-
type: 'heading',
|
|
286
|
-
/*
|
|
287
|
-
* `textAlign: 'left'` — дефолт у Maily, выставляем явно,
|
|
288
|
-
* чтобы heading-нода прошла валидацию TipTap-schema
|
|
289
|
-
* (heading-extension объявляет `textAlign` обязательным
|
|
290
|
-
* атрибутом, без него нода может выпасть на этапе
|
|
291
|
-
* `parse → render`-цикла в редакторе).
|
|
292
|
-
*/
|
|
293
|
-
attrs: { level: headingLevel, textAlign: 'left' },
|
|
294
|
-
content: inline,
|
|
295
|
-
},
|
|
296
|
-
];
|
|
297
|
-
}
|
|
298
|
-
// (e) Блочный контейнер — собираем inline content рекурсивно
|
|
299
|
-
const blockType = blockTypeForTag(tag);
|
|
300
|
-
if (blockType) {
|
|
301
|
-
const inline = collectInline(el, [], ctx);
|
|
302
|
-
if (inline.length === 0)
|
|
303
|
-
return [];
|
|
304
|
-
// Если параграф состоит только из пустых текстов (вроде <p><br></p>) — выкидываем
|
|
305
|
-
const hasMeaningfulContent = inline.some((n) => n.type !== 'hardBreak' && (n.type !== 'text' || (n.text && n.text.trim() !== '')));
|
|
306
|
-
if (!hasMeaningfulContent)
|
|
307
|
-
return [];
|
|
308
|
-
return [paragraphFromInline(inline)];
|
|
309
|
-
}
|
|
310
|
-
// (e) Неизвестный тэг на блочном уровне — раскрываем содержимое (transparent)
|
|
311
|
-
return walk(el, ctx);
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* Собирает inline-content (содержимое одного параграфа). Возвращает
|
|
315
|
-
* массив `text`/`variable`/`hardBreak`/`image` нод. Marks накапливаются
|
|
316
|
-
* через стек.
|
|
317
|
-
*/
|
|
318
|
-
function collectInline(el, marks, ctx) {
|
|
319
|
-
const out = [];
|
|
320
|
-
for (const child of el.childNodes) {
|
|
321
|
-
if (child.nodeType === node_html_parser_1.NodeType.TEXT_NODE) {
|
|
322
|
-
const text = child.rawText;
|
|
323
|
-
if (!text)
|
|
324
|
-
continue;
|
|
325
|
-
const decoded = decodeText(text);
|
|
326
|
-
if (!decoded)
|
|
327
|
-
continue;
|
|
328
|
-
out.push(textNode(decoded, marks));
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
if (child.nodeType !== node_html_parser_1.NodeType.ELEMENT_NODE)
|
|
332
|
-
continue;
|
|
333
|
-
const c = child;
|
|
334
|
-
const tag = c.tagName?.toLowerCase() || '';
|
|
335
|
-
// Variable-маркер
|
|
336
|
-
if (c.getAttribute(MARKER_VAR)) {
|
|
337
|
-
const id = c.getAttribute(MARKER_VAR) || '';
|
|
338
|
-
const fallback = c.getAttribute(MARKER_VAR_FALLBACK);
|
|
339
|
-
/*
|
|
340
|
-
* `inlineValues` — литеральная подстановка ДО любых других
|
|
341
|
-
* проверок. Используется для legacy-плейсхолдеров без
|
|
342
|
-
* резолвера (`profileTitle`): зашиваем буквальное название
|
|
343
|
-
* компании в текст, чтобы пользователь не получил пустую
|
|
344
|
-
* variable-ноду на месте уже не существующей переменной.
|
|
345
|
-
*
|
|
346
|
-
* Лукап по исходному (pre-mapping) id, потому что
|
|
347
|
-
* inline-value семантика — про legacy слово, а не про
|
|
348
|
-
* новый namespaced id (которого может вовсе не быть в
|
|
349
|
-
* `idMap`).
|
|
350
|
-
*/
|
|
351
|
-
if (id in ctx.inlineValues) {
|
|
352
|
-
const literal = ctx.inlineValues[id];
|
|
353
|
-
if (literal) {
|
|
354
|
-
out.push(textNode(literal, marks));
|
|
355
|
-
}
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
|
-
/*
|
|
359
|
-
* Особый случай: `{photos}` в legacy шаблонах — это HTML
|
|
360
|
-
* галерея <img>'ов всех загруженных фотографий команды.
|
|
361
|
-
* В новом редакторе мы заменяем это на одну `image`-ноду
|
|
362
|
-
* с переменной `src = photos` (которая на send-time
|
|
363
|
-
* резолвится в URL ПЕРВОЙ фотки игры). Так пользователь
|
|
364
|
-
* видит ОДНУ нормально стилизованную картинку, которую
|
|
365
|
-
* можно ресайзить/выравнивать через UI Maily, а не
|
|
366
|
-
* raw-HTML-блок, который нельзя редактировать.
|
|
367
|
-
*
|
|
368
|
-
* Если в order'е нет ни одной фотки — резолвер `photos`
|
|
369
|
-
* вернёт `''`, и v2-renderer корректно отрисует пустую
|
|
370
|
-
* `src=""` — почтовики такие image просто скрывают.
|
|
371
|
-
* См. также `order.resolver.ts → photos`.
|
|
372
|
-
*/
|
|
373
|
-
if (id === 'photos') {
|
|
374
|
-
out.push(photoFirstImageNode());
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
/*
|
|
378
|
-
* `servicesList` (legacy up-sale list) — функция выпилена
|
|
379
|
-
* из email-системы целиком. Если приехало в HTML/JSON из
|
|
380
|
-
* старых данных, просто игнорим chip: `continue` без push'а
|
|
381
|
-
* пропускает variable-ноду, текст вокруг остаётся как был.
|
|
382
|
-
* См. также `LEGACY_BLOCK_VARIABLE_REPLACERS` (там тот же
|
|
383
|
-
* id мапится в пустой массив для уже сохранённых doc'ов).
|
|
384
|
-
*/
|
|
385
|
-
if (id === 'servicesList')
|
|
386
|
-
continue;
|
|
387
|
-
/*
|
|
388
|
-
* `bookingManagementLink` — единственный смысловой вариант
|
|
389
|
-
* этой переменной: красивая центрированная кнопка «Manage
|
|
390
|
-
* booking». В legacy его иногда вставляли inline (`{bookingManagementLink}`
|
|
391
|
-
* прямо в текст ссылки), что давало `<a href="…токен…">…</a>`.
|
|
392
|
-
* Промоутим в полноценную button-ноду — `promoteBlockHtmlVariables`
|
|
393
|
-
* сам вынесет её наверх параграфа.
|
|
394
|
-
*
|
|
395
|
-
* Не трогаем варианты внутри `{{#manageBooking}}…{{/}}` —
|
|
396
|
-
* они уже стали `MARKER_BUTTON` на этапе preprocess'а и
|
|
397
|
-
* сюда не доходят (другая ветка с `MARKER_BUTTON`).
|
|
398
|
-
*/
|
|
399
|
-
if (id === 'bookingManagementLink') {
|
|
400
|
-
out.push(bookingLinkButtonNode());
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
out.push(variableNode(id, fallback ?? null, ctx.labels));
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
// Button-маркер (manageBooking). Maily-button — block-level
|
|
407
|
-
// нода, но в исходнике он стоит внутри `<p>`, так что ловим
|
|
408
|
-
// его inline и потом промотируем наверх в `promoteBlockHtmlVariables`.
|
|
409
|
-
if (c.getAttribute(MARKER_BUTTON)) {
|
|
410
|
-
const urlVar = c.getAttribute(MARKER_BUTTON) || 'bookingManagementLink';
|
|
411
|
-
const text = c.text?.trim() || 'Управлять бронью';
|
|
412
|
-
// Минимальный набор attrs — см. `bookingLinkButtonNode()`.
|
|
413
|
-
out.push({
|
|
414
|
-
type: 'button',
|
|
415
|
-
attrs: {
|
|
416
|
-
text,
|
|
417
|
-
isTextVariable: false,
|
|
418
|
-
url: urlVar,
|
|
419
|
-
isUrlVariable: true,
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
// <br>
|
|
425
|
-
if (tag === 'br') {
|
|
426
|
-
out.push({ type: 'hardBreak' });
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
// <img> внутри inline — оставляем как inline (paragraph-level image
|
|
430
|
-
// тоже валиден, см. maily-render `image()`).
|
|
431
|
-
if (tag === 'img') {
|
|
432
|
-
const src = c.getAttribute('src') || '';
|
|
433
|
-
if (src) {
|
|
434
|
-
out.push({
|
|
435
|
-
type: 'image',
|
|
436
|
-
attrs: {
|
|
437
|
-
src,
|
|
438
|
-
alt: c.getAttribute('alt') || '',
|
|
439
|
-
title: c.getAttribute('title') || '',
|
|
440
|
-
width: 'auto',
|
|
441
|
-
height: 'auto',
|
|
442
|
-
alignment: 'left',
|
|
443
|
-
borderRadius: 0,
|
|
444
|
-
externalLink: '',
|
|
445
|
-
},
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
continue;
|
|
449
|
-
}
|
|
450
|
-
// Inline-mark теги
|
|
451
|
-
const markName = INLINE_MARK_BY_TAG[tag];
|
|
452
|
-
if (markName) {
|
|
453
|
-
const next = [...marks, { type: markName }];
|
|
454
|
-
for (const n of collectInline(c, next, ctx))
|
|
455
|
-
out.push(n);
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
// <a> — link-mark
|
|
459
|
-
if (tag === 'a') {
|
|
460
|
-
const href = c.getAttribute('href') || '';
|
|
461
|
-
const next = [
|
|
462
|
-
...marks,
|
|
463
|
-
{
|
|
464
|
-
type: 'link',
|
|
465
|
-
attrs: {
|
|
466
|
-
href,
|
|
467
|
-
target: c.getAttribute('target') || '_blank',
|
|
468
|
-
rel: c.getAttribute('rel') || 'noopener noreferrer nofollow',
|
|
469
|
-
class: null,
|
|
470
|
-
},
|
|
471
|
-
},
|
|
472
|
-
];
|
|
473
|
-
for (const n of collectInline(c, next, ctx))
|
|
474
|
-
out.push(n);
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
// Неизвестный inline-тэг → раскрываем содержимое
|
|
478
|
-
for (const n of collectInline(c, marks, ctx))
|
|
479
|
-
out.push(n);
|
|
480
|
-
}
|
|
481
|
-
return out;
|
|
482
|
-
}
|
|
483
|
-
function textNode(text, marks) {
|
|
484
|
-
if (marks.length === 0)
|
|
485
|
-
return { type: 'text', text };
|
|
486
|
-
return { type: 'text', text, marks: [...marks] };
|
|
487
|
-
}
|
|
488
|
-
function variableNode(id, fallback, labels) {
|
|
489
|
-
return {
|
|
490
|
-
type: 'variable',
|
|
491
|
-
attrs: {
|
|
492
|
-
id,
|
|
493
|
-
label: labels[id] || id,
|
|
494
|
-
fallback,
|
|
495
|
-
required: VARIABLE_NODE_DEFAULTS.required,
|
|
496
|
-
hideDefaultValue: VARIABLE_NODE_DEFAULTS.hideDefaultValue,
|
|
497
|
-
},
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
21
|
/**
|
|
501
22
|
* Image-нода с переменным `src = photos`. Заменяет legacy
|
|
502
23
|
* `{photos}`-плейсхолдер (HTML-галерею) на один редактируемый image,
|
|
503
|
-
* совместимый с Maily UI (resize/align).
|
|
504
|
-
* выше, в ветке `id === 'photos'`.
|
|
24
|
+
* совместимый с Maily UI (resize/align).
|
|
505
25
|
*
|
|
506
26
|
* `isSrcVariable: true` — режим Maily, при котором `attrs.src` лечится
|
|
507
27
|
* как имя переменной, а не URL. На рендере (`@maily-to/render`) этот
|
|
@@ -534,17 +54,25 @@ function photoFirstImageNode() {
|
|
|
534
54
|
*/
|
|
535
55
|
width: 200,
|
|
536
56
|
height: 200,
|
|
537
|
-
alignment: '
|
|
57
|
+
alignment: 'left',
|
|
538
58
|
borderRadius: 8,
|
|
539
|
-
|
|
59
|
+
/*
|
|
60
|
+
* Картинка сама является ссылкой: `externalLink` — та же
|
|
61
|
+
* переменная `photos` (`isExternalLinkVariable: true`),
|
|
62
|
+
* резолвится в URL первого фото. Maily оборачивает `<img>`
|
|
63
|
+
* в `<a href={URL}>`, поэтому клик по фото открывает
|
|
64
|
+
* полноразмерный снимок (отдельная ссылка «Скачать» не нужна).
|
|
65
|
+
*/
|
|
66
|
+
externalLink: 'photos',
|
|
67
|
+
isExternalLinkVariable: true,
|
|
540
68
|
},
|
|
541
69
|
};
|
|
542
70
|
}
|
|
543
71
|
/*
|
|
544
72
|
* Все известные id'ы, которыми «фото первого заказа» когда-либо обоз-
|
|
545
73
|
* началось в TipTap-doc. Используется нормализатором ниже, чтобы при
|
|
546
|
-
* следующей операции с письмом (
|
|
547
|
-
*
|
|
74
|
+
* следующей операции с письмом (hydrate, render) все варианты сходились
|
|
75
|
+
* к одному `image`-узлу с `src = photos`.
|
|
548
76
|
*
|
|
549
77
|
* - `photos` — текущий id (flat, исторический legacy-token);
|
|
550
78
|
* - `order.photoFirst` — временный namespaced id, существовал на
|
|
@@ -571,9 +99,7 @@ const isServicesListVariableNode = (node) => node?.type === 'variable' && node?.
|
|
|
571
99
|
* выпилена; см. `isServicesListVariableNode` и factory `() => []`
|
|
572
100
|
* в `LEGACY_BLOCK_VARIABLE_REPLACERS`).
|
|
573
101
|
*
|
|
574
|
-
* Используется
|
|
575
|
-
* - финальный шаг `convertLegacyHtmlToEmailContent` — гарантия для
|
|
576
|
-
* свежих конверсий из legacy HTML;
|
|
102
|
+
* Используется в двух каналах:
|
|
577
103
|
* - `hydrateEmailLocales` на фронте — лечит уже-сохранённые
|
|
578
104
|
* contentJson при открытии формы;
|
|
579
105
|
* - `renderEmailV2Local` на бэке — последний рубеж перед send,
|
|
@@ -586,11 +112,11 @@ const isServicesListVariableNode = (node) => node?.type === 'variable' && node?.
|
|
|
586
112
|
*
|
|
587
113
|
* Правила обхода:
|
|
588
114
|
* 1. Top-level `variable[id ∈ matchLegacyBlockVariable]` или
|
|
589
|
-
* `htmlCodeBlock` с такой variable внутри
|
|
590
|
-
*
|
|
115
|
+
* `htmlCodeBlock` с такой variable внутри — заменяются полностью
|
|
116
|
+
* на соответствующий block-узел.
|
|
591
117
|
* 2. `paragraph` с единственным непустым ребёнком-legacy-variable —
|
|
592
|
-
* целиком заменяется на block-узел (image
|
|
593
|
-
*
|
|
118
|
+
* целиком заменяется на block-узел (image — block-level, жить
|
|
119
|
+
* inline в параграфе не может).
|
|
594
120
|
* 3. Variable посреди смешанного параграфа — оставляем как есть:
|
|
595
121
|
* это пользовательский кейс, безопаснее не ломать layout.
|
|
596
122
|
* 4. Внутрь `section`/`repeat`/`for`/`show` и т.п. block-контейнеров
|
|
@@ -636,8 +162,7 @@ const LEGACY_BLOCK_VARIABLE_REPLACERS = [
|
|
|
636
162
|
* Сохранённые doc'и могут всё ещё содержать variable-chip с
|
|
637
163
|
* этим id (или его обёртку в `<p>` / `htmlCodeBlock`) — заменяем
|
|
638
164
|
* на пустой массив, и `migratePhotosInNodes` просто удалит
|
|
639
|
-
* соответствующий узел из doc'а.
|
|
640
|
-
* 'servicesList'` для свежих HTML-конверсий.
|
|
165
|
+
* соответствующий узел из doc'а.
|
|
641
166
|
*/
|
|
642
167
|
match: isServicesListVariableNode,
|
|
643
168
|
factory: () => [],
|
|
@@ -725,293 +250,12 @@ function migratePhotosInNodes(nodes) {
|
|
|
725
250
|
}
|
|
726
251
|
return out;
|
|
727
252
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
* bubble-menu toolbar в редакторе тогда корректно `updateAttributes`-ит
|
|
738
|
-
* эти поля. Если зашить значения сюда (как было раньше:
|
|
739
|
-
* `alignment:'center'`, `paddingTop:10`), toolbar-кнопки «выровнять
|
|
740
|
-
* влево/по центру/вправо» могут не реагировать — ProseMirror
|
|
741
|
-
* `updateAttributes` мерджит, но визуально предыдущие зашитые цифры
|
|
742
|
-
* перекрывают пользовательский выбор. См. attr-список button-extension
|
|
743
|
-
* в `@maily-to/core/dist/index.cjs` (`addAttributes` для node `button`:
|
|
744
|
-
* `text`, `isTextVariable`, `url`, `isUrlVariable`, `alignment`,
|
|
745
|
-
* `variant`, `borderRadius`, `showIfKey`, `buttonColor`, `textColor`,
|
|
746
|
-
* `padding[Top|Right|Bottom|Left]`).
|
|
747
|
-
*
|
|
748
|
-
* `isUrlVariable: true` → resolver подставит реальный signed-URL в
|
|
749
|
-
* момент отправки. Сам текст «Manage booking» — литерал (не переменная),
|
|
750
|
-
* пользователь может перевести его в редакторе.
|
|
751
|
-
*/
|
|
752
|
-
function bookingLinkButtonNode() {
|
|
753
|
-
return {
|
|
754
|
-
type: 'button',
|
|
755
|
-
attrs: {
|
|
756
|
-
text: 'Manage booking',
|
|
757
|
-
isTextVariable: false,
|
|
758
|
-
url: 'bookingManagementLink',
|
|
759
|
-
isUrlVariable: true,
|
|
760
|
-
},
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
function paragraphFromInline(content) {
|
|
764
|
-
return {
|
|
765
|
-
type: 'paragraph',
|
|
766
|
-
attrs: { textAlign: 'left' },
|
|
767
|
-
content,
|
|
768
|
-
};
|
|
769
|
-
}
|
|
770
|
-
/**
|
|
771
|
-
* Декодирование HTML-сущностей в текстовых нодах. node-html-parser
|
|
772
|
-
* не делает этого автоматически — отдаёт rawText с буквальными
|
|
773
|
-
* ` ` и т.п. Заодно нормализуем no-break space в обычный, чтобы
|
|
774
|
-
* редактор не показывал «странные» пробелы.
|
|
775
|
-
*/
|
|
776
|
-
function decodeText(text) {
|
|
777
|
-
return text
|
|
778
|
-
.replace(/ /g, ' ')
|
|
779
|
-
.replace(/ /g, ' ')
|
|
780
|
-
.replace(/ /gi, ' ')
|
|
781
|
-
.replace(/\u00A0/g, ' ')
|
|
782
|
-
.replace(/&/g, '&')
|
|
783
|
-
.replace(/</g, '<')
|
|
784
|
-
.replace(/>/g, '>')
|
|
785
|
-
.replace(/"/g, '"')
|
|
786
|
-
.replace(/'/g, "'")
|
|
787
|
-
.replace(/'/g, "'");
|
|
788
|
-
}
|
|
789
|
-
/**
|
|
790
|
-
* Пост-обработка: блочные HTML-плейсхолдеры (`orderDetailsHtml`,
|
|
791
|
-
* `photos`) внутри параграфов вырываются наверх в отдельные
|
|
792
|
-
* `htmlCodeBlock`-ноды. Параграф разрывается на «до» и «после»;
|
|
793
|
-
* пустые сегменты выбрасываются.
|
|
794
|
-
*
|
|
795
|
-
* Зачем: их значение — HTML с `<div>`/`<table>`. Если оставить как
|
|
796
|
-
* inline-variable в `<p>`, итоговый HTML письма получит `<div>`
|
|
797
|
-
* внутри `<p>`, что почтовые клиенты рендерят непредсказуемо.
|
|
798
|
-
*/
|
|
799
|
-
function promoteBlockHtmlVariables(content) {
|
|
800
|
-
const out = [];
|
|
801
|
-
for (const node of content) {
|
|
802
|
-
if (node.type === 'paragraph' && node.content) {
|
|
803
|
-
promotedFromParagraph(node, out);
|
|
804
|
-
continue;
|
|
805
|
-
}
|
|
806
|
-
if (node.type === 'section' && node.content) {
|
|
807
|
-
// Внутри `<section>` рекурсивно повторяем
|
|
808
|
-
out.push({ ...node, content: promoteBlockHtmlVariables(node.content) });
|
|
809
|
-
continue;
|
|
810
|
-
}
|
|
811
|
-
out.push(node);
|
|
812
|
-
}
|
|
813
|
-
return out;
|
|
814
|
-
}
|
|
815
|
-
function promotedFromParagraph(paragraph, out) {
|
|
816
|
-
const inline = paragraph.content || [];
|
|
817
|
-
let buffer = [];
|
|
818
|
-
const flushBuffer = () => {
|
|
819
|
-
if (buffer.length === 0)
|
|
820
|
-
return;
|
|
821
|
-
const hasMeaningfulContent = buffer.some((n) => n.type !== 'hardBreak' && (n.type !== 'text' || (n.text && n.text.trim() !== '')));
|
|
822
|
-
if (hasMeaningfulContent) {
|
|
823
|
-
out.push({ ...paragraph, content: buffer });
|
|
824
|
-
}
|
|
825
|
-
buffer = [];
|
|
826
|
-
};
|
|
827
|
-
for (const child of inline) {
|
|
828
|
-
if (child.type === 'variable' &&
|
|
829
|
-
child.attrs?.id &&
|
|
830
|
-
typeof child.attrs.id === 'string' &&
|
|
831
|
-
(0, placeholders_1.isBlockHtmlPlaceholder)(child.attrs.id)) {
|
|
832
|
-
flushBuffer();
|
|
833
|
-
out.push({
|
|
834
|
-
type: 'htmlCodeBlock',
|
|
835
|
-
attrs: { language: 'html' },
|
|
836
|
-
content: [child],
|
|
837
|
-
});
|
|
838
|
-
continue;
|
|
839
|
-
}
|
|
840
|
-
if (child.type === 'button') {
|
|
841
|
-
// Button — block-level в Maily. Внутри `collectInline` мы
|
|
842
|
-
// создаём его inline, чтобы поймать `{{#manageBooking}}…{{/}}`
|
|
843
|
-
// в любом месте параграфа; здесь поднимаем наверх,
|
|
844
|
-
// разрывая родительский параграф.
|
|
845
|
-
flushBuffer();
|
|
846
|
-
out.push(child);
|
|
847
|
-
continue;
|
|
848
|
-
}
|
|
849
|
-
if (child.type === 'image' &&
|
|
850
|
-
child.attrs?.isSrcVariable === true) {
|
|
851
|
-
/*
|
|
852
|
-
* Photos-image (Maily-style image с `src` = переменной).
|
|
853
|
-
* Промоутим наверх — чтобы Maily `alignment: 'center'`
|
|
854
|
-
* сработал без обёртки в параграф. Inline `<img>` из
|
|
855
|
-
* legacy `<img src="url">` НЕ трогаем — у них
|
|
856
|
-
* `isSrcVariable === false`, они валидно живут внутри `<p>`.
|
|
857
|
-
*/
|
|
858
|
-
flushBuffer();
|
|
859
|
-
out.push(child);
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
if (child.type === 'repeat') {
|
|
863
|
-
/*
|
|
864
|
-
* `repeat`-нода (iterable list) — block-level: Maily
|
|
865
|
-
* рендерит её через `Row`/`Section`-компоненты, размещение
|
|
866
|
-
* inline внутри `<p>` поломает разметку. Поднимаем наверх,
|
|
867
|
-
* рвём родительский параграф на «до» и «после» — тот же
|
|
868
|
-
* принцип, что и для button/image. Сейчас наш converter
|
|
869
|
-
* `repeat`-ноды не порождает (single live use-case,
|
|
870
|
-
* `servicesList`, выпилен), но branch держится как страховка
|
|
871
|
-
* для legacy doc'ов, в которых нода уже могла оказаться.
|
|
872
|
-
*/
|
|
873
|
-
flushBuffer();
|
|
874
|
-
out.push(child);
|
|
875
|
-
continue;
|
|
876
|
-
}
|
|
877
|
-
buffer.push(child);
|
|
878
|
-
}
|
|
879
|
-
flushBuffer();
|
|
880
|
-
}
|
|
881
|
-
/**
|
|
882
|
-
* Главная точка входа конвертера. Принимает legacy HTML (Quill-style
|
|
883
|
-
* с плоскими плейсхолдерами и handlebars-блоками) и возвращает готовый
|
|
884
|
-
* `EmailContentJsonV2`, готовый к сохранению в БД и открытию в
|
|
885
|
-
* EmailBuilder.
|
|
886
|
-
*
|
|
887
|
-
* Идемпотентен: пустой/null/whitespace вход даёт минимальный валидный
|
|
888
|
-
* `doc` (с логотипом если указан, иначе пустой массив content).
|
|
889
|
-
*
|
|
890
|
-
* Шаги обработки (в порядке выполнения):
|
|
891
|
-
* 1. `preprocessHtml` — text-level замены (`{{#if}}`, `{var}`, ...)
|
|
892
|
-
* 2. parse через node-html-parser
|
|
893
|
-
* 3. `walk` — построение TipTap-нод верхнего уровня
|
|
894
|
-
* 4. `promoteBlockHtmlVariables` — вынос block-payload плейсхолдеров
|
|
895
|
-
* 5. `normalizeVariableIds` — flat → namespaced id (если включено)
|
|
896
|
-
* 6. оборачивание в `EmailContentJsonV2` с логотипом и темой
|
|
897
|
-
*/
|
|
898
|
-
function convertLegacyHtmlToEmailContent(html, options = {}) {
|
|
899
|
-
const { logo, labels = {}, idMap = placeholders_1.LEGACY_TO_NAMESPACED_ID_MAP, inlineValues = {} } = options;
|
|
900
|
-
const content = [];
|
|
901
|
-
const logoNode = (0, email_builder_1.createLogoNode)(logo);
|
|
902
|
-
if (logoNode)
|
|
903
|
-
content.push(logoNode);
|
|
904
|
-
const cleaned = (html || '').trim();
|
|
905
|
-
if (cleaned) {
|
|
906
|
-
const preprocessed = preprocessHtml(cleaned);
|
|
907
|
-
const root = (0, node_html_parser_1.parse)(preprocessed, { lowerCaseTagName: true });
|
|
908
|
-
const blocks = walk(root, { labels, inlineValues });
|
|
909
|
-
const promoted = promoteBlockHtmlVariables(blocks);
|
|
910
|
-
const normalized = idMap ? normalizeVariableIds(promoted, idMap, labels) : promoted;
|
|
911
|
-
for (const b of normalized)
|
|
912
|
-
content.push(b);
|
|
913
|
-
}
|
|
914
|
-
const result = {
|
|
915
|
-
version: email_builder_1.EMAIL_CONTENT_VERSION_V2,
|
|
916
|
-
doc: { type: 'doc', content: content },
|
|
917
|
-
theme: email_builder_1.DEFAULT_EMAIL_THEME,
|
|
918
|
-
};
|
|
919
|
-
/*
|
|
920
|
-
* Финальный pass-проход через `migratePhotosToImage`: гарантирует,
|
|
921
|
-
* что даже если в legacy-HTML встречалось `{{order.photoFirst}}`
|
|
922
|
-
* как chip-variable (а не `{photos}`-block), оно тоже выльется
|
|
923
|
-
* в редактируемую `image`-ноду. Walk-логика выше обрабатывает
|
|
924
|
-
* только `id === 'photos'` напрямую — этот шаг закрывает все
|
|
925
|
-
* остальные углы.
|
|
926
|
-
*/
|
|
927
|
-
return migratePhotosToImage(result);
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* Рекурсивный пост-проход: для каждой `variable`-ноды переписывает
|
|
931
|
-
* `attrs.id` через `idMap` (если в карте есть запись), и заодно
|
|
932
|
-
* освежает `attrs.label`, если для нового id есть конкретный лейбл в
|
|
933
|
-
* `labels`. Дерево не клонируется глубоко: меняем только то, что
|
|
934
|
-
* реально нужно (`attrs`); экономит выделения на больших шаблонах.
|
|
935
|
-
*/
|
|
936
|
-
function normalizeVariableIds(nodes, idMap, labels) {
|
|
937
|
-
return nodes.map((node) => {
|
|
938
|
-
if (node.type === 'variable' && node.attrs?.id && typeof node.attrs.id === 'string') {
|
|
939
|
-
const mapped = idMap[node.attrs.id];
|
|
940
|
-
if (mapped && mapped !== node.attrs.id) {
|
|
941
|
-
return {
|
|
942
|
-
...node,
|
|
943
|
-
attrs: {
|
|
944
|
-
...node.attrs,
|
|
945
|
-
id: mapped,
|
|
946
|
-
label: labels[mapped] || mapped,
|
|
947
|
-
},
|
|
948
|
-
};
|
|
949
|
-
}
|
|
950
|
-
return node;
|
|
951
|
-
}
|
|
952
|
-
/*
|
|
953
|
-
* `section.showIfKey` — id переменной, по которой v2-render
|
|
954
|
-
* проверяет truthy перед рендером содержимого секции. Если не
|
|
955
|
-
* отнормализовать, секции с legacy ключами вроде
|
|
956
|
-
* `howToFindLocation` всегда будут схлопываться (резолверы
|
|
957
|
-
* возвращают `location.howToFind`).
|
|
958
|
-
*/
|
|
959
|
-
if (node.type === 'section' &&
|
|
960
|
-
node.attrs?.showIfKey &&
|
|
961
|
-
typeof node.attrs.showIfKey === 'string') {
|
|
962
|
-
const mapped = idMap[node.attrs.showIfKey];
|
|
963
|
-
const nextAttrs = mapped && mapped !== node.attrs.showIfKey
|
|
964
|
-
? { ...node.attrs, showIfKey: mapped }
|
|
965
|
-
: node.attrs;
|
|
966
|
-
return {
|
|
967
|
-
...node,
|
|
968
|
-
attrs: nextAttrs,
|
|
969
|
-
content: node.content
|
|
970
|
-
? normalizeVariableIds(node.content, idMap, labels)
|
|
971
|
-
: node.content,
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
if (node.content) {
|
|
975
|
-
return { ...node, content: normalizeVariableIds(node.content, idMap, labels) };
|
|
976
|
-
}
|
|
977
|
-
return node;
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
/**
|
|
981
|
-
* Подсчёт неизвестных плейсхолдеров в legacy HTML. Возвращает уникальные
|
|
982
|
-
* имена `{X}` и `{{X}}`, которые **не входят** в `KNOWN_PLACEHOLDERS`.
|
|
983
|
-
* Используется до миграции, чтобы найти ручные/опечатанные токены в
|
|
984
|
-
* шаблонах клиентов и решить, что с ними делать (исправить → конвертим,
|
|
985
|
-
* проигнорить → оставляем как литеральный текст).
|
|
986
|
-
*/
|
|
987
|
-
function findUnknownPlaceholders(html) {
|
|
988
|
-
if (!html)
|
|
989
|
-
return [];
|
|
990
|
-
const found = new Set();
|
|
991
|
-
const SINGLE = /\{([\w]+)\}/g;
|
|
992
|
-
const DOUBLE = /\{\{\s*([\w.]+)\s*(?:\|\s*"[^"]*")?\s*\}\}/g;
|
|
993
|
-
const STRUCT = new Set([
|
|
994
|
-
'if',
|
|
995
|
-
'/if',
|
|
996
|
-
'#if',
|
|
997
|
-
'manageBooking',
|
|
998
|
-
'/manageBooking',
|
|
999
|
-
'#manageBooking',
|
|
1000
|
-
]);
|
|
1001
|
-
for (const m of html.matchAll(SINGLE)) {
|
|
1002
|
-
if (!(0, placeholders_1.isKnownPlaceholder)(m[1]))
|
|
1003
|
-
found.add(m[1]);
|
|
1004
|
-
}
|
|
1005
|
-
for (const m of html.matchAll(DOUBLE)) {
|
|
1006
|
-
const name = m[1];
|
|
1007
|
-
if (STRUCT.has(name))
|
|
1008
|
-
continue;
|
|
1009
|
-
// Двойные `{{var}}` для известных плейсхолдеров (профильный
|
|
1010
|
-
// title, и т.п.) — ok. Игнорируем `manageBooking` (служебный).
|
|
1011
|
-
if (name === 'manageBooking')
|
|
1012
|
-
continue;
|
|
1013
|
-
if (!(0, placeholders_1.isKnownPlaceholder)(name) && !name.includes('.'))
|
|
1014
|
-
found.add(name);
|
|
1015
|
-
}
|
|
1016
|
-
return [...found].sort();
|
|
1017
|
-
}
|
|
253
|
+
// Re-export реестра плейсхолдеров — потребителю удобно знать без
|
|
254
|
+
// отдельного импорта из `./placeholders`.
|
|
255
|
+
var placeholders_1 = require("./placeholders");
|
|
256
|
+
Object.defineProperty(exports, "BLOCK_HTML_PLACEHOLDERS", { enumerable: true, get: function () { return placeholders_1.BLOCK_HTML_PLACEHOLDERS; } });
|
|
257
|
+
Object.defineProperty(exports, "isBlockHtmlPlaceholder", { enumerable: true, get: function () { return placeholders_1.isBlockHtmlPlaceholder; } });
|
|
258
|
+
Object.defineProperty(exports, "isKnownPlaceholder", { enumerable: true, get: function () { return placeholders_1.isKnownPlaceholder; } });
|
|
259
|
+
Object.defineProperty(exports, "KNOWN_PLACEHOLDERS", { enumerable: true, get: function () { return placeholders_1.KNOWN_PLACEHOLDERS; } });
|
|
260
|
+
Object.defineProperty(exports, "LEGACY_TO_NAMESPACED_ID_MAP", { enumerable: true, get: function () { return placeholders_1.LEGACY_TO_NAMESPACED_ID_MAP; } });
|
|
261
|
+
Object.defineProperty(exports, "normalizePlaceholderId", { enumerable: true, get: function () { return placeholders_1.normalizePlaceholderId; } });
|