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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -1
  3. data/README.md +231 -42
  4. data/docs/deployment.md +138 -0
  5. data/examples/cloudflare/src/worker.js +254 -0
  6. data/examples/cloudflare/wrangler.toml +8 -0
  7. data/examples/netlify/netlify/edge-functions/markdown-negotiation.ts +281 -0
  8. data/examples/netlify/netlify.toml +6 -0
  9. data/examples/nginx/negotiation.js +267 -0
  10. data/examples/nginx/nginx.conf +40 -0
  11. data/lib/jekyll/agent_markdown/agent_markdown_link_tag.rb +20 -0
  12. data/lib/jekyll/agent_markdown/author_metadata.rb +28 -0
  13. data/lib/jekyll/agent_markdown/collection_validator.rb +73 -0
  14. data/lib/jekyll/agent_markdown/configuration.rb +67 -6
  15. data/lib/jekyll/agent_markdown/date_metadata.rb +2 -8
  16. data/lib/jekyll/agent_markdown/document_exporter.rb +113 -0
  17. data/lib/jekyll/agent_markdown/document_header.rb +59 -0
  18. data/lib/jekyll/agent_markdown/document_settings.rb +170 -0
  19. data/lib/jekyll/agent_markdown/exported_document.rb +17 -0
  20. data/lib/jekyll/agent_markdown/generator.rb +78 -70
  21. data/lib/jekyll/agent_markdown/llms_document_index.rb +125 -0
  22. data/lib/jekyll/agent_markdown/llms_document_ordering.rb +40 -0
  23. data/lib/jekyll/agent_markdown/llms_full_renderer.rb +72 -0
  24. data/lib/jekyll/agent_markdown/llms_headings.rb +10 -15
  25. data/lib/jekyll/agent_markdown/llms_index_renderer.rb +102 -0
  26. data/lib/jekyll/agent_markdown/llms_text.rb +30 -0
  27. data/lib/jekyll/agent_markdown/metadata_footer.rb +26 -0
  28. data/lib/jekyll/agent_markdown/source_documents.rb +66 -0
  29. data/lib/jekyll/agent_markdown/version.rb +1 -1
  30. data/lib/jekyll-agent-markdown.rb +3 -0
  31. 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 "markdown_sibling_path"
9
- require_relative "raw_markdown_file"
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
- URL_REQUIRED_MESSAGE =
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
- @context = Liquid::Context.new({}, {}, { site: site })
28
- destination_claims = destination_claims(site)
29
- included_posts = export_posts(site, settings, destination_claims) if Configuration.enabled?(settings, "posts")
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 destination_claims(site)
36
- DestinationClaims.new(site.dest).tap do |claims|
37
- site.each_site_file { |item| claims.add_existing(item.destination(site.dest)) }
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 export_posts(site, settings, destination_claims)
42
- site.posts.docs.filter_map { |post| export_post(site, settings, destination_claims, post) }
62
+ def source_documents(site, settings, collection_validator)
63
+ SourceDocuments.new(site, settings, collection_validator:)
43
64
  end
44
65
 
45
- def export_post(site, settings, destination_claims, post)
46
- setting = post.data.fetch("agent_markdown", true)
47
- setting_name = "agent_markdown in #{post.relative_path}"
48
- return unless Configuration.enabled_value?(setting, name: setting_name)
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, posts, destination_claims)
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", llms_txt(site, posts, settings))
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
- # A missing url only fails the build when llms_txt was explicitly
84
- # configured; the default is to warn and skip so adding the gem never
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
- Jekyll.logger.warn "AgentMarkdown:", "#{URL_REQUIRED_MESSAGE}; skipping llms.txt"
91
- false
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 llms_txt(site, posts, settings)
95
- sections = [LlmsHeadings.new(site, settings).to_s, article_links(site, posts, settings)]
96
- "#{sections.reject(&:empty?).join("\n\n")}\n"
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 article_links(site, posts, settings)
100
- site_url = site.config["url"].sub(%r{/+\z}, "")
101
- sorted_posts(posts, settings).map { |post| article_link(site_url, post, settings) }.join("\n")
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 sorted_posts(posts, settings)
105
- dated, undated = posts.map { |post| [post, date_metadata(post).published_date] }.partition(&:last)
106
- dated.sort_by!(&:last)
107
- dated.reverse! if Configuration.sort_order(settings) == "desc"
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
- def article_link(site_url, post, settings)
112
- url = "#{site_url}#{relative_url(post.data.fetch("agent_markdown_url"))}"
113
- link = "- [#{link_title(post)}](#{escaped_link_url(url)})"
114
- return link unless Configuration.enabled?(settings, "include_dates")
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
- [link, date_metadata(post).to_s].reject(&:empty?).join(" | ")
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
- return post.content unless Configuration.enabled?(settings, "include_dates")
121
-
122
- date_metadata(post).append_to(post.content)
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
- # Backslashes and square brackets would end the Markdown link text early;
128
- # whitespace runs (including newlines) would break the one-entry-per-line
129
- # format.
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