This post demonstrates how to set up Action Text to highlight/color code block output in Rails. It goes from scratch to minimally functional code highlighting, including the ability to specify code language on a per-code-block basis. Note: this walk-through does not include code highlighting in the editor itself, which is (1) hard to do in real-time and (2) probably unnecessary for most use cases.
The steps below follow the commits in this repo.
=====
Commit 1. Create a new app
-------
Check Rails/Ruby versions:
The steps below follow the commits in this repo.
=====
Commit 1. Create a new app
-------
Check Rails/Ruby versions:
rails -v Rails 7.1.1 ruby -v ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-darwin22]
Create a new app (see flags here):
rails new action-text-code-highlighting -d postgresql -j esbuild -c tailwind
Change into the directory with
Create the database with
Start a server with
Create an initial commit:
cd action-text-code-highlighting
.Create the database with
rails db:create
.Start a server with
bin/dev
and navigate to localhost:3000 to confirm you see the rails welcome screen.Create an initial commit:
git add . git commit -m 'initial commit'
After creating a repo on GitHub, push up the code:
git remote add origin https://github.com/[repo_name_here].git git branch -M main git push -u origin main
=====
Commit 2. Install Action Text
-------
Run
bin/rails action_text:install
to install Action Text (from Rails Guides):bin/rails action_text:install Installing JavaScript dependencies run yarn add @rails/actiontext trix from "." yarn add v1.22.19 [1/4] 🔍 Resolving packages... [2/4] 🚚 Fetching packages... [3/4] 🔗 Linking dependencies... [4/4] 🔨 Building fresh packages... success Saved lockfile. success Saved 4 new dependencies. info Direct dependencies ├─ @rails/actiontext@7.1.1 └─ trix@2.0.7 info All dependencies ├─ @rails/actiontext@7.1.1 ├─ @rails/activestorage@7.1.1 ├─ spark-md5@3.0.2 └─ trix@2.0.7 ✨ Done in 1.87s. append app/javascript/application.js create app/assets/stylesheets/actiontext.css append app/assets/stylesheets/application.tailwind.css create app/views/active_storage/blobs/_blob.html.erb create app/views/layouts/action_text/contents/_content.html.erb Ensure image_processing gem has been enabled so image uploads will work (remember to bundle!) gsub Gemfile rails railties:install:migrations FROM=active_storage,action_text Copied migration 20231105234910_create_active_storage_tables.active_storage.rb from active_storage Copied migration 20231105234911_create_action_text_tables.action_text.rb from action_text invoke test_unit create test/fixtures/action_text/rich_texts.yml
You'll need to run
bundle install
to install the image_processing
gem that the install script added:bundle install Fetching gem metadata from https://rubygems.org/......... Resolving dependencies... Using rake 13.1.0 Using base64 0.1.1 Using bigdecimal 3.1.4 Using concurrent-ruby 1.2.2 Using crass 1.0.6 Using ruby2_keywords 0.0.5 Using connection_pool 2.4.1 Using websocket-extensions 0.1.5 Using minitest 5.20.0 Using timeout 0.4.0 Using marcel 1.0.2 Using mini_mime 1.1.5 Using date 3.3.3 Using public_suffix 5.0.3 Using bindex 0.8.1 Using msgpack 1.7.2 Using bundler 2.4.10 Using matrix 0.4.2 Using regexp_parser 2.8.2 Using stringio 3.0.8 Using io-console 0.6.0 Using builder 3.2.4 Using mutex_m 0.1.2 Using erubi 1.12.0 Using racc 1.7.3 Using pg 1.5.4 Using rexml 3.2.6 Using rubyzip 2.3.2 Using websocket 1.2.10 Using drb 2.1.1 Using i18n 1.14.1 Using nio4r 2.5.9 Using websocket-driver 0.7.6 Using rack 3.0.8 Using thor 1.3.0 Using addressable 2.8.5 Using bootsnap 1.17.0 Using psych 5.1.1.1 Using tzinfo 2.0.6 Using nokogiri 1.15.4 (x86_64-darwin) Using net-protocol 0.2.1 Using redis-client 0.18.0 Using rack-session 2.0.0 Using rack-test 2.1.0 Using zeitwerk 2.6.12 Using reline 0.3.9 Using loofah 2.21.4 Using selenium-webdriver 4.15.0 Using rdoc 6.5.0 Using puma 6.4.0 Using net-pop 0.1.2 Fetching mini_magick 4.12.0 Using sprockets 4.2.1 Using redis 5.0.8 Using rails-html-sanitizer 1.6.0 Using activesupport 7.1.1 Using net-smtp 0.4.0 Using xpath 3.2.0 Fetching ffi 1.16.3 Using rails-dom-testing 2.2.0 Using capybara 3.39.2 Using irb 1.8.3 Using net-imap 0.4.4 Using actionview 7.1.1 Using debug 1.8.0 Using globalid 1.2.1 Using activemodel 7.1.1 Using webrick 1.8.1 Using actionpack 7.1.1 Using activejob 7.1.1 Using activerecord 7.1.1 Using mail 2.8.1 Using rackup 2.1.0 Using jbuilder 2.11.5 Using actioncable 7.1.1 Using activestorage 7.1.1 Using actionmailer 7.1.1 Using railties 7.1.1 Using sprockets-rails 3.4.2 Using actionmailbox 7.1.1 Using actiontext 7.1.1 Using cssbundling-rails 1.3.3 Using rails 7.1.1 Using jsbundling-rails 1.2.1 Using stimulus-rails 1.3.0 Using web-console 4.2.1 Using turbo-rails 1.5.0 Installing mini_magick 4.12.0 Installing ffi 1.16.3 with native extensions Fetching ruby-vips 2.2.0 Installing ruby-vips 2.2.0 Fetching image_processing 1.12.2 Installing image_processing 1.12.2 Bundle complete! 17 Gemfile dependencies, 89 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed.
And also run
rails db:migrate
to migrate the database with the new active storage and action text tables the install script created:rails db:migrate == 20231105234910 CreateActiveStorageTables: migrating ======================== -- create_table(:active_storage_blobs, {:id=>:primary_key}) -> 0.0444s -- create_table(:active_storage_attachments, {:id=>:primary_key}) -> 0.0175s -- create_table(:active_storage_variant_records, {:id=>:primary_key}) -> 0.0132s == 20231105234910 CreateActiveStorageTables: migrated (0.0752s) =============== == 20231105234911 CreateActionTextTables: migrating =========================== -- create_table(:action_text_rich_texts, {:id=>:primary_key}) -> 0.0274s == 20231105234911 CreateActionTextTables: migrated (0.0274s) ==================
Commit with
git add .
and git commit -m 'install action text'
:git add . git commit -m 'install action text' [main 2e19f75] install action text 13 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 app/assets/stylesheets/actiontext.css create mode 100644 app/views/active_storage/blobs/_blob.html.erb create mode 100644 app/views/layouts/action_text/contents/_content.html.erb create mode 100644 db/migrate/20231105234910_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20231105234911_create_action_text_tables.action_text.rb create mode 100644 db/schema.rb create mode 100644 test/fixtures/action_text/rich_texts.yml
=====
Commit 3. Scaffold a simple
Post
model and UI-------
Use the Rails generator to create simple
Post
scaffolding:rails g scaffold Post title content:rich_text invoke active_record create db/migrate/20231106004849_create_posts.rb create app/models/post.rb invoke test_unit create test/models/post_test.rb create test/fixtures/posts.yml invoke resource_route route resources :posts invoke scaffold_controller create app/controllers/posts_controller.rb invoke erb create app/views/posts create app/views/posts/index.html.erb create app/views/posts/edit.html.erb create app/views/posts/show.html.erb create app/views/posts/new.html.erb create app/views/posts/_form.html.erb create app/views/posts/_post.html.erb invoke resource_route invoke test_unit create test/controllers/posts_controller_test.rb create test/system/posts_test.rb invoke helper create app/helpers/posts_helper.rb invoke test_unit invoke jbuilder create app/views/posts/index.json.jbuilder create app/views/posts/show.json.jbuilder create app/views/posts/_post.json.jbuilder
Run
rails db:migrate
to create the posts table in the database:rails db:migrate == 20231106004849 CreatePosts: migrating ====================================== -- create_table(:posts) -> 0.0161s == 20231106004849 CreatePosts: migrated (0.0161s) =============================
In
config/routes.rb
, route the root path to the posts#index
action:Rails.application.routes.draw do resources :posts # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check # Defines the root path route ("/") root "posts#index" end
To work around a conflict between Tailwind and Action Text and style the Action Text toolbar, in
app/assets/stylesheets/application.tailwind.css
, add @import 'trix/dist/trix.css';
and move both @import
at-rules above the @tailwind
directives. application.tailwind.css should look like this:@import 'trix/dist/trix.css'; @import 'actiontext.css'; @tailwind base; @tailwind components; @tailwind utilities;
Finally, add some minimal and somewhat arbitrary styling and layout tweaks via Tailwind.
Starting with the form at
app/views/posts/_form.html.erb
:<%= form_with(model: post) do |form| %> <% if post.errors.any? %> <div class="text-red-600"> <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2> <ul> <% post.errors.each do |error| %> <li><%= error.full_message %></li> <% end %> </ul> </div> <% end %> <div class="mb-2"> <%= form.label :title, class: "block" %> <%= form.text_field :title, class: "px-2 py-1 w-full border border-gray-400 focus:outline-none" %> </div> <div class="mb-2"> <%= form.label :content, class: "block" %> <%= form.rich_text_area :content, class: "min-h-[15rem]" %> </div> <div class="flex justify-end"> <%= form.submit class: "px-2 py-1 text-white bg-blue-600 rounded hover:bg-blue-800" %> </div> <% end %>
The index view at
app/views/posts/index.html.erb
:<p class="text-green-600"><%= notice %></p> <h1 class="text-lg font-semibold">Posts</h1> <div class="my-2"> <%= link_to "New post", new_post_path, class: "px-2 py-1 text-white bg-blue-600 rounded hover:bg-blue-800" %> </div> <div id="posts"> <% @posts.each do |post| %> <div> <%= link_to post.title, post, class: "text-blue-600 hover:text-blue-800" %> </div> <% end %> </div>
The show view at
app/views/posts/show.html.erb
:<div class="my-2 text-sm"> <%= link_to "< Back to all posts", posts_path, class: "text-gray-400 hover:text-gray-500" %> </div> <p class="text-green-600"><%= notice %></p> <%= render @post %> <div class="flex mt-4 space-x-2 text-sm"> <%= link_to "Edit", edit_post_path(@post), class: "text-blue-500 hover:text-blue-700" %> <%= button_to "Delete", @post, method: :delete, class: "text-red-500 hover:text-red-700" %> </div>
The post partial at
app/views/posts/_post.html.erb
:<div id="<%= dom_id post %>"> <h1 class="text-lg font-semibold"> <%= post.title %> </h1> <div> <%= post.content %> </div> </div>
The edit view at app/views/posts/edit.html.erb:
<div class="my-2 text-sm"> <%= link_to "< Back to post", @post, class: "text-gray-400 hover:text-gray-500" %> </div> <%= render "form", post: @post %>
And, finally, the new view at
app/views/posts/new.html.erb
:<div class="my-2 text-sm"> <%= link_to "< Back to post", @post, class: "text-gray-400 hover:text-gray-500" %> </div> <%= render "form", post: @post %>
Double check that the autogenerated tests work with
rails t
:rails t yarn install v1.22.19 [1/4] 🔍 Resolving packages... success Already up-to-date. ✨ Done in 0.18s. yarn run v1.22.19 $ esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets app/assets/builds/application.js 494.6kb app/assets/builds/application.js.map 815.2kb ✨ Done in 0.51s. yarn install v1.22.19 [1/4] 🔍 Resolving packages... success Already up-to-date. ✨ Done in 0.18s. yarn run v1.22.19 $ tailwindcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css --minify Rebuilding... Done in 683ms. ✨ Done in 1.49s. Running 7 tests in a single process (parallelization threshold is 50) Run options: --seed 7472 # Running: ....... Finished in 0.629348s, 11.1226 runs/s, 17.4784 assertions/s. 7 runs, 11 assertions, 0 failures, 0 errors, 0 skips
Add and commit changes:
git add . git commit -m 'scaffold posts' [main 212e44f] scaffold posts 21 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 app/controllers/posts_controller.rb create mode 100644 app/helpers/posts_helper.rb create mode 100644 app/models/post.rb create mode 100644 app/views/posts/_form.html.erb create mode 100644 app/views/posts/_post.html.erb create mode 100644 app/views/posts/_post.json.jbuilder create mode 100644 app/views/posts/edit.html.erb create mode 100644 app/views/posts/index.html.erb create mode 100644 app/views/posts/index.json.jbuilder create mode 100644 app/views/posts/new.html.erb create mode 100644 app/views/posts/show.html.erb create mode 100644 app/views/posts/show.json.jbuilder create mode 100644 db/migrate/20231106004849_create_posts.rb create mode 100644 test/controllers/posts_controller_test.rb create mode 100644 test/fixtures/posts.yml create mode 100644 test/models/post_test.rb create mode 100644 test/system/posts_test.rb
=====
Commit 4. Set up code highlighting with Highlight.js
-------
To add highlighting, let's start by installing Highlight.js via Yarn using
yarn add highlight.js
:yarn add highlight.js yarn add v1.22.19 [1/4] 🔍 Resolving packages... [2/4] 🚚 Fetching packages... [3/4] 🔗 Linking dependencies... [4/4] 🔨 Building fresh packages... success Saved lockfile. success Saved 1 new dependency. info Direct dependencies └─ highlight.js@11.9.0 info All dependencies └─ highlight.js@11.9.0 ✨ Done in 1.13s.
Then we'll create a Stimulus controller to handle the highlighting using
bin/rails generate stimulus highlightController
:./bin/rails generate stimulus highlightController create app/javascript/controllers/highlight_controller.js rails stimulus:manifest:update
Inside that Stimulus controller, we'll import the core Highlight.js package and a few language libraries. At this point,
app/javascript/controllers/highlight_controller.js
will look like this:import { Controller } from "@hotwired/stimulus" import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; import erb from 'highlight.js/lib/languages/erb'; import ruby from 'highlight.js/lib/languages/ruby'; import xml from 'highlight.js/lib/languages/xml'; // Connects to data-controller="highlight" export default class extends Controller { connect() { } }
Then, we'll need some code in the
connect()
method that (1) registers the languages we imported, and (2) highlights the <pre>
elements inside whichever element instantiates the Stimulus controller. After adding this code to the controller, it will look like this:import { Controller } from "@hotwired/stimulus" import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; import erb from 'highlight.js/lib/languages/erb'; import ruby from 'highlight.js/lib/languages/ruby'; import xml from 'highlight.js/lib/languages/xml'; // Connects to data-controller="highlight" export default class extends Controller { connect() { hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('erb', erb); hljs.registerLanguage('ruby', ruby); hljs.registerLanguage('xml', xml); this.element.querySelectorAll('pre').forEach((element) => { hljs.highlightElement(element); }); } }
Finally, we need to connect the controller to our view. We'll do that by adding
data-controller="highlight"
to the element that wraps our content in app/views/posts/show.html.erb
. The relevant code looks like so:<div data-controller="highlight"> <%= post.content %> </div>
To apply styles, we'll need to import some CSS from Highlight.js. To do that, add a line with
@import 'highlight.js/styles/atom-one-dark.css';
to app/assets/stylesheets/application.tailwind.css
:@import 'trix/dist/trix.css'; @import 'actiontext.css'; @import 'highlight.js/styles/atom-one-dark.css'; @tailwind base; @tailwind components; @tailwind utilities;
Finally, to override the default styling on
<pre>
elements, add the following rules to app/assets/stylesheets/actiontext.css
:.trix-content pre, trix-editor pre { display: inline-block; width: 100%; vertical-align: top; font-family: monospace; font-size: .7em; padding: 1em; white-space: pre; background-color: #24272D; overflow-x: auto; border-radius: 2px; } trix-editor pre { color: #c9cfd7; }
Now, you should see the formatted code in the show view. A couple of quick notes: (1) the code in the editor will remain unformatted because formatting as you type is more work than it's worth for this demonstration, and (2) at this point, Highlight.js will do its best to decipher the code language automatically. In the next commit, we'll add some additional code to allow you to specify code language in each code block.
After running a quick
rails t
, let's commit the code:git add . git commit -m 'highlighted code blocks' [main c4f8a0e] highlighted code blocks 7 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 app/javascript/controllers/highlight_controller.js
=====
Commit 5. Specify language and add plaintext support
-------
In practice, I found that Highlight.js language autodetection was hit or miss and I wanted a lightweight way to jump between languages without mucking around in code. Therefore, as a final customization, let's add a feature to specify the language for each code block.
Under the hood, Highlight.js is looking for a class attribute on a <code> element to set the language, like this:
<pre class="language-html">...</pre>
However, by default, Action Text renders code blocks like this:
<pre>...</pre>
To remedy this, create a helper in
app/helpers/application_helper.rb
called language_formatter
:module ApplicationHelper def language_formatter(content) doc = Nokogiri::HTML.fragment(content.to_s) doc.css("pre").each do |pre| language = pre.content.split.first if language pre.content = pre.content.sub(language, "") pre["class"] = "hljs language-#{language}" end end doc.to_html end end
This method parses the content HTML string and sets the language based on the first word in the code block.
Then, in
app/views/posts/_post.html.erb
, you parse the content with the language_formatter
method before rendering on the page:<div id="<%= dom_id post %>"> <h1 class="text-lg font-semibold"> <%= post.title %> </h1> <div data-controller="highlight"> <%= raw language_formatter(post.content) %> </div> </div>
This code allows you to specify the language of the code block on the first line, like so:
ruby def ruby_method "this is a Ruby method" end
Which will be rendered like this:
def ruby_method "this is a Ruby method" end
As a final tweak, we'll add plaintext language support to Highlight.js with the following code in
app/javascript/controllers/highlight_controller.js
:import { Controller } from "@hotwired/stimulus" import hljs from 'highlight.js/lib/core'; import plaintext from 'highlight.js/lib/languages/plaintext'; import javascript from 'highlight.js/lib/languages/javascript'; import erb from 'highlight.js/lib/languages/erb'; import ruby from 'highlight.js/lib/languages/ruby'; import xml from 'highlight.js/lib/languages/xml'; // Connects to data-controller="highlight" export default class extends Controller { connect() { hljs.registerLanguage('plaintext', plaintext); hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('erb', erb); hljs.registerLanguage('ruby', ruby); hljs.registerLanguage('xml', xml); this.element.querySelectorAll('pre').forEach((element) => { hljs.highlightElement(element); }); } }
Now you can easily write code blocks in Action Text.
Test and commit your code with
rails t
and:git add . git commit -m 'specify language; add plaintext'