@escapenavigator/utils 1.10.133 → 1.10.135
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/booking-lead-time/index.js +3 -3
- package/dist/email-builder/from-html.d.ts +149 -0
- package/dist/email-builder/from-html.js +1017 -0
- package/dist/email-builder/from-html.test.d.ts +1 -0
- package/dist/email-builder/from-html.test.js +753 -0
- package/dist/email-builder/placeholders.d.ts +89 -0
- package/dist/email-builder/placeholders.js +176 -0
- package/dist/email-builder/strip-html.d.ts +31 -0
- package/dist/email-builder/strip-html.js +73 -0
- package/dist/email-builder/strip-html.test.d.ts +1 -0
- package/dist/email-builder/strip-html.test.js +99 -0
- package/dist/format-flex-duration-label/index.d.ts +7 -0
- package/dist/format-flex-duration-label/index.js +21 -0
- package/dist/format-flex-duration-label/index.test.d.ts +1 -0
- package/dist/format-flex-duration-label/index.test.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/promocode-nominal-rules.d.ts +29 -4
- package/dist/promocode-nominal-rules.js +38 -7
- package/dist/promocode-nominal-rules.spec.js +68 -6
- package/dist/sanitize-quill-html.d.ts +16 -0
- package/dist/sanitize-quill-html.js +17 -3
- package/dist/sanitize-quill-html.test.d.ts +8 -0
- package/dist/sanitize-quill-html.test.js +71 -0
- package/dist/strip-html-tags.d.ts +13 -0
- package/dist/strip-html-tags.js +24 -0
- package/package.json +4 -3
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Конвертер legacy HTML (Quill output, использовавшийся в marketing-
|
|
4
|
+
* emails: cross-sale, retargeting, up-sale, early-booking, birthday,
|
|
5
|
+
* birthdayChild, custom-client-booking, scenario-emails) в формат
|
|
6
|
+
* `EmailContentJsonV2` нового редактора (Maily/TipTap).
|
|
7
|
+
*
|
|
8
|
+
* Что обрабатывает:
|
|
9
|
+
* - Параграфы `<p>` + переносы `<br>`, схлопывает пустые абзацы
|
|
10
|
+
* - Inline-форматирование `<strong>`/`<em>`/`<u>`/`<s>` (marks)
|
|
11
|
+
* - Ссылки `<a href="…">` → link-mark
|
|
12
|
+
* - Изображения `<img>` → image-нода
|
|
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`)
|
|
25
|
+
*
|
|
26
|
+
* Что НЕ обрабатывает (по дизайну):
|
|
27
|
+
* - Списки `<ul>`/`<ol>` — в legacy шаблонах не встречаются (Quill в
|
|
28
|
+
* нашей конфигурации их не выставлял). Если попадутся — fallback на
|
|
29
|
+
* обычный текст; добавим если потребуется по факту.
|
|
30
|
+
* - Таблицы — то же самое.
|
|
31
|
+
* - Заголовки `<h1>`-`<h6>` — Quill их не использовал в шаблонах.
|
|
32
|
+
*
|
|
33
|
+
* Преобразование чистое и идемпотентное: повторный вызов на той же
|
|
34
|
+
* строке даёт тот же результат.
|
|
35
|
+
*/
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.normalizePlaceholderId = exports.LEGACY_TO_NAMESPACED_ID_MAP = exports.KNOWN_PLACEHOLDERS = exports.isKnownPlaceholder = exports.isBlockHtmlPlaceholder = exports.BLOCK_HTML_PLACEHOLDERS = void 0;
|
|
38
|
+
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
|
+
/**
|
|
501
|
+
* Image-нода с переменным `src = photos`. Заменяет legacy
|
|
502
|
+
* `{photos}`-плейсхолдер (HTML-галерею) на один редактируемый image,
|
|
503
|
+
* совместимый с Maily UI (resize/align). См. подробное объяснение
|
|
504
|
+
* выше, в ветке `id === 'photos'`.
|
|
505
|
+
*
|
|
506
|
+
* `isSrcVariable: true` — режим Maily, при котором `attrs.src` лечится
|
|
507
|
+
* как имя переменной, а не URL. На рендере (`@maily-to/render`) этот
|
|
508
|
+
* флаг включает подстановку через variable-resolvers.
|
|
509
|
+
*
|
|
510
|
+
* NB: variable id — flat `photos` (без namespace). Исторический
|
|
511
|
+
* legacy-token, в шаблонах клиентов годами лежит `{photos}`;
|
|
512
|
+
* переименование в `order.photo*` стоит миграции ради косметики.
|
|
513
|
+
* Семантика — «URL ПЕРВОЙ фотки игры» (см. `OrderVariableResolver`).
|
|
514
|
+
*/
|
|
515
|
+
function photoFirstImageNode() {
|
|
516
|
+
return {
|
|
517
|
+
type: 'image',
|
|
518
|
+
attrs: {
|
|
519
|
+
// Variable id — flat `photos` (без namespace), исторический
|
|
520
|
+
// legacy-token. Резолвится в URL ПЕРВОЙ фотки игры
|
|
521
|
+
// (`OrderVariableResolver`).
|
|
522
|
+
src: 'photos',
|
|
523
|
+
isSrcVariable: true,
|
|
524
|
+
alt: '',
|
|
525
|
+
title: '',
|
|
526
|
+
/*
|
|
527
|
+
* Жёсткий размер 200×200. Maily NodeView в редакторе показывает
|
|
528
|
+
* placeholder-картинку (broken-img icon) до подгрузки реального
|
|
529
|
+
* URL, и без явных width/height лейаут письма прыгает каждый
|
|
530
|
+
* раз, когда юзер открывает превью. 200×200 — тот же размер,
|
|
531
|
+
* который мы передаём в `@`-меню (`editor-inner.tsx`), и
|
|
532
|
+
* совпадает с размером миниатюры в большинстве почтовых
|
|
533
|
+
* клиентов.
|
|
534
|
+
*/
|
|
535
|
+
width: 200,
|
|
536
|
+
height: 200,
|
|
537
|
+
alignment: 'center',
|
|
538
|
+
borderRadius: 8,
|
|
539
|
+
externalLink: '',
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/*
|
|
544
|
+
* Все известные id'ы, которыми «фото первого заказа» когда-либо обоз-
|
|
545
|
+
* началось в TipTap-doc. Используется нормализатором ниже, чтобы при
|
|
546
|
+
* следующей операции с письмом (backfill, hydrate, refill) все варианты
|
|
547
|
+
* сходились к одному `image`-узлу с `src = photos`.
|
|
548
|
+
*
|
|
549
|
+
* - `photos` — текущий id (flat, исторический legacy-token);
|
|
550
|
+
* - `order.photoFirst` — временный namespaced id, существовал на
|
|
551
|
+
* переходный период; ещё мог остаться в чьих-то локально-сохранённых
|
|
552
|
+
* contentJson, нормализатор подменит его обратно на `photos`.
|
|
553
|
+
*/
|
|
554
|
+
const PHOTOS_VARIABLE_IDS = new Set(['photos', 'order.photoFirst']);
|
|
555
|
+
const isPhotosVariableNode = (node) => node?.type === 'variable' &&
|
|
556
|
+
typeof node.attrs?.id === 'string' &&
|
|
557
|
+
PHOTOS_VARIABLE_IDS.has(node.attrs.id);
|
|
558
|
+
/*
|
|
559
|
+
* `servicesList` — legacy up-sale list, функция выпилена из email-системы.
|
|
560
|
+
* Любой variable-chip с этим id (или wrapper-параграф вокруг него)
|
|
561
|
+
* должен молча выбрасываться при нормализации, чтобы старые
|
|
562
|
+
* сохранённые doc'и не показывали мёртвый плейсхолдер.
|
|
563
|
+
*/
|
|
564
|
+
const isServicesListVariableNode = (node) => node?.type === 'variable' && node?.attrs?.id === 'servicesList';
|
|
565
|
+
/**
|
|
566
|
+
* Идемпотентный нормализатор устаревших variable-чипов, у которых
|
|
567
|
+
* есть выделенное block-представление в Maily:
|
|
568
|
+
* - `{photos}` / `{order.photoFirst}` → одна `image`-нода
|
|
569
|
+
* (`src = photos`, 200×200);
|
|
570
|
+
* - `{servicesList}` → удаляется (функция up-sale-листа в письмах
|
|
571
|
+
* выпилена; см. `isServicesListVariableNode` и factory `() => []`
|
|
572
|
+
* в `LEGACY_BLOCK_VARIABLE_REPLACERS`).
|
|
573
|
+
*
|
|
574
|
+
* Используется во всех трёх каналах:
|
|
575
|
+
* - финальный шаг `convertLegacyHtmlToEmailContent` — гарантия для
|
|
576
|
+
* свежих конверсий из legacy HTML;
|
|
577
|
+
* - `hydrateEmailLocales` на фронте — лечит уже-сохранённые
|
|
578
|
+
* contentJson при открытии формы;
|
|
579
|
+
* - `renderEmailV2Local` на бэке — последний рубеж перед send,
|
|
580
|
+
* спасает scheduled / queue-задачи, идущие в обход UI.
|
|
581
|
+
*
|
|
582
|
+
* Имя сохранено по историческим причинам (когда-то функция занималась
|
|
583
|
+
* только photos). Сейчас правильнее читать его как «migrate legacy
|
|
584
|
+
* block variables» — добавление новой замены сводится к одной строке
|
|
585
|
+
* в `LEGACY_BLOCK_VARIABLE_REPLACERS`.
|
|
586
|
+
*
|
|
587
|
+
* Правила обхода:
|
|
588
|
+
* 1. Top-level `variable[id ∈ matchLegacyBlockVariable]` или
|
|
589
|
+
* `htmlCodeBlock` с такой variable внутри (артефакт `promoteBlockHtmlVariables`)
|
|
590
|
+
* — заменяются полностью на соответствующий block-узел.
|
|
591
|
+
* 2. `paragraph` с единственным непустым ребёнком-legacy-variable —
|
|
592
|
+
* целиком заменяется на block-узел (image/repeat — block-level,
|
|
593
|
+
* жить inline в параграфе не могут).
|
|
594
|
+
* 3. Variable посреди смешанного параграфа — оставляем как есть:
|
|
595
|
+
* это пользовательский кейс, безопаснее не ломать layout.
|
|
596
|
+
* 4. Внутрь `section`/`repeat`/`for`/`show` и т.п. block-контейнеров
|
|
597
|
+
* ходим рекурсивно.
|
|
598
|
+
*
|
|
599
|
+
* Возвращает НОВЫЙ объект, не мутирует входной. Идемпотентна:
|
|
600
|
+
* повторный вызов — no-op (после первой замены variable-чипов
|
|
601
|
+
* с такими id уже не остаётся).
|
|
602
|
+
*/
|
|
603
|
+
function migratePhotosToImage(content) {
|
|
604
|
+
const doc = content.doc;
|
|
605
|
+
const nextContent = migratePhotosInNodes(doc.content || []);
|
|
606
|
+
return {
|
|
607
|
+
...content,
|
|
608
|
+
doc: {
|
|
609
|
+
...doc,
|
|
610
|
+
content: nextContent,
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/*
|
|
615
|
+
* Карта «найден variable с таким id → вставь ВОТ эти ноды». Используется
|
|
616
|
+
* `migratePhotosInNodes` для свёртки разнородных legacy-вариантов
|
|
617
|
+
* (`photos`) к единой Maily-репрезентации либо для УДАЛЕНИЯ выпиленных
|
|
618
|
+
* плейсхолдеров (`servicesList` — factory возвращает `[]`, узел просто
|
|
619
|
+
* пропадает из doc'а).
|
|
620
|
+
*
|
|
621
|
+
* Factory может вернуть как одну ноду, так и массив (включая пустой):
|
|
622
|
+
* - 1 нода — обычная замена (photos → image).
|
|
623
|
+
* - массив — multi-node-разворачивание (исторический use-case);
|
|
624
|
+
* сегодня неиспользуем, но API сохраняется на будущее.
|
|
625
|
+
* - пустой массив — узел удаляется без замены (servicesList).
|
|
626
|
+
*
|
|
627
|
+
* Каждый узел-замена возвращается ФАБРИКОЙ, а не одной shared-инстансой,
|
|
628
|
+
* потому что TipTap-доку нельзя shar'ить идентичные объекты между
|
|
629
|
+
* позициями (теряется reactivity в редакторе).
|
|
630
|
+
*/
|
|
631
|
+
const LEGACY_BLOCK_VARIABLE_REPLACERS = [
|
|
632
|
+
{ match: isPhotosVariableNode, factory: photoFirstImageNode },
|
|
633
|
+
{
|
|
634
|
+
/*
|
|
635
|
+
* `servicesList` — функция up-sale-листа в письмах выпилена.
|
|
636
|
+
* Сохранённые doc'и могут всё ещё содержать variable-chip с
|
|
637
|
+
* этим id (или его обёртку в `<p>` / `htmlCodeBlock`) — заменяем
|
|
638
|
+
* на пустой массив, и `migratePhotosInNodes` просто удалит
|
|
639
|
+
* соответствующий узел из doc'а. См. `walkBlock → id ===
|
|
640
|
+
* 'servicesList'` для свежих HTML-конверсий.
|
|
641
|
+
*/
|
|
642
|
+
match: isServicesListVariableNode,
|
|
643
|
+
factory: () => [],
|
|
644
|
+
},
|
|
645
|
+
];
|
|
646
|
+
const matchLegacyBlockVariable = (node) => {
|
|
647
|
+
for (const r of LEGACY_BLOCK_VARIABLE_REPLACERS) {
|
|
648
|
+
if (r.match(node))
|
|
649
|
+
return r.factory;
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
};
|
|
653
|
+
/**
|
|
654
|
+
* Развёртывает результат factory'а в плоский список. Factory может
|
|
655
|
+
* вернуть одну ноду или массив; на стороне walker'а удобнее иметь
|
|
656
|
+
* единый `TipTapNode[]`, чтобы просто `out.push(...factoryNodes)`.
|
|
657
|
+
*/
|
|
658
|
+
function expandReplacement(factory) {
|
|
659
|
+
const result = factory();
|
|
660
|
+
return Array.isArray(result) ? result : [result];
|
|
661
|
+
}
|
|
662
|
+
function migratePhotosInNodes(nodes) {
|
|
663
|
+
const out = [];
|
|
664
|
+
for (const node of nodes) {
|
|
665
|
+
/*
|
|
666
|
+
* (0) `section[showIfKey='servicesList']` — IF-обёртка вокруг
|
|
667
|
+
* списка доп. услуг. Раньше шаблоны типично выглядели как
|
|
668
|
+
* `{{#if servicesList}} <strong>Сделайте игру ярче…</strong>
|
|
669
|
+
* {servicesList} {{/if}}`. После выпиливания фичи сам chip
|
|
670
|
+
* выкидывается factory `() => []`, но IF-обёртка с
|
|
671
|
+
* заголовком оставалась бы пустой section'ой (на render-time
|
|
672
|
+
* `shouldShow('servicesList')` = false, в редакторе section
|
|
673
|
+
* видна как пустой блок с заголовком «Сделайте игру ярче…»).
|
|
674
|
+
*
|
|
675
|
+
* Удаляем секцию целиком: содержимое (заголовок-приглашение,
|
|
676
|
+
* legacy текст «Доп. услуги:» и т.п.) тоже больше не нужно,
|
|
677
|
+
* потому что без списка услуг оно теряет смысл. Это
|
|
678
|
+
* симметрично factory `() => []` для самой переменной.
|
|
679
|
+
*/
|
|
680
|
+
if (node.type === 'section' &&
|
|
681
|
+
node.attrs?.showIfKey === 'servicesList') {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// (1) Прямая variable-нода на верхнем уровне.
|
|
685
|
+
const direct = matchLegacyBlockVariable(node);
|
|
686
|
+
if (direct) {
|
|
687
|
+
out.push(...expandReplacement(direct));
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
// (1b) `htmlCodeBlock` с единственной legacy variable
|
|
691
|
+
// (артефакт старого `promoteBlockHtmlVariables` — раньше он
|
|
692
|
+
// оборачивал {photos} в htmlCodeBlock).
|
|
693
|
+
if (node.type === 'htmlCodeBlock') {
|
|
694
|
+
const inner = (node.content || []);
|
|
695
|
+
if (inner.length === 1) {
|
|
696
|
+
const wrapped = matchLegacyBlockVariable(inner[0]);
|
|
697
|
+
if (wrapped) {
|
|
698
|
+
out.push(...expandReplacement(wrapped));
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// (2) Параграф, содержащий ТОЛЬКО legacy variable.
|
|
704
|
+
if (node.type === 'paragraph') {
|
|
705
|
+
const children = (node.content || []);
|
|
706
|
+
const meaningful = children.filter((c) => !(c.type === 'text' && !(c.text || '').trim()));
|
|
707
|
+
if (meaningful.length === 1) {
|
|
708
|
+
const single = matchLegacyBlockVariable(meaningful[0]);
|
|
709
|
+
if (single) {
|
|
710
|
+
out.push(...expandReplacement(single));
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
// (4) Рекурсия по детям для block-контейнеров.
|
|
716
|
+
const innerNodes = node.content;
|
|
717
|
+
if (innerNodes && innerNodes.length > 0) {
|
|
718
|
+
out.push({
|
|
719
|
+
...node,
|
|
720
|
+
content: migratePhotosInNodes(innerNodes),
|
|
721
|
+
});
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
out.push(node);
|
|
725
|
+
}
|
|
726
|
+
return out;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Кнопка «Manage booking» — единственная допустимая визуализация
|
|
730
|
+
* `bookingManagementLink`.
|
|
731
|
+
*
|
|
732
|
+
* Сетим МИНИМАЛЬНЫЙ набор attrs (text + url + флаги переменных).
|
|
733
|
+
* Остальное (`alignment` / `variant` / `borderRadius` / `padding*` /
|
|
734
|
+
* `buttonColor` / `textColor`) намеренно не зашиваем — Maily-button-
|
|
735
|
+
* extension берёт дефолты `left` / `filled` / `smooth` / `null`-padding
|
|
736
|
+
* (`null` → padding из `theme.button`, т.е. фирменный оранжевый), и
|
|
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
|
+
}
|