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:
238
docs/01-user-guide.md
Normal file
238
docs/01-user-guide.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Aspekter VA — Руководство пользователя
|
||||
|
||||
## Что это такое
|
||||
|
||||
Aspekter VA — визуальный конструктор email-рассылок для интернет-магазина VipAvenue. Позволяет собирать письма из готовых блоков, редактировать тексты, подставлять товары из фида, проверять орфографию и выгружать готовый HTML для отправки через Mindbox.
|
||||
|
||||
Адрес: **https://aspekter.ru**
|
||||
|
||||
---
|
||||
|
||||
## Вход в систему
|
||||
|
||||
1. Открыть https://aspekter.ru
|
||||
2. Ввести логин и пароль
|
||||
3. Нажать «Войти»
|
||||
|
||||
При первом запуске создаётся учётная запись `admin` с временным паролем (выводится в консоль контейнера). После входа рекомендуется сменить пароль через панель администратора.
|
||||
|
||||
Сессия живёт 7 дней. После этого потребуется повторный вход.
|
||||
|
||||
---
|
||||
|
||||
## Основной интерфейс
|
||||
|
||||
### Страницы (навигация слева)
|
||||
|
||||
| Страница | Назначение |
|
||||
|---|---|
|
||||
| **Конструктор** | Сборка письма из блоков |
|
||||
| **План** | Календарь рассылок из Yonote |
|
||||
| **Статистика** | Время работы над письмами |
|
||||
| **Настройки** | Параметры проекта |
|
||||
|
||||
### Конструктор — левая панель
|
||||
|
||||
- **Список блоков** — перетаскивание, добавление, удаление, изменение порядка
|
||||
- **Поля блока** — при выборе блока показываются редактируемые поля (тексты, ссылки, картинки, ID товаров)
|
||||
- **Пресеты** — сохранённые наборы блоков для быстрого старта
|
||||
|
||||
### Конструктор — правая панель (превью)
|
||||
|
||||
- **Превью письма** — iframe с отрендеренным HTML
|
||||
- **Масштаб** — ползунок 40–100%
|
||||
- **Кнопки Жен / Муж** — переключение гендерной версии превью
|
||||
- **Кнопка ⇅** — переставляет блоки для мужской/женской версии (flip)
|
||||
- **Копировать HTML** — копирует итоговый HTML в буфер обмена
|
||||
|
||||
---
|
||||
|
||||
## Работа с блоками
|
||||
|
||||
### Добавление блока
|
||||
1. Нажать «+ Добавить блок» в левой панели
|
||||
2. Выбрать блок из списка (появится dropdown с иконками)
|
||||
3. Блок добавляется в конец списка
|
||||
|
||||
### Редактирование полей
|
||||
Каждый блок раскрывается по клику. Внутри — поля:
|
||||
- **Текст** — многострочное поле, поддерживает пробелы и переносы
|
||||
- **Ссылка** — URL (кнопки, картинки, ссылки меню)
|
||||
- **ID товаров** — через запятую, подтягиваются из фида
|
||||
- **Картинка** — URL изображения
|
||||
|
||||
### Перетаскивание
|
||||
Блоки можно перетаскивать за иконку ⠿ слева от названия.
|
||||
|
||||
### Удаление
|
||||
Кнопка 🗑 справа от блока.
|
||||
|
||||
### Секции внутри блока
|
||||
Некоторые блоки (например «Текст») состоят из секций. Секции можно:
|
||||
- Менять местами перетаскиванием
|
||||
- Удалять (кроме первой)
|
||||
- Добавлять новые
|
||||
|
||||
---
|
||||
|
||||
## Гендерная сегментация
|
||||
|
||||
Письма VipAvenue отправляются в двух версиях: женской и мужской.
|
||||
|
||||
### Как это работает
|
||||
- Каждое письмо собирается с набором блоков
|
||||
- **Кнопка «Жен» / «Муж»** — переключает превью на соответствующую версию (меняет header/footer и порядок блоков)
|
||||
- **Кнопка ⇅ (flip)** — физически переставляет блоки: блоки до разделителя и после меняются местами
|
||||
- **Разделитель (⊕)** — блок-разделитель, служит осью для flip. Устанавливается кнопкой ⊕ только на блоке типа «dividerVA»
|
||||
|
||||
### Хедеры и футеры
|
||||
Настраиваются в разделе «Настройки» → «Гендерные пути»:
|
||||
- Хедер женский / мужской
|
||||
- Футер женский / мужской
|
||||
|
||||
Выбираются из списка файлов в `email-gen/emails/vipavenue/parts/`.
|
||||
|
||||
---
|
||||
|
||||
## Товары из фида
|
||||
|
||||
### Настройка фида
|
||||
В настройках проекта указывается URL XML-фида Mindbox. Фид кэшируется на 3 часа.
|
||||
|
||||
### Подстановка товаров
|
||||
1. В блоке с товарами ввести ID через запятую
|
||||
2. Система найдёт товары в фиде и покажет превью (название, цена, картинка)
|
||||
3. Если товар не найден или недоступен — появится предупреждение
|
||||
|
||||
### Замена товара
|
||||
Если товар недоступен, система предложит замену. Алгоритм подбора:
|
||||
- Тот же тип товара (+20 баллов)
|
||||
- Та же категория (+15)
|
||||
- Тот же бренд (+25)
|
||||
- Тот же гендер (+10)
|
||||
- Близкая цена (+0..10, исключение если >3x разница)
|
||||
- Тот же цвет (+5)
|
||||
|
||||
### Пул ID
|
||||
В левой панели можно вести «Пул ID» — список товаров для быстрой вставки.
|
||||
|
||||
---
|
||||
|
||||
## Пресеты
|
||||
|
||||
Пресет — сохранённый набор блоков с их содержимым.
|
||||
|
||||
- **Сохранить** — кнопка «Сохранить как пресет», вводится название
|
||||
- **Загрузить** — выбрать из списка, блоки заменятся
|
||||
- **Удалить** — кнопка удаления в списке пресетов
|
||||
|
||||
---
|
||||
|
||||
## Письма (карточки рассылок)
|
||||
|
||||
### Создание
|
||||
Каждая рассылка — карточка с полями:
|
||||
- Тема письма
|
||||
- Дата
|
||||
- Тег (категория)
|
||||
- Набор блоков
|
||||
|
||||
### Список писем
|
||||
В левой панели — список карточек. Можно:
|
||||
- Создать новое письмо
|
||||
- Открыть существующее
|
||||
- Удалить
|
||||
- Просмотреть историю изменений
|
||||
|
||||
### История
|
||||
Каждое сохранение создаёт снимок. Можно откатиться к предыдущей версии.
|
||||
|
||||
---
|
||||
|
||||
## Заметки
|
||||
|
||||
В боковой панели есть раздел «Заметки» — текстовые заметки привязанные к проекту.
|
||||
- Создание, редактирование, удаление
|
||||
- Данные хранятся на сервере
|
||||
|
||||
---
|
||||
|
||||
## План рассылок
|
||||
|
||||
Страница «План» подтягивает данные из Yonote (API). Показывает:
|
||||
- Календарь с отмеченными датами рассылок
|
||||
- Список с темами, статусами, датами
|
||||
- Уведомления о новых/удалённых рассылках (polling каждые 3 минуты)
|
||||
|
||||
---
|
||||
|
||||
## Проверка ссылок
|
||||
|
||||
Кнопка «Проверить ссылки» в превью:
|
||||
- Находит все URL в сгенерированном HTML
|
||||
- Проверяет каждый (HEAD-запрос)
|
||||
- Показывает статус: ✅ работает, ❌ битая, ⚠️ редирект
|
||||
|
||||
---
|
||||
|
||||
## Проверка орфографии
|
||||
|
||||
Использует Яндекс.Спеллер. Подсвечивает ошибки в превью красным волнистым подчёркиванием с подсказкой при наведении.
|
||||
|
||||
---
|
||||
|
||||
## Типографика
|
||||
|
||||
Кнопка «Типограф» отправляет тексты блоков через сервис Артемия Лебедева (typograf.artlebedev.ru). Применяет:
|
||||
- Неразрывные пробелы после предлогов
|
||||
- Правильные кавычки «ёлочки»
|
||||
- Тире вместо дефиса
|
||||
- И другие правила русской типографики
|
||||
|
||||
---
|
||||
|
||||
## FTP / Галерея изображений
|
||||
|
||||
### Настройка
|
||||
В настройках проекта → FTP:
|
||||
- Хост, порт, логин, пароль
|
||||
- Протокол (FTP / SFTP)
|
||||
- Удалённый путь
|
||||
- Публичный URL
|
||||
|
||||
### Использование
|
||||
- Кнопка «Галерея» открывает модальное окно
|
||||
- Загрузка: выбрать файлы (множественный выбор), загрузка автоматическая
|
||||
- Копирование URL: клик по миниатюре
|
||||
- Удаление: кнопка на миниатюре
|
||||
|
||||
---
|
||||
|
||||
## Копирование HTML
|
||||
|
||||
1. Нажать «Копировать HTML»
|
||||
2. HTML копируется в буфер обмена
|
||||
3. Вставить в Mindbox
|
||||
|
||||
**Важно:** в сгенерированном HTML предлоги и короткие слова (≤3 символов) склеены со следующим словом через `<span style="white-space:nowrap">` — это предотвращает висячие предлоги в почтовых клиентах.
|
||||
|
||||
---
|
||||
|
||||
## Тема оформления
|
||||
|
||||
Переключатель светлая/тёмная тема — в верхней панели. Настройка сохраняется в профиле пользователя на сервере.
|
||||
|
||||
---
|
||||
|
||||
## Администрирование
|
||||
|
||||
Доступно только пользователям с ролью `admin`.
|
||||
|
||||
### Управление пользователями
|
||||
- Создание новых пользователей (логин, пароль ≥8 символов, имя, роль)
|
||||
- Редактирование (смена пароля, имени, роли)
|
||||
- Удаление (нельзя удалить самого себя)
|
||||
|
||||
### Роли
|
||||
- **admin** — полный доступ + управление пользователями
|
||||
- **user** — работа с конструктором
|
||||
574
docs/02-developer-guide.md
Normal file
574
docs/02-developer-guide.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Aspekter VA — Руководство разработчика
|
||||
|
||||
## 1. Обзор архитектуры
|
||||
|
||||
Aspekter VA — монолитное web-приложение для сборки email-рассылок VipAvenue.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Браузер │
|
||||
│ App.svelte (UI) ←→ api.js (HTTP client) │
|
||||
└────────────────────┬────────────────────────────┘
|
||||
│ HTTP
|
||||
┌────────────────────▼────────────────────────────┐
|
||||
│ builder (порт 6001) │
|
||||
│ Vite dev server + API middleware │
|
||||
│ vite.config.js — весь backend (~1500 строк) │
|
||||
└────────────────────┬────────────────────────────┘
|
||||
│ HTTP (внутренний)
|
||||
┌────────────────────▼────────────────────────────┐
|
||||
│ email-gen-api (порт 8787) │
|
||||
│ server.js — Pug → HTML рендер │
|
||||
│ email-gen/ — шаблоны, стили, миксины │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Стек
|
||||
- **Frontend:** Svelte 5 + Vite 7
|
||||
- **Backend:** Vite middleware plugin (Node.js, встроен в dev-server)
|
||||
- **Email рендер:** Pug шаблоны + email-templates (npm)
|
||||
- **Хранение данных:** файловая система (JSON)
|
||||
- **Инфраструктура:** Docker Compose, nginx reverse proxy
|
||||
|
||||
### Почему так устроено
|
||||
Backend встроен как Vite middleware plugin, а не как отдельный сервер. Это значит:
|
||||
- Один процесс, один порт
|
||||
- HMR для фронтенда работает из коробки
|
||||
- Нет CORS-проблем
|
||||
- Но: vite.config.js не перезагружается через HMR — нужен `docker restart builder`
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура файлов
|
||||
|
||||
```
|
||||
VA.ASPEKTER/
|
||||
├── docker-compose.yml # Оркестрация контейнеров
|
||||
├── deploy/
|
||||
│ ├── nginx/ # Nginx конфиг + лендинг
|
||||
│ │ ├── nginx.conf
|
||||
│ │ └── landing.html
|
||||
│ └── email-gen-api/
|
||||
│ ├── Dockerfile
|
||||
│ └── server.js # Standalone Pug render service
|
||||
├── email-gen/ # Git-репозиторий коллег (шаблоны)
|
||||
│ └── emails/vipavenue/
|
||||
│ ├── layout/layout.pug # Базовый layout (width=600)
|
||||
│ ├── blocks/block.pug # Все блоки конструктора
|
||||
│ ├── includes/mixins.pug # Pug миксины (товары, кнопки, preheader)
|
||||
│ ├── css/style.css # Стили (инлайнятся при рендере)
|
||||
│ └── parts/ # Хедеры, футеры (по гендеру)
|
||||
│ ├── header/
|
||||
│ └── footer/
|
||||
└── z51-pug-builder/ # Основное приложение
|
||||
├── Dockerfile
|
||||
├── package.json
|
||||
├── vite.config.js # ВЕСЬ BACKEND API (~1500 строк)
|
||||
├── index.html # Входная точка Vite
|
||||
├── public/ # Статика (favicon)
|
||||
├── src/
|
||||
│ ├── App.svelte # ВЕСЬ FRONTEND UI (~6400 строк)
|
||||
│ ├── app.css # Все стили
|
||||
│ ├── main.js # Точка входа Svelte
|
||||
│ └── lib/
|
||||
│ ├── api.js # API-клиент (все эндпоинты)
|
||||
│ ├── parsing.js # Парсинг Pug-блоков, миксинов, аргументов
|
||||
│ ├── spellcheck.js # Проверка орфографии (Yandex Speller)
|
||||
│ └── utils.js # Утилиты (normalizeNewlines, escapeRegExp, etc)
|
||||
└── data/ # Данные (том Docker)
|
||||
├── config.json # Глобальный конфиг (Yonote токен, URL)
|
||||
├── feed-cache.json # Кэш товарного фида
|
||||
├── render-cache.json # Кэш рендера (Pug→HTML)
|
||||
├── _system/
|
||||
│ ├── users.json # Пользователи (логин, хэш пароля, роль)
|
||||
│ └── sessions.json # Активные сессии
|
||||
└── vipavenue/ # Данные проекта
|
||||
├── settings.json # Настройки (блоки, FTP, гендер, фид)
|
||||
├── block.pug # Текущий пуг-файл конструктора
|
||||
├── presets.json # Пресеты
|
||||
├── notes.json # Заметки (индекс)
|
||||
├── notes/ # Файлы заметок
|
||||
├── stats.json # Статистика времени
|
||||
├── drafts/ # Черновики по пользователям
|
||||
└── letters/ # Карточки писем по пользователям
|
||||
└── {userId}/
|
||||
├── letters.json # Индекс писем
|
||||
├── {id}.json # Данные письма
|
||||
└── {id}.history.json # История снимков
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Docker — контейнеры и тома
|
||||
|
||||
### Контейнеры
|
||||
|
||||
| Контейнер | Образ | Порт | Назначение |
|
||||
|---|---|---|---|
|
||||
| `builder` | z51-pug-builder/Dockerfile | 6001→5173 | Vite dev server + API |
|
||||
| `email-gen-api` | deploy/email-gen-api/Dockerfile | (внутренний) 8787 | Pug→HTML рендер |
|
||||
| nginx | (отдельно) | 80/443 | Reverse proxy |
|
||||
|
||||
### Тома builder
|
||||
|
||||
| Хост | Контейнер | Режим | HMR? |
|
||||
|---|---|---|---|
|
||||
| `./z51-pug-builder/src` | `/app/src` | rw | ✅ Мгновенно |
|
||||
| `./z51-pug-builder/vite.config.js` | `/app/vite.config.js` | rw | ❌ Нужен restart |
|
||||
| `./z51-pug-builder/data` | `/app/data` | rw | — |
|
||||
| `./email-gen` | `/email-gen` | rw | ❌ Нужен build |
|
||||
|
||||
### Тома email-gen-api
|
||||
|
||||
| Хост | Контейнер |
|
||||
|---|---|
|
||||
| `./email-gen` | `/workspace/email-gen` (rw) |
|
||||
| Named volume | `/workspace/email-gen/node_modules` |
|
||||
|
||||
### Правила пересборки (КРИТИЧЕСКИ ВАЖНО)
|
||||
|
||||
**`docker compose build && docker compose up -d` нужен при:**
|
||||
- Изменения в `email-gen/` (pug шаблоны, миксины, стили)
|
||||
- Изменения в `deploy/email-gen-api/server.js`
|
||||
- Изменения в `z51-pug-builder/Dockerfile`
|
||||
- Изменения в `z51-pug-builder/package.json` (новые зависимости)
|
||||
|
||||
**`docker compose restart builder` нужен при:**
|
||||
- Изменения в `z51-pug-builder/vite.config.js`
|
||||
|
||||
**Ничего не нужно (HMR подхватит):**
|
||||
- Изменения в `z51-pug-builder/src/` (App.svelte, app.css, lib/*.js)
|
||||
|
||||
> **НЕ использовать** `docker cp` или `docker exec` для правки исходников на проде. Только редактировать на хосте, тома примонтированы.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend API — все эндпоинты
|
||||
|
||||
Весь API реализован в `vite.config.js` как Vite middleware plugin.
|
||||
|
||||
### Авторизация (без аутентификации)
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/auth/login` | Вход: `{login, password}` → cookie `va_token` |
|
||||
| POST | `/api/auth/logout` | Выход: удаляет cookie |
|
||||
| GET | `/api/auth/me` | Текущий пользователь (или 401) |
|
||||
| PUT | `/api/auth/preferences` | Сохранить тему/activePage/zoom |
|
||||
|
||||
### Администрирование (role: admin)
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/users` | Список пользователей |
|
||||
| POST | `/api/admin/users` | Создать: `{login, password, name, role}` |
|
||||
| PUT | `/api/admin/users/:id` | Обновить пользователя |
|
||||
| DELETE | `/api/admin/users/:id` | Удалить (нельзя удалить себя) |
|
||||
|
||||
### Проект (все требуют аутентификации)
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/projects` | Список проектов (всегда `["vipavenue"]`) |
|
||||
| GET | `/api/project/:name` | Данные проекта (блок, настройки, черновик, пресеты, письма, заметки) |
|
||||
| PUT | `/api/project/:name/block` | Сохранить block.pug |
|
||||
| PUT | `/api/project/:name/block-custom` | Сохранить кастомный блок |
|
||||
| PUT | `/api/project/:name/settings` | Сохранить настройки |
|
||||
| PUT | `/api/project/:name/draft` | Сохранить черновик |
|
||||
| PUT | `/api/project/:name/presets` | Сохранить пресеты |
|
||||
|
||||
### Письма
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/project/:name/letter/:id` | Получить письмо |
|
||||
| PUT | `/api/project/:name/letter` | Сохранить: `{id, ...data}` |
|
||||
| DELETE | `/api/project/:name/letter/:id` | Удалить |
|
||||
| GET | `/api/project/:name/letter/:id/history` | История снимков |
|
||||
| PUT | `/api/project/:name/letter/:id/history` | Добавить снимок |
|
||||
|
||||
### Заметки
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/project/:name/notes` | Индекс заметок |
|
||||
| PUT | `/api/project/:name/notes` | Сохранить индекс |
|
||||
| GET | `/api/project/:name/note/:id` | Получить заметку |
|
||||
| PUT | `/api/project/:name/note` | Сохранить: `{id, ...data}` |
|
||||
| DELETE | `/api/project/:name/note/:id` | Удалить |
|
||||
|
||||
### Рендер email
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/project/:name/render-email` | Рендер Pug→HTML. Body: `{projectSlug, pug, preheader, gender}` |
|
||||
|
||||
**Цепочка рендера:**
|
||||
1. Frontend отправляет Pug-код + preheader + gender
|
||||
2. Backend формирует hash, проверяет кэш
|
||||
3. Если кэш-промах → отправляет в email-gen-api
|
||||
4. email-gen-api: записывает `letters/let.pug`, генерирует `html.pug`, запускает `email-templates` → `public/index.html`
|
||||
5. Backend читает HTML, обрабатывает Mindbox-теги (подставляет товары), применяет nowrap
|
||||
6. Возвращает `{html, previewHtml, unavailableProducts}`
|
||||
|
||||
### Товарный фид
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/project/:name/feed-refresh` | Очистить кэш фида, перезагрузить |
|
||||
| POST | `/api/project/:name/feed-lookup` | Найти товары по ID: `{ids: [...]}` |
|
||||
| POST | `/api/project/:name/feed-suggest` | Подобрать замену: `{productId, excludeIds, search}` |
|
||||
|
||||
Фид кэшируется на 3 часа. XML парсится regex-ом (не DOM), поддерживается windows-1251.
|
||||
|
||||
### FTP/SFTP
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| POST | `/api/project/:name/ftp/test` | Проверить подключение |
|
||||
| POST | `/api/project/:name/ftp/upload` | Загрузить файл: `{imageData, fileName, folder}` |
|
||||
| POST | `/api/project/:name/ftp/list` | Список файлов: `{folder}` |
|
||||
| POST | `/api/project/:name/ftp/delete` | Удалить файл: `{folder, fileName}` |
|
||||
|
||||
### Прочее
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/api/config` | Глобальный конфиг (Yonote, upload URL) |
|
||||
| PUT | `/api/config` | Сохранить конфиг |
|
||||
| GET | `/api/yonote/status` | Статус подключения Yonote |
|
||||
| GET | `/api/yonote/databases` | Список баз Yonote |
|
||||
| GET | `/api/yonote/database/:id/properties` | Свойства базы |
|
||||
| GET | `/api/yonote/database/:id/rows` | Строки базы |
|
||||
| POST | `/api/yonote/row/update` | Обновить строку: `{rowId, values}` |
|
||||
| POST | `/api/upload-image` | Загрузить изображение: `{imageData, fileName}` |
|
||||
| POST | `/api/check-links` | Проверить ссылки: `{urls: [...]}` |
|
||||
| GET | `/api/parts-files` | Список pug-файлов из parts/ |
|
||||
| GET | `/api/parts-file-read?path=...` | Прочитать parts-файл |
|
||||
| POST | `/api/parts-file-write` | Записать parts-файл |
|
||||
| GET | `/api/project/:name/stats` | Статистика проекта |
|
||||
| POST | `/api/project/:name/stats` | Сохранить запись статистики |
|
||||
| GET | `/api/stats` | Вся статистика (все проекты) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend — архитектура
|
||||
|
||||
### Общая структура
|
||||
|
||||
Весь UI — один файл `App.svelte` (~6400 строк). Это монолит. Причина — проект начинался как прототип и вырос.
|
||||
|
||||
### Ключевые модули (lib/)
|
||||
|
||||
**api.js** — HTTP-клиент. Все функции `api*()` вызывают `apiRequest()` который делает `fetch()` с `Content-Type: application/json` и обрабатывает ошибки.
|
||||
|
||||
**parsing.js** — парсинг Pug-кода:
|
||||
- `parseBlocks(text)` — разбивает Pug на блоки по `//` комментариям
|
||||
- `buildBaseSchema(content, blockName, mixinRules)` — строит схему полей блока (какие поля редактируемые)
|
||||
- `parseSections(content)` — разбивает блок на секции для drag-n-drop
|
||||
- `findMixinArgRange(line, argIndex)` — находит позицию аргумента миксина в строке
|
||||
- `extractMixinArgValue()` / `replaceMixinArgQuoted()` — чтение/запись аргументов
|
||||
|
||||
**spellcheck.js** — `checkSpelling(text)` отправляет в Yandex Speller, `injectSpellMarks(html, errors)` подсвечивает ошибки в превью.
|
||||
|
||||
**utils.js** — `normalizeNewlines()`, `escapeRegExp()`, `unquoteValue()`, `downloadFile()`.
|
||||
|
||||
### State management
|
||||
|
||||
Svelte 5 reactive `let` переменные. Ключевые группы:
|
||||
|
||||
| Группа | Переменные | Описание |
|
||||
|---|---|---|
|
||||
| UI | `activePage`, `theme`, `sidebarTab`, `settingsBlockIndex` | Состояние интерфейса |
|
||||
| Данные | `assembledBlocks`, `presets`, `planRows`, `allStats` | Основные данные |
|
||||
| Рендер | `renderedHtml`, `previewHtml`, `autoRenderTimer` | Результат рендера |
|
||||
| Гендер | `currentGenderVersion`, `segmentFlipped` | Гендерная сегментация |
|
||||
| Фид | `feedProducts`, `unavailableProducts` | Товары |
|
||||
| Письма | `currentLetterId`, `letterName` | Текущее письмо |
|
||||
| Настройки | `settings` | settings.json проекта |
|
||||
|
||||
### Ключевые функции
|
||||
|
||||
| Функция | Назначение |
|
||||
|---|---|
|
||||
| `loadProject()` | Загрузка проекта при старте |
|
||||
| `rebuildOutput()` | Пересборка Pug из блоков → рендер |
|
||||
| `renderEmail()` | Отправка Pug на сервер для рендера |
|
||||
| `assembleGenderVersion(target)` | Сборка гендерной версии |
|
||||
| `flipSegmentOrder()` | Переключение порядка блоков (⇅) |
|
||||
| `addBlock(name)` / `removeBlock(id)` | Управление блоками |
|
||||
| `updateBlockContent(id, content)` | Обновление содержимого блока |
|
||||
| `saveDraft()` | Автосохранение черновика |
|
||||
| `copyHtml()` | Копирование HTML в буфер |
|
||||
| `handlePreviewElementClick(data)` | Клик по элементу в превью → фокус на поле |
|
||||
|
||||
### Реактивные вычисления ($:)
|
||||
|
||||
```javascript
|
||||
$: settingsBlockName = allBlocks[settingsBlockIndex]?.name
|
||||
$: settingsTemplate = getEffectiveTemplate(settingsBlockName)
|
||||
$: baseSchema = buildBaseSchema(settingsTemplate, settingsBlockName, ...)
|
||||
$: allBlocks = [...blockTemplates, ...customBlocksList]
|
||||
$: productOptionsList = (settings.productOptions || []).map(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Email rendering pipeline
|
||||
|
||||
### Полная цепочка
|
||||
|
||||
```
|
||||
1. App.svelte: assembleGenderVersion() + rebuildOutput()
|
||||
↓ Собирает Pug из блоков
|
||||
2. App.svelte: apiRenderEmail(name, {projectSlug, pug, preheader, gender})
|
||||
↓ HTTP POST
|
||||
3. vite.config.js: /api/project/:name/render-email
|
||||
↓ pugHash() → проверка кэша
|
||||
↓ Если промах:
|
||||
4. vite.config.js → HTTP POST email-gen-api:8787/render
|
||||
↓ {projectSlug, pug, preheader, gender, genderPaths}
|
||||
5. email-gen-api/server.js:
|
||||
a) Записывает letters/let.pug (содержимое из конструктора)
|
||||
b) rewriteHtmlPug() — генерирует html.pug с правильным header/footer
|
||||
c) renderWithNode() — вызывает email-templates (Pug → HTML)
|
||||
d) Читает public/index.html → возвращает HTML
|
||||
6. vite.config.js:
|
||||
a) Кэширует HTML
|
||||
b) processMindboxTags() — подставляет товары из фида
|
||||
c) applyNowrap() — склеивает предлоги с nowrap-спанами
|
||||
d) Возвращает {html, previewHtml, unavailableProducts}
|
||||
7. App.svelte: показывает превью в iframe
|
||||
```
|
||||
|
||||
### Кэш рендера
|
||||
- Ключ: `md5(slug + pug + gender + genderPaths)`
|
||||
- Максимум 30 записей (LRU)
|
||||
- Персистится в `data/render-cache.json`
|
||||
- Инвалидируется при смене Pug-кода, гендера или путей header/footer
|
||||
|
||||
### Mindbox-обработка
|
||||
- Замена `@{for...}@{end for}` блоков
|
||||
- Извлечение ID товаров из `GetByValue('12345')`
|
||||
- Подстановка данных из фида (цена, название, картинка, URL)
|
||||
- Отслеживание недоступных товаров
|
||||
|
||||
### Nowrap-обработка
|
||||
- Только для текстовых блоков (span с классом `h3`)
|
||||
- Оборачивает `предлог + слово` в `<span style="white-space:nowrap">`
|
||||
- Использует placeholder-подход чтобы избежать двойной обработки
|
||||
|
||||
---
|
||||
|
||||
## 7. Гендерная сегментация — техническая реализация
|
||||
|
||||
### Данные
|
||||
```json
|
||||
// settings.json → genderPaths
|
||||
{
|
||||
"headerFemale": "./parts/header/header-woman",
|
||||
"headerMale": "./parts/header/header-man",
|
||||
"footerFemale": "./parts/footer/footer-woman",
|
||||
"footerMale": "./parts/footer/footer-man"
|
||||
}
|
||||
```
|
||||
|
||||
### Цепочка передачи gender
|
||||
|
||||
1. **App.svelte** → `apiRenderEmail(..., { gender: 'female'|'male' })`
|
||||
2. **vite.config.js** → читает `genderPaths` из settings.json, форвардит в email-gen-api
|
||||
3. **email-gen-api/server.js** → `rewriteHtmlPug(projectDir, preheader, gender, genderPaths)` — записывает `html.pug` с нужным include header/footer
|
||||
4. Pug рендерит HTML с правильным header/footer
|
||||
|
||||
### Кэш
|
||||
`pugHash(slug, pug, gender, genderPaths)` — gender и genderPaths включены в ключ, female/male кэшируются отдельно.
|
||||
|
||||
### Flip (⇅)
|
||||
- `flipSegmentOrder()` в App.svelte
|
||||
- Находит блок-разделитель с `swapCenter = true`
|
||||
- Блоки до разделителя и после меняются местами
|
||||
- `segmentFlipped` (bool) отслеживает состояние toggle
|
||||
|
||||
---
|
||||
|
||||
## 8. Авторизация и безопасность
|
||||
|
||||
### Аутентификация
|
||||
- **Хэширование:** scrypt (salt 16 bytes, key length 64)
|
||||
- **Сессии:** случайный токен 32 bytes hex, хранится в Map + файл sessions.json
|
||||
- **Cookie:** `va_token`, HttpOnly, SameSite=Strict, Secure, Max-Age=7d
|
||||
- **Brute-force:** 5 попыток на IP за 15 минут
|
||||
|
||||
### Middleware стек (порядок важен!)
|
||||
1. Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
|
||||
2. Auth endpoints (/api/auth/*) — без проверки сессии
|
||||
3. CSRF проверка (origin/referer vs host)
|
||||
4. Auth middleware — проверка cookie → req.user
|
||||
5. Admin middleware (/api/admin/*) — проверка role=admin
|
||||
6. Uploads middleware (/uploads/*) — статика с проверкой path traversal
|
||||
7. API middleware — основные эндпоинты
|
||||
|
||||
### Защита от атак
|
||||
- **Path traversal:** `sanitizeFileId()` для всех файловых ID, `getProjectDir()` проверяет startsWith
|
||||
- **SSRF:** фильтрация приватных IP (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16)
|
||||
- **CSRF:** проверка origin/referer на state-changing запросах
|
||||
- **XSS:** не применимо (внутренний инструмент, HTML генерируется из шаблонов)
|
||||
|
||||
---
|
||||
|
||||
## 9. Настройки проекта (settings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"globalSpacing": 40,
|
||||
"blocks": {
|
||||
"Текст": { "spacing": 20, "template": "..." },
|
||||
"Кнопка": { "spacing": 10 }
|
||||
},
|
||||
"genderPaths": {
|
||||
"headerFemale": "./parts/header/header-woman",
|
||||
"headerMale": "./parts/header/header-man",
|
||||
"footerFemale": "./parts/footer/footer-woman",
|
||||
"footerMale": "./parts/footer/footer-man"
|
||||
},
|
||||
"feedUrl": "https://...",
|
||||
"ftpConfig": {
|
||||
"host": "...",
|
||||
"port": 21,
|
||||
"user": "...",
|
||||
"password": "***",
|
||||
"protocol": "ftp",
|
||||
"remotePath": "/images",
|
||||
"publicUrl": "https://..."
|
||||
},
|
||||
"mixinRules": [
|
||||
{ "mixin": "buttonRounded", "argIndex": 0, "type": "mixin-text", "label": "Текст кнопки" }
|
||||
],
|
||||
"productOptions": [
|
||||
{ "code": "showPrice:true", "label": "Показывать цену", "defaultEnabled": true }
|
||||
],
|
||||
"customBlocks": [
|
||||
{ "id": "...", "name": "Мой блок", "content": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. email-gen — шаблоны (репозиторий коллег)
|
||||
|
||||
### Структура
|
||||
```
|
||||
email-gen/emails/vipavenue/
|
||||
├── layout/layout.pug # Основной layout, table width=600
|
||||
├── blocks/block.pug # Все блоки (//Текст, //Кнопка, //Товары, etc)
|
||||
├── includes/mixins.pug # Pug-миксины (+products, +buttonRounded, +preheader, etc)
|
||||
├── css/style.css # CSS (инлайнится при рендере)
|
||||
└── parts/
|
||||
├── header/ # Хедеры (header-woman.pug, header-man.pug, header-dark.pug)
|
||||
└── footer/ # Футеры (аналогично)
|
||||
```
|
||||
|
||||
### Как добавить новый блок
|
||||
1. Добавить секцию в `blocks/block.pug`:
|
||||
```pug
|
||||
//Мой новый блок
|
||||
tr
|
||||
td.paddingWrapper
|
||||
+defaultTable("100%")
|
||||
tr
|
||||
td(align="center")
|
||||
span.font.h3.blackText Текст блока
|
||||
```
|
||||
2. Пересобрать контейнеры: `docker compose build && docker compose up -d`
|
||||
3. Блок появится в конструкторе
|
||||
|
||||
### Preheader
|
||||
- Миксин `+preheader(text)` в mixins.pug
|
||||
- Формат: `{текст} <vk-snippet-end>⠀×130`
|
||||
- `#MAILRU_PREHEADER_TAG#` в pug заменяется на `<vk-snippet-end>` в server.js
|
||||
|
||||
---
|
||||
|
||||
## 11. Деплой
|
||||
|
||||
### Сервер
|
||||
- IP: определяется из конфига
|
||||
- Проект: `/opt/va/` (или аналогичный путь)
|
||||
- Nginx: reverse proxy → localhost:6001
|
||||
|
||||
### Обновление кода
|
||||
|
||||
**Frontend (App.svelte, app.css, lib/*.js):**
|
||||
```bash
|
||||
# Файлы примонтированы через том — просто редактируй на хосте
|
||||
# HMR подхватит автоматически
|
||||
```
|
||||
|
||||
**Backend (vite.config.js):**
|
||||
```bash
|
||||
# Файл примонтирован, но Vite не перечитывает конфиг
|
||||
docker compose restart builder
|
||||
```
|
||||
|
||||
**Шаблоны email (email-gen/):**
|
||||
```bash
|
||||
docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
**email-gen-api/server.js:**
|
||||
```bash
|
||||
docker compose build email-gen-api && docker compose up -d email-gen-api
|
||||
```
|
||||
|
||||
### Первый запуск
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd VA.ASPEKTER
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
# Смотреть логи для временного пароля admin:
|
||||
docker compose logs builder | grep "Временный пароль"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Известные особенности и ограничения
|
||||
|
||||
### Архитектурные
|
||||
- **Монолит:** App.svelte ~6400 строк, vite.config.js ~1500 строк. Декомпозиция не проводилась.
|
||||
- **Файловое хранилище:** нет БД, всё в JSON. Race condition при параллельных записях теоретически возможен (writeFileSync без блокировки).
|
||||
- **Single-project:** код спроектирован под vipavenue. Мультипроектность убрана, но следы остались (PROJECT_NAME constant).
|
||||
|
||||
### Рендер
|
||||
- **Кэш:** 30 записей LRU. При изменении CSS в email-gen кэш не инвалидируется (нужно очистить вручную или пересобрать).
|
||||
- **Mindbox-теги:** парсятся regex-ом, не DOM. Если формат тегов изменится — сломается.
|
||||
- **XML-фид:** парсится regex-ом. CDATA, вложенные теги с одинаковым именем — потенциально проблемны.
|
||||
|
||||
### Фронтенд
|
||||
- **Svelte 5:** используется Svelte 5, но не все runes-паттерны применены. `ensureSchema()` мутирует block.schema напрямую.
|
||||
- **MutationObserver:** на document.body с subtree=true. Debounce 200ms добавлен, но это всё равно потенциальная нагрузка.
|
||||
|
||||
### TODO (отмечено в коде)
|
||||
- Google Sheets → Yonote API миграция (поиск по `TODO: YONOTE`)
|
||||
- Пользовательские настройки (тема, zoom) частично перенесены на сервер, localStorage ещё используется как fallback
|
||||
- Pull email-gen из git — функция запланирована, но не реализована
|
||||
|
||||
---
|
||||
|
||||
## 13. Conventions
|
||||
|
||||
### Именование
|
||||
- localStorage ключи: `va-*` (va-active-page, va-theme, va-plan-cache, va-previewZoom)
|
||||
- Cookie: `va_token`
|
||||
- CSS классы: `va-spell` (spellcheck), `vaclick` (preview detection)
|
||||
- Docker: `vaaspekter-builder-1`, `vaaspekter-email-gen-api-1`
|
||||
|
||||
### Форматирование кода
|
||||
- 2 пробела отступ
|
||||
- Одинарные кавычки в JS
|
||||
- Без точек с запятой (semicolons) — inconsistent, в некоторых местах есть
|
||||
|
||||
### Git
|
||||
- `email-gen/` — это отдельный git-репозиторий коллег, вложен как поддиректория (НЕ submodule)
|
||||
- Основной репозиторий: `VA.ASPEKTER/`
|
||||
168
docs/applyNowrap.md
Normal file
168
docs/applyNowrap.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# applyNowrap — защита от висячих предлогов в email
|
||||
|
||||
## Назначение
|
||||
|
||||
Функция `applyNowrap(html)` предотвращает «висячие» предлоги и короткие слова (≤3 букв) в email-рассылках. Оборачивает короткое слово + следующее слово в `<span style="white-space:nowrap">`, чтобы они не разрывались на разные строки.
|
||||
|
||||
**Почему нужна:** Mail.ru и некоторые другие почтовые клиенты игнорируют ` ` при переносе строк. Единственный надёжный способ — `white-space:nowrap` на `<span>`.
|
||||
|
||||
## Полный код
|
||||
|
||||
```javascript
|
||||
function applyNowrap(html) {
|
||||
function wrapShort(text) {
|
||||
return text.replace(
|
||||
/(?<![a-zA-Zа-яА-ЯёЁ])([a-zA-Zа-яА-ЯёЁ]{1,3})(?:\s| )+(\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| )+(\S+)/gu
|
||||
```
|
||||
|
||||
Разбор по частям:
|
||||
|
||||
| Часть | Значение |
|
||||
|-------|----------|
|
||||
| `(?<![a-zA-Zа-яА-ЯёЁ])` | Negative lookbehind: перед словом НЕ должна стоять буква (иначе это часть длинного слова) |
|
||||
| `([a-zA-Zа-яА-ЯёЁ]{1,3})` | Группа 1: короткое слово от 1 до 3 букв (предлоги, союзы, частицы) |
|
||||
| `(?:\s\| )+` | Пробел ИЛИ ` ` (типограф заменяет пробелы на ` `) |
|
||||
| `(\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;">С первыми весенними днями дизайнеры и стилисты</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: ` ` не матчился (2026-03-20)
|
||||
|
||||
**Симптом:** Nowrap-спаны не появляются в HTML, хотя функция вызывается.
|
||||
|
||||
**Причина:** Типограф (Артемий Лебедев / любой другой) вставляет ` ` между предлогами и словами. Оригинальный regex искал только `\s+` и не находил ` `.
|
||||
|
||||
**Фикс:** Заменить `\s+` на `(?:\s| )+`.
|
||||
|
||||
### Баг 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 уже обрабатывает ` `. Если нет — `(?:\s| )+` всё равно будет работать корректно (просто ` `-ветка не сработает)
|
||||
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 с подставленными товарами для превью
|
||||
Reference in New Issue
Block a user