Syntax highlighting with Rails

By Exequiel Rozas

As developers, there usually comes a time when we have to deal with syntax highlighting.

Be it if we're creating a blog for ourselves, working on an app that's used by developers or an app that grows until a significant part of the user base is technical and requires the feature.

In this article, we will learn how to add syntax highlighting to Rails applications by showing different ways to do it and a couple of nice tricks to improve the user experience.

Let's start by seeing what we will build:

What we will build

To show the different ways to add syntax highlighting to a Rails application, we will add it in different circumstances:

  • To the content of a Post using Markdown where we will use Commonmarker and its syntax_highlighter plugin to highlight the code, just like we did in the GitHub flavored Markdown in Rails with the Commonmarker gem article.
  • To the same content but using the Redcarpet and Rouge for markdown parsing into HTML and syntax highlighting respectively.
  • Using Markdown but producing the highlighting on the front-end using a library like Highlight.js.
  • Using a WYSIWYG editor like the Rhino Editor, which uses the TipTap editor and Highlight.js under the hood.

Before anything, let's start by setting up our application:

Application set up

The first step is to create a new Rails application:

rails new syntax_highlight --css=tailwind --javascript=esbuild

Next, we will a Post scaffold with a content field where we will include the Markdown that we will parse:

bin/rails generate scaffold Post title content:text published_at:datetime

We run the migrations:

bin/rails db:migrate

Then, we will add the most common Markdown parsing libraries used with Ruby and Rails applications:

bundle add commonmarker redcarpet kramdown

We then install those dependencies:

bundle install

As the content field will initially use Markdown, let's add the Marksmith Editor and install it:

bundle add marksmith && bundle install && yarn add @avo-hq/marksmith

Then, we add the appropriate JavaScript:

// app/javascript/controllers/index.js
import { MarksmithController, ListContinuationController } from '@avo-hq/marksmith'

application.register('marksmith', MarksmithController)
application.register('list-continuation', ListContinuationController)

Finally, we add the stylesheet tag to our application layout:

<!-- app/views/layouts/application.html.erb -->
<%= stylesheet_link_tag "marksmith" %>

Now, after working a little on the scaffold's appearance, we get something like this:

Post scaffold with Markdown field

Now that we have the initial setup ready, let's start by adding syntax highlighting, starting with the Commonmarker gem:

Using the Commonmarker gem

The easiest way to add the feature is using the Commonmarker gem, as it adds the syntax highlighting styles inline.

This means that we don't need to add a third-party library like we do with other alternatives.

Let's add a formatted_content method to the Post model that returns HTML with syntax highlighting:

class Post < ApplicationRecord
  # Rest of the code

  def formatted_content
    Commonmarker.to_html(content, options: {
      parse: {smart: true},
    },
      plugins: {
        syntax_highlighter: {
          theme: "base16-ocean.dark",
        },
      },
    )
  end
end

Then, in the show view, we add:

<%== @post.formatted_content %>

Please note that adding <%== %> outputs raw HTML, which is not ideal, especially if we're dealing with user-produced content. We have to make sure that the content is safe or sanitized before rendering the HTML. Otherwise, we can be exposed to security issues like XSS attacks.

And we get the following:

Syntax highlighting with Commonmarker

The gem comes with the following themes: base16-ocean.dark which is the default, base16-eighties.dark, base16-mocha.dark, base16-ocean.light, InspiredGitHub, Solarized (dark) and Solarized (light).

However, we can add new themes by explicitly declaring the theme path within our application:

def formatted_content
  Commonmarker.to_html(content, options: {
    parse: {smart: true},
  },
    plugins: {
      syntax_highlighter: {
        theme: "ayu-dark",
        path: Rails.root.join("app", "assets", "themes").to_s
      },
    },
  )
end

Commonmarker uses the tmTheme format behind the scenes, an XML-based format created by TextMate.

We could follow a tutorial to create new color schemes with this format, but to save time, we will use Google to find themes that we want to add and copy them into the app/assets/themes folder.

For example, if we intend to find the Ayu Dark theme, all we have to do is use the site and inurl operators to find it:

Using Google search operators to find TmThemes

Then, we go to the file, copy its content into the app/assets/theme/ayu-dark.tmtheme file and then, our custom theme should work:

Syntax highlighting with a custom TmTheme using Commonmarker

Using Redcarpet with Rouge

This is probably the most common stack Rails developers pick when adding syntax highlighting to their applications.

As we've already installed the redcarpet gem, let's install Rouge:

bundle add rouge && bundle install

To avoid cluttering our Post class, let's create a custom HTML renderer that inherits from Redcarpet::Render::HTML where we will include Rouge:

# app/models/html_renderer.rb
require 'redcarpet'
require 'rouge'
require 'rouge/plugins/redcarpet'

class HtmlRenderer < Redcarpet::Render::HTML
  include Rouge::Plugins::Redcarpet

  def initialize(options = {})
    super(options)
  end
end

Now, let's change the formatted_content method to use Redcarpet instead of Commonmarker like we did before:

class Post < ApplicationRecord
  # Rest of the code

  def formatted_content
    Redcarpet::Markdown.new(HtmlRenderer, {
      fenced_code_blocks: true,
    }).render(content)
  end
end

Now, after we create a new post and add code to it, we should see the following result:

Rendering Markdown with the Redcarpet gem and no syntax highlighting

Even though the actual highlighting is not produced, if we inspect the source we can see that CSS classes are added to the code nodes:

Redcarpet with Rouge CSS classes added

Now, the easiest way to add a theme is to render it within a style tag:

<% content_for :head do %>
  <style><%= Rouge::Themes::Molokai.render %></style>
<% end %>

Producing the following result:

Syntax highlighting with Rouge and Redcarpet

If you are not a fan of adding CSS within <style> tags in the head of the document, we can add a themes folder within app/assets/stylesheets and generate the styles by running rougify with the theme name.

Let's add a generate_rouge_themes.rb script into the script folder:

#!/usr/bin/env ruby
require 'fileutils'

OUTPUT_DIR = 'public/stylesheets/rouge_themes'
FileUtils.mkdir_p(OUTPUT_DIR)
THEMES = [
  'base16', 'base16.dark', 'base16.light', 'base16.monokai', 'base16.monokai.dark',
  'base16.monokai.light', 'base16.solarized', 'base16.solarized.dark', 'base16.solarized.light',
  'bw', 'colorful', 'github', 'github.dark', 'github.light', 'gruvbox', 'gruvbox.dark',
  'gruvbox.light', 'igorpro', 'magritte', 'molokai', 'monokai', 'monokai.sublime',
  'pastie', 'thankful_eyes', 'tulip'
]

THEMES.each do |theme|   
  safe_filename = theme.gsub('.', '_')
  output_file = "#{OUTPUT_DIR}/#{safe_filename}.css"

  css = `bundle exec rougify style #{theme} 2>/dev/null`
  if $?.success? && !css.empty?
    File.write(output_file, css)
    puts "✓ #{safe_filename}.css"
  else
    puts "✗ Failed: #{theme}"
  end
end

Now, we run chmod +x script/generate_rouge_themes, and run it with ruby script/generate_rouge_themes.rb to get the themes generated into the rouge_themes folder.

Then, we can remove the reference to the Molokai theme and import the theme to our application CSS:

@import "../"

Using Highlight.js

Highlight.js is maybe the most popular front-end syntax highlighting library.

To use Highlight.js with Rails for Markdown content, we need to parse the Markdown into HTML because that's what the library expects.

The first thing we will do is to add a formatted_plain method to the Post model, where we will use Commonmarker to parse the Markdown content without adding syntax highlighting:

class Post < ApplicationRecord
  def formatted_plain
    Commonmarker.to_html(content, plugins: {syntax_highlighter: nil})
  end
end

Which produces the following result:

Plain HTML with Commonmarker

Now, let's start by installing Highlight.JS:

yarn add highlight.js

Then, let's create a syntax_highlight.js file where we add the initial configuration and import the tokyo-night-dark theme:

// app/javascript/syntax_highlight.js
import hljs from "highlight.js"

hljs.configure({
  languages: ["ruby", "erb", "javascript"],
})

document.addEventListener("turbo:load", () => {
  const codeBlocks = document.querySelectorAll("pre code")
  if (codeBlocks.length > 0) {
    codeBlocks.forEach((block) => {
      hljs.highlightElement(block)
    })
  }
})

Because we're using cssbundling, we have to require the CSS in .css files like application.tailwind.css:

@import "highlight.js/styles/tokyo-night-dark.css"

Then, we can import the file into application.js without any issues:

// app/javascript/application.js
import "./syntax_highlight"

And everything should be working. However, consider that we're importing the CSS globally, and we probably don't need syntax highlighting on every page of our application.

To solve this, let's import the CSS into the JavaScript file but, instead of importing it into application.js, which produces an error, let's include it directly in the layout:

// app/javascript/syntax_highlight.js
import hljs from "highlight.js"
import "highlight.js/styles/tokyo-night-dark.css"
<%= javascript_include_tag "syntax_highlight", "data-turbo-track": "reload" %>

Lastly, we can also avoid including the syntax_highlight file everywhere and add it exclusively on views that require it using the following:

<%# app/views/posts/show.html.erb %>
<%= content_for :head do %>
  <%= javascript_include_tag "syntax_highlight", "data-turbo-track": "reload" %>
<% end %>

This is what we get after successfully adding Highlight.js:

Syntax highlighting with Markdown and Highlight.js in Rails

However, when we import hljs we're importing every language the library can handle. To avoid loading unnecessary code and making our requests slower, let's replace what we have with the following:

import hljs from "highlight.js/lib/core"
import ruby from 'highlight.js/lib/languages/ruby'
import erb from 'highlight.js/lib/languages/erb'
import javascript from 'highlight.js/lib/languages/javascript'
import bash from 'highlight.js/lib/languages/bash'

hljs.registerLanguage('ruby', ruby);
hljs.registerLanguage('erb', erb);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('bash', bash);

document.addEventListener("turbo:load", () => {
  const codeBlocks = document.querySelectorAll("pre")
  codeBlocks.forEach((block) => {
    hljs.highlightElement(block)
  })
})

Finally, we add a bit of CSS to add padding and rounded borders:

pre {
  @apply rounded-lg my-3 px-4 py-3;
}

A slight delay between the page loading and the syntax highlighting is common when using a front-end library like Highlight.js. We can prevent that by preloading the CSS when the page loads, but that somewhat defeats the purpose of using this library.

Using the Rhino Editor

Now that we added syntax highlighting using Highlight.js with Markdown, let's see how to approach the feature using the Rhino Editor, an Action Text compatible WYSIWYG editor.

Let's start by installing Action Text and Active Storage:

bin/rails active_storage:install && bin/rails action_text:install

That will create a couple of migrations and plenty of files that come from Action Text. Let's run the migrations:

bin/rails db:migrate

Now, considering that we have a content field that we use to store Markdown, let's set a body association with Action Text:

class Post < ApplicationRecord
  has_rich_text :body
end

We don't need to have body as a column in our Post table because Action Text uses the action_text_rich_texts table to produce the link to our model.

The next step is to install the Rhino Editor:

yarn add rhino-editor

Then, we import the JS code into application.js:

import "rhino-editor"

We also have to import the styles for it, but as we're using cssbundling we need to import the CSS into our application.tailwind.css file or to a separate file if you like:

@import "tailwindcss";
@import "rhino-editor/exports/styles/trix.css";

Now, let's comment the Marksmith field out and add the following:

<%= form.hidden_field :body, id: form.field_id(:body), value: form.object.body.try(:to_trix_html) || form.object.body %>
<rhino-editor
  input="<%= form.field_id(:body) %>"
  data-blob-url-template="<%= rails_service_blob_url(":signed_id", ":filename") %>"
  data-direct-upload-url="<%= rails_direct_uploads_url %>"
></rhino-editor>

Now, if we refresh the form, we get the following:

Post form with Rhino editor

Let's not forget to add body to the permitted params in the PostsController:

def post_params
  params.expect(post: [ :title, :content, :body, :published_at ])
end

Now, if we create a post that has a code block in it:

Rhino editor with code block

We get the following result:

Syntax highlighting using the Rhino Editor

If you get a TypeError: map.get is not a function error when submitting the form with Rhino, make sure to add type: "module" to the application JavaScript include tag in your layout. This fixes the error that's produced because Turbo tries to use the fetch function from the set_operations file instead of the browser's fetch.

Because we previously added Highlight.js, we get syntax highlighting out of the box automatically. It works because Highlight.js is capable of auto-detecting the language inside the code block, and then it processes the content to add the appropriate CSS selectors.

However, Rhino has the ability for us to highlight code while we're editing the content using the lowlight extension for Tip Tap. Let's see how to achieve that:

With the lowlight extension

Before anything, let's remove any reference to the syntax_highlight file we might have. This should produce the following result:

Rhino Editor without Highlight.js

Before starting with the process, we need to understand the way the feature works in Rhino.

Unlike our previous examples, the intended way to use syntax highlighting with Rhino is to:

  • Use lowlight with the CodeBlockLowlight extension to add syntax highlighting and fenced code blocks on the editor.
  • Store the highlighted syntax in the database by processing the content using the hast-util-to-html library.

However, I found the demo code to have some issues, especially when editing the content multiple times and, even though the purpose of WYSIWYG is storing HTML in the database, I think that the approach where we store the code without any syntax highlighting classes added and then using a library like Highlight.js to achieve the task is probably a better decision.

So, we will skip the second part of the documentation where they process the content before submitting the form to the server.

Let's start by adding lowlight and the code block extension for Tip Tap that uses it:

yarn add lowlight @tiptap/extension-code-block-lowlight

Now, let's replace the default codeBlock from the starterKitOptions using the extension by extending the Rhino editor before the initialization process.

Let's start by creating a new file to customize the editor without polluting application.js and adding the imports:

// app/javascript/rhino_editor.js
import "rhino-editor"

import { common, createLowlight } from 'lowlight'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'

Then, we will create an instance of lowlight and assign it to a variable with the same name that we pass to the CodeBlockLowlight.configure method:

// Imports are here

const lowlight = createLowlight(common)
const syntaxHighlight = CodeBlockLowlight.configure({
  lowlight,
})

With this, the extension will know to use lowlight for the syntax highlighting.

Let's define a extendRhinoEditor function that we will call before initialization:

// Rest of the code
function extendRhinoEditor(event) {
  const editor = event.target
  if (editor === null) return

  editor.starterKitOptions = {
    ...editor.starterKitOptions,
    codeblock: false
  }

  editor.extensions = [syntaxHighlight]
  editor.rebuildEditor()
}

document.addEventListener('rhino-before-initialize', extendRhinoEditor)

Now, we get fenced code blocks with live syntax highlighting:

However, to get syntax highlighting on the post itself, we have to add the syntax_highlight back, producing the following result on the post content itself:

Syntax highlighting with Rhino and fenced code blocks

If you enjoy working with the Rhino Editor, please note that you can use it with Avo by adding the avo-rhino_field gem and defining an attribute in the resource configuration: field :body, as: :rhino. It's as easy as that! Check Avo's Rhino field documentation for more information.

Choosing the right approach

For a short answer, we can probably use server-side parsing with gems like Commonmarker, redcarpet working alongside Rouge or other libraries like Pygments most of the time, especially if we're building vanilla Rails applications.

However, there are a couple of reasons where we might choose a front-end library:

  • We need real-time highlighting: there are some cases where this is a need, and are most likely the perfect use case for a FE highlighting library.
  • We use a mixed front-end: if we're using a front-end framework to render our views, like if we're using Inertia Rails we could use server-side syntax highlighting, but that might require some workarounds so using a library like Highlight.js might be a good idea.
  • Theme variety: Highlight.js has 512 themes we can pick from, while Rouge comes with 13 themes out of the box and Pygments 48. Of course, we can always add themes, but the defaults usually matter, so you might be inclined to pick one over the other for this motive.

Conversely, we might not want to choose a front-end library because:

  • Bundle size increase: if we're not careful with what we load, our bundle size can increase, thus making requests slower and degrading the user experience without much to show for.
  • Highlight flickering: because the parsing and highlighting happens on the client, users might notice a flicker between an unstyled code block and the final result.
  • Language limitation: probably not a problem for most use cases, but Rouge has compatibility with more languages than Highlight.js.

The fact that we use a Markdown parsing library doesn't mean we have to use a Ruby syntax highlighting library. We can always add the CSS manually to match the selectors that are produced by the parsing library.

Summary

In this article, we learned how to add syntax highlighting to a Rails app by showing a couple of ways to do it:

  • With a Markdown field and a server-side parser like Commonmarker.
  • With front-end parsing using Highlight.js.
  • With the Rhino Editor, an Action Text compatible WYSIWYG editor.
  • With static content.

Using a gem like Commonmarker, we get syntax highlighting without any extra dependency because the gem adds inline styles and comes with 7 default themes, but we can easily add more themes using the TmTheme format.

To get syntax highlighting using Commonmarker with Markdown content, we need to do the following:

def formatted_content
  Commonmarker.to_html(content, options: {
    parse: {smart: true},
  },
    plugins: {
      syntax_highlighter: {
        theme: "base16-ocean.dark",
      },
    },
  )
end

We can also customize the theme by providing a path to the .tmtheme file.

Besides Commonmarker, we can also use Redcarpet together with the Rouge gem to add syntax highlighting from Markdown content. The first step is to create a custom HtmlRenderer class inside our models directory:

require 'redcarpet'
require 'rouge'
require 'rouge/plugins/redcarpet'

class HtmlRenderer < Redcarpet::Render::HTML
  include Rouge::Plugins::Redcarpet

  def initialize(options = {})
    super(options)
  end
end

Then, we use this custom renderer with the Redcarpet gem:

def formatted_content
  Redcarpet::Markdown.new(HtmlRenderer, {
    fenced_code_blocks: true,
  }).render(content)
end

Finally, we include the styles for our desired theme, Rouge provides 18 themes out of the box:

<% content_for :head do %>
  <style><%= Rouge::Themes::Molokai.render %></style>
<% end %>

The other way to add syntax highlighting is by using a front-end library like Highlight.js to add the appropriate structure and CSS classes to the code. We start by installing the library, creating a syntax_highlight.js file within our application's JavaScript and including the following:

import hljs from "highlight.js/lib/core"
import ruby from 'highlight.js/lib/languages/ruby'
import erb from 'highlight.js/lib/languages/erb'
import javascript from 'highlight.js/lib/languages/javascript'
import bash from 'highlight.js/lib/languages/bash'
import "highlight.js/styles/base16/gruvbox-dark-hard.css"

hljs.registerLanguage('ruby', ruby);
hljs.registerLanguage('erb', erb);
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('bash', bash);

document.addEventListener("turbo:load", () => {
  const codeBlocks = document.querySelectorAll("pre > code")
  if (codeBlocks.length > 0) {
    codeBlocks.forEach((block) => {
      hljs.highlightElement(block)
    })
  }
})

In order for this not to break because of the CSS import within JS, we have to make sure to include our app's JavaScript using type: "module":

<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>

Finally, there's another approach which is using a WYSIWYG editor like Rhino, which produces HTML that we can parse in the frontend using the same code we used above.

However, if we want to add live syntax highlighting while editing the code, we need to add lowlight and Tip Tap's code block extension for it:

import "rhino-editor"

import { common, createLowlight } from 'lowlight'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'

const lowlight = createLowlight(common)
const syntaxHighlight = CodeBlockLowlight.configure({
  lowlight,
})

function extendRhinoEditor(event) {
  const editor = event.target
  if (editor === null) return

  editor.starterKitOptions = {
    ...editor.starterKitOptions,
    codeblock: false
  }

  editor.extensions = [syntaxHighlight]
  editor.rebuildEditor()
}

document.addEventListener('rhino-before-initialize', extendRhinoEditor)

I hope you enjoyed this article and that it helps you add the feature to your next project and makes you save some time. Let us know about it!

Have a good one and happy coding!


Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.