boop.py 10.8 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
from enum import Enum
12

13
import markdown
14
import pytz
15

16
import boopsy
17
import boopfeed
18
19


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

22

23
24
25
26
27
class Environment(Enum):
    DEVELOPMENT = 1
    PRODUCTION = 2


28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class ProgramError(Exception):
    """Exception raised during the program execution.

    Examples:

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

    pass


43
class Article:
44
    def __init__(self, slug, article_content, configuration={}):
45
46
        # We convert the file's content into HTML
        md = markdown.Markdown(extensions=["meta", "fenced_code", "attr_list"])
47
        content = md.convert(article_content)
48
49
50
51

        # We get the local variables from the Markdown file (metadata)
        # which will be accessible in the article template
        self.meta = {}
52
53
        self.meta.update(configuration)
        self.meta["ARTICLE_SLUG"] = slug
54
55
56
57
58
59
60
        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]
61
        self.meta["ARTICLE_CONTENT"] = content
62
63

        # And we initialize a template for the article
64
        article_template_filepath = os.path.join("templates", "article.html")
65
66
67
        self.template = boopsy.Template(article_template_filepath)

    def render(self):
68
69
70
71
72
73
74
75
        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")

76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
    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()))

91
92
    def content(self):
        return self.meta["ARTICLE_CONTENT"]
93

94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
    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())

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
class Page:
    def __init__(self, slug, page_content, configuration={}):
        # We initialize the page meta variables which will be accessible in the
        # template
        self.meta = {}
        self.meta.update(configuration)
        self.meta["PAGE_SLUG"] = slug
        self.meta["PAGE_CONTENT"] = page_content

        # And we initialize a template for the page
        page_template_filepath = os.path.join("templates", "page.html")
        if os.path.exists(page_template_filepath):
            self.template = boopsy.Template(page_template_filepath)

    def render(self):
        if self.template is None:
            return self.content()
        else:
            return self.template.render(self.meta)

    def slug(self):
        return self.meta["PAGE_SLUG"]

    def content(self):
        return self.meta["PAGE_CONTENT"]


144
145
146
147
148
149
150
151
152
153
154
155
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
156
157
158
159
160
161
162
def mkdirs_for_file(filepath):
    """Create parent directories for a given file.
    """
    path = os.path.dirname(filepath)
    os.makedirs(path, exist_ok=True)


163
def main(environment):
164
    # Make sure ./site folder is empty
berumuron's avatar
berumuron committed
165
    output_path = os.path.join(os.curdir, "site")
166
    if os.path.isdir(output_path):
167
        # it should be a directory...
168
        shutil.rmtree(output_path)
169
170
171
    elif os.path.exists(output_path):
        # ... but it also can be a simple file if user created it manually!
        os.remove(output_path)
172
    os.mkdir(output_path)
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
    # Load the configuration (it can be overidden by a configuration.yml file)
    configuration = {
        "SITE_URL": "filesystem",
        "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():
            if key == "development":
                continue
            site_key = f"SITE_{key.upper()}"
            configuration[site_key] = value

        if environment == Environment.DEVELOPMENT and "development" in conf_data:
            for key, value in conf_data["development"].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"
    if configuration["SITE_URL"] == "filesystem":
        configuration["SITE_URL"] = f"file://{os.path.abspath(output_path)}/"

204
    # Check that index file exists and load a Page from it
205
206
207
    index_filepath = os.path.join(os.curdir, "index.html")
    if not os.path.exists(index_filepath):
        raise ProgramError("./index.html file does not exist")
208
209
210
211
212
213
214

    pages = []
    with open(index_filepath, "r") as index_file:
        page = Page("index", index_file.read(), configuration=configuration)
        pages.append(page)

    # Check that pages directory exists and load Pages from it
berumuron's avatar
berumuron committed
215
216
217
    pages_path = os.path.join(os.curdir, "pages")
    if os.path.isdir(pages_path):
        for filename in dir_tree(pages_path):
218
            slug, ext = os.path.splitext(filename)
berumuron's avatar
berumuron committed
219
220
221
222
            if ext != ".html":
                # Don't consider files which are not HTML
                continue

223
            # We read the page file and initialize a Page from it
berumuron's avatar
berumuron committed
224
            page_filepath = os.path.join(pages_path, filename)
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
            with open(page_filepath, "r") as page_file:
                page = Page(slug, page_file.read(), configuration=configuration)
                pages.append(page)

    # And write them in the ./site folder
    for page in pages:
        output_filepath = os.path.join(output_path, f"{page.slug()}.html")

        # Create dir structure for the file
        mkdirs_for_file(output_filepath)

        # And write the file
        with open(output_filepath, "w") as output_file:
            output_file.write(page.render())
        print(f"Written article: file://{os.path.abspath(output_filepath)}")
240

241
242
243
    # Check that a ./static folder exists and copy its content under the ./site
    # folder
    static_dirpath = os.path.join(os.curdir, "static")
244
245
246
    if os.path.isdir(static_dirpath):
        distutils.dir_util.copy_tree(static_dirpath, output_path)

247
248
249
    # Check that articles directory exists on the filesystem
    articles_path = os.path.join(os.curdir, "articles")
    if not os.path.isdir(articles_path):
250
251
        # If not, just stop here
        return
252

253
254
    # List files from ./articles
    articles = []
255
    for filename in dir_tree(articles_path):
256
        filename_no_ext, ext = os.path.splitext(filename)
257
258
        if ext != ".md":
            # Don't consider files which are not Markdown
259
            continue
260

261
262
        slug = os.path.basename(filename_no_ext)

263
        # We read the article file and initialize an Article from it
264
        article_filepath = os.path.join(articles_path, filename)
265
        with open(article_filepath, "r") as article_file:
berumuron's avatar
berumuron committed
266
            article = Article(slug, article_file.read(), configuration=configuration)
267
            articles.append(article)
268

269
270
271
272
    # If there is no articles, stop there
    if len(articles) == 0:
        return

273
274
275
    # Sort the articles by publication date
    articles.sort(key=lambda article: article.date(), reverse=True)

276
277
    # And write them in the ./site folder
    for article in articles:
278
        output_filepath = os.path.join(output_path, f"{article.slug()}.html")
279
        with open(output_filepath, "w") as output_file:
280
            output_file.write(article.render())
berumuron's avatar
berumuron committed
281
        print(f"Written article: file://{os.path.abspath(output_filepath)}")
282

283
284
285
286
287
288
289
290
    # 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)

291
292
293
294
295
296
297
298
299
300
    # 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,
        }
301
302
303
304
    )

    for article in articles:
        # and complete it for each article
305
306
307
308
309
310
311
312
        entry = {
            "title": article.title(),
            "id": f"urn:uuid:{article.uuid()}",
            "article_link": article.url(),
            "published": article.date(),
            "content": article.content(),
        }

313
314
        article_author = article.author()
        if article_author:
315
            entry["author"] = article_author
316
317
        article_update = article.update()
        if article_update:
318
319
320
            entry["updated"] = article_update

        feed.add_entry(entry)
321
322

    # And write it on the filesystem
323
324
    feed_dirpath = os.path.join(output_path, "feeds")
    os.mkdir(feed_dirpath)
325
    feed_filepath = os.path.join(feed_dirpath, "all.atom.xml")
326
    feed.write(feed_filepath)
berumuron's avatar
berumuron committed
327
    print(f"Written feed: file://{os.path.abspath(feed_filepath)}")
328

329
330

if __name__ == "__main__":
331
332
333
334
    environment = Environment.PRODUCTION
    if len(sys.argv) == 2 and sys.argv[1] == "--development":
        environment = Environment.DEVELOPMENT

335
    try:
336
        main(environment)
337
338
339
        print("Boop!")
    except ProgramError as e:
        print(e.args[0], file=sys.stderr)