Packages

A pure Elixir library for structured document representation with rich text support

Current section

Files

Jump to
quillon README.md
Raw

README.md

# 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:
```elixir
{type, attrs, children}
# Example
{:paragraph, %{}, [{:text, %{text: "Hello", marks: [:bold]}, []}]}
```
### Element Types
| Type | Examples | Description |
|------|----------|-------------|
| **Container** | `document` | Root node holding blocks |
| **Block** | `paragraph`, `heading`, `image`, `table`, `row`, `grid` | Vertical stacking elements; `row` and `grid` are flex/grid container blocks |
| **List content** | `list_item` | Children of a list |
| **Table content** | `table_row`, `table_cell` | Children of a table and of a row |
| **Inline** | `text` | Text 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:
```elixir
# 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:
```elixir
# 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
| Option | Purpose |
|--------|---------|
| `inclusive` | New text at mark boundary inherits mark |
| `keep_on_split` | Mark persists when Enter splits node |
| `excludes` | Mutually exclusive marks (e.g., `code` excludes `link`) |
The default schema sets these per mark - see [Mark Configuration](guides/marks.md#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:
```elixir
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:
```elixir
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](guides/document_model.md#extensibility). For the schema route - full content expressions and attribute validation for your types - see the [Schema guide](guides/schema.md).
## Rendering to HTML
`Quillon.to_html/2` turns a document into HTML, escaping text and attribute values
as it goes:
```elixir
Quillon.to_html(Quillon.document([Quillon.paragraph("Hello")]))
#=> ~s(<div class="quillon"><p>Hello</p></div>)
```
Layout and styling tokens become `data-*` attributes by default, so any stylesheet
can target them without the core library taking a position on CSS:
```elixir
Quillon.to_html(Quillon.paragraph("Centered", align: :center))
#=> ~s(<p data-align="center">Centered</p>)
```
To map tokens onto a design system, pass a `Quillon.HTML.Classes` module or a
two-argument function. `Quillon.HTML.Tailwind` ships as a working example:
```elixir
Quillon.to_html(doc, classes: Quillon.HTML.Tailwind)
#=> ~s(<p class="leading-relaxed text-center">Centered</p>)
```
Custom node and mark types degrade rather than disappear - an unknown node becomes a
`<span data-node="...">` when its content is inline and a `<div>` otherwise.
## Selection
A selection says where you are: a path to a block plus an offset inside it. Editing
goes through `Quillon.Edit`, which resolves the selection and answers with the
document *and the caret it left behind*:
```elixir
doc = Quillon.document([Quillon.paragraph("HelloWorld")])
{:ok, doc, selection} = Quillon.split_block(doc, Quillon.cursor([0], 5))
Quillon.to_html(doc)
#=> ~s(<div class="quillon"><p>Hello</p><p>World</p></div>)
```
Carets live beside the document rather than inside it, so several people's cursors
are a plain map of selections. `Quillon.to_html/2` draws them on request:
```elixir
Quillon.to_html(doc, cursors: [%{label: "alice", selection: Quillon.cursor([0], 5)}])
```
See the [Selection guide](guides/selection.md) for the full picture, including what
is deliberately not built yet.
## 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
| Decision | Rationale |
|----------|-----------|
| No Grove dependency | Sync is separate concern; users may not need CRDT |
| No LiveView dependency | Keep core pure Elixir; framework-agnostic |
| Extensible schema | Consumers add their own node and mark types without forking |
| Tokens, not CSS | Renderers map constrained tokens to their own design system |
## Installation
```elixir
def deps do
[
{:quillon, "~> 0.4.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](guides/roadmap.md) for what exists today.
## Documentation
- [Getting Started](guides/getting_started.md) - Build and edit your first document
- [Cheatsheet](guides/cheatsheet.md) - The API at a glance
- [Marks](guides/marks.md) - Mark semantics and configuration
- [Selection](guides/selection.md) - Cursors, ranges, and editing through them
- [Schema](guides/schema.md) - Content expressions, custom node and mark types
- [Cookbook](guides/cookbook.md) - Recipes for common editing tasks
- [Document Model](guides/document_model.md) - Full architecture spec
- [Roadmap](guides/roadmap.md) - Milestones and what is built so far
## License
MIT