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

14
import markdown
15
import pytz
16

17
import boopsy
18
import boopfeed
19
20


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

23

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


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

    Examples:

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

    pass


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

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

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

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

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

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

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

117

118
119
120
121
122
123
124
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
125
126
127
128
        yaml_conf, page_content = extract_yaml_configuration(page_content)
        for key, value in yaml_conf.items():
            article_key = f"PAGE_{key.upper()}"
            self.meta[article_key] = value
129
130
131
132
        self.meta["PAGE_CONTENT"] = page_content

        # And we initialize a template for the page
        page_template_filepath = os.path.join("templates", "page.html")
133
        self.template = None
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
        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"]


150
151
152
153
154
155
156
157
158
159
160
161
YAML_FRONT_MATTER_REGEX = re.compile("(---\n.*\n)(---\n)(.*)", re.M | re.S)


def extract_yaml_configuration(content):
    match = YAML_FRONT_MATTER_REGEX.match(content)
    if match:
        yaml_conf = yaml.load(match.group(1))
        return yaml_conf, match.group(3)
    else:
        return {}, content


162
163
164
165
166
167
168
169
170
171
172
173
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
174
175
176
177
178
179
180
def mkdirs_for_file(filepath):
    """Create parent directories for a given file.
    """
    path = os.path.dirname(filepath)
    os.makedirs(path, exist_ok=True)


181
def main(environment):
182
    # Make sure ./site folder is empty
berumuron's avatar
berumuron committed
183
    output_path = os.path.join(os.curdir, "site")
184
    if os.path.isdir(output_path):
185
        # it should be a directory...
186
        shutil.rmtree(output_path)
187
188
189
    elif os.path.exists(output_path):
        # ... but it also can be a simple file if user created it manually!
        os.remove(output_path)
190
    os.mkdir(output_path)
191

192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
    # 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)}/"

222
    # Check that index file exists and load a Page from it
223
224
225
    index_filepath = os.path.join(os.curdir, "index.html")
    if not os.path.exists(index_filepath):
        raise ProgramError("./index.html file does not exist")
226
227
228
229
230
231
232

    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
233
234
235
    pages_path = os.path.join(os.curdir, "pages")
    if os.path.isdir(pages_path):
        for filename in dir_tree(pages_path):
236
            slug, ext = os.path.splitext(filename)
berumuron's avatar
berumuron committed
237
238
239
240
            if ext != ".html":
                # Don't consider files which are not HTML
                continue

241
            # We read the page file and initialize a Page from it
berumuron's avatar
berumuron committed
242
            page_filepath = os.path.join(pages_path, filename)
243
244
245
246
247
248
249
250
251
252
253
254
255
256
            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())
berumuron's avatar
berumuron committed
257
        print(f"Written page: file://{os.path.abspath(output_filepath)}")
258

259
260
261
    # Check that a ./static folder exists and copy its content under the ./site
    # folder
    static_dirpath = os.path.join(os.curdir, "static")
262
263
264
    if os.path.isdir(static_dirpath):
        distutils.dir_util.copy_tree(static_dirpath, output_path)

265
266
267
    # Check that articles directory exists on the filesystem
    articles_path = os.path.join(os.curdir, "articles")
    if not os.path.isdir(articles_path):
268
269
        # If not, just stop here
        return
270

271
272
    # List files from ./articles
    articles = []
273
    for filename in dir_tree(articles_path):
274
        filename_no_ext, ext = os.path.splitext(filename)
275
276
        if ext != ".md":
            # Don't consider files which are not Markdown
277
            continue
278

279
280
        slug = os.path.basename(filename_no_ext)

281
        # We read the article file and initialize an Article from it
282
        article_filepath = os.path.join(articles_path, filename)
283
        with open(article_filepath, "r") as article_file:
berumuron's avatar
berumuron committed
284
            article = Article(slug, article_file.read(), configuration=configuration)
285
            articles.append(article)
286

287
288
289
290
    # If there is no articles, stop there
    if len(articles) == 0:
        return

291
292
293
    # Sort the articles by publication date
    articles.sort(key=lambda article: article.date(), reverse=True)

294
295
    # And write them in the ./site folder
    for article in articles:
296
        output_filepath = os.path.join(output_path, f"{article.slug()}.html")
297
        with open(output_filepath, "w") as output_file:
298
            output_file.write(article.render())
berumuron's avatar
berumuron committed
299
        print(f"Written article: file://{os.path.abspath(output_filepath)}")
300

301
302
303
304
305
306
307
308
    # 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)

309
310
311
312
313
314
315
316
317
318
    # 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,
        }
319
320
321
322
    )

    for article in articles:
        # and complete it for each article
323
324
325
326
327
328
329
330
        entry = {
            "title": article.title(),
            "id": f"urn:uuid:{article.uuid()}",
            "article_link": article.url(),
            "published": article.date(),
            "content": article.content(),
        }

331
332
        article_author = article.author()
        if article_author:
333
            entry["author"] = article_author
334
335
        article_update = article.update()
        if article_update:
336
337
338
            entry["updated"] = article_update

        feed.add_entry(entry)
339
340

    # And write it on the filesystem
341
342
    feed_dirpath = os.path.join(output_path, "feeds")
    os.mkdir(feed_dirpath)
343
    feed_filepath = os.path.join(feed_dirpath, "all.atom.xml")
344
    feed.write(feed_filepath)
berumuron's avatar
berumuron committed
345
    print(f"Written feed: file://{os.path.abspath(feed_filepath)}")
346

347
348

if __name__ == "__main__":
349
350
351
352
    environment = Environment.PRODUCTION
    if len(sys.argv) == 2 and sys.argv[1] == "--development":
        environment = Environment.DEVELOPMENT

353
    try:
354
        main(environment)
355
356
357
        print("Boop!")
    except ProgramError as e:
        print(e.args[0], file=sys.stderr)