better_seo 0.14.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 +61 -0
  3. data/README.md +297 -179
  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 +1 -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 +55 -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,316 @@
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
+ if sitemap.enabled && sitemap.host.nil?
146
+ errors << "sitemap.host is required when sitemap is enabled"
147
+ end
148
+
149
+ # Validate meta tags lengths
150
+ if meta_tags.default_title && meta_tags.default_title.length > 60
151
+ errors << "meta_tags.default_title should be max 60 characters"
152
+ end
153
+
154
+ if meta_tags.default_description && meta_tags.default_description.length > 160
155
+ errors << "meta_tags.default_description should be max 160 characters"
156
+ end
157
+
158
+ raise ValidationError, errors.join(", ") if errors.any?
159
+
160
+ true
161
+ end
162
+
163
+ # Feature enabled checks
164
+ def sitemap_enabled?
165
+ sitemap.enabled == true
166
+ end
167
+
168
+ def robots_enabled?
169
+ robots.enabled == true
170
+ end
171
+
172
+ def images_enabled?
173
+ images.enabled == true
174
+ end
175
+
176
+ def open_graph_enabled?
177
+ open_graph.enabled == true
178
+ end
179
+
180
+ def twitter_enabled?
181
+ twitter.enabled == true
182
+ end
183
+
184
+ def structured_data_enabled?
185
+ structured_data.enabled == true
186
+ end
187
+
188
+ # Convert to hash
189
+ def to_h
190
+ {
191
+ site_name: site_name,
192
+ default_locale: default_locale,
193
+ available_locales: available_locales,
194
+ meta_tags: meta_tags.to_h,
195
+ open_graph: open_graph.to_h,
196
+ twitter: twitter.to_h,
197
+ structured_data: structured_data.to_h,
198
+ sitemap: sitemap.to_h,
199
+ robots: robots.to_h,
200
+ images: images.to_h,
201
+ i18n: i18n.to_h
202
+ }
203
+ end
204
+
205
+ private
206
+
207
+ def merge_hash!(hash)
208
+ hash = hash.with_indifferent_access if hash.respond_to?(:with_indifferent_access)
209
+
210
+ # Merge top-level attributes
211
+ @site_name = hash[:site_name] if hash.key?(:site_name)
212
+ @default_locale = hash[:default_locale] if hash.key?(:default_locale)
213
+ @available_locales = hash[:available_locales] if hash.key?(:available_locales)
214
+
215
+ # Merge nested configurations
216
+ @meta_tags.merge!(hash[:meta_tags]) if hash[:meta_tags]
217
+ @open_graph.merge!(hash[:open_graph]) if hash[:open_graph]
218
+ @twitter.merge!(hash[:twitter]) if hash[:twitter]
219
+ @structured_data.merge!(hash[:structured_data]) if hash[:structured_data]
220
+ @sitemap.merge!(hash[:sitemap]) if hash[:sitemap]
221
+ @robots.merge!(hash[:robots]) if hash[:robots]
222
+ @images.merge!(hash[:images]) if hash[:images]
223
+ @i18n.merge!(hash[:i18n]) if hash[:i18n]
224
+ end
225
+
226
+ def deep_dup(hash)
227
+ hash.transform_values do |value|
228
+ value.is_a?(Hash) ? deep_dup(value) : (value.dup rescue value)
229
+ end
230
+ end
231
+
232
+ # Nested configuration object
233
+ class NestedConfiguration
234
+ def initialize(hash = {})
235
+ @data = hash.with_indifferent_access
236
+ # Wrap nested hashes in NestedConfiguration objects for deep setter support
237
+ @data.each do |key, value|
238
+ @data[key] = NestedConfiguration.new(value) if value.is_a?(Hash) && !value.is_a?(NestedConfiguration)
239
+ end
240
+ define_accessors!
241
+ end
242
+
243
+ def merge!(other_hash)
244
+ @data.deep_merge!(other_hash.with_indifferent_access)
245
+ # Wrap any new nested hashes in NestedConfiguration objects
246
+ @data.each do |key, value|
247
+ @data[key] = NestedConfiguration.new(value) if value.is_a?(Hash) && !value.is_a?(NestedConfiguration)
248
+ end
249
+ define_accessors!
250
+ self
251
+ end
252
+
253
+ def to_h
254
+ # Recursively convert NestedConfiguration objects back to plain hashes
255
+ convert_to_hash(@data)
256
+ end
257
+
258
+ def [](key)
259
+ @data[key]
260
+ end
261
+
262
+ def []=(key, value)
263
+ @data[key] = value.is_a?(Hash) ? NestedConfiguration.new(value) : value
264
+ define_accessor(key)
265
+ end
266
+
267
+ def method_missing(method_name, *args, &block)
268
+ method_str = method_name.to_s
269
+
270
+ if method_str.end_with?("=")
271
+ key = method_str.chomp("=").to_sym
272
+ value = args.first
273
+ @data[key] = value.is_a?(Hash) ? NestedConfiguration.new(value) : value
274
+ define_accessor(key)
275
+ elsif @data.key?(method_name)
276
+ @data[method_name]
277
+ else
278
+ super
279
+ end
280
+ end
281
+
282
+ def respond_to_missing?(method_name, include_private = false)
283
+ @data.key?(method_name.to_s.chomp("=").to_sym) || super
284
+ end
285
+
286
+ private
287
+
288
+ def convert_to_hash(value)
289
+ case value
290
+ when NestedConfiguration
291
+ value.to_h
292
+ when Hash
293
+ value.transform_values { |v| convert_to_hash(v) }
294
+ when Array
295
+ value.map { |v| convert_to_hash(v) }
296
+ else
297
+ value
298
+ end
299
+ end
300
+
301
+ def define_accessors!
302
+ @data.keys.each { |key| define_accessor(key) }
303
+ end
304
+
305
+ def define_accessor(key)
306
+ # Check if method is actually defined (not just available via method_missing)
307
+ return if singleton_class.method_defined?(key)
308
+
309
+ singleton_class.class_eval do
310
+ define_method(key) { @data[key] }
311
+ define_method("#{key}=") { |value| @data[key] = value }
312
+ end
313
+ end
314
+ end
315
+ end
316
+ 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