jekyll-aeo 1.0.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.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module Generators
5
+ class RobotsTxt < Jekyll::Generator
6
+ priority :low
7
+ safe true
8
+
9
+ def generate(site)
10
+ config = JekyllAeo::Config.from_site(site)
11
+ return if config["enabled"] == false
12
+
13
+ robots_config = config["robots_txt"] || {}
14
+ return if robots_config["enabled"] == false
15
+ return if user_robots_exists?(site)
16
+
17
+ content = build_content(robots_config, site)
18
+ page = Jekyll::PageWithoutAFile.new(site, site.source, "", "robots.txt")
19
+ page.content = content
20
+ page.data["layout"] = nil
21
+ site.pages << page
22
+ end
23
+
24
+ private
25
+
26
+ def user_robots_exists?(site)
27
+ File.exist?(File.join(site.source, "robots.txt"))
28
+ end
29
+
30
+ def build_content(robots_config, site)
31
+ lines = []
32
+ bot_rules(lines, robots_config["allow"] || [], "Allow")
33
+ bot_rules(lines, robots_config["disallow"] || [], "Disallow")
34
+ lines << "User-agent: *"
35
+ lines << "Allow: /"
36
+ lines << ""
37
+ custom_rules(lines, robots_config["custom_rules"] || [])
38
+ append_metadata_lines(lines, robots_config, site)
39
+ "#{lines.join("\n")}\n"
40
+ end
41
+
42
+ def bot_rules(lines, bots, directive)
43
+ bots.each do |bot|
44
+ lines << "User-agent: #{bot}"
45
+ lines << "#{directive}: /"
46
+ lines << ""
47
+ end
48
+ end
49
+
50
+ def custom_rules(lines, rules)
51
+ rules.each do |rule|
52
+ lines << "User-agent: #{rule['user_agent']}"
53
+ lines << "Allow: #{rule['allow']}" if rule["allow"]
54
+ lines << "Disallow: #{rule['disallow']}" if rule["disallow"]
55
+ lines << ""
56
+ end
57
+ end
58
+
59
+ def append_metadata_lines(lines, robots_config, site)
60
+ base_url = site.config["url"].to_s.chomp("/")
61
+ baseurl = site.config["baseurl"].to_s.chomp("/")
62
+ lines << "Sitemap: #{base_url}#{baseurl}/sitemap.xml" if robots_config["include_sitemap"] != false
63
+ lines << "Llms-txt: #{base_url}#{baseurl}/llms.txt" if robots_config["include_llms_txt"] != false
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module JekyllAeo
6
+ module Generators
7
+ module UrlMap
8
+ COLUMN_HEADERS = {
9
+ "page_id" => "Page ID",
10
+ "url" => "URL",
11
+ "lang" => "Lang",
12
+ "layout" => "Layout",
13
+ "path" => "Path",
14
+ "redirects" => "Redirects",
15
+ "markdown_copy" => "Markdown Copy",
16
+ "skipped" => "Skipped"
17
+ }.freeze
18
+
19
+ def self.generate(site)
20
+ config = JekyllAeo::Config.from_site(site)
21
+ return if config["enabled"] == false
22
+
23
+ url_map_config = config["url_map"] || {}
24
+ return if url_map_config["enabled"] == false
25
+
26
+ columns = url_map_config["columns"] || COLUMN_HEADERS.keys
27
+ items = collect_all_items(site, config, columns)
28
+ sections = build_sections(items)
29
+ markdown = build_markdown(sections, columns, url_map_config)
30
+
31
+ output_file = File.join(project_root(site), url_map_config["output_filepath"] || "docs/Url-Map.md")
32
+ FileUtils.mkdir_p(File.dirname(output_file))
33
+ File.write(output_file, markdown)
34
+ end
35
+
36
+ def self.collect_all_items(site, config, columns)
37
+ items = []
38
+ needs_skip = columns.include?("skipped")
39
+ needs_md = columns.include?("markdown_copy")
40
+ baseurl = site.config["baseurl"].to_s.chomp("/")
41
+
42
+ site.documents.each do |doc|
43
+ next unless doc.output_ext == ".html"
44
+ next if doc.respond_to?(:collection) && doc.collection&.label == "assets"
45
+
46
+ items << build_item(doc, doc.collection&.label, site, config, needs_skip, needs_md, baseurl)
47
+ end
48
+
49
+ site.pages.each do |page|
50
+ next unless page.output_ext == ".html"
51
+
52
+ items << build_item(page, nil, site, config, needs_skip, needs_md, baseurl)
53
+ end
54
+
55
+ items
56
+ end
57
+
58
+ def self.build_item(obj, collection_label, site, config, needs_skip, needs_md, baseurl = "")
59
+ redirect_from = obj.data["redirect_from"]
60
+ redirects_str = case redirect_from
61
+ when Array then redirect_from.join(", ")
62
+ when String then redirect_from
63
+ else ""
64
+ end
65
+
66
+ item = {
67
+ url: obj.url,
68
+ page_id: obj.data["page_id"] || "",
69
+ lang: obj.data["lang"] || "",
70
+ layout: obj.data["layout"] || "",
71
+ path: obj.relative_path,
72
+ redirects: redirects_str,
73
+ collection: collection_label
74
+ }
75
+
76
+ item[:skipped] = JekyllAeo::Utils::SkipLogic.skip_reason(obj, site, config) || "" if needs_skip
77
+ item[:markdown_copy] = md_url(obj.url, baseurl) if needs_md && (!needs_skip || item[:skipped].empty?)
78
+ item[:markdown_copy] ||= "" if needs_md
79
+
80
+ item
81
+ end
82
+
83
+ def self.build_sections(items)
84
+ grouped = items.group_by { |item| item[:collection] }
85
+ sections = []
86
+
87
+ sections << { title: "Pages", items: grouped.delete(nil).sort_by { |i| i[:url] } } if grouped.key?(nil)
88
+
89
+ sorted_keys = grouped.keys.compact.sort
90
+ sorted_keys.each do |key|
91
+ sections << { title: titleize(key), items: grouped[key].sort_by { |i| i[:url] } }
92
+ end
93
+
94
+ sections
95
+ end
96
+
97
+ def self.build_markdown(sections, columns, url_map_config)
98
+ lines = []
99
+ lines << "# Url Map"
100
+ if url_map_config["show_created_at"] != false
101
+ lines << "Generated by Jekyll-AEO on #{Time.now.strftime('%Y-%m-%d at %H:%M:%S')}"
102
+ end
103
+ lines << ""
104
+
105
+ sections.each do |section|
106
+ next if section[:items].empty?
107
+
108
+ lines << "## #{section[:title]}"
109
+ lines << ""
110
+ lines << table_header(columns)
111
+ lines << table_separator(columns)
112
+
113
+ section[:items].each do |item|
114
+ lines << table_row(item, columns)
115
+ end
116
+
117
+ lines << ""
118
+ end
119
+
120
+ "#{lines.join("\n").rstrip}\n"
121
+ end
122
+
123
+ def self.table_header(columns)
124
+ cells = columns.map { |col| COLUMN_HEADERS[col] || col }
125
+ "| #{cells.join(' | ')} |"
126
+ end
127
+
128
+ def self.table_separator(columns)
129
+ cells = columns.map { |col| "-" * (COLUMN_HEADERS[col] || col).length }
130
+ "| #{cells.join(' | ')} |"
131
+ end
132
+
133
+ def self.table_row(item, columns)
134
+ cells = columns.map { |col| escape_pipe(item[col.to_sym].to_s) }
135
+ "| #{cells.join(' | ')} |"
136
+ end
137
+
138
+ def self.escape_pipe(value)
139
+ value.gsub("|", "\\|")
140
+ end
141
+
142
+ def self.md_url(url, baseurl = "")
143
+ JekyllAeo::Utils::MdUrl.for(url, baseurl)
144
+ end
145
+
146
+ def self.titleize(label)
147
+ case label
148
+ when "posts"
149
+ "Posts"
150
+ else
151
+ label.split(/[_-]/).map(&:capitalize).join(" ")
152
+ end
153
+ end
154
+
155
+ def self.project_root(site)
156
+ pwd = Dir.pwd
157
+ if %w[_config.yml _config.yaml _config.toml].any? { |f| File.exist?(File.join(pwd, f)) }
158
+ pwd
159
+ else
160
+ site.source
161
+ end
162
+ end
163
+
164
+ private_class_method :collect_all_items, :build_item, :build_sections,
165
+ :build_markdown, :table_header, :table_separator,
166
+ :table_row, :escape_pipe, :md_url, :titleize,
167
+ :project_root
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ Jekyll::Hooks.register :documents, :pre_render do |doc|
4
+ JekyllAeo::LinkTag.set_data(doc, doc.site)
5
+ end
6
+
7
+ Jekyll::Hooks.register :pages, :pre_render do |page|
8
+ JekyllAeo::LinkTag.set_data(page, page.site)
9
+ end
10
+
11
+ Jekyll::Hooks.register :documents, :post_render do |doc|
12
+ JekyllAeo::LinkTag.inject(doc, doc.site)
13
+ end
14
+
15
+ Jekyll::Hooks.register :pages, :post_render do |page|
16
+ JekyllAeo::LinkTag.inject(page, page.site)
17
+ end
18
+
19
+ Jekyll::Hooks.register :documents, :post_write do |doc|
20
+ JekyllAeo::Generators::MarkdownPage.process(doc, doc.site)
21
+ end
22
+
23
+ Jekyll::Hooks.register :pages, :post_write do |page|
24
+ JekyllAeo::Generators::MarkdownPage.process(page, page.site)
25
+ end
26
+
27
+ Jekyll::Hooks.register :site, :post_write do |site|
28
+ JekyllAeo::Generators::LlmsTxt.generate(site)
29
+ JekyllAeo::Generators::LlmsFullTxt.generate(site)
30
+ JekyllAeo::Generators::UrlMap.generate(site)
31
+ JekyllAeo::Generators::DomainProfile.generate(site)
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module LinkTag
5
+ def self.inject(obj, site)
6
+ config = JekyllAeo::Config.from_site(site)
7
+ dotmd_config = config["dotmd"]
8
+ return unless dotmd_config["link_tag"] == "auto"
9
+ return if JekyllAeo::Utils::SkipLogic.skip?(obj, site, config)
10
+
11
+ md_url = JekyllAeo::Utils::MdUrl.for(obj.url, site.config["baseurl"])
12
+ tag = %(<link rel="alternate" type="text/markdown" href="#{md_url}">)
13
+ obj.output = obj.output.sub("</head>", "#{tag}\n</head>")
14
+ end
15
+
16
+ def self.set_data(obj, site)
17
+ config = JekyllAeo::Config.from_site(site)
18
+ dotmd_config = config["dotmd"]
19
+ return unless dotmd_config["link_tag"] == "data"
20
+ return if JekyllAeo::Utils::SkipLogic.skip?(obj, site, config)
21
+
22
+ md_url = JekyllAeo::Utils::MdUrl.for(obj.url, site.config["baseurl"])
23
+ obj.data["md_url"] = md_url
24
+ obj.data["md_link_tag"] = %(<link rel="alternate" type="text/markdown" href="#{md_url}">)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module Schema
5
+ module Article
6
+ def self.build(page, site_config, _aeo_config = {})
7
+ return nil unless page["date"]
8
+ return nil if seo_tag_present?
9
+
10
+ base_url = site_config["url"].to_s.chomp("/")
11
+ baseurl = site_config["baseurl"].to_s.chomp("/")
12
+
13
+ schema = {
14
+ "@context" => "https://schema.org",
15
+ "@type" => "Article",
16
+ "headline" => page["title"] || "",
17
+ "url" => "#{base_url}#{baseurl}#{page['url']}",
18
+ "datePublished" => format_date(page["date"])
19
+ }
20
+
21
+ description = page["description"] || page["excerpt"]
22
+ schema["description"] = description.to_s if description
23
+
24
+ author = page["author"]
25
+ if author
26
+ schema["author"] = {
27
+ "@type" => "Person",
28
+ "name" => author
29
+ }
30
+ end
31
+
32
+ lm = page["last_modified_at"]
33
+ schema["dateModified"] = format_date(lm) if lm
34
+
35
+ schema
36
+ end
37
+
38
+ def self.seo_tag_present?
39
+ !Liquid::Template.tags["seo"].nil?
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ def self.format_date(value)
45
+ case value
46
+ when Time, DateTime
47
+ value.iso8601
48
+ else
49
+ value.to_s
50
+ end
51
+ end
52
+
53
+ private_class_method :seo_tag_present?, :format_date
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module Schema
5
+ module BreadcrumbList
6
+ def self.build(page, site_config, _aeo_config = {})
7
+ url = page["url"]
8
+ return nil if url.nil? || url == "/"
9
+
10
+ base_url = site_config["url"].to_s.chomp("/")
11
+ baseurl = site_config["baseurl"].to_s.chomp("/")
12
+ segments = url.split("/").reject(&:empty?)
13
+ return nil if segments.empty?
14
+
15
+ {
16
+ "@context" => "https://schema.org",
17
+ "@type" => "BreadcrumbList",
18
+ "itemListElement" => build_items(page, segments, base_url, baseurl)
19
+ }
20
+ end
21
+
22
+ def self.build_items(page, segments, base_url, baseurl)
23
+ items = [{
24
+ "@type" => "ListItem", "position" => 1,
25
+ "name" => "Home", "item" => "#{base_url}#{baseurl}/"
26
+ }]
27
+ accumulated = ""
28
+ segments.each_with_index do |segment, index|
29
+ accumulated += "/#{segment}"
30
+ name = if page["title"] && index == segments.length - 1
31
+ page["title"]
32
+ else
33
+ segment.split(/[_-]/).map(&:capitalize).join(" ")
34
+ end
35
+ items << {
36
+ "@type" => "ListItem", "position" => index + 2,
37
+ "name" => name, "item" => "#{base_url}#{baseurl}#{accumulated}/"
38
+ }
39
+ end
40
+ items
41
+ end
42
+
43
+ private_class_method :build_items
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module Schema
5
+ module FaqPage
6
+ def self.build(page, _site_config, _aeo_config = {})
7
+ faq = page["faq"]
8
+ return nil unless faq.is_a?(Array) && faq.any?
9
+
10
+ entities = faq.filter_map do |item|
11
+ next unless item.is_a?(Hash) && item["q"] && item["a"]
12
+
13
+ {
14
+ "@type" => "Question",
15
+ "name" => item["q"],
16
+ "acceptedAnswer" => {
17
+ "@type" => "Answer",
18
+ "text" => item["a"]
19
+ }
20
+ }
21
+ end
22
+
23
+ return nil if entities.empty?
24
+
25
+ {
26
+ "@context" => "https://schema.org",
27
+ "@type" => "FAQPage",
28
+ "mainEntity" => entities
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module Schema
5
+ module HowTo
6
+ def self.build(page, _site_config, _aeo_config = {})
7
+ howto = page["howto"]
8
+ return nil unless howto.is_a?(Hash)
9
+
10
+ steps = howto["steps"]
11
+ return nil unless steps.is_a?(Array) && steps.any?
12
+
13
+ step_entities = steps.each_with_index.filter_map { |step, i| build_step(step, i) }
14
+ return nil if step_entities.empty?
15
+
16
+ schema = { "@context" => "https://schema.org", "@type" => "HowTo", "step" => step_entities }
17
+ add_optional_fields(schema, howto, page)
18
+ schema
19
+ end
20
+
21
+ def self.build_step(step, index)
22
+ return unless step.is_a?(Hash) && step["text"]
23
+
24
+ entity = { "@type" => "HowToStep", "position" => index + 1, "text" => step["text"] }
25
+ entity["name"] = step["name"] if step["name"]
26
+ entity["url"] = step["url"] if step["url"]
27
+ entity["image"] = step["image"] if step["image"]
28
+ entity
29
+ end
30
+
31
+ def self.add_optional_fields(schema, howto, page)
32
+ schema["name"] = howto["name"] || page["title"] if howto["name"] || page["title"]
33
+ schema["description"] = howto["description"] if howto["description"]
34
+ schema["totalTime"] = howto["totalTime"] if howto["totalTime"]
35
+ end
36
+
37
+ private_class_method :build_step, :add_optional_fields
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module Schema
5
+ module Organization
6
+ def self.build(page, site_config, aeo_config = {})
7
+ return nil unless page["url"] == "/"
8
+
9
+ name = site_config["title"] || site_config["name"]
10
+ return nil unless name
11
+
12
+ base_url = site_config["url"].to_s.chomp("/")
13
+
14
+ schema = {
15
+ "@context" => "https://schema.org",
16
+ "@type" => "Organization",
17
+ "name" => name,
18
+ "url" => base_url.empty? ? "/" : base_url
19
+ }
20
+
21
+ description = site_config["description"]
22
+ schema["description"] = description if description && !description.to_s.empty?
23
+
24
+ logo = aeo_config.dig("domain_profile", "logo")
25
+ schema["logo"] = logo if logo
26
+
27
+ same_as = aeo_config.dig("domain_profile", "jsonld", "sameAs")
28
+ schema["sameAs"] = same_as if same_as.is_a?(Array) && same_as.any?
29
+
30
+ schema
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllAeo
4
+ module Schema
5
+ module Speakable
6
+ def self.build(page, site_config, _aeo_config = {})
7
+ return nil unless page["speakable"] == true
8
+
9
+ base_url = site_config["url"].to_s.chomp("/")
10
+ baseurl = site_config["baseurl"].to_s.chomp("/")
11
+ url = "#{base_url}#{baseurl}#{page['url']}"
12
+
13
+ selectors = [
14
+ ".post-title, .page-title, h1",
15
+ ".post-content p:first-of-type, .page-content p:first-of-type"
16
+ ]
17
+
18
+ {
19
+ "@context" => "https://schema.org",
20
+ "@type" => "WebPage",
21
+ "name" => page["title"] || "",
22
+ "url" => url,
23
+ "speakable" => {
24
+ "@type" => "SpeakableSpecification",
25
+ "cssSelector" => selectors
26
+ }
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JekyllAeo
6
+ module Tags
7
+ class AeoJsonLd < Liquid::Tag
8
+ BUILDERS = [
9
+ JekyllAeo::Schema::FaqPage,
10
+ JekyllAeo::Schema::HowTo,
11
+ JekyllAeo::Schema::BreadcrumbList,
12
+ JekyllAeo::Schema::Organization,
13
+ JekyllAeo::Schema::Speakable,
14
+ JekyllAeo::Schema::Article
15
+ ].freeze
16
+
17
+ def render(context)
18
+ site = context.registers[:site]
19
+ page = context.registers[:page]
20
+
21
+ aeo_config = JekyllAeo::Config.from_site(site)
22
+ results = BUILDERS.filter_map { |builder| builder.build(page, site.config, aeo_config) }
23
+
24
+ results.map do |schema|
25
+ json = JSON.pretty_generate(schema).gsub("</", "<\\/")
26
+ "<script type=\"application/ld+json\">\n#{json}\n</script>"
27
+ end.join("\n")
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ Liquid::Template.register_tag("aeo_json_ld", JekyllAeo::Tags::AeoJsonLd)