Quillon

A pure Elixir library for structured document representation with rich text support, similar to ProseMirror/Tiptap/Slate in the JavaScript ecosystem.

Core Concepts

AST Structure

Documents are trees of nodes represented as tuples:

{type, attrs, children}
# Example
{:paragraph, %{}, [{:text, %{text: "Hello", marks: [:bold]}, []}]}

Element Types

TypeExamplesDescription
ContainerdocumentRoot node holding blocks
Blockparagraph, heading, image, table, row, gridVertical stacking elements; row and grid are flex/grid container blocks
List contentlist_itemChildren of a list
Table contenttable_row, table_cellChildren of a table and of a row
InlinetextText nodes with marks, flow within blocks

Quillon.block?/1 answers true for row and grid too - they are blocks that hold other blocks.

Marks System

Marks apply formatting to text nodes. Not markdown - structured data:

# Simple marks (atoms)
:bold, :italic, :underline, :strike, :code, :subscript, :superscript
# Marks with attributes (tuples)
{:link, %{href: "https://example.com"}}
{:highlight, %{color: "yellow"}}
{:mention, %{id: "user_123", type: "user", label: "@alice"}}

Layout & Styling

All block nodes accept optional Tailwind-inspired layout and styling tokens:

# Layout: align, width, spacing, indent, valign
Quillon.paragraph("Centered text", align: :center, spacing: :lg)
Quillon.image("/photo.jpg", "Photo", width: :wide, rounded: :lg, shadow: :md)
# Container layouts
Quillon.row([card1, card2, card3], justify: :between, gap: :md)
Quillon.grid([item1, item2, item3, item4], columns: 2, gap: :sm)
# Styling: font_size, font_weight, color, background, border, rounded, shadow, opacity
Quillon.heading(1, "Alert", color: :danger, font_weight: :bold)

All properties use constrained value sets (atoms), not arbitrary CSS. Renderers map tokens to their own design system.

Mark Configuration

OptionPurpose
inclusiveNew text at mark boundary inherits mark
keep_on_splitMark persists when Enter splits node
excludesMutually exclusive marks (e.g., code excludes link)

The default schema sets these per mark - see Mark Configuration.

Custom Node Types

Decode node types Quillon does not know about by naming them. The atoms must already exist in your application, and a :schema option takes precedence when you have one:

json = %{
"type" => "paragraph",
"attrs" => %{},
"children" => [
%{
"type" => "line",
"attrs" => %{"page" => 2},
"children" => [
%{"type" => "text", "attrs" => %{"text" => "Hello", "marks" => []}, "children" => []}
]
}
]
}
{:ok, paragraph} = Quillon.from_json(json, extra_types: [:line])
#=> {:paragraph, %{}, [{:line, %{page: 2}, [{:text, %{text: "Hello", marks: []}, []}]}]}

A custom node is a full participant in the transform layer, not just in serialization:

marked = Quillon.apply_mark(paragraph, 0, 5, :bold)
#=> {:paragraph, %{}, [{:line, %{page: 2}, [{:text, %{text: "Hello", marks: [:bold]}, []}]}]}
Quillon.range_has_mark?(marked, 0, 5, :bold)
#=> true

The transform layer treats a custom node as a transparent wrapper around the text inside it. For the exact offset, mark, split and normalization rules, see Extensibility. For the schema route - full content expressions and attribute validation for your types - see the Schema guide.

Key Algorithms

  1. Text Splitting - Split at END offset first, then START (preserves positions)
  2. Normalization - Merge adjacent text nodes with identical marks
  3. Loose Equality - Compare marks only, ignore text content when merging
  4. Schema Validation - Content expressions like "block+", "inline*"

Architecture Decisions

DecisionRationale
No Grove dependencySync is separate concern; users may not need CRDT
No LiveView dependencyKeep core pure Elixir; framework-agnostic
Extensible schemaConsumers add their own node and mark types without forking

Installation

def deps do
[
{:quillon, "~> 0.3.0"}
]
end

Package Structure

This package is the core, and it is pure Elixir. LiveView components and Grove CRDT integration ship as separate packages - see the Roadmap for what exists today.

Documentation

License

MIT