boop.py 11.3 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
        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
58
59
60
61
62
            # if there is only one value.
            if len(values) > 1:
                self.meta[article_key] = values
            else:
                self.meta[article_key] = values[0]
63
        self.meta["ARTICLE_CONTENT"] = content
64
65

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

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

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

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

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

118

119
120
121
122
123
124
125
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
126
127
128
129
        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
130
131
132
133
        self.meta["PAGE_CONTENT"] = page_content

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


151
152
153
154
155
156
157
158
159
160
161
162
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


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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        feed.add_entry(entry)
340
341

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

348
349

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

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