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 <pre was 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_depth now 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 whose src is 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 language was validated as any string and rendered as class="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é.pdf as 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 :form option could never work: Phoenix.LiveViewTest.form/3 refuses 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.exs and mix test --cover in CI. The number is not the point; the list of lines nothing had ever run is. It named Coelho itself 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/2 was 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/2 says what nil means. Five shapes of empty document answer it; they draw different pages. nil == nil is 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 :labels built out of one would be a key nobody can read. :icon the 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: true is 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.Storage gained an optional stream/2 callback, and Coelho.Plug.Attachments answers send_chunked when a storage implements it. read/2 puts 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 by send_file and 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/4 is 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, and Coelho.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

  • :debounce renders phx-debounce on 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_event beside 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_1 to heading_6, beside heading — the value baked into the name, the way align_center already carries its own. They are filtered against the schema like everything else, so a :level that accepts two levels shows two buttons; they take their own entry in :labels and :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 wrote level: 2 from 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 an h1 and every renderer reading the schema's default printed one, while the button suggested h2. 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, and heading on its own makes the level the schema calls defaulth1 for the shipped schema, where it used to make an h2. An application that wants another level names it: heading_2.
  • Coelho.Render.attr/3 says that the default to pass is the schema's own, and Coelho.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 setAlign has 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, so 2 puts it under the area it counts and 0 takes 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 AbortController now, aborted with the hook, and an abort is teardown rather than a failure worth telling anyone about.
  • previewUrls grew for the lifetime of the page and never released a blob: 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 new clearPreviewUrl(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.Schema gained spec_of/2, fetch_node_spec/2, fetch_mark_spec/2 and mark_allowed?/2 — the questions six walks of a document each answered with their own copy of the same three steps — and json/1, empty/1, parse_tags/1 and attr_keys/1 to 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 back nil, and mark_index/2 falls back to the declaration order rather than ranking every mark the same.

Less work per keystroke, per render and per import

  • Coelho.Render.escape/1 escapes 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/2 emits 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.Telemetry says that :key is not a metric tag: a storage key is unbounded, so tagging by it is one metric series per attachment.
  • Coelho.Storage.read/2 says that it buffers the whole object, that the plug prefers path/2 where a storage has one, and that a storage fronting large objects wants redirect_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

  • :render and :parse are exported with the schema when they are declarations rather than functions, as renderDOM and parseDOM. The browser builds toDOM and parseDOM out of them, so highlight: [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 what render_as: {:class, …} did for an attribute, done for the element.
  • A node or mark the browser still has no rendering for — one whose :render is 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 give createCoelhoHook({marks: …}). It used to be a dead editor and no message.
  • The class and editor_attrs a spec declares are no longer dropped in silence for want of a toDOM. 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/2 answered a document over :max_text_length or :max_nodes with 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_depth emptied 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/3 takes :limits, which override the schema's for that call. A bound is a bound on writing — the browser posts into a hidden field no maxlength constrains — 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 bold a theme's class was a line that silently took its parse: ~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 (default none) and --coelho-height (default auto). 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 color beside the background it already set. Half a pair is worse than neither: an application giving --coelho-surface its 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-theme is documented, takes dark as well as light, 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 in currentColor, so they take the button's colour in every state the stylesheet already draws, and sized by a new --coelho-icon custom property.
  • The command's name is now the button's title and its aria-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_list is worse than no tooltip. :labels still 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.
  • :icons replaces 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 with Schema.extend/2 — a highlight, an effect — gets its button without the application writing a line of JavaScript. link keeps 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-pressed is 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. The align attribute 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_item and its paragraph both declare it, and writing both would nest two text-aligns and put a redundant attribute into the canonical serialization, so into Coelho.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_left on 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_terms would have been dropped though the hook would toggle it, and a node declaring align with no validator (Attr.validate(nil, _) is :ok) would have rendered align_middle as a button with no command.

An attribute can say how it renders

  • :render_as on 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 :class on a node already was, for a class the value decides.

  • Coelho.Schema.Default.build/1 takes :align, so switching the shipped alignment to classes is one line rather than three redeclared blocks — Schema.extend/2 replaces a node's declaration rather than completing it, and restating paragraph, heading and list_item to 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.Type does 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 :render may name its tag with a function of the node, which is what heading now does. It was a render function — building its whole element, and so reaching neither the spec's :class nor 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 @import goes 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.8 app.css the last @custom-variant sits two hundred lines in, past @plugin blocks and rules, where CSS drops a late @import silently.
  • mix coelho.install now diagnoses the esbuild config: bare prosemirror-* imports resolve from deps/coelho/, which never reaches assets/node_modules on its own, and the first build failed with Could not resolve "prosemirror-keymap" and no pointer to the cause. The task never edits config/config.exs — the profile is the application's own — it says which path to add to NODE_PATH, as a list entry, keeping Mix.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 bundles app.js is not something to guess at, and "another profile has it" is the all-clear that hides the failure the step exists for. It reads env as a map or as a keyword list, both of which esbuild accepts, and resolves a relative NODE_PATH entry from the profile's cd:, 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/3 and to_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 paragraph to render nothing gives un 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_inline on 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 is void: true, so unwrapping it towards its children gives nothing, and contributing nothing would have blank?/2 answer "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. :space by 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 into assets/js/app.js and adds it to the LiveSocket, imports the stylesheet into assets/css/app.css, and runs the attachments migration.

    It is idempotent, it asks before running npm, --dry-run reports without writing, and it says what to do rather than guessing when an app.js is not shaped the way it expects — an application's app.js is 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_labels on coelho_editor/1. "Link address", "https://…", "Caption" and "Describe this attachment" were hard-coded English in the JavaScript, and :labels covers 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_labels once 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-describedby at 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 :nodes override, including for the text node — 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_inline is 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. filename accepts "" and so does key, and a contribution of nothing is dropped when blocks are joined — out of a document blank?/2 calls non-blank, which is the contradiction :render_inline exists to avoid. It always renders an element now, with the classes the page and the editor already use.
  • mix coelho.install finds where a statement ends rather than where a line matches, in both files it edits. import {\n LiveSocket\n} from "…" has import { as its last line matching import, 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.js itself uses multi-line imports.
  • Inline rendering honours a caller's :nodes override for a block too, not only for text and inline nodes. Overriding attachment is 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/3 emits the render span, so a new public render path is not invisible to a handler the README tells applications to attach.
  • mix coelho.install checks the version a package is declared at, not only its name. An application pinned to an older prosemirror-view was told everything was there and failed inside coelho.js, a long way from the cause.
  • mix coelho.install reports a failed npm install as 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-run no 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_text in 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.

  • Coelho.to_safe_html/3 answers {:safe, iodata}. to_html/3 answers a String.t(), which a template treats as text — correctly, since it cannot know it is markup — so the caller had to remember raw/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 what Phoenix.HTML.Engine unwraps directly. There is no Phoenix.HTML.Safe implementation because there is nothing to implement it for — a document is a bare map, deliberately, and an implementation for Map would apply to every map in the application.

Asking whether there is anything there

  • Coelho.blank?/2. An application deciding whether to render a block at all had only empty/1, which builds an empty document, and the obvious stand-in text_length(document) == 0 is 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 declares void: true renders 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, jsonb in 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/:exception shape. :telemetry is 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: a jsonb column 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 :telemetry was 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 :telemetry calls 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's cast_atomic/2 answers {:ok, …} for a literal document, which is what Ash's own default answers. {:atomic, …} puts the value straight into the changeset's atomics, past the allow_nil? and required-attribute checks — and nil is 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 defines handle_change/3.
  • And it routes through the type's own cast_input/2, so a module overriding it — which defoverridable invites — keeps the override on this path too.
  • blank?/2 no 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.
  • nil renders as nothing rather than raising. It is what a nullable column holds and what both stored types cast an absent document to, so 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 figcaption rather than not at all. A captioned attachment looked materially different while it was being written.
  • Changing :labels on 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-link a display, 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 made hidden mean 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/3 never called: it built its block directly, so a run it wrapped reached storage exactly as it arrived. That bites when fit/3 has 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 as a 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' :authorize does at its edges, none of which the documentation had answered and all of which the code already got right. It runs before :metadata is looked up and before Coelho.Storage.redirect_url/3 is asked for anything, so a refusal costs one callback and no query. A refusal is 403 with the same body a bad signature gets, and deliberately not 404 — 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 a 500 and 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

  • :name and :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 into phx-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 lets phx-change carry it, so a phx-debounce can 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 is Coelho.Document.text_length/1, the same unit the schema's max_text_length is 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, so editor_id/1 and insert_node/3 are 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.LiveViewTesttype/4 posts a document as the hook would, document/2 reads back what an editor is holding, params/3 builds the parameters for a test that sends them its own way. The editor's container is phx-update="ignore", so it is invisible to render_change/2: every test touching it was encoding JSON and nesting parameters by hand.

Serving attachments

  • :authorize on Coelho.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/1 takes 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.Disk shards 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/3 must 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/1 takes 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/1 is an English default that says block 2, "href": … instead of content[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 a data- attribute now — patched like anything else, and with none of a script's escaping rules. Marking it phx-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.fromJSON is 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. pushEvent rejects a promise rather than throwing when the socket has gone, so the try/catch never 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/2 and Coelho.HTML.from_html/2 now 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 through Coelho.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/4 folds a document into any term at all, through a :node and a :text callback, where :text is 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/4 is the same thing over the shipped schema.
  • Coelho.Render.attr/3 reads an attribute with its schema default in hand.

Proving what was accepted

  • Coelho.Document.canonical/1 serialises a validated document byte for byte the same however its keys are ordered — which a plain JSON encoding cannot promise, since jsonb reorders keys on its own.
  • Coelho.Document.hash/2 is its digest, nil for 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/2 turns 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: a javascript: link becomes plain text, a heading claiming level 99 becomes a level 1 heading, an unknown node goes.

Ash

  • Coelho.Ash.Type, a use rather than a ready-made module, because Coelho does not depend on Ash — not even optionally: Ash depends on :stream_data in every environment and Coelho keeps it to :dev and :test. One module in your application, and the schema arrives as a constraint. Failures surface as Ash.Error.Changes.InvalidAttribute with the location in the document tree in vars, so a form can say more than "is invalid".

Schemas

  • Coelho.Schema.restrict/2 narrows 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.
  • :class and :editor_attrs on 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, heading and list_item in the shipped schema carry an align attribute — left, center, right, justify — rendered as a text-align style and read back on import.
  • A schema may declare a :version. validate/2 stamps it and refuses a document stamped differently, and Coelho.migrate/2 is the deliberate move between two versions.

Counting

  • Coelho.Document.text_length/1, and textLength in the browser half, count the same thing: the text nodes concatenated, no bullets and no blank lines. A counter measured on to_text/2 rejects 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 no href arrived 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.onclick rather than content[0].attrs. The path is what sanitize/2 repairs from, and an error at attrs took every attribute on the node with it, so one stray key cost a heading its level.
  • Coelho.Document.Error.format_path/1 is 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/2 adds to the schema that ships rather than replacing it, and to_json/1 exports it for the browser, ordered, because ProseMirror resolves default types by position.
  • Coelho.Documentvalidate/2 is 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 :context for what only the application knows.

Storing it

  • Coelho.Ecto.rich_text/2 and 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 :map column 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

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 a javascript: 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.