RichTextView
Кратко для AI
RichTextView — показ сохранённого HTML RichTextEditor с той же типографикой, что внутри редактора. Движка нет: в графе модулей ни Tiptap, ни lowlight — компонент рендерится в серверном компоненте и сам не несёт "use client"; клиентских кусков в графе два, оба острова: кнопка «Копировать» у блоков кода и — только при пропе tools и своём Блоке в Документе — Остров своего Блока (раздел «Остров своего Блока»). Он не санитизирует HTML: html должен прийти от сервера уже очищенным по белому списку Схемы — sanitizeRichText(html, DOMPurify) из @olddevs/ui/editor/schema (раздел «Санитизация»). Живёт во входе @olddevs/ui/editor, не в barrel. Подсветку блоков кода не делает сам: HTML подсвечивает на сервере highlightCodeBlocks(html) из @olddevs/ui/editor/highlight после очистки, а RichTextView красит готовые токены темой GitHub Light/Dark. У каждого блока кода — шапка с языком слева и кнопкой «Копировать» справа (раздел «Кнопка «Копировать» и шапка»).
import { RichTextView } from "@olddevs/ui/editor";Когда использовать
- Страница показывает документ, который сохранил
RichTextEditor: статью, описание, регламент. - Компонент нужен в серверном компоненте или там, где тащить Tiptap ради чтения нельзя.
Когда не использовать
- HTML пришёл от пользователя и сервер его не очищал — сначала очистка (раздел «Санитизация»), потом показ. Компонент положит в страницу всё, что получит.
- Нужна правка —
RichTextEditor. ЕгоreadOnlyдержит движок живым ради текста, который не меняется; для страницы просмотра беритеRichTextView. - Произвольный HTML вне Схемы (
styleвне цвета автора,thead,caption, картинки безfigure): типографика рассчитана на теги Схемы, остальное будет выглядеть как у браузера по умолчанию. - Короткий однострочный текст —
Text.
Минимальный пример
В Next.js вход @olddevs/ui/editor должен стоять в optimizePackageImports своей строкой (getting-started.md): иначе вместе с просмотром в браузер уедет весь Редактор с движком — 435 кБ First Load JS вместо 137.
// Серверный компонент: директива не нужна.
import { RichTextView } from "@olddevs/ui/editor";
export default async function ArticlePage({ id }: { id: string }) {
const article = await loadArticle(id); // article.html очищен при сохранении
return <RichTextView html={article.html} className="max-w-prose" />;
}Public API
html— сохранённый Документ, HTML-строка. Не санитизируется: кладётся в разметку черезdangerouslySetInnerHTML, как есть. Пустая строка даёт пустой контейнер.className— добавляется после классов типографики и может их переопределять.surface—"theme"(по умолчанию) или"paper": светлый лист в любой теме, см. «Поверхность». Тип —RichTextSurface.tools— свои Инструменты Документа, тот же массив, что уRichTextEditorиsanitizeRichText. Просмотр читает из него только Острова своих Блоков (blocks[имя].island, типCustomIslandProps); безtoolsПросмотр прежний (раздел «Остров своего Блока»).- Остальное — обычные пропсы
div:id,aria-*,data-*,ref.childrenиdangerouslySetInnerHTMLне принимаются. highlightCodeBlocks(html)— серверная подсветка блоков кода, вход@olddevs/ui/editor/highlight(раздел «Подсветка блоков кода»). Проп для этого уRichTextViewнет намеренно.sanitizeRichText(html, purify, tools?)— очистка HTML по Схеме экземпляром DOMPurify, который передаёт потребитель; ставит хуки Схемы на время вызова. Тип экземпляра — структурныйRichTextPurifier(событие хука —RichTextAttributeHookEvent). Третий аргумент — тот же массивtools, что у Редактора: с ним проходят свои Блоки и Метки по их правилам (раздел «Свои Инструменты: третий аргумент»); без него очистка прежняя. Безопасный путь по умолчанию (раздел «Санитизация»).richTextSchema(tools?)— Схема данными для набораtools: поляRICH_TEXT_SCHEMAплюсcustomBlocksиcustomMarks— правила своих Блоков и Меток (RichTextCustomBlockRules,RichTextCustomMarkRules). Тип —RichTextSchema. Сама константаRICH_TEXT_SCHEMA— по-прежнему Схема кита.richTextSanitizeConfig()— закрытый конфиг очистки для DOMPurify без хуков: безопасен сам по себе, но часть разметки Редактора теряет. Тип —RichTextSanitizeConfig.- Все три — только данные и функции, без зависимостей; в серверном модуле безопасны. Лёгкий вход —
@olddevs/ui/editor/schema, из@olddevs/ui/editorдоступны тоже.
Типографика — тот же модуль src/editor/prose.ts, что вешает классы на содержимое Редактора: текст в Редакторе и на странице выглядит одинаково. Классы лежат на контейнере и находят теги по имени, поэтому в HTML Документа классов и style нет. Цвета берутся из токенов кита, и темы dark, light, dim переключаются сами. Отдельный @source для Tailwind не нужен: строка из getting-started.md покрывает классы.
Поверхность: surface
surface="paper" — светлый лист в любой теме приложения, как у RichTextEditor: текст, ссылки, палитры, маркеры, таблицы, подписи и блоки кода (GitHub Light) выглядят как в светлой теме, а лист отделён от страницы рамкой и тенью темы приложения. По умолчанию ("theme") Документ следует теме приложения и фона не красит.
<RichTextView html={cleanHtml} surface="paper" />Когда брать лист: предпросмотр печати и PDF, просмотр письма или документа, который уйдёт из приложения светлым. Документ, который читают только в приложении, остаётся на "theme". Всё вокруг листа — заголовки страницы, кнопки, меню — в теме приложения: лист покрывает только контейнер RichTextView.
Компонент остаётся серверным: лист — это классы и атрибуты контейнера (data-theme="light", data-surface="paper"), без style и без клиентского кода. Один документ на листе выглядит одинаково в Редакторе и здесь — это сверяет тест.
Схема и безопасность
Схема — единственный источник допустимой разметки Редактора: всё, чего в ней нет, он отбрасывает и при загрузке, и при вставке. RichTextView Схему не проверяет: показ идёт без разбора HTML.
Белый список тегов и атрибутов (таблица повторяет src/editor/schema.ts и проверяется тестом; список растёт вместе с набором Блоков и Меток):
| Что | Разрешено |
|---|---|
| Блоки | p, h1, h2, h3, h4, ul, ol, li, blockquote, hr, pre, table, tbody, tr, th, td; label, input, span — только внутри пункта чек-листа; div — там же, а ещё Раскладка и её колонки (Раскладка — только на верхнем уровне, колонка — только прямо в ней) и Обёртка (на верхнем уровне и прямо в колонке, не в другой Обёртке); figure — картинка, внутри неё img и необязательная figcaption (вне figure их нет) |
| Метки | strong, em, u, s, code, sup, sub, mark (только с цветом), br, a |
| Атрибуты | ol[start] — только когда нумерация не с 1; ul[data-type], li[data-type], li[data-checked] — чек-лист; input[type], input[checked] — его флажок; code[class] — язык блока кода, только «language-python», «language-typescript», «language-javascript», «language-go», «language-html», «language-json», «language-yaml» или «language-markdown» (без языка класса нет); span[data-color], mark[data-color] — цвет текста и маркер, только имена своей палитры — на span цвета текста, на mark маркера; span[style], mark[style] — произвольный цвет автора, только «color:#rrggbb» и «background-color:#rrggbb», и не вместе с data-color; p[data-align], h1[data-align], h2[data-align], h3[data-align], h4[data-align] — выравнивание Блока, по центру или справа; th[colspan], td[colspan] — слияние по горизонтали, целое от 2 до 20; th[rowspan], td[rowspan] — по вертикали, от 2 до 50; th[data-align], td[data-align] — выравнивание столбца, по центру или справа, на каждой его ячейке; a[href] — только http, https или mailto; a[rel] — всегда «noopener noreferrer»; img[src] — только абсолютный http или https, без data и blob; img[alt] — всегда, пустой у картинки без описания; img[data-width] — 50, 75 или 100; div[data-block] — Раскладка («layout»), её колонка («column») и Обёртка («wrapper»); div[data-layout] — вариант Раскладки, доли колонок в процентах: «100», «50-50», «33-33-33», «25-25-25-25», «60-40» или «40-60»; div[data-tone] — тон Обёртки, только на ней: «neutral», «accent», «success», «warning» или «danger» (без тона атрибута нет) |
Пункт чек-листа Tiptap пишет так: <li data-type="taskItem" data-checked="true"><label><input type="checkbox" checked><span></span></label><div><p>…</p></div></li>. sanitizeRichText пускает input, label, span и div только внутри такого пункта, а input — только с type="checkbox".
Таблицу Редактор пишет так: <table><tbody><tr><th><p>…</p></th></tr><tr><td colspan="2" data-align="center"><p>…</p></td></tr></tbody></table> — строка-заголовок — первая строка из th, а у ячейки бывают только три атрибута: colspan и rowspan (слияние, целое от 2 до RICH_TEXT_SCHEMA.tableSpans — 20 по горизонтали и 50 по вертикали; 1 не пишется) и data-align (выравнивание столбца, center или right, на каждой ячейке столбца). Строка, целиком накрытая слиянием сверху, остаётся пустой <tr></tr>. thead, tfoot, colgroup, caption в Схеме нет, и DOMPurify по умолчанию вместе с thead и colgroup убирает и их содержимое: заголовок чужой таблицы, записанный в thead, пропадёт. Редактор thead не пишет, так что для его HTML это не потеря. tbody, tr, th и td вне таблицы парсер HTML выбрасывает до очистки (текст остаётся), style и colwidth не проходят, а colspan и rowspan пропускает только хук sanitizeRichText — на th и td и только цифрами в пределах Схемы; голый конфиг их не знает вовсе. Без такой проверки colspan="10000" из чужого HTML развернётся в десять тысяч пустых ячеек у каждого, кто откроет документ.
Других классов, style (кроме произвольного цвета, см. ниже), aria-* и data-* (кроме пяти имён Схемы: data-type и data-checked чек-листа, data-color цвета, data-align выравнивания и data-width ширины картинки) в отдаваемом HTML нет. h1 в Документе бывает только у Редактора с инструментом heading1 (по умолчанию в full); экран, у которого H1 стоит над Редактором, этот инструмент выключает.
Картинка Редактор пишет так: <figure><img src="https://…" alt="…" data-width="50"><figcaption>…</figcaption></figure>. figcaption есть, только когда подпись не пуста, data-width — только у картинки с заданной шириной (50, 75 или 100 процентов ширины Документа, RICH_TEXT_SCHEMA.imageWidths; без него — свой размер, не шире Документа), alt есть всегда и пуст у картинки без описания. Адрес — только абсолютный http или https (RICH_TEXT_SCHEMA.imageProtocols): data: (base64) и blob: Редактор не пишет никогда, а style, width, height и class у картинки не бывает — ни у img, ни у figure. Голый <img> вне figure Редактор не отдаёт; sanitizeRichText его удаляет.
Цвет и выравнивание — имена, а не значения CSS. data-color принимает только имена палитры Редактора, и палитр две — по тегу. На span (цвет текста) — accent, success, warning, danger, lime, muted (RICH_TEXT_SCHEMA.textColors), на mark (маркер) — gray, brown, orange, yellow, green, blue, purple, pink, red (RICH_TEXT_SCHEMA.markerColors): яркие yellow, green, pink предлагает меню Редактора, остальные шесть остаются читаемыми и красятся прежними значениями; имя чужой палитры на теге не проходит. Документы, сохранённые до палитры маркера, несут на mark шесть старых имён — они переводятся (RICH_TEXT_SCHEMA.legacyMarkerColors, таблица — в документации Редактора): sanitizeRichText делает это хуком, а сам RichTextView красит старое имя тем же цветом, в который оно переводится. data-align — center или right (left Редактор не пишет, а читает как отсутствие атрибута; RICH_TEXT_SCHEMA.alignments). Конкретные цвета подставляет тема, поэтому один и тот же сохранённый текст читается в dark, light и dim: RichTextView и Редактор красят data-color теми же классами (prose.ts). DOMPurify значений атрибутов не проверяет — это делают хуки sanitizeRichText.
Порядок Меток у маркера. С OLDSUI-60 Редактор пишет маркер самой внешней Меткой после цвета текста — <span data-color><mark><sup>… — и полоса не прыгает на индексах и не рвётся на ссылке. RichTextView HTML не перестраивает: документ, сохранённый раньше как <sup><mark>…</mark></sup>, показывается в старом порядке, и кусок маркера на индексе поднимается вместе с ним. Порядок выправляется, когда документ один раз открывают и сохраняют в Редакторе.
Произвольный цвет — единственное исключение из «style нет». Кроме цветов палитры автор может выбрать любой в ColorPicker («Свой цвет…» в меню цвета Редактора). У такого цвета нет имени, а носитель значения в HTML — style, поэтому в документ он пишется строго так: <span style="color:#ff0000"> для текста и <mark style="background-color:#ffee00"> для маркера. Правило узкое и записано в Схеме (RICH_TEXT_SCHEMA.customColor.styles, регулярные выражения строкой): строчные буквы, ровно шесть цифр, без пробелов и ;, одно объявление на тег, и никогда вместе с data-color на одном элементе. Любой другой style — #f00, rgb(…), red, второе объявление рядом, style на другом теге — Редактор отбрасывает при загрузке и вставке, и sanitizeRichText снимает его так же: хук сверяет тег и значение с теми же выражениями. В голом richTextSanitizeConfig() style нет вовсе — ALLOWED_ATTR у DOMPurify общий на все теги, и без хука он пропустил бы любой CSS.
Цвет автора не следует за темой. Цвета палитры — токены, и один сохранённый текст читается в dark, light и dim. #ff0000 — значение: в каждой теме он одинаков, и пара «цвет — фон» может не держать контраст там, где автор её не видел. Редактор предупреждает об этом при выборе (контраст 4.5:1 против фона текущей темы, для маркера — основной текст на нём), но не запрещает и не пересчитывает цвет при смене темы. Если документ показывается в нескольких темах, пользуйтесь палитрой.
Ссылка Редактор пишет так: <a href="https://…" rel="noopener noreferrer">…</a>. Адрес — только абсолютный, со схемой http, https или mailto: относительные адреса и прочие схемы (javascript:, data:, ftp:) Редактор не принимает ни из value, ни из вставки. target он не пишет: открывать ли ссылки в новой вкладке, решает страница показа, а не Документ.
Санитизация — на сервере потребителя
Клиентская очистка от XSS не защищает: запрос в обход интерфейса принесёт что угодно. HTML очищается на сервере при сохранении (а лучше и при выдаче, если в базе лежат старые записи). DOMPurify в зависимости кита не входит — ставите сами, а политику Схемы приносит кит хелпером sanitizeRichText:
npm install dompurify jsdom # или isomorphic-dompurifyimport { JSDOM } from "jsdom";
import createDOMPurify from "dompurify";
import { sanitizeRichText } from "@olddevs/ui/editor/schema";
const DOMPurify = createDOMPurify(new JSDOM("").window);
export function sanitizeDocument(html: string): string {
return sanitizeRichText(html, DOMPurify);
}sanitizeRichText(html, purify) берёт экземпляр DOMPurify аргументом — кит его не импортирует, тип экземпляра структурный (RichTextPurifier), и dompurify с jsdom или isomorphic-dompurify подходят без приведений. На время одного вызова хелпер ставит на экземпляр хуки Схемы и снимает их после: экземпляр у вас общий (комментарии, письма), и правила Редактора не срабатывают в чужой очистке, а ваши хуки остаются на месте. Разметка Редактора проходит байт в байт; всё остальное хуки снимают — текст из снятой обёртки остаётся, как и в Редакторе. Что они проверяют:
label,input,spanиdiv— только разметка пункта чек-листа;input— толькоtype="checkbox";ul[data-type],li[data-type],li[data-checked]— только значения чек-листа;ol[start]— только наol;code[class]— только каноническийlanguage-<имя>изRICH_TEXT_SCHEMA.codeLanguages: псевдоним (language-py), неизвестный язык, второй класс и класс на другом теге снимаются, токеныhljs-*— тоже;span[data-color]иmark[data-color]— только имена своей палитры; старые имена маркера переводятся (legacyMarkerColors);markбез цвета снимается;style— толькоcolor:#rrggbbнаspanиbackground-color:#rrggbbнаmark(RICH_TEXT_SCHEMA.customColor.styles) и никогда вместе сdata-color;data-align—centerилиrightнаp,h1–h4,th,td;colspanиrowspan— только наthиtd, целое не большеRICH_TEXT_SCHEMA.tableSpans;- ссылка —
hrefтолькоhttp,httpsилиmailto,relвсегдаnoopener noreferrer, чужойtargetснимается, ссылка без годного адреса становится текстом; - картинка —
imgс абсолютнымhttp/httpsпрямо внутриfigure(безdata:иblob:),data-width— пресет,altесть всегда;imgвнеfigureудаляется,figcaptionвнеfigureтеряет обёртку.
Проверено настоящим DOMPurify 3.4.16 на jsdom в tests/editor-sanitize.test.tsx: Документ со всеми Блоками и Метками Редактора проходит без изменений, а подделки — классы Tailwind (class="fixed inset-0"), style на p, картинка data:, ссылка javascript:, имя чужой палитры, label вне пункта, colspan="10000" и другие — снимаются.
Свои Инструменты: третий аргумент
Если Редактор работает со своими Инструментами (RichTextEditor, «Свой Инструмент»), сервер передаёт очистке тот же массив tools. Описание своего Инструмента — данные, и модуль с ним импортируется на сервере: очистка читает только правила Схемы (blocks, marks), Часть для движка не трогает. Чтобы в графе сервера не оказался и Tiptap, держите описание в модуле без импорта расширений — Часть для движка тогда подаётся загрузчиком (RichTextEditor, «Загрузчик Части для движка») или собирается рядом с Редактором.
import { sanitizeRichText } from "@olddevs/ui/editor/schema";
import { EDITOR_TOOLS } from "./editor-tools"; // тот же массив, что в `tools` Редактора
export function sanitizeDocument(html: string): string {
return sanitizeRichText(html, DOMPurify, EDITOR_TOOLS);
}- Свой Блок проходит как
div[data-block="<имя>"], строчный атом — какspan[data-block="<имя>"], своя Метка — какspan[data-mark="<имя>"]. Блок другого вида (атом вdiv, блочный Блок вspan) и неизвестное имя теряют обёртку, текст остаётся. - Свои атрибуты
data-<имя>— только на своём элементе и только со значением по правилу: из списка, по шаблону или адрес по политике ссылки (абсолютныйhttp,https,mailto). Значение с разметкой (<) не проходит никогда. Недопустимое значение снимается вместе с атрибутом, сам элемент остаётся — так же поступает Редактор. - Чужое на своём элементе —
class,style,data-color, обработчики, атрибут другого Блока — снимается. - Место
top— только верхний уровень Документа: вложенный Блок (в цитату, Обёртку, другой Блок) теряет обёртку, как Раскладка. - Имена Инструментов кита белый список не сужают.
["bold", acmeCard]очищает так же, как[...RICH_TEXT_PRESETS.full, acmeCard]: экраны с разными наборами пишут один Документ, и сервер не вправе срезать таблицу, которую записал экран сfull. Правила с именем без Префикса потребителя сервер не читает: такой набор Редактор отвергает ошибкой поля, а на сервере он не должен открыть обход правил кита. - Без третьего аргумента очистка та же, что была до своих Инструментов: свои Блоки снимаются, как любой чужой HTML.
richTextSchema(tools) отдаёт те же правила данными — для своего санитайзера или проверки на стороне базы: customBlocks (вид, место, атрибуты — с заполненными умолчаниями) и customMarks. Признаков Блока (container, turnInto, plainAtom) там нет: это поведение Редактора, а не то, что приходит в HTML. Проверено в tests/editor-sanitize-tools.test.tsx: Документ Редактора со своими Блоками, атомом и Меткой проходит очистку байт в байт, а подделки снимаются так же, как их снимает Редактор.
richTextSanitizeConfig() — закрытый по умолчанию. Это конфиг для того, кто передаёт его DOMPurify напрямую, без хуков. В общий ALLOWED_ATTR у DOMPurify (один список на все теги, значений он не проверяет) попадает только безвредное на любом теге и с любым значением: data-type, data-checked, data-color, data-align, data-width, start, alt, rel и href. style, class, src, colspan, rowspan, type, checked и теги input и img разрешает только sanitizeRichText, сверив тег и значение: без проверки они пропустили бы любой CSS и класс, base64-картинку и десять тысяч пустых ячеек. Голый конфиг поэтому безопасен, но теряет часть разметки Редактора — цвет автора, язык блока кода, слияние ячеек, флажки и картинки. ALLOW_DATA_ATTR и ALLOW_ARIA_ATTR выключены: по умолчанию DOMPurify пропускает любые data-* и aria-*. Каждый вызов отдаёт новые массивы.
Адрес ссылки конфиг проверяет сам: ALLOWED_URI_REGEXP пускает только http:, https: и mailto: — ту же политику, что Редактор. DOMPurify сверяет с этим выражением каждый разрешённый атрибут, кроме «безопасных для URI», и строгое выражение срезало бы start="3" или data-color="danger"; поэтому все атрибуты, кроме адресов, конфиг объявляет безопасными (ADD_URI_SAFE_ATTR). Адрес картинки выражением не закрыть: data: в src у <img> DOMPurify пропускает в обход ALLOWED_URI_REGEXP — поэтому src разрешает только хук.
Все берутся из отдельного лёгкого входа @olddevs/ui/editor/schema: в нём только Схема данными (RICH_TEXT_SCHEMA, richTextSchema), sanitizeRichText, richTextSanitizeConfig() и их типы, а ещё помощник Карточки у текста markRange для описания своего Инструмента — без React и без Tiptap, поэтому серверу, у которого редактора нет, peer-зависимости ставить не нужно. Те же имена по-прежнему отдаёт @olddevs/ui/editor, но этот вход на сервере без сборщика загрузит весь редактор вместе с движком.
Подсветка блоков кода
В Документе у блока кода только язык — <pre><code class="language-python">…</code></pre>; токенов подсветки в нём нет (ADR-0051). Подсвечивает сохранённый HTML серверный хелпер:
// Серверный компонент.
import { RichTextView } from "@olddevs/ui/editor";
import { highlightCodeBlocks } from "@olddevs/ui/editor/highlight";
export default async function ArticlePage({ id }: { id: string }) {
const article = await loadArticle(id); // article.html очищен при сохранении
return <RichTextView html={highlightCodeBlocks(article.html)} />;
}- Что делает. В каждом
<pre><code class="language-…">на языке Редактора (RICH_TEXT_SCHEMA.codeLanguages, псевдонимы тоже) текст заменяется токенами<span class="hljs-…">, разобранными lowlight той же грамматикой, что в Редакторе, а класс блока пишется каноническим именем Схемы:language-pyиlanguage-PYвыходятlanguage-python,language-js—language-javascript. Остальной HTML не меняется ни на байт. Блок без языка, на неизвестном языке и блок, внутри которого уже есть разметка, остаются как были — поэтому повторный вызов ничего не меняет. - Экранирование. Хелпер не разбирает HTML, а ищет блоки выражением: внутри блока Схема допускает только текст. Текст раскрывается из сущностей (
<,&,",', , числовые) и после подсветки снова экранируется:<script>в коде остаётся текстом<script>. Блок с другой именованной сущностью (©) не подсвечивается — лучше без цвета, чем с другим текстом. На выходе толькоspanсclass="hljs-…", безstyle. - Порядок — очистка, потом подсветка. Хелпер рассчитан на разметку Схемы, которую отдаёт санитайзер, а токенов
hljs-*в Схеме нет —sanitizeRichTextих снимает. Подсвечивайте после очистки: при выдаче страницы или сразу после очистки при сохранении. Если в базе лежит подсвеченный HTML, повторная очистка при выдаче вернёт блок к голому тексту, аhighlightCodeBlocksподсветит его заново; Редактор, получив такой HTML вvalue, токены отбросит сам. - Тема. Цвета токенов — GitHub Light в теме
light, GitHub Dark вdarkиdim, как в Редакторе: правила.hljs-*— классы на контейнереRichTextView(src/editor/codeTheme.ts), цвета — переменные вstyles.cssна элементах сdata-theme. Блок кода берёт тему ближайшего предка сdata-theme, поэтомуRichTextViewво вложенномdata-theme="light"внутри тёмного приложения показывает GitHub Light. Ниstyle, ни отдельного CSS. - Цена. Вход
@olddevs/ui/editor/highlight— хелпер, lowlight, ядро highlight.js и восемь грамматик, 22,6 кБ gzip; Tiptap в его графе нет, и Node без сборщика его загружает. Из peer-зависимостей кита в графе толькоreact— ради второго хелпера входа,highlightCodeNodesдляCodeView(ADR-0052).RichTextViewего не импортирует и остаётся около 8,4 кБ (с островом «Копировать») — поэтому подсветка не проп компонента: флаг не убрал бы подсветчик из графа, а платил бы за него каждый показ. Ставить для входа нужно толькоlowlightиhighlight.js(опциональные peer-зависимости).
Кнопка «Копировать» и шапка
У каждого блока pre > code над кодом стоит шапка: слева название языка, справа кнопка «Копировать» — CopyButton кита с подсказкой «Copy code» → «Copied». Кнопка есть всегда, пропа для отключения нет (решение владельца): шапка — часть вида блока, и в Редакторе она такая же (RichTextEditor). Обе части видны без наведения, на десктопе и на касании; код начинается под полосой, и кнопка его не перекрывает.
Как это устроено, и почему компонент остаётся серверным. RichTextView не получает обработчиков и состояния — он по-прежнему строка в разметку:
- Рядом с
dangerouslySetInnerHTMLон оборачивает каждый блок<pre><code>в шапку строка в строку (withCodeHeaders,src/editor/codeHeader.ts): подпись языка берётся из списка языков Редактора по приведённому имени (класс из документа в разметку не попадает:language-pyдаёт «Python»,language-rust— пустую подпись) и пустое гнездо для кнопки. Это разметка компонента, собранная после очистки, поэтому санитайзеру ничего разрешать не нужно. - Если в Документе есть хоть один блок кода, рядом с контейнером он рисует остров
CodeCopyIsland(src/editor/CodeCopyIsland.tsx, свой"use client"); у Документа без кода острова нет вовсе — ни разметки, ни эффекта. После монтирования остров находит гнёзда своего просмотра и порталом вставляет в каждоеCodeCopyButton. На сервере и при гидрации остров рисует только пустой инертный<template>— метку своего места, порталов ещё нет, — поэтому разметка сервера и первый клиентский проход совпадают, а страница без JS читается так же: подпись языка и код на месте, кнопки нет. - Связь острова с гнёздами — его место в дереве. Контейнер ссылкой не передать — у серверного компонента её нет, — поэтому остров берёт соседа своей метки
<template>слева, то есть контейнер своего просмотра, и ищет гнёзда только в нём, а не по глобальномуdocument: просмотр в теневом корне или вiframeполучает кнопки так же, а обход не идёт по всей странице. Гнездо дополнительно сверяется с идентификаторомuseIdпросмотра (единственный хук, и он разрешён серверным компонентам), а короткий отпечаток HTML сообщает острову о смене документа — тогда он ищет гнёзда заново. Два просмотра на странице кнопок не путают.
Граница клиента проходит по файлу острова, а не по RichTextView: tests/rich-text-view.test.tsx требует ровно один такой файл в графе (и то, что он тянет — кнопку и подсказку кита, react-dom, иконки и каталог сообщений), а @tiptap/*, lowlight и highlight.js по-прежнему запрещает; tests/rsc-gate.test.ts сверяет, что в самом RichTextView.tsx директивы нет. Остров в серверном компоненте Next.js работает без обёртки.
Что копируется. textContent элемента code — исходный код строкой. И для блока без подсветки, и для HTML после highlightCodeBlocks это один и тот же текст: токены span текста не добавляют. Это правило ADR-0039: копируется текст, а не DOM — иначе в буфер уехали бы границы токенов подсветки. Отказ буфера кнопка показывает («Could not copy»), а не изображает успех.
Подпись у блока без языка нет. Строка «Обычный текст» — из каталога сообщений, которого у серверного компонента нет, а английская подпись у русского потребителя была бы ошибкой. Шапка у такого блока — одна кнопка справа; в Редакторе в режимах readOnly и disabled то же самое.
Клавиатура. Кнопка в обычном порядке Tab: её тут единственный путь с клавиатуры. Подсказка по фокусу и наведению, Enter и Space копируют.
Цена. Остров тянет кнопку и подсказку кита, react-dom для портала и react-aria-components ради useLocale в каталоге сообщений; @radix-ui/react-tooltip и lucide-react — обязательные peer-зависимости кита, ставить новых не нужно. Замер editorView (RichTextView из @olddevs/ui/editor, движок в замер включён) вырос с 1,7 до 8,4 кБ gzip — это цена острова, а не типографики; после листа surface="paper" и метки острова — 9,1 кБ при бюджете 10,4 кБ (ADR-0030). Замер статический — граф модулей входа, — поэтому то, что Документу без блоков кода остров не рисуется, числа не меняет: файл острова в графе остаётся. Экономия — на клиенте: ни метки в разметке, ни эффекта, ни обхода контейнера. Так же и у Next.js: клиентский граф страницы строится по импортам, а не по тому, что отрисовано.
Таблицы
Редактор пишет table > tbody > tr > th|td, у ячейки — только слияние (colspan, rowspan) и выравнивание столбца (data-align) (раздел «Таблицы» страницы RichTextEditor), а оформление — границы, отступы ячеек, подложка строки-заголовка, выравнивание текста в ячейке по data-align — берёт из prose.ts на контейнере: в Редакторе и в RichTextView таблица выглядит одинаково. Слитая ячейка — обычная ячейка браузерной таблицы с colspan/rowspan, своего стиля у неё нет.
Прокрутка на узком экране — на самой <table>. display: block; max-width: 100%; overflow-x: auto: таблица шире блока прокручивается внутри себя и не раздвигает страницу, минимальная ширина столбца — 6 rem (min-w-24), поэтому таблица из пяти столбцов на экране в 375 px прокручивается, а не сжимается в щели. Обёртки нет намеренно: RichTextView кладёт сохранённый HTML как есть, а Схема не знает ни div-обёртки, ни tabindex.
Цена этого выбора, о которой стоит знать до выпуска:
- Табличная семантика. На
tableсdisplay: blockчасть скринридеров теряет строки и столбцы. В Chrome и Firefox это давно исправлено, в Safari с VoiceOver зависит от версии; проверяйте в ручном прогоне на своих версиях. Альтернатива —table-layout: fixedбез прокрутки — оставляет семантику, но делает все столбцы равными и узкими и рвёт длинные слова, на телефоне это хуже. - Фокус на прокручиваемой области. Правило axe
scrollable-region-focusableтребует, чтобы прокручиваемый блок можно было достичь с клавиатуры. В Редакторе в каждой ячейке есть каретка, а вRichTextViewфокусируемого содержимого нет: правило срабатывает на таблице, которая реально шире экрана. Закрыть это можно только обёрткой сtabindex="0", а её в сохранённый HTML не положить. Если на вашем экране такие таблицы бывают, обернитеRichTextViewсвоим контейнером сoverflow-x: autoиtabindex="0"или держите таблицы узкими.
Раскладка
Раскладка Редактора (div[data-block="layout"] с вариантом в data-layout и колонками div[data-block="column"], раздел «Раскладка» страницы RichTextEditor) рисуется той же сеткой, что в Редакторе, — классами prose.ts на контейнере, без style в разметке. Рамок у колонок нет: пунктир — подсказка автору в Редакторе, читателю граница не нужна. Колонки стоят рядом, когда широк сам Документ (две — от 30 rem, три — от 40 rem, четыре — от 48 rem), уже — столбцом в порядке чтения, слева направо. Порог — контейнерный запрос по ширине Документа: корень RichTextView с Раскладкой становится контейнером (container-type: inline-size), поэтому в сжимающем контексте (элемент flex-ряда без ширины) дайте ему ширину. На листе surface="paper" — та же сетка.
sanitizeRichText держит Раскладку там, где её пишет Редактор: только на верхнем уровне, с вариантом из RICH_TEXT_SCHEMA.layouts, колонки — только прямо в ней; иначе обёртки снимаются, Блоки остаются подряд.
Обёртка
Обёртка Редактора (div[data-block="wrapper"] с Блоками внутри, раздел «Обёртка» страницы RichTextEditor) без тона в показе не видна: рамки нет, отступы между её Блоками — те же, что между Блоками Документа, и текст читается так, будто обёртки нет. Пунктир — подсказка автору в Редакторе. Своей роли у Обёртки нет.
Обёртка с тоном (data-tone: accent, success, neutral, warning или danger, OLDSUI-78) видна и читателю — тем же видом, что в Редакторе: мягкая подложка тона и полоса 3 px слева, текст — обычный текст Документа, внутренний отступ, как у Alert. Цвета — роли --rte-tone-* классов prose.ts, без style в разметке; на листе surface="paper" — значения светлой темы. Роли и aria-* тон не добавляет: смысл заметки несёт слово в её тексте («Внимание: …»), а не цвет. Тон вне этих пяти имён RichTextView не красит — sanitizeRichText снимает его на сервере. Палитра, контраст и Markdown — раздел «Обёртка», «Тон».
sanitizeRichText держит Обёртку там, где её пишет Редактор: на верхнем уровне и прямо в колонке Раскладки, не в другой Обёртке, списке, цитате и таблице; иначе обёртка снимается, Блоки остаются подряд. Раскладка внутри Обёртки разворачивается в Блоки.
Свой Блок
Свой Блок потребителя (div[data-block="acme_card"], раздел «Свой Инструмент» страницы RichTextEditor) RichTextView показывает очищенным HTML и даёт ему тот же вертикальный ритм, что абзацу, — и больше ничего: отступ до следующего Блока такой же, как у абзаца на том же месте (в Документе, цитате, ячейке, колонке). Блоки внутри своего Блока-контейнера стоят с ритмом Документа, как в Обёртке; строчное содержимое (текст, атомы, Метки) отступов не получает. На листе surface="paper" — то же.
Вид — CSS потребителя по селектору data-block и токенам кита; полей стилей в описании Инструмента нет. Пишите стили на переменных темы — тогда Блок одинаков в Редакторе, в RichTextView и на листе paper, который переключает токены сам:
[data-block="acme_card"] {
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 0.75rem;
}Вид узла из Части для движка (React-вид) в RichTextView не используется: движка здесь нет. Строчный атом показывается тем, что лежит в HTML, — пустой span без текста не виден; оживить Блок в показе можно Островом (раздел ниже). Ритм сверяет tests/rich-text-paper-css.test.tsx на настоящем каскаде.
Остров своего Блока
Свой Блок можно оживить в RichTextView — кнопкой, плеером, раскрывающимся блоком — клиентским компонентом, который описание Инструмента кладёт в поле island Блока. Остров нужен не всякому Блоку: без него Блок остаётся очищенным HTML («Свой Блок»).
// acme-player.tsx — модуль с директивой: сервер получит лишь ссылку на компонент
"use client";
import type { CustomIslandProps } from "@olddevs/ui/editor";
export function AcmePlayer({ attributes, text }: CustomIslandProps) {
return <audio controls src={attributes.acme_src} aria-label={text} />;
}// acme-tool.ts — описание Инструмента, общее у Редактора, очистки и Просмотра
import { AcmePlayer } from "./acme-player";
export const acmePlayer: CustomTool = {
name: "acme_player",
engine: [AcmePlayerNode],
blocks: {
acme_player: { kind: "block", attributes: { acme_src: { kind: "url" } }, island: AcmePlayer },
},
};
// страница — серверный компонент
<RichTextView html={safeHtml} tools={[acmePlayer]} />;Что получает компонент (CustomIslandProps): block — имя Блока, attributes — объявленные в attributes Блока атрибуты, которые есть в HTML, по именам без data- (acme_src; необъявленный атрибут компонент не видит), text — textContent Блока из серверного HTML. Значения — строки из очищенного HTML Документа: проверку по виду значения на сервере делает sanitizeRichText(html, DOMPurify, tools), и без неё Остров получит то, что лежит в html.
Как это устроено, и почему RichTextView остаётся серверным. Так же, как у «Копировать» (раздел выше): сам компонент без состояния, клиентский кусок — отдельный файл со своей директивой.
- Описание Инструмента целиком (Часть для движка, функции) через границу RSC не пройдёт, поэтому
RichTextViewвыбирает изtoolsтолько Острова — и только тех Блоков, чейdata-block="<имя>"есть в Документе (islandsFor,src/editor/viewIsland.ts) — и отдаёт острову ссылки на клиентские компоненты и имена атрибутов. Нетtools, нетislandу Блока или Блока в Документе — острова нет вовсе: ни метки в разметке, ни эффекта, ни загрузки кода Острова потребителя (сервер сериализует ссылку на клиентский компонент только когда Блок есть).islandобязан быть компонентом из модуля с"use client": серверный компонент или функция-загрузчик в клиентский остров не передаются. - Серверный HTML Блока приходит всегда. До гидрации, до загрузки Острова и без JS читатель видит его — то есть содержимое Блока как оно сохранено.
- После монтирования остров (
src/editor/BlockIsland.tsxи телоBlockIslandSlots.tsx, свой"use client") находит Блоки своего просмотра так же, как «Копировать» находит гнёзда: от своей метки<template>налево, а не по глобальномуdocument— просмотр в теневом корне илиiframeработает. В каждом вхождении Блока содержимое заменяется Островом; при сменеtools(Остров пропал) или уходе Блока содержимое возвращается. Если Блок с Островом стоит внутри другого Блока с Островом, оживает внешний: компонент получает его текст целиком, а внутренний остаётся его содержимым. - Состояние Острова не сбрасывается перерисовкой родителя: остров ищет Блоки заново только когда сменился Документ, а не
tools(инлайновый литерал массива у родителя не перемонтирует Остров). Для этогоRichTextViewдержит объектdangerouslySetInnerHTMLстабильным (useMemoпо Документу): новый объект на каждой перерисовке заставил бы React переписать разметку вместе со вставленными Островами и кнопками «Копировать».
Вид узла из движка в Просмотре не используется: Остров — отдельный компонент, а не React-вид Части для движка; Редактор поле island не читает.
Цена. Тело острова (BlockIslandSlots.tsx) подключается лениво (React.lazy) и в первую загрузку просмотра не входит: пока чанк идёт, виден серверный HTML Блока. В статический замер editorView попадает только оболочка и выбор Островов — около 0,2 кБ gzip; Tiptap в графе Просмотра по-прежнему нет, это проверяет tests/rich-text-view.test.tsx.
Доступность
Компонент не добавляет ролей: это обычный div с семантическим HTML внутри — заголовки, списки, цитата. Заголовки Документа начинаются с H2, если Редактор работал без heading1: страница сама держит H1. Документ с heading1 несёт собственный h1, и тогда страница своего второго не ставит. H1 в RichTextView рисуется той же типографикой, что в Редакторе. Если документ — отдельная область страницы, дайте контейнеру имя через aria-label и role="region".
Антипаттерны
- Класть в
htmlстроку, не прошедшую серверную очистку. - Очищать только в браузере перед отправкой: сервер этого не увидит.
- Брать
RichTextEditor readOnlyдля страницы просмотра: движок на 130–170 кБ gzip ради текста. - Добавлять
"use client"в файл, гдеRichTextViewстоит один: он не нужен и уводит соседей в клиентский граф (остров с кнопкой «Копировать» — отдельный файл со своей директивой, он уRichTextViewуже есть). - Передавать в
islandсерверный компонент или функцию-загрузчик: через границу RSC проходит только ссылка на клиентский компонент из модуля с"use client". - Заводить свою кнопку копирования поверх
RichTextViewили читать текст из DOM блока: шапка и кнопка уже есть, а копируетсяtextContentкода, не разметка.
Ссылки
- Исходник:
src/editor/RichTextView.tsx; шапка и кнопка —src/editor/codeHeader.ts(разметка шапки),src/editor/CodeCopyIsland.tsx(клиентский остров),src/editor/CodeCopyButton.tsx; Остров своего Блока —src/editor/viewIsland.ts(выбор Островов по Документу),src/editor/BlockIsland.tsxиsrc/editor/BlockIslandSlots.tsx(клиентский остров); листsurface="paper"—src/editor/surface.tsиsrc/editor/tokens.ts; типографика —src/editor/prose.ts, тема подсветки —src/editor/codeTheme.ts; серверная подсветка —src/editor/highlight.ts, вход —src/editor/highlight-entry.ts; Схема данными,sanitizeRichTextи конфиг очистки —src/editor/schema.ts, лёгкий вход —src/editor/schema-entry.ts. - Тесты:
tests/rich-text-view.test.tsx(в нём же граф модулей и сверка таблицы со Схемой),tests/rich-text-view-code-copy.test.tsx(шапка, остров, копирование),tests/rich-text-view-island.test.tsx(Остров своего Блока),tests/rich-text-paper.test.tsxиtests/rich-text-paper-css.test.tsx(листsurface="paper"),tests/editor-highlight-entry.test.ts(хелпер подсветки, экранирование, граф входа),tests/editor-sanitize.test.tsx(очистка настоящим DOMPurify: разметка Редактора без изменений, подделки, голый конфиг),tests/editor-sanitize-tools.test.tsx(очистка иrichTextSchemaсо своими Инструментами),tests/rsc-gate.test.ts. - Storybook:
Primitives/RichTextView. - Редактор — RichTextEditor; свои Инструменты, Остров и граница semver — API расширения Редактора; решения — ADR-0050, подсветка — ADR-0051, расширение — ADR-0058.