Редактор MDC-компонентов: отчёт о состоянии и план перестройки

Что проверялось: app/components/ContentEditor.vue, весь каталог app/components/editor/mdc/, компоненты app/components/content/*, разметка content/2.activity/2.news/example-mdc.md.
Окружение: Nuxt 4.2 · Nuxt UI 4.3 (UEditor) · TipTap 3.17 · @nuxtjs/mdc 0.20 · remark-mdc 3.10 · @nuxt/content 3.10.
Статус: план выполнен целиком, этапы 1–7. Разметку читает и пишет мост через MDC AST, блок стал контейнерным узлом ProseMirror, текст правится основным редактором, параметры — типизированной панелью, вид редактора совпадает со страницей, дубли компонентов убраны.
Коротко
Редактор рисует MDC-блоки настоящим рендерером страницы — это правильно и это работает. Но тело блока хранится в узле TipTap строкой markdown, а не документом. Из одного этого решения вырастает всё остальное: вложенные редакторы внутри блока, служебные заглушки ::mdc-seg, ручная ловля каретки, два разных вида одного и того же блока, панель «ключ = значение» вместо нормальных элементов управления и — что серьёзнее всего — пять воспроизводимых случаев, когда редактор молча портит разметку. Для двух из них достаточно кликнуть в блок, ничего не набирая.
Лечится это не заплатками, а сменой схемы: блок должен стать обычным контейнерным узлом ProseMirror с содержимым, а перевод markdown ⇄ документ — идти через MDC AST (parseMarkdown / stringifyMarkdown), а не через самописные регулярные выражения.
1. Как устроено сейчас
Путь материала от файла до файла проходит через четыре преобразования, и ни одно из них не является обратным другому:
| Шаг | Чем сделан | Файл |
|---|---|---|
| markdown → HTML-заглушки | своя построчная разборка ::name{…} регулярками | editor/mdc/parser.ts |
| HTML → узлы TipTap | parseHTML узлов mdcBlock / mdcInline | editor/mdc/MdcBlockExtension.ts |
| узлы → markdown | renderMarkdown, а если не сработал — регулярки по HTML, а если и это не сработало — обход документа | editor/mdc/serialize.ts, ContentEditor.vue (buildBody) |
| показ блока | parseMarkdown + ContentRenderer внутри <Suspense> | editor/mdc/MdcBlockNode.vue |
Правка текста внутри блока устроена отдельным механизмом: на время редактирования тело пересобирается, куски текста подменяются служебным ::mdc-seg{i="N"}, а рендерер ставит на их место MdcSegment.vue — ещё один полноценный экземпляр UEditor (editor/mdc/segments.ts, MdcSegment.vue).
2. Что ломается: воспроизведённые случаи
У редактора два независимых пути, и портят они материал по-разному.
2.1. Порча при сохранении материала
Открыли материал, ничего не набрали, сохранили.
Кавычки внутри значения атрибута обрезают его
Было: ::alert{title="Он сказал: \"да\""}
Стало: ::alert{title="Он сказал: \"}
Регулярка \{[^}]*\} не знает про экранирование. Кусок заголовка исчезает без предупреждения.
Фигурная скобка в значении уничтожает блок целиком
Было: ::alert{title="{вот так}"}
Стало: :"}
Открывающая строка перестаёт распознаваться как блочная, обработчик строчных элементов принимает её за :alert{…} — и от врезки в материале не остаётся ничего, кроме огрызка. Текст внутри неё выживает, оформление пропадает.
Строчные элементы стираются
Было: Текст :icon{name="i-lucide-star"} дальше.
Стало: Текст дальше.
Это оба строчных элемента, которые редактор предлагает в своём же меню, — :icon и :kbd. Причина: MdcInline объявляет сериализатор через addStorage().markdown — приём @tiptap/markdown второй версии. Третья читает renderMarkdown из конфигурации расширения, как это и сделано у MdcBlock. Обработчик просто не зовётся, узел выпадает из документа.
2.2. Порча от одного клика в блок
Здесь достаточно открыть врезку посмотреть: тело блока пересобирается с заглушками, куски уезжают во вложенный редактор и возвращаются уже другими. Ни одного символа набирать не нужно.
YAML-параметры блока превращаются в заголовок
Было: --- Стало: ---
defaultValue: ## defaultValue:
- '1' - '1'
---
Разборщик кусков не отличает YAML-врезку от абзаца и отдаёт её вложенному редактору как обычный текст. Тот видит --- под строкой и читает это как заголовок в стиле setext. После сохранения параметры блока — уже не параметры, а текст. Именно так устроен ::accordion в example-mdc.md.
Разметка переписывается сама
_курсив_ становится *курсив*; список, стоявший сразу под строкой текста, отрывается от неё пустой строкой. Вид материала при этом не меняется — но файл меняется, и правка попадает в историю изменений как чужая.
Отдельно стоит buildBody() в ContentEditor.vue: если сериализатор потерял блоки, последний резерв дописывает их в конец документа. Порядок материала при этом меняется молча.
2.3. Что оказалось цело
Проверено тем же прогоном, фиксируется сторожами, чтобы не сломалось при перестройке: весь example-mdc.md проходит круг посимвольно; переживают сохранение все тринадцать блочных шаблонов из меню, пустое значение атрибута (title=""), сокращения .класс и #якорь, вложенные карточки в группе, YAML-параметры блока — если в него не заходить.
Расхождение двух реализаций attrsToInline (в parser.ts и в serialize.ts) при этом никуда не делось: одна пишет title="", другая выбрасывает атрибут. На основном пути работает первая, вторая ждёт своего часа на резервном.
3. Что неудобно в правке
- Вложенные редакторы. Каждый кусок текста в блоке — отдельный экземпляр
UEditorсо своей историей. Ctrl+Z снаружи не отменяет то, что набрано внутри блока. На странице вродеexample-mdc.mdтаких экземпляров создаётся несколько десятков. - Выделение не пересекает границу блока. Узел объявлен
atom: true— протянуть выделение от абзаца над блоком к тексту внутри него нельзя, скопировать «кусок статьи вместе с врезкой» — тоже. - Вложенные блоки не существуют как объекты.
::cardвнутри::card-group,::tabвнутри::tabs,::accordion-item— всё это строки в теле родителя. Добавить вкладку, поменять их местами, удалить карточку мышью невозможно: только набрать markdown руками в панели «Параметры». - Параметры вводятся как «ключ = значение». Чтобы поменять тип уведомления, нужно открыть панель, найти строку
type, вписатьwarning. Иконка — вписатьi-lucide-…по памяти. Ровно то, чего быть не должно. Закрыто этапом 4. - Задержки и ловля каретки. Вход в правку ждёт асинхронного разбора, потом ждёт, пока вложенный редактор сообщит, как в него встать (
waitForSegments, до 3 секунд), потом угадывает нужный кусок по высоте клика. Запись в узел идёт с задержкой 400 мс. - Разметка на время правки показывается символами. Кликнув в текст, редактор видит
**жирный**вместо жирного — обратно из DOM markdown не собрать.
4. Почему в редакторе и на странице разный вид
Причины независимы друг от друга и лечатся по отдельности.
Нет обёртки .content-body. Тело редактора — это .tiptap без неё. А вся типографика материала висит ровно на этом хуке (app/assets/css/main.css): мера строки 75ch у заголовков, отступ между абзацами 1.5em, text-wrap: pretty, выключка, переносы, масштаб текста из панели «Как читать». В редакторе не применяется ничего из этого.
Три разных представления одного материала. Внутри блока — ContentRenderer без .content-body; вокруг блока — типографика UEditor из темы Nuxt UI; кнопка «Предпросмотр» — третий вид, уже с .content-body, но по сохранённым данным коллекции.
Дублирующиеся наборы компонентов. Nuxt UI 4 регистрирует свою карту соответствий (mdc.components.map в модуле): card → ProseCard, card-group → ProseCardGroup, tabs → ProseTabs, callout/note/tip/warning/caution, accordion, steps, collapsible, badge, kbd, icon. Карта применяется при отрисовке и имеет приоритет над одноимёнными компонентами проекта. Отсюда:
| Имя в markdown | Что реально рисует | Что лежит в проекте |
|---|---|---|
::card, ::card-group | ProseCard / ProseCardGroup из Nuxt UI | content/Card.vue, content/CardGroup.vue — не используются |
::tabs | ProseTabs — переопределён проектом (правка гидрации) | content/ProseTabs.vue |
::tab | content/Tab.vue проекта | у Nuxt UI для этого ::tabs-item |
::alert | content/Alert.vue проекта | дублирует ::callout по смыслу |
То есть в меню редактора соседствуют два набора с пересекающимися задачами, а часть кода в app/components/content/ мертва.
5. Целевая схема
Всё описанное снимается одним решением: тело MDC-блока должно быть содержимым узла, а не строкой атрибута. Это штатный способ TipTap для контейнеров, а не изобретение.
Блок — контейнерный узел с NodeViewContent
mdcBlock объявляется с content: 'block+', его вид рисует оболочку компонента и ставит внутрь <NodeViewContent>. Текст правится тем же самым внешним редактором: общая история отмен, общее выделение, те же панели и слэш-меню, никаких вложенных экземпляров, никаких ::mdc-seg, никакой ловли каретки и задержек. Вложенные элементы (::card в группе, ::tab, ::accordion-item) — обычные дочерние узлы mdcItem, поэтому перетаскивание, Enter, Backspace и удаление работают из коробки.
Перевод разметки — через MDC AST, а не регулярками
markdown → MDC AST—parseMarkdownиз@nuxtjs/mdc/runtime(тот же парсер, что у страницы);MDC AST ⇄ документ TipTap— один собственный мост, единственное место с логикой соответствия;MDC AST → markdown—stringifyMarkdownиз@nuxtjs/mdc/runtime/stringify(проверено: модуль есть в установленной версии). Синтаксис::name{…}пишетremark-mdc, который его и читает — круг замыкается по определению.
UEditor при этом переводится на content-type="json", markdown остаётся только на границах загрузки и сохранения. Уходят: parser.ts, serialize.ts, оба attrsToInline, три резервных пути в buildBody() и зависимость горячего пути от @tiptap/markdown.
Один реестр компонентов
shared/utils/mdc-registry.ts — единственный источник правды: имя, подпись, иконка, типизированная схема атрибутов (перечисление / иконка / строка / ссылка / флаг), какой компонент рисует, какие дети допустимы, шаблон вставки. Из него собираются слэш-меню, схема узлов, панель параметров и тесты. Сейчас эти сведения размазаны по templates.ts, MDC_META, обработчикам в ContentEditor.vue и карте Nuxt UI.
Параметры — панелью у блока, без окон и без «ключ = значение»
Курсор внутри блока — над блоком всплывает его панель (штатный UEditorToolbar с layout="bubble" и своим should-show, как уже сделано для таблиц): переключатель типа врезки, выбор иконки списком с поиском, «заголовок вкл/выкл», дублировать, удалить. Заголовок блока правится как текст — отдельным дочерним узлом внутри содержимого. Панель «Параметры» с текстовым полем исходника и парами ключ/значение убирается совсем.
Один вид
Тело редактора оборачивается в .content-body, оболочки блоков берутся у тех же Prose-компонентов, что рисуют страницу. Настройки панели «Как читать» (кегль, выключка, переносы) начинают действовать и в редакторе — для сайта слабовидящих это не мелочь: редактор тоже рабочее место, и оно тоже должно масштабироваться.
6. План работ
Сторожа кругового преобразования — выполнено 25 августа 2026
72 проверки, из них 7 помечены it.fails — это и есть перечень дефектов из раздела 2, зафиксированный в коде.
| Файл | Что стережёт |
|---|---|
tests/editor/support/editor-pipeline.ts | круг через настоящий редактор: те же расширения, что монтирует UEditor |
tests/editor/mdc-roundtrip.test.ts | сохранение материала: весь example-mdc.md, все 13 блочных шаблонов, значения атрибутов, вложенность, порядок блоков |
tests/editor/mdc-in-block-editing.test.ts | клик в блок: тела всех шаблонов, разметка внутри врезки, вложенные вкладки, таблица, код |
Круг прогоняется настоящим TipTap, а не строковыми функциями: половина потерь случается ровно на его шаге. Виды узлов при этом отключены — они рисуют блок на экране и к разметке отношения не имеют. Для этого понадобилась среда с DOM: добавлен happy-dom (только в разработку), в vitest.config.ts — плагин Vue (расширения тянут за собой .vue-виды) и алиас ~; файлы с DOM объявляют среду сами строкой // @vitest-environment happy-dom.
it.fails означает «проверка обязана падать сегодня». Когда этап 2 или 3 починит разметку, такой тест станет зелёным — и от этого красным: vitest ругается на неожиданно прошедший it.fails. Это и есть сигнал снять пометку, а не искать, что сломалось.
roundTrip(markdown) → markdown, а не против внутренностей. На этапе 2 содержимое editor-pipeline.ts меняется целиком, сами проверки — ни одной строкой.Мост MDC AST ⇄ документ TipTap — выполнено 25 августа 2026
app/components/editor/mdc/bridge.ts на parseMarkdown / stringifyMarkdown, UEditor переведён на content-type="json", buildBody() с тремя резервными путями стал одним вызовом. Плюс MdcAttrsExtension.ts: пометка для [текст]{атрибуты} и поля filename/meta у блока кода — через штатный addGlobalAttributes, без своего узла.
Закрыто из раздела 2: строчные элементы не стираются, фигурная скобка не уничтожает врезку, кавычки не обрезают значение, порядок блоков не съезжает. Сверх плана: [важный текст]{style="…"} и `код`{lang="…"} впервые доходят до редактора как разметка, а не как литеральная строка.
Чего это стоило. Сторожа выловили четыре дефекта чужих библиотек — все воспроизводятся на голом stringifyMarkdown, без участия редактора:
| Что | Как проявлялось | Обход |
|---|---|---|
| Полужирный на двоеточии | **Дата:** → **Дата:**, разметка теряется | свой обработчик strong/emphasis через данные конвейера |
| Жёсткий перенос | br выходит как :br; узел break рвёт абзац и оставляет голую косую | замена на явный <br> перед сборкой |
| Оформление ссылок | rel="noopener…" приклеивался к каждой ссылке при сохранении | отключён rehype-external-links, у ссылки берём только адрес и подпись |
| Ограждение без языка | ``` превращалось в ```text | язык text в файл не записываем |
Ещё два дефекта были собственные, и нашли их те же сторожа: code считался блочным элементом и разваливал пункт списка «- таблица — описание» надвое; соседние куски одного полужирного собирались как отдельные **…** вокруг каждого.
Правило сторожа изменилось. Посимвольное сравнение файла заменено парой «содержание + устойчивость написания». Причина в раскладе выше: remark-mdc пишет канонически, и требовать посимвольного повторения исходника — значит требовать второй реализации разметки, то есть ровно того, от чего этап 2 уходил. Содержание сверяется по разобранному дереву строго, написание — проверкой на повторный прогон. Добавилась широкая проверка: круг проходят все материалы раздела «Новости».
Контейнерные узлы — выполнено 25 августа 2026
mdcBlock объявлен с content: 'block+', вид узла отдаёт <NodeViewContent>. Текст внутри блока правит тот же внешний редактор: одна история отмен, одно выделение через границу блока, те же панели и слэш-меню.
Отдельный mdcItem не понадобился: ::card внутри ::card-group — это тот же mdcBlock внутри mdcBlock. Вложенность даёт перетаскивание, удаление и добавление соседа обычными средствами редактора, без набора markdown руками.
Оболочку по возможности рисует настоящий компонент страницы (::alert — врезкой, ::card — карточкой). Компоненты, которые сами распоряжаются содержимым — вкладки, аккордеон, спойлер, шаги, — в оболочку не годятся: они прячут или переставляют отданное, и править текст стало бы нельзя. Им пока рамка с подписью; полное сходство — работа этапа 5.
Удалено: segments.ts, MdcSegment.vue, parser.ts, serialize.ts, editorConfig.ts и оба сторожа прежнего механизма. Из редактора ушли вложенные экземпляры UEditor, служебные заглушки ::mdc-seg, ожидание каретки до трёх секунд и запись в узел с задержкой.
Раздел 2.2 закрыт: порча от клика в блок исчезла вместе с механизмом, который её вызывал — YAML-параметры и разметку внутри блока больше некому переписывать.
Найдено по дороге, сторожами: блок без содержимого и с тремя атрибутами сборщик разжаловал в строчный компонент — ::figure-image{…} превращался в :figure-image{…} внутри абзаца, то есть картинка оказывалась внутри <p>. Держит блок блоком пустой абзац внутри. Ещё мост дописывал картинке умолчания (align="center", пустую ширину), которых автор не ставил.
Реестр и панель блока — выполнено 25 августа 2026
shared/utils/mdc-registry.ts — один источник правды: имя, подпись, значок, типизированные параметры, шаблон вставки и имя дочернего элемента. Из него собираются слэш-меню, обработчики вставки, панель у блока и сторожа. Прежние templates.ts и карта MDC_META удалены.
Главное — параметры описаны типами, а не строками. Вид врезки это выбор из пяти значений, значок — выбор из списка, заголовок — поле, адрес — ссылка. Панель стоит прямо над блоком, всплывает по наведению и по фокусу; отдельного окна «Параметры» с парами «ключ = значение» и текстовым полем исходника больше нет.
Компонент из дочерних элементов вставляется сразу с двумя готовыми пунктами: пустая оболочка бесполезна, а собирать её руками — то самое «заморачиваться с вводом». Дочерние элементы в меню не предлагаются — они появляются вместе с родителем.
Сторож добавлен под сам реестр: у каждого параметра каждого компонента значение обязано доехать до файла. Разойдись имя параметра с тем, что понимает разметка — человек крутил бы переключатель, а в файле ничего не менялось бы.
Единый вид — выполнено 26 августа 2026
Тело редактора размечено как .content-body — тем же единственным хуком, на котором держится типографика материала. Значит в редакторе действуют та же мера строки, те же отступы между абзацами и те же настройки чтения (выключка, переносы, кегль), что и на странице. Раньше правка шла в одном оформлении, а результат выходил в другом, и узнать об этом можно было только после сохранения.
Оболочку блока теперь рисует настоящий компонент страницы: врезка — врезкой, карточка — карточкой, группа карточек — сеткой, бейдж — бейджем.
Годятся не все, и причина не в лени. Место для содержимого узла обязано быть настоящим элементом DOM — иначе ProseMirror некуда писать. Значит между оболочкой и содержимым всегда есть прослойка, и компоненты, чьё оформление зависит от того, что лежит их прямыми детьми, через неё не работают:
| Компонент | Почему не оболочка |
|---|---|
::steps | нумерует шаги правилом [&>h4] — заголовки уходят на уровень глубже |
::collapsible | прячет содержимое под переключатель — править спрятанное нельзя |
::tabs, ::accordion | сами решают, какого ребёнка показать, а какого скрыть |
Им рисуется рамка с подписью, а содержимое лежит открыто и правится. Сетке группы карточек прослойка не мешает: раскладка считается по дереву коробок, и display: contents убирает лишний уровень, не трогая разметку.
Сторож tests/a11y/reading-settings.test.ts расширен: он следит, что тело редактора и предпросмотр размечены одинаково. Разойдись они — переключение режима меняло бы вид текста, хотя текст тот же.
Особые случаи — выполнено 26 августа 2026
Переименование ::tab → ::tabs-item сделано: три вхождения в example-mdc.md. Запись прежнего имени из реестра убрана — новых материалов с ним появиться уже не может.
YAML-параметры блока оказались целы и без правок: MDC приносит их с двоеточием впереди и значением в JSON (:defaultValue="[\"1\"]"), мост сохраняет их как атрибуты узла и возвращает YAML-врезкой. Правка понадобилась в другом месте — в оболочке блока: компоненту такой параметр надо отдавать разобранным, иначе он получает пропс со странным именем и строку вместо массива. Круг теперь под сторожем.
Оглавление составного блока. Над вкладками, аккордеоном и группой карточек стоит полоса подписей дочерних элементов: видно, сколько их, как называются и в каком порядке, а нажатие переносит каретку внутрь нужного.
Показать «только активный», как на странице, нельзя, и это не упрощение: спрятанное не отредактируешь, а содержимое дочерних узлов рисует сам ProseMirror — распоряжаться его видимостью снаружи значит спорить с отрисовкой. Поэтому элементы лежат стопкой, а расхождение со страницей проговорено прямо в блоке: у спойлера, вкладок и аккордеона есть подпись «на странице это содержимое скрыто до нажатия».
Шаги и спойлер проверены сторожами: заголовки внутри ::steps остаются заголовками, содержимое ::collapsible доезжает до файла целиком.
Строчные элементы и уборка — выполнено 26 августа 2026
:icon и :kbd перешли на реестр ещё на этапе 4: значок выбирается списком, а не вписывается по памяти.
Удалены мёртвые компоненты: content/Card.vue, CardGroup.vue, Tab.vue, Tabs.vue. Перед удалением проверено не по чтению, а на живой странице: ::card рисуется ProseCard из Nuxt UI (карта mdc.components.map перекрывает одноимённый компонент проекта), разметка проектного Card.vue в выдаче не встречается. После удаления страницы с карточками и вкладками отдаются по-прежнему.
Меню приведено к решению 1: врезку редактор предлагает одну — свою ::alert. Выноски Nuxt UI (::callout, ::note, ::tip, ::warning, ::caution) остались в реестре, но из меню убраны: в материалах их уже 11, и без реестра они показывались бы безымянными блоками без параметров.
Последний обходной приём убран. Контакты филиалов в content/1.about/1.info.md собирали вкладки из :::div{label} — работало только потому, что переопределение ProseTabs читает подпись у любого дочернего элемента. Теперь это :::tabs-item, настоящий компонент Nuxt UI: он объявляет label и рисует содержимое слотом.
Карта проекта обновлена. CLAUDE.md описывал прежнее устройство и ссылался на удалённые файлы (parser.ts, segments.ts, MdcSegment.vue). Переписаны строки про MDC-блоки, добавлены строки про мост, реестр, понятные адреса и правило ссылок.
7. Принятые решения
Развилки закрыты 25 августа 2026 года.
1. Врезка — своя, ::alert
Alert.vue остаётся основной врезкой: он доведён по контрасту до всех 14 схем оформления, ProseCallout такой проверки не проходил. Контент на ::callout не переводится.
При этом ::callout, ::note, ::tip, ::warning и ::caution из Nuxt UI продолжают рисоваться — в материалах уже 11 таких блоков, ломать их нельзя. Разница только в меню: врезку редактор предлагает одну, свою; остальные пять живут в реестре как поддерживаемые, но не предлагаемые к вставке.
2. Вкладки — на компоненты Nuxt UI
::tab → ::tabs-item, дочерний элемент рисует ProseTabsItem. Проектные Tab.vue и Tabs.vue удаляются. Разовая правка двух файлов входит в этап 6, объём — три вхождения.
3. Кнопку «Предпросмотр» не трогаем
Остаётся как есть до конца перестройки. Вернуться к вопросу имеет смысл после этапа 5, когда редактор и страница станут выглядеть одинаково — тогда и будет видно, нужна ли она.
4. Существующие материалы не трогаем
Массовый прогон content/**/*.md через мост и поиск уже испорченных мест — отдельная задача на потом, в этот план не входит. Сторожа из этапа 1 гарантируют, что новых повреждений не появится.
Оценка
| Этап | Объём |
|---|---|
| 1. Сторожа | небольшой |
| 2. Мост AST | средний, самый ответственный |
| 3. Контейнерные узлы | средний |
| 4. Реестр и панель | средний |
| 5. Единый вид | небольшой |
| 6. Особые случаи | средний |
| 7. Строчные и уборка | небольшой |
Этапы 1–3 имеют смысл только вместе: они закрывают порчу разметки и снимают вложенные редакторы. Этапы 4–7 после них делаются независимо и в любом порядке.
editor/mdc/ станет меньше кода, чем сейчас, и при этом исчезнут все обходные пути — потому что схема совпадёт с той, для которой TipTap и MDC сделаны.