Перейти к содержимому
ФреймворкJavaScript

OutputData: формат сохранённого JSON

Структура данных, возвращаемая методом save(). Во входных позициях — опция конфигурации `data`, `render()`, `blocks.render()` и `blocks.insertMany()` — также принимаются нестрогие варианты `LooseOutputData` / `LooseOutputBlockData`, где `data`, `id`, `parent`, `content` и `time` блока могут быть `null`: `null` в `data` становится `{}`, `null`/пустой `id` заменяется сгенерированным, а `null` в `parent` и `null`/пустой массив в `content` считаются отсутствующими (блок корневой и без детей). Сохранённый вывод всегда имеет строгую форму.

Как получить экземпляр редактора

Методы ниже вызываются на редакторе, созданном через new Blok(). Они доступны после того, как разрешится editor.isReady.

TypeScript
// You already hold the instance returned by the constructor.
const editor = new Blok({ holder: 'editor' });
await editor.isReady;

// Call any API method on it.
editor.caret.setToLastBlock('end');

Методы

equalsOutputData(a, b, options?)

boolean

Структурное равенство сохранённых документов, экспортируется из главной точки входа. Глубоко сравнивает массивы `blocks`; изменчивые поля-конверты `time` и `version` игнорируются, поэтому документ, прошедший через save(), равен своему эху. Идентификаторы блоков участвуют в сравнении, только когда они есть у ОБЕИХ сторон: редактор выдаёт свежие id контенту без идентификаторов, поэтому legacy-документ (или бэкенд, вырезающий id) всё равно равен своему сохранённому эху — обёртка, снимающая id, на стороне потребителя не нужна. Метаданные правки (`lastEditedAt` / `lastEditedBy`) тоже не участвуют: они фиксируют, кто и когда тронул блок, а не что в нём написано, поэтому документ, отличающийся только меткой, считается неизменённым. Принимаются nullish-документы и нестрогие форматы — `null`/`undefined` равны `{ blocks: [] }`, а `parent: null` / `content: null` из DTO равны сохранённой форме, где этих ключей нет. Третий аргумент — `EqualsOutputDataOptions` (тоже экспортируется из главной точки входа): `ignoreEmptyDefaultBlocks` (по умолчанию `false`) убирает с обеих сторон пустые блоки инструмента ПО УМОЛЧАНИЮ до сравнения, поэтому нетронутый редактор с одним пустым параграфом равен пустому сохранённому эталону — этот флаг и нужен для проверок «изменено или нет». Пустые НЕ-дефолтные блоки (разделитель без содержимого, пустое изображение) сохраняются.

Когда использовать

Используйте, чтобы сохранять или обновлять состояние только при реальных изменениях — time и version различаются при каждом save(), поэтому наивное глубокое сравнение всегда сообщает об изменении.

TypeScript
import { equalsOutputData } from '@bloklabs/core';

const saved = await editor.save();
if (!equalsOutputData(saved, previousData)) {
  await persist(saved); // only hit the backend on real changes
}

isEmptyOutputData(data)

boolean

True, когда документ не содержит пользовательского контента, экспортируется из главной точки входа: он nullish, не имеет блоков, либо данные каждого блока содержат только пустые значения (пустые строки и строки из пробелов, пустые массивы/объекты). Числа и булевы значения (`level`, `checked`, стили) — презентационные метаданные и сами по себе контентом не считаются.

Когда использовать

Визуальные блоки без контента (например, разделитель с data: {}) считаются пустыми — проверяйте blocks.length, когда важно само наличие блоков.

TypeScript
import { isEmptyOutputData } from '@bloklabs/core';

const data = await editor.save();
submitButton.disabled = isEmptyOutputData(data);
// → true for a fresh editor holding one blank paragraph

normalizeOutputData(data)

OutputData

Нормализует целый нестрогий backend-DTO в строгую сохранённую форму OutputData, экспортируется из главной точки входа. Nullish-документ становится `{ blocks: [] }`; поля-конверты со значением `null` (`time`/`version`) отбрасываются; каждый блок нормализуется так, что `null` или отсутствующий `data` становится `{}`, `null`/пустые id отбрасываются ради перегенерации, а nullish/пустые ссылки иерархии (`parent: null`, `content: null`, `content: []`) отбрасываются как отсутствующие. В отличие от написанного вручную маппера `blocks.map(...)`, он сохраняет каждое сквозное поле — `tunes`, настоящие ссылки `parent`/`content`, `indent`, метаданные правки, — поэтому иерархия и тюны никогда не теряются молча. Идемпотентно: строгий документ проходит насквозь без изменений.

TypeScript
import { normalizeOutputData } from '@bloklabs/core';

// A loose Editor.js-era DTO (data: null, id: null) from your backend
const strict = normalizeOutputData(dtoFromApi);
// → strict OutputData, safe to persist or diff — no blind `as OutputData` cast

normalizeOutputBlocks(blocks)

OutputBlockData[]

Аналог normalizeOutputData на уровне блоков, экспортируется из главной точки входа: нормализует массив нестрогих блоков в транспортном формате в строгую сохранённую форму (`null`/отсутствующий `data` → `{}`, `null`/пустой `id` отбрасывается ради перегенерации, nullish/пустые `parent`/`content` отбрасываются как отсутствующие), пропуская все остальные поля нетронутыми. Используйте normalizeOutputData, когда у вас на руках весь конверт документа.

TypeScript
import { normalizeOutputBlocks } from '@bloklabs/core';

const blocks = normalizeOutputBlocks(looseBlocksFromApi);
// → OutputBlockData[] with tunes/parent/content/indent intact

BlokData<T>

{ [K in keyof T]: T[K] }

Хелпер типов, экспортируется из главной точки входа; позволяет форме данных блока, объявленной через `interface`, подойти в слот `data`. У `interface` в TS нет неявной индексной сигнатуры, и поэтому он не присваивается типу `Record<string, unknown>`, так что `OutputBlockData<'task', TaskData>` не компилируется, когда `TaskData` — интерфейс. `BlokData<T>` перепроецирует `T` через гомоморфный отображённый тип, который компилятор всё же считает имеющим неявную индексную сигнатуру, при этом каждый объявленный ключ сохраняет свой точный тип. Переписывать ничего не нужно: существующее значение интерфейса присваивается типу `BlokData<T>`, а псевдоним `type` и так удовлетворяет слоту сам по себе.

TypeScript
import type { BlokData, OutputBlockData } from '@bloklabs/core';

interface TaskData { title: string; done: boolean }

const block: OutputBlockData<'task', BlokData<TaskData>> = {
  type: 'task',
  data: { title: 'Ship it', done: false },
};

flattenTree(spec, options?)

Array<OutputBlockData & { id: string }>

Превращает эргономичную вложенную спецификацию в плоский `OutputBlockData[]` в порядке DFS pre-order, который хранит Blok, попутно связывая за вас каждую ссылку `parent`/`content`; экспортируется из главной точки входа вместе со своими типами `BlockTreeSpec`, `BlockRunSpec`, `BlockTreeNode` и `FlattenTreeOptions`. Узел спецификации — это `{ type?, data?, tunes?, id?, children? }`; чистый аналог живой мутации `blocks.insertTree()` — тот же DFS, но без редактора, — поэтому вложенный контент (колонки, таблицы, целый документ) можно засеять, не выписывая вручную массивы id в `parent`/`content`. У каждого возвращённого блока есть разрешённый `id` (генерируется, когда спецификация его не задала), поэтому на массив безопасно ссылаться по id; листья опускают пустой массив `content`. Контент, который уже плоский — сохранённый документ Blok, который миграция вклеивает в страницу, — попадает внутрь как **узел-прогон**, `{ blocks: [...] }`, в корне или в качестве дочернего: прогон вклеивается дословно (id, `data`, `tunes` и существующие ссылки `parent`/`content` сохраняются), и только те блоки, которые он оставил без родителя, переподчиняются охватывающему узлу — то же правило, которое `blocks.insertMarkdown()` применяет к преобразованному прогону. Поскольку ничего не выводится заново, id остаются стабильными, поэтому миграция может выполняться пакетами, не дублируя уже записанные блоки. `options` принимает `parentId` (значение `parent`, назначаемое корневому узлу или узлам) и `generateId` (генератор id для узлов без явного `id` — передайте детерминированный ради воспроизводимого вывода). Повторное использование явного `id` внутри спецификации выбрасывает исключение, как и передача уже плоского блока в качестве узла дерева (его ссылки `parent`/`content` были бы отброшены молча).

TypeScript
import { flattenTree } from '@bloklabs/core';

// A two-column layout, written as a tree instead of parent/content id arrays
const blocks = flattenTree([
  {
    type: 'column_list',
    children: [
      { type: 'column', children: [{ type: 'paragraph', data: { text: 'Left' } }] },
      { type: 'column', children: [{ type: 'paragraph', data: { text: 'Right' } }] },
    ],
  },
]);

// Ready to hand to the `data` config option, render() or blocks.insertMany()
console.log(blocks); // flat, DFS pre-order, every parent/content link wired

// An already-flat saved document goes in as a run node — spliced verbatim,
// ids kept, only its top-level blocks re-parented under the column
const migrated = flattenTree({
  type: 'column',
  children: [{ blocks: legacyPage.blocks }],
});

isBlockType(block, type)

block is OutputBlockData<K, BlokBlockDataMap[K]>

Предикат типа, экспортируемый из `@bloklabs/core/tools`, который сужает сохранённый блок до известного типа блока, так что его `data` типизируется через реестр `BlokBlockDataMap`, а не как `Record<string, unknown>` — он заменяет проверку `block.type === 'header'` вместе с приведением `data as HeaderData`. `BlokBlockDataMap` сопоставляет каждому встроенному типу блока его форму данных и экспортируется из того же подпути; он расширяем, поэтому пользовательский инструмент регистрирует собственную форму через слияние деклараций и сужается точно так же.

TypeScript
import { isBlockType } from '@bloklabs/core/tools';
import type { OutputData } from '@bloklabs/core';

function logHeadings(saved: OutputData) {
  for (const block of saved.blocks) {
    if (isBlockType(block, 'header')) {
      console.log(block.data.level); // number — no cast
    }
  }
}

// A custom tool joins the registry by declaration merging
declare module '@bloklabs/core/tools' {
  interface BlokBlockDataMap {
    'my-widget': { widgetId: string };
  }
}

blocksOfType(data, type)

Array<OutputBlockData<K, BlokBlockDataMap[K]>>

Коллекционный аналог `isBlockType`, тоже экспортируется из `@bloklabs/core/tools`: собирает из документа каждый сохранённый блок заданного типа, причём `data` каждого результата типизируется через `BlokBlockDataMap`. Допускает null — документ `null`/`undefined` и нестрогая транспортная форма `LooseOutputData` принимаются — поэтому он заменяет `(data?.blocks ?? []).filter(...)` вместе с приведением типа, которое каждая фича пишет заново.

TypeScript
import { blocksOfType } from '@bloklabs/core/tools';
import type { OutputData } from '@bloklabs/core';

// `saved` may be null — blocksOfType tolerates it and returns []
function buildToc(saved: OutputData | null) {
  return blocksOfType(saved, 'header')
    // data.text / data.level are typed — no cast
    .map((block) => ({ text: block.data.text, level: block.data.level }));
}

EMPTY_OUTPUT_DATA

OutputData

Общий, глубоко замороженный пустой документ (`{ blocks: [] }`), экспортируется из главной точки входа. Используйте его вместо написанного вручную литерала `{ blocks: [] }` для очищенных и нетронутых эталонов. Заморожен (включая массив блоков), поэтому общая ссылка никогда не может быть изменена в устаревший непустой эталон.

TypeScript
import { EMPTY_OUTPUT_DATA, equalsOutputData } from '@bloklabs/core';

const saved = await editor.save();
const isPristine = equalsOutputData(saved, EMPTY_OUTPUT_DATA, {
  ignoreEmptyDefaultBlocks: true,
});

toRenderableData(data)

OutputData | LooseOutputData

Отображает контролируемое значение `data` в то, что принимают render()/blocks.render(); экспортируется из главной точки входа: `null` вместо документа целиком (контролируемая «очистка до пустого») становится `{ blocks: [] }`; любой настоящий документ проходит нетронутым. Строгая проверка render() читает `data.blocks` и выбросила бы исключение на `null`, поэтому сначала пропустите nullable-значение контролируемого поля через эту функцию.

TypeScript
import { toRenderableData } from '@bloklabs/core';

// `draft` may be null when the host clears the document
await editor.blocks.render(toRenderableData(draft));

createEmittedEchoWindow(capacity?)

{ record; matches; clear }

Создаёт ограниченное окно недавно отправленных полезных нагрузок onSave для распознавания эха контролируемого `data`, экспортируется из главной точки входа. Дедупликации только по ПОСЛЕДНЕЙ отправленной нагрузке недостаточно: хост, который сохраняет данные при save и перезапрашивает их, может вернуть УСТАРЕВШЕЕ эхо (более ранний save приходит после того, как более новый уже заменил эталон), и его повторная отрисовка затёрла бы курсор и весь набранный с тех пор контент. Сопоставление структурное (equalsOutputData), поэтому конверты, изменённые по пути (свежий `time`, вырезанные id), всё равно считаются эхом.

TypeScript
import { createEmittedEchoWindow } from '@bloklabs/core';

const echoes = createEmittedEchoWindow();
// in onSave: echoes.record(data)
// before re-rendering incoming props: if (echoes.matches(next)) return;

migrateLegacyBlocks(blocks, options?)

OutputBlockData[]

Перевести legacy-блоки / блоки в стиле Editor.js в иерархический формат Blok «плоский со ссылками», экспортируется из подпути `@bloklabs/core/migrate` — то же преобразование, которое рендерер выполняет автоматически при загрузке. Вложенные legacy-формы (пункты списка, дочерние блоки тоггла и выноски) разворачиваются в отдельные блоки, связанные через `parent`/`content` (`parent` — поле сохранённого документа; `parentId` — поле снимка BlockNode из useBlocks), а блокам без id проставляется id. Уже иерархические блоки проходят без изменений, поэтому запуск на текущих данных безопасен и идемпотентен при повторных прогонах. `options` открывает контекст миграции: `generateId` делает проход ЧИСТЫМ (мигрируйте один и тот же документ дважды — выводы будут равны; это нужно, чтобы сравнить сохранённый документ с его миграцией или перезапускать миграцию на каждый рендер, не выпуская свежие id), `onLossyField` доставляет каждое отброшенное поле вместо сброса его в `console.warn`, а `rules` добавляет ваши собственные записи грамматики. `migrateLegacyOutputData(data, options?)` — вариант, сохраняющий конверт; `needsLegacyMigration(blocks, options?)` сообщает, изменит ли миграция хоть что-нибудь; `matchLegacyRule(block, options?)` — примитив на один блок, который возвращает запись, претендующую на этот блок (или `null`), не пересканируя таблицу для каждого блока. Каждая точка входа, принимающая правила, принимает либо объект опций, либо голый массив `rules`, поэтому передача массива напрямую не может молча прочитаться как «нет правил».

Когда использовать

Для формы данных, понятной только конкретному инструменту (раскладка колонок, собственный конверт медиа), которую встроенная миграция ядра прочитать не может, задайте этому инструменту статический upgradeData(data) — чистую функцию, возвращающую текущую форму данных инструмента. Blok выполняет её при загрузке, во время сборки каждого сохранённого блока, до того как инструмент будет сконструирован; хук, выбросивший исключение, перехватывается, и блок загружается со своими сохранёнными данными.

TypeScript
import {
  migrateLegacyBlocks,
  migrateLegacyOutputData,
  needsLegacyMigration,
  matchLegacyRule,
} from '@bloklabs/core/migrate';

// Batch-upgrade persisted Editor.js documents
const upgraded = migrateLegacyOutputData(storedDocument);

// Or migrate just the blocks, skipping the pass when already current
const blocks = needsLegacyMigration(stored.blocks)
  ? migrateLegacyBlocks(stored.blocks)
  : stored.blocks;

// Deterministic migration: same input → equal output, every time
let n = 0;
const pure = migrateLegacyBlocks(stored.blocks, {
  generateId: () => `blk-${n++}`,
  onLossyField: ({ blockType, field }) => report(blockType, field),
});

// Dispatch per block without allocating a throwaway array
const entry = matchLegacyRule(stored.blocks[0]); // → { legacyType, targetType, … } | null

migrations (config) & migrateOutputData(data, migrations)

OutputData

Объявить правила «старая форма данных → новая форма данных» по типам СНАРУЖИ класса инструмента. Тогда как `upgradeData` должен жить внутри инструмента, которым вы владеете, `migrations` — это карта с ключами по типу блока, которую вы передаёте в конфигурации редактора, — поэтому вы можете мигрировать сторонний инструмент, который вам не подконтролен, или свой собственный инструмент, не редактируя (и не перевыпуская) его класс. Каждое правило — чистое преобразование `(data) => data` (верните вход без изменений или `undefined`, когда данные уже актуальны). Blok применяет его при загрузке, после собственного `upgradeData` инструмента и ДО анализа формата, — поэтому `dataModel: 'auto'` видит форму после миграции, и цикл в режиме 'auto' не может тихо откатить миграцию, сохранив старую форму обратно. Правило, выбросившее исключение, откатывается к сохранённым данным (никогда не к пустому редактору). Та же карта работает офлайн: передайте её в `migrateOutputData(data, migrations)` (или `migrateBlocks(blocks, migrations)`) из `@bloklabs/core/migrate`, чтобы пакетно обновить сохранённые записи, не открывая редактор. Доступна во всех трёх адаптерах фреймворков как проп/инпут `migrations`.

Когда использовать

Правила должны быть чистыми и идемпотентными — они выполняются при каждой загрузке, в том числе на уже актуальных данных. Предпочитайте migrations (конфигурация) для форм, которые хост решает снаружи; предпочитайте собственный upgradeData инструмента для форм, понятных только этому инструменту. Они сочетаются: сначала выполняется upgradeData, затем правило migrations из конфигурации для этого типа.

TypeScript
// 1. At load, via editor config
new Blok({
  tools: { myCard: MyCard },
  migrations: {
    // key = block type; old shape → new shape
    myCard: (data) => ('name' in data ? { ...data, title: data.name } : data),
    // `data` is BlockToolData (Record<string, unknown>), so narrow before reading
    image: (data) => {
      const file = data.file as { url?: string } | undefined;

      return file?.url ? { ...data, url: file.url } : data;
    },
  },
});

// 2. Offline / batch — same rules, no editor
import { migrateOutputData } from '@bloklabs/core/migrate';

const upgraded = migrateOutputData(storedDocument, {
  myCard: (data) => ({ ...data, title: data.name }),
});

migrate(data, { migrations, rules, generateId, onLossyField })

{ data: OutputData; report: MigrationReport }

Составная точка входа: выполняет ОБА прохода миграции в единственно верном порядке и сообщает, во что миграция обошлась. Сначала выполняются правила данных (`migrations`), затем правила грамматики (`rules`) перестраивают дерево. Этот порядок принципиально важен: правила данных заданы по ТИПУ блока, а грамматика переписывает типы (`linkTool` → `bookmark`) и разворачивает контейнеры во множество блоков — поэтому правило, запущенное после грамматики, никогда не срабатывает, и блок молча остаётся немигрированным. Правила данных формируют вход грамматики; за форму вывода для типов, которые она переписывает, отвечает грамматика. `report` называет каждое поле, которое сопоставление не смогло перенести (`lossyFields`), и каждое правило данных, выбросившее исключение (`errors`), поэтому пакетное обновление сохранённых записей больше не молчит о собственной потере данных.

Когда использовать

report.lossyFields — это то, что раньше говорил console.warn и что ничем нельзя было прочитать. Логируйте его рядом с пакетным обновлением — и вы получите проверяемую запись о том, что именно обновление отбросило, по типам блоков.

TypeScript
import { migrate } from '@bloklabs/core/migrate';

let n = 0;
const { data, report } = migrate(storedDocument, {
  // 1. data rules — old data shape → new data shape, by block type
  migrations: {
    myCard: (d) => ('name' in d ? { ...d, title: d.name } : d),
  },
  // 2. grammar rules — structural: type changes, 1:N splits, sibling absorption
  rules: [alertRule],
  generateId: () => `blk-${n++}`,
});

report.lossyFields; // [{ blockType: 'linkTool', field: 'meta.site_name', verb: 'dropped' }]
report.errors;      // [{ type: 'myCard', error }] — that block kept its stored data

rules (custom legacy grammar entries)

LegacyGrammarEntry[]

Научить механизм миграции legacy-форме, которую Blok не знает. Запись грамматики — это `{ legacyType, detect, expand, targetType, cardinality, contributesNesting, lossyFields, docNote }`; передача записей через `rules` переиспользует весь интерпретатор — рекурсию в тела контейнеров, инвариант переподчинения сирот, разбиения 1:N, выпуск id — вместо повторной реализации цикла диспетчеризации вокруг правила, работающего только с данными. Записи хоста сопоставляются ДО встроенной таблицы, поэтому ими можно ещё и переопределить встроенное сопоставление. В отличие от правила `migrations`, запись может изменить `type` блока и выдать несколько блоков. `expand(block, ctx, { siblings, index })` может также вернуть `{ blocks, consumed }`, чтобы поглотить следующие `consumed` соседних блоков — форма, которая нужна legacy-форматам «плоский со счётчиком» (контейнер хранит своё тело как «следующие N блоков»); `consumed` ограничивается тем, что осталось, поэтому усечённый документ не может поглотить лишнего. Прочитайте `LEGACY_GRAMMAR`, чтобы изучить встроенное покрытие.

Когда использовать

Правило контейнера, тело которого хранится как КОЛИЧЕСТВО следующих соседних блоков, возвращает { blocks, consumed } — интерпретатор пропускает ровно столько соседей, поэтому дочерние блоки переподчиняются один раз и никогда не выдаются дважды.

TypeScript
import { migrate, LEGACY_GRAMMAR, type LegacyGrammarEntry } from '@bloklabs/core/migrate';

// A legacy `alert` → callout + child paragraph (type change AND a 1:N split).
// The annotation is load-bearing: without it `cardinality` widens to `string`
// and `detect`/`expand` lose their contextual parameter types.
const alertRule: LegacyGrammarEntry = {
  legacyType: 'alert',
  targetType: 'callout',
  cardinality: '1:N',
  contributesNesting: true,
  lossyFields: [],
  docNote: '`alert` → `callout` + message paragraph.',
  detect: (block) => block.type === 'alert',
  expand: (block, ctx) => {
    const calloutId = block.id ?? ctx.generateId();
    const childId = ctx.generateId();

    return [
      { id: calloutId, type: 'callout', data: { emoji: '🚨' }, content: [childId] },
      { id: childId, type: 'paragraph', data: { text: block.data.message }, parent: calloutId },
    ];
  },
};

const { data } = migrate(storedDocument, { rules: [alertRule] });

// What does Blok migrate out of the box?
LEGACY_GRAMMAR.map((entry) => [entry.legacyType, entry.targetType, entry.lossyFields]);
TypeScript
// Save editor content
const data = await editor.save();

// Result structure:
interface OutputData {
  version?: string;    // Editor version
  time?: number;       // Save timestamp
  blocks: OutputBlockData[]; // Array of block data
}

// Example output:
{
  "version": "1.13.0",
  "time": 1704067200000,
  "blocks": [
    {
      "id": "p6QK0Xz1Ab",
      "type": "paragraph",
      "data": { "text": "Hello, world!" }
    },
    {
      "id": "hM3lTn9RdC",
      "type": "header",
      "data": { "text": "Title", "level": 2 }
    }
  ]
}

OutputData

СвойствоОписание
versionstring (optional)Версия редактора
timenumber (optional)Метка времени сохранения
blocksOutputBlockData[]Массив данных блоков