Reputation: 21
A few months back a had an idea and went at it wrong, but it worked, mostly. After my buddy @max looked it over and gave me some pointers, I went back to it with some help from Monica. It works. I wrote the service and am calling it before save in the rails model. It works. Here is the original question: Simple Table of Contents in Rails is not working as intended
Quick refresher, because this is driving me bats. I want to create a Table of Contents for my blog, Rails 8 app. I have the Rails action text installed and using the trix editor. The trix editor has a "Title" button which just slaps a h1 tag in the content. I wrote a service that is called from the blog model before save and before update. The service will look through the content and grab the content of the h1 tags. It will use that content to:
Like I said, this works as intended. The concept is simple. You do this in HTML all the time. If a section, div, element has a id attribute, then you can target that with a anchor. I do this on my home page to target specific parts of the info page.
Upon review of the saved content to the database, I can see the id attributes have been added to the h1 tags. I can see in the rails logs where the content is modified and saved. I can display the content on the web page using to_s method or to_trix_html method or to_html method and verify the id attribute in the content body. However, when rendering the content as I normally would, the browser does not include the id attribute for the h1 tag. I inquired with the google browser AI, which needed to run some javascript. It came back with yes, it could see that there should indeed be a id attribute, but the browser was not displaying it. It suggested that some plug in or some other javascript was removing it. This is happening in both Chrome browser and Firefox. In looking through my plug ins and the app code, I do not see what could be causing this. Here is the toc_generator in the repo for review: https://github.com/Developer3027/milk-rails8/blob/main/app/services/toc_generator.rb
I have left the code and generator in place, save the toc in the view. The app is set up with a admin side for creating various things, including blog articles. Once installed, you can run the seed to create a admin account. Then when running, click the coffee mug to sign in. Create any article using a title. On saving the toc and new content are created. You will need to add the @blog.toc back into the view for a show,(I have done so in this example view). Here is the set up,
Model:
# before update or create run process_body
before_save :process_body
# 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.content = result[:body] # Update the :content with modified body
end
controller:
# POST /milk_admin/blogs
#
# Creates a new blog post using provided blog parameters.
# Associates the blog post with the current milk admin.
#
# On success:
# - 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
# PATCH/PUT /milk_admin/blogs/1
#
# Updates a blog using the given blog parameters.
# Associates the blog post with the current milk admin.
#
# On success:
# - 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 update
@blog.milk_admin_id = current_milk_admin.id
respond_to do |format|
if @blog.update(blog_params)
# @blog.process_body # Call process_body to ensure TOC and body are updated
set_image_url(@blog) if @blog.blog_image.attached?
format.html { redirect_to milk_admin_blogs_path, notice: "Blog was successfully updated." }
format.json { render :show, status: :created, location: @blog }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @blog.errors, status: :unprocessable_entity }
end
end
end
toc_generator - service:
# 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)
{ toc: toc.html_safe, body: modified_body.html_safe }
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_html
end
end
blog show view:
<section class="w-full text-base-dark bg-slate-200 py-4">
<div class="max-w-4xl mx-auto">
<div class="flex flex-col items-center bg-slate-50 rounded-md px-2">
<!-- Blog Header -->
<div class="border-b-4 border-base-dark w-full py-5">
<%= link_to blogs_path do %>
<h1 class="font-bold text-xl">
MILK-00 Blog Articles
</h1>
<% end %>
<p>
Find me on <%= link_to "SubStack", "https://masonroberts.substack.com/", target: "_blank", class: "font-medium text-milk-dark underline" %>
</p>
<p>
<i>Welcome to the ramblings of my learning journey.</i>
</p>
</div>
<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>
<%= @blog.toc %>
</div>
<div class="max-w-4xl my-3 mx-1 px-1">
<%= @blog.content %>
</div>
</div>
</div>
</section>
This is the closest I have gotten to making this work. Everything seems to love it except the browser and I am at a loss. The app is Rail 8 running ruby 3.3.0 with Tailwind and PostgreSQL. Please review the original issue, although it really has nothing to do with this problem. Max, if your out there, unsheathe that ruler, my knuckles are ready. I could really use your super power help here.
Upvotes: 0
Views: 52