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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterSeo
6
+ module Analytics
7
+ class GoogleAnalytics
8
+ attr_reader :measurement_id, :anonymize_ip
9
+
10
+ def initialize(measurement_id, anonymize_ip: false)
11
+ @measurement_id = measurement_id
12
+ @anonymize_ip = anonymize_ip
13
+ end
14
+
15
+ # Generate GA4 script tag
16
+ def to_script_tag(nonce: nil, **config)
17
+ nonce_attr = nonce ? %( nonce="#{escape_html(nonce)}") : ""
18
+
19
+ config_options = { "anonymize_ip" => @anonymize_ip } if @anonymize_ip
20
+ config_options ||= {}
21
+ config_options.merge!(config.transform_keys(&:to_s))
22
+
23
+ config_json = config_options.empty? ? "" : ", #{JSON.generate(config_options)}"
24
+
25
+ <<~HTML.strip
26
+ <script async src="https://www.googletagmanager.com/gtag/js?id=#{@measurement_id}"#{nonce_attr}></script>
27
+ <script#{nonce_attr}>
28
+ window.dataLayer = window.dataLayer || [];
29
+ function gtag(){dataLayer.push(arguments);}
30
+ gtag('js', new Date());
31
+ gtag('config', '#{@measurement_id}'#{config_json});
32
+ </script>
33
+ HTML
34
+ end
35
+
36
+ # Track custom event
37
+ def track_event(event_name, **parameters)
38
+ params_json = parameters.empty? ? "" : ", #{JSON.generate(parameters.transform_keys(&:to_s))}"
39
+
40
+ <<~JS.strip
41
+ gtag('event', '#{event_name}'#{params_json});
42
+ JS
43
+ end
44
+
45
+ # Track page view
46
+ def track_page_view(page_path, title: nil)
47
+ params = { "page_path" => page_path }
48
+ params["page_title"] = title if title
49
+
50
+ <<~JS.strip
51
+ gtag('config', '#{@measurement_id}', #{JSON.generate(params)});
52
+ JS
53
+ end
54
+
55
+ # Track e-commerce purchase
56
+ def ecommerce_purchase(transaction_id:, value:, currency: "USD", items: [])
57
+ params = {
58
+ "transaction_id" => transaction_id,
59
+ "value" => value,
60
+ "currency" => currency,
61
+ "items" => items.map { |item| item.transform_keys(&:to_s) }
62
+ }
63
+
64
+ <<~JS.strip
65
+ gtag('event', 'purchase', #{JSON.generate(params)});
66
+ JS
67
+ end
68
+
69
+ private
70
+
71
+ def escape_html(text)
72
+ return "" if text.nil?
73
+
74
+ text.to_s
75
+ .gsub("&", "&amp;")
76
+ .gsub("<", "&lt;")
77
+ .gsub(">", "&gt;")
78
+ .gsub('"', "&quot;")
79
+ .gsub("'", "&#39;")
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterSeo
6
+ module Analytics
7
+ class GoogleTagManager
8
+ attr_reader :container_id, :data_layer_name
9
+
10
+ def initialize(container_id, data_layer_name: "dataLayer")
11
+ @container_id = container_id
12
+ @data_layer_name = data_layer_name
13
+ end
14
+
15
+ # Generate GTM head script tag
16
+ def to_head_script(nonce: nil)
17
+ nonce_attr = nonce ? %( nonce="#{escape_html(nonce)}") : ""
18
+
19
+ <<~HTML.strip
20
+ <script#{nonce_attr}>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
21
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
22
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
23
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
24
+ })(window,document,'script','#{@data_layer_name}','#{@container_id}');</script>
25
+ HTML
26
+ end
27
+
28
+ # Generate GTM body noscript tag
29
+ def to_body_noscript
30
+ <<~HTML.strip
31
+ <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{@container_id}"
32
+ height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
33
+ HTML
34
+ end
35
+
36
+ # Push data to data layer
37
+ def push_data_layer(**data)
38
+ <<~JS.strip
39
+ #{@data_layer_name}.push(#{JSON.generate(data.transform_keys(&:to_s))});
40
+ JS
41
+ end
42
+
43
+ # Push e-commerce data
44
+ def push_ecommerce(event:, ecommerce:)
45
+ data = {
46
+ "event" => event,
47
+ "ecommerce" => ecommerce.transform_keys(&:to_s)
48
+ }
49
+
50
+ <<~JS.strip
51
+ #{@data_layer_name}.push(#{JSON.generate(data)});
52
+ JS
53
+ end
54
+
55
+ # Push user data
56
+ def push_user_data(**user_data)
57
+ push_data_layer(**user_data)
58
+ end
59
+
60
+ private
61
+
62
+ def escape_html(text)
63
+ return "" if text.nil?
64
+
65
+ text.to_s
66
+ .gsub("&", "&amp;")
67
+ .gsub("<", "&lt;")
68
+ .gsub(">", "&gt;")
69
+ .gsub('"', "&quot;")
70
+ .gsub("'", "&#39;")
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+ require "active_support/core_ext/hash/deep_merge"
5
+
6
+ module BetterSeo
7
+ class Configuration
8
+ # Default values
9
+ DEFAULT_CONFIG = {
10
+ site_name: nil,
11
+ default_locale: :en,
12
+ available_locales: [:en],
13
+
14
+ # Meta tags defaults
15
+ meta_tags: {
16
+ default_title: nil,
17
+ title_separator: " | ",
18
+ append_site_name: true,
19
+ default_description: nil,
20
+ default_keywords: [],
21
+ default_author: nil
22
+ },
23
+
24
+ # Open Graph defaults
25
+ open_graph: {
26
+ enabled: true,
27
+ site_name: nil,
28
+ default_type: "website",
29
+ default_locale: "en_US",
30
+ default_image: {
31
+ url: nil,
32
+ width: 1200,
33
+ height: 630
34
+ }
35
+ },
36
+
37
+ # Twitter Cards defaults
38
+ twitter: {
39
+ enabled: true,
40
+ site: nil,
41
+ creator: nil,
42
+ card_type: "summary_large_image"
43
+ },
44
+
45
+ # Structured Data defaults
46
+ structured_data: {
47
+ enabled: true,
48
+ organization: {},
49
+ website: {}
50
+ },
51
+
52
+ # Sitemap defaults
53
+ sitemap: {
54
+ enabled: false,
55
+ output_path: "public/sitemap.xml",
56
+ host: nil,
57
+ compress: false,
58
+ ping_search_engines: false,
59
+ defaults: {
60
+ changefreq: "weekly",
61
+ priority: 0.5
62
+ }
63
+ },
64
+
65
+ # Robots.txt defaults
66
+ robots: {
67
+ enabled: false,
68
+ output_path: "public/robots.txt",
69
+ user_agents: {
70
+ "*" => {
71
+ allow: ["/"],
72
+ disallow: [],
73
+ crawl_delay: nil
74
+ }
75
+ }
76
+ },
77
+
78
+ # Image optimization defaults
79
+ images: {
80
+ enabled: false,
81
+ webp: {
82
+ enabled: true,
83
+ quality: 80
84
+ },
85
+ sizes: {
86
+ thumbnail: { width: 150, height: 150 },
87
+ small: { width: 300 },
88
+ medium: { width: 600 },
89
+ large: { width: 1200 },
90
+ og_image: { width: 1200, height: 630, crop: true }
91
+ }
92
+ },
93
+
94
+ # I18n settings
95
+ i18n: {
96
+ load_path: "config/locales/seo/**/*.yml",
97
+ auto_reload: false
98
+ }
99
+ }.freeze
100
+
101
+ attr_accessor :site_name, :default_locale, :available_locales
102
+ attr_reader :meta_tags, :open_graph, :twitter, :structured_data,
103
+ :sitemap, :robots, :images, :i18n
104
+
105
+ def initialize
106
+ # Deep dup per evitare shared state
107
+ @config = deep_dup(DEFAULT_CONFIG)
108
+
109
+ # Initialize nested configurations
110
+ @meta_tags = NestedConfiguration.new(@config[:meta_tags])
111
+ @open_graph = NestedConfiguration.new(@config[:open_graph])
112
+ @twitter = NestedConfiguration.new(@config[:twitter])
113
+ @structured_data = NestedConfiguration.new(@config[:structured_data])
114
+ @sitemap = NestedConfiguration.new(@config[:sitemap])
115
+ @robots = NestedConfiguration.new(@config[:robots])
116
+ @images = NestedConfiguration.new(@config[:images])
117
+ @i18n = NestedConfiguration.new(@config[:i18n])
118
+
119
+ # Top-level attributes
120
+ @site_name = @config[:site_name]
121
+ @default_locale = @config[:default_locale]
122
+ @available_locales = @config[:available_locales]
123
+ end
124
+
125
+ # Load configuration from hash
126
+ def load_from_hash(hash)
127
+ merge_hash!(hash)
128
+ self
129
+ end
130
+
131
+ # Validate configuration
132
+ def validate!
133
+ errors = []
134
+
135
+ # Validate locales
136
+ unless available_locales.is_a?(Array) && available_locales.any?
137
+ errors << "available_locales must be a non-empty array"
138
+ end
139
+
140
+ if available_locales.is_a?(Array) && !available_locales.include?(default_locale)
141
+ errors << "default_locale must be included in available_locales"
142
+ end
143
+
144
+ # Validate sitemap
145
+ errors << "sitemap.host is required when sitemap is enabled" if sitemap.enabled && sitemap.host.nil?
146
+
147
+ # Validate meta tags lengths
148
+ if meta_tags.default_title && meta_tags.default_title.length > 60
149
+ errors << "meta_tags.default_title should be max 60 characters"
150
+ end
151
+
152
+ if meta_tags.default_description && meta_tags.default_description.length > 160
153
+ errors << "meta_tags.default_description should be max 160 characters"
154
+ end
155
+
156
+ raise ValidationError, errors.join(", ") if errors.any?
157
+
158
+ true
159
+ end
160
+
161
+ # Feature enabled checks
162
+ def sitemap_enabled?
163
+ sitemap.enabled == true
164
+ end
165
+
166
+ def robots_enabled?
167
+ robots.enabled == true
168
+ end
169
+
170
+ def images_enabled?
171
+ images.enabled == true
172
+ end
173
+
174
+ def open_graph_enabled?
175
+ open_graph.enabled == true
176
+ end
177
+
178
+ def twitter_enabled?
179
+ twitter.enabled == true
180
+ end
181
+
182
+ def structured_data_enabled?
183
+ structured_data.enabled == true
184
+ end
185
+
186
+ # Convert to hash
187
+ def to_h
188
+ {
189
+ site_name: site_name,
190
+ default_locale: default_locale,
191
+ available_locales: available_locales,
192
+ meta_tags: meta_tags.to_h,
193
+ open_graph: open_graph.to_h,
194
+ twitter: twitter.to_h,
195
+ structured_data: structured_data.to_h,
196
+ sitemap: sitemap.to_h,
197
+ robots: robots.to_h,
198
+ images: images.to_h,
199
+ i18n: i18n.to_h
200
+ }
201
+ end
202
+
203
+ private
204
+
205
+ def merge_hash!(hash)
206
+ hash = hash.with_indifferent_access if hash.respond_to?(:with_indifferent_access)
207
+
208
+ # Merge top-level attributes
209
+ @site_name = hash[:site_name] if hash.key?(:site_name)
210
+ @default_locale = hash[:default_locale] if hash.key?(:default_locale)
211
+ @available_locales = hash[:available_locales] if hash.key?(:available_locales)
212
+
213
+ # Merge nested configurations
214
+ @meta_tags.merge!(hash[:meta_tags]) if hash[:meta_tags]
215
+ @open_graph.merge!(hash[:open_graph]) if hash[:open_graph]
216
+ @twitter.merge!(hash[:twitter]) if hash[:twitter]
217
+ @structured_data.merge!(hash[:structured_data]) if hash[:structured_data]
218
+ @sitemap.merge!(hash[:sitemap]) if hash[:sitemap]
219
+ @robots.merge!(hash[:robots]) if hash[:robots]
220
+ @images.merge!(hash[:images]) if hash[:images]
221
+ @i18n.merge!(hash[:i18n]) if hash[:i18n]
222
+ end
223
+
224
+ def deep_dup(hash)
225
+ hash.transform_values do |value|
226
+ if value.is_a?(Hash)
227
+ deep_dup(value)
228
+ else
229
+ begin
230
+ value.dup
231
+ rescue StandardError
232
+ value
233
+ end
234
+ end
235
+ end
236
+ end
237
+
238
+ # Nested configuration object
239
+ class NestedConfiguration
240
+ def initialize(hash = {})
241
+ @data = hash.with_indifferent_access
242
+ # Wrap nested hashes in NestedConfiguration objects for deep setter support
243
+ @data.each do |key, value|
244
+ @data[key] = NestedConfiguration.new(value) if value.is_a?(Hash) && !value.is_a?(NestedConfiguration)
245
+ end
246
+ define_accessors!
247
+ end
248
+
249
+ def merge!(other_hash)
250
+ @data.deep_merge!(other_hash.with_indifferent_access)
251
+ # Wrap any new nested hashes in NestedConfiguration objects
252
+ @data.each do |key, value|
253
+ @data[key] = NestedConfiguration.new(value) if value.is_a?(Hash) && !value.is_a?(NestedConfiguration)
254
+ end
255
+ define_accessors!
256
+ self
257
+ end
258
+
259
+ def to_h
260
+ # Recursively convert NestedConfiguration objects back to plain hashes
261
+ convert_to_hash(@data)
262
+ end
263
+
264
+ def [](key)
265
+ @data[key]
266
+ end
267
+
268
+ def []=(key, value)
269
+ @data[key] = value.is_a?(Hash) ? NestedConfiguration.new(value) : value
270
+ define_accessor(key)
271
+ end
272
+
273
+ def method_missing(method_name, *args, &block)
274
+ method_str = method_name.to_s
275
+
276
+ if method_str.end_with?("=")
277
+ key = method_str.chomp("=").to_sym
278
+ value = args.first
279
+ @data[key] = value.is_a?(Hash) ? NestedConfiguration.new(value) : value
280
+ define_accessor(key)
281
+ elsif @data.key?(method_name)
282
+ @data[method_name]
283
+ else
284
+ super
285
+ end
286
+ end
287
+
288
+ def respond_to_missing?(method_name, include_private = false)
289
+ @data.key?(method_name.to_s.chomp("=").to_sym) || super
290
+ end
291
+
292
+ private
293
+
294
+ def convert_to_hash(value)
295
+ case value
296
+ when NestedConfiguration
297
+ value.to_h
298
+ when Hash
299
+ value.transform_values { |v| convert_to_hash(v) }
300
+ when Array
301
+ value.map { |v| convert_to_hash(v) }
302
+ else
303
+ value
304
+ end
305
+ end
306
+
307
+ def define_accessors!
308
+ @data.each_key { |key| define_accessor(key) }
309
+ end
310
+
311
+ def define_accessor(key)
312
+ # Check if method is actually defined (not just available via method_missing)
313
+ return if singleton_class.method_defined?(key)
314
+
315
+ singleton_class.class_eval do
316
+ define_method(key) { @data[key] }
317
+ define_method("#{key}=") { |value| @data[key] = value }
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module DSL
5
+ class Base
6
+ attr_reader :config
7
+
8
+ def initialize
9
+ @config = {}
10
+ end
11
+
12
+ # Generic setter method
13
+ def set(key, value)
14
+ @config[key] = value
15
+ self
16
+ end
17
+
18
+ # Generic getter method
19
+ def get(key)
20
+ @config[key]
21
+ end
22
+
23
+ # Block evaluation
24
+ def evaluate(&block)
25
+ instance_eval(&block) if block_given?
26
+ self
27
+ end
28
+
29
+ # Build final configuration
30
+ def build
31
+ validate!
32
+ @config.dup
33
+ end
34
+
35
+ # Convert to hash
36
+ def to_h
37
+ @config.dup
38
+ end
39
+
40
+ # Merge another config
41
+ def merge!(other)
42
+ if other.is_a?(Hash)
43
+ @config.merge!(other)
44
+ elsif other.respond_to?(:to_h)
45
+ @config.merge!(other.to_h)
46
+ else
47
+ raise DSLError, "Cannot merge #{other.class}"
48
+ end
49
+ self
50
+ end
51
+
52
+ protected
53
+
54
+ # Override in subclasses for validation
55
+ def validate!
56
+ true
57
+ end
58
+
59
+ # Dynamic method handling
60
+ def method_missing(method_name, *args, &block)
61
+ method_str = method_name.to_s
62
+
63
+ if method_str.end_with?("=")
64
+ # Setter: title = "value"
65
+ key = method_str.chomp("=").to_sym
66
+ set(key, args.first)
67
+ elsif block_given?
68
+ # Nested block: open_graph do ... end
69
+ nested_builder = self.class.new
70
+ nested_builder.evaluate(&block)
71
+ set(method_name, nested_builder.build)
72
+ elsif args.any?
73
+ # Setter without =: title "value"
74
+ set(method_name, args.first)
75
+ else
76
+ # Getter
77
+ get(method_name)
78
+ end
79
+ end
80
+
81
+ def respond_to_missing?(_method_name, _include_private = false)
82
+ true
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module DSL
5
+ class MetaTags < Base
6
+ def title(value = nil)
7
+ value ? set(:title, value) : get(:title)
8
+ end
9
+
10
+ def description(value = nil)
11
+ value ? set(:description, value) : get(:description)
12
+ end
13
+
14
+ def keywords(*values)
15
+ values.any? ? set(:keywords, values.flatten) : get(:keywords)
16
+ end
17
+
18
+ def author(value = nil)
19
+ value ? set(:author, value) : get(:author)
20
+ end
21
+
22
+ def canonical(value = nil)
23
+ value ? set(:canonical, value) : get(:canonical)
24
+ end
25
+
26
+ def robots(index: true, follow: true, **options)
27
+ set(:robots, { index: index, follow: follow }.merge(options))
28
+ end
29
+
30
+ def viewport(value = "width=device-width, initial-scale=1.0")
31
+ set(:viewport, value)
32
+ end
33
+
34
+ def charset(value = "UTF-8")
35
+ set(:charset, value)
36
+ end
37
+
38
+ protected
39
+
40
+ def validate!
41
+ errors = []
42
+
43
+ if config[:title] && config[:title].length > 60
44
+ errors << "Title too long (#{config[:title].length} chars, max 60 recommended)"
45
+ end
46
+
47
+ if config[:description] && config[:description].length > 160
48
+ errors << "Description too long (#{config[:description].length} chars, max 160 recommended)"
49
+ end
50
+
51
+ raise ValidationError, errors.join(", ") if errors.any?
52
+ end
53
+ end
54
+ end
55
+ end