GrCodeBlock

Package: @feugene/granularity-codecompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • show the answer of a service as it is — the body of a request, the answer of a model, the payload of an event: the value arrives as unknown and the structure is not known in advance;
  • it gets copied — into a ticket, into a support chat; the button copies the source text rather than what is visible on the screen;
  • the value is technical — identifiers, hashes, a config: a monospaced font and highlighting answer “this is data, not prose”;
  • the answer is longmaxHeight turns the block into a scroller reachable from the keyboard.

When to take something else

NeedTake
The code is edited rather than readGrCodeEditor
Compare two versionsGrDiff
Expand and collapse the nodes of the dataGrJsonViewer or GrTree (the core)
Collapse the block as a wholeGrCollapse (the core)
A “property → value” pairGrDescriptionList (the core)
A key or a shortcut inside textGrKbd (the core)
The value is ordinary text rather than codeGrTextarea (the core)

Serialisation has no right to bring the page down

code accepts unknown, because the data comes from a database and can be anything. Hence three decisions, each of which covers a real failure:

  • a circular reference is replaced with a [Circular] marker rather than hanging the tab. An object met a second time in different branches gets the marker as well: a repeat can be told from a real cycle only with the stack of ancestors, and replacer does not give it away. An imprecision here is cheaper than a freeze;
  • BigInt is printed with an n suffix — the standard JSON.stringify throws on it;
  • everything else that failed during serialisation (a hostile toJSON) gives [Unserializable].

A string goes through as it is, with no quotes and no reformatting: it is ready text already. undefined prints an empty block, and null the null literal; “there is no data” and “the value equals null” are different statements, and deciding between them is the task of the page rather than of the block.

What is copied is the source, not the screen

The button puts into the clipboard the same string that is rendered — but taken from the model rather than from the markup. The difference is visible with line numbers: copying together with them means getting text there is nowhere to paste.

The line numbers are made with a CSS counter for exactly that: as text they are not in the markup at all, so they enter neither the clipboard nor a selection with the mouse.

Without a secure context there is no button. navigator.clipboard is unavailable over http://, and a button that silently does nothing is worse than its absence. The presence of the clipboard is checked after mounting — in the first render there is no button either on the server or on the client, so the hydration matches.

A success goes into a live region (useAnnouncer) and into a copy event — the toast is shown by the consumer: the block has no place of its own for it.

The button stands beside the scroller rather than on top of it. The scrollbar is drawn by the browser at the right edge of the <pre>, and a button covering it takes away the very pixels by which the bar is grabbed with the mouse. The block therefore reserves a gutter on the right, and the code itself narrows by its width; padding does not solve that — it would have to be taken larger than the width of the button, and the corner would stop being a corner. There is no gutter when there is no button: with copyable: false and without a secure context no width is lost.

The scroller and the keyboard

The block enters the tab order when it is a scroller by props: maxHeight is set or wrap is switched off (then a long line gives horizontal scrolling). Measuring the overflow is deliberately not used: it would make the Tab stop flicker on every change of the data and on the loading of the font.

ariaLabel gives the area a role="region" and a name. A nameless area is announced by a screen reader simply as “region”, and on a page with four blocks they cannot be told apart.

The highlighting is its own and only for JSON

The tokenisation is a pure function over four roles: a key, a string, a number, a literal. Dragging in highlight.js for that is out of proportion, and language="text" switches the parsing off entirely.

The colours are references to the roles of the theme, so the highlighting works in the light and in the dark one without a theme layer of its own. The number is taken from azure rather than from info: info is a blue two steps from the indigo of primary, and the “key ↔ number” pair would merge in {"count": 42}. The contrast and the distinguishability of the roles are checked by a test rather than by eye.

Limits

It does not edit, does not diff and does not highlight languages other than JSON. It does not collapse nodes either — that is GrJsonViewer (the core), and the distinction between them is simple: text is read in full, or a field is looked for in it.

Install

npm i @feugene/granularity-code

Import

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

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 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>

Accessibility

APG pattern
блок (если скроллер) + кнопка

Full keyboard contract of the package

Component documentationAll components