Files
VA.ASPEKTER/docs/applyNowrap.md
Sergey Zotov c090bfcf47 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>
2026-04-13 01:21:00 +05:00

8.9 KiB
Raw Permalink Blame History

applyNowrap — защита от висячих предлогов в email

Назначение

Функция applyNowrap(html) предотвращает «висячие» предлоги и короткие слова (≤3 букв) в email-рассылках. Оборачивает короткое слово + следующее слово в <span style="white-space:nowrap">, чтобы они не разрывались на разные строки.

Почему нужна: Mail.ru и некоторые другие почтовые клиенты игнорируют &nbsp; при переносе строк. Единственный надёжный способ — white-space:nowrap на <span>.

Полный код

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 → реальные теги

.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>

Результат на примере

Вход:

<span class="font h3 blackText" style="font-size: 18px;">С&nbsp;первыми весенними днями дизайнеры и&nbsp;стилисты</span></td>

Выход:

<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-тегов:

const nowrapHtml = applyNowrap(rawHtml)
const nowrapPreview = applyNowrap(mindbox.html)

Результаты отдаются клиенту:

  • nowrapHtml — финальный HTML для экспорта/отправки
  • nowrapPreview — HTML с подставленными товарами для превью