Initial commit — Aspekter VA email builder

Full project: Svelte 5 frontend, Vite 7 backend API,
Pug email templates (email-gen), Docker deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Zotov
2026-04-13 01:20:24 +05:00
commit c090bfcf47
61 changed files with 18907 additions and 0 deletions

168
docs/applyNowrap.md Normal file
View File

@@ -0,0 +1,168 @@
# applyNowrap — защита от висячих предлогов в email
## Назначение
Функция `applyNowrap(html)` предотвращает «висячие» предлоги и короткие слова (≤3 букв) в email-рассылках. Оборачивает короткое слово + следующее слово в `<span style="white-space:nowrap">`, чтобы они не разрывались на разные строки.
**Почему нужна:** Mail.ru и некоторые другие почтовые клиенты игнорируют `&nbsp;` при переносе строк. Единственный надёжный способ — `white-space:nowrap` на `<span>`.
## Полный код
```javascript
function applyNowrap(html) {
function wrapShort(text) {
return text.replace(
/(?<![a-zA-Zа-яА-ЯёЁ])([a-zA-Zа-яА-ЯёЁ]{1,3})(?:\s|&nbsp;)+(\S+)/gu,
'\u200Bspan\u200Bnwr\u200B$1\u00A0$2\u200B/span\u200Bnwr\u200B'
)
}
// Шаг 1: Найти текстовые блоки (h3 spans)
const result = html.replace(
/(<span[^>]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi,
(_match, open, content, close) => {
// Шаг 2: Обработать текст МЕЖДУ тегами: >текст<
const processed = content.replace(
/>([^<]+)</g,
(m, t) => '>' + wrapShort(t) + '<'
)
// Шаг 3: Обработать текст В НАЧАЛЕ (до первого тега)
const firstText = processed.replace(
/^([^<]+)/,
(m) => wrapShort(m)
)
return open + firstText + close
}
)
// Шаг 4: Заменить placeholder'ы на реальные span-теги
return result
.replace(/\u200Bspan\u200Bnwr\u200B/g, '<span style="white-space:nowrap">')
.replace(/\u200B\/span\u200Bnwr\u200B/g, '</span>')
}
```
## Алгоритм пошагово
### Шаг 1 — Выбор целевых блоков
```
/(<span[^>]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi
```
Ищет `<span>` с классом `h3` (текстовые блоки писем), закрытый `</span></td>`.
Три группы захвата:
- `open` — открывающий тег `<span class="... h3 ...">`
- `content` — всё содержимое между открывающим и закрывающим тегами
- `close` — закрывающий `</span></td>`
> **Адаптация для другого проекта:** заменить `h3` на нужный CSS-класс текстовых блоков. Заменить `</span></td>` на ваш закрывающий паттерн.
### Шаг 2 — Обработка текста между вложенными тегами
```
/>([^<]+)</g
```
Находит текстовые фрагменты между `>` и `<` (текст между HTML-тегами внутри блока). Например в `<span style="font-weight:700">Что это дает</span>?` поймает `Что это дает`.
### Шаг 3 — Обработка текста в начале блока
```
/^([^<]+)/
```
Ловит текст от начала content до первого HTML-тега. Нужен отдельно, потому что regex из шага 2 ищет `>текст<`, а в начале блока нет `>` перед текстом.
### Шаг 4 — Placeholder → реальные теги
```javascript
.replace(/\u200Bspan\u200Bnwr\u200B/g, '<span style="white-space:nowrap">')
.replace(/\u200B\/span\u200Bnwr\u200B/g, '</span>')
```
Заменяет placeholder-маркеры на настоящие HTML-теги.
**Зачем placeholder'ы?** Если бы мы сразу вставляли `<span>`, то regex из шага 2 (`>([^<]+)<`) мог бы снова поймать текст внутри уже вставленного span'а и обработать его повторно. Маркеры `\u200B` (zero-width space) невидимы и не матчатся как `<` или `>`, поэтому двойной обработки не происходит.
## Ключевой regex — wrapShort
```
/(?<![a-zA-Zа-яА-ЯёЁ])([a-zA-Zа-яА-ЯёЁ]{1,3})(?:\s|&nbsp;)+(\S+)/gu
```
Разбор по частям:
| Часть | Значение |
|-------|----------|
| `(?<![a-zA-Zа-яА-ЯёЁ])` | Negative lookbehind: перед словом НЕ должна стоять буква (иначе это часть длинного слова) |
| `([a-zA-Zа-яА-ЯёЁ]{1,3})` | Группа 1: короткое слово от 1 до 3 букв (предлоги, союзы, частицы) |
| `(?:\s\|&nbsp;)+` | Пробел ИЛИ `&nbsp;` (типограф заменяет пробелы на `&nbsp;`) |
| `(\S+)` | Группа 2: следующее слово (любые непробельные символы) |
| Флаг `g` | Глобальный поиск |
| Флаг `u` | Unicode-режим |
Строка замены: `'\u200Bspan\u200Bnwr\u200B$1\u00A0$2\u200B/span\u200Bnwr\u200B'`
| Часть | Значение |
|-------|----------|
| `\u200Bspan\u200Bnwr\u200B` | Placeholder для `<span style="white-space:nowrap">` |
| `$1` | Короткое слово (предлог) |
| `\u00A0` | Non-breaking space между словами (двойная защита) |
| `$2` | Следующее слово |
| `\u200B/span\u200Bnwr\u200B` | Placeholder для `</span>` |
## Результат на примере
**Вход:**
```html
<span class="font h3 blackText" style="font-size: 18px;">С&nbsp;первыми весенними днями дизайнеры и&nbsp;стилисты</span></td>
```
**Выход:**
```html
<span class="font h3 blackText" style="font-size: 18px;">
<span style="white-space:nowrap">С первыми</span> весенними днями
дизайнеры <span style="white-space:nowrap">и стилисты</span>
</span></td>
```
## Исправленные баги
### Баг 1: `&nbsp;` не матчился (2026-03-20)
**Симптом:** Nowrap-спаны не появляются в HTML, хотя функция вызывается.
**Причина:** Типограф (Артемий Лебедев / любой другой) вставляет `&nbsp;` между предлогами и словами. Оригинальный regex искал только `\s+` и не находил `&nbsp;`.
**Фикс:** Заменить `\s+` на `(?:\s|&nbsp;)+`.
### Баг 2: `\b` не работает с кириллицей (2026-03-21)
**Симптом:** Regex вообще ничего не матчит для кириллических предлогов (С, и, в, на, от, из, но, до, по, за, ко).
**Причина:** В JavaScript `\b` (word boundary) работает **ТОЛЬКО с ASCII-символами** (`[a-zA-Z0-9_]`). Кириллица не считается «word character». Поэтому `\b` перед кириллическими буквами НИКОГДА не срабатывает.
**Фикс:** Заменить `\b` на `(?<![a-zA-Zа-яА-ЯёЁ])` (negative lookbehind — «не предшествует буква»).
> ⚠️ **Правило:** Никогда не использовать `\b` для кириллицы в JavaScript. Всегда использовать lookbehind/lookahead.
## Адаптация для другого проекта
1. **Целевой CSS-класс:** Заменить `h3` в regex шага 1 на класс текстовых блоков вашего шаблона
2. **Закрывающий паттерн:** `</span></td>` — подстроить под структуру вашего HTML (может быть `</p></td>`, `</div>` и т.д.)
3. **Типограф:** Если используется типограф, regex уже обрабатывает `&nbsp;`. Если нет — `(?:\s|&nbsp;)+` всё равно будет работать корректно (просто `&nbsp;`-ветка не сработает)
4. **Вызов:** Применять к финальному HTML **после** типографа, но **до** отдачи клиенту
5. **Тестирование:** Обязательно проверять в Mail.ru — это самый строгий клиент по обработке пробелов
## Где вызывается
В бэкенде (vite.config.js), в endpoint рендера email — после получения HTML из email-gen-api и обработки Mindbox-тегов:
```javascript
const nowrapHtml = applyNowrap(rawHtml)
const nowrapPreview = applyNowrap(mindbox.html)
```
Результаты отдаются клиенту:
- `nowrapHtml` — финальный HTML для экспорта/отправки
- `nowrapPreview` — HTML с подставленными товарами для превью