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.
Build it once. Everything derived from a schema is derived when it is built — the parsed content expressions, the group and name tables, the JSON the editor receives, the fingerprint, the empty document — so a schema is a value to keep, in a module attribute where it costs compile time and nothing after. Building one per call, inside a function that renders, pays all of that per rendered document.
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,
max_attr_length: 2_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.
:max_text_length counts the text a writer typed — the number the
editor's counter shows — and :max_attr_length bounds each attribute
value, which that count deliberately does not see. :max_depth applies to
an attribute's value as well as to the tree, for a schema declaring an
attribute with no validator: those accept anything JSON can express.
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.
The spec of a mark type named as it is written in a document, or :error.
The spec of a node type named as it is written in a document, or :error.
A stable number identifying this schema's exported shape.
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.
Whether a node's :marks list admits a mark by name.
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.
The spec of the node a document node names, or :error.
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, max_attr_length: pos_integer() | :infinity }
@type t() :: %Coelho.Schema{ empty: map() | nil, fingerprint: non_neg_integer(), groups: %{optional(atom()) => MapSet.t(atom())}, json: String.t() | nil, limits: limits(), mark_names: %{optional(String.t()) => atom()}, mark_order: [atom()], mark_ranks: %{optional(atom()) => non_neg_integer()}, marks: %{optional(atom()) => Coelho.Schema.MarkSpec.t()}, node_names: %{optional(String.t()) => atom()}, node_order: [atom()], nodes: %{optional(atom()) => Coelho.Schema.NodeSpec.t()}, parse_tags: MapSet.t(String.t()) | nil, 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 a name
Redeclaring an existing name adjusts it: what the declaration names is
taken from the declaration, and what it leaves out is kept from the spec
already there. Giving the shipped bold the class of a theme is a line,
and it keeps the parse: ~w(strong b) it was shipped with:
Coelho.Schema.extend(schema, marks: [bold: [class: "font-bold"]])A whole declaration key is the unit — attrs: [level: …] replaces the
attribute map rather than merging into it, because an attribute that can
only be added and never taken away is not an override.
@spec fetch_mark_spec(t(), term()) :: {:ok, Coelho.Schema.MarkSpec.t()} | :error
The spec of a mark type named as it is written in a document, or :error.
@spec fetch_node_spec(t(), term()) :: {:ok, Coelho.Schema.NodeSpec.t()} | :error
The spec of a node type named as it is written in a document, or :error.
@spec fingerprint(t()) :: non_neg_integer()
A stable number identifying this schema's exported shape.
What telemetry metadata carries instead of the schema itself — a schema is a few kilobytes of specs and parse rules, and handing every handler a copy of it on every keystroke is not a measurement — and what the editor stamps on its element so the browser notices the schema moved.
Two schemas exporting the same JSON have the same fingerprint, whichever way they were built.
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.
Whether a node's :marks list admits a mark by name.
:all is the default and admits everything; a list admits what it names.
@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.
@spec spec_of(t(), term()) :: {:ok, Coelho.Schema.NodeSpec.t()} | :error
The spec of the node a document node names, or :error.
The question every walk of a document asks first, and the one place the
three steps it takes live: the "type" has to be there and be a string, it
has to name something this schema declares, and the answer is that
declaration. Written out at the call site — which it was, in six of them —
each copy gets to disagree about what a missing type means.
iex> {:ok, spec} = Coelho.Schema.spec_of(Coelho.Schema.default(), %{"type" => "paragraph"})
iex> spec.name
:paragraph
iex> Coelho.Schema.spec_of(Coelho.Schema.default(), %{"type" => "marquee"})
:error
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.
A :render and a :parse that are declarations rather than functions —
{"mark", []}, parse: ["mark"] — are exported too, as renderDOM and
parseDOM. That is what lets a mark an application added show up in the
editor without a line of JavaScript: the browser builds its toDOM and
parseDOM from them when nothing was passed to createCoelhoHook. A
render function cannot be exported, and neither can a parse rule that
extracts with one; those still need their browser half declared by hand.