A Jekyll plugin that serves a clean Markdown version of every page, for AI agents and other machine readers.
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. Read more in Serving Markdown for AI Agents, Now as a Jekyll Plugin.
Add this line to your Jekyll site's Gemfile:
group :jekyll_plugins do
gem 'jekyll-md'
endAnd then run bundle install.
Note
This plugin requires a custom Ruby gem and therefore cannot run in GitHub Pages' default build (which only allows a fixed whitelist of plugins). Deploy via a GitHub Actions workflow that runs bundle exec jekyll build instead (GitHub Pages' "GitHub Actions" build type), and it will work.
No configuration is required to get started; every rendered HTML page gets a Markdown counterpart.
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.
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:
md:
selector: "#markdown-content"<!-- _layouts/post.html -->
<article>
<div id="markdown-content">
{{ content }}
</div>
</article>You can override the selector for an individual page via front matter:
---
md_selector: "#post-body"
---md:
enabled: true # master on/off switch, default true
selector: "#markdown-content" # CSS selector to convert; default nil (try <main>/[role=main], then the whole page)
strip: [script, style] # elements always removed from the selected content before conversion
link: true # inject <link rel="alternate" type="text/markdown"> into <head>, default true
renderer: reverse_markdown # HTML-to-Markdown engine, default reverse_markdown
exclude: # array of URL glob patterns to skip entirely
- /404.html
- /assets/**jekyll-md supports three HTML-to-Markdown renderers, selected via md: renderer::
reverse_markdown(default) - a hard dependency of this gem, no extra setup required.html-to-markdown- a fast, Rust-backed native gem that produces output nearly identical toreverse_markdown. It is not a dependency of this gem, so add it to your ownGemfile(gem "html-to-markdown") before selecting it.kramdown- already ships with Jekyll, so it requires no extra dependency, but its HTML-to-Markdown conversion has some limitations compared toreverse_markdown: links are emitted in reference style ([text][1]with a footnote) rather than inline, code blocks are indented with four spaces instead of fenced with triple backticks, and tables and generic containers (<div>,<span>) are left as raw HTML rather than being converted.
md:
renderer: html-to-markdownBy default, the generated .md file is just the converted content, with nothing added. If you want a title heading, a front matter block, a "Source:" link back to the HTML page, or any combination of these in any order, wrap the content in a layout. This reuses Jekyll's own layout mechanism (_layouts/), rendered against the converted Markdown instead of HTML — give it a name distinct from any HTML layout (e.g. _layouts/md_page.liquid, not _layouts/page.html) so the two don't collide:
<!-- _layouts/md_page.liquid -->
---
title: {{ page.title }}
---
# {{ page.title }}
{{ content }}
Source: {{ site.url }}{{ page.url }}md:
layout: md_pageThe layout has access to content (the already-converted Markdown), page (the same front matter/data a Jekyll layout sees), and site (the site payload) — the same variables available in a normal Jekyll layout.
You can override, or opt out of, the site-wide layout for an individual page via front matter:
---
md_layout: md_alt # use a different layout for this page only
md_layout: false # skip the layout for this page even though one is configured site-wide
------
md: false # opt this page out of Markdown generation entirely
md_link: false # generate the .md file, but don't add the <link> tag to this page
md_selector: "#x" # override the selector for this page only
md_layout: "..." # override the site-wide layout for this page only, or `false` to skip it
---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.
jekyll-md hooks into two points in the Jekyll build:
:pages/:documents,:post_render— after a page's layout and Liquid have fully rendered, inject the<link rel="alternate">tag into its<head>.: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.
jekyll-md intentionally does not generate an llms.txt. The spec asks for a curated index that "stays small enough to fit in context," explicitly contrasting itself with sitemap.xml, which it criticizes for being too large and unfiltered. A plugin can't know which of your pages are worth surfacing, and dumping every post/page (as some plugins do) just recreates the sitemap problem in Markdown.
Instead, author llms.txt yourself as a plain Jekyll page with Liquid front matter, opting in specific content (e.g. via a per-page pinned: true/llms: true flag) rather than listing everything. See code.dblock.org for a working example that lists pinned highlights, the 10 most recent posts, and key pages out of a blog with almost 600 posts: the template.
The v2 spec also allows nested/scoped llms.txt files, e.g. /docs/llms.txt covering only pages under /docs/, with the most specific file (closest to a given URL) taking precedence over the site-wide one at /llms.txt. Same idea applies: each is just another hand-curated Jekyll page, not a plugin feature.
| Markdown source | Discovery <link> |
llms.txt |
Notes | |
|---|---|---|---|---|
| jekyll-md | Rendered HTML | Automatic | Not generated (author your own, see above) | Covers generated pages (tags, pagination); no source-to-source fidelity issues, but HTML round-trip is lossy for complex markup; optional custom Jekyll layout (md: layout:) can add a title heading, front matter block, source link, or any combination. |
| jekyll-llms | Source file | Automatic | Yes, exhaustive by default | Inline HTML leaks through verbatim; only pages with Markdown/HTML source get a sidecar. |
| jekyll-markdown-output | Source file | None (manual URL guessing) | No | Adds a synthetic YAML front matter block (title, date, tags, etc.) and optional # Title heading to each sidecar. |
| jekyll-agent-markdown | Source file | Manual ({% agent_markdown_link %} in layout) |
Yes, curated (opt-in pages/collections, per-doc section/optional) |
Can append a metadata footer/header (dates, author, description, source link) to each sidecar; also supports llms-full.txt. |
| jekyll-third-audience | Source file | Manual (Liquid tag in layout) | No | Posts only (configurable layouts); adds a synthetic front matter block (title, date, author, description, tags, url); can strip or rewrite {% include %} tags from the source before writing. |
All four alternatives convert from each document's source rather than its rendered HTML: they re-read the original Markdown/HTML file from disk (Liquid resolved, but otherwise untouched), so generated pages without a Markdown/HTML source (tag pages, pagination) don't get a sidecar, and inline HTML in the source leaks through verbatim rather than being converted.
See CONTRIBUTING.
MIT License, see LICENSE for details.