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.
- checksums.yaml +7 -0
- data/AGENTS.md +244 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE.txt +21 -0
- data/README.md +428 -0
- data/app/controllers/seo_geo/index_now_controller.rb +13 -0
- data/app/controllers/seo_geo/llms_controller.rb +29 -0
- data/app/controllers/seo_geo/og_images_controller.rb +67 -0
- data/app/controllers/seo_geo/robots_controller.rb +14 -0
- data/app/controllers/seo_geo/sitemaps_controller.rb +16 -0
- data/app/jobs/seo_geo/index_now_job.rb +21 -0
- data/app/views/seo_geo/fallback/_body.html.erb +21 -0
- data/app/views/seo_geo/fallback/_faq.html.erb +12 -0
- data/config/routes.rb +17 -0
- data/lib/generators/seo_geo/install/install_generator.rb +138 -0
- data/lib/generators/seo_geo/install/templates/home.yml.tt +14 -0
- data/lib/generators/seo_geo/install/templates/initializer.rb.tt +52 -0
- data/lib/seo_geo/audit/checks.rb +107 -0
- data/lib/seo_geo/audit/html_document.rb +132 -0
- data/lib/seo_geo/audit/report.rb +85 -0
- data/lib/seo_geo/audit/runner.rb +327 -0
- data/lib/seo_geo/audit/static_scan.rb +60 -0
- data/lib/seo_geo/audit.rb +14 -0
- data/lib/seo_geo/bot_verifier.rb +278 -0
- data/lib/seo_geo/configuration.rb +832 -0
- data/lib/seo_geo/controller.rb +72 -0
- data/lib/seo_geo/crawler_fallback.rb +45 -0
- data/lib/seo_geo/crawlers.rb +79 -0
- data/lib/seo_geo/engine.rb +123 -0
- data/lib/seo_geo/index_now.rb +187 -0
- data/lib/seo_geo/json_ld.rb +259 -0
- data/lib/seo_geo/llms_txt.rb +183 -0
- data/lib/seo_geo/markdown/document.rb +163 -0
- data/lib/seo_geo/markdown/negotiation.rb +49 -0
- data/lib/seo_geo/markdown/path_suffix.rb +72 -0
- data/lib/seo_geo/markdownable.rb +141 -0
- data/lib/seo_geo/meta.rb +382 -0
- data/lib/seo_geo/model.rb +95 -0
- data/lib/seo_geo/og_image/card.html.erb +22 -0
- data/lib/seo_geo/og_image/ferrum_backend.rb +72 -0
- data/lib/seo_geo/og_image/renderer.rb +163 -0
- data/lib/seo_geo/page.rb +201 -0
- data/lib/seo_geo/path_match.rb +36 -0
- data/lib/seo_geo/publication.rb +68 -0
- data/lib/seo_geo/robots/evaluator.rb +85 -0
- data/lib/seo_geo/robots.rb +105 -0
- data/lib/seo_geo/robots_directives.rb +170 -0
- data/lib/seo_geo/robots_path.rb +125 -0
- data/lib/seo_geo/rspec.rb +197 -0
- data/lib/seo_geo/safe_regexp.rb +50 -0
- data/lib/seo_geo/sitemap/registry.rb +239 -0
- data/lib/seo_geo/sitemap/xml.rb +138 -0
- data/lib/seo_geo/testing/environment.rb +31 -0
- data/lib/seo_geo/testing.rb +111 -0
- data/lib/seo_geo/undeclared_host.rb +40 -0
- data/lib/seo_geo/version.rb +5 -0
- data/lib/seo_geo.rb +80 -0
- data/lib/tasks/seo_geo.rake +23 -0
- data/llms.txt +27 -0
- 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
|
data/lib/seo_geo/meta.rb
ADDED
|
@@ -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
|