jekyll-md 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: 9b38e647be1b229a45a26dc56ff22daa49501775f7be57ffeb5f109167342ae1
4
+ data.tar.gz: 5938e251a981da7e8e8c6934515edb6050a0bc7df213ccf729e4bc1c3fc441ae
5
+ SHA512:
6
+ metadata.gz: 4de38715b596bdb012af865144ee385d83b2428e85f813322cdfac1f34f61f4c75c3fc9ef73e598839724b35c7ed6c5e747c3bc697cd7313e78d1d8819828e52
7
+ data.tar.gz: 5a0876b411b7215de3b8a0ce006b8b9a0a45b11b7fadb7f758a2608fd784e3d5e4dd003e1f0575c35e43053731a455e6d63aa7a289be271549f876d672c6dadc
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ ### 0.2.0 (Next)
2
+
3
+ * Your contribution here.
4
+
5
+ ### 0.1.0 (2026/09/19)
6
+
7
+ * Initial release - [@dblock](https://github.com/dblock).
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Doubrovkine and Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # jekyll-md
2
+
3
+ [![Test](https://github.com/dblock/jekyll-md/actions/workflows/test.yml/badge.svg)](https://github.com/dblock/jekyll-md/actions/workflows/test.yml)
4
+ [![Coverage Status](https://coveralls.io/repos/github/dblock/jekyll-md/badge.svg?branch=main)](https://coveralls.io/github/dblock/jekyll-md?branch=main)
5
+
6
+ A Jekyll plugin that serves a clean Markdown version of every page, for AI agents and other machine readers.
7
+
8
+ For every rendered HTML page, `jekyll-md` writes a sibling `.md` file (e.g. `/about/index.html` -> `/about.md`) and adds a `<link rel="alternate" type="text/markdown">` tag to the page's `<head>` so agents can discover it.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your Jekyll site's `Gemfile`:
13
+
14
+ ```ruby
15
+ group :jekyll_plugins do
16
+ gem 'jekyll-md'
17
+ end
18
+ ```
19
+
20
+ And then run `bundle install`.
21
+
22
+ > [!NOTE]
23
+ > This plugin requires a custom Ruby gem and therefore cannot run in GitHub Pages' default build (which only allows a fixed [whitelist of plugins](https://pages.github.com/versions/)). Deploy via a [GitHub Actions workflow](https://jekyllrb.com/docs/continuous-integration/github-actions/) that runs `bundle exec jekyll build` instead (GitHub Pages' "GitHub Actions" build type), and it will work.
24
+
25
+ ## Usage
26
+
27
+ No configuration is required to get started; every rendered HTML page gets a Markdown counterpart.
28
+
29
+ ### Configuring the CSS Selector
30
+
31
+ By default (no `selector` configured), `jekyll-md` looks for a `<main>` element or `[role="main"]` in the rendered page (the closest thing HTML has to a convention for "this is the content, not the header/nav/footer chrome"), and converts that. If your layouts don't use either of these, it falls back to converting the entire `<body>`, including navigation, headers, footers, and anything else on the page — this is simple but rarely what you want for a real site, since it dumps your header/nav/footer HTML into every single `.md` file.
32
+
33
+ Many themes (including Jekyll's default `minima`) already wrap page content in `<main>`, so this default may work with no configuration at all. Otherwise, set `selector` to a CSS selector that scopes the conversion to just your content, e.g. the wrapper `div` around `{{ content }}` in your layout:
34
+
35
+ ```yaml
36
+ md:
37
+ selector: "#markdown-content"
38
+ ```
39
+
40
+ ```html
41
+ <!-- _layouts/post.html -->
42
+ <article>
43
+ <div id="markdown-content">
44
+ {{ content }}
45
+ </div>
46
+ </article>
47
+ ```
48
+
49
+ You can override the selector for an individual page via front matter:
50
+
51
+ ```yaml
52
+ ---
53
+ md_selector: "#post-body"
54
+ ---
55
+ ```
56
+
57
+ ### Other Configuration
58
+
59
+ ```yaml
60
+ md:
61
+ enabled: true # master on/off switch, default true
62
+ selector: "#markdown-content" # CSS selector to convert; default nil (try <main>/[role=main], then the whole page)
63
+ strip: [script, style] # elements always removed from the selected content before conversion
64
+ link: true # inject <link rel="alternate" type="text/markdown"> into <head>, default true
65
+ exclude: # array of URL glob patterns to skip entirely
66
+ - /404.html
67
+ - /assets/**
68
+ ```
69
+
70
+ ### Per-Page Front Matter
71
+
72
+ ```yaml
73
+ ---
74
+ md: false # opt this page out of Markdown generation entirely
75
+ md_link: false # generate the .md file, but don't add the <link> tag to this page
76
+ md_selector: "#x" # override the selector for this page only
77
+ ---
78
+ ```
79
+
80
+ ### Avoiding Clobbering Hand-Authored Markdown Pages
81
+
82
+ If a page at the derived destination path already exists after Jekyll writes the site (for example, you hand-author `/tags.md` yourself from a data-driven Liquid template), `jekyll-md` will not overwrite it.
83
+
84
+ ## How It Works
85
+
86
+ `jekyll-md` hooks into two points in the Jekyll build:
87
+
88
+ 1. `:pages`/`:documents`, `:post_render` — after a page's layout and Liquid have fully rendered, inject the `<link rel="alternate">` tag into its `<head>`.
89
+ 2. `:site`, `:post_write` — after Jekyll has written the whole site to disk, walk every page and document, extract the configured selector (or the whole `<body>`) from its rendered HTML, convert it to Markdown, and write it next to the HTML output.
90
+
91
+ ## Similar Projects
92
+
93
+ ### jekyll-llms
94
+
95
+ Unlike `jekyll-md`, [jekyll-llms](https://github.com/skatkov/jekyll-llms) generates Markdown sidecars from each page's **source** — your original Markdown/HTML file, with Liquid resolved but otherwise untouched — plus an `llms.txt` index. Inline HTML (`<a>`, `<img>`, tables, embeds, etc.) leaks through verbatim, and only pages with Markdown/HTML source get a sidecar, not generated pages like tag or pagination pages.
96
+
97
+ ## Contributing
98
+
99
+ See [CONTRIBUTING](CONTRIBUTING.md).
100
+
101
+ ## Copyright and License
102
+
103
+ MIT License, see [LICENSE](LICENSE.md) for details.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Md
5
+ # Reads and merges site-wide and per-page configuration.
6
+ class Configuration
7
+ DEFAULTS = {
8
+ 'enabled' => true,
9
+ 'selector' => nil,
10
+ 'strip' => %w[script style],
11
+ 'link' => true,
12
+ 'exclude' => []
13
+ }.freeze
14
+
15
+ def initialize(site_config)
16
+ @config = DEFAULTS.merge(site_config || {})
17
+ end
18
+
19
+ def enabled?
20
+ @config['enabled'] != false
21
+ end
22
+
23
+ def link?
24
+ @config['link'] != false
25
+ end
26
+
27
+ def selector
28
+ @config['selector']
29
+ end
30
+
31
+ def strip_selectors
32
+ Array(@config['strip'])
33
+ end
34
+
35
+ def excluded?(url)
36
+ Array(@config['exclude']).any? { |pattern| File.fnmatch(pattern, url, File::FNM_PATHNAME) }
37
+ end
38
+
39
+ # Per-page overrides via front matter:
40
+ # md: false -- opt this page out entirely
41
+ # md_selector: "#main" -- override the CSS selector for this page
42
+ # md_link: false -- don't inject the <link rel="alternate"> tag for this page
43
+ def enabled_for?(item)
44
+ return false unless enabled?
45
+
46
+ item.data['md'] != false && !excluded?(item.url)
47
+ end
48
+
49
+ def link_for?(item)
50
+ return false unless link?
51
+
52
+ item.data['md_link'] != false && !excluded?(item.url)
53
+ end
54
+
55
+ def selector_for(item)
56
+ item.data['md_selector'] || selector
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'reverse_markdown'
5
+
6
+ module Jekyll
7
+ module Md
8
+ # Converts the final, fully-rendered HTML of a page into Markdown.
9
+ #
10
+ # Unlike plugins that render Markdown from the page's *source*
11
+ # (with Liquid tags resolved but HTML left as-is), this converts the
12
+ # actual rendered HTML output, so links, images and other inline
13
+ # HTML end up as clean Markdown syntax instead of leaking through
14
+ # verbatim, and any Liquid that only makes sense in the context of a
15
+ # full page render (includes, site variables, conditionals) is
16
+ # already fully resolved.
17
+ class Converter
18
+ # When no selector is configured, try these, in order, before
19
+ # falling back to the whole <body>. `<main>`/`[role="main"]` are
20
+ # the closest thing to an HTML convention for "this is the page's
21
+ # content, not its header/nav/footer chrome".
22
+ DEFAULT_SELECTORS = ['main', '[role="main"]'].freeze
23
+
24
+ def initialize(strip_selectors: [])
25
+ @strip_selectors = strip_selectors
26
+ end
27
+
28
+ # Returns the converted Markdown for +html+, scoped to +selector+
29
+ # (a CSS selector), or nil if the selector doesn't match anything.
30
+ #
31
+ # When +selector+ is nil, tries DEFAULT_SELECTORS in turn, falling
32
+ # back to the entire page's <body> if none of them match.
33
+ def convert(html, selector: nil)
34
+ doc = Nokogiri::HTML(html)
35
+ node = selector ? doc.at_css(selector) : default_node_for(doc)
36
+ return nil unless node
37
+
38
+ @strip_selectors.each { |s| node.css(s).remove }
39
+
40
+ markdown = ReverseMarkdown.convert(
41
+ node.inner_html,
42
+ unknown_tags: :bypass,
43
+ github_flavored: true
44
+ ).strip
45
+
46
+ return nil if markdown.empty?
47
+
48
+ "#{markdown}\n"
49
+ end
50
+
51
+ # Derives the relative Markdown URL for a page's URL, e.g.:
52
+ # "/" -> "/index.md"
53
+ # "/about/" -> "/about.md"
54
+ # "/2026/foo.html" -> "/2026/foo.md"
55
+ # "/tags/ruby/" -> "/tags/ruby.md"
56
+ def self.md_url_for(url)
57
+ return '/index.md' if url.nil? || url.empty? || url == '/'
58
+
59
+ if url.end_with?('/')
60
+ "#{url.chomp('/')}.md"
61
+ elsif url.end_with?('.html', '.htm')
62
+ url.sub(/\.html?\z/, '.md')
63
+ else
64
+ "#{url}.md"
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def default_node_for(doc)
71
+ DEFAULT_SELECTORS.each do |selector|
72
+ node = doc.at_css(selector)
73
+ return node if node
74
+ end
75
+
76
+ doc.at_css('body')
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'jekyll'
5
+ require_relative 'configuration'
6
+ require_relative 'converter'
7
+
8
+ module Jekyll
9
+ module Md
10
+ # Runs after Jekyll has written the site: converts every eligible
11
+ # page's rendered HTML output into a sibling Markdown file.
12
+ Jekyll::Hooks.register :site, :post_write do |site|
13
+ config = Configuration.new(site.config['md'])
14
+ next unless config.enabled?
15
+
16
+ converter = Converter.new(strip_selectors: config.strip_selectors)
17
+
18
+ (site.pages + site.docs_to_write).each do |item|
19
+ next unless item.destination(site.dest).end_with?('.html')
20
+ next unless item.output.is_a?(String)
21
+ next unless config.enabled_for?(item)
22
+
23
+ md_url = Converter.md_url_for(item.url)
24
+ dest_path = File.join(site.dest, md_url)
25
+
26
+ # Don't clobber a page that Jekyll itself already wrote to this
27
+ # path, e.g. a hand-authored /tags.md or /posts.md.
28
+ next if File.exist?(dest_path)
29
+
30
+ markdown = converter.convert(item.output, selector: config.selector_for(item))
31
+ next unless markdown
32
+
33
+ FileUtils.mkdir_p(File.dirname(dest_path))
34
+ File.write(dest_path, markdown)
35
+ end
36
+ end
37
+
38
+ # Runs after each page/document is rendered (before layouts are
39
+ # written to disk isn't quite right -- :post_render fires after the
40
+ # full layout chain has been applied, so `item.output` is the final
41
+ # HTML) and injects a <link rel="alternate" type="text/markdown">
42
+ # tag pointing at the page's Markdown counterpart.
43
+ Jekyll::Hooks.register %i[pages documents], :post_render do |item|
44
+ config = Configuration.new(item.site.config['md'])
45
+ next unless config.link_for?(item)
46
+ next unless item.output.is_a?(String) && item.output.include?('</head>')
47
+
48
+ md_url = Converter.md_url_for(item.url)
49
+ link_tag = %(<link href="#{md_url}" type="text/markdown" rel="alternate" title="Markdown">\n)
50
+ item.output = item.output.sub('</head>', "#{link_tag}</head>")
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Md
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
data/lib/jekyll/md.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'md/version'
4
+ require_relative 'md/configuration'
5
+ require_relative 'md/converter'
6
+ require_relative 'md/generator'
data/lib/jekyll-md.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'jekyll/md'
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-md
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Doubrovkine
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-19 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.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: reverse_markdown
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ description: |2
56
+ A Jekyll plugin that converts each page's fully rendered HTML output into a Markdown
57
+ sibling file (e.g. /about/index.html -> /about.md) and adds a discovery
58
+ <link rel="alternate" type="text/markdown"> tag to every page's <head>.
59
+ email: dblock@dblock.org
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - LICENSE.md
66
+ - README.md
67
+ - lib/jekyll-md.rb
68
+ - lib/jekyll/md.rb
69
+ - lib/jekyll/md/configuration.rb
70
+ - lib/jekyll/md/converter.rb
71
+ - lib/jekyll/md/generator.rb
72
+ - lib/jekyll/md/version.rb
73
+ homepage: http://github.com/dblock/jekyll-md
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ rubygems_mfa_required: 'true'
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.0'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '2.5'
92
+ requirements: []
93
+ rubygems_version: 3.5.16
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Serves a clean Markdown version of every page, converted from the rendered
97
+ HTML.
98
+ test_files: []