@chepchik/json-highlighters
v0.1.4
Published
Три подхода к живой редактируемой подсветке JSON в React: regex, AST-токенизатор с позициями и AST внутри contentEditable.
Maintainers
Readme
@chepchik/json-highlighters
Три самописных подхода к живой, редактируемой подсветке синтаксиса JSON в
React. Поддерживает React 18 и 19 (peerDependencies: react ^18.0.0 || ^19.0.0).
npm install @chepchik/json-highlightersCSS подключается отдельной строкой (один раз, где угодно до рендера компонентов — обычно в точке входа приложения):
import "@chepchik/json-highlighters/styles.css";import {
RegexHighlighter,
AstPositionHighlighter,
AstContentEditableHighlighter,
} from "@chepchik/json-highlighters";
function App() {
return (
<div style={{ display: "flex", gap: 16, height: 400 }}>
<RegexHighlighter initialJson={'{\n "a": 1\n}'} onChange={console.log} />
<AstPositionHighlighter />
<AstContentEditableHighlighter title={null} theme="dark" />
</div>
);
}Каждый компонент неконтролируемый (хранит текст в собственном состоянии) и принимает четыре опциональных пропа:
| Проп | Тип | Описание |
|---|---|---|
| initialJson | string | Начальный текст. По умолчанию — маленький пример {"hello": "world"}. |
| title | string \| null | Заголовок панели (<h3>). null — не рендерить вовсе. |
| theme | "system" \| "light" \| "dark" | По умолчанию "system" — следует prefers-color-scheme страницы-хоста. "light"/"dark" — фиксирует тему явно, независимо от системной. |
| onChange | (text: string) => void | Вызывается с текущим текстом на каждое изменение (ввод, Tab, форматирование). |
Каждый компонент занимает 100% высоты и ширины родителя (display: flex;
flex-direction: column; height: 100%) — оберните в контейнер с заданной
высотой.
Стили и темы
Все стили вынесены в один CSS-файл (src/styles.css → публикуется как
dist/styles.css), доступный из пакета через subpath-экспорт:
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./styles.css": "./dist/styles.css"
}Внутри — обычные CSS custom properties (--jh-bg, --jh-key,
--jh-string, --jh-border и т.д.), заскоупленные на класс .json-viewer
(а не на :root), чтобы не протекать на остальную страницу хоста:
.json-viewer {
--jh-bg: #1e1e1e;
--jh-key: #79c0ff;
/* ... */
}
@media (prefers-color-scheme: light) {
.json-viewer { /* светлые значения */ }
}
.json-viewer[data-theme="dark"] { /* явные тёмные значения */ }
.json-viewer[data-theme="light"] { /* явные светлые значения */ }Проп theme компонента просто проставляет (или не проставляет)
data-theme на корневой <div>:
theme="system"(по умолчанию) — атрибут не ставится, работает@media (prefers-color-scheme: ...), то есть цвета следуют настройке ОС/браузера посетителя.theme="light"/theme="dark"— ставитсяdata-theme, который побеждает медиа-запрос за счёт более высокой специфичности атрибутного селектора (.json-viewer[data-theme="dark"]специфичнее, чем просто.json-viewerвнутри@media), поэтому тема остаётся зафиксированной независимо от системной настройки.
Три варианта
Все три работают с одним и тем же типом входных данных — сырым JSON-текстом,
как будто он только что пришёл по сети (fetch(url).then(r => r.text())), а не
с уже готовым JS-объектом. Каждая панель редактируется независимо.
1. RegexHighlighter
Прогоняет по сырому тексту один regex и оборачивает каждое совпадение (строка,
число, true/false, null) в цветной <span>. Ключ объекта отличается от
строки-значения хитрым приёмом внутри самого regex: совпадение может
«прихватить» с собой хвостовой \s*: — если прихватило, значит это ключ.
Поскольку подход только размечает текст и никогда не пересобирает его,
подсвеченный результат всегда посимвольно совпадает по длине и позициям с тем,
что находится в редакторе. Это делает живую редактируемую подсветку тривиальной:
прозрачная <textarea> лежит поверх цветного <pre> — textarea владеет
настоящим курсором и вводом текста, а <pre> снизу просвечивает цветом.
Пересчитывается на каждое нажатие клавиши; текст не обязан быть валидным JSON —
у regex вообще нет понятия «невалидно».
Компромисс: корректность зависит от паттерна regex; экзотические edge-cases в грамматике токенов теоретически можно классифицировать неверно.
2. AstPositionHighlighter
Самописный рекурсивный парсер (src/lib/jsonTokenizer.ts), который вместо
построения дерева JS-значений выдаёт плоский список токенов — каждый со своим
реальным смещением start/end в исходной строке. Строка помечается как key
не по угадыванию соседней пунктуации, а потому что парсер структурно находится
внутри parseObject(), сразу после { или , — по грамматике там может быть
только ключ.
Если разбор ломается на середине (например, во время редактирования текст временно невалиден), функция возвращает токены, накопленные до места ошибки, плюс текст ошибки — неразобранный хвост текста рендерится без подсветки, а не пропадает.
Поскольку позиции токенов сохраняются (ничего не переформатируется), здесь
работает тот же приём наложения прозрачной textarea на <pre>, что и в
regex-варианте — но раскраска теперь основана на грамматике, а не на догадке
regex. Живая подсветка, пересчитывается на каждое нажатие (токенизация — это
дешёвый линейный проход по строке).
Компромисс: кода больше, чем в regex-варианте (настоящий парсер вместо одного паттерна), хотя токенизатор переиспользуется и в 3-м варианте.
3. AstContentEditableHighlighter
Редактирование происходит прямо внутри раскрашенного элемента
contentEditable, без второго наложенного слоя. Это вариант на случай, когда
наложение слоёв в принципе не подходит (например, если логика подсветки сама
по себе переформатирует текст, как это делает обычный AST-рендерер,
пересобирающий значение с нуля) — здесь ничего не зависит от посимвольного
совпадения позиций между двумя слоями, потому что слой всего один.
Загвоздка: замена innerHTML у contentEditable-элемента сбрасывает курсор
браузера. Поэтому DOM этого компонента не управляется React после первого
монтирования — обновления происходят императивно:
onInputпросто запоминает текущий текст вrefи (пере)запускает таймер дебаунса на 400мс — так быстрый набор текста никогда не прерывается заменой DOM.- когда пауза в наборе достигнута,
recolor()сохраняет символьную позицию курсора (черезSelection/Range), заново токенизирует текст, строит HTML-строку с подсветкой, подменяетinnerHTML, а затем возвращает курсор на то же смещение, обходя текстовые узлы нового DOM. Tabвставляет два пробела черезdocument.execCommand("insertText", …), потому что уcontentEditableнетselectionStart/selectionEnd, как у<textarea>.
Компромисс: подсветка запаздывает на ~400мс после остановки набора текста
(не мгновенно посимвольно, как в вариантах 1 и 2), и учёт курсора требует
заметно больше кода — contentEditable в принципе не создан для того, чтобы
им управлял фреймворк.
Форматирование (отступы: пробелы/таб)
В каждом компоненте есть тулбар над редактором: выбор стиля отступа
(2 пробела / 4 пробела / Таб) и кнопка «Форматировать».
Логика общая для всех трёх, вынесена в src/lib/formatJson.ts:
export type IndentStyle = "2" | "4" | "tab";
export function formatJson(text: string, style: IndentStyle): string {
const parsed = JSON.parse(text);
return JSON.stringify(parsed, null, style === "tab" ? "\t" : Number(style));
}JSON.parse превращает текущий текст в настоящий JS-объект, а
JSON.stringify(parsed, null, indentParam) собирает его обратно в текст —
третий аргумент stringify и есть «стиль отступа». Если JSON.parse бросает
исключение (текст невалиден), formatJson пробрасывает его выше —
форматирование принципиально не может работать с битым JSON, в отличие от
подсветки, которая у вариантов 1 и 2 может работать и с временно невалидным
текстом. Поэтому кнопка «Форматировать» задизейблена, пока текст невалиден
(у варианта 1, которому подсветка не требует валидности, ошибка форматирования
показывается отдельным сообщением рядом с кнопкой).
Общий код
src/lib/jsonTokenizer.ts — токенизатор с сохранением позиций, используемый
AstPositionHighlighter и AstContentEditableHighlighter, чтобы не дублировать
логику разбора между ними. Экспортируется и из пакета (tokenizeWithPositions)
на случай, если нужен сам разбор без готового UI.
src/lib/formatJson.ts — форматирование текста с выбранным стилем отступа
(formatJson, INDENT_OPTIONS), тоже экспортируется отдельно.
Разработка
Это часть монорепозитория; см. example/ в корне репозитория — рабочее демо
всех трёх компонентов рядом друг с другом на одной странице.
npm install # из корня репозитория (npm workspaces)
npm run dev # в этом пакете — пересборка в watch-режиме