Document Model Architecture

Copy Markdown View Source

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

CategoryTypesPurpose
Blockheading, paragraph, blockquote, callout, code_block, divider, image, video, bullet_list, ordered_list, table, row, gridVertical stacking elements
InlinetextText content with marks
ContainerdocumentStructural root
List contentlist_itemChildren of a list
Table contenttable_row, table_cellChildren 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: []}, []}
]}
AttributeTypeDefaultDescription
levelintegerrequiredHeading 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}, []}
AttributeTypeDefaultDescription
styleatom:solidLine style (:solid, :dashed, :dotted)

Design Goals

  1. Pure Elixir - No JavaScript dependencies in the core library
  2. Immutable operations - All transforms return new AST, never mutate
  3. Structured inline content - Text nodes with marks, not markdown strings
  4. Schema validation - Content expressions define valid structures
  5. Framework agnostic - Core library works without Phoenix/LiveView
  6. 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.

CategoryTypes
Textheading, paragraph, blockquote, callout, code_block
Mediaimage, video
Listsbullet_list, ordered_list
Tablestable
Separatordivider
Layout containers (hold block+)row, grid

Blockquote

{:blockquote, %{citation: "Shakespeare"}, [
  {:paragraph, %{}, [
    {:text, %{text: "To be or not to be", marks: []}, []}
  ]}
]}
AttributeTypeDefaultDescription
citationstringnilAttribution

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: []}, []}
  ]}
]}
AttributeTypeDefaultDescription
typeatomrequiredCallout type - Quillon.Types.callout_types/0 lists the conventional values (:info, :warning, :success, :error)
titlestringnilOptional 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"}, []}
AttributeTypeDefaultDescription
codestringrequiredCode content
languagestring""Syntax highlighting language

Children: none (code stored in attrs)

Image

{:image, %{src: "/uploads/photo.jpg", alt: "Photo", caption: "A nice photo"}, []}
AttributeTypeDefaultDescription
srcstringrequiredImage URL
altstring""Alt text
captionstringnilOptional caption
widthintegernilWidth 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

MarkTypeAttributesDescription
:boldatom-Bold text
:italicatom-Italic text
:underlineatom-Underlined text
:strikeatom-Strikethrough
:codeatom-Inline code (monospace)
:subscriptatom-Subscript text
:superscriptatom-Superscript text
:linktuplehref, title, targetHyperlink
:highlighttuplecolorBackground highlight
:font_colortuplecolorText color
:mentiontupleid, type, labelUser/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)

  1. Programmatic manipulation - add/remove formatting without parsing strings
  2. Collaborative editing - CRDT can track changes to individual text nodes
  3. Validation - enforce allowed marks per context
  4. Rendering flexibility - same structure renders to HTML, plain text, or other formats
  5. Cursor positioning - track cursor position within rich text

Mark Configuration

Each mark type has configuration in the schema that controls its behavior:

PropertyDescription
inclusiveWhether new text typed at mark boundary gets the mark
keep_on_splitWhether mark persists when node is split (e.g., pressing Enter)
excludesMarks that cannot coexist with this mark
attrsMap 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])
# => true

The 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:

OptionMeaning
:schemaA Quillon.Schema. Node types are validated against its node keys and marks against its mark keys. Takes precedence over the two below.
:extra_typesExtra atom node types to accept beyond the built-in types
:extra_marksExtra 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 flow

Two 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/1 sums 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 :text descendants. Its own type and attrs are left alone.
  • range_has_mark?/4 counts 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/1 returns 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?/4 ignores the text inside it, so an unmarked footnote does not make a fully marked sentence answer false.
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 marker

Both 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 ArgumentError

Groups

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:

ExpressionMeaning
"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)    # => nil

Validation 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)
# => true

The 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)   # => false

Validation 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

FeatureQuillonProseMirror/TiptapLexicalSlate
StructureElixir tuplesJS objectsJS classesJS objects
ImmutableYes (language native)No (mutable DOM)YesYes
Block typesSchema-definedSchema-definedNode classesElement types
Inline formattingStructured nodes + marksMark systemFormat statesLeaf nodes
CollaborationCRDT-ready (separate pkg)Yjs pluginYjs pluginYjs plugin
Server-sideNative ElixirN/AN/AN/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)