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.
- checksums.yaml +4 -4
- data/.rubocop.yml +30 -0
- data/.rubocop_todo.yml +360 -0
- data/CHANGELOG.md +105 -0
- data/README.md +280 -180
- data/docs/00_OVERVIEW.md +472 -0
- data/docs/01_CORE_AND_CONFIGURATION.md +913 -0
- data/docs/02_META_TAGS_AND_OPEN_GRAPH.md +251 -0
- data/docs/03_STRUCTURED_DATA.md +140 -0
- data/docs/04_SITEMAP_AND_ROBOTS.md +131 -0
- data/docs/05_RAILS_INTEGRATION.md +175 -0
- data/docs/06_I18N_PAGE_GENERATOR.md +233 -0
- data/docs/07_IMAGE_OPTIMIZATION.md +260 -0
- data/docs/DEPENDENCIES.md +383 -0
- data/docs/README.md +180 -0
- data/docs/TESTING_STRATEGY.md +663 -0
- data/lib/better_seo/analytics/google_analytics.rb +83 -0
- data/lib/better_seo/analytics/google_tag_manager.rb +74 -0
- data/lib/better_seo/configuration.rb +322 -0
- data/lib/better_seo/dsl/base.rb +86 -0
- data/lib/better_seo/dsl/meta_tags.rb +55 -0
- data/lib/better_seo/dsl/open_graph.rb +105 -0
- data/lib/better_seo/dsl/twitter_cards.rb +129 -0
- data/lib/better_seo/errors.rb +31 -0
- data/lib/better_seo/generators/amp_generator.rb +77 -0
- data/lib/better_seo/generators/breadcrumbs_generator.rb +127 -0
- data/lib/better_seo/generators/canonical_url_manager.rb +100 -0
- data/lib/better_seo/generators/meta_tags_generator.rb +101 -0
- data/lib/better_seo/generators/open_graph_generator.rb +110 -0
- data/lib/better_seo/generators/robots_txt_generator.rb +96 -0
- data/lib/better_seo/generators/twitter_cards_generator.rb +102 -0
- data/lib/better_seo/image/optimizer.rb +145 -0
- data/lib/better_seo/rails/helpers/controller_helpers.rb +120 -0
- data/lib/better_seo/rails/helpers/seo_helper.rb +172 -0
- data/lib/better_seo/rails/helpers/structured_data_helper.rb +123 -0
- data/lib/better_seo/rails/model_helpers.rb +62 -0
- data/lib/better_seo/rails/railtie.rb +22 -0
- data/lib/better_seo/sitemap/builder.rb +65 -0
- data/lib/better_seo/sitemap/generator.rb +57 -0
- data/lib/better_seo/sitemap/sitemap_index.rb +73 -0
- data/lib/better_seo/sitemap/url_entry.rb +155 -0
- data/lib/better_seo/structured_data/article.rb +55 -0
- data/lib/better_seo/structured_data/base.rb +74 -0
- data/lib/better_seo/structured_data/breadcrumb_list.rb +49 -0
- data/lib/better_seo/structured_data/event.rb +205 -0
- data/lib/better_seo/structured_data/faq_page.rb +55 -0
- data/lib/better_seo/structured_data/generator.rb +75 -0
- data/lib/better_seo/structured_data/how_to.rb +96 -0
- data/lib/better_seo/structured_data/local_business.rb +94 -0
- data/lib/better_seo/structured_data/organization.rb +67 -0
- data/lib/better_seo/structured_data/person.rb +51 -0
- data/lib/better_seo/structured_data/product.rb +123 -0
- data/lib/better_seo/structured_data/recipe.rb +134 -0
- data/lib/better_seo/validators/seo_recommendations.rb +165 -0
- data/lib/better_seo/validators/seo_validator.rb +205 -0
- data/lib/better_seo/version.rb +1 -1
- data/lib/better_seo.rb +1 -0
- data/lib/generators/better_seo/install_generator.rb +21 -0
- data/lib/generators/better_seo/templates/README +29 -0
- data/lib/generators/better_seo/templates/better_seo.rb +40 -0
- 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("&", "&")
|
|
148
|
+
.gsub("<", "<")
|
|
149
|
+
.gsub(">", ">")
|
|
150
|
+
.gsub('"', """)
|
|
151
|
+
.gsub("'", "'")
|
|
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
|