docs/plans/6.0-rich-text-descriptions.md
Status: Partially Complete (description_html shipped in 5.4, ActionText removal in 6.0) Target: Spree 5.4 (description_html) + Spree 6.0 (ActionText removal) Depends on: Admin SPA (6.0-admin-spa.md) for ActionText removal Author: Damian + Claude Last updated: 2026-03-21
Standardize how rich text content (product descriptions, category descriptions, policy bodies) is stored and served across Spree. Move away from ActionText as a storage backend in favor of plain HTML text columns, since the 6.0 admin uses Tiptap (not Trix). Unify the API response shape so every rich text field returns both description (plain text) and description_html (HTML).
Rich text storage is inconsistent across models:
| Model | Current storage | Translation | API output |
|---|---|---|---|
| Product | text column on spree_products | Mobility (table backend) | description → raw HTML string |
| Taxon | ActionText (action_text_rich_texts) | Mobility (action_text backend) | description → plain text, description_html → HTML |
| Policy | ActionText (action_text_rich_texts) | Mobility (action_text backend) | body → plain text, body_html → HTML |
This creates three problems:
ActionText is a Trix companion, not a generic HTML store. It sanitizes HTML against Trix's allowlist, which strips valid Tiptap output (tables, colored text, custom attributes). It wraps content in <div class="trix-content"> and uses <action-text-attachment> tags for embeds. None of this is compatible with a Tiptap editor.
Inconsistent API responses. Product returns raw HTML as description. Taxon returns plain text as description and HTML as description_html. Consumers can't predict what format they'll get.
Inconsistent storage. Two different storage mechanisms (text column vs action_text_rich_texts table) with different querying, indexing, and translation strategies.
editor.getHTML(). Store that directly. No ActionText, no Tiptap JSON.Nokogiri::HTML.fragment(html).text.squish strips tags without any ActionText dependency.description (plain text) and description_html (HTML). Same pattern for body / body_html on Policy._html variant.All rich text fields are text columns on the model's own table, translated via Mobility table backend:
class Spree::Product < Spree.base_class
TRANSLATABLE_FIELDS = %i[name description slug meta_description meta_title].freeze
translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
end
class Spree::Taxon < Spree.base_class
TRANSLATABLE_FIELDS = %i[name pretty_name description permalink].freeze
translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
# Remove: translates :description, backend: :action_text
end
class Spree::Policy < Spree.base_class
TRANSLATABLE_FIELDS = %i[name body].freeze
translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
# Remove: translates :body, backend: :action_text
end
module Spree
module SanitizableRichText
extend ActiveSupport::Concern
class_methods do
def sanitizes_rich_text(*attributes)
before_save do
attributes.each do |attr|
value = public_send(attr)
next if value.blank?
public_send(:"#{attr}=", Spree::RichTextSanitizer.sanitize(value))
end
end
end
end
end
end
module Spree
class RichTextSanitizer
ALLOWED_TAGS = %w[
h1 h2 h3 h4 h5 h6 p br hr
ul ol li
table thead tbody tfoot tr th td
a img
strong em u s del sub sup
blockquote pre code
span div figure figcaption
].freeze
ALLOWED_ATTRIBUTES = %w[
href src alt title target rel
class style
colspan rowspan
width height
].freeze
def self.sanitize(html)
Rails::HTML5::SafeListSanitizer.new.sanitize(
html,
tags: ALLOWED_TAGS,
attributes: ALLOWED_ATTRIBUTES
)
end
end
end
Usage in models:
class Spree::Product < Spree.base_class
include Spree::SanitizableRichText
sanitizes_rich_text :description
end
A shared helper for serializers using Nokogiri (no ActionText dependency):
module Spree
module RichTextHelper
def self.to_plain_text(html)
return '' if html.blank?
Nokogiri::HTML.fragment(html).text.squish
end
end
end
Product (adding description_html, changing description to plain text):
class ProductSerializer < BaseSerializer
typelize description: :string, description_html: :string
attribute :description do |product|
Spree::RichTextHelper.to_plain_text(product.description)
end
attribute :description_html do |product|
product.description.to_s
end
end
Category (no change to shape, but simplified implementation — no ActionText storage):
class CategorySerializer < BaseSerializer
typelize description: :string, description_html: :string
attribute :description do |category|
Spree::RichTextHelper.to_plain_text(category.description)
end
attribute :description_html do |category|
category.description.to_s
end
end
Policy (same pattern with body / body_html):
class PolicySerializer < BaseSerializer
typelize body: :string, body_html: :string
attribute :body do |policy|
Spree::RichTextHelper.to_plain_text(policy.body)
end
attribute :body_html do |policy|
policy.body.to_s
end
end
The admin API accepts HTML in the description (or body) parameter. The Tiptap editor calls editor.getHTML() on save and sends the result. No special endpoint or content type needed — it's just a string param.
has_rich_text :internal_note on Order and User stays on ActionText for now. These are admin-only fields edited within the Rails admin (or later in the Tiptap admin), never exposed in the Store API, and don't need translation. They can be migrated later if desired.
Add Spree::SanitizableRichText concern and Spree::RichTextHelper. Apply to Product immediately (it already stores HTML in a text column). No data migration needed.
description to return plain text via RichTextHelper.to_plain_textdescription_html returning raw HTMLtranslates :description, backend: :action_text from Taxonaction_text_rich_texts into the description column on spree_taxons (and Mobility translation records)sanitizes_rich_text :descriptionRichTextHelper instead of .to_plain_text / .body.to_sSame as Phase 3, but for body field.
action_text_rich_texts records for migrated modelsinternal_note, User internal_note, Metafields::RichText). Plain text extraction uses Nokogiri directly — no ActionText dependency for that path.has_rich_text or backend: :action_text declarations. New rich text fields should use text columns + SanitizableRichText concern.field (plain text) and field_html (HTML).style and class attributes in sanitizer allowlist. Allowing arbitrary style attributes opens the door to CSS-based attacks (e.g., position: fixed overlays). Allowing class could conflict with storefront CSS or be exploited if malicious classes are defined. May want to restrict both to specific values (e.g., Tiptap's text-align classes) or strip them entirely and rely on semantic HTML tags.6.0-admin-spa.md (Tiptap editor in React admin)description), Policy (body), Order (internal_note), User (internal_note), Metafields::RichText (value)