better_seo 0.13.0 → 1.0.0.1

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -3
  3. data/README.md +299 -181
  4. data/docs/00_OVERVIEW.md +472 -0
  5. data/docs/01_CORE_AND_CONFIGURATION.md +913 -0
  6. data/docs/02_META_TAGS_AND_OPEN_GRAPH.md +251 -0
  7. data/docs/03_STRUCTURED_DATA.md +140 -0
  8. data/docs/04_SITEMAP_AND_ROBOTS.md +131 -0
  9. data/docs/05_RAILS_INTEGRATION.md +175 -0
  10. data/docs/06_I18N_PAGE_GENERATOR.md +233 -0
  11. data/docs/07_IMAGE_OPTIMIZATION.md +260 -0
  12. data/docs/DEPENDENCIES.md +383 -0
  13. data/docs/README.md +180 -0
  14. data/docs/TESTING_STRATEGY.md +663 -0
  15. data/lib/better_seo/analytics/google_analytics.rb +83 -0
  16. data/lib/better_seo/analytics/google_tag_manager.rb +74 -0
  17. data/lib/better_seo/configuration.rb +316 -0
  18. data/lib/better_seo/dsl/base.rb +86 -0
  19. data/lib/better_seo/dsl/meta_tags.rb +55 -0
  20. data/lib/better_seo/dsl/open_graph.rb +109 -0
  21. data/lib/better_seo/dsl/twitter_cards.rb +131 -0
  22. data/lib/better_seo/errors.rb +31 -0
  23. data/lib/better_seo/generators/amp_generator.rb +83 -0
  24. data/lib/better_seo/generators/breadcrumbs_generator.rb +126 -0
  25. data/lib/better_seo/generators/canonical_url_manager.rb +106 -0
  26. data/lib/better_seo/generators/meta_tags_generator.rb +100 -0
  27. data/lib/better_seo/generators/open_graph_generator.rb +110 -0
  28. data/lib/better_seo/generators/robots_txt_generator.rb +102 -0
  29. data/lib/better_seo/generators/twitter_cards_generator.rb +102 -0
  30. data/lib/better_seo/image/optimizer.rb +143 -0
  31. data/lib/better_seo/rails/helpers/controller_helpers.rb +118 -0
  32. data/lib/better_seo/rails/helpers/seo_helper.rb +176 -0
  33. data/lib/better_seo/rails/helpers/structured_data_helper.rb +123 -0
  34. data/lib/better_seo/rails/model_helpers.rb +62 -0
  35. data/lib/better_seo/rails/railtie.rb +22 -0
  36. data/lib/better_seo/sitemap/builder.rb +65 -0
  37. data/lib/better_seo/sitemap/generator.rb +57 -0
  38. data/lib/better_seo/sitemap/sitemap_index.rb +73 -0
  39. data/lib/better_seo/sitemap/url_entry.rb +157 -0
  40. data/lib/better_seo/structured_data/article.rb +55 -0
  41. data/lib/better_seo/structured_data/base.rb +73 -0
  42. data/lib/better_seo/structured_data/breadcrumb_list.rb +49 -0
  43. data/lib/better_seo/structured_data/event.rb +207 -0
  44. data/lib/better_seo/structured_data/faq_page.rb +55 -0
  45. data/lib/better_seo/structured_data/generator.rb +75 -0
  46. data/lib/better_seo/structured_data/how_to.rb +96 -0
  47. data/lib/better_seo/structured_data/local_business.rb +94 -0
  48. data/lib/better_seo/structured_data/organization.rb +67 -0
  49. data/lib/better_seo/structured_data/person.rb +51 -0
  50. data/lib/better_seo/structured_data/product.rb +123 -0
  51. data/lib/better_seo/structured_data/recipe.rb +135 -0
  52. data/lib/better_seo/validators/seo_recommendations.rb +165 -0
  53. data/lib/better_seo/validators/seo_validator.rb +195 -0
  54. data/lib/better_seo/version.rb +1 -1
  55. data/lib/better_seo.rb +5 -0
  56. data/lib/generators/better_seo/install_generator.rb +21 -0
  57. data/lib/generators/better_seo/templates/README +29 -0
  58. data/lib/generators/better_seo/templates/better_seo.rb +40 -0
  59. metadata +69 -2
@@ -0,0 +1,176 @@
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, &block)
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
+ if merged_config[:meta]
59
+ tags << seo_meta_tags(merged_config[:meta])
60
+ end
61
+
62
+ if merged_config[:og] && BetterSeo.configuration.open_graph_enabled?
63
+ tags << seo_open_graph_tags(merged_config[:og])
64
+ end
65
+
66
+ if merged_config[:twitter] && BetterSeo.configuration.twitter_enabled?
67
+ tags << seo_twitter_tags(merged_config[:twitter])
68
+ end
69
+
70
+ raw(tags.compact.join("\n"))
71
+ end
72
+
73
+ private
74
+
75
+ # Build SEO configuration by merging controller data with defaults
76
+ def build_seo_config(controller_data)
77
+ config = BetterSeo.configuration
78
+ result = {}
79
+
80
+ # Meta tags
81
+ meta = {}
82
+
83
+ # Start with controller data
84
+ if controller_data[:meta]
85
+ meta = controller_data[:meta].dup
86
+ end
87
+
88
+ # Use default title if not set
89
+ meta[:title] ||= config.meta_tags.default_title
90
+
91
+ # Apply site_name to title if configured
92
+ if meta[:title] && config.meta_tags.append_site_name && config.site_name
93
+ separator = config.meta_tags.title_separator || " | "
94
+ meta[:title] = "#{meta[:title]}#{separator}#{config.site_name}"
95
+ end
96
+
97
+ # Use defaults for missing values
98
+ meta[:description] ||= config.meta_tags.default_description
99
+ meta[:keywords] ||= config.meta_tags.default_keywords
100
+ meta[:author] ||= config.meta_tags.default_author
101
+
102
+ result[:meta] = meta if meta.any?
103
+
104
+ # Open Graph tags
105
+ if config.open_graph_enabled?
106
+ og = controller_data[:og]&.dup || {}
107
+
108
+ # Use meta title/description as fallbacks
109
+ og[:title] ||= meta[:title] || config.meta_tags.default_title
110
+ og[:description] ||= meta[:description]
111
+ og[:type] ||= config.open_graph.default_type
112
+ og[:locale] ||= config.open_graph.default_locale
113
+ og[:site_name] ||= config.open_graph.site_name || config.site_name
114
+
115
+ # Default image
116
+ if og[:image].nil? && config.open_graph.default_image&.url
117
+ og[:image] = config.open_graph.default_image.url
118
+ og[:image_width] = config.open_graph.default_image.width
119
+ og[:image_height] = config.open_graph.default_image.height
120
+ og[:image_alt] = config.open_graph.default_image.alt if config.open_graph.default_image.respond_to?(:alt)
121
+ end
122
+
123
+ result[:og] = og if og.any?
124
+ end
125
+
126
+ # Twitter Card tags
127
+ if config.twitter_enabled?
128
+ twitter = controller_data[:twitter]&.dup || {}
129
+
130
+ twitter[:card] ||= config.twitter.card_type
131
+ twitter[:site] ||= config.twitter.site
132
+ twitter[:creator] ||= config.twitter.creator
133
+ twitter[:title] ||= meta[:title] || config.meta_tags.default_title
134
+ twitter[:description] ||= meta[:description]
135
+
136
+ # Use OG image as fallback
137
+ twitter[:image] ||= result.dig(:og, :image)
138
+
139
+ result[:twitter] = twitter if twitter.any?
140
+ end
141
+
142
+ result
143
+ end
144
+
145
+ # Context class for building all SEO tags with block syntax
146
+ class SeoTagsContext
147
+ def initialize
148
+ @config = {}
149
+ end
150
+
151
+ def meta(&block)
152
+ builder = BetterSeo::DSL::MetaTags.new
153
+ builder.evaluate(&block)
154
+ @config[:meta] = builder.build
155
+ end
156
+
157
+ def og(&block)
158
+ builder = BetterSeo::DSL::OpenGraph.new
159
+ builder.evaluate(&block)
160
+ @config[:og] = builder.build
161
+ end
162
+
163
+ def twitter(&block)
164
+ builder = BetterSeo::DSL::TwitterCards.new
165
+ builder.evaluate(&block)
166
+ @config[:twitter] = builder.build
167
+ end
168
+
169
+ def to_h
170
+ @config
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ 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, &block)
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, &block)
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(&block)
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