RSS 2.0 feed generation for MkDocs, with per-language feeds and git-derived dates
Project description
mkdocs-feed
RSS 2.0 feed generation for MkDocs, with per-language feeds and git-derived dates.
plugins:
- feed
site_url must be set. RSS items require absolute URLs, so a feed built without
it would be broken; the build fails rather than guessing.
Why the plugin key is feed, not rss
mkdocs-rss-plugin already registers the rss entry point, and
Material for MkDocs' base.html reacts to any plugin named rss by emitting
hardcoded <link rel="alternate"> tags pointing at that plugin's filenames:
{% if "rss" in config.plugins %}
<link rel="alternate" type="application/rss+xml" href="{{ 'feed_rss_created.xml' | url }}">
<link rel="alternate" type="application/rss+xml" href="{{ 'feed_rss_updated.xml' | url }}">
{% endif %}
Taking the name rss would therefore mean an entry point collision and two
dead links in every page's <head>. Using feed avoids both, and this plugin
injects its own correct link instead.
Configuration
Every option with its default:
plugins:
- feed:
enabled: true
# --- output ---
filename: feed.xml
length: 20
pretty_print: true
include_last_build_date: true
# --- item content ---
full_content: false
abstract_chars_count: 300
# --- dates ---
timezone: UTC
date_priority: [meta, git, mtime]
git_history_valid_after: null
sort_by: created # created | updated
# --- which pages ---
exclude: []
That is the whole surface: twelve options. Everything else is fixed behaviour, because MkDocs and Material spell these things exactly one way.
Which pages end up in the feed
Every page is a candidate. Pages are dropped when they:
- set
feed: falsein front matter (rss: falseis an accepted alias, and YAML meansnoandoffcount too); - match an
excludeentry — a path prefix, soabout/legalalso coversabout/legal/terms, but notabout/legalese; - are a blog listing page — Material's blog index, archive, category and pagination views;
- are the tags plugin's index page, or MkDocs'
404; - have no date from any source in
date_priority.
Listing pages are recognised by type, not by URL: Material builds them as
subclasses of blog.structure.View, so detection holds no matter what
blog_dir, archive_url_format, categories_url_format or
pagination_url_format are set to, and for however many blog instances the
site configures. A hand-written page at notes/archive/ is therefore kept,
while blog2/by-year/2026/p/2/ is dropped.
None of that is configurable, because the blog plugin generates those pages —
there is no source file to put feed: false in. Everything you authored does
have front matter, so that is the escape hatch for it. Section index pages get
no special treatment: add feed: false to blog/index.md if you do not want
it in the feed.
Slugs are matched with any language prefix removed, so one exclude entry
covers every language rather than needing an en/… duplicate.
An item without a pubDate is broken, so a page with no date is always
skipped. With the default date_priority there is always an mtime to fall back
on, so this never fires; drop mtime from date_priority to make undated
pages genuinely undated, and therefore skipped.
Run mkdocs build --verbose to see, per page, whether it was included and
which date source won.
Dates
date_priority lists the sources consulted, first hit wins:
| Source | Created | Updated |
|---|---|---|
meta |
front matter date |
front matter updated |
git |
first commit touching the file | last commit touching the file |
mtime |
file mtime | file mtime |
git history is read in a single git log pass over the docs tree, not once
per page, and renames are followed (the bulk equivalent of git log --follow)
so a moved page keeps its original date.
A shallow clone has no real first commits. The plugin warns when it detects one
— in CI, set fetch-depth: 0.
Timezones
Two different rules apply, on purpose:
- git commits and file mtimes are real instants, so they are converted
into
timezone. A commit at10:00+00:00becomes18:00+0800underAsia/Shanghai. - front matter dates are literals a human typed. The number is taken at
face value and merely labelled with
timezone; any offset written in the file is ignored. Sodate: 2025-06-01T12:00:00+00:00underAsia/ShanghaiyieldsSun, 01 Jun 2025 12:00:00 +0800— the hour does not move.
The second rule exists because front matter offsets are routinely written by tooling that assumed UTC, while the numbers beside them are what the author meant. This is deliberate, not a bug.
git_history_valid_after
If a repository was created by importing existing content, its history says nothing about when anything was written:
git_history_valid_after: 2026-01-26T23:19:30+00:00
git's creation date for a page is then discarded when either of these holds:
- the page declares a front matter date at or before the cutoff — old content that was only committed later, so its first commit is far too late;
- the file's first commit is at or before the cutoff — it arrived in the bulk import, so its first commit is far too early.
Both are needed. Gating on the commit alone misses an article written in 2023 and committed a year after the migration; gating on the declared date alone misses an imported file that declares no date at all.
When git is discarded, resolution falls through to the next source in
date_priority. The last commit is still used for updated, but is not
allowed to stand in for the creation date — that would quietly undo the cutoff.
Set the cutoff just after the import commit, and mind the offset: an import
commit at 2026-01-26T23:19:20+00:00 is 2026-01-27 07:19:20 +0800, so a
cutoff of 2026-01-26T23:19:00+08:00 is eight hours too early and matches
nothing. A cutoff written without an offset is read in timezone.
Item content
description is filled from the first of:
- front matter
description; - the content before Material's
<!-- more -->marker; - the rendered body's paragraphs, truncated to
abstract_chars_count.
abstract_chars_count caps only the third. The first two are the author saying
where the summary ends, so cutting them would truncate mid-sentence at a
boundary somebody chose on purpose. Expect item lengths to vary accordingly.
Everything is read from the rendered HTML, not the Markdown source, and
only <p> content is used. Code blocks, headings, tables and list markup are
skipped — flattened into one sentence they read as noise, and a page opening
with a config listing would otherwise get the entire listing as its
description. Permalink anchors go too, so a toc.permalink: "#" setting does
not leave a stray # after every heading. Inline <code> is kept, since a
file path mid-sentence is prose. A page with no <p> at all — one built
entirely from lists or tables — falls back to all of its text rather than
ending up with an empty description.
The page's own leading <h1> is stripped first, since it is almost always the
same string as the item's <title> and would otherwise be repeated in every
description. Truncation backs off to a word boundary when one is close enough,
which keeps CJK text (no spaces) from collapsing to nothing.
With full_content: true the rendered HTML also goes into <content:encoded>,
with every relative href, src, srcset and poster rewritten to an
absolute URL — otherwise images and links resolve against the reader and
silently break.
Categories and the channel image
<category> comes from the categories and tags front matter keys (blog
plugin and tags plugin respectively); scalars and lists both work.
The channel <image> — the icon a reader shows beside your feed — is taken
from theme.logo, or theme.favicon when the logo is an icon name rather than
a path.
Neither is configurable: MkDocs and Material use exactly these keys.
No author is emitted. RSS 2.0's own <author> element is specified to hold an
email address, and <dc:creator> — the usual workaround — is a Dublin Core
extension rather than part of RSS.
Multi-language sites
Under mkdocs-static-i18n the whole build runs once per language, so each
language gets its own feed with the right <language>, <atom:link rel="self">
and items: feed.xml for the language published at the site root, and
<locale>/feed.xml for the others.
The language comes from page.file.locale, not from config.theme.locale —
the latter reports the wrong locale during the default-language pass.
Other notes
<lastBuildDate>changes on every build, so the feed file differs on every deploy. Setinclude_last_build_date: falseif you commit build output or depend on CDN validators.<guid>is the git blob hash the page had at its first commit, withisPermaLink="false". A commit hash would not do: one commit routinely adds several pages, so they would share a guid and readers would collapse them into one item. The current blob would not do either, since it changes on every edit and would re-notify every subscriber. Pages git does not know yet fall back to the URL withisPermaLink="true".- A feed file is written even when no page qualifies. Every page links to it
from
<head>, so a missing file would be a 404. - Every page gets a
<link rel="alternate">in its<head>, which is how a reader given only your site URL finds the feed. Not configurable — there is no upside to omitting it.
Development
python -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest
The renderer is separated from a format-agnostic data model
(mkdocs_feed/models.py), so adding Atom or JSON Feed means adding a module
under mkdocs_feed/renderers/ rather than touching the collection path.
The RSS-specific names (renderers/rss2.py, templates/rss2.xml.jinja2) refer
to the output format, not to the project, so they stay accurate once a second
format sits beside them.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mkdocs_feed-0.1.0.tar.gz.
File metadata
- Download URL: mkdocs_feed-0.1.0.tar.gz
- Upload date:
- Size: 27.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
82a2e0bd23c2940f1e860a3f6f8f39ddd099f5250f08086d0ade86a164537a68
|
|
| MD5 |
635ef571ad9873056bcf8e59a5411efe
|
|
| BLAKE2b-256 |
af9a63185f10ef598e637c2efb6aa4c42cfe81339aaa2c14c3cfabc3df4fc353
|
File details
Details for the file mkdocs_feed-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mkdocs_feed-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a383ca1ee4db14fdb941a628f7218c8a1cc623f71c726e3b677710dd4fa7bc20
|
|
| MD5 |
5d662f79ae6929f9a9390011c1cac199
|
|
| BLAKE2b-256 |
b14d2910305025a41f6538bc8b479f24d57c03c9ad7f5dbcedaf0275d9503a14
|