Skip to main content

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: false in front matter (rss: false is an accepted alias, and YAML means no and off count too);
  • match an exclude entry — a path prefix, so about/legal also covers about/legal/terms, but not about/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 at 10:00+00:00 becomes 18:00+0800 under Asia/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. So date: 2025-06-01T12:00:00+00:00 under Asia/Shanghai yields Sun, 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:

  1. front matter description;
  2. the content before Material's <!-- more --> marker;
  3. 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. Set include_last_build_date: false if you commit build output or depend on CDN validators.
  • <guid> is the git blob hash the page had at its first commit, with isPermaLink="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 with isPermaLink="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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mkdocs_feed-0.1.0.tar.gz (27.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mkdocs_feed-0.1.0-py3-none-any.whl (26.7 kB view details)

Uploaded Python 3

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

Hashes for mkdocs_feed-0.1.0.tar.gz
Algorithm Hash digest
SHA256 82a2e0bd23c2940f1e860a3f6f8f39ddd099f5250f08086d0ade86a164537a68
MD5 635ef571ad9873056bcf8e59a5411efe
BLAKE2b-256 af9a63185f10ef598e637c2efb6aa4c42cfe81339aaa2c14c3cfabc3df4fc353

See more details on using hashes here.

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

Hashes for mkdocs_feed-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a383ca1ee4db14fdb941a628f7218c8a1cc623f71c726e3b677710dd4fa7bc20
MD5 5d662f79ae6929f9a9390011c1cac199
BLAKE2b-256 b14d2910305025a41f6538bc8b479f24d57c03c9ad7f5dbcedaf0275d9503a14

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page