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,267 @@
1
+ const HTML = {
2
+ name: "html",
3
+ contentType: "text/html; charset=utf-8",
4
+ type: "text",
5
+ subtype: "html",
6
+ parameters: {charset: "utf-8"}
7
+ };
8
+ const MARKDOWN = {
9
+ name: "markdown",
10
+ contentType: "text/markdown; charset=utf-8",
11
+ type: "text",
12
+ subtype: "markdown",
13
+ parameters: {charset: "utf-8"}
14
+ };
15
+ const HOP_BY_HOP = {
16
+ connection: true,
17
+ "keep-alive": true,
18
+ "proxy-authenticate": true,
19
+ "proxy-authorization": true,
20
+ "proxy-connection": true,
21
+ te: true,
22
+ trailer: true,
23
+ "transfer-encoding": true,
24
+ upgrade: true
25
+ };
26
+ const SAFE_METHODS = {GET: true, HEAD: true};
27
+
28
+ async function serve(r) {
29
+ // njs subrequests are always GET, so a POST or DELETE reaching this handler
30
+ // would be silently downgraded into a static read of the page body.
31
+ if (!SAFE_METHODS[r.method]) {
32
+ r.headersOut.Allow = "GET, HEAD";
33
+ r.headersOut["Content-Type"] = "text/plain; charset=utf-8";
34
+ r.return(405, "Method Not Allowed\n");
35
+ return;
36
+ }
37
+
38
+ const explicitMarkdown = r.uri.endsWith(".md");
39
+ const preference = explicitMarkdown
40
+ ? {selected: "markdown", htmlAcceptable: false}
41
+ : negotiate(r.headersIn.Accept);
42
+
43
+ if (!preference.selected) {
44
+ r.headersOut["Content-Type"] = "text/plain; charset=utf-8";
45
+ r.headersOut.Vary = mergeVary(r.headersOut.Vary, "Accept");
46
+ r.return(406, "Not Acceptable\n");
47
+ return;
48
+ }
49
+
50
+ const paths = variantPaths(r.uri, explicitMarkdown);
51
+ const variant = await fetchVariant(r, paths, preference, explicitMarkdown);
52
+ const reply = variant.reply;
53
+
54
+ copyResponseHeaders(r, reply);
55
+ if (!explicitMarkdown) r.headersOut.Vary = mergeVary(reply.headersOut.Vary, "Accept");
56
+
57
+ if (reply.status < 200 || reply.status >= 300) {
58
+ r.return(reply.status, reply.responseBuffer);
59
+ return;
60
+ }
61
+
62
+ const representation = variant.served === "markdown" ? MARKDOWN : HTML;
63
+ r.headersOut["Content-Type"] = representation.contentType;
64
+ r.headersOut.Link = mergeLink(reply.headersOut.Link, alternateLink(variant.served, paths));
65
+ r.return(reply.status, reply.responseBuffer);
66
+ }
67
+
68
+ // A negotiated Markdown variant is not guaranteed to exist: the plugin exports
69
+ // posts by default, so most page URLs have no .md sibling until the site opts
70
+ // pages or collections in. Fall back to HTML when the client accepts it rather
71
+ // than turning a page the host could serve into a 404.
72
+ async function fetchVariant(r, paths, preference, explicitMarkdown) {
73
+ const reply = await r.subrequest(`/_jekyll_asset${paths[preference.selected]}`);
74
+ if (preference.selected !== "markdown" || explicitMarkdown) {
75
+ return {reply, served: preference.selected};
76
+ }
77
+ if (reply.status !== 404 || !preference.htmlAcceptable) return {reply, served: "markdown"};
78
+
79
+ const fallback = await r.subrequest(`/_jekyll_asset${paths.html}`);
80
+ if (fallback.status === 404) return {reply, served: "markdown"};
81
+
82
+ return {reply: fallback, served: "html"};
83
+ }
84
+
85
+ function negotiate(accept) {
86
+ if (!accept || !accept.trim()) return {selected: "html", htmlAcceptable: true};
87
+
88
+ const ranges = parseAccept(accept);
89
+ const html = qualityFor(HTML, ranges);
90
+ const markdown = qualityFor(MARKDOWN, ranges);
91
+ const htmlAcceptable = html.quality > 0;
92
+
93
+ if (html.quality <= 0 && markdown.quality <= 0) return {selected: null, htmlAcceptable};
94
+ if (markdown.quality > html.quality) return {selected: "markdown", htmlAcceptable};
95
+ if (html.quality > markdown.quality) return {selected: "html", htmlAcceptable};
96
+
97
+ const explicitMarkdown = ranges.some((range) =>
98
+ range.type === "text" &&
99
+ range.subtype === "markdown" &&
100
+ range.quality > 0 &&
101
+ specificity(range, MARKDOWN) !== null
102
+ );
103
+ return {selected: explicitMarkdown ? "markdown" : "html", htmlAcceptable};
104
+ }
105
+
106
+ function qualityFor(representation, ranges) {
107
+ let bestSpecificity = -1;
108
+ let quality = 0;
109
+
110
+ for (const range of ranges) {
111
+ const rangeSpecificity = specificity(range, representation);
112
+ if (rangeSpecificity === null || rangeSpecificity < bestSpecificity) continue;
113
+
114
+ if (rangeSpecificity > bestSpecificity) {
115
+ bestSpecificity = rangeSpecificity;
116
+ quality = range.quality;
117
+ } else {
118
+ quality = Math.max(quality, range.quality);
119
+ }
120
+ }
121
+
122
+ return {quality, specificity: bestSpecificity};
123
+ }
124
+
125
+ function specificity(range, representation) {
126
+ if (range.type === "*" && range.subtype !== "*") return null;
127
+ if (range.type !== "*" && range.type !== representation.type) return null;
128
+ if (range.subtype !== "*" && range.subtype !== representation.subtype) return null;
129
+
130
+ for (const name in range.parameters) {
131
+ if (representation.parameters[name] !== range.parameters[name]) return null;
132
+ }
133
+
134
+ const mediaSpecificity = range.type === "*" ? 0 : range.subtype === "*" ? 1 : 2;
135
+ return (mediaSpecificity * 100) + Object.keys(range.parameters).length;
136
+ }
137
+
138
+ function parseAccept(accept) {
139
+ return splitQuoted(accept, ",").map(parseRange).filter((range) => range !== null);
140
+ }
141
+
142
+ function parseRange(source) {
143
+ const parts = splitQuoted(source, ";");
144
+ const media = parts.shift().trim().toLowerCase();
145
+ const match = media.match(/^([^/\s]+)\/([^/\s]+)$/);
146
+ if (!match) return null;
147
+
148
+ const range = {type: match[1], subtype: match[2], quality: 1, parameters: {}};
149
+ for (const sourceParameter of parts) {
150
+ const separator = sourceParameter.indexOf("=");
151
+ if (separator < 1) {
152
+ if (sourceParameter.trim().toLowerCase() === "q") range.quality = 0;
153
+ continue;
154
+ }
155
+
156
+ const name = sourceParameter.slice(0, separator).trim().toLowerCase();
157
+ const rawValue = sourceParameter.slice(separator + 1).trim();
158
+ if (name === "q") {
159
+ range.quality = parseQuality(rawValue.toLowerCase());
160
+ } else {
161
+ range.parameters[name] = unquote(rawValue).toLowerCase();
162
+ }
163
+ }
164
+
165
+ return range;
166
+ }
167
+
168
+ function parseQuality(value) {
169
+ return /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(value) ? Number(value) : 0;
170
+ }
171
+
172
+ function splitQuoted(source, separator) {
173
+ const parts = [];
174
+ let current = "";
175
+ let quoted = false;
176
+ let escaped = false;
177
+
178
+ for (const character of source) {
179
+ if (escaped) {
180
+ current += character;
181
+ escaped = false;
182
+ } else if (character === "\\" && quoted) {
183
+ current += character;
184
+ escaped = true;
185
+ } else if (character === '"') {
186
+ current += character;
187
+ quoted = !quoted;
188
+ } else if (character === separator && !quoted) {
189
+ parts.push(current);
190
+ current = "";
191
+ } else {
192
+ current += character;
193
+ }
194
+ }
195
+
196
+ parts.push(current);
197
+ return parts;
198
+ }
199
+
200
+ function unquote(value) {
201
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
202
+ return value.slice(1, -1).replace(/\\(.)/g, "$1");
203
+ }
204
+ return value;
205
+ }
206
+
207
+ function variantPaths(pathname, explicitMarkdown) {
208
+ if (explicitMarkdown) return {html: htmlPathFor(pathname), markdown: pathname};
209
+ return {html: pathname, markdown: markdownPathFor(pathname)};
210
+ }
211
+
212
+ function markdownPathFor(pathname) {
213
+ if (pathname === "/") return "/index.md";
214
+ if (pathname.endsWith("/")) return `${pathname.slice(0, -1)}.md`;
215
+ if (/\.html?$/.test(pathname)) return pathname.replace(/\.html?$/, ".md");
216
+ return `${pathname}.md`;
217
+ }
218
+
219
+ function htmlPathFor(pathname) {
220
+ if (pathname === "/index.md") return "/";
221
+ return `${pathname.slice(0, -3)}/`;
222
+ }
223
+
224
+ function alternateLink(selected, paths) {
225
+ const alternate = selected === "markdown" ? "html" : "markdown";
226
+ return `<${paths[alternate]}>; rel="alternate"; type="text/${alternate}"`;
227
+ }
228
+
229
+ function mergeVary(current, field) {
230
+ const fields = (current || "").split(",").map((value) => value.trim()).filter(Boolean);
231
+ if (!fields.some((value) => value.toLowerCase() === field.toLowerCase())) fields.push(field);
232
+ return fields.join(", ");
233
+ }
234
+
235
+ function mergeLink(current, alternate) {
236
+ if (!current || current.includes(alternate)) return current || alternate;
237
+ return `${current}, ${alternate}`;
238
+ }
239
+
240
+ function copyResponseHeaders(r, reply) {
241
+ const excluded = connectionOptions(reply.headersOut);
242
+ for (const name in HOP_BY_HOP) excluded[name] = true;
243
+ excluded["content-length"] = true;
244
+
245
+ for (const name in reply.headersOut) {
246
+ if (excluded[name.toLowerCase()]) continue;
247
+ r.headersOut[name] = reply.headersOut[name];
248
+ }
249
+ }
250
+
251
+ function connectionOptions(headers) {
252
+ const options = {};
253
+ for (const name in headers) {
254
+ if (name.toLowerCase() !== "connection") continue;
255
+
256
+ const values = Array.isArray(headers[name]) ? headers[name] : [headers[name]];
257
+ for (const value of values) {
258
+ for (const token of String(value).split(",")) {
259
+ const option = token.trim().toLowerCase();
260
+ if (option) options[option] = true;
261
+ }
262
+ }
263
+ }
264
+ return options;
265
+ }
266
+
267
+ export default {serve};
@@ -0,0 +1,40 @@
1
+ load_module modules/ngx_http_js_module.so;
2
+
3
+ events {}
4
+
5
+ http {
6
+ # Without these the pass-through location below labels CSS, JavaScript,
7
+ # fonts, and images as text/plain, which stops stylesheets and modules
8
+ # from loading in browsers.
9
+ include mime.types;
10
+ default_type application/octet-stream;
11
+
12
+ js_engine qjs;
13
+ js_import negotiation from /etc/nginx/njs/negotiation.js;
14
+ subrequest_output_buffer_size 10m;
15
+
16
+ server {
17
+ listen 8080;
18
+ server_name _;
19
+ root /srv/jekyll/_site;
20
+ index index.html;
21
+
22
+ location ^~ /_jekyll_asset/ {
23
+ internal;
24
+ alias /srv/jekyll/_site/;
25
+ index index.html;
26
+ }
27
+
28
+ location ~* \.(?:md|html?)$ {
29
+ js_content negotiation.serve;
30
+ }
31
+
32
+ location ~ \.[^/]+$ {
33
+ try_files $uri =404;
34
+ }
35
+
36
+ location / {
37
+ js_content negotiation.serve;
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "jekyll"
5
+
6
+ module Jekyll
7
+ module AgentMarkdown
8
+ class AgentMarkdownLinkTag < Liquid::Tag
9
+ include Jekyll::Filters::URLFilters
10
+
11
+ def render(context)
12
+ @context = context
13
+ markdown_url = context["page"]&.[]("agent_markdown_url")
14
+ return "" unless markdown_url
15
+
16
+ %(<link rel="alternate" type="text/markdown" href="#{CGI.escapeHTML(relative_url(markdown_url))}">)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llms_text"
4
+
5
+ module Jekyll
6
+ module AgentMarkdown
7
+ class AuthorMetadata
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def to_s
13
+ author_name = name
14
+ author_name.empty? ? "" : "Author: #{author_name}"
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :config
20
+
21
+ def name
22
+ author = config["author"]
23
+ author = author["name"] || author[:name] if author.is_a?(Hash)
24
+ LlmsText.one_line(author)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ module Jekyll
6
+ module AgentMarkdown
7
+ class CollectionValidator
8
+ def initialize(site)
9
+ @site = site
10
+ end
11
+
12
+ def validate!(collection_names)
13
+ collection_names.each { |collection_name| collection!(collection_name) }
14
+ end
15
+
16
+ def collection!(collection_name)
17
+ collection = site.collections[collection_name]
18
+ return collection if collection&.write? && valid_document_urls?(collection, collection_name)
19
+
20
+ raise_collection_error(collection, collection_name)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :site
26
+
27
+ def valid_document_urls?(collection, collection_name)
28
+ collection.docs.all? do |document|
29
+ !markdown_document?(document) || public_url?(document, collection_name)
30
+ end
31
+ end
32
+
33
+ def markdown_document?(document)
34
+ document.is_a?(Jekyll::Document) && markdown_extension?(document.extname)
35
+ end
36
+
37
+ def markdown_extension?(extension)
38
+ site.config.fetch("markdown_ext").split(",").any? do |markdown_extension|
39
+ ".#{markdown_extension.strip.downcase}" == extension.to_s.downcase
40
+ end
41
+ end
42
+
43
+ def public_url?(document, collection_name)
44
+ url = document.url
45
+ return true if url.is_a?(String) && url.start_with?("/") && !url.start_with?("//")
46
+
47
+ public_url_error(document, collection_name)
48
+ rescue Jekyll::Errors::FatalException
49
+ raise
50
+ rescue StandardError => e
51
+ public_url_error(document, collection_name, e.message)
52
+ end
53
+
54
+ def public_url_error(document, collection_name, detail = nil)
55
+ message = "#{document.relative_path}: configured collection #{collection_name.inspect} " \
56
+ "cannot produce a public document URL"
57
+ message = "#{message} (#{detail})" if detail
58
+ raise Jekyll::Errors::FatalException,
59
+ message
60
+ end
61
+
62
+ def raise_collection_error(collection, collection_name)
63
+ if collection.nil?
64
+ raise Jekyll::Errors::FatalException,
65
+ "agent_markdown.collections includes #{collection_name.inspect}, but that collection does not exist"
66
+ end
67
+
68
+ raise Jekyll::Errors::FatalException,
69
+ "agent_markdown.collections includes #{collection_name.inspect}, but its output is disabled"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -6,11 +6,25 @@ require "uri"
6
6
  module Jekyll
7
7
  module AgentMarkdown
8
8
  class Configuration
9
- ALLOWED_SETTINGS = %w[include_author include_dates llms_txt posts sort].freeze
9
+ DEFAULTS = {
10
+ "posts" => true,
11
+ "pages" => false,
12
+ "collections" => [].freeze,
13
+ "llms_txt" => true,
14
+ "llms_full_txt" => false,
15
+ "include_descriptions" => false,
16
+ "include_document_header" => false,
17
+ "include_author" => true,
18
+ "include_dates" => true,
19
+ "sort" => "desc"
20
+ }.freeze
21
+ ALLOWED_SETTINGS = DEFAULTS.keys.freeze
10
22
  FALSE_STRINGS = %w[false no off].freeze
11
23
  SORT_ORDERS = %w[asc desc].freeze
12
24
 
13
25
  class << self
26
+ def defaults = DEFAULTS.dup
27
+
14
28
  def for(site)
15
29
  settings = site.config["agent_markdown"]
16
30
  return {} if settings.nil? || settings == true
@@ -24,11 +38,15 @@ module Jekyll
24
38
  end
25
39
 
26
40
  def enabled?(settings, key)
27
- !disabled?(settings.fetch(key, true))
41
+ !disabled?(settings.fetch(key, DEFAULTS.fetch(key)))
28
42
  end
29
43
 
30
44
  def sort_order(settings)
31
- settings.fetch("sort", "desc")
45
+ settings.fetch("sort", DEFAULTS.fetch("sort"))
46
+ end
47
+
48
+ def collection_names(settings)
49
+ settings.fetch("collections", DEFAULTS.fetch("collections"))
32
50
  end
33
51
 
34
52
  def enabled_value?(value, name:)
@@ -56,7 +74,7 @@ module Jekyll
56
74
  def normalized_settings(settings)
57
75
  normalized = settings.to_h { |key, value| [key.to_s, value] }
58
76
  validate_keys!(normalized)
59
- validate_values!(normalized)
77
+ normalize_values!(normalized)
60
78
  normalized
61
79
  end
62
80
 
@@ -69,10 +87,13 @@ module Jekyll
69
87
  "unknown agent_markdown setting#{suffix}: #{unknown.sort.join(", ")}"
70
88
  end
71
89
 
72
- def validate_values!(settings)
90
+ def normalize_values!(settings)
73
91
  settings.each do |key, value|
74
- if key == "sort"
92
+ case key
93
+ when "sort"
75
94
  validate_sort_order!(value)
95
+ when "collections"
96
+ settings[key] = CollectionNames.normalize(value)
76
97
  else
77
98
  validate_value!("agent_markdown.#{key}", value)
78
99
  end
@@ -97,6 +118,46 @@ module Jekyll
97
118
  value == true || disabled?(value)
98
119
  end
99
120
  end
121
+
122
+ module CollectionNames
123
+ module_function
124
+
125
+ def normalize(value)
126
+ validate_array!(value)
127
+ names = value.map { |name| normalized_name!(name) }
128
+ validate_unique!(names)
129
+ validate_reserved!(names)
130
+ names
131
+ end
132
+
133
+ def validate_array!(value)
134
+ return if value.is_a?(Array)
135
+
136
+ raise Jekyll::Errors::FatalException,
137
+ "agent_markdown.collections must be an Array of unique, non-empty collection names; " \
138
+ "got #{value.inspect}"
139
+ end
140
+
141
+ def normalized_name!(name)
142
+ return name.strip if name.is_a?(String) && !name.strip.empty?
143
+
144
+ raise Jekyll::Errors::FatalException,
145
+ "agent_markdown.collections must contain only non-empty collection names; got #{name.inspect}"
146
+ end
147
+
148
+ def validate_unique!(names)
149
+ return if names.uniq.length == names.length
150
+
151
+ raise Jekyll::Errors::FatalException, "agent_markdown.collections must contain unique collection names"
152
+ end
153
+
154
+ def validate_reserved!(names)
155
+ return unless names.include?("posts")
156
+
157
+ raise Jekyll::Errors::FatalException,
158
+ "agent_markdown.collections cannot include \"posts\"; use agent_markdown.posts instead"
159
+ end
160
+ end
100
161
  end
101
162
  end
102
163
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require_relative "metadata_footer"
4
5
 
5
6
  module Jekyll
6
7
  module AgentMarkdown
@@ -21,10 +22,7 @@ module Jekyll
21
22
  end
22
23
 
23
24
  def append_to(content)
24
- return content if to_s.empty?
25
- return "#{self}\n" if content.empty?
26
-
27
- "#{content}#{separator_for(content)}---\n#{self}\n"
25
+ MetadataFooter.new([to_s]).append_to(content)
28
26
  end
29
27
 
30
28
  def published_date = parsed_date(@data["date"])
@@ -36,10 +34,6 @@ module Jekyll
36
34
  "#{label}: #{date}" if date
37
35
  end
38
36
 
39
- def separator_for(content)
40
- content.end_with?("\n") ? "\n" : "\n\n"
41
- end
42
-
43
37
  def formatted_date(value)
44
38
  parsed_date(value)&.strftime(DATE_FORMAT)
45
39
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+ require_relative "configuration"
5
+ require_relative "document_header"
6
+ require_relative "document_settings"
7
+ require_relative "exported_document"
8
+ require_relative "markdown_sibling_path"
9
+ require_relative "raw_markdown_file"
10
+
11
+ module Jekyll
12
+ module AgentMarkdown
13
+ class DocumentExporter
14
+ include Jekyll::Filters::URLFilters
15
+
16
+ # The published bytes and the same document without the generated header.
17
+ # llms-full.txt renders its own heading and Source line, so inlining the
18
+ # published bytes there would repeat both and nest a second level-one
19
+ # heading under the section.
20
+ Contents = Data.define(:content, :body)
21
+
22
+ def initialize(site, settings, destination_claims, content_for_post:)
23
+ @site = site
24
+ @settings = settings
25
+ @destination_claims = destination_claims
26
+ @content_for_post = content_for_post
27
+ @context = Liquid::Context.new({}, {}, { site: site })
28
+ @header = DocumentHeader.new(site, method(:relative_url))
29
+ end
30
+
31
+ def export(document, source_kind:, collection_name: nil)
32
+ # Opted-out and collided documents must not keep a stale or authored
33
+ # value: the alternate-link tag reads it as proof of publication.
34
+ document.data.delete("agent_markdown_url")
35
+ document_settings = DocumentSettings.new(
36
+ document, settings, source_kind: source_kind, collection_name: collection_name
37
+ )
38
+ return unless document_settings.export?
39
+
40
+ claim_export(document, source_kind, document_settings)
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :site, :settings, :destination_claims, :content_for_post, :header
46
+
47
+ def claim_export(document, source_kind, document_settings)
48
+ file, contents = markdown_file(document, source_kind, document_settings)
49
+ return collision_warning(document, file.url) unless destination_claims.claim?(file.destination(site.dest))
50
+
51
+ publish(document, source_kind, document_settings, file, contents)
52
+ end
53
+
54
+ def markdown_file(document, source_kind, document_settings)
55
+ contents = document_contents(document, source_kind, document_settings)
56
+ file = RawMarkdownFile.new(site, MarkdownSiblingPath.for(document.url), contents.content)
57
+ [file, contents]
58
+ end
59
+
60
+ def publish(document, source_kind, document_settings, file, contents)
61
+ document.data["agent_markdown_url"] = file.url
62
+ site.static_files << file
63
+ exported_document(document, source_kind, document_settings, file, contents)
64
+ end
65
+
66
+ def exported_document(document, source_kind, document_settings, file, contents)
67
+ ExportedDocument.new(
68
+ source_document: document,
69
+ source_kind: source_kind,
70
+ markdown_url: file.url,
71
+ markdown_content: contents.content,
72
+ body: contents.body,
73
+ html_url: html_url(document, document_settings),
74
+ **placement(document_settings)
75
+ )
76
+ end
77
+
78
+ def placement(document_settings)
79
+ {
80
+ section: document_settings.section,
81
+ index: document_settings.index?,
82
+ optional: document_settings.optional?
83
+ }
84
+ end
85
+
86
+ def document_contents(document, source_kind, document_settings)
87
+ body = source_kind == :post ? content_for_post.call(document) : document.content.to_s
88
+ return Contents.new(content: body, body: body) unless document_settings.include_document_header?
89
+
90
+ Contents.new(content: header.prepend_to(document, body), body: body)
91
+ end
92
+
93
+ def html_url(document, document_settings)
94
+ return unvalidated_html_url(document) unless document_settings.include_document_header?
95
+
96
+ header.html_url(document)
97
+ end
98
+
99
+ def unvalidated_html_url(document)
100
+ path = relative_url(document.url)
101
+ return path unless Configuration.absolute_http_url?(site.config["url"])
102
+
103
+ "#{site.config["url"].sub(%r{/+\z}, "")}#{path}"
104
+ end
105
+
106
+ def collision_warning(document, url)
107
+ Jekyll.logger.warn "AgentMarkdown:",
108
+ "skipping #{document.relative_path}: #{url} already belongs to another file"
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end