Metadata-Version: 2.4
Name: mkdocs-feed
Version: 0.1.1
Summary: RSS 2.0 feed generation for MkDocs, with per-language feeds and git-derived dates
Author-email: liWanr <dotWanr@icloud.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/liWanr/mkdocs-feed
Project-URL: Repository, https://github.com/liWanr/mkdocs-feed
Project-URL: Issues, https://github.com/liWanr/mkdocs-feed/issues
Keywords: mkdocs,mkdocs-plugin,rss,feed,blog
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: MkDocs
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Documentation
Classifier: Topic :: Text Processing :: Markup :: HTML
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mkdocs>=1.5
Requires-Dist: tzdata; sys_platform == "win32"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: mkdocs-material; extra == "dev"
Requires-Dist: mkdocs-static-i18n; extra == "dev"
Dynamic: license-file

# mkdocs-feed

RSS 2.0 feed generation for MkDocs, with per-language feeds and git-derived dates.

```yaml
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`][upstream] 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:

```jinja
{% 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.

[upstream]: https://github.com/Guts/mkdocs-rss-plugin

## Configuration

Every option with its default:

```yaml
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:

```yaml
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][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.

[i18n]: https://github.com/ultrabug/mkdocs-static-i18n

### 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

```bash
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.
