Rails::Domternal
A Domternal (ProseMirror-based) rich text editor integration for Rails, shaped as a drop-in replacement for ActionText:
has_rich_domternal :body— a dependent rich text model, mirroringhas_rich_text.f.domternal_area :body/domternal_content post.body— form and display helpers, mirroringrich_text_area/ the<trix-editor>tag pair.<domternal-editor>— a self-registering custom element (no Stimulus dependency) that mounts the editor and syncs a hidden input on every change.- Active Storage-backed image uploads, wired through the same
data-direct-upload-url/data-blob-url-templateconvention ActionText uses. rails g domternal:install— detects importmap-rails vs. a Node/Bun bundler and wires up the JS accordingly.
See demo/ for a working Rails 8 app (importmap + Propshaft) with a Post model using
has_rich_domternal.
Installation
Add to your Gemfile:
gem "rails-domternal"
Then:
bundle install
bin/rails g domternal:install
bin/rails db:migrate
Usage
class Post < ApplicationRecord
has_rich_domternal :body, extensions: %i[starter_kit image table]
end
<%= form_with model: @post do |form| %>
<%= form.domternal_area :body %>
<% end %>
<%= domternal_content @post.body %>
Configure defaults in config/initializers/domternal.rb (generated by the install task) —
see Rails::Domternal::Configuration for the full list of options (default extensions,
toolbar, theme, sanitizer allowlist, upload limits).
#to_plain_text converts a body to text the way ActionText does: block boundaries and
<br> become newlines rather than collapsing into spaces, so line-oriented content (one
list entry or answer per line) survives, and search columns built from rich text stay
readable. blank?/present?/empty? delegate to it.
simple_form
If your app has simple_form, as: :domternal_area
works out of the box — the input is registered only when simple_form is loaded, and an
app-defined app/inputs/domternal_area_input.rb takes precedence:
<%= f.input :body, as: :domternal_area %>
Primary key types
domternal_rich_texts.record_id holds the primary key of every model declaring
has_rich_domternal, so it has to fit all of them. Both column types default to your
config.generators primary_key_type, and can be overridden — but only before you run
the migration, since that's when they're read:
Rails::Domternal.configure do |config|
config.primary_key_type = :uuid
config.record_id_type = :string # models don't all share one key type
end
If some models use uuid primary keys and others bigint, neither fits: use :string.
Migrating from ActionText
bin/rails g domternal:migrate_from_action_text
bin/rails db:migrate
This writes a reversible data migration that copies every action_text_rich_texts row into
domternal_rich_texts. Bodies carry over as-is (both store sanitized HTML), and the
<action-text-attachment> nodes Domternal has no equivalent for are rewritten:
-
Image attachments become
<img>tags pointing at the Active Storage redirect URL — the same markup the editor's own upload handler writes — and the blobs are re-attached asDomternal::RichTextembeds. -
Your own
ActionText::Attachablemodels are dropped unless you say what they should render as:bin/rails g domternal:migrate_from_action_text --attachment-partials HtmlTable:contentwhich inlines
html_table.contentin place of each attachment node.
action_text_rich_texts is left untouched, so the migration can be rolled back. Then swap
has_rich_text for has_rich_domternal, f.rich_text_area for f.domternal_area, and the
.trix-content class for .domternal-content.
If you relied on an ActionText::Attachable model purely to embed tables, the :table
extension replaces it — tables become real <table> markup the editor edits natively.
Extensions
Extensions are referenced by symbolic name (:starter_kit, :image, :table, :mention,
:markdown, :emoji, :math, :code_block, :toc, :block_controls, :details),
resolved server-side by Rails::Domternal::ExtensionRegistry (fails fast on typos) and
lazy-loaded client-side by app/assets/javascripts/domternal/extensions.js. Register a
custom extension with:
Rails::Domternal.extension_registry.register(:my_extension)
import { registerExtension } from "domternal/extensions"
registerExtension("my_extension", async () => (await import("my-extension-package")).MyExtension)
Toolbar modes
toolbar: (on has_rich_domternal, domternal_area, or the global config) accepts:
-
:full(default) /:minimal— a fixedDomternalToolbar. -
:notion— no fixed toolbar; instead a selection bubble menu handles inline formatting and a "+" floating menu (shown only via the block handle's button) inserts blocks. Pair this with the:block_controlsextension, which supplies the drag handle, block context menu, slash command, smart paste, and keyboard reorder:has_rich_domternal :internal_notes, extensions: %i[starter_kit block_controls toc], toolbar: :notion -
false— no chrome at all, just the raw editable surface.
Content styling
rails g domternal:install also generates a baseline typography stylesheet (headings,
lists, blockquotes, code, tables — none of which Domternal's own theme opinionates on)
scoped to .domternal-content/<domternal-editor>, covering both the live editor and
domternal_content read-only output. It's generated once and meant to be edited freely.
By default it autodetects tailwindcss-rails (app/assets/tailwind/application.css) and
generates a @apply-based version wired into your existing Tailwind build; otherwise it
generates plain CSS under app/assets/stylesheets/. Force either explicitly with
rails g domternal:install --stylesheet=tailwind (or =css).
Deferred mounting
Editors mount when they first become visible, not on connectedCallback. Each one loads its
extension modules and builds a ProseMirror instance, so a form with an editor per locale (or
per tab, or inside a collapsed section) would otherwise pay for all of them on page load.
An editor that hasn't mounted never writes to its hidden input, so the value the server
rendered is what gets submitted — leaving a tab untouched is always safe. Opt out per editor
with the eager attribute, and use await element.ready() to force mounting before driving
an editor from your own JavaScript.
JavaScript delivery
Two setups, and the choice matters more than it looks:
Importmap (default). The generator pins @domternal/* from esm.sh at
an exact version resolved at install time. Cross-package peer dependencies
(@domternal/core, @domternal/pm) are explicitly shared via ?external= params and a
@domternal/pm/ prefix pin — omitting this loads ProseMirror's plugin registry twice and
crashes with "Adding different instances of a keyed plugin."
This is the quickest way to get running, but the editor is then fetched from a third-party CDN in the browser on every page that uses one: an outage or slow response degrades your admin UI, and the request graph is deep (the packages pull in ~12 ProseMirror modules).
Bundled (recommended for production). With a package.json present, the generator runs
npm add/bun add for the real packages and copies the gem's JS into
app/javascript/domternal, so your bundler resolves and ships everything at build time —
no runtime CDN, one copy of each dependency guaranteed by the resolver. To move an existing
importmap app over, add jsbundling-rails and
re-run rails g domternal:install, then drop the @domternal/* pins from
config/importmap.rb.
There's deliberately no "vendor the CDN files into vendor/javascript" option: esm.sh URLs
embed semver ranges and resolve server-side, so downloading that graph correctly means
reimplementing package resolution, and getting it subtly wrong produces duplicate ProseMirror
instances that only fail at runtime. Use a bundler for that job.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake test
to run the tests. You can also run bin/console for an interactive prompt.
To try it against a real app, see demo/ — it references this gem via a path: Gemfile
entry, so changes to the gem are picked up immediately.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/getrestful/rails-domternal.
License
The gem is available as open source under the terms of the MIT License.