boop.py 9.68 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
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
129
130
131
132
133
134
135
def mkdirs_for_file(filepath):
    """Create parent directories for a given file.
    """
    path = os.path.dirname(filepath)
    os.makedirs(path, exist_ok=True)


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

    # 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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
    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)}")
173

174
175
176
    # Check that a ./static folder exists and copy its content under the ./site
    # folder
    static_dirpath = os.path.join(os.curdir, "static")
177
178
179
    if os.path.isdir(static_dirpath):
        distutils.dir_util.copy_tree(static_dirpath, output_path)

180
181
182
    # Check that articles directory exists on the filesystem
    articles_path = os.path.join(os.curdir, "articles")
    if not os.path.isdir(articles_path):
183
184
        # If not, just stop here
        return
185

berumuron's avatar
berumuron committed
186
187
    # Load the configuration (it can be overidden by a configuration.yml file)
    configuration = {
188
        "SITE_URL": "filesystem",
berumuron's avatar
berumuron committed
189
190
191
192
193
194
195
196
197
        "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():
198
199
            if key == "development":
                continue
berumuron's avatar
berumuron committed
200
201
202
            site_key = f"SITE_{key.upper()}"
            configuration[site_key] = value

203
204
205
206
207
        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

208
209
210
    configuration["SITE_UUID"] = str(
        uuid.uuid5(uuid.NAMESPACE_URL, configuration["SITE_URL"])
    )
berumuron's avatar
berumuron committed
211
212
    if "SITE_AUTHOR" not in configuration:
        configuration["SITE_AUTHOR"] = f"{configuration['SITE_TITLE']}'s author"
213
214
    if configuration["SITE_URL"] == "filesystem":
        configuration["SITE_URL"] = f"file://{os.path.abspath(output_path)}/"
berumuron's avatar
berumuron committed
215

216
217
    # List files from ./articles
    articles = []
218
    for filename in dir_tree(articles_path):
219
        filename_no_ext, ext = os.path.splitext(filename)
220
221
        if ext != ".md":
            # Don't consider files which are not Markdown
222
            continue
223

224
225
        slug = os.path.basename(filename_no_ext)

226
        # We read the article file and initialize an Article from it
227
        article_filepath = os.path.join(articles_path, filename)
228
        with open(article_filepath, "r") as article_file:
berumuron's avatar
berumuron committed
229
            article = Article(slug, article_file.read(), configuration=configuration)
230
            articles.append(article)
231

232
233
234
235
    # If there is no articles, stop there
    if len(articles) == 0:
        return

236
237
238
    # Sort the articles by publication date
    articles.sort(key=lambda article: article.date(), reverse=True)

239
240
    # And write them in the ./site folder
    for article in articles:
241
        output_filepath = os.path.join(output_path, f"{article.slug()}.html")
242
        with open(output_filepath, "w") as output_file:
243
            output_file.write(article.render())
berumuron's avatar
berumuron committed
244
        print(f"Written article: file://{os.path.abspath(output_filepath)}")
245

246
247
248
249
250
251
252
253
    # 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)

254
255
256
257
258
259
260
261
262
263
    # 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,
        }
264
265
266
267
    )

    for article in articles:
        # and complete it for each article
268
269
270
271
272
273
274
275
        entry = {
            "title": article.title(),
            "id": f"urn:uuid:{article.uuid()}",
            "article_link": article.url(),
            "published": article.date(),
            "content": article.content(),
        }

276
277
        article_author = article.author()
        if article_author:
278
            entry["author"] = article_author
279
280
        article_update = article.update()
        if article_update:
281
282
283
            entry["updated"] = article_update

        feed.add_entry(entry)
284
285

    # And write it on the filesystem
286
287
    feed_dirpath = os.path.join(output_path, "feeds")
    os.mkdir(feed_dirpath)
288
    feed_filepath = os.path.join(feed_dirpath, "all.atom.xml")
289
    feed.write(feed_filepath)
berumuron's avatar
berumuron committed
290
    print(f"Written feed: file://{os.path.abspath(feed_filepath)}")
291

292
293

if __name__ == "__main__":
294
295
296
297
    environment = Environment.PRODUCTION
    if len(sys.argv) == 2 and sys.argv[1] == "--development":
        environment = Environment.DEVELOPMENT

298
    try:
299
        main(environment)
300
301
302
        print("Boop!")
    except ProgramError as e:
        print(e.args[0], file=sys.stderr)