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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module Generators
5
+ class OpenGraphGenerator
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def generate
11
+ tags = []
12
+ tags << meta_tag("og:title", @config[:title])
13
+ tags << meta_tag("og:description", @config[:description])
14
+ tags << meta_tag("og:type", @config[:type])
15
+ tags << meta_tag("og:url", @config[:url])
16
+ tags.concat(image_tags)
17
+ tags << meta_tag("og:site_name", @config[:site_name])
18
+ tags << meta_tag("og:locale", @config[:locale])
19
+ tags.concat(locale_alternate_tags)
20
+ tags.concat(article_tags)
21
+ tags.concat(video_tags)
22
+ tags << meta_tag("og:audio", @config[:audio])
23
+
24
+ tags.compact.join("\n")
25
+ end
26
+
27
+ private
28
+
29
+ def meta_tag(property, content)
30
+ return nil unless content
31
+
32
+ %(<meta property="#{property}" content="#{escape(content)}">)
33
+ end
34
+
35
+ def image_tags
36
+ image = @config[:image]
37
+ return [] unless image
38
+
39
+ tags = []
40
+
41
+ if image.is_a?(Hash)
42
+ tags << meta_tag("og:image", image[:url])
43
+ tags << meta_tag("og:image:width", image[:width])
44
+ tags << meta_tag("og:image:height", image[:height])
45
+ tags << meta_tag("og:image:alt", image[:alt])
46
+ else
47
+ tags << meta_tag("og:image", image)
48
+ end
49
+
50
+ tags.compact
51
+ end
52
+
53
+ def locale_alternate_tags
54
+ alternates = @config[:locale_alternate]
55
+ return [] unless alternates&.any?
56
+
57
+ Array(alternates).map do |locale|
58
+ meta_tag("og:locale:alternate", locale)
59
+ end
60
+ end
61
+
62
+ def article_tags
63
+ article = @config[:article]
64
+ return [] unless article
65
+
66
+ tags = []
67
+ tags << meta_tag("article:author", article[:author])
68
+ tags << meta_tag("article:published_time", article[:published_time])
69
+ tags << meta_tag("article:modified_time", article[:modified_time])
70
+ tags << meta_tag("article:expiration_time", article[:expiration_time])
71
+ tags << meta_tag("article:section", article[:section])
72
+
73
+ # Handle multiple tags
74
+ if article[:tag]
75
+ Array(article[:tag]).each do |tag|
76
+ tags << meta_tag("article:tag", tag)
77
+ end
78
+ end
79
+
80
+ tags.compact
81
+ end
82
+
83
+ def video_tags
84
+ video = @config[:video]
85
+ return [] unless video
86
+
87
+ tags = []
88
+
89
+ if video.is_a?(Hash)
90
+ tags << meta_tag("og:video", video[:url])
91
+ tags << meta_tag("og:video:width", video[:width])
92
+ tags << meta_tag("og:video:height", video[:height])
93
+ tags << meta_tag("og:video:type", video[:type])
94
+ else
95
+ tags << meta_tag("og:video", video)
96
+ end
97
+
98
+ tags.compact
99
+ end
100
+
101
+ def escape(text)
102
+ text.to_s
103
+ .gsub("&", "&amp;")
104
+ .gsub('"', "&quot;")
105
+ .gsub("<", "&lt;")
106
+ .gsub(">", "&gt;")
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module BetterSeo
6
+ module Generators
7
+ class RobotsTxtGenerator
8
+ attr_reader :rules, :sitemaps
9
+
10
+ def initialize
11
+ @rules = []
12
+ @sitemaps = []
13
+ end
14
+
15
+ # Add rule for user agent
16
+ def add_rule(user_agent, allow: nil, disallow: nil)
17
+ rule = { user_agent: user_agent }
18
+ rule[:allow] = Array(allow).compact if allow
19
+ rule[:disallow] = Array(disallow).compact if disallow
20
+ @rules << rule
21
+ self
22
+ end
23
+
24
+ # Set crawl delay for user agent
25
+ def set_crawl_delay(user_agent, seconds)
26
+ rule = @rules.find { |r| r[:user_agent] == user_agent }
27
+
28
+ if rule
29
+ rule[:crawl_delay] = seconds
30
+ else
31
+ @rules << { user_agent: user_agent, crawl_delay: seconds }
32
+ end
33
+
34
+ self
35
+ end
36
+
37
+ # Add sitemap URL
38
+ def add_sitemap(url)
39
+ @sitemaps << url
40
+ self
41
+ end
42
+
43
+ # Clear all rules and sitemaps
44
+ def clear
45
+ @rules = []
46
+ @sitemaps = []
47
+ self
48
+ end
49
+
50
+ # Generate robots.txt content
51
+ def to_text
52
+ return "" if @rules.empty? && @sitemaps.empty?
53
+
54
+ lines = []
55
+
56
+ # Generate rules for each user agent
57
+ @rules.each_with_index do |rule, index|
58
+ lines << "User-agent: #{rule[:user_agent]}"
59
+
60
+ # Allow directives
61
+ rule[:allow]&.each do |path|
62
+ lines << "Allow: #{path}"
63
+ end
64
+
65
+ # Disallow directives
66
+ rule[:disallow]&.each do |path|
67
+ lines << "Disallow: #{path}"
68
+ end
69
+
70
+ # Crawl delay
71
+ lines << "Crawl-delay: #{rule[:crawl_delay]}" if rule[:crawl_delay]
72
+
73
+ # Add blank line between user agent sections (except last)
74
+ lines << "" if index < @rules.size - 1
75
+ end
76
+
77
+ # Add blank line before sitemaps if there are rules
78
+ lines << "" if @rules.any? && @sitemaps.any?
79
+
80
+ # Add sitemaps
81
+ @sitemaps.each do |sitemap|
82
+ lines << "Sitemap: #{sitemap}"
83
+ end
84
+
85
+ lines.join("\n")
86
+ end
87
+
88
+ # Write robots.txt to file
89
+ def write_to_file(path)
90
+ directory = File.dirname(path)
91
+ FileUtils.mkdir_p(directory) unless File.directory?(directory)
92
+ File.write(path, to_text)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module Generators
5
+ class TwitterCardsGenerator
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def generate
11
+ tags = []
12
+ tags << meta_tag("twitter:card", @config[:card])
13
+ tags << meta_tag("twitter:site", @config[:site])
14
+ tags << meta_tag("twitter:creator", @config[:creator])
15
+ tags << meta_tag("twitter:title", @config[:title])
16
+ tags << meta_tag("twitter:description", @config[:description])
17
+ tags.concat(image_tags)
18
+ tags.concat(player_tags)
19
+ tags.concat(app_tags)
20
+
21
+ tags.compact.join("\n")
22
+ end
23
+
24
+ private
25
+
26
+ def meta_tag(name, content)
27
+ return nil unless content
28
+
29
+ %(<meta name="#{name}" content="#{escape(content)}">)
30
+ end
31
+
32
+ def image_tags
33
+ image = @config[:image]
34
+ return [] unless image
35
+
36
+ tags = []
37
+
38
+ if image.is_a?(Hash)
39
+ tags << meta_tag("twitter:image", image[:url])
40
+ tags << meta_tag("twitter:image:alt", image[:alt])
41
+ else
42
+ tags << meta_tag("twitter:image", image)
43
+ end
44
+
45
+ # Add separate image_alt if present
46
+ tags << meta_tag("twitter:image:alt", @config[:image_alt]) if @config[:image_alt]
47
+
48
+ tags.compact
49
+ end
50
+
51
+ def player_tags
52
+ return [] unless @config[:player]
53
+
54
+ tags = []
55
+ tags << meta_tag("twitter:player", @config[:player])
56
+ tags << meta_tag("twitter:player:width", @config[:player_width])
57
+ tags << meta_tag("twitter:player:height", @config[:player_height])
58
+ tags << meta_tag("twitter:player:stream", @config[:player_stream])
59
+
60
+ tags.compact
61
+ end
62
+
63
+ def app_tags
64
+ tags = []
65
+
66
+ # App name tags
67
+ if @config[:app_name]
68
+ app_name = @config[:app_name]
69
+ tags << meta_tag("twitter:app:name:iphone", app_name[:iphone])
70
+ tags << meta_tag("twitter:app:name:ipad", app_name[:ipad])
71
+ tags << meta_tag("twitter:app:name:googleplay", app_name[:googleplay])
72
+ end
73
+
74
+ # App ID tags
75
+ if @config[:app_id]
76
+ app_id = @config[:app_id]
77
+ tags << meta_tag("twitter:app:id:iphone", app_id[:iphone])
78
+ tags << meta_tag("twitter:app:id:ipad", app_id[:ipad])
79
+ tags << meta_tag("twitter:app:id:googleplay", app_id[:googleplay])
80
+ end
81
+
82
+ # App URL tags
83
+ if @config[:app_url]
84
+ app_url = @config[:app_url]
85
+ tags << meta_tag("twitter:app:url:iphone", app_url[:iphone])
86
+ tags << meta_tag("twitter:app:url:ipad", app_url[:ipad])
87
+ tags << meta_tag("twitter:app:url:googleplay", app_url[:googleplay])
88
+ end
89
+
90
+ tags.compact
91
+ end
92
+
93
+ def escape(text)
94
+ text.to_s
95
+ .gsub("&", "&amp;")
96
+ .gsub('"', "&quot;")
97
+ .gsub("<", "&lt;")
98
+ .gsub(">", "&gt;")
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mini_magick"
5
+ rescue LoadError
6
+ # mini_magick is optional
7
+ end
8
+
9
+ module BetterSeo
10
+ module Image
11
+ class Optimizer
12
+ SUPPORTED_FORMATS = %w[jpg jpeg png webp gif].freeze
13
+
14
+ attr_reader :quality
15
+
16
+ def initialize(quality: 85)
17
+ @quality = quality
18
+ check_imagemagick!
19
+ end
20
+
21
+ # Validate image format
22
+ def validate_format!(path)
23
+ ext = File.extname(path).downcase.delete(".")
24
+ unless SUPPORTED_FORMATS.include?(ext)
25
+ raise ImageError, "Unsupported format: #{ext}. Supported: #{SUPPORTED_FORMATS.join(", ")}"
26
+ end
27
+
28
+ true
29
+ end
30
+
31
+ # Convert image to WebP
32
+ def convert_to_webp(source, destination)
33
+ check_imagemagick!
34
+ validate_source!(source)
35
+
36
+ image = MiniMagick::Image.open(source)
37
+ image.format "webp"
38
+ image.quality @quality
39
+ image.write destination
40
+
41
+ destination
42
+ rescue MiniMagick::Error => e
43
+ raise ImageError, "Failed to convert to WebP: #{e.message}"
44
+ end
45
+
46
+ # Resize image
47
+ def resize(source, destination, width: nil, height: nil)
48
+ check_imagemagick!
49
+ validate_source!(source)
50
+
51
+ raise ImageError, "Either width or height must be specified" if width.nil? && height.nil?
52
+
53
+ image = MiniMagick::Image.open(source)
54
+
55
+ if width && height
56
+ image.resize "#{width}x#{height}!"
57
+ elsif width
58
+ image.resize "#{width}x"
59
+ else
60
+ image.resize "x#{height}"
61
+ end
62
+
63
+ image.write destination
64
+
65
+ destination
66
+ rescue MiniMagick::Error => e
67
+ raise ImageError, "Failed to resize: #{e.message}"
68
+ end
69
+
70
+ # Compress image
71
+ def compress(source, destination)
72
+ check_imagemagick!
73
+ validate_source!(source)
74
+
75
+ image = MiniMagick::Image.open(source)
76
+ image.quality @quality
77
+ image.write destination
78
+
79
+ destination
80
+ rescue MiniMagick::Error => e
81
+ raise ImageError, "Failed to compress: #{e.message}"
82
+ end
83
+
84
+ # Generate responsive image sizes
85
+ def generate_responsive(source, output_dir, sizes: {}, prefix: "responsive")
86
+ check_imagemagick!
87
+ validate_source!(source)
88
+
89
+ FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
90
+
91
+ results = {}
92
+ ext = File.extname(source)
93
+
94
+ sizes.each do |name, width|
95
+ output_path = File.join(output_dir, "#{prefix}_#{name}_#{width}w#{ext}")
96
+ resize(source, output_path, width: width)
97
+ results[name] = output_path
98
+ end
99
+
100
+ results
101
+ rescue MiniMagick::Error => e
102
+ raise ImageError, "Failed to generate responsive images: #{e.message}"
103
+ end
104
+
105
+ # Optimize image (resize + compress)
106
+ def optimize(source, destination, resize: nil)
107
+ check_imagemagick!
108
+ validate_source!(source)
109
+
110
+ original_size = File.size(source)
111
+
112
+ if resize
113
+ resize(source, destination, **resize)
114
+ else
115
+ compress(source, destination)
116
+ end
117
+
118
+ optimized_size = File.size(destination)
119
+ reduction = ((original_size - optimized_size).to_f / original_size * 100).round(2)
120
+
121
+ {
122
+ original_size: original_size,
123
+ optimized_size: optimized_size,
124
+ reduction_percent: reduction
125
+ }
126
+ rescue MiniMagick::Error => e
127
+ raise ImageError, "Failed to optimize: #{e.message}"
128
+ end
129
+
130
+ private
131
+
132
+ def check_imagemagick!
133
+ return if defined?(MiniMagick)
134
+
135
+ raise ImageError, "ImageMagick is not available. Please install ImageMagick and the mini_magick gem."
136
+ end
137
+
138
+ def validate_source!(source)
139
+ raise ImageError, "Source file not found: #{source}" unless File.exist?(source)
140
+
141
+ validate_format!(source)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module Rails
5
+ module Helpers
6
+ module ControllerHelpers
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ helper_method :better_seo_data if respond_to?(:helper_method)
11
+ end
12
+
13
+ # Set meta tags data
14
+ def set_meta_tags(data = nil, &block)
15
+ if block_given?
16
+ meta_data = {}
17
+ yield(meta_data)
18
+ merge_seo_data(:meta, meta_data)
19
+ else
20
+ merge_seo_data(:meta, data)
21
+ end
22
+ end
23
+
24
+ # Set Open Graph tags data
25
+ def set_og_tags(data = nil, &block)
26
+ if block_given?
27
+ og_data = {}
28
+ yield(og_data)
29
+ merge_seo_data(:og, og_data)
30
+ else
31
+ merge_seo_data(:og, data)
32
+ end
33
+ end
34
+
35
+ # Set Twitter Card tags data
36
+ def set_twitter_tags(data = nil, &block)
37
+ if block_given?
38
+ twitter_data = {}
39
+ yield(twitter_data)
40
+ merge_seo_data(:twitter, twitter_data)
41
+ else
42
+ merge_seo_data(:twitter, data)
43
+ end
44
+ end
45
+
46
+ # Set canonical URL
47
+ def set_canonical(url)
48
+ merge_seo_data(:meta, { canonical: url })
49
+ end
50
+
51
+ # Set page title with optional prefix/suffix
52
+ def set_page_title(title, prefix: nil, suffix: nil)
53
+ full_title = ""
54
+ full_title += prefix if prefix
55
+ full_title += title
56
+ full_title += suffix if suffix
57
+
58
+ merge_seo_data(:meta, { title: full_title })
59
+ end
60
+
61
+ # Set page description with optional truncation
62
+ def set_page_description(description, max_length: nil)
63
+ final_description = description
64
+
65
+ # rubocop:disable Style/IfUnlessModifier
66
+ if max_length && description.length > max_length
67
+ final_description = "#{description[0...(max_length - 3)]}..."
68
+ end
69
+ # rubocop:enable Style/IfUnlessModifier
70
+
71
+ merge_seo_data(:meta, { description: final_description })
72
+ end
73
+
74
+ # Set page keywords (array or comma-separated string)
75
+ def set_page_keywords(keywords)
76
+ keywords_array = if keywords.is_a?(String)
77
+ keywords.split(",").map(&:strip)
78
+ else
79
+ keywords
80
+ end
81
+
82
+ merge_seo_data(:meta, { keywords: keywords_array })
83
+ end
84
+
85
+ # Set page image for both OG and Twitter
86
+ def set_page_image(url, width: nil, height: nil)
87
+ og_data = { image: url }
88
+ og_data[:image_width] = width if width
89
+ og_data[:image_height] = height if height
90
+
91
+ merge_seo_data(:og, og_data)
92
+ merge_seo_data(:twitter, { image: url })
93
+ end
94
+
95
+ # Set noindex robot directive
96
+ def set_noindex(nofollow: false)
97
+ robots = "noindex"
98
+ robots += ", nofollow" if nofollow
99
+
100
+ merge_seo_data(:meta, { robots: robots })
101
+ end
102
+
103
+ # Get all SEO data stored in controller
104
+ def better_seo_data
105
+ @_better_seo_data ||= {}
106
+ end
107
+
108
+ private
109
+
110
+ def merge_seo_data(category, data)
111
+ return unless data
112
+
113
+ @_better_seo_data ||= {}
114
+ @_better_seo_data[category] ||= {}
115
+ @_better_seo_data[category].merge!(data)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end