OutputData: Blok's saved JSON format
The data structure returned by the save() method. Input positions — the `data` config option, `render()`, `blocks.render()`, and `blocks.insertMany()` — also accept the loose wire variants `LooseOutputData` / `LooseOutputBlockData`, where block `data`, `id`, `parent`, `content`, and `time` may be `null`: a `null` `data` becomes `{}`, a `null`/empty `id` gets a generated one, and a `null` `parent` / `null`-or-empty `content` is treated as absent (root-level, childless). Saved output is always the strict shape.
Reaching the editor instance
The methods below run on the editor you created with new Blok(). They are available once editor.isReady resolves.
// 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');Methods
equalsOutputData(a, b, options?)
booleanStructural equality for saved documents, exported from the main entry. Compares the `blocks` arrays deeply; the volatile `time` and `version` envelope fields are ignored, so a document round-tripped through save() compares equal to its echo. Block ids participate only when BOTH sides carry one: the editor mints fresh ids for id-less content, so a legacy document (or a backend that strips ids) still compares equal to its saved echo — no id-stripping wrapper needed on the consumer side. Edit metadata (`lastEditedAt` / `lastEditedBy`) never participates either: it records who touched a block and when, not what it says, so a document whose only delta is a stamp counts as unchanged. Nullish documents and loose wire shapes are accepted — `null`/`undefined` compares equal to `{ blocks: [] }`, and a DTO's `parent: null` / `content: null` equals the saved shape that omits them. The third argument is `EqualsOutputDataOptions` (also exported from the main entry): `ignoreEmptyDefaultBlocks` (default `false`) drops empty blocks of the DEFAULT block tool from both sides before comparing, so a pristine editor holding one empty paragraph equals a saved-empty baseline — the flag to use for dirty-vs-baseline checks. Empty NON-default blocks (a content-less divider, an empty image) are kept.
When to use
Use it to gate persistence or state updates on real changes — time and version differ on every save, so a naive deep-equal always reports a change.
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)
booleanTrue when the document carries no user content, exported from the main entry: it is nullish, has no blocks, or every block's data holds only empty values (blank/whitespace-only strings, empty arrays/objects). Numbers and booleans (`level`, `checked`, styles) are presentation metadata and never count as content on their own.
When to use
Content-less visual blocks (e.g. a divider with data: {}) count as empty — check blocks.length when mere block presence matters.
import { isEmptyOutputData } from '@bloklabs/core';
const data = await editor.save();
submitButton.disabled = isEmptyOutputData(data);
// → true for a fresh editor holding one blank paragraphnormalizeOutputData(data)
OutputDataNormalizes a whole loose backend DTO into the strict saved OutputData shape, exported from the main entry. A nullish document becomes `{ blocks: [] }`; `null` envelope fields (`time`/`version`) are dropped; each block is normalized so `null`/missing `data` becomes `{}`, `null`/empty ids are dropped for regeneration, and nullish/empty hierarchy references (`parent: null`, `content: null`, `content: []`) are dropped as absent. Unlike a hand-written `blocks.map(...)` mapper it preserves every passthrough field — `tunes`, real `parent`/`content` references, `indent`, edit metadata — so hierarchy and tunes are never silently lost. Idempotent: a strict document passes through unchanged.
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` castnormalizeOutputBlocks(blocks)
OutputBlockData[]Block-level counterpart of normalizeOutputData, exported from the main entry: normalizes an array of loose wire blocks into the strict saved shape (`null`/missing `data` → `{}`, `null`/empty `id` dropped for regeneration, nullish/empty `parent`/`content` dropped as absent) while passing every other field through untouched. Use normalizeOutputData when you hold the whole document envelope.
import { normalizeOutputBlocks } from '@bloklabs/core';
const blocks = normalizeOutputBlocks(looseBlocksFromApi);
// → OutputBlockData[] with tunes/parent/content/indent intactBlokData<T>
{ [K in keyof T]: T[K] }A type helper, exported from the main entry, that lets an `interface` block-data shape fit the `data` slot. A TS `interface` has no implicit index signature and is therefore not assignable to `Record<string, unknown>`, so `OutputBlockData<'task', TaskData>` fails to compile when `TaskData` is an interface. `BlokData<T>` re-projects `T` through a homomorphic mapped type, which the compiler does treat as having an implicit index signature, while every declared key keeps its precise type. No rewrite is needed: an existing interface value is assignable to `BlokData<T>`, and a `type` alias already satisfies the slot on its own.
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 }>Turns an ergonomic nested spec into the flat DFS pre-order `OutputBlockData[]` Blok stores, wiring every `parent`/`content` link for you, exported from the main entry alongside its `BlockTreeSpec`, `BlockRunSpec`, `BlockTreeNode` and `FlattenTreeOptions` types. A spec node is `{ type?, data?, tunes?, id?, children? }`; the pure counterpart of the live `blocks.insertTree()` mutation — the same DFS without an editor — so nested content (columns, tables, a whole document) can be seeded without hand-authoring `parent`/`content` id arrays. Every returned block has a resolved `id` (generated when the spec omitted one), so the array is safe to reference by id; leaves omit the empty `content` array. Content that is already flat — a stored Blok document a migration is splicing into a page — goes in as a **run node**, `{ blocks: [...] }`, at the root or as a child: the run is spliced verbatim (ids, `data`, `tunes` and existing `parent`/`content` links are kept) and only the blocks it left un-parented are re-parented onto the enclosing node, the same rule `blocks.insertMarkdown()` applies to a converted run. Because nothing is re-derived, ids stay stable, so a migration can run in batches without duplicating blocks it already wrote. `options` takes `parentId` (the `parent` assigned to the root node(s)) and `generateId` (id generator for nodes without an explicit `id` — pass a deterministic one for reproducible output). Reusing an explicit `id` within the spec throws, and so does passing a pre-flat block as a tree node (its `parent`/`content` links would be dropped silently).
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]>A type guard exported from `@bloklabs/core/tools` that narrows a saved block to a known block type, so its `data` is typed through the `BlokBlockDataMap` registry instead of `Record<string, unknown>` — it replaces the `block.type === 'header'` check plus `data as HeaderData` cast. `BlokBlockDataMap` maps each built-in block type to its data shape and is exported from the same subpath; it is augmentable, so a custom tool registers its own shape by declaration merging and gets narrowed the same way.
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]>>The collection counterpart of `isBlockType`, also exported from `@bloklabs/core/tools`: collects every saved block of a given type from a document with each result's `data` typed through `BlokBlockDataMap`. Null-tolerant — a `null`/`undefined` document, and the loose `LooseOutputData` wire shape, are accepted — so it replaces the `(data?.blocks ?? []).filter(...)` plus cast that every feature re-writes.
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
OutputDataA shared, deeply frozen empty document (`{ blocks: [] }`), exported from the main entry. Use it in place of a hand-written `{ blocks: [] }` literal for cleared/pristine baselines. Frozen (blocks array included) so a shared reference can never be mutated into a stale non-empty baseline.
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 | LooseOutputDataMaps a controlled `data` value to something render()/blocks.render() accepts, exported from the main entry: a whole-document `null` (a controlled "clear to empty") becomes `{ blocks: [] }`; any real document passes through untouched. render()'s strict guard reads `data.blocks` and would throw on `null`, so route a nullable controlled value through this first.
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 }Creates a bounded window of recently emitted onSave payloads for recognizing controlled-`data` echoes, exported from the main entry. Deduping against only the LAST emitted payload is not enough: a host that persists on save and refetches can hand back a STALE echo (an earlier save arriving after a newer one already replaced the baseline), and re-rendering it would clobber the caret and any content typed since. Matching is structural (equalsOutputData), so envelopes reshaped in transit (fresh `time`, stripped ids) still count as echoes.
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[]Migrate legacy / Editor.js-style blocks into Blok's hierarchical flat-with-references format, exported from the `@bloklabs/core/migrate` subpath — the same transform the renderer runs automatically at load. Legacy nested shapes (list items, toggle/callout children) explode into separate blocks linked by `parent`/`content` (`parent` is the saved-document field; `parentId` is the useBlocks BlockNode snapshot field), and id-less blocks are stamped with an id. Already-hierarchical blocks pass through unchanged, so it is safe to run on current data and idempotent across repeated runs. `options` exposes the migration context: `generateId` makes the pass PURE (migrate the same document twice and the outputs are equal — needed to compare a stored doc against its migration, or to re-run migration per render without minting fresh ids), `onLossyField` delivers every dropped field instead of dumping it to `console.warn`, and `rules` adds your own grammar entries. `migrateLegacyOutputData(data, options?)` is the envelope-preserving variant; `needsLegacyMigration(blocks, options?)` reports whether a migration would change anything; `matchLegacyRule(block, options?)` is the per-block primitive that returns the entry claiming a single block (or `null`) without re-scanning the table for every block. Every rules-taking entry point accepts either the options object or a bare `rules` array, so passing the array directly can't silently read as "no rules".
When to use
For a data shape only a specific tool understands (a columns layout, a custom media envelope) that core's built-in migration can't read, give that tool a static upgradeData(data) — a pure function returning the tool's current data shape. Blok runs it at load, while composing each stored block, before the tool is constructed; a hook that throws is caught and the block loads with its stored data.
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, … } | nullmigrations (config) & migrateOutputData(data, migrations)
OutputDataDeclare per-type "old data shape → new data shape" rules from OUTSIDE the tool class. Where `upgradeData` must live inside a tool you own, `migrations` is a map keyed by block type you pass in editor config — so you can migrate a third-party tool you don't control, or your own tool without editing (and re-shipping) its class. Each rule is a pure `(data) => data` transform (return the input unchanged, or `undefined`, when already current). Blok applies it at load, after the tool's own `upgradeData` and BEFORE format analysis — so `dataModel: 'auto'` sees the post-migration shape and an 'auto' round-trip can't quietly undo the migration by saving the old shape back. A throwing rule falls back to the stored data (never a blank editor). The same map works offline: pass it to `migrateOutputData(data, migrations)` (or `migrateBlocks(blocks, migrations)`) from `@bloklabs/core/migrate` to batch-upgrade persisted records without opening an editor. Available on all three framework adapters as the `migrations` prop/input.
When to use
Rules must be pure and idempotent — they run on every load, including on already-current data. Prefer migrations (config) for shapes a host decides from the outside; prefer a tool's own upgradeData for shapes only that tool understands. They compose: upgradeData runs first, then the config migrations rule for that type.
// 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 }The composed entry point: runs BOTH migration passes in the one correct order and reports what the migration cost. Data rules (`migrations`) run first, then grammar rules (`rules`) restructure the tree. That order is load-bearing: data rules are keyed by block TYPE, and the grammar rewrites types (`linkTool` → `bookmark`) and explodes containers into many blocks — so a rule run after the grammar never fires, and the block stays silently unmigrated. Data rules shape the grammar's input; the grammar owns the output shape for the types it rewrites. The `report` names every field the mapping could not carry over (`lossyFields`) and every data rule that threw (`errors`), so a batch upgrade of persisted records is no longer silent about its own data loss.
When to use
report.lossyFields is what console.warn used to say and nothing could read. Log it next to a batch upgrade and you get an auditable record of exactly what the upgrade dropped, per block type.
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 datarules (custom legacy grammar entries)
LegacyGrammarEntry[]Teach the migration machinery a legacy shape Blok doesn't know. A grammar entry is `{ legacyType, detect, expand, targetType, cardinality, contributesNesting, lossyFields, docNote }`; passing entries via `rules` reuses the whole interpreter — recursion into container bodies, the orphan re-parenting invariant, 1:N splits, id minting — instead of re-implementing the dispatch loop around a data-only rule. Host entries are matched BEFORE the built-in table, so they can also override a built-in mapping. Unlike a `migrations` rule, an entry may change a block's `type` and emit several blocks. `expand(block, ctx, { siblings, index })` may also return `{ blocks, consumed }` to absorb the following `consumed` siblings — the shape flat-with-count legacy formats need (a container storing its body as "the next N blocks"); `consumed` is clamped to what remains, so a truncated document can't over-consume. Read `LEGACY_GRAMMAR` to introspect the built-in coverage.
When to use
A container rule whose body is stored as a COUNT of following siblings returns { blocks, consumed } — the interpreter skips exactly that many siblings, so the children are re-parented once and never emitted twice.
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]);// 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
| Property | Description | |
|---|---|---|
version | string (optional) | Editor version |
time | number (optional) | Timestamp of save |
blocks | OutputBlockData[] | Array of block data |