API reference

mkdocs_changelog_feed_plugin

changelog_feed

MkDocs changelog feed plugin and config schema.

ChangelogFeedPlugin

Bases: BasePlugin[ChangelogFeedPluginConfig]

MkDocs plugin that adds RSS and Atom feeds for a changelog in the Keep a Changelog format.

Source code in mkdocs_changelog_feed_plugin/changelog_feed.py
class ChangelogFeedPlugin(BasePlugin[ChangelogFeedPluginConfig]):
    """MkDocs plugin that adds RSS and Atom feeds for a changelog in the Keep a
    Changelog format.
    """

    def on_config(self, config: MkDocsConfig):
        """Make sure that the `site_url` setting, which is required to build absolute
        URLs, is present.

        See also <https://www.mkdocs.org/dev-guide/plugins/#on_config>.
        """
        if not config.site_url:
            raise PluginError(
                'The site_url setting is required by the changelog feed plugin.'
            )

        if not config.site_author:
            log.warning(
                'The site_author setting is required to create a valid Atom feed.'
            )

    def on_page_content(
        self, html: str, page: Page, config: MkDocsConfig, files: Files
    ):
        """If `page` is the changelog as specified by the `changelog_file`, this method
        splits the HTML it into sections for each changelog entry and generates RSS and
        Atom feeds with items for each of those entries. Then, links to the feeds are
        added at the top of the page.

        See also <https://www.mkdocs.org/dev-guide/plugins/#on_page_content>.
        """
        if page.file.src_uri != self.config.changelog_file:
            return

        feed_title = self.config.feed_title or f'{page.title} - {config.site_name}'

        feeds = [
            feed_class(
                feed_title,
                page.canonical_url,
                self.config.feed_description,
                author_name=config.site_author,
                feed_copyright=config.copyright,
            )
            for feed_class in feed_classes
        ]

        soup = BeautifulSoup(html, features='html.parser')

        if self.config.remove_permalinks:
            try:
                permalink_class = config['mdx_configs']['toc']['permalink_class']
            except KeyError:
                permalink_class = 'headerlink'
            for permalink in soup.select(f'a.{permalink_class}'):
                permalink.decompose()

        for h2 in soup.find_all('h2'):
            try:
                fragment = h2['id']
            except KeyError:
                fragment = None

            try:
                heading_version, heading_date = (
                    s.strip() for s in re.split(r'\s[-‒––]\s', h2.text, maxsplit=2)
                )
            except ValueError:
                heading_version, heading_date = h2.text, None

            if heading_date:
                try:
                    day = date.fromisoformat(heading_date[:10])
                except ValueError as e:
                    raise PluginError(
                        f'Unable to parse date "{heading_date}" in section heading.'
                    ) from e
                pubdate = datetime.combine(day, time(12, 00), tzinfo=timezone.utc)
            else:
                pubdate = datetime.fromtimestamp(
                    Path(page.file.abs_src_path).stat().st_mtime, tz=timezone.utc
                )

            section_content = ''.join(
                str(s) for s in takewhile(lambda s: s.name != 'h2', h2.next_siblings)
            ).strip()

            for feed in feeds:
                link = (
                    f'{page.canonical_url}#{fragment}'
                    if fragment
                    else page.canonical_url
                )
                feed.add_item(
                    heading_version,
                    link,
                    section_content,
                    pubdate=pubdate,
                    unique_id=link,
                    unique_id_is_permalink=True,
                )

        link_tags = []
        a_tags = []

        for feed in feeds:
            feed_file = File.generated(
                config,
                Path(page.file.src_uri).with_suffix(feed.file_suffix),
                content=feed.writeString('utf-8'),
            )
            files.append(feed_file)

            feed_url = feed_file.url_relative_to(page.file)

            link_tags.append(
                soup.new_tag(
                    'link',
                    attrs={
                        'rel': 'alternate',
                        'type': feed.content_type.split(';')[0],
                        'href': feed_url,
                        'title': f'{page.title} {feed.verbose_name}',
                    },
                )
            )
            a_tags.append(
                soup.new_tag('a', attrs={'href': feed_url}, string=feed.verbose_name)
            )

        page.meta['changelog_feed'] = {'link_tags': link_tags}

        div = soup.new_tag('div', attrs={'style': 'float: inline-end'})
        div.append(BeautifulSoup(self.config.links_icon, 'html.parser'))
        for a_tag in a_tags:
            div.append(' ')
            div.append(a_tag)

        html = '\n'.join([div.decode(formatter=formatter), html])

        return html

    def on_post_page(self, output: str, page: Page, config: MkDocsConfig):
        """Add `<link rel="alternate" …>` tags to the head of the HTML document for
        each generated feed, if stored in the [`Page`][mkdocs.structure.pages.Page]'s
        metadata by
        [`on_page_content`][mkdocs_changelog_feed_plugin.changelog_feed.ChangelogFeedPlugin.on_page_content].

        See also <https://www.mkdocs.org/dev-guide/plugins/#on_post_page>.
        """
        try:
            link_tags = page.meta['changelog_feed']['link_tags']
        except KeyError:
            return

        # Split at the beginning of the line with with the closing head tag.
        parts = re.split(r'^(\s*</head>)', output, maxsplit=1, flags=re.M)

        output = ''.join(
            [
                parts[0],
                *[tag.decode(indent_level=2, formatter=formatter) for tag in link_tags],
                *parts[1:],
            ]
        )

        return output
on_config
on_config(config: MkDocsConfig)

Make sure that the site_url setting, which is required to build absolute URLs, is present.

See also https://www.mkdocs.org/dev-guide/plugins/#on_config.

Source code in mkdocs_changelog_feed_plugin/changelog_feed.py
def on_config(self, config: MkDocsConfig):
    """Make sure that the `site_url` setting, which is required to build absolute
    URLs, is present.

    See also <https://www.mkdocs.org/dev-guide/plugins/#on_config>.
    """
    if not config.site_url:
        raise PluginError(
            'The site_url setting is required by the changelog feed plugin.'
        )

    if not config.site_author:
        log.warning(
            'The site_author setting is required to create a valid Atom feed.'
        )
on_page_content
on_page_content(html: str, page: Page, config: MkDocsConfig, files: Files)

If page is the changelog as specified by the changelog_file, this method splits the HTML it into sections for each changelog entry and generates RSS and Atom feeds with items for each of those entries. Then, links to the feeds are added at the top of the page.

See also https://www.mkdocs.org/dev-guide/plugins/#on_page_content.

Source code in mkdocs_changelog_feed_plugin/changelog_feed.py
def on_page_content(
    self, html: str, page: Page, config: MkDocsConfig, files: Files
):
    """If `page` is the changelog as specified by the `changelog_file`, this method
    splits the HTML it into sections for each changelog entry and generates RSS and
    Atom feeds with items for each of those entries. Then, links to the feeds are
    added at the top of the page.

    See also <https://www.mkdocs.org/dev-guide/plugins/#on_page_content>.
    """
    if page.file.src_uri != self.config.changelog_file:
        return

    feed_title = self.config.feed_title or f'{page.title} - {config.site_name}'

    feeds = [
        feed_class(
            feed_title,
            page.canonical_url,
            self.config.feed_description,
            author_name=config.site_author,
            feed_copyright=config.copyright,
        )
        for feed_class in feed_classes
    ]

    soup = BeautifulSoup(html, features='html.parser')

    if self.config.remove_permalinks:
        try:
            permalink_class = config['mdx_configs']['toc']['permalink_class']
        except KeyError:
            permalink_class = 'headerlink'
        for permalink in soup.select(f'a.{permalink_class}'):
            permalink.decompose()

    for h2 in soup.find_all('h2'):
        try:
            fragment = h2['id']
        except KeyError:
            fragment = None

        try:
            heading_version, heading_date = (
                s.strip() for s in re.split(r'\s[-‒––]\s', h2.text, maxsplit=2)
            )
        except ValueError:
            heading_version, heading_date = h2.text, None

        if heading_date:
            try:
                day = date.fromisoformat(heading_date[:10])
            except ValueError as e:
                raise PluginError(
                    f'Unable to parse date "{heading_date}" in section heading.'
                ) from e
            pubdate = datetime.combine(day, time(12, 00), tzinfo=timezone.utc)
        else:
            pubdate = datetime.fromtimestamp(
                Path(page.file.abs_src_path).stat().st_mtime, tz=timezone.utc
            )

        section_content = ''.join(
            str(s) for s in takewhile(lambda s: s.name != 'h2', h2.next_siblings)
        ).strip()

        for feed in feeds:
            link = (
                f'{page.canonical_url}#{fragment}'
                if fragment
                else page.canonical_url
            )
            feed.add_item(
                heading_version,
                link,
                section_content,
                pubdate=pubdate,
                unique_id=link,
                unique_id_is_permalink=True,
            )

    link_tags = []
    a_tags = []

    for feed in feeds:
        feed_file = File.generated(
            config,
            Path(page.file.src_uri).with_suffix(feed.file_suffix),
            content=feed.writeString('utf-8'),
        )
        files.append(feed_file)

        feed_url = feed_file.url_relative_to(page.file)

        link_tags.append(
            soup.new_tag(
                'link',
                attrs={
                    'rel': 'alternate',
                    'type': feed.content_type.split(';')[0],
                    'href': feed_url,
                    'title': f'{page.title} {feed.verbose_name}',
                },
            )
        )
        a_tags.append(
            soup.new_tag('a', attrs={'href': feed_url}, string=feed.verbose_name)
        )

    page.meta['changelog_feed'] = {'link_tags': link_tags}

    div = soup.new_tag('div', attrs={'style': 'float: inline-end'})
    div.append(BeautifulSoup(self.config.links_icon, 'html.parser'))
    for a_tag in a_tags:
        div.append(' ')
        div.append(a_tag)

    html = '\n'.join([div.decode(formatter=formatter), html])

    return html
on_post_page
on_post_page(output: str, page: Page, config: MkDocsConfig)

Add <link rel="alternate" …> tags to the head of the HTML document for each generated feed, if stored in the Page’s metadata by on_page_content.

See also https://www.mkdocs.org/dev-guide/plugins/#on_post_page.

Source code in mkdocs_changelog_feed_plugin/changelog_feed.py
def on_post_page(self, output: str, page: Page, config: MkDocsConfig):
    """Add `<link rel="alternate" …>` tags to the head of the HTML document for
    each generated feed, if stored in the [`Page`][mkdocs.structure.pages.Page]'s
    metadata by
    [`on_page_content`][mkdocs_changelog_feed_plugin.changelog_feed.ChangelogFeedPlugin.on_page_content].

    See also <https://www.mkdocs.org/dev-guide/plugins/#on_post_page>.
    """
    try:
        link_tags = page.meta['changelog_feed']['link_tags']
    except KeyError:
        return

    # Split at the beginning of the line with with the closing head tag.
    parts = re.split(r'^(\s*</head>)', output, maxsplit=1, flags=re.M)

    output = ''.join(
        [
            parts[0],
            *[tag.decode(indent_level=2, formatter=formatter) for tag in link_tags],
            *parts[1:],
        ]
    )

    return output

ChangelogFeedPluginConfig

Bases: Config

Plugin config schema.

See also https://www.mkdocs.org/dev-guide/plugins/#config_scheme.

Source code in mkdocs_changelog_feed_plugin/changelog_feed.py
class ChangelogFeedPluginConfig(Config):
    """Plugin config schema.

    See also <https://www.mkdocs.org/dev-guide/plugins/#config_scheme>.
    """

    changelog_file = config_options.Type(str, default='CHANGELOG.md')
    """The file within your `docs` directory containing the changelog."""

    feed_title = config_options.Optional(config_options.Type(str))
    """The feed's title.

    Defaults to "*page name* - *site name*", if not set.
    """

    feed_description = config_options.Optional(config_options.Type(str))
    """The feed's description (RSS)/subtitle (Atom)."""

    remove_permalinks = config_options.Type(bool, default=True)
    """Remove permalinks added by Python-Markdown's Table of Contents extension from
    the feeds.
    """

    links_icon = config_options.Type(str, default='<i class="fa fa-square-rss"></i>')
    """Icon to be displayed next to the links to the feeds at the top of the page."""
changelog_file class-attribute instance-attribute
changelog_file = Type(str, default='CHANGELOG.md')

The file within your docs directory containing the changelog.

feed_description class-attribute instance-attribute
feed_description = Optional(Type(str))

The feed’s description (RSS)/subtitle (Atom).

feed_title class-attribute instance-attribute
feed_title = Optional(Type(str))

The feed’s title.

Defaults to “page name - site name”, if not set.

links_icon = Type(str, default='<i class="fa fa-square-rss"></i>')

Icon to be displayed next to the links to the feeds at the top of the page.

remove_permalinks = Type(bool, default=True)

Remove permalinks added by Python-Markdown’s Table of Contents extension from the feeds.

feeds

Subclasses of FeedGenerator’s Atom1Feed and Rss201rev2Feed classes with some adjustments.

AtomFeed

Bases: Atom1Feed

An Atom feed.

Source code in mkdocs_changelog_feed_plugin/feeds.py
class AtomFeed(feedgenerator.Atom1Feed):
    """An Atom feed."""

    verbose_name = 'Atom feed'
    file_suffix = '.atom.xml'

    def add_item(
        self,
        title,
        link,
        description,
        author_email=None,
        author_name=None,
        author_link=None,
        pubdate=None,
        comments=None,
        unique_id=None,
        unique_id_is_permalink=None,
        categories=(),
        item_copyright=None,
        ttl=None,
        content=None,
        updateddate=None,
        enclosures=None,
        **kwargs,
    ):
        """Add an item to the feed.

        Ensure that items with full text `description` are correctly added to the feed.
        """
        # Workaround for feedgenerator's “backwards-compatibility not in Django”
        description, content = None, description

        super().add_item(
            title,
            link,
            description,
            author_email=author_email,
            author_name=author_name,
            author_link=author_link,
            pubdate=pubdate,
            comments=comments,
            unique_id=unique_id,
            unique_id_is_permalink=unique_id_is_permalink,
            categories=categories,
            item_copyright=item_copyright,
            ttl=ttl,
            content=content,
            updateddate=updateddate,
            enclosures=enclosures,
            **kwargs,
        )
add_item
add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, unique_id_is_permalink=None, categories=(), item_copyright=None, ttl=None, content=None, updateddate=None, enclosures=None, **kwargs)

Add an item to the feed.

Ensure that items with full text description are correctly added to the feed.

Source code in mkdocs_changelog_feed_plugin/feeds.py
def add_item(
    self,
    title,
    link,
    description,
    author_email=None,
    author_name=None,
    author_link=None,
    pubdate=None,
    comments=None,
    unique_id=None,
    unique_id_is_permalink=None,
    categories=(),
    item_copyright=None,
    ttl=None,
    content=None,
    updateddate=None,
    enclosures=None,
    **kwargs,
):
    """Add an item to the feed.

    Ensure that items with full text `description` are correctly added to the feed.
    """
    # Workaround for feedgenerator's “backwards-compatibility not in Django”
    description, content = None, description

    super().add_item(
        title,
        link,
        description,
        author_email=author_email,
        author_name=author_name,
        author_link=author_link,
        pubdate=pubdate,
        comments=comments,
        unique_id=unique_id,
        unique_id_is_permalink=unique_id_is_permalink,
        categories=categories,
        item_copyright=item_copyright,
        ttl=ttl,
        content=content,
        updateddate=updateddate,
        enclosures=enclosures,
        **kwargs,
    )

RSSFeed

Bases: Rss201rev2Feed

An RSS feed.

Source code in mkdocs_changelog_feed_plugin/feeds.py
class RSSFeed(feedgenerator.Rss201rev2Feed):
    """An RSS feed."""

    verbose_name = 'RSS feed'
    file_suffix = '.rss.xml'

    def add_item(
        self,
        title,
        link,
        description,
        author_email=None,
        author_name=None,
        author_link=None,
        pubdate=None,
        comments=None,
        unique_id=None,
        unique_id_is_permalink=None,
        categories=(),
        item_copyright=None,
        ttl=None,
        content=None,
        updateddate=None,
        enclosures=None,
        **kwargs,
    ):
        """Add an item to the feed.

        If not provided as arguments, the author information is copied from the feed
        object to the item, as (unlike Atom) RSS doesn't support author information for
        the whole feed.
        """
        if not author_email and not author_name and not author_link:
            author_email = self.feed['author_email']
            author_name = self.feed['author_name']
            author_link = self.feed['author_link']

        super().add_item(
            title,
            link,
            description,
            author_email=author_email,
            author_name=author_name,
            author_link=author_link,
            pubdate=pubdate,
            comments=comments,
            unique_id=unique_id,
            unique_id_is_permalink=unique_id_is_permalink,
            categories=categories,
            item_copyright=item_copyright,
            ttl=ttl,
            content=content,
            updateddate=updateddate,
            enclosures=enclosures,
            **kwargs,
        )
add_item
add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, unique_id_is_permalink=None, categories=(), item_copyright=None, ttl=None, content=None, updateddate=None, enclosures=None, **kwargs)

Add an item to the feed.

If not provided as arguments, the author information is copied from the feed object to the item, as (unlike Atom) RSS doesn’t support author information for the whole feed.

Source code in mkdocs_changelog_feed_plugin/feeds.py
def add_item(
    self,
    title,
    link,
    description,
    author_email=None,
    author_name=None,
    author_link=None,
    pubdate=None,
    comments=None,
    unique_id=None,
    unique_id_is_permalink=None,
    categories=(),
    item_copyright=None,
    ttl=None,
    content=None,
    updateddate=None,
    enclosures=None,
    **kwargs,
):
    """Add an item to the feed.

    If not provided as arguments, the author information is copied from the feed
    object to the item, as (unlike Atom) RSS doesn't support author information for
    the whole feed.
    """
    if not author_email and not author_name and not author_link:
        author_email = self.feed['author_email']
        author_name = self.feed['author_name']
        author_link = self.feed['author_link']

    super().add_item(
        title,
        link,
        description,
        author_email=author_email,
        author_name=author_name,
        author_link=author_link,
        pubdate=pubdate,
        comments=comments,
        unique_id=unique_id,
        unique_id_is_permalink=unique_id_is_permalink,
        categories=categories,
        item_copyright=item_copyright,
        ttl=ttl,
        content=content,
        updateddate=updateddate,
        enclosures=enclosures,
        **kwargs,
    )