jekyll-llms-output 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 77bd6b72574803bc37691dc39fed7ae2630293858a717ffcdff715df1d950a7a
4
+ data.tar.gz: cbf85998d24ee83072793940c227902068ae4cec56a767ec54eece3cde62ca1e
5
+ SHA512:
6
+ metadata.gz: d8ad8e05490ecbeb45d9b3ea064473fdb40e6d4989b9349719c45340c9ed378e21bee730ffac30477d28e0f6c3765131aa7af9ea7dcc11b527bacd66767afa2a
7
+ data.tar.gz: 51f71c2b80c5ec81f48c173ea9e7f9746ed3b633ccc27c1a90cd9c39ee9a8e0768a4c4357fada0d95ea6966cf357253af7d9cf4e11ab7ca6d13b8d41e6823d1b
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-05-05
4
+
5
+ - Initial release.
6
+ - Generates `/llms.txt` from `_data/llms.yml` (curated) or auto from collections.
7
+ - Generates `/llms-full.txt` by concatenating source bodies (Liquid rendered).
8
+ - Per-document opt-out for `llms-full.txt` via `llms_output: false` in frontmatter.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abhinav Saxena
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # jekyll-llms-output
2
+
3
+ A Jekyll plugin that generates two files agents and LLM crawlers expect:
4
+
5
+ - **`/llms.txt`** - an index of your content following the [llmstxt.org](https://llmstxt.org) format. Hand-curate it via `_data/llms.yml`, or let the plugin auto-generate from your collections.
6
+ - **`/llms-full.txt`** - a single concatenated dump of all your post bodies (Liquid rendered, source markdown). Useful for agents that want to ingest your whole site in one fetch.
7
+
8
+ Pairs nicely with [`jekyll-markdown-output`](https://github.com/abhinavs/jekyll-markdown-output), which writes per-page `.md` siblings.
9
+
10
+ ## Install
11
+
12
+ ```ruby
13
+ group :jekyll_plugins do
14
+ gem "jekyll-llms-output"
15
+ end
16
+ ```
17
+
18
+ ```yaml
19
+ plugins:
20
+ - jekyll-llms-output
21
+ ```
22
+
23
+ ## Configure
24
+
25
+ Defaults are sensible for a typical blog. Override via `_config.yml`:
26
+
27
+ ```yaml
28
+ llms_output:
29
+ enabled: true # global on/off
30
+
31
+ index:
32
+ enabled: true # write /llms.txt
33
+ output: /llms.txt
34
+ data: llms # reads _data/llms.yml if it exists
35
+ title: ~ # default: site.title
36
+ description: ~ # default: site.description
37
+ collections: [posts] # used in auto mode (no data file)
38
+
39
+ full:
40
+ enabled: true # write /llms-full.txt
41
+ output: /llms-full.txt
42
+ collections: [posts]
43
+ pages: false
44
+ page_extensions: [.md, .markdown]
45
+ separator: "\n\n---\n\n"
46
+ include_url: true
47
+ include_date: true
48
+ respect_markdown_output: false # if true, also honor jekyll-markdown-output's opt-out flag
49
+ ```
50
+
51
+ ## `/llms.txt` modes
52
+
53
+ ### Curated (recommended)
54
+
55
+ Drop a `_data/llms.yml` in your site:
56
+
57
+ ```yaml
58
+ title: Abhinav Saxena
59
+ description: |
60
+ Personal website of Abhinav Saxena, a generalist with interests in
61
+ entrepreneurship, software engineering, and engineering leadership.
62
+
63
+ sections:
64
+ - heading: Agent Resources
65
+ links:
66
+ - title: agents.md
67
+ url: https://www.abhinav.co/.well-known/agents.md
68
+ description: Context and instructions for coding agents
69
+ - title: skills.md
70
+ url: https://www.abhinav.co/.well-known/skills.md
71
+ description: Decision table mapping needs to specific posts
72
+
73
+ - heading: Featured Writing
74
+ links:
75
+ - title: Terminal is having a second life
76
+ url: https://www.abhinav.co/terminal-second-life
77
+ description: Why agentic tooling has pulled the terminal back to the centre of the dev workflow
78
+ ```
79
+
80
+ The plugin renders this exact structure to `/llms.txt`. Curation is preserved, you can section by topic, and you control which links appear.
81
+
82
+ ### Auto
83
+
84
+ If `_data/llms.yml` is absent, the plugin generates from `index.collections` - one `## Section` per collection, one bullet per document, with the `summary` (or excerpt) as the description.
85
+
86
+ ## `/llms-full.txt`
87
+
88
+ Concatenates the source body of every document in `full.collections` (and pages if `full.pages: true`), with a `# Title` header per item and a `---` separator between them.
89
+
90
+ ```text
91
+ # Hello World
92
+
93
+ URL: https://example.com/2024/01/01/hello
94
+ Date: 2024-01-01T09:00:00+00:00
95
+
96
+ This is the body of the post.
97
+
98
+ ---
99
+
100
+ # Another Post
101
+ ...
102
+ ```
103
+
104
+ ### Per-document opt-out
105
+
106
+ Skip a single document from `llms-full.txt`:
107
+
108
+ ```yaml
109
+ ---
110
+ title: Draft thinking
111
+ llms_output: false
112
+ ---
113
+ ```
114
+
115
+ ## Compatibility
116
+
117
+ - Jekyll 3.7+ and 4.x
118
+ - Ruby 2.7+
119
+
120
+ ### GitHub Pages
121
+
122
+ GitHub Pages restricts plugins to a whitelist; this gem isn't on it. Build your site in CI (Actions, Netlify, Cloudflare Pages, Vercel) and deploy `_site/` to GH Pages.
123
+
124
+ ## License
125
+
126
+ MIT. See `LICENSE`.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module LlmsOutput
5
+ # Builds the body of /llms-full.txt: a concatenation of every document's
6
+ # source markdown, separated by a configurable delimiter, each with a
7
+ # small header (title + url) for context.
8
+ class FullBuilder
9
+ attr_reader :site, :options
10
+
11
+ def initialize(site, options)
12
+ @site = site
13
+ @options = options
14
+ end
15
+
16
+ def build
17
+ chunks = []
18
+
19
+ Array(options["collections"]).each do |coll_name|
20
+ collection = site.collections[coll_name.to_s]
21
+ next unless collection
22
+ collection.docs.each { |doc| chunks << render_doc(doc) }
23
+ end
24
+
25
+ if options["pages"]
26
+ exts = Array(options["page_extensions"]).map(&:downcase)
27
+ site.pages.each do |page|
28
+ next unless exts.include?(File.extname(page.path).downcase)
29
+ chunks << render_doc(page)
30
+ end
31
+ end
32
+
33
+ chunks.compact.join(options["separator"] || "\n\n---\n\n") + "\n"
34
+ end
35
+
36
+ private
37
+
38
+ def render_doc(doc)
39
+ return nil if doc.data["llms_output"] == false
40
+ return nil if doc.data["markdown_output"] == false && options["respect_markdown_output"]
41
+
42
+ url = absolute_url(doc.url)
43
+ title = doc.data["title"] || url
44
+ body = source_body(doc).strip
45
+ body = render_liquid(doc, body) if doc.data["render_with_liquid"] != false
46
+
47
+ header = +"# #{title}\n"
48
+ header << "\nURL: #{url}\n" if options["include_url"]
49
+ header << "Date: #{doc.data["date"].iso8601}\n" if options["include_date"] && doc.data["date"].respond_to?(:iso8601)
50
+ header << "\n"
51
+
52
+ header + body
53
+ end
54
+
55
+ def source_path(doc)
56
+ File.expand_path(doc.path.to_s, site.source)
57
+ end
58
+
59
+ def source_body(doc)
60
+ raw = File.read(source_path(doc), encoding: "UTF-8")
61
+ parts = raw.split(/^---\s*$\n/, 3)
62
+ parts.length >= 3 ? parts[2] : raw
63
+ end
64
+
65
+ def render_liquid(doc, body)
66
+ info = {
67
+ filters: [Jekyll::Filters],
68
+ registers: { site: site, page: doc.to_liquid },
69
+ }
70
+ template = site.liquid_renderer.file(source_path(doc)).parse(body)
71
+ template.render!(site.site_payload.merge("page" => doc.to_liquid), info)
72
+ rescue StandardError => e
73
+ rel = doc.respond_to?(:relative_path) ? doc.relative_path : doc.path
74
+ Jekyll.logger.warn("LlmsOutput:", "render failed for #{rel}: #{e.message}")
75
+ body
76
+ end
77
+
78
+ def absolute_url(url)
79
+ site_url = site.config["url"]
80
+ return url if url.to_s.start_with?("http")
81
+ site_url ? "#{site_url}#{url}" : url
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Jekyll
6
+ module LlmsOutput
7
+ DEFAULTS = {
8
+ "enabled" => true,
9
+ "index" => {
10
+ "enabled" => true,
11
+ "output" => "/llms.txt",
12
+ "data" => "llms",
13
+ "title" => nil,
14
+ "description" => nil,
15
+ "collections" => ["posts"],
16
+ },
17
+ "full" => {
18
+ "enabled" => true,
19
+ "output" => "/llms-full.txt",
20
+ "collections" => ["posts"],
21
+ "pages" => false,
22
+ "page_extensions" => [".md", ".markdown"],
23
+ "separator" => "\n\n---\n\n",
24
+ "include_url" => true,
25
+ "include_date" => true,
26
+ "respect_markdown_output" => false,
27
+ },
28
+ }.freeze
29
+
30
+ def self.config_for(site)
31
+ user = site.config["llms_output"] || {}
32
+ merged = deep_merge(DEFAULTS, user)
33
+ merged
34
+ end
35
+
36
+ def self.deep_merge(a, b)
37
+ a.merge(b) do |_, av, bv|
38
+ av.is_a?(Hash) && bv.is_a?(Hash) ? deep_merge(av, bv) : bv
39
+ end
40
+ end
41
+
42
+ def self.write_all(site)
43
+ config = config_for(site)
44
+ return unless config["enabled"]
45
+
46
+ if config["index"]["enabled"]
47
+ body = IndexBuilder.new(site, config["index"]).build
48
+ write_file(site, config["index"]["output"], body)
49
+ Jekyll.logger.info("LlmsOutput:", "wrote #{config["index"]["output"]}")
50
+ end
51
+
52
+ if config["full"]["enabled"]
53
+ body = FullBuilder.new(site, config["full"]).build
54
+ write_file(site, config["full"]["output"], body)
55
+ Jekyll.logger.info("LlmsOutput:", "wrote #{config["full"]["output"]}")
56
+ end
57
+ end
58
+
59
+ def self.write_file(site, output_path, body)
60
+ path = File.join(site.dest, output_path)
61
+ FileUtils.mkdir_p(File.dirname(path))
62
+ File.write(path, body)
63
+ end
64
+ end
65
+ end
66
+
67
+ Jekyll::Hooks.register :site, :post_write do |site|
68
+ Jekyll::LlmsOutput.write_all(site)
69
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module LlmsOutput
5
+ # Builds the body of /llms.txt.
6
+ #
7
+ # Two modes:
8
+ # 1. Data-driven: site.data[<key>] holds a hash with title / description /
9
+ # sections. The plugin renders that structure to llmstxt.org format.
10
+ # 2. Auto: no data hash present. The plugin generates one section per
11
+ # configured collection with a bullet for each document.
12
+ #
13
+ # llmstxt.org spec we follow:
14
+ # # Title
15
+ # > Optional one-line description
16
+ # ## Section heading
17
+ # - [Title](url): optional description
18
+ class IndexBuilder
19
+ attr_reader :site, :options
20
+
21
+ def initialize(site, options)
22
+ @site = site
23
+ @options = options
24
+ end
25
+
26
+ def build
27
+ data_key = options["data"]
28
+ data_hash = data_key && site.data[data_key.to_s]
29
+
30
+ if data_hash.is_a?(Hash) && data_hash["sections"]
31
+ render_from_data(data_hash)
32
+ else
33
+ render_auto
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def render_from_data(data)
40
+ out = +""
41
+ title = data["title"] || site.config["title"]
42
+ description = data["description"] || site.config["description"]
43
+
44
+ out << "# #{title}\n\n" if title && !title.to_s.empty?
45
+ if description && !description.to_s.empty?
46
+ out << "> #{description.to_s.strip}\n\n"
47
+ end
48
+
49
+ Array(data["sections"]).each do |section|
50
+ heading = section["heading"] || section["title"]
51
+ next unless heading
52
+ out << "## #{heading}\n\n"
53
+ Array(section["links"]).each do |link|
54
+ out << format_link(link["title"], link["url"], link["description"])
55
+ end
56
+ out << "\n"
57
+ end
58
+
59
+ out.strip + "\n"
60
+ end
61
+
62
+ def render_auto
63
+ out = +""
64
+ title = options["title"] || site.config["title"]
65
+ description = options["description"] || site.config["description"]
66
+
67
+ out << "# #{title}\n\n" if title && !title.to_s.empty?
68
+ if description && !description.to_s.empty?
69
+ out << "> #{description.to_s.strip}\n\n"
70
+ end
71
+
72
+ Array(options["collections"]).each do |coll_name|
73
+ collection = site.collections[coll_name.to_s]
74
+ next unless collection && !collection.docs.empty?
75
+ out << "## #{section_heading_for(coll_name)}\n\n"
76
+ docs_in_render_order(collection.docs).each do |doc|
77
+ url = absolute_url(doc.url)
78
+ out << format_link(doc.data["title"] || url, url, summary_for(doc))
79
+ end
80
+ out << "\n"
81
+ end
82
+
83
+ out.strip + "\n"
84
+ end
85
+
86
+ def docs_in_render_order(docs)
87
+ # Newest first when docs have a date.
88
+ docs.sort_by { |d| d.respond_to?(:date) && d.date ? d.date : Time.at(0) }.reverse
89
+ end
90
+
91
+ def section_heading_for(name)
92
+ # "posts" -> "Posts", "design_notes" -> "Design notes"
93
+ s = name.to_s.tr("_-", " ")
94
+ s.empty? ? "Items" : s[0].upcase + s[1..]
95
+ end
96
+
97
+ def format_link(title, url, description)
98
+ line = "- [#{title}](#{url})"
99
+ line += ": #{description.to_s.strip}" if description && !description.to_s.empty?
100
+ line + "\n"
101
+ end
102
+
103
+ def summary_for(doc)
104
+ s = doc.data["summary"]
105
+ return s.strip if s.is_a?(String) && !s.strip.empty?
106
+ excerpt = doc.data["excerpt"]
107
+ return nil if excerpt.nil?
108
+ text = excerpt.respond_to?(:content) ? excerpt.content : excerpt
109
+ text = text.to_s.gsub(/<[^>]+>/, "").strip
110
+ text.empty? ? nil : text.split(/\n\n/).first.to_s.strip
111
+ end
112
+
113
+ def absolute_url(url)
114
+ site_url = site.config["url"]
115
+ return url if url.to_s.start_with?("http")
116
+ site_url ? "#{site_url}#{url}" : url
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module LlmsOutput
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ require_relative "jekyll-llms-output/version"
6
+ require_relative "jekyll-llms-output/index_builder"
7
+ require_relative "jekyll-llms-output/full_builder"
8
+ require_relative "jekyll-llms-output/generator"
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-llms-output
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Abhinav Saxena
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jekyll
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.7'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '3.7'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: kramdown-parser-gfm
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rake
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.12'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.12'
75
+ description: |
76
+ A Jekyll plugin that writes /llms.txt (an index following llmstxt.org)
77
+ and /llms-full.txt (a concatenated full-text dump of your content).
78
+ Supports a hand-curated _data/llms.yml structure or auto-generation
79
+ from configured collections.
80
+ email:
81
+ - abhinav061@gmail.com
82
+ executables: []
83
+ extensions: []
84
+ extra_rdoc_files: []
85
+ files:
86
+ - CHANGELOG.md
87
+ - LICENSE
88
+ - README.md
89
+ - lib/jekyll-llms-output.rb
90
+ - lib/jekyll-llms-output/full_builder.rb
91
+ - lib/jekyll-llms-output/generator.rb
92
+ - lib/jekyll-llms-output/index_builder.rb
93
+ - lib/jekyll-llms-output/version.rb
94
+ homepage: https://github.com/abhinavs/jekyll-llms-output
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/abhinavs/jekyll-llms-output
99
+ source_code_uri: https://github.com/abhinavs/jekyll-llms-output/tree/main
100
+ bug_tracker_uri: https://github.com/abhinavs/jekyll-llms-output/issues
101
+ changelog_uri: https://github.com/abhinavs/jekyll-llms-output/blob/main/CHANGELOG.md
102
+ rubygems_mfa_required: 'true'
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 2.7.0
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.5.22
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Generate /llms.txt and /llms-full.txt for a Jekyll site.
122
+ test_files: []