Diil Docs
  1. Документация
  2. Руководства

Типы блоков и формат их содержимого

Обновлено:

Блок — самый мелкий кусочек контента, который может поменять редактор: заголовок, цена, фото на первом экране, целый FAQ. Типов блоков двенадцать, и поле type точно говорит, как будет выглядеть content. Запомните двенадцать форм — и любой сайт на Diil выводится без гаданий.

Обёртка у всех блоков одна

Откуда бы ни пришёл блок — из страницы, секции или запроса одного блока, — обёртка у него всегда одинаковая. От типа к типу меняется только content.

Блокjson
{
  "id": 95,
  "marker": "hero_title",
  "name": "Заголовок первого экрана",
  "type": "text",
  "multilang": true,
  "updatedAt": "2026-09-20T16:33:23.000Z",
  "content": { "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }
}
idnumber
Внутренний номер блока. В коде опирайтесь на маркер: номера на разных окружениях разные.
markerstring
Маркер блока, например hero_title. Уникален в пределах своей секции.
namestring
Название для редакторов из CRM. Оно для людей в админке, а не для посетителей сайта.
typestring
Один из двенадцати типов ниже. От него зависит форма content.
multilangboolean
Для картинок и видео: true, если у каждого языка свой файл.
updatedAtstring (ISO 8601)
Когда блок правили в последний раз.
contentзависит от типа
Само значение. Вся остальная страница — про это одно поле.

Все типы блоков в одной таблице

Эту таблицу стоит держать под рукой: она отвечает на вопрос «а как выглядит content?» для каждого типа.

ТипcontentДля чего обычно
text{ "en": "…", "de": "…" }Заголовки, кнопки, короткие подписи
html{ "en": "<p>…</p>" }Текст с оформлением: абзацы, списки, ссылки
image{ "file": "https://…" } или { "multilang": true, "en": "…" }Фото, баннеры, логотипы
videoкак у imageФоновые и продуктовые ролики
link{ "url", "target", "title": { "en": "…" } }Кнопки, пункты меню, призывы к действию
number149 или nullЦены, счётчики, пороги
color"#3D3D5C" или nullАкцентные цвета, темы
date"2026-10-01" или nullДаты событий, сроки
date_range{ "from": "…", "to": "…" } или nullАкции, сезоны
booleantrue, false или nullПереключатели «показать / скрыть»
object{ "field": value, … }Группа полей: карточка, FAQ, блок контактов
array[ { "field": value, … }, … ]Повторяющиеся элементы: команда, отзывы, преимущества

В примерах ниже встречаются два маленьких помощника: t() для карт переводов и pickMedia() для файлов. Оба лежат в универсальном рендерере в конце страницы — их можно сразу копировать.

text — обычный текст

В CRM редактор пишет простой текст, отдельно для каждого языка. Без оформления и без сюрпризов.

contentjson
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }
Выводtsx
<h1>{t(block.content, lang, defaultLang)}</h1>
  • Ключи — коды языков, в том порядке, что задан в CRM, а не в том, в каком вы их запросили.
  • Стёртый редактором перевод приходит пустой строкой "". Язык, который вообще не заполняли, просто отсутствует. Код должен пережить оба случая — t() переживает.
  • React сам экранирует текст, так что случайный < в заголовке ничего не сломает.

html — текст с оформлением

В CRM у редактора текст с оформлением — жирный, списки, ссылки и так далее. Вам приходит готовый HTML, по строке на язык.

contentjson
{
  "en": "<p>Free delivery on orders over <strong>$50</strong>.</p><ul><li>1–3 days</li><li>Tracking included</li></ul>",
  "de": "<p>Kostenloser Versand ab <strong>50 $</strong>.</p>"
}
Выводtsx
<div
  className="prose"
  dangerouslySetInnerHTML={{ __html: t(block.content, lang, defaultLang) }}
/>

Внутренние теги оформляйте своим CSS (классом .prose или похожим). Редактор решает, что будет жирным, а вы — как этот жирный выглядит.

image — картинки

В CRM редактор загружает картинку: либо один файл на все языки, либо, если блок многоязычный, отдельный файл для каждого языка (удобно для баннеров, где текст нарисован прямо на картинке). От этого выбора зависит форма content:

Один файл на все языкиjson
{ "file": "https://cdn.example.com/storage/your-site/hero.jpg" }
Свой файл на каждый язык (multilang)json
{
  "multilang": true,
  "en": "https://cdn.example.com/storage/your-site/banner-en.jpg",
  "de": ""
}
Выводtsx
const src = pickMedia(block.content, lang, defaultLang);

{src && <img src={src} alt={t(altBlock.content, lang, defaultLang)} />}
  • Различайте две формы по content.multilang. Этот флаг едет вместе со значением, поэтому работает и внутри объектов и списков, где обёртки блока нет.
  • Если файл для языка ещё не загрузили, там "", как у de выше. Подставьте другой язык, а не выводите <img src="">.
  • Адреса приходят уже экранированными (пробелы и прочее). Используйте их как есть: повторное кодирование превратит %20 в %2520, а картинку — в значок «битого» файла.
  • Подписи (alt) в блоке картинки нет. Держите её в соседнем текстовом блоке — так редакторы смогут её перевести.

video — видеофайлы

В CRM редактор загружает видеофайл. Всё, что сказано про картинки, верно и здесь: те же две формы, те же пустые строки, те же готовые к работе адреса.

contentjson
{ "file": "https://cdn.example.com/storage/your-site/promo.mp4" }
Выводtsx
const src = pickMedia(block.content, lang, defaultLang);

{src && <video src={src} autoPlay muted loop playsInline />}

Это файл, а не код для встраивания, так что хватит обычного <video>. Фоновому ролику нужны muted и playsInline, иначе мобильные браузеры откажутся запускать его сами.

В CRM редактор указывает адрес, где открывать ссылку — в той же вкладке или в новой, — и текст ссылки для каждого языка.

contentjson
{
  "url": "https://example.com/pricing",
  "target": "_blank",
  "title": { "en": "See pricing", "de": "Preise ansehen" }
}
Выводtsx
const { url, target, title } = block.content;

<a
  href={url}
  target={target}
  rel={target === '_blank' ? 'noopener noreferrer' : undefined}
>
  {t(title, lang, defaultLang)}
</a>
  • target — это либо _self, либо _blank, его можно сразу отдавать в тег.
  • Ссылкам с _blank добавляйте rel="noopener noreferrer" — и вежливо, и безопасно.
  • title — карта переводов с теми же правилами, что у текстового блока: пустая строка или отсутствующий язык вполне возможны.

Простые значения: number, color, date, date_range, boolean

Эти пять типов не переводятся, поэтому карты языков у них нет: content — это само значение в том виде, в каком оно сохранено. Если редактор ещё ничего не задал, придёт null.

number

В CRM — числовое поле. Цены, «лет на рынке», порог бесплатной доставки.

contentjson
149
Выводtsx
{block.content !== null && (
  <span className="price">{new Intl.NumberFormat(lang).format(block.content)}</span>
)}

color

В CRM — цвет. Акцентный цвет, фон промо-полоски.

contentjson
"#3D3D5C"
Выводtsx
<section style={{ background: block.content ?? '#ffffff' }}>…</section>

Цвет — это настройка, а не текст для вывода. Отдавайте его в style или CSS-переменную и всегда держите запасной цвет на случай null.

date

В CRM — дата. Когда начинается событие, когда заканчивается акция.

contentjson
"2026-10-01"
Выводtsx
{block.content && (
  <time dateTime={block.content}>
    {new Date(block.content).toLocaleDateString(lang, { timeZone: 'UTC' })}
  </time>
)}

Значение приходит в том виде, в каком его ввели в CRM, — прежде чем зашивать формат в код, посмотрите на настоящий ответ. И классическая ловушка: new Date('2026-10-01') — это полночь по UTC, поэтому форматируйте с timeZone: 'UTC', иначе посетители западнее Гринвича увидят 30 сентября.

date_range

В CRM — дата начала и дата конца. Неделя распродаж, летний сезон.

contentjson
{ "from": "2026-10-01", "to": "2026-10-07" }
Выводtsx
const range = block.content;
const fmt = (d: string) => new Date(d).toLocaleDateString(lang, { timeZone: 'UTC' });

{range?.from && <p>{fmt(range.from)} – {range.to ? fmt(range.to) : '…'}</p>}

Правило то же, что у date: значение хранится так, как его ввели в CRM, поэтому сверьтесь с живым ответом и пишите код с запасом прочности.

boolean

В CRM — переключатель «вкл / выкл». «Показывать баннер распродажи», «Принимаем заказы».

contentjson
true
Выводtsx
{(block.content ?? false) && <SaleBanner />}

Состояний три, а не два: true, false и null (ещё не задавали). Решите, что null значит на вашем сайте, и запишите это явно через ??.

object — группа полей

В CRM блок-объект выглядит как маленькая форма: несколько именованных полей, которые живут вместе, — карточка контактов, тариф, FAQ с заголовком и списком вопросов.

content — объект, где ключи — маркеры полей. Каждое поле подчиняется правилам своего типа: текстовые поля — карты переводов, ссылки — { url, target, title }, картинки — { file } или { multilang, … }, всё остальное — простые значения. Поле может быть даже списком элементов.

FAQ в виде блока-объектаjson
"faq_section": {
  "type": "object",
  "content": {
    "title": { "en": "FAQ" },
    "items": [
      { "question": { "en": "How long is delivery?" }, "answer": { "en": "1–3 days." } },
      { "question": { "en": "Can I return it?" }, "answer": { "en": "Within 30 days." } }
    ]
  }
}
components/Faq.tsxtsx
import { t, type Block, type LangMap, type ObjectValue, type RenderContext } from '@/lib/diil';

export function Faq(block: Block, { lang, fallback }: RenderContext) {
  if (block.type !== 'object') return null;
  const items = (block.content.items ?? []) as ObjectValue[];

  return (
    <section>
      <h2>{t(block.content.title as LangMap, lang, fallback)}</h2>
      {items.map((item, i) => (
        <details key={i}>
          <summary>{t(item.question as LangMap, lang, fallback)}</summary>
          <p>{t(item.answer as LangMap, lang, fallback)}</p>
        </details>
      ))}
    </section>
  );
}

array — список элементов

В CRM список — это набор повторяющихся элементов с одинаковыми полями: сотрудники, отзывы, карточки преимуществ. Редакторы добавляют и удаляют элементы, а приходят они в том порядке, что в CRM.

content — обычный JSON-массив. Каждый элемент устроен ровно как содержимое блока-объекта: поля по маркерам.

contentjson
[
  {
    "title": { "en": "Noise cancelling" },
    "text": { "en": "Up to 40 dB quieter." },
    "icon": { "file": "https://cdn.example.com/storage/your-site/anc.svg" }
  },
  {
    "title": { "en": "42 hours" },
    "text": { "en": "With the charging case." },
    "icon": { "file": "https://cdn.example.com/storage/your-site/battery.svg" }
  }
]
Выводtsx
<ul className="features">
  {block.content.map((item, i) => (
    <li key={i}>
      <img src={pickMedia(item.icon as Media, lang, defaultLang)} alt="" />
      <h3>{t(item.title as LangMap, lang, defaultLang)}</h3>
      <p>{t(item.text as LangMap, lang, defaultLang)}</p>
    </li>
  ))}
</ul>
  • Элемент — это только его поля, поэтому ключом в React служит индекс (или поле, про которое вы точно знаете, что оно уникально).
  • Пустой список — это []. Решите заранее, прятать ли всю секцию, когда показывать нечего.
  • Хотите, чтобы редакторы правили элементы списка прямо на сайте? Разметьте список атрибутом data-crm-array — подробности в разделе «Правка на сайте».

Шпаргалка по пустым значениям

Причина номер один для вопроса «а почему тут на странице дырка?». Вот как выглядит «ничего»:

СитуацияЧто придёт
Перевод в text или html стёрли"" для этого языка
Язык вообще не заполнялиКлюча языка нет
Картинку или видео для языка ещё не загрузили"" для этого языка
number, color, date, date_range или boolean не заданnull
В списке нет элементов[]

Подставлять другой язык вместо пропущенного API не будет — это решение за вами, а аккуратный способ его принять описан в руководстве «Языки и запасной язык».

Универсальный рендерер для всех типов блоков

Всё, что выше, — в одном файле на TypeScript: типы для каждого блока, помощники t() и pickMedia() и функция renderBlock(), которая умеет все двенадцать типов. Три фрагмента ниже складываются в один файл lib/diil.tsx. Это обычный React, так что подойдёт Next.js, Remix, Vite и всё, что понимает JSX.

Типы

Block — размеченное объединение по полю type. После if (block.type === 'link') TypeScript уже знает, что block.content.url существует, а забытый тип превращается в ошибку компиляции, а не в пустое место на боевом сайте.

lib/diil.tsx — типыts
import type { ReactNode } from 'react';

/** Строка на каждый код языка: { en: 'Hello', de: 'Hallo' } */
export type LangMap = Record<string, string>;

/** Один файл на все языки */
export type SingleMedia = { file: string; multilang?: never };

/** Свой файл на каждый язык; '' — «ещё не загружен» */
export type MultilangMedia = { multilang: true; [lang: string]: string | boolean };

export type Media = SingleMedia | MultilangMedia;

export type LinkValue = { url: string; target: '_self' | '_blank'; title: LangMap };

/** Приходит так, как ввели в CRM: сверьтесь с живым ответом */
export type DateRange = { from?: string; to?: string };

/** Поле внутри блока-объекта или элемента списка */
export type FieldValue =
  | string | number | boolean | null
  | LangMap | Media | LinkValue | DateRange
  | ObjectValue | ObjectValue[];

export type ObjectValue = { [field: string]: FieldValue };

type BlockOf<T extends string, C> = {
  id: number;
  marker: string;
  name: string;
  type: T;
  multilang: boolean;
  updatedAt: string;
  content: C;
};

export type Block =
  | BlockOf<'text', LangMap>
  | BlockOf<'html', LangMap>
  | BlockOf<'image', Media>
  | BlockOf<'video', Media>
  | BlockOf<'link', LinkValue>
  | BlockOf<'number', number | null>
  | BlockOf<'color', string | null>
  | BlockOf<'date', string | null>
  | BlockOf<'date_range', DateRange | null>
  | BlockOf<'boolean', boolean | null>
  | BlockOf<'object', ObjectValue>
  | BlockOf<'array', ObjectValue[]>;

export type RenderContext = {
  lang: string;
  /** Язык сайта по умолчанию: первый из /v1/langs */
  fallback?: string;
  /** Ваши компоненты для объектов и списков, по маркеру блока */
  components?: Record<string, (block: Block, ctx: RenderContext) => ReactNode>;
};

Помощники: t, pickMedia и проверки формы

t() выбирает перевод в строгом порядке: запрошенный язык, потом запасной, потом первое непустое значение, а если нет ничего — пустая строка. pickMedia() делает то же для файлов и прячет разницу между «один файл» и «файл на язык».

lib/diil.tsx — помощникиts
/** Запрошенный язык → запасной → первый непустой перевод → '' */
export function t(map: LangMap | null | undefined, lang: string, fallback?: string): string {
  if (!map) return '';
  const own = map[lang];
  if (own) return own;
  const backup = fallback ? map[fallback] : '';
  if (backup) return backup;
  return Object.values(map).find((value) => value !== '') ?? '';
}

/** Адрес картинки или видео для языка либо '' */
export function pickMedia(media: Media | null | undefined, lang: string, fallback?: string): string {
  if (!media) return '';
  if (!media.multilang) return media.file || '';

  const files: LangMap = {};
  for (const [key, value] of Object.entries(media)) {
    if (key !== 'multilang' && typeof value === 'string') files[key] = value;
  }
  return t(files, lang, fallback);
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null && !Array.isArray(value);

/** Угадываем форму поля внутри объекта: схемы в ответе нет */
export const isLink = (value: unknown): value is LinkValue =>
  isRecord(value) && typeof value.url === 'string' && isRecord(value.title);

export const isMedia = (value: unknown): value is Media =>
  isRecord(value) && (typeof value.file === 'string' || value.multilang === true);

function formatDate(value: string, lang: string): string {
  const date = new Date(value);
  // '2026-10-01' читается как полночь по UTC — и форматируем в UTC,
  // иначе западнее Гринвича покажется предыдущий день
  return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString(lang, { timeZone: 'UTC' });
}

renderBlock()

lib/diil.tsx — renderBlocktsx
export function renderBlock(block: Block, ctx: RenderContext): ReactNode {
  const { lang, fallback } = ctx;

  switch (block.type) {
    case 'text':
      return t(block.content, lang, fallback) || null;

    case 'html': {
      const html = t(block.content, lang, fallback);
      return html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : null;
    }

    case 'image': {
      const src = pickMedia(block.content, lang, fallback);
      // alt — тоже контент: держите его в текстовом блоке рядом с картинкой
      return src ? <img src={src} alt="" /> : null;
    }

    case 'video': {
      const src = pickMedia(block.content, lang, fallback);
      return src ? <video src={src} controls playsInline /> : null;
    }

    case 'link': {
      const { url, target, title } = block.content;
      if (!url) return null;
      return (
        <a href={url} target={target} rel={target === '_blank' ? 'noopener noreferrer' : undefined}>
          {t(title, lang, fallback) || url}
        </a>
      );
    }

    case 'number':
      return block.content === null ? null : new Intl.NumberFormat(lang).format(block.content);

    case 'date':
      return block.content ? formatDate(block.content, lang) : null;

    case 'date_range': {
      const range = block.content;
      if (!range?.from) return null;
      const from = formatDate(range.from, lang);
      return range.to ? from + ' – ' + formatDate(range.to, lang) : from;
    }

    case 'color':
    case 'boolean':
      // Это настройки, а не текст: читайте block.content в стилях и условиях
      return null;

    case 'object':
    case 'array': {
      const render = ctx.components?.[block.marker];
      return render ? render(block, ctx) : null;
    }

    default: {
      // При сборке: все типы учтены. В работе: новый тип просто ничего не выводит
      const unknownBlock: never = block;
      void unknownBlock;
      return null;
    }
  }
}

У составных блоков (object и array) нет универсального вида: у FAQ и сетки сотрудников общего только форма JSON. Поэтому renderBlock() передаёт их вашим собственным компонентам, выбирая по маркеру блока, — как компонент FAQ выше.

Собираем всё вместе

app/[lang]/page.tsxtsx
import { renderBlock, type Block, type RenderContext } from '@/lib/diil';
import { Faq } from '@/components/Faq';

export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
  const { lang } = await params;
  const res = await fetch('https://back.sitecog.com/content/v1/pages/home', {
    headers: { 'x-crm-key': process.env.CRM_KEY! },
    next: { revalidate: 60 },
  });
  const page = await res.json();

  const hero: Record<string, Block> = page.content.hero.content;
  const faq: Record<string, Block> = page.content.faq.content;
  const ctx: RenderContext = {
    lang,
    fallback: 'en', // язык сайта по умолчанию: первый из /v1/langs
    components: { faq_section: Faq },
  };

  return (
    <main>
      <h1>{renderBlock(hero.hero_title, ctx)}</h1>
      {renderBlock(hero.hero_image, ctx)}
      {renderBlock(faq.faq_section, ctx)}
    </main>
  );
}