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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "date"
5
+
6
+ module BetterSeo
7
+ module Sitemap
8
+ class UrlEntry
9
+ VALID_CHANGEFREQ = %w[always hourly daily weekly monthly yearly never].freeze
10
+
11
+ attr_reader :loc, :lastmod, :changefreq, :priority, :alternates, :images, :videos
12
+
13
+ def initialize(loc, lastmod: nil, changefreq: "weekly", priority: 0.5)
14
+ @loc = loc
15
+ @lastmod = format_date(lastmod) if lastmod
16
+ @changefreq = changefreq
17
+ @priority = priority
18
+ @alternates = []
19
+ @images = []
20
+ @videos = []
21
+ end
22
+
23
+ def lastmod=(value)
24
+ @lastmod = format_date(value)
25
+ end
26
+
27
+ def changefreq=(value)
28
+ unless VALID_CHANGEFREQ.include?(value)
29
+ raise ValidationError, "Invalid changefreq: #{value}. Must be one of: #{VALID_CHANGEFREQ.join(", ")}"
30
+ end
31
+
32
+ @changefreq = value
33
+ end
34
+
35
+ def priority=(value)
36
+ raise ValidationError, "Priority must be between 0.0 and 1.0" unless value.between?(0.0, 1.0)
37
+
38
+ @priority = value
39
+ end
40
+
41
+ # Add alternate language version (hreflang)
42
+ def add_alternate(href, hreflang:)
43
+ @alternates << { href: href, hreflang: hreflang }
44
+ self
45
+ end
46
+
47
+ # Add image
48
+ def add_image(loc, title: nil, caption: nil)
49
+ image = { loc: loc }
50
+ image[:title] = title if title
51
+ image[:caption] = caption if caption
52
+ @images << image
53
+ self
54
+ end
55
+
56
+ # Add video
57
+ def add_video(thumbnail_loc:, title:, description:, content_loc:, duration: nil)
58
+ video = {
59
+ thumbnail_loc: thumbnail_loc,
60
+ title: title,
61
+ description: description,
62
+ content_loc: content_loc
63
+ }
64
+ video[:duration] = duration if duration
65
+ @videos << video
66
+ self
67
+ end
68
+
69
+ def to_xml
70
+ xml = []
71
+ xml << " <url>"
72
+ xml << " <loc>#{escape_xml(@loc)}</loc>"
73
+ xml << " <lastmod>#{@lastmod}</lastmod>" if @lastmod
74
+ xml << " <changefreq>#{@changefreq}</changefreq>"
75
+ xml << " <priority>#{@priority}</priority>"
76
+
77
+ # Add images
78
+ @images.each do |image|
79
+ xml << " <image:image>"
80
+ xml << " <image:loc>#{escape_xml(image[:loc])}</image:loc>"
81
+ xml << " <image:title>#{escape_xml(image[:title])}</image:title>" if image[:title]
82
+ xml << " <image:caption>#{escape_xml(image[:caption])}</image:caption>" if image[:caption]
83
+ xml << " </image:image>"
84
+ end
85
+
86
+ # Add videos
87
+ @videos.each do |video|
88
+ xml << " <video:video>"
89
+ xml << " <video:thumbnail_loc>#{escape_xml(video[:thumbnail_loc])}</video:thumbnail_loc>"
90
+ xml << " <video:title>#{escape_xml(video[:title])}</video:title>"
91
+ xml << " <video:description>#{escape_xml(video[:description])}</video:description>"
92
+ xml << " <video:content_loc>#{escape_xml(video[:content_loc])}</video:content_loc>"
93
+ xml << " <video:duration>#{video[:duration]}</video:duration>" if video[:duration]
94
+ xml << " </video:video>"
95
+ end
96
+
97
+ # Add hreflang alternates
98
+ @alternates.each do |alternate|
99
+ xml << " <xhtml:link rel=\"alternate\" hreflang=\"#{alternate[:hreflang]}\" href=\"#{escape_xml(alternate[:href])}\" />"
100
+ end
101
+
102
+ xml << " </url>"
103
+ xml.join("\n")
104
+ end
105
+
106
+ def to_h
107
+ hash = {
108
+ loc: @loc,
109
+ changefreq: @changefreq,
110
+ priority: @priority
111
+ }
112
+ hash[:lastmod] = @lastmod if @lastmod
113
+ hash[:alternates] = @alternates if @alternates.any?
114
+ hash
115
+ end
116
+
117
+ def validate!
118
+ raise ValidationError, "Location is required" if @loc.nil? || @loc.empty?
119
+
120
+ begin
121
+ uri = URI.parse(@loc)
122
+ raise ValidationError, "Invalid URL format: #{@loc}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
123
+ rescue URI::InvalidURIError
124
+ raise ValidationError, "Invalid URL format: #{@loc}"
125
+ end
126
+
127
+ true
128
+ end
129
+
130
+ private
131
+
132
+ def format_date(value)
133
+ case value
134
+ when String
135
+ value
136
+ when Date
137
+ value.strftime("%Y-%m-%d")
138
+ when Time, DateTime
139
+ value.strftime("%Y-%m-%d")
140
+ else
141
+ value.to_s
142
+ end
143
+ end
144
+
145
+ def escape_xml(text)
146
+ text.to_s
147
+ .gsub("&", "&amp;")
148
+ .gsub("<", "&lt;")
149
+ .gsub(">", "&gt;")
150
+ .gsub('"', "&quot;")
151
+ .gsub("'", "&apos;")
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module StructuredData
5
+ class Article < Base
6
+ def initialize(**properties)
7
+ super("Article", **properties)
8
+ end
9
+
10
+ def headline(value)
11
+ set(:headline, value)
12
+ end
13
+
14
+ def description(value)
15
+ set(:description, value)
16
+ end
17
+
18
+ def image(value)
19
+ set(:image, value)
20
+ end
21
+
22
+ def author(value)
23
+ set(:author, value)
24
+ end
25
+
26
+ def publisher(value)
27
+ set(:publisher, value)
28
+ end
29
+
30
+ def date_published(value)
31
+ set(:datePublished, value)
32
+ end
33
+
34
+ def date_modified(value)
35
+ set(:dateModified, value)
36
+ end
37
+
38
+ def url(value)
39
+ set(:url, value)
40
+ end
41
+
42
+ def word_count(value)
43
+ set(:wordCount, value)
44
+ end
45
+
46
+ def keywords(value)
47
+ set(:keywords, value)
48
+ end
49
+
50
+ def article_section(value)
51
+ set(:articleSection, value)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterSeo
6
+ module StructuredData
7
+ class Base
8
+ attr_reader :type, :properties
9
+
10
+ def initialize(type, **properties)
11
+ @type = type
12
+ @properties = {}
13
+ properties.each { |key, value| set(key, value) }
14
+ end
15
+
16
+ def set(key, value)
17
+ @properties[key] = value unless value.nil?
18
+ self
19
+ end
20
+
21
+ def get(key)
22
+ @properties[key]
23
+ end
24
+
25
+ def to_h
26
+ hash = {
27
+ "@context" => "https://schema.org",
28
+ "@type" => @type
29
+ }
30
+
31
+ @properties.each do |key, value|
32
+ hash[key.to_s] = convert_value(value)
33
+ end
34
+
35
+ hash
36
+ end
37
+
38
+ def to_json(*_args)
39
+ JSON.pretty_generate(to_h)
40
+ end
41
+
42
+ def to_script_tag
43
+ <<~HTML.strip
44
+ <script type="application/ld+json">
45
+ #{to_json}
46
+ </script>
47
+ HTML
48
+ end
49
+
50
+ def valid?
51
+ !@type.nil? && !@type.to_s.empty?
52
+ end
53
+
54
+ def validate!
55
+ raise ValidationError, "@type is required for structured data" unless valid?
56
+
57
+ true
58
+ end
59
+
60
+ private
61
+
62
+ def convert_value(value)
63
+ case value
64
+ when Base
65
+ value.to_h
66
+ when Array
67
+ value.map { |v| convert_value(v) }
68
+ else
69
+ value
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module StructuredData
5
+ class BreadcrumbList < Base
6
+ attr_reader :items
7
+
8
+ def initialize(**properties)
9
+ super("BreadcrumbList", **properties)
10
+ @items = []
11
+ end
12
+
13
+ def add_item(name:, url:, position: nil)
14
+ position ||= @items.size + 1
15
+ @items << { name: name, url: url, position: position }
16
+ self
17
+ end
18
+
19
+ def add_items(items_array)
20
+ items_array.each do |item|
21
+ add_item(
22
+ name: item[:name],
23
+ url: item[:url],
24
+ position: item[:position]
25
+ )
26
+ end
27
+ self
28
+ end
29
+
30
+ def clear
31
+ @items = []
32
+ self
33
+ end
34
+
35
+ def to_h
36
+ hash = super
37
+ hash["itemListElement"] = @items.map do |item|
38
+ {
39
+ "@type" => "ListItem",
40
+ "position" => item[:position],
41
+ "name" => item[:name],
42
+ "item" => item[:url]
43
+ }
44
+ end
45
+ hash
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module StructuredData
5
+ class Event < Base
6
+ EVENT_STATUS_MAPPING = {
7
+ "EventScheduled" => "https://schema.org/EventScheduled",
8
+ "EventCancelled" => "https://schema.org/EventCancelled",
9
+ "EventPostponed" => "https://schema.org/EventPostponed",
10
+ "EventRescheduled" => "https://schema.org/EventRescheduled"
11
+ }.freeze
12
+
13
+ ATTENDANCE_MODE_MAPPING = {
14
+ "OfflineEventAttendanceMode" => "https://schema.org/OfflineEventAttendanceMode",
15
+ "OnlineEventAttendanceMode" => "https://schema.org/OnlineEventAttendanceMode",
16
+ "MixedEventAttendanceMode" => "https://schema.org/MixedEventAttendanceMode"
17
+ }.freeze
18
+
19
+ AVAILABILITY_MAPPING = {
20
+ "InStock" => "https://schema.org/InStock",
21
+ "SoldOut" => "https://schema.org/SoldOut",
22
+ "PreOrder" => "https://schema.org/PreOrder"
23
+ }.freeze
24
+
25
+ def initialize(**properties)
26
+ super("Event", **properties)
27
+ end
28
+
29
+ # Basic properties
30
+ def name(value)
31
+ set(:name, value)
32
+ end
33
+
34
+ def description(value)
35
+ set(:description, value)
36
+ end
37
+
38
+ def url(value)
39
+ set(:url, value)
40
+ end
41
+
42
+ def image(value)
43
+ set(:image, value)
44
+ end
45
+
46
+ # Date and time
47
+ def start_date(value)
48
+ formatted_value = format_date(value)
49
+ set(:startDate, formatted_value)
50
+ end
51
+
52
+ def end_date(value)
53
+ formatted_value = format_date(value)
54
+ set(:endDate, formatted_value)
55
+ end
56
+
57
+ # Event status
58
+ def event_status(value)
59
+ status_value = if value.start_with?("https://schema.org/")
60
+ value
61
+ else
62
+ EVENT_STATUS_MAPPING[value] || value
63
+ end
64
+ set(:eventStatus, status_value)
65
+ end
66
+
67
+ # Event attendance mode
68
+ def event_attendance_mode(value)
69
+ mode_value = if value.start_with?("https://schema.org/")
70
+ value
71
+ else
72
+ ATTENDANCE_MODE_MAPPING[value] || value
73
+ end
74
+ set(:eventAttendanceMode, mode_value)
75
+ end
76
+
77
+ # Location (Place or VirtualLocation)
78
+ def location(type: nil, name: nil, address: nil, url: nil, **other_properties)
79
+ location_hash = {}
80
+
81
+ location_hash["@type"] = type if type
82
+ location_hash["name"] = name if name
83
+ location_hash["url"] = url if url
84
+
85
+ location_hash["address"] = build_address(address) if address
86
+
87
+ other_properties.each do |key, value|
88
+ location_hash[key.to_s] = value
89
+ end
90
+
91
+ set(:location, location_hash)
92
+ end
93
+
94
+ # Organizer (Organization or Person)
95
+ def organizer(value)
96
+ organizer_value = if value.is_a?(Base)
97
+ value
98
+ elsif value.is_a?(Hash)
99
+ build_organizer(value)
100
+ else
101
+ value
102
+ end
103
+
104
+ set(:organizer, organizer_value)
105
+ end
106
+
107
+ # Offers
108
+ def offers(value)
109
+ if value.is_a?(Array)
110
+ set(:offers, value.map { |v| build_offer(v) })
111
+ elsif value.is_a?(Hash)
112
+ set(:offers, build_offer(value))
113
+ else
114
+ set(:offers, value)
115
+ end
116
+ end
117
+
118
+ # Performer
119
+ def performer(value)
120
+ if value.is_a?(Array)
121
+ set(:performer, value.map { |v| build_performer(v) })
122
+ elsif value.is_a?(Hash)
123
+ set(:performer, build_performer(value))
124
+ else
125
+ set(:performer, value)
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def format_date(value)
132
+ case value
133
+ when Date
134
+ value.to_s
135
+ when Time, DateTime
136
+ value.iso8601
137
+ else
138
+ value
139
+ end
140
+ end
141
+
142
+ def build_address(address)
143
+ return address unless address.is_a?(Hash)
144
+
145
+ address_hash = {
146
+ "@type" => "PostalAddress"
147
+ }
148
+
149
+ address_hash["streetAddress"] = address[:street] if address[:street]
150
+ address_hash["addressLocality"] = address[:city] if address[:city]
151
+ address_hash["addressRegion"] = address[:region] if address[:region]
152
+ address_hash["postalCode"] = address[:postal_code] if address[:postal_code]
153
+ address_hash["addressCountry"] = address[:country] if address[:country]
154
+
155
+ address_hash
156
+ end
157
+
158
+ def build_organizer(hash)
159
+ organizer = {
160
+ "@type" => hash[:type] || "Organization"
161
+ }
162
+
163
+ organizer["name"] = hash[:name] if hash[:name]
164
+ organizer["url"] = hash[:url] if hash[:url]
165
+ organizer["email"] = hash[:email] if hash[:email]
166
+
167
+ organizer
168
+ end
169
+
170
+ def build_offer(hash)
171
+ offer = {
172
+ "@type" => "Offer"
173
+ }
174
+
175
+ offer["name"] = hash[:name] if hash[:name]
176
+ offer["price"] = hash[:price] if hash[:price]
177
+ offer["priceCurrency"] = hash[:price_currency] if hash[:price_currency]
178
+ offer["url"] = hash[:url] if hash[:url]
179
+ offer["validFrom"] = hash[:valid_from] if hash[:valid_from]
180
+ offer["validThrough"] = hash[:valid_through] if hash[:valid_through]
181
+
182
+ if hash[:availability]
183
+ offer["availability"] = if hash[:availability].start_with?("https://")
184
+ hash[:availability]
185
+ else
186
+ AVAILABILITY_MAPPING[hash[:availability]] || hash[:availability]
187
+ end
188
+ end
189
+
190
+ offer
191
+ end
192
+
193
+ def build_performer(hash)
194
+ performer = {
195
+ "@type" => hash[:type] || "Person"
196
+ }
197
+
198
+ performer["name"] = hash[:name] if hash[:name]
199
+ performer["url"] = hash[:url] if hash[:url]
200
+
201
+ performer
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module StructuredData
5
+ class FAQPage < Base
6
+ attr_reader :questions
7
+
8
+ def initialize(**properties)
9
+ super("FAQPage", **properties)
10
+ @questions = []
11
+ end
12
+
13
+ # Add a single question
14
+ def add_question(question:, answer:)
15
+ @questions << { question: question, answer: answer }
16
+ self
17
+ end
18
+
19
+ # Add multiple questions from an array
20
+ def add_questions(questions_array)
21
+ questions_array.each do |q|
22
+ add_question(question: q[:question], answer: q[:answer])
23
+ end
24
+ self
25
+ end
26
+
27
+ # Clear all questions
28
+ def clear
29
+ @questions = []
30
+ @properties.delete(:mainEntity)
31
+ self
32
+ end
33
+
34
+ # Override to_h to include mainEntity
35
+ def to_h
36
+ hash = super
37
+
38
+ if @questions.any?
39
+ hash["mainEntity"] = @questions.map do |q|
40
+ {
41
+ "@type" => "Question",
42
+ "name" => q[:question],
43
+ "acceptedAnswer" => {
44
+ "@type" => "Answer",
45
+ "text" => q[:answer]
46
+ }
47
+ }
48
+ end
49
+ end
50
+
51
+ hash
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterSeo
4
+ module StructuredData
5
+ class Generator
6
+ class << self
7
+ def generate_script_tags(structured_data_array)
8
+ return "" if structured_data_array.empty?
9
+
10
+ structured_data_array.map(&:to_script_tag).join("\n\n")
11
+ end
12
+
13
+ def organization(**properties)
14
+ org = Organization.new(**properties)
15
+ yield(org) if block_given?
16
+ org
17
+ end
18
+
19
+ def article(**properties)
20
+ article = Article.new(**properties)
21
+ yield(article) if block_given?
22
+ article
23
+ end
24
+
25
+ def person(**properties)
26
+ person = Person.new(**properties)
27
+ yield(person) if block_given?
28
+ person
29
+ end
30
+
31
+ def product(**properties)
32
+ product = Product.new(**properties)
33
+ yield(product) if block_given?
34
+ product
35
+ end
36
+
37
+ def breadcrumb_list(**properties)
38
+ breadcrumb = BreadcrumbList.new(**properties)
39
+ yield(breadcrumb) if block_given?
40
+ breadcrumb
41
+ end
42
+
43
+ def local_business(**properties)
44
+ business = LocalBusiness.new(**properties)
45
+ yield(business) if block_given?
46
+ business
47
+ end
48
+
49
+ def event(**properties)
50
+ event = Event.new(**properties)
51
+ yield(event) if block_given?
52
+ event
53
+ end
54
+
55
+ def faq_page(**properties)
56
+ faq = FAQPage.new(**properties)
57
+ yield(faq) if block_given?
58
+ faq
59
+ end
60
+
61
+ def how_to(**properties)
62
+ how_to = HowTo.new(**properties)
63
+ yield(how_to) if block_given?
64
+ how_to
65
+ end
66
+
67
+ def recipe(**properties)
68
+ recipe = Recipe.new(**properties)
69
+ yield(recipe) if block_given?
70
+ recipe
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end