Coelho.Document (coelho v0.14.0)

Copy Markdown View Source

Validation, normalisation and plain text extraction of documents.

A document is a plain map tree with string keys, exactly as it comes out of Jason.decode/1 or out of ProseMirror's toJSON():

%{
  "type" => "doc",
  "content" => [
    %{
      "type" => "paragraph",
      "content" => [
        %{"type" => "text", "text" => "hello", "marks" => [%{"type" => "bold"}]}
      ]
    }
  ]
}

No struct wraps it, so what is validated is what is stored, and a jsonb round trip is the identity.

Two boundaries, not one

validate/2 is the boundary at the keyboard, and it is strict on purpose: an unknown node type, an unknown mark, an unknown attribute or an attribute failing its validator all reject the document, and say where. Nothing outside the schema reaches the database, so rendering never has to escape its way out of untrusted markup. This is what storing the document buys over storing HTML and filtering tags on the way in.

sanitize/2 is the boundary at the screen. Stored documents are not re-validated when they are read, so a row written under a looser schema, or by a direct SQL write, is not covered by the paragraph above. Put it through sanitize/2 before rendering it anywhere a reader will see.

Validation is also normalisation

What comes back from validate/2 is canonical: the same rich text always produces the same document, byte for byte, which is what makes hash/2 worth storing.

  • marks are sorted into the schema's declaration order, not the order the editor happened to add them in
  • an attribute left at its schema default is not written out, so two editors that disagree on whether to send align: "left" still store the same thing
  • adjacent text nodes carrying the same marks are merged

A renderer therefore reads attributes with the schema default in hand — Coelho.Render.attr/3 is the one place that knows the shape.

Untrusted input

validate/2 is the boundary a hostile document hits first, so it is written to survive one: node type names are resolved against the schema rather than converted to atoms, the schema's :limits are checked before anything is allocated, and error paths are accumulated in reverse so that validating a deep document stays linear in its size.

What is not a document

nil, "" and any other non-map are rejected with a single error, expected an object, on the empty path. %{} is rejected with missing "type". None of them raise, and none of them are quietly treated as the empty document — Coelho.empty/1 is how you ask for that. Casting a form field is the one place where an empty string means "no document", and Coelho.Ecto.Type and Coelho.Ash.Type handle it there, before validation.

Summary

Functions

Whether a document would put anything on the page.

A byte-for-byte stable serialisation of a document.

The hex digest of canonical/1, or nil for a document with nothing in it.

Turns any term into a document the schema accepts, without failing.

The number of characters a writer typed.

Extracts the plain text of a document, for full text search.

Validates and normalises a document against a schema.

Functions

blank?(document, schema \\ Schema.default())

@spec blank?(term(), Coelho.Schema.t()) :: boolean()

Whether a document would put anything on the page.

What an application asks before deciding to render a block at all — a portal panel, an announcement, a set of opening hours — where an empty document should mean the block is not there rather than a heading with nothing under it.

The obvious stand-in, text_length(document) == 0, is wrong, and wrong in the direction that loses content: a document holding one image, or one attachment, has no text and is very much not blank. So the question is put to the schema instead — a node it declares void: true renders an element of its own and counts, whatever text it has none of.

Coelho.blank?(page.intro_doc, MyApp.RichText.schema())

Blank means: no text anywhere, and no void node with anything to show. Empty paragraphs and empty lists are blank, however many of them there are, and so is a paragraph of nothing but hard breaks — an inline void node declaring no attributes is punctuation between words, which is what a pasted-then-emptied field usually leaves behind. An image or an attachment has a source to point at, and a horizontal rule draws a line; all three count.

This is a narrower question than the one hash/2 answers with nil, which is "was there anything to agree to" and needs no schema. The two differ only on a document whose whole content is an attribute-less void node — a horizontal rule and nothing else is blank to hash/2 and not blank here, because it does put a line on the page.

canonical(document)

@spec canonical(term()) :: binary()

A byte-for-byte stable serialisation of a document.

Two documents describing the same rich text serialise identically, which is what hash/2 needs and what a plain JSON encoding cannot promise: map key order is not part of a map, and jsonb reorders keys of its own accord.

Three things make it stable, and all three are already true of a document validate/2 returned:

  • object keys are emitted in sorted order
  • marks are in the schema's declaration order, not the order the editor added them
  • attributes left at their schema default are absent, not written out

Which is why this must be given a validated document. Serialising what came back from the database instead — where a jsonb round trip has reordered the keys and an older writer may have spelled the defaults out — answers a different question, and answers it differently on two rows that hold the same text.

hash(document, algorithm \\ :sha256)

@spec hash(term(), :sha256 | :sha512 | :sha384 | :sha224 | :sha) :: String.t() | nil

The hex digest of canonical/1, or nil for a document with nothing in it.

What makes a proof of acceptance hold: store the digest of the terms the reader agreed to, and a later document that hashes the same is the same document, whatever the editor or the database did to the key order in between.

iex> document = %{"type" => "doc", "content" => [
...>   %{"type" => "paragraph", "content" => [%{"type" => "text", "text" => "hi"}]}
...> ]}
iex> {:ok, document} = Coelho.validate(document)
iex> Coelho.Document.hash(document)
"00dc4439f0dcbb463ab186b5b8f81b68e50d70a7b1e3538b86a13e532a17a65d"

A document is empty when it holds no text and no node carrying attributes, and nil is what every such document answers — an empty paragraph, a top node with no children, a document whose only content is a horizontal rule, three hard breaks in a row, an empty bullet with an empty item. They are different documents and they draw different pages; as digests they are one thing, which is "nothing was agreed to".

So nil == nil is not "the same document": an application comparing a stored digest with a fresh one has to decide what an absent digest means before it compares. Any document with a word in it has a digest of its own, and no empty one can collide with it.

Hash a validated document, for the reason canonical/1 gives.

sanitize(document, schema, opts \\ [])

@spec sanitize(term(), Coelho.Schema.t(), keyword()) :: map()

Turns any term into a document the schema accepts, without failing.

validate/2 is the boundary at the keyboard: it says no, and says where. This is the boundary at the screen. Stored documents are not re-validated when they are read — Coelho.Ecto.Type deliberately trusts the column — so a row written under a looser schema, by a direct SQL write, or by a version of the application that has since tightened its vocabulary, would otherwise reach a public page unchecked.

Nothing is reported and nothing is raised: what falls outside the schema is removed, and what is left is a document validate/2 accepts. A hostile document becomes a poor document, never an unexpected rendering.

What removal means, from the gentlest repair to the harshest:

  • a key the schema does not know is dropped
  • an attribute failing its validator is dropped, so the schema default applies — a heading claiming level: 99 renders as a level 1 heading
  • a mark that is unknown, not allowed here, or whose own attributes fail is dropped, and the text it covered stays — a link with a javascript: href becomes plain text
  • a node whose type is unknown, or whose content cannot satisfy its content expression, is dropped whole, along with the text inside it
  • a document over the schema's bounds is cut to fit them: text past :max_text_length is truncated, nodes past :max_nodes are cut off, and what is nested deeper than :max_depth is dropped — the rest of the document stays either way
  • a document that cannot be repaired at all becomes Coelho.empty/1

A document stamped with another schema version is repaired against this schema and restamped with its version, rather than refused the way validate/2 refuses it. Rendering a document written under an older vocabulary badly beats rendering it as nothing; migrating it properly is Coelho.migrate/2.

It is idempotent: a document that already validates comes back normalised and unchanged, and sanitising twice is sanitising once.

Coelho.Document.sanitize(row.body, MyApp.RichText.schema())
|> Coelho.Render.to_html(MyApp.RichText.schema())

Bounds this call does not want

:limits overrides the schema's for this call alone. A bound is a bound on writing — the browser posts into a hidden field no maxlength constrains, which is what :max_text_length is there to refuse — and reading is a different question: what is already stored is stored, and cutting it to fit on the way to the page loses text nobody asked to lose.

Coelho.Document.sanitize(row.body, schema,
  limits: [max_text_length: :infinity, max_attr_length: :infinity]
)

So an application that wants the structure cleaned and the length left alone says so here, instead of keeping a second schema per field to sanitise against. Judging the length itself is then its own to do, with the whole document in hand to do it on.

:max_attr_length belongs in that list for the same reason and bites differently: an attribute over the bound is dropped, and a required attribute dropped takes its node with it — a stored image whose src is longer than the bound disappears from the page rather than being shortened. A row written before the bound existed, or under a schema that set it higher, is exactly the case to lift it for.

text_length(document)

@spec text_length(term()) :: non_neg_integer()

The number of characters a writer typed.

This is the concatenation of the text nodes, nothing else: no bullet, no blank line between paragraphs, no filename standing in for an attachment. to_text/2 materialises all of those because full text search wants them, and a length counted on its result rejects a document the editor still shows as under the limit — with nothing on screen to explain the gap.

The browser half counts the same way, so the editor's counter and the server's check agree on the number.

to_text(document, schema)

@spec to_text(map(), Coelho.Schema.t()) :: String.t()

Extracts the plain text of a document, for full text search.

Bullets are materialised, blocks are separated, and a node with a :to_text in its spec contributes whatever that says — an attachment its caption or its filename, a hard break a newline. What comes out reads like the document, which is what a search index wants and what text_length/1 deliberately does not count.

Indexing it

A jsonb document is not searchable as it stands: an index over it can answer "does this key exist", never "does this say tomato". The text has to become a column of its own, written when the document is:

# migration
alter table(:posts) do
  add :body_text, :text
end

create index(:posts, ["body_text gin_trgm_ops"], using: :gin)

# changeset
def changeset(post, attrs) do
  post
  |> cast(attrs, [:body])
  |> put_body_text()
end

defp put_body_text(changeset) do
  case fetch_change(changeset, :body) do
    {:ok, document} -> put_change(changeset, :body_text, Coelho.to_text(document))
    :error -> changeset
  end
end

Derived at write time and not read time, because the alternative is extracting the text of every row on every search. A generated column would do as well where the database can call out to nothing — PostgreSQL cannot run this from SQL, so the application writes it.

Two things follow. The column is a derivative, so it is never the source: a migration that changes what to_text/2 produces means rewriting it, the same way any denormalisation does. And it holds no markup at all, which is what makes to_tsvector and trigram search behave — indexing rendered HTML matches on strong and href.

validate(document, schema)

@spec validate(term(), Coelho.Schema.t()) ::
  {:ok, map()} | {:error, [Coelho.Document.Error.t()]}

Validates and normalises a document against a schema.

Returns the normalised document, or every error found. Paths in the errors read from the root, as in content[0].attrs.href.