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.