An Ecto.Type that stores a rich text document with a cached rendering.
A Kotoba.Content struct has:
:doc- the document envelope, as a map with string keys (seeKotoba.Document).:html- the safe HTML of the document, rendered with the:defaultpolicy, as a string.:text- the plain text of the document, as a string.:version- the content cache version, currently1.
cast/1 reads a JSON string (as a form posts it through the hidden
input), a document envelope, a bare Lexical root node (wrapped in an
envelope), a Kotoba.Content struct, nil or "" (both cast to
empty/0). It parses the input through Kotoba.Document.parse/2, with
the node registry from the application configuration, and renders
:html and :text right away, so a row always carries a fresh cache.
Invalid input casts to :error, which Ecto.Changeset turns into an
"is invalid" error.
dump/1 and load/1 store and read %{"doc", "html", "text", "version"}. When a loaded map has no "html" or no "text" (an older
row, or one written outside Kotoba), load/1 re-renders them from
"doc". When "doc" itself no longer parses (for example a custom
node lost a field it once allowed), load/1 still succeeds, with an
empty cache, rather than failing the whole query; a row where "doc"
is not even a map is refused.
equal?/2 compares the :doc field of two Kotoba.Content structs,
so an Ecto.Changeset does not mark the field changed when only the
cache was recomputed; it falls back to == for anything else, so nil
equals nil.
Use rerender/2 after a node registry or a render policy changes, to
bring the cache of a stored document up to date. Use from_markdown/2
to build a Kotoba.Content from a Markdown string, on a best-effort
basis.
Forms and changesets
The Kotoba editor hook always posts a JSON document through the hidden
input, on every change: an empty editor posts the empty envelope (the
doc of empty/0), never "" and never no param at all. So in the
ordinary form-submit path, cast/1 only ever sees a JSON string, and
the nil/"" handling above does not come into play.
Ecto.Changeset.cast/4 has its own, separate handling of "": by
default, it treats a param of "" (or all white space) as absent and
keeps the field's current value (nil, unless the schema gives it a
default) instead of calling this type's cast/1 at all. A blank param
never reaches empty/0 this way. A host that wants "" to cast to
empty/0 — a manual API call, say, rather than the editor — opts in
with empty_values: [] on that field's own cast/4 call. This turns
off the "" → current-value behaviour for every field named in that
same cast/4 call, not only :body, so give the field its own
cast/4 call when the form has other string fields that should keep
the default. A host can instead give the field a starting document with
field :body, Kotoba.Content, default: Kotoba.Content.empty().
Examples
iex> content = Kotoba.Content.empty()
iex> content.html
""
iex> content.text
""
Summary
Functions
Returns the current content cache version.
Casts a JSON string, a document envelope, a bare Lexical root node, a
Kotoba.Content struct, nil or "" to a Kotoba.Content.
Returns an empty Kotoba.Content, for a new form or a blank field.
Builds a Kotoba.Content from a Markdown string.
Re-renders :html and :text from :doc, with a node registry and a
render policy.
Returns the document envelope of anything that cast/1 accepts, with the
same checks, but without rendering :html and :text.
Validates that the Kotoba.Content of a changeset field uses only the
given features (see Kotoba.Features), the ones of the field's editor.
The editor keeps out what it does not have, but a request can post any
document; this refuses one with a table in a comment field, say.
Types
@type t() :: %Kotoba.Content{ doc: map(), html: String.t(), text: String.t(), version: pos_integer() }
Functions
@spec cache_version() :: pos_integer()
Returns the current content cache version.
A Kotoba.Content with another :version has a cache made by another
version of Kotoba; Kotoba.Components.kotoba_content/1 renders such a
document again instead of using its :html.
Casts a JSON string, a document envelope, a bare Lexical root node, a
Kotoba.Content struct, nil or "" to a Kotoba.Content.
Renders :html and :text at cast time, also for a Kotoba.Content
struct: its :doc is parsed again, and its :html and :text are not
kept. Returns :error when the
input is not valid JSON, is not an object, or does not parse as a
Kotoba.Document (see Kotoba.Document.parse/2).
Examples
iex> {:ok, content} = Kotoba.Content.cast(%{"type" => "root", "children" => []})
iex> content.text
""
iex> Kotoba.Content.cast("not json")
:error
@spec empty() :: t()
Returns an empty Kotoba.Content, for a new form or a blank field.
Builds a Kotoba.Content from a Markdown string.
MDEx (comrak, a port of GitHub's cmark-gfm) reads the Markdown:
CommonMark and GitHub Flavored Markdown, with tables, task lists,
strikethrough, autolinks and footnotes, and __underline__ and
==highlight==, as Kotoba.Renderer.to_markdown/2 writes them (bold is
**). Each construct becomes its
node: quotes hold blocks, an image is a Kotoba.Nodes.Image, and a
paragraph of is a blank line (see Kotoba.Nodes.Paragraph), so
the Markdown of Kotoba.Renderer.to_markdown/2 reads back to the same
document.
Raw HTML is text: Kotoba never passes HTML through. A list item holds
inline content and one nested list; the paragraphs of an item with
several are joined by line breaks, and a code block, a quote, a table or
a heading in an item comes after the list. When the built document
somehow fails to parse, returns empty/0.
Options
:features- the features of the editor (seeKotoba.Features), every feature by default. A construct whose feature is off becomes its closest form, with no text lost: a heading a paragraph, a quote its blocks, a list its items, a table a line for each row, an image its alternative text, a footnote its[^label]text, and a format is dropped.:attachments- a map of names to attachment nodes (Kotoba.Nodes.Attachment, asKotoba.Attachments.store/2returns them). A paragraph of onlyimages becomes the attachment of that name, or a gallery of several, as the editor makes them for a file someone drops in. The image's alternative text is the attachment'scaption(itsaltandfigcaptionin HTML), unless it is empty or the file name; acaptionthe node has already wins. A name that is not in the map raisesArgumentError.:nodesand:policy- as inrerender/2.
Examples
iex> content = Kotoba.Content.from_markdown("# Title\n\nHello **world**.")
iex> content.text
"Title\nHello world."
Re-renders :html and :text from :doc, with a node registry and a
render policy.
When :doc no longer parses (for example a required field that a
removed custom node used to allow), the content is returned unchanged.
Options
:nodes- extra node modules for the registry, as inKotoba.Document.parse/2.:policy- the render policy for:html. The default is:default.
Returns the document envelope of anything that cast/1 accepts, with the
same checks, but without rendering :html and :text.
Kotoba.Components.kotoba/1 uses it for the hidden input on every
render.
Examples
iex> {:ok, doc} = Kotoba.Content.to_doc(~s({"type": "root", "children": []}))
iex> doc["kotoba"]
1
iex> Kotoba.Content.to_doc(42)
:error
@spec validate_features(Ecto.Changeset.t(), atom(), [atom() | String.t()]) :: Ecto.Changeset.t()
Validates that the Kotoba.Content of a changeset field uses only the
given features (see Kotoba.Features), the ones of the field's editor.
The editor keeps out what it does not have, but a request can post any
document; this refuses one with a table in a comment field, say.
changeset
|> cast(params, [:body])
|> Kotoba.Content.validate_features(:body, ~w(bold italic links lists)a)The error is "has content that is not allowed: %{names}", with
names: "tables" (the features, joined with ", ") and
validation: :features in its keys. A field with no content, or with a
document that no longer parses, passes. The features are checked with
Kotoba.Features.names!/1, which raises for an unknown name.