Understanding Schema & Validation

Copy Markdown View Source

Schemas define the valid structure of Quillon documents. They specify which node types are allowed, what children each can contain, which marks can be applied, and attribute requirements.

Why Schemas?

Rich text documents need structure constraints:

  • A document must contain blocks, not raw text
  • Lists must contain list items, not paragraphs directly
  • Links require an href attribute
  • Subscript and superscript can't both be applied to the same text

Without validation, it's easy to create malformed documents that break rendering or cause unexpected behavior. Schemas catch these issues early.

Schema Structure

A schema has three main components:

%Quillon.Schema{
  groups: %{...},   # Named groups of node types
  nodes: %{...},    # Node type specifications
  marks: %{...}     # Mark specifications
}

Groups

Groups bundle node types for use in content expressions:

groups: %{
  block: [:paragraph, :heading, :divider, :blockquote, ...],
  inline: [:text],
  container: [:document],
  list_content: [:list_item],
  table_content: [:table_row],
  table_row_content: [:table_cell]
}

Instead of listing every allowed type, you can reference a group: "block+" means "one or more of any block type."

Groups also answer Quillon.block?/2, inline?/2 and container?/2, so a node type you add to a group is categorised like a built-in one.

Node Specifications

Each node type has a specification defining its structure:

nodes: %{
  paragraph: %{
    content: "inline*",  # What children are allowed
    group: :block,       # Which group this belongs to
    marks: :all          # Which marks can be applied
  },
  heading: %{
    content: "inline*",
    group: :block,
    marks: :all,
    attrs: %{
      level: %{required: true}  # Required attribute
    }
  },
  divider: %{
    content: nil,        # No children allowed
    group: :block,
    marks: nil,          # No marks allowed
    attrs: %{
      style: %{default: :solid}  # Optional with default
    }
  }
}

Node Spec Fields

FieldTypeDescription
contentString.t() or nilContent expression defining allowed children
groupatom()Group this node belongs to
marks:all, [atom()], or nilAllowed marks (:all = any, nil = none)
attrsmap()Attribute specifications

Attribute Specs

FieldTypeDescription
requiredboolean()Must be present (default: false)
defaultany()Default value if not provided

Mark Specifications

Each mark type has a specification controlling its behavior:

marks: %{
  bold: %{
    inclusive: true,
    keep_on_split: true
  },
  link: %{
    inclusive: false,
    keep_on_split: true,
    attrs: %{
      href: %{required: true},
      title: %{},
      target: %{}
    }
  },
  subscript: %{
    inclusive: true,
    keep_on_split: true,
    excludes: [:superscript]
  }
}

Mark Spec Fields

FieldTypeDescription
inclusiveboolean()Text at boundary inherits mark
keep_on_splitboolean()Mark persists after Enter/split
excludes[atom()]Conflicting marks that can't coexist
attrsmap()Attribute specifications

Only excludes and attrs drive Quillon's own behavior — the validator reads them to report :mark_conflict and :missing_attr. inclusive and keep_on_split are declarative metadata that Quillon stores and hands back through Quillon.Schema.get_mark_spec/2 for a consuming editor to interpret. The transform layer does not read them; splitting a text node copies the whole mark list to both halves regardless. There is no implicit default either — if a mark spec omits the key, get_mark_spec/2 simply returns a map without it.

See the Marks Guide for detailed explanations of inclusive, keep_on_split, and excludes.

Content Expressions

Content expressions define what children a node can contain using a simple grammar.

Basic Syntax

ExpressionMeaning
"paragraph"Exactly one paragraph
"paragraph+"One or more paragraphs
"paragraph*"Zero or more paragraphs
"block+"One or more from the block group
"inline*"Zero or more from the inline group
nilNo children allowed

Choice Syntax

Use parentheses and | for alternatives:

ExpressionMeaning
"(paragraph | heading)"Exactly one paragraph OR heading
"(paragraph | heading)+"One or more of either type
"(paragraph | heading)*"Zero or more of either type

Sequence Syntax

Separate elements with spaces for sequences:

ExpressionMeaning
"heading paragraph+"One heading followed by one or more paragraphs
"paragraph+ divider"One or more paragraphs followed by a divider

Quantifiers are greedy and never backtrack: + and * consume every matching child they can, and matching does not retry with fewer. So an element quantified over a group cannot be followed by a member of that same group — "block+ divider block+" never matches, because the leading block+ swallows the divider (:divider is itself in the block group) and the rest of the sequence has nothing left to match.

Examples in Practice

# Document contains one or more blocks
document: %{content: "block+"}

# Paragraph contains zero or more inline (text) nodes
paragraph: %{content: "inline*"}

# Table contains one or more rows
table: %{content: "table_row+"}

# List item contains one or more blocks (for nesting)
list_item: %{content: "block+"}

# Code block has no children (code stored in attrs)
code_block: %{content: nil}

Using the Default Schema

Quillon provides a comprehensive default schema:

schema = Quillon.Schema.default()

# Check what's in the schema
Map.keys(schema.nodes)
Map.keys(schema.marks)

Map.keys/1 returns an unordered set, so don't depend on the order. For the node and mark types the default schema defines, see the Default Schema Reference below.

Validating Documents

Basic Validation

doc = Quillon.document([
  Quillon.paragraph("Hello world")
])

# Validate with ok/error tuple
case Quillon.validate(doc) do
  {:ok, doc} ->
    # Document is valid
    save_to_database(doc)

  {:error, errors} ->
    # Handle validation errors
    Enum.each(errors, &IO.inspect/1)
end

# Validate with exception
doc = Quillon.validate!(doc)  # Raises on error

Understanding Errors

Validation errors include path, type, and message:

bad_doc = {:unknown_type, %{}, []}

{:error, errors} = Quillon.validate(bad_doc)
# errors = [
#   %{
#     path: [],
#     type: :unknown_type,
#     message: "Unknown node type: unknown_type"
#   }
# ]

Error Types

TypeDescription
:malformed_nodeTerm is not a {type, attrs, children} tuple
:malformed_markMark is not an atom or a {type, attrs} pair, or marks is not a list
:unknown_typeNode type not in schema
:invalid_contentChildren don't match content expression
:missing_attrRequired attribute not present
:mark_not_allowedMark on a text node is not permitted by the schema
:mark_conflictTwo marks that exclude each other
:unknown_markMark type not in schema

The validator inspects marks only on :text nodes — a marks attribute on any other node type is ignored. Because the default schema gives text marks: :all, :mark_not_allowed fires there only for a mark the schema doesn't define at all, so it always arrives alongside an :unknown_mark error.

Error Paths

The path field tells you where the error occurred:

doc = Quillon.document([
  Quillon.paragraph("First"),       # path: [0]
  {:bad_node, %{}, []}              # path: [1]
])

{:error, errors} = Quillon.validate(doc)
# errors = [
#   %{path: [], type: :invalid_content,
#     message: "Children don't match content expression: block+"},
#   %{path: [1], type: :unknown_type,
#     message: "Unknown node type: bad_node"}
# ]

One malformed child usually produces two errors: the child's own error at its path, plus a content error on the parent, whose content expression no longer matches once a child has an unrecognized type. The parent is checked before the validator recurses, so the parent's error comes first.

For nested structures:

# path: [0, 1, 0] means:
# - First child of document (the list)
# - Second child of list (second list_item)
# - First child of list_item (the paragraph)

Validation Examples

Malformed Node

# A child that is not a {type, attrs, children} tuple
bad = {:document, %{}, ["not a node"]}

{:error, errors} = Quillon.validate(bad)

# errors includes:
#   %{path: [0], type: :malformed_node,
#     message: ~s(Expected a node tuple, got: "not a node")}

Validation reports a malformed term rather than raising, so validate/1 is safe to point at a document from storage or from an older version of your schema.

Missing Required Attribute

# Heading requires a level
bad = {:heading, %{}, [{:text, %{text: "Title", marks: []}, []}]}

{:error, [%{type: :missing_attr, message: "Missing required attribute: level"}]} =
  Quillon.validate(bad)

Invalid Content

# Document requires block+ (one or more blocks)
empty_doc = {:document, %{}, []}

{:error, [%{type: :invalid_content, ...}]} = Quillon.validate(empty_doc)

# Paragraph allows inline*, but not blocks
para_with_block = {:paragraph, %{}, [
  {:heading, %{level: 1}, [{:text, %{text: "Wrong", marks: []}, []}]}
]}

{:error, [%{type: :invalid_content, ...}]} = Quillon.validate(para_with_block)

Mark Conflicts

# Subscript and superscript conflict
text = {:text, %{text: "H2O", marks: [:subscript, :superscript]}, []}
doc = Quillon.document([{:paragraph, %{}, [text]}])

{:error, errors} = Quillon.validate(doc)
# Contains :mark_conflict error

Creating Custom Schemas

Extending the Default

Use Schema.merge/2 to extend the default schema:

custom = Quillon.Schema.merge(
  Quillon.Schema.default(),
  %Quillon.Schema{
    # Add a custom node type
    nodes: %{
      aside: %{
        content: "block+",
        group: :block,
        attrs: %{
          position: %{default: :right}
        }
      }
    },
    # Add a custom mark
    marks: %{
      redacted: %{
        inclusive: false,
        keep_on_split: false
      }
    },
    # List only what you add - the base members are kept
    groups: %{
      block: [:aside]
    }
  }
)

Everything composes. Declare only what you change and the rest of the base survives, so two consumers extending the same type or group coexist rather than unregistering each other's work:

  • groups union their members
  • specs merge key by key, so supplying only attrs keeps content, group and marks
  • attrs merge entry by entry
  • an individual attr spec replaces, since it holds only required/default

An explicit key still overwrites, which is how you narrow a type - give content: nil to forbid children, or marks: [:bold] to restrict them. What a merge cannot do is remove a key the base declared; build the schema from scratch for that.

Working with a Custom Node Type

A merged schema governs validation only. Two other layers need to know about :aside as well.

Decoding. Quillon.from_json/1 accepts only the built-in types, so a document containing an aside fails with {:error, "Unknown node type: aside"}. Pass the schema (or just the extra atoms) to decode it:

{:ok, doc} = Quillon.from_json(json, schema: custom)
{:ok, doc} = Quillon.from_json(json, extra_types: [:aside])

Transforms. The transform layer has defined behavior for a node type it doesn't recognize, described in Extensibility. The consequence that bites schema authors: an :aside never splits, so a mark range cutting through its interior can't be applied — mark a range that covers the node whole instead.

Creating from Scratch

For complete control, build a schema from scratch:

minimal_schema = %Quillon.Schema{
  groups: %{
    block: [:paragraph],
    inline: [:text]
  },
  nodes: %{
    document: %{content: "block+"},
    paragraph: %{content: "inline*", group: :block, marks: :all},
    text: %{content: nil, group: :inline, marks: :all}
  },
  marks: %{
    bold: %{inclusive: true, keep_on_split: true},
    italic: %{inclusive: true, keep_on_split: true}
  }
}

# Validate against custom schema
{:ok, doc} = Quillon.Schema.Validator.validate(doc, minimal_schema)

Restricting Marks

Declare which marks a node type permits with the marks field:

%Quillon.Schema{
  nodes: %{
    paragraph: %{marks: :all},           # All marks allowed
    heading: %{marks: [:bold, :italic]}, # Only bold/italic
    code_block: %{marks: nil}            # No marks
  }
}

Quillon.validate/1 evaluates these permissions against the node that carries the marks attribute, which in practice is always :text. A restriction on a block type such as heading or code_block therefore doesn't reach that block's text children — a heading whose spec allows only [:bold, :italic] still validates clean when its text child carries :underline. Treat a marks restriction on a block type as advisory metadata for consuming editors, and enforce it yourself if you need it. To restrict marks in a way the validator enforces, narrow the text spec:

nodes: %{
  text: %{content: nil, group: :inline, marks: [:bold, :italic]}
}

Query the declared permissions with Quillon.Schema.allowed_marks/2 and Quillon.Schema.mark_allowed?/3.

Schema Functions

The Quillon.Schema module provides utilities for querying schemas:

schema = Quillon.Schema.default()

# Check existence
Quillon.Schema.node_type?(schema, :paragraph)  # => true
Quillon.Schema.mark_type?(schema, :bold)       # => true

# Get specifications
Quillon.Schema.get_node_spec(schema, :heading)
# => %{content: "inline*", group: :block, marks: :all,
#      attrs: %{level: %{required: true}, align: %{}, ...}}

Quillon.Schema.get_mark_spec(schema, :link)
# => %{inclusive: false, keep_on_split: true, attrs: %{...}}

# Query groups
Quillon.Schema.get_group(schema, :block)
# => [:paragraph, :heading, :divider, ...]

# Check mark permissions
Quillon.Schema.allowed_marks(schema, :paragraph)  # => :all
Quillon.Schema.allowed_marks(schema, :divider)    # => nil
Quillon.Schema.mark_allowed?(schema, :paragraph, :bold)  # => true

# Check conflicts
Quillon.Schema.marks_conflict?(schema, :subscript, :superscript)  # => true
Quillon.Schema.marks_conflict?(schema, :bold, :italic)            # => false

Best Practices

1. Validate at Boundaries

Validate documents at system boundaries:

# When receiving from API
def create_document(params) do
  with {:ok, doc} <- Quillon.from_json(params["content"]),
       {:ok, doc} <- Quillon.validate(doc) do
    save_document(doc)
  end
end

# When loading from database
def load_document(id) do
  raw = get_from_db(id)
  {:ok, doc} = Quillon.from_json(raw)
  {:ok, doc} = Quillon.validate(doc)
  doc
end

2. Fail Fast in Development

Use validate!/1 in tests and development:

# In tests - fail immediately on invalid docs
test "creates valid document" do
  doc = MyApp.create_document(attrs)
  Quillon.validate!(doc)  # Raises with details if invalid
end

3. Handle Errors Gracefully in Production

Use validate/1 in production for graceful error handling:

def process_document(doc) do
  case Quillon.validate(doc) do
    {:ok, valid_doc} ->
      {:ok, render(valid_doc)}

    {:error, errors} ->
      Logger.error("Invalid document", errors: errors)
      {:error, :invalid_document}
  end
end

4. Custom Validation Rules

Layer business rules on top of schema validation:

def validate_blog_post(doc) do
  with {:ok, doc} <- Quillon.validate(doc),
       :ok <- validate_has_title(doc),
       :ok <- validate_word_count(doc, min: 100),
       :ok <- validate_no_empty_paragraphs(doc) do
    {:ok, doc}
  end
end

defp validate_has_title(doc) do
  case find_heading(doc, level: 1) do
    nil -> {:error, "Blog post requires an H1 title"}
    _ -> :ok
  end
end

Default Schema Reference

Here's the complete default schema for reference:

Nodes

TypeContentGroupMarksRequired Attrs
documentblock+---
paragraphinline*blockall-
headinginline*blockalllevel
dividernilblocknil-
textnilinlinealltext
blockquoteblock+block--
calloutblock+block-type
code_blocknilblock-code
imagenilblock-src
videonilblock-src
bullet_listlist_item+block--
ordered_listlist_item+block--
list_itemblock+list_content--
tabletable_row+block--
table_rowtable_cell+table_content--
table_cellblock+table_row_content--
rowblock+block--
gridblock+block--

Marks

MarkInclusiveKeep on SplitExcludesRequired Attrs
boldtruetrue--
italictruetrue--
underlinetruetrue--
striketruetrue--
codefalsetruelink-
linkfalsetrue-href
subscripttruetruesuperscript-
superscripttruetruesubscript-
highlighttruetrue-color
font_colortruetrue-color
mentionfalsefalse-id, type, label