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 draft: true in front matter;
  • 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.

draft: true is checked even though Material's blog plugin already drops drafts from the build, because it only does so on a real build: draft_on_serve is on by default, so mkdocs serve renders drafts — and serving is when you preview the feed. The check also covers draft_on_serve: false, drafts outside any blog directory, and sites not using Material. Only the literal value true excludes; draft: false is kept, which is how an author overrides draft_if_future_date.

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.1.tar.gz (28.1 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.1-py3-none-any.whl (27.1 kB view details)

Uploaded Python 3

File details

Details for the file mkdocs_feed-0.1.1.tar.gz.

File metadata

  • Download URL: mkdocs_feed-0.1.1.tar.gz
  • Upload date:
  • Size: 28.1 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.1.tar.gz
Algorithm Hash digest
SHA256 e57d910abfacfa9d737fa4815cf18a823875d95695232202a6f212d628d467a8
MD5 544ba72ef4a16eb332e6630aacd553f9
BLAKE2b-256 5f4adaf4c65d7c601e2ea6953bf633468c6ca2e5da6dbfaad995dd7b71fdd36a

See more details on using hashes here.

File details

Details for the file mkdocs_feed-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: mkdocs_feed-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 27.1 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.1-py3-none-any.whl
Algorithm Hash digest
SHA256 3cc14192d019b8dfba55754d48d63d389848144aea40ec3d3aff69f8e7fdc3cb
MD5 f39a8a2b7041ce980996a7d308e9219f
BLAKE2b-256 e2be04f2d15207068f73320a5a3c0b5febce19ea327c605b9a3daa56d379e827

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