OutputData: Blok's saved JSON format
The data structure returned by the save() method. Input positions also accept the loose wire variants LooseOutputData / LooseOutputBlockData. Those positions are the data config option, render(), blocks.render(), and blocks.insertMany().
In the loose variants a block's data, id, parent, content, and time may be null.
- A
nulldatabecomes{}. - A
nullor emptyidgets a generated one. - A
nullparentand anullor emptycontentare treated as absent, so the block is root-level and childless.
Saved output is always the strict shape.
When a document loads, Blok puts the blocks array in depth-first order: each block comes right before its whole subtree. That happens for the data config option, render() and blocks.render(). blocks.insertMany() keeps the order it is given.
- Root blocks keep their input order. A block whose
parentis not in the document counts as a root block. - A parent's children follow it in the order its
contentlists. - Children that
contentdoes not list come after those, in input order. They are also added to the end ofcontent, except in a table or a database.
So if stored data is not in that order, save() returns the blocks in a different order than they came in. A document with duplicate ids is loaded as it is.
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. It compares the blocks arrays deeply.
- The volatile
timeandversionenvelope fields are ignored, so a document round-tripped through save() compares equal to its echo. - Block ids count 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. You need no id-stripping wrapper on the consumer side.
- Edit metadata (
lastEditedAt/lastEditedBy) never counts 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/undefinedcompares equal to{ blocks: [] }, and a DTO'sparent: null/content: nullequals the saved shape that omits them.
The third argument is EqualsOutputDataOptions, also exported from the main entry. Its ignoreEmptyDefaultBlocks option (default false) drops empty blocks of the DEFAULT block tool from both sides before comparing.
A pristine editor holding one empty paragraph then equals a saved-empty baseline, which is 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.
That means the document is nullish, has no blocks, or every block's data holds only empty values: blank or whitespace-only strings, empty arrays and objects. Numbers and booleans (level, checked, styles) are presentation metadata. On their own they never count as content.
When to use
Content-less visual blocks (for example 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: [] }. nullenvelope fields (time/version) are dropped.- Each block is normalized too.
- A
nullor missingdatabecomes{}. - A
nullor empty id is dropped, so a new one is generated. - Nullish or empty hierarchy references (
parent: null,content: null,content: []) are dropped as absent.
- A
Unlike a hand-written blocks.map(...) mapper, it keeps every passthrough field: tunes, real parent/content references, indent, and edit metadata. So hierarchy and tunes are never silently lost. It is 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. It normalizes an array of loose wire blocks into the strict saved shape.
A null or missing data becomes {}, a null or empty id is dropped so a new one is generated, and nullish or empty parent/content is dropped as absent. Every other field passes 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. It lets a block-data shape declared with an interface fit the data slot.
A TS interface has no implicit index signature, so it is not assignable to Record<string, unknown>. That is why OutputBlockData<'task', TaskData> fails to compile when TaskData is an interface.
BlokData<T> re-projects T through a homomorphic mapped type. The compiler does treat that as having an implicit index signature, and every declared key keeps its precise type. You need no rewrite: 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[] that Blok stores. It wires every parent/content link for you.
Exported from the main entry, along with its BlockTreeSpec, BlockRunSpec, BlockTreeNode and FlattenTreeOptions types. A spec node is { type?, data?, tunes?, id?, children? }.
It is the pure counterpart of the live blocks.insertTree() mutation: the same DFS without an editor. So you can seed nested content (columns, tables, a whole document) 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 goes in as a run node, { blocks: [...] }, at the root or as a child. A stored Blok document that a migration is splicing into a page is the usual case.
- The run is spliced verbatim: ids,
data,tunesand existingparent/contentlinks are kept. - Only the blocks it left un-parented are re-parented onto the enclosing node.
That is 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 or nodes, and generateId, the id generator for nodes without an explicit id. Pass a deterministic generator for reproducible output.
Reusing an explicit id within the spec throws, and so does passing a pre-flat block as a tree node, because 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. It 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 the 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.
The types describe documents saved by this version, or converted with migrateToRichText. A document stored earlier can still hold HTML strings in rich text fields. Convert it before a typed read.
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. It collects every saved block of a given type from a document, and each result's data is typed through BlokBlockDataMap.
It tolerates null: a null or undefined document is accepted, and so is the loose LooseOutputData wire shape. So it replaces the (data?.blocks ?? []).filter(...) plus cast that every feature re-writes.
The types describe documents saved by this version, or converted with migrateToRichText. A document stored earlier can still hold HTML strings in rich text fields. Convert it before a typed read.
import { blocksOfType } from '@bloklabs/core/tools';
import { migrateToRichText, richTextToPlainText } from '@bloklabs/core/migrate';
import type { OutputData } from '@bloklabs/core';
// `stored` may be null — blocksOfType tolerates it and returns []
function buildToc(stored: OutputData | null) {
// A document saved by an older version can still hold HTML strings
const saved = stored === null ? null : migrateToRichText(stored);
return blocksOfType(saved, 'header')
// data.text is RichText, data.level is number — no cast
.map((block) => ({ text: richTextToPlainText(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 or pristine baselines. It is 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, which is a controlled "clear to empty", becomes { blocks: [] }. Any real document passes through untouched.
The strict guard in render() 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, so you can recognize 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. Re-rendering that echo 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 or Editor.js-style blocks into Blok's hierarchical flat-with-references format, exported from the @bloklabs/core/migrate subpath. It is the same transform the renderer runs automatically at load.
Legacy nested shapes (list items, toggle and callout children) explode into separate blocks linked by parent/content. parent is the saved-document field; parentId is the useBlocks BlockNode snapshot field.
Blocks with no id are stamped with one. 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.
generateIdmakes the pass PURE: migrate the same document twice and the outputs are equal.- You need that to compare a stored doc against its migration, or to re-run migration per render without minting fresh ids.
onLossyFielddelivers every dropped field instead of dumping it toconsole.warn.rulesadds 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: it 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
Some data shapes only a specific tool understands (a columns layout, a custom media envelope), and core's built-in migration can't read them. Give that tool a static upgradeData(data), a pure function that returns 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. upgradeData must live inside a tool you own. migrations is a map keyed by block type that 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 the data is already current.
Blok applies the rule at load, after the tool's own upgradeData and BEFORE format analysis. That way 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 rule that throws 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. All three framework adapters take it as the migrations prop or 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
upgradeDatafor 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. It 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, and id minting. You do not re-implement 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. That is the shape flat-with-count legacy formats need, where a container stores 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]);migrateToRichText(data, options?)
OutputDataConverts the HTML rich text fields of a stored document to segments, the shape save() returns. It is exported from @bloklabs/core/migrate and runs in Node without a DOM.
The editor converts an old document on its next save. Until then a database can hold both shapes. Run this to convert stored documents in bulk, and run it before typed reads such as blocksOfType.
Only built-in block types are converted. A custom tool's fields are left as they are. Legacy Editor.js shapes are converted too, so migrate() can run before or after it. Fields that already hold segments pass through, so running it twice is safe.
onLossy hears about markup that has no plain mark.
html-embed: markup kept verbatim as an{ embed: { html } }segment.custom-mark: an unknown tag kept as atag:<name>mark.
richTextToHtml(rich) turns segments back into canonical HTML. The editor can save a different string for the same segments. On render it maps a raw colour to the nearest preset and can add target and rel to a link.
richTextToHtml does not sanitize. Segments can come from collaborators, the server or a host. To display them, use blocksToHtml from @bloklabs/core/view, which sanitizes.
richTextToPlainText(rich) returns the plain text.
import { migrateToRichText, richTextToHtml, richTextToPlainText } from '@bloklabs/core/migrate';
const converted = migrateToRichText(storedDocument, {
onLossy: ({ blockType, field, reason }) => report(blockType, field, reason),
});
richTextToHtml([{ text: 'Hi ' }, { text: 'there', marks: { bold: true } }]); // 'Hi <strong>there</strong>'
richTextToPlainText([{ text: 'Hi ' }, { text: 'there', marks: { bold: true } }]); // 'Hi there'// Save editor content
const data = await editor.save();
// Result structure:
interface OutputData {
id?: string; // Document id, used by tab sync
version?: string; // Editor version
time?: number; // Save timestamp
blocks: OutputBlockData[]; // Array of block data
}
// Example output:
{
"version": "1.16.0",
"time": 1704067200000,
"blocks": [
{
"id": "p6QK0Xz1Ab",
"type": "paragraph",
"data": { "text": [{ "text": "Hello, world!" }] }
},
{
"id": "hM3lTn9RdC",
"type": "header",
"data": { "text": [{ "text": "Title" }], "level": 2 }
}
]
}OutputData
| Property | Description | |
|---|---|---|
id | string (optional) | The document's id. Blok writes one on the first save when the loaded document has none, and keeps it. Tab sync uses it to find other tabs showing this document. |
version | string (optional) | Editor version |
time | number (optional) | Timestamp of save |
blocks | OutputBlockData[] | Array of block data |