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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +297 -179
- 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 +316 -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 +109 -0
- data/lib/better_seo/dsl/twitter_cards.rb +131 -0
- data/lib/better_seo/errors.rb +31 -0
- data/lib/better_seo/generators/amp_generator.rb +83 -0
- data/lib/better_seo/generators/breadcrumbs_generator.rb +126 -0
- data/lib/better_seo/generators/canonical_url_manager.rb +106 -0
- data/lib/better_seo/generators/meta_tags_generator.rb +100 -0
- data/lib/better_seo/generators/open_graph_generator.rb +110 -0
- data/lib/better_seo/generators/robots_txt_generator.rb +102 -0
- data/lib/better_seo/generators/twitter_cards_generator.rb +102 -0
- data/lib/better_seo/image/optimizer.rb +143 -0
- data/lib/better_seo/rails/helpers/controller_helpers.rb +118 -0
- data/lib/better_seo/rails/helpers/seo_helper.rb +176 -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 +157 -0
- data/lib/better_seo/structured_data/article.rb +55 -0
- data/lib/better_seo/structured_data/base.rb +73 -0
- data/lib/better_seo/structured_data/breadcrumb_list.rb +49 -0
- data/lib/better_seo/structured_data/event.rb +207 -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 +135 -0
- data/lib/better_seo/validators/seo_recommendations.rb +165 -0
- data/lib/better_seo/validators/seo_validator.rb +195 -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 +55 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterSeo
|
|
4
|
+
module DSL
|
|
5
|
+
class OpenGraph < 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 type(value = nil)
|
|
15
|
+
value ? set(:type, value) : get(:type)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def url(value = nil)
|
|
19
|
+
value ? set(:url, value) : get(:url)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def image(value = nil)
|
|
23
|
+
return get(:image) if value.nil?
|
|
24
|
+
|
|
25
|
+
if value.is_a?(Hash)
|
|
26
|
+
set(:image, value)
|
|
27
|
+
else
|
|
28
|
+
set(:image, value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def site_name(value = nil)
|
|
33
|
+
value ? set(:site_name, value) : get(:site_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def locale(value = nil)
|
|
37
|
+
value ? set(:locale, value) : get(:locale)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def locale_alternate(*values)
|
|
41
|
+
values.any? ? set(:locale_alternate, values.flatten) : get(:locale_alternate)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def article(&block)
|
|
45
|
+
if block_given?
|
|
46
|
+
article_builder = ArticleBuilder.new
|
|
47
|
+
article_builder.evaluate(&block)
|
|
48
|
+
set(:article, article_builder.build)
|
|
49
|
+
else
|
|
50
|
+
get(:article)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def video(value = nil)
|
|
55
|
+
return get(:video) if value.nil?
|
|
56
|
+
|
|
57
|
+
if value.is_a?(Hash)
|
|
58
|
+
set(:video, value)
|
|
59
|
+
else
|
|
60
|
+
set(:video, value)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def audio(value = nil)
|
|
65
|
+
value ? set(:audio, value) : get(:audio)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
protected
|
|
69
|
+
|
|
70
|
+
def validate!
|
|
71
|
+
errors = []
|
|
72
|
+
|
|
73
|
+
errors << "og:title is required" unless config[:title]
|
|
74
|
+
errors << "og:type is required" unless config[:type]
|
|
75
|
+
errors << "og:image is required" unless config[:image]
|
|
76
|
+
errors << "og:url is required" unless config[:url]
|
|
77
|
+
|
|
78
|
+
raise ValidationError, errors.join(", ") if errors.any?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Article builder for og:article properties
|
|
82
|
+
class ArticleBuilder < Base
|
|
83
|
+
def author(value = nil)
|
|
84
|
+
value ? set(:author, value) : get(:author)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def published_time(value = nil)
|
|
88
|
+
value ? set(:published_time, value) : get(:published_time)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def modified_time(value = nil)
|
|
92
|
+
value ? set(:modified_time, value) : get(:modified_time)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def expiration_time(value = nil)
|
|
96
|
+
value ? set(:expiration_time, value) : get(:expiration_time)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def section(value = nil)
|
|
100
|
+
value ? set(:section, value) : get(:section)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def tag(*values)
|
|
104
|
+
values.any? ? set(:tag, values.flatten) : get(:tag)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterSeo
|
|
4
|
+
module DSL
|
|
5
|
+
class TwitterCards < Base
|
|
6
|
+
VALID_CARD_TYPES = %w[summary summary_large_image app player].freeze
|
|
7
|
+
|
|
8
|
+
def card(value = nil)
|
|
9
|
+
value ? set(:card, value) : get(:card)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def site(value = nil)
|
|
13
|
+
return get(:site) if value.nil?
|
|
14
|
+
|
|
15
|
+
# Ensure @ prefix
|
|
16
|
+
value = "@#{value}" unless value.to_s.start_with?("@")
|
|
17
|
+
set(:site, value)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def creator(value = nil)
|
|
21
|
+
return get(:creator) if value.nil?
|
|
22
|
+
|
|
23
|
+
# Ensure @ prefix
|
|
24
|
+
value = "@#{value}" unless value.to_s.start_with?("@")
|
|
25
|
+
set(:creator, value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def title(value = nil)
|
|
29
|
+
value ? set(:title, value) : get(:title)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def description(value = nil)
|
|
33
|
+
value ? set(:description, value) : get(:description)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def image(value = nil)
|
|
37
|
+
return get(:image) if value.nil?
|
|
38
|
+
|
|
39
|
+
if value.is_a?(Hash)
|
|
40
|
+
set(:image, value)
|
|
41
|
+
else
|
|
42
|
+
set(:image, value)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def image_alt(value = nil)
|
|
47
|
+
value ? set(:image_alt, value) : get(:image_alt)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Player card properties
|
|
51
|
+
def player(value = nil)
|
|
52
|
+
value ? set(:player, value) : get(:player)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def player_width(value = nil)
|
|
56
|
+
value ? set(:player_width, value) : get(:player_width)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def player_height(value = nil)
|
|
60
|
+
value ? set(:player_height, value) : get(:player_height)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def player_stream(value = nil)
|
|
64
|
+
value ? set(:player_stream, value) : get(:player_stream)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# App card properties
|
|
68
|
+
def app_name(value = nil, platform: nil)
|
|
69
|
+
return get(:app_name) if value.nil?
|
|
70
|
+
|
|
71
|
+
if platform
|
|
72
|
+
current = get(:app_name) || {}
|
|
73
|
+
set(:app_name, current.merge(platform => value))
|
|
74
|
+
else
|
|
75
|
+
# Set for all platforms
|
|
76
|
+
set(:app_name, {
|
|
77
|
+
iphone: value,
|
|
78
|
+
ipad: value,
|
|
79
|
+
googleplay: value
|
|
80
|
+
})
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def app_id(value = nil, platform: nil)
|
|
85
|
+
return get(:app_id) if value.nil?
|
|
86
|
+
|
|
87
|
+
current = get(:app_id) || {}
|
|
88
|
+
set(:app_id, current.merge(platform => value))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def app_url(value = nil, platform: nil)
|
|
92
|
+
return get(:app_url) if value.nil?
|
|
93
|
+
|
|
94
|
+
current = get(:app_url) || {}
|
|
95
|
+
set(:app_url, current.merge(platform => value))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
protected
|
|
99
|
+
|
|
100
|
+
def validate!
|
|
101
|
+
errors = []
|
|
102
|
+
|
|
103
|
+
# Validate card type
|
|
104
|
+
card_type = config[:card]
|
|
105
|
+
if card_type && !VALID_CARD_TYPES.include?(card_type)
|
|
106
|
+
errors << "Invalid card type: #{card_type}. Valid types: #{VALID_CARD_TYPES.join(", ")}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Validate required fields
|
|
110
|
+
errors << "twitter:title is required" unless config[:title]
|
|
111
|
+
errors << "twitter:description is required" unless config[:description]
|
|
112
|
+
|
|
113
|
+
# Validate image for summary_large_image
|
|
114
|
+
if card_type == "summary_large_image" && !config[:image]
|
|
115
|
+
errors << "twitter:image is required for summary_large_image card"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate lengths
|
|
119
|
+
if config[:title] && config[:title].length > 70
|
|
120
|
+
errors << "Title too long (#{config[:title].length} chars, max 70 recommended)"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if config[:description] && config[:description].length > 200
|
|
124
|
+
errors << "Description too long (#{config[:description].length} chars, max 200 recommended)"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
raise ValidationError, errors.join(", ") if errors.any?
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterSeo
|
|
4
|
+
# Base error class
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Configuration errors
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
class ValidationError < Error; end
|
|
10
|
+
|
|
11
|
+
# DSL errors
|
|
12
|
+
class DSLError < Error; end
|
|
13
|
+
class InvalidBuilderError < DSLError; end
|
|
14
|
+
|
|
15
|
+
# Generator errors
|
|
16
|
+
class GeneratorError < Error; end
|
|
17
|
+
class TemplateNotFoundError < GeneratorError; end
|
|
18
|
+
|
|
19
|
+
# Validator errors
|
|
20
|
+
class ValidatorError < Error; end
|
|
21
|
+
class InvalidDataError < ValidatorError; end
|
|
22
|
+
|
|
23
|
+
# Image errors
|
|
24
|
+
class ImageError < Error; end
|
|
25
|
+
class ImageConversionError < ImageError; end
|
|
26
|
+
class ImageValidationError < ImageError; end
|
|
27
|
+
|
|
28
|
+
# I18n errors
|
|
29
|
+
class I18nError < Error; end
|
|
30
|
+
class MissingTranslationError < I18nError; end
|
|
31
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterSeo
|
|
6
|
+
module Generators
|
|
7
|
+
class AmpGenerator
|
|
8
|
+
attr_accessor :canonical_url, :title, :description, :image, :structured_data
|
|
9
|
+
|
|
10
|
+
def initialize(canonical_url: nil, title: nil, description: nil, image: nil, structured_data: nil)
|
|
11
|
+
@canonical_url = canonical_url
|
|
12
|
+
@title = title
|
|
13
|
+
@description = description
|
|
14
|
+
@image = image
|
|
15
|
+
@structured_data = structured_data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Generate AMP boilerplate CSS
|
|
19
|
+
def to_boilerplate
|
|
20
|
+
boilerplate = <<~HTML
|
|
21
|
+
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
|
|
22
|
+
HTML
|
|
23
|
+
boilerplate.strip
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Generate AMP runtime script tag
|
|
27
|
+
def to_amp_script_tag
|
|
28
|
+
'<script async src="https://cdn.ampproject.org/v0.js"></script>'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Generate meta tags for AMP page
|
|
32
|
+
def to_meta_tags
|
|
33
|
+
tags = []
|
|
34
|
+
|
|
35
|
+
if @canonical_url
|
|
36
|
+
tags << %(<link rel="canonical" href="#{escape_html(@canonical_url)}">)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if @title
|
|
40
|
+
tags << %(<meta property="og:title" content="#{escape_html(@title)}">)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if @description
|
|
44
|
+
tags << %(<meta name="description" content="#{escape_html(@description)}">)
|
|
45
|
+
tags << %(<meta property="og:description" content="#{escape_html(@description)}">)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if @image
|
|
49
|
+
tags << %(<meta property="og:image" content="#{escape_html(@image)}">)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
tags.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generate structured data script tag
|
|
56
|
+
def to_structured_data
|
|
57
|
+
return "" unless @structured_data
|
|
58
|
+
|
|
59
|
+
%(<script type="application/ld+json">\n#{JSON.generate(@structured_data)}\n</script>)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Wrap custom CSS in amp-custom style tag
|
|
63
|
+
def to_custom_css(css)
|
|
64
|
+
return "" if css.nil? || css.empty?
|
|
65
|
+
|
|
66
|
+
%(<style amp-custom>\n#{css}\n</style>)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def escape_html(text)
|
|
72
|
+
return "" if text.nil?
|
|
73
|
+
|
|
74
|
+
text.to_s
|
|
75
|
+
.gsub("&", "&")
|
|
76
|
+
.gsub("<", "<")
|
|
77
|
+
.gsub(">", ">")
|
|
78
|
+
.gsub('"', """)
|
|
79
|
+
.gsub("'", "'")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterSeo
|
|
6
|
+
module Generators
|
|
7
|
+
class BreadcrumbsGenerator
|
|
8
|
+
attr_reader :items
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@items = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Add single breadcrumb item
|
|
15
|
+
def add_item(name, url)
|
|
16
|
+
@items << { name: name, url: url }
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Add multiple items from array
|
|
21
|
+
def add_items(items_array)
|
|
22
|
+
items_array.each do |item|
|
|
23
|
+
add_item(item[:name], item[:url])
|
|
24
|
+
end
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Clear all items
|
|
29
|
+
def clear
|
|
30
|
+
@items = []
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generate HTML breadcrumbs
|
|
35
|
+
def to_html(schema: false, nav_class: "breadcrumb", list_class: "breadcrumb")
|
|
36
|
+
return "" if @items.empty?
|
|
37
|
+
|
|
38
|
+
html = []
|
|
39
|
+
|
|
40
|
+
if schema
|
|
41
|
+
html << %(<nav class="#{escape_html(nav_class)}" aria-label="breadcrumb" itemscope itemtype="https://schema.org/BreadcrumbList">)
|
|
42
|
+
else
|
|
43
|
+
html << %(<nav class="#{escape_html(nav_class)}" aria-label="breadcrumb">)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
html << %( <ol class="#{escape_html(list_class)}">)
|
|
47
|
+
|
|
48
|
+
@items.each_with_index do |item, index|
|
|
49
|
+
if schema
|
|
50
|
+
html << %( <li class="breadcrumb-item#{item[:url].nil? ? " active" : ""}" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem"#{item[:url].nil? ? ' aria-current="page"' : ""}>)
|
|
51
|
+
else
|
|
52
|
+
html << %( <li class="breadcrumb-item#{item[:url].nil? ? " active" : ""}"#{item[:url].nil? ? ' aria-current="page"' : ""}>)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if item[:url]
|
|
56
|
+
if schema
|
|
57
|
+
html << %( <a href="#{escape_html(item[:url])}" itemprop="item">)
|
|
58
|
+
html << %( <span itemprop="name">#{escape_html(item[:name])}</span>)
|
|
59
|
+
html << %( </a>)
|
|
60
|
+
html << %( <meta itemprop="position" content="#{index + 1}" />)
|
|
61
|
+
else
|
|
62
|
+
html << %( <a href="#{escape_html(item[:url])}">#{escape_html(item[:name])}</a>)
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
if schema
|
|
66
|
+
html << %( <span itemprop="name">#{escape_html(item[:name])}</span>)
|
|
67
|
+
html << %( <meta itemprop="position" content="#{index + 1}" />)
|
|
68
|
+
else
|
|
69
|
+
html << %( #{escape_html(item[:name])})
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
html << %( </li>)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
html << %( </ol>)
|
|
77
|
+
html << %(</nav>)
|
|
78
|
+
|
|
79
|
+
html.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Generate JSON-LD structured data
|
|
83
|
+
def to_json_ld
|
|
84
|
+
return "" if @items.empty?
|
|
85
|
+
|
|
86
|
+
data = {
|
|
87
|
+
"@context" => "https://schema.org",
|
|
88
|
+
"@type" => "BreadcrumbList",
|
|
89
|
+
"itemListElement" => []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@items.each_with_index do |item, index|
|
|
93
|
+
list_item = {
|
|
94
|
+
"@type" => "ListItem",
|
|
95
|
+
"position" => index + 1,
|
|
96
|
+
"name" => item[:name]
|
|
97
|
+
}
|
|
98
|
+
list_item["item"] = item[:url] if item[:url]
|
|
99
|
+
data["itemListElement"] << list_item
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
JSON.generate(data)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Generate script tag with JSON-LD
|
|
106
|
+
def to_script_tag
|
|
107
|
+
return "" if @items.empty?
|
|
108
|
+
|
|
109
|
+
%(<script type="application/ld+json">\n#{to_json_ld}\n</script>)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def escape_html(text)
|
|
115
|
+
return "" if text.nil?
|
|
116
|
+
|
|
117
|
+
text.to_s
|
|
118
|
+
.gsub("&", "&")
|
|
119
|
+
.gsub("<", "<")
|
|
120
|
+
.gsub(">", ">")
|
|
121
|
+
.gsub('"', """)
|
|
122
|
+
.gsub("'", "'")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module BetterSeo
|
|
6
|
+
module Generators
|
|
7
|
+
class CanonicalUrlManager
|
|
8
|
+
attr_reader :url
|
|
9
|
+
attr_accessor :remove_query_params, :lowercase
|
|
10
|
+
|
|
11
|
+
def initialize(url = nil)
|
|
12
|
+
@remove_query_params = false
|
|
13
|
+
@lowercase = false
|
|
14
|
+
self.url = url if url
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def url=(value)
|
|
18
|
+
return @url = nil if value.nil?
|
|
19
|
+
|
|
20
|
+
@url = normalize_url(value)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Generate canonical link HTML tag
|
|
24
|
+
def to_html
|
|
25
|
+
return "" unless @url
|
|
26
|
+
|
|
27
|
+
%(<link rel="canonical" href="#{escape_html(@url)}">)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Generate Link HTTP header
|
|
31
|
+
def to_http_header
|
|
32
|
+
return "" unless @url
|
|
33
|
+
|
|
34
|
+
%(<#{@url}>; rel="canonical")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Validate the canonical URL
|
|
38
|
+
def validate!
|
|
39
|
+
raise ValidationError, "URL is required" if @url.nil? || @url.empty?
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
uri = URI.parse(@url)
|
|
43
|
+
|
|
44
|
+
# Check if it's a relative URL
|
|
45
|
+
unless uri.absolute?
|
|
46
|
+
raise ValidationError, "Canonical URL must be absolute: #{@url}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if it's HTTP or HTTPS
|
|
50
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
51
|
+
raise ValidationError, "Invalid URL format: #{@url}"
|
|
52
|
+
end
|
|
53
|
+
rescue URI::InvalidURIError
|
|
54
|
+
raise ValidationError, "Invalid URL format: #{@url}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def normalize_url(url)
|
|
63
|
+
return url if url.empty?
|
|
64
|
+
|
|
65
|
+
normalized = url.dup
|
|
66
|
+
|
|
67
|
+
# Parse URL
|
|
68
|
+
begin
|
|
69
|
+
uri = URI.parse(normalized)
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
return normalized
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Remove fragment identifier
|
|
75
|
+
uri.fragment = nil
|
|
76
|
+
|
|
77
|
+
# Remove query parameters if configured
|
|
78
|
+
uri.query = nil if @remove_query_params
|
|
79
|
+
|
|
80
|
+
# Rebuild URL
|
|
81
|
+
normalized = uri.to_s
|
|
82
|
+
|
|
83
|
+
# Remove trailing slash (except for root URL)
|
|
84
|
+
if normalized.end_with?("/") && normalized.count("/") > 3
|
|
85
|
+
normalized = normalized[0...-1]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Lowercase if configured
|
|
89
|
+
normalized = normalized.downcase if @lowercase
|
|
90
|
+
|
|
91
|
+
normalized
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def escape_html(text)
|
|
95
|
+
return "" if text.nil?
|
|
96
|
+
|
|
97
|
+
text.to_s
|
|
98
|
+
.gsub("&", "&")
|
|
99
|
+
.gsub("<", "<")
|
|
100
|
+
.gsub(">", ">")
|
|
101
|
+
.gsub('"', """)
|
|
102
|
+
.gsub("'", "'")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterSeo
|
|
4
|
+
module Generators
|
|
5
|
+
class MetaTagsGenerator
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def generate
|
|
11
|
+
tags = []
|
|
12
|
+
tags << charset_tag
|
|
13
|
+
tags << viewport_tag
|
|
14
|
+
tags << title_tag
|
|
15
|
+
tags << description_tag
|
|
16
|
+
tags << keywords_tag
|
|
17
|
+
tags << author_tag
|
|
18
|
+
tags << robots_tag
|
|
19
|
+
tags << canonical_tag
|
|
20
|
+
|
|
21
|
+
tags.compact.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def charset_tag
|
|
25
|
+
return nil unless @config[:charset]
|
|
26
|
+
|
|
27
|
+
%(<meta charset="#{escape(@config[:charset])}">)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def viewport_tag
|
|
31
|
+
return nil unless @config[:viewport]
|
|
32
|
+
|
|
33
|
+
%(<meta name="viewport" content="#{escape(@config[:viewport])}">)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def title_tag
|
|
37
|
+
return nil unless @config[:title]
|
|
38
|
+
|
|
39
|
+
%(<title>#{escape(@config[:title])}</title>)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def description_tag
|
|
43
|
+
return nil unless @config[:description]
|
|
44
|
+
|
|
45
|
+
%(<meta name="description" content="#{escape(@config[:description])}">)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def keywords_tag
|
|
49
|
+
keywords = @config[:keywords]
|
|
50
|
+
return nil unless keywords && keywords.any?
|
|
51
|
+
|
|
52
|
+
keywords_str = Array(keywords).join(", ")
|
|
53
|
+
%(<meta name="keywords" content="#{escape(keywords_str)}">)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def author_tag
|
|
57
|
+
return nil unless @config[:author]
|
|
58
|
+
|
|
59
|
+
%(<meta name="author" content="#{escape(@config[:author])}">)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def canonical_tag
|
|
63
|
+
return nil unless @config[:canonical]
|
|
64
|
+
|
|
65
|
+
%(<link rel="canonical" href="#{escape(@config[:canonical])}">)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def robots_tag
|
|
69
|
+
robots = @config[:robots]
|
|
70
|
+
return nil unless robots
|
|
71
|
+
|
|
72
|
+
directives = []
|
|
73
|
+
|
|
74
|
+
# Index/noindex
|
|
75
|
+
directives << (robots[:index] ? "index" : "noindex")
|
|
76
|
+
|
|
77
|
+
# Follow/nofollow
|
|
78
|
+
directives << (robots[:follow] ? "follow" : "nofollow")
|
|
79
|
+
|
|
80
|
+
# Additional directives
|
|
81
|
+
robots.each do |key, value|
|
|
82
|
+
next if [:index, :follow].include?(key)
|
|
83
|
+
directives << key.to_s if value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
%(<meta name="robots" content="#{directives.join(", ")}">)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def escape(text)
|
|
92
|
+
text.to_s
|
|
93
|
+
.gsub("&", "&")
|
|
94
|
+
.gsub('"', """)
|
|
95
|
+
.gsub("<", "<")
|
|
96
|
+
.gsub(">", ">")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|