0.18.0 — 2026-10-04
Coelho in Livebook
Coelho.Kino.new/1 puts the editor in a notebook cell: the same component and
hook a LiveView uses, so a notebook shows what an application gets.
Coelho.Kino.read/1 is what was typed, as the document that would be stored,
Coelho.Kino.set/2 replaces it, and Kino.listen/2 sees every change. Every
change is validated on the server before it is kept; one the schema refuses is
not stored, and the editor that sent it is put back. Needs the optional kino
and phoenix_live_view dependencies.
Three notebooks, each with a "Run in Livebook" badge in the README and a page in the documentation: a tour, your own schema, and migrating stored HTML. CI runs every one of them in a real Livebook.
0.17.0 — 2026-10-04
No npm: the hook ships built, with ProseMirror inside
Coelho now ships priv/static/coelho.esm.js, the hook with ProseMirror
inside, the way phoenix_live_view ships its client. It resolves as coelho
through the deps/ path a Phoenix application already gives esbuild:
import { Coelho } from "coelho"An application installs no npm package and sets no NODE_PATH. The bundle is
298 KB minified, 93 KB with gzip. The ProseMirror modules it was built with
are exported beside the hook (model, state, view, …), for code of your
own that touches ProseMirror.
mix coelho.install wires that import and installs nothing. mix coelho.install --npm keeps the previous way — the source, and the ProseMirror
packages from npm — for an application that uses ProseMirror itself and wants
one copy of it. Use one or the other, never both. --no-npm, which printed
the install command instead of running it, is now --npm --no-install.
An application already importing deps/coelho/assets/js/coelho.js keeps
working unchanged, and the installer leaves it alone. It also no longer asks a
freshly generated application to fix the esbuild package's own empty
default profile.
Rendering content from people you do not trust
policy: :untrusted on every render function takes out what a document points
at, for comments, reviews and anything else a stranger wrote:
Coelho.to_safe_html(comment.body, policy: :untrusted)A link renders as its text, an image as its alt text, an attachment as its
file name and caption, with no URL resolved. The rule is fail-closed for nodes
and marks of your own: a :safe_url attribute makes one its children, a
{tag, attrs} render keeps only an allow list of attributes that cannot fetch,
follow or style anything — a literal style, id or data-* goes, since
phoenix_html.js, htmx and lazy loaders act on data-* — and draws only
the structural and text elements on a second allow list, so an a, img,
iframe, button, raw-text or custom element is its children; and a
render function is its children unless the spec declares :render_untrusted
— with :render_untrusted_inline beside it for a block node's inline form. An
unknown policy raises. The default, :trusted, renders exactly as before.
A nil override means the same thing inline
nodes: %{attachment: nil} rendered nothing but the node's children on the
page and was ignored by to_inline_html/3, which drew the attachment anyway.
It is now the children in both, so an application hiding a node from a page
hides it from the excerpt too. A void node has no children, so it contributes
nothing.
Underline, highlight, subscript and superscript — and this one can bite an upgrade
The default schema has four more marks, rendered as <u>, <mark>, <sub>
and <sup>. Each has a toolbar icon, and all but highlight a shortcut:
Mod-u, Mod-, and Mod-. — Mod-Shift-H is the browser's own. The
default toolbar is unchanged, so an application
names them to show them. Coelho.Markdown.to_markdown/3 writes them as
those elements, since Markdown has no delimiters for them, and
from_markdown/3 reads them back.
Coelho.HTML.from_html/3 against the default schema now keeps these four
elements, where it used to warn and drop them.
An application that already declared one of these names through
Schema.extend/2 now extends the shipped mark rather than declaring a new
one, because redeclaring a name overlays only the keys it gives. A
highlight: [class: "hl"] with no :render or :parse rendered as bare
text and imported nothing on 0.16; it now renders as <mark class="hl">,
keeps an imported <mark>, and is written to Markdown as <mark>. One that
gave its own :render keeps it, in the editor and in Markdown too — written
there as a mark of the application's rather than as the shipped element —
but still inherits the shipped parse: ["mark"] unless it gives its own.
Markdown, out and back
Coelho.Markdown.to_markdown/3 writes a document as CommonMark, with GitHub's
tables and strikethrough. What Markdown would read as syntax is escaped, a mark
spanning several text nodes is written once, and a mark is written as its HTML
element (<strong>, <em>, <del>) where CommonMark would not read its
delimiters. :nodes and :marks take an application's own; without them a
node is its content, or its :to_text. What Markdown cannot say is listed in
the module's documentation.
Coelho.Markdown.from_markdown/3 reads it back under the schema, through
Coelho.HTML.from_html/3, with the new optional :mdex dependency.
The HTML import fills a node the way the editor would
Coelho.HTML.from_html/3 used to fail the whole import on <li></li>, or on a
<li> holding only a nested list, since a list item opens with a paragraph.
Editors write both, and Markdown's empty - item reads as one. A node missing
the block its content opens with now gets an empty one of its default block,
as the editor fills it.
Two properties hold the pair to the HTML round trip: generated documents, one set drawn from Markdown's own syntax characters, come back from Markdown as they come back from HTML.
The content type of an upload, from its bytes
Coelho.Attachments.content_type/2 reads the first bytes of an upload and
answers the type to record, instead of trusting entry.client_type:
type = Coelho.Attachments.content_type({:file, path}, entry.client_type)PNG, JPEG, GIF, WebP, AVIF and PDF come back only when the bytes prove them,
so an application that records this answer no longer lets a claimed image/
type make the renderer draw an <img> or the plug serve a file inline. Any
other plain type the browser claims is kept; anything else is
application/octet-stream.
Nothing changes until the application calls it: the plug still serves the
type :metadata answers, and rows already stored keep the type they were
recorded with.
A content security policy on every served file
Coelho.Plug.Attachments now sends content-security-policy: default-src 'none'; sandbox beside nosniff on every response carrying bytes. A redirect
to object storage still carries none of the plug's headers.
0.16.0 — 2026-09-27
What typing makes, and one dependency fewer.
Typing at the start of a paragraph
# makes a heading, - a bullet list, 1. an ordered one, > a
quote, three backticks a code block. Each rule exists only where the schema
declares the node it makes, and a heading only at a level the schema's own
:level validator accepts: the closed list travels to the browser beside
the attributes, as attrValues, so #### in a schema of three levels
stays four characters rather than becoming a block the changeset refuses.
Nothing fires inside a code block, where # comment is a comment: a node
holding code now says so with code: true in its declaration, which
code_block does and which travels to the browser with the rest of the
schema, so an application drawing the node itself keeps the flag. The
code mark says the same of the text it covers, and no rule fires there
either.
Backspace right after a rule fired puts the characters back — # was
meant literally — and falls through to the ordinary key otherwise.
Two cursors ProseMirror does not draw by itself come with it: one where
there is no text to put a caret in (before a rule at the top of the
document, between two tables), and one showing where a drag will land. The
first needs a stylesheet, which coelho.css now carries.
The hook no longer depends on @nseaprotector/acme-script — and this one can bite an upgrade
It was a wrapper around three LiveView calls, and it is gone. Three
ProseMirror packages take its place in peerDependencies:
prosemirror-inputrules, prosemirror-dropcursor and
prosemirror-gapcursor. An application upgrading has to install them:
mix coelho.install names what is missing and installs it, and
npm uninstall @nseaprotector/acme-script in assets/ is the application's
to run, since the task never removes anything.
README
The dependency line names the current release, and the status paragraph no longer says the demo is still to come.
0.15.0 — 2026-09-09
Tables, a list the writer opens by typing, and the ground both were built on: the places where a term this library did not build reaches code that does something with it, written down and, where it mattered most, held to what they promised by a property. Eight defects went with them, all of the same shape — a value nobody validated, trusted anyway — and three of them were found by the properties on their first run.
Tables
Coelho.Schema.Default.build(tables: true)Four nodes — table, table_row, table_cell, table_header — with
colspan and rowspan on the two kinds of cell, and blocks rather than text
inside one, so a paragraph, a list or a quote goes in a cell. Off unless
asked for, so nothing already declared moves.
On the server, a table validates, renders to <table>, extracts to text a
row at a time, and survives an import that used to drop it: from_html/3
warned about an unknown table and handed the document back without it,
which is the one shape a migration cannot afford to lose silently. The
wrappers a browser writes — tbody, thead, tfoot, colgroup, col —
hold nothing of their own and say nothing; a caption still warns, because
what it holds is text and the text is lost.
In the editor, Tab and Shift+Tab move from cell to cell, a drag selects a
rectangle of them, and five commands act on the table the caret is in:
table_row_after, table_row_delete, table_column_after,
table_column_delete and table_delete. Filtered like every other command —
a schema without tables draws none of them — and each does something rather
than turning something on, so none reports a pressed state.
Putting a table in is not among them, and that is the rule in
CONTRIBUTING.md rather than an omission: how many rows and how many columns
is a decision no schema can be asked for. It goes through
Coelho.LiveView.insert_node/3 like every other decision an application
owns, and the demo shows the whole of it.
A span is a count of cells, bounded at 1000, refused when it is anything else, and left out of the markup when it is one — the pair a heading's level already had, because a stored row was written under whatever schema was in force then.
prosemirror-tables joins the browser packages mix coelho.install puts in.
An application that never declares tables carries it and never runs it: the
plugin is added only where the schema has them. Nothing in the library
changed for the server half to work, which is the part worth knowing: the
content expressions a table needs were already expressible, and an
application wanting a different table can declare one with
Coelho.Schema.extend/2 and get the same treatment.
A list the writer opens by typing
The seam CONTRIBUTING.md said a suggestion list would get instead of a
command now exists, and mentions are what it is for:
<.coelho_editor field={@form[:body]} suggest={[{"@", event: "mention_query"}]} />The editor pushes that event with what is being typed after the trigger, and
again with "query" => nil when there is no longer a query, which is what
closes the list. "rect" carries the caret's place in the viewport, for
putting the list beside it. A trigger has to start a word — a@b is an
address — the query ends at the first space, a node before the trigger
counts as a word boundary, and no list opens inside a code block, where
@media is code.
Coelho.LiveView.insert_node/3 takes replace: :query, and that is the
half that is easy to miss: without it the node goes in beside the @ad the
writer typed and they are left to delete it themselves. The range replaced is
the one the editor holds when the node arrives rather than the one that was
pushed, so a writer who kept typing while the list was open still loses
exactly their query — and @ada typed in two paragraphs is two queries, not
one.
Clicking away closes the list, and there is nothing to write for that. The editor waits a moment first, because a click on the list blurs the editor on the way down and lands on the way up, and it keeps the range through that blur so the node still lands on the query. A list dismissed that way stays dismissed until the writer types: the query is what opens one.
Nothing about the list reaches the schema, which is the point. What it holds, how it filters and what a click does are the application's, and a schema cannot be asked what a name matches. The node it settles on is an ordinary one, declared the way a variable is. Two suggestions cannot share a trigger: the editor looks for the character rather than the event behind it, so the second could never have been pushed.
The demo does the whole thing, and the browser suite drives it in all three engines.
insert_node/3 takes the editor's field
insert_node(socket, node, editor: @form[:body]):id still works for an editor whose id was given by hand, but the field or
the name the component was rendered for is what an application has in hand,
and deriving the id from it was the one thing every call site had to spell.
The seams are written down
CONTRIBUTING.md now lists every place a term this library did not build
reaches code that does something with it: the render and text functions a
schema declares, an attribute's validator, the storage callbacks, the
attachment resolver, the plug's authorisation, the import rules, the
toolbar's words and markup, the stored-type overrides, and the browser hook's
node views. Each row says what holds it to what it promised, and most say
"nothing yet" — which is the point of writing it down.
Every defect in this release lived on one of those rows.
Three properties for what reaches the renderer without being validated
Coelho.HTML has carried "no HTML raises, whatever it is" since the import
existed, because HTML plainly comes from outside. The renderer reads two
things that come from outside just as much and had no such promise: a row
written under whatever schema was in force when it was stored, and a value
handed back by a function the application wrote.
- A row written under any schema at all still renders. Every attribute
of a generated document, node and mark alike, set to an arbitrary term.
Rendering, inline rendering and plain text extraction all have to answer,
and
sanitize/2has to produce somethingvalidate/2accepts. - An attribute a schema's own render hands back never raises, whatever it is. Through a function rather than a literal, because the two are gated differently: a literal is encoded into the schema's fingerprint when the schema is built, so a value that cannot be exported is refused there. What a function returns at render time meets nothing.
- A value that renders alone is still there beside a spec's class. Two values do not accumulate and say so: the empty string, which has nothing to add, and a boolean, which is whether the attribute is written rather than what it holds.
What they found
- Plain text extraction raised on a stored attachment. A
:to_textis declared as iodata and builds it out of a row, so it hands back whatever that row held — and a filename that isfalsemade the join for the whole document raise. One row would take down the extraction a search index runs over every one of them. What a:to_textreturns is checked now. - An attachment rendered its label through
to_string/1, and raised on a map exactly the way the attribute path used to. - Counting characters raised on bytes that are not text.
String.length/1walks a binary as text and raises on some sequences that are not one, in six places:text_length/1, the:max_text_lengthbound, the trim that enforces it, and the attribute budget's own count of a key and a value. A document holding those bytes could not be counted, trimmed, validated or read. A character stays the unit whereverStringcan walk one, which is almost always: giving up on every binary that is merely invalid would trim"héllo" <> <<255>>to half of ané, which is a documentsanitize/2produced andJSON.encode!refuses. Only the sequences that actually raise fall back to counting bytes.
An attribute to_string/1 cannot answer for is dropped at render
heading.level and code_block.language were clamped at render in 0.14.0
because a stored row was written under whatever schema was in force then.
Every other attribute still went through to_string/1, and two shapes a
stored row can hold answer that badly: a map raises, on every page that
shows the row, and an array of numbers becomes control bytes inside the
attribute, with nothing said. Both are left out of the element now, which is
what a nil value already did.
What a schema has reason to return is untouched: a string, a number, an
atom, a struct that says how it prints — a Date, a URI, a Decimal —
and the iodata Coelho.Render.tag/3 has always accepted, which is a tree of
strings, improper tail and all. That iodata is joined byte for byte now
rather than through to_string/1, which decodes, so a list holding a binary
that is not valid UTF-8 renders the same bytes a bare binary always did
instead of raising.
class and style accumulate across every value that renders, not only
across strings. They are the two attributes that join rather than replace —
a spec's :class on top of what its :render put there — and joining was
between binaries, which failed differently on each side: iodata already in
place was silently replaced, iodata arriving to join it raised, and a
number was dropped in the join though class="7" renders on its own.
One value pays for this: a charlist, which is a list of numbers and
cannot be told apart from a stored array at render — ~c"hi" and the JSON
[104, 105] are the same term. A schema handing back what an Erlang
function returned should wrap it in List.to_string/1.
One attachment, one name
Three renderers read one row — the block, the inline form and the plain
text — and a row written under a looser validator got three answers out of
them. Which of a caption and a filename to show was decided with || and
"is it nil", and every value but false and nil is truthy: a caption a
looser row wrote took the place of a filename that was there, so the search
index lost the only name the row had, while the page drew an empty
figcaption and the inline form drew nothing. A filename stored as the
empty string — which the schema accepts — drew an anchor with no text. All
three ask the same question now, the one the inline form already asked: is
this text somebody typed.
reduce/4 folds nil to nil
to_html/3 renders a nil document as nothing, because that is what a
nullable column holds and the README promises it. reduce/4 raised on the
same value. It folds to nil now; a nil inside a document is not a
document and still raises, and a callback map missing its :node is refused
whatever the document holds — a fold that answered for every empty column and
raised on the first row holding a document was a typo found in production.
The specs went with it. Every render function accepted a nil document and
declared map(), so an application running Dialyzer over the very call the
documentation tells it to write — to_safe_html(@post.body) on a nullable
column — was warned about it. to_iodata/3, to_html/3, to_inline_html/3,
to_inline_iodata/3, their safe forms, to_text/2 and the delegates in
Coelho all say map() | nil now.
The Ash type refuses a stored value that is not a document — and this one can bite an upgrade
Coelho.Ash.Type.cast_stored/2 read anything that was not a map as nil,
so a column holding something else came back as an absent document with
nothing said about it. It returns :error now, as Coelho.Ecto.Type.load/3
always has.
What changes for you: an Ash resource whose column holds rows that are
not documents — text written before the type was adopted, a value another
tool put there — read them as empty documents before and fails to load
them now. That is the row saying what it holds rather than hiding it, but
it is a read that used to succeed. The way through is the override the
moduledoc shows: cast_stored/2 deciding what the bytes are before calling
super, which is unaffected by this change.
Two tests measured the machine
The two ratio tests that guard the linear paths of 0.14.0 timed their runs with the wall clock, and the wall clock measures the machine as much as the work: on a busy machine the content-expression matcher came out at 16.1 times the work for 4 times the input — the square exactly, from a function that had not changed. Both count reductions now, which are steps this process took however busy the schedulers are.
mix docs is quiet again
The changelog names Coelho.Schema.empty/1 and Coelho.LiveView.node_commands/0,
which left the documentation in 0.13.0, and ex_doc warned on each. The
changelog is a record and is not rewritten to keep a link resolving; the
warning is skipped for that file instead.
0.14.0 — 2026-08-22
A security review of the whole surface — the first one — and what measuring things nobody had measured turned up beside it. No vulnerability was found: no XSS, no path traversal, no authorisation bypass, no atom built from a document, no hash collision that could be constructed. Six real defects were, and they are the release.
Two paths were quadratic
- Validating a document. The content-expression matcher stepped its whole reachable set on every pass, and that set grows by one position per pass — so the work was the square of the child count at one level. Measured on the shipped schema: 9 990 sibling paragraphs, a 210 KB document inside every bound the schema declares, took 8.6 seconds to accept, and again on every render that sanitises. It now steps only what was reached since the last pass, which is equivalent because every transition distributes over union. Same document: 14 ms. Fuzzed against the old implementation over 20 000 random expressions and child lists — zero divergences.
- Importing HTML. The
<pre>pre-pass was a lazy regex, and.*?starts its search again at every<pre, running to the end of the input when nothing closes it: 20 KB of unclosed<pre>took 346 ms, and a paste out of a word processor is a great deal more than 20 KB. A byte scanner replaces it — 1.8 ms — and it fixes an old misclassification on the way, where a fragment merely beginning with<prewas treated as a code region.
Both are guarded by tests that assert the ratio rather than a duration: four times the input costs about four times the time when the work is linear, sixteen when it is the square.
An attribute is bounded now — and this one can bite an upgrade
The bounds stopped at the document's shape and its text, and an attribute is
neither: max_text_length counts what a writer typed, because that is the
number the editor's counter shows. Half a megabyte of alt validated in
1.7 ms while text_length/1 answered 1 — in the one field no maxlength
constrains.
:max_attr_length(10 000 characters) is a budget spent through the whole value: every character of every string, in a key as well as a value, and one per element besides. A string wrapped in a list is the same string.:max_depthnow applies to an attribute's value too, for a schema declaring an attribute with no validator — those accept anything JSON can express, and ten thousand nested lists used to validate instantly.On the way out as well as in. An attribute over the bound is dropped by
sanitize/2, and a required attribute dropped takes its node with it: a stored image whosesrcis longer than the bound disappears from the page rather than being shortened. A row written before this bound existed is exactly the case to lift it for, beside:max_text_length:Coelho.Document.sanitize(row.body, schema, limits: [max_text_length: :infinity, max_attr_length: :infinity] )
Three defects that only showed under a real input
- A code block's
languagewas validated as any string and rendered asclass="language-#{value}", so a value carrying spaces contributed class names of its own choosing. It is one token now, validated and clamped at render — the same pair the heading level already had, because a stored row was written under whatever schema was in force then. - Serving an attachment whose filename is not valid UTF-8 — a form submitted
as latin-1 sends
café.pdfas bytes that are not — raised, turning every fetch of that file into a 500. The sanitiser's regex was in unicode mode. Byte mode does not raise, but CI then caught what a green local run could not: the same call strips the stray byte on macOS and keeps it on Linux, and a header value is not the place to find out which build of PCRE is installed. The bytes a filename may carry are written out now. Coelho.LiveViewTest.type/4's:formoption could never work:Phoenix.LiveViewTest.form/3refuses a hidden input whose value differs from the rendered one, and an editor's field is hidden and differs by definition — that is what typing is. It now raises with what to do instead (pass the form's other fields as:params, nested the way the form nests them). Found by making the demo use the helper the library ships for other people's tests, which nothing had ever used.
Measured for the first time
- Coverage, with a floor in
mix.exsandmix test --coverin CI. The number is not the point; the list of lines nothing had ever run is. It namedCoelhoitself at 76.9 % —canonical/1,sanitize/3,text_length/1,to_safe_html/2: one-line delegates nobody had tested, because a delegate cannot go wrong. It can point at the wrong function, swap two arguments, or drop an option on the way through. migrate/2was run end to end for the first time: a document written under one schema, refused by the next, migrated, accepted, rendered — and refused when replayed.hash/2says whatnilmeans. Five shapes of empty document answer it; they draw different pages.nil == nilis not "the same document".
What may become a toolbar command, written down
The list of node commands has been called closed twice and opened twice —
for heading levels, then for inserting an inline void node — each time on a
good argument. CONTRIBUTING.md now carries the rule those two openings were
really applying, so it answers before the next argument starts: a command is
a verb the hook can run for a whole class of schema declarations, with no
decision left to the application. Four questions decide it, the naming
follows from whether the value is scalar and closed, and what will never be
one — a menu, a picker, a suggestion list filtered as the writer types — has
a seam instead: an event, Coelho.LiveView.insert_node/3, a node view.
The README gains the four kinds of command as a table, and :toolbar's own
documentation names the insert entry it had been missing.
0.13.0 — 2026-08-22
The bytes are promised, and now they are written down
A property says a document normalises the same way twice. Nothing said it normalises the same way this month as last month — and that is what a stored hash compares, what an ETag is built from, and what a diff between two revisions of a page shows.
test/fixtures/corpus.json holds fifteen documents with the canonical form,
the hash, the rendered HTML, the inline HTML and the extracted text they
produce today. A change to any of them arrives as a diff to read rather than
as a page that stopped matching in somebody's production. Regenerating is
deliberate — mix run priv/corpus/write.exs — and the diff is the report.
Nothing about the library changed for this. It is a promise being written down where it can be checked.
Four functions leave the documentation
Coelho.Schema.empty/1, json/1, parse_tags/1 and attr_keys/1 were
added in 0.9.0 and announced as public. They exist for this library's own
paths — validation asking a spec for its attribute names, the import asking
which tags it parses — and an application has better answers to the same
questions: Coelho.empty/1 for the empty document, to_json/1 for
the export as a term, and spec.attrs for what a spec declares. They are
@doc false now: still callable, no longer promised. Coelho.LiveView.node_commands/0
goes the same way — it exists so the two halves can be checked against each
other.
The browser packages are installed the way the application installs them
mix coelho.install read npm into every project. It now reads the
lockfile — pnpm-lock.yaml, yarn.lock, bun.lockb, package-lock.json,
in assets/ or at the root — and runs that one, falling back to npm, which
is what a freshly generated Phoenix application has. Installing with one
manager into a project that uses another leaves two layouts of
node_modules in one application, and the one that breaks is whichever
esbuild does not resolve through — a failure that surfaces inside the hook
at mount, a long way from its cause.
0.12.0 — 2026-08-22
A placeholder written as text is not safe in a document. Text is a tree, so
bolding half of {{number}} splits it across two text nodes and every
substitution downstream stops matching — silently, with the writer seeing a
bold placeholder and the reader receiving one. The answer is not to
substitute more cleverly: it is for a variable to stop being a pattern in
text and become a node nothing can split. The schema half of that already
worked; putting one in did not.
A toolbar entry can insert what the schema declares
toolbar={
~w(bold italic link) ++
[
{"insert", node: :variable, attrs: %{name: "number", label: "{{number}}"},
label: "Invoice number"},
{"insert", text: "🎉", label: "Party popper"}
]
}- The entry carries the node and its attributes, and says its own words:
six variables are six buttons differing only in an attribute, and a key
into
:labelsbuilt out of one would be a key nobody can read.:iconthe same way. - Filtered against the schema like every other command: the node has to be declared, it has to be inline and void, and the attributes the entry names have to be ones the node would accept, asked of their own validators. A button that writes what the server refuses is a button that loses what the writer typed.
- The node commands stay a closed list, and for the reason they always did:
a block needs a decision about what happens to the selection and to what
surrounds it, and every kind of block needs a different one. An atom needs
none —
inline: true, void: trueis the verb. That is what makes this a command the hook knows rather than a door onto arbitrary node types. text:puts characters in instead — an emoji, an em dash, a non-breaking space. The attributes travel as JSON on a DOM attribute, so a variable keyed by a number arrives as a number rather than as"7".
A void node can show what it stands for
editor_text: names the attribute the editor draws inside a node that
has no content of its own, exported with the rest of the schema:
variable: [
group: "inline", inline: true, void: true,
attrs: [name: [...], label: [...]],
render: {"span", [{"data-variable", ""}]},
editor_text: :label
]The value is passed to ProseMirror as a text child rather than interpolated
into the spec, so what a writer put in an attribute cannot become an element.
The page gets whatever :render says, which is where the value is
substituted. Naming an attribute the node does not declare, or asking for
this on a node that holds content, raises when the schema is built rather
than drawing an empty chip in somebody's browser.
Emoji, and anything else that is one character made of several
Nothing changed here, which is the point — it is now pinned by tests. Text is
counted in grapheme clusters on both sides, so a family emoji is one
character in the editor's counter, one against :max_text_length, and
Coelho.Document.sanitize/2 never cuts one in half.
Said out loud
Coelho.Ash.Type documents how to keep the :string column a field already
lives in: override storage_type/1, encode in dump_to_native/2, decode in
cast_stored/2, and put the fallback for rows that still hold plain text
inside the type — the one place every reader goes through by construction
rather than by discipline. Found by an application reading the
defoverridable list, which is not where a feature that ships instead of
waiting should have to be found.
0.11.0 — 2026-08-22
What the audit of 0.9.0 left open, and what an application reported after running 0.10.0 in anger.
The field follows the editor
A debounce and an upload on the same form did not work together, and it was not the upload. Read off the socket: the upload completes, the attachment is inserted, and then LiveView patches the hidden field with the server's older copy — which the editor recognised as something it had written, and therefore left alone. The field stayed behind the editor, and the next thing to read it, a debounced change or a submit, posted the older document. An attachment inserted by the server vanished seconds after appearing.
- The editor now puts the field back whenever it is handed a document it has already moved past, whatever caused the re-render.
- Told apart from a decision by whether the server was still behind when it spoke: an application assigning a stored document back under a mounted editor — a Discard button, a reset, a moderation step — is sending something this editor may well have held earlier in the session, and that is taken, edits and all. It is also pushed back at most once per answer, so a server that insists is obeyed rather than argued with.
- What the editor has written is remembered as digests rather than as the documents themselves: less memory, and a window of two hundred documents instead of twenty. An echo answering a keystroke from further back than that used to be applied as a replacement, which is a writer rolled back to what they had typed by then.
The counter sits under the text
--coelho-counter-order defaults to 2, so the counter renders under the
editing area and against its right edge rather than between the toolbar and
the area it commands. A number counting what has been typed belongs at the
end of what it counts. --coelho-counter-order: 0 puts it back where it
was, and --coelho-counter-inset: 0 takes it to the left edge.
A storage can hand over its bytes in pieces
Coelho.Storagegained an optionalstream/2callback, andCoelho.Plug.Attachmentsanswerssend_chunkedwhen a storage implements it.read/2puts the whole object on the heap of whichever process asked, so N concurrent downloads held N copies of it; a storage with a local path was already served bysend_fileand never read into the VM at all.- A stream that gives up halfway — a socket reset, a credential that expired mid-read — ends the body rather than the request. The status and the headers are gone by then, so what a raise reaches is an error handler trying to answer a connection already answered.
An import can skip the reporting
Coelho.HTML.from_html/3 takes warnings: false. Whether an attribute was
used cannot be read off the names that came out — a rule is free to rename
as it extracts, and the shipped ones do — so the question is asked of the
rule instead, by taking the attribute away and matching again: one match per
attribute per element, and the only general way to ask. A migration whose
warnings nobody will read can now decline to pay for it.
Said out loud
- Build the schema once. Everything derived from a schema is derived when
it is built, so a schema is a value to keep in a module attribute rather
than rebuild per call — measured by an application at 110 µs a call against
2.8 µs once it was. Said in the README and in
Coelho.Schema. - Why the sixteen walks are not one.
Coelho.Render.reduce/4is documented as the fold to write a target on, and nothing in the library uses it. That is not an oversight: validation has to report on a node it does not know, sanitisation has to remove it, andCoelho.Attachments.keys/2— which decides what gets deleted — 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 a caller that what it holds is a document this schema admits. The two contracts are now written down, and pinned by tests rather than by intention.
0.10.0 — 2026-08-22
Three things an application could not reach from outside coelho_editor/1,
reported from a settings screen with a live preview beside the editor and a
document allowed twenty thousand characters.
The round trip can be spaced out
:debouncerendersphx-debounceon the hidden input the document travels in. Without it every keystroke ships the whole document and the server decodes and validates it again, which is what a long document beside a live preview pays per character. It could not be given from outside: LiveView reads the attribute off the element that emits and walks no ancestors, so neither:rest— which lands on the root — nor the enclosing form reaches the input, and a hook writing the attribute has it patched away by the next render.- Milliseconds and nothing else. LiveView's
"blur"waits for a blur event on the element carrying the attribute and a hidden input never blurs, so the change would be held for the life of the page; the component refuses anything that is not a number rather than rendering an editor that goes quiet. - Name
:flush_eventbeside it: a debounce still holding the last edit when the element leaves the page loses it, and the flush is what carries it out. - One interaction, measured and left open: on a form that also carries an
auto_upload, a debounce still pending on the field was observed to stop the change event that starts the upload from taking effect, in all three browser engines. The demo therefore uploads without one. Check the pair together before relying on it.
A toolbar button can name the level it makes
heading_1toheading_6, besideheading— the value baked into the name, the wayalign_centeralready carries its own. They are filtered against the schema like everything else, so a:levelthat accepts two levels shows two buttons; they take their own entry in:labelsand:icons; and each reads as pressed inside a heading of its own level and no other, so a second click turns the block back into a paragraph rather than re-levelling it. Before this, a toolbar could only ever make one level, and clicking it on a heading of another level silently re-levelled that heading.- A level button shows its words rather than a drawing unless an application passes one: six H's differing by a numeral are six buttons nobody can tell apart at 20px.
The two heading defaults agreed to disagree
- The schema declared
level: [default: 1, …]and the hook's button wrotelevel: 2from a constant of its own. Since validation drops an attribute equal to its default, a heading made by that button stored no level at all — so the editor drew anh1and every renderer reading the schema's default printed one, while the button suggestedh2. Reported by an application whose HTML and PDF renderers had been aligned on 2, shipping a WYSIWYG that lied. - The hook now reads
level's default off the exported schema. There is no second constant to keep in step, andheadingon its own makes the level the schema calls default —h1for the shipped schema, where it used to make anh2. An application that wants another level names it:heading_2. Coelho.Render.attr/3says that the default to pass is the schema's own, andCoelho.Schema.Default's heading render says which default it is written for.
A block keeps what the click said nothing about
- Toggling a block built the new one from the attributes the command named
and let every other one fall back to its default, so re-levelling a centred
heading straightened it and turning it back into a paragraph did the same.
The block's own attributes are carried across now, the way
setAlignhas always carried them. It mattered less when a toolbar had one heading button and no gesture that re-levels.
Two layout decisions become an application's
--coelho-command-pad(6px) sizes the room around a toolbar icon, so a button can be made to fit a settings panel's other controls without overriding the rule.--coelho-counter-order(0) and--coelho-counter-inset(auto) place the counter: it is a flex item of the editor's column, so2puts it under the area it counts and0takes it back to the left edge. Nothing moves for an application that says nothing.
0.9.0 — 2026-08-22
An audit for leaks, wasted work and duplication, and what it found. The BEAM side had no leak to find: no ETS, no processes, no atoms built from anything but a schema the developer wrote. What it had was work redone on paths that run once per keystroke and once per render. The browser side had three real leaks, and a bug hiding behind one of them.
An editor that is gone stays gone
- A pasted image being captured armed a fifteen-second timer that nothing cancelled. It fired after the editor was destroyed, dispatched into a view that no longer existed, and until it fired it held the element, the view, the schema and the twenty documents the editor remembers having written. Timers are cancelled on the way out, and everything that can come back late asks first whether there is still an editor to come back to.
- The fetch behind that capture could not be abandoned either. It carries an
AbortControllernow, aborted with the hook, and an abort is teardown rather than a failure worth telling anyone about. previewUrlsgrew for the lifetime of the page and never released ablob:URL. It is bounded now, and a URL is revoked where something knows it is finished with: the same key given a different one, or the application saying so through the newclearPreviewUrl(key)export. Never on eviction — an editor may still be showing it, and a blank image is worse than a preview the map has forgotten.- The alignment memo was one module-level slot holding a whole editor state
past the teardown of the editor it came from, and — with the selection
listener on
document— missing on every caret move whenever two editors shared a page. It is keyed on the state itself, so it holds nothing.
A schema changed under an editor is the schema its buttons use
A schema that moves while someone is writing rebuilds the view in place. The toolbar went on building its commands from the schema read at mount, so a click dispatched node and mark types belonging to a schema the current state had never heard of — the button did nothing, and said nothing. This is the path an application takes when it adds a mark of its own, so the demo grew a schema switch and the browser suite a check for it.
Settled once, where the schema is built
Everything derived from a schema is derived when the schema is built, in the
one place new/1, extend/2 and restrict/2 all converge on: the JSON the
editor component used to encode on every render, the empty document, the
mark ranks a canonical document sorts by, the tags the import recognises,
and the attribute names validation asks of every node. Deliberately not a
cache keyed by schema: an application building one per request would grow
that without bound, which is the shape of leak this went looking for.
Coelho.Schemagainedspec_of/2,fetch_node_spec/2,fetch_mark_spec/2andmark_allowed?/2— the questions six walks of a document each answered with their own copy of the same three steps — andjson/1,empty/1,parse_tags/1andattr_keys/1to read what the build settled.%Coelho.Schema{},%Coelho.Schema.NodeSpec{}and%Coelho.Schema.MarkSpec{}carry the derived fields. A schema built any other way still answers: every accessor derives on the spot rather than handing backnil, andmark_index/2falls back to the declaration order rather than ranking every mark the same.
Less work per keystroke, per render and per import
Coelho.Render.escape/1escapes in one pass rather than five, and hands back text that escapes to itself without copying it.- Adjacent text nodes are merged as iodata rather than re-concatenated pair by pair — quadratic in the length of a run, and a paste out of a word processor arrives as one node per word.
- The style property behind
render_as: {:style, …}is read by splitting rather than by a regex compiled again for every attribute of every element an import walks, and an element's attributes become a map once instead of once per node spec and once per mark spec. Coelho.Document.sanitize/2emits one[:coelho, :validate]event for the whole repair instead of one per pass, up to eight.- Whether a rendered child came to nothing is answered at its first byte rather than by measuring the whole subtree at every level.
- In the browser: the toolbar finds its buttons once, memoises its commands per schema, and compares before it writes; emptiness is asked without building the document as a string; and the counter is written once per frame rather than once per key.
Said rather than left to be discovered
Coelho.Telemetrysays that:keyis not a metric tag: a storage key is unbounded, so tagging by it is one metric series per attachment.Coelho.Storage.read/2says that it buffers the whole object, that the plug preferspath/2where a storage has one, and that a storage fronting large objects wantsredirect_url/3.
0.8.0 — 2026-08-22
The other half of 0.6.0. A mark an application added could be stored, validated and rendered, and 0.6.0 gave it a toolbar button — but nothing drew it in the editor, and a schema ProseMirror cannot draw is a schema no document can be mounted on. The editor came up empty, accepted typing, never read the stored document back, never counted, never emitted a change, and said nothing anywhere. Reported by the same application, after a day of eliminating eight other explanations first.
The thread through all of it: the server half was closed and explicit — strict validation, argued refusals — and the browser half was permissively silent. Both halves now answer the same way.
A mark of the schema draws itself
:renderand:parseare exported with the schema when they are declarations rather than functions, asrenderDOMandparseDOM. The browser buildstoDOMandparseDOMout of them, sohighlight: [class: "hl", render: {"mark", []}]draws in the editor exactly as it renders on the page, with no JavaScript written for it at all. This is whatrender_as: {:class, …}did for an attribute, done for the element.- A node or mark the browser still has no rendering for — one whose
:renderis a function, which only Elixir can run — is named at mount, and drawn as a bare<span>or<div>carrying whatever class the schema declared. The document mounts, the editor works, and the console says which name to givecreateCoelhoHook({marks: …}). It used to be a dead editor and no message. - The class and
editor_attrsa spec declares are no longer dropped in silence for want of atoDOM. Nothing but the text node and the top node can reach that case now, and either one says so. - A stored document the schema cannot mount logs the error that made it impossible before falling back to an empty editor. Swallowing it was what turned a schema bug into an afternoon of bisecting: the message names the node, mark or attribute that is missing, which is the whole answer.
A bound cuts to fit rather than emptying
Coelho.Document.sanitize/2answered a document over:max_text_lengthor:max_nodeswith an empty document. The mechanism was understandable — a bound is reported at the root, and the only repair at the root is replacing the root — and the effect was not: terms and conditions five hundred characters over a bound came back as nothing at all, and the application's own length guard, measuring what came back, then saw zero and had nothing to say. Text is now truncated at the bound and nodes are cut off at it, which is the repair that loses least.:max_depthemptied the document too, and for the same reason: what is nested too deep was replaced with an empty node rather than removed, so the bound went on refusing it. One bullet indented a level too far took every paragraph beside it. It is dropped now, and what cannot stand without it — a list item with nothing left in it — goes the way any node whose content no longer satisfies its expression goes.sanitize/3takes:limits, which override the schema's for that call. A bound is a bound on writing — the browser posts into a hidden field nomaxlengthconstrains — and reading is a different question:limits: [max_text_length: :infinity]cleans the structure and leaves the length for the application to judge, with the whole document in hand to judge it on. It replaces keeping a second schema per field to sanitise against.
Schema.extend/2 adjusts a name rather than replacing it
- Redeclaring an existing node or mark now keeps what the declaration does
not mention. Giving the shipped
bolda theme's class was a line that silently took itsparse: ~w(strong b)with it, so a<strong>pasted out of a word processor came in as plain text — found by a code review, not by a test. A whole declaration key is still the unit:attrs:replaces the attribute map rather than merging into it, because an override that can only add is not an override.
The stylesheet
--coelho-max-height(defaultnone) and--coelho-height(defaultauto). An editor that grows with its text pushes the buttons that save it off the bottom of the screen, and several thousand characters of terms and conditions is exactly that.- The editor's own rule sets
colorbeside thebackgroundit already set. Half a pair is worse than neither: an application giving--coelho-surfaceits own theme's colour got the page's text colour on it, which is how a field holding a thousand words comes to look empty. data-coelho-themeis documented, takesdarkas well aslight, and now opts out of the automatic dark palette whatever its value. The palette has to be all or nothing: a property set on the element beats one the element merely inherited, whatever either one's specificity, so an application declaring its own tokens further up the page got its surface and our text — light on light, and a field that looks blank. Weakening the selector only moves which half is lost; one attribute settles it.
0.7.0 — 2026-08-22
The toolbar draws
- An icon per command, from the new
Coelho.Icons— line drawings written for the library rather than taken from an icon set, so there is nothing to attribute and no second visual identity arriving with the dependency. They are stroked incurrentColor, so they take the button's colour in every state the stylesheet already draws, and sized by a new--coelho-iconcustom property. - The command's name is now the button's
titleand itsaria-label, so a pointer and a screen reader are told the same thing. It used to be the button's visible text. - Those names are English by default, like the field's own words and for the
same reason: a tooltip reading
bullet_listis worse than no tooltip.:labelsstill overrides them, still redraws the toolbar when it changes, and still reaches the field beside it through:field_labels— the translation path did not need anything new. :iconsreplaces one drawing or all of them. A command the library does not draw — a mark an application added — shows its label as text, which is what it did before an icon existed.
0.6.0 — 2026-08-21
The third layer. A formatting exists end to end when the schema can carry it, the renderer can show it, and a toolbar button can set it — and the third was closed over a hard-coded list. Reported by an application whose own marks were stored, validated, rendered, and invisible.
Any mark of the schema is a button
- The toolbar's mark list (
bold italic strike code link) is gone from both halves. A mark always toggles the same way, so a mark added withSchema.extend/2— a highlight, an effect — gets its button without the application writing a line of JavaScript.linkkeeps its own case, which opens the field. - Node commands stay a closed list, deliberately: each kind of node takes its own verb in the hook — toggle a block, wrap, list — and a node added to a schema gets no verb with it. The server now filters against that list, so a custom node's button is dropped rather than rendered inert.
- A button the hook has no command for is greyed out.
Boolean(command) && !command(…)read as "enabled" precisely when there was no command — the one case the guard was for. aria-pressedis removed when a command has no state to report, rather than left as it was. A button that stays in the toolbar while its answer turns to "no state" — an alignment button once the selection leaves every alignable block — went on announcing pressed to a screen reader about a block that was no longer there.
Alignment has commands
align_left,align_center,align_right,align_justify. Thealignattribute was declared, rendered and read back since 0.2, but nothing could set it: it existed only where an import had carried it in. Not in the default toolbar — name them to show them.- The toggle is decided once for the whole selection, like a mark button: everything already carries the value, the click removes it everywhere; otherwise it applies it everywhere. Per-node would center some blocks and uncenter others in the same click.
- Only the outermost alignable block takes the attribute — a
list_itemand itsparagraphboth declare it, and writing both would nest twotext-aligns and put a redundant attribute into the canonical serialization, so intoCoelho.hash/2. - Aligning left clears the attribute rather than writing
"left". The two look the same, and a hash used as proof of acceptance must not tell apart documents that differ only in which buttons the writer happened to click. An explicit"left", which only import produces, is read as the same alignment as none throughout:align_lefton such a block reads pressed and offers nothing to do, instead of a click that would change only the hash. A command that finds nothing to change dispatches nothing at all — an empty transaction still rewrites the hidden input with ProseMirror's serialization, which is not the server's canonical form, and WebKit lost the race with the server's echo where Chromium happened to win it. - The server keeps an
align_*button by asking the attribute's own validator, so a schema that narrows the alignments filters its buttons by itself — for the four standard values only, which are the four the hook has a command for. Matching the prefix alone took names from the marks:align_termswould have been dropped though the hook would toggle it, and a node declaringalignwith no validator (Attr.validate(nil, _)is:ok) would have renderedalign_middleas a button with no command.
An attribute can say how it renders
:render_ason an attribute spec —{:style, property}or{:class, %{value => class}}. Data rather than a render function, so the server applies it and the exported schema carries it to the browser: the editor shows what the page will carry, and an application changes it in one place without writing any JavaScript. It is the mechanism:classon a node already was, for a class the value decides.Coelho.Schema.Default.build/1takes:align, so switching the shipped alignment to classes is one line rather than three redeclared blocks —Schema.extend/2replaces a node's declaration rather than completing it, and restatingparagraph,headingandlist_itemto change one attribute is the friction this exists to remove.Coelho.Schema.Default.build(align: {:class, %{"center" => "text-center"}})The default is
{:style, "text-align"}, so nothing moves: the shipped output is byte for byte what it was, which is what the parity tests assert. The style is what ships because it needs no stylesheet — the HTML works in an email, a feed, an export — and it is also what a page's own CSS cannot answer, which is why the other form now exists.Both forms are closed over the values they name, because
Coelho.Ecto.Typedoes not re-validate a stored document and every renderer is therefore a security boundary. A class map is its own allow list.{:style, property}has none of its own, so it is refused at schema build time on an attribute whose validator is not a{:one_of, list}, and the value is checked against that list again when it renders.A node's
:rendermay name its tag with a function of the node, which is whatheadingnow does. It was a render function — building its whole element, and so reaching neither the spec's:classnor its attributes':render_as— only because its tag is what its level decides. Three hand-written alignment paths go with it, one in each half.
The installer, twice
- The stylesheet's
@importgoes right after the last@import, as the doc always said — not after the last at-rule of a preamble that counted Tailwind's. In a Phoenix 1.8app.cssthe last@custom-variantsits two hundred lines in, past@pluginblocks and rules, where CSS drops a late@importsilently. mix coelho.installnow diagnoses the esbuild config: bareprosemirror-*imports resolve fromdeps/coelho/, which never reachesassets/node_moduleson its own, and the first build failed withCould not resolve "prosemirror-keymap"and no pointer to the cause. The task never editsconfig/config.exs— the profile is the application's own — it says which path to add toNODE_PATH, as a list entry, keepingMix.Project.build_path()and the rest of what is there. It names every profile that is short rather than clearing the lot on the first one that is covered — which profile bundlesapp.jsis not something to guess at, and "another profile has it" is the all-clear that hides the failure the step exists for. It readsenvas a map or as a keyword list, both of which esbuild accepts, and resolves a relativeNODE_PATHentry from the profile'scd:, as esbuild does.
0.5.0 — 2026-08-21
The three things left after 0.4.0, all of them.
Rendering inside a paragraph
Coelho.to_inline_html/3,to_safe_inline_html/3andto_inline_iodata/3. A<p>inside a<p>is not nested by the browser, it is closed by it — so a document rendered into a banner, a map bubble or a card excerpt ended the enclosing paragraph where its own first one began, stopped every class on it from applying, left an empty paragraph behind, and ran the words of two paragraphs together where the tags that separated them had been. The last is the one nobody sees, because it looks like text.It was doable with render overrides and nobody would have got it right: overriding
paragraphto render nothing givesun grasdeux— two words fused — and still emits the<h2>and the<ul>.The guarantee is one sentence, which is also what makes it testable in one assertion rather than thirty cases: the output holds nothing that is illegal in an inline context. Everything else follows without a judgement call — marks stay,
<img>and<br>stay, every block is unwrapped to its children, and a node with no inline form contributes nothing.:render_inlineon a node spec, for a node whose block-ness lives inside a render function rather than in the tree. The attachment is the case: it isvoid: true, so unwrapping it towards its children gives nothing, and contributing nothing would haveblank?/2answer "there is something here" about a document that then rendered as empty — two functions of the same library contradicting each other about the same document.The separator belongs to the caller, because only the caller knows whether its container can take a line break.
:spaceby default: a space where a break was wanted puts two sentences on one line, which reads, while a break where a space was wanted grows the caller's box and breaks their layout. A separator of your own is escaped unless it is{:safe, iodata}, since a value that reached it from data would otherwise be markup.
Installing it
mix coelho.install, the last of the three things left after 0.4.0 and the one that decides whether anyone tries the library on a Sunday. It installs the browser packages, imports the hook intoassets/js/app.jsand adds it to the LiveSocket, imports the stylesheet intoassets/css/app.css, and runs the attachments migration.It is idempotent, it asks before running
npm,--dry-runreports without writing, and it says what to do rather than guessing when anapp.jsis not shaped the way it expects — an application'sapp.jsis its own, and an unfamiliar LiveSocket is not a reason to rewrite it badly.The package list comes from Coelho's own
peerDependencies, read when the task compiles, so it cannot drift from what the hook imports.
What the field says
:field_labelsoncoelho_editor/1."Link address","https://…","Caption"and"Describe this attachment"were hard-coded English in the JavaScript, and:labelscovers only the toolbar's commands. And there was no hint under the field at all — the gesture that removes a link, emptying the field, was something a writer had to be told or discover.- Both are in the toolbar's fingerprint and not the schema's, so a language switched mid-session redraws the words without costing the writer their undo history.
Fixed
- A language switched mid-session reaches the field, which is what the whole
arrangement was for. The hook read
:field_labelsonce at mount and never again, so the buttons changed and the link field kept the words it was born with until a full remount — and every line of documentation saying otherwise was wrong. Re-read when the toolbar's fingerprint moves, and when the editor is rebuilt. - The hint is announced, not only drawn. It carries an id and the input
points
aria-describedbyat it while it is shown: a hint exists for the writer who has not been told the gesture, which is first of all the writer who cannot see it. ""is an answer. An application passing an empty string for one of the field's words meant "say nothing here", and fell back to the English instead.- Inline rendering honours a caller's
:nodesoverride, including for thetextnode — the block renderer goes out of its way not to short circuit there so that no node is the one nobody can reach, and the inline one advertised the same options while quietly ignoring them. :render_inlineis reached for an inline node too. It was checked after:inline, so the one escape hatch for an inline-grouped node whose ordinary render is a block-ish wrapper did nothing at all.- An attachment with no filename no longer vanishes from an inline render.
filenameaccepts""and so doeskey, and a contribution of nothing is dropped when blocks are joined — out of a documentblank?/2calls non-blank, which is the contradiction:render_inlineexists to avoid. It always renders an element now, with the classes the page and the editor already use. mix coelho.installfinds where a statement ends rather than where a line matches, in both files it edits.import {\n LiveSocket\n} from "…"hasimport {as its last line matchingimport, so the hook went into the middle of it — a syntax error in the file the whole bundle is built from.@plugin "…" { … }spans lines the same way, so the stylesheet went inside the block — invalid CSS. Both on the first run of the command that exists to make the first ten minutes work, and neither shape is exotic:coelho.jsitself uses multi-line imports.- Inline rendering honours a caller's
:nodesoverride for a block too, not only for text and inline nodes. Overridingattachmentis the natural thing for an application resolving its own URLs, and it was being ignored. - An attachment that resolves keeps its link inline.
<a>is legal in an inline context, so there was no reason to drop the href — a card excerpt lost the download entirely. - An empty caption adds nothing rather than a trailing space, which the join then followed with a separator.
to_inline_iodata/3emits the render span, so a new public render path is not invisible to a handler the README tells applications to attach.mix coelho.installchecks the version a package is declared at, not only its name. An application pinned to an olderprosemirror-viewwas told everything was there and failed insidecoelho.js, a long way from the cause.mix coelho.installreports a failednpm installas a failure. The exit status was discarded, so a run with no network told the reader the editor would render with none of its packages present.- And its
--dry-runno longer offers to generate a migration that is already there.
Also
- The document generators are shared between the properties rather than
copied into each — which immediately widened what the older ones see, and
showed that "plain text extraction only ever yields text the document
holds" had always been narrower than its name: a node with a
:to_textin its spec contributes something the document does not hold as a text node, which is exactly what that field is for.
0.4.0 — 2026-08-21
A third adoption report, on 0.3.1, and the theme this time is the distance between "it works" and "you can pick it up". Most of it is things that were there and unsaid, or there and left to the caller.
The last link in the rendering argument
Coelho.to_safe_html/3answers{:safe, iodata}.to_html/3answers aString.t(), which a template treats as text — correctly, since it cannot know it is markup — so the caller had to rememberraw/1, with two ways to get it wrong: forget it and the reader is shown the source of their own document; reach for it elsewhere and something that should have been escaped no longer is. For a package whose argument is that rendering is safe by construction, that was the last link left to the caller. It costs no dependency:{:safe, iodata}is whatPhoenix.HTML.Engineunwraps directly. There is noPhoenix.HTML.Safeimplementation because there is nothing to implement it for — a document is a bare map, deliberately, and an implementation forMapwould apply to every map in the application.
Asking whether there is anything there
Coelho.blank?/2. An application deciding whether to render a block at all had onlyempty/1, which builds an empty document, and the obvious stand-intext_length(document) == 0is wrong in the direction that loses content: a document holding one image, or one attachment, has no text and is very much not blank. This asks the schema — a node it declaresvoid: truerenders an element of its own and counts.
Ash
cast_atomic/2, explicitly. The inherited default refused an expression with a message about the type not supporting atomic updates, which is true and no help. A document is validated by walking its tree in Elixir, and none of that can be handed to the database, so this is not a gap to close later: the reason now says so and says what to do instead. A literal document still goes through, validated like any other cast.- What the type does about storage and tenants, in one place where an Ash
user will look for it: nothing to configure,
jsonbin the row that owns it, no interaction with AshPostgres multitenancy — and the rule that a key never decides whose a file is, repeated where it is needed rather than only in the plug's documentation.
Ready to use
- A starter stylesheet, at
assets/css/coelho.css. Structure, states and the things a person needs to see — focus, which commands are in force, a counter gone over — with no identity of its own: every colour, radius and space is a custom property with a neutral default. The demo imports it and overrides two properties, which is now the whole of its editor styling; hand-writing them there meant the stylesheet nobody had to write was also the stylesheet nobody was running. - The keyboard, written down. It was all bound and none of it was documented, which for a ready-to-use editor is part of the contract.
Coelho.Telemetry— spans around validation, rendering and storage, in the usual:start/:stop/:exceptionshape.:telemetryis optional and the spans compile down to calling the function without it. The schema travels as a fingerprint, because a schema in the metadata of every keystroke's validation would hand every handler a copy of it.- How to make a document searchable, on
Coelho.Document.to_text/2: ajsonbcolumn is not searchable as it stands, so the text has to become a column of its own, written when the document is. With the migration, the changeset, and the two things that follow from it being a derivative.
Fixed
- Telemetry costs nothing where nothing is listening. The first cut built its
metadata as an ordinary argument, so exporting and hashing the schema —
6.4 µs — plus two walks of the document ran on every validate and every
render whether
:telemetrywas loaded or not, which is the measurement costing more than the work on a path documented as running per keystroke. The metadata and the measurements are functions now and a build without:telemetrycalls neither; the schema's fingerprint is settled once when the schema is built (Coelho.Schema.fingerprint/1); and the node and character counts come from the bounds check validation runs anyway rather than from walks of their own. Measured back down from 71 to 59 µs per validate. Coelho.Ash.Type'scast_atomic/2answers{:ok, …}for a literal document, which is what Ash's own default answers.{:atomic, …}puts the value straight into the changeset's atomics, past theallow_nil?and required-attribute checks — andnilis a value this type casts, so the difference was a document that could be nulled on an attribute declaring it may not be. Unreachable through Ash today, which short-circuits literals, and reachable the moment a type defineshandle_change/3.- And it routes through the type's own
cast_input/2, so a module overriding it — whichdefoverridableinvites — keeps the override on this path too. blank?/2no longer counts a hard break as content. An inline void node declaring no attributes is punctuation, and a pasted-then-emptied field usually leaves behind a paragraph holding exactly one — which would have rendered a heading with nothing under it, the failure the function exists to prevent.nilrenders as nothing rather than raising. It is what a nullable column holds and what both stored types cast an absent document to, so the one-liner this release recommends would have taken the page down on it.- The editor draws an attachment the way the page does: the same classes, and
the caption as a
figcaptionrather than not at all. A captioned attachment looked materially different while it was being written. - Changing
:labelson a mounted editor redraws the toolbar. The fingerprint the toolbar's id carries covered the schema and the commands but not their words, so a language switched mid-session changed nothing on screen — and the editor now carries two fingerprints rather than one, because they cost very different things. The schema's makes the hook rebuild the editor, which re-parses the document and starts a fresh undo history; the toolbar's only redraws the buttons. Folding the labels into a single fingerprint would have made a changed word throw away the writer's undo stack and move their caret. - The component's documentation still said the button list was fixed once rendered, which stopped being true in 0.3.0 when the fingerprint went in — and was read, reasonably, as the behaviour.
- The demo's hand-written styles gave
.coelho-linkadisplay, which beat the browser's own rule for[hidden]— so the link field was never actually hidden, and the browser check that fills it was filling a field permanently on screen rather than one the toolbar had opened. Importing the shipped stylesheet madehiddenmean hidden and the check started failing, which is the check finally doing its job. - And with it, a fragility in the browser harness: it pressed select-all in the same breath as the click that focused the editor, which sends the press before ProseMirror has put its selection where the click asked. It selected nothing, silently, and what failed was whatever needed the selection three lines later. Only visible once the shipped stylesheet gave the editable its own height — clicks then land in the empty space below the text rather than on it, which is what a tall editor is mostly made of.
0.3.1 — 2026-08-21
Fixed
The third route to the shortening 0.3.0 closed twice. "Every text node in an inline context is collapsed" was enforced in one place that
wrap_inline_runs/3never called: it built its block directly, so a run it wrapped reached storage exactly as it arrived. That bites whenfit/3has lifted a code block out of somewhere it could not sit — the text comes up verbatim, is left loose among the block's children, and gets wrapped:<blockquote><pre>a b</pre></blockquote>stored as
a b, rendered as<blockquote><p>a b</p></blockquote>, and imported again asa b. The shipped schema cannot reach it — no node there admits blocks while refusing code blocks, so the lift never happens — but a schema of your own can. Routing the run through the same place fixes the other half too: lifting can leave two text nodes of the same marks side by side, and they belong together.
Documented
- What
Coelho.Plug.Attachments':authorizedoes at its edges, none of which the documentation had answered and all of which the code already got right. It runs before:metadatais looked up and beforeCoelho.Storage.redirect_url/3is asked for anything, so a refusal costs one callback and no query. A refusal is403with the same body a bad signature gets, and deliberately not404— telling "this is not yours" apart from "this does not exist" tells the caller it exists. And it fails closed by failing: nothing rescues, so an exception becomes a500and is never turned into permission. A test each.
0.3.0 — 2026-08-21
A second adoption report, on 0.2.0. Almost all of it is the editor: 0.2.0 answered what the document could not do, and left the component assuming a shape — a form, a changeset, a field — that the application reporting had nowhere to get.
The editor
:nameand:value, instead of a:field. The component required a%Phoenix.HTML.FormField{}, which a surface with no changeset behind it — a JSONB draft posted straight intophx-change— could only satisfy by fabricating one. Give it a name and a value instead.:flush_event, for the keystrokes a debounce is still holding. The editor writes into its hidden input and letsphx-changecarry it, so aphx-debouncecan still be holding the last edit when the element leaves the DOM: LiveView cancels the timer with the element and the change is lost. Cancelling a draft, switching a tab, collapsing a section — each removes the editor, and each was where the writer lost their last few characters. The hook now pushes the document on the way out, with a token the application chose, so a flush from before a cancellation can be refused rather than putting back what the cancellation threw away.:maxlength, and a counter that is right at the first paint. The count isCoelho.Document.text_length/1, the same unit the schema'smax_text_lengthis checked against, and the server renders the first one so an existing document does not read zero until the hook has started. It shows; refusing is still the schema's job.- A schema changed under a mounted editor is now picked up. The container
carries
phx-update="ignore", which meant the node types, the marks and the classes they carry stayed the ones read at mount — what the writer saw stopped matching what the page would render. The hook follows a fingerprint on its own element and rebuilds the view, keeping the document; the toolbar is redrawn with it. Ids are untouched, soeditor_id/1andinsert_node/3are unaffected. :labels, because a toolbar has to speak the reader's language and the commands are not words.- The flush token comes back as a string, since it travels as a DOM attribute. Comparing it to an integer generation is always false, and every flush is dropped by the clause meant to catch the stale ones.
coelho_schema/1, so several editors share one copy of the exported schema instead of carrying 1.3 KB each.
Testing it
Coelho.LiveViewTest—type/4posts a document as the hook would,document/2reads back what an editor is holding,params/3builds the parameters for a test that sends them its own way. The editor's container isphx-update="ignore", so it is invisible torender_change/2: every test touching it was encoding JSON and nesting parameters by hand.
Serving attachments
:authorizeonCoelho.Plug.Attachments. A signed URL is a bearer token: whoever holds it, holds the file. Mounting the plug behind the application's authentication pipeline does not close that — it answers "may this person use the application", never "is this file theirs", so a URL minted for one organisation and replayed by a member of another passes both the pipeline and the signature. The callback is given the connection and the key, and the tenant comes from the connection: the key arrives from the URL, so deriving it from there would be asking the attacker which tenant they are in.Coelho.Attachment.generate_key/1takes a:prefix, for an application that backs up or purges per organisation and has no way to list one organisation's objects. It is an inventory aid and never an authorization boundary, and it says so. It belongs with object storage:Coelho.Storage.Diskshards on a key's first two characters, which a shared prefix makes identical for every tenant.- A reference S3 adapter, in
Coelho.Storage's documentation rather than in the package: an adapter means an HTTP client and a signing library, and the document core has no dependencies at all. With the two things that bite —put/3must stream rather than read, and ExAws over HTTP/2 fails above about a megabyte with:send_buffer_full, which looks like a Coelho problem and is a transport one.
Messages
Coelho.Document.Error.describe/1takes an error apart — position counted from 1, scope, attribute name, mark index — for an application that has to word it in its own language.humanize/1is an English default that saysblock 2, "href": …instead ofcontent[1].marks[0].attrs.href. Both changesets carry it, under:human, beside the machine one.
Fixed
coelho_schema/1's JSON was rendered literally as{@json}: HEEx leaves the content of a<script>alone so that a JavaScript object literal survives it, and the curly interpolation is not interpolation there. It is a hidden element with adata-attribute now — patched like anything else, and with none of a script's escaping rules. Marking itphx-update="ignore"to keep LiveView off it, which the first version did, froze the one thing an editor reads to notice its schema moved.- Rebuilding after a schema change no longer empties the document.
Node.fromJSONis all or nothing — it throws on the first node, mark or attribute the new schema does not recognise — so a renamed mark used to take the writer's whole document with it, while the hidden input went on holding the old JSON. The document is re-interpreted through the DOM, which is ProseMirror's own lenient path: what the new schema can parse it keeps, what it cannot it drops. - The link and caption field is found again after a rebuild. The toolbar's id carries the schema fingerprint, so a schema change makes LiveView replace it — and the field captured at mount was left detached, with the button appearing to do nothing at all.
- The HTML import shortens text no longer, the other way it could. 0.2.0
fixed a run split across two nodes by an element the schema drops; this is
the same defect reached from the other side. A
<pre>keeps its whitespace to the character, which is what a code block is for — but a code block that cannot sit where it landed is lifted, and its text arrives verbatim in a paragraph, where nothing keeps it. Stored like that, the next import collapses it. Every text node in an inline context is collapsed now, whole; a node that takes its text verbatim never reaches that path at all. Found by CI, on a seed 120 local seeds had not drawn. - The flush's failure guard catches the failure it was written for.
pushEventrejects a promise rather than throwing when the socket has gone, so thetry/catchnever ran and a page navigation logged an uncaught error instead of a warning.
0.2.0 — 2026-08-21
Everything here answers a report from an application that tried to adopt 0.1.0 and listed what stopped it. The theme is that 0.1.0 covered the path from the editor to the database, and left the paths out of it — to a renderer that is not HTML, to a proof of what was accepted, to a page served from a row nobody re-checked — to the application.
Breaking
Coelho.from_html/2andCoelho.HTML.from_html/2now answer{:ok, document, warnings}rather than{:ok, document}. The import is lenient by design, and staying silent about what it dropped meant an imported document lost its tables and the person who pasted it found out from a reader.- An attribute left at its schema default is no longer written into the
document. Two editors that disagreed on whether to send
align: "left"stored different documents for the same text, which made a digest of the document worth nothing — and the absent key is also what stops a plain paragraph from carrying an"attrs"object. Renderers reading attributes directly should read them throughCoelho.Render.attr/3, which takes the schema default. - Marks are sorted into the schema's declaration order, which is what
ProseMirror ranks them by.
["bold", "link"]and["link", "bold"]describe the same fragment and now normalise to the same document. - Every schema carries
:limits— 10 000 nodes, 100 levels, 1 000 000 characters unless it says otherwise. Nothing bounded the size of a document arriving in a hidden form field before.limits: [max_nodes: :infinity]lifts a bound deliberately.
Rendering somewhere other than a web page
Coelho.Render.reduce/4folds a document into any term at all, through a:nodeand a:textcallback, where:textis handed the marks resolved against the schema in a stable order. The result is not constrained to iodata, so a target with its own escaping — a typesetting language, a search index — gets a tree of plain terms and lets its own encoder do the quoting.Coelho.reduce/4is the same thing over the shipped schema.Coelho.Render.attr/3reads an attribute with its schema default in hand.
Proving what was accepted
Coelho.Document.canonical/1serialises a validated document byte for byte the same however its keys are ordered — which a plain JSON encoding cannot promise, sincejsonbreorders keys on its own.Coelho.Document.hash/2is its digest,nilfor a document holding nothing. Hash a validated document: a digest taken on the value read back from the database answers a different question.
The way out of storage
Coelho.Document.sanitize/2turns any term into a document the schema accepts, without failing and without reporting. Stored documents are not re-validated on load, so a row written under a looser schema or by a direct SQL write reached a public page unchecked. A hostile document becomes a poor document: ajavascript:link becomes plain text, a heading claiming level 99 becomes a level 1 heading, an unknown node goes.
Ash
Coelho.Ash.Type, auserather than a ready-made module, because Coelho does not depend on Ash — not even optionally: Ash depends on:stream_datain every environment and Coelho keeps it to:devand:test. One module in your application, and the schema arrives as a constraint. Failures surface asAsh.Error.Changes.InvalidAttributewith the location in the document tree invars, so a form can say more than "is invalid".
Schemas
Coelho.Schema.restrict/2narrows a schema by subtraction. Six fields with six different vocabularies were six full schemas to keep consistent by hand; a restricted schema cannot accept what its parent rejects.:classand:editor_attrson a node or mark spec. The class is applied by the server renderer and exported to the browser, so the writer sees the class the public page will carry, declared once.paragraph,headingandlist_itemin the shipped schema carry analignattribute —left,center,right,justify— rendered as atext-alignstyle and read back on import.- A schema may declare a
:version.validate/2stamps it and refuses a document stamped differently, andCoelho.migrate/2is the deliberate move between two versions.
Counting
Coelho.Document.text_length/1, andtextLengthin the browser half, count the same thing: the text nodes concatenated, no bullets and no blank lines. A counter measured onto_text/2rejects a document the editor still shows as under the limit, with nothing on screen to explain the gap.
Fixed
The HTML import no longer shortens text a space at a time. Whitespace is collapsed per text node, and an element the schema does not know is transparent — so
a <a> b</a>with nohrefarrived as two nodes that had each kept one space, and storing them side by side stored two. Importing what that rendered to collapsed the pair back to one, so a round trip through storage kept rewriting people's documents. A run of text is now made whole before it is stored and collapsed as the one run it is, never joined across a mark — the space inside an emphasis is emphasised.This was what made the round-trip property fail on roughly one seed in twelve.
Also
- An unknown mark is now named in the error rather than reported as
missing or unknown "type". - An unknown attribute is reported at its own key —
content[0].attrs.onclickrather thancontent[0].attrs. The path is whatsanitize/2repairs from, and an error atattrstook every attribute on the node with it, so one stray key cost a heading its level. Coelho.Document.Error.format_path/1is public.
0.1.0
First release. Everything below is new, so the list is what the library does rather than what changed.
The document
Coelho.Schema— nodes, marks, content expressions, attribute validators.extend/2adds to the schema that ships rather than replacing it, andto_json/1exports it for the browser, ordered, because ProseMirror resolves default types by position.Coelho.Document—validate/2is the sanitisation: an unknown node, mark or attribute rejects the document, so nothing outside the schema reaches the database. It also normalises — attribute defaults filled, marks deduplicated, adjacent text runs merged — so what is stored is canonical. Nesting past 100 levels is refused, error paths and sibling errors accumulate linearly, and the strings kept are copied so a document does not pin the payload it was parsed from.Coelho.Render— HTML from the document, overridable per node and per mark, with a:contextfor what only the application knows.
Storing it
Coelho.Ecto.rich_text/2and a parameterized Ecto type that validates on cast, attaching the schema violations to the changeset. Documents already in the database are not re-validated on load.- Inline in a
:mapcolumn on the table that owns it. No side table, no join.
Editing it
Coelho.LiveView.coelho_editor/1— the editor as a function component. The document travels through a hidden input, so it is an ordinary form field. The toolbar says what is in force and disables what cannot run; links and captions are edited in a field beside it.assets/js/coelho.js— the browser half, built on ProseMirror. Images pasted from other sites are fetched and stored rather than hotlinked.
Attachments
Coelho.Attachmentandmix coelho.gen.migrationfor the metadata,Coelho.Storagefor the bytes, with a local-filesystem implementation.Coelho.Attachments.signed_url/4andCoelho.Plug.Attachments— the document stores a key, never a URL, and the URL is signed and expiring. Only a short list of image types is served inline; everything else, SVG included, is sent as a download.Coelho.Attachments.orphans/3andsweep/4for the bytes no document refers to any more.
Coming from HTML
Coelho.HTML.from_html/2— the migration path for content already stored as markup. Unknown elements are transparent,<script>and friends are dropped with their content, and an element whose attributes fail the schema is treated as unknown, so ajavascript:link loses the link and keeps the text.
Known gaps
- No tables, and no image resizing.
- Only a local-filesystem storage ships; object storage means implementing four callbacks.
- Composition (IME) is checked committing and surviving a round trip, but not with a server echo landing mid-composition.
- Real-time collaboration is out of scope for now.