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.
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
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".
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)
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.
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.
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_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_inlinein 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.
@spec to_inline_iodata(map(), Coelho.Schema.t(), opts()) :: iodata()
to_inline_html/3 as iodata.
@spec to_iodata(map(), Coelho.Schema.t(), opts()) :: iodata()
Renders a document to iodata.
@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.
@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.
Builds a childless element, self-closing only if HTML says it is.