Commit 43428c21 authored by Marien Fressinaud's avatar Marien Fressinaud

add: Provide an Atom feed

parents 9be19d59 edfcf822
......@@ -73,6 +73,8 @@ $ tree
├── articles
│   └──
├── site
│   ├── feeds
│   │   └── all.atom.xml
│   ├── index.html
│   ├── my-article.html
├── theme
......@@ -85,6 +87,9 @@ $ echo site/my-article.html
Please note that `./articles` folder only accepts Markdown files (i.e.
extension is `.md`).
As you can see, it also generates automatically [an Atom feed](
under `./site/feeds/all.atom.xml`.
You can define meta variables in the Markdown file which will be accessible
then in the template. Variables are uppercased and prepended by `ARTICLE_`. For
example, for the following Markdown file:
......@@ -134,6 +139,21 @@ For now, you can use any valid Python expression between `{{ }}`, you have
access to the variables defined in the meta header, the Python builtin
functions and the `datetime` module.
The following meta variables have specific significance, especially in the Atom
- `slug` (defines the final URL of the article)
- `title`
- `author`
- `date` (the date of publication)
- `update` (the date of the last update)
It is highly recommended to always specify `title` and `date` (or default
values will be generated). The default value of `slug` is the name of the file
without its extension. A default value for `author` can be set in the
`configuration.yml` file. `date` and `update` must follow this format:
The `./theme` folder can contain a `static` folder as well. Its content will be
copied under the `./site` folder:
......@@ -143,6 +163,8 @@ $ tree
├── articles
│   └──
├── site
│   ├── feeds
│   │   └── all.atom.xml
│   ├── index.html
│   ├── my-article.html
│   └── style.css
......@@ -153,6 +175,19 @@ $ tree
└── index.html
Finally, you might want to create a `./configuration.yml` file. Four
information are used by the Atom feed, so you might want to declare them:
title: My website
author: Dale Cooper
timezone: Europe/Paris
You can declare as many variables as you want, they all will be accessible in
the article template, uppercased and prepended by `SITE_`.
## Tests
There are some tests (i.e. doctests) that can be run to check that everything
......@@ -5,8 +5,14 @@ import sys
import shutil
import distutils.dir_util
import locale
import yaml
import datetime
import uuid
import markdown
import pytz
import xml.etree.ElementTree as ET
import boopsy
......@@ -30,16 +36,16 @@ class ProgramError(Exception):
class Article:
def __init__(self, slug, article_content):
self.slug = slug
def __init__(self, slug, article_content, configuration={}):
# We convert the file's content into HTML
md = markdown.Markdown(extensions=["meta", "fenced_code", "attr_list"])
self.content = md.convert(article_content)
content = md.convert(article_content)
# We get the local variables from the Markdown file (metadata)
# which will be accessible in the article template
self.meta = {}
self.meta["ARTICLE_SLUG"] = slug
for key, values in md.Meta.items():
article_key = f"ARTICLE_{key.upper()}"
# The Markdown's meta extension extract all the values in arrays, even
......@@ -47,15 +53,60 @@ class Article:
# the template system, for the moment we just get the first value of
# the array.
self.meta[article_key] = values[0]
self.meta["ARTICLE_CONTENT"] = content
# And we initialize a template for the article
article_template_filepath = os.path.join("theme", "article.html")
self.template = boopsy.Template(article_template_filepath)
def render(self):
variables = self.meta.copy()
variables["ARTICLE_CONTENT"] = self.content
return self.template.render(variables)
return self.template.render(self.meta)
def slug(self):
return self.meta["ARTICLE_SLUG"]
def title(self):
return self.meta.get("ARTICLE_TITLE", "An article")
def author(self):
if "ARTICLE_AUTHOR" in self.meta:
return self.meta["ARTICLE_AUTHOR"]
elif "SITE_AUTHOR" in self.meta:
return self.meta["SITE_AUTHOR"]
return None
def url(self):
site_url = self.meta.get("SITE_URL", "")
return f"{site_url}{self.slug()}.html"
def uuid(self):
return str(uuid.uuid5(uuid.NAMESPACE_URL, self.url()))
def content(self):
return self.meta["ARTICLE_CONTENT"]
def timezone(self):
site_timezone = self.meta.get("SITE_TIMEZONE", "UTC")
return pytz.timezone(site_timezone)
def date(self):
if "ARTICLE_DATE" not in self.meta:
article_date = datetime.datetime.strptime(
self.meta["ARTICLE_DATE"], "%Y-%m-%d %H:%M"
return article_date.astimezone(tz=self.timezone())
def update(self):
if "ARTICLE_UPDATE" not in self.meta:
return None
article_update = datetime.datetime.strptime(
self.meta["ARTICLE_UPDATE"], "%Y-%m-%d %H:%M"
return article_update.astimezone(tz=self.timezone())
def dir_tree(path):
......@@ -100,6 +151,27 @@ def main():
# If not, just stop here
# Load the configuration (it can be overidden by a configuration.yml file)
configuration = {
"SITE_URL": "http://localhost",
"SITE_TITLE": "A website",
configuration_path = os.path.join(os.curdir, "configuration.yml")
if os.path.exists(configuration_path):
with open(configuration_path) as conf_file:
conf_data = yaml.load(conf_file)
for key, value in conf_data.items():
site_key = f"SITE_{key.upper()}"
configuration[site_key] = value
configuration["SITE_UUID"] = str(
uuid.uuid5(uuid.NAMESPACE_URL, configuration["SITE_URL"])
if "SITE_AUTHOR" not in configuration:
configuration["SITE_AUTHOR"] = f"{configuration['SITE_TITLE']}'s author"
# List files from ./articles
articles = []
for filename in dir_tree(articles_path):
......@@ -111,15 +183,71 @@ def main():
# We read the article file and initialize an Article from it
article_filepath = os.path.join(articles_path, filename)
with open(article_filepath, "r") as article_file:
article = Article(slug,
article = Article(slug,, configuration=configuration)
# If there is no articles, stop there
if len(articles) == 0:
# And write them in the ./site folder
for article in articles:
output_filepath = os.path.join(output_path, f"{article.slug}.html")
output_filepath = os.path.join(output_path, f"{article.slug()}.html")
with open(output_filepath, "w") as output_file:
# Finish by writing the news feed
feed_dirpath = os.path.join(output_path, "feeds")
# Just create some variables so it's easier to manipulate after
site_url = configuration["SITE_URL"]
site_uuid = configuration["SITE_UUID"]
site_title = configuration["SITE_TITLE"]
site_timezone = configuration["SITE_TIMEZONE"]
timezone = pytz.timezone(site_timezone)
current_datetime =
# Start to build the XML feed with the root information
feed = ET.Element("feed", xmlns="")
ET.SubElement(feed, "title").text = site_title
ET.SubElement(feed, "link", href=site_url, rel="alternate", type="text/html")
ET.SubElement(feed, "id").text = f"urn:uuid:{site_uuid}"
ET.SubElement(feed, "updated").text = current_datetime.isoformat(timespec="seconds")
for article in articles:
# and complete it for each article
entry = ET.SubElement(feed, "entry")
ET.SubElement(entry, "title").text = article.title()
ET.SubElement(entry, "id").text = f"urn:uuid:{article.uuid()}"
article_author =
if article_author:
author = ET.SubElement(entry, "author")
ET.SubElement(author, "name").text = article_author
entry, "link", href=article.url(), rel="alternate", type="text/html"
ET.SubElement(entry, "published").text =
article_update = article.update()
if article_update:
ET.SubElement(entry, "updated").text = article_update.isoformat(
ET.SubElement(entry, "content", type="html").text = article.content()
# And write it on the filesystem
feed_filepath = os.path.join(feed_dirpath, "all.atom.xml")
ET.ElementTree(feed).write(feed_filepath, encoding="unicode", xml_declaration=True)
if __name__ == "__main__":
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment