jekyll-geo-kit 0.0.1

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: 474d52c9e7b0c7dd088a685112ac2bd84a0e047e1c07991a648dc460620d4014
4
+ data.tar.gz: 4e3f157daadd1fc35543f485b908e66e60a2f913edb2d02350c15198cfbe9ea6
5
+ SHA512:
6
+ metadata.gz: fe744eb153718d96d5da26b9502e3d735b03d232e84421cf1667ad27633e5f4acf168fcc68198d2f8d3d5e96b65953ff50e5ddaaefc3648713becc59ab70fd31
7
+ data.tar.gz: b228bc8d8bf9d8258cbb274fcbfc855aa4193824974b21cdb094729fb3645b752b1b4f248ebb8eb826b941a62c31d87ec9e904e7cbd8d0db167734a2f6d2bcfd
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.1.0
6
+
7
+ - Extracted from `rahendablog.github.io`'s `_plugins/md_alternates.rb`.
8
+ - Generates a Markdown alternate at `/<slug>.md` for every written document in
9
+ the configured collections.
10
+ - Configurable through `markdown_alternates:` in `_config.yml`: `enabled`,
11
+ `collections`, `index`, `extension`, `skip_key`, `authors_data`.
12
+ - Adds the `markdown_alternate_url` Liquid filter.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rahenda
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,111 @@
1
+ ## About jekyll-geo-kit
2
+
3
+ A Jekyll plugin that serves every post as clean Markdown alongside its HTML, so
4
+ LLM crawlers and AI agents can read your content without parsing a page — GEO
5
+ (Generative Engine Optimization).
6
+
7
+ [![CI](https://github.com/rahendatri/jekyll-geo-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/rahendatri/jekyll-geo-kit/actions/workflows/ci.yml)
8
+
9
+
10
+ ## Quick start
11
+
12
+ Add the gem to your site's `Gemfile`, in the `:jekyll_plugins` group so Bundler
13
+ loads it for you:
14
+
15
+ ```ruby
16
+ group :jekyll_plugins do
17
+ gem "jekyll-geo-kit"
18
+ end
19
+ ```
20
+
21
+ Then run `bundle install`.
22
+
23
+ If you prefer to list your plugins in configuration instead, add it to
24
+ `_config.yml`:
25
+
26
+ ```yaml
27
+ plugins:
28
+ - jekyll-geo-kit
29
+ ```
30
+
31
+ That's it ! The plugin generates `.md` for every post.
32
+
33
+
34
+ ## Using GitHub Pages
35
+
36
+ `jekyll-geo-kit` is a third-party plugin, so GitHub's own Jekyll builder will not
37
+ run it.
38
+
39
+ Build your own workflow instead:
40
+
41
+ 1. Use or adapt the example here : [custom-jekyll-github-action](https://github.com/rahendatri/rahendatri.github.io/blob/main/.github/workflows/jekyll.yml)
42
+ 2. Activate the custom Github Pages workflow by choosing `Github Actions` in the following place: `<project-settings> / Pages / Build and Deployment / Source`
43
+
44
+
45
+
46
+ ## What it does
47
+
48
+ jekyll-geo-kit generates a Markdown alternate at `/<slug>.md` for every written
49
+ document in the configured collections. Each one contains:
50
+
51
+ * A pointer to your site index (`llms.txt` by default), so an agent that lands
52
+ on one document can discover the rest of the site
53
+ * The document's metadata as plain lines — title, canonical source URL,
54
+ publication date, resolved author name, language
55
+ * The description collapsed onto a single quoted line
56
+ * The body exactly as you wrote it, still in Markdown and never converted to
57
+ HTML
58
+
59
+ It also includes the `markdown_alternate_url` Liquid filter, so the
60
+ `<link rel="alternate" type="text/markdown">` you put in `head.html` derives its
61
+ path through the same logic the generator used and the two cannot drift apart.
62
+
63
+ ```
64
+ > ## Site index
65
+ > Fetch the complete site index at: https://example.com/llms.txt
66
+ > Use it to discover every page on this site before exploring further.
67
+
68
+ # A published post
69
+ Source: https://example.com/2026/01/15/a-post.html
70
+ Published: 2026-01-15
71
+ Author: Rahenda
72
+ Language: en
73
+
74
+ > The post description, collapsed onto one line.
75
+
76
+ The body of the post, exactly as written, still in Markdown.
77
+ ```
78
+
79
+ While you could write this generator yourself, jekyll-geo-kit keeps the served
80
+ paths, the metadata format and the `rel=alternate` declaration in one place.
81
+
82
+ ## What it doesn't do
83
+
84
+ jekyll-geo-kit only generates the Markdown documents and the filter that links
85
+ them. It does not write your `llms.txt` index — it links to whatever path you
86
+ point `index` at, and leaves creating that file to you or to another plugin.
87
+
88
+ It does not inject anything into your HTML. Declaring the alternates is a
89
+ one-line Liquid snippet you add to your own `head.html`, documented under
90
+ [Usage](docs/usage.md).
91
+
92
+ It does not convert HTML to Markdown. The alternates carry the document's
93
+ unrendered source body, so a document authored in Markdown round-trips exactly
94
+ while one authored in HTML is served as that HTML.
95
+
96
+ It covers collection documents — `posts` by default, any collection you
97
+ configure — and not standalone pages like `about.md`.
98
+
99
+ Being a third-party plugin, it does not run on GitHub's own Jekyll builder; see
100
+ [Using GitHub Pages](#using-github-pages) for the workflow that does.
101
+
102
+ ## Documentation
103
+
104
+ For more information, see:
105
+
106
+ * [Usage](docs/usage.md)
107
+ * [Advanced usage](docs/advanced-usage.md)
108
+
109
+ ## License
110
+
111
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module GeoKit
5
+ module MarkdownAlternates
6
+ # The Markdown text served at /<slug>.md: a pointer to the site index, the
7
+ # document's metadata as plain lines, then its unrendered body.
8
+ class Body
9
+ def initialize(site, document, config)
10
+ @site = site
11
+ @document = document
12
+ @config = config
13
+ end
14
+
15
+ def to_s
16
+ (banner + heading + description + content).join("\n")
17
+ end
18
+
19
+ private
20
+
21
+ def banner
22
+ index_url = @config.index_url
23
+ return [] if index_url.nil?
24
+
25
+ [
26
+ "> ## Site index",
27
+ "> Fetch the complete site index at: #{index_url}",
28
+ "> Use it to discover every page on this site before exploring further.",
29
+ "",
30
+ ]
31
+ end
32
+
33
+ def heading
34
+ lines = ["# #{@document.data["title"]}"]
35
+ lines << "Source: #{MarkdownAlternates.absolute_url(@site, @document.url)}"
36
+ lines << "Published: #{date.strftime("%Y-%m-%d")}" if date
37
+ lines << "Author: #{author_name}" if author_name
38
+ lines << "Language: #{language}"
39
+ lines
40
+ end
41
+
42
+ def description
43
+ text = @document.data["description"].to_s.strip.gsub(/\s+/, " ")
44
+ return [] if text.empty?
45
+
46
+ ["", "> #{text}"]
47
+ end
48
+
49
+ def content
50
+ ["", @document.content.to_s.strip, ""]
51
+ end
52
+
53
+ def date
54
+ @document.data["date"] || (@document.respond_to?(:date) ? @document.date : nil)
55
+ end
56
+
57
+ # Prefers the matching entry in _data/<authors_data>.yml, then the site's
58
+ # single-author config.
59
+ def author_name
60
+ key = @document.data["author"].to_s
61
+ author = @site.data.dig(@config.authors_data, key) || {}
62
+ name = author["name"] if author.is_a?(Hash)
63
+ name ||= @site.config.dig("author", "name")
64
+ name ||= (key unless key.empty?)
65
+ name
66
+ end
67
+
68
+ def language
69
+ @document.data["lang"] || @site.config["lang"] || "en"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module GeoKit
5
+ module MarkdownAlternates
6
+ # The `markdown_alternates:` block of _config.yml, with the defaults a site
7
+ # gets when it says nothing at all.
8
+ class Config
9
+ DEFAULTS = {
10
+ "enabled" => true,
11
+ "collections" => ["posts"],
12
+ "index" => "/llms.txt",
13
+ "extension" => ".md",
14
+ "skip_key" => "unlisted",
15
+ "authors_data" => "authors",
16
+ }.freeze
17
+
18
+ def initialize(site)
19
+ @site = site
20
+ overrides = site.config["markdown_alternates"]
21
+ @options = DEFAULTS.merge(overrides.is_a?(Hash) ? overrides : {})
22
+ end
23
+
24
+ def enabled?
25
+ @options["enabled"] != false
26
+ end
27
+
28
+ def collections
29
+ Array(@options["collections"]).map(&:to_s)
30
+ end
31
+
32
+ def extension
33
+ ext = @options["extension"].to_s
34
+ ext.empty? ? DEFAULTS["extension"] : ext
35
+ end
36
+
37
+ # Front-matter key that excludes a document from the alternates, e.g.
38
+ # `unlisted: true`. Set to nil/false to generate one for every document.
39
+ def skip_key
40
+ key = @options["skip_key"]
41
+ return nil if key.nil? || key == false || key.to_s.empty?
42
+
43
+ key.to_s
44
+ end
45
+
46
+ def authors_data
47
+ @options["authors_data"].to_s
48
+ end
49
+
50
+ # Absolute URL of the site index linked at the top of every alternate, or
51
+ # nil when the site has none and the banner should be left out.
52
+ def index_url
53
+ path = @options["index"]
54
+ return nil if path.nil? || path == false || path.to_s.empty?
55
+
56
+ MarkdownAlternates.absolute_url(@site, path)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module GeoKit
5
+ module MarkdownAlternates
6
+ # Liquid filter so a template links the alternate through the same path
7
+ # logic the generator used:
8
+ #
9
+ # <link rel="alternate" type="text/markdown"
10
+ # href="{{ page.url | markdown_alternate_url }}">
11
+ module Filters
12
+ def markdown_alternate_url(url)
13
+ site = @context.registers[:site]
14
+ extension = Config.new(site).extension
15
+ path = MarkdownAlternates.alternate_path(url, extension)
16
+
17
+ baseurl = site.config["baseurl"].to_s.chomp("/")
18
+ "#{baseurl}#{path}"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ Liquid::Template.register_filter(Jekyll::GeoKit::MarkdownAlternates::Filters)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module GeoKit
5
+ module MarkdownAlternates
6
+ class Generator < Jekyll::Generator
7
+ safe true
8
+ priority :low
9
+
10
+ def generate(site)
11
+ config = Config.new(site)
12
+ return unless config.enabled?
13
+
14
+ documents(site, config).each do |document|
15
+ next if skip?(document, config)
16
+
17
+ site.pages << Page.new(site, document, config)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def documents(site, config)
24
+ config.collections.flat_map do |label|
25
+ collection = site.collections[label]
26
+ collection ? collection.docs : []
27
+ end
28
+ end
29
+
30
+ # Leaves out drafts, `published: false`, output-less collections and
31
+ # anything the site marks with the configured skip key.
32
+ def skip?(document, config)
33
+ return true unless document.write?
34
+
35
+ key = config.skip_key
36
+ return false if key.nil?
37
+
38
+ document.data[key] == true
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module GeoKit
5
+ module MarkdownAlternates
6
+ # The generated page for one document's Markdown alternate.
7
+ #
8
+ # The source name ends in .txt on purpose: Jekyll picks its converter from
9
+ # the source extension, so a .txt name leaves the Markdown body alone, while
10
+ # the permalink decides the served path (/<slug>.md) and the output
11
+ # extension. GitHub Pages serves .md as text/markdown; charset=utf-8.
12
+ class Page < Jekyll::PageWithoutAFile
13
+ def initialize(site, document, config)
14
+ super(site, site.source, "", "#{document.basename_without_ext}.txt")
15
+
16
+ self.data = {
17
+ "layout" => nil,
18
+ "permalink" => MarkdownAlternates.alternate_path(document.url, config.extension),
19
+ "sitemap" => false,
20
+ "title" => document.data["title"],
21
+ }
22
+ self.content = Body.new(site, document, config).to_s
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "markdown_alternates/config"
4
+ require_relative "markdown_alternates/body"
5
+ require_relative "markdown_alternates/page"
6
+ require_relative "markdown_alternates/generator"
7
+ require_relative "markdown_alternates/filters"
8
+
9
+ # Markdown alternates: /<slug>.md for every published document.
10
+ #
11
+ # An AI agent that lands on a page can fetch the same text as Markdown instead
12
+ # of parsing the HTML, and finds a pointer to the site index at the top of it.
13
+ # Declare the pages from head.html with
14
+ # <link rel="alternate" type="text/markdown">.
15
+ module Jekyll
16
+ module GeoKit
17
+ module MarkdownAlternates
18
+ # Joins site.url and site.baseurl onto an absolute path. Jekyll's own
19
+ # absolute_url filter needs a Liquid context, so the generator does it here.
20
+ def self.absolute_url(site, path)
21
+ base = site.config["url"].to_s.chomp("/")
22
+ baseurl = site.config["baseurl"].to_s
23
+ baseurl = "" if baseurl == "/"
24
+ "#{base}#{baseurl.chomp("/")}#{path}"
25
+ end
26
+
27
+ # The served path of a document's Markdown alternate: /a/post/ -> /a/post.md
28
+ def self.alternate_path(url, extension = ".md")
29
+ url.to_s.sub(%r{\.html\z}, "").sub(%r{/\z}, "") + extension
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module GeoKit
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ require_relative "jekyll-geo-kit/version"
6
+ require_relative "jekyll-geo-kit/markdown_alternates"
7
+
8
+ # GEO (Generative Engine Optimization) features for a Jekyll site, one module
9
+ # per feature.
10
+ module Jekyll
11
+ module GeoKit
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-geo-kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Rahenda
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-24 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: '4.0'
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: '4.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rake
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.13'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.13'
61
+ description: Generates /<slug>.md companions for each post, declared via rel=alternate
62
+ type=text/markdown, so LLM crawlers and AI agents can fetch clean Markdown instead
63
+ of parsing HTML. Each document links back to the site's llms.txt index.
64
+ email:
65
+ - rahendatri@users.noreply.github.com
66
+ executables: []
67
+ extensions: []
68
+ extra_rdoc_files: []
69
+ files:
70
+ - CHANGELOG.md
71
+ - LICENSE
72
+ - README.md
73
+ - lib/jekyll-geo-kit.rb
74
+ - lib/jekyll-geo-kit/markdown_alternates.rb
75
+ - lib/jekyll-geo-kit/markdown_alternates/body.rb
76
+ - lib/jekyll-geo-kit/markdown_alternates/config.rb
77
+ - lib/jekyll-geo-kit/markdown_alternates/filters.rb
78
+ - lib/jekyll-geo-kit/markdown_alternates/generator.rb
79
+ - lib/jekyll-geo-kit/markdown_alternates/page.rb
80
+ - lib/jekyll-geo-kit/version.rb
81
+ homepage: https://github.com/rahendatri/jekyll-geo-kit
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ source_code_uri: https://github.com/rahendatri/jekyll-geo-kit
86
+ bug_tracker_uri: https://github.com/rahendatri/jekyll-geo-kit/issues
87
+ changelog_uri: https://github.com/rahendatri/jekyll-geo-kit/blob/main/CHANGELOG.md
88
+ keywords: jekyll, geo, generative-engine-optimization, llms-txt, markdown, ai-crawlers,
89
+ seo
90
+ post_install_message:
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '3.0'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.5.22
106
+ signing_key:
107
+ specification_version: 4
108
+ summary: 'GEO (Generative Engine Optimization) for Jekyll: serve every post as a Markdown
109
+ alternate'
110
+ test_files: []