@escapenavigator/utils 1.10.132 → 1.10.134

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Реестр плейсхолдеров для legacy marketing-emails. Источник правды:
3
+ * `HandlerKey` из `packages/app/src/components/quill/get-handlers.tsx`
4
+ * — список всех ключей, которые пользователь мог вставить через
5
+ * старый Quill-редактор.
6
+ *
7
+ * Зачем дублируется здесь:
8
+ * - `HandlerKey` живёт в `@escapenavigator/app` (frontend-only пакет).
9
+ * Конвертеру нужен тот же список, но без зависимости от React-стека.
10
+ * - Бек-миграция (Nest worker) тоже использует этот список, чтобы
11
+ * знать, какие токены превращать в `variable`-ноды.
12
+ *
13
+ * Если на фронте появится новый `HandlerKey` — добавь его и сюда.
14
+ * Тест `from-html.test.ts` явно проверяет, что новые имена не теряются
15
+ * при конверсии (если ключ не в реестре, токен `{X}` оставляется в
16
+ * тексте как есть — это безопаснее, чем терять данные).
17
+ */
18
+ export declare const KNOWN_PLACEHOLDERS: readonly ["clientName", "childName", "questroomTitle", "promocode", "discount", "validity", "website", "howToFindLocation", "importantInfo", "prepareInfo", "gameResult", "additionalInfo", "phone", "date", "photos", "servicesList", "address", "profileTitle", "birthday", "currency", "orderDetailsHtml", "bookingManagementLink", "questroomPhoto", "code", "language", "players", "mode", "comment"];
19
+ export type KnownPlaceholder = (typeof KNOWN_PLACEHOLDERS)[number];
20
+ export declare const isKnownPlaceholder: (id: string) => id is KnownPlaceholder;
21
+ /**
22
+ * Legacy block-HTML плейсхолдеры. Пустой Set — все три удалены:
23
+ * - `photos` — заменился на редактируемую `image`-ноду
24
+ * с `src = photos` (см. `from-html.ts →
25
+ * photoFirstImageNode`). На рендере id `photos`
26
+ * резолвится в URL ПЕРВОЙ фотки игры
27
+ * (`OrderVariableResolver.photos`).
28
+ * - `orderDetailsHtml` — на проде не использовался.
29
+ * - `servicesList` — функция up-sale-листа выпилена; legacy токены
30
+ * из старых шаблонов конвертер превращает в
31
+ * variable-чип, а нормализатор `migratePhotosInNodes`
32
+ * его удаляет (factory `() => []`). См. также
33
+ * `from-html.ts → isServicesListVariableNode`.
34
+ *
35
+ * Оставлен пустой экспорт + helper, чтобы внешние потребители
36
+ * (`from-html.ts`, `v2-renderer.util.ts`, send-pipelines) не падали
37
+ * на отсутствующий символ при инкрементальном удалении кода.
38
+ * После полного выпиливания block-payload пути в email.order.service
39
+ * и v2-renderer — можно удалить и этот файл.
40
+ *
41
+ * @deprecated удаляется вместе с block-payload-каналом v2-рендерера.
42
+ */
43
+ export declare const BLOCK_HTML_PLACEHOLDERS: Set<string>;
44
+ export declare const isBlockHtmlPlaceholder: (_id: string) => boolean;
45
+ /**
46
+ * Маппинг legacy flat-плейсхолдеров на современные namespaced id'ы,
47
+ * которыми оперируют резолверы в `packages/api/src/modules/marketing-email-variables/resolvers/`.
48
+ *
49
+ * Зачем: конвертер по умолчанию переписывает id'ы в variable-нодах с
50
+ * `{clientName}` → `client.firstName`, чтобы v2-renderer
51
+ * (`replaceVars` в `v2-renderer.util.ts`) находил их по тому же ключу,
52
+ * по которому резолверы возвращают значения. Без этой карты после
53
+ * миграции все `{var}` в письмах висели бы пустыми.
54
+ *
55
+ * Источник правды для целевых id'ов:
56
+ * - `client.resolver.ts`, `profile.resolver.ts`, `order.resolver.ts`,
57
+ * `questroom.resolver.ts`, `location.resolver.ts`, `promocode.resolver.ts`,
58
+ * `booking-link.resolver.ts` — `.declare()` каждого
59
+ *
60
+ * Что НЕ маппится (намеренно, остаётся как flat id):
61
+ * - `photos`, `orderDetailsHtml` — block-payload плейсхолдеры, у которых
62
+ * нет резолвера: значение собирается отдельной HTML-строкой на этапе
63
+ * рендеринга (видим как `htmlCodeBlock` в TipTap-doc).
64
+ * - `servicesList` — функция выпилена; флэт-id оставлен в `KNOWN_PLACEHOLDERS`,
65
+ * чтобы legacy `{servicesList}` распознавался как variable-чип и
66
+ * дропался нормализатором, а не оставался литеральным текстом.
67
+ * - `bookingManagementLink` — спецзначение, используется в `button.url`
68
+ * с `isUrlVariable:true`, никогда как inline-text variable.
69
+ * - `manageBooking` — служебная директива `{{#manageBooking}}`, уже
70
+ * распакована в `button`-ноду на этапе HTML-parse.
71
+ *
72
+ * Спорные кейсы:
73
+ * - `website` → `location.site` (а не `profile.site`): в legacy `v2-renderer`
74
+ * есть приоритет «если есть location.site, использовать его, иначе
75
+ * profile.site». В новой схеме фолбэк делает резолвер. Если у профиля
76
+ * нет локации (рассылка) — `location.site` будет пустым; templates
77
+ * должны быть defensive (inline fallback или if-block).
78
+ * - `prepareInfo` → `questroom.prepareText`: в crm-crosssales.json комментарий
79
+ * говорит «из настроек локации», но в текущих резолверах prepare-info
80
+ * лежит на квесте (`questroom.prepareText`). Источник правды — резолвер.
81
+ */
82
+ export declare const LEGACY_TO_NAMESPACED_ID_MAP: Readonly<Record<string, string>>;
83
+ /**
84
+ * Нормализация id переменной для нового формата. Если id есть в
85
+ * маппинге — возвращаем namespaced версию; иначе оставляем как есть
86
+ * (block-payload'ы, неизвестные кастомные токены, токены, которые
87
+ * пользователь добавил вручную).
88
+ */
89
+ export declare const normalizePlaceholderId: (id: string) => string;
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ /**
3
+ * Реестр плейсхолдеров для legacy marketing-emails. Источник правды:
4
+ * `HandlerKey` из `packages/app/src/components/quill/get-handlers.tsx`
5
+ * — список всех ключей, которые пользователь мог вставить через
6
+ * старый Quill-редактор.
7
+ *
8
+ * Зачем дублируется здесь:
9
+ * - `HandlerKey` живёт в `@escapenavigator/app` (frontend-only пакет).
10
+ * Конвертеру нужен тот же список, но без зависимости от React-стека.
11
+ * - Бек-миграция (Nest worker) тоже использует этот список, чтобы
12
+ * знать, какие токены превращать в `variable`-ноды.
13
+ *
14
+ * Если на фронте появится новый `HandlerKey` — добавь его и сюда.
15
+ * Тест `from-html.test.ts` явно проверяет, что новые имена не теряются
16
+ * при конверсии (если ключ не в реестре, токен `{X}` оставляется в
17
+ * тексте как есть — это безопаснее, чем терять данные).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.normalizePlaceholderId = exports.LEGACY_TO_NAMESPACED_ID_MAP = exports.isBlockHtmlPlaceholder = exports.BLOCK_HTML_PLACEHOLDERS = exports.isKnownPlaceholder = exports.KNOWN_PLACEHOLDERS = void 0;
21
+ exports.KNOWN_PLACEHOLDERS = [
22
+ 'clientName',
23
+ 'childName',
24
+ 'questroomTitle',
25
+ 'promocode',
26
+ 'discount',
27
+ 'validity',
28
+ 'website',
29
+ 'howToFindLocation',
30
+ 'importantInfo',
31
+ 'prepareInfo',
32
+ 'gameResult',
33
+ 'additionalInfo',
34
+ 'phone',
35
+ 'date',
36
+ 'photos',
37
+ /*
38
+ * `servicesList` сознательно оставлен в реестре: функция up-sale-листа
39
+ * выпилена (см. `from-html.ts → isServicesListVariableNode`), но legacy
40
+ * HTML-шаблоны на проде до сих пор содержат `{servicesList}`-токен.
41
+ * Без этой записи `convertLegacyHtmlToEmailContent` оставит токен
42
+ * как литеральный текст в письме (`{servicesList}` будет видна
43
+ * клиенту). С записью токен превращается в `variable`-чип, который
44
+ * нормализатор тут же удаляет из doc'а — итог одинаковый: «пусто».
45
+ */
46
+ 'servicesList',
47
+ 'address',
48
+ 'profileTitle',
49
+ 'birthday',
50
+ 'currency',
51
+ 'orderDetailsHtml',
52
+ 'bookingManagementLink',
53
+ 'questroomPhoto',
54
+ 'code',
55
+ 'language',
56
+ 'players',
57
+ 'mode',
58
+ 'comment',
59
+ ];
60
+ const KNOWN_PLACEHOLDERS_SET = new Set(exports.KNOWN_PLACEHOLDERS);
61
+ const isKnownPlaceholder = (id) => KNOWN_PLACEHOLDERS_SET.has(id);
62
+ exports.isKnownPlaceholder = isKnownPlaceholder;
63
+ /**
64
+ * Legacy block-HTML плейсхолдеры. Пустой Set — все три удалены:
65
+ * - `photos` — заменился на редактируемую `image`-ноду
66
+ * с `src = photos` (см. `from-html.ts →
67
+ * photoFirstImageNode`). На рендере id `photos`
68
+ * резолвится в URL ПЕРВОЙ фотки игры
69
+ * (`OrderVariableResolver.photos`).
70
+ * - `orderDetailsHtml` — на проде не использовался.
71
+ * - `servicesList` — функция up-sale-листа выпилена; legacy токены
72
+ * из старых шаблонов конвертер превращает в
73
+ * variable-чип, а нормализатор `migratePhotosInNodes`
74
+ * его удаляет (factory `() => []`). См. также
75
+ * `from-html.ts → isServicesListVariableNode`.
76
+ *
77
+ * Оставлен пустой экспорт + helper, чтобы внешние потребители
78
+ * (`from-html.ts`, `v2-renderer.util.ts`, send-pipelines) не падали
79
+ * на отсутствующий символ при инкрементальном удалении кода.
80
+ * После полного выпиливания block-payload пути в email.order.service
81
+ * и v2-renderer — можно удалить и этот файл.
82
+ *
83
+ * @deprecated удаляется вместе с block-payload-каналом v2-рендерера.
84
+ */
85
+ exports.BLOCK_HTML_PLACEHOLDERS = new Set([]);
86
+ const isBlockHtmlPlaceholder = (_id) => false;
87
+ exports.isBlockHtmlPlaceholder = isBlockHtmlPlaceholder;
88
+ /**
89
+ * Маппинг legacy flat-плейсхолдеров на современные namespaced id'ы,
90
+ * которыми оперируют резолверы в `packages/api/src/modules/marketing-email-variables/resolvers/`.
91
+ *
92
+ * Зачем: конвертер по умолчанию переписывает id'ы в variable-нодах с
93
+ * `{clientName}` → `client.firstName`, чтобы v2-renderer
94
+ * (`replaceVars` в `v2-renderer.util.ts`) находил их по тому же ключу,
95
+ * по которому резолверы возвращают значения. Без этой карты после
96
+ * миграции все `{var}` в письмах висели бы пустыми.
97
+ *
98
+ * Источник правды для целевых id'ов:
99
+ * - `client.resolver.ts`, `profile.resolver.ts`, `order.resolver.ts`,
100
+ * `questroom.resolver.ts`, `location.resolver.ts`, `promocode.resolver.ts`,
101
+ * `booking-link.resolver.ts` — `.declare()` каждого
102
+ *
103
+ * Что НЕ маппится (намеренно, остаётся как flat id):
104
+ * - `photos`, `orderDetailsHtml` — block-payload плейсхолдеры, у которых
105
+ * нет резолвера: значение собирается отдельной HTML-строкой на этапе
106
+ * рендеринга (видим как `htmlCodeBlock` в TipTap-doc).
107
+ * - `servicesList` — функция выпилена; флэт-id оставлен в `KNOWN_PLACEHOLDERS`,
108
+ * чтобы legacy `{servicesList}` распознавался как variable-чип и
109
+ * дропался нормализатором, а не оставался литеральным текстом.
110
+ * - `bookingManagementLink` — спецзначение, используется в `button.url`
111
+ * с `isUrlVariable:true`, никогда как inline-text variable.
112
+ * - `manageBooking` — служебная директива `{{#manageBooking}}`, уже
113
+ * распакована в `button`-ноду на этапе HTML-parse.
114
+ *
115
+ * Спорные кейсы:
116
+ * - `website` → `location.site` (а не `profile.site`): в legacy `v2-renderer`
117
+ * есть приоритет «если есть location.site, использовать его, иначе
118
+ * profile.site». В новой схеме фолбэк делает резолвер. Если у профиля
119
+ * нет локации (рассылка) — `location.site` будет пустым; templates
120
+ * должны быть defensive (inline fallback или if-block).
121
+ * - `prepareInfo` → `questroom.prepareText`: в crm-crosssales.json комментарий
122
+ * говорит «из настроек локации», но в текущих резолверах prepare-info
123
+ * лежит на квесте (`questroom.prepareText`). Источник правды — резолвер.
124
+ */
125
+ exports.LEGACY_TO_NAMESPACED_ID_MAP = {
126
+ clientName: 'client.firstName',
127
+ childName: 'client.childName',
128
+ questroomTitle: 'questroom.title',
129
+ importantInfo: 'questroom.importantInfo',
130
+ prepareInfo: 'questroom.prepareText',
131
+ promocode: 'promocode.code',
132
+ discount: 'promocode.discount',
133
+ validity: 'promocode.expireDate',
134
+ website: 'location.site',
135
+ howToFindLocation: 'location.howToFind',
136
+ phone: 'location.phone',
137
+ address: 'location.address',
138
+ date: 'order.date',
139
+ gameResult: 'order.result',
140
+ additionalInfo: 'order.additionalInfo',
141
+ // Booking-only переменные (см. `OrderBookingVariableResolver`):
142
+ // в legacy шаблонах попадались редко, но если есть — маппим, чтобы
143
+ // не потерять смысл. Если письмо НЕ booking-confirmation, рендер
144
+ // оставит их пустыми (резолвер живёт только в CustomBookingTemplate).
145
+ players: 'order.players',
146
+ mode: 'order.mode',
147
+ /*
148
+ * Удалённые из declare()-стека, но ВСЁ ЕЩЁ встречающиеся в проде
149
+ * legacy-шаблонах. Маппим, чтобы конвертер не дропал и не оставлял
150
+ * как литеральный текст; на рендере они получат пустую строку и
151
+ * пользователь увидит «дыру», после чего сам поправит шаблон.
152
+ *
153
+ * Не пытаемся переадресовать на что-то «похожее» — это потеряло бы
154
+ * смысл (`order.code` ≠ `bookingManagementLink`).
155
+ */
156
+ code: 'order.code',
157
+ currency: 'order.currency',
158
+ comment: 'order.comment',
159
+ birthday: 'client.birthday',
160
+ questroomPhoto: 'questroom.photo',
161
+ /*
162
+ * `profileTitle` → инлайн-замена на буквальное название компании.
163
+ * Сам маппинг id'а сюда не пишем (мы хотим, чтобы это превратилось
164
+ * в `text`-ноду, а не в `variable`). Замена реализована через
165
+ * `inlineValues` в `convertLegacyHtmlToEmailContent`, см.
166
+ * `from-html.ts`.
167
+ */
168
+ };
169
+ /**
170
+ * Нормализация id переменной для нового формата. Если id есть в
171
+ * маппинге — возвращаем namespaced версию; иначе оставляем как есть
172
+ * (block-payload'ы, неизвестные кастомные токены, токены, которые
173
+ * пользователь добавил вручную).
174
+ */
175
+ const normalizePlaceholderId = (id) => exports.LEGACY_TO_NAMESPACED_ID_MAP[id] ?? id;
176
+ exports.normalizePlaceholderId = normalizePlaceholderId;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * HTML → plain-text utility. Используется в `marketing-email-variables`-
3
+ * резолверах для полей, которые юзер заполняет через Quill-редактор:
4
+ * `questroom.importantInfo`, `questroom.prepareText`, `location.howToFind`,
5
+ * `order.additionalInfo`. Без чистки эти строки приезжают в Maily-render
6
+ * как HTML-разметка (`<p>`, `<strong>`, `&nbsp;`, `<a>`), а Maily
7
+ * подставляет variable-значения как текст с экранированием — юзер видит
8
+ * литеральные `<p>` / `&amp;nbsp;` в письме.
9
+ *
10
+ * Стратегия:
11
+ * - `<a href="X">Y</a>` → `Y` (ссылку убираем целиком, текст оставляем).
12
+ * Пользователь сам делает «очищать ссылки» — см. quill setting'и в
13
+ * админке. Здесь backend-safety net на случай legacy/не-cleaned данных.
14
+ * - block-теги (`<p>` / `<div>` / `<br>` / `<h*>` / `<li>` / `<tr>`)
15
+ * → пробел, чтобы соседние параграфы не слипались.
16
+ * - inline-теги (`<strong>` / `<em>` / `<span>` / `<u>`) → убираем,
17
+ * контент сохраняем.
18
+ * - HTML-entities декодируем (`&nbsp;` → пробел, `&amp;` → `&`, и т.д.).
19
+ * - Whitespace runs (включая `\n` / `\t`) сжимаем в один пробел.
20
+ *
21
+ * Что НЕ делаем (сознательно):
22
+ * - не сохраняем формат (bold/italic): Maily-`variable`-нода рендерит
23
+ * значение как plain text, теги внутри значения остаются буквальными.
24
+ * Если когда-то понадобится rich-text-подстановка — отдельная история
25
+ * (надо конвертить HTML в TipTap-ноды и инлайнить в doc, см.
26
+ * `convertLegacyHtmlToEmailContent`).
27
+ * - не сохраняем переносы строк: Maily-render не превращает `\n` в
28
+ * `<br>` (он эмитит весь текст одним `Fragment`), так что переносы
29
+ * визуально не видны. Лучше один параграф, чем «странные» дыры.
30
+ */
31
+ export declare function stripHtml(input: string | undefined | null): string;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stripHtml = stripHtml;
4
+ /**
5
+ * HTML → plain-text utility. Используется в `marketing-email-variables`-
6
+ * резолверах для полей, которые юзер заполняет через Quill-редактор:
7
+ * `questroom.importantInfo`, `questroom.prepareText`, `location.howToFind`,
8
+ * `order.additionalInfo`. Без чистки эти строки приезжают в Maily-render
9
+ * как HTML-разметка (`<p>`, `<strong>`, `&nbsp;`, `<a>`), а Maily
10
+ * подставляет variable-значения как текст с экранированием — юзер видит
11
+ * литеральные `<p>` / `&amp;nbsp;` в письме.
12
+ *
13
+ * Стратегия:
14
+ * - `<a href="X">Y</a>` → `Y` (ссылку убираем целиком, текст оставляем).
15
+ * Пользователь сам делает «очищать ссылки» — см. quill setting'и в
16
+ * админке. Здесь backend-safety net на случай legacy/не-cleaned данных.
17
+ * - block-теги (`<p>` / `<div>` / `<br>` / `<h*>` / `<li>` / `<tr>`)
18
+ * → пробел, чтобы соседние параграфы не слипались.
19
+ * - inline-теги (`<strong>` / `<em>` / `<span>` / `<u>`) → убираем,
20
+ * контент сохраняем.
21
+ * - HTML-entities декодируем (`&nbsp;` → пробел, `&amp;` → `&`, и т.д.).
22
+ * - Whitespace runs (включая `\n` / `\t`) сжимаем в один пробел.
23
+ *
24
+ * Что НЕ делаем (сознательно):
25
+ * - не сохраняем формат (bold/italic): Maily-`variable`-нода рендерит
26
+ * значение как plain text, теги внутри значения остаются буквальными.
27
+ * Если когда-то понадобится rich-text-подстановка — отдельная история
28
+ * (надо конвертить HTML в TipTap-ноды и инлайнить в doc, см.
29
+ * `convertLegacyHtmlToEmailContent`).
30
+ * - не сохраняем переносы строк: Maily-render не превращает `\n` в
31
+ * `<br>` (он эмитит весь текст одним `Fragment`), так что переносы
32
+ * визуально не видны. Лучше один параграф, чем «странные» дыры.
33
+ */
34
+ function stripHtml(input) {
35
+ if (input == null)
36
+ return '';
37
+ let s = String(input);
38
+ if (!s)
39
+ return '';
40
+ /*
41
+ * Ссылки: `<a ...>текст</a>` → `текст`. Atomic-replace, чтобы
42
+ * после общего `<[^>]+>` strip'а не осталось «голого» URL'а из
43
+ * `href`. Поддерживаем оба варианта кавычек.
44
+ */
45
+ s = s.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1');
46
+ /*
47
+ * Block-теги → пробел. `<br>` сюда тоже попадает, потому что в
48
+ * plain-output мы хотим иметь хотя бы пробел между «строками»
49
+ * (Maily всё равно склеит, но без пробела получим `Lorem Ipsumis`
50
+ * вместо `Lorem Ipsum is` если рядом стояли блочные теги).
51
+ */
52
+ s = s.replace(/<\/?(p|div|br|h[1-6]|li|tr|ul|ol)\b[^>]*>/gi, ' ');
53
+ // Все остальные теги — просто убираем, контент сохраняем.
54
+ s = s.replace(/<[^>]+>/g, '');
55
+ /*
56
+ * HTML-entities decode. Делаем явный whitelist популярных вместо
57
+ * полного html-entities-парсера (лишняя зависимость) — этих
58
+ * хватает для всех кейсов, которые реально выходят из Quill.
59
+ */
60
+ s = s
61
+ .replace(/&nbsp;/g, ' ')
62
+ .replace(/&#160;/g, ' ')
63
+ .replace(/&#xa0;/gi, ' ')
64
+ .replace(/\u00A0/g, ' ')
65
+ .replace(/&amp;/g, '&')
66
+ .replace(/&lt;/g, '<')
67
+ .replace(/&gt;/g, '>')
68
+ .replace(/&quot;/g, '"')
69
+ .replace(/&#39;/g, "'")
70
+ .replace(/&apos;/g, "'");
71
+ // Сжимаем whitespace runs (включая newlines/tabs) в один пробел.
72
+ return s.replace(/\s+/g, ' ').trim();
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const strip_html_1 = require("./strip-html");
4
+ describe('stripHtml', () => {
5
+ describe('базовые кейсы', () => {
6
+ it('возвращает пустую строку для null/undefined/пустого ввода', () => {
7
+ expect((0, strip_html_1.stripHtml)(null)).toBe('');
8
+ expect((0, strip_html_1.stripHtml)(undefined)).toBe('');
9
+ expect((0, strip_html_1.stripHtml)('')).toBe('');
10
+ });
11
+ it('plain-текст без HTML отдаёт как есть', () => {
12
+ expect((0, strip_html_1.stripHtml)('Hello, world')).toBe('Hello, world');
13
+ });
14
+ it('тримит ведущие/замыкающие пробелы', () => {
15
+ expect((0, strip_html_1.stripHtml)(' hello ')).toBe('hello');
16
+ });
17
+ });
18
+ describe('block-теги (превращаются в пробел)', () => {
19
+ it('<p>...</p><p>...</p> → "первый второй" с пробелом', () => {
20
+ expect((0, strip_html_1.stripHtml)('<p>first</p><p>second</p>')).toBe('first second');
21
+ });
22
+ it('<br> между словами не склеивает слова', () => {
23
+ expect((0, strip_html_1.stripHtml)('a<br>b')).toBe('a b');
24
+ });
25
+ it('<div> + <h1-6> тоже считаются блочными', () => {
26
+ expect((0, strip_html_1.stripHtml)('<h1>Title</h1><div>Body</div>')).toBe('Title Body');
27
+ });
28
+ it('<ul>/<ol>/<li> сжимаются с пробелами вокруг', () => {
29
+ expect((0, strip_html_1.stripHtml)('<ul><li>a</li><li>b</li></ul>')).toBe('a b');
30
+ });
31
+ });
32
+ describe('inline-теги (убираются, контент остаётся)', () => {
33
+ it('<strong>X</strong> → X', () => {
34
+ expect((0, strip_html_1.stripHtml)('<strong>Bold</strong>')).toBe('Bold');
35
+ });
36
+ it('<em>/<span>/<u> аналогично', () => {
37
+ expect((0, strip_html_1.stripHtml)('<em>i</em><span>s</span><u>u</u>')).toBe('isu');
38
+ });
39
+ it('вложенные inline-теги корректно разворачиваются', () => {
40
+ expect((0, strip_html_1.stripHtml)('<strong><em>Bold italic</em></strong>')).toBe('Bold italic');
41
+ });
42
+ });
43
+ describe('ссылки', () => {
44
+ it('<a href="X">Y</a> → "Y" (ссылка убирается, текст остаётся)', () => {
45
+ expect((0, strip_html_1.stripHtml)('Click <a href="https://example.com">here</a>')).toBe('Click here');
46
+ });
47
+ it('ссылка с любыми атрибутами тоже схлопывается до текста', () => {
48
+ const html = '<a href="https://x.com" rel="noopener" target="_blank">link</a>';
49
+ expect((0, strip_html_1.stripHtml)(html)).toBe('link');
50
+ });
51
+ it('пустая ссылка (без текста внутри) исчезает', () => {
52
+ expect((0, strip_html_1.stripHtml)('a<a href="x"></a>b')).toBe('ab');
53
+ });
54
+ });
55
+ describe('HTML-entities', () => {
56
+ it('&nbsp; → пробел', () => {
57
+ expect((0, strip_html_1.stripHtml)('a&nbsp;b')).toBe('a b');
58
+ });
59
+ it('&#160; / &#xa0; / NBSP-юникод → пробел', () => {
60
+ expect((0, strip_html_1.stripHtml)('a&#160;b')).toBe('a b');
61
+ expect((0, strip_html_1.stripHtml)('a&#xA0;b')).toBe('a b');
62
+ expect((0, strip_html_1.stripHtml)('a\u00A0b')).toBe('a b');
63
+ });
64
+ it('&amp; → &, &lt;/&gt; → </>', () => {
65
+ expect((0, strip_html_1.stripHtml)('A &amp; B')).toBe('A & B');
66
+ expect((0, strip_html_1.stripHtml)('&lt;tag&gt;')).toBe('<tag>');
67
+ });
68
+ it('&quot; / &#39; / &apos; → " и \'', () => {
69
+ expect((0, strip_html_1.stripHtml)('&quot;hi&quot; &#39;there&#39; &apos;dude&apos;')).toBe('"hi" \'there\' \'dude\'');
70
+ });
71
+ });
72
+ describe('whitespace', () => {
73
+ it('множественные пробелы сжимаются в один', () => {
74
+ expect((0, strip_html_1.stripHtml)('a b')).toBe('a b');
75
+ });
76
+ it('переносы строк и табы — тоже whitespace', () => {
77
+ expect((0, strip_html_1.stripHtml)('a\n\nb\t\tc')).toBe('a b c');
78
+ });
79
+ it('лидирующий/трейлинг nbsp обрезается', () => {
80
+ expect((0, strip_html_1.stripHtml)('&nbsp;&nbsp;hello&nbsp;&nbsp;')).toBe('hello');
81
+ });
82
+ });
83
+ describe('реалистичные кейсы из Quill', () => {
84
+ it('Lorem ipsum с &nbsp; внутри слов и <p>-обёрткой', () => {
85
+ const html = '<p><strong>Lorem&nbsp;Ipsum</strong>&nbsp;is&nbsp;simply&nbsp;dummy&nbsp;text.</p>';
86
+ expect((0, strip_html_1.stripHtml)(html)).toBe('Lorem Ipsum is simply dummy text.');
87
+ });
88
+ it('параграф с inline-ссылкой и форматированием', () => {
89
+ const html = '<p><strong>Donate:</strong> click <strong><a href="https://www.lipsum.com/donate" rel="noopener noreferrer" target="_blank">here</a></strong> to donate using PayPal.</p>';
90
+ expect((0, strip_html_1.stripHtml)(html)).toBe('Donate: click here to donate using PayPal.');
91
+ });
92
+ it('идемпотентность: повторный strip уже plain-строки ничего не меняет', () => {
93
+ const once = (0, strip_html_1.stripHtml)('<p>hello&nbsp;world</p>');
94
+ const twice = (0, strip_html_1.stripHtml)(once);
95
+ expect(once).toBe('hello world');
96
+ expect(twice).toBe('hello world');
97
+ });
98
+ });
99
+ });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './agreement-config';
2
+ export * from './booking-lead-time';
2
3
  export * from './convert-hhmm-to-minutes';
3
4
  export * from './convert-minutes-to-hhmm';
4
5
  export * from './convert-to-options';
@@ -19,6 +20,7 @@ export * from './redirect';
19
20
  export * from './serialize-record';
20
21
  export * from './serialize-slot';
21
22
  export * from './slot-representative-price';
23
+ export * from './strip-html-tags';
22
24
  export * from './tz-date';
23
25
  export * from './user-session-permissions';
24
26
  export * from './utm-touchpoints';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./agreement-config"), exports);
18
+ __exportStar(require("./booking-lead-time"), exports);
18
19
  __exportStar(require("./convert-hhmm-to-minutes"), exports);
19
20
  __exportStar(require("./convert-minutes-to-hhmm"), exports);
20
21
  __exportStar(require("./convert-to-options"), exports);
@@ -35,6 +36,7 @@ __exportStar(require("./redirect"), exports);
35
36
  __exportStar(require("./serialize-record"), exports);
36
37
  __exportStar(require("./serialize-slot"), exports);
37
38
  __exportStar(require("./slot-representative-price"), exports);
39
+ __exportStar(require("./strip-html-tags"), exports);
38
40
  __exportStar(require("./tz-date"), exports);
39
41
  __exportStar(require("./user-session-permissions"), exports);
40
42
  __exportStar(require("./utm-touchpoints"), exports);
@@ -28,7 +28,23 @@
28
28
  export type SanitizeQuillHtmlOptions = {
29
29
  /** Снять inline-цвета/фоны и `ql-color-*`/`ql-background-*` классы. */
30
30
  stripColors?: boolean;
31
+ /**
32
+ * Удалить `<a>`-обёртки, сохранив только текст внутри. Используется
33
+ * для полей с `noLinks={true}` (`howToFind` / `prepareText` локации,
34
+ * importantInfo квеста и пр.), где ссылка только мешает: рендерится
35
+ * в orders/widget на разных темах, ссылается куда попало, ломает
36
+ * автогенерируемые UTM. Сам ReactQuill кнопку link в toolbar'е
37
+ * прячет через `noLinks`, но paste из браузера/Word всё равно
38
+ * протаскивает `<a>` атомарно — этот пост-санитайзер закрывает дыру.
39
+ */
40
+ stripLinks?: boolean;
31
41
  };
42
+ /**
43
+ * Удаляет `<a>`-обёртки, сохраняя текст. Поддерживает вложенные форматы
44
+ * внутри ссылки (`<a><strong>text</strong></a>` → `<strong>text</strong>`).
45
+ * Идемпотентна: повторный вызов на уже очищенном HTML возвращает то же.
46
+ */
47
+ export declare function stripQuillLinks(html: string): string;
32
48
  export declare function stripQuillColors(html: string): string;
33
49
  export declare function normalizeQuillWhitespace(html: string): string;
34
50
  export declare function sanitizeQuillHtml(html: string | null | undefined, options?: SanitizeQuillHtmlOptions): string;
@@ -27,6 +27,7 @@
27
27
  * на стороне клиентских настроек, см. `client-agreement.tsx`).
28
28
  */
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.stripQuillLinks = stripQuillLinks;
30
31
  exports.stripQuillColors = stripQuillColors;
31
32
  exports.normalizeQuillWhitespace = normalizeQuillWhitespace;
32
33
  exports.sanitizeQuillHtml = sanitizeQuillHtml;
@@ -47,6 +48,14 @@ function cleanStyleAttribute(style) {
47
48
  })
48
49
  .join('; ');
49
50
  }
51
+ /**
52
+ * Удаляет `<a>`-обёртки, сохраняя текст. Поддерживает вложенные форматы
53
+ * внутри ссылки (`<a><strong>text</strong></a>` → `<strong>text</strong>`).
54
+ * Идемпотентна: повторный вызов на уже очищенном HTML возвращает то же.
55
+ */
56
+ function stripQuillLinks(html) {
57
+ return html.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1');
58
+ }
50
59
  function stripQuillColors(html) {
51
60
  return html
52
61
  .replace(/\s*style="([^"]*)"/gi, (_match, styleContent) => {
@@ -98,12 +107,17 @@ function sanitizeQuillHtml(html, options) {
98
107
  if (!html)
99
108
  return '';
100
109
  const stripColors = options?.stripColors ? '1' : '0';
101
- const key = `${stripColors}|${html}`;
110
+ const stripLinks = options?.stripLinks ? '1' : '0';
111
+ const key = `${stripColors}|${stripLinks}|${html}`;
102
112
  const cached = cache.get(key);
103
113
  if (cached !== undefined)
104
114
  return cached;
105
- const colorStripped = options?.stripColors ? stripQuillColors(html) : html;
106
- const result = normalizeQuillWhitespace(colorStripped);
115
+ let intermediate = html;
116
+ if (options?.stripColors)
117
+ intermediate = stripQuillColors(intermediate);
118
+ if (options?.stripLinks)
119
+ intermediate = stripQuillLinks(intermediate);
120
+ const result = normalizeQuillWhitespace(intermediate);
107
121
  cache.set(key, result);
108
122
  return result;
109
123
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Узкая проверка опций `sanitizeQuillHtml` (особенно `stripLinks`,
3
+ * добавленной для полей с `noLinks={true}` в location-форме).
4
+ *
5
+ * Полная регрессия `normalizeQuillWhitespace` / `stripQuillColors` —
6
+ * предмет отдельных рег-тестов; здесь ловим именно регрессию ссылок.
7
+ */
8
+ export {};