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, mirroring has_rich_text.
  • f.domternal_area :body / domternal_content post.body — form and display helpers, mirroring rich_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-template convention 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 as Domternal::RichText embeds.

  • Your own ActionText::Attachable models are dropped unless you say what they should render as:

    bin/rails g domternal:migrate_from_action_text --attachment-partials HtmlTable:content
    

    which inlines html_table.content in 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 fixed DomternalToolbar.

  • :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_controls extension, 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.