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,157 @@
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
+ @changefreq = value
32
+ end
33
+
34
+ def priority=(value)
35
+ unless value.between?(0.0, 1.0)
36
+ raise ValidationError, "Priority must be between 0.0 and 1.0"
37
+ end
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
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
123
+ raise ValidationError, "Invalid URL format: #{@loc}"
124
+ end
125
+ rescue URI::InvalidURIError
126
+ raise ValidationError, "Invalid URL format: #{@loc}"
127
+ end
128
+
129
+ true
130
+ end
131
+
132
+ private
133
+
134
+ def format_date(value)
135
+ case value
136
+ when String
137
+ value
138
+ when Date
139
+ value.strftime("%Y-%m-%d")
140
+ when Time, DateTime
141
+ value.strftime("%Y-%m-%d")
142
+ else
143
+ value.to_s
144
+ end
145
+ end
146
+
147
+ def escape_xml(text)
148
+ text.to_s
149
+ .gsub("&", "&amp;")
150
+ .gsub("<", "&lt;")
151
+ .gsub(">", "&gt;")
152
+ .gsub('"', "&quot;")
153
+ .gsub("'", "&apos;")
154
+ end
155
+ end
156
+ end
157
+ 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,73 @@
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
+ true
57
+ end
58
+
59
+ private
60
+
61
+ def convert_value(value)
62
+ case value
63
+ when Base
64
+ value.to_h
65
+ when Array
66
+ value.map { |v| convert_value(v) }
67
+ else
68
+ value
69
+ end
70
+ end
71
+ end
72
+ end
73
+ 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,207 @@
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
+ if address
86
+ location_hash["address"] = build_address(address)
87
+ end
88
+
89
+ other_properties.each do |key, value|
90
+ location_hash[key.to_s] = value
91
+ end
92
+
93
+ set(:location, location_hash)
94
+ end
95
+
96
+ # Organizer (Organization or Person)
97
+ def organizer(value)
98
+ organizer_value = if value.is_a?(Base)
99
+ value
100
+ elsif value.is_a?(Hash)
101
+ build_organizer(value)
102
+ else
103
+ value
104
+ end
105
+
106
+ set(:organizer, organizer_value)
107
+ end
108
+
109
+ # Offers
110
+ def offers(value)
111
+ if value.is_a?(Array)
112
+ set(:offers, value.map { |v| build_offer(v) })
113
+ elsif value.is_a?(Hash)
114
+ set(:offers, build_offer(value))
115
+ else
116
+ set(:offers, value)
117
+ end
118
+ end
119
+
120
+ # Performer
121
+ def performer(value)
122
+ if value.is_a?(Array)
123
+ set(:performer, value.map { |v| build_performer(v) })
124
+ elsif value.is_a?(Hash)
125
+ set(:performer, build_performer(value))
126
+ else
127
+ set(:performer, value)
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def format_date(value)
134
+ case value
135
+ when Date
136
+ value.to_s
137
+ when Time, DateTime
138
+ value.iso8601
139
+ else
140
+ value
141
+ end
142
+ end
143
+
144
+ def build_address(address)
145
+ return address unless address.is_a?(Hash)
146
+
147
+ address_hash = {
148
+ "@type" => "PostalAddress"
149
+ }
150
+
151
+ address_hash["streetAddress"] = address[:street] if address[:street]
152
+ address_hash["addressLocality"] = address[:city] if address[:city]
153
+ address_hash["addressRegion"] = address[:region] if address[:region]
154
+ address_hash["postalCode"] = address[:postal_code] if address[:postal_code]
155
+ address_hash["addressCountry"] = address[:country] if address[:country]
156
+
157
+ address_hash
158
+ end
159
+
160
+ def build_organizer(hash)
161
+ organizer = {
162
+ "@type" => hash[:type] || "Organization"
163
+ }
164
+
165
+ organizer["name"] = hash[:name] if hash[:name]
166
+ organizer["url"] = hash[:url] if hash[:url]
167
+ organizer["email"] = hash[:email] if hash[:email]
168
+
169
+ organizer
170
+ end
171
+
172
+ def build_offer(hash)
173
+ offer = {
174
+ "@type" => "Offer"
175
+ }
176
+
177
+ offer["name"] = hash[:name] if hash[:name]
178
+ offer["price"] = hash[:price] if hash[:price]
179
+ offer["priceCurrency"] = hash[:price_currency] if hash[:price_currency]
180
+ offer["url"] = hash[:url] if hash[:url]
181
+ offer["validFrom"] = hash[:valid_from] if hash[:valid_from]
182
+ offer["validThrough"] = hash[:valid_through] if hash[:valid_through]
183
+
184
+ if hash[:availability]
185
+ offer["availability"] = if hash[:availability].start_with?("https://")
186
+ hash[:availability]
187
+ else
188
+ AVAILABILITY_MAPPING[hash[:availability]] || hash[:availability]
189
+ end
190
+ end
191
+
192
+ offer
193
+ end
194
+
195
+ def build_performer(hash)
196
+ performer = {
197
+ "@type" => hash[:type] || "Person"
198
+ }
199
+
200
+ performer["name"] = hash[:name] if hash[:name]
201
+ performer["url"] = hash[:url] if hash[:url]
202
+
203
+ performer
204
+ end
205
+ end
206
+ end
207
+ 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, &block)
14
+ org = Organization.new(**properties)
15
+ yield(org) if block_given?
16
+ org
17
+ end
18
+
19
+ def article(**properties, &block)
20
+ article = Article.new(**properties)
21
+ yield(article) if block_given?
22
+ article
23
+ end
24
+
25
+ def person(**properties, &block)
26
+ person = Person.new(**properties)
27
+ yield(person) if block_given?
28
+ person
29
+ end
30
+
31
+ def product(**properties, &block)
32
+ product = Product.new(**properties)
33
+ yield(product) if block_given?
34
+ product
35
+ end
36
+
37
+ def breadcrumb_list(**properties, &block)
38
+ breadcrumb = BreadcrumbList.new(**properties)
39
+ yield(breadcrumb) if block_given?
40
+ breadcrumb
41
+ end
42
+
43
+ def local_business(**properties, &block)
44
+ business = LocalBusiness.new(**properties)
45
+ yield(business) if block_given?
46
+ business
47
+ end
48
+
49
+ def event(**properties, &block)
50
+ event = Event.new(**properties)
51
+ yield(event) if block_given?
52
+ event
53
+ end
54
+
55
+ def faq_page(**properties, &block)
56
+ faq = FAQPage.new(**properties)
57
+ yield(faq) if block_given?
58
+ faq
59
+ end
60
+
61
+ def how_to(**properties, &block)
62
+ how_to = HowTo.new(**properties)
63
+ yield(how_to) if block_given?
64
+ how_to
65
+ end
66
+
67
+ def recipe(**properties, &block)
68
+ recipe = Recipe.new(**properties)
69
+ yield(recipe) if block_given?
70
+ recipe
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end