Joel Christiansen

Action Text Code Highlighting

November 6, 2023
Link
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:
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 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'