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, whereattrsis either a static list of{name, value}pairs or a function of the node returning such a listfun/2— full control, receiving the node and its already rendered children as iodatafun/3— the same, plus the:contextgiven 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/1before 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
Functions
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".
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.
@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:
:node—fn node, children -> term end, wherechildrenis the list of what this node's children folded to, in order:text—fn text, marks -> term end, wheremarksis the node's marks, resolved against the schema, in the schema's declaration order. Leave it out and text nodes go through:nodewith 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.
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.
Builds an element from a tag, an attribute list and rendered children.
@spec to_html(map(), Coelho.Schema.t(), opts()) :: String.t()
Renders a document to an HTML string.
@spec to_iodata(map(), Coelho.Schema.t(), opts()) :: iodata()
Renders a document to iodata.
Builds a childless element, self-closing only if HTML says it is.