On this sectionDocument format

Document format

The JSON document envelope, its schema version and size limits, what the validator accepts, and what happens to content it rejects.

The value

A document's content is a Plate value: an array of block objects, each with a type and children. EditorValue is that type.

ts
import type { Value } from "platejs";

export type EditorValue = Value;

export const EMPTY_EDITOR_VALUE = [
  {
    type: "p",
    children: [{ text: "" }],
  },
] satisfies EditorValue;

EMPTY_EDITOR_VALUE is what an editor with no initialValue starts from: one empty paragraph. Both names are re-exported from the entry point.

The stored envelope

What is saved is an envelope around the value. serializeEditorDocument writes exactly these four fields and nothing else.

json
{
  "schemaVersion": 1,
  "documentId": "notes",
  "revision": 3,
  "content": [
    { "type": "h1", "children": [{ "text": "Release notes" }] },
    { "type": "p", "children": [{ "text": "Shipped today." }] }
  ]
}
  • schemaVersion is the literal 1.
  • documentId is a non-empty string. It matches the id the editor was given.
  • revision is a non-negative integer. It goes up by one on each save, and it is the value your adapter compares for conflicts.
  • content is the array of blocks.
ts
import { z } from "zod";

import { EMPTY_EDITOR_VALUE, type EditorValue } from "./editor-value";

export const EDITOR_SCHEMA_VERSION = 1;

export const EDITOR_DOCUMENT_LIMITS = {
  maxDepth: 32,
  maxNodes: 20_000,
  maxBytes: 2_000_000,
} as const;

export const editorDocumentSchema = z.object({
  schemaVersion: z.literal(EDITOR_SCHEMA_VERSION),
  documentId: z.string().min(1),
  revision: z.number().int().nonnegative(),
  content: z.custom<EditorValue>((value): value is EditorValue => Array.isArray(value)),
});

export type EditorDocument = z.infer<typeof editorDocumentSchema>;

export function formatBlockLabel(path: readonly number[]): string {
  return `Block ${path.map((index) => String(index + 1)).join(".")}`;
}

export function createEditorDocument(
  documentId: string,
  content: EditorValue = EMPTY_EDITOR_VALUE,
): EditorDocument {
  return {
    schemaVersion: EDITOR_SCHEMA_VERSION,
    documentId,
    revision: 0,
    content,
  };
}

export function serializeEditorDocument(document: EditorDocument): string {
  return JSON.stringify({
    schemaVersion: document.schemaVersion,
    documentId: document.documentId,
    revision: document.revision,
    content: document.content,
  });
}

Size limits

  • maxDepth: 32 levels of nesting.
  • maxNodes: 20,000 nodes.
  • maxBytes: 2,000,000 bytes of JSON.

These are the limits a document is opened against. A document over the byte limit is invalid: the user sees the recovery notice, not a half-loaded editor.

Migration on load

Before a document opens, a bare array is wrapped in an envelope with revision 0 and the document id, so a value you stored before you had an envelope still opens. Any other value passes through unchanged and is validated as it is.

What the validator decides

ok
The document opens. Repairs are applied and returned with the result, such as a missing block id, a disallowed value, or a list number out of range. The editor shows no notice for repairs on load; paste shows its repairs in a notice.
unsupported
The document uses a block type, attribute, mark or value the allow list does not contain, or a structure the validator rejects. The editor shows a recovery notice with the first five issues and the raw JSON. The user can copy the JSON or start over, and starting over saves the original to recovery storage first.
invalid
The stored value is not a document, for example the envelope is missing fields or the JSON is too large. The same recovery notice is shown. Storage that cannot be read shows the same notice.
future-version
schemaVersion is higher than 1. The recovery notice says the document is from a newer editor.

The allow lists are EDITOR_ELEMENT_RULES and EDITOR_MARK_RULES in the installed lib/document/editor-document-schema.ts. A value outside them is the difference between a repair and a rejection, so it is worth reading before you hand-write a document.

Block ids

A generated block id is 10 characters. Block ids are normalized on load: a missing id gets a new one, a duplicate gets a new one, and ids already in the document are reserved first so a generated id cannot take one that a later block still owns. Ids are what page links and synced references point at, so they are stable once written.

Structure rules worth knowing

  • A code line, a table row or a table cell at the top level makes the document unsupported.
  • A footnote definition is top level only; anywhere else it is read as a paragraph.
  • A table cannot sit inside a table, and a column group cannot hold another column group.
  • Callout and quote children are paragraphs only. Table cells hold paragraphs only.