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
hrefattribute - 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
| Field | Type | Description |
|---|---|---|
content | String.t() or nil | Content expression defining allowed children |
group | atom() | Group this node belongs to |
marks | :all, [atom()], or nil | Allowed marks (:all = any, nil = none) |
attrs | map() | Attribute specifications |
Attribute Specs
| Field | Type | Description |
|---|---|---|
required | boolean() | Must be present (default: false) |
default | any() | 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
| Field | Type | Description |
|---|---|---|
inclusive | boolean() | Text at boundary inherits mark |
keep_on_split | boolean() | Mark persists after Enter/split |
excludes | [atom()] | Conflicting marks that can't coexist |
attrs | map() | 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
| Expression | Meaning |
|---|---|
"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 |
nil | No children allowed |
Choice Syntax
Use parentheses and | for alternatives:
| Expression | Meaning |
|---|---|
"(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:
| Expression | Meaning |
|---|---|
"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 errorUnderstanding 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
| Type | Description |
|---|---|
:malformed_node | Term is not a {type, attrs, children} tuple |
:malformed_mark | Mark is not an atom or a {type, attrs} pair, or marks is not a list |
:unknown_type | Node type not in schema |
:invalid_content | Children don't match content expression |
:missing_attr | Required attribute not present |
:mark_not_allowed | Mark on a text node is not permitted by the schema |
:mark_conflict | Two marks that exclude each other |
:unknown_mark | Mark 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 errorCreating 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
attrskeepscontent,groupandmarks attrsmerge 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) # => falseBest 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
end2. 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
end3. 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
end4. 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
endDefault Schema Reference
Here's the complete default schema for reference:
Nodes
| Type | Content | Group | Marks | Required Attrs |
|---|---|---|---|---|
document | block+ | - | - | - |
paragraph | inline* | block | all | - |
heading | inline* | block | all | level |
divider | nil | block | nil | - |
text | nil | inline | all | text |
blockquote | block+ | block | - | - |
callout | block+ | block | - | type |
code_block | nil | block | - | code |
image | nil | block | - | src |
video | nil | block | - | src |
bullet_list | list_item+ | block | - | - |
ordered_list | list_item+ | block | - | - |
list_item | block+ | list_content | - | - |
table | table_row+ | block | - | - |
table_row | table_cell+ | table_content | - | - |
table_cell | block+ | table_row_content | - | - |
row | block+ | block | - | - |
grid | block+ | block | - | - |
Marks
| Mark | Inclusive | Keep on Split | Excludes | Required Attrs |
|---|---|---|---|---|
bold | true | true | - | - |
italic | true | true | - | - |
underline | true | true | - | - |
strike | true | true | - | - |
code | false | true | link | - |
link | false | true | - | href |
subscript | true | true | superscript | - |
superscript | true | true | subscript | - |
highlight | true | true | - | color |
font_color | true | true | - | color |
mention | false | false | - | id, type, label |