GrCodeBlock
Берут, когда показать ответ сервиса как есть.
Обзор
Когда брать
- показать ответ сервиса как есть — тело запроса, ответ модели, payload
события: значение приходит
unknownи структура заранее неизвестна; - это копируют — в тикет, в чат поддержки; кнопка копирует исходный текст, а не то, что видно на экране;
- значение техническое — идентификаторы, хеши, конфиг: моноширинный шрифт и подсветка отвечают «это данные, а не проза»;
- ответ длинный —
maxHeightпревращает блок в скроллер, достижимый с клавиатуры.
Когда взять другое
| Нужно | Берите |
|---|---|
| Код правят, а не читают | GrCodeEditor |
| Сравнить две версии | GrDiff |
| Разворачивать и сворачивать узлы данных | GrJsonViewer или GrTree (ядро) |
| Свернуть блок целиком | GrCollapse (ядро) |
| Пара «характеристика → значение» | GrDescriptionList (ядро) |
| Клавиша или сочетание в тексте | GrKbd (ядро) |
| Значение — обычный текст, а не код | GrTextarea (ядро) |
Примеры 4 у каждого — живое демо и его код
Доставка вебхука: запрос и ответ
codeпринимает тело как объект и сериализует его с отступами; JSON — единственный язык, который блок подсвечивает сам. Кнопка копирования кладёт в буфер исходник — без номеров строк: это CSS-счётчик, а не текст, — аcopyдаёт странице сказать «скопировано». Ответ — простойlanguage="text".invoice.paid200 OK312 ms · 6 Oct, 09:14Request body{ "id": "evt_1Q7b3x2LkS", "type": "invoice.paid", "created": "2026-10-06T09:14:22Z", "data": { "invoice": "INV-0418", "customer": { "id": "cus_8812", "name": "Northwind Traders" }, "amount": 1284.5, "currency": "EUR", "paid_with": "sepa_debit", "metadata": { "po_number": "PO-77120", "cost_centre": null } }, "attempt": 1}ResponseHTTP/1.1 200 OKcontent-type: application/jsonx-request-id: 7f3e1c9a{"received": true}Команда curl со своей подсветкой
highlighterпринимает любую функцию, которая режет текст на роли, — здесь двадцать строк регулярных выражений для shell, без Shiki и Prism. Роли названы по смыслу: флаг —keyword,$ACME_KEY—variable, и тема красит их в обоих режимах.Create your first invoiceReplace $ACME_KEYwith a test key# Create a draft invoicecurl -X POST https://api.acme-billing.com/v1/invoices \ -H "Authorization: Bearer $ACME_KEY" \ -d customer=cus_8812 \ -d "items[0][price]=price_team_monthly"Лог упавшего деплоя
max-heightпревращает блок в окно со своей прокруткой, и длинный лог не выталкивает страницу за экран; окно — остановка Tab, иaria-labelдаёт ему имя.wrapпереносит длинные строки: лог читают с переносами, код — как написан.Deploy #812 · productionFailed 09:41:02 Cloning acme/billing-web@4f2c1e909:41:05 Installing dependencies (yarn 4.5.0, node 22.11)09:41:48 Resolved 1,284 packages in 43s09:41:49 Running build: vite build --mode production09:42:11 ✓ 2,418 modules transformed09:42:12 rendering chunks…09:42:19 dist/assets/invoice-editor-3f9a1c.js 412.70 kB │ gzip: 128.33 kB09:42:19 (!) Some chunks are larger than 400 kB after minification. Consider dynamic import() to split the application09:42:20 Running checks: bundle budget09:42:20 ✗ invoice-editor exceeds its budget: 128.3 kB gzip > 120 kB09:42:20 Build failed with exit code 1Данные, на которых падает JSON.stringify
Трекер ошибок захватывает всё, что было в памяти: сессию, ссылающуюся обратно на запрос, 64-битный идентификатор в
BigInt.JSON.stringifyна обоих бросает, а блок печатает[Circular]и9007199254740993n— страница живёт, пока показывает, что сломалось.TypeError in send-invoiceCaptured context · 14 events in the last hour { "error": "TypeError: Cannot read properties of undefined (reading 'email')", "release": "[email protected]", "ledgerEntryId": "9007199254740993n", "request": { "method": "POST", "path": "/v1/invoices/INV-0418/send", "session": { "id": "sess_51c0", "user": "[email protected]", "plan": "Team", "lastRequest": "[Circular]" } }}
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
language | GrCodeBlockLanguage | undefined | "json" | Язык подсветки: `json` и `text` разбираются встроенно, остальные — подключённой подсветкой. По умолчанию `json`. |
highlighter | GrCodeTokenizer | undefined | undefined | Подсветка для языков сверх встроенных. Не задана — берётся из `provide`/`inject` приложения (`GR_CODE_HIGHLIGHTER_KEY`). |
maxHeight | string | number | undefined | undefined | Высота, после которой включается вертикальный скролл. |
copyable | boolean | undefined | undefined | Кнопка копирования. Без защищённого контекста не рисуется вовсе. |
wrap | boolean | undefined | undefined | Переносить длинные строки вместо горизонтальной прокрутки. |
lineNumbers | boolean | undefined | undefined | Номера строк в колонке слева; в выделение и буфер они не попадают. Не задан — из `GrConfigProvider`, иначе `false`. |
ariaLabel | string | undefined | undefined | Имя области прокрутки. Безымянную скринридер объявляет просто «регион». |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Ступень размера: кегль кода и внутренние отступы. По умолчанию `md`; берётся из `GrConfigProvider`. |
codeобязательный | unknown | — | Строка или значение: объект сериализуется с отступом. |
События
| Event | Сигнатура | Описание |
|---|---|---|
copy | (event: "copy", code: string): void | — |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrCodeBlockLanguage, GrCodeBlockProps, GrCodeBlockEmits } from '@feugene/granularity-code/components/GrCodeBlock'Файлdist/types/components/GrCodeBlock/config.d.ts
Кнопка «скопировать» — GrButton ядра, и ребро объявлено с указанием
провайдера: в ядре хватало имени, потому что компонент был соседом по
реестру, а у спутника свой провайдер, и 'GrButton' там не резолвится.
Ребро обязательно: granum подмешивает safelist и CSS только компонентам из
селекции, и без него потребитель, выбравший один GrCodeBlock, получил бы
кнопку без фона и без фокус-кольца.
const grCodeBlockConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCodeBlock">;Файлdist/types/components/GrCodeBlock/GrCodeBlock.vue.d.ts
Язык подсветки.
'json' и 'text' разбирает встроенный токенизатор — без единой зависимости.
Любая другая строка уходит подключённой подсветке (highlighter или
провайдер приложения); её нет — строка показывается обычным текстом, и это
нормальный режим, а не отказ.
type GrCodeBlockLanguage = 'json' | 'text' | (string & {});Блок сырого текста или JSON: моноширинный, с подсветкой, номерами строк и
копированием в буфер.
Показывает, а не редактирует и не сворачивает узлы. Свёртка по узлам — задача
GrJsonViewer, дерево выбора — GrTree.
interface GrCodeBlockProps {
/** Строка или значение: объект сериализуется с отступом. */
code: unknown;
/**
* Язык подсветки: `json` и `text` разбираются встроенно, остальные — подключённой подсветкой.
* По умолчанию `json`.
*/
language?: GrCodeBlockLanguage;
/**
* Подсветка для языков сверх встроенных. Не задана — берётся из
* `provide`/`inject` приложения (`GR_CODE_HIGHLIGHTER_KEY`).
*/
highlighter?: GrCodeTokenizer;
/** Высота, после которой включается вертикальный скролл. */
maxHeight?: string | number;
/** Кнопка копирования. Без защищённого контекста не рисуется вовсе. */
copyable?: boolean;
/** Переносить длинные строки вместо горизонтальной прокрутки. */
wrap?: boolean;
/**
* Номера строк в колонке слева; в выделение и буфер они не попадают. Не задан — из
* `GrConfigProvider`, иначе `false`.
*/
lineNumbers?: boolean;
/** Имя области прокрутки. Безымянную скринридер объявляет просто «регион». */
ariaLabel?: string;
/**
* Ступень размера: кегль кода и внутренние отступы. По умолчанию `md`; берётся из
* `GrConfigProvider`.
*/
size?: GrComponentSize;
}interface GrCodeBlockEmits {
/** Текст скопирован. Тост показывает потребитель: у блока своего места для него нет. */
(e: 'copy', code: string): void;
}Файлdist/types/highlight/palette.d.ts
type GrCodeRole = typeof GR_CODE_ROLES[number];Один разобранный кусок строки.
interface GrCodeToken {
text: string;
role: GrCodeRole;
}Файлdist/types/components/GrCodeBlock/defaults.d.ts
Пропы GrCodeBlock, настраиваемые глобально через
<GrConfigProvider :component-defaults="{ GrCodeBlock: { … } }">.
Только оформление: code, language и ariaLabel принадлежат экземпляру.
lineNumbers и wrap здесь не случайно — в админке блоки кода выглядят
одинаково на всех страницах, и повторять решение у каждого значит однажды
забыть.
interface GrCodeBlockConfigurableProps {
size: GrComponentSize;
copyable: boolean;
wrap: boolean;
lineNumbers: boolean;
}Файлdist/types/components/GrCodeBlock/safelist.d.ts
const grCodeBlockSafelist: string[];Доступность
Паттерн APG
блок (если скроллер) + кнопка
| Клавиша | Действие |
|---|---|
| — | своих нет. Блок встаёт в таб-порядок (tabindex="0"), когда он скроллер **по пропам** — задан maxHeight либо выключен wrap; листается стрелками и PageUp/PageDown. Кнопка копирования — обычная кнопка, своя остановка |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Сериализация не имеет права уронить страницу
code принимает unknown, потому что данные приходят из БД и бывают любыми.
Отсюда три решения, каждое из которых закрывает реальный отказ:
- циклическая ссылка заменяется маркером
[Circular], а не вешает вкладку. Маркер получает и объект, встреченный второй раз в разных ветках: обход по стеку предков разворачивал бы каждую общую ветку заново, и на «ромбе» из общих ссылок это экспонента. Неточность здесь дешевле зависания; BigIntпечатается с суффиксомn— штатныйJSON.stringifyна нём бросает;- всё остальное, что упало при сериализации (враждебный
toJSON, геттер, который бросает), даёт[Unserializable]— на месте этого значения, а не вместо всего блока. Дерево обходится значение за значением, у каждого свойtry: одно сломанное поле не прячет соседей, циклы иBigIntрядом с ним.
Строка проходит как есть, без кавычек и переформатирования: это уже готовый
текст. undefined печатает пустой блок, null — литерал null; «данных нет»
и «значение равно null» это разные утверждения, и решать между ними — задача
страницы, а не блока.
Копируется исходник, а не экран
Кнопка кладёт в буфер ту же строку, что отрисована, — но взятую из модели, а не из разметки. Разница видна с номерами строк: скопировать вместе с ними значит получить текст, который некуда вставить.
Номера строк для этого и сделаны CSS-счётчиком: как текста их в разметке нет вовсе, поэтому они не попадают ни в буфер, ни в выделение мышью.
Без защищённого контекста кнопки нет. navigator.clipboard недоступен по
http://, и кнопка, которая молча ничего не делает, хуже её отсутствия.
Наличие буфера проверяется после монтирования — в первом рендере кнопки нет ни
на сервере, ни на клиенте, поэтому гидрация совпадает.
Успех уходит в живой регион (useAnnouncer) и в событие copy — тост показывает
потребитель: своего места для него у блока нет.
Кнопка стоит рядом со скроллером, а не поверх него. Полосу прокрутки браузер
рисует у правого края <pre>, и накрывшая её кнопка отбирает те самые пиксели,
за которые полосу хватают мышью. Поэтому блок резервирует справа жёлоб, а сам
код сужается на его ширину; отступами это не решается — их пришлось бы взять
больше ширины кнопки, и угол перестал бы быть углом. Жёлоба нет, когда нет
кнопки: при copyable: false и без защищённого контекста ширина не теряется.
Скроллер и клавиатура
Блок встаёт в таб-порядок, когда он скроллер по пропам: задан maxHeight
либо выключен wrap (тогда длинная строка даёт горизонтальную прокрутку).
Замер переполнения не используется намеренно: он делал бы остановку Tab
мигающей на каждой смене данных и на загрузке шрифта.
ariaLabel даёт области role="region" и имя. Безымянную область скринридер
объявляет просто «регион», и на странице с четырьмя блоками их не различить.
Подсветка своя и только для JSON
Токенизация — чистая функция на четыре роли: ключ, строка, число, литерал.
Тянуть highlight.js ради этого несоразмерно, а language="text" отключает
разбор совсем.
Цвета — ссылки на роли темы, поэтому подсветка работает в светлой и тёмной без
своего theme-слоя. Число взято от azure, а не от info: info — синий в двух
шагах от индиго primary, и пара «ключ ↔ число» сливалась бы в {"count": 42}.
Контраст и различимость ролей проверяются тестом, а не глазом.
Границы
Не редактирует, не диффит и не подсвечивает языки кроме JSON. Узлы он тоже не
сворачивает — это GrJsonViewer (ядро), и различитель между ними
простой: текст читают целиком или в нём ищут поле.
Пропы этого примера