boop.py 9.01 KB
Newer Older
1
2
3
#!/bin/env python3

import os
4
import sys
5
import shutil
6
import distutils.dir_util
7
import locale
berumuron's avatar
berumuron committed
8
import yaml
9
10
import datetime
import uuid
11

12
import markdown
13
import pytz
14

15
import boopsy
16
import boopfeed
17
18


19
locale.setlocale(locale.LC_ALL, "")
20

21

22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class ProgramError(Exception):
    """Exception raised during the program execution.

    Examples:

    >>> try:
    ...     raise ProgramError("Oops")
    ... except ProgramError as e:
    ...     print(e.args[0])
    Oops
    """

    pass


37
class Article:
38
    def __init__(self, slug, article_content, configuration={}):
39
40
        # We convert the file's content into HTML
        md = markdown.Markdown(extensions=["meta", "fenced_code", "attr_list"])
41
        content = md.convert(article_content)
42
43
44
45

        # We get the local variables from the Markdown file (metadata)
        # which will be accessible in the article template
        self.meta = {}
46
47
        self.meta.update(configuration)
        self.meta["ARTICLE_SLUG"] = slug
48
49
50
51
52
53
54
        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
            # if there is only one value. Because there is no iteration system in
            # the template system, for the moment we just get the first value of
            # the array.
            self.meta[article_key] = values[0]
55
        self.meta["ARTICLE_CONTENT"] = content
56
57

        # And we initialize a template for the article
58
        article_template_filepath = os.path.join("templates", "article.html")
59
60
61
        self.template = boopsy.Template(article_template_filepath)

    def render(self):
62
63
64
65
66
67
68
69
        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")

70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
    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"]
        else:
            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()))

85
86
    def content(self):
        return self.meta["ARTICLE_CONTENT"]
87

88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
    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:
            return datetime.datetime.now(tz=self.timezone())

        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())

110

111
112
113
114
115
116
117
118
119
120
121
122
def dir_tree(path):
    """List all the tree directory under the given path.
    """
    for filename in os.listdir(path):
        full_path = os.path.join(path, filename)
        if os.path.isdir(full_path):
            for sub_filename in dir_tree(full_path):
                yield os.path.join(filename, sub_filename)
        else:
            yield filename


berumuron's avatar
berumuron committed
123
124
125
126
127
128
129
def mkdirs_for_file(filepath):
    """Create parent directories for a given file.
    """
    path = os.path.dirname(filepath)
    os.makedirs(path, exist_ok=True)


130
def main():
131
    # Make sure ./site folder is empty
berumuron's avatar
berumuron committed
132
    output_path = os.path.join(os.curdir, "site")
133
    if os.path.isdir(output_path):
134
        # it should be a directory...
135
        shutil.rmtree(output_path)
136
137
138
    elif os.path.exists(output_path):
        # ... but it also can be a simple file if user created it manually!
        os.remove(output_path)
139
    os.mkdir(output_path)
140
141
142
143
144
145
146

    # Check that index file exists and copy it under the ./site folder
    index_filepath = os.path.join(os.curdir, "index.html")
    if not os.path.exists(index_filepath):
        raise ProgramError("./index.html file does not exist")
    index_output_filepath = os.path.join(output_path, "index.html")
    shutil.copyfile(index_filepath, index_output_filepath)
berumuron's avatar
berumuron committed
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
    print(f"Written page: file://{os.path.abspath(index_output_filepath)}")

    # Check that pages directory exists on the filesystem
    pages_path = os.path.join(os.curdir, "pages")
    if os.path.isdir(pages_path):
        for filename in dir_tree(pages_path):
            _, ext = os.path.splitext(filename)
            if ext != ".html":
                # Don't consider files which are not HTML
                continue

            page_filepath = os.path.join(pages_path, filename)
            output_filepath = os.path.join(output_path, filename)

            # Create dir structure for the file
            mkdirs_for_file(output_filepath)

            # And copy it
            shutil.copyfile(page_filepath, output_filepath)
            print(f"Written page: file://{os.path.abspath(output_filepath)}")
167

168
169
170
    # Check that a ./static folder exists and copy its content under the ./site
    # folder
    static_dirpath = os.path.join(os.curdir, "static")
171
172
173
    if os.path.isdir(static_dirpath):
        distutils.dir_util.copy_tree(static_dirpath, output_path)

174
175
176
    # Check that articles directory exists on the filesystem
    articles_path = os.path.join(os.curdir, "articles")
    if not os.path.isdir(articles_path):
177
178
        # If not, just stop here
        return
179

berumuron's avatar
berumuron committed
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
    # Load the configuration (it can be overidden by a configuration.yml file)
    configuration = {
        "SITE_URL": "http://localhost",
        "SITE_TITLE": "A website",
        "SITE_TIMEZONE": "UTC",
    }

    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

195
196
197
    configuration["SITE_UUID"] = str(
        uuid.uuid5(uuid.NAMESPACE_URL, configuration["SITE_URL"])
    )
berumuron's avatar
berumuron committed
198
199
200
    if "SITE_AUTHOR" not in configuration:
        configuration["SITE_AUTHOR"] = f"{configuration['SITE_TITLE']}'s author"

201
202
    # List files from ./articles
    articles = []
203
    for filename in dir_tree(articles_path):
204
        filename_no_ext, ext = os.path.splitext(filename)
205
206
        if ext != ".md":
            # Don't consider files which are not Markdown
207
            continue
208

209
210
        slug = os.path.basename(filename_no_ext)

211
        # We read the article file and initialize an Article from it
212
        article_filepath = os.path.join(articles_path, filename)
213
        with open(article_filepath, "r") as article_file:
berumuron's avatar
berumuron committed
214
            article = Article(slug, article_file.read(), configuration=configuration)
215
            articles.append(article)
216

217
218
219
220
    # If there is no articles, stop there
    if len(articles) == 0:
        return

221
222
223
    # Sort the articles by publication date
    articles.sort(key=lambda article: article.date(), reverse=True)

224
225
    # And write them in the ./site folder
    for article in articles:
226
        output_filepath = os.path.join(output_path, f"{article.slug()}.html")
227
        with open(output_filepath, "w") as output_file:
228
            output_file.write(article.render())
berumuron's avatar
berumuron committed
229
        print(f"Written article: file://{os.path.abspath(output_filepath)}")
230

231
232
233
234
235
236
237
238
    # 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 = datetime.datetime.now(tz=timezone)

239
240
241
242
243
244
245
246
247
248
    # Build the Atom feed
    feed = boopfeed.Atom()
    feed.add_header(
        {
            "title": site_title,
            "self_link": f"{site_url}feeds/all.atom.xml",
            "site_link": site_url,
            "id": f"urn:uuid:{site_uuid}",
            "updated": current_datetime,
        }
249
250
251
252
    )

    for article in articles:
        # and complete it for each article
253
254
255
256
257
258
259
260
        entry = {
            "title": article.title(),
            "id": f"urn:uuid:{article.uuid()}",
            "article_link": article.url(),
            "published": article.date(),
            "content": article.content(),
        }

261
262
        article_author = article.author()
        if article_author:
263
            entry["author"] = article_author
264
265
        article_update = article.update()
        if article_update:
266
267
268
            entry["updated"] = article_update

        feed.add_entry(entry)
269
270

    # And write it on the filesystem
271
272
    feed_dirpath = os.path.join(output_path, "feeds")
    os.mkdir(feed_dirpath)
273
    feed_filepath = os.path.join(feed_dirpath, "all.atom.xml")
274
    feed.write(feed_filepath)
berumuron's avatar
berumuron committed
275
    print(f"Written feed: file://{os.path.abspath(feed_filepath)}")
276

277
278

if __name__ == "__main__":
279
280
281
282
283
    try:
        main()
        print("Boop!")
    except ProgramError as e:
        print(e.args[0], file=sys.stderr)