Skip to main content

Custom Schemas

Chronicle’s editor is schema-driven. You can define custom document schemas to control what content types are available, how they nest, and what attributes they support.

Default Schema

Out of the box, Chronicle provides a rich-text schema:
import { defaultSchema } from '@chronicle-hq/editor';

// Includes: paragraph, heading (1-6), blockquote, code_block,
// horizontal_rule, bullet_list, ordered_list, list_item,
// image, hard_break

Defining a Custom Schema

Schemas follow the ProseMirror schema specification:
import { Schema } from '@chronicle-hq/editor';

const blogSchema = new Schema({
  nodes: {
    doc: { content: 'title subtitle block+' },
    title: {
      content: 'text*',
      marks: '',
      parseDOM: [{ tag: 'h1.title' }],
      toDOM: () => ['h1', { class: 'title' }, 0],
    },
    subtitle: {
      content: 'text*',
      marks: 'em',
      parseDOM: [{ tag: 'p.subtitle' }],
      toDOM: () => ['p', { class: 'subtitle' }, 0],
    },
    paragraph: {
      content: 'inline*',
      group: 'block',
      parseDOM: [{ tag: 'p' }],
      toDOM: () => ['p', 0],
    },
    callout: {
      content: 'paragraph+',
      group: 'block',
      attrs: { type: { default: 'info' } },
      parseDOM: [{ tag: 'div.callout', getAttrs: (dom) => ({ type: dom.dataset.type }) }],
      toDOM: (node) => ['div', { class: 'callout', 'data-type': node.attrs.type }, 0],
    },
    text: { group: 'inline' },
  },
  marks: {
    em: { parseDOM: [{ tag: 'em' }], toDOM: () => ['em', 0] },
    strong: { parseDOM: [{ tag: 'strong' }], toDOM: () => ['strong', 0] },
    code: { parseDOM: [{ tag: 'code' }], toDOM: () => ['code', 0] },
    link: {
      attrs: { href: {}, title: { default: null } },
      parseDOM: [{ tag: 'a[href]', getAttrs: (dom) => ({ href: dom.href, title: dom.title }) }],
      toDOM: (mark) => ['a', { href: mark.attrs.href, title: mark.attrs.title }, 0],
    },
  },
});

Using a Custom Schema

Pass your schema when initialising the editor:
import { Chronicle } from '@chronicle-hq/editor';

const editor = new Chronicle({
  schema: blogSchema,
  plugins: [
    // ... your plugins
  ],
});

Schema Validation

Chronicle validates documents against their schema on load and on every operation:
  • Structural violations (e.g., a title node appearing mid-document) are rejected
  • Invalid marks (e.g., strong on a node that doesn’t allow marks) are stripped
  • Missing required content triggers a validation error on save

Schema Migration

When evolving schemas, use the migration helpers:
import { migrateDocument } from '@chronicle-hq/editor';

const migrated = migrateDocument(oldDoc, {
  from: schemaV1,
  to: schemaV2,
  transforms: {
    // Convert old 'note' nodes to new 'callout' nodes
    note: (node) => ({
      type: 'callout',
      attrs: { type: 'info' },
      content: node.content,
    }),
  },
});
Custom schemas work seamlessly with Chronicle’s CRDT layer. The fork-node engine operates on structural operations (insert, delete, move) that are schema-agnostic. Schema validation happens at the editor level before operations are broadcast.This means:
  • Two users with the same schema version always see consistent documents
  • Schema version mismatches are caught on connection to the relay
  • The relay rejects operations from clients with outdated schemas

Best Practices

  1. Start with the default schema and extend it rather than writing from scratch
  2. Keep schemas narrow — only allow the content types your application actually needs
  3. Version your schemas — include a version field in your schema metadata
  4. Test migrations before deploying schema changes to production
  5. Document node types — maintain a schema reference for your team