Quillon uses an AST (Abstract Syntax Tree) to represent document structures. This guide documents the architecture and extension strategies for supporting rich content elements.
Architecture Overview
AST Structure
Documents are represented as nested Elixir tuples following a strict grammar:
{:type, attrs, children}Example:
{:document, %{id: "doc_123", name: "Welcome"},
[
{:heading, %{level: 1}, [
{:text, %{text: "Hello ", marks: []}, []},
{:text, %{text: "World", marks: [:bold]}, []}
]},
{:paragraph, %{}, [
{:text, %{text: "This is a ", marks: []}, []},
{:text, %{text: "rich text", marks: [:bold, :italic]}, []},
{:text, %{text: " editor.", marks: []}, []}
]}
]}Element Categories
| Category | Types | Purpose |
|---|---|---|
| Block | heading, paragraph, blockquote, callout, code_block, divider, image, video, bullet_list, ordered_list, table, row, grid | Vertical stacking elements |
| Inline | text | Text content with marks |
| Container | document | Structural root |
| List content | list_item | Children of a list |
| Table content | table_row, table_cell | Children of a table and of a row |
Type Definitions
@container_types [:document]
@block_types [
:paragraph, :heading, :divider, :blockquote, :callout, :code_block,
:image, :video,
:bullet_list, :ordered_list,
:table,
:row, :grid
]
@list_content_types [:list_item]
@table_content_types [:table_row]
@table_row_content_types [:table_cell]
@inline_types [:text]Quillon.container?/1 is true only for :document under the default schema. A :list_item, :table_row or :table_cell belongs to its own type group, which is what lets content expressions name it.
The category predicates read the schema's groups, so pass your own schema to answer for
your own node types: Quillon.block?(node, schema), and likewise inline?/2 and
container?/2.
Key Operations
All operations are immutable - they return a new AST rather than modifying in place.
# Path-based access (indices into children)
Quillon.get(doc, [0, 2]) # Get node at path
Quillon.update(doc, [0, 2], fn) # Update node at path
Quillon.insert(doc, [0, 3], node) # Insert at position
Quillon.delete(doc, [0, 2]) # Delete node
Quillon.reorder(doc, [0], ids) # Reorder children by ID list
Quillon.move(doc, [0, 1], [1, 0]) # Move between positions
# ID-based access
Quillon.find_path(doc, "node_id") # Find path to node
Quillon.get_by_id(doc, "id") # Get node by ID
Quillon.update_by_id(doc, "id", fn) # Update by ID
# Factory functions
Quillon.new(:document, %{name: "My Doc"})
Quillon.new(:heading, %{level: 1}, "Title")
Quillon.new(:paragraph, %{}, "Some text content")
Quillon.paragraph("Some text content")new/2 takes a type and an attrs map. The string shortcut - a bare string in place of a children list, wrapped into a text node - lives on new/3 and on the per-type helpers such as Quillon.paragraph/1.
JSON Serialization
# AST → JSON
Quillon.to_json({:paragraph, %{}, [{:text, %{text: "Hello", marks: [:bold]}, []}]})
# => %{"type" => "paragraph", "attrs" => %{}, "children" => [
# %{"type" => "text", "attrs" => %{"text" => "Hello", "marks" => ["bold"]}, "children" => []}
# ]}
# JSON → AST
{:ok, doc} = Quillon.from_json(%{"type" => "paragraph", "attrs" => %{}, "children" => []})
# => {:ok, {:paragraph, %{}, []}}
# Raising variant
Quillon.from_json!(%{"type" => "paragraph", "attrs" => %{}, "children" => []})
# => {:paragraph, %{}, []}from_json/1,2 returns {:ok, node} or {:error, message}. from_json!/1,2 returns the node or raises ArgumentError.
Block Elements
Heading
{:heading, %{level: 2}, [
{:text, %{text: "Section Title", marks: []}, []}
]}| Attribute | Type | Default | Description |
|---|---|---|---|
| level | integer | required | Heading level (1-6) |
Children: inline content (text nodes with marks)
Paragraph
{:paragraph, %{}, [
{:text, %{text: "Hello ", marks: []}, []},
{:text, %{text: "world", marks: [:bold]}, []}
]}Children: inline content (text nodes with marks)
Divider
{:divider, %{style: :solid}, []}| Attribute | Type | Default | Description |
|---|---|---|---|
| style | atom | :solid | Line style (:solid, :dashed, :dotted) |
Design Goals
- Pure Elixir - No JavaScript dependencies in the core library
- Immutable operations - All transforms return new AST, never mutate
- Structured inline content - Text nodes with marks, not markdown strings
- Schema validation - Content expressions define valid structures
- Framework agnostic - Core library works without Phoenix/LiveView
- CRDT-ready - Structure supports collaborative editing (via separate package)
Block Types Reference
Block elements are content that takes up its own vertical space (stacks vertically). This is distinct from inline elements which flow within a line of text.
Block vs Inline:
┌──────────────────────────────────────┐
│ {:heading, ...} ← block │
├──────────────────────────────────────┤
│ {:paragraph, %{}, [ │
│ {:text, %{text: "Hello "}, []} │ ← inline
│ {:text, %{text: "world", │
│ marks: [:bold]}, []} │ ← inline
│ ]} ← block │
├──────────────────────────────────────┤
│ {:image, ...} ← block │
└──────────────────────────────────────┘Block Type Categories
Quillon.Types.block_types/0 returns one flat list. These informal categories group it for reading; the library draws no distinction between them.
| Category | Types |
|---|---|
| Text | heading, paragraph, blockquote, callout, code_block |
| Media | image, video |
| Lists | bullet_list, ordered_list |
| Tables | table |
| Separator | divider |
Layout containers (hold block+) | row, grid |
Blockquote
{:blockquote, %{citation: "Shakespeare"}, [
{:paragraph, %{}, [
{:text, %{text: "To be or not to be", marks: []}, []}
]}
]}| Attribute | Type | Default | Description |
|---|---|---|---|
| citation | string | nil | Attribution |
Children: any block content ("block+") - a blockquote can hold headings, lists or nested quotes, not just paragraphs
Callout
{:callout, %{type: :info, title: "Note"}, [
{:paragraph, %{}, [
{:text, %{text: "Important information", marks: []}, []}
]}
]}| Attribute | Type | Default | Description |
|---|---|---|---|
| type | atom | required | Callout type - Quillon.Types.callout_types/0 lists the conventional values (:info, :warning, :success, :error) |
| title | string | nil | Optional title |
The schema requires type but does not restrict its value, so a callout carrying
any other term still validates.
Children: any block content ("block+")
Code Block
{:code_block, %{code: "def hello, do: :world", language: "elixir"}, []}| Attribute | Type | Default | Description |
|---|---|---|---|
| code | string | required | Code content |
| language | string | "" | Syntax highlighting language |
Children: none (code stored in attrs)
Image
{:image, %{src: "/uploads/photo.jpg", alt: "Photo", caption: "A nice photo"}, []}| Attribute | Type | Default | Description |
|---|---|---|---|
| src | string | required | Image URL |
| alt | string | "" | Alt text |
| caption | string | nil | Optional caption |
| width | integer | nil | Width in pixels |
Children: none
Lists
{:bullet_list, %{}, [
{:list_item, %{}, [
{:paragraph, %{}, [{:text, %{text: "First item", marks: []}, []}]}
]},
{:list_item, %{}, [
{:paragraph, %{}, [{:text, %{text: "Second item", marks: []}, []}]}
]}
]}
{:ordered_list, %{start: 1}, [
{:list_item, %{}, [
{:paragraph, %{}, [{:text, %{text: "Step one", marks: []}, []}]}
]},
{:list_item, %{}, [
{:paragraph, %{}, [{:text, %{text: "Step two", marks: []}, []}]}
]}
]}Tables
{:table, %{}, [
{:table_row, %{header: true}, [
{:table_cell, %{}, [
{:paragraph, %{}, [{:text, %{text: "Name", marks: []}, []}]}
]},
{:table_cell, %{}, [
{:paragraph, %{}, [{:text, %{text: "Email", marks: []}, []}]}
]}
]},
{:table_row, %{}, [
{:table_cell, %{}, [
{:paragraph, %{}, [{:text, %{text: "John", marks: []}, []}]}
]},
{:table_cell, %{}, [
{:paragraph, %{}, [{:text, %{text: "john@example.com", marks: []}, []}]}
]}
]}
]}Inline Content (Rich Text)
Inline content uses text nodes with marks, similar to ProseMirror/Tiptap, Lexical, and Slate.
Text Node Structure
{:text, %{text: "content", marks: [mark1, mark2, ...]}, []}All built-in inline content uses the :text node type. Formatting (including links) is applied via marks. A consumer can introduce further inline node types - see Extensibility.
Example
# "Hello world! Visit our site for more."
# ^^^^^ bold
# ^^^^^^^^^^ link
{:paragraph, %{}, [
{:text, %{text: "Hello ", marks: []}, []},
{:text, %{text: "world", marks: [:bold]}, []},
{:text, %{text: "! Visit ", marks: []}, []},
{:text, %{text: "our site", marks: [{:link, %{href: "https://example.com"}}]}, []},
{:text, %{text: " for more.", marks: []}, []}
]}Mark Types
Marks can be simple atoms or tuples with attributes:
# Simple marks (no attributes needed)
:bold
:italic
:underline
:strike
:code
:subscript
:superscript
# Marks with attributes
{:link, %{href: "https://example.com", title: "Link title", target: "_blank"}}
{:highlight, %{color: "yellow"}}
{:font_color, %{color: "#FF5500"}}
{:mention, %{id: "user_123", type: "user", label: "@john"}}Mark Reference
| Mark | Type | Attributes | Description |
|---|---|---|---|
:bold | atom | - | Bold text |
:italic | atom | - | Italic text |
:underline | atom | - | Underlined text |
:strike | atom | - | Strikethrough |
:code | atom | - | Inline code (monospace) |
:subscript | atom | - | Subscript text |
:superscript | atom | - | Superscript text |
:link | tuple | href, title, target | Hyperlink |
:highlight | tuple | color | Background highlight |
:font_color | tuple | color | Text color |
:mention | tuple | id, type, label | User/item mention |
Complex Formatted Text
# "Hello world! Click here for more info."
# ^^^^^ bold
# ^^^^^^^^^^ bold + italic + link
{:paragraph, %{}, [
{:text, %{text: "Hello ", marks: []}, []},
{:text, %{text: "world", marks: [:bold]}, []},
{:text, %{text: "! ", marks: []}, []},
{:text, %{text: "Click here", marks: [:bold, :italic, {:link, %{href: "/info"}}]}, []},
{:text, %{text: " for more info.", marks: []}, []}
]}Why Structured Text (Not Markdown)
- Programmatic manipulation - add/remove formatting without parsing strings
- Collaborative editing - CRDT can track changes to individual text nodes
- Validation - enforce allowed marks per context
- Rendering flexibility - same structure renders to HTML, plain text, or other formats
- Cursor positioning - track cursor position within rich text
Mark Configuration
Each mark type has configuration in the schema that controls its behavior:
| Property | Description |
|---|---|
inclusive | Whether new text typed at mark boundary gets the mark |
keep_on_split | Whether mark persists when node is split (e.g., pressing Enter) |
excludes | Marks that cannot coexist with this mark |
attrs | Map of attribute specs for marks with data, e.g. %{href: %{required: true}} |
Code is not exclusive with the other formatting marks: bold and code coexist on the same text node. Its only exclusion is :link.
For the values the default schema assigns to each mark, see Default Schema Configuration.
Text Transforms
Mark manipulation lives in Quillon.Transform, surfaced on the Quillon facade. apply_mark/4, remove_mark/4 and toggle_mark/4 operate on a :paragraph or a :heading - the block types whose children are inline content - and raise for any other node type.
para = {:paragraph, %{}, [{:text, %{text: "Hello world", marks: []}, []}]}
Quillon.apply_mark(para, 0, 5, :bold)
# => {:paragraph, %{}, [
# {:text, %{text: "Hello", marks: [:bold]}, []},
# {:text, %{text: " world", marks: []}, []}
# ]}Text Splitting Algorithm
When applying a mark to a selection, text nodes are split at the selection boundaries. Quillon.Transform.Split does the splitting: split_range/3 splits at the END offset first, so the START offset still points where it did, then splits at START.
Quillon.split_range([{:text, %{text: "Hello world", marks: []}, []}], 2, 7)
# => [
# {:text, %{text: "He", marks: []}, []},
# {:text, %{text: "llo w", marks: []}, []},
# {:text, %{text: "orld", marks: []}, []}
# ]split_at_offset/2 finds the node containing the offset and splits it, preserving marks on both halves. An offset that lands on a node boundary, or past the end of the content, leaves the list unchanged.
Only text nodes split. When the node at the offset is of another type - a custom type introduced through extra_types:, say - the offset is clamped and the list comes back unchanged, because splitting that node would need a schema the library does not have:
Quillon.split_at_offset([{:line, %{page: 2}, [Quillon.text("Hello")]}], 3)
# => [{:line, %{page: 2}, [{:text, %{text: "Hello", marks: []}, []}]}]Normalization Algorithm
After every edit, normalize the block to remove empty text nodes and merge adjacent text nodes with identical marks. Quillon.Transform.Normalize does the work; normalize/1 on a children list, normalize_block/1 on a block. The transforms above already normalize their output.
Quillon.normalize({:paragraph, %{}, [
{:text, %{text: "Hello", marks: [:bold]}, []},
{:text, %{text: " world", marks: [:bold]}, []},
{:text, %{text: "", marks: []}, []}
]})
# => {:paragraph, %{}, [{:text, %{text: "Hello world", marks: [:bold]}, []}]}Quillon.normalize/1 accepts a :paragraph or a :heading and raises for any other node type: those are the blocks whose children are inline text, and merging the children of a container is a different operation. A child that is not a text node passes through untouched.
Mark ordering and equality live in Quillon.Transform.MarkOrder. Marks are sorted before comparison so that equality does not depend on the order they were applied:
Quillon.sort_marks([{:link, %{href: "/"}}, :italic, :bold])
# => [:bold, :italic, {:link, %{href: "/"}}]
Quillon.Transform.marks_equal?([:bold, :italic], [:italic, :bold])
# => trueThe priority map covers six marks; every other mark sorts last, alphabetically by name:
@mark_priority %{bold: 0, italic: 1, underline: 2, strike: 3, code: 4, link: 5}Toggle Mark Command
Quillon.toggle_mark/4 checks whether the mark is already active across the range and removes it if so, applies it otherwise:
Quillon.toggle_mark({:paragraph, %{}, [{:text, %{text: "Hello", marks: [:bold]}, []}]}, 0, 5, :bold)
# => {:paragraph, %{}, [{:text, %{text: "Hello", marks: []}, []}]}Quillon.Commands layers per-mark helpers over it - toggle_bold/3, toggle_italic/3, toggle_code/3, set_link/4, unset_link/3, set_highlight/4, set_mention/4, clear_formatting/3, and so on:
Quillon.Commands.toggle_bold({:paragraph, %{}, [{:text, %{text: "Hello world", marks: []}, []}]}, 0, 5)
# => {:paragraph, %{}, [
# {:text, %{text: "Hello", marks: [:bold]}, []},
# {:text, %{text: " world", marks: []}, []}
# ]}The active check is Quillon.range_has_mark?/4, also exposed as Quillon.Commands.selection_has_mark?/4. It splits at the range boundaries first, then asks whether every text node in range carries the mark. Three details matter:
- Only nodes fully inside the range count, not every node the range touches.
- Text nested inside a node of another type counts; the wrapping node itself carries no marks and is ignored.
- A range containing no text at all is
false, not vacuously true.
Extensibility
A consumer can introduce node and mark types beyond the built-in ones. Quillon.from_json/2 takes three options:
| Option | Meaning |
|---|---|
:schema | A Quillon.Schema. Node types are validated against its node keys and marks against its mark keys. Takes precedence over the two below. |
:extra_types | Extra atom node types to accept beyond the built-in types |
:extra_marks | Extra atom mark types to accept beyond the built-in marks |
The atoms must already exist in the atom table - decoding uses String.to_existing_atom/1 so that untrusted JSON cannot exhaust it.
json = %{"type" => "paragraph", "attrs" => %{}, "children" => [
%{"type" => "line", "attrs" => %{"page" => 2}, "children" => [
%{"type" => "text", "attrs" => %{"text" => "Hello", "marks" => []}, "children" => []}
]}
]}
{:ok, para} = Quillon.from_json(json, extra_types: [:line])
# => {:ok, {:paragraph, %{}, [{:line, %{page: 2}, [{:text, %{text: "Hello", marks: []}, []}]}]}}A custom node survives more than serialization. How the transform layer treats it depends on whether its text belongs to the surrounding sentence.
Interior flow
A block cannot sit inside an inline text run - that is what makes it a block. So a
node whose children are blocks holds a flow of its own, and one whose children are
inline is part of its parent's. Quillon.Transform.Position.own_flow?/1 derives this
from the document; nothing declares it.
Quillon.Transform.Position.own_flow?({:line, %{page: 2}, [Quillon.text("Hi")]})
# => false - child is inline, so this is part of the sentence
Quillon.Transform.Position.own_flow?({:footnote, %{}, [Quillon.paragraph("See p. 412.")]})
# => true - child is a block, so this has its own flowTwo cases the structure cannot answer are settled by an atomic: true attribute,
which declares the answer and takes precedence over the inference.
# A childless node has no children to infer from. Declaring it atomic gives it
# one position, so a marker can be selected instead of being zero-width.
{:footnote_ref, %{id: "fn1", atomic: true}, []}
# A node holding custom blocks reads as transparent, because an unregistered
# type is not a known block type. The consumer can say otherwise.
{:card, %{atomic: true}, [{:my_block, %{}, []}]}A node that joins its parent's flow
The transform layer treats it as a transparent wrapper around the text inside it.
- Offsets see through it.
node_length/1sums its children, so it contributes the length of the text it wraps and that text stays addressable by a flat offset. - Marks recurse into it. Mark application walks into its children with offsets
rebased to its start, rewriting only
:textdescendants. Its own type and attrs are left alone. range_has_mark?/4counts the text inside it and ignores the node itself, which carries no marks of its own.
Quillon.apply_mark(para, 0, 5, :bold)
# => {:paragraph, %{}, [{:line, %{page: 2}, [{:text, %{text: "Hello", marks: [:bold]}, []}]}]}A node that holds its own flow
It occupies exactly one position in its parent, and its interior is addressed separately rather than as part of the surrounding sentence.
node_length/1returns 1, so the sentence measures what a reader sees plus one position for the node itself.- Marks stop at its boundary. A mark applied to the parent's range never rewrites its interior.
range_has_mark?/4ignores the text inside it, so an unmarked footnote does not make a fully marked sentence answerfalse.
footnote = {:footnote, %{}, [Quillon.paragraph("See Rabuya, p. 412.")]}
sentence = {:paragraph, %{}, [Quillon.text("The doctrine applies"), footnote,
Quillon.text(" here.")]}
Quillon.Transform.total_length(elem(sentence, 2))
# => 27 - the 26 characters a reader sees, plus one position for the markerBoth kinds
- They do not split. An offset landing inside a custom node is clamped rather than split, since splitting would need that node's schema.
- Normalization passes them through. They are never merged with a neighbour and never discarded as empty.
- A childless custom node measures 0 unless it declares
atomic: true. With no children to inspect, structure cannot say whether it is a selectable marker or invisible, so the default is invisible and the node opts in.
Schema Validation
Schema validation ensures documents conform to valid structures, similar to ProseMirror's schema system.
Quillon.validate(doc) # Returns {:ok, doc} or {:error, errors}
Quillon.validate!(doc) # Returns doc or raises ArgumentErrorGroups
Groups simplify content rules by categorizing node types:
@groups %{
# Block-level content, sourced from Quillon.Types.block_types()
block: Types.block_types(),
# Inline content (text with marks)
inline: Types.inline_types(),
# List items
list_content: Types.list_content_types(),
# Table rows, and the cells within a row
table_content: Types.table_content_types(),
table_row_content: Types.table_row_content_types()
}Content Expressions
ProseMirror-style content expressions for declarative rules:
| Expression | Meaning |
|---|---|
"block+" | One or more block nodes |
"block*" | Zero or more block nodes |
"inline*" | Zero or more inline nodes (text) |
"paragraph" | Exactly one paragraph |
"(paragraph | heading)+" | One or more paragraphs or headings |
"paragraph block*" | One paragraph followed by zero or more blocks |
Node Schema
@node_schema %{
# Root type
document: %{
content: "block+"
},
# Text container blocks
paragraph: %{
content: "inline*",
group: :block,
marks: :all
},
heading: %{
content: "inline*",
group: :block,
marks: :all,
attrs: [:level]
},
blockquote: %{
content: "block+",
group: :block,
attrs: [:citation]
},
callout: %{
content: "block+",
group: :block,
attrs: [:type, :title]
},
code_block: %{
content: nil,
group: :block,
marks: [],
attrs: [:code, :language]
},
divider: %{
content: nil,
group: :block,
attrs: [:style]
},
# Lists
bullet_list: %{
content: "list_item+",
group: :block
},
ordered_list: %{
content: "list_item+",
group: :block,
attrs: [:start]
},
list_item: %{
content: "block+",
group: :list_content
},
# Tables
table: %{
content: "table_row+",
group: :block
},
table_row: %{
content: "table_cell+",
group: :table_content,
attrs: [:header]
},
table_cell: %{
content: "block+",
group: :table_row_content,
attrs: [:colspan, :rowspan, :align, :valign, :background, :border]
},
# Layout containers
row: %{
content: "block+",
group: :block,
attrs: [:justify, :items, :wrap, :gap]
},
grid: %{
content: "block+",
group: :block,
attrs: [:columns, :gap]
},
# Media
image: %{
content: nil,
group: :block,
attrs: [:src, :alt, :caption, :width],
required_attrs: [:src]
},
video: %{
content: nil,
group: :block,
attrs: [:src, :poster],
required_attrs: [:src]
},
# Inline text node
text: %{
content: nil,
group: :inline,
attrs: [:text, :marks]
}
}Mark Schema
Defines what marks exist and their behavior:
@mark_schema %{
# Simple formatting marks
bold: %{
inclusive: true,
keep_on_split: true,
excludes: [],
attrs: []
},
italic: %{
inclusive: true,
keep_on_split: true,
excludes: [],
attrs: []
},
underline: %{
inclusive: true,
keep_on_split: true,
excludes: [],
attrs: []
},
strike: %{
inclusive: true,
keep_on_split: true,
excludes: [],
attrs: []
},
code: %{
inclusive: false,
keep_on_split: true,
excludes: [:link], # code and link can't coexist
attrs: []
},
subscript: %{
inclusive: true,
keep_on_split: true,
excludes: [:superscript], # can't be both
attrs: []
},
superscript: %{
inclusive: true,
keep_on_split: true,
excludes: [:subscript],
attrs: []
},
# Marks with attributes
link: %{
inclusive: false, # typing at end doesn't extend link
keep_on_split: true,
excludes: [],
attrs: [:href, :title, :target],
required_attrs: [:href]
},
highlight: %{
inclusive: true,
keep_on_split: true,
excludes: [],
attrs: [:color],
required_attrs: [:color]
},
font_color: %{
inclusive: true,
keep_on_split: true,
excludes: [],
attrs: [:color],
required_attrs: [:color]
},
mention: %{
inclusive: false,
keep_on_split: false,
excludes: [],
attrs: [:id, :type, :label],
required_attrs: [:id, :type, :label]
}
}Mark Allowance per Node
Some nodes restrict which marks are allowed:
Quillon.Schema.allowed_marks/2 returns the :marks key of a node spec: :all, a list of mark types, or nil for a node that allows no marks.
schema = Quillon.Schema.default()
Quillon.Schema.allowed_marks(schema, :paragraph) # => :all
Quillon.Schema.allowed_marks(schema, :heading) # => :all
Quillon.Schema.allowed_marks(schema, :divider) # => nilValidation Rules
Quillon.Schema.Validator walks the tree and collects every error it finds, rather than stopping at the first. For each node it checks that the type exists in the schema, that required attributes are present, that the children match the content expression, and - on text nodes - that each mark exists, is allowed, carries its required attributes, and does not conflict with a sibling mark.
Content expressions are parsed and matched by Quillon.Schema.ContentExpr. parse/1 turns a string into a structured form:
Quillon.Schema.ContentExpr.parse("block+") # => {:one_or_more, :block}
Quillon.Schema.ContentExpr.parse("inline*") # => {:zero_or_more, :inline}
Quillon.Schema.ContentExpr.parse("paragraph") # => {:one, :paragraph}
Quillon.Schema.ContentExpr.parse("(paragraph | heading)+")
# => {:choice, [:paragraph, :heading], :one_or_more}A whitespace-separated expression parses to {:seq, elements}. matches?/3 then matches a list of child types against the parsed form, resolving group names through the schema's groups:
expr = Quillon.Schema.ContentExpr.parse("block+")
Quillon.Schema.ContentExpr.matches?(expr, [:paragraph], Quillon.Schema.default().groups)
# => trueThe grammar understands a bare name, +, *, and a parenthesised choice with an optional quantifier. There is no ? quantifier.
Mark rules are answered by Quillon.Schema:
schema = Quillon.Schema.default()
Quillon.Schema.mark_allowed?(schema, :paragraph, :bold) # => true
Quillon.Schema.mark_allowed?(schema, :divider, :bold) # => false
Quillon.Schema.marks_conflict?(schema, :code, :link) # => true
Quillon.Schema.marks_conflict?(schema, :bold, :italic) # => falseValidation Errors
validate/1,2 returns {:error, errors}, where each error is a map with a path (child indices from the validated node), a type, and a message.
# Invalid: list_item outside list
Quillon.validate({:paragraph, %{}, [{:list_item, %{}, [Quillon.paragraph("x")]}]})
# => {:error, [
# %{path: [], type: :invalid_content,
# message: "Children don't match content expression: inline*"}
# ]}
# Invalid: heading without its required level
Quillon.validate({:heading, %{}, []})
# => {:error, [
# %{path: [], type: :missing_attr,
# message: "Missing required attribute: level"}
# ]}
# Invalid: conflicting marks (subscript + superscript)
Quillon.validate({:text, %{text: "x", marks: [:subscript, :superscript]}, []})
# => {:error, [
# %{path: [], type: :mark_conflict,
# message: "Marks subscript and superscript conflict"},
# %{path: [], type: :mark_conflict,
# message: "Marks superscript and subscript conflict"}
# ]}
# Invalid: link without href
Quillon.validate({:text, %{text: "click", marks: [{:link, %{title: "Link"}}]}, []})
# => {:error, [
# %{path: [], type: :missing_attr,
# message: "Mark link missing required attribute: href"}
# ]}Error types are :malformed_node, :malformed_mark, :unknown_type, :invalid_content, :missing_attr, :mark_not_allowed, :mark_conflict and :unknown_mark. A malformed term is one the library cannot read at all; an unknown one is well-formed but not registered in the schema. validate!/1,2 formats the same errors into a message and raises ArgumentError.
JSON Serialization
{
"type": "document",
"attrs": { "id": "doc_123", "name": "My Document" },
"children": [
{
"type": "heading",
"attrs": { "level": 1 },
"children": [
{ "type": "text", "attrs": { "text": "Hello", "marks": [] }, "children": [] }
]
},
{
"type": "paragraph",
"attrs": {},
"children": [
{ "type": "text", "attrs": { "text": "Hello ", "marks": [] }, "children": [] },
{ "type": "text", "attrs": { "text": "world", "marks": ["bold"] }, "children": [] }
]
}
]
}Mark serialization:
- Simple marks:
"bold","italic","code" - Marks with attrs:
{ "type": "link", "attrs": { "href": "..." } }
Comparison with JS Editors
| Feature | Quillon | ProseMirror/Tiptap | Lexical | Slate |
|---|---|---|---|---|
| Structure | Elixir tuples | JS objects | JS classes | JS objects |
| Immutable | Yes (language native) | No (mutable DOM) | Yes | Yes |
| Block types | Schema-defined | Schema-defined | Node classes | Element types |
| Inline formatting | Structured nodes + marks | Mark system | Format states | Leaf nodes |
| Collaboration | CRDT-ready (separate pkg) | Yjs plugin | Yjs plugin | Yjs plugin |
| Server-side | Native Elixir | N/A | N/A | N/A |
Quillon advantages:
- Native Elixir immutability (no runtime overhead)
- Same structure on client and server
- JSON serialization built-in
- Server-side rendering without JS dependency
- Framework agnostic (works without Phoenix/LiveView)