jekyll-llms-generator 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: ded1a72f3e590ee00a81fa796362243e35b58e419f158f851a7ca4db19905ce8
4
+ data.tar.gz: 89c1b7b0b246359871a47b872c92e198f85bd8d61d6bf9f72ca8e9c49ce2dca9
5
+ SHA512:
6
+ metadata.gz: d1238923ed9780e6a0314b9b2a03407b40036948f1ee241147e4820036d2db3c5167972d970f468803853eca4613ee2881e4d3471df8ccf4222889563d026f96
7
+ data.tar.gz: 284fd114b4d63d660c3619ecbb1352057035b8ef9ed1ed6708eb1bdbeef78932849f9a111ef96acebfbb2fc120a79d87e990d671f9c0a6a4076707eb40869495
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2025-07-08
6
+
7
+ ### Added
8
+ - Initial release
9
+ - `Jekyll::Generator` that builds `/llms.txt` automatically on each build
10
+ - Support for posts, named collections, and explicit page URLs per section
11
+ - Per-document opt-out via `llms_txt: false` frontmatter
12
+ - Optional `/llms-full.txt` with stripped-HTML content (config: `full: true`)
13
+ - Optional `<link rel="ai" href="/llms.txt">` injection into HTML `<head>`
14
+ - Limit control per section (`limit:`)
15
+ - Geocoding-style configuration with `title`, `tagline`, `intro`, and `sections`
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jason Chance
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,117 @@
1
+ # jekyll-llms-generator
2
+
3
+ A Jekyll generator plugin that automatically builds an `/llms.txt` file every time your site builds — keeping AI language models up to date with your latest content.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/jekyll-llms-generator.svg)](https://badge.fury.io/rb/jekyll-llms-generator)
6
+
7
+ ## What is llms.txt?
8
+
9
+ [llms.txt](https://llmstxt.org/) is an emerging convention that lets website owners expose a structured plain-text summary of their site's content for AI models and LLM crawlers. It's like `robots.txt`, but instead of telling crawlers what to ignore it tells them what matters and where to find it.
10
+
11
+ ## Installation
12
+
13
+ Add to your site's `Gemfile`:
14
+
15
+ ```ruby
16
+ gem "jekyll-llms-generator", "~> 0.1"
17
+ ```
18
+
19
+ Add to your `_config.yml` plugins list:
20
+
21
+ ```yaml
22
+ plugins:
23
+ - jekyll-llms-generator
24
+ ```
25
+
26
+ Then run `bundle install`.
27
+
28
+ ## Configuration
29
+
30
+ All configuration lives under the `llms_txt:` key in `_config.yml`.
31
+
32
+ ```yaml
33
+ llms_txt:
34
+ title: "Jason Chance"
35
+ tagline: "Downtown revitalization, local government technology, and public finance."
36
+ intro: |
37
+ Jason Chance is a DDA director and web developer writing about public finance,
38
+ local government technology, and downtown revitalization.
39
+ link_tag: true # inject <link rel="ai" href="/llms.txt"> into every HTML page (default: true)
40
+ full: false # also generate /llms-full.txt with full stripped-HTML content (default: false)
41
+ sections:
42
+ - title: "Blog Posts"
43
+ collection: posts
44
+ limit: 20 # optional: cap number of items
45
+ - title: "Projects"
46
+ collection: projects
47
+ - title: "Pages"
48
+ pages:
49
+ - /about/
50
+ - /contact/
51
+ ```
52
+
53
+ ### Options
54
+
55
+ | Key | Default | Description |
56
+ |-----|---------|-------------|
57
+ | `title` | site `title` | H1 heading at the top of `llms.txt` |
58
+ | `tagline` | site `description` | Blockquote line below the title |
59
+ | `intro` | _(none)_ | Optional paragraph after the tagline |
60
+ | `link_tag` | `true` | Inject `<link rel="ai" href="/llms.txt">` into HTML head |
61
+ | `full` | `false` | Also generate `/llms-full.txt` with full content |
62
+ | `sections` | `[]` | Array of section definitions (see below) |
63
+
64
+ ### Section options
65
+
66
+ Each section must have a `title`. Specify one or both of `collection` and `pages`.
67
+
68
+ | Key | Description |
69
+ |-----|-------------|
70
+ | `collection` | Jekyll collection name (`posts`, `projects`, etc.) |
71
+ | `limit` | Max number of items from this collection |
72
+ | `pages` | Array of page URLs to include |
73
+
74
+ ### Opt-out per document
75
+
76
+ Add `llms_txt: false` to any post or collection document's frontmatter to exclude it:
77
+
78
+ ```yaml
79
+ ---
80
+ title: "Internal Draft"
81
+ llms_txt: false
82
+ ---
83
+ ```
84
+
85
+ ### Description used in llms.txt
86
+
87
+ The plugin looks for a description in this order:
88
+
89
+ 1. `description:` frontmatter
90
+ 2. `tagline:` frontmatter
91
+ 3. Automatically generated excerpt (first paragraph)
92
+
93
+ ## Output
94
+
95
+ **`/llms.txt`** — compact reference with title, tagline, intro, and a bulleted list of links per section.
96
+
97
+ **`/llms-full.txt`** — same structure but each item also includes the full plain-text content of the document (when `full: true`).
98
+
99
+ ## Discovery
100
+
101
+ AI crawlers discover `llms.txt` by checking `<your-site>/llms.txt` directly, the same way they check `robots.txt` or `sitemap.xml`. The optional `<link rel="ai">` injection in the `<head>` of every page provides an additional signal.
102
+
103
+ You can also submit your site to the [llmstxt.org directory](https://directory.llmstxt.cloud/) for broader discovery.
104
+
105
+ ## Compatibility
106
+
107
+ - Jekyll 3.7 – 4.x
108
+ - Ruby 2.7+
109
+ - Works with GitHub Pages via the `remote_theme` workflow (plugin must be listed in `plugins:`)
110
+
111
+ ## Contributing
112
+
113
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
114
+
115
+ ## License
116
+
117
+ [MIT](LICENSE.txt) © Jason Chance
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ module Jekyll
6
+ class LlmsTxtGenerator < Generator
7
+ safe true
8
+ priority :low
9
+
10
+ def generate(site)
11
+ @site = site
12
+ @config = site.config["llms_txt"] || {}
13
+ return unless @config["enabled"] != false
14
+
15
+ # Store content on site object so the post_write hook can access it
16
+ site.data["__llms_txt_content"] = build_llms_txt
17
+
18
+ if @config["full"]
19
+ site.data["__llms_full_txt_content"] = build_llms_full_txt
20
+ end
21
+
22
+ inject_link_tag(site) if @config["link_tag"] != false
23
+ end
24
+
25
+ private
26
+
27
+ def site_base_url
28
+ url = @site.config["url"].to_s.chomp("/")
29
+ baseurl = @site.config["baseurl"].to_s.chomp("/")
30
+ "#{url}#{baseurl}"
31
+ end
32
+
33
+ def build_llms_txt
34
+ lines = []
35
+
36
+ title = @config["title"] || @site.config["title"] || "Site"
37
+ lines << "# #{title}"
38
+
39
+ tagline = @config["tagline"] || @site.config["description"]
40
+ lines << "\n> #{tagline}" if tagline && !tagline.empty?
41
+
42
+ intro = @config["intro"]
43
+ if intro && !intro.empty?
44
+ lines << ""
45
+ lines << intro.strip
46
+ end
47
+
48
+ sections = @config["sections"] || []
49
+ sections.each do |section|
50
+ items = collect_section_items(section)
51
+ next if items.empty?
52
+
53
+ lines << ""
54
+ lines << "## #{section["title"]}"
55
+ items.each do |item|
56
+ lines << format_item(item)
57
+ end
58
+ end
59
+
60
+ lines.join("\n") + "\n"
61
+ end
62
+
63
+ def build_llms_full_txt
64
+ lines = []
65
+
66
+ title = @config["title"] || @site.config["title"] || "Site"
67
+ lines << "# #{title}"
68
+
69
+ tagline = @config["tagline"] || @site.config["description"]
70
+ lines << "\n> #{tagline}" if tagline && !tagline.empty?
71
+
72
+ intro = @config["intro"]
73
+ if intro && !intro.empty?
74
+ lines << ""
75
+ lines << intro.strip
76
+ end
77
+
78
+ sections = @config["sections"] || []
79
+ sections.each do |section|
80
+ items = collect_section_items(section)
81
+ next if items.empty?
82
+
83
+ lines << ""
84
+ lines << "## #{section["title"]}"
85
+ items.each do |item|
86
+ lines << format_item_full(item)
87
+ end
88
+ end
89
+
90
+ lines.join("\n") + "\n"
91
+ end
92
+
93
+ def collect_section_items(section)
94
+ items = []
95
+
96
+ if section["collection"]
97
+ items += items_from_collection(section["collection"], section["limit"])
98
+ end
99
+
100
+ if section["pages"]
101
+ items += items_from_pages(section["pages"])
102
+ end
103
+
104
+ items
105
+ end
106
+
107
+ def items_from_collection(collection_name, limit = nil)
108
+ docs = if collection_name == "posts"
109
+ @site.posts.docs
110
+ elsif @site.collections[collection_name]
111
+ @site.collections[collection_name].docs
112
+ else
113
+ []
114
+ end
115
+
116
+ docs = docs.reject { |d| d.data["llms_txt"] == false }
117
+ docs = docs.sort_by { |d| d.data["date"] || Time.now }.reverse
118
+ docs = docs.first(limit) if limit
119
+
120
+ docs.map { |d| doc_to_item(d) }
121
+ end
122
+
123
+ def items_from_pages(page_urls)
124
+ page_urls.filter_map do |url|
125
+ page = @site.pages.find { |p| p.url == url || p.url == "#{url}index.html" }
126
+ next if page.nil?
127
+ next if page.data["llms_txt"] == false
128
+
129
+ {
130
+ title: page.data["title"] || page.url,
131
+ url: "#{site_base_url}#{page.url}",
132
+ description: page.data["description"] || page.data["excerpt"],
133
+ content: page.content
134
+ }
135
+ end
136
+ end
137
+
138
+ def doc_to_item(doc)
139
+ description = doc.data["description"] ||
140
+ doc.data["tagline"] ||
141
+ (doc.data["excerpt"]&.output ? strip_html(doc.data["excerpt"].output) : nil) ||
142
+ (doc.excerpt ? strip_html(doc.excerpt.output) : nil)
143
+
144
+ {
145
+ title: doc.data["title"],
146
+ url: "#{site_base_url}#{doc.url}",
147
+ description: description&.strip&.gsub(/\s+/, " ")&.slice(0, 200),
148
+ content: doc.content
149
+ }
150
+ end
151
+
152
+ def format_item(item)
153
+ line = "- [#{item[:title]}](#{item[:url]})"
154
+ desc = item[:description]
155
+ line += ": #{desc}" if desc && !desc.empty?
156
+ line
157
+ end
158
+
159
+ def format_item_full(item)
160
+ lines = []
161
+ lines << "- [#{item[:title]}](#{item[:url]})"
162
+ if item[:content] && !item[:content].empty?
163
+ stripped = strip_html(item[:content]).strip.gsub(/\s+/, " ")
164
+ lines << "" << stripped << ""
165
+ end
166
+ lines.join("\n")
167
+ end
168
+
169
+ def strip_html(html)
170
+ return "" unless html
171
+
172
+ html.gsub(/<script[^>]*>.*?<\/script>/mi, "")
173
+ .gsub(/<style[^>]*>.*?<\/style>/mi, "")
174
+ .gsub(/<[^>]+>/, " ")
175
+ .gsub(/&nbsp;/, " ")
176
+ .gsub(/&amp;/, "&")
177
+ .gsub(/&lt;/, "<")
178
+ .gsub(/&gt;/, ">")
179
+ .gsub(/&quot;/, '"')
180
+ .gsub(/&#39;/, "'")
181
+ .gsub(/\s+/, " ")
182
+ .strip
183
+ end
184
+
185
+ def write_file(filename, content)
186
+ dest = @site.in_dest_dir(filename)
187
+ FileUtils.mkdir_p(File.dirname(dest))
188
+ File.write(dest, content)
189
+ end
190
+
191
+ def inject_link_tag(site)
192
+ llms_url = "#{site_base_url}/llms.txt"
193
+ site.pages.each do |page|
194
+ next unless page.output_ext == ".html"
195
+
196
+ page.content = page.content.to_s.sub(
197
+ %r{</head>}i,
198
+ %(<link rel="ai" href="#{llms_url}" />\n</head>)
199
+ )
200
+ end
201
+ end
202
+ end
203
+ end
204
+
205
+ Jekyll::Hooks.register :site, :post_write do |site|
206
+ content = site.data.delete("__llms_txt_content")
207
+ if content
208
+ dest = site.in_dest_dir("llms.txt")
209
+ FileUtils.mkdir_p(File.dirname(dest))
210
+ File.write(dest, content)
211
+ end
212
+
213
+ full_content = site.data.delete("__llms_full_txt_content")
214
+ if full_content
215
+ dest = site.in_dest_dir("llms-full.txt")
216
+ FileUtils.mkdir_p(File.dirname(dest))
217
+ File.write(dest, full_content)
218
+ end
219
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllLlmsGenerator
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll-llms-generator/version"
4
+ require "jekyll/llms_txt_generator"
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-llms-generator
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jason Chance
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-08-21 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ description: jekyll-llms-generator is a Jekyll generator plugin that builds a standards-compliant
33
+ llms.txt file from your site's posts, collections, and pages so AI language models
34
+ can efficiently discover your content.
35
+ email:
36
+ - jason@jasonchance.com
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - LICENSE.txt
43
+ - README.md
44
+ - lib/jekyll-llms-generator.rb
45
+ - lib/jekyll-llms-generator/version.rb
46
+ - lib/jekyll/llms_txt_generator.rb
47
+ homepage: https://github.com/jchance/jekyll-llms-txt
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/jchance/jekyll-llms-txt
52
+ source_code_uri: https://github.com/jchance/jekyll-llms-txt
53
+ changelog_uri: https://github.com/jchance/jekyll-llms-txt/blob/main/CHANGELOG.md
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 2.7.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.6.2
69
+ specification_version: 4
70
+ summary: Automatically generate an llms.txt file for your Jekyll site.
71
+ test_files: []