seo_geo 0.1.0

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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +244 -0
  3. data/CHANGELOG.md +40 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +428 -0
  6. data/app/controllers/seo_geo/index_now_controller.rb +13 -0
  7. data/app/controllers/seo_geo/llms_controller.rb +29 -0
  8. data/app/controllers/seo_geo/og_images_controller.rb +67 -0
  9. data/app/controllers/seo_geo/robots_controller.rb +14 -0
  10. data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
  11. data/app/jobs/seo_geo/index_now_job.rb +21 -0
  12. data/app/views/seo_geo/fallback/_body.html.erb +21 -0
  13. data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
  14. data/config/routes.rb +17 -0
  15. data/lib/generators/seo_geo/install/install_generator.rb +138 -0
  16. data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
  17. data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
  18. data/lib/seo_geo/audit/checks.rb +107 -0
  19. data/lib/seo_geo/audit/html_document.rb +132 -0
  20. data/lib/seo_geo/audit/report.rb +85 -0
  21. data/lib/seo_geo/audit/runner.rb +327 -0
  22. data/lib/seo_geo/audit/static_scan.rb +60 -0
  23. data/lib/seo_geo/audit.rb +14 -0
  24. data/lib/seo_geo/bot_verifier.rb +278 -0
  25. data/lib/seo_geo/configuration.rb +832 -0
  26. data/lib/seo_geo/controller.rb +72 -0
  27. data/lib/seo_geo/crawler_fallback.rb +45 -0
  28. data/lib/seo_geo/crawlers.rb +79 -0
  29. data/lib/seo_geo/engine.rb +123 -0
  30. data/lib/seo_geo/index_now.rb +187 -0
  31. data/lib/seo_geo/json_ld.rb +259 -0
  32. data/lib/seo_geo/llms_txt.rb +183 -0
  33. data/lib/seo_geo/markdown/document.rb +163 -0
  34. data/lib/seo_geo/markdown/negotiation.rb +49 -0
  35. data/lib/seo_geo/markdown/path_suffix.rb +72 -0
  36. data/lib/seo_geo/markdownable.rb +141 -0
  37. data/lib/seo_geo/meta.rb +382 -0
  38. data/lib/seo_geo/model.rb +95 -0
  39. data/lib/seo_geo/og_image/card.html.erb +22 -0
  40. data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
  41. data/lib/seo_geo/og_image/renderer.rb +163 -0
  42. data/lib/seo_geo/page.rb +201 -0
  43. data/lib/seo_geo/path_match.rb +36 -0
  44. data/lib/seo_geo/publication.rb +68 -0
  45. data/lib/seo_geo/robots/evaluator.rb +85 -0
  46. data/lib/seo_geo/robots.rb +105 -0
  47. data/lib/seo_geo/robots_directives.rb +170 -0
  48. data/lib/seo_geo/robots_path.rb +125 -0
  49. data/lib/seo_geo/rspec.rb +197 -0
  50. data/lib/seo_geo/safe_regexp.rb +50 -0
  51. data/lib/seo_geo/sitemap/registry.rb +239 -0
  52. data/lib/seo_geo/sitemap/xml.rb +138 -0
  53. data/lib/seo_geo/testing/environment.rb +31 -0
  54. data/lib/seo_geo/testing.rb +111 -0
  55. data/lib/seo_geo/undeclared_host.rb +40 -0
  56. data/lib/seo_geo/version.rb +5 -0
  57. data/lib/seo_geo.rb +80 -0
  58. data/lib/tasks/seo_geo.rake +23 -0
  59. data/llms.txt +27 -0
  60. metadata +224 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ module Markdown
5
+ # Rack middleware that makes +/<path>.md+ work for every route: it strips the suffix, maps
6
+ # +/index.md+ to +/+ and marks the request as a Markdown request (+Accept: text/markdown+ and
7
+ # +env["seo_geo.markdown"]+). Actions that include {SeoGeo::Markdownable} answer in Markdown;
8
+ # every other action gets a plain 406 — whether Rails raised +UnknownFormat+ or the action
9
+ # rendered HTML anyway — so a +.md+ URL never 500s or serves HTML.
10
+ #
11
+ # Appended to the middleware stack by the engine (after ActionDispatch::Static), so real
12
+ # +public/*.md+ files still win.
13
+ # llms.txt links pages as +/<path>.md+ and the root as +/index.md+.
14
+ class PathSuffix
15
+ ENV_KEY = "seo_geo.markdown"
16
+ NOT_ACCEPTABLE = [406, { "content-type" => "text/plain; charset=utf-8", "vary" => "Accept" },
17
+ ["Not Acceptable: no Markdown version of this page\n"]].freeze
18
+ private_constant :NOT_ACCEPTABLE
19
+
20
+ def initialize(app)
21
+ @app = app
22
+ end
23
+
24
+ def call(env)
25
+ path = env["PATH_INFO"].to_s
26
+ if SeoGeo.config.markdown.suffix_urls? && path.end_with?(".md") && %w[GET HEAD].include?(env["REQUEST_METHOD"])
27
+ env["PATH_INFO"] = self.class.html_path(path)
28
+ env["HTTP_ACCEPT"] = "text/markdown"
29
+ env[ENV_KEY] = true
30
+ return markdown_call(env)
31
+ end
32
+ @app.call(env)
33
+ end
34
+
35
+ # @param path [String] e.g. "/docs/intro.md", "/index.md", "/docs/index.md"
36
+ # @return [String] "/docs/intro", "/", "/docs/"
37
+ def self.html_path(path)
38
+ stripped = path.delete_suffix(".md")
39
+ return stripped.delete_suffix("index") if stripped.end_with?("/index")
40
+
41
+ stripped.empty? ? "/" : stripped
42
+ end
43
+
44
+ private
45
+
46
+ def markdown_call(env)
47
+ status, headers, body = @app.call(env)
48
+ return [status, headers, body] unless status.to_i == 200 && !markdown_response?(headers)
49
+
50
+ body.close if body.respond_to?(:close)
51
+ not_acceptable
52
+ rescue ActionController::UnknownFormat
53
+ not_acceptable
54
+ rescue ActionView::MissingTemplate => e
55
+ # An explicit +render :show+ in a non-markdownable action looks up an +.md+ template.
56
+ raise unless e.message.match?(/formats.{0,4}\[:md\]/)
57
+
58
+ not_acceptable
59
+ end
60
+
61
+ def markdown_response?(headers)
62
+ type = headers.find { |name, _| name.to_s.casecmp?("content-type") }&.last
63
+ type.to_s.start_with?("text/markdown")
64
+ end
65
+
66
+ def not_acceptable
67
+ status, headers, body = NOT_ACCEPTABLE
68
+ [status, headers.dup, body.dup]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "markdown/negotiation"
5
+ require_relative "markdown/document"
6
+ require_relative "markdown/path_suffix"
7
+
8
+ module SeoGeo
9
+ # Serves a Markdown version of HTML actions to agents (+Accept: text/markdown+ or a +.md+ URL).
10
+ #
11
+ # The action runs and renders HTML as usual; an +after_action+ then converts the main content to
12
+ # Markdown, prepends YAML frontmatter and appends JSON-LD in a +json+ fence. Responses of
13
+ # markdownable actions always carry +Vary: Accept+, HTML or not, so caches keep the two apart.
14
+ #
15
+ # @example Every action of a controller
16
+ # class PagesController < ApplicationController
17
+ # include SeoGeo::Markdownable
18
+ # end
19
+ #
20
+ # @example Selected actions, an Inertia page backed by page copy, a custom selector
21
+ # class LandingController < ApplicationController
22
+ # include SeoGeo::Markdownable
23
+ # seo_geo_markdown only: :show, page: :home, selector: "#content"
24
+ # end
25
+ #
26
+ # @example .md URLs need no routes: {SeoGeo::Markdown::PathSuffix} maps /articles/1.md to /articles/1
27
+ # and /index.md to /, so every markdownable action answers both forms.
28
+ module Markdownable
29
+ extend ActiveSupport::Concern
30
+
31
+ # Header that tells an origin-side CDN converter (or a test) the response already is Markdown.
32
+ CONTENT_TYPE = "text/markdown; charset=utf-8"
33
+
34
+ included do
35
+ class_attribute :seo_geo_markdown_options, instance_writer: false, default: {}
36
+ before_action :seo_geo_prepare_markdown, if: :seo_geo_markdownable_action?
37
+ after_action :seo_geo_finish_markdown, if: :seo_geo_markdownable_action?
38
+ end
39
+
40
+ class_methods do
41
+ # @param only [Symbol, Array<Symbol>, nil] restrict to these actions
42
+ # @param except [Symbol, Array<Symbol>, nil] every action but these
43
+ # @param page [Symbol, #call, nil] page key (or +(controller) -> key+) whose copy is used when the
44
+ # HTML has no main content, e.g. an Inertia mount point
45
+ # @param selector [String, nil] main-content selector overriding +config.markdown.selector+
46
+ # @return [void]
47
+ def seo_geo_markdown(only: nil, except: nil, page: nil, selector: nil)
48
+ self.seo_geo_markdown_options = {
49
+ only: only && Array(only).map(&:to_s), except: except && Array(except).map(&:to_s),
50
+ page: page, selector: selector
51
+ }
52
+ end
53
+ end
54
+
55
+ # @return [Boolean] true when Markdown is enabled and this action is covered by +seo_geo_markdown only:/except:+
56
+ def seo_geo_markdown_available?
57
+ SeoGeo.config.markdown.enabled && seo_geo_markdownable_action?
58
+ end
59
+
60
+ private
61
+
62
+ def seo_geo_markdownable_action?
63
+ options = seo_geo_markdown_options
64
+ return false if options[:only] && !options[:only].include?(action_name)
65
+ return false if options[:except]&.include?(action_name)
66
+
67
+ true
68
+ end
69
+
70
+ def seo_geo_markdown_requested?
71
+ return false unless SeoGeo.config.markdown.enabled
72
+
73
+ request.env[SeoGeo::Markdown::PathSuffix::ENV_KEY] || params[:format].to_s == "md" ||
74
+ SeoGeo::Markdown::Negotiation.markdown?(request.headers["Accept"])
75
+ end
76
+
77
+ # Render HTML whatever was asked for; the after_action converts it. When Rails picked :md from an
78
+ # Accept header the negotiation turned down (e.g. +text/markdown;q=0+), HTML is the fallback.
79
+ def seo_geo_prepare_markdown
80
+ @seo_geo_markdown = seo_geo_markdown_requested?
81
+ request.format = :html if @seo_geo_markdown || request.format&.symbol == :md
82
+ end
83
+
84
+ def seo_geo_finish_markdown
85
+ seo_geo_append_vary("Accept")
86
+ return unless @seo_geo_markdown && response.status == 200 && response.media_type.to_s == "text/html"
87
+
88
+ document = SeoGeo::Markdown::Document.new(
89
+ response.body, config: SeoGeo.config, url: seo_geo_html_url, page: seo_geo_markdown_page,
90
+ selector: seo_geo_markdown_options[:selector]
91
+ )
92
+ seo_geo_merge_robots_header(document.robots)
93
+ response.body = document.to_s
94
+ response.headers["Content-Type"] = CONTENT_TYPE
95
+ seo_geo_content_signal_headers
96
+ end
97
+
98
+ # Carries the HTML's robots metas (global and crawler-specific) over to the Markdown response, merged with
99
+ # the X-Robots-Tag already set ({SeoGeo::RobotsDirectives}): global directives stay global even when the
100
+ # header only had scoped ones (+googlebot: noindex+ does not cover other crawlers), and nothing is dropped.
101
+ # @param directives [SeoGeo::RobotsDirectives]
102
+ def seo_geo_merge_robots_header(directives)
103
+ existing = SeoGeo::RobotsDirectives.parse_header(response.headers["X-Robots-Tag"])
104
+ merged = existing.merge(directives.restrictive)
105
+ return if merged == existing
106
+
107
+ response.headers["X-Robots-Tag"] = merged.to_header
108
+ end
109
+
110
+ def seo_geo_append_vary(value)
111
+ current = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:empty?)
112
+ return if current.any? { |name| name.casecmp?(value) }
113
+
114
+ response.headers["Vary"] = (current + [value]).join(", ")
115
+ end
116
+
117
+ def seo_geo_content_signal_headers
118
+ config = SeoGeo.config
119
+ return unless config.markdown.content_signal_header
120
+
121
+ response.headers["Content-Signal"] = config.content_signal_value
122
+ usage = config.content_usage_value
123
+ response.headers["Content-Usage"] = usage if usage
124
+ end
125
+
126
+ # The HTML URL this Markdown represents (".md" removed), for the canonical fallback.
127
+ def seo_geo_html_url
128
+ request.url.sub(/\.md(?=\?|\z)/, "")
129
+ end
130
+
131
+ def seo_geo_markdown_page
132
+ key = seo_geo_markdown_options[:page]
133
+ key = key.call(self) if key.respond_to?(:call)
134
+ return nil if key.nil?
135
+
136
+ SeoGeo.page(key)
137
+ rescue KeyError
138
+ nil
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "erb"
5
+ require "json"
6
+ require "meta-tags"
7
+
8
+ module SeoGeo
9
+ # Resolves the head tags of one request from the configuration, the controller's +seo(...)+
10
+ # call and the page copy. Pure Ruby (no view), so the rules are unit-testable:
11
+ #
12
+ # - title: +seo title:+ > app +set_meta_tags title:+ > page title > site name, rendered once through
13
+ # {Configuration#format_title} (the only title template).
14
+ # - description: +seo description:+ > page description > site description, truncated by
15
+ # {Configuration#truncate_description}.
16
+ # - canonical: {Configuration#canonical_url} of the request URL (aliases and query folded), or of
17
+ # +seo canonical: "/path"+; +seo canonical: false+ drops it. With +locales+, a non-default locale's
18
+ # canonical is its own hreflang URL (self-referencing, like the hreflang alternates).
19
+ # - robots: noindex when the host policy is not indexable (every host outside indexable_environments),
20
+ # when +seo noindex: true+, or when +default_noindex+ is on and the controller did not call +seo index: true+.
21
+ class Meta
22
+ # Per-request values set by the controller ({SeoGeo::Controller#seo}) or views.
23
+ State = Struct.new(:title, :description, :image, :noindex, :index, :canonical, :page, :type,
24
+ :markdown, :json_ld, keyword_init: true) do
25
+ def initialize(**)
26
+ super
27
+ self.json_ld ||= []
28
+ end
29
+ end
30
+
31
+ # @return [Configuration]
32
+ attr_reader :config
33
+ # @return [State]
34
+ attr_reader :state
35
+ # @return [String] the request URL
36
+ attr_reader :url
37
+ # @return [Symbol, nil]
38
+ attr_reader :locale
39
+
40
+ # @param config [Configuration]
41
+ # @param url [String] absolute request URL (+request.url+)
42
+ # @param locale [Symbol, String, nil] current locale (+I18n.locale+)
43
+ # @param state [State]
44
+ # @param app_title [String, nil] title set through meta-tags' +set_meta_tags+ / +title+ helpers
45
+ # @param markdown_available [Boolean] whether this request's action serves Markdown
46
+ # ({SeoGeo::Controller#seo_geo_markdown_available?}); +seo markdown: true+ overrides it
47
+ def initialize(url:, config: SeoGeo.config, locale: nil, state: State.new, app_title: nil, markdown_available: true)
48
+ @config = config
49
+ @url = url.to_s
50
+ @locale = (locale || config.site.default_locale)&.to_sym
51
+ @state = state
52
+ @app_title = app_title
53
+ @markdown_available = markdown_available
54
+ end
55
+
56
+ # @return [Page, nil]
57
+ def page
58
+ return @page if defined?(@page)
59
+
60
+ @page = case state.page
61
+ when nil then nil
62
+ when Page then state.page
63
+ else SeoGeo.page(state.page, locale: locale)
64
+ end
65
+ end
66
+
67
+ # @return [String, nil] title without the site suffix
68
+ def title
69
+ [state.title, @app_title, page&.title, config.site.name].find { |value| present?(value) }&.to_s
70
+ end
71
+
72
+ # @return [String, nil] the +<title>+ text
73
+ def full_title
74
+ title && config.format_title(title)
75
+ end
76
+
77
+ # @return [String, nil]
78
+ def description
79
+ config.truncate_description([state.description, page&.description, config.site.description].find { |value| present?(value) })
80
+ end
81
+
82
+ # @return [String, nil] lower-case request host
83
+ def host
84
+ URI.parse(url).host&.downcase
85
+ rescue URI::InvalidURIError
86
+ nil
87
+ end
88
+
89
+ # @return [HostPolicy]
90
+ def host_policy
91
+ config.host_policy(host.to_s)
92
+ end
93
+
94
+ # @return [Boolean] true outside indexable environments and on non-indexable hosts
95
+ def environment_noindex?
96
+ return false if host_policy.indexable
97
+
98
+ UndeclaredHost.warn_once(host, config: config)
99
+ true
100
+ end
101
+
102
+ # @return [Boolean] also true for paths declared noindex ({Configuration#noindex_paths} or sitemap
103
+ # +add path, noindex: true+), even when the controller called +seo index: true+
104
+ def noindex?
105
+ return true if environment_noindex? || state.noindex == true || declared_noindex?
106
+
107
+ config.default_noindex ? !(state.index == true || declared_index?) : false
108
+ end
109
+
110
+ # @return [String, nil] canonical URL with aliases/query folded; nil when disabled
111
+ def canonical
112
+ return @canonical if defined?(@canonical)
113
+
114
+ @canonical = if state.canonical == false then nil
115
+ elsif present?(state.canonical) then config.canonical_url(state.canonical.to_s)
116
+ elsif localized? then locale_url(locale, base_canonical)
117
+ else base_canonical
118
+ end
119
+ end
120
+
121
+ # @return [Array<Hash{Symbol => String}>] +{hreflang:, href:}+ for every locale plus x-default
122
+ def alternates
123
+ return [] unless localized? && canonical && state.canonical.nil?
124
+
125
+ links = config.locales.map { |name| { hreflang: name.to_s, href: locale_url(name, base_canonical) } }
126
+ x_default = config.effective_x_default_locale
127
+ links << { hreflang: "x-default", href: locale_url(x_default, base_canonical) } if x_default
128
+ links
129
+ end
130
+
131
+ # URL of the Markdown version advertised with +<link rel="alternate" type="text/markdown">+:
132
+ # +/+ -> +/index.md+, +/docs/a+ -> +/docs/a.md+ (query kept). nil when Markdown is off, the page
133
+ # is noindex or has no canonical, or the action does not serve Markdown (+seo markdown: true+ forces it).
134
+ # @return [String, nil]
135
+ def markdown_url
136
+ return nil unless config.markdown.suffix_urls? && state.markdown != false && canonical && !noindex?
137
+ return nil unless state.markdown == true || @markdown_available
138
+
139
+ self.class.markdown_url_for(canonical)
140
+ end
141
+
142
+ # @param url [String] absolute URL
143
+ # @return [String] the +.md+ URL for it
144
+ def self.markdown_url_for(url)
145
+ uri = URI.parse(url)
146
+ path = uri.path.to_s
147
+ uri.path = path.empty? || path.end_with?("/") ? "#{path.chomp("/")}/index.md" : "#{path}.md"
148
+ uri.to_s
149
+ end
150
+
151
+ # @return [String, nil] absolute OG image URL (+seo image:+ > page +image+ > og_image.fallback_url)
152
+ def image_url
153
+ image = state.image
154
+ image = image[:url] || image["url"] if image.is_a?(Hash)
155
+ image ||= page&.extra&.dig(:image)
156
+ image ||= config.og_image.fallback_url
157
+ return nil unless present?(image)
158
+
159
+ absolute(image.to_s)
160
+ end
161
+
162
+ # @return [Hash] options for meta-tags' renderer (everything except +title+)
163
+ def tags
164
+ tags = { description: description, canonical: canonical, alternate: alternate_links }
165
+ if noindex?
166
+ tags[:noindex] = true
167
+ tags[:nofollow] = true if environment_noindex?
168
+ end
169
+ tags[:og] = open_graph
170
+ tags[:twitter] = twitter
171
+ tags.merge(config.verification.meta_tags.transform_keys(&:to_sym)).compact
172
+ end
173
+
174
+ # @return [JsonLd] WebSite + Organization (when site.name is set), the page's FAQPage, then nodes
175
+ # added with +seo json_ld:+ / +seo_geo_json_ld+
176
+ def json_ld
177
+ document = JsonLd.new(config: config)
178
+ if present?(config.site.name)
179
+ document.website(in_language: locale&.to_s)
180
+ document.organization
181
+ end
182
+ document.add(page.json_ld(url: canonical || "#{document.origin}/", config: config)) if page
183
+ state.json_ld.each { |node| document.add(node) }
184
+ document
185
+ end
186
+
187
+ private
188
+
189
+ # one declaration drives robots meta, sitemap.xml and llms.txt.
190
+ def declared_noindex?
191
+ Publication.declared_noindex?(request_path, config: config)
192
+ end
193
+
194
+ # Under +default_noindex+, a path registered with +index: true+ for this host is indexable (same rule as
195
+ # sitemap.xml): a static path or a record of a collection added with +match:+.
196
+ def declared_index?
197
+ Publication.declared_index?(host.to_s, request_path, config: config)
198
+ end
199
+
200
+ def request_path
201
+ RobotsPath.path(url)
202
+ end
203
+
204
+ def localized?
205
+ !config.locales.empty? && config.locales.map(&:to_sym).include?(locale)
206
+ end
207
+
208
+ def base_canonical
209
+ @base_canonical ||= config.canonical_url(url)
210
+ end
211
+
212
+ def locale_url(name, base)
213
+ return config.hreflang_url.call(name.to_sym, base) if config.hreflang_url
214
+ return base if name.to_sym == config.site.default_locale.to_sym
215
+
216
+ "#{base}#{base.include?("?") ? "&" : "?"}locale=#{ERB::Util.url_encode(name.to_s)}"
217
+ end
218
+
219
+ def alternate_links
220
+ links = alternates.map { |link| { href: link[:href], hreflang: link[:hreflang] } }
221
+ links << { href: markdown_url, type: "text/markdown" } if markdown_url
222
+ links.empty? ? nil : links
223
+ end
224
+
225
+ def open_graph
226
+ image = image_url
227
+ {
228
+ type: state.type || "website",
229
+ site_name: config.site.name,
230
+ title: title,
231
+ description: description,
232
+ url: canonical,
233
+ locale: og_locale,
234
+ image: image && { _: image, **image_size(image) }.compact
235
+ }.compact
236
+ end
237
+
238
+ # +og:image:width/height+ only when the size is known: an explicit +seo image: {url:, width:, height:}+, an image
239
+ # the gem renders (+/og/<key>.png+ on this origin while og_image is enabled => og_image.width x height), or
240
+ # the fallback image with +og_image.fallback_width/height+. A wrong size is worse than none.
241
+ def image_size(url)
242
+ explicit = state.image
243
+ if explicit.is_a?(Hash)
244
+ width = explicit[:width] || explicit["width"]
245
+ height = explicit[:height] || explicit["height"]
246
+ return { width: width, height: height } if width && height
247
+ end
248
+ og = config.og_image
249
+ return { width: og.width, height: og.height } if og.enabled? && gem_rendered?(url)
250
+ return { width: og.fallback_width, height: og.fallback_height } if url == og.fallback_url && og.fallback_width && og.fallback_height
251
+
252
+ {}
253
+ end
254
+
255
+ OG_IMAGE_PATH = %r{\A/og/[a-zA-Z0-9_-]{1,100}\.png\z} # GET /og/:key.png (OgImage::KEY_FORMAT)
256
+
257
+ def gem_rendered?(url)
258
+ uri = URI.parse(url)
259
+ URI.parse(absolute("/")).host.to_s.casecmp?(uri.host.to_s) && uri.path.match?(OG_IMAGE_PATH)
260
+ rescue URI::InvalidURIError
261
+ false
262
+ end
263
+
264
+ def twitter
265
+ image = image_url
266
+ { card: image ? "summary_large_image" : "summary", title: title, description: description, image: image }.compact
267
+ end
268
+
269
+ # Open Graph wants language_TERRITORY: +ko+ => +ko_KR+, +en-GB+ => +en_GB+ ({Configuration#og_locale}).
270
+ def og_locale
271
+ config.og_locale(locale)
272
+ end
273
+
274
+ def absolute(value)
275
+ return value if value.match?(%r{\Ahttps?://}i)
276
+
277
+ "#{config.canonical_origin}#{value.start_with?("/") ? "" : "/"}#{value}"
278
+ end
279
+
280
+ def present?(value)
281
+ !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
282
+ end
283
+
284
+ # View helpers, included into ActionView by the engine (initializer "seo_geo.helpers").
285
+ module Helper
286
+ FALLBACK_TIMEOUT_MS = 5000
287
+
288
+ # The whole SEO head in one line: +<title>+, description, canonical, robots, hreflang, Markdown
289
+ # alternate, verification metas, Open Graph/Twitter, JSON-LD and the crawler-fallback snippet.
290
+ # Values set with meta-tags' +set_meta_tags+ still apply (seo_geo supplies the defaults), except
291
+ # that the title always goes through +title_template+ and non-indexable environments always get noindex.
292
+ #
293
+ # @param page [Symbol, Page, nil] page copy for title/description/FAQ (defaults to +seo page:+)
294
+ # @param json_ld [Boolean] emit the JSON-LD script
295
+ # @param fallback [Boolean] emit the crawler-fallback hide/unhide snippet
296
+ # @return [ActiveSupport::SafeBuffer]
297
+ def seo_geo_head(page: nil, json_ld: true, fallback: true)
298
+ meta = seo_geo_meta(page: page)
299
+ parts = [meta.full_title && content_tag(:title, meta.full_title), seo_geo_render_meta_tags(meta)]
300
+ parts << seo_geo_json_ld_tag(meta.json_ld) if json_ld && !meta.json_ld.empty?
301
+ parts << content_for(:seo_geo_json_ld) if content_for?(:seo_geo_json_ld)
302
+ parts << seo_geo_fallback_snippet if fallback
303
+ safe_join(parts.compact, "\n")
304
+ end
305
+
306
+ # @param page [Symbol, Page, nil]
307
+ # @return [Meta] resolved values for the current request (also handy for custom tags)
308
+ def seo_geo_meta(page: nil)
309
+ state = respond_to?(:seo_geo_state) ? seo_geo_state : State.new
310
+ state.page = page if page
311
+ app_title = respond_to?(:meta_tags) ? meta_tags[:title] : nil
312
+ app_title = nil if app_title.is_a?(Array)
313
+ Meta.new(config: SeoGeo.config, url: request.url, locale: seo_geo_locale,
314
+ state: state, app_title: app_title,
315
+ markdown_available: respond_to?(:seo_geo_markdown_available?) && seo_geo_markdown_available?)
316
+ end
317
+
318
+ # @return [String] value for +<html lang>+: the same locale as +og:locale+ and +WebSite.inLanguage+
319
+ def seo_geo_html_lang
320
+ seo_geo_locale.to_s
321
+ end
322
+
323
+ # @return [Symbol] +I18n.locale+ when +config.locales+ lists it, else +site.default_locale+
324
+ # ({Configuration#request_locale})
325
+ def seo_geo_locale
326
+ SeoGeo.config.request_locale(defined?(I18n) ? I18n.locale : nil)
327
+ end
328
+
329
+ # Adds nodes to this page's JSON-LD graph (rendered by {#seo_geo_head}). Call it from a view;
330
+ # views render before the layout's head.
331
+ # @param node [Hash, JsonLd]
332
+ # @return [nil]
333
+ def seo_geo_json_ld(node)
334
+ seo_geo_state.json_ld << node if respond_to?(:seo_geo_state)
335
+ nil
336
+ end
337
+
338
+ # A +<script type="application/ld+json">+ tag, escaped with +ERB::Util.json_escape+ and carrying
339
+ # the CSP nonce when one is configured. Use it for extra scripts in +content_for(:seo_geo_json_ld)+.
340
+ # @param data [Hash, JsonLd]
341
+ # @return [ActiveSupport::SafeBuffer]
342
+ def seo_geo_json_ld_tag(data)
343
+ hash = data.is_a?(JsonLd) ? data.to_h : data
344
+ json = ERB::Util.json_escape(JSON.generate(hash))
345
+ content_tag(:script, json.html_safe, type: "application/ld+json", nonce: seo_geo_nonce) # rubocop:disable Rails/OutputSafety
346
+ end
347
+
348
+ private
349
+
350
+ def seo_geo_render_meta_tags(meta)
351
+ collection = respond_to?(:meta_tags) ? meta_tags : MetaTags::MetaTagsCollection.new
352
+ collection.with_defaults(meta.tags) do
353
+ collection[:title] = nil
354
+ collection[:site] = nil
355
+ if meta.noindex?
356
+ %i[index follow].each { |key| collection.meta_tags.delete(key) }
357
+ collection[:noindex] = true
358
+ collection[:nofollow] = true if meta.environment_noindex?
359
+ end
360
+ MetaTags::Renderer.new(collection).render(self)
361
+ end
362
+ end
363
+
364
+ # Hides [data-seo-fallback] while JavaScript runs; shows it again when the app has not called
365
+ # window.SeoGeo.mounted() within FALLBACK_TIMEOUT_MS (bundle failed to load).
366
+ def seo_geo_fallback_snippet
367
+ script = "(function(w,d){var r=d.documentElement,m=false;r.classList.add(\"seo-geo-js\");" \
368
+ "w.SeoGeo=w.SeoGeo||{};w.SeoGeo.mounted=function(){m=true;r.classList.add(\"seo-geo-mounted\")};" \
369
+ "setTimeout(function(){if(!m)r.classList.remove(\"seo-geo-js\")},#{FALLBACK_TIMEOUT_MS})})(window,document)"
370
+ style = ".seo-geo-js [data-seo-fallback]{display:none}" \
371
+ "[data-seo-fallback]{max-width:720px;margin:0 auto;padding:32px 16px;line-height:1.6;" \
372
+ "word-break:keep-all;overflow-wrap:break-word}"
373
+ safe_join([content_tag(:script, script.html_safe, nonce: seo_geo_nonce), # rubocop:disable Rails/OutputSafety
374
+ content_tag(:style, style.html_safe, nonce: seo_geo_nonce)], "\n") # rubocop:disable Rails/OutputSafety
375
+ end
376
+
377
+ def seo_geo_nonce
378
+ respond_to?(:content_security_policy_nonce) ? content_security_policy_nonce : nil
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeoGeo
4
+ # Model macro that submits a record's public URL to IndexNow after commit.
5
+ # The engine extends ActiveRecord::Base with it; other ORMs can +extend SeoGeo::Model+ as long as
6
+ # they provide +after_commit+.
7
+ #
8
+ # @example
9
+ # class Article < ApplicationRecord
10
+ # seo_geo_indexnow on: :publish, url: ->(article) { Rails.application.routes.url_helpers.article_url(article) }
11
+ # end
12
+ module Model
13
+ EVENTS = %i[create update destroy publish].freeze
14
+
15
+ # @param on [Symbol, Array<Symbol>] +:create+, +:update+, +:destroy+ and/or +:publish+.
16
+ # +:publish+ submits after create or update while the record is published (see +published+) and
17
+ # after the update that unpublishes it, so search engines also learn about removals.
18
+ # @param url [Symbol, #call] method name or +(record) -> String | Array<String>+ (paths are folded onto
19
+ # the canonical origin)
20
+ # @param published [Symbol, #call] predicate for +:publish+ (default +:published?+)
21
+ # @param if [Symbol, #call, nil] extra condition
22
+ # @return [void]
23
+ def seo_geo_indexnow(on: %i[create update destroy], url:, published: :published?, if: nil)
24
+ events = Array(on).map(&:to_sym)
25
+ unknown = events - EVENTS
26
+ raise ConfigurationError, "seo_geo_indexnow on: unknown event(s) #{unknown.inspect}" unless unknown.empty?
27
+ raise ConfigurationError, "seo_geo_indexnow needs url: (a method name or a callable)" if url.nil?
28
+
29
+ condition = binding.local_variable_get(:if)
30
+ commit_on = events.flat_map { |event| event == :publish ? %i[create update] : [event] }.uniq
31
+
32
+ after_commit(on: commit_on) do
33
+ Model.submit_for(self, events: events, url: url, published: published, condition: condition)
34
+ end
35
+ end
36
+
37
+ class << self
38
+ # @api private
39
+ def submit_for(record, events:, url:, published:, condition:)
40
+ return unless call_option(record, condition, default: true)
41
+ return unless publish_relevant?(record, events, published)
42
+
43
+ urls = call_option(record, url, default: nil)
44
+ SeoGeo::IndexNow.submit(urls) if urls
45
+ rescue StandardError => e
46
+ # A failed notification must never break the transaction's callbacks chain.
47
+ SeoGeo::IndexNow.logger.error("[seo_geo] IndexNow submit failed for #{record.class}: #{e.class}: #{e.message}")
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ # Explicit :create/:update/:destroy events always submit. :publish alone submits only while
54
+ # published, or right after the update that unpublished the record.
55
+ def publish_relevant?(record, events, published)
56
+ return true unless events.include?(:publish)
57
+ return true if (events & %i[create update destroy]).any? { |event| fired?(record, event) }
58
+ return true if call_option(record, published, default: true)
59
+
60
+ was_published?(record, published)
61
+ end
62
+
63
+ def fired?(record, event)
64
+ case event
65
+ when :create then record.respond_to?(:previously_new_record?) && record.previously_new_record?
66
+ when :destroy then record.respond_to?(:destroyed?) && record.destroyed?
67
+ when :update
68
+ !(record.respond_to?(:previously_new_record?) && record.previously_new_record?) &&
69
+ !(record.respond_to?(:destroyed?) && record.destroyed?)
70
+ end
71
+ end
72
+
73
+ # For +published: :published?+ backed by a +published+/+published_at+/+status+ column, the
74
+ # saved change tells whether the record was published before this commit.
75
+ def was_published?(record, published)
76
+ return false unless published.is_a?(Symbol) && record.respond_to?(:saved_changes)
77
+
78
+ column = published.to_s.delete_suffix("?")
79
+ change = record.saved_changes[column] || record.saved_changes["#{column}_at"]
80
+ return false unless change
81
+
82
+ before = change.first
83
+ before.respond_to?(:empty?) ? !before.empty? : !!before
84
+ end
85
+
86
+ def call_option(record, option, default:)
87
+ case option
88
+ when nil then default
89
+ when Symbol, String then record.respond_to?(option) ? record.public_send(option) : default
90
+ else option.arity.zero? ? record.instance_exec(&option) : option.call(record)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end