Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #12978 -- Added support for RSS feed stylesheets. #18120

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions django/contrib/syndication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ def get_feed(self, obj, request):
feed_copyright=self._get_dynamic_attr("feed_copyright", obj),
feed_guid=self._get_dynamic_attr("feed_guid", obj),
ttl=self._get_dynamic_attr("ttl", obj),
stylesheets=self._get_dynamic_attr("stylesheets", obj),
**self.feed_extra_kwargs(obj),
)

Expand Down
74 changes: 74 additions & 0 deletions django/utils/feedgenerator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import datetime
import email
import mimetypes
from io import StringIO
from urllib.parse import urlparse

Expand Down Expand Up @@ -57,6 +58,53 @@ def get_tag_uri(url, date):
return "tag:%s%s:%s/%s" % (bits.hostname, d, bits.path, bits.fragment)


def _guess_stylesheet_mimetype(url):
"""
Return the given stylesheet's mimetype tuple, using a slightly custom
version of Python's mimetypes.guess_type().
"""
mimetypedb = mimetypes.MimeTypes()

# The official mimetype for XSLT files is technically `application/xslt+xml`
# but as of 2024 almost no browser supports that (they all expect text/xsl).
# On top of that, windows seems to assume that the type for xsl is text/xml.
mimetypedb.readfp(StringIO("text/xsl\txsl\ntext/xsl\txslt"))

return mimetypedb.guess_type(url)


class Stylesheet:
"""An RSS stylesheet"""

def __init__(self, url, mimetype="", media="screen"):
self._url = url
self._mimetype = mimetype
self.media = media

# Using a property to delay the evaluation of self._url as late as possible
# in case of a lazy object (like reverse_lazy(...) for example).
@property
def url(self):
return iri_to_uri(self._url)

@property
def mimetype(self):
if self._mimetype == "":
return _guess_stylesheet_mimetype(self.url)[0]
return self._mimetype

def __str__(self):
data = [f'href="{self.url}"']
if self.mimetype is not None:
data.append(f'type="{self.mimetype}"')
if self.media is not None:
data.append(f'media="{self.media}"')
return " ".join(data)

def __repr__(self):
return repr((self.url, self.mimetype, self.media))


class SyndicationFeed:
"Base class for all syndication feeds. Subclasses should provide write()"

Expand All @@ -75,12 +123,24 @@ def __init__(
feed_copyright=None,
feed_guid=None,
ttl=None,
stylesheets=None,
**kwargs,
):
def to_str(s):
return str(s) if s is not None else s

def to_stylesheet(s):
return s if isinstance(s, Stylesheet) else Stylesheet(s)

categories = categories and [str(c) for c in categories]

if stylesheets is None:
pass
elif isinstance(stylesheets, (list, tuple)):
stylesheets = [to_stylesheet(s) for s in stylesheets]
else:
stylesheets = [to_stylesheet(stylesheets)]

self.feed = {
"title": to_str(title),
"link": iri_to_uri(link),
Expand All @@ -95,6 +155,7 @@ def to_str(s):
"feed_copyright": to_str(feed_copyright),
"id": feed_guid or link,
"ttl": to_str(ttl),
"stylesheets": stylesheets,
**kwargs,
}
self.items = []
Expand Down Expand Up @@ -166,6 +227,12 @@ def add_root_elements(self, handler):
"""
pass

def add_stylesheets(self, handler):
"""
Add stylesheet(s) to the feed. Called from write().
"""
pass
bmispelon marked this conversation as resolved.
Show resolved Hide resolved

def item_attributes(self, item):
"""
Return extra attributes to place on each item (i.e. item/entry) element.
Expand Down Expand Up @@ -228,6 +295,9 @@ class RssFeed(SyndicationFeed):
def write(self, outfile, encoding):
handler = SimplerXMLGenerator(outfile, encoding, short_empty_elements=True)
handler.startDocument()
# any stylesheet must come after the start of the document but before any tag
# https://www.w3.org/Style/styling-XML.en.html
self.add_stylesheets(handler)
handler.startElement("rss", self.rss_attributes())
handler.startElement("channel", self.root_attributes())
self.add_root_elements(handler)
Expand All @@ -247,6 +317,10 @@ def write_items(self, handler):
self.add_item_elements(handler, item)
handler.endElement("item")

def add_stylesheets(self, handler):
for stylesheet in self.feed["stylesheets"] or []:
handler.processingInstruction("xml-stylesheet", stylesheet)

def add_root_elements(self, handler):
handler.addQuickElement("title", self.feed["title"])
handler.addQuickElement("link", self.feed["link"])
Expand Down
117 changes: 113 additions & 4 deletions docs/ref/contrib/syndication.txt
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,30 @@ This example illustrates all possible attributes and methods for a

ttl = 600 # Hard-coded Time To Live.

# STYLESHEETS -- Optional. To set, provide one of the following three.
# The framework looks for them in this order.

def stylesheets(self, obj):
"""
Takes the object returned by get_object() and returns the feed's
stylesheets (an URL string or a Stylesheet instance).
"""

def stylesheets(self):
"""
Returns the feed's stylesheet (an URL string or a Stylesheet
instance).
"""

# Hardcoded stylesheets (can be a single stylesheet or a list)
stylesheets = "/link/to/stylesheet.xsl"
stylesheets = feedgenerator.Stylesheet("/link/to/stylesheet.xsl")
stylesheets = ["/stylesheet1.xsl", "stylesheet2.xls"]
stylesheets = [
feedgenerator.Stylesheet("/stylesheet1.xsl"),
feedgenerator.Stylesheet("/stylesheet2.xsl"),
]
bmispelon marked this conversation as resolved.
Show resolved Hide resolved

# ITEMS -- One of the following three is required. The framework looks
# for them in this order.

Expand Down Expand Up @@ -961,14 +985,21 @@ They share this interface:
* ``feed_copyright``
* ``feed_guid``
* ``ttl``
* ``stylesheets``

Any extra keyword arguments you pass to ``__init__`` will be stored in
``self.feed`` for use with `custom feed generators`_.
bmispelon marked this conversation as resolved.
Show resolved Hide resolved

All parameters should be strings, except ``categories``, which should be a
sequence of strings. Beware that some control characters
are `not allowed <https://www.w3.org/International/questions/qa-controls>`_
in XML documents. If your content has some of them, you might encounter a
All parameters should be strings, except for two:

* ``categories`` should be a sequence of strings.
* ``stylesheets`` can be a single string, an instance of
:class:`~django.utils.feedgenerator.Stylesheet`, or a list of either
strings or ``Stylesheet`` instances.

Beware that some control characters are
`not allowed <https://www.w3.org/International/questions/qa-controls>`_ in
XML documents. If your content has some of them, you might encounter a
:exc:`ValueError` when producing the feed.
bmispelon marked this conversation as resolved.
Show resolved Hide resolved

:meth:`.SyndicationFeed.add_item`
Expand Down Expand Up @@ -1095,3 +1126,81 @@ For example, you might start implementing an iTunes RSS feed generator like so::

There's a lot more work to be done for a complete custom feed class, but the
above example should demonstrate the basic idea.

Feed stylesheets
----------------

.. versionadded:: 5.1

You can add styling to your RSS feed using a stylesheet (typically in the XSLT_
or CSS formats) by setting the ``stylesheets`` attribute on the feed class.

This can be a hardcoded URL::
sarahboyce marked this conversation as resolved.
Show resolved Hide resolved

from django.contrib.syndication.views import Feed


class FeedWithHardcodedStylesheet(Feed):
... # author, etc.
stylesheets = "https://example.com/rss_stylesheet.xslt"

bmispelon marked this conversation as resolved.
Show resolved Hide resolved
You can also use Django's static files system::

from django.contrib.syndication.views import Feed
from django.templatetags.static import static


class FeedWithStaticFileStylesheet(Feed):
... # author, etc.
stylesheets = static("rss_styles.xslt")

bmispelon marked this conversation as resolved.
Show resolved Hide resolved
Another option is to have a view in your project that renders the XSLT document.
You can then link it like so::

from django.contrib.syndication.views import Feed
from django.urls import reverse_lazy


class FeedWithStylesheetView(Feed):
... # author, etc.
stylesheets = reverse_lazy("your-custom-view-name")

Django will normally try to guess the MIME type of the given URL based on its
extension, but if that fails you can specify it using the
:class:`~django.utils.feedgenerator.Stylesheet` class::

from django.contrib.syndication.views import Feed
from django.utils.feedgenerator import Stylesheet


class FeedWithHardcodedStylesheet(Feed):
... # author, etc.
stylesheets = Stylesheet("https://example.com/rss_stylesheet", mimetype="text/xsl")

Similarly, if you'd like to use a different ``media`` attribute than ``screen``
(Django's default), you can use the
:class:`~django.utils.feedgenerator.Stylesheet` class again::

from django.contrib.syndication.views import Feed
from django.utils.feedgenerator import Stylesheet


class FeedWithHardcodedStylesheet(Feed):
... # author, etc.
stylesheets = Stylesheet("https://example.com/rss_stylesheet.xslt", media="print")

Finally, if you need support for multiple stylesheets you can pass a list of
stylesheets (either as strings or as ``Stylesheet`` objects as shown above)::

from django.contrib.syndication.views import Feed
from django.utils.feedgenerator import Stylesheet


class MultiStylesheetFeed(Feed):
... # author, etc.
stylesheets = [
"/stylesheet1.xsl",
Stylesheet("/stylesheet2.xsl"),
]

.. _xslt: https://developer.mozilla.org/en-US/docs/Web/XSLT/Transforming_XML_with_XSLT
47 changes: 44 additions & 3 deletions docs/ref/utils.txt
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,32 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004

See https://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id

``Stylesheet``
--------------

.. versionadded:: 5.1

.. class:: Stylesheet(url, mimetype="", media="screen")

Represents an RSS stylesheet

.. attribute:: url

Required argument. The URL where the stylesheet is located.

.. attribute:: mimetype

An optional string containing the MIME type of the stylesheet. If not
specified, Django will attempt to guess it by using Python's
:py:func:`mimetypes.guess_type()`. Use ``mimetype=None`` if you don't
want your stylesheet to have a MIME type specified.

.. attribute:: media

An optional string which will be used as the ``media`` attribute of
the stylesheet. Defaults to ``"screen"`. Use ``media=None`` if you
don't want your stylesheet to have a ``media`` attribute.

``SyndicationFeed``
-------------------

Expand All @@ -339,16 +365,24 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004
Base class for all syndication feeds. Subclasses should provide
``write()``.

.. method:: __init__(title, link, description, language=None, author_email=None, author_name=None, author_link=None, subtitle=None, categories=None, feed_url=None, feed_copyright=None, feed_guid=None, ttl=None, **kwargs)
.. method:: __init__(title, link, description, language=None, author_email=None, author_name=None, author_link=None, subtitle=None, categories=None, feed_url=None, feed_copyright=None, feed_guid=None, ttl=None, stylesheets=None, **kwargs)

Initialize the feed with the given dictionary of metadata, which applies
to the entire feed.

Any extra keyword arguments you pass to ``__init__`` will be stored in
``self.feed``.

All parameters should be strings, except ``categories``, which should
be a sequence of strings.
All parameters should be strings, except for two:

* ``categories`` should be a sequence of strings.
* ``stylesheets`` can be a single string, an instance of
:class:`Stylesheet`, or a list of either strings or ``Stylesheet``
instances.

.. versionchanged:: 5.1

The ``stylesheets`` argument was added.

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

Expand All @@ -368,6 +402,13 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004
Add elements in the root (i.e. feed/channel) element.
Called from ``write()``.

.. method:: add_stylesheets(self, handler)

bmispelon marked this conversation as resolved.
Show resolved Hide resolved
.. versionadded:: 5.1

Add stylesheet information to the document.
Called from ``write()``.

.. method:: item_attributes(item)

Return extra attributes to place on each item (i.e. item/entry)
Expand Down
4 changes: 3 additions & 1 deletion docs/releases/5.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ Minor features
:mod:`django.contrib.syndication`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

* ...
* All :class:`~django.utils.feedgenerator.SyndicationFeed` classes now support
a ``stylesheet`` attribute. If specified, an ``<? xml-stylesheet ?>``
bmispelon marked this conversation as resolved.
Show resolved Hide resolved
processing instruction will be added to the top of the document.

Asynchronous views
~~~~~~~~~~~~~~~~~~
Expand Down
11 changes: 11 additions & 0 deletions tests/syndication_tests/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,17 @@ def item_title(self, item):
return "Title: %s" % item.title


class TestFeedWithStylesheet(TestRss2Feed):
stylesheets = "/stylesheet.xsl"


class TestFeedWithMultipleStylesheets(TestRss2Feed):
stylesheets = [
"/stylesheet1.xsl",
feedgenerator.Stylesheet("/stylesheet2.xsl"),
]


class NaiveDatesFeed(TestAtomFeed):
"""
A feed with naive (non-timezone-aware) dates.
Expand Down