fix(prosemirror-markdown): литеральный <br> в тексте экранируется, не становится hardBreak (#554) #560

Merged
vvzvlad merged 1 commits from fix/554-literal-br into develop 2026-07-12 18:57:03 +03:00
Collaborator

Что (#554)

Литеральный <br> (или <br/>, <br />), НАБРАННЫЙ пользователем как ТЕКСТ абзаца, молча превращался в hardBreak-ноду на round-trip pm→md→pm: case "text" не экранировал литеральные </>, поэтому <br> уходил в markdown как есть, а marked на реимпорте парсил его как inline-HTML-break. Пред-существующий баг (идентичен на develop), но #553 (#549) сделал <br>↔hardBreak транспортом, так что различать пользовательский <br> стало нужнее.

Фикс

В case "text" (в не-code ветке, рядом с существующими escape'ами ==/$…$/^[) добавлено энтити-экранирование углов break-тега:

textContent = textContent.replace(/<(\s*br\s*\/?\s*)>/gi, "&lt;$1&gt;");

marked пропускает &lt;…&gt; как литеральный текст, импортёр декодит обратно в <br> — ран переживает round-trip как текст с нулём hardBreak'ов.

Комплементарность с #553 (транспорт <br>)

Пути раздельны по типу узла: hardBreak-нода эмитит <br> из case "hardBreak"/inlineToHtml; литеральный текст экранируется в case "text"&lt;br&gt;. На импорте различаются: сырой <br> (транспорт→hardBreak) vs энтити (текст). #553 уже в develop — ветка отребейзена на него, конфликта нет, оба пути работают вместе (полный сьют 833/833).

Известное ограничение (осознанное)

Экранируются только «голые» варианты <br>/<br/>/<br />. Break-тег с атрибутами (<br class="x">) НЕ экранируется и на импорте всё ещё станет hardBreak — редкий литерал, за скоупом. Regex точечный: <abbr>/<b>/<hr>/<break> не задеваются.

Как проверял

Round-trip через реальный сериализатор+импортёр: a<br>b/a<br/>b/a<br />b → md с &lt;br → реимпорт 0 hardBreak, текст <br> цел. Реальный hardBreak-узел (a \nb) и stray-углы (a < b > c) не тронуты. Идемпотентно (уже-энтити текст не двоится). prosemirror-markdown сьют 833/833 (после ребейза на develop с #553), tsc 0.

внутренний цикл: 1 проход — APPROVE (верная ветка без порчи code-span, точечный regex, нет коллизии с #553, идемпотентно; мутация снятия escape краснит ровно 3 варианта литерального <br>).

closes #554

## Что (#554) Литеральный `<br>` (или `<br/>`, `<br />`), НАБРАННЫЙ пользователем как ТЕКСТ абзаца, молча превращался в hardBreak-ноду на round-trip pm→md→pm: `case "text"` не экранировал литеральные `<`/`>`, поэтому `<br>` уходил в markdown как есть, а marked на реимпорте парсил его как inline-HTML-break. Пред-существующий баг (идентичен на develop), но #553 (#549) сделал `<br>`↔hardBreak транспортом, так что различать пользовательский `<br>` стало нужнее. ## Фикс В `case "text"` (в не-code ветке, рядом с существующими escape'ами `==`/`$…$`/`^[`) добавлено энтити-экранирование углов break-тега: ```js textContent = textContent.replace(/<(\s*br\s*\/?\s*)>/gi, "&lt;$1&gt;"); ``` marked пропускает `&lt;…&gt;` как литеральный текст, импортёр декодит обратно в `<br>` — ран переживает round-trip как текст с нулём hardBreak'ов. ### Комплементарность с #553 (транспорт `<br>`) Пути раздельны по типу узла: hardBreak-нода эмитит `<br>` из `case "hardBreak"`/`inlineToHtml`; литеральный текст экранируется в `case "text"` → `&lt;br&gt;`. На импорте различаются: сырой `<br>` (транспорт→hardBreak) vs энтити (текст). #553 уже в develop — ветка отребейзена на него, конфликта нет, оба пути работают вместе (полный сьют 833/833). ## Известное ограничение (осознанное) Экранируются только «голые» варианты `<br>`/`<br/>`/`<br />`. Break-тег с атрибутами (`<br class="x">`) НЕ экранируется и на импорте всё ещё станет hardBreak — редкий литерал, за скоупом. Regex точечный: `<abbr>`/`<b>`/`<hr>`/`<break>` не задеваются. ## Как проверял Round-trip через реальный сериализатор+импортёр: `a<br>b`/`a<br/>b`/`a<br />b` → md с `&lt;br` → реимпорт 0 hardBreak, текст `<br>` цел. Реальный hardBreak-узел (`a \nb`) и stray-углы (`a < b > c`) не тронуты. Идемпотентно (уже-энтити текст не двоится). prosemirror-markdown сьют **833/833** (после ребейза на develop с #553), tsc 0. внутренний цикл: 1 проход — APPROVE (верная ветка без порчи code-span, точечный regex, нет коллизии с #553, идемпотентно; мутация снятия escape краснит ровно 3 варианта литерального `<br>`). closes #554
agent_coder added 1 commit 2026-07-12 17:59:35 +03:00
A literal inline-HTML break tag typed as prose text (`<br>`, `<br/>`,
`<br />`) was emitted verbatim into markdown, so on re-import marked
parsed it as an inline-HTML line break and silently turned the user's
text into a hardBreak node, dropping the text. HTML-entity-encode only
the angle brackets of a break-tag sequence in `case "text"` so it lands
as `&lt;br&gt;` and the importer decodes it back to literal `<br>`.

Scoped strictly to the `<br…>` pattern (not every `<`/`>`), so stray
angle brackets in prose (`a < b > c`) are untouched, and to the
text-content path only — a real hardBreak serializes from its own case
(`  \n`, or `<br>` via inlineToHtml), so the serializer's own emitted
breaks are never escaped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder force-pushed fix/554-literal-br from 1c0c6fb9a5 to 2c4fc565b6 2026-07-12 17:59:36 +03:00 Compare
agent_coder added the review/needs label 2026-07-12 17:59:37 +03:00
Collaborator

Ревью — литеральный <br> экранируется, не становится hardBreak (#554). Вердикт: approved

Чинит ровно ту потерю, что я сам завёл в #554 при ревью #553. Проверил своим round-trip'ом + агент (оба-направления, контейнеры, мутация). Нового бага НЕ вносит, #553 НЕ ломает. Эскалаций нет.

📋 Проверка + adversarial

Фикс: в case "text" энтити-энкодит только угловые скобки break-тега: textContent.replace(/<(\s*br\s*\/?\s*)>/gi, "&lt;$1&gt;") → в md уходит &lt;br&gt;, marked пропускает энтити, импортёр декодит обратно в литеральный <br> → раннится как ТЕКСТ, не hardBreak. Строго scoped на <br…> (не любой </>). Только text-path — эмитнутый hardBreak-<br> (из своего case, #553) не трогается.

Мой эмпирический round-trip (собранный модуль):

  • #554 fix: a<br>b→md a&lt;br&gt;b→back-text a<br>b (0 hb ); <br/>/<br /> так же.
  • #553 НЕ сломан: trailing/consecutive/mid-para hardBreak → 1/2/1 hb (эмитнутый <br> НЕ экранируется).
  • a < b > c — не тронут . Inline-code/code-block <br> — НЕ экранируется (литерал в коде) .

Агент (parent-baseline, оба направления): decode симметричен (byte-stable, triple round-trip); over-fire корректен (heading/blockquote/list/link-text/table-cell держат литерал; code — нет; link-title/image-alt через своё экранирование); эмитнутый break цел во ВСЕХ контейнерах; смешанный кейс (литерал <br> + реальный hardBreak в одной ячейке) различает верно (1 hb + текст). Мутация: снял .replace → все 3 литерал-<br> теста краснеют, hardBreak-тест зелёный. Не вакуумно.

Гейт: pm-markdown 833/833 (47 файлов, вкл. новый roundtrip.test). Дерево чисто.

Security/stability/regressions/coherence/conventions — LGTM. ниже порога (кодеру НЕ делать): <br class=x> (с атрибутами) всё ещё → hardBreak (regex ловит только bare-форму) — тот же класс, но вне scope bare-<br> #554, пред-существующее; <br\t> (TAB в теге) marked нормализует — контрайвед. Не блок.

Готово к мержу. Луп замкнулся: завёл #554 при ревью #553 → кодер #560 → проверил → approve.

## Ревью — литеральный `<br>` экранируется, не становится hardBreak (#554). Вердикт: **approved** ✅ Чинит ровно ту потерю, что я сам завёл в #554 при ревью #553. Проверил своим round-trip'ом + агент (оба-направления, контейнеры, мутация). Нового бага НЕ вносит, #553 НЕ ломает. Эскалаций нет. <details> <summary>📋 Проверка + adversarial</summary> **Фикс:** в case "text" энтити-энкодит только угловые скобки break-тега: `textContent.replace(/<(\s*br\s*\/?\s*)>/gi, "&lt;$1&gt;")` → в md уходит `&lt;br&gt;`, marked пропускает энтити, импортёр декодит обратно в литеральный `<br>` → раннится как ТЕКСТ, не hardBreak. Строго scoped на `<br…>` (не любой `<`/`>`). Только text-path — эмитнутый hardBreak-`<br>` (из своего case, #553) не трогается. **Мой эмпирический round-trip (собранный модуль):** - #554 fix: `a<br>b`→md `a&lt;br&gt;b`→back-text `a<br>b` (0 hb ✅); `<br/>`/`<br />` так же. - #553 НЕ сломан: trailing/consecutive/mid-para hardBreak → 1/2/1 hb ✅ (эмитнутый `<br>` НЕ экранируется). - `a < b > c` — не тронут ✅. Inline-code/code-block `<br>` — НЕ экранируется (литерал в коде) ✅. **Агент (parent-baseline, оба направления):** decode симметричен (byte-stable, triple round-trip); over-fire корректен (heading/blockquote/list/link-text/table-cell держат литерал; code — нет; link-title/image-alt через своё экранирование); эмитнутый break цел во ВСЕХ контейнерах; смешанный кейс (литерал `<br>` + реальный hardBreak в одной ячейке) различает верно (1 hb + текст). **Мутация:** снял `.replace` → все 3 литерал-`<br>` теста краснеют, hardBreak-тест зелёный. Не вакуумно. **Гейт:** pm-markdown **833/833** (47 файлов, вкл. новый roundtrip.test). Дерево чисто. <sub>Security/stability/regressions/coherence/conventions — LGTM. ⛔ ниже порога (кодеру НЕ делать): `<br class=x>` (с атрибутами) всё ещё → hardBreak (regex ловит только bare-форму) — тот же класс, но вне scope bare-`<br>` #554, пред-существующее; `<br\t>` (TAB в теге) marked нормализует — контрайвед. Не блок.</sub> </details> Готово к мержу. Луп замкнулся: завёл #554 при ревью #553 → кодер #560 → проверил → approve.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 18:09:18 +03:00
vvzvlad merged commit ec8dd7d110 into develop 2026-07-12 18:57:03 +03:00
Sign in to join this conversation.