Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ActionEditor

Ruby Rails Gem Version License: MIT

ActionEditor is a Rails-native content editing framework. It provides structured block-based editing, rich text support, rendering, serialization, versioning, full-text search, and pluggable editor adapters — all following Rails conventions like Action Mailer, Action Text, and Active Storage.


Philosophy

ActionEditor is designed to be:

  • Rails-first — feels like a natural extension of Rails conventions
  • Editor-agnostic — no JavaScript editor is bundled; adapters translate between any editor and ActionEditor's canonical format
  • Block-based — content is composed of typed, validated, serializable blocks
  • Stable — strong separation between the storage format and the editor protocol

Features

Feature Details
Block DSL field :title, :string — typed, validated, documented
16 built-in blocks Hero, Heading, Paragraph, Quote, Image, Gallery, Video, Feature, Pricing, FAQ, Button, Divider, HTML, Markdown, Embed, Code, Container
20 field types string, text, rich_text, integer, float, decimal, boolean, color, date, datetime, url, email, phone, image, attachment, gallery, repeater, object, array, enum, relation, json
Editor adapters Editor.js, Tiptap (ProseMirror), Lexical — swap without changing models
Rendering Rails partials, HTML serializer (no context), Markdown, plain text
Rich text Independent of Action Text — own ProseMirror-compatible format
Search page.content.search_text — flat string for any search backend
Versioning Schema version migrations (v1 → v2)
Hotwire Stimulus controller + editor adapters
Generators install, block, migration, adapter

Installation

Add to your Gemfile:

gem "action_editor"

Run:

bundle install
rails generate action_editor:install
rails db:migrate

Quick Start

1. Declare the field

class Page < ApplicationRecord
  has_action_editor :content
end

2. Add a JSONB column

rails generate action_editor:migration Page content
rails db:migrate

3. Use in your controller

class PagesController < ApplicationController
  include ActionEditor::ControllerHelpers

  def update
    @page.update(page_params)
  end

  private

  def page_params
    params.require(:page).permit(:title, action_editor_params(:content))
  end
end

4. Render in your view

<%= render_action_editor(@page.content) %>

Document API

page = Page.find(1)

page.content                  # => ActionEditor::Document
page.content.blocks           # => ActionEditor::Document::BlockCollection
page.content.blocks.size      # => 5
page.content.render           # => HTML string (via partials in Rails context)
page.content.to_html          # => HTML string (no context needed)
page.content.to_markdown      # => Markdown string
page.content.to_text          # => Plain text string
page.content.to_json          # => Canonical ActionEditor JSON
page.content.search_text      # => Flat text for search indexing
page.content.valid?           # => true/false
page.content.errors_by_block  # => { "block-uuid" => ["Title can't be blank"] }

Creating Custom Blocks

rails generate action_editor:block TestimonialSection quote:rich_text author:string avatar:image

Or by hand:

class TestimonialBlock < ActionEditor::Block::Base
  field :quote,  :rich_text, required: true
  field :author, :string,    required: true
  field :role,   :string
  field :avatar, :image
  field :rating, :integer,   min: 1, max: 5, default: 5

  validates :quote,  presence: true
  validates :author, presence: true

  def search_text
    "#{quote&.to_plain_text} #{author} #{role}"
  end
end

ActionEditor.register TestimonialBlock

And create a partial at app/views/action_editor/blocks/_testimonial.html.erb:

<div class="testimonial" id="ae-block-<%= block.block_id %>">
  <blockquote><%= block.quote&.to_html&.html_safe %></blockquote>
  <cite>
    <%= block.author %>
    <% if block.role.present? %>, <%= block.role %><% end %>
  </cite>
</div>

Field Types

Type Ruby Type Notes
:string String max_length:, min_length:
:text String Multi-line
:rich_text RichText::Content ProseMirror JSON
:integer Integer min:, max:
:float Float
:decimal BigDecimal
:boolean Boolean
:color String Hex validated
:date Date ISO 8601
:datetime Time ISO 8601
:url String HTTP/HTTPS validated
:email String Format validated
:phone String
:image Hash { url:, alt:, caption: }
:attachment Hash Active Storage metadata
:gallery Array Array of image hashes
:repeater Array min_items:, max_items:
:object Hash Free-form
:array Array
:enum String values: %w[a b c]
:relation Hash { type:, id: }
:json Hash/Array Raw JSON

Editor Adapters

ActionEditor ships with three server-side adapters. The adapter translates the editor's payload to/from the canonical ActionEditor document format.

In a controller

def update
  document = ActionEditor::Adapter::Tiptap.new.parse(params[:editor_output])
  @page.content = document
  @page.save
end

Configuration

ActionEditor.configure do |config|
  config.default_adapter = :tiptap  # :editor_js | :tiptap | :lexical
end

JavaScript (Hotwire)

// app/javascript/application.js
import { Application } from "@hotwired/stimulus"
import { registerActionEditor } from "action_editor"

const application = Application.start()
registerActionEditor(application)

Then in your form:

<%= action_editor_tag(form, :content, adapter: :tiptap) %>

Search

# In a model callback:
after_save { update_column(:search_body, content.search_text) }

# Or use the async job:
ActionEditor::Search::IndexJob.perform_later(self.class.name, id)

# PostgreSQL full-text search:
Page.where("to_tsvector('english', search_body) @@ plainto_tsquery(?)", query)

Versioning

# In config/initializers/action_editor.rb
ActionEditor.configure do |config|
  config.versioning_enabled = true
end

# Define a migration:
class V1ToV2Migration < ActionEditor::Versioning::Migration
  def self.from_version = 1
  def self.to_version   = 2

  def up(document_hash)
    document_hash["blocks"].each do |block|
      # e.g. rename a field
      block["data"]["body"] = block["data"].delete("content")
    end
    document_hash
  end
end

# Apply:
migrator = ActionEditor::Versioning::Migrator.new
migrator.register(V1ToV2Migration)
migrated = migrator.migrate(page.content, from: 1, to: 2)

Configuration

ActionEditor.configure do |config|
  config.default_adapter       = :editor_js   # or :tiptap, :lexical
  config.renderer              = :partial
  config.partial_prefix        = "action_editor/blocks"
  config.cache_rendered_blocks = true
  config.sanitize_html         = true
  config.versioning_enabled    = false
  config.search_backend        = :none         # or :pg
  config.attachment_service    = :local
end

Generators

rails generate action_editor:install          # Install
rails generate action_editor:block Hero       # Custom block
rails generate action_editor:migration Page content  # Add JSONB column

Testing

bundle exec rake test

ActiveSupport Notifications

Subscribe to ActionEditor events:

ActiveSupport::Notifications.subscribe("action_editor.render") do |event|
  Rails.logger.debug "Rendered #{event.payload[:block_count]} blocks in #{event.duration.round(2)}ms"
end

Available events: action_editor.render, action_editor.render_block, action_editor.serialize, action_editor.validate, action_editor.search_index.


License

MIT — see LICENSE.

About

A Rails-native framework for building structured content editors with blocks, rich text, rendering, and extensible editor adapters.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages