jekyll-agent-markdown 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +12 -1
- data/README.md +231 -42
- data/docs/deployment.md +138 -0
- data/examples/cloudflare/src/worker.js +254 -0
- data/examples/cloudflare/wrangler.toml +8 -0
- data/examples/netlify/netlify/edge-functions/markdown-negotiation.ts +281 -0
- data/examples/netlify/netlify.toml +6 -0
- data/examples/nginx/negotiation.js +267 -0
- data/examples/nginx/nginx.conf +40 -0
- data/lib/jekyll/agent_markdown/agent_markdown_link_tag.rb +20 -0
- data/lib/jekyll/agent_markdown/author_metadata.rb +28 -0
- data/lib/jekyll/agent_markdown/collection_validator.rb +73 -0
- data/lib/jekyll/agent_markdown/configuration.rb +67 -6
- data/lib/jekyll/agent_markdown/date_metadata.rb +2 -8
- data/lib/jekyll/agent_markdown/document_exporter.rb +113 -0
- data/lib/jekyll/agent_markdown/document_header.rb +59 -0
- data/lib/jekyll/agent_markdown/document_settings.rb +170 -0
- data/lib/jekyll/agent_markdown/exported_document.rb +17 -0
- data/lib/jekyll/agent_markdown/generator.rb +78 -70
- data/lib/jekyll/agent_markdown/llms_document_index.rb +125 -0
- data/lib/jekyll/agent_markdown/llms_document_ordering.rb +40 -0
- data/lib/jekyll/agent_markdown/llms_full_renderer.rb +72 -0
- data/lib/jekyll/agent_markdown/llms_headings.rb +10 -15
- data/lib/jekyll/agent_markdown/llms_index_renderer.rb +102 -0
- data/lib/jekyll/agent_markdown/llms_text.rb +30 -0
- data/lib/jekyll/agent_markdown/metadata_footer.rb +26 -0
- data/lib/jekyll/agent_markdown/source_documents.rb +66 -0
- data/lib/jekyll/agent_markdown/version.rb +1 -1
- data/lib/jekyll-agent-markdown.rb +3 -0
- metadata +25 -4
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jekyll"
|
|
4
|
+
require_relative "configuration"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module AgentMarkdown
|
|
8
|
+
class DocumentHeader
|
|
9
|
+
URL_REQUIRED_MESSAGE =
|
|
10
|
+
"url is required for agent_markdown document headers; it must be an absolute HTTP(S) URL " \
|
|
11
|
+
"without credentials, a query, or a fragment"
|
|
12
|
+
|
|
13
|
+
def initialize(site, relative_url)
|
|
14
|
+
@site = site
|
|
15
|
+
@relative_url = relative_url
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def prepend_to(document, body)
|
|
19
|
+
entries = ["# #{title(document)}"]
|
|
20
|
+
description = one_line(document.data["description"])
|
|
21
|
+
entries << description if description
|
|
22
|
+
entries << "Source: #{html_url(document)}"
|
|
23
|
+
"#{entries.join("\n\n")}\n\n---\n\n#{body}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def html_url(document)
|
|
27
|
+
validate_site_url!(document)
|
|
28
|
+
"#{site.config["url"].to_s.sub(%r{/+\z}, "")}#{relative_url.call(document.url)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :site, :relative_url
|
|
34
|
+
|
|
35
|
+
def title(document)
|
|
36
|
+
one_line(document.data["title"]) || one_line(basename(document)) || "Document"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def basename(document)
|
|
40
|
+
return document.basename_without_ext if document.respond_to?(:basename_without_ext)
|
|
41
|
+
|
|
42
|
+
document.basename
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def one_line(value)
|
|
46
|
+
return unless value.is_a?(String)
|
|
47
|
+
|
|
48
|
+
normalized = value.gsub(/\s+/, " ").strip
|
|
49
|
+
normalized unless normalized.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_site_url!(document)
|
|
53
|
+
return if Configuration.absolute_http_url?(site.config["url"])
|
|
54
|
+
|
|
55
|
+
raise Jekyll::Errors::FatalException, "#{document.relative_path}: #{URL_REQUIRED_MESSAGE}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jekyll"
|
|
4
|
+
require_relative "configuration"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module AgentMarkdown
|
|
8
|
+
class DocumentSettings
|
|
9
|
+
ALLOWED_SETTINGS = %w[export index section optional include_document_header].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :section
|
|
12
|
+
|
|
13
|
+
def initialize(document, settings, source_kind:, collection_name: nil)
|
|
14
|
+
@document = document
|
|
15
|
+
@settings = settings
|
|
16
|
+
@source_kind = SourceSettings.kind(source_kind, document_path)
|
|
17
|
+
@collection_name = SourceSettings.collection_name(collection_name, @source_kind, document_path)
|
|
18
|
+
|
|
19
|
+
apply(normalized_document_settings)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def export? = @export
|
|
23
|
+
|
|
24
|
+
def index? = @index
|
|
25
|
+
|
|
26
|
+
def optional? = @optional
|
|
27
|
+
|
|
28
|
+
def include_document_header? = @include_document_header
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :document, :settings, :source_kind, :collection_name
|
|
33
|
+
|
|
34
|
+
def normalized_document_settings
|
|
35
|
+
value = document.data.fetch("agent_markdown", true)
|
|
36
|
+
if value.is_a?(Hash)
|
|
37
|
+
return value.to_h { |key, setting| [key.to_s, setting] }.tap do |normalized|
|
|
38
|
+
validate_keys!(normalized)
|
|
39
|
+
validate_values!(normalized)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Configuration.enabled_value?(value, name: setting_name)
|
|
44
|
+
return { "export" => false, "index" => false } if Configuration.disabled?(value)
|
|
45
|
+
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_keys!(document_settings)
|
|
50
|
+
unknown = document_settings.keys - ALLOWED_SETTINGS
|
|
51
|
+
return if unknown.empty?
|
|
52
|
+
|
|
53
|
+
suffix = "s" if unknown.length > 1
|
|
54
|
+
configuration_error("unknown agent_markdown setting#{suffix}: #{unknown.sort.join(", ")}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_values!(document_settings)
|
|
58
|
+
%w[export index optional include_document_header].each do |key|
|
|
59
|
+
next unless document_settings.key?(key)
|
|
60
|
+
|
|
61
|
+
Configuration.enabled_value?(document_settings.fetch(key), name: "agent_markdown.#{key} in #{document_path}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return unless document_settings.key?("section")
|
|
65
|
+
|
|
66
|
+
section = document_settings.fetch("section")
|
|
67
|
+
return if section.is_a?(String) && !section.strip.empty?
|
|
68
|
+
|
|
69
|
+
configuration_error("agent_markdown.section must be a non-empty String; got #{section.inspect}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply(document_settings)
|
|
73
|
+
@export, @index = export_status(document_settings)
|
|
74
|
+
@optional, @section = placement(document_settings)
|
|
75
|
+
@include_document_header = enabled_value(
|
|
76
|
+
document_settings,
|
|
77
|
+
"include_document_header",
|
|
78
|
+
Configuration.enabled?(settings, "include_document_header")
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def export_status(document_settings)
|
|
83
|
+
source_enabled = source_enabled?
|
|
84
|
+
export = enabled_value(document_settings, "export", source_enabled)
|
|
85
|
+
index = enabled_value(document_settings, "index", source_enabled)
|
|
86
|
+
validate_export_and_index!(document_settings, index)
|
|
87
|
+
export &&= source_enabled
|
|
88
|
+
[export, export && index]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_export_and_index!(document_settings, index)
|
|
92
|
+
return unless explicitly_disabled?(document_settings, "export") && document_settings.key?("index") && index
|
|
93
|
+
|
|
94
|
+
configuration_error("agent_markdown.export cannot be false when agent_markdown.index is true")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def explicitly_disabled?(document_settings, key)
|
|
98
|
+
document_settings.key?(key) && Configuration.disabled?(document_settings.fetch(key))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def placement(document_settings)
|
|
102
|
+
optional = enabled_value(document_settings, "optional", false)
|
|
103
|
+
section = SourceSettings.normalized_section(document_settings["section"])
|
|
104
|
+
if optional && section
|
|
105
|
+
configuration_error("agent_markdown.optional cannot be combined with agent_markdown.section")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
[optional, optional ? "Optional" : section]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def enabled_value(document_settings, key, default)
|
|
112
|
+
return default unless document_settings.key?(key)
|
|
113
|
+
|
|
114
|
+
!Configuration.disabled?(document_settings.fetch(key))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def source_enabled?
|
|
118
|
+
case source_kind
|
|
119
|
+
when :post
|
|
120
|
+
Configuration.enabled?(settings, "posts")
|
|
121
|
+
when :page
|
|
122
|
+
Configuration.enabled?(settings, "pages")
|
|
123
|
+
when :collection
|
|
124
|
+
Configuration.collection_names(settings).include?(collection_name)
|
|
125
|
+
else
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def setting_name = "agent_markdown in #{document_path}"
|
|
131
|
+
|
|
132
|
+
def document_path
|
|
133
|
+
return document.relative_path if document.respond_to?(:relative_path)
|
|
134
|
+
return document.path if document.respond_to?(:path)
|
|
135
|
+
|
|
136
|
+
"unknown document"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def configuration_error(message)
|
|
140
|
+
raise Jekyll::Errors::FatalException, "#{document_path}: #{message}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
module SourceSettings
|
|
145
|
+
SOURCE_KINDS = %i[post page collection].freeze
|
|
146
|
+
|
|
147
|
+
module_function
|
|
148
|
+
|
|
149
|
+
def kind(value, document_path)
|
|
150
|
+
source_kind = value.to_sym if value.respond_to?(:to_sym)
|
|
151
|
+
return source_kind if SOURCE_KINDS.include?(source_kind)
|
|
152
|
+
|
|
153
|
+
error(document_path, "unsupported source kind: #{value.inspect}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def collection_name(value, source_kind, document_path)
|
|
157
|
+
return nil unless source_kind == :collection
|
|
158
|
+
return value.strip if value.is_a?(String) && !value.strip.empty?
|
|
159
|
+
|
|
160
|
+
error(document_path, "collection_name must be a non-empty String; got #{value.inspect}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def normalized_section(value) = value&.gsub(/\s+/, " ")&.strip
|
|
164
|
+
|
|
165
|
+
def error(document_path, message)
|
|
166
|
+
raise Jekyll::Errors::FatalException, "#{document_path}: #{message}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module AgentMarkdown
|
|
5
|
+
ExportedDocument = Data.define(
|
|
6
|
+
:source_document,
|
|
7
|
+
:source_kind,
|
|
8
|
+
:markdown_url,
|
|
9
|
+
:markdown_content,
|
|
10
|
+
:body,
|
|
11
|
+
:html_url,
|
|
12
|
+
:section,
|
|
13
|
+
:index,
|
|
14
|
+
:optional
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,72 +1,79 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "jekyll"
|
|
4
|
+
require_relative "author_metadata"
|
|
5
|
+
require_relative "collection_validator"
|
|
4
6
|
require_relative "configuration"
|
|
5
7
|
require_relative "date_metadata"
|
|
6
8
|
require_relative "destination_claims"
|
|
9
|
+
require_relative "document_exporter"
|
|
10
|
+
require_relative "llms_full_renderer"
|
|
7
11
|
require_relative "llms_headings"
|
|
8
|
-
require_relative "
|
|
9
|
-
require_relative "
|
|
12
|
+
require_relative "llms_index_renderer"
|
|
13
|
+
require_relative "llms_document_ordering"
|
|
14
|
+
require_relative "metadata_footer"
|
|
15
|
+
require_relative "source_documents"
|
|
10
16
|
|
|
11
17
|
module Jekyll
|
|
12
18
|
module AgentMarkdown
|
|
13
19
|
class Generator < Jekyll::Generator
|
|
14
20
|
include Jekyll::Filters::URLFilters
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
LLMS_TXT_URL_REQUIRED_MESSAGE =
|
|
17
23
|
"url is required for agent_markdown.llms_txt and must be an absolute HTTP(S) URL " \
|
|
18
24
|
"without credentials, a query, or a fragment"
|
|
25
|
+
LLMS_FULL_TXT_URL_REQUIRED_MESSAGE =
|
|
26
|
+
"url is required for agent_markdown.llms_full_txt and must be an absolute HTTP(S) URL " \
|
|
27
|
+
"without credentials, a query, or a fragment"
|
|
28
|
+
ONE_MEBIBYTE = 1_048_576
|
|
19
29
|
|
|
20
30
|
# Run after normal- and high-priority generators so their post changes are exported.
|
|
31
|
+
# Destination claims therefore cover only what Jekyll knows at this point; a
|
|
32
|
+
# generator declaring `priority :lowest` runs later and can still claim the
|
|
33
|
+
# same destination. Jekyll offers no hook to detect that from here.
|
|
21
34
|
priority :low
|
|
22
35
|
|
|
36
|
+
attr_reader :exported_documents
|
|
37
|
+
|
|
23
38
|
def generate(site)
|
|
24
39
|
settings = Configuration.for(site)
|
|
25
40
|
return if settings == false
|
|
26
41
|
|
|
27
|
-
@
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
write_llms_txt(site, settings, included_posts || [], destination_claims)
|
|
42
|
+
@exported_documents = exported_documents_for(site, settings)
|
|
43
|
+
write_llms_txt(site, settings, exported_documents, @destination_claims)
|
|
44
|
+
write_llms_full_txt(site, settings, exported_documents, @destination_claims)
|
|
31
45
|
end
|
|
32
46
|
|
|
33
47
|
private
|
|
34
48
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
def exported_documents_for(site, settings)
|
|
50
|
+
@context = Liquid::Context.new({}, {}, { site: site })
|
|
51
|
+
collection_validator = CollectionValidator.new(site)
|
|
52
|
+
collection_validator.validate!(Configuration.collection_names(settings))
|
|
53
|
+
@destination_claims = destination_claims(site)
|
|
54
|
+
exporter = DocumentExporter.new(
|
|
55
|
+
site, settings, @destination_claims, content_for_post: ->(post) { post_content(site, post, settings) }
|
|
56
|
+
)
|
|
57
|
+
source_documents(site, settings, collection_validator).filter_map do |document, source_kind, collection_name|
|
|
58
|
+
exporter.export(document, source_kind:, collection_name:)
|
|
38
59
|
end
|
|
39
60
|
end
|
|
40
61
|
|
|
41
|
-
def
|
|
42
|
-
|
|
62
|
+
def source_documents(site, settings, collection_validator)
|
|
63
|
+
SourceDocuments.new(site, settings, collection_validator:)
|
|
43
64
|
end
|
|
44
65
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
file = RawMarkdownFile.new(site, MarkdownSiblingPath.for(post.url), post_content(post, settings))
|
|
51
|
-
url = file.url
|
|
52
|
-
return collision_warning(post, url) unless claim_destination?(destination_claims, site, file)
|
|
53
|
-
|
|
54
|
-
post.data["agent_markdown_url"] = url
|
|
55
|
-
site.static_files << file
|
|
56
|
-
post
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def collision_warning(post, url)
|
|
60
|
-
Jekyll.logger.warn "AgentMarkdown:",
|
|
61
|
-
"skipping #{post.relative_path}: #{url} already belongs to another file"
|
|
62
|
-
nil
|
|
66
|
+
def destination_claims(site)
|
|
67
|
+
DestinationClaims.new(site.dest).tap do |claims|
|
|
68
|
+
site.each_site_file { |item| claims.add_existing(item.destination(site.dest)) }
|
|
69
|
+
end
|
|
63
70
|
end
|
|
64
71
|
|
|
65
|
-
def write_llms_txt(site, settings,
|
|
72
|
+
def write_llms_txt(site, settings, documents, destination_claims)
|
|
66
73
|
return unless Configuration.enabled?(settings, "llms_txt")
|
|
67
74
|
return unless llms_txt_ready?(site, settings)
|
|
68
75
|
|
|
69
|
-
file = RawMarkdownFile.new(site, "/llms.txt",
|
|
76
|
+
file = RawMarkdownFile.new(site, "/llms.txt", LlmsIndexRenderer.new(site, settings, documents).to_s)
|
|
70
77
|
return llms_txt_collision_warning unless claim_destination?(destination_claims, site, file)
|
|
71
78
|
|
|
72
79
|
site.static_files << file
|
|
@@ -80,61 +87,62 @@ module Jekyll
|
|
|
80
87
|
nil
|
|
81
88
|
end
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# breaks a previously green build.
|
|
86
|
-
def llms_txt_ready?(site, settings)
|
|
87
|
-
return true if Configuration.absolute_http_url?(site.config["url"])
|
|
88
|
-
raise Jekyll::Errors::FatalException, URL_REQUIRED_MESSAGE if settings.key?("llms_txt")
|
|
90
|
+
def write_llms_full_txt(site, settings, documents, destination_claims)
|
|
91
|
+
return unless Configuration.enabled?(settings, "llms_full_txt")
|
|
89
92
|
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
validate_llms_full_txt_url!(site)
|
|
94
|
+
placeholder = RawMarkdownFile.new(site, "/llms-full.txt", "")
|
|
95
|
+
return llms_full_txt_collision_warning unless claim_destination?(destination_claims, site, placeholder)
|
|
96
|
+
|
|
97
|
+
content = LlmsFullRenderer.new(site, settings, documents).to_s
|
|
98
|
+
warn_if_llms_full_txt_is_large(content)
|
|
99
|
+
file = RawMarkdownFile.new(site, "/llms-full.txt", content)
|
|
100
|
+
|
|
101
|
+
site.static_files << file
|
|
92
102
|
end
|
|
93
103
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
def validate_llms_full_txt_url!(site)
|
|
105
|
+
return if Configuration.absolute_http_url?(site.config["url"])
|
|
106
|
+
|
|
107
|
+
raise Jekyll::Errors::FatalException, LLMS_FULL_TXT_URL_REQUIRED_MESSAGE
|
|
97
108
|
end
|
|
98
109
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
def warn_if_llms_full_txt_is_large(content)
|
|
111
|
+
return unless content.bytesize > ONE_MEBIBYTE
|
|
112
|
+
|
|
113
|
+
Jekyll.logger.warn "AgentMarkdown:", "llms-full.txt exceeds 1 MiB; consider reducing its size"
|
|
102
114
|
end
|
|
103
115
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
(dated + undated).map(&:first)
|
|
116
|
+
def llms_full_txt_collision_warning
|
|
117
|
+
Jekyll.logger.warn "AgentMarkdown:",
|
|
118
|
+
"skipping /llms-full.txt: the destination already belongs to another file"
|
|
119
|
+
nil
|
|
109
120
|
end
|
|
110
121
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
# A missing url only fails the build when llms_txt was explicitly
|
|
123
|
+
# configured; the default is to warn and skip so adding the gem never
|
|
124
|
+
# breaks a previously green build.
|
|
125
|
+
def llms_txt_ready?(site, settings)
|
|
126
|
+
return true if Configuration.absolute_http_url?(site.config["url"])
|
|
127
|
+
raise Jekyll::Errors::FatalException, LLMS_TXT_URL_REQUIRED_MESSAGE if settings.key?("llms_txt")
|
|
115
128
|
|
|
116
|
-
|
|
129
|
+
Jekyll.logger.warn "AgentMarkdown:", "#{LLMS_TXT_URL_REQUIRED_MESSAGE}; skipping llms.txt"
|
|
130
|
+
false
|
|
117
131
|
end
|
|
118
132
|
|
|
119
|
-
def post_content(post, settings)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
def post_content(site, post, settings)
|
|
134
|
+
entries = []
|
|
135
|
+
entries << date_metadata(post).to_s if Configuration.enabled?(settings, "include_dates")
|
|
136
|
+
entries << AuthorMetadata.new(site.config).to_s if Configuration.enabled?(settings, "include_author")
|
|
137
|
+
MetadataFooter.new(entries).append_to(post.content)
|
|
123
138
|
end
|
|
124
139
|
|
|
125
140
|
def date_metadata(post) = DateMetadata.new(post.data)
|
|
126
141
|
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def link_title(post)
|
|
131
|
-
post.data.fetch("title", post.basename_without_ext)
|
|
132
|
-
.to_s.gsub(/\s+/, " ").strip
|
|
133
|
-
.gsub(/[\\\[\]]/) { |character| "\\#{character}" }
|
|
142
|
+
# Retained for the public test seam used by existing integrations.
|
|
143
|
+
def sorted_posts(posts, settings)
|
|
144
|
+
LlmsDocumentOrdering.new(settings).legacy_ordered(posts) { |post| date_metadata(post).published_date }
|
|
134
145
|
end
|
|
135
|
-
|
|
136
|
-
# Unescaped parentheses would end the Markdown link destination early.
|
|
137
|
-
def escaped_link_url(url) = url.gsub("(", "%28").gsub(")", "%29")
|
|
138
146
|
end
|
|
139
147
|
end
|
|
140
148
|
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
require_relative "llms_text"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module AgentMarkdown
|
|
8
|
+
class LlmsDocumentIndex
|
|
9
|
+
Section = Data.define(:name, :documents)
|
|
10
|
+
OPTIONAL_KEY = "optional"
|
|
11
|
+
|
|
12
|
+
def initialize(settings, documents)
|
|
13
|
+
@settings = settings
|
|
14
|
+
@documents = documents.select(&:index)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def default_compatibility?
|
|
18
|
+
Configuration.enabled?(settings, "posts") &&
|
|
19
|
+
!Configuration.enabled?(settings, "pages") &&
|
|
20
|
+
Configuration.collection_names(settings).empty? &&
|
|
21
|
+
!Configuration.enabled?(settings, "include_descriptions") &&
|
|
22
|
+
documents.none? { |document| document.optional || document.section }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sections
|
|
26
|
+
@sections ||= build_sections
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :settings, :documents
|
|
32
|
+
|
|
33
|
+
def build_sections
|
|
34
|
+
groups, names = grouped_documents
|
|
35
|
+
section_registry(groups, names).filter_map do |key, name|
|
|
36
|
+
Section.new(name, groups.fetch(key)) if groups.key?(key)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def grouped_documents
|
|
41
|
+
documents.each_with_object([Hash.new { |hash, key| hash[key] = [] }, {}]) do |document, entries|
|
|
42
|
+
groups, names = entries
|
|
43
|
+
key = resolved_section_key(document)
|
|
44
|
+
groups[key] << document
|
|
45
|
+
names[key] ||= section_name(document, key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def section_registry(groups, names)
|
|
50
|
+
registry = default_registry.merge(custom_registry(groups, names))
|
|
51
|
+
if groups.key?(OPTIONAL_KEY)
|
|
52
|
+
registry.delete(OPTIONAL_KEY)
|
|
53
|
+
registry[OPTIONAL_KEY] = "Optional"
|
|
54
|
+
end
|
|
55
|
+
registry
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_registry
|
|
59
|
+
@default_registry ||= default_section_names.each_with_object({}) do |name, registry|
|
|
60
|
+
registry[section_key(name)] ||= name
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def custom_registry(groups, names)
|
|
65
|
+
groups.each_with_object({}) do |(key, _documents), registry|
|
|
66
|
+
registry[key] = names.fetch(key) if custom_section?(key)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def custom_section?(key) = key != OPTIONAL_KEY && !default_registry.key?(key)
|
|
71
|
+
|
|
72
|
+
def default_section_names
|
|
73
|
+
@default_section_names ||= %w[Articles Pages] + collection_section_names
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def collection_section_names
|
|
77
|
+
Configuration.collection_names(settings).map { |name| humanize(name) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resolved_section_key(document)
|
|
81
|
+
return OPTIONAL_KEY if document.optional || section_key(document.section) == OPTIONAL_KEY
|
|
82
|
+
return section_key(document.section) if document.section
|
|
83
|
+
|
|
84
|
+
implicit_section_key(document)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def section_name(document, key)
|
|
88
|
+
return "Optional" if key == OPTIONAL_KEY
|
|
89
|
+
return document.section if document.section
|
|
90
|
+
|
|
91
|
+
default_registry.fetch(key)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def implicit_section_key(document)
|
|
95
|
+
case document.source_kind
|
|
96
|
+
when :post
|
|
97
|
+
section_key("Articles")
|
|
98
|
+
when :page
|
|
99
|
+
section_key("Pages")
|
|
100
|
+
when :collection
|
|
101
|
+
section_key(humanize(collection_label(document)))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def section_key(name)
|
|
106
|
+
LlmsText.one_line(name).downcase.split(/[-_\s]+/).reject(&:empty?).join(" ")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def collection_label(document)
|
|
110
|
+
return unless document.source_kind == :collection
|
|
111
|
+
|
|
112
|
+
collection = document.source_document.collection
|
|
113
|
+
collection.label if collection.respond_to?(:label)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def humanize(name)
|
|
117
|
+
name.split(/[-_\s]+/).filter_map do |word|
|
|
118
|
+
next if word.empty?
|
|
119
|
+
|
|
120
|
+
"#{word[0].upcase}#{word[1..].to_s.downcase}"
|
|
121
|
+
end.join(" ")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module AgentMarkdown
|
|
7
|
+
class LlmsDocumentOrdering
|
|
8
|
+
def initialize(settings)
|
|
9
|
+
@settings = settings
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def ordered(documents, &published_date)
|
|
13
|
+
dated, undated = records(documents, &published_date).partition { |_document, date, _order| !date.nil? }
|
|
14
|
+
dated.sort! { |left, right| compare(left, right) }
|
|
15
|
+
(dated + undated).map(&:first)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def legacy_ordered(documents, &published_date)
|
|
19
|
+
dated, undated = records(documents, &published_date).partition { |_document, date, _order| !date.nil? }
|
|
20
|
+
dated.sort_by! { |_document, date, _order| date }
|
|
21
|
+
dated.reverse! if Configuration.sort_order(settings) == "desc"
|
|
22
|
+
(dated + undated).map(&:first)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :settings
|
|
28
|
+
|
|
29
|
+
def records(documents)
|
|
30
|
+
documents.each_with_index.map { |document, order| [document, yield(document), order] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def compare(left, right)
|
|
34
|
+
comparison = left[1] <=> right[1]
|
|
35
|
+
comparison = -comparison if Configuration.sort_order(settings) == "desc"
|
|
36
|
+
comparison.zero? ? left[2] <=> right[2] : comparison
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|