Coelho.Render (coelho v0.14.0)

Copy Markdown View Source

Turns a validated document into HTML.

Rendering is driven by the :render field of each node and mark spec, which takes one of three forms:

  • nil — the node contributes nothing but its children
  • {tag, attrs} — an element, where attrs is either a static list of {name, value} pairs or a function of the node returning such a list
  • fun/2 — full control, receiving the node and its already rendered children as iodata
  • fun/3 — the same, plus the :context given to this render call

A spec's :class is merged into the {tag, attrs} form, after whatever class the attributes already carry. A render function is not touched: it builds the whole element itself, so it applies its own class.

Callers can override any of them per call through the :nodes and :marks options, which is how a Phoenix application injects its own markup — mentions, embeds, syntax highlighted code — without changing what is stored.

The :context option carries whatever the render functions need from the application and cannot know on their own. Attachments use it to turn a stored key into a URL at render time, which is what lets signed and expiring URLs work at all — see Coelho.Attachments.

What is escaped

Two things, and between them they are the safety guarantee of the package:

  • the text of every text node, through escape/1
  • every attribute value, through escape/1 before it is quoted

&, <, >, " and ' all become entities. Nothing a writer typed is ever emitted as markup, because a document holds no markup to begin with — it holds a tree, and this builds the tags.

What escaping cannot cover is a value that is perfectly quoted and still dangerous: javascript: in an href executes however well it is escaped. safe_url/1 is for those, and the shipped renderers put every URL through it.

Two things are not escaped, and both are the schema author's to get right: tag names, and attribute names. Both come from the schema, which is code.

nil renders as nothing rather than raising: it is what a nullable column holds and what both stored types cast an absent document to, so a template writing to_safe_html(@post.body) should not have to guard it.

Only validated documents should be rendered. Rendering does not re-check the document against the schema; it trusts Coelho.Document.validate/2 to have run, and raises on anything it does not recognise. For a document read back out of storage, where that is not a safe assumption, put it through Coelho.Document.sanitize/2 first.

Summary

Functions

Reads an attribute out of a node or a mark, falling back to a default.

Escapes text for inclusion in HTML, attribute values included.

Folds a document into any term at all.

Returns a URL fit to be emitted, or nil for one that is not.

Builds an element from a tag, an attribute list and rendered children.

Renders a document to an HTML string.

Renders a document where only inline elements are legal.

Renders a document to iodata.

Renders a validated document as {:safe, iodata}.

to_inline_html/3 in the shape a template will not escape again.

Builds a childless element, self-closing only if HTML says it is.

Types

callbacks()

@type callbacks() :: %{
  :node => (map(), [term()] -> term()) | (map(), [term()], term() -> term()),
  optional(:text) =>
    (String.t(), [map()] -> term()) | (String.t(), [map()], term() -> term())
}

opts()

@type opts() :: [
  nodes: %{optional(atom()) => term()},
  marks: %{optional(atom()) => term()},
  context: term()
]

Functions

attr(node, name, default \\ nil)

@spec attr(map(), String.t(), term()) :: term()

Reads an attribute out of a node or a mark, falling back to a default.

An attribute sitting at its schema default is not stored — see Coelho.Document.canonical/1 for why — so a renderer must supply the default rather than read the key and hope. This is the one place that knows the shape of "attrs".

The default to pass is the schema's, not one chosen here: a heading stored with no "level" is a heading at whatever :level declares as its default, and a renderer answering something else prints a document the editor drew differently. Where the schema is at hand, read it from there rather than writing the number twice:

level = Render.attr(node, "level", Schema.node_spec(schema, :heading).attrs.level.default)

escape(text)

@spec escape(String.t()) :: String.t()

Escapes text for inclusion in HTML, attribute values included.

Both halves of what an element carries go through this: the text of a text node, and every attribute value, escaped in attributes/1 before it is quoted. &, <, >, " and ' all become entities, which is what makes an attribute value safe inside either quoting style and text safe outside a tag.

What escaping does not cover, and what safe_url/1 exists for, is a value that is correctly quoted and still dangerous — a javascript: URL in an href.

reduce(document, schema, callbacks, opts \\ [])

@spec reduce(map(), Coelho.Schema.t(), callbacks(), opts()) :: term()

Folds a document into any term at all.

to_html/3 and to_iodata/3 answer one question — what does this look like on a web page — and answer it in iodata, which is the wrong shape for every other target. An invoice rendered through a typesetter, a search index, a word count per heading, a summary of the links a document contains: each of those is a fold over the same tree, and reimplementing the traversal per target is how a consumer ends up quietly disagreeing with the schema about what a document may hold.

Two callbacks, and the accumulator is whatever they return:

  • :nodefn node, children -> term end, where children is the list of what this node's children folded to, in order
  • :textfn text, marks -> term end, where marks is the node's marks, resolved against the schema, in the schema's declaration order. Leave it out and text nodes go through :node with no children.

Either callback may take a third argument, which receives the :context option, exactly as the render functions do.

The point of returning a term rather than iodata is that a target with its own escaping rules — a typesetting language, a template engine — can hand back a list of maps and let its own encoder do the quoting. Nothing the writer typed is ever concatenated into a string that something downstream will interpret as code.

Coelho.Render.reduce(document, schema, %{
  text: fn text, marks -> %{"text" => text, "marks" => Enum.map(marks, & &1["type"])} end,
  node: fn node, children -> %{"block" => node["type"], "children" => children} end
})

Like to_iodata/3, this trusts the document: an unknown node or mark type raises rather than being skipped. Fold a validated document, or one that has been through Coelho.Document.sanitize/2.

That strictness is why the library's own walks are not written on top of this one, which is otherwise the obvious thing to ask. Validation has to report on a node it does not know, sanitisation has to remove it, and Coelho.Attachments.keys/2 is handed rows written under whatever schema was in force years ago — none of the three may raise, and a fold that cannot raise cannot promise the caller that what it was handed is a document this schema admits. The two contracts are different on purpose; this one is for a consumer with a document it has already validated.

safe_url(url)

@spec safe_url(term()) :: String.t() | nil

Returns a URL fit to be emitted, or nil for one that is not.

Validation already rejects unsafe URLs on the way in, but stored documents are not re-validated on the way out — Coelho.Ecto.Type deliberately trusts what is in the column. A row written before the schema tightened, by a direct database write, or under a looser custom schema, would otherwise put javascript: straight into an href. Escaping does not help there: the value is quoted correctly and still executes.

Attributes built with this return nil and are dropped, so a suspect link renders as an <a> without an href rather than as a live one.

tag(name, attrs, inner)

@spec tag(String.t(), [{String.t(), term()}], iodata()) :: iolist()

Builds an element from a tag, an attribute list and rendered children.

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

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

Renders a document to an HTML string.

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

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

Renders a document where only inline elements are legal.

Why this exists

A <p> inside a <p> is not nested by the browser, it is closed by it. Put to_html/3 inside a paragraph or a span — a news banner, the detail panel of a map marker, a truncated card excerpt — and four things happen, none of them reported:

  • the enclosing paragraph ends where the document's first one begins, so every class on it stops applying from there
  • an empty paragraph appears where the enclosing one was reopened
  • a block element inside a <span> breaks the line, whatever the span's layout was for
  • and the words of two paragraphs run together, because the tags that separated them are gone

The last is the one nobody sees, because it looks like text.

What it guarantees

The output holds nothing that is illegal in an inline context. That is the whole contract, and everything else follows from it without a judgement call:

  • marks stay — <strong>, <em>, <code>, <a> are inline
  • <img> and <br> stay
  • every block is unwrapped to its children: a heading contributes its words, a list its items, a code block its text. A block that had a tag loses the tag, which is what "inline" means
  • a node whose inline form is not simply its children says so, with :render_inline in its spec — an attachment renders its image and its caption's text rather than the <figure> it is on a page of its own
  • a node with no inline equivalent at all contributes nothing. A horizontal rule is the example, and it is mechanical rather than a policy

Empty contributions are dropped rather than separated, which is not a mode: it is what joining correctly means.

The separator is yours

Only the caller knows whether its container can take a line break. A map bubble wants :br; a fixed-height banner wants :space, and so does a truncated excerpt.

Coelho.Render.to_inline_html(document, schema, separator: :br)

:space is the default because the two mistakes do not cost the same. A space where a break was wanted puts two sentences on one line, which reads. A break where a space was wanted grows the caller's box and breaks their layout.

A separator of your own is escaped, because a value that reached this from data would otherwise be markup. Pass {:safe, iodata} to say it is not.

It governs the boundaries between blocks and nothing else. A hard break the writer typed is content, and still renders as <br> under :space — it is not a seam this put there, and dropping it would be dropping something someone wrote.

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

@spec to_inline_iodata(map(), Coelho.Schema.t(), opts()) :: iodata()

to_inline_html/3 as iodata.

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

@spec to_iodata(map(), Coelho.Schema.t(), opts()) :: iodata()

Renders a document to iodata.

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

@spec to_safe_html(map(), Coelho.Schema.t(), opts()) :: {:safe, iodata()}

Renders a validated document as {:safe, iodata}.

What to_html/3 renders, in the shape a template will not escape again. to_html/3 answers a String.t(), which HEEx and Phoenix.HTML treat as text — correctly, since they cannot know it is markup — so the caller has to remember raw/1, and has two ways to get it wrong: forget it, and the document is shown as its own source; reach for it somewhere else, and something that should have been escaped no longer is.

For a package whose argument is that rendering is safe by construction, that is the last link left to the caller. This closes it:

<div class="prose">{Coelho.to_safe_html(@post.body)}</div>

The {:safe, iodata} pair is what Phoenix.HTML.Engine unwraps directly, so this costs no dependency — Coelho has none for HTML, and does not acquire one here. There is no Phoenix.HTML.Safe implementation to go with it because there is nothing to implement it for: a document is a bare map, deliberately, and an implementation for Map would apply to every map in the application.

It renders the same iodata to_iodata/3 builds, so the escaping guarantees above hold unchanged.

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

@spec to_safe_inline_html(map(), Coelho.Schema.t(), opts()) :: {:safe, iodata()}

to_inline_html/3 in the shape a template will not escape again.

The same reasoning as to_safe_html/3: having removed the need to remember raw/1 in one place, this does not reintroduce it in the other.

void_tag(name, attrs)

@spec void_tag(String.t(), [{String.t(), term()}]) :: iolist()

Builds a childless element, self-closing only if HTML says it is.