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
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
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
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
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,
    )