Сейчас открыто, до 18:00
план работ

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

#редактор #MDC #Nuxt UI #технический долг
2
2
Как читать
100 %

Что проверялось: 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 → узлы TipTapparseHTML узлов mdcBlock / mdcInlineeditor/mdc/MdcBlockExtension.ts
узлы → markdownrenderMarkdown, а если не сработал — регулярки по 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-параметры блока — если в него не заходить.

Расхождение двух реализаций attrsToInlineparser.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 в модуле): cardProseCard, card-groupProseCardGroup, tabsProseTabs, callout/note/tip/warning/caution, accordion, steps, collapsible, badge, kbd, icon. Карта применяется при отрисовке и имеет приоритет над одноимёнными компонентами проекта. Отсюда:

Имя в markdownЧто реально рисуетЧто лежит в проекте
::card, ::card-groupProseCard / ProseCardGroup из Nuxt UIcontent/Card.vue, content/CardGroup.vue — не используются
::tabsProseTabs — переопределён проектом (правка гидрации)content/ProseTabs.vue
::tabcontent/Tab.vue проектау Nuxt UI для этого ::tabs-item
::alertcontent/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 ASTparseMarkdown из @nuxtjs/mdc/runtime (тот же парсер, что у страницы);
  • MDC AST ⇄ документ TipTap — один собственный мост, единственное место с логикой соответствия;
  • MDC AST → markdownstringifyMarkdown из @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 гарантируют, что новых повреждений не появится.

Решение 4 закрывает только разбор уже испорченных материалов. Переименование вкладок из решения 2 — не оно: это правка двух файлов, без которой вкладки перестанут рисоваться совсем.

Оценка

ЭтапОбъём
1. Сторожанебольшой
2. Мост ASTсредний, самый ответственный
3. Контейнерные узлысредний
4. Реестр и панельсредний
5. Единый виднебольшой
6. Особые случаисредний
7. Строчные и уборканебольшой

Этапы 1–3 имеют смысл только вместе: они закрывают порчу разметки и снимают вложенные редакторы. Этапы 4–7 после них делаются независимо и в любом порядке.

Итог перестройки в одну строку: в файлах editor/mdc/ станет меньше кода, чем сейчас, и при этом исчезнут все обходные пути — потому что схема совпадёт с той, для которой TipTap и MDC сделаны.
редакторMDCNuxt UIтехнический долг