Build the TOC Generator

Author: admin@example.com
Created: Sun, 08 Jun 2025
Category: Rails
The Concept
Rails is a great framework for building blogs. It comes with Action Text, you just need to turn it on. Action Text includes a WYSWYG editor that can be configured but is fine right out of the box. It includes a "Title" button that provides a h1 tag in the underlying html. I thought it would be great if I could use that title button to help generate a table of contents, (TOC), on saving the post.
The service
In the app folder I created a new folder called "services". In the new services folder I created a file called toc_generator.rb. Here I will use Nokogiri to process the content.
The model
The model needs to grab the content from the controller and pass it to the service:
I called this method with a before save action:
The blog_controller
The controller is typical and nothing special here. I call process_body on the @blog variable when it is saved. This sets it all in motion. I also added the new column to the params permitted.
The view for show
Here is the blog view. I use tailwind and this has not been broken down yet.
** You may find that text overflows and the code blocks aren't formatted correctly. The fix is above, fix sanitize body, and in the show view on Github **
The problem
What did I need to happen? I need to look through the content. Pull out the content of the h1 tags and create a list of links. I also needed to add a id attribute to the h1 tags that included the h1 content. This way I had a link that when clicked will navigate to the location in the content.
The framework
I am using Rails which uses MVC architecture. So my biggest initial question was how to implement this. The model is the piece that Rails depends on to talk with the database. The controller makes sure everything is square before sending info to the view. The view is what is presented in the browser. It took some research and tinkering but the concept I landed on was to create a service. The controller will take the content from the form and pass it to the model. The model will pass the content to the service. The service will handle the processing and pass back to the model to finish up.
Processing HTML
Nokogiri is designed to deal with HTML in a Ruby environment. It has been used with Rails and was a solid choice for me. It would quickly and easily do the work of finding and manipulating the HTML.
The process
Install Nokogiri
First I added the nokogiri gem to my gem file and ran bundle install in the command line.
The table
In the end I needed a column in the Blog to save the processed html. I ran a migration to add the new column to the Blog.
The table
In the end I needed a column in the Blog to save the processed html. I ran a migration to add the new column to the Blog.
bin/rails g migration AddProcessedBodyToBlogs processed_body:text bin/rails db:migrate
The service
In the app folder I created a new folder called "services". In the new services folder I created a file called toc_generator.rb. Here I will use Nokogiri to process the content.
# Use the Nokogiri gem to generate the table of contents from the HTML content of the body (content) # Using the default trix editor, using the Title button provides a h1 tag, # search through the body content and generate the table of contents from the h1 tags content. # Add the same id attribute to the h1 tags so that we can link to them from the table of contents. class TocGenerator # Initialize a new TocGenerator with the given body string. # # @param [String] body the body content to generate the table of contents from. def initialize(body) @body = body end # Returns a hash with keys :toc and :body. # # The :toc key maps to a string containing a table of contents generated from # the headings in the body content. # # The :body key maps to the body content with the headings modified to have # ids matching the text of the headings, for linking from the table of # contents. def generate doc = Nokogiri::HTML::DocumentFragment.parse(@body) headings = doc.css("h1") return { toc: "", body: @body } if headings.empty? toc = generate_toc(headings) modified_body = modify_headings_with_ids(headings, doc) # Remove Action Text wrapper div if present cleaned_body = modified_body.to_s.gsub(/<div class="trix-content">(.*?)<\/div>/m, '\1') { toc: toc.to_s, body: cleaned_body } end private # Generates an HTML table of contents from the given headings. # # @param [Nokogiri::XML::NodeSet] headings a collection of h1 elements from # which to generate the table of contents. # @return [String] an HTML string representing the table of contents, where # each item links to the corresponding heading. def generate_toc(headings) toc = "<ul>" headings.each do |heading| id = heading.text.gsub(/\s+/, "-").downcase toc += "<li><a href='##{id}'>#{heading.text}</a></li>" end toc += "</ul>" toc end # Modifies the given headings by adding an id attribute to each one. # # The id is generated by downcasing the heading text and replacing any spaces # with hyphens. If the heading already has an id, we don't overwrite it. # # @param [Nokogiri::XML::NodeSet] headings a collection of h1 elements from # which to generate the ids. # @param [Nokogiri::HTML::DocumentFragment] doc the document fragment in which # the headings exist. # @return [String] the modified HTML document fragment as a string. def modify_headings_with_ids(headings, doc) headings.each do |heading| id = heading.text.gsub(/\s+/, "-").downcase heading["id"] = id unless heading["id"] # Only set id if it doesn't exist end doc.to_s end end
The model
The model needs to grab the content from the controller and pass it to the service:
# Process the body content of a blog post by extracting the HTML content # from the rich text body, generating a table of contents from any headings, # and modifying the body content by adding ids to the headings so that they # can be linked to from the table of contents. # This process_body method is handled by the TocGenerator service. def process_body # Extract the HTML content from the rich text body body_content = content.to_s # Use the service to generate TOC and modify body content result = TocGenerator.new(body_content).generate self.toc = result[:toc] # Update the TOC self.processed_body = result[:body] # Update the :content with modified body end
I called this method with a before save action:
# before update or create run process_body before_save :process_body
The blog_controller
The controller is typical and nothing special here. I call process_body on the @blog variable when it is saved. This sets it all in motion. I also added the new column to the params permitted.
# POST /milk_admin/blogs # # Creates a new blog post using provided blog parameters. # Associates the blog post with the current milk admin. # # On success: # - process the body content for toc. # - Sets the image URL if an image is attached. # - Redirects to the blogs listing page with a success notice. # - Renders the blog as JSON with a 201 status code. # # On failure: # - Renders the new blog form with an unprocessable entity status. # - Renders the errors as JSON with an unprocessable entity status. def create @blog = Blog.new(blog_params) @blog.milk_admin_id = current_milk_admin.id respond_to do |format| if @blog.save @blog.process_body # Call process_body to ensure TOC and body are updated set_image_url(@blog) format.html { redirect_to milk_admin_blogs_path, notice: "Blog was successfully created." } format.json { render json: @blog } else format.html { render :new, status: :unprocessable_entity } format.json { render json: @blog.errors, status: :unprocessable_entity } end end end
Sanitize the body
The html that is returned comes from Action Text and you may have trouble rendering it. In the service you will notice the line that removes the Action Text wrapper. Here is a helper, placed in helpers/blogs_helper.rb, that will sanitize the returned content to keep it safe and secure.
** There are a few tags missing, can you add them so your code will format correctly? **
The html that is returned comes from Action Text and you may have trouble rendering it. In the service you will notice the line that removes the Action Text wrapper. Here is a helper, placed in helpers/blogs_helper.rb, that will sanitize the returned content to keep it safe and secure.
** There are a few tags missing, can you add them so your code will format correctly? **
def display_blog_body(blog) sanitize(blog.processed_body, tags: %w[h1 h2 h3 h4 p a ul ol li strong em br img span div], attributes: %w[href src id class style title alt]) end
The view for show
Here is the blog view. I use tailwind and this has not been broken down yet.
** You may find that text overflows and the code blocks aren't formatted correctly. The fix is above, fix sanitize body, and in the show view on Github **
<section class="w-full text-base-dark bg-slate-200 py-4"> <div class="max-w-5xl mx-auto"> <div class="flex flex-col items-center bg-slate-50 rounded-md px-2"> <h1 class="font-bold text-xl md:text-2xl p-4"> <%= @blog.title %> </h1> <% if @blog.blog_image.present? %> <%= image_tag @blog.blog_image, class: "rounded-md w-full object-cover" %> <% end %> <div class="flex justify-between w-full my-3"> <div class="flex flex-col justify-between font-medium w-1-2 m-1 p-1"> <span>Author: <%= @blog.milk_admin.email %></span> <span>Created: <%= @blog.created_at.to_date.inspect %></span> </div> <div class="flex justify-start w-1/2 font-medium m-1 p-1"> <span class="bg-input-border rounded-full px-5">Category: <%= @blog.blog_category.title %></span> </div> </div> <div class="flex"> <div class="min-w-fit my-3 mx-1 px-1"> <%= @blog.toc %> </div> <div class="my-3 mx-1 px-1"> <% if @blog.processed_body.present? %> <%= display_blog_body(@blog) %> <% else %> <%= @blog.content %> <% end %> </div> </div> </div> </div> </section>
Conclusion
Everything about this worked but the id attribute never showed up in the html of the view. I could see it in the process and in the Action Table blob. It wasn't until I saved the content in the blog table and changed the view to show it, did I see it. I hope you take this and improve on it. Let me know what you come up with. I am excited to see what comes of it.