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,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 && 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,102 @@
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
+ if rule[:allow]
62
+ rule[:allow].each do |path|
63
+ lines << "Allow: #{path}"
64
+ end
65
+ end
66
+
67
+ # Disallow directives
68
+ if rule[:disallow]
69
+ rule[:disallow].each do |path|
70
+ lines << "Disallow: #{path}"
71
+ end
72
+ end
73
+
74
+ # Crawl delay
75
+ if rule[:crawl_delay]
76
+ lines << "Crawl-delay: #{rule[:crawl_delay]}"
77
+ end
78
+
79
+ # Add blank line between user agent sections (except last)
80
+ lines << "" if index < @rules.size - 1
81
+ end
82
+
83
+ # Add blank line before sitemaps if there are rules
84
+ lines << "" if @rules.any? && @sitemaps.any?
85
+
86
+ # Add sitemaps
87
+ @sitemaps.each do |sitemap|
88
+ lines << "Sitemap: #{sitemap}"
89
+ end
90
+
91
+ lines.join("\n")
92
+ end
93
+
94
+ # Write robots.txt to file
95
+ def write_to_file(path)
96
+ directory = File.dirname(path)
97
+ FileUtils.mkdir_p(directory) unless File.directory?(directory)
98
+ File.write(path, to_text)
99
+ end
100
+ end
101
+ end
102
+ 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,143 @@
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
+ true
28
+ end
29
+
30
+ # Convert image to WebP
31
+ def convert_to_webp(source, destination)
32
+ check_imagemagick!
33
+ validate_source!(source)
34
+
35
+ image = MiniMagick::Image.open(source)
36
+ image.format "webp"
37
+ image.quality @quality
38
+ image.write destination
39
+
40
+ destination
41
+ rescue MiniMagick::Error => e
42
+ raise ImageError, "Failed to convert to WebP: #{e.message}"
43
+ end
44
+
45
+ # Resize image
46
+ def resize(source, destination, width: nil, height: nil)
47
+ check_imagemagick!
48
+ validate_source!(source)
49
+
50
+ raise ImageError, "Either width or height must be specified" if width.nil? && height.nil?
51
+
52
+ image = MiniMagick::Image.open(source)
53
+
54
+ if width && height
55
+ image.resize "#{width}x#{height}!"
56
+ elsif width
57
+ image.resize "#{width}x"
58
+ else
59
+ image.resize "x#{height}"
60
+ end
61
+
62
+ image.write destination
63
+
64
+ destination
65
+ rescue MiniMagick::Error => e
66
+ raise ImageError, "Failed to resize: #{e.message}"
67
+ end
68
+
69
+ # Compress image
70
+ def compress(source, destination)
71
+ check_imagemagick!
72
+ validate_source!(source)
73
+
74
+ image = MiniMagick::Image.open(source)
75
+ image.quality @quality
76
+ image.write destination
77
+
78
+ destination
79
+ rescue MiniMagick::Error => e
80
+ raise ImageError, "Failed to compress: #{e.message}"
81
+ end
82
+
83
+ # Generate responsive image sizes
84
+ def generate_responsive(source, output_dir, sizes: {}, prefix: "responsive")
85
+ check_imagemagick!
86
+ validate_source!(source)
87
+
88
+ FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
89
+
90
+ results = {}
91
+ ext = File.extname(source)
92
+
93
+ sizes.each do |name, width|
94
+ output_path = File.join(output_dir, "#{prefix}_#{name}_#{width}w#{ext}")
95
+ resize(source, output_path, width: width)
96
+ results[name] = output_path
97
+ end
98
+
99
+ results
100
+ rescue MiniMagick::Error => e
101
+ raise ImageError, "Failed to generate responsive images: #{e.message}"
102
+ end
103
+
104
+ # Optimize image (resize + compress)
105
+ def optimize(source, destination, resize: nil)
106
+ check_imagemagick!
107
+ validate_source!(source)
108
+
109
+ original_size = File.size(source)
110
+
111
+ if resize
112
+ resize(source, destination, **resize)
113
+ else
114
+ compress(source, destination)
115
+ end
116
+
117
+ optimized_size = File.size(destination)
118
+ reduction = ((original_size - optimized_size).to_f / original_size * 100).round(2)
119
+
120
+ {
121
+ original_size: original_size,
122
+ optimized_size: optimized_size,
123
+ reduction_percent: reduction
124
+ }
125
+ rescue MiniMagick::Error => e
126
+ raise ImageError, "Failed to optimize: #{e.message}"
127
+ end
128
+
129
+ private
130
+
131
+ def check_imagemagick!
132
+ return if defined?(MiniMagick)
133
+
134
+ raise ImageError, "ImageMagick is not available. Please install ImageMagick and the mini_magick gem."
135
+ end
136
+
137
+ def validate_source!(source)
138
+ raise ImageError, "Source file not found: #{source}" unless File.exist?(source)
139
+ validate_format!(source)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,118 @@
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
+ if max_length && description.length > max_length
66
+ final_description = description[0...max_length - 3] + "..."
67
+ end
68
+
69
+ merge_seo_data(:meta, { description: final_description })
70
+ end
71
+
72
+ # Set page keywords (array or comma-separated string)
73
+ def set_page_keywords(keywords)
74
+ keywords_array = if keywords.is_a?(String)
75
+ keywords.split(",").map(&:strip)
76
+ else
77
+ keywords
78
+ end
79
+
80
+ merge_seo_data(:meta, { keywords: keywords_array })
81
+ end
82
+
83
+ # Set page image for both OG and Twitter
84
+ def set_page_image(url, width: nil, height: nil)
85
+ og_data = { image: url }
86
+ og_data[:image_width] = width if width
87
+ og_data[:image_height] = height if height
88
+
89
+ merge_seo_data(:og, og_data)
90
+ merge_seo_data(:twitter, { image: url })
91
+ end
92
+
93
+ # Set noindex robot directive
94
+ def set_noindex(nofollow: false)
95
+ robots = "noindex"
96
+ robots += ", nofollow" if nofollow
97
+
98
+ merge_seo_data(:meta, { robots: robots })
99
+ end
100
+
101
+ # Get all SEO data stored in controller
102
+ def better_seo_data
103
+ @_better_seo_data ||= {}
104
+ end
105
+
106
+ private
107
+
108
+ def merge_seo_data(category, data)
109
+ return unless data
110
+
111
+ @_better_seo_data ||= {}
112
+ @_better_seo_data[category] ||= {}
113
+ @_better_seo_data[category].merge!(data)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end