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,105 @@
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
+ end
27
+ set(:image, value)
28
+ end
29
+
30
+ def site_name(value = nil)
31
+ value ? set(:site_name, value) : get(:site_name)
32
+ end
33
+
34
+ def locale(value = nil)
35
+ value ? set(:locale, value) : get(:locale)
36
+ end
37
+
38
+ def locale_alternate(*values)
39
+ values.any? ? set(:locale_alternate, values.flatten) : get(:locale_alternate)
40
+ end
41
+
42
+ def article(&block)
43
+ if block_given?
44
+ article_builder = ArticleBuilder.new
45
+ article_builder.evaluate(&block)
46
+ set(:article, article_builder.build)
47
+ else
48
+ get(:article)
49
+ end
50
+ end
51
+
52
+ def video(value = nil)
53
+ return get(:video) if value.nil?
54
+
55
+ if value.is_a?(Hash)
56
+ end
57
+ set(:video, value)
58
+ end
59
+
60
+ def audio(value = nil)
61
+ value ? set(:audio, value) : get(:audio)
62
+ end
63
+
64
+ protected
65
+
66
+ def validate!
67
+ errors = []
68
+
69
+ errors << "og:title is required" unless config[:title]
70
+ errors << "og:type is required" unless config[:type]
71
+ errors << "og:image is required" unless config[:image]
72
+ errors << "og:url is required" unless config[:url]
73
+
74
+ raise ValidationError, errors.join(", ") if errors.any?
75
+ end
76
+
77
+ # Article builder for og:article properties
78
+ class ArticleBuilder < Base
79
+ def author(value = nil)
80
+ value ? set(:author, value) : get(:author)
81
+ end
82
+
83
+ def published_time(value = nil)
84
+ value ? set(:published_time, value) : get(:published_time)
85
+ end
86
+
87
+ def modified_time(value = nil)
88
+ value ? set(:modified_time, value) : get(:modified_time)
89
+ end
90
+
91
+ def expiration_time(value = nil)
92
+ value ? set(:expiration_time, value) : get(:expiration_time)
93
+ end
94
+
95
+ def section(value = nil)
96
+ value ? set(:section, value) : get(:section)
97
+ end
98
+
99
+ def tag(*values)
100
+ values.any? ? set(:tag, values.flatten) : get(:tag)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,129 @@
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
+ end
41
+ set(:image, value)
42
+ end
43
+
44
+ def image_alt(value = nil)
45
+ value ? set(:image_alt, value) : get(:image_alt)
46
+ end
47
+
48
+ # Player card properties
49
+ def player(value = nil)
50
+ value ? set(:player, value) : get(:player)
51
+ end
52
+
53
+ def player_width(value = nil)
54
+ value ? set(:player_width, value) : get(:player_width)
55
+ end
56
+
57
+ def player_height(value = nil)
58
+ value ? set(:player_height, value) : get(:player_height)
59
+ end
60
+
61
+ def player_stream(value = nil)
62
+ value ? set(:player_stream, value) : get(:player_stream)
63
+ end
64
+
65
+ # App card properties
66
+ def app_name(value = nil, platform: nil)
67
+ return get(:app_name) if value.nil?
68
+
69
+ if platform
70
+ current = get(:app_name) || {}
71
+ set(:app_name, current.merge(platform => value))
72
+ else
73
+ # Set for all platforms
74
+ set(:app_name, {
75
+ iphone: value,
76
+ ipad: value,
77
+ googleplay: value
78
+ })
79
+ end
80
+ end
81
+
82
+ def app_id(value = nil, platform: nil)
83
+ return get(:app_id) if value.nil?
84
+
85
+ current = get(:app_id) || {}
86
+ set(:app_id, current.merge(platform => value))
87
+ end
88
+
89
+ def app_url(value = nil, platform: nil)
90
+ return get(:app_url) if value.nil?
91
+
92
+ current = get(:app_url) || {}
93
+ set(:app_url, current.merge(platform => value))
94
+ end
95
+
96
+ protected
97
+
98
+ def validate!
99
+ errors = []
100
+
101
+ # Validate card type
102
+ card_type = config[:card]
103
+ if card_type && !VALID_CARD_TYPES.include?(card_type)
104
+ errors << "Invalid card type: #{card_type}. Valid types: #{VALID_CARD_TYPES.join(", ")}"
105
+ end
106
+
107
+ # Validate required fields
108
+ errors << "twitter:title is required" unless config[:title]
109
+ errors << "twitter:description is required" unless config[:description]
110
+
111
+ # Validate image for summary_large_image
112
+ if card_type == "summary_large_image" && !config[:image]
113
+ errors << "twitter:image is required for summary_large_image card"
114
+ end
115
+
116
+ # Validate lengths
117
+ if config[:title] && config[:title].length > 70
118
+ errors << "Title too long (#{config[:title].length} chars, max 70 recommended)"
119
+ end
120
+
121
+ if config[:description] && config[:description].length > 200
122
+ errors << "Description too long (#{config[:description].length} chars, max 200 recommended)"
123
+ end
124
+
125
+ raise ValidationError, errors.join(", ") if errors.any?
126
+ end
127
+ end
128
+ end
129
+ 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,77 @@
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
+ tags << %(<link rel="canonical" href="#{escape_html(@canonical_url)}">) if @canonical_url
36
+
37
+ tags << %(<meta property="og:title" content="#{escape_html(@title)}">) if @title
38
+
39
+ if @description
40
+ tags << %(<meta name="description" content="#{escape_html(@description)}">)
41
+ tags << %(<meta property="og:description" content="#{escape_html(@description)}">)
42
+ end
43
+
44
+ tags << %(<meta property="og:image" content="#{escape_html(@image)}">) if @image
45
+
46
+ tags.join("\n")
47
+ end
48
+
49
+ # Generate structured data script tag
50
+ def to_structured_data
51
+ return "" unless @structured_data
52
+
53
+ %(<script type="application/ld+json">\n#{JSON.generate(@structured_data)}\n</script>)
54
+ end
55
+
56
+ # Wrap custom CSS in amp-custom style tag
57
+ def to_custom_css(css)
58
+ return "" if css.nil? || css.empty?
59
+
60
+ %(<style amp-custom>\n#{css}\n</style>)
61
+ end
62
+
63
+ private
64
+
65
+ def escape_html(text)
66
+ return "" if text.nil?
67
+
68
+ text.to_s
69
+ .gsub("&", "&amp;")
70
+ .gsub("<", "&lt;")
71
+ .gsub(">", "&gt;")
72
+ .gsub('"', "&quot;")
73
+ .gsub("'", "&#39;")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,127 @@
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
+ active_class = item[:url].nil? ? " active" : ""
50
+ aria_current = item[:url].nil? ? ' aria-current="page"' : ""
51
+
52
+ if schema
53
+ html << %( <li class="breadcrumb-item#{active_class}" itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem"#{aria_current}>)
54
+ else
55
+ html << %( <li class="breadcrumb-item#{active_class}"#{aria_current}>)
56
+ end
57
+
58
+ if item[:url]
59
+ if schema
60
+ html << %( <a href="#{escape_html(item[:url])}" itemprop="item">)
61
+ html << %( <span itemprop="name">#{escape_html(item[:name])}</span>)
62
+ html << %( </a>)
63
+ html << %( <meta itemprop="position" content="#{index + 1}" />)
64
+ else
65
+ html << %( <a href="#{escape_html(item[:url])}">#{escape_html(item[:name])}</a>)
66
+ end
67
+ elsif schema
68
+ html << %( <span itemprop="name">#{escape_html(item[:name])}</span>)
69
+ html << %( <meta itemprop="position" content="#{index + 1}" />)
70
+ else
71
+ html << %( #{escape_html(item[:name])})
72
+ end
73
+
74
+ html << %( </li>)
75
+ end
76
+
77
+ html << %( </ol>)
78
+ html << %(</nav>)
79
+
80
+ html.join("\n")
81
+ end
82
+
83
+ # Generate JSON-LD structured data
84
+ def to_json_ld
85
+ return "" if @items.empty?
86
+
87
+ data = {
88
+ "@context" => "https://schema.org",
89
+ "@type" => "BreadcrumbList",
90
+ "itemListElement" => []
91
+ }
92
+
93
+ @items.each_with_index do |item, index|
94
+ list_item = {
95
+ "@type" => "ListItem",
96
+ "position" => index + 1,
97
+ "name" => item[:name]
98
+ }
99
+ list_item["item"] = item[:url] if item[:url]
100
+ data["itemListElement"] << list_item
101
+ end
102
+
103
+ JSON.generate(data)
104
+ end
105
+
106
+ # Generate script tag with JSON-LD
107
+ def to_script_tag
108
+ return "" if @items.empty?
109
+
110
+ %(<script type="application/ld+json">\n#{to_json_ld}\n</script>)
111
+ end
112
+
113
+ private
114
+
115
+ def escape_html(text)
116
+ return "" if text.nil?
117
+
118
+ text.to_s
119
+ .gsub("&", "&amp;")
120
+ .gsub("<", "&lt;")
121
+ .gsub(">", "&gt;")
122
+ .gsub('"', "&quot;")
123
+ .gsub("'", "&#39;")
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,100 @@
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
+ raise ValidationError, "Canonical URL must be absolute: #{@url}" unless uri.absolute?
46
+
47
+ # Check if it's HTTP or HTTPS
48
+ raise ValidationError, "Invalid URL format: #{@url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
49
+ rescue URI::InvalidURIError
50
+ raise ValidationError, "Invalid URL format: #{@url}"
51
+ end
52
+
53
+ true
54
+ end
55
+
56
+ private
57
+
58
+ def normalize_url(url)
59
+ return url if url.empty?
60
+
61
+ normalized = url.dup
62
+
63
+ # Parse URL
64
+ begin
65
+ uri = URI.parse(normalized)
66
+ rescue URI::InvalidURIError
67
+ return normalized
68
+ end
69
+
70
+ # Remove fragment identifier
71
+ uri.fragment = nil
72
+
73
+ # Remove query parameters if configured
74
+ uri.query = nil if @remove_query_params
75
+
76
+ # Rebuild URL
77
+ normalized = uri.to_s
78
+
79
+ # Remove trailing slash (except for root URL)
80
+ normalized = normalized[0...-1] if normalized.end_with?("/") && normalized.count("/") > 3
81
+
82
+ # Lowercase if configured
83
+ normalized = normalized.downcase if @lowercase
84
+
85
+ normalized
86
+ end
87
+
88
+ def escape_html(text)
89
+ return "" if text.nil?
90
+
91
+ text.to_s
92
+ .gsub("&", "&amp;")
93
+ .gsub("<", "&lt;")
94
+ .gsub(">", "&gt;")
95
+ .gsub('"', "&quot;")
96
+ .gsub("'", "&#39;")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,101 @@
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&.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 %i[index follow].include?(key)
83
+
84
+ directives << key.to_s if value
85
+ end
86
+
87
+ %(<meta name="robots" content="#{directives.join(", ")}">)
88
+ end
89
+
90
+ private
91
+
92
+ def escape(text)
93
+ text.to_s
94
+ .gsub("&", "&amp;")
95
+ .gsub('"', "&quot;")
96
+ .gsub("<", "&lt;")
97
+ .gsub(">", "&gt;")
98
+ end
99
+ end
100
+ end
101
+ end