better_seo 0.13.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 +121 -3
- data/README.md +299 -181
- 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 +5 -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 +69 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterSeo
|
|
6
|
+
module Analytics
|
|
7
|
+
class GoogleAnalytics
|
|
8
|
+
attr_reader :measurement_id, :anonymize_ip
|
|
9
|
+
|
|
10
|
+
def initialize(measurement_id, anonymize_ip: false)
|
|
11
|
+
@measurement_id = measurement_id
|
|
12
|
+
@anonymize_ip = anonymize_ip
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Generate GA4 script tag
|
|
16
|
+
def to_script_tag(nonce: nil, **config)
|
|
17
|
+
nonce_attr = nonce ? %( nonce="#{escape_html(nonce)}") : ""
|
|
18
|
+
|
|
19
|
+
config_options = { 'anonymize_ip' => @anonymize_ip } if @anonymize_ip
|
|
20
|
+
config_options ||= {}
|
|
21
|
+
config_options.merge!(config.transform_keys(&:to_s))
|
|
22
|
+
|
|
23
|
+
config_json = config_options.empty? ? "" : ", #{JSON.generate(config_options)}"
|
|
24
|
+
|
|
25
|
+
<<~HTML.strip
|
|
26
|
+
<script async src="https://www.googletagmanager.com/gtag/js?id=#{@measurement_id}"#{nonce_attr}></script>
|
|
27
|
+
<script#{nonce_attr}>
|
|
28
|
+
window.dataLayer = window.dataLayer || [];
|
|
29
|
+
function gtag(){dataLayer.push(arguments);}
|
|
30
|
+
gtag('js', new Date());
|
|
31
|
+
gtag('config', '#{@measurement_id}'#{config_json});
|
|
32
|
+
</script>
|
|
33
|
+
HTML
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Track custom event
|
|
37
|
+
def track_event(event_name, **parameters)
|
|
38
|
+
params_json = parameters.empty? ? "" : ", #{JSON.generate(parameters.transform_keys(&:to_s))}"
|
|
39
|
+
|
|
40
|
+
<<~JS.strip
|
|
41
|
+
gtag('event', '#{event_name}'#{params_json});
|
|
42
|
+
JS
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Track page view
|
|
46
|
+
def track_page_view(page_path, title: nil)
|
|
47
|
+
params = { 'page_path' => page_path }
|
|
48
|
+
params['page_title'] = title if title
|
|
49
|
+
|
|
50
|
+
<<~JS.strip
|
|
51
|
+
gtag('config', '#{@measurement_id}', #{JSON.generate(params)});
|
|
52
|
+
JS
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Track e-commerce purchase
|
|
56
|
+
def ecommerce_purchase(transaction_id:, value:, currency: "USD", items: [])
|
|
57
|
+
params = {
|
|
58
|
+
'transaction_id' => transaction_id,
|
|
59
|
+
'value' => value,
|
|
60
|
+
'currency' => currency,
|
|
61
|
+
'items' => items.map { |item| item.transform_keys(&:to_s) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
<<~JS.strip
|
|
65
|
+
gtag('event', 'purchase', #{JSON.generate(params)});
|
|
66
|
+
JS
|
|
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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterSeo
|
|
6
|
+
module Analytics
|
|
7
|
+
class GoogleTagManager
|
|
8
|
+
attr_reader :container_id, :data_layer_name
|
|
9
|
+
|
|
10
|
+
def initialize(container_id, data_layer_name: "dataLayer")
|
|
11
|
+
@container_id = container_id
|
|
12
|
+
@data_layer_name = data_layer_name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Generate GTM head script tag
|
|
16
|
+
def to_head_script(nonce: nil)
|
|
17
|
+
nonce_attr = nonce ? %( nonce="#{escape_html(nonce)}") : ""
|
|
18
|
+
|
|
19
|
+
<<~HTML.strip
|
|
20
|
+
<script#{nonce_attr}>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
21
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
22
|
+
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
23
|
+
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
|
24
|
+
})(window,document,'script','#{@data_layer_name}','#{@container_id}');</script>
|
|
25
|
+
HTML
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generate GTM body noscript tag
|
|
29
|
+
def to_body_noscript
|
|
30
|
+
<<~HTML.strip
|
|
31
|
+
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{@container_id}"
|
|
32
|
+
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
|
33
|
+
HTML
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Push data to data layer
|
|
37
|
+
def push_data_layer(**data)
|
|
38
|
+
<<~JS.strip
|
|
39
|
+
#{@data_layer_name}.push(#{JSON.generate(data.transform_keys(&:to_s))});
|
|
40
|
+
JS
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Push e-commerce data
|
|
44
|
+
def push_ecommerce(event:, ecommerce:)
|
|
45
|
+
data = {
|
|
46
|
+
'event' => event,
|
|
47
|
+
'ecommerce' => ecommerce.transform_keys(&:to_s)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
<<~JS.strip
|
|
51
|
+
#{@data_layer_name}.push(#{JSON.generate(data)});
|
|
52
|
+
JS
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Push user data
|
|
56
|
+
def push_user_data(**user_data)
|
|
57
|
+
push_data_layer(**user_data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def escape_html(text)
|
|
63
|
+
return "" if text.nil?
|
|
64
|
+
|
|
65
|
+
text.to_s
|
|
66
|
+
.gsub("&", "&")
|
|
67
|
+
.gsub("<", "<")
|
|
68
|
+
.gsub(">", ">")
|
|
69
|
+
.gsub('"', """)
|
|
70
|
+
.gsub("'", "'")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
4
|
+
require "active_support/core_ext/hash/deep_merge"
|
|
5
|
+
|
|
6
|
+
module BetterSeo
|
|
7
|
+
class Configuration
|
|
8
|
+
# Default values
|
|
9
|
+
DEFAULT_CONFIG = {
|
|
10
|
+
site_name: nil,
|
|
11
|
+
default_locale: :en,
|
|
12
|
+
available_locales: [:en],
|
|
13
|
+
|
|
14
|
+
# Meta tags defaults
|
|
15
|
+
meta_tags: {
|
|
16
|
+
default_title: nil,
|
|
17
|
+
title_separator: " | ",
|
|
18
|
+
append_site_name: true,
|
|
19
|
+
default_description: nil,
|
|
20
|
+
default_keywords: [],
|
|
21
|
+
default_author: nil
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
# Open Graph defaults
|
|
25
|
+
open_graph: {
|
|
26
|
+
enabled: true,
|
|
27
|
+
site_name: nil,
|
|
28
|
+
default_type: "website",
|
|
29
|
+
default_locale: "en_US",
|
|
30
|
+
default_image: {
|
|
31
|
+
url: nil,
|
|
32
|
+
width: 1200,
|
|
33
|
+
height: 630
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
# Twitter Cards defaults
|
|
38
|
+
twitter: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
site: nil,
|
|
41
|
+
creator: nil,
|
|
42
|
+
card_type: "summary_large_image"
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
# Structured Data defaults
|
|
46
|
+
structured_data: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
organization: {},
|
|
49
|
+
website: {}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
# Sitemap defaults
|
|
53
|
+
sitemap: {
|
|
54
|
+
enabled: false,
|
|
55
|
+
output_path: "public/sitemap.xml",
|
|
56
|
+
host: nil,
|
|
57
|
+
compress: false,
|
|
58
|
+
ping_search_engines: false,
|
|
59
|
+
defaults: {
|
|
60
|
+
changefreq: "weekly",
|
|
61
|
+
priority: 0.5
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
# Robots.txt defaults
|
|
66
|
+
robots: {
|
|
67
|
+
enabled: false,
|
|
68
|
+
output_path: "public/robots.txt",
|
|
69
|
+
user_agents: {
|
|
70
|
+
"*" => {
|
|
71
|
+
allow: ["/"],
|
|
72
|
+
disallow: [],
|
|
73
|
+
crawl_delay: nil
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
# Image optimization defaults
|
|
79
|
+
images: {
|
|
80
|
+
enabled: false,
|
|
81
|
+
webp: {
|
|
82
|
+
enabled: true,
|
|
83
|
+
quality: 80
|
|
84
|
+
},
|
|
85
|
+
sizes: {
|
|
86
|
+
thumbnail: { width: 150, height: 150 },
|
|
87
|
+
small: { width: 300 },
|
|
88
|
+
medium: { width: 600 },
|
|
89
|
+
large: { width: 1200 },
|
|
90
|
+
og_image: { width: 1200, height: 630, crop: true }
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
# I18n settings
|
|
95
|
+
i18n: {
|
|
96
|
+
load_path: "config/locales/seo/**/*.yml",
|
|
97
|
+
auto_reload: false
|
|
98
|
+
}
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
101
|
+
attr_accessor :site_name, :default_locale, :available_locales
|
|
102
|
+
attr_reader :meta_tags, :open_graph, :twitter, :structured_data,
|
|
103
|
+
:sitemap, :robots, :images, :i18n
|
|
104
|
+
|
|
105
|
+
def initialize
|
|
106
|
+
# Deep dup per evitare shared state
|
|
107
|
+
@config = deep_dup(DEFAULT_CONFIG)
|
|
108
|
+
|
|
109
|
+
# Initialize nested configurations
|
|
110
|
+
@meta_tags = NestedConfiguration.new(@config[:meta_tags])
|
|
111
|
+
@open_graph = NestedConfiguration.new(@config[:open_graph])
|
|
112
|
+
@twitter = NestedConfiguration.new(@config[:twitter])
|
|
113
|
+
@structured_data = NestedConfiguration.new(@config[:structured_data])
|
|
114
|
+
@sitemap = NestedConfiguration.new(@config[:sitemap])
|
|
115
|
+
@robots = NestedConfiguration.new(@config[:robots])
|
|
116
|
+
@images = NestedConfiguration.new(@config[:images])
|
|
117
|
+
@i18n = NestedConfiguration.new(@config[:i18n])
|
|
118
|
+
|
|
119
|
+
# Top-level attributes
|
|
120
|
+
@site_name = @config[:site_name]
|
|
121
|
+
@default_locale = @config[:default_locale]
|
|
122
|
+
@available_locales = @config[:available_locales]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Load configuration from hash
|
|
126
|
+
def load_from_hash(hash)
|
|
127
|
+
merge_hash!(hash)
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Validate configuration
|
|
132
|
+
def validate!
|
|
133
|
+
errors = []
|
|
134
|
+
|
|
135
|
+
# Validate locales
|
|
136
|
+
unless available_locales.is_a?(Array) && available_locales.any?
|
|
137
|
+
errors << "available_locales must be a non-empty array"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if available_locales.is_a?(Array) && !available_locales.include?(default_locale)
|
|
141
|
+
errors << "default_locale must be included in available_locales"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Validate sitemap
|
|
145
|
+
if sitemap.enabled && sitemap.host.nil?
|
|
146
|
+
errors << "sitemap.host is required when sitemap is enabled"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate meta tags lengths
|
|
150
|
+
if meta_tags.default_title && meta_tags.default_title.length > 60
|
|
151
|
+
errors << "meta_tags.default_title should be max 60 characters"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if meta_tags.default_description && meta_tags.default_description.length > 160
|
|
155
|
+
errors << "meta_tags.default_description should be max 160 characters"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
raise ValidationError, errors.join(", ") if errors.any?
|
|
159
|
+
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Feature enabled checks
|
|
164
|
+
def sitemap_enabled?
|
|
165
|
+
sitemap.enabled == true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def robots_enabled?
|
|
169
|
+
robots.enabled == true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def images_enabled?
|
|
173
|
+
images.enabled == true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def open_graph_enabled?
|
|
177
|
+
open_graph.enabled == true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def twitter_enabled?
|
|
181
|
+
twitter.enabled == true
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def structured_data_enabled?
|
|
185
|
+
structured_data.enabled == true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Convert to hash
|
|
189
|
+
def to_h
|
|
190
|
+
{
|
|
191
|
+
site_name: site_name,
|
|
192
|
+
default_locale: default_locale,
|
|
193
|
+
available_locales: available_locales,
|
|
194
|
+
meta_tags: meta_tags.to_h,
|
|
195
|
+
open_graph: open_graph.to_h,
|
|
196
|
+
twitter: twitter.to_h,
|
|
197
|
+
structured_data: structured_data.to_h,
|
|
198
|
+
sitemap: sitemap.to_h,
|
|
199
|
+
robots: robots.to_h,
|
|
200
|
+
images: images.to_h,
|
|
201
|
+
i18n: i18n.to_h
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def merge_hash!(hash)
|
|
208
|
+
hash = hash.with_indifferent_access if hash.respond_to?(:with_indifferent_access)
|
|
209
|
+
|
|
210
|
+
# Merge top-level attributes
|
|
211
|
+
@site_name = hash[:site_name] if hash.key?(:site_name)
|
|
212
|
+
@default_locale = hash[:default_locale] if hash.key?(:default_locale)
|
|
213
|
+
@available_locales = hash[:available_locales] if hash.key?(:available_locales)
|
|
214
|
+
|
|
215
|
+
# Merge nested configurations
|
|
216
|
+
@meta_tags.merge!(hash[:meta_tags]) if hash[:meta_tags]
|
|
217
|
+
@open_graph.merge!(hash[:open_graph]) if hash[:open_graph]
|
|
218
|
+
@twitter.merge!(hash[:twitter]) if hash[:twitter]
|
|
219
|
+
@structured_data.merge!(hash[:structured_data]) if hash[:structured_data]
|
|
220
|
+
@sitemap.merge!(hash[:sitemap]) if hash[:sitemap]
|
|
221
|
+
@robots.merge!(hash[:robots]) if hash[:robots]
|
|
222
|
+
@images.merge!(hash[:images]) if hash[:images]
|
|
223
|
+
@i18n.merge!(hash[:i18n]) if hash[:i18n]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def deep_dup(hash)
|
|
227
|
+
hash.transform_values do |value|
|
|
228
|
+
value.is_a?(Hash) ? deep_dup(value) : (value.dup rescue value)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Nested configuration object
|
|
233
|
+
class NestedConfiguration
|
|
234
|
+
def initialize(hash = {})
|
|
235
|
+
@data = hash.with_indifferent_access
|
|
236
|
+
# Wrap nested hashes in NestedConfiguration objects for deep setter support
|
|
237
|
+
@data.each do |key, value|
|
|
238
|
+
@data[key] = NestedConfiguration.new(value) if value.is_a?(Hash) && !value.is_a?(NestedConfiguration)
|
|
239
|
+
end
|
|
240
|
+
define_accessors!
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def merge!(other_hash)
|
|
244
|
+
@data.deep_merge!(other_hash.with_indifferent_access)
|
|
245
|
+
# Wrap any new nested hashes in NestedConfiguration objects
|
|
246
|
+
@data.each do |key, value|
|
|
247
|
+
@data[key] = NestedConfiguration.new(value) if value.is_a?(Hash) && !value.is_a?(NestedConfiguration)
|
|
248
|
+
end
|
|
249
|
+
define_accessors!
|
|
250
|
+
self
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def to_h
|
|
254
|
+
# Recursively convert NestedConfiguration objects back to plain hashes
|
|
255
|
+
convert_to_hash(@data)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def [](key)
|
|
259
|
+
@data[key]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def []=(key, value)
|
|
263
|
+
@data[key] = value.is_a?(Hash) ? NestedConfiguration.new(value) : value
|
|
264
|
+
define_accessor(key)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def method_missing(method_name, *args, &block)
|
|
268
|
+
method_str = method_name.to_s
|
|
269
|
+
|
|
270
|
+
if method_str.end_with?("=")
|
|
271
|
+
key = method_str.chomp("=").to_sym
|
|
272
|
+
value = args.first
|
|
273
|
+
@data[key] = value.is_a?(Hash) ? NestedConfiguration.new(value) : value
|
|
274
|
+
define_accessor(key)
|
|
275
|
+
elsif @data.key?(method_name)
|
|
276
|
+
@data[method_name]
|
|
277
|
+
else
|
|
278
|
+
super
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
283
|
+
@data.key?(method_name.to_s.chomp("=").to_sym) || super
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
def convert_to_hash(value)
|
|
289
|
+
case value
|
|
290
|
+
when NestedConfiguration
|
|
291
|
+
value.to_h
|
|
292
|
+
when Hash
|
|
293
|
+
value.transform_values { |v| convert_to_hash(v) }
|
|
294
|
+
when Array
|
|
295
|
+
value.map { |v| convert_to_hash(v) }
|
|
296
|
+
else
|
|
297
|
+
value
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def define_accessors!
|
|
302
|
+
@data.keys.each { |key| define_accessor(key) }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def define_accessor(key)
|
|
306
|
+
# Check if method is actually defined (not just available via method_missing)
|
|
307
|
+
return if singleton_class.method_defined?(key)
|
|
308
|
+
|
|
309
|
+
singleton_class.class_eval do
|
|
310
|
+
define_method(key) { @data[key] }
|
|
311
|
+
define_method("#{key}=") { |value| @data[key] = value }
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterSeo
|
|
4
|
+
module DSL
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :config
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@config = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Generic setter method
|
|
13
|
+
def set(key, value)
|
|
14
|
+
@config[key] = value
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Generic getter method
|
|
19
|
+
def get(key)
|
|
20
|
+
@config[key]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Block evaluation
|
|
24
|
+
def evaluate(&block)
|
|
25
|
+
instance_eval(&block) if block_given?
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Build final configuration
|
|
30
|
+
def build
|
|
31
|
+
validate!
|
|
32
|
+
@config.dup
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Convert to hash
|
|
36
|
+
def to_h
|
|
37
|
+
@config.dup
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Merge another config
|
|
41
|
+
def merge!(other)
|
|
42
|
+
if other.is_a?(Hash)
|
|
43
|
+
@config.merge!(other)
|
|
44
|
+
elsif other.respond_to?(:to_h)
|
|
45
|
+
@config.merge!(other.to_h)
|
|
46
|
+
else
|
|
47
|
+
raise DSLError, "Cannot merge #{other.class}"
|
|
48
|
+
end
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
protected
|
|
53
|
+
|
|
54
|
+
# Override in subclasses for validation
|
|
55
|
+
def validate!
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Dynamic method handling
|
|
60
|
+
def method_missing(method_name, *args, &block)
|
|
61
|
+
method_str = method_name.to_s
|
|
62
|
+
|
|
63
|
+
if method_str.end_with?("=")
|
|
64
|
+
# Setter: title = "value"
|
|
65
|
+
key = method_str.chomp("=").to_sym
|
|
66
|
+
set(key, args.first)
|
|
67
|
+
elsif block_given?
|
|
68
|
+
# Nested block: open_graph do ... end
|
|
69
|
+
nested_builder = self.class.new
|
|
70
|
+
nested_builder.evaluate(&block)
|
|
71
|
+
set(method_name, nested_builder.build)
|
|
72
|
+
elsif args.any?
|
|
73
|
+
# Setter without =: title "value"
|
|
74
|
+
set(method_name, args.first)
|
|
75
|
+
else
|
|
76
|
+
# Getter
|
|
77
|
+
get(method_name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterSeo
|
|
4
|
+
module DSL
|
|
5
|
+
class MetaTags < 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 keywords(*values)
|
|
15
|
+
values.any? ? set(:keywords, values.flatten) : get(:keywords)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def author(value = nil)
|
|
19
|
+
value ? set(:author, value) : get(:author)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def canonical(value = nil)
|
|
23
|
+
value ? set(:canonical, value) : get(:canonical)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def robots(index: true, follow: true, **options)
|
|
27
|
+
set(:robots, { index: index, follow: follow }.merge(options))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def viewport(value = "width=device-width, initial-scale=1.0")
|
|
31
|
+
set(:viewport, value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def charset(value = "UTF-8")
|
|
35
|
+
set(:charset, value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
protected
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
errors = []
|
|
42
|
+
|
|
43
|
+
if config[:title] && config[:title].length > 60
|
|
44
|
+
errors << "Title too long (#{config[:title].length} chars, max 60 recommended)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if config[:description] && config[:description].length > 160
|
|
48
|
+
errors << "Description too long (#{config[:description].length} chars, max 160 recommended)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise ValidationError, errors.join(", ") if errors.any?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|