jekyll-agent-markdown 0.1.0 → 0.3.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 +9 -0
- data/README.md +20 -4
- data/lib/jekyll/agent_markdown/configuration.rb +18 -2
- data/lib/jekyll/agent_markdown/date_metadata.rb +60 -0
- data/lib/jekyll/agent_markdown/generator.rb +32 -32
- data/lib/jekyll/agent_markdown/llms_headings.rb +47 -0
- data/lib/jekyll/agent_markdown/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aefdb0b717baa8666d581d48746d9b0afad4f730bbf25af6e534dc4a13800ec8
|
|
4
|
+
data.tar.gz: c702b617f5b8b022f7f3ddc5fe05ca69eeeadc94ed8ecbb87d20b84406c33291
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aef9d1e51132fd68ea7f6e0936addd32c5d69d1da0ef0a9c721df1076d6e9ee99680f7bd2b6c1005af3080b1fd0cf8091da49a68d423acfb8d587afa2ad64007
|
|
7
|
+
data.tar.gz: db2b8abb8fdb9c4d622483bffc2fadf7c32d65b20b1f338d6c2e09bcdefe6a51c351d32cdd203befdb4a97ec111689833432f794592bb2ac81a78799e56b19c9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-08-20
|
|
4
|
+
|
|
5
|
+
- Include the site author's name in `llms.txt` by default, with an `include_author` opt-out.
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-08-20
|
|
8
|
+
|
|
9
|
+
- Add configurable `llms.txt` ordering by normalized published date, defaulting to newest first and placing missing or invalid dates last.
|
|
10
|
+
- Include available published and updated dates in `llms.txt` and Markdown exports by default, with an `include_dates` opt-out.
|
|
11
|
+
|
|
3
12
|
## [0.1.0] - 2026-08-18
|
|
4
13
|
|
|
5
14
|
- Export Jekyll posts as raw Markdown siblings.
|
data/README.md
CHANGED
|
@@ -27,9 +27,14 @@ Both exports are enabled by default:
|
|
|
27
27
|
agent_markdown:
|
|
28
28
|
posts: true
|
|
29
29
|
llms_txt: true
|
|
30
|
+
sort: desc
|
|
31
|
+
include_author: true
|
|
32
|
+
include_dates: true
|
|
30
33
|
```
|
|
31
34
|
|
|
32
|
-
`posts` and `
|
|
35
|
+
`posts`, `llms_txt`, `include_author`, and `include_dates` accept `true`, `false`, or one of the false-style strings `"false"`, `"no"`, and `"off"` (case-insensitively). `sort` accepts `asc` or `desc` and defaults to `desc`, ordering the `llms.txt` article list by normalized published date. Posts with a missing, incomplete, or invalid published date are listed last in their existing order. Unknown keys and other values stop the build with a configuration error instead of being silently ignored. An explicit per-post `agent_markdown` value follows the boolean-setting rules, so a mistyped opt-out cannot publish raw source by accident.
|
|
36
|
+
|
|
37
|
+
By default, `llms.txt` includes the blog author's name from either a scalar Jekyll setting (`author: Jane Doe`) or a mapping (`author: { name: Jane Doe }`). If no author name is configured, the line is omitted. Set `include_author: false` under `agent_markdown` to omit it explicitly.
|
|
33
38
|
|
|
34
39
|
Set `url` to your site's absolute HTTP(S) URL; the `llms.txt` index uses it to generate absolute article links:
|
|
35
40
|
|
|
@@ -54,7 +59,16 @@ Markdown sibling paths are deterministic:
|
|
|
54
59
|
|
|
55
60
|
Extensions are matched case-insensitively, and percent-encoded aliases are compared by their final decoded destination. Destination ownership also treats case-only and Unicode-normalization aliases as equivalent on every platform, keeping builds portable across filesystems. File-versus-directory conflicts are reserved as well. Before adding an export, the plugin checks every page, static file, and writable collection document already known to Jekyll. The existing destination owner wins; later post exports are skipped with a warning and omitted from `llms.txt`. A committed `llms.txt` wins in the same way.
|
|
56
61
|
|
|
57
|
-
The generated file
|
|
62
|
+
The generated file preserves the post's original Markdown body as its initial content, with no front matter, HTML conversion, or Liquid rendering. Liquid tags and directives such as `{{ site.title }}` are published literally. By default, the plugin appends the post's published `date` and optional `last_modified_at` value as a small footer:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
---
|
|
66
|
+
Published at: 2026-01-01 | Updated at: 2026-02-03
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Each available date is formatted as `YYYY-MM-DD`. String values must contain an explicit year, month, and day; missing, incomplete, and invalid values and their labels are omitted. An empty post contains only the metadata line, without a leading `---` that could be mistaken for front matter. Set `include_dates: false` to omit date metadata from both Markdown exports and `llms.txt`, leaving the body-only export shape used before date metadata was introduced.
|
|
70
|
+
|
|
71
|
+
Exclude one post with front matter:
|
|
58
72
|
|
|
59
73
|
```yaml
|
|
60
74
|
agent_markdown: false
|
|
@@ -77,11 +91,13 @@ The generated `/llms.txt` is a compact index, for example:
|
|
|
77
91
|
|
|
78
92
|
> A short description
|
|
79
93
|
|
|
94
|
+
Author: Example Author
|
|
95
|
+
|
|
80
96
|
## Articles
|
|
81
97
|
|
|
82
98
|
> Posts only. Pages and collections are not included.
|
|
83
99
|
|
|
84
|
-
- [First article](https://example.com/articles/first.md)
|
|
100
|
+
- [First article](https://example.com/articles/first.md) | Published at: 2026-01-01
|
|
85
101
|
```
|
|
86
102
|
|
|
87
103
|
## Deployment notes
|
|
@@ -90,7 +106,7 @@ Configure your host to serve generated `.md` files as `Content-Type: text/markdo
|
|
|
90
106
|
|
|
91
107
|
## v0.1.0 limitations
|
|
92
108
|
|
|
93
|
-
Only posts are exported. Pages and custom collections, custom Markdown transformations or templates, front-matter allowlisting, automatic response headers, sitemaps, and
|
|
109
|
+
Only posts are exported. Pages and custom collections, custom Markdown transformations or templates, front-matter allowlisting, automatic response headers, sitemaps, and richer article metadata are intentionally deferred.
|
|
94
110
|
|
|
95
111
|
## Development
|
|
96
112
|
|
|
@@ -6,8 +6,9 @@ require "uri"
|
|
|
6
6
|
module Jekyll
|
|
7
7
|
module AgentMarkdown
|
|
8
8
|
class Configuration
|
|
9
|
-
ALLOWED_SETTINGS = %w[llms_txt posts].freeze
|
|
9
|
+
ALLOWED_SETTINGS = %w[include_author include_dates llms_txt posts sort].freeze
|
|
10
10
|
FALSE_STRINGS = %w[false no off].freeze
|
|
11
|
+
SORT_ORDERS = %w[asc desc].freeze
|
|
11
12
|
|
|
12
13
|
class << self
|
|
13
14
|
def for(site)
|
|
@@ -26,6 +27,10 @@ module Jekyll
|
|
|
26
27
|
!disabled?(settings.fetch(key, true))
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
def sort_order(settings)
|
|
31
|
+
settings.fetch("sort", "desc")
|
|
32
|
+
end
|
|
33
|
+
|
|
29
34
|
def enabled_value?(value, name:)
|
|
30
35
|
validate_value!(name, value)
|
|
31
36
|
!disabled?(value)
|
|
@@ -66,10 +71,21 @@ module Jekyll
|
|
|
66
71
|
|
|
67
72
|
def validate_values!(settings)
|
|
68
73
|
settings.each do |key, value|
|
|
69
|
-
|
|
74
|
+
if key == "sort"
|
|
75
|
+
validate_sort_order!(value)
|
|
76
|
+
else
|
|
77
|
+
validate_value!("agent_markdown.#{key}", value)
|
|
78
|
+
end
|
|
70
79
|
end
|
|
71
80
|
end
|
|
72
81
|
|
|
82
|
+
def validate_sort_order!(value)
|
|
83
|
+
return if SORT_ORDERS.include?(value)
|
|
84
|
+
|
|
85
|
+
raise Jekyll::Errors::FatalException,
|
|
86
|
+
"agent_markdown.sort must be asc or desc; got #{value.inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
73
89
|
def validate_value!(name, value)
|
|
74
90
|
return if valid_value?(value)
|
|
75
91
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module AgentMarkdown
|
|
7
|
+
class DateMetadata
|
|
8
|
+
DATE_FORMAT = "%Y-%m-%d"
|
|
9
|
+
FIELDS = {
|
|
10
|
+
"Published at" => "date",
|
|
11
|
+
"Updated at" => "last_modified_at"
|
|
12
|
+
}.freeze
|
|
13
|
+
REQUIRED_DATE_PARTS = %i[year mon mday].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(data)
|
|
16
|
+
@data = data
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
@to_s ||= FIELDS.filter_map { |label, key| entry(label, @data[key]) }.join(" | ")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
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"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def published_date = parsed_date(@data["date"])
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def entry(label, value)
|
|
35
|
+
date = formatted_date(value)
|
|
36
|
+
"#{label}: #{date}" if date
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def separator_for(content)
|
|
40
|
+
content.end_with?("\n") ? "\n" : "\n\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def formatted_date(value)
|
|
44
|
+
parsed_date(value)&.strftime(DATE_FORMAT)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parsed_date(value)
|
|
48
|
+
return Date.strptime(value.strftime(DATE_FORMAT), DATE_FORMAT) if value.respond_to?(:strftime)
|
|
49
|
+
return unless value.is_a?(String)
|
|
50
|
+
|
|
51
|
+
parts = Date._parse(value, false)
|
|
52
|
+
return unless REQUIRED_DATE_PARTS.all? { |part| parts.key?(part) }
|
|
53
|
+
|
|
54
|
+
Date.new(*parts.values_at(*REQUIRED_DATE_PARTS))
|
|
55
|
+
rescue Date::Error
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "jekyll"
|
|
4
4
|
require_relative "configuration"
|
|
5
|
+
require_relative "date_metadata"
|
|
5
6
|
require_relative "destination_claims"
|
|
7
|
+
require_relative "llms_headings"
|
|
6
8
|
require_relative "markdown_sibling_path"
|
|
7
9
|
require_relative "raw_markdown_file"
|
|
8
10
|
|
|
@@ -24,7 +26,7 @@ module Jekyll
|
|
|
24
26
|
|
|
25
27
|
@context = Liquid::Context.new({}, {}, { site: site })
|
|
26
28
|
destination_claims = destination_claims(site)
|
|
27
|
-
included_posts = export_posts(site, destination_claims) if Configuration.enabled?(settings, "posts")
|
|
29
|
+
included_posts = export_posts(site, settings, destination_claims) if Configuration.enabled?(settings, "posts")
|
|
28
30
|
write_llms_txt(site, settings, included_posts || [], destination_claims)
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -36,16 +38,16 @@ module Jekyll
|
|
|
36
38
|
end
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
def export_posts(site, destination_claims)
|
|
40
|
-
site.posts.docs.filter_map { |post| export_post(site, destination_claims, post) }
|
|
41
|
+
def export_posts(site, settings, destination_claims)
|
|
42
|
+
site.posts.docs.filter_map { |post| export_post(site, settings, destination_claims, post) }
|
|
41
43
|
end
|
|
42
44
|
|
|
43
|
-
def export_post(site, destination_claims, post)
|
|
45
|
+
def export_post(site, settings, destination_claims, post)
|
|
44
46
|
setting = post.data.fetch("agent_markdown", true)
|
|
45
47
|
setting_name = "agent_markdown in #{post.relative_path}"
|
|
46
48
|
return unless Configuration.enabled_value?(setting, name: setting_name)
|
|
47
49
|
|
|
48
|
-
file = RawMarkdownFile.new(site, MarkdownSiblingPath.for(post.url), post
|
|
50
|
+
file = RawMarkdownFile.new(site, MarkdownSiblingPath.for(post.url), post_content(post, settings))
|
|
49
51
|
url = file.url
|
|
50
52
|
return collision_warning(post, url) unless claim_destination?(destination_claims, site, file)
|
|
51
53
|
|
|
@@ -64,15 +66,13 @@ module Jekyll
|
|
|
64
66
|
return unless Configuration.enabled?(settings, "llms_txt")
|
|
65
67
|
return unless llms_txt_ready?(site, settings)
|
|
66
68
|
|
|
67
|
-
file = RawMarkdownFile.new(site, "/llms.txt", llms_txt(site, posts))
|
|
69
|
+
file = RawMarkdownFile.new(site, "/llms.txt", llms_txt(site, posts, settings))
|
|
68
70
|
return llms_txt_collision_warning unless claim_destination?(destination_claims, site, file)
|
|
69
71
|
|
|
70
72
|
site.static_files << file
|
|
71
73
|
end
|
|
72
74
|
|
|
73
|
-
def claim_destination?(destination_claims, site, file)
|
|
74
|
-
destination_claims.claim?(file.destination(site.dest))
|
|
75
|
-
end
|
|
75
|
+
def claim_destination?(destination_claims, site, file) = destination_claims.claim?(file.destination(site.dest))
|
|
76
76
|
|
|
77
77
|
def llms_txt_collision_warning
|
|
78
78
|
Jekyll.logger.warn "AgentMarkdown:",
|
|
@@ -91,37 +91,39 @@ module Jekyll
|
|
|
91
91
|
false
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
def llms_txt(site, posts)
|
|
95
|
-
sections = [
|
|
94
|
+
def llms_txt(site, posts, settings)
|
|
95
|
+
sections = [LlmsHeadings.new(site, settings).to_s, article_links(site, posts, settings)]
|
|
96
96
|
"#{sections.reject(&:empty?).join("\n\n")}\n"
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
description = one_line(site.config["description"])
|
|
103
|
-
headings << "> #{description}" unless description.empty?
|
|
104
|
-
headings << "## Articles"
|
|
105
|
-
headings << "> Posts only. Pages and collections are not included."
|
|
106
|
-
headings.join("\n\n")
|
|
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")
|
|
107
102
|
end
|
|
108
103
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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)
|
|
113
109
|
end
|
|
114
110
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
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")
|
|
115
|
+
|
|
116
|
+
[link, date_metadata(post).to_s].reject(&:empty?).join(" | ")
|
|
118
117
|
end
|
|
119
118
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
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)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
def date_metadata(post) = DateMetadata.new(post.data)
|
|
126
|
+
|
|
125
127
|
# Backslashes and square brackets would end the Markdown link text early;
|
|
126
128
|
# whitespace runs (including newlines) would break the one-entry-per-line
|
|
127
129
|
# format.
|
|
@@ -132,9 +134,7 @@ module Jekyll
|
|
|
132
134
|
end
|
|
133
135
|
|
|
134
136
|
# Unescaped parentheses would end the Markdown link destination early.
|
|
135
|
-
def escaped_link_url(url)
|
|
136
|
-
url.gsub("(", "%28").gsub(")", "%29")
|
|
137
|
-
end
|
|
137
|
+
def escaped_link_url(url) = url.gsub("(", "%28").gsub(")", "%29")
|
|
138
138
|
end
|
|
139
139
|
end
|
|
140
140
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module AgentMarkdown
|
|
7
|
+
class LlmsHeadings
|
|
8
|
+
def initialize(site, settings)
|
|
9
|
+
@site = site
|
|
10
|
+
@settings = settings
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_s
|
|
14
|
+
[title_heading, description_heading, author_heading, "## Articles", articles_description]
|
|
15
|
+
.compact.join("\n\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :site, :settings
|
|
21
|
+
|
|
22
|
+
def title_heading = "# #{one_line(site.config.fetch("title", ""))}".rstrip
|
|
23
|
+
|
|
24
|
+
def description_heading
|
|
25
|
+
description = one_line(site.config["description"])
|
|
26
|
+
"> #{description}" unless description.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def author_heading
|
|
30
|
+
return unless Configuration.enabled?(settings, "include_author")
|
|
31
|
+
|
|
32
|
+
name = author_name
|
|
33
|
+
"Author: #{name}" unless name.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def author_name
|
|
37
|
+
author = site.config["author"]
|
|
38
|
+
author = author["name"] || author[:name] if author.is_a?(Hash)
|
|
39
|
+
one_line(author)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def articles_description = "> Posts only. Pages and collections are not included."
|
|
43
|
+
|
|
44
|
+
def one_line(value) = value ? value.to_s.gsub(/\s+/, " ").strip : ""
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jekyll-agent-markdown
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucian Ghinda
|
|
@@ -42,8 +42,10 @@ files:
|
|
|
42
42
|
- README.md
|
|
43
43
|
- lib/jekyll-agent-markdown.rb
|
|
44
44
|
- lib/jekyll/agent_markdown/configuration.rb
|
|
45
|
+
- lib/jekyll/agent_markdown/date_metadata.rb
|
|
45
46
|
- lib/jekyll/agent_markdown/destination_claims.rb
|
|
46
47
|
- lib/jekyll/agent_markdown/generator.rb
|
|
48
|
+
- lib/jekyll/agent_markdown/llms_headings.rb
|
|
47
49
|
- lib/jekyll/agent_markdown/markdown_sibling_path.rb
|
|
48
50
|
- lib/jekyll/agent_markdown/raw_markdown_file.rb
|
|
49
51
|
- lib/jekyll/agent_markdown/version.rb
|