I had this thought of a simple Table of contents in Rails. I have created a new Rails 8 app, worked. I installed action text. Scaffold'ed out a post model with title and body. Body has the rich text and uses the trix editor. Post.rb model has the needed "has_rich_text :body". I went at this in a few different ways but finally landed on just grabbing the body in the model using "before_save" and making adjustments there. The concept is straight forward. When you create a new post, If you add a "heading" (will give you a h1 tag), then I can look through the body, grab those h1 tags, snatch the content, create the anchor tag from the content and modify the h1 to include the id attribute, which would also be the content.
I added the migration for adding "toc" to posts model where toc:rich_text. Ensured "has_rich_text :toc" was in the post.rb model and :toc was added to the strong params. Added the field in the _post.html.erb to show it. Here is my frustration. It works. Once the post is created I get the view with the links that are the heading in the post body. The h1 tags do not have the id attributes. Oh, but if I log it, the before save to body does have the modified code, after save, it is not there, but rather the original post body.
Here is the actions I am working on in the post.rb.
class Post < ApplicationRecord has_rich_text :body has_rich_text :toc # has_rich_text :toc_body before_save :process_body private def process_body return if body.blank? # Convert the ActionText::RichText to a string body_content = body.to_s toc_data = generate_toc(body_content) # Log the modified body content # Rails.logger.debug("Modified Body: #{toc_data[:content]}") self.body = toc_data[:content] # Store the modified body # Log the body after update # Rails.logger.debug("Body after update: #{self.toc_body}") self.toc = toc_data[:toc] # Store the TOC end # Method to generate TOC and modify body def generate_toc(body) headings = body.scan(/<h1[^>]*>(.*?)<\/h1>/).flatten return { toc: "", content: body } if headings.empty? toc = "<ul>" ids = [] # Array to store generated IDs headings.each do |heading| id = heading.gsub(/\s+/, "-").downcase toc += "<li><a href='##{id}'>#{heading}</a></li>" ids << { heading: heading, id: id } # Store heading and corresponding ID end # Manually build the new body content ids.each do |item| body.gsub!(/<h1>(#{Regexp.escape(item[:heading])})<\/h1>/, "<h1 id='#{item[:id]}'>\\1</h1>") end toc += "</ul>" { toc: toc.html_safe, content: body.html_safe } endend
Here are the logs, I put bold ** around the points of interest to try and help:
Started POST "/posts" for ::1 at 2024-11-29 15:15:00 -0500Processing by PostsController#create as TURBO_STREAM Parameters: {"authenticity_token"=>"[FILTERED]", "post"=>{"title"=>"Ruby Blog", "body"=>"<h1>Chapter 1</h1><div>Rails is sunshine and lollipops</div><h1>Chapter 2</h1><div>Not everyone like Brussels Sprouts </div>"}, "commit"=>"Create Post"} Rendered vendor/bundle/ruby/3.3.0/gems/actiontext-8.0.0/app/views/action_text/contents/_content.html.erb within layouts/action_text/contents/_content (Duration: 4.2ms | GC: 0.0ms)**Modified Body:** <!-- BEGIN app/views/layouts/action_text/contents/_content.html.erb --><div class="trix-content"><!-- BEGIN vendor/bundle/ruby/3.3.0/gems/actiontext-8.0.0/app/views/action_text/contents/_content.html.erb -->**<h1 id='chapter-1'>**Chapter 1</h1><div>Rails is sunshine and lollipops</div>**<h1 id='chapter-2'>**Chapter 2</h1><div>Not everyone like Brussels Sprouts </div><!-- END vendor/bundle/ruby/3.3.0/gems/actiontext-8.0.0/app/views/action_text/contents/_content.html.erb --></div><!-- END app/views/layouts/action_text/contents/_content.html.erb --> Rendered vendor/bundle/ruby/3.3.0/gems/actiontext-8.0.0/app/views/action_text/contents/_content.html.erb within layouts/action_text/contents/_content (Duration: 2.7ms | GC: 0.0ms)**Body after update:** <!-- BEGIN app/views/layouts/action_text/contents/_content.html.erb --><div class="trix-content"><!-- BEGIN vendor/bundle/ruby/3.3.0/gems/actiontext-8.0.0/app/views/action_text/contents/_content.html.erb --><div class="trix-content"> **<h1>**Chapter 1</h1><div>Rails is sunshine and lollipops</div>**<h1>**Chapter 2</h1><div>Not everyone like Brussels Sprouts </div></div><!-- END vendor/bundle/ruby/3.3.0/gems/actiontext-8.0.0/app/views/action_text/contents/_content.html.erb --></div><!-- END app/views/layouts/action_text/contents/_content.html.erb -->
Another on a Slack channel suggested using Nokogiri. I installed the gem and rewrote the post.rb actions using it. I am still getting the same result. A helpful sort has suggested that the post.rb model may not be the best place for this. The best place I could think of to intercept and modify before save was the post model. I will think on this. Any advice would be appreciated. Here is the rewrite with Nokogiri:
class Post < ApplicationRecord # ActionText body has_rich_text :body # ActionText toc has_rich_text :toc # Use separate callbacks for create and update before_save :process_body private def process_body # Extract the HTML content from the rich text body body_content = body.to_trix_html # Make modifications and create toc result = generate_toc(body_content) self.toc = result[:toc] # Update the TOC # Log result before save Rails.logger.debug("MODIFIED BODY BEFORE SAVE: #{result[:content]}") self.body = result[:content] # Update the body with modified content # Log result after save Rails.logger.debug("BODY AFTER SAVE: #{self.body}") end def generate_toc(body) # Parse the HTML body with Nokogiri doc = Nokogiri::HTML::DocumentFragment.parse(body) # Find all <h1> tags headings = doc.css("h1") return { toc: "", content: body } if headings.empty? toc = "<ul>" headings.each do |heading| # Generate an ID for the heading id = heading.text.gsub(/\s+/, "-").downcase toc += "<li><a href='##{id}'>#{heading.text}</a></li>" # Set the ID attribute on the heading heading["id"] = id end toc += "</ul>" # Return the TOC and the modified content { toc: toc.html_safe, content: doc.to_html.html_safe } endend
I would love any help here. At first I thought it was a issue with the loop and gsub, but now I am not so sure.