Kotoba.Content (Kotoba v0.5.0)

Copy Markdown View Source

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 (see Kotoba.Document).
  • :html - the safe HTML of the document, rendered with the :default policy, as a string.
  • :text - the plain text of the document, as a string.
  • :version - the content cache version, currently 1.

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

t()

@type t() :: %Kotoba.Content{
  doc: map(),
  html: String.t(),
  text: String.t(),
  version: pos_integer()
}

Functions

cache_version()

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

cast(json)

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

empty()

@spec empty() :: t()

Returns an empty Kotoba.Content, for a new form or a blank field.

from_markdown(markdown, opts \\ [])

@spec from_markdown(String.t(), keyword()) :: t()

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 (see Kotoba.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, as Kotoba.Attachments.store/2 returns them). A paragraph of only ![alt](attachment:name) images 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's caption (its alt and figcaption in HTML), unless it is empty or the file name; a caption the node has already wins. A name that is not in the map raises ArgumentError.
  • :nodes and :policy - as in rerender/2.

Examples

iex> content = Kotoba.Content.from_markdown("# Title\n\nHello **world**.")
iex> content.text
"Title\nHello world."

rerender(content, opts \\ [])

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

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 in Kotoba.Document.parse/2.
  • :policy - the render policy for :html. The default is :default.

to_doc(json)

@spec to_doc(term()) :: {:ok, map()} | :error

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

validate_features(changeset, field, features)

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