Project

better_seo

0.0
The project is in a healthy, maintained state
BetterSeo provides a clean, fluent DSL for managing meta tags, Open Graph, Twitter Cards, XML sitemaps, and more. Features include automatic HTML generation, dynamic sitemap generation, validation, Rails integration, and 99.7% test coverage.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Development

~> 3.0
~> 0.22

Runtime

 Project Readme

πŸš€ BetterSeo

A comprehensive, production-ready SEO toolkit for Ruby and Rails applications

BetterSeo provides a clean, fluent DSL for managing meta tags, Open Graph, Twitter Cards, structured data, sitemaps, and advanced SEO tools. Built with 899 passing tests and 94.3% code coverage.

Tests Coverage Ruby Rails

🎯 Key Features at a Glance

Feature Description
🏷️ Meta Tags Complete DSL for title, description, keywords, robots
πŸ“± Social Media Open Graph & Twitter Cards with validation
πŸ—ΊοΈ Sitemaps XML sitemaps with hreflang, images, videos
πŸ“Š Structured Data 10+ JSON-LD types (Article, Product, Recipe, etc.)
πŸ€– Robots.txt Dynamic robots.txt generation
βœ… SEO Validator Score pages 0-100 with recommendations
πŸ–ΌοΈ Image Optimizer WebP conversion, resize, compress
πŸ“ˆ Analytics Google Analytics 4 & Tag Manager

πŸ“‹ Table of Contents

  • ✨ Features
  • πŸ“¦ Installation
  • πŸš€ Quick Start
  • πŸ“– Core Features
    • 🏷️ Meta Tags DSL
    • πŸ“± Open Graph & Twitter Cards
    • 🎯 Rails Integration
    • πŸ—ΊοΈ Sitemap Generation
    • πŸ“Š Structured Data (JSON-LD)
  • πŸ› οΈ Advanced Tools
    • 🍞 Breadcrumbs Generator
    • ⚑ AMP Support
    • πŸ”— Canonical URL Manager
    • πŸ€– Robots.txt Generator
    • βœ… SEO Validator & Recommendations
    • πŸ–ΌοΈ Image Optimizer
    • πŸ“ˆ Analytics Integration
  • βš™οΈ Configuration
  • πŸ’» Development
  • 🀝 Contributing
  • πŸ“„ License

⚑ Quick Reference

# 1️⃣ Install
gem 'better_seo', '~> 1.0'

# 2️⃣ Configure
BetterSeo.configure do |config|
  config.site_name = "My Site"
  config.meta_tags.default_title = "Default Title"
end

# 3️⃣ Use in Views
<%= seo_tags do |seo|
  seo.meta { |m| m.title("Page Title").description("Description") }
  seo.og { |og| og.type("article").image(image_url) }
  seo.twitter { |t| t.card("summary_large_image") }
end %>

# 4️⃣ Generate Sitemap
xml = BetterSeo::Sitemap::Generator.generate do |sitemap|
  sitemap.add_url("https://example.com", priority: 1.0)
end

# 5️⃣ Add Structured Data
<%= article_sd(headline: @post.title, author: @post.author.name) %>

✨ Features

🎯 Core Capabilities (v1.0.0 - Production Ready)

πŸ—οΈ Core Configuration System
  • βš™οΈ Singleton configuration with block-style setup
  • πŸ”§ Nested configuration objects
  • 🚩 Feature flags for enabling/disabling modules
  • βœ… Validation with detailed error messages
  • 🌍 i18n support with multiple locales
🏷️ DSL Builders
  • Meta Tags: title, description, keywords, author, canonical, robots, viewport, charset
  • Open Graph: Complete OG protocol including articles, images, videos, audio
  • Twitter Cards: All card types (summary, summary_large_image, app, player)
  • πŸ”— Fluent interface with method chaining
  • βœ… Automatic validation (length, required fields)
🎨 HTML Generators
  • 🏷️ MetaTagsGenerator: Converts DSL to HTML meta tags
  • πŸ“± OpenGraphGenerator: Open Graph meta tags
  • 🐦 TwitterCardsGenerator: Twitter Card meta tags
  • 🍞 BreadcrumbsGenerator: HTML breadcrumbs with Schema.org
  • ⚑ AMP Generator: Accelerated Mobile Pages support
  • πŸ”— Canonical URL Manager: URL normalization and management
  • πŸ”’ HTML entity escaping for security
🎯 Rails Integration
  • View Helpers: seo_meta_tags, seo_open_graph_tags, seo_twitter_tags, seo_tags
  • Structured Data Helpers: 10+ helpers for all Schema.org types
  • Controller Helpers: set_page_title, set_page_description, set_page_keywords, and more
  • Model Helpers: seo_attributes macro for automatic SEO
  • Railtie: Automatic initialization and helper injection
  • Generator: rails generate better_seo:install
πŸ—ΊοΈ Sitemap Generation
  • πŸ“ XML Sitemap Builder with fluent API
  • 🌍 Multi-language support (hreflang alternates)
  • πŸ–ΌοΈ Image sitemaps with title and caption
  • πŸŽ₯ Video sitemaps with metadata
  • πŸ“š Sitemap Index for 50,000+ URLs
  • πŸ”„ Dynamic generation with lambda support
  • πŸ’Ύ File writing capabilities
  • βœ… Automatic URL validation
πŸ“Š Structured Data (JSON-LD)

10 comprehensive Schema.org types:

  • 🏒 Organization: Company info with address, social profiles
  • πŸ“° Article: Blog posts with author, publisher, metadata
  • πŸ‘€ Person: Author profiles with job title, social links
  • πŸ›οΈ Product: E-commerce with price, ratings, reviews
  • 🍞 BreadcrumbList: Navigation breadcrumbs
  • πŸͺ LocalBusiness: Physical locations with hours, geo coordinates
  • 🎫 Event: Conferences, webinars with dates, tickets
  • ❓ FAQPage: Structured FAQ for rich snippets
  • πŸ“‹ HowTo: Step-by-step guides
  • 🍳 Recipe: Cooking recipes with ingredients, nutrition

✨ Full Rails integration with dedicated view helpers

πŸ› οΈ Advanced SEO Tools
  • πŸ€– Robots.txt Generator: Control crawler access
  • βœ… SEO Validator: Page scoring (0-100) with detailed reports
  • πŸ’‘ SEO Recommendations: AI-powered suggestions by priority
  • πŸ–ΌοΈ Image Optimizer: WebP conversion, resize, compress
  • πŸ“ˆ Google Analytics 4: GA4 integration
  • 🏷️ Google Tag Manager: GTM support with custom events

πŸ“¦ Installation

πŸ’Ž Production Use (RubyGems)

Add to your Gemfile:

gem 'better_seo', '~> 1.0'

Then run:

bundle install

Or install directly:

gem install better_seo

πŸ”§ Development (from source)

From Git:

gem 'better_seo', git: 'https://github.com/alessiobussolari/better_seo.git', tag: 'v1.0.0'

Clone and build locally:

git clone https://github.com/alessiobussolari/better_seo.git
cd better_seo
gem build better_seo.gemspec
gem install better_seo-1.0.0.gem

πŸš€ Quick Start

1️⃣ Configuration

Create an initializer in Rails or configure at app startup:

# config/initializers/better_seo.rb
BetterSeo.configure do |config|
  config.site_name = "My Awesome Site"
  config.default_locale = :en
  config.available_locales = [:en, :it, :fr]

  # Configure defaults for meta tags
  config.meta_tags.default_title = "My Awesome Site"
  config.meta_tags.title_separator = " | "
  config.meta_tags.append_site_name = true
  config.meta_tags.default_description = "The best site on the internet"
  config.meta_tags.default_keywords = ["awesome", "site", "seo"]

  # Configure Open Graph defaults
  config.open_graph.site_name = "My Awesome Site"
  config.open_graph.default_type = "website"
  config.open_graph.default_locale = "en_US"

  # Configure Twitter Cards defaults
  config.twitter.site = "@mysite"
  config.twitter.creator = "@myhandle"
  config.twitter.card_type = "summary_large_image"
end

2️⃣ Using DSL Builders

🏷️ Meta Tags

meta = BetterSeo::DSL::MetaTags.new

meta.evaluate do
  title "My Page Title"
  description "This is an amazing page about Ruby and SEO"
  keywords "ruby", "seo", "meta tags"
  author "John Doe"
  canonical "https://example.com/my-page"
  robots index: true, follow: true, noarchive: true
  viewport # uses default: "width=device-width, initial-scale=1.0"
  charset   # uses default: "UTF-8"
end

# Get the configuration
config = meta.build
# => {
#   title: "My Page Title",
#   description: "This is an amazing page...",
#   keywords: ["ruby", "seo", "meta tags"],
#   ...
# }

πŸ“± Open Graph

og = BetterSeo::DSL::OpenGraph.new

og.evaluate do
  title "My OG Title"
  description "Description for social media"
  type "article"
  url "https://example.com/article"
  image "https://example.com/image.jpg"
  site_name "My Site"
  locale "en_US"
  locale_alternate "it_IT", "fr_FR"

  # For article type
  article do
    author "John Doe"
    published_time "2024-01-01T00:00:00Z"
    modified_time "2024-01-02T00:00:00Z"
    section "Technology"
    tag "Ruby", "SEO", "OpenGraph"
  end
end

config = og.build

🐦 Twitter Cards

twitter = BetterSeo::DSL::TwitterCards.new

twitter.evaluate do
  card "summary_large_image"
  site "@mysite"          # @ prefix added automatically
  creator "myhandle"      # @ prefix added automatically
  title "Twitter Card Title"
  description "Description for Twitter"
  image "https://example.com/twitter-image.jpg"
  image_alt "Image description for accessibility"
end

config = twitter.build

πŸ”— Method Chaining

All DSL builders support fluent interface:

meta = BetterSeo::DSL::MetaTags.new
  .title("Chained Title")
  .description("Chained Description")
  .keywords("ruby", "rails", "seo")
  .author("Jane Doe")
  .canonical("https://example.com/page")

og = BetterSeo::DSL::OpenGraph.new
  .title("OG Title")
  .type("article")
  .url("https://example.com")
  .image("https://example.com/og.jpg")

twitter = BetterSeo::DSL::TwitterCards.new
  .card("summary_large_image")
  .site("@mysite")
  .title("Twitter Title")
  .description("Twitter Description")
  .image("https://example.com/twitter.jpg")

3️⃣ HTML Generation

Once you've built your SEO configuration with DSL builders, use generators to convert them to HTML tags:

🎨 Meta Tags Generator

# Build configuration with DSL
meta = BetterSeo::DSL::MetaTags.new
meta.title("My Page Title")
meta.description("Page description for SEO")
meta.keywords("ruby", "seo", "rails")
meta.canonical("https://example.com/page")
meta.robots(index: true, follow: true)

# Generate HTML tags
generator = BetterSeo::Generators::MetaTagsGenerator.new(meta.build)
html = generator.generate

# Output:
# <meta charset="UTF-8">
# <meta name="viewport" content="width=device-width, initial-scale=1.0">
# <title>My Page Title</title>
# <meta name="description" content="Page description for SEO">
# <meta name="keywords" content="ruby, seo, rails">
# <link rel="canonical" href="https://example.com/page">
# <meta name="robots" content="index, follow">

πŸ“± Open Graph Generator

# Build configuration with DSL
og = BetterSeo::DSL::OpenGraph.new
og.title("Article Title")
og.type("article")
og.url("https://example.com/article")
og.image(url: "https://example.com/og.jpg", width: 1200, height: 630)

# Generate HTML tags
generator = BetterSeo::Generators::OpenGraphGenerator.new(og.build)
html = generator.generate

# Output:
# <meta property="og:title" content="Article Title">
# <meta property="og:type" content="article">
# <meta property="og:url" content="https://example.com/article">
# <meta property="og:image" content="https://example.com/og.jpg">
# <meta property="og:image:width" content="1200">
# <meta property="og:image:height" content="630">

🐦 Twitter Cards Generator

# Build configuration with DSL
twitter = BetterSeo::DSL::TwitterCards.new
twitter.card("summary_large_image")
twitter.site("@mysite")
twitter.title("Twitter Card Title")
twitter.description("Description for Twitter")
twitter.image("https://example.com/twitter.jpg")

# Generate HTML tags
generator = BetterSeo::Generators::TwitterCardsGenerator.new(twitter.build)
html = generator.generate

# Output:
# <meta name="twitter:card" content="summary_large_image">
# <meta name="twitter:site" content="@mysite">
# <meta name="twitter:title" content="Twitter Card Title">
# <meta name="twitter:description" content="Description for Twitter">
# <meta name="twitter:image" content="https://example.com/twitter.jpg">

✨ Complete Example

# Build all SEO tags for a page
meta = BetterSeo::DSL::MetaTags.new.evaluate do
  title "My Awesome Page"
  description "This page is about Ruby SEO"
  keywords "ruby", "seo", "meta tags"
end

og = BetterSeo::DSL::OpenGraph.new.evaluate do
  title "My Awesome Page"
  type "article"
  url "https://example.com/page"
  image "https://example.com/og.jpg"
end

twitter = BetterSeo::DSL::TwitterCards.new.evaluate do
  card "summary_large_image"
  site "@mysite"
  title "My Awesome Page"
  image "https://example.com/twitter.jpg"
end

# Generate all HTML
meta_html = BetterSeo::Generators::MetaTagsGenerator.new(meta.build).generate
og_html = BetterSeo::Generators::OpenGraphGenerator.new(og.build).generate
twitter_html = BetterSeo::Generators::TwitterCardsGenerator.new(twitter.build).generate

# Combine and render in your view
all_tags = [meta_html, og_html, twitter_html].join("\n")

πŸ”’ Security Features

All generators automatically escape HTML entities to prevent XSS attacks:

meta = BetterSeo::DSL::MetaTags.new
meta.title('Title with "quotes" & <script>alert("xss")</script>')

generator = BetterSeo::Generators::MetaTagsGenerator.new(meta.build)
html = generator.generate

# Output:
# <title>Title with &quot;quotes&quot; &amp; &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</title>
# All dangerous characters are properly escaped

4️⃣ Validation

All DSL builders include automatic validation:

meta = BetterSeo::DSL::MetaTags.new
meta.title("A" * 80)  # Too long (max 60 chars recommended)
meta.build
# => BetterSeo::ValidationError: Title too long (80 chars, max 60 recommended)

og = BetterSeo::DSL::OpenGraph.new
og.title("Title")
og.build
# => BetterSeo::ValidationError: og:type is required, og:image is required, og:url is required

twitter = BetterSeo::DSL::TwitterCards.new
twitter.card("invalid_type")
twitter.build
# => BetterSeo::ValidationError: Invalid card type: invalid_type. Valid types: summary, summary_large_image, app, player

πŸ“– Core Features

🎯 Rails Integration

BetterSeo provides comprehensive view helpers for seamless Rails integration.

πŸ”§ Setup

Include the helpers in your ApplicationHelper:

# app/helpers/application_helper.rb
module ApplicationHelper
  include BetterSeo::Rails::Helpers::SeoHelper
end

Or include them globally in ApplicationController:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper BetterSeo::Rails::Helpers::SeoHelper
end

🎨 Using View Helpers

🏷️ Single Tag Group Helpers
<%# app/views/layouts/application.html.erb %>
<head>
  <%= seo_meta_tags do |meta|
    meta.title "My Page Title"
    meta.description "Page description"
    meta.keywords "ruby", "rails", "seo"
    meta.canonical request.original_url
    meta.robots index: true, follow: true
  end %>

  <%= seo_open_graph_tags do |og|
    og.title "My Page Title"
    og.type "website"
    og.url request.original_url
    og.image image_url("og-image.jpg")
    og.site_name "My Site"
  end %>

  <%= seo_twitter_tags do |twitter|
    twitter.card "summary_large_image"
    twitter.site "@mysite"
    twitter.title "My Page Title"
    twitter.description "Page description"
    twitter.image image_url("twitter-image.jpg")
  end %>
</head>
✨ All-in-One Helper
<%# Generate all SEO tags at once %>
<head>
  <%= seo_tags do |seo|
    seo.meta do |meta|
      meta.title @page_title || "Default Title"
      meta.description @page_description
      meta.keywords @page_keywords if @page_keywords
      meta.canonical request.original_url
    end

    seo.og do |og|
      og.title @page_title || "Default Title"
      og.type "article"
      og.url request.original_url
      og.image @og_image || image_url("default-og.jpg")
    end

    seo.twitter do |twitter|
      twitter.card "summary_large_image"
      twitter.site "@mysite"
      twitter.title @page_title || "Default Title"
      twitter.image @twitter_image || image_url("default-twitter.jpg")
    end
  end %>
</head>

Controller Integration

Set SEO data in your controllers:

class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])

    # Set SEO variables for the view
    @page_title = @article.title
    @page_description = @article.excerpt
    @page_keywords = @article.tags.pluck(:name)
    @og_image = url_for(@article.cover_image) if @article.cover_image.attached?
  end
end

Then use them in your layout:

<head>
  <%= seo_tags do |seo|
    seo.meta do |meta|
      meta.title @page_title if @page_title
      meta.description @page_description if @page_description
      meta.keywords(*@page_keywords) if @page_keywords
      meta.canonical request.original_url
    end

    seo.og do |og|
      og.title @page_title || "Default Title"
      og.type "article"
      og.url request.original_url
      og.image @og_image if @og_image
    end

    seo.twitter do |twitter|
      twitter.card "summary_large_image"
      twitter.title @page_title || "Default Title"
      twitter.description @page_description if @page_description
    end
  end %>
</head>

Hash Configuration

You can also pass hash configurations directly:

<%= seo_meta_tags(
  title: "My Page",
  description: "Description",
  keywords: ["ruby", "rails"]
) %>

<%= seo_open_graph_tags(
  title: "My Page",
  type: "article",
  url: request.original_url,
  image: image_url("og.jpg")
) %>

<%= seo_twitter_tags(
  card: "summary",
  title: "My Page",
  description: "Description"
) %>

Partial Integration

Create reusable SEO partials:

<%# app/views/shared/_seo.html.erb %>
<%= seo_tags do |seo|
  seo.meta do |meta|
    meta.title local_assigns[:title] || "Default Title"
    meta.description local_assigns[:description]
    meta.canonical request.original_url
  end

  seo.og do |og|
    og.title local_assigns[:title] || "Default Title"
    og.type local_assigns[:og_type] || "website"
    og.url request.original_url
    og.image local_assigns[:og_image] || image_url("default-og.jpg")
  end

  seo.twitter do |twitter|
    twitter.card "summary_large_image"
    twitter.title local_assigns[:title] || "Default Title"
  end
end %>

Then use it in your views:

<%# app/views/articles/show.html.erb %>
<%= render "shared/seo",
    title: @article.title,
    description: @article.excerpt,
    og_type: "article",
    og_image: url_for(@article.cover_image) %>

πŸ—ΊοΈ Sitemap Generation

BetterSeo provides a comprehensive sitemap generation system with support for XML sitemaps, dynamic content, and model collections.

πŸ“ Basic Sitemap Generation

Generate a simple sitemap using the block syntax:

xml = BetterSeo::Sitemap::Generator.generate do |sitemap|
  sitemap.add_url("https://example.com")
  sitemap.add_url("https://example.com/about")
  sitemap.add_url("https://example.com/contact")
end

puts xml
# <?xml version="1.0" encoding="UTF-8"?>
# <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#   <url>
#     <loc>https://example.com</loc>
#     <changefreq>weekly</changefreq>
#     <priority>0.5</priority>
#   </url>
#   ...
# </urlset>

βš™οΈ URL Entry with Full Attributes

Add URLs with all sitemap attributes (lastmod, changefreq, priority):

xml = BetterSeo::Sitemap::Generator.generate do |sitemap|
  sitemap.add_url(
    "https://example.com",
    lastmod: Date.today,
    changefreq: "daily",
    priority: 1.0
  )

  sitemap.add_url(
    "https://example.com/blog",
    lastmod: "2024-01-15",
    changefreq: "weekly",
    priority: 0.8
  )

  sitemap.add_url(
    "https://example.com/about",
    changefreq: "monthly",
    priority: 0.5
  )
end

Valid changefreq values: always, hourly, daily, weekly, monthly, yearly, never

Priority range: 0.0 to 1.0 (default: 0.5)

πŸ”— Method Chaining

The builder supports fluent method chaining:

xml = BetterSeo::Sitemap::Generator.generate do |sitemap|
  sitemap
    .add_url("https://example.com", priority: 1.0)
    .add_url("https://example.com/about", priority: 0.8)
    .add_url("https://example.com/contact", priority: 0.6)
end

Generate from Array

Create a sitemap from an array of URLs:

urls = [
  "https://example.com",
  "https://example.com/about",
  "https://example.com/contact",
  "https://example.com/blog"
]

xml = BetterSeo::Sitemap::Generator.generate_from(
  urls,
  changefreq: "weekly",
  priority: 0.7
)

Generate from Model Collection

Generate sitemaps dynamically from your Rails models:

# Simple example with Post model
xml = BetterSeo::Sitemap::Generator.generate_from_collection(
  Post.published,
  url: ->(post) { "https://example.com/posts/#{post.slug}" },
  lastmod: ->(post) { post.updated_at },
  changefreq: "weekly",
  priority: 0.8
)

Dynamic attributes with lambdas:

xml = BetterSeo::Sitemap::Generator.generate_from_collection(
  Article.all,
  url: ->(article) { "https://example.com/articles/#{article.slug}" },
  lastmod: ->(article) { article.updated_at },
  changefreq: ->(article) do
    article.featured? ? "daily" : "weekly"
  end,
  priority: ->(article) do
    article.featured? ? 0.9 : 0.6
  end
)

Rails Routes Integration

Generate sitemap from Rails routes:

# config/routes.rb
Rails.application.routes.draw do
  # Your routes...

  # Sitemap endpoint
  get '/sitemap.xml', to: 'sitemaps#index', defaults: { format: 'xml' }
end

# app/controllers/sitemaps_controller.rb
class SitemapsController < ApplicationController
  def index
    @sitemap_xml = generate_sitemap
    render xml: @sitemap_xml
  end

  private

  def generate_sitemap
    BetterSeo::Sitemap::Generator.generate do |sitemap|
      # Static pages
      sitemap.add_url(root_url, priority: 1.0, changefreq: "daily")
      sitemap.add_url(about_url, priority: 0.8, changefreq: "monthly")
      sitemap.add_url(contact_url, priority: 0.7, changefreq: "monthly")

      # Dynamic content from models
      Post.published.find_each do |post|
        sitemap.add_url(
          post_url(post),
          lastmod: post.updated_at,
          changefreq: "weekly",
          priority: 0.8
        )
      end

      Category.all.find_each do |category|
        sitemap.add_url(
          category_url(category),
          lastmod: category.updated_at,
          changefreq: "weekly",
          priority: 0.7
        )
      end
    end
  end
end

Write Sitemap to File

Save sitemap directly to a file:

# In a Rake task or script
BetterSeo::Sitemap::Generator.write_to_file('public/sitemap.xml') do |sitemap|
  sitemap.add_url("https://example.com", priority: 1.0)

  Post.published.find_each do |post|
    sitemap.add_url(
      "https://example.com/posts/#{post.slug}",
      lastmod: post.updated_at,
      changefreq: "weekly",
      priority: 0.8
    )
  end
end

# Returns the file path: "public/sitemap.xml"

Rake Task for Sitemap Generation

Create a Rake task to regenerate your sitemap:

# lib/tasks/sitemap.rake
namespace :sitemap do
  desc "Generate sitemap.xml"
  task generate: :environment do
    file_path = BetterSeo::Sitemap::Generator.write_to_file('public/sitemap.xml') do |sitemap|
      # Add static pages
      sitemap.add_url("#{ENV['SITE_URL']}", priority: 1.0, changefreq: "daily")
      sitemap.add_url("#{ENV['SITE_URL']}/about", priority: 0.8)
      sitemap.add_url("#{ENV['SITE_URL']}/contact", priority: 0.7)

      # Add dynamic content
      Post.published.find_each do |post|
        sitemap.add_url(
          "#{ENV['SITE_URL']}/posts/#{post.slug}",
          lastmod: post.updated_at,
          changefreq: "weekly",
          priority: 0.8
        )
      end
    end

    puts "Sitemap generated at #{file_path}"
  end
end

# Run with: rake sitemap:generate

Using the Builder Directly

For more control, use the Builder class directly:

builder = BetterSeo::Sitemap::Builder.new

# Add URLs
builder.add_url("https://example.com", priority: 1.0)
builder.add_url("https://example.com/about", priority: 0.8)

# Add multiple URLs at once
builder.add_urls(
  ["https://example.com/blog", "https://example.com/contact"],
  changefreq: "weekly",
  priority: 0.7
)

# Remove a URL
builder.remove_url("https://example.com/contact")

# Check size
puts builder.size # => 3

# Iterate over URLs
builder.each do |url|
  puts "#{url.loc} - Priority: #{url.priority}"
end

# Generate XML
xml = builder.to_xml

# Validate all URLs
builder.validate! # Raises ValidationError if any URL is invalid

# Clear all URLs
builder.clear

URL Entry Details

Work with individual URL entries:

entry = BetterSeo::Sitemap::UrlEntry.new(
  "https://example.com/page",
  lastmod: Date.today,
  changefreq: "daily",
  priority: 0.8
)

# Access attributes
entry.loc         # => "https://example.com/page"
entry.lastmod     # => "2024-01-15"
entry.changefreq  # => "daily"
entry.priority    # => 0.8

# Update attributes
entry.lastmod = Date.new(2024, 1, 20)
entry.changefreq = "weekly"
entry.priority = 0.9

# Generate XML for single entry
entry.to_xml
# <url>
#   <loc>https://example.com/page</loc>
#   <lastmod>2024-01-20</lastmod>
#   <changefreq>weekly</changefreq>
#   <priority>0.9</priority>
# </url>

# Convert to hash
entry.to_h
# {
#   loc: "https://example.com/page",
#   lastmod: "2024-01-20",
#   changefreq: "weekly",
#   priority: 0.9
# }

# Validate
entry.validate! # Raises ValidationError if invalid

Advanced: Multi-Model Sitemap

Combine multiple models in a single sitemap:

xml = BetterSeo::Sitemap::Generator.generate do |sitemap|
  # Homepage
  sitemap.add_url("https://example.com", priority: 1.0, changefreq: "daily")

  # Blog posts
  Post.published.find_each do |post|
    sitemap.add_url(
      "https://example.com/posts/#{post.slug}",
      lastmod: post.updated_at,
      changefreq: post.featured? ? "daily" : "weekly",
      priority: post.featured? ? 0.9 : 0.7
    )
  end

  # Categories
  Category.all.find_each do |category|
    sitemap.add_url(
      "https://example.com/categories/#{category.slug}",
      lastmod: category.updated_at,
      changefreq: "weekly",
      priority: 0.6
    )
  end

  # Static pages
  %w[about contact privacy terms].each do |page|
    sitemap.add_url(
      "https://example.com/#{page}",
      changefreq: "monthly",
      priority: 0.5
    )
  end
end

Validation

All URLs are automatically validated when generating:

# This will raise BetterSeo::ValidationError
xml = BetterSeo::Sitemap::Generator.generate do |sitemap|
  sitemap.add_url("")  # Error: Location is required
  sitemap.add_url("not-a-valid-url")  # Error: Invalid URL format
  sitemap.add_url("ftp://example.com")  # Error: Must be HTTP/HTTPS
end

# Validate manually
builder = BetterSeo::Sitemap::Builder.new
builder.add_url("https://example.com")
builder.validate!  # Returns true if all URLs valid

Complete Example: Production Sitemap

# app/services/sitemap_generator_service.rb
class SitemapGeneratorService
  def self.generate
    BetterSeo::Sitemap::Generator.write_to_file(Rails.root.join('public', 'sitemap.xml')) do |sitemap|
      add_static_pages(sitemap)
      add_blog_posts(sitemap)
      add_categories(sitemap)
      add_products(sitemap) if defined?(Product)
    end
  end

  private_class_method def self.add_static_pages(sitemap)
    sitemap.add_url(Rails.application.routes.url_helpers.root_url, priority: 1.0, changefreq: "daily")
    sitemap.add_url(Rails.application.routes.url_helpers.about_url, priority: 0.8, changefreq: "monthly")
    sitemap.add_url(Rails.application.routes.url_helpers.contact_url, priority: 0.7, changefreq: "monthly")
  end

  private_class_method def self.add_blog_posts(sitemap)
    Post.published.find_each do |post|
      sitemap.add_url(
        Rails.application.routes.url_helpers.post_url(post),
        lastmod: post.updated_at,
        changefreq: post.frequently_updated? ? "daily" : "weekly",
        priority: calculate_post_priority(post)
      )
    end
  end

  private_class_method def self.add_categories(sitemap)
    Category.all.find_each do |category|
      sitemap.add_url(
        Rails.application.routes.url_helpers.category_url(category),
        lastmod: category.posts.maximum(:updated_at),
        changefreq: "weekly",
        priority: 0.6
      )
    end
  end

  private_class_method def self.add_products(sitemap)
    Product.available.find_each do |product|
      sitemap.add_url(
        Rails.application.routes.url_helpers.product_url(product),
        lastmod: product.updated_at,
        changefreq: "daily",
        priority: product.featured? ? 0.95 : 0.75
      )
    end
  end

  private_class_method def self.calculate_post_priority(post)
    base_priority = 0.7
    base_priority += 0.2 if post.featured?
    base_priority += 0.1 if post.comments_count > 10
    [base_priority, 1.0].min
  end
end

# Call from rake task or controller:
# SitemapGeneratorService.generate

πŸ“Š Structured Data (JSON-LD)

BetterSeo provides comprehensive support for Schema.org structured data using JSON-LD format, helping search engines better understand your content.

πŸ’‘ Basic Usage

Create structured data objects and generate JSON-LD script tags:

# Create an Organization
org = BetterSeo::StructuredData::Organization.new
org.name("Acme Corporation")
org.url("https://www.acme.com")
org.logo("https://www.acme.com/logo.png")
org.description("Leading provider of innovative solutions")

# Generate JSON-LD script tag
org.to_script_tag
# <script type="application/ld+json">
# {
#   "@context": "https://schema.org",
#   "@type": "Organization",
#   "name": "Acme Corporation",
#   ...
# }
# </script>

πŸ“š Available Types

🏒 Organization - Company/organization information:

org = BetterSeo::StructuredData::Organization.new
org.name("Tech Innovations Inc")
org.url("https://techinnovations.com")
org.logo("https://techinnovations.com/logo.png")
org.description("Innovative technology solutions")
org.email("contact@techinnovations.com")
org.telephone("+1-555-0100")
org.address(
  street: "123 Tech Boulevard",
  city: "San Francisco",
  region: "CA",
  postal_code: "94105",
  country: "US"
)
org.same_as([
  "https://twitter.com/techinnovations",
  "https://linkedin.com/company/techinnovations"
])
org.founding_date("2015-03-20")

πŸ“° Article - Blog posts, news articles, content:

article = BetterSeo::StructuredData::Article.new
article.headline("The Future of Web Development")
article.description("An in-depth analysis of emerging trends")
article.image("https://example.com/article-image.jpg")
article.author("Jane Smith")  # Or use Person object
article.date_published("2024-01-15T09:00:00Z")
article.date_modified("2024-01-20T14:30:00Z")
article.url("https://example.com/articles/future-of-web-dev")
article.word_count(2500)
article.keywords(["Web Development", "Technology", "Trends"])
article.article_section("Technology")

πŸ‘€ Person - Author profiles, team members:

person = BetterSeo::StructuredData::Person.new
person.name("Dr. Jane Smith")
person.given_name("Jane")
person.family_name("Smith")
person.email("jane@example.com")
person.url("https://janesmith.dev")
person.image("https://janesmith.dev/profile.jpg")
person.job_title("Chief Technology Officer")
person.telephone("+1-555-0199")
person.same_as([
  "https://twitter.com/janesmith",
  "https://linkedin.com/in/janesmith"
])

πŸ›οΈ Product - E-commerce products:

product = BetterSeo::StructuredData::Product.new
product.name("Premium Wireless Headphones")
product.description("High-quality wireless headphones with noise cancellation")
product.image("https://example.com/headphones.jpg")
product.brand("AudioTech")
product.sku("HEADPHONES-WL-NC-2024")
product.offers(
  price: 299.99,
  price_currency: "USD",
  availability: "InStock",
  url: "https://example.com/products/headphones"
)
product.aggregate_rating(
  rating_value: 4.7,
  review_count: 342
)

🍞 BreadcrumbList - Navigation breadcrumbs:

breadcrumb = BetterSeo::StructuredData::BreadcrumbList.new
breadcrumb
  .add_item(name: "Home", url: "https://example.com")
  .add_item(name: "Electronics", url: "https://example.com/electronics")
  .add_item(name: "Headphones", url: "https://example.com/electronics/headphones")

Method Chaining

All structured data classes support fluent method chaining:

org = BetterSeo::StructuredData::Organization.new
  .name("Acme Corp")
  .url("https://acme.com")
  .logo("https://acme.com/logo.png")
  .description("Innovation at its finest")

Nested Structured Data

Combine multiple structured data objects:

# Create publisher organization
publisher = BetterSeo::StructuredData::Organization.new
publisher.name("Tech Publishing Co")
publisher.logo("https://techpub.com/logo.png")

# Create author person
author = BetterSeo::StructuredData::Person.new
author.name("Jane Smith")
author.email("jane@techpub.com")
author.url("https://janesmith.dev")

# Create article with nested data
article = BetterSeo::StructuredData::Article.new
article.headline("Introduction to Ruby on Rails")
article.description("A comprehensive guide for beginners")
article.image("https://example.com/rails-guide.jpg")
article.author(author)  # Nested Person
article.publisher(publisher)  # Nested Organization
article.date_published("2024-01-15")

# Generates nested JSON-LD automatically
article.to_script_tag

Using the Generator Helper

The Generator class provides convenient factory methods:

# Create with block
org = BetterSeo::StructuredData::Generator.organization do |o|
  o.name("Acme Corp")
  o.url("https://acme.com")
  o.logo("https://acme.com/logo.png")
end

article = BetterSeo::StructuredData::Generator.article do |a|
  a.headline("Amazing Article")
  a.author("John Doe")
  a.date_published("2024-01-15")
end

person = BetterSeo::StructuredData::Generator.person do |p|
  p.name("John Doe")
  p.email("john@example.com")
end

# Generate multiple script tags at once
tags = BetterSeo::StructuredData::Generator.generate_script_tags([org, article, person])
# Returns all three script tags joined with newlines

Rails Integration

Add structured data to your Rails views:

<%# app/views/articles/show.html.erb %>
<%
  author = BetterSeo::StructuredData::Generator.person do |p|
    p.name(@article.author.name)
    p.email(@article.author.email)
    p.url(@article.author.website)
  end

  article_sd = BetterSeo::StructuredData::Generator.article do |a|
    a.headline(@article.title)
    a.description(@article.excerpt)
    a.image(url_for(@article.cover_image))
    a.author(author)
    a.date_published(@article.published_at.iso8601)
    a.date_modified(@article.updated_at.iso8601)
    a.url(article_url(@article))
    a.word_count(@article.word_count)
    a.keywords(@article.tags.pluck(:name))
  end
%>

<%== article_sd.to_script_tag %>

Or in a helper:

# app/helpers/structured_data_helper.rb
module StructuredDataHelper
  def article_structured_data(article)
    author = BetterSeo::StructuredData::Generator.person do |p|
      p.name(article.author.name)
      p.url(author_url(article.author))
    end

    article_sd = BetterSeo::StructuredData::Generator.article do |a|
      a.headline(article.title)
      a.description(article.excerpt)
      a.author(author)
      a.date_published(article.published_at.iso8601)
      a.url(article_url(article))
    end

    article_sd.to_script_tag.html_safe
  end

  def organization_structured_data
    org = BetterSeo::StructuredData::Generator.organization do |o|
      o.name(Rails.application.config.site_name)
      o.url(root_url)
      o.logo(image_url('logo.png'))
      o.same_as([
        "https://twitter.com/yourcompany",
        "https://facebook.com/yourcompany"
      ])
    end

    org.to_script_tag.html_safe
  end
end

Then in your layout:

<%# app/views/layouts/application.html.erb %>
<head>
  ...
  <%= organization_structured_data %>
</head>

And in article views:

<%# app/views/articles/show.html.erb %>
<%= article_structured_data(@article) %>

Rails View Helpers (v0.8.0)

BetterSeo includes built-in Rails view helpers for easy structured data integration:

Generic Helper - structured_data_tag

<%# Create from type symbol with hash %>
<%= structured_data_tag(:organization,
  name: "Acme Corp",
  url: "https://acme.com",
  logo: "https://acme.com/logo.png"
) %>

<%# Create from type symbol with block %>
<%= structured_data_tag(:article) do |article|
  article.headline("My Article")
  article.author("John Doe")
  article.date_published("2024-01-15")
end %>

<%# Pass an existing object %>
<% org = BetterSeo::StructuredData::Organization.new(name: "Acme") %>
<%= structured_data_tag(org) %>

Type-Specific Helpers

Convenience methods for each structured data type:

<%# Organization %>
<%= organization_sd(
  name: "Acme Corp",
  url: "https://acme.com"
) %>

<%# Or with block %>
<%= organization_sd do |org|
  org.name("Acme Corp")
  org.url("https://acme.com")
  org.logo("https://acme.com/logo.png")
end %>

<%# Article %>
<%= article_sd(
  headline: @article.title,
  author: @article.author.name,
  date_published: @article.published_at.iso8601
) %>

<%# Person %>
<%= person_sd do |person|
  person.name("John Doe")
  person.email("john@example.com")
  person.job_title("Software Engineer")
end %>

<%# Product %>
<%= product_sd do |product|
  product.name(@product.name)
  product.brand(@product.brand)
  product.offers(
    price: @product.price,
    price_currency: "USD",
    availability: "InStock"
  )
  product.aggregate_rating(
    rating_value: @product.average_rating,
    review_count: @product.reviews_count
  )
end %>

<%# Breadcrumb List %>
<%= breadcrumb_list_sd do |breadcrumb|
  breadcrumb.add_item(name: "Home", url: root_url)
  breadcrumb.add_item(name: "Products", url: products_url)
  breadcrumb.add_item(name: @product.name, url: product_url(@product))
end %>

<%# Or from array %>
<% items = [
  { name: "Home", url: root_url },
  { name: "Products", url: products_url }
] %>
<%= breadcrumb_list_sd(items: items) %>

Multiple Tags Helper - structured_data_tags

Generate multiple script tags at once:

<% org = BetterSeo::StructuredData::Organization.new(name: "Acme") %>
<% person = BetterSeo::StructuredData::Person.new(name: "John") %>
<% article = BetterSeo::StructuredData::Article.new(headline: "Title") %>

<%= structured_data_tags([org, person, article]) %>

Complete Rails Example

<%# app/views/products/show.html.erb %>
<head>
  <%# Page SEO tags %>
  <%= seo_tags do |seo|
    seo.meta do |meta|
      meta.title @product.name
      meta.description @product.description
    end
    seo.og do |og|
      og.type "product"
      og.title @product.name
      og.image @product.image_url
    end
  end %>

  <%# Structured data %>
  <%= organization_sd do |org|
    org.name("My Shop")
    org.url(root_url)
  end %>

  <%= breadcrumb_list_sd do |bc|
    bc.add_item(name: "Home", url: root_url)
    bc.add_item(name: "Products", url: products_url)
    bc.add_item(name: @product.category, url: category_url(@product.category))
    bc.add_item(name: @product.name, url: product_url(@product))
  end %>

  <%= product_sd do |product|
    product.name(@product.name)
    product.description(@product.description)
    product.image(@product.image_url)
    product.brand(@product.brand)
    product.sku(@product.sku)
    product.offers(
      price: @product.price,
      price_currency: "USD",
      availability: @product.in_stock? ? "InStock" : "OutOfStock",
      url: product_url(@product)
    )
    if @product.reviews.any?
      product.aggregate_rating(
        rating_value: @product.average_rating,
        review_count: @product.reviews_count,
        best_rating: 5
      )
    end
  end %>
</head>

Helper Method Reference

Helper Description
structured_data_tag(type, **props, &block) Generic helper for any type
organization_sd(**props, &block) Organization structured data
article_sd(**props, &block) Article structured data
person_sd(**props, &block) Person structured data
product_sd(**props, &block) Product structured data
breadcrumb_list_sd(items:, &block) Breadcrumb list structured data
structured_data_tags(array) Multiple structured data tags

All helpers support both hash configuration and block-based DSL for maximum flexibility.

Complete Example

# Create complete structured data for a blog article
publisher = BetterSeo::StructuredData::Generator.organization do |o|
  o.name("Tech Blog Publishing")
  o.url("https://techblog.com")
  o.logo("https://techblog.com/logo.png")
end

author = BetterSeo::StructuredData::Generator.person do |p|
  p.name("Dr. Sarah Johnson")
  p.email("sarah@techblog.com")
  p.url("https://sarahjohnson.dev")
  p.job_title("Senior Technology Writer")
end

article = BetterSeo::StructuredData::Generator.article do |a|
  a.headline("The Complete Guide to Ruby on Rails in 2024")
  a.description("Everything you need to know about Rails")
  a.image([
    "https://techblog.com/images/rails-2024-1.jpg",
    "https://techblog.com/images/rails-2024-2.jpg"
  ])
  a.author(author)
  a.publisher(publisher)
  a.date_published("2024-01-15T09:00:00Z")
  a.date_modified("2024-01-20T15:30:00Z")
  a.url("https://techblog.com/rails-complete-guide-2024")
  a.word_count(3500)
  a.keywords(["Ruby on Rails", "Web Development", "2024"])
  a.article_section("Programming")
end

# Get JSON-LD
json_ld = article.to_json

# Get script tag for HTML
script_tag = article.to_script_tag

# Or generate all at once
all_tags = BetterSeo::StructuredData::Generator.generate_script_tags([
  publisher,
  author,
  article
])

Benefits

  • SEO Enhancement: Help search engines understand your content better
  • Rich Snippets: Enable rich results in search results (ratings, images, etc.)
  • Type Safety: Fluent API with method chaining
  • Nested Data: Automatic handling of complex relationships
  • Standards Compliant: Follows Schema.org specifications
  • Easy Integration: Works seamlessly with Rails views and helpers

πŸ› οΈ Advanced Tools

🍞 Breadcrumbs Generator

Generate HTML breadcrumb navigation with Schema.org structured data support.

πŸ’‘ Basic Usage

generator = BetterSeo::Generators::BreadcrumbsGenerator.new
generator.add_item("Home", "/")
generator.add_item("Products", "/products")
generator.add_item("Laptops", "/products/laptops")
generator.add_item("MacBook Pro", nil) # Current page (no link)

# Generate HTML breadcrumbs
html = generator.to_html
# <nav class="breadcrumb" aria-label="breadcrumb">
#   <ol class="breadcrumb">
#     <li class="breadcrumb-item">
#       <a href="/">Home</a>
#     </li>
#     ...
#   </ol>
# </nav>

# Generate JSON-LD structured data
json_ld = generator.to_json_ld
script_tag = generator.to_script_tag

With Schema.org Markup

# Generate breadcrumbs with microdata
html = generator.to_html(schema: true)
# Includes itemscope, itemtype, itemprop attributes for rich snippets

Custom Styling

html = generator.to_html(
  nav_class: "my-breadcrumb-nav",
  list_class: "my-breadcrumb-list"
)

Rails Integration

<!-- app/views/layouts/application.html.erb -->
<%
  breadcrumbs = BetterSeo::Generators::BreadcrumbsGenerator.new
  breadcrumbs.add_item("Home", root_path)
  breadcrumbs.add_item("Blog", blog_path)
  breadcrumbs.add_item(@post.title, nil)
%>

<%= raw breadcrumbs.to_html(schema: true) %>
<%= raw breadcrumbs.to_script_tag %>

Multiple Items at Once

generator.add_items([
  { name: "Home", url: "/" },
  { name: "Products", url: "/products" },
  { name: "Laptops", url: "/products/laptops" }
])

⚑ AMP Generator

Generate Accelerated Mobile Pages (AMP) HTML components.

πŸ’‘ Basic Usage

amp = BetterSeo::Generators::AmpGenerator.new(
  canonical_url: "https://example.com/article",
  title: "My Article Title",
  description: "Article description",
  image: "https://example.com/image.jpg"
)

# AMP boilerplate CSS
boilerplate = amp.to_boilerplate

# AMP runtime script
amp_script = amp.to_amp_script_tag
# <script async src="https://cdn.ampproject.org/v0.js"></script>

# Meta tags
meta_tags = amp.to_meta_tags
# Includes canonical, og:title, og:description, og:image

Complete AMP Page

<!doctype html>
<html ⚑>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">

  <%= raw amp.to_meta_tags %>
  <%= raw amp.to_amp_script_tag %>
  <%= raw amp.to_boilerplate %>

  <% custom_css = "body { font-family: Arial; } h1 { color: #333; }" %>
  <%= raw amp.to_custom_css(custom_css) %>
</head>
<body>
  <!-- Your AMP content -->
</body>
</html>

With Structured Data

article_data = {
  "@context" => "https://schema.org",
  "@type" => "Article",
  "headline" => "My Article",
  "author" => { "@type" => "Person", "name" => "John Doe" }
}

amp.structured_data = article_data
script = amp.to_structured_data
# <script type="application/ld+json">
# { "@context": "https://schema.org", "@type": "Article", ... }
# </script>

Rails Controller Integration

class ArticlesController < ApplicationController
  def amp
    @article = Article.find(params[:id])

    @amp = BetterSeo::Generators::AmpGenerator.new(
      canonical_url: article_url(@article),
      title: @article.title,
      description: @article.excerpt,
      image: @article.featured_image_url
    )

    render layout: 'amp'
  end
end

πŸ”— Canonical URL Manager

Manage and normalize canonical URLs with validation.

πŸ’‘ Basic Usage

manager = BetterSeo::Generators::CanonicalUrlManager.new("https://example.com/page")

# Generate HTML link tag
html = manager.to_html
# <link rel="canonical" href="https://example.com/page">

# Generate HTTP header
header = manager.to_http_header
# <https://example.com/page>; rel="canonical"

# Validate URL
manager.validate! # Raises ValidationError if invalid

URL Normalization

manager = BetterSeo::Generators::CanonicalUrlManager.new

# Removes trailing slashes
manager.url = "https://example.com/page/"
manager.url # => "https://example.com/page"

# Removes fragment identifiers
manager.url = "https://example.com/page#section"
manager.url # => "https://example.com/page"

# Optional: Remove query parameters
manager.remove_query_params = true
manager.url = "https://example.com/page?utm_source=twitter&ref=123"
manager.url # => "https://example.com/page"

# Optional: Lowercase URL
manager.lowercase = true
manager.url = "https://Example.com/Page"
manager.url # => "https://example.com/page"

Rails Integration

# Controller
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])

    canonical = BetterSeo::Generators::CanonicalUrlManager.new
    canonical.remove_query_params = true
    canonical.url = article_url(@article)

    response.headers['Link'] = canonical.to_http_header
    @canonical_tag = canonical.to_html
  end
end

# View (app/views/articles/show.html.erb)
<head>
  <%= raw @canonical_tag %>
</head>

Advanced Normalization

manager = BetterSeo::Generators::CanonicalUrlManager.new
manager.remove_query_params = true
manager.lowercase = true
manager.url = "https://Example.com/Page/?utm_source=google#section"

manager.url # => "https://example.com/page"
manager.validate! # => true

Error Handling

manager = BetterSeo::Generators::CanonicalUrlManager.new

# Relative URLs not allowed
manager.url = "/page"
manager.validate! # Raises: "Canonical URL must be absolute: /page"

# Invalid URL format
manager.url = "not a url"
manager.validate! # Raises: "Invalid URL format: not a url"

# URL is required
manager.url = nil
manager.validate! # Raises: "URL is required"

πŸ€– Robots.txt Generator

Generate robots.txt files to control search engine crawler access.

πŸ’‘ Basic Usage

robots = BetterSeo::Generators::RobotsTxtGenerator.new

# Allow all bots except specific paths
robots.add_rule("*", disallow: ["/admin", "/api/private", "/tmp"])

# Add sitemap
robots.add_sitemap("https://example.com/sitemap.xml")

# Generate robots.txt content
text = robots.to_text
# User-agent: *
# Disallow: /admin
# Disallow: /api/private
# Disallow: /tmp
#
# Sitemap: https://example.com/sitemap.xml

Multiple User Agents

robots = BetterSeo::Generators::RobotsTxtGenerator.new

# Rules for all bots
robots.add_rule("*", allow: "/", disallow: "/admin")

# Specific rules for Googlebot
robots.add_rule("Googlebot", allow: "/api/public", disallow: "/temp")

# Crawl delay for aggressive bots
robots.add_rule("Bingbot", disallow: "/admin")
robots.set_crawl_delay("Bingbot", 2)

text = robots.to_text

Write to File

robots = BetterSeo::Generators::RobotsTxtGenerator.new
robots.add_rule("*", disallow: ["/admin", "/private"])
robots.add_sitemap("https://example.com/sitemap.xml")

# Write to public directory
robots.write_to_file(Rails.root.join('public', 'robots.txt'))

Rails Controller Integration

# app/controllers/robots_controller.rb
class RobotsController < ApplicationController
  def index
    robots = BetterSeo::Generators::RobotsTxtGenerator.new

    if Rails.env.production?
      robots.add_rule("*", disallow: ["/admin", "/api/private"])
      robots.add_sitemap("#{request.base_url}/sitemap.xml")
    else
      # Block all bots in non-production
      robots.add_rule("*", disallow: "/")
    end

    render plain: robots.to_text, content_type: "text/plain"
  end
end

# config/routes.rb
get '/robots.txt', to: 'robots#index'

βœ… SEO Validator & Recommendations

Validate and score your pages for SEO best practices with AI-powered recommendations.

πŸ’‘ Basic Usage

validator = BetterSeo::Validators::SeoValidator.new

# Check individual elements
title_result = validator.check_title("My Awesome SEO-Friendly Page Title")
# => { valid: true, score: 100, message: "Title length is optimal", length: 35 }

desc_result = validator.check_description("This is a comprehensive meta description...")
# => { valid: true, score: 100, message: "Description length is optimal", length: 145 }

Validate Complete Page

validator = BetterSeo::Validators::SeoValidator.new

html = <<~HTML
  <!DOCTYPE html>
  <html>
  <head>
    <title>Best Ruby SEO Gem for Rails Applications</title>
    <meta name="description" content="BetterSeo provides comprehensive SEO tools for Ruby on Rails including meta tags, structured data, sitemaps, and validators.">
  </head>
  <body>
    <h1>BetterSeo: Complete SEO Solution</h1>
    <img src="logo.png" alt="BetterSeo Logo">
    <h2>Features</h2>
    <h2>Getting Started</h2>
  </body>
  </html>
HTML

result = validator.validate_page(html)
# => {
#   overall_score: 95,
#   title: { valid: true, score: 100, ... },
#   description: { valid: true, score: 100, ... },
#   headings: { valid: true, score: 100, h1_count: 1, ... },
#   images: { valid: true, score: 100, ... },
#   issues: []
# }

Generate SEO Report

validator = BetterSeo::Validators::SeoValidator.new
result = validator.validate_page(html)

report = validator.generate_report(result)
puts report
# ============================================================
# SEO Validation Report
# ============================================================
#
# Overall Score: 95/100
#
# Title:
#   Status: βœ“
#   Score: 100/100
#   Length: 45 chars
#   Message: Title length is optimal
#
# Description:
#   Status: βœ“
#   Score: 100/100
#   ...

Rails Integration

# app/services/seo_validator_service.rb
class SeoValidatorService
  def self.validate_page_seo(url)
    html = fetch_page_html(url)
    validator = BetterSeo::Validators::SeoValidator.new
    validator.validate_page(html)
  end

  def self.audit_site
    pages = Page.published.pluck(:url)
    validator = BetterSeo::Validators::SeoValidator.new

    results = pages.map do |url|
      html = fetch_page_html(url)
      validation = validator.validate_page(html)
      { url: url, score: validation[:overall_score], issues: validation[:issues] }
    end

    results.sort_by { |r| r[:score] }
  end
end

Validation Rules

  • Title: 30-60 characters optimal
  • Description: 120-160 characters optimal
  • Headings: Exactly one H1 tag required
  • Images: All images must have alt text
  • Overall Score: Weighted average (Title: 30%, Description: 30%, Headings: 20%, Images: 20%)

βš™οΈ Configuration

πŸ”§ Global Configuration

BetterSeo.configure do |config|
  # Site-wide settings
  config.site_name = "My Site"
  config.default_locale = :en
  config.available_locales = [:en, :it, :fr, :de, :es]

  # Meta tags configuration
  config.meta_tags.default_title = "Default Title"
  config.meta_tags.title_separator = " | "
  config.meta_tags.append_site_name = true
  config.meta_tags.default_description = "Default description"
  config.meta_tags.default_keywords = ["keyword1", "keyword2"]
  config.meta_tags.default_author = "Your Name"

  # Open Graph configuration
  config.open_graph.enabled = true
  config.open_graph.site_name = "My Site"
  config.open_graph.default_type = "website"
  config.open_graph.default_locale = "en_US"
  config.open_graph.default_image.url = "https://example.com/default-og.jpg"
  config.open_graph.default_image.width = 1200
  config.open_graph.default_image.height = 630

  # Twitter Cards configuration
  config.twitter.enabled = true
  config.twitter.site = "@mysite"
  config.twitter.creator = "@myhandle"
  config.twitter.card_type = "summary_large_image"

  # Structured Data configuration
  config.structured_data.enabled = true
  config.structured_data.organization = {
    name: "My Organization",
    url: "https://example.com"
  }

  # Sitemap configuration (planned)
  config.sitemap.enabled = false
  config.sitemap.host = "https://example.com"
  config.sitemap.output_path = "public/sitemap.xml"

  # Robots.txt configuration (planned)
  config.robots.enabled = false
  config.robots.output_path = "public/robots.txt"

  # Image optimization configuration (planned)
  config.images.enabled = false
  config.images.webp.enabled = true
  config.images.webp.quality = 80
end

βœ… Checking Configuration

# Access configuration
BetterSeo.configuration.site_name
# => "My Site"

# Check if features are enabled
BetterSeo.enabled?(:open_graph)
# => true

BetterSeo.enabled?(:sitemap)
# => false

# Reset configuration (useful for testing)
BetterSeo.reset_configuration!

πŸ’» Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.

# Install dependencies
bundle install

# Run tests
bundle exec rspec

# Run tests with coverage
bundle exec rspec --format documentation

# Check code coverage
open coverage/index.html

πŸ§ͺ Running Tests

The gem uses RSpec with SimpleCov for test coverage.

# Run all tests
bundle exec rspec

# Run specific test file
bundle exec rspec spec/dsl/meta_tags_spec.rb

# Run with documentation format
bundle exec rspec --format documentation

πŸ“Š Test Statistics:

  • βœ… 899 tests passing
  • πŸ“ˆ 94.3% code coverage
  • πŸ—οΈ Complete test suite for all modules
  • πŸ”’ Production-ready quality

πŸ—οΈ Architecture

lib/better_seo/
β”œβ”€β”€ version.rb                          # Gem version
β”œβ”€β”€ errors.rb                           # Custom error classes
β”œβ”€β”€ configuration.rb                    # Main configuration class
β”œβ”€β”€ dsl/
β”‚   β”œβ”€β”€ base.rb                        # Base DSL builder class
β”‚   β”œβ”€β”€ meta_tags.rb                   # Meta tags DSL
β”‚   β”œβ”€β”€ open_graph.rb                  # Open Graph DSL
β”‚   └── twitter_cards.rb               # Twitter Cards DSL
β”œβ”€β”€ generators/
β”‚   β”œβ”€β”€ meta_tags_generator.rb         # HTML meta tags generator
β”‚   β”œβ”€β”€ open_graph_generator.rb        # Open Graph tags generator
β”‚   └── twitter_cards_generator.rb     # Twitter Cards generator
β”œβ”€β”€ rails/
β”‚   └── helpers/
β”‚       └── seo_helper.rb              # Rails view helpers
└── (planned)
    β”œβ”€β”€ validators/                    # SEO validators
    └── sitemap/                       # Sitemap generation

🀝 Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/better_seo.

  1. 🍴 Fork it
  2. 🌿 Create your feature branch (git checkout -b feature/my-new-feature)
  3. βœ… Write tests (we maintain high test coverage)
  4. πŸ’Ύ Commit your changes (git commit -am 'Add some feature')
  5. πŸ“€ Push to the branch (git push origin feature/my-new-feature)
  6. πŸŽ‰ Create new Pull Request

πŸ“„ License

The gem is available as open source under the terms of the MIT License.


πŸ“œ Code of Conduct

Everyone interacting in the BetterSeo project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.


🌟 Why BetterSeo?

  • βœ… Production-Ready: 899 tests, 94.3% coverage
  • πŸš€ Complete Solution: Everything you need for SEO in one gem
  • 🎯 Rails-First: Designed specifically for Rails applications
  • πŸ”§ Flexible: Use as much or as little as you need
  • πŸ“š Well-Documented: Comprehensive examples and guides
  • 🌍 i18n Support: Built-in internationalization
  • πŸ”’ Secure: Automatic XSS protection
  • πŸ’Ž Modern Ruby: Built with Ruby 3.0+ and Rails 6.1+

Made with ❀️ by Alessio Bussolari

Report Bug Β· Request Feature Β· Documentation