better_seo 0.14.0 → 1.0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +30 -0
  3. data/.rubocop_todo.yml +360 -0
  4. data/CHANGELOG.md +105 -0
  5. data/README.md +280 -180
  6. data/docs/00_OVERVIEW.md +472 -0
  7. data/docs/01_CORE_AND_CONFIGURATION.md +913 -0
  8. data/docs/02_META_TAGS_AND_OPEN_GRAPH.md +251 -0
  9. data/docs/03_STRUCTURED_DATA.md +140 -0
  10. data/docs/04_SITEMAP_AND_ROBOTS.md +131 -0
  11. data/docs/05_RAILS_INTEGRATION.md +175 -0
  12. data/docs/06_I18N_PAGE_GENERATOR.md +233 -0
  13. data/docs/07_IMAGE_OPTIMIZATION.md +260 -0
  14. data/docs/DEPENDENCIES.md +383 -0
  15. data/docs/README.md +180 -0
  16. data/docs/TESTING_STRATEGY.md +663 -0
  17. data/lib/better_seo/analytics/google_analytics.rb +83 -0
  18. data/lib/better_seo/analytics/google_tag_manager.rb +74 -0
  19. data/lib/better_seo/configuration.rb +322 -0
  20. data/lib/better_seo/dsl/base.rb +86 -0
  21. data/lib/better_seo/dsl/meta_tags.rb +55 -0
  22. data/lib/better_seo/dsl/open_graph.rb +105 -0
  23. data/lib/better_seo/dsl/twitter_cards.rb +129 -0
  24. data/lib/better_seo/errors.rb +31 -0
  25. data/lib/better_seo/generators/amp_generator.rb +77 -0
  26. data/lib/better_seo/generators/breadcrumbs_generator.rb +127 -0
  27. data/lib/better_seo/generators/canonical_url_manager.rb +100 -0
  28. data/lib/better_seo/generators/meta_tags_generator.rb +101 -0
  29. data/lib/better_seo/generators/open_graph_generator.rb +110 -0
  30. data/lib/better_seo/generators/robots_txt_generator.rb +96 -0
  31. data/lib/better_seo/generators/twitter_cards_generator.rb +102 -0
  32. data/lib/better_seo/image/optimizer.rb +145 -0
  33. data/lib/better_seo/rails/helpers/controller_helpers.rb +120 -0
  34. data/lib/better_seo/rails/helpers/seo_helper.rb +172 -0
  35. data/lib/better_seo/rails/helpers/structured_data_helper.rb +123 -0
  36. data/lib/better_seo/rails/model_helpers.rb +62 -0
  37. data/lib/better_seo/rails/railtie.rb +22 -0
  38. data/lib/better_seo/sitemap/builder.rb +65 -0
  39. data/lib/better_seo/sitemap/generator.rb +57 -0
  40. data/lib/better_seo/sitemap/sitemap_index.rb +73 -0
  41. data/lib/better_seo/sitemap/url_entry.rb +155 -0
  42. data/lib/better_seo/structured_data/article.rb +55 -0
  43. data/lib/better_seo/structured_data/base.rb +74 -0
  44. data/lib/better_seo/structured_data/breadcrumb_list.rb +49 -0
  45. data/lib/better_seo/structured_data/event.rb +205 -0
  46. data/lib/better_seo/structured_data/faq_page.rb +55 -0
  47. data/lib/better_seo/structured_data/generator.rb +75 -0
  48. data/lib/better_seo/structured_data/how_to.rb +96 -0
  49. data/lib/better_seo/structured_data/local_business.rb +94 -0
  50. data/lib/better_seo/structured_data/organization.rb +67 -0
  51. data/lib/better_seo/structured_data/person.rb +51 -0
  52. data/lib/better_seo/structured_data/product.rb +123 -0
  53. data/lib/better_seo/structured_data/recipe.rb +134 -0
  54. data/lib/better_seo/validators/seo_recommendations.rb +165 -0
  55. data/lib/better_seo/validators/seo_validator.rb +205 -0
  56. data/lib/better_seo/version.rb +1 -1
  57. data/lib/better_seo.rb +1 -0
  58. data/lib/generators/better_seo/install_generator.rb +21 -0
  59. data/lib/generators/better_seo/templates/README +29 -0
  60. data/lib/generators/better_seo/templates/better_seo.rb +40 -0
  61. metadata +57 -2
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module Rails
5
+ module Helpers
6
+ module SeoHelper
7
+ # Generate meta tags from configuration or block
8
+ def seo_meta_tags(config = nil, &block)
9
+ if block_given?
10
+ builder = BetterSeo::DSL::MetaTags.new
11
+ builder.evaluate(&block)
12
+ config = builder.build
13
+ end
14
+
15
+ generator = BetterSeo::Generators::MetaTagsGenerator.new(config || {})
16
+ raw(generator.generate)
17
+ end
18
+
19
+ # Generate Open Graph tags from configuration or block
20
+ def seo_open_graph_tags(config = nil, &block)
21
+ if block_given?
22
+ builder = BetterSeo::DSL::OpenGraph.new
23
+ builder.evaluate(&block)
24
+ config = builder.build
25
+ end
26
+
27
+ generator = BetterSeo::Generators::OpenGraphGenerator.new(config || {})
28
+ raw(generator.generate)
29
+ end
30
+
31
+ # Generate Twitter Card tags from configuration or block
32
+ def seo_twitter_tags(config = nil, &block)
33
+ if block_given?
34
+ builder = BetterSeo::DSL::TwitterCards.new
35
+ builder.evaluate(&block)
36
+ config = builder.build
37
+ end
38
+
39
+ generator = BetterSeo::Generators::TwitterCardsGenerator.new(config || {})
40
+ raw(generator.generate)
41
+ end
42
+
43
+ # Generate all SEO tags (meta + og + twitter) from configuration or block
44
+ def seo_tags(config = nil)
45
+ if block_given?
46
+ context = SeoTagsContext.new
47
+ yield(context)
48
+ config = context.to_h
49
+ end
50
+
51
+ config ||= {}
52
+
53
+ # Merge with defaults from BetterSeo configuration
54
+ merged_config = build_seo_config(config)
55
+
56
+ tags = []
57
+
58
+ tags << seo_meta_tags(merged_config[:meta]) if merged_config[:meta]
59
+
60
+ if merged_config[:og] && BetterSeo.configuration.open_graph_enabled?
61
+ tags << seo_open_graph_tags(merged_config[:og])
62
+ end
63
+
64
+ if merged_config[:twitter] && BetterSeo.configuration.twitter_enabled?
65
+ tags << seo_twitter_tags(merged_config[:twitter])
66
+ end
67
+
68
+ raw(tags.compact.join("\n"))
69
+ end
70
+
71
+ private
72
+
73
+ # Build SEO configuration by merging controller data with defaults
74
+ def build_seo_config(controller_data)
75
+ config = BetterSeo.configuration
76
+ result = {}
77
+
78
+ # Meta tags
79
+ meta = {}
80
+
81
+ # Start with controller data
82
+ meta = controller_data[:meta].dup if controller_data[:meta]
83
+
84
+ # Use default title if not set
85
+ meta[:title] ||= config.meta_tags.default_title
86
+
87
+ # Apply site_name to title if configured
88
+ if meta[:title] && config.meta_tags.append_site_name && config.site_name
89
+ separator = config.meta_tags.title_separator || " | "
90
+ meta[:title] = "#{meta[:title]}#{separator}#{config.site_name}"
91
+ end
92
+
93
+ # Use defaults for missing values
94
+ meta[:description] ||= config.meta_tags.default_description
95
+ meta[:keywords] ||= config.meta_tags.default_keywords
96
+ meta[:author] ||= config.meta_tags.default_author
97
+
98
+ result[:meta] = meta if meta.any?
99
+
100
+ # Open Graph tags
101
+ if config.open_graph_enabled?
102
+ og = controller_data[:og]&.dup || {}
103
+
104
+ # Use meta title/description as fallbacks
105
+ og[:title] ||= meta[:title] || config.meta_tags.default_title
106
+ og[:description] ||= meta[:description]
107
+ og[:type] ||= config.open_graph.default_type
108
+ og[:locale] ||= config.open_graph.default_locale
109
+ og[:site_name] ||= config.open_graph.site_name || config.site_name
110
+
111
+ # Default image
112
+ if og[:image].nil? && config.open_graph.default_image&.url
113
+ og[:image] = config.open_graph.default_image.url
114
+ og[:image_width] = config.open_graph.default_image.width
115
+ og[:image_height] = config.open_graph.default_image.height
116
+ og[:image_alt] = config.open_graph.default_image.alt if config.open_graph.default_image.respond_to?(:alt)
117
+ end
118
+
119
+ result[:og] = og if og.any?
120
+ end
121
+
122
+ # Twitter Card tags
123
+ if config.twitter_enabled?
124
+ twitter = controller_data[:twitter]&.dup || {}
125
+
126
+ twitter[:card] ||= config.twitter.card_type
127
+ twitter[:site] ||= config.twitter.site
128
+ twitter[:creator] ||= config.twitter.creator
129
+ twitter[:title] ||= meta[:title] || config.meta_tags.default_title
130
+ twitter[:description] ||= meta[:description]
131
+
132
+ # Use OG image as fallback
133
+ twitter[:image] ||= result.dig(:og, :image)
134
+
135
+ result[:twitter] = twitter if twitter.any?
136
+ end
137
+
138
+ result
139
+ end
140
+
141
+ # Context class for building all SEO tags with block syntax
142
+ class SeoTagsContext
143
+ def initialize
144
+ @config = {}
145
+ end
146
+
147
+ def meta(&block)
148
+ builder = BetterSeo::DSL::MetaTags.new
149
+ builder.evaluate(&block)
150
+ @config[:meta] = builder.build
151
+ end
152
+
153
+ def og(&block)
154
+ builder = BetterSeo::DSL::OpenGraph.new
155
+ builder.evaluate(&block)
156
+ @config[:og] = builder.build
157
+ end
158
+
159
+ def twitter(&block)
160
+ builder = BetterSeo::DSL::TwitterCards.new
161
+ builder.evaluate(&block)
162
+ @config[:twitter] = builder.build
163
+ end
164
+
165
+ def to_h
166
+ @config
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module Rails
5
+ module Helpers
6
+ module StructuredDataHelper
7
+ TYPE_MAPPING = {
8
+ organization: BetterSeo::StructuredData::Organization,
9
+ article: BetterSeo::StructuredData::Article,
10
+ person: BetterSeo::StructuredData::Person,
11
+ product: BetterSeo::StructuredData::Product,
12
+ breadcrumb_list: BetterSeo::StructuredData::BreadcrumbList,
13
+ local_business: BetterSeo::StructuredData::LocalBusiness,
14
+ event: BetterSeo::StructuredData::Event,
15
+ faq_page: BetterSeo::StructuredData::FAQPage,
16
+ how_to: BetterSeo::StructuredData::HowTo,
17
+ recipe: BetterSeo::StructuredData::Recipe
18
+ }.freeze
19
+
20
+ def structured_data_tag(type_or_object, **properties, &block)
21
+ sd_object = if type_or_object.is_a?(Symbol)
22
+ create_structured_data(type_or_object, properties, &block)
23
+ else
24
+ type_or_object
25
+ end
26
+
27
+ raw(sd_object.to_script_tag)
28
+ end
29
+
30
+ def structured_data_tags(objects = nil)
31
+ array = block_given? ? yield : objects
32
+ return "" if array.nil? || array.empty?
33
+
34
+ tags = array.map(&:to_script_tag).join("\n\n")
35
+ raw(tags)
36
+ end
37
+
38
+ def organization_sd(**properties, &block)
39
+ structured_data_tag(:organization, **properties, &block)
40
+ end
41
+
42
+ def article_sd(**properties, &block)
43
+ structured_data_tag(:article, **properties, &block)
44
+ end
45
+
46
+ def person_sd(**properties, &block)
47
+ structured_data_tag(:person, **properties, &block)
48
+ end
49
+
50
+ def product_sd(**properties, &block)
51
+ structured_data_tag(:product, **properties, &block)
52
+ end
53
+
54
+ def breadcrumb_list_sd(items: nil, &block)
55
+ if block_given?
56
+ structured_data_tag(:breadcrumb_list, &block)
57
+ elsif items
58
+ structured_data_tag(:breadcrumb_list) do |breadcrumb|
59
+ breadcrumb.add_items(items)
60
+ end
61
+ else
62
+ structured_data_tag(:breadcrumb_list)
63
+ end
64
+ end
65
+
66
+ def local_business_sd(**properties, &block)
67
+ structured_data_tag(:local_business, **properties, &block)
68
+ end
69
+
70
+ def event_sd(**properties, &block)
71
+ structured_data_tag(:event, **properties, &block)
72
+ end
73
+
74
+ def faq_page_sd(questions: nil, &block)
75
+ if block_given?
76
+ structured_data_tag(:faq_page, &block)
77
+ elsif questions
78
+ structured_data_tag(:faq_page) do |faq|
79
+ faq.add_questions(questions)
80
+ end
81
+ else
82
+ structured_data_tag(:faq_page)
83
+ end
84
+ end
85
+
86
+ def how_to_sd(steps: nil, &block)
87
+ if block_given?
88
+ structured_data_tag(:how_to, &block)
89
+ elsif steps
90
+ structured_data_tag(:how_to) do |how_to|
91
+ how_to.add_steps(steps)
92
+ end
93
+ else
94
+ structured_data_tag(:how_to)
95
+ end
96
+ end
97
+
98
+ def recipe_sd(ingredients: nil, instructions: nil, &block)
99
+ if block_given?
100
+ structured_data_tag(:recipe, &block)
101
+ else
102
+ structured_data_tag(:recipe) do |recipe|
103
+ recipe.add_ingredients(ingredients) if ingredients
104
+ recipe.add_instructions(instructions) if instructions
105
+ yield(recipe) if block_given?
106
+ end
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def create_structured_data(type, properties)
113
+ klass = TYPE_MAPPING[type]
114
+ raise ArgumentError, "Unknown structured data type: #{type}" unless klass
115
+
116
+ sd_object = klass.new(**properties)
117
+ yield(sd_object) if block_given?
118
+ sd_object
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module Rails
5
+ module ModelHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ # Define SEO attribute mappings
10
+ # @example
11
+ # seo_attributes(
12
+ # title: :post_title,
13
+ # description: :excerpt,
14
+ # keywords: -> { tags.map(&:name).join(", ") },
15
+ # image: :featured_image_url
16
+ # )
17
+ def seo_attributes(mappings = {})
18
+ @_seo_attribute_mappings = mappings
19
+
20
+ # Define accessor methods for each SEO attribute
21
+ mappings.each do |seo_attr, source|
22
+ define_method("seo_#{seo_attr}") do
23
+ evaluate_seo_attribute(source)
24
+ end
25
+ end
26
+ end
27
+
28
+ def seo_attribute_mappings
29
+ @_seo_attribute_mappings || {}
30
+ end
31
+ end
32
+
33
+ # Convert model to SEO hash
34
+ def to_seo_hash
35
+ mappings = self.class.seo_attribute_mappings
36
+ return {} if mappings.empty?
37
+
38
+ hash = {}
39
+
40
+ mappings.each_key do |seo_attr|
41
+ value = send("seo_#{seo_attr}")
42
+ hash[seo_attr] = value if value
43
+ end
44
+
45
+ hash
46
+ end
47
+
48
+ private
49
+
50
+ def evaluate_seo_attribute(source)
51
+ case source
52
+ when Proc
53
+ instance_exec(&source)
54
+ when Symbol
55
+ send(source) if respond_to?(source)
56
+ else
57
+ source
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module BetterSeo
6
+ module Rails
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "better_seo.controller_helpers" do
9
+ ActiveSupport.on_load(:action_controller) do
10
+ include BetterSeo::Rails::Helpers::ControllerHelpers
11
+ end
12
+ end
13
+
14
+ initializer "better_seo.view_helpers" do
15
+ ActiveSupport.on_load(:action_view) do
16
+ include BetterSeo::Rails::Helpers::SeoHelper
17
+ include BetterSeo::Rails::Helpers::StructuredDataHelper
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module Sitemap
5
+ class Builder
6
+ include Enumerable
7
+
8
+ attr_reader :urls
9
+
10
+ def initialize(urls: [])
11
+ @urls = urls
12
+ end
13
+
14
+ def add_url(location, lastmod: nil, changefreq: "weekly", priority: 0.5)
15
+ @urls << UrlEntry.new(location, lastmod: lastmod, changefreq: changefreq, priority: priority)
16
+ self
17
+ end
18
+
19
+ def add_urls(locations, lastmod: nil, changefreq: "weekly", priority: 0.5)
20
+ locations.each do |location|
21
+ add_url(location, lastmod: lastmod, changefreq: changefreq, priority: priority)
22
+ end
23
+ self
24
+ end
25
+
26
+ def remove_url(location)
27
+ @urls.reject! { |url| url.loc == location }
28
+ self
29
+ end
30
+
31
+ def clear
32
+ @urls = []
33
+ self
34
+ end
35
+
36
+ def to_xml
37
+ xml = []
38
+ xml << '<?xml version="1.0" encoding="UTF-8"?>'
39
+ xml << '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
40
+ @urls.each do |url|
41
+ xml << url.to_xml
42
+ end
43
+ xml << "</urlset>"
44
+ xml.join("\n")
45
+ end
46
+
47
+ def validate!
48
+ @urls.each(&:validate!)
49
+ true
50
+ end
51
+
52
+ def size
53
+ @urls.size
54
+ end
55
+
56
+ def empty?
57
+ @urls.empty?
58
+ end
59
+
60
+ def each(&block)
61
+ @urls.each(&block)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module BetterSeo
6
+ module Sitemap
7
+ class Generator
8
+ class << self
9
+ def generate
10
+ builder = Builder.new
11
+ yield(builder) if block_given?
12
+ builder.to_xml
13
+ end
14
+
15
+ def generate_from(urls, lastmod: nil, changefreq: "weekly", priority: 0.5)
16
+ builder = Builder.new
17
+ builder.add_urls(urls, lastmod: lastmod, changefreq: changefreq, priority: priority)
18
+ builder.to_xml
19
+ end
20
+
21
+ def generate_from_collection(collection, url: nil, lastmod: nil, changefreq: "weekly", priority: 0.5)
22
+ raise ArgumentError, "url option is required" if url.nil?
23
+ raise ArgumentError, "url option must be callable" unless url.respond_to?(:call)
24
+
25
+ builder = Builder.new
26
+
27
+ collection.each do |item|
28
+ url_value = url.call(item)
29
+ lastmod_value = lastmod.respond_to?(:call) ? lastmod.call(item) : lastmod
30
+ changefreq_value = changefreq.respond_to?(:call) ? changefreq.call(item) : changefreq
31
+ priority_value = priority.respond_to?(:call) ? priority.call(item) : priority
32
+
33
+ builder.add_url(
34
+ url_value,
35
+ lastmod: lastmod_value,
36
+ changefreq: changefreq_value,
37
+ priority: priority_value
38
+ )
39
+ end
40
+
41
+ builder.to_xml
42
+ end
43
+
44
+ def write_to_file(file_path, &block)
45
+ xml = generate(&block)
46
+
47
+ # Create parent directories if they don't exist
48
+ dir = File.dirname(file_path)
49
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
50
+
51
+ File.write(file_path, xml)
52
+ file_path
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "date"
5
+
6
+ module BetterSeo
7
+ module Sitemap
8
+ class SitemapIndex
9
+ attr_reader :sitemaps
10
+
11
+ def initialize
12
+ @sitemaps = []
13
+ end
14
+
15
+ # Add a sitemap to the index
16
+ def add_sitemap(loc, lastmod: nil)
17
+ sitemap = { loc: loc }
18
+ sitemap[:lastmod] = format_date(lastmod) if lastmod
19
+ @sitemaps << sitemap
20
+ self
21
+ end
22
+
23
+ # Generate XML for sitemap index
24
+ def to_xml
25
+ xml = []
26
+ xml << '<?xml version="1.0" encoding="UTF-8"?>'
27
+ xml << '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
28
+
29
+ @sitemaps.each do |sitemap|
30
+ xml << " <sitemap>"
31
+ xml << " <loc>#{escape_xml(sitemap[:loc])}</loc>"
32
+ xml << " <lastmod>#{sitemap[:lastmod]}</lastmod>" if sitemap[:lastmod]
33
+ xml << " </sitemap>"
34
+ end
35
+
36
+ xml << "</sitemapindex>"
37
+ xml.join("\n")
38
+ end
39
+
40
+ # Write sitemap index to file
41
+ def write_to_file(path)
42
+ directory = File.dirname(path)
43
+ FileUtils.mkdir_p(directory) unless File.directory?(directory)
44
+
45
+ File.write(path, to_xml)
46
+ end
47
+
48
+ private
49
+
50
+ def format_date(value)
51
+ case value
52
+ when String
53
+ value
54
+ when Date
55
+ value.strftime("%Y-%m-%d")
56
+ when Time, DateTime
57
+ value.strftime("%Y-%m-%d")
58
+ else
59
+ value.to_s
60
+ end
61
+ end
62
+
63
+ def escape_xml(text)
64
+ text.to_s
65
+ .gsub("&", "&amp;")
66
+ .gsub("<", "&lt;")
67
+ .gsub(">", "&gt;")
68
+ .gsub('"', "&quot;")
69
+ .gsub("'", "&apos;")
70
+ end
71
+ end
72
+ end
73
+ end