GrCodeBlock

Пакет: @feugene/granularity-codeспутникГруппа: Прочее

Берут, когда показать ответ сервиса как есть.

Когда брать

  • показать ответ сервиса как есть — тело запроса, ответ модели, payload события: значение приходит unknown и структура заранее неизвестна;
  • это копируют — в тикет, в чат поддержки; кнопка копирует исходный текст, а не то, что видно на экране;
  • значение техническое — идентификаторы, хеши, конфиг: моноширинный шрифт и подсветка отвечают «это данные, а не проза»;
  • ответ длинныйmaxHeight превращает блок в скроллер, достижимый с клавиатуры.

Когда взять другое

НужноБерите
Код правят, а не читаютGrCodeEditor
Сравнить две версииGrDiff
Разворачивать и сворачивать узлы данныхGrJsonViewer или GrTree (ядро)
Свернуть блок целикомGrCollapse (ядро)
Пара «характеристика → значение»GrDescriptionList (ядро)
Клавиша или сочетание в текстеGrKbd (ядро)
Значение — обычный текст, а не кодGrTextarea (ядро)

Сериализация не имеет права уронить страницу

code принимает unknown, потому что данные приходят из БД и бывают любыми. Отсюда три решения, каждое из которых закрывает реальный отказ:

  • циклическая ссылка заменяется маркером [Circular], а не вешает вкладку. Маркер получает и объект, встреченный второй раз в разных ветках: отличить повтор от настоящего цикла можно только стеком предков, а replacer его не отдаёт. Неточность здесь дешевле зависания;
  • BigInt печатается с суффиксом n — штатный JSON.stringify на нём бросает;
  • всё остальное, что упало при сериализации (враждебный toJSON), даёт [Unserializable].

Строка проходит как есть, без кавычек и переформатирования: это уже готовый текст. 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 (ядро), и различитель между ними простой: текст читают целиком или в нём ищут поле.

Установка

npm i @feugene/granularity-code

Импорт

import { GrCodeBlock } from '@feugene/granularity-code/components/GrCodeBlock'

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 4

Basic

Basic
<script setup lang="ts">
import { ref } from 'vue'

import { GrSegmented } from '@feugene/granularity'

/** Ответ сервиса, как он приходит из БД: `unknown`, а не заранее известная форма. */
const response = {
  id: 'ord_8241',
  status: 'shipped',
  total: 12490.5,
  paid: true,
  shipping: { carrier: 'СДЭК', track: '1094887312', days: 3 },
  items: [
    { sku: 'KB-87', title: 'Клавиатура 87 клавиш', qty: 1 },
    { sku: 'MS-02', title: 'Мышь беспроводная', qty: 2 },
  ],
  note: null,
}

const size = ref<'xs' | 'sm' | 'md' | 'lg'>('md')
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented
      v-model="size"
      size="sm"
      :options="[
        { value: 'xs', label: 'xs' },
        { value: 'sm', label: 'sm' },
        { value: 'md', label: 'md' },
        { value: 'lg', label: 'lg' },
      ]"
    />

    <GrCodeBlock :code="response" :size="size" line-numbers copyable max-height="18rem" />
  </div>
</template>

Highlight

Highlight
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrSwitch } from '@feugene/granularity'
import type { GrCodeLine, GrCodeRole, GrCodeTokenizer } from '@feugene/granularity-code'

const SOURCE = `// Разбор конфигурации приложения
export interface AppConfig {
  retries: number
  featureFlags: string[]
}

export function loadConfig(raw: string): AppConfig {
  const parsed = JSON.parse(raw)
  return { retries: parsed.retries ?? 3, featureFlags: parsed.flags ?? [] }
}`

/**
 * Подсветка для демонстрации: настоящий Shiki витрине сюда тащить незачем —
 * важно показать, что подсветка приходит **функцией**, а какой она будет,
 * решает приложение.
 */
const KEYWORDS = new Set(['export', 'interface', 'function', 'const', 'return', 'number', 'string'])

const demoTokenizer: GrCodeTokenizer = code => code.split('\n').map<GrCodeLine>((line) => {
  if (line.trimStart().startsWith('//'))
    return [{ text: line, role: 'comment' }]

  return (line.match(/\w+|\W+/g) ?? []).map((part) => {
    const role: GrCodeRole = KEYWORDS.has(part.trim())
      ? 'keyword'
      : /^\d+$/.test(part.trim())
        ? 'number'
        : 'plain'

    return { text: part, role }
  })
})

const highlighted = ref(true)

/**
 * Подпись говорит **текущее** состояние, а не одно из двух: «Подсветка
 * подключена» рядом с выключенным тумблером — прямая неправда на экране.
 */
const switchLabel = computed(() => highlighted.value
  ? 'Подсветка подключена'
  : 'Подсветка выключена')
</script>

<template>
  <div class="grid gap-4">
    <GrSwitch v-model="highlighted" size="sm">
      {{ switchLabel }}
    </GrSwitch>

    <GrCodeBlock
      :code="SOURCE"
      language="ts"
      :highlighter="highlighted ? demoTokenizer : undefined"
      line-numbers
    />
  </div>
</template>

Shiki Theme

Shiki Theme
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'

import { GrButton, GrSegmented } from '@feugene/granularity'
import { createShikiTokenizer, GR_CODE_SHIKI_THEME } from '@feugene/granularity-code'
import type { GrCodeTokenizer, ShikiLike } from '@feugene/granularity-code'

/**
 * Разбирает Shiki, красит тема приложения.
 *
 * Токен нашего контракта несёт **роль, а не цвет**: `createShikiTokenizer` даёт
 * Shiki тему-метку и разбирает цвета обратно в одиннадцать ролей. Цвет ролей
 * приходит из токенов `--gr-code-block-*` — поэтому «подключить тему» здесь это
 * не поставить пакет, а переопределить одиннадцать переменных. Зато одна и та
 * же тема разом ложится на блок, дифф и редактор, и слушается светлой/тёмной
 * схемы страницы.
 */
const SOURCE = `// Пересчёт корзины после смены купона
export async function recalc(cart: Cart, coupon?: string) {
  const discount = coupon ? await fetchDiscount(coupon) : 0
  const total = cart.items.reduce((sum, item) => sum + item.price, 0)

  return { total: total - discount, applied: discount > 0 }
}`

/**
 * Палитры настоящих тем, записанные нашими токенами.
 *
 * Ровно то, что делает потребитель: берёт цвета любимой темы и раскладывает их
 * по ролям. Ничего, кроме CSS-переменных, для этого не нужно.
 */
const PALETTES = {
  'app': null,
  'one-dark': {
    '--gr-code-block-bg': '#282c34',
    '--gr-code-block-fg': '#abb2bf',
    '--gr-code-block-key': '#e06c75',
    '--gr-code-block-string': '#98c379',
    '--gr-code-block-number': '#d19a66',
    '--gr-code-block-literal': '#d19a66',
    '--gr-code-block-punctuation': '#abb2bf',
    '--gr-code-block-keyword': '#c678dd',
    '--gr-code-block-comment': '#5c6370',
    '--gr-code-block-type': '#e5c07b',
    '--gr-code-block-function': '#61afef',
    '--gr-code-block-variable': '#abb2bf',
    '--gr-code-block-line-number': '#4b5263',
    // Дифф стоит рядом и красится теми же переменными: перекрась только код —
    // подложки правок останутся светлыми и станут нечитаемыми.
    '--gr-diff-added': '#2b3a2e',
    '--gr-diff-removed': '#3f2b2b',
    '--gr-diff-word-added': '#4b7f56',
    '--gr-diff-word-removed': '#a04c4c',
    '--gr-diff-word-added-fg': '#e6f4ea',
    '--gr-diff-word-removed-fg': '#fbeaea',
    '--gr-diff-gutter': '#5c6370',
    '--gr-diff-gap-bg': '#21252b',
  },
  'nord': {
    '--gr-code-block-bg': '#2e3440',
    '--gr-code-block-fg': '#d8dee9',
    '--gr-code-block-key': '#88c0d0',
    '--gr-code-block-string': '#a3be8c',
    '--gr-code-block-number': '#b48ead',
    '--gr-code-block-literal': '#81a1c1',
    '--gr-code-block-punctuation': '#eceff4',
    '--gr-code-block-keyword': '#81a1c1',
    '--gr-code-block-comment': '#616e88',
    '--gr-code-block-type': '#8fbcbb',
    '--gr-code-block-function': '#88c0d0',
    '--gr-code-block-variable': '#d8dee9',
    '--gr-code-block-line-number': '#4c566a',
    '--gr-diff-added': '#3b4a3f',
    '--gr-diff-removed': '#4a3b3f',
    '--gr-diff-word-added': '#5b8a63',
    '--gr-diff-word-removed': '#a3616f',
    '--gr-diff-word-added-fg': '#eceff4',
    '--gr-diff-word-removed-fg': '#eceff4',
    '--gr-diff-gutter': '#4c566a',
    '--gr-diff-gap-bg': '#3b4252',
  },
} as const

type Palette = keyof typeof PALETTES

const palette = ref<Palette>('app')
const tokenizer = shallowRef<GrCodeTokenizer | null>(null)
const loading = ref(false)

/**
 * Shiki грузит **потребитель**: движок регулярок и набор грамматик выбирает он,
 * а пакет о Shiki не знает даже в импортах типов.
 */
async function loadShiki(): Promise<void> {
  loading.value = true

  const { createHighlighter } = await import('shiki')
  const shiki = await createHighlighter({
    langs: ['ts'],
    // Тема-метка вместо настоящей: цвета Shiki нам не нужны, нужны роли.
    themes: [GR_CODE_SHIKI_THEME],
  })

  // Приведение — плата за то, что пакет типизует Shiki структурно, по одному
  // методу: у самого Shiki он объявлен через дженерики набора тем и языков.
  // Ровно поэтому переименование метода в мажоре Shiki ломает эту строку и
  // адаптер пакета, а контракт `GrCodeTokenizer` не ломает никогда.
  tokenizer.value = createShikiTokenizer(shiki as unknown as ShikiLike)
  loading.value = false
}

const style = computed(() => PALETTES[palette.value] ?? undefined)
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" :loading="loading" :disabled="!!tokenizer" @click="loadShiki">
        {{ tokenizer ? 'Shiki подключён' : 'Подключить Shiki' }}
      </GrButton>
      <GrSegmented
        v-model="palette"
        size="sm"
        :options="[
          { value: 'app', label: 'Тема приложения' },
          { value: 'one-dark', label: 'One Dark' },
          { value: 'nord', label: 'Nord' },
        ]"
      />
    </div>

    <!-- Палитра — обычные CSS-переменные на обёртке: ниже её наследуют оба компонента. -->
    <div class="grid gap-3" :style="style">
      <GrCodeBlock
        :code="SOURCE"
        language="ts"
        :highlighter="tokenizer ?? undefined"
        aria-label="Пересчёт корзины"
        line-numbers
      />

      <GrDiff
        :before="SOURCE"
        :after="SOURCE.replace('discount > 0', 'discount > 0 && cart.items.length > 0')"
        language="ts"
        :highlighter="tokenizer ?? undefined"
        :context="1"
      />
    </div>

    <p class="showcase-demo-text text-sm">
      <template v-if="tokenizer">
        Разбирает Shiki, цвет берут одиннадцать токенов — поэтому тема легла и на блок, и на дифф разом
      </template>
      <template v-else>
        Пока Shiki не подключён, работает встроенный разбор: JSON и обычный текст
      </template>
    </p>
  </div>
</template>

Wrap

Wrap
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrSwitch } from '@feugene/granularity'

/** Строка лога, которая в колонку не помещается: типичный ответ шлюза. */
const LOG = `2026-08-31T10:12:04.881Z WARN  gateway upstream=orders-api attempt=3 status=502 latency_ms=1841 trace=7f3a91c0b28d4e15 message="upstream returned bad gateway, retrying with backoff"
2026-08-31T10:12:06.204Z INFO  gateway upstream=orders-api attempt=4 status=200 latency_ms=212 trace=7f3a91c0b28d4e15
2026-08-31T10:12:06.205Z INFO  gateway request completed`

const wrap = ref(false)
const copyable = ref(true)
const copies = ref(0)

const wrapLabel = computed(() => wrap.value ? 'Перенос строк' : 'Горизонтальная прокрутка')
const copyLabel = computed(() => copyable.value ? 'Кнопка копирования есть' : 'Кнопка копирования убрана')
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-4">
      <GrSwitch v-model="wrap" size="sm">
        {{ wrapLabel }}
      </GrSwitch>
      <GrSwitch v-model="copyable" size="sm">
        {{ copyLabel }}
      </GrSwitch>
    </div>

    <GrCodeBlock
      :code="LOG"
      language="text"
      :wrap="wrap"
      :copyable="copyable"
      aria-label="Лог шлюза"
      line-numbers
      max-height="12rem"
      @copy="copies += 1"
    />

    <p class="showcase-demo-text text-sm">
      Событие <code>copy</code> получено раз: <b>{{ copies }}</b>
    </p>
  </div>
</template>

Доступность

Паттерн APG
блок (если скроллер) + кнопка
Клавиши
своих нет. Блок встаёт в таб-порядок (tabindex="0"), когда он скроллер **по пропам** — задан maxHeight либо выключен wrap; листается стрелками и PageUp/PageDown. Кнопка копирования — обычная кнопка, своя остановка

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты