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,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("&", "&amp;")
76
+ .gsub("<", "&lt;")
77
+ .gsub(">", "&gt;")
78
+ .gsub('"', "&quot;")
79
+ .gsub("'", "&#39;")
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("&", "&amp;")
119
+ .gsub("<", "&lt;")
120
+ .gsub(">", "&gt;")
121
+ .gsub('"', "&quot;")
122
+ .gsub("'", "&#39;")
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("&", "&amp;")
99
+ .gsub("<", "&lt;")
100
+ .gsub(">", "&gt;")
101
+ .gsub('"', "&quot;")
102
+ .gsub("'", "&#39;")
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("&", "&amp;")
94
+ .gsub('"', "&quot;")
95
+ .gsub("<", "&lt;")
96
+ .gsub(">", "&gt;")
97
+ end
98
+ end
99
+ end
100
+ end