Coelho.Schema (coelho v0.3.1)

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.

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

limits()

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

t()

@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

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 an existing name replaces it, which is how the default schema's rendering or attributes get adjusted without a fork.

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

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.