Coelho.Render (coelho v0.3.1)

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.

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 to iodata.

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

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.

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_iodata(document, schema, opts \\ [])

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

Renders a document to iodata.

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.