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,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
|
-
|
|
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,
|
|
41
|
+
!disabled?(settings.fetch(key, DEFAULTS.fetch(key)))
|
|
28
42
|
end
|
|
29
43
|
|
|
30
44
|
def sort_order(settings)
|
|
31
|
-
settings.fetch("sort", "
|
|
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
|
-
|
|
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
|
|
90
|
+
def normalize_values!(settings)
|
|
73
91
|
settings.each do |key, value|
|
|
74
|
-
|
|
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
|
-
|
|
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
|