A rich text schema: the set of node and mark types a document may use.
The schema is the single source of truth of a Coelho document. It is
declared once in Elixir, used server side to validate and render
documents, and exported with to_json/1 to build the matching
ProseMirror schema in the browser. A document the server would reject is
therefore a document the client could not have produced.
Declaring a schema
Coelho.Schema.new(
top_node: :doc,
nodes: [
doc: [content: "block+"],
paragraph: [content: "inline*", group: "block", render: {"p", []}],
text: [group: "inline", inline: true, text: true]
],
marks: [
bold: [render: {"strong", []}]
]
)Node and mark declaration order is preserved: ProseMirror resolves default
types by position, so the first node of a group is its default. It also
fixes the order marks are stored in, which is what makes a document
canonical — see Coelho.Document.canonical/1.
A text node is injected automatically when the declaration omits it.
Bounds
Every schema carries :limits, and the defaults apply whether or not the
application thought about them:
Coelho.Schema.new(...,
limits: [max_nodes: 500, max_depth: 6, max_text_length: 20_000]
)A document arrives from the browser in a hidden form field that no
maxlength constrains, so an unbounded schema is an unbounded allocation
on input that is untrusted by definition. default_limits/0 is what a
schema gets when it says nothing; :infinity lifts a bound deliberately.
Narrowing
Several rich text fields in one application usually want different
vocabularies. restrict/2 subtracts from a schema rather than restating
it, so the narrower one cannot drift into accepting more than its parent —
see restrict/2.
Styling the editor as the page is styled
A node or mark spec may carry a :class, which is applied by the server
renderer and exported to the browser, so the writer sees the class the
public page will carry without an application writing a hook to put it
there. :editor_attrs carries DOM attributes for the editor alone.
Versions
A schema may declare a :version. Coelho.Document.validate/2 then stamps
it on every document and refuses one stamped differently, which is what
makes Coelho.migrate/2 possible: without it there is no way to tell a
document written under an older vocabulary from one that is simply wrong.
Summary
Functions
The schema Coelho ships with: paragraphs, headings, lists, quotes, code blocks, images, and the usual inline marks.
The bounds a schema is given when it does not set its own.
Adds nodes and marks to an existing schema.
Whether a node type answers to a name used in a content expression, either because it is that node, or because it belongs to that group.
The position of a mark in the schema's declaration order.
Looks up a mark spec by name, returning nil when unknown.
Builds a schema from a declaration.
Looks up a node spec by name, returning nil when unknown.
Resolves a mark type name coming from untrusted input.
Resolves a node type name coming from untrusted input.
Narrows a schema to a subset of its nodes and marks.
Exports the schema in the shape the browser side consumes to build the matching ProseMirror schema.
Types
@type limits() :: %{ max_nodes: pos_integer() | :infinity, max_depth: pos_integer() | :infinity, max_text_length: pos_integer() | :infinity }
@type t() :: %Coelho.Schema{ groups: %{optional(atom()) => MapSet.t(atom())}, limits: limits(), mark_names: %{optional(String.t()) => atom()}, mark_order: [atom()], marks: %{optional(atom()) => Coelho.Schema.MarkSpec.t()}, node_names: %{optional(String.t()) => atom()}, node_order: [atom()], nodes: %{optional(atom()) => Coelho.Schema.NodeSpec.t()}, top_node: atom(), version: pos_integer() | nil }
Functions
@spec default() :: t()
The schema Coelho ships with: paragraphs, headings, lists, quotes, code blocks, images, and the usual inline marks.
@spec default_limits() :: limits()
The bounds a schema is given when it does not set its own.
Adds nodes and marks to an existing schema.
Most applications want the default schema and one thing of their own — a mention, an embed, a callout — and re-declaring the other fifteen nodes to get there would guarantee they drift.
Coelho.Schema.extend(Coelho.Schema.default(),
nodes: [
mention: [
group: "inline",
inline: true,
void: true,
attrs: [user_id: [required: true, validate: :integer], label: [default: nil, validate: {:nullable, :string}]],
render: &MyApp.RichText.render_mention/2
]
]
)Additions keep their declaration order, after what was already there. Redeclaring an existing name replaces it, which is how the default schema's rendering or attributes get adjusted without a fork.
Whether a node type answers to a name used in a content expression, either because it is that node, or because it belongs to that group.
@spec mark_index(t(), atom()) :: non_neg_integer()
The position of a mark in the schema's declaration order.
Marks are a set, so the order they are written in carries no meaning —
which is exactly why a canonical document has to pick one. ProseMirror
ranks marks by their position in the schema, and Coelho.Document sorts
them the same way, so that the same fragment hashes the same however the
editor happened to add its marks.
@spec mark_spec(t(), atom()) :: Coelho.Schema.MarkSpec.t() | nil
Looks up a mark spec by name, returning nil when unknown.
Builds a schema from a declaration.
Raises ArgumentError when the declaration is inconsistent: an unparsable
content expression, a name no node or group answers to, an unknown mark in
a node's :marks list, or a missing top node. A schema is developer
authored, so an invalid one is a bug rather than a runtime condition.
@spec node_spec(t(), atom()) :: Coelho.Schema.NodeSpec.t() | nil
Looks up a node spec by name, returning nil when unknown.
Resolves a mark type name coming from untrusted input.
Resolves a node type name coming from untrusted input.
Never converts to an atom blindly: only names the schema already knows are resolved, so a hostile document cannot grow the atom table.
Narrows a schema to a subset of its nodes and marks.
An application with several rich text fields usually wants one vocabulary
per field — a portal blurb that is paragraphs and four marks, terms and
conditions that add headings and lists but only bold and links. Declaring
each of them with new/1 means keeping several full schemas consistent by
hand; this subtracts from one instead.
Coelho.Schema.restrict(Coelho.Schema.default(),
nodes: [:paragraph],
marks: [:bold, :link]
)Only the keys given are narrowed: leaving :nodes out keeps every node.
The top node and the text node are always kept, since a schema without
them could not hold a document at all.
Limits are narrowed the same way — a value given here applies only if it is tighter than the parent's. That is what makes the guarantee hold in both directions: a restricted schema never accepts a document its parent would reject.
Raises ArgumentError when a name is not in the parent — asking to keep
what is not there is a bug, not a narrowing — and when the narrowing
leaves a surviving node referring to something that is gone, such as
keeping bullet_list without list_item.
Exports the schema in the shape the browser side consumes to build the matching ProseMirror schema.
Nodes and marks are emitted as ordered pairs rather than objects: node order carries meaning in ProseMirror and map key order does not survive a round trip through Elixir.