From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
UNPUBLISHED
  • Overview
  • Why This Fork
  • Examples
Walkthroughs
  • Installing Plite
  • Adding Event Handlers
  • Defining Custom Elements
  • Applying Custom Formatting
  • Executing Commands
  • Saving to a Database
  • Canonical Change Substrate
  • Improving Performance
Concepts
  • Interfaces
  • Nodes
  • Locations
  • Transforms
  • Document Changes
  • Commands
  • Editor
  • Extensions
  • Rendering
  • Serializing
  • Normalizing
  • Using TypeScript
  • Roots
  • Document State
  • Editing Behavior
  • Selection And DOM
  • Clipboard And Paste
  • Projection And Overlays
  • Schema
API
  • Anchor API
  • Location API
  • Path API
  • PointEntry API
  • Point API
  • Range API
  • Selection API
  • Location Types APIs
  • Span API
  • Editor
  • Element API
  • NodeEntry API
  • Node API
  • Node Types APIs
  • Text API
  • Debug Value Scrubbing
  • Transforms API
Libraries
  • Plite DOM
  • History Editor API
  • History Extension Setup
  • History
  • Plite History
  • Plite Hyperscript
  • Plite Layout
  • Annotations
  • DOM Coverage Boundaries
  • Editable Component
  • Plite React Event Handling
  • Virtualized Rendering
  • Plite React Hooks
  • React Editor Setup
  • React Editor
  • Plite React
  • Plite Component
  • Plite Yjs
  • Plite
General
  • Migration
  • Contributing
  • Docs Proof Map
  • FAQ
  • Resources

Schema

PreviousNext

Declare, compile, validate, fit, and reconfigure Plite document structure.

Plite schema is the immutable contract for element structure, property values, roots, and insertion fitting. A declared schema rejects invalid external data and unknown vocabulary by default; a raw editor without one stays permissive.

Choose The Right Surface

NeedAPIOwner
Declare the complete document vocabularydefineEditorSchema(...)@platejs/plite
Add feature-owned schema declarationsextension schema contribution@platejs/plite or a framework adapter
Projection And OverlaysAnchor API

On This Page

Choose The Right SurfaceDefine A SchemaContent GrammarDetached Element-Owned RootsProperties And TargetsUnknown VocabularyExternal ValidationFragment And Slice FittingSchema DeltasAtomic ReconfigurationSchema Identity And PersistenceRelated Docs
Build your editor
Production-ready AI template and reusable components.
Get all-access
Inspect compiled behavioreditor.read.schema.*committed editor state
Validate a complete persisted documenteditor.read.schema.assertDocument(...)compiled schema
Validate closed childreneditor.read.schema.assertFragment(...)compiled schema
Import a complete document snapshottx.value.replace(...)transaction fitter
Change one named roottx.roots.*transaction fitter
Insert closed application contenttx.fragment.replace(...)transaction fitter
Preserve open transport boundariesContentSlice and tx.slice.replace(...)slice fitter
Fit content under a detached parentstate.slice.fitContent(...)read-only slice service
Replace a schema at runtimetx.extensions.reconfigure(...)atomic extension transaction

The declaration builders describe policy. The compiled state API answers runtime questions. Transactions fit and publish content.

Define A Schema

defineEditorSchema(...) packages one complete schema as an editor extension. The definition names every element and the primary-root grammar. Omit id and version for a schema whose identity is derived entirely from its compiled semantics. Provide both for a durable named lineage. The definition also states whether unknown vocabulary is rejected or preserved.

import {
  createEditor,
  defineEditorSchema,
  property,
  schema,
  target,
} from "@platejs/plite";
 
const ArticleSchema = defineEditorSchema("schema:article", {
  elements: {
    paragraph: schema.element.textBlock({
      properties: {
        id: property.string(),
      },
    }),
    quote: {
      content: schema.content.group("block", {
        default: { type: "paragraph" },
        min: 1,
      }),
      slice: { preserveContext: true },
    },
  },
  id: "article",
  properties: [
    schema.textProperty("bold", property.boolean(), {
      target: target.type("paragraph"),
    }),
    schema.elementProperty("align", property.string(), {
      target: target.group("block"),
      typeChange: "preserve-if-allowed",
    }),
  ],
  root: schema.content.group("block", {
    default: { type: "paragraph" },
    min: 1,
  }),
  version: 1,
});
 
const editor = createEditor({
  extensions: [ArticleSchema] as const,
  initialValue: [
    { type: "paragraph", id: "intro", children: [{ text: "Hello" }] },
  ],
});
import {
  createEditor,
  defineEditorSchema,
  property,
  schema,
  target,
} from "@platejs/plite";
 
const ArticleSchema = defineEditorSchema("schema:article", {
  elements: {
    paragraph: schema.element.textBlock({
      properties: {
        id: property.string(),
      },
    }),
    quote: {
      content: schema.content.group("block", {
        default: { type: "paragraph" },
        min: 1,
      }),
      slice: { preserveContext: true },
    },























Element, group, and root declarations are contextual objects. Tagged builders such as property.*, schema.content.*, and target.* express policy and return deeply frozen, serializable declarations. The extension boundary freezes and validates the complete declaration before the compiler resolves it into immutable indexes, construction plans, property placement, and slice policy. Extension order does not decide defaults or conflict winners.

Content Grammar

Element and root content use explicit rules.

BuilderAccepts
schema.content.text(...)text children
schema.content.type(type, ...)one element type
schema.content.types(types, ...)any listed element type
schema.content.group(group, ...)any element in a compiled group
schema.content.any(rules, ...)at least one nested rule
schema.content.all(rules, ...)every nested rule
schema.content.not(rule, ...)anything outside the nested rule

Use schema.element.textBlock(options?) for an editable element that accepts text and inline elements, requires one child, and constructs an empty text child by default. Structural and void elements still declare their own shape.

min, max, and default define cardinality and construction. A required content rule needs a constructable default. state.schema.create(type) uses the compiled plan, and state.schema.findWrapping(parent, child) uses the same grammar.

schema.content.not(...) is a true complement. It permits undeclared element types when they are outside the excluded rule. Inspect compiled grammar through state.schema.element(type)?.content; its allowsUnknownElements field makes that open-world behavior explicit alongside allowedElementTypes and allowsText.

The compiler owns all, element, text, block, inline, and textBlock. Non-inline elements join block; inline elements join inline; compatible text containers join textBlock. Application groups are transitive: declare them as { extends: [...] } and query them with state.schema.isElementTypeInGroup(type, group).

Detached Element-Owned Roots

Keep structurally owned editable content in an element's normal children. Declare contentRoots only when the content needs a separate root identity, mount lifecycle, or shared ownership.

const SyncedBlocksSchema = defineEditorSchema('documentSchema', {
  elements: {
    "synced-block": {
      content: schema.content.text({ min: 1 }),
      contentRoots: {
        body: {
          content: schema.content.type("paragraph", {
            default: { type: "paragraph" },
            min: 1,
          }),
          ownership: "shared",
        },
      },
    },
    paragraph: schema.element.textBlock(),
  },
  root: schema.content.types(["synced-block", "paragraph"], {
    default: { type: "paragraph" },
    min: 1,
  }),
});
const SyncedBlocksSchema = defineEditorSchema('documentSchema', {
  elements: {
    "synced-block": {
      content: schema.content.text({ min: 1 }),
      contentRoots: {
        body: {
          content: schema.content.type("paragraph", {
            default: { type: "paragraph" },
            min: 1,
          }),
          ownership: "shared",
        },
      },
    },
    paragraph: schema.element.textBlock(),
  },
  root: schema.content.types(["synced-block", "paragraph"], {
    default: { type: "paragraph"


The owner stores childRoots: { body: "synced:1" }; the detached blocks live in document.roots["synced:1"]. Use ownership: "shared" only when multiple owners intentionally project the same root. See Roots for rendering, persistence, clipboard, and lifecycle behavior.

Properties And Targets

Property descriptors define JSON value laws. Use property.boolean(), property.string(), property.number(), property.enum(values), property.json(), or property.set(item). All values use structural equality; defaults can be omitted canonically with { default, omitDefault: true }.

Element-owned fields live in the element declaration's properties map. Cross-cut element fields use schema.elementProperty(...) with an explicit target. Text marks use schema.textProperty(...).

Use a SchemaPropertyHandle when generic code needs both persisted identity and the property's inferred value type. Raw Plite schemas create handles from their schema descriptor. Plate publishes the corresponding compiled handles on each installed plugin.

const paragraph = schema.handle.element(ArticleSchema, "paragraph");
const align = schema.handle.property(paragraph, "align");
 
const node = editor.read.schema.create(paragraph, { align: "center" });
const value = editor.read.schema.getProperty(node, align);
// value: string | undefined
const paragraph = schema.handle.element(ArticleSchema, "paragraph");
const align = schema.handle.property(paragraph, "align");
 
const node = editor.read.schema.create(paragraph, { align: "center" });
const value = editor.read.schema.getProperty(node, align);
// value: string | undefined

Direct domain code still reads node.align. Handles are for schema-aware construction, generic property reads, matching, codecs, and inspection. An element handle owns type; a property handle owns key, placement, value type, and compiled property id. Extension names never substitute for either.

Properties participate in document content by default. Set role: "metadata" on an element or text property placement when the value supports runtime bookkeeping and should not make a node meaningfully non-empty or appear in content-only serialization.

const ImportSource = schema.elementProperty("importSource", property.string(), {
  role: "metadata",
  target: target.group("block"),
});
const ImportSource = schema.elementProperty("importSource", property.string(), {
  role: "metadata",
  target: target.group("block"),
});

Use one frozen group when separate text properties cannot coexist:

const ScriptPosition = schema.property.exclusive("app:script-position");
 
const properties = [
  schema.textProperty("subscript", property.boolean(), {
    exclusive: [ScriptPosition],
  }),
  schema.textProperty("superscript", property.boolean(), {
    exclusive: [ScriptPosition],
  }),
];
const ScriptPosition = schema.property.exclusive("app:script-position");
 
const properties = [
  schema.textProperty("subscript", property.boolean(), {
    exclusive: [ScriptPosition],
  }),
  schema.textProperty("superscript", property.boolean(), {
    exclusive: [ScriptPosition],
  }),
];

Toggling one member removes active conflicting members. Schema validation and collaboration projection enforce the same invariant.

Targets are frozen syntax, not callbacks. Compose target.type, target.types, target.group, target.root, target.parent, target.and, target.or, and target.not. Use schema.key.prefix(...) only for a real namespaced property family.

Put custom validation directly on the property declaration. validate and a positive-integer validationVersion must appear together. Increment the version whenever the validator's accepted value set changes.

const MediaSchema = defineEditorSchema('documentSchema', {
  elements: {
    image: {
      properties: {
        size: property.json({
          validate: (value): value is { height: number; width: number } =>
            typeof value === "object" &&
            value !== null &&
            "height" in value &&
            typeof value.height === "number" &&
            "width" in value &&
            typeof value.width === "number",
          validationVersion: 1,
        }),
        width: property.number({
          validate: (value): value is number =>
            typeof value === "number" && value > 0,
          validationVersion: 1,
        }),
      },
      void: "block",
    },
  },
  root: schema.content.type("image", {
    min: 1,
  }),
});
const MediaSchema = defineEditorSchema('documentSchema', {
  elements: {
    image: {
      properties: {
        size: property.json({
          validate: (value): value is { height: number; width: number } =>
            typeof value === "object" &&
            value !== null &&
            "height" in value &&
            typeof value.height === "number" &&
            "width" in value &&
            typeof value.width === "number",
          validationVersion: 1,
        }),












property.json(...) infers its structured value from the validator's type predicate. The descriptor identity and validationVersion contribute to schema identity; function identity does not. The validator receives untrusted unknown input and narrows it to the declared property type.

Unknown Vocabulary

Complete schemas default to unknown: "reject". Use "preserve" only when the application intentionally carries forward undeclared element types or properties.

const ForwardCompatibleSchema = defineEditorSchema("schema:forward-compatible", {
  id: "forward-compatible",
  root: schema.content.not(schema.content.text()),
  unknown: "preserve",
  version: 1,
});
const ForwardCompatibleSchema = defineEditorSchema("schema:forward-compatible", {
  id: "forward-compatible",
  root: schema.content.not(schema.content.text()),
  unknown: "preserve",
  version: 1,
});

Preservation is still grammar-bound. An undeclared element is valid only when its parent content rule admits unknown elements; a closed type, types, or group rule still rejects it. Compiled content reports that decision through allowsUnknownElements. Declared elements and properties continue to use their compiled validation and lifecycle laws, while undeclared properties are kept as JSON values. The unknown policy contributes to schema identity.

External Validation

createEditor(...) snapshots initialValue, fits every root through the candidate schema, canonicalizes node properties and representation, maps the initial selection, and validates the result before publishing the editor. Unknown element types, roots, properties, invalid property values, and illegal parent/child relationships fail atomically when they cannot be fitted.

Use the compiled read API at other external boundaries:

let decodedDocument: unknown = JSON.parse(documentJson);
let decodedChildren: unknown = JSON.parse(fragmentJson);
 
editor.read.schema.assertDocument(decodedDocument);
editor.read.schema.assertFragment(decodedChildren);
 
// Both values are narrowed after the assertions.
decodedDocument.children;
decodedChildren.length;
let decodedDocument: unknown = JSON.parse(documentJson);
let decodedChildren: unknown = JSON.parse(fragmentJson);
 
editor.read.schema.assertDocument(decodedDocument);
editor.read.schema.assertFragment(decodedChildren);
 
// Both values are narrowed after the assertions.
decodedDocument.children;
decodedChildren.length;

assertDocument checks primary children, named roots, and JSON shape. assertFragment checks a closed fragment without changing it. Both accept untrusted unknown input, throw EditorSchemaValidationError on failure, and narrow the input on success. Do not run a separate canonicalization pass before importing content. Use tx.value.replace(...) as the sole complete-document publication boundary. Use tx.roots.create(...) or tx.roots.replace(...) for one targeted named root. Every path routes content through the same schema fitter and publishes atomically.

ContentSlice.fromJSON(...) validates generic JSON shape, immutability, and open depths. Schema vocabulary and grammar are checked when the slice is fitted against an editor state. See Clipboard And Paste.

Fragment And Slice Fitting

Use the smallest surface that preserves the information you have.

import { ContentSlice } from "@platejs/plite";
 
editor.update.fragment.replace([
  { type: "paragraph", children: [{ text: "Closed content" }] },
]);
 
const openSlice = ContentSlice.fromJSON({
  content: decodedContent,
  openEnd: 1,
  openStart: 1,
});
 
editor.update.slice.replace(openSlice);
import { ContentSlice } from "@platejs/plite";
 
editor.update.fragment.replace([
  { type: "paragraph", children: [{ text: "Closed content" }] },
]);
 
const openSlice = ContentSlice.fromJSON({
  content: decodedContent,
  openEnd: 1,
  openStart: 1,
});
 
editor.update.slice.replace(openSlice);

fragment.replace is the ordinary closed-content API. slice.replace keeps open edge context from HTML, clipboard, drag, or another transport. Both fit at the actual target and publish at most one canonical transaction.

Use state.slice.fit(slice, options?) to build a TransactionSpec without publishing. Use state.slice.fitContent(slice, { parent, root? }) when a feature such as a table needs grammar-valid children for a detached parent. fitContent returns frozen children or null; it does not mutate the parent or editor.

Schema Deltas

state.schema.delta() reports the semantic difference published with the current configuration revision. Its sorted, immutable sets name changed elementTypes, propertyIds, roots, and constructionTypes. React and host adapters use those sets with runtime indexes to refresh only affected mounted nodes and projected roots. A schema delta is configuration metadata, not a document operation, and does not mutate the value.

Atomic Reconfiguration

Put a replaceable schema in a named extension slot. Reconfiguration compiles and validates the candidate before publication. If the current document does not satisfy the candidate, provide migrate to return one complete document for the candidate schema.

import {
  createEditor,
  defineEditorSchema,
  defineExtensionSlot,
  schema,
} from "@platejs/plite";
 
const articleSchema = (version: number, type: string) =>
  defineEditorSchema("schema:article", {
    elements: {
      [type]: {
        content: schema.content.text({ min: 1 }),
      },
    },
    id: "article",
    root: schema.content.type(type, {
      default: { type },
      min: 1,
    }),
    version,
  });
 
const articleSlot = defineExtensionSlot("article-schema");
const ArticleV1 = articleSchema(1, "paragraph");
const ArticleV2 = articleSchema(2, "body");
const editor = createEditor({
  extensions: [articleSlot.of(ArticleV1)] as const,
  initialValue: [{ type: "paragraph", children: [{ text: "Draft" }] }],
});
 
editor.update.extensions.reconfigure(articleSlot, ArticleV2, {
  migrate({ document }) {
    return {
      ...document,
      children: document.children.map((node) =>
        "type" in node && node.type === "paragraph"
          ? { ...node, type: "body" }
          : node
      ),
    };
  },
});
import {
  createEditor,
  defineEditorSchema,
  defineExtensionSlot,
  schema,
} from "@platejs/plite";
 
const articleSchema = (version: number, type: string) =>
  defineEditorSchema("schema:article", {
    elements: {
      [type]: {
        content: schema.content.text({ min: 1 }),
      },
    },
    id: "article",
    root: schema.content.type(type, {
      default: { type },
      min: 1,
    }),
    version,





















The callback sees the immutable current document and the candidate schema as next. The returned document, schema, fields, facets, APIs, and configuration revision publish in one commit. Compilation, migration, or validation failure publishes nothing. Equivalent schema configuration is a no-op.

The Schema Reconfiguration example isolates this publication and migration lifecycle. The Plate Schema Descriptors example separately demonstrates how Plate plugin declarations compile into the same schema model.

Schema Identity And Persistence

editor.read.schema.identity() always returns a schema identity. A complete schema without id and version returns { kind: "derived", fingerprint }. A named lineage returns { kind: "named", id, version, fingerprint }. The fingerprint is deterministic for the compiled semantics. Increment a named lineage's version whenever those semantics change; a matching ID and version with a different fingerprint is an invalid deployment contract.

The document returned by editor.read.value() does not embed schema identity. Persist it in an application envelope when documents may cross schema versions. @platejs/plite-history does this in History.toJSON(editor) and rejects a mismatch in History.fromJSON(editor, json) before decoding any batch.

Related Docs

  • Nodes
  • Extensions
  • Document Changes
  • Document Meta
  • Clipboard And Paste
  • History
},
id: "article",
properties: [
schema.textProperty("bold", property.boolean(), {
target: target.type("paragraph"),
}),
schema.elementProperty("align", property.string(), {
target: target.group("block"),
typeChange: "preserve-if-allowed",
}),
],
root: schema.content.group("block", {
default: { type: "paragraph" },
min: 1,
}),
version: 1,
});
const editor = createEditor({
extensions: [ArticleSchema] as const,
initialValue: [
{ type: "paragraph", id: "intro", children: [{ text: "Hello" }] },
],
});
},
min: 1,
}),
});
width: property.
number
({
validate: (value): value is number =>
typeof value === "number" && value > 0,
validationVersion: 1,
}),
},
void: "block",
},
},
root: schema.content.type("image", {
min: 1,
}),
});
});
const articleSlot = defineExtensionSlot("article-schema");
const ArticleV1 = articleSchema(1, "paragraph");
const ArticleV2 = articleSchema(2, "body");
const editor = createEditor({
extensions: [articleSlot.of(ArticleV1)] as const,
initialValue: [{ type: "paragraph", children: [{ text: "Draft" }] }],
});
editor.update.extensions.reconfigure(articleSlot, ArticleV2, {
migrate({ document }) {
return {
...document,
children: document.children.map((node) =>
"type" in node && node.type === "paragraph"
? { ...node, type: "body" }
: node
),
};
},
});