# `Coelho.Schema`
[🔗](https://github.com/nseaSeb/coelho/blob/main/lib/coelho/schema.ex#L1)

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.

# `limits`

```elixir
@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`

```elixir
@type t() :: %Coelho.Schema{
  empty: map() | nil,
  fingerprint: non_neg_integer(),
  groups: %{optional(atom()) =&gt; MapSet.t(atom())},
  json: String.t() | nil,
  limits: limits(),
  mark_names: %{optional(String.t()) =&gt; atom()},
  mark_order: [atom()],
  mark_ranks: %{optional(atom()) =&gt; non_neg_integer()},
  marks: %{optional(atom()) =&gt; Coelho.Schema.MarkSpec.t()},
  node_names: %{optional(String.t()) =&gt; atom()},
  node_order: [atom()],
  nodes: %{optional(atom()) =&gt; Coelho.Schema.NodeSpec.t()},
  parse_tags: MapSet.t(String.t()) | nil,
  top_node: atom(),
  version: pos_integer() | nil
}
```

# `default`

```elixir
@spec default() :: t()
```

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

# `default_limits`

```elixir
@spec default_limits() :: limits()
```

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

# `extend`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

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

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

# `new`

```elixir
@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`

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

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

# `resolve_mark_name`

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

Resolves a mark type name coming from untrusted input.

# `resolve_node_name`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
