boop.py 12 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
        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"]

147
148
149
150
    def url(self):
        site_url = self.meta.get("SITE_URL", "")
        return f"{site_url}{self.slug()}.html"

151
152
153
154
    def content(self):
        return self.meta["PAGE_CONTENT"]


155
156
157
158
159
160
161
162
163
164
165
166
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


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


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

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

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

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

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

270
271
272
    # Check that articles directory exists on the filesystem
    articles_path = os.path.join(os.curdir, "articles")
    if not os.path.isdir(articles_path):
273
274
        # If not, just stop here
        return
275

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

284
285
        slug = os.path.basename(filename_no_ext)

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

292
293
294
295
    # If there is no articles, stop there
    if len(articles) == 0:
        return

296
297
298
    # Sort the articles by publication date
    articles.sort(key=lambda article: article.date(), reverse=True)

299
300
    # And write them in the ./site folder
    for article in articles:
301
        output_filepath = os.path.join(output_path, f"{article.slug()}.html")
302
        with open(output_filepath, "w") as output_file:
303
            output_file.write(article.render())
304
        print(f"Written article: {article.url()}")
305

306
307
308
309
310
311
312
313
314
315
    # Write the page with the list of articles if blog template exists
    blog_template_filepath = os.path.join("templates", "blog.html")
    if os.path.exists(blog_template_filepath):
        blog_template = boopsy.Template(blog_template_filepath)
        context = {}
        context.update(configuration)
        context["ARTICLES"] = articles
        blog_output_filepath = os.path.join(output_path, f"blog.html")
        with open(blog_output_filepath, "w") as output_file:
            output_file.write(blog_template.render(context))
316
        print(f"Written page: {configuration['SITE_URL']}blog.html")
317

318
319
320
321
322
323
324
325
    # 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)

326
327
328
329
330
331
332
333
334
335
    # 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,
        }
336
337
338
339
    )

    for article in articles:
        # and complete it for each article
340
341
342
343
344
345
346
347
        entry = {
            "title": article.title(),
            "id": f"urn:uuid:{article.uuid()}",
            "article_link": article.url(),
            "published": article.date(),
            "content": article.content(),
        }

348
349
        article_author = article.author()
        if article_author:
350
            entry["author"] = article_author
351
352
        article_update = article.update()
        if article_update:
353
354
355
            entry["updated"] = article_update

        feed.add_entry(entry)
356
357

    # And write it on the filesystem
358
359
    feed_dirpath = os.path.join(output_path, "feeds")
    os.mkdir(feed_dirpath)
360
    feed_filepath = os.path.join(feed_dirpath, "all.atom.xml")
361
    feed.write(feed_filepath)
362
    print(f"Written feed: {configuration['SITE_URL']}feeds/all.atom.xml")
363

364
365

if __name__ == "__main__":
366
367
368
369
    environment = Environment.PRODUCTION
    if len(sys.argv) == 2 and sys.argv[1] == "--development":
        environment = Environment.DEVELOPMENT

370
    try:
371
        main(environment)
372
373
374
        print("Boop!")
    except ProgramError as e:
        print(e.args[0], file=sys.stderr)