Coelho.Schema (coelho v0.14.0)

Copy Markdown View Source

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

limits()

@type limits() :: %{
  max_nodes: pos_integer() | :infinity,
  max_depth: pos_integer() | :infinity,
  max_text_length: pos_integer() | :infinity,
  max_attr_length: pos_integer() | :infinity
}

t()

@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

default()

@spec default() :: t()

The schema Coelho ships with: paragraphs, headings, lists, quotes, code blocks, images, and the usual inline marks.

default_limits()

@spec default_limits() :: limits()

The bounds a schema is given when it does not set its own.

extend(schema, opts)

@spec extend(
  t(),
  keyword()
) :: t()

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.

fetch_mark_spec(schema, type)

@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.

fetch_node_spec(schema, type)

@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.

fingerprint(schema)

@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.

instance_of?(schema, name, node_type)

@spec instance_of?(t(), atom(), atom()) :: boolean()

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.

mark_allowed?(allowed, name)

@spec mark_allowed?(:all | [atom()], atom()) :: boolean()

Whether a node's :marks list admits a mark by name.

:all is the default and admits everything; a list admits what it names.

mark_index(schema, name)

@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.

mark_spec(schema, name)

@spec mark_spec(t(), atom()) :: Coelho.Schema.MarkSpec.t() | nil

Looks up a mark spec by name, returning nil when unknown.

new(opts)

@spec new(keyword()) :: t()

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.

node_spec(schema, name)

@spec node_spec(t(), atom()) :: Coelho.Schema.NodeSpec.t() | nil

Looks up a node spec by name, returning nil when unknown.

resolve_mark_name(schema, name)

@spec resolve_mark_name(t(), term()) :: {:ok, atom()} | :error

Resolves a mark type name coming from untrusted input.

resolve_node_name(schema, name)

@spec resolve_node_name(t(), term()) :: {:ok, atom()} | :error

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.

restrict(schema, opts)

@spec restrict(
  t(),
  keyword()
) :: t()

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_of(schema, arg2)

@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

to_json(schema)

@spec to_json(t()) :: map()

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.