Skip to content

Adding new RSS feeds

To add new RSS feeds, we need a form!

Let's start by creating a partial for it:

templates/partials/add_feed.html
<form class="flex flex-col items-center" action="/add_feed" method="POST">
    <div>
        <label for="url">URL</label>
        <input type="text" id="url" name="url" class="bg-slate-100 p-2 mb-2">
    </div>
    <div>
        <label for="title">Title</label>
        <input type="text" id="title" name="title" class="bg-slate-100 p-2 mb-2">
    </div>
    <div>
        <label for="showImages">Show Images</label>
        <input type="checkbox"
               id="showImages"
               name="showImages"
               class="bg-slate-100 p-2 mb-2">
    </div>
    <input type="submit"
           value="Add"
           class="bg-slate-600 text-white font-semibold rounded-md px-4 py-2">
</form>

Then we can either:

  • Show the form by calling an endpoint that returns its HTML; or
  • Show the form using JavaScript 😱 (don't worry, AlpineJS is very minimal)

Adding feeds using HTMX

We'll begin by adding two endpoints to our Flask app: one for rendering the form, and one for dealing with the form submission:

__init__.py
import feedparser
import jinja_partials
from flask import Flask, abort, redirect, render_template, request, url_for

feeds = {
    "https://blog.teclado.com/rss/": {"title": "The Teclado Blog", "href": "https://blog.teclado.com/rss/", "show_images": True, "entries": {}},
    "https://www.joshwcomeau.com/rss.xml": {"title": "Josh W. Comeau", "href": "https://www.joshwcomeau.com/rss.xml", "show_images": False, "entries": {}},
}

def create_app():
    app = Flask(__name__)
    jinja_partials.register_extensions(app)


    @app.route("/feed/")
    @app.route("/feed/<path:feed_url>")
    def render_feed(feed_url: str = None):
        for url, feed_ in feeds.items():
            parsed_feed = feedparser.parse(url)
            for entry in parsed_feed.entries:
                if entry.link not in feed_["entries"]:
                    feed_["entries"][entry.link] = entry

        if feed_url is None:
            feed = list(feeds.values())[0]
        else:
            feed = feeds[feed_url]
        return render_template("feed.html", feed=feed, feeds=feeds)


    @app.route("/entries/<path:feed_url>")
    def render_feed_entries(feed_url: str):
        try:
            feed = feeds[feed_url]
        except KeyError:
            abort(400)
        page = int(request.args.get("page", 0))

        # Below we're paginating the entries even though
        # in this application it's not necessary, just to
        # show what it might look like if it were.
        return render_template(
            "partials/entry_page.html",
            entries=list(feed["entries"].values())[page*5:page*5+5],
            href=feed["href"],
            page=page,
            max_page=len(feed["entries"])//5
        )

    @app.route("/add_feed", methods=["POST"])
    def add_feed():
        feed = request.form.get("url")
        title = request.form.get("title")
        show_images = request.form.get("showImages")
        feeds[feed] = {"title": title, "href": feed, "show_images": show_images, "entries": {}}
        return redirect(url_for("render_feed", feed=feed))

    @app.route("/render_add_feed")
    def render_add_feed():
        return render_template("partials/add_feed.html")


    return app

Then we'll add a button in our feed.html template that, when clicked, calls the /render_add_feed endpoint and replaces the HTML returned into the current page:

templates/feed.html
{% extends "base.html" %}
{% block content %}
  <div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
    <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 py-4">
      <nav class="flex flex-1 flex-col">
        <ul role="list" class="flex flex-1 flex-col gap-y-3">
          {% for feed_url, feed_ in feeds.items() %}
            <li>
              <a href="{{ url_for('render_feed', feed_url=feed_.href) }}"
                 hx-boost
                 class="{{ 'bg-green-700 text-white' if feed.href == feed_['href'] else '' }} group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
                {{ feed_['title'] }}
              </a>
            </li>
          {% endfor %}
        </ul>
      </nav>
      <div class="flex justify-center items-center">
        <button hx-get="/render_add_feed"
                hx-swap="outerHTML"
                class="bg-slate-600 text-white font-semibold rounded-md px-4 py-2">Add</button>
      </div>
    </div>
  </div>
  <div class="pl-72">
    <main class="py-10">
      <div class="px-4 sm:px-6 lg:px-8"
           id="entries"
           hx-get="{{ url_for('render_feed_entries', feed_url=feed.href, page=0) }}"
           hx-swap="afterbegin"
           hx-target="this"
           hx-trigger="load"></div>
    </main>
  </div>
{% endblock content %}

That's it! Doesn't get much easier than that!

Adding feeds with AlpineJS

If you want to be able to hide the form once you've shown it, then you start needing JavaScript. AlpineJS offers a very minimalistic API that complements HTMX very well.

Let's bring it into our base template:

templates/base.html
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Feed reader</title>
        <script src="https://cdn.tailwindcss.com?plugins=typography,aspect-ratio"></script>
        <script src="https://unpkg.com/htmx.org@1.9.9"></script>
        <script src="https://unpkg.com/alpinejs" defer></script>
    </head>
    <body>
        {% block content %}
        {% endblock content %}
    </body>
</html>

Then let's change our feed.html so it uses AlpineJS:

templates/feed.html
{% extends "base.html" %}
{% block content %}
  <div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
    <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 py-4">
      <nav class="flex flex-1 flex-col">
        <ul role="list" class="flex flex-1 flex-col gap-y-3">
          {% for feed_url, feed_ in feeds.items() %}
            <li>
              <a href="{{ url_for('render_feed', feed_url=feed_.href) }}"
                 hx-boost
                 class="{{ 'bg-green-700 text-white' if feed.href == feed_['href'] else '' }} group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
                {{ feed_['title'] }}
              </a>
            </li>
          {% endfor %}
        </ul>
      </nav>
      <div class="flex flex-col justify-center items-center"
           x-data="{show: false}">
        <!-- The below doesn't need to be a partial anymore because we're never rendering it in a Flask endpoint -->
        <div x-show="show">{{ render_partial("partials/add_feed.html") }}</div>
        <button x-on:click="show = !show"
                class="bg-slate-600 text-white font-semibold rounded-md px-4 py-2"
                x-text="show ? 'Hide' : 'Show'">Show</button>
      </div>
    </div>
  </div>
  <div class="pl-72">
    <main class="py-10">
      <div class="px-4 sm:px-6 lg:px-8"
           id="entries"
           hx-get="{{ url_for('render_feed_entries', feed_url=feed.href, page=0) }}"
           hx-swap="afterbegin"
           hx-target="this"
           hx-trigger="load"></div>
    </main>
  </div>
{% endblock content %}

Here we have the following:

  • x-data="{show: false}" on the button parent.
  • The first child div contains our form, but it's hidden to begin with because x-show="show" will hide it (since show: false).
  • Then there's a button that toggles show with x-on:click="show = !show.
    • The button text changes depending on the show variable, and lets us hide or show the form.

If we use Alpine, we can get rid of the /render_add_feed template, so that's a small bonus:

__init__.py
-     
-     @app.route("/render_add_feed")
-     def render_add_feed():
-         return render_template("partials/add_feed.html")

We could also move add_feed.html outside of partials (since we're no longer rendering it on its own). We could make it a macro. Or we could put the HTML directly in the page. But keeping it as a partial is fine too!